fix(command-center): keep Recharts cards from blanking

Measure Command Center Recharts wrappers directly and keep usable fallback dimensions until lazy dashboard cards report a drawable box. This restores line charts such as Daily activity, Tokens trend, and Activity trend, plus model-share pies that used the same sizing path.
This commit is contained in:
gsxdsm
2026-06-23 09:12:44 -07:00
parent a670f5ce98
commit d06e31693e
5 changed files with 293 additions and 59 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix Command Center Recharts line and pie graphs rendering blank when their cards initially report unusable responsive dimensions.

View File

@@ -3,11 +3,11 @@ import {
Legend, Legend,
Line, Line,
LineChart as RechartsLineChart, LineChart as RechartsLineChart,
ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { useLayoutEffect, useRef, useState } from "react";
import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme"; import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme";
import "../charts.css"; import "../charts.css";
@@ -38,6 +38,11 @@ interface SanitizedLineChartSeries {
type LineChartPoint = { index: number } & Record<string, number>; type LineChartPoint = { index: number } & Record<string, number>;
type ResponsiveDimension = number | `${number}%`; type ResponsiveDimension = number | `${number}%`;
type ChartDimensions = { width: number; height: number };
const FALLBACK_CHART_DIMENSIONS: ChartDimensions = { width: 360, height: 220 };
const MIN_USABLE_CHART_WIDTH = 120;
const MIN_USABLE_CHART_HEIGHT = 120;
function prefersReducedMotion(): boolean { function prefersReducedMotion(): boolean {
return ( return (
@@ -98,6 +103,74 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime
return typeof value === "string" && /^\d+(?:\.\d+)?%$/.test(value) ? (value as `${number}%`) : "100%"; return typeof value === "string" && /^\d+(?:\.\d+)?%$/.test(value) ? (value as `${number}%`) : "100%";
} }
function finiteDimension(value: number, min: number): number | null {
return Number.isFinite(value) && value >= min ? value : null;
}
function resolvedDimensions(
measured: ChartDimensions,
width?: number | string,
height?: number | string,
): ChartDimensions {
return {
width: typeof width === "number" && width > 0 ? width : measured.width,
height: typeof height === "number" && height > 0 ? height : measured.height,
};
}
function dimensionsMatch(left: ChartDimensions, right: ChartDimensions): boolean {
return Math.abs(left.width - right.width) < 0.5 && Math.abs(left.height - right.height) < 0.5;
}
function dimensionsFromElement(element: HTMLElement): ChartDimensions | null {
const rect = element.getBoundingClientRect();
const width = finiteDimension(rect.width, MIN_USABLE_CHART_WIDTH);
const height = finiteDimension(rect.height, MIN_USABLE_CHART_HEIGHT);
if (width === null || height === null) {
return null;
}
return { width, height };
}
function useMeasuredChartDimensions() {
const ref = useRef<HTMLDivElement | null>(null);
const [dimensions, setDimensions] = useState<ChartDimensions>(FALLBACK_CHART_DIMENSIONS);
useLayoutEffect(() => {
const element = ref.current;
if (!element) {
return undefined;
}
const applyDimensions = (next: ChartDimensions | null) => {
if (!next) {
return;
}
setDimensions((current) => (dimensionsMatch(current, next) ? current : next));
};
applyDimensions(dimensionsFromElement(element));
if (typeof ResizeObserver === "undefined") {
return undefined;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const width = finiteDimension(entry.contentRect.width, MIN_USABLE_CHART_WIDTH);
const height = finiteDimension(entry.contentRect.height, MIN_USABLE_CHART_HEIGHT);
applyDimensions(width === null || height === null ? null : { width, height });
});
observer.observe(element);
return () => observer.disconnect();
}, []);
return { ref, dimensions };
}
/** /**
* FNXC:CommandCenterCharts 2026-06-18-21:52: * FNXC:CommandCenterCharts 2026-06-18-21:52:
* User requested real graphical pie + line charts on every Command Center surface using a proper chart library (recharts); this shared line wrapper preserves the existing series shape while coercing zero/NaN/Infinity inputs into safe responsive, token-themed, reduced-motion-aware recharts data. * User requested real graphical pie + line charts on every Command Center surface using a proper chart library (recharts); this shared line wrapper preserves the existing series shape while coercing zero/NaN/Infinity inputs into safe responsive, token-themed, reduced-motion-aware recharts data.
@@ -107,9 +180,14 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime
* *
* FNXC:CommandCenterCharts 2026-06-19-07:58: * FNXC:CommandCenterCharts 2026-06-19-07:58:
* FN-6723 found the Activity trend still looked broken after the height/clipping fix because mixed-unit series shared one absolute axis; normalize only callers that opt into `scaleMode="series"` so low-count agent lines stay legible without changing comparable-unit charts elsewhere. * FN-6723 found the Activity trend still looked broken after the height/clipping fix because mixed-unit series shared one absolute axis; normalize only callers that opt into `scaleMode="series"` so low-count agent lines stay legible without changing comparable-unit charts elsewhere.
*
* FNXC:CommandCenterCharts 2026-06-23-08:47:
* Daily activity line and token/model line graphs must load even when Recharts cannot resolve a percentage `ResponsiveContainer` during the card's first layout pass. Measure the chart wrapper directly and pass concrete usable dimensions into Recharts, with a first-paint fallback that is replaced by ResizeObserver only after the observed box is large enough to draw a legible chart.
*/ */
export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data", scaleMode = "shared" }: LineChartProps) { export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data", scaleMode = "shared" }: LineChartProps) {
const theme = getCommandCenterChartTheme(); const theme = getCommandCenterChartTheme();
const { ref, dimensions } = useMeasuredChartDimensions();
const chartDimensions = resolvedDimensions(dimensions, width, height);
const chartSeries = sanitizeSeries(series, scaleMode); const chartSeries = sanitizeSeries(series, scaleMode);
const chartData = lineChartData(chartSeries); const chartData = lineChartData(chartSeries);
@@ -122,39 +200,46 @@ export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No c
} }
return ( return (
<div className="cc-recharts-chart" role="img" aria-label={ariaLabel} style={containerStyle(width, height)} data-scale-mode={scaleMode}> <div
<ResponsiveContainer width={responsiveDimension(width)} height={responsiveDimension(height)}> ref={ref}
<RechartsLineChart data={chartData}> className="cc-recharts-chart"
<CartesianGrid stroke={theme.grid} /> role="img"
<XAxis dataKey="index" stroke={theme.tick} tick={{ fill: theme.tick }} /> aria-label={ariaLabel}
<YAxis style={containerStyle(width, height)}
stroke={theme.tick} data-scale-mode={scaleMode}
tick={{ fill: theme.tick }} data-responsive-width={responsiveDimension(width)}
domain={scaleMode === "series" ? [0, 100] : undefined} data-responsive-height={responsiveDimension(height)}
tickFormatter={scaleMode === "series" ? (value) => `${value}%` : undefined} >
<RechartsLineChart width={chartDimensions.width} height={chartDimensions.height} data={chartData}>
<CartesianGrid stroke={theme.grid} />
<XAxis dataKey="index" stroke={theme.tick} tick={{ fill: theme.tick }} />
<YAxis
stroke={theme.tick}
tick={{ fill: theme.tick }}
domain={scaleMode === "series" ? [0, 100] : undefined}
tickFormatter={scaleMode === "series" ? (value) => `${value}%` : undefined}
/>
<Tooltip
contentStyle={{
background: theme.tooltipBackground,
borderColor: theme.tooltipBorder,
color: theme.tooltipText,
}}
itemStyle={{ color: theme.tooltipText }}
labelStyle={{ color: theme.tooltipText }}
/>
<Legend wrapperStyle={{ color: theme.legendText }} />
{chartSeries.map((entry, index) => (
<Line
key={entry.dataKey}
type="monotone"
dataKey={entry.plotKey}
name={entry.label}
stroke={getCommandCenterChartColor(index, theme)}
isAnimationActive={!prefersReducedMotion()}
/> />
<Tooltip ))}
contentStyle={{ </RechartsLineChart>
background: theme.tooltipBackground,
borderColor: theme.tooltipBorder,
color: theme.tooltipText,
}}
itemStyle={{ color: theme.tooltipText }}
labelStyle={{ color: theme.tooltipText }}
/>
<Legend wrapperStyle={{ color: theme.legendText }} />
{chartSeries.map((entry, index) => (
<Line
key={entry.dataKey}
type="monotone"
dataKey={entry.plotKey}
name={entry.label}
stroke={getCommandCenterChartColor(index, theme)}
isAnimationActive={!prefersReducedMotion()}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
</div> </div>
); );
} }

View File

@@ -3,9 +3,9 @@ import {
Legend, Legend,
Pie, Pie,
PieChart as RechartsPieChart, PieChart as RechartsPieChart,
ResponsiveContainer,
Tooltip, Tooltip,
} from "recharts"; } from "recharts";
import { useLayoutEffect, useRef, useState } from "react";
import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme"; import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme";
import "../charts.css"; import "../charts.css";
@@ -28,6 +28,11 @@ interface SanitizedPieChartDatum {
} }
type ResponsiveDimension = number | `${number}%`; type ResponsiveDimension = number | `${number}%`;
type ChartDimensions = { width: number; height: number };
const FALLBACK_CHART_DIMENSIONS: ChartDimensions = { width: 320, height: 220 };
const MIN_USABLE_CHART_WIDTH = 120;
const MIN_USABLE_CHART_HEIGHT = 120;
function prefersReducedMotion(): boolean { function prefersReducedMotion(): boolean {
return ( return (
@@ -58,15 +63,88 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime
return typeof value === "string" && /^\d+(?:\.\d+)?%$/.test(value) ? (value as `${number}%`) : "100%"; return typeof value === "string" && /^\d+(?:\.\d+)?%$/.test(value) ? (value as `${number}%`) : "100%";
} }
function finiteDimension(value: number, min: number): number | null {
return Number.isFinite(value) && value >= min ? value : null;
}
function resolvedDimensions(
measured: ChartDimensions,
width?: number | string,
height?: number | string,
): ChartDimensions {
return {
width: typeof width === "number" && width > 0 ? width : measured.width,
height: typeof height === "number" && height > 0 ? height : measured.height,
};
}
function dimensionsMatch(left: ChartDimensions, right: ChartDimensions): boolean {
return Math.abs(left.width - right.width) < 0.5 && Math.abs(left.height - right.height) < 0.5;
}
function dimensionsFromElement(element: HTMLElement): ChartDimensions | null {
const rect = element.getBoundingClientRect();
const width = finiteDimension(rect.width, MIN_USABLE_CHART_WIDTH);
const height = finiteDimension(rect.height, MIN_USABLE_CHART_HEIGHT);
if (width === null || height === null) {
return null;
}
return { width, height };
}
function useMeasuredChartDimensions() {
const ref = useRef<HTMLDivElement | null>(null);
const [dimensions, setDimensions] = useState<ChartDimensions>(FALLBACK_CHART_DIMENSIONS);
useLayoutEffect(() => {
const element = ref.current;
if (!element) {
return undefined;
}
const applyDimensions = (next: ChartDimensions | null) => {
if (!next) {
return;
}
setDimensions((current) => (dimensionsMatch(current, next) ? current : next));
};
applyDimensions(dimensionsFromElement(element));
if (typeof ResizeObserver === "undefined") {
return undefined;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const width = finiteDimension(entry.contentRect.width, MIN_USABLE_CHART_WIDTH);
const height = finiteDimension(entry.contentRect.height, MIN_USABLE_CHART_HEIGHT);
applyDimensions(width === null || height === null ? null : { width, height });
});
observer.observe(element);
return () => observer.disconnect();
}, []);
return { ref, dimensions };
}
/** /**
* FNXC:CommandCenterCharts 2026-06-18-21:47: * FNXC:CommandCenterCharts 2026-06-18-21:47:
* User requested real graphical pie + line charts on every Command Center surface using a proper chart library (recharts); this shared pie wrapper is token-themed, responsive, reduced-motion aware, and filters zero/NaN/negative values before recharts can receive invalid geometry. * User requested real graphical pie + line charts on every Command Center surface using a proper chart library (recharts); this shared pie wrapper is token-themed, responsive, reduced-motion aware, and filters zero/NaN/negative values before recharts can receive invalid geometry.
* *
* FNXC:CommandCenterCharts 2026-06-19-05:24: * FNXC:CommandCenterCharts 2026-06-19-05:24:
* Recharts ResponsiveContainer requires a measurable parent height. Import the shared chart CSS here so pie charts keep the same non-zero token-sized wrapper and empty fallback on Activity, Team, Overview, and other Command Center surfaces. * Recharts ResponsiveContainer requires a measurable parent height. Import the shared chart CSS here so pie charts keep the same non-zero token-sized wrapper and empty fallback on Activity, Team, Overview, and other Command Center surfaces.
*
* FNXC:CommandCenterCharts 2026-06-23-08:47:
* Token share by model and the other Command Center pie graphs must not depend on Recharts resolving percentage container dimensions during lazy card layout. Measure the wrapper ourselves and provide concrete usable chart dimensions, falling back until the observed box is large enough to draw a legible chart.
*/ */
export function PieChart({ data, ariaLabel, width, height, emptyLabel = "No chart data" }: PieChartProps) { export function PieChart({ data, ariaLabel, width, height, emptyLabel = "No chart data" }: PieChartProps) {
const theme = getCommandCenterChartTheme(); const theme = getCommandCenterChartTheme();
const { ref, dimensions } = useMeasuredChartDimensions();
const chartDimensions = resolvedDimensions(dimensions, width, height);
const chartData = sanitizePieData(data); const chartData = sanitizePieData(data);
if (chartData.length === 0) { if (chartData.length === 0) {
@@ -78,31 +156,37 @@ export function PieChart({ data, ariaLabel, width, height, emptyLabel = "No char
} }
return ( return (
<div className="cc-recharts-chart" role="img" aria-label={ariaLabel} style={containerStyle(width, height)}> <div
<ResponsiveContainer width={responsiveDimension(width)} height={responsiveDimension(height)}> ref={ref}
<RechartsPieChart> className="cc-recharts-chart"
<Pie role="img"
data={chartData} aria-label={ariaLabel}
dataKey="value" style={containerStyle(width, height)}
nameKey="label" data-responsive-width={responsiveDimension(width)}
isAnimationActive={!prefersReducedMotion()} data-responsive-height={responsiveDimension(height)}
> >
{chartData.map((entry, index) => ( <RechartsPieChart width={chartDimensions.width} height={chartDimensions.height}>
<Cell key={entry.label} fill={getCommandCenterChartColor(index, theme)} stroke={theme.tooltipBorder} /> <Pie
))} data={chartData}
</Pie> dataKey="value"
<Tooltip nameKey="label"
contentStyle={{ isAnimationActive={!prefersReducedMotion()}
background: theme.tooltipBackground, >
borderColor: theme.tooltipBorder, {chartData.map((entry, index) => (
color: theme.tooltipText, <Cell key={entry.label} fill={getCommandCenterChartColor(index, theme)} stroke={theme.tooltipBorder} />
}} ))}
itemStyle={{ color: theme.tooltipText }} </Pie>
labelStyle={{ color: theme.tooltipText }} <Tooltip
/> contentStyle={{
<Legend wrapperStyle={{ color: theme.legendText }} /> background: theme.tooltipBackground,
</RechartsPieChart> borderColor: theme.tooltipBorder,
</ResponsiveContainer> color: theme.tooltipText,
}}
itemStyle={{ color: theme.tooltipText }}
labelStyle={{ color: theme.tooltipText }}
/>
<Legend wrapperStyle={{ color: theme.legendText }} />
</RechartsPieChart>
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { LineChart } from "../LineChart";
import type { LineChartSeries } from "../LineChart"; import type { LineChartSeries } from "../LineChart";
const chartSize = { width: 360, height: 220 }; const chartSize = { width: 360, height: 220 };
const fallbackChartSize = { width: 360, height: 220 };
function chartHtml(label: string): string { function chartHtml(label: string): string {
return screen.getByRole("img", { name: label }).outerHTML; return screen.getByRole("img", { name: label }).outerHTML;
@@ -20,6 +21,13 @@ function ySpanForDots(seriesName: string): number {
return Math.max(...values) - Math.min(...values); return Math.max(...values) - Math.min(...values);
} }
function renderedSvg(label: string): SVGSVGElement {
const svgs = Array.from(screen.getByRole("img", { name: label }).querySelectorAll<SVGSVGElement>("svg.recharts-surface"));
const svg = svgs.sort((left, right) => Number(right.getAttribute("width")) - Number(left.getAttribute("width")))[0];
expect(svg).toBeTruthy();
return svg;
}
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -37,6 +45,28 @@ describe("recharts LineChart", () => {
expect(chartHtml("activity trend")).not.toMatch(/NaN|Infinity/); expect(chartHtml("activity trend")).not.toMatch(/NaN|Infinity/);
}); });
it("renders without explicit dimensions so dashboard cards do not blank during first layout", () => {
// FNXC:CommandCenterCharts 2026-06-23-08:47: Daily activity line renders from dashboard cards without passing width/height props; first paint needs finite SVG dimensions before browser measurement settles.
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
width: 0,
height: 0,
x: 0,
y: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON: () => ({}),
} as DOMRect);
expect(() => render(<LineChart series={[{ label: "Messages", values: [1, 3, 2] }]} ariaLabel="daily activity line" />)).not.toThrow();
const svg = renderedSvg("daily activity line");
expect(svg.getAttribute("width")).toBe(String(fallbackChartSize.width));
expect(svg.getAttribute("height")).toBe(String(fallbackChartSize.height));
expect(chartHtml("daily activity line")).not.toMatch(/NaN|Infinity/);
rectSpy.mockRestore();
});
it("renders a single-point series cleanly", () => { it("renders a single-point series cleanly", () => {
expect(() => renderChart([{ label: "Single", values: [5] }], "single point")).not.toThrow(); expect(() => renderChart([{ label: "Single", values: [5] }], "single point")).not.toThrow();

View File

@@ -4,6 +4,7 @@ import { PieChart } from "../PieChart";
import type { PieChartProps } from "../PieChart"; import type { PieChartProps } from "../PieChart";
const chartSize = { width: 320, height: 220 }; const chartSize = { width: 320, height: 220 };
const fallbackChartSize = { width: 320, height: 220 };
function chartHtml(label: string): string { function chartHtml(label: string): string {
return screen.getByRole("img", { name: label }).outerHTML; return screen.getByRole("img", { name: label }).outerHTML;
@@ -14,6 +15,13 @@ function renderChart(data: PieChartProps["data"], ariaLabel = "pie chart") {
return render(<PieChart data={data} ariaLabel={ariaLabel} {...chartSize} />); return render(<PieChart data={data} ariaLabel={ariaLabel} {...chartSize} />);
} }
function renderedSvg(label: string): SVGSVGElement {
const svgs = Array.from(screen.getByRole("img", { name: label }).querySelectorAll<SVGSVGElement>("svg.recharts-surface"));
const svg = svgs.sort((left, right) => Number(right.getAttribute("width")) - Number(left.getAttribute("width")))[0];
expect(svg).toBeTruthy();
return svg;
}
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -28,6 +36,28 @@ describe("recharts PieChart", () => {
expect(chartHtml("status split")).not.toMatch(/NaN|Infinity/); expect(chartHtml("status split")).not.toMatch(/NaN|Infinity/);
}); });
it("renders without explicit dimensions so dashboard cards do not blank during first layout", () => {
// FNXC:CommandCenterCharts 2026-06-23-08:47: Token share by model renders from dashboard cards without width/height props; the wrapper must provide finite chart dimensions before ResizeObserver reports.
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
width: 0,
height: 0,
x: 0,
y: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON: () => ({}),
} as DOMRect);
expect(() => render(<PieChart data={[{ label: "gpt-5", value: 10 }]} ariaLabel="token share by model" />)).not.toThrow();
const svg = renderedSvg("token share by model");
expect(svg.getAttribute("width")).toBe(String(fallbackChartSize.width));
expect(svg.getAttribute("height")).toBe(String(fallbackChartSize.height));
expect(chartHtml("token share by model")).not.toMatch(/NaN|Infinity/);
rectSpy.mockRestore();
});
it("renders a single-item pie without invalid geometry", () => { it("renders a single-item pie without invalid geometry", () => {
expect(() => renderChart([{ label: "Only", value: 3 }], "single slice")).not.toThrow(); expect(() => renderChart([{ label: "Only", value: 3 }], "single slice")).not.toThrow();