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:
5
.changeset/fn-6723-command-center-activity-charts.md
Normal file
5
.changeset/fn-6723-command-center-activity-charts.md
Normal 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.
|
||||
@@ -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: [] }]} />);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user