FN-6723: normalize activity trend chart scaling

Normalize Command Center activity chart lines so low-volume agent series remain visible beside message volume.

- Add an opt-in per-series scale mode to the Recharts line chart while preserving shared scaling by default.
- Enable per-series scaling for the Activity trend chart and expose scale mode for regression coverage.
- Cover mixed-unit, non-finite, and finite chart geometry cases across Command Center chart tests.
- Add a patch changeset for the published Fusion package.

Files changed:
 .../fn-6723-command-center-activity-charts.md      |  5 +++
 .../command-center/__tests__/charts.test.tsx       |  8 ++++
 .../command-center/areas/ActivityArea.tsx          |  5 +++
 .../command-center/areas/__tests__/areas.test.tsx  | 42 ++++++++++++++++++
 .../command-center/charts/recharts/LineChart.tsx   | 50 +++++++++++++++++-----
 .../charts/recharts/__tests__/LineChart.test.tsx   | 34 ++++++++++++++-
 6 files changed, 131 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-6723

Fusion-Task-Lineage: 8f7ee1cb-5488-4237-b0c1-b4547fa4961d
This commit is contained in:
gsxdsm
2026-06-19 08:38:20 -07:00
parent ee72c942f1
commit 8f052c6b92
6 changed files with 131 additions and 13 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix Command Center Activity trend charts so mixed-unit agent/activity series stay visually legible instead of being flattened by high-volume message counts.

View File

