FN-6699: fix Command Center chart rendering
Command Center charts now reserve enough geometry and wrapper size to render activity graphs reliably. - Pad SVG line chart coordinates so endpoints and zero/max values do not clip at the viewBox edge. - Give shared Recharts wrappers a default measurable height and import the shared chart CSS from Recharts chart components. - Add regression coverage for chart wrapper sizing, non-clipped activity graph points, and the retained Mailbox mobile tab font-size rule. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-6699-command-center-charts.md | 5 ++++ packages/dashboard/app/components/MailboxModal.css | 4 +-- .../app/components/__tests__/MailboxModal.test.tsx | 2 +- .../CommandCenter.mobile-chart-layout.test.ts | 1 + .../command-center/__tests__/charts.test.tsx | 35 ++++++++++++++++++++-- .../command-center/areas/__tests__/areas.test.tsx | 32 ++++++++++++++++++++ .../components/command-center/charts/LineChart.tsx | 9 ++++-- .../components/command-center/charts/charts.css | 12 ++++++++ .../command-center/charts/recharts/LineChart.tsx | 4 +++ .../command-center/charts/recharts/PieChart.tsx | 4 +++ 10 files changed, 100 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-6699 Fusion-Task-Lineage: 8227a7be-e855-462a-aa0d-368890398a4c
This commit is contained in:
5
.changeset/fn-6699-command-center-charts.md
Normal file
5
.changeset/fn-6699-command-center-charts.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix Command Center activity chart rendering so plotted extrema stay visible and chart wrappers keep a measurable default height.
|
||||
@@ -789,7 +789,7 @@
|
||||
.mailbox-modal .mailbox-tab {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-xs, 0.8rem);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-content {
|
||||
@@ -881,7 +881,7 @@
|
||||
.mailbox-view .mailbox-tab {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-xs, 0.8rem);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-content {
|
||||
|
||||
@@ -1180,7 +1180,7 @@ describe("MailboxModal", () => {
|
||||
expect(mailboxMobileSection).toContain("display: none;");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-tab");
|
||||
expect(mailboxMobileSection).toContain("padding: var(--space-sm) var(--space-md);");
|
||||
expect(mailboxMobileSection).toContain("font-size: var(--font-size-xs, 0.8rem);");
|
||||
expect(mailboxMobileSection).toContain("font-size: 0.8rem;");
|
||||
expect(mailboxMobileSection).toContain("max-height: calc(100dvh - var(--header-height) - var(--space-2xl) - var(--space-xl));");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-detail-header");
|
||||
expect(mailboxMobileSection).toContain("flex-direction: column;");
|
||||
|
||||
@@ -65,6 +65,7 @@ describe("CommandCenter.mobile-chart-layout.css", () => {
|
||||
});
|
||||
|
||||
it("keeps recharts ResponsiveContainer parents sized without becoming scroll owners", () => {
|
||||
expect(cssContent).toMatch(/\.cc-recharts-chart,[\s\S]*\.cc-recharts-empty\s*\{[\s\S]*inline-size:\s*100%;[\s\S]*block-size:\s*calc\(var\(--space-2xl\)\s*\*\s*7\s*\+\s*var\(--space-lg\)\);[\s\S]*min-inline-size:\s*0/);
|
||||
expect(cssContent).toMatch(/\.cc-overview-chart-card \.cc-recharts-chart,[\s\S]*\.cc-overview-chart-card \.cc-recharts-empty\s*\{[\s\S]*inline-size:\s*100%;[\s\S]*block-size:\s*calc\(var\(--space-2xl\)\s*\*\s*7\s*\+\s*var\(--space-lg\)\);[\s\S]*min-inline-size:\s*0/);
|
||||
expect(cssContent).toMatch(/\.cc-area \.cc-recharts-chart,[\s\S]*\.cc-area \.cc-recharts-empty\s*\{[\s\S]*inline-size:\s*100%;[\s\S]*block-size:\s*calc\(var\(--space-2xl\)\s*\*\s*7\s*\+\s*var\(--space-lg\)\);[\s\S]*min-inline-size:\s*0/);
|
||||
expect(cssContent).not.toMatch(/\.cc-recharts-(?:chart|empty)[^{]*\{[^}]*overflow-y:\s*(?:auto|scroll)/);
|
||||
|
||||
@@ -145,6 +145,22 @@ describe("TokenSeriesChart", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function numericAttribute(element: Element, name: string): number {
|
||||
return Number(element.getAttribute(name));
|
||||
}
|
||||
|
||||
function expectLineChartPointsInsideViewBox(chart: Element): void {
|
||||
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(cy).toBeGreaterThanOrEqual(r);
|
||||
expect(cy).toBeLessThanOrEqual(100 - r);
|
||||
}
|
||||
}
|
||||
|
||||
describe("LineChart", () => {
|
||||
it("renders a populated finite SVG line with an accessible label", () => {
|
||||
render(<LineChart ariaLabel="activity trend" series={[{ label: "messages", values: [2, 4, 1] }]} />);
|
||||
@@ -156,14 +172,16 @@ describe("LineChart", () => {
|
||||
expect(line).toBeTruthy();
|
||||
expect(points).not.toBe("");
|
||||
expect(points).not.toMatch(/NaN|Infinity/);
|
||||
expectLineChartPointsInsideViewBox(chart);
|
||||
});
|
||||
|
||||
it("renders all-zero values as valid baseline geometry without NaN", () => {
|
||||
it("renders all-zero values as valid baseline geometry without NaN or edge clipping", () => {
|
||||
render(<LineChart ariaLabel="zero trend" series={[{ label: "zero", values: [0, 0] }]} />);
|
||||
|
||||
const points = screen.getByRole("img", { name: "zero trend" }).querySelector(".cc-line-chart-path")?.getAttribute("points") ?? "";
|
||||
expect(points).toBe("0,100 100,100");
|
||||
const chart = screen.getByRole("img", { name: "zero trend" });
|
||||
const points = chart.querySelector(".cc-line-chart-path")?.getAttribute("points") ?? "";
|
||||
expect(points).not.toMatch(/NaN|Infinity/);
|
||||
expectLineChartPointsInsideViewBox(chart);
|
||||
});
|
||||
|
||||
it("renders a single-point series as a visible point without a malformed line", () => {
|
||||
@@ -174,6 +192,17 @@ describe("LineChart", () => {
|
||||
const point = chart.querySelector(".cc-line-chart-point");
|
||||
expect(point?.getAttribute("cx")).toBe("50");
|
||||
expect(point?.getAttribute("cy")).not.toMatch(/NaN|Infinity/);
|
||||
expectLineChartPointsInsideViewBox(chart);
|
||||
});
|
||||
|
||||
it("keeps max-value endpoints inside the SVG viewBox instead of clipping them", () => {
|
||||
render(<LineChart ariaLabel="edge trend" series={[{ label: "edge", values: [0, 10] }]} />);
|
||||
|
||||
const chart = screen.getByRole("img", { name: "edge trend" });
|
||||
const points = chart.querySelector(".cc-line-chart-path")?.getAttribute("points") ?? "";
|
||||
expect(points).not.toBe("0,100 100,0");
|
||||
expect(points).not.toMatch(/NaN|Infinity/);
|
||||
expectLineChartPointsInsideViewBox(chart);
|
||||
});
|
||||
|
||||
it("renders an empty series as an empty valid SVG without throwing", () => {
|
||||
|
||||
@@ -194,6 +194,27 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function expectRechartsWrapperWithin(testId: string, label: string): void {
|
||||
const section = screen.getByTestId(testId);
|
||||
const chart = within(section).getByRole("img", { name: label });
|
||||
expect(chart.classList.contains("cc-recharts-chart") || chart.classList.contains("cc-recharts-empty")).toBe(true);
|
||||
expect(chart.outerHTML).not.toMatch(/NaN|Infinity/);
|
||||
}
|
||||
|
||||
function expectSvgLinePointsInsideViewBox(testId: string, label: string): void {
|
||||
const section = screen.getByTestId(testId);
|
||||
const chart = within(section).getByRole("img", { name: label });
|
||||
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(cy).toBeGreaterThanOrEqual(r);
|
||||
expect(cy).toBeLessThanOrEqual(100 - r);
|
||||
}
|
||||
}
|
||||
|
||||
describe("useAnalyticsArea", () => {
|
||||
it("polls only when pollMs is provided and clears the interval on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
@@ -284,6 +305,13 @@ describe("ActivityArea", () => {
|
||||
expect(screen.getByTestId("cc-activity-agent-runs-sparkline")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Agent runs / day" })).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-activity-line-throughput")).toBeTruthy();
|
||||
expectRechartsWrapperWithin("cc-activity-line", "Activity trend");
|
||||
expectRechartsWrapperWithin("cc-activity-pie", "Agent run outcome share");
|
||||
expectSvgLinePointsInsideViewBox("cc-activity-line-messages", "Messages / day");
|
||||
expectSvgLinePointsInsideViewBox("cc-activity-line-agents", "Active agents / day");
|
||||
expectSvgLinePointsInsideViewBox("cc-activity-line-nodes", "Active nodes / day");
|
||||
expectSvgLinePointsInsideViewBox("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");
|
||||
});
|
||||
|
||||
it("renders zero agent-run cards when counts are zero and other activity exists", async () => {
|
||||
@@ -781,6 +809,10 @@ describe("TeamArea", () => {
|
||||
expect(screen.getByTestId("cc-team-tokens-chart")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-team-completed-chart")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-team-pie").textContent).not.toContain("NaN");
|
||||
expectRechartsWrapperWithin("cc-team-pie", "Token share by agent");
|
||||
expect(within(screen.getByTestId("cc-team-tokens-chart")).getByRole("list", { name: "Tokens by agent" }).classList).toContain("cc-bar-chart");
|
||||
expect(within(screen.getByTestId("cc-team-completed-chart")).getByRole("list", { name: "Tasks done by agent" }).classList).toContain("cc-bar-chart");
|
||||
expect(within(screen.getByTestId("cc-team-spread-chart")).getByRole("img", { name: "Team spread" }).classList).toContain("cc-sparkline");
|
||||
});
|
||||
|
||||
it("keeps the team pie safe for single-item and non-finite data", async () => {
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface LineChartProps {
|
||||
const VIEWBOX_SIZE = 100;
|
||||
const SINGLE_POINT_X = VIEWBOX_SIZE / 2;
|
||||
const POINT_RADIUS = 1.8;
|
||||
const PLOT_PADDING = 3;
|
||||
const PLOT_SIZE = VIEWBOX_SIZE - PLOT_PADDING * 2;
|
||||
|
||||
function safeHeightPercent(value: number, max: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
@@ -31,11 +33,11 @@ function safeCoord(value: number): number {
|
||||
}
|
||||
|
||||
function pointFor(value: number, index: number, count: number, max: number): { x: number; y: number } {
|
||||
const x = count <= 1 ? SINGLE_POINT_X : (index / (count - 1)) * VIEWBOX_SIZE;
|
||||
const x = count <= 1 ? SINGLE_POINT_X : PLOT_PADDING + (index / (count - 1)) * PLOT_SIZE;
|
||||
const height = safeHeightPercent(value, max);
|
||||
return {
|
||||
x: safeCoord(x),
|
||||
y: safeCoord(VIEWBOX_SIZE - height),
|
||||
y: safeCoord(PLOT_PADDING + PLOT_SIZE * (1 - height / VIEWBOX_SIZE)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +65,9 @@ function computedMaxFor(series: LineChartSeries[], max?: number): number {
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function LineChart({ series, ariaLabel, max }: LineChartProps) {
|
||||
const computedMax = computedMaxFor(series, max);
|
||||
|
||||
@@ -298,6 +298,18 @@ Line-chart motion is decorative, token-timed, and disabled for reduced-motion us
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Recharts wrappers ---- */
|
||||
/*
|
||||
FNXC:CommandCenterCharts 2026-06-19-05:24:
|
||||
ResponsiveContainer measures its direct parent. Keep the shared recharts wrapper non-zero by default so Activity, Team, Overview, and any future Command Center chart surface render populated data instead of a blank zero-height box; scoped area/overview CSS may restate this size but must not remove the measurable block axis.
|
||||
*/
|
||||
.cc-recharts-chart,
|
||||
.cc-recharts-empty {
|
||||
inline-size: 100%;
|
||||
block-size: calc(var(--space-2xl) * 7 + var(--space-lg));
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
/* ---- RadialGauge ---- */
|
||||
.cc-radial-gauge {
|
||||
min-inline-size: 0;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme";
|
||||
import "../charts.css";
|
||||
|
||||
export interface LineChartSeries {
|
||||
label: string;
|
||||
@@ -80,6 +81,9 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* FNXC:CommandCenterCharts 2026-06-19-05:24:
|
||||
* Recharts ResponsiveContainer renders blank when its parent has no measurable block-size. Import the shared chart CSS here so every Command Center surface using this wrapper gets the token-sized default parent height even if it does not also render a hand-rolled chart primitive.
|
||||
*/
|
||||
export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data" }: LineChartProps) {
|
||||
const theme = getCommandCenterChartTheme();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { getCommandCenterChartColor, getCommandCenterChartTheme } from "./theme";
|
||||
import "../charts.css";
|
||||
|
||||
export interface PieChartDatum {
|
||||
label: string;
|
||||
@@ -60,6 +61,9 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function PieChart({ data, ariaLabel, width, height, emptyLabel = "No chart data" }: PieChartProps) {
|
||||
const theme = getCommandCenterChartTheme();
|
||||
|
||||
Reference in New Issue
Block a user