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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StackedBar } from "../charts/StackedBar";
|
||||
import { Sparkline } from "../charts/Sparkline";
|
||||
import { Funnel } from "../charts/Funnel";
|
||||
import { RadialGauge } from "../charts/RadialGauge";
|
||||
import { LineChart } from "../charts/LineChart";
|
||||
|
||||
function widthOf(el: HTMLElement): string {
|
||||
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", () => {
|
||||
it("renders the percentage for a valid ratio with an accessible label", () => {
|
||||
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 type { ActivityAnalytics } from "@fusion/core";
|
||||
import type { DateRange } from "../DateRangePicker";
|
||||
import { Sparkline } from "../charts/Sparkline";
|
||||
import { LineChart } from "../charts/LineChart";
|
||||
import { AreaShell } from "./AreaShell";
|
||||
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
|
||||
* the range, plus per-day sparklines for messages and active nodes.
|
||||
* FNXC:CommandCenter 2026-06-18-14:29:
|
||||
* 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 }) {
|
||||
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 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 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 =
|
||||
!data ||
|
||||
(data.sessions === 0 && data.messages === 0 && data.activeNodes === 0 && data.activeAgents === 0);
|
||||
|
||||
return (
|
||||
<AreaShell testId="activity" isLoading={isLoading} error={error} isEmpty={isEmpty}>
|
||||
<AreaShell testId="activity" isLoading={isInitialLoading} error={error} isEmpty={isEmpty}>
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.activity.summaryTitle", "Summary")}</h3>
|
||||
<div className="cc-stat-grid">
|
||||
@@ -52,17 +71,36 @@ export function ActivityArea({ range }: { range: DateRange }) {
|
||||
</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>
|
||||
<Sparkline
|
||||
values={messagesSeries}
|
||||
<LineChart
|
||||
series={[{ label: t("commandCenter.activity.messages", "Messages"), values: messagesSeries }]}
|
||||
ariaLabel={t("commandCenter.activity.messagesPerDay", "Messages / day")}
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
</AreaShell>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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.
|
||||
*/
|
||||
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";
|
||||
|
||||
// Mock the api() helper so the areas fetch deterministic fixtures.
|
||||
@@ -15,6 +15,7 @@ import { TokensArea } from "../TokensArea";
|
||||
import { ToolsArea } from "../ToolsArea";
|
||||
import { ProductivityArea } from "../ProductivityArea";
|
||||
import { SignalsArea } from "../SignalsArea";
|
||||
import { ActivityArea } from "../ActivityArea";
|
||||
import type { DateRange } from "../DateRangePicker";
|
||||
|
||||
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(() => {
|
||||
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", () => {
|
||||
it("shows per-model totals + cost and renders rows", async () => {
|
||||
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);
|
||||
}
|
||||
|
||||
/* ---- 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 ---- */
|
||||
.cc-radial-gauge {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user