From 31dd7429a36d248fd08eaa933d8737790a44e13a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 18:02:49 -0700 Subject: [PATCH] FN-6883: fill Command Center line charts Restore Command Center Activity line charts so their plotted geometry fills the rendered chart box without distorting point markers. - Measure the rendered SVG dimensions and use them as the line chart viewBox so paths span wide desktop and mobile layouts. - Preserve safe bounded geometry for zero, single-point, NaN, and resized chart states. - Update Command Center chart tests and dashboard rendering guidance for the fill-width/circular-marker invariant. Files changed: docs/dashboard-guide.md | 2 +- .../command-center/__tests__/charts.test.tsx | 71 ++++++++++++++--- .../command-center/areas/__tests__/areas.test.tsx | 35 ++++++--- .../components/command-center/charts/LineChart.tsx | 89 ++++++++++++++++++---- .../components/command-center/charts/charts.css | 4 +- 5 files changed, 164 insertions(+), 37 deletions(-) Fusion-Task-Id: FN-6883 Fusion-Task-Lineage: 71c19a62-1a76-455d-92cb-9c22d0613881 --- docs/dashboard-guide.md | 2 +- .../command-center/__tests__/charts.test.tsx | 71 ++++++++++++-- .../areas/__tests__/areas.test.tsx | 35 +++++-- .../command-center/charts/LineChart.tsx | 93 +++++++++++++++---- .../command-center/charts/charts.css | 4 +- 5 files changed, 166 insertions(+), 39 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 8a93ca7b71..1e810cb47c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -713,7 +713,7 @@ Features: Rendering invariants: - On mobile (`max-width: 768px`), `.cc-tabpanel` remains the sole vertical scroll owner for every chart-bearing tab. Shared chart primitives (`Bar`, `StackedBar`, `Sparkline`, `LineChart`, `RadialGauge`, `Funnel`, `TokenSeriesChart`, and the Command Center recharts wrappers) must shrink within the tabpanel, keep non-zero usable height, avoid stretch/clipping artifacts, and never introduce a competing vertical overflow container. -- The hand-rolled Activity `LineChart` uses uniform SVG scaling so point markers remain true circles and line geometry remains proportional even when the CSS chart box is wide/short on desktop or auto-aspect on mobile. +- The hand-rolled Activity `LineChart` tracks its rendered SVG box for coordinates: populated paths fill the available chart width (no centered square letterboxing), while point markers remain true circles even when the CSS chart box is wide/short on desktop or auto-aspect on mobile. - Mobile chart text must not rely on min-content luck: bar labels, values, token-series axis labels, funnel headers, radial labels, legends, and chart tracks need explicit `min-inline-size: 0`, wrapping, or ellipsis rules so long model/agent/repo labels cannot crush the track or create hidden horizontal overflow in a real browser. - On tablet (`min-width: 769px` and `max-width: 1024px`), `.project-content`, `.command-center`, and `.cc-tabpanel` keep the same definite flex/min-height scroll-owner chain, while the live strip and chart grids collapse before they can create document-level horizontal overflow. - Command Center stat cards, overview chart cards, live strips, table wrappers, Team chart panels, token-series plots, system control cards, and gauge/chart cards share the same tokenized rhythm: `--space-md` gaps/padding for card-like surfaces, `1px solid var(--border-subtle)` borders, `--radius-md` radii, and `--surface-1` backgrounds. Area-specific accents may use `color-mix(...)`, but layout, border, radius, text color, and motion must stay on design tokens, with the named 4px spacing scale (`--space-xs`/`sm`/`md`/`lg`/`xl`/`2xl`) as the canonical vocabulary. diff --git a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx index ada5ed788d..db0e91bcce 100644 --- a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { readFileSync } from "fs"; import { resolve } from "path"; import { Bar } from "../charts/Bar"; @@ -234,23 +234,49 @@ function numericAttribute(element: Element, name: string): number { } function expectLineChartPointsInsideViewBox(chart: Element): void { + const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart); for (const point of Array.from(chart.querySelectorAll(".cc-line-chart-point"))) { const cx = numericAttribute(point, "cx"); const cy = numericAttribute(point, "cy"); const r = numericAttribute(point, "r"); expect(cx).toBeGreaterThanOrEqual(r); - expect(cx).toBeLessThanOrEqual(100 - r); + expect(cx).toBeLessThanOrEqual(viewBoxWidth - r); expect(cy).toBeGreaterThanOrEqual(r); - expect(cy).toBeLessThanOrEqual(100 - r); + expect(cy).toBeLessThanOrEqual(viewBoxHeight - r); } } -function expectLineChartMarkersDistortionProof(chart: Element): void { - expect(chart.getAttribute("preserveAspectRatio")).toBe("xMidYMid meet"); - expect(chart.getAttribute("preserveAspectRatio")).not.toBe("none"); +function viewBoxNumbers(chart: Element): [number, number, number, number] { + return (chart.getAttribute("viewBox") ?? "") + .split(/\s+/) + .map(Number) as [number, number, number, number]; +} + +function linePointTuples(line: Element): Array<[number, number]> { + return (line.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .filter(Boolean) + .map((pair) => pair.split(",").map(Number) as [number, number]); +} + +function expectLineChartFillsBoxAndKeepsRoundMarkers(chart: Element): void { + const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart); + expect(viewBoxWidth).toBeGreaterThan(viewBoxHeight); + expect(chart.getAttribute("preserveAspectRatio")).toBe("none"); + expect(chart.getAttribute("viewBox")).not.toBe("0 0 100 100"); expect(chart.querySelectorAll(".cc-line-chart-point").length).toBeGreaterThan(0); } +function expectLineChartPathFillsPlotWidth(chart: Element): void { + const [, , viewBoxWidth] = viewBoxNumbers(chart); + const line = chart.querySelector(".cc-line-chart-path"); + expect(line).toBeTruthy(); + const points = linePointTuples(line!); + expect(points[0]?.[0]).toBe(3); + expect(points.at(-1)?.[0]).toBe(viewBoxWidth - 3); +} + describe("LineChart", () => { it("renders a populated finite SVG line with an accessible label", () => { render(); @@ -263,14 +289,15 @@ describe("LineChart", () => { expect(points).not.toBe(""); expect(points).not.toMatch(/NaN|Infinity/); expectLineChartPointsInsideViewBox(chart); - expectLineChartMarkersDistortionProof(chart); + expectLineChartFillsBoxAndKeepsRoundMarkers(chart); + expectLineChartPathFillsPlotWidth(chart); }); it("uses uniform SVG scaling so marker circles cannot stretch into ovals", () => { render(); const chart = screen.getByRole("img", { name: "mobile activity trend" }); - expectLineChartMarkersDistortionProof(chart); + expectLineChartFillsBoxAndKeepsRoundMarkers(chart); }); it("keeps mobile sizing non-square while SVG geometry remains uniformly scaled", () => { @@ -278,7 +305,31 @@ describe("LineChart", () => { render(); - expectLineChartMarkersDistortionProof(screen.getByRole("img", { name: "narrow activity trend" })); + const chart = screen.getByRole("img", { name: "narrow activity trend" }); + expectLineChartFillsBoxAndKeepsRoundMarkers(chart); + expectLineChartPathFillsPlotWidth(chart); + }); + + it("updates the viewBox from ResizeObserver so variable mobile boxes keep round markers", async () => { + const originalResizeObserver = globalThis.ResizeObserver; + class ImmediateResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe() { + this.callback([{ contentRect: { width: 320, height: 160 } as DOMRectReadOnly } as ResizeObserverEntry], this as unknown as ResizeObserver); + } + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = ImmediateResizeObserver as unknown as typeof ResizeObserver; + try { + render(); + const chart = screen.getByRole("img", { name: "measured mobile activity trend" }); + await waitFor(() => expect(chart.getAttribute("viewBox")).toBe("0 0 320 160")); + expectLineChartFillsBoxAndKeepsRoundMarkers(chart); + expectLineChartPathFillsPlotWidth(chart); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + } }); it("renders all-zero values as valid baseline geometry without NaN or edge clipping", () => { @@ -296,7 +347,7 @@ describe("LineChart", () => { const chart = screen.getByRole("img", { name: "single trend" }); expect(chart.querySelector(".cc-line-chart-path")).toBeNull(); const point = chart.querySelector(".cc-line-chart-point"); - expect(point?.getAttribute("cx")).toBe("50"); + expect(point?.getAttribute("cx")).toBe("125"); expect(point?.getAttribute("cy")).not.toMatch(/NaN|Infinity/); expectLineChartPointsInsideViewBox(chart); }); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index 3135cd5b15..000c698ab9 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -287,26 +287,43 @@ function expectSparklineHeightsFinite(testId: string): void { } } +function viewBoxNumbers(chart: Element): [number, number, number, number] { + return (chart.getAttribute("viewBox") ?? "") + .split(/\s+/) + .map(Number) as [number, number, number, number]; +} + function expectSvgLinePointsInsideViewBox(testId: string, label: string): void { const section = screen.getByTestId(testId); const chart = within(section).getByRole("img", { name: label }); + const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart); for (const point of Array.from(chart.querySelectorAll(".cc-line-chart-point"))) { const cx = Number(point.getAttribute("cx")); const cy = Number(point.getAttribute("cy")); const r = Number(point.getAttribute("r")); expect(cx).toBeGreaterThanOrEqual(r); - expect(cx).toBeLessThanOrEqual(100 - r); + expect(cx).toBeLessThanOrEqual(viewBoxWidth - r); expect(cy).toBeGreaterThanOrEqual(r); - expect(cy).toBeLessThanOrEqual(100 - r); + expect(cy).toBeLessThanOrEqual(viewBoxHeight - r); } } -function expectSvgLineMarkersUndistorted(testId: string, label: string): void { +function expectSvgLineFillsBoxAndKeepsRoundMarkers(testId: string, label: string): void { const section = screen.getByTestId(testId); const chart = within(section).getByRole("img", { name: label }); - expect(chart.getAttribute("preserveAspectRatio")).toBe("xMidYMid meet"); - expect(chart.getAttribute("preserveAspectRatio")).not.toBe("none"); + const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers(chart); + const line = chart.querySelector(".cc-line-chart-path"); + const pointPairs = (line?.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .filter(Boolean) + .map((pair) => pair.split(",").map(Number) as [number, number]); + expect(viewBoxWidth).toBeGreaterThan(viewBoxHeight); + expect(chart.getAttribute("preserveAspectRatio")).toBe("none"); + expect(chart.getAttribute("viewBox")).not.toBe("0 0 100 100"); expect(chart.querySelectorAll(".cc-line-chart-point").length).toBeGreaterThan(0); + expect(pointPairs[0]?.[0]).toBe(3); + expect(pointPairs.at(-1)?.[0]).toBe(viewBoxWidth - 3); } describe("useAnalyticsArea", () => { @@ -403,13 +420,13 @@ describe("ActivityArea", () => { expect(screen.getByRole("img", { name: "Activity trend" })).toHaveAttribute("data-scale-mode", "series"); expectRechartsWrapperWithin("cc-activity-pie", "Agent run outcome share"); expectSvgLinePointsInsideViewBox("cc-activity-line-messages", "Messages / day"); - expectSvgLineMarkersUndistorted("cc-activity-line-messages", "Messages / day"); + expectSvgLineFillsBoxAndKeepsRoundMarkers("cc-activity-line-messages", "Messages / day"); expectSvgLinePointsInsideViewBox("cc-activity-line-agents", "Active agents / day"); - expectSvgLineMarkersUndistorted("cc-activity-line-agents", "Active agents / day"); + expectSvgLineFillsBoxAndKeepsRoundMarkers("cc-activity-line-agents", "Active agents / day"); expectSvgLinePointsInsideViewBox("cc-activity-line-nodes", "Active nodes / day"); - expectSvgLineMarkersUndistorted("cc-activity-line-nodes", "Active nodes / day"); + expectSvgLineFillsBoxAndKeepsRoundMarkers("cc-activity-line-nodes", "Active nodes / day"); expectSvgLinePointsInsideViewBox("cc-activity-line-throughput", "Throughput / day"); - expectSvgLineMarkersUndistorted("cc-activity-line-throughput", "Throughput / day"); + expectSvgLineFillsBoxAndKeepsRoundMarkers("cc-activity-line-throughput", "Throughput / day"); expect(within(screen.getByTestId("cc-activity-agent-runs-sparkline")).getByRole("img", { name: "Agent runs / day" }).classList).toContain("cc-sparkline"); expectSparklineHeightsFinite("cc-activity-agent-runs-sparkline"); }); diff --git a/packages/dashboard/app/components/command-center/charts/LineChart.tsx b/packages/dashboard/app/components/command-center/charts/LineChart.tsx index 5748bef915..c40fdfd9c9 100644 --- a/packages/dashboard/app/components/command-center/charts/LineChart.tsx +++ b/packages/dashboard/app/components/command-center/charts/LineChart.tsx @@ -1,3 +1,4 @@ +import { useLayoutEffect, useRef, useState } from "react"; import "./charts.css"; export interface LineChartSeries { @@ -14,35 +15,53 @@ export interface LineChartProps { max?: number; } -const VIEWBOX_SIZE = 100; -const SINGLE_POINT_X = VIEWBOX_SIZE / 2; +const FALLBACK_VIEWBOX_WIDTH = 250; +const FALLBACK_VIEWBOX_HEIGHT = 100; const POINT_RADIUS = 1.8; const PLOT_PADDING = 3; -const PLOT_SIZE = VIEWBOX_SIZE - PLOT_PADDING * 2; + +interface ChartGeometry { + width: number; + height: number; +} + +const FALLBACK_GEOMETRY: ChartGeometry = { width: FALLBACK_VIEWBOX_WIDTH, height: FALLBACK_VIEWBOX_HEIGHT }; function safeHeightPercent(value: number, max: number): number { if (!Number.isFinite(value) || value <= 0) { return 0; } const denom = Number.isFinite(max) && max > 0 ? max : 1; - return Math.max(0, Math.min(VIEWBOX_SIZE, (value / denom) * VIEWBOX_SIZE)); + return Math.max(0, Math.min(100, (value / denom) * 100)); } function safeCoord(value: number): number { return Number.isFinite(value) ? value : 0; } -function pointFor(value: number, index: number, count: number, max: number): { x: number; y: number } { - const x = count <= 1 ? SINGLE_POINT_X : PLOT_PADDING + (index / (count - 1)) * PLOT_SIZE; - const height = safeHeightPercent(value, max); +function boundedGeometry(width: number, height: number): ChartGeometry | null { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } return { - x: safeCoord(x), - y: safeCoord(PLOT_PADDING + PLOT_SIZE * (1 - height / VIEWBOX_SIZE)), + width: Math.max(width, PLOT_PADDING * 2 + POINT_RADIUS * 2), + height: Math.max(height, PLOT_PADDING * 2 + POINT_RADIUS * 2), }; } -function pointsFor(values: number[], max: number): { x: number; y: number }[] { - return values.map((value, index) => pointFor(value, index, values.length, max)); +function pointFor(value: number, index: number, count: number, max: number, geometry: ChartGeometry): { x: number; y: number } { + const plotWidth = Math.max(0, geometry.width - PLOT_PADDING * 2); + const plotHeight = Math.max(0, geometry.height - PLOT_PADDING * 2); + const x = count <= 1 ? geometry.width / 2 : PLOT_PADDING + (index / (count - 1)) * plotWidth; + const height = safeHeightPercent(value, max); + return { + x: safeCoord(x), + y: safeCoord(PLOT_PADDING + plotHeight * (1 - height / 100)), + }; +} + +function pointsFor(values: number[], max: number, geometry: ChartGeometry): { x: number; y: number }[] { + return values.map((value, index) => pointFor(value, index, values.length, max, geometry)); } function pointsAttribute(points: { x: number; y: number }[]): string { @@ -62,6 +81,14 @@ function computedMaxFor(series: LineChartSeries[], max?: number): number { }, 0); } +function geometryFromRect(rect: Pick): ChartGeometry | null { + return boundedGeometry(rect.width, rect.height); +} + +function geometriesMatch(left: ChartGeometry, right: ChartGeometry): boolean { + return Math.abs(left.width - right.width) < 0.5 && Math.abs(left.height - right.height) < 0.5; +} + /** * FNXC:CommandCenterCharts 2026-06-18-14:29: * Command Center needed a true, zero/NaN-safe, reduced-motion-aware animated line chart for time-series metrics; reuse the Bar/Sparkline safe-height convention so malformed analytics values never leak NaN or Infinity into SVG geometry. @@ -69,22 +96,54 @@ function computedMaxFor(series: LineChartSeries[], max?: number): number { * FNXC:CommandCenterCharts 2026-06-19-05:24: * Activity line charts were clipping max/min points because the data domain mapped to the full SVG viewBox edge. Reserve plot padding equal to the rendered point/stroke margin so populated, single-point, zero, and max-value series stay inside the viewBox on desktop and mobile. * - * FNXC:CommandCenterCharts 2026-06-20-22:59: - * FN-6818 requires Activity markers to render as true circles independent of the chart container's aspect ratio. `preserveAspectRatio="none"` on the square viewBox stretched circle coordinates into ovals on narrow/mobile layouts; `vectorEffect="non-scaling-stroke"` only protects stroke width, not coordinate geometry, so the SVG must use uniform scaling. + * FNXC:CommandCenterCharts 2026-06-21-17:10: + * FN-6883 restores the dual invariant FN-6818 could not satisfy with a square `xMidYMid meet` viewBox: Activity line charts must fill the wide desktop/mobile CSS box without centered blank margins, and markers must remain true circles. Track the rendered SVG box with ResizeObserver and use that measured coordinate system with `preserveAspectRatio="none"`; the fallback matches the desktop 5:2 CSS ratio so first paint and jsdom tests do not reintroduce square letterboxing. */ export function LineChart({ series, ariaLabel, max }: LineChartProps) { + const svgRef = useRef(null); + const [geometry, setGeometry] = useState(FALLBACK_GEOMETRY); const computedMax = computedMaxFor(series, max); + useLayoutEffect(() => { + const svg = svgRef.current; + if (!svg) { + return undefined; + } + + const updateGeometry = (next: ChartGeometry | null) => { + if (!next) { + return; + } + setGeometry((current) => (geometriesMatch(current, next) ? current : next)); + }; + + updateGeometry(geometryFromRect(svg.getBoundingClientRect())); + + if (typeof ResizeObserver === "undefined") { + return undefined; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + updateGeometry(geometryFromRect(entry.contentRect)); + } + }); + observer.observe(svg); + return () => observer.disconnect(); + }, []); + return ( {series.map((entry, seriesIndex) => { - const points = pointsFor(entry.values, computedMax); + const points = pointsFor(entry.values, computedMax, geometry); const pointString = pointsAttribute(points); return ( @@ -92,7 +151,7 @@ export function LineChart({ series, ariaLabel, max }: LineChartProps) { diff --git a/packages/dashboard/app/components/command-center/charts/charts.css b/packages/dashboard/app/components/command-center/charts/charts.css index 25b0eaedee..d2ec133a1d 100644 --- a/packages/dashboard/app/components/command-center/charts/charts.css +++ b/packages/dashboard/app/components/command-center/charts/charts.css @@ -349,8 +349,8 @@ The token-over-time chart is live-updated and animated, but the motion is decora FNXC:CommandCenterStyling 2026-06-18-14:29: Line-chart motion is decorative, token-timed, and disabled for reduced-motion users; sizing and stroke colors stay on design tokens so the Activity area remains readable across desktop and mobile without chart-specific hardcoded colors or lengths. -FNXC:CommandCenterCharts 2026-06-20-22:59: -FN-6818 keeps the CSS box wide/short for the Activity layout while the SVG now uses uniform scaling for geometry. Do not switch the square viewBox back to non-uniform scaling to fill this box: that made point markers render as ovals on mobile, and non-scaling stroke was insufficient because it did not protect circle coordinates. +FNXC:CommandCenterCharts 2026-06-21-17:10: +FN-6883 keeps the Activity line chart CSS box wide/short while the SVG coordinate system now tracks the rendered box. FN-6818's square `xMidYMid meet` viewBox kept markers circular but collapsed the plot into a centered square with blank margins; the measured viewBox fills desktop and mobile boxes while preserving round markers. */ .cc-line-chart { display: block;