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:
5
.changeset/fix-command-center-line-charts.md
Normal file
5
.changeset/fix-command-center-line-charts.md
Normal 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.
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
Legend,
|
||||
Line,
|
||||
LineChart as RechartsLineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme";
|
||||
import "../charts.css";
|
||||
|
||||
@@ -38,6 +38,11 @@ interface SanitizedLineChartSeries {
|
||||
|
||||
type LineChartPoint = { index: number } & Record<string, 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 {
|
||||
return (
|
||||
@@ -98,6 +103,74 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime
|
||||
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:
|
||||
* 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:
|
||||
* 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) {
|
||||
const theme = getCommandCenterChartTheme();
|
||||
const { ref, dimensions } = useMeasuredChartDimensions();
|
||||
const chartDimensions = resolvedDimensions(dimensions, width, height);
|
||||
const chartSeries = sanitizeSeries(series, scaleMode);
|
||||
const chartData = lineChartData(chartSeries);
|
||||
|
||||
@@ -122,39 +200,46 @@ export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No c
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cc-recharts-chart" role="img" aria-label={ariaLabel} style={containerStyle(width, height)} data-scale-mode={scaleMode}>
|
||||
<ResponsiveContainer width={responsiveDimension(width)} height={responsiveDimension(height)}>
|
||||
<RechartsLineChart 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}
|
||||
<div
|
||||
ref={ref}
|
||||
className="cc-recharts-chart"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
style={containerStyle(width, height)}
|
||||
data-scale-mode={scaleMode}
|
||||
data-responsive-width={responsiveDimension(width)}
|
||||
data-responsive-height={responsiveDimension(height)}
|
||||
>
|
||||
<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={{
|
||||
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>
|
||||
))}
|
||||
</RechartsLineChart>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart as RechartsPieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme";
|
||||
import "../charts.css";
|
||||
|
||||
@@ -28,6 +28,11 @@ interface SanitizedPieChartDatum {
|
||||
}
|
||||
|
||||
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 {
|
||||
return (
|
||||
@@ -58,15 +63,88 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime
|
||||
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:
|
||||
* 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:
|
||||
* 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) {
|
||||
const theme = getCommandCenterChartTheme();
|
||||
const { ref, dimensions } = useMeasuredChartDimensions();
|
||||
const chartDimensions = resolvedDimensions(dimensions, width, height);
|
||||
const chartData = sanitizePieData(data);
|
||||
|
||||
if (chartData.length === 0) {
|
||||
@@ -78,31 +156,37 @@ export function PieChart({ data, ariaLabel, width, height, emptyLabel = "No char
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cc-recharts-chart" role="img" aria-label={ariaLabel} style={containerStyle(width, height)}>
|
||||
<ResponsiveContainer width={responsiveDimension(width)} height={responsiveDimension(height)}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
isAnimationActive={!prefersReducedMotion()}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={entry.label} fill={getCommandCenterChartColor(index, theme)} stroke={theme.tooltipBorder} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: theme.tooltipBackground,
|
||||
borderColor: theme.tooltipBorder,
|
||||
color: theme.tooltipText,
|
||||
}}
|
||||
itemStyle={{ color: theme.tooltipText }}
|
||||
labelStyle={{ color: theme.tooltipText }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: theme.legendText }} />
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
<div
|
||||
ref={ref}
|
||||
className="cc-recharts-chart"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
style={containerStyle(width, height)}
|
||||
data-responsive-width={responsiveDimension(width)}
|
||||
data-responsive-height={responsiveDimension(height)}
|
||||
>
|
||||
<RechartsPieChart width={chartDimensions.width} height={chartDimensions.height}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
isAnimationActive={!prefersReducedMotion()}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={entry.label} fill={getCommandCenterChartColor(index, theme)} stroke={theme.tooltipBorder} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: theme.tooltipBackground,
|
||||
borderColor: theme.tooltipBorder,
|
||||
color: theme.tooltipText,
|
||||
}}
|
||||
itemStyle={{ color: theme.tooltipText }}
|
||||
labelStyle={{ color: theme.tooltipText }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: theme.legendText }} />
|
||||
</RechartsPieChart>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LineChart } from "../LineChart";
|
||||
import type { LineChartSeries } from "../LineChart";
|
||||
|
||||
const chartSize = { width: 360, height: 220 };
|
||||
const fallbackChartSize = { width: 360, height: 220 };
|
||||
|
||||
function chartHtml(label: string): string {
|
||||
return screen.getByRole("img", { name: label }).outerHTML;
|
||||
@@ -20,6 +21,13 @@ function ySpanForDots(seriesName: string): number {
|
||||
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(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -37,6 +45,28 @@ describe("recharts LineChart", () => {
|
||||
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", () => {
|
||||
expect(() => renderChart([{ label: "Single", values: [5] }], "single point")).not.toThrow();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PieChart } from "../PieChart";
|
||||
import type { PieChartProps } from "../PieChart";
|
||||
|
||||
const chartSize = { width: 320, height: 220 };
|
||||
const fallbackChartSize = { width: 320, height: 220 };
|
||||
|
||||
function chartHtml(label: string): string {
|
||||
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} />);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -28,6 +36,28 @@ describe("recharts PieChart", () => {
|
||||
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", () => {
|
||||
expect(() => renderChart([{ label: "Only", value: 3 }], "single slice")).not.toThrow();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user