FN-6656: add live activity trend charts
Add live Command Center activity line charts with safe animated rendering. - Replace activity sparklines with reusable SVG line charts for messages, agents, nodes, and throughput trends. - Refresh activity analytics on a bounded interval while preserving existing data during revalidation. - Add zero/NaN-safe chart geometry, reduced-motion styling, tests, docs, and a patch changeset. Files changed: .../fn-6656-command-center-activity-line-charts.md | 5 + docs/dashboard-guide.md | 2 +- .../command-center/__tests__/charts.test.tsx | 41 ++++++++ .../command-center/areas/ActivityArea.tsx | 62 ++++++++--- .../command-center/areas/__tests__/areas.test.tsx | 115 ++++++++++++++++++++- .../components/command-center/charts/LineChart.tsx | 108 +++++++++++++++++++ .../components/command-center/charts/charts.css | 62 +++++++++++ 7 files changed, 381 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-6656 Fusion-Task-Lineage: 301de13a-91c0-425d-b743-8688fed48d41
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add live animated Command Center Activity line charts for messages, active agents, active nodes, and combined throughput, backed by a reusable zero/NaN-safe LineChart primitive.
|
||||||
@@ -666,7 +666,7 @@ Features:
|
|||||||
- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. It also shows a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics; no extra endpoint is called. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
|
- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. It also shows a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics; no extra endpoint is called. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
|
||||||
- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage.
|
- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage.
|
||||||
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories.
|
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories.
|
||||||
- **Activity** tracks sessions, messages, active nodes, active agents, stickiness, and daily activity sparklines.
|
- **Activity** tracks sessions, messages, active nodes, active agents, and stickiness, then renders live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`). These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
|
||||||
- **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language.
|
- **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language.
|
||||||
- **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero.
|
- **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero.
|
||||||
- **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected.
|
- **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { StackedBar } from "../charts/StackedBar";
|
|||||||
import { Sparkline } from "../charts/Sparkline";
|
import { Sparkline } from "../charts/Sparkline";
|
||||||
import { Funnel } from "../charts/Funnel";
|
import { Funnel } from "../charts/Funnel";
|
||||||
import { RadialGauge } from "../charts/RadialGauge";
|
import { RadialGauge } from "../charts/RadialGauge";
|
||||||
|
import { LineChart } from "../charts/LineChart";
|
||||||
|
|
||||||
function widthOf(el: HTMLElement): string {
|
function widthOf(el: HTMLElement): string {
|
||||||
return el.style.width;
|
return el.style.width;
|
||||||
@@ -100,6 +101,46 @@ describe("Sparkline", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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] }]} />);
|
||||||
|
|
||||||
|
const chart = screen.getByRole("img", { name: "activity trend" });
|
||||||
|
const line = chart.querySelector(".cc-line-chart-path");
|
||||||
|
const points = line?.getAttribute("points") ?? "";
|
||||||
|
|
||||||
|
expect(line).toBeTruthy();
|
||||||
|
expect(points).not.toBe("");
|
||||||
|
expect(points).not.toMatch(/NaN|Infinity/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all-zero values as valid baseline geometry without NaN", () => {
|
||||||
|
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");
|
||||||
|
expect(points).not.toMatch(/NaN|Infinity/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a single-point series as a visible point without a malformed line", () => {
|
||||||
|
render(<LineChart ariaLabel="single trend" series={[{ label: "single", values: [5] }]} />);
|
||||||
|
|
||||||
|
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("cy")).not.toMatch(/NaN|Infinity/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty series as an empty valid SVG without throwing", () => {
|
||||||
|
render(<LineChart ariaLabel="empty line" series={[{ label: "empty", values: [] }]} />);
|
||||||
|
|
||||||
|
const chart = screen.getByRole("img", { name: "empty line" });
|
||||||
|
expect(chart.querySelector(".cc-line-chart-path")).toBeNull();
|
||||||
|
expect(chart.querySelector(".cc-line-chart-point")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("RadialGauge", () => {
|
describe("RadialGauge", () => {
|
||||||
it("renders the percentage for a valid ratio with an accessible label", () => {
|
it("renders the percentage for a valid ratio with an accessible label", () => {
|
||||||
render(<RadialGauge value={0.73} label="Completion" ariaLabel="Completion rate" />);
|
render(<RadialGauge value={0.73} label="Completion" ariaLabel="Completion rate" />);
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ActivityAnalytics } from "@fusion/core";
|
import type { ActivityAnalytics } from "@fusion/core";
|
||||||
import type { DateRange } from "../DateRangePicker";
|
import type { DateRange } from "../DateRangePicker";
|
||||||
import { Sparkline } from "../charts/Sparkline";
|
import { LineChart } from "../charts/LineChart";
|
||||||
import { AreaShell } from "./AreaShell";
|
import { AreaShell } from "./AreaShell";
|
||||||
import { useAnalyticsArea } from "./useAnalyticsArea";
|
import { useAnalyticsArea } from "./useAnalyticsArea";
|
||||||
import { formatCount } from "./areaShared";
|
import { formatCount, isInvalidRange } from "./areaShared";
|
||||||
|
|
||||||
|
const ACTIVITY_LIVE_REFRESH_MS = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity area: sessions / messages / active-nodes / stickiness (DAU/MAU) over
|
* FNXC:CommandCenter 2026-06-18-14:29:
|
||||||
* the range, plus per-day sparklines for messages and active nodes.
|
* Activity metrics surface as live, animated line charts auto-refreshed via reload() on a bounded interval; motion is decorative and reduced-motion-safe, uses the existing activity endpoint, and keeps prior data visible during polling revalidation.
|
||||||
*/
|
*/
|
||||||
export function ActivityArea({ range }: { range: DateRange }) {
|
export function ActivityArea({ range }: { range: DateRange }) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { data, isLoading, error } = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range);
|
const { data, isLoading, error, reload } = useAnalyticsArea<ActivityAnalytics>("/command-center/activity", range);
|
||||||
|
|
||||||
const daily = useMemo(() => data?.daily ?? [], [data?.daily]);
|
const daily = useMemo(() => data?.daily ?? [], [data?.daily]);
|
||||||
const messagesSeries = useMemo(() => daily.map((d) => d.messages), [daily]);
|
const messagesSeries = useMemo(() => daily.map((d) => d.messages), [daily]);
|
||||||
|
const agentsSeries = useMemo(() => daily.map((d) => d.activeAgents), [daily]);
|
||||||
const nodesSeries = useMemo(() => daily.map((d) => d.activeNodes), [daily]);
|
const nodesSeries = useMemo(() => daily.map((d) => d.activeNodes), [daily]);
|
||||||
|
const throughputSeries = useMemo(
|
||||||
|
() => daily.map((d) => d.messages + d.activeAgents + d.activeNodes),
|
||||||
|
[daily],
|
||||||
|
);
|
||||||
|
const invalidRange = isInvalidRange(range);
|
||||||
|
const isInitialLoading = isLoading && data === null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (invalidRange) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
reload();
|
||||||
|
}, ACTIVITY_LIVE_REFRESH_MS);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [invalidRange, reload]);
|
||||||
|
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
!data ||
|
!data ||
|
||||||
(data.sessions === 0 && data.messages === 0 && data.activeNodes === 0 && data.activeAgents === 0);
|
(data.sessions === 0 && data.messages === 0 && data.activeNodes === 0 && data.activeAgents === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AreaShell testId="activity" isLoading={isLoading} error={error} isEmpty={isEmpty}>
|
<AreaShell testId="activity" isLoading={isInitialLoading} error={error} isEmpty={isEmpty}>
|
||||||
<div className="cc-area-section">
|
<div className="cc-area-section">
|
||||||
<h3 className="cc-area-section-title">{t("commandCenter.activity.summaryTitle", "Summary")}</h3>
|
<h3 className="cc-area-section-title">{t("commandCenter.activity.summaryTitle", "Summary")}</h3>
|
||||||
<div className="cc-stat-grid">
|
<div className="cc-stat-grid">
|
||||||
@@ -52,17 +71,36 @@ export function ActivityArea({ range }: { range: DateRange }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cc-area-section">
|
<div className="cc-area-section" data-testid="cc-activity-line-messages">
|
||||||
<h3 className="cc-area-section-title">{t("commandCenter.activity.messagesPerDay", "Messages / day")}</h3>
|
<h3 className="cc-area-section-title">{t("commandCenter.activity.messagesPerDay", "Messages / day")}</h3>
|
||||||
<Sparkline
|
<LineChart
|
||||||
values={messagesSeries}
|
series={[{ label: t("commandCenter.activity.messages", "Messages"), values: messagesSeries }]}
|
||||||
ariaLabel={t("commandCenter.activity.messagesPerDay", "Messages / day")}
|
ariaLabel={t("commandCenter.activity.messagesPerDay", "Messages / day")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cc-area-section">
|
<div className="cc-area-section" data-testid="cc-activity-line-agents">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.activity.agentsPerDay", "Active agents / day")}</h3>
|
||||||
|
<LineChart
|
||||||
|
series={[{ label: t("commandCenter.activity.activeAgents", "Active agents"), values: agentsSeries }]}
|
||||||
|
ariaLabel={t("commandCenter.activity.agentsPerDay", "Active agents / day")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cc-area-section" data-testid="cc-activity-line-nodes">
|
||||||
<h3 className="cc-area-section-title">{t("commandCenter.activity.nodesPerDay", "Active nodes / day")}</h3>
|
<h3 className="cc-area-section-title">{t("commandCenter.activity.nodesPerDay", "Active nodes / day")}</h3>
|
||||||
<Sparkline values={nodesSeries} ariaLabel={t("commandCenter.activity.nodesPerDay", "Active nodes / day")} />
|
<LineChart
|
||||||
|
series={[{ label: t("commandCenter.activity.activeNodes", "Active nodes"), values: nodesSeries }]}
|
||||||
|
ariaLabel={t("commandCenter.activity.nodesPerDay", "Active nodes / day")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cc-area-section" data-testid="cc-activity-line-throughput">
|
||||||
|
<h3 className="cc-area-section-title">{t("commandCenter.activity.throughputPerDay", "Throughput / day")}</h3>
|
||||||
|
<LineChart
|
||||||
|
series={[{ label: t("commandCenter.activity.throughput", "Throughput"), values: throughputSeries }]}
|
||||||
|
ariaLabel={t("commandCenter.activity.throughputPerDay", "Throughput / day")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AreaShell>
|
</AreaShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
FNXC:CommandCenter 2026-06-16-09:42:
|
FNXC:CommandCenter 2026-06-16-09:42:
|
||||||
Command Center area component tests (PR #1683). Pin loading/error/unavailable-vs-zero rendering for each analytics area against mocked fixtures so the "—" sentinel and cost-unavailable contracts can't regress.
|
Command Center area component tests (PR #1683). Pin loading/error/unavailable-vs-zero rendering for each analytics area against mocked fixtures so the "—" sentinel and cost-unavailable contracts can't regress.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
|
||||||
|
|
||||||
// Mock the api() helper so the areas fetch deterministic fixtures.
|
// Mock the api() helper so the areas fetch deterministic fixtures.
|
||||||
@@ -15,6 +15,7 @@ import { TokensArea } from "../TokensArea";
|
|||||||
import { ToolsArea } from "../ToolsArea";
|
import { ToolsArea } from "../ToolsArea";
|
||||||
import { ProductivityArea } from "../ProductivityArea";
|
import { ProductivityArea } from "../ProductivityArea";
|
||||||
import { SignalsArea } from "../SignalsArea";
|
import { SignalsArea } from "../SignalsArea";
|
||||||
|
import { ActivityArea } from "../ActivityArea";
|
||||||
import type { DateRange } from "../DateRangePicker";
|
import type { DateRange } from "../DateRangePicker";
|
||||||
|
|
||||||
const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" };
|
const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" };
|
||||||
@@ -59,10 +60,122 @@ function tokenFixture() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activityFixture() {
|
||||||
|
return {
|
||||||
|
from: "2026-06-08",
|
||||||
|
to: null,
|
||||||
|
sessions: 4,
|
||||||
|
messages: 12,
|
||||||
|
activeNodes: 3,
|
||||||
|
activeAgents: 2,
|
||||||
|
daily: [
|
||||||
|
{ day: "2026-06-08", messages: 2, activeNodes: 1, activeAgents: 1 },
|
||||||
|
{ day: "2026-06-09", messages: 4, activeNodes: 2, activeAgents: 1 },
|
||||||
|
{ day: "2026-06-10", messages: 6, activeNodes: 3, activeAgents: 2 },
|
||||||
|
],
|
||||||
|
stickiness: 0.5,
|
||||||
|
mttr: { value: null, unavailable: true, sampleCount: 0 },
|
||||||
|
monitor: {
|
||||||
|
mttr: { value: null, unavailable: true, sampleCount: 0 },
|
||||||
|
incidentsOpened: 0,
|
||||||
|
incidentsResolved: 0,
|
||||||
|
openIncidents: 0,
|
||||||
|
deployments: 0,
|
||||||
|
},
|
||||||
|
funnel: {
|
||||||
|
stages: [],
|
||||||
|
enteredInRange: 0,
|
||||||
|
doneInRange: 0,
|
||||||
|
completionRate: null,
|
||||||
|
throughputPerDay: 0,
|
||||||
|
rangeDays: 7,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiMock.mockReset();
|
apiMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ActivityArea", () => {
|
||||||
|
it("renders summary stats and the live line chart sections for populated daily activity", async () => {
|
||||||
|
apiMock.mockResolvedValue(activityFixture());
|
||||||
|
render(<ActivityArea range={range7d} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("cc-area-activity");
|
||||||
|
expect(screen.getByTestId("cc-activity-sessions").textContent).toContain("4");
|
||||||
|
expect(screen.getByTestId("cc-activity-messages").textContent).toContain("12");
|
||||||
|
expect(screen.getByTestId("cc-activity-nodes").textContent).toContain("3");
|
||||||
|
expect(screen.getByTestId("cc-activity-agents").textContent).toContain("2");
|
||||||
|
expect(screen.getByTestId("cc-activity-stickiness").textContent).toContain("50%");
|
||||||
|
expect(screen.getByTestId("cc-activity-line-messages")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-activity-line-agents")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-activity-line-nodes")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("cc-activity-line-throughput")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the empty state for zero activity without empty chart shells", async () => {
|
||||||
|
apiMock.mockResolvedValue({
|
||||||
|
...activityFixture(),
|
||||||
|
sessions: 0,
|
||||||
|
messages: 0,
|
||||||
|
activeNodes: 0,
|
||||||
|
activeAgents: 0,
|
||||||
|
daily: [],
|
||||||
|
stickiness: 0,
|
||||||
|
});
|
||||||
|
render(<ActivityArea range={range7d} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("cc-area-activity-empty");
|
||||||
|
expect(screen.queryByTestId("cc-activity-line-messages")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-activity-line-agents")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-activity-line-nodes")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("cc-activity-line-throughput")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polls activity while mounted, keeps content during refresh, and clears the interval on unmount", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
apiMock.mockResolvedValue(activityFixture());
|
||||||
|
const { unmount } = render(<ActivityArea range={range7d} />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("cc-area-activity")).toBeTruthy();
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(15_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(screen.getByTestId("cc-area-activity")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("cc-area-activity-loading")).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(15_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not poll or fetch for an inverted custom activity range", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<ActivityArea range={customRange("2026-06-10", "2026-06-01")} />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(apiMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("TokensArea", () => {
|
describe("TokensArea", () => {
|
||||||
it("shows per-model totals + cost and renders rows", async () => {
|
it("shows per-model totals + cost and renders rows", async () => {
|
||||||
apiMock.mockResolvedValue(tokenFixture());
|
apiMock.mockResolvedValue(tokenFixture());
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import "./charts.css";
|
||||||
|
|
||||||
|
export interface LineChartSeries {
|
||||||
|
label: string;
|
||||||
|
values: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineChartProps {
|
||||||
|
/** One or more named time-series rendered against the same 0..max scale. */
|
||||||
|
series: LineChartSeries[];
|
||||||
|
/** Accessible label for the whole chart. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Max value mapped to full height. Defaults to the largest finite series value. */
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWBOX_SIZE = 100;
|
||||||
|
const SINGLE_POINT_X = VIEWBOX_SIZE / 2;
|
||||||
|
const POINT_RADIUS = 1.8;
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : (index / (count - 1)) * VIEWBOX_SIZE;
|
||||||
|
const height = safeHeightPercent(value, max);
|
||||||
|
return {
|
||||||
|
x: safeCoord(x),
|
||||||
|
y: safeCoord(VIEWBOX_SIZE - height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointsFor(values: number[], max: number): { x: number; y: number }[] {
|
||||||
|
return values.map((value, index) => pointFor(value, index, values.length, max));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointsAttribute(points: { x: number; y: number }[]): string {
|
||||||
|
return points.map((point) => `${point.x},${point.y}`).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function computedMaxFor(series: LineChartSeries[], max?: number): number {
|
||||||
|
if (Number.isFinite(max) && max !== undefined && max > 0) {
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
return series.reduce((largest, next) => {
|
||||||
|
const seriesMax = next.values.reduce(
|
||||||
|
(innerLargest, value) => (Number.isFinite(value) && value > innerLargest ? value : innerLargest),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return seriesMax > largest ? seriesMax : largest;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function LineChart({ series, ariaLabel, max }: LineChartProps) {
|
||||||
|
const computedMax = computedMaxFor(series, max);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="cc-line-chart"
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
viewBox={`0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
{series.map((entry, seriesIndex) => {
|
||||||
|
const points = pointsFor(entry.values, computedMax);
|
||||||
|
const pointString = pointsAttribute(points);
|
||||||
|
return (
|
||||||
|
<g key={seriesIndex} className="cc-line-chart-series" aria-label={entry.label}>
|
||||||
|
{points.length > 1 ? (
|
||||||
|
<polyline
|
||||||
|
className="cc-line-chart-path"
|
||||||
|
points={pointString}
|
||||||
|
pathLength={VIEWBOX_SIZE}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{points.map((point, pointIndex) => (
|
||||||
|
<circle
|
||||||
|
key={pointIndex}
|
||||||
|
className="cc-line-chart-point"
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r={POINT_RADIUS}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -118,6 +118,68 @@ Chart labels and legends must use --text-muted so command-center CSS stays align
|
|||||||
transition: height var(--transition-normal);
|
transition: height var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- LineChart ---- */
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
.cc-line-chart {
|
||||||
|
display: block;
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: clamp(var(--space-16), 22vw, calc(var(--space-20) * 2));
|
||||||
|
aspect-ratio: 5 / 2;
|
||||||
|
color: var(--color-accent);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-line-chart-series {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-line-chart-series:nth-child(2n) {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-line-chart-series:nth-child(3n) {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-line-chart-path {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: var(--border-width-thick, var(--border-width));
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-dasharray: 100;
|
||||||
|
stroke-dashoffset: 100;
|
||||||
|
animation: cc-line-chart-draw calc(var(--duration-slow) * 4) ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-line-chart-point {
|
||||||
|
fill: var(--surface-1);
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: var(--border-width-thick, var(--border-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cc-line-chart-draw {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cc-line-chart-path {
|
||||||
|
animation: none;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cc-line-chart {
|
||||||
|
block-size: clamp(var(--space-14), 34vw, calc(var(--space-20) + var(--space-12)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- RadialGauge ---- */
|
/* ---- RadialGauge ---- */
|
||||||
.cc-radial-gauge {
|
.cc-radial-gauge {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user