@@ -289,6 +289,14 @@ describe("LineChart", () => {
expectLineChartPointsInsideViewBox(chart);
});
it("coerces non-finite and negative values without leaking invalid SVG geometry", () => {
render(<LineChart ariaLabel="invalid trend" series={[{ label: "invalid", values: [Number.NaN, Number.POSITIVE_INFINITY, -2, 4] }]} />);
const chart = screen.getByRole("img", { name: "invalid trend" });
expect(chart.outerHTML).not.toMatch(/NaN|Infinity/);
expectLineChartPointsInsideViewBox(chart);
});
it("renders an empty series as an empty valid SVG without throwing", () => {
render(<LineChart ariaLabel="empty line" series={[{ label: "empty", values: [] }]} />);

View File

@@ -123,9 +123,14 @@ export function ActivityArea({ range }: { range: DateRange }) {
{daily.length > 0 ? (
<div className="cc-area-section" data-testid="cc-activity-line">
<h3 className="cc-area-section-title">{t("commandCenter.activity.rechartsLine", "Activity trend")}</h3>
{/*
FNXC:CommandCenterCharts 2026-06-19-07:58:
The Activity trend combines message volume with low-count agent/run series. Opt into per-series normalization so agent activity remains visible instead of being flattened by message-scale values.
*/}
<RechartsLineChart
series={rechartsLineSeries}
ariaLabel={t("commandCenter.activity.rechartsLine", "Activity trend")}
scaleMode="series"
/>
</div>
) : null}

View File

@@ -201,6 +201,22 @@ function expectRechartsWrapperWithin(testId: string, label: string): void {
expect(chart.outerHTML).not.toMatch(/NaN|Infinity/);
}
function expectBarFillsFinite(testId: string): void {
const section = screen.getByTestId(testId);
for (const fill of Array.from(section.querySelectorAll<HTMLElement>(".cc-bar-fill"))) {
expect(fill.style.width).toMatch(/^\d+(?:\.\d+)?%$/);
expect(fill.style.width).not.toMatch(/NaN|Infinity/);
}
}
function expectSparklineHeightsFinite(testId: string): void {
const section = screen.getByTestId(testId);
for (const bar of Array.from(section.querySelectorAll<HTMLElement>(".cc-sparkline-bar"))) {
expect(bar.style.height).toMatch(/^\d+(?:\.\d+)?%$/);
expect(bar.style.height).not.toMatch(/NaN|Infinity/);
}
}
function expectSvgLinePointsInsideViewBox(testId: string, label: string): void {
const section = screen.getByTestId(testId);
const chart = within(section).getByRole("img", { name: label });
@@ -306,12 +322,35 @@ describe("ActivityArea", () => {
expect(screen.getByRole("img", { name: "Agent runs / day" })).toBeTruthy();
expect(screen.getByTestId("cc-activity-line-throughput")).toBeTruthy();
expectRechartsWrapperWithin("cc-activity-line", "Activity trend");
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");
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");
expectSparklineHeightsFinite("cc-activity-agent-runs-sparkline");
});
it("opts only the mixed-unit activity trend into per-series scaling", async () => {
apiMock.mockResolvedValue({
...activityFixture(),
messages: 3_300,
activeAgents: 2,
agentRuns: { total: 9, active: 1, completed: 7, failed: 1 },
daily: [
{ day: "2026-06-08", messages: 1_000, activeNodes: 20, activeAgents: 1, agentRuns: 2 },
{ day: "2026-06-09", messages: 1_200, activeNodes: 22, activeAgents: 2, agentRuns: 4 },
{ day: "2026-06-10", messages: 1_100, activeNodes: 21, activeAgents: 1, agentRuns: 3 },
],
});
render(<ActivityArea range={range7d} />);
await screen.findByTestId("cc-area-activity");
expect(screen.getByRole("img", { name: "Activity trend" })).toHaveAttribute("data-scale-mode", "series");
expectSvgLinePointsInsideViewBox("cc-activity-line-agents", "Active agents / day");
expectSvgLinePointsInsideViewBox("cc-activity-line-throughput", "Throughput / day");
expectSparklineHeightsFinite("cc-activity-agent-runs-sparkline");
});
it("renders zero agent-run cards when counts are zero and other activity exists", async () => {
@@ -813,6 +852,9 @@ describe("TeamArea", () => {
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");
expectBarFillsFinite("cc-team-tokens-chart");
expectBarFillsFinite("cc-team-completed-chart");
expectSparklineHeightsFinite("cc-team-spread-chart");
});
it("keeps the team pie safe for single-item and non-finite data", async () => {

View File

@@ -22,12 +22,18 @@ export interface LineChartProps {
width?: number | string;
height?: number | string;
emptyLabel?: string;
/**
* `shared` keeps comparable values on one absolute axis. `series` normalizes each series to its own max so mixed-unit trends remain legible.
*/
scaleMode?: "shared" | "series";
}
interface SanitizedLineChartSeries {
label: string;
dataKey: string;
values: number[];
plotKey: string;
plotValues: number[];
}
type LineChartPoint = { index: number } & Record<string, number>;
@@ -45,13 +51,26 @@ function sanitizeLineValue(value: number): number {
return Number.isFinite(value) && value > 0 ? value : 0;
}
function sanitizeSeries(series: LineChartSeries[]): SanitizedLineChartSeries[] {
function normalizeSeriesValues(values: number[]): number[] {
const max = values.reduce((largest, value) => (value > largest ? value : largest), 0);
if (max <= 0) {
return values.map(() => 0);
}
return values.map((value) => (value / max) * 100);
}
function sanitizeSeries(series: LineChartSeries[], scaleMode: LineChartProps["scaleMode"]): SanitizedLineChartSeries[] {
return series
.map((entry, index) => ({
label: entry.label,
dataKey: `series${index}`,
values: entry.values.map(sanitizeLineValue),
}))
.map((entry, index) => {
const values = entry.values.map(sanitizeLineValue);
return {
label: entry.label,
dataKey: `series${index}`,
values,
plotKey: scaleMode === "series" ? `series${index}Normalized` : `series${index}`,
plotValues: scaleMode === "series" ? normalizeSeriesValues(values) : values,
};
})
.filter((entry) => entry.values.length > 0);
}
@@ -61,6 +80,7 @@ function lineChartData(series: SanitizedLineChartSeries[]): LineChartPoint[] {
const point: LineChartPoint = { index: index + 1 };
for (const entry of series) {
point[entry.dataKey] = entry.values[index] ?? 0;
point[entry.plotKey] = entry.plotValues[index] ?? 0;
}
return point;
});
@@ -84,10 +104,13 @@ function responsiveDimension(value: number | string | undefined): ResponsiveDime
*
* 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.
*
* 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.
*/
export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data" }: LineChartProps) {
export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No chart data", scaleMode = "shared" }: LineChartProps) {
const theme = getCommandCenterChartTheme();
const chartSeries = sanitizeSeries(series);
const chartSeries = sanitizeSeries(series, scaleMode);
const chartData = lineChartData(chartSeries);
if (chartSeries.length === 0 || chartData.length === 0) {
@@ -99,12 +122,17 @@ 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)}>
<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 }} />
<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,
@@ -119,7 +147,7 @@ export function LineChart({ series, ariaLabel, width, height, emptyLabel = "No c
<Line
key={entry.dataKey}
type="monotone"
dataKey={entry.dataKey}
dataKey={entry.plotKey}
name={entry.label}
stroke={getCommandCenterChartColor(index, theme)}
isAnimationActive={!prefersReducedMotion()}

View File

@@ -9,9 +9,15 @@ function chartHtml(label: string): string {
return screen.getByRole("img", { name: label }).outerHTML;
}
function renderChart(series: LineChartSeries[], ariaLabel = "line chart") {
function renderChart(series: LineChartSeries[], ariaLabel = "line chart", scaleMode?: "shared" | "series") {
// FNXC:CommandCenterCharts 2026-06-18-22:03: jsdom's ResizeObserver mock does not report dimensions, so tests pass explicit dimensions through the wrapper to mount recharts children while production remains responsive.
return render(<LineChart series={series} ariaLabel={ariaLabel} {...chartSize} />);
return render(<LineChart series={series} ariaLabel={ariaLabel} {...chartSize} scaleMode={scaleMode} />);
}
function ySpanForDots(seriesName: string): number {
const values = Array.from(document.querySelectorAll<SVGCircleElement>(`.recharts-line-dot[name="${seriesName}"]`)).map((dot) => Number(dot.getAttribute("cy")));
expect(values.length).toBeGreaterThan(1);
return Math.max(...values) - Math.min(...values);
}
afterEach(() => {
@@ -56,6 +62,30 @@ describe("recharts LineChart", () => {
expect(chartHtml("zero line")).not.toMatch(/NaN|Infinity/);
});
it("normalizes mixed-unit series so low-count activity lines are not flattened", () => {
renderChart([
{ label: "Messages", values: [1_000, 1_200, 1_100] },
{ label: "Active agents", values: [1, 2, 1] },
{ label: "Agent runs", values: [2, 4, 3] },
], "activity trend", "series");
const chart = screen.getByRole("img", { name: "activity trend" });
expect(chart).toHaveAttribute("data-scale-mode", "series");
expect(ySpanForDots("Active agents")).toBeGreaterThan(40);
expect(ySpanForDots("Agent runs")).toBeGreaterThan(40);
expect(chartHtml("activity trend")).not.toMatch(/NaN|Infinity/);
});
it("keeps shared absolute scale as the default for comparable series", () => {
renderChart([
{ label: "Filed", values: [3, 6, 9] },
{ label: "Fixed", values: [2, 4, 8] },
], "issue flow");
expect(screen.getByRole("img", { name: "issue flow" })).toHaveAttribute("data-scale-mode", "shared");
expect(chartHtml("issue flow")).not.toContain("% of series");
});
it("coerces non-finite and negative values without leaking invalid output", () => {
expect(() => renderChart([{ label: "Invalid", values: [Number.NaN, Number.POSITIVE_INFINITY, -4, 2] }], "invalid line")).not.toThrow();