diff --git a/.changeset/fix-command-center-line-charts.md b/.changeset/fix-command-center-line-charts.md new file mode 100644 index 0000000000..05e8b37125 --- /dev/null +++ b/.changeset/fix-command-center-line-charts.md @@ -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. diff --git a/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx b/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx index c4a0ff197c..6d66b13fd6 100644 --- a/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx +++ b/packages/dashboard/app/components/command-center/charts/recharts/LineChart.tsx @@ -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; 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(null); + const [dimensions, setDimensions] = useState(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 ( -
- - - - - `${value}%` : undefined} +
+ + + + `${value}%` : undefined} + /> + + + {chartSeries.map((entry, index) => ( + - - - {chartSeries.map((entry, index) => ( - - ))} - - + ))} +
); } diff --git a/packages/dashboard/app/components/command-center/charts/recharts/PieChart.tsx b/packages/dashboard/app/components/command-center/charts/recharts/PieChart.tsx index 60462ad1ce..f91266aef9 100644 --- a/packages/dashboard/app/components/command-center/charts/recharts/PieChart.tsx +++ b/packages/dashboard/app/components/command-center/charts/recharts/PieChart.tsx @@ -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(null); + const [dimensions, setDimensions] = useState(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 ( -
- - - - {chartData.map((entry, index) => ( - - ))} - - - - - +
+ + + {chartData.map((entry, index) => ( + + ))} + + + +
); } diff --git a/packages/dashboard/app/components/command-center/charts/recharts/__tests__/LineChart.test.tsx b/packages/dashboard/app/components/command-center/charts/recharts/__tests__/LineChart.test.tsx index 367b4b6390..d94852163d 100644 --- a/packages/dashboard/app/components/command-center/charts/recharts/__tests__/LineChart.test.tsx +++ b/packages/dashboard/app/components/command-center/charts/recharts/__tests__/LineChart.test.tsx @@ -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("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()).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(); diff --git a/packages/dashboard/app/components/command-center/charts/recharts/__tests__/PieChart.test.tsx b/packages/dashboard/app/components/command-center/charts/recharts/__tests__/PieChart.test.tsx index 3015bf6a74..047dce87de 100644 --- a/packages/dashboard/app/components/command-center/charts/recharts/__tests__/PieChart.test.tsx +++ b/packages/dashboard/app/components/command-center/charts/recharts/__tests__/PieChart.test.tsx @@ -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(); } +function renderedSvg(label: string): SVGSVGElement { + const svgs = Array.from(screen.getByRole("img", { name: label }).querySelectorAll("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()).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();