+ {/* Mounted with the chart and filled in place, so a move to a point
+ with a different sentence is read out (two points that read the
+ same are not re-read). Polite, so hovering does not cut the reader
+ off. No status role: a page's tests that query getByRole("status")
+ should still find only their own. */}
+
+ {live}
+
+ {/* sr-only on a wrapper, not the table: a table's width and height are
+ minimums, so the 1px box would not hold on the table itself. */}
+
+
+
{title}
+
+
+ {table.head.map((cell, c) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: columns follow series order, and labels may repeat.
+
+ {cell}
+
+ ))}
+
+
+
+ {table.rows.map((row, r) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: rows follow data order, and categories may repeat.
+
+ {row.map((cell, c) =>
+ c === 0 ? (
+ // biome-ignore lint/suspicious/noArrayIndexKey: cells follow series order.
+
+ );
+}
diff --git a/frontend/src/v1/components/ui/chart/ChartTooltip.test.tsx b/frontend/src/v1/components/ui/chart/ChartTooltip.test.tsx
new file mode 100644
index 0000000000..f09fd84063
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/ChartTooltip.test.tsx
@@ -0,0 +1,159 @@
+import { render } from "@testing-library/react";
+import { expect, it, vi } from "vitest";
+import { ChartTooltip } from "./ChartTooltip";
+
+const series = [
+ { key: "pages", label: "Pages", color: "var(--chart-1)", format: String },
+];
+const fmt = { value: String, x: String };
+
+/*
+ * The sparkline's heading used to be looked up in its `data` prop by row
+ * index; it now comes from the row Recharts hands over with the values. The
+ * two only differ for the frame before Recharts' store takes new data, which
+ * happy-dom flushes inside act(), so no chart-level test can see it. The
+ * category-key tests below pin the mechanism instead: the heading is read
+ * from that row and never from the label.
+ */
+it("names the category from the hovered row when given a category key, not from the label", () => {
+ // Recharts' label here is a row index (1); the row it hands over is the
+ // one plotted, which is what the values belong to.
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("p")).toHaveTextContent("plotted row");
+});
+
+it("uses Recharts' label as the category without a category key", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("p")).toHaveTextContent("10 Aug");
+ expect(container).toHaveTextContent("Pages7");
+});
+
+it("renders nothing while inactive", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+});
+
+it("names no category, and does not throw, when the hovered row is missing", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("p")).toBeNull();
+ expect(container).toHaveTextContent("Pages–");
+});
+
+it("speaks a gap as no data, with no category and no stray separator", () => {
+ const onAnnounce = vi.fn();
+ render(
+ ,
+ );
+ expect(onAnnounce).toHaveBeenLastCalledWith("Pages no data");
+});
+
+it("clears the sentence when it unmounts while open", () => {
+ const onAnnounce = vi.fn();
+ const { unmount } = render(
+ ,
+ );
+ expect(onAnnounce).toHaveBeenLastCalledWith("10 Aug: Pages 7");
+ unmount();
+ expect(onAnnounce).toHaveBeenLastCalledWith("");
+});
+
+it("names no category when the hovered row lacks the category key, rather than the label", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("p")).toBeNull();
+ expect(container).toHaveTextContent("Pages7");
+});
+
+it("names no category, and does not throw, when the hovered row is null", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("p")).toBeNull();
+ expect(container).toHaveTextContent("Pages7");
+});
+
+it("reports the active point's sentence while open and an empty one when closed", () => {
+ const onAnnounce = vi.fn();
+ const view = (active: boolean, value = 7) => (
+
+ );
+ const { rerender } = render(view(false));
+ expect(onAnnounce).toHaveBeenLastCalledWith("");
+ rerender(view(true));
+ expect(onAnnounce).toHaveBeenLastCalledWith("10 Aug: Pages 7");
+ rerender(view(true, 8));
+ expect(onAnnounce).toHaveBeenLastCalledWith("10 Aug: Pages 8");
+ rerender(view(false));
+ expect(onAnnounce).toHaveBeenLastCalledWith("");
+});
diff --git a/frontend/src/v1/components/ui/chart/ChartTooltip.tsx b/frontend/src/v1/components/ui/chart/ChartTooltip.tsx
new file mode 100644
index 0000000000..b6a81cb85f
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/ChartTooltip.tsx
@@ -0,0 +1,121 @@
+import { type JSX, useEffect } from "react";
+import type { Formatters } from "./describe";
+
+export type TooltipEntry = Readonly<{
+ dataKey?: unknown;
+ value?: unknown;
+ /** The hovered row, as Recharts plotted it. */
+ payload?: unknown;
+}>;
+
+export type TooltipSeries = Readonly<{
+ key: string;
+ label: string;
+ color: string;
+ format: (value: number) => string;
+}>;
+
+type ChartTooltipProps = Readonly<{
+ /** Set by Recharts, which clones this element with the hovered point. */
+ active?: boolean;
+ label?: unknown;
+ payload?: readonly TooltipEntry[];
+ series: readonly TooltipSeries[];
+ fmt: Formatters;
+ /**
+ * Read the category from this field of the hovered row instead of from
+ * Recharts' `label`, where the label is something else (a row index). The
+ * row comes from Recharts with the values, so heading and values always
+ * describe the same row.
+ */
+ categoryKey?: string;
+ /**
+ * Called with a sentence for the active point (category, then each series
+ * and its value, "no data" for a gap), and with "" when Recharts reports
+ * the tooltip inactive or this element unmounts. An Escape dismissal hides
+ * the tooltip without either, so the last sentence stays. The chart puts it
+ * in a live region that stays mounted, outside Recharts' tooltip wrapper.
+ */
+ onAnnounce?: (text: string) => void;
+}>;
+
+/**
+ * Recharts' tooltip `content`, passed as an element (not a function, which
+ * Recharts would mount as a new component type on every render): the
+ * category, then one row per series with a swatch, its label and its value
+ * (frame D14). A gap shows "–", as in the data table, so a category with no
+ * values still names itself. Marker series never appear: only keys in
+ * `series` are shown.
+ */
+export function ChartTooltip({
+ active,
+ label,
+ payload,
+ series,
+ fmt,
+ categoryKey,
+ onAnnounce,
+}: ChartTooltipProps): JSX.Element | null {
+ const row = payload?.[0]?.payload;
+ const category =
+ categoryKey === undefined
+ ? label
+ : typeof row === "object" && row !== null
+ ? (row as Record)[categoryKey]
+ : undefined;
+ const heading =
+ typeof category === "string" || typeof category === "number"
+ ? fmt.x(category)
+ : undefined;
+ const rows = series.map((s) => {
+ const value = payload?.find((p) => p.dataKey === s.key)?.value;
+ const known = typeof value === "number" && Number.isFinite(value);
+ return {
+ series: s,
+ text: known ? s.format(value) : "–",
+ // Screen readers skip a lone dash, so a gap is said in words.
+ spoken: known ? s.format(value) : "no data",
+ };
+ });
+ const sentence = active
+ ? [heading, rows.map((r) => `${r.series.label} ${r.spoken}`).join(", ")]
+ .filter((part) => part !== undefined)
+ .join(": ")
+ : "";
+ // Recharts keeps this element mounted while the tooltip is closed (it
+ // re-renders it with `active` false), so the effect reports the close too.
+ // The cleanup clears the sentence when this element unmounts while open
+ // (the chart going to loading, or a sparkline's tooltip turned off).
+ useEffect(() => {
+ onAnnounce?.(sentence);
+ return () => onAnnounce?.("");
+ }, [onAnnounce, sentence]);
+ if (!active) {
+ return null;
+ }
+ return (
+
+ {heading === undefined ? null : (
+
{heading}
+ )}
+
+ {rows.map(({ series: s, text }) => (
+
+
+
+ {s.label}
+
+ {text}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/v1/components/ui/chart/LineChart.tsx b/frontend/src/v1/components/ui/chart/LineChart.tsx
new file mode 100644
index 0000000000..cc4cc183e9
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/LineChart.tsx
@@ -0,0 +1,12 @@
+import type { JSX } from "react";
+import { CartesianChart } from "./CartesianChart";
+import type { CartesianChartProps, ChartDatum } from "./types";
+
+export type LineChartProps = CartesianChartProps;
+
+/** Trends over a category axis, one line per series (frames D01, D14). */
+export function LineChart(
+ props: LineChartProps,
+): JSX.Element {
+ return ;
+}
diff --git a/frontend/src/v1/components/ui/chart/README.md b/frontend/src/v1/components/ui/chart/README.md
new file mode 100644
index 0000000000..e69c20cacf
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/README.md
@@ -0,0 +1,213 @@
+# Charts
+
+Five charts on Recharts, imported from `@v1/components/ui/chart`:
+`LineChart`, `AreaChart`, `BarChart`, `StackedBarChart` and `Sparkline`. They
+share one props model, take their colours from tokens, and each has loading
+and empty states and a text alternative; the four axis charts also have a
+tooltip and a legend (the sparkline's tooltip is opt-in, and it has no
+legend). Never
+import `recharts` in a page for a chart these cover; add what you need here
+so every chart keeps the same colours, states and accessibility.
+
+## Example
+
+```tsx
+import { LineChart } from "@v1/components/ui/chart";
+
+} // must fit `height`
+/>
+```
+
+A chart has no error state. When the request fails, render the page's
+`InlineAlert` with `onRetry` **instead of** the chart, so a failure never reads
+as "No data for this period":
+
+```tsx
+{query.isError ? (
+
+) : (
+
+)}
+```
+
+```tsx
+import { StackedBarChart } from "@v1/components/ui/chart";
+
+// LLMWhisperer pages by mode: no tones, so the series take the ramp in order,
+// lightest first. Order them cheapest to most expensive.
+ ({ key: m.id, label: m.name }))}
+/>
+```
+
+```tsx
+import { Sparkline } from "@v1/components/ui/chart";
+
+
+```
+
+## Props
+
+Every chart:
+
+| Prop | Type | Default | Notes |
+| --- | --- | --- | --- |
+| `data` | `readonly Row[]` | required | One row per category. `Row` is any object type, an `interface` or an API type with extra fields included, and is inferred from here: type an inline empty literal (`data={[] as Row[]}`), or it infers `never` and rejects every key. |
+| `xKey` | key of `Row` holding a string or number | required | The category field. Categories may repeat. |
+| `series` | `ChartSeries[]` | required | `{ key, label, tone?, axis?, format? }`, drawn in this order. `key` must name a field typed `number \| null \| undefined`; null, undefined or anything not a finite number is a gap to the tooltip, table, summary, markers and empty state. Parse numeric strings (a DRF Decimal) first: an unparsed one is a gap and logs a console warning naming the series (if every value is one, the chart shows its empty state), though Recharts may still draw it. |
+| `title` | `string` | required | The plot's accessible name and the table caption. The card shows the visible heading. |
+| `summary` | `string` | generated | The sentence screen readers hear. Pass one when the page knows the story ("10 Aug alone carries 49% of the month's pages"). |
+| `xLabel` | `string` | `"Category"` | First column header of the data table. |
+| `state` | `"ready" \| "loading" \| "empty"` | `"ready"` | `ready` with no rows, or only gaps, shows the empty state. |
+| `emptySlot` | `ReactNode` | — | Replaces the empty message. It is centred in a box of the chart's `height` (40 on a sparkline) and the box does not grow, so the slot must fit; it replaces the screen-reader title prefix too, so name the chart in it. |
+| `emptyMessage` | `string` | `"No data for this period"` | |
+| `height` | `number` | 240 (sparkline 40) | Plot height in pixels. |
+| `width` | `number` | fills the container | Fixed width in pixels. |
+| `valueFormat` | `(n) => string` | `2,981`, viewer's locale | Tooltip, summary and table. A series' `format` overrides it. |
+| `xFormat` | `(x) => string` | `String` | X ticks, tooltip heading, summary and table. |
+| `className` | `string` | — | On the outer element. |
+
+`LineChart`, `AreaChart`, `BarChart`, `StackedBarChart` also take:
+
+| Prop | Type | Default | Notes |
+| --- | --- | --- | --- |
+| `markers` | `ChartMarker[]` | `[]` | `{ index, series, label }`: a labelled dot on row `index` of `series`. A marker on a series not charted, a row outside `data`, a gap, or a point an earlier marker already labels is dropped (and every marker, when a series key is reserved; see below), from the plot and the summary alike, with a console warning saying why (only when the chart shows data, so markers computed ahead of loading rows do not warn). |
+| `legend` | `"top" \| "bottom" \| "none"` | `"bottom"` | |
+| `axisFormat` | `(n) => string` | `2.3K`, viewer's locale | Left Y axis ticks. |
+| `rightAxisFormat` | `(n) => string` | `axisFormat` | Right Y axis ticks. |
+| `grid` | `boolean` | `true` | Horizontal grid lines. |
+
+`Sparkline` has no axes, grid, legend or markers, and takes `tooltip`
+(default `false`).
+
+`series` and `markers` may be inline literals: the chart keys its work on
+what they say, not on their identity. `data` is different: pass a stable
+array (the query result, or a `useMemo` over it). A new array on every
+render makes Recharts reset its data store (and the axis charts recompute
+their rows). Rows typed with a string index
+signature (`Record`, pivoted rows with one field per dynamic
+series) accept any key whose field could hold a number, since the type
+cannot check them per field (`Record` accepts none;
+`Record` accepts any). Field
+names starting with `__marker` are reserved for markers: a chart with such
+a series key warns (in any state) and draws no markers. Wrappers
+that take a key generically can type it with the exported `NumericKey`
+and `CategoryKey`.
+
+A series with `axis: "right"` reads against a second Y axis, drawn only when
+some series asks for it. In a `StackedBarChart`, right-axis series form their
+own stack.
+
+## Colour
+
+- A single series takes `primary`, or the tone for its meaning
+ (`destructive` for failures).
+- Series that are not stacked (several lines, overlaid areas, grouped bars):
+ give each a `tone` for what it means, as the Document Studio dashboard
+ design does for "Throughput and failures" (`primary` for pages processed,
+ `destructive` for failed pages). The tones are `primary`, `destructive`
+ (failures), `success`, `warning` and `muted` (`muted-foreground`). A series
+ left untoned takes the next ramp step, with no warning.
+- The segments of a stack, ordered buckets such as modes or tiers, take the
+ ordinal ramp `--chart-1` … `--chart-5`: leave them untoned and they take
+ it in order. The ramp is the LLMWhisperer design's mode colours
+ (`--mode-native-text` … `--mode-table`), lightest to darkest, per mode as
+ designed; its "Pages by mode" stack is the model. A 1px `card`-coloured
+ stroke splits the segments.
+- Keep a chart to five untoned series, counting both stacks together when
+ some series sit on the right axis: the ramp has five steps and the sixth
+ untoned series repeats `--chart-1`, with no warning. Group the tail into
+ "Other".
+- Use tones or the ramp on a chart, not both. Nothing checks colours against
+ each other or against the background.
+
+## Known gaps
+
+Designed charts these components do not cover yet. Each is for its page's
+owner to settle, not part of P1-T17.
+
+- **Categorical donuts** (Human Review H29, changes by field and by user): no
+ Pie or Donut chart here, and the design's segment colours are not tokens.
+- **A free-choice comparison series** (Document Studio D12, "Compare on this
+ chart"; P4-T04): a series picked from nine metrics has no meaning to give
+ it a tone, and no colour is designed for it.
+- **KPI sparklines whose colour is not a meaning** (Document Studio D01):
+ "LLM spend" is drawn in `warning` and "Documents processed" in a blue that
+ is not a token. The single-series rule above gives them `primary` until
+ the design names a token.
+
+## Markers: drawn from the series, never a reference line
+
+Mark a point (a peak day, a spike) with `markers`. On a line or area they
+become a dot-only series aligned to the row; on a bar they are drawn from the
+bar itself, so the dot sits on its own bar's tip in a group and on its own
+segment's tip in a stack (the top, or the bottom below zero, where the label
+reads under the tip, or beside the dot at the plot's bottom edge). Do not reach for Recharts' `ReferenceLine` or
+`ReferenceDot` on a categorical axis: they address a point by its category
+label, and when a label repeats ("Mon" across two weeks) recharts 3.7.0
+silently draws nothing. A marker addresses a row by index, so it always
+lands. `charts.test.tsx` asserts no reference element is rendered.
+
+## Text alternative
+
+Every chart renders, beside the plot:
+
+- the `summary` (or a generated sentence: chart kind, category range, each
+ series' highest and lowest point, and the markers), which the plot names
+ through `aria-describedby`;
+- a visually hidden table of every value, captioned with `title`;
+- a polite live region (`data-slot="chart-live"`) that reads out the active
+ point as the tooltip moves ("no data" for a gap), and empties when it
+ closes.
+
+The plot itself is a focusable `role="application"` with `aria-label={title}`:
+Tab to it, then the arrow keys move the tooltip from category to category. A
+sparkline without a tooltip is a static `role="img"` outside the tab order.
+
+## States
+
+- `loading`: the shared `Skeleton` with one `SkeletonBlock` at the chart's
+ height, announced as "Loading {title}". Never a spinner.
+- `empty`: a centred message at the chart's height, or `emptySlot`, which must
+ fit that height.
+- Both keep the ready chart's footprint when `series` is known before the
+ data: its legend is laid out, invisibly, in the same place, so a card does
+ not jump when data arrives. Series built from the response (pivoted rows)
+ are empty until it arrives, so nothing is reserved; pass the series you
+ expect while loading if the card must not move.
+- error: not a chart state. Render `InlineAlert` with `onRetry` in its place.
+
+A point with no defined neighbour (a single row, or a value between two gaps)
+is drawn as a dot, since a line through one point paints nothing.
+
+## Testing a page that uses a chart
+
+Pass `width` (and `height`), or call `installResizeObserver()` from
+`@v1/test` and `resize` the container. A hover needs
+`getBoundingClientRect`, `offsetWidth` and `offsetHeight` stubbed on
+`HTMLElement.prototype` and a `waitFor`, because Recharts reads the pointer on
+the next animation frame (see `stubLayout` in `charts.test.tsx`). Default
+number formats follow the machine's locale, so pass
+`valueFormat={numberFormats("en-US").value}` (and `axisFormat`) where a test
+asserts formatted numbers. The arrow
+keys need no stubs. Recharts draws SVG; there is no canvas.
diff --git a/frontend/src/v1/components/ui/chart/Sparkline.tsx b/frontend/src/v1/components/ui/chart/Sparkline.tsx
new file mode 100644
index 0000000000..7c178a58fd
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/Sparkline.tsx
@@ -0,0 +1,165 @@
+import { type JSX, useEffect, useId, useState } from "react";
+import {
+ Line,
+ LineChart as RechartsLineChart,
+ ResponsiveContainer,
+ Tooltip,
+ YAxis,
+} from "recharts";
+import {
+ ChartFrame,
+ DEFAULT_EMPTY_MESSAGE,
+ DEFAULT_X_LABEL,
+} from "./ChartFrame";
+import { ChartTooltip } from "./ChartTooltip";
+import {
+ describeChart,
+ type Formatters,
+ formatCategory,
+ formatValue,
+ hasValues,
+ tableRows,
+ unparsedWarnings,
+} from "./describe";
+import { isolatedDot } from "./marks";
+import { seriesColors } from "./palette";
+import type { BaseChartProps, ChartDatum } from "./types";
+
+export const DEFAULT_SPARKLINE_HEIGHT = 40;
+
+const MARGIN = { top: 4, right: 2, bottom: 4, left: 2 };
+
+export type SparklineProps = BaseChartProps &
+ Readonly<{
+ /** Shows a tooltip on hover and arrow keys. Default false. */
+ tooltip?: boolean;
+ }>;
+
+/**
+ * A small trend line for a KPI card (frame D01's four cards): no axes, grid or
+ * legend, and no tooltip unless asked. Loading, empty and the text
+ * alternative work as in the other charts.
+ */
+export function Sparkline({
+ data,
+ xKey,
+ xLabel = DEFAULT_X_LABEL,
+ series,
+ title,
+ summary,
+ state = "ready",
+ emptySlot,
+ emptyMessage = DEFAULT_EMPTY_MESSAGE,
+ height = DEFAULT_SPARKLINE_HEIGHT,
+ width,
+ valueFormat = formatValue,
+ xFormat = formatCategory,
+ tooltip = false,
+ className,
+}: SparklineProps): JSX.Element {
+ const summaryId = useId();
+ // The active point's sentence, for the live region (see ChartTooltip).
+ const [live, setLive] = useState("");
+ const fmt: Formatters = { value: valueFormat, x: xFormat };
+ const colors = seriesColors(series);
+ const resolved =
+ state === "ready" && !hasValues(data, series) ? "empty" : state;
+ // An unparsed value reads as a gap, so a payload the page forgot to parse
+ // shows the empty state; say why in the console (see CartesianChart).
+ const warnings = JSON.stringify(unparsedWarnings(data, series));
+ useEffect(() => {
+ for (const warning of JSON.parse(warnings) as string[]) {
+ console.warn(warning);
+ }
+ }, [warnings]);
+ const text =
+ summary ??
+ describeChart({ kind: "sparkline", title, data, xKey, series, fmt });
+ const chart = (
+
+ {/* A series with axis "right" scales on its own hidden axis, as on the
+ axis charts, so a small-valued series beside a large one is not flat. */}
+
+ {series.some((s) => s.axis === "right") ? (
+
+ ) : null}
+ {tooltip ? (
+ ({
+ key: s.key,
+ label: s.label,
+ color: colors[i],
+ format: s.format ?? valueFormat,
+ }))}
+ fmt={fmt}
+ />
+ }
+ />
+ ) : null}
+ {series.map((s, i) => (
+
+ ))}
+
+ );
+ return (
+ s.label)],
+ rows: tableRows(data, xKey, series, fmt),
+ }}
+ className={className}
+ >
+ {width === undefined ? (
+
+ {chart}
+
+ ) : (
+ chart
+ )}
+
+ );
+}
diff --git a/frontend/src/v1/components/ui/chart/StackedBarChart.tsx b/frontend/src/v1/components/ui/chart/StackedBarChart.tsx
new file mode 100644
index 0000000000..bf7cc8da86
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/StackedBarChart.tsx
@@ -0,0 +1,19 @@
+import type { JSX } from "react";
+import { CartesianChart } from "./CartesianChart";
+import type { CartesianChartProps, ChartDatum } from "./types";
+
+export type StackedBarChartProps =
+ CartesianChartProps;
+
+/**
+ * Series stacked into one bar per category, first series at the bottom
+ * (LLMWhisperer frame D01, pages by mode). Order series as the ramp reads,
+ * lightest first. Series on the right axis form their own stack.
+ */
+export function StackedBarChart(
+ props: StackedBarChartProps,
+): JSX.Element {
+ return (
+
+ );
+}
diff --git a/frontend/src/v1/components/ui/chart/__snapshots__/markup.md b/frontend/src/v1/components/ui/chart/__snapshots__/markup.md
new file mode 100644
index 0000000000..8dfba221bb
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/__snapshots__/markup.md
@@ -0,0 +1,1001 @@
+# Chart markup
+
+Generated by `markup.test.tsx`; update with `vitest -u`.
+
+## line
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+Pages processed
+
+
+Failed pages
+
+
Throughput. Line chart, 3 points from 9 Aug to 11 Aug. Pages processed: highest 2,981 at 10 Aug, lowest 10 at 9 Aug. Failed pages: highest 600 at 10 Aug, lowest 1 at 9 Aug. Marked: Peak.
+
+
+
+
+
Throughput
+
+
+
Category
+
Pages processed
+
Failed pages
+
+
+
+
+
9 Aug
+
10
+
1
+
+
+
10 Aug
+
2,981
+
600
+
+
+
11 Aug
+
20
+
2
+
+
+
+
+
+```
+
+## area
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+Failed
+
+
+Pages
+
+
Throughput. Area chart, 3 points from 9 Aug to 11 Aug. Failed: highest 600 at 10 Aug, lowest 1 at 9 Aug. Pages: highest 2,981 at 10 Aug, lowest 10 at 9 Aug.
+
+
+
+
+
Throughput
+
+
+
Category
+
Failed
+
Pages
+
+
+
+
+
9 Aug
+
1
+
10
+
+
+
10 Aug
+
600
+
2,981
+
+
+
11 Aug
+
2
+
20
+
+
+
+
+
+```
+
+## bar
+
+```html
+
+
+
+
+Failed
+
+
+Pages
+
+
+
+
+
+
+
+
+
Throughput. Bar chart, 3 points from 9 Aug to 11 Aug. Failed: highest 600 at 10 Aug, lowest 1 at 9 Aug. Pages: highest 2,981 at 10 Aug, lowest 10 at 9 Aug.
+
+
+
+
+
Throughput
+
+
+
Category
+
Failed
+
Pages
+
+
+
+
+
9 Aug
+
1
+
10
+
+
+
10 Aug
+
600
+
2,981
+
+
+
11 Aug
+
2
+
20
+
+
+
+
+
+```
+
+## stacked bar
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+Failed
+
+
+Pages
+
+
Throughput. Stacked bar chart, 3 points from 9 Aug to 11 Aug. Failed: highest 600 at 10 Aug, lowest 1 at 9 Aug. Pages: highest 2,981 at 10 Aug, lowest 10 at 9 Aug. Marked: Peak.
+
+
+
+
+
Throughput
+
+
+
Category
+
Failed
+
Pages
+
+
+
+
+
9 Aug
+
1
+
10
+
+
+
10 Aug
+
600
+
2,981
+
+
+
11 Aug
+
2
+
20
+
+
+
+
+
+```
+
+## sparkline
+
+```html
+
+
+
+
+
+
+
Throughput. Sparkline, 3 points from 9 Aug to 11 Aug. Pages processed: highest 2,981 at 10 Aug, lowest 10 at 9 Aug.
+
+
+
+
+
Throughput
+
+
+
Category
+
Pages processed
+
+
+
+
+
9 Aug
+
10
+
+
+
10 Aug
+
2,981
+
+
+
11 Aug
+
20
+
+
+
+
+
+```
+
+## loading
+
+```html
+
+
+
+Loading Throughput
+
+
+
+
+
+
+
+
+
+Pages processed
+
+
+Failed pages
+
+
+```
+
+## empty
+
+```html
+
+
+
+Throughput: No data for this period
+
+
+
+
+Failed
+
+
+Pages
+
+
+```
diff --git a/frontend/src/v1/components/ui/chart/charts.test.tsx b/frontend/src/v1/components/ui/chart/charts.test.tsx
new file mode 100644
index 0000000000..9a1488b059
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/charts.test.tsx
@@ -0,0 +1,1370 @@
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { THEME_MODES } from "@v1/lib/theme";
+import { installResizeObserver, renderV1 } from "@v1/test";
+import type { JSX } from "react";
+import { describe, expect, it, vi } from "vitest";
+import {
+ AreaChart,
+ BarChart,
+ type CartesianChartProps,
+ LineChart,
+ numberFormats,
+ Sparkline,
+ StackedBarChart,
+} from "./index";
+
+// A fixed locale, so "2,981" holds on any machine; the charts default to the
+// viewer's.
+const EN = numberFormats("en-US");
+
+const WIDTH = 400;
+const HEIGHT = 200;
+
+const data = [
+ { day: "9 Aug", pages: 10, failed: 1 },
+ { day: "10 Aug", pages: 2981, failed: 600 },
+ { day: "11 Aug", pages: 20, failed: 2 },
+];
+type Row = (typeof data)[number];
+
+const SERIES = [
+ { key: "pages", label: "Pages processed" },
+ { key: "failed", label: "Failed pages" },
+] as const;
+
+const VARIANTS: [string, (p: CartesianChartProps) => JSX.Element][] = [
+ ["line-chart", (p) => ],
+ ["area-chart", (p) => ],
+ ["bar-chart", (p) => ],
+ ["stacked-bar-chart", (p) => ],
+];
+
+function props(
+ overrides: Partial> = {},
+): CartesianChartProps {
+ return {
+ title: "Throughput",
+ data,
+ xKey: "day",
+ xLabel: "Day",
+ series: SERIES,
+ width: WIDTH,
+ height: HEIGHT,
+ valueFormat: EN.value,
+ axisFormat: EN.axis,
+ ...overrides,
+ };
+}
+
+/**
+ * Recharts maps the pointer to a category from the wrapper's bounding box and
+ * its offset size, which happy-dom reports as zero. Stubs both to the chart's
+ * size so a mouse move lands where the test says. (Undone after each test by
+ * `restoreMocks`.) A candidate for the shared harness if other charts need it.
+ */
+function stubLayout(): void {
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(
+ new DOMRect(0, 0, WIDTH, HEIGHT),
+ );
+ vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(WIDTH);
+ vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(
+ HEIGHT,
+ );
+}
+
+function plot(container: HTMLElement): SVGSVGElement {
+ const svg = container.querySelector("svg.recharts-surface");
+ if (svg === null) {
+ throw new Error("no chart surface");
+ }
+ return svg;
+}
+
+describe.each(VARIANTS)("%s", (slot, Chart) => {
+ it("colours untoned series from the ramp in order and toned ones from their token", () => {
+ const { container } = renderV1(
+ ,
+ );
+ const root = container.querySelector(`[data-slot=${slot}]`);
+ expect(root).toHaveAttribute("data-state", "ready");
+ const attr = slot.includes("bar") ? "fill" : "stroke";
+ // An area's fill path has no stroke; its curve carries the colour.
+ const paint = (name: string) =>
+ new Set(
+ [...container.querySelectorAll(`[name="${name}"]`)]
+ .map((el) => el.getAttribute(attr))
+ .filter((value) => value !== "none"),
+ );
+ expect(paint("Pages processed")).toEqual(new Set(["var(--chart-1)"]));
+ expect(paint("Failed pages")).toEqual(new Set(["var(--destructive)"]));
+ });
+
+ it("lists every series in the legend, below the plot by default", () => {
+ const { container } = renderV1();
+ const legend = container.querySelector("[data-slot=chart-legend]");
+ expect(legend).not.toBeNull();
+ expect(
+ within(legend as HTMLElement)
+ .getAllByRole("listitem")
+ .map((li) => li.textContent),
+ ).toEqual(["Pages processed", "Failed pages"]);
+ expect(
+ plot(container).compareDocumentPosition(legend as Node) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("names the plot, points it at the summary and carries every value in a table", async () => {
+ const { container } = renderV1();
+ const svg = plot(container);
+ expect(svg).toHaveAttribute("aria-label", "Throughput");
+ const summary = document.getElementById(
+ svg.getAttribute("aria-describedby") ?? "",
+ );
+ expect(summary).toHaveTextContent(
+ /^Throughput\. .* chart, 3 points from 9 Aug to 11 Aug\. Pages processed: highest 2,981 at 10 Aug/,
+ );
+ const table = screen.getByRole("table", { name: "Throughput" });
+ // Hidden by its wrapper: a table's width and height are minimums.
+ expect(table.parentElement).toHaveClass("ui:sr-only");
+ expect(table).not.toHaveClass("ui:sr-only");
+ expect(
+ within(table)
+ .getAllByRole("columnheader")
+ .map((c) => c.textContent),
+ ).toEqual(["Day", "Pages processed", "Failed pages"]);
+ expect(
+ within(table)
+ .getAllByRole("row")
+ .slice(1)
+ .map((r) => r.textContent),
+ ).toEqual(["9 Aug101", "10 Aug2,981600", "11 Aug202"]);
+ await expect(container).toHaveNoA11yViolations();
+ });
+
+ it("shows the tooltip on hover", async () => {
+ stubLayout();
+ const { container } = renderV1();
+ const wrapper = container.querySelector(".recharts-wrapper");
+ // The middle of the plot is the second category.
+ fireEvent.mouseMove(wrapper as Element, {
+ clientX: WIDTH / 2,
+ clientY: 100,
+ });
+ // Recharts handles the move on the next animation frame.
+ await waitFor(() =>
+ expect(
+ container.querySelector("[data-slot=chart-tooltip]"),
+ ).toHaveTextContent("10 AugPages processed2,981Failed pages600"),
+ );
+ });
+
+ it("moves the tooltip with the arrow keys once the plot has focus", async () => {
+ const { container, user } = renderV1();
+ const svg = plot(container);
+ expect(svg).toHaveAttribute("tabindex", "0");
+ await user.tab();
+ expect(svg).toHaveFocus();
+ await user.keyboard("{ArrowRight}");
+ const tooltip = () => container.querySelector("[data-slot=chart-tooltip]");
+ expect(tooltip()).toHaveTextContent(/^10 Aug/);
+ await user.keyboard("{ArrowRight}");
+ expect(tooltip()).toHaveTextContent(/^11 Aug/);
+ await user.keyboard("{ArrowLeft}");
+ expect(tooltip()).toHaveTextContent(/^10 Aug/);
+ });
+
+ it("reads the active point out through a live region that is mounted before the first move", async () => {
+ const { container, user } = renderV1();
+ const live = container.querySelector("[data-slot=chart-live]");
+ expect(live).toHaveAttribute("aria-live", "polite");
+ expect(live).toBeEmptyDOMElement();
+ await user.tab();
+ await user.keyboard("{ArrowRight}");
+ expect(live).toHaveTextContent(
+ "10 Aug: Pages processed 2,981, Failed pages 600",
+ );
+ await user.keyboard("{ArrowRight}");
+ expect(container.querySelector("[data-slot=chart-live]")).toBe(live);
+ expect(live).toHaveTextContent(/^11 Aug: Pages processed/);
+ });
+
+ it("empties the live region when the pointer leaves the plot", async () => {
+ stubLayout();
+ const { container } = renderV1();
+ const wrapper = container.querySelector(".recharts-wrapper") as Element;
+ const live = container.querySelector("[data-slot=chart-live]");
+ fireEvent.mouseMove(wrapper, { clientX: WIDTH / 2, clientY: 100 });
+ await waitFor(() => expect(live).toHaveTextContent(/^10 Aug: /));
+ fireEvent.mouseLeave(wrapper);
+ await waitFor(() => expect(live).toBeEmptyDOMElement());
+ expect(container.querySelector("[data-slot=chart-live]")).toBe(live);
+ });
+
+ it("leaves no sentence behind across loading", async () => {
+ const view = (state: "loading" | "ready") => (
+
+ );
+ const { container, user, rerender } = renderV1(view("ready"));
+ await user.tab();
+ await user.keyboard("{ArrowRight}");
+ expect(container.querySelector("[data-slot=chart-live]")).toHaveTextContent(
+ /^10 Aug: /,
+ );
+ rerender(view("loading"));
+ rerender(view("ready"));
+ expect(
+ container.querySelector("[data-slot=chart-live]"),
+ ).toBeEmptyDOMElement();
+ });
+
+ it("draws markers as a dot series, labelled, and never as a reference line or dot", () => {
+ const { container } = renderV1(
+ ,
+ );
+ const markers = container.querySelectorAll("[data-slot=chart-marker]");
+ expect(markers).toHaveLength(1);
+ expect(markers[0]).toHaveTextContent("10 Aug · 2,981 pages");
+ expect(
+ container.querySelector(
+ ".recharts-reference-line, .recharts-reference-dot, .recharts-reference-area",
+ ),
+ ).toBeNull();
+ expect(
+ screen.getByText(/Marked: 10 Aug · 2,981 pages\./),
+ ).toBeInTheDocument();
+ });
+
+ it("shows a skeleton sized to the chart while loading, and no plot", () => {
+ const { container } = renderV1();
+ expect(screen.getByRole("status")).toHaveTextContent("Loading Throughput");
+ expect(container.querySelector("[data-slot=skeleton-block]")).toHaveClass(
+ "ui:h-(--chart-height)",
+ );
+ expect(container.querySelector("svg")).toBeNull();
+ expect(container.querySelector(`[data-slot=${slot}]`)).toHaveStyle({
+ "--chart-height": "200px",
+ });
+ });
+
+ it("shows the empty message when there are no rows, or the slot when given", () => {
+ const { container, rerender } = renderV1(
+ ,
+ );
+ expect(container.querySelector(`[data-slot=${slot}]`)).toHaveAttribute(
+ "data-state",
+ "empty",
+ );
+ expect(screen.getByText("No data for this period")).toBeInTheDocument();
+ // A screen reader hears which chart is empty.
+ expect(screen.getByText("No data for this period")).toHaveTextContent(
+ "Throughput: No data for this period",
+ );
+ expect(container.querySelector("svg")).toBeNull();
+ rerender(
+ Nothing extracted yet
})}
+ />,
+ );
+ expect(screen.getByText("Nothing extracted yet")).toBeInTheDocument();
+ expect(screen.queryByText("No data for this period")).toBeNull();
+ });
+});
+
+describe("cartesian options", () => {
+ it("puts the legend above the plot, or hides it", () => {
+ const { container, rerender } = renderV1(
+ ,
+ );
+ const legend = container.querySelector("[data-slot=chart-legend]");
+ expect(
+ plot(container).compareDocumentPosition(legend as Node) &
+ Node.DOCUMENT_POSITION_PRECEDING,
+ ).toBeTruthy();
+ rerender();
+ expect(container.querySelector("[data-slot=chart-legend]")).toBeNull();
+ });
+
+ it("uses a caller's summary, formats and empty message", () => {
+ renderV1(
+ `${v} pg`,
+ xFormat: (x) => `day ${x}`,
+ axisFormat: (v) => `${v}!`,
+ })}
+ />,
+ );
+ expect(
+ screen.getByText("10 Aug carries most of the month."),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("rowheader", { name: "day 10 Aug" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("cell", { name: "2981 pg" })).toBeInTheDocument();
+ expect(screen.getAllByText(/!$/).length).toBeGreaterThan(0);
+ // xFormat reaches the visible X ticks too, not only the table.
+ const ticks = [
+ ...document.querySelectorAll(".recharts-xAxis-tick-labels text"),
+ ].map((t) => t.textContent);
+ expect(ticks.length).toBeGreaterThan(0);
+ for (const tick of ticks) {
+ expect(tick).toMatch(/^day /);
+ }
+ });
+
+ it("draws a right axis only when a series asks for it", () => {
+ const { container, rerender } = renderV1();
+ expect(container.querySelectorAll(".recharts-yAxis")).toHaveLength(1);
+ rerender(
+ `$${v}`,
+ },
+ ],
+ })}
+ />,
+ );
+ expect(container.querySelectorAll(".recharts-yAxis")).toHaveLength(2);
+ expect(screen.getByRole("cell", { name: "$600" })).toBeInTheDocument();
+ });
+
+ it("keeps a marker on the row it names when category labels repeat", () => {
+ const weeks = [
+ { day: "Mon", pages: 1, failed: 0 },
+ { day: "Tue", pages: 2, failed: 0 },
+ { day: "Mon", pages: 9, failed: 0 },
+ ];
+ const { container } = renderV1(
+ ,
+ );
+ const marker = container.querySelector("[data-slot=chart-marker] circle");
+ const curve = container
+ .querySelector(`.recharts-line-curve[name="Pages processed"]`)
+ ?.getAttribute("d");
+ // The curve's last vertex is the third row; the marker sits on it.
+ const last = curve?.split("L").at(-1)?.split(",").map(Number);
+ expect([
+ Number(marker?.getAttribute("cx")),
+ Number(marker?.getAttribute("cy")),
+ ]).toEqual(last);
+ });
+
+ it("puts a bar marker over its own bar in a group, and on top of its own segment in a stack", () => {
+ const geometry = (el: Element | null | undefined) => ({
+ x: Number(el?.getAttribute("x")),
+ y: Number(el?.getAttribute("y")),
+ width: Number(el?.getAttribute("width")),
+ });
+ const markerAt = (container: HTMLElement) => {
+ const circle = container.querySelector("[data-slot=chart-marker] circle");
+ return {
+ cx: Number(circle?.getAttribute("cx")),
+ cy: Number(circle?.getAttribute("cy")),
+ };
+ };
+ const marker = [{ index: 1, series: "pages", label: "peak" }] as const;
+ // Grouped: "pages" is the first of two bars in row 1.
+ const grouped = renderV1();
+ const bar = geometry(
+ grouped.container.querySelectorAll(`path[name="Pages processed"]`)[1],
+ );
+ expect(markerAt(grouped.container).cx).toBeCloseTo(bar.x + bar.width / 2);
+ expect(markerAt(grouped.container).cy).toBeCloseTo(bar.y);
+ grouped.unmount();
+ // Stacked: "pages" is the bottom segment, with "failed" above it.
+ const stacked = renderV1(
+ ,
+ );
+ const segment = geometry(
+ stacked.container.querySelectorAll(`path[name="Pages processed"]`)[1],
+ );
+ const above = geometry(
+ stacked.container.querySelectorAll(`path[name="Failed pages"]`)[1],
+ );
+ expect(above.y).toBeLessThan(segment.y);
+ expect(markerAt(stacked.container).cx).toBeCloseTo(
+ segment.x + segment.width / 2,
+ );
+ expect(markerAt(stacked.container).cy).toBeCloseTo(segment.y);
+ });
+
+ it("keeps a bar marker on its row when earlier rows are gaps, which draw no bar", () => {
+ const gappy = [
+ { day: "1", pages: 10 as number | null },
+ { day: "2", pages: null },
+ { day: "3", pages: 30 },
+ { day: "4", pages: 40 },
+ ];
+ const { container } = renderV1(
+ ,
+ );
+ // Bars drawn: rows 1, 3 and 4 (the gap draws none).
+ const bars = [...container.querySelectorAll(`path[name="Pages"]`)].map(
+ (el) => ({
+ centre:
+ Number(el.getAttribute("x")) + Number(el.getAttribute("width")) / 2,
+ top: Number(el.getAttribute("y")),
+ }),
+ );
+ expect(bars).toHaveLength(3);
+ const marks = [...container.querySelectorAll("[data-slot=chart-marker]")];
+ expect(marks.map((m) => m.textContent)).toEqual(["third", "last"]);
+ const at = (m: Element) => {
+ const circle = m.querySelector("circle");
+ return {
+ centre: Number(circle?.getAttribute("cx")),
+ top: Number(circle?.getAttribute("cy")),
+ };
+ };
+ expect(at(marks[0]).centre).toBeCloseTo(bars[1].centre);
+ expect(at(marks[0]).top).toBeCloseTo(bars[1].top);
+ expect(at(marks[1]).centre).toBeCloseTo(bars[2].centre);
+ expect(at(marks[1]).top).toBeCloseTo(bars[2].top);
+ });
+
+ it("stacks right-axis series separately, beside the left stack and from their own baseline", () => {
+ const { container } = renderV1(
+ ,
+ );
+ const bottom = (el: Element | undefined) =>
+ Number(el?.getAttribute("y")) + Number(el?.getAttribute("height"));
+ const left = container.querySelectorAll(`path[name="Pages processed"]`);
+ const right = container.querySelectorAll(`path[name="Failed pages"]`);
+ // Row 1: the right-axis bar starts at the plot's baseline, not on top of
+ // the left-axis bar.
+ expect(bottom(right[1])).toBeCloseTo(bottom(left[1]));
+ // ...and beside it, not drawn over it (one shared stack would overlap).
+ expect(Number(right[1]?.getAttribute("x"))).toBeGreaterThanOrEqual(
+ Number(left[1]?.getAttribute("x")) +
+ Number(left[1]?.getAttribute("width")),
+ );
+ });
+
+ it("colours a marker as its series, and keeps marker series out of the tooltip", async () => {
+ const { container, user } = renderV1(
+ ,
+ );
+ expect(
+ container.querySelector("[data-slot=chart-marker] circle"),
+ ).toHaveAttribute("fill", "var(--destructive)");
+ await user.tab();
+ await user.keyboard("{ArrowRight}");
+ expect(
+ container.querySelector("[data-slot=chart-tooltip]"),
+ ).toHaveTextContent(/^10 AugPages processed2,981Failed pages600$/);
+ });
+
+ it("neither draws nor reads out a marker on a gap or an uncharted series", () => {
+ const gappy = [
+ { day: "9 Aug", pages: 10, failed: 1 },
+ { day: "10 Aug", pages: null, failed: 600 },
+ ];
+ const { container } = renderV1(
+ ,
+ );
+ expect(container.querySelector("[data-slot=chart-marker]")).toBeNull();
+ expect(screen.queryByText(/Marked:/)).toBeNull();
+ });
+
+ it("draws a point with no neighbour as a dot, and leaves connected points to the line", () => {
+ const rows = [
+ { day: "1", pages: 5, failed: 1 },
+ { day: "2", pages: null, failed: 2 },
+ { day: "3", pages: 7, failed: 3 },
+ { day: "4", pages: 8, failed: null },
+ ];
+ for (const Chart of [LineChart, AreaChart]) {
+ const { container, unmount } = renderV1(
+ ,
+ );
+ // pages: row 1 stands alone; rows 3 and 4 join. failed: rows 1-3 join.
+ const points = container.querySelectorAll("[data-slot=chart-point]");
+ expect(points).toHaveLength(1);
+ expect(points[0]).toHaveAttribute("fill", "var(--chart-1)");
+ unmount();
+ }
+ const { container } = renderV1(
+ ,
+ );
+ expect(container.querySelectorAll("[data-slot=chart-point]")).toHaveLength(
+ 1,
+ );
+ });
+
+ it("still names the category in the tooltip when every series is a gap there", async () => {
+ const rows = [
+ { day: "9 Aug", pages: 10, failed: 1 },
+ { day: "10 Aug", pages: null, failed: null },
+ { day: "11 Aug", pages: 20, failed: 2 },
+ ];
+ const { container, user } = renderV1(
+ ,
+ );
+ await user.tab();
+ await user.keyboard("{ArrowRight}");
+ expect(
+ container.querySelector("[data-slot=chart-tooltip]"),
+ ).toHaveTextContent("10 AugPages processed–Failed pages–");
+ // Recharts hides the wrapper of an empty payload; gap entries keep it shown.
+ expect(container.querySelector(".recharts-tooltip-wrapper")).toHaveStyle({
+ visibility: "visible",
+ });
+ });
+
+ it("formats the left axis with axisFormat and the right with rightAxisFormat", () => {
+ const { container } = renderV1(
+ `${v}p`,
+ rightAxisFormat: (v) => `$${v}`,
+ })}
+ />,
+ );
+ const ticks = (axis: Element | undefined) =>
+ [...(axis?.querySelectorAll("text") ?? [])].map(
+ (t) => t.textContent ?? "",
+ );
+ const [left, right] = [
+ ...container.querySelectorAll(".recharts-yAxis-tick-labels"),
+ ];
+ expect(ticks(left).length).toBeGreaterThan(0);
+ expect(ticks(left).every((t) => t.endsWith("p"))).toBe(true);
+ expect(ticks(right).length).toBeGreaterThan(0);
+ expect(ticks(right).every((t) => t.startsWith("$"))).toBe(true);
+ });
+
+ it("redraws markers and the summary when markers change, even with the same data and inline series", () => {
+ const view = (markers: CartesianChartProps["markers"]) => (
+
+ );
+ const { container, rerender } = renderV1(
+ view([{ index: 1, series: "pages", label: "first" }]),
+ );
+ const marker = () =>
+ container.querySelector("[data-slot=chart-marker]")?.textContent;
+ expect(marker()).toBe("first");
+ rerender(view([{ index: 1, series: "pages", label: "renamed" }]));
+ expect(marker()).toBe("renamed");
+ expect(screen.getByText(/Marked: renamed\./)).toBeInTheDocument();
+ const cx = () =>
+ container
+ .querySelector("[data-slot=chart-marker] circle")
+ ?.getAttribute("cx");
+ const before = cx();
+ rerender(view([{ index: 2, series: "pages", label: "renamed" }]));
+ expect(cx()).not.toBe(before);
+ rerender(view([{ index: 2, series: "failed", label: "renamed" }]));
+ expect(
+ container.querySelector("[data-slot=chart-marker] circle"),
+ ).toHaveAttribute("fill", "var(--chart-2)");
+ });
+
+ it("warns once per dropped marker while showing data, and not while loading", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const view = (state: "loading" | "ready", rows: Row[]) => (
+
+ );
+ const { rerender } = renderV1(view("loading", []));
+ rerender(view("loading", []));
+ expect(warn).not.toHaveBeenCalled();
+ rerender(view("ready", [...data]));
+ rerender(view("ready", [...data]));
+ expect(warn.mock.calls.map(([message]) => message)).toEqual([
+ 'Chart marker "again" not drawn: an earlier marker already labels that point.',
+ ]);
+ });
+
+ // The default locale is the runtime's, which cannot be varied in-process
+ // (Intl reads it at start-up), so this pins the default formats, grouped and
+ // compact, not which locale they use.
+ it("formats grouped values and compact ticks by default", () => {
+ const viewer = numberFormats();
+ const { container } = renderV1(
+ ,
+ );
+ expect(
+ screen.getByRole("cell", { name: viewer.value(2981) }),
+ ).toBeInTheDocument();
+ const ticks = [
+ ...container.querySelectorAll(".recharts-yAxis-tick-labels text"),
+ ].map((t) => t.textContent);
+ expect(ticks).toContain(viewer.axis(3000));
+ });
+
+ it("warns about a reserved series key in any state, and draws no markers then", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ type Pivot = Record;
+ const rows: Pivot[] = [{ day: "1", a: 1, __marker_b: 2 }];
+ const view = (state: "loading" | "ready") => (
+
+ );
+ const { container, rerender } = renderV1(view("loading"));
+ expect(warn).toHaveBeenCalledWith(
+ 'Chart series key "__marker_b" starts with "__marker", which the chart uses for markers; rename the field.',
+ );
+ rerender(view("ready"));
+ expect(container.querySelector("[data-slot=chart-marker]")).toBeNull();
+ expect(warn).toHaveBeenLastCalledWith(
+ 'Chart marker "peak" not drawn: a series key starts with "__marker", which markers reserve.',
+ );
+ });
+
+ it("labels each of two series marked on the same row with its own marker", () => {
+ const both = [
+ { index: 1, series: "pages", label: "pages peak" },
+ { index: 1, series: "failed", label: "failed peak" },
+ ] as const;
+ for (const Chart of [LineChart, BarChart]) {
+ const { container, unmount } = renderV1(
+ ,
+ );
+ const marks = [
+ ...container.querySelectorAll("[data-slot=chart-marker]"),
+ ].map((m) => [
+ m.textContent,
+ m.querySelector("circle")?.getAttribute("fill"),
+ ]);
+ expect(marks).toEqual(
+ expect.arrayContaining([
+ ["pages peak", "var(--chart-1)"],
+ ["failed peak", "var(--chart-2)"],
+ ]),
+ );
+ expect(marks).toHaveLength(2);
+ unmount();
+ }
+ });
+
+ it("reads edge marker labels inward on a real line and area chart", () => {
+ for (const Chart of [LineChart, AreaChart]) {
+ const { container, unmount } = renderV1(
+ ,
+ );
+ const anchors = Object.fromEntries(
+ [...container.querySelectorAll("[data-slot=chart-marker]")].map((m) => [
+ m.textContent,
+ m.querySelector("text")?.getAttribute("text-anchor"),
+ ]),
+ );
+ expect(anchors).toEqual({ first: "start", m: "middle", last: "end" });
+ unmount();
+ }
+ });
+
+ it("reads long bar marker labels inward at the first and last bar of a real bar chart", () => {
+ const long = "10 Aug · 2,981 pages";
+ const { container } = renderV1(
+ ,
+ );
+ const anchors = [
+ ...container.querySelectorAll("[data-slot=chart-marker]"),
+ ].map((m) => [
+ m.textContent,
+ m.querySelector("text")?.getAttribute("text-anchor"),
+ ]);
+ expect(anchors).toEqual(
+ expect.arrayContaining([
+ [long, "start"],
+ ["m", "middle"],
+ [`${long} `, "end"],
+ ]),
+ );
+ });
+
+ it("measures bar marker labels against the plot area, which a right axis narrows", () => {
+ // The last "pages" bar centres at about 315 without a right axis (plot
+ // ends at 392) and about 283 with one (plot ends at 352). A 147-wide
+ // label fits centred in the first case (by about 4) and not in the
+ // second (over by about 5).
+ const anchorWith = (axis: "left" | "right") => {
+ const { container, unmount } = renderV1(
+ ,
+ );
+ const anchor = container
+ .querySelector("[data-slot=chart-marker] text")
+ ?.getAttribute("text-anchor");
+ unmount();
+ return anchor;
+ };
+ expect(anchorWith("left")).toBe("middle");
+ expect(anchorWith("right")).toBe("end");
+ });
+
+ it("lays out the ready chart's legend, invisibly and once, while loading and empty", async () => {
+ const legendOf = (container: HTMLElement) =>
+ container.querySelector("[data-slot=chart-legend]");
+ const items = (legend: Element | null) =>
+ [...(legend?.querySelectorAll("li") ?? [])].map((li) => li.textContent);
+ for (const position of ["top", "bottom"] as const) {
+ const ready = renderV1();
+ const readyItems = items(legendOf(ready.container));
+ ready.unmount();
+ for (const state of ["loading", "empty"] as const) {
+ const { container, unmount } = renderV1(
+ ,
+ );
+ const root = container.querySelector("[data-slot=line-chart]");
+ expect(root).toHaveClass("ui:flex", "ui:flex-col", "ui:gap-3");
+ const legend = legendOf(container);
+ expect(items(legend)).toEqual(readyItems);
+ expect(legend).toHaveClass("ui:invisible");
+ expect(legend).toHaveAttribute("aria-hidden", "true");
+ // On the same side as the ready chart's, and only once.
+ expect(
+ container.querySelectorAll("[data-slot=chart-legend]"),
+ ).toHaveLength(1);
+ expect(
+ position === "top" ? root?.firstElementChild : root?.lastElementChild,
+ ).toBe(legend);
+ await expect(container).toHaveNoA11yViolations();
+ unmount();
+ }
+ }
+ // No legend, nothing reserved.
+ const { container } = renderV1(
+ ,
+ );
+ expect(legendOf(container)).toBeNull();
+ });
+
+ it("separates stacked segments with a card-coloured stroke", () => {
+ const { container } = renderV1();
+ const segments = container.querySelectorAll(".recharts-bar-rectangle path");
+ expect(segments).toHaveLength(6);
+ for (const s of segments) {
+ expect(s).toHaveAttribute("stroke", "var(--card)");
+ }
+ });
+
+ it("fills its container through ResizeObserver when no width is given", () => {
+ const size = installResizeObserver();
+ const { container } = renderV1(
+ ,
+ );
+ expect(container.querySelector("[data-slot=bar-chart]")).toHaveClass(
+ "ui:w-full",
+ );
+ const [observed] = size.observed();
+ size.resize(observed, { width: 640, height: HEIGHT });
+ expect(plot(container)).toHaveAttribute("width", "640");
+ });
+
+ it.each(
+ THEME_MODES,
+ )("renders inside the %s theme root, so the ramp tokens resolve for that mode", (theme) => {
+ const { container } = renderV1(, { theme });
+ expect(plot(container).closest(".v1")).toHaveAttribute("data-theme", theme);
+ });
+});
+
+describe("Sparkline", () => {
+ const sparkProps = {
+ title: "Pages processed, last 30 days",
+ data,
+ xKey: "day",
+ series: [{ key: "pages", label: "Pages" }],
+ width: 120,
+ valueFormat: EN.value,
+ } as const;
+
+ it("scales a right-axis series on its own axis, so it spans the plot like the left one", () => {
+ const { container } = renderV1(
+ ,
+ );
+ const ys = (name: string) =>
+ (
+ container
+ .querySelector(`.recharts-line-curve[name="${name}"]`)
+ ?.getAttribute("d") ?? ""
+ )
+ .split(/[ML]/)
+ .filter(Boolean)
+ .map((point) => Number(point.split(",")[1]));
+ const span = (values: number[]) =>
+ Math.max(...values) - Math.min(...values);
+ // Minimums far from zero, so each axis's dataMin..dataMax domain shows:
+ // both lines span the plot (40 less the 4 + 4 top and bottom margin = 32). On one shared axis
+ // "Failed" (90 to 100) would sit flat far below "Pages" (900 to 1,000).
+ expect(span(ys("Pages"))).toBeCloseTo(32, 0);
+ expect(span(ys("Failed"))).toBeCloseTo(32, 0);
+ });
+
+ it("draws one line with no axes, grid, legend or tooltip, as a static image", async () => {
+ const { container } = renderV1();
+ const svg = plot(container);
+ expect(svg).toHaveAttribute("role", "img");
+ expect(svg).not.toHaveAttribute("tabindex");
+ expect(svg).toHaveAttribute("height", "40");
+ expect(
+ container.querySelector(".recharts-xAxis, .recharts-cartesian-grid"),
+ ).toBeNull();
+ expect(container.querySelector("[data-slot=chart-legend]")).toBeNull();
+ expect(container.querySelector(`[name="Pages"]`)).toHaveAttribute(
+ "stroke",
+ "var(--chart-1)",
+ );
+ expect(
+ screen.getByRole("table", { name: sparkProps.title }),
+ ).toBeInTheDocument();
+ expect(
+ document.getElementById(svg.getAttribute("aria-describedby") ?? ""),
+ ).toHaveTextContent(/^Pages processed, last 30 days\. Sparkline, 3 points/);
+ await expect(container).toHaveNoA11yViolations();
+ });
+
+ it("shows a tooltip with the category on the arrow keys when asked", async () => {
+ const { container, user } = renderV1();
+ expect(plot(container)).toHaveAttribute("role", "application");
+ await user.tab();
+ await user.keyboard("{ArrowRight}");
+ expect(
+ container.querySelector("[data-slot=chart-tooltip]"),
+ ).toHaveTextContent("10 AugPages2,981");
+ expect(container.querySelector("[data-slot=chart-live]")).toHaveTextContent(
+ "10 Aug: Pages 2,981",
+ );
+ });
+
+ it("still names the category in its tooltip on a gap", async () => {
+ const { container, user } = renderV1(
+ ,
+ );
+ await user.tab();
+ await user.keyboard("{ArrowRight}");
+ expect(
+ container.querySelector("[data-slot=chart-tooltip]"),
+ ).toHaveTextContent("10 AugPages–");
+ expect(container.querySelector(".recharts-tooltip-wrapper")).toHaveStyle({
+ visibility: "visible",
+ });
+ });
+
+ it("has loading and empty states", () => {
+ const { rerender } = renderV1(
+ ,
+ );
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Loading Pages processed, last 30 days",
+ );
+ rerender(
+ ,
+ );
+ expect(screen.getByText("No pages yet")).toBeInTheDocument();
+ });
+});
+
+describe("edge cases", () => {
+ it("stacks a negative segment down from zero, not inside the positive one", () => {
+ const rows = [{ day: "a", gain: 5, loss: -3 }];
+ const { container } = renderV1(
+ ,
+ );
+ const box = (name: string) => {
+ const el = container.querySelector(`path[name="${name}"]`);
+ const y = Number(el?.getAttribute("y"));
+ const h = Number(el?.getAttribute("height"));
+ return { top: Math.min(y, y + h), bottom: Math.max(y, y + h) };
+ };
+ // The gain bar's bottom is the zero line; the loss bar lies below it.
+ expect(box("Loss").top).toBeGreaterThanOrEqual(box("Gain").bottom - 0.5);
+ expect(box("Loss").bottom).toBeGreaterThan(box("Gain").bottom + 1);
+ });
+
+ it("reads a negative bar's marker label under its tip, or beside the dot at the plot's bottom edge", () => {
+ const rows = [
+ { day: "a", v: 10 },
+ { day: "b", v: -20 },
+ { day: "c", v: -40 },
+ ];
+ const { container } = renderV1(
+ ,
+ );
+ const plotBottom = Math.max(
+ ...[
+ ...container.querySelectorAll(
+ ".recharts-cartesian-grid-horizontal line",
+ ),
+ ].map((l) => Number(l.getAttribute("y1"))),
+ );
+ const [dip, low] = [
+ ...container.querySelectorAll("[data-slot=chart-marker]"),
+ ].map((m) => ({
+ cx: Number(m.querySelector("circle")?.getAttribute("cx")),
+ cy: Number(m.querySelector("circle")?.getAttribute("cy")),
+ x: Number(m.querySelector("text")?.getAttribute("x")),
+ y: Number(m.querySelector("text")?.getAttribute("y")),
+ }));
+ // Away from the edge: under the tip, its glyphs (about 9px above the
+ // baseline) clear of the dot (radius 4 and a 2px ring).
+ expect(dip.y - 9).toBeGreaterThanOrEqual(dip.cy + 6);
+ expect(dip.y).toBeLessThanOrEqual(plotBottom);
+ // The lowest bar's tip is within 16px of the plot's bottom (the domain
+ // bottoms out at -45), with no room under it: the label reads beside
+ // the dot, not over it.
+ expect(low.cy).toBeGreaterThan(plotBottom - 16);
+ expect(low.y).toBeLessThanOrEqual(plotBottom);
+ // Beside, not above: the dot sits at the label's middle (baseline 4
+ // below the dot, unless the plot's bottom is nearer).
+ expect(low.y).toBeGreaterThan(low.cy);
+ expect(low.y).toBeLessThanOrEqual(low.cy + 4);
+ // The rightmost bar's long label would leave the plot on its right: it
+ // reads leftward from the dot.
+ expect(low.cx - low.x).toBeGreaterThanOrEqual(8);
+ });
+
+ it("reads a bottom-edge label on the side where less of it leaves the plot, never above the dot", () => {
+ const label = "the lowest day of the whole month";
+ const bottomEdge = (
+ rows: { day: string; v: number }[],
+ index: number,
+ width = WIDTH,
+ ) => {
+ const { container, unmount } = renderV1(
+ ,
+ );
+ const mark = container.querySelector("[data-slot=chart-marker]");
+ const text = mark?.querySelector("text");
+ const plotBottom = Math.max(
+ ...[
+ ...container.querySelectorAll(
+ ".recharts-cartesian-grid-horizontal line",
+ ),
+ ].map((l) => Number(l.getAttribute("y1"))),
+ );
+ const out = {
+ cx: Number(mark?.querySelector("circle")?.getAttribute("cx")),
+ cy: Number(mark?.querySelector("circle")?.getAttribute("cy")),
+ x: Number(text?.getAttribute("x")),
+ y: Number(text?.getAttribute("y")),
+ anchor: text?.getAttribute("text-anchor"),
+ plotBottom,
+ };
+ unmount();
+ return out;
+ };
+ // Leftmost bar of a narrow chart: the long label fits on neither side,
+ // and leaves the plot less on its right.
+ const first = bottomEdge(
+ [
+ { day: "a", v: -40 },
+ { day: "b", v: 10 },
+ { day: "c", v: -20 },
+ ],
+ 0,
+ 200,
+ );
+ expect(first.anchor).toBe("start");
+ expect(first.x - first.cx).toBeGreaterThanOrEqual(8);
+ expect(first.y).toBeGreaterThan(first.cy);
+ // Third of four bars: more room on its left, so less of it leaves the
+ // plot there.
+ const middle = bottomEdge(
+ [
+ { day: "a", v: 10 },
+ { day: "b", v: 10 },
+ { day: "c", v: -40 },
+ { day: "d", v: 10 },
+ ],
+ 2,
+ );
+ expect(middle.anchor).toBe("end");
+ expect(middle.cx - middle.x).toBeGreaterThanOrEqual(8);
+ // A minimum on a nice tick puts the tip on the bottom edge itself: the
+ // baseline is held at the plot's bottom.
+ const onEdge = bottomEdge(
+ [
+ { day: "a", v: 20 },
+ { day: "b", v: -20 },
+ ],
+ 1,
+ );
+ expect(onEdge.cy).toBeCloseTo(onEdge.plotBottom, 0);
+ expect(onEdge.y).toBeLessThanOrEqual(onEdge.plotBottom);
+ // ...and so it is when the label reads rightward.
+ const onEdgeRight = bottomEdge(
+ [
+ { day: "a", v: -20 },
+ { day: "b", v: 20 },
+ { day: "c", v: 20 },
+ { day: "d", v: 20 },
+ ],
+ 0,
+ );
+ expect(onEdgeRight.anchor).toBe("start");
+ expect(onEdgeRight.cy).toBeCloseTo(onEdgeRight.plotBottom, 0);
+ expect(onEdgeRight.y).toBeLessThanOrEqual(onEdgeRight.plotBottom);
+ for (const placed of [first, middle, onEdge, onEdgeRight]) {
+ expect(placed.y).toBeGreaterThanOrEqual(placed.cy);
+ }
+ });
+
+ it("reads a bottom-edge label to the right of the dot where it fits", () => {
+ const { container } = renderV1(
+ ,
+ );
+ const mark = container.querySelector("[data-slot=chart-marker]");
+ const cx = Number(mark?.querySelector("circle")?.getAttribute("cx"));
+ const x = Number(mark?.querySelector("text")?.getAttribute("x"));
+ expect(x - cx).toBeGreaterThanOrEqual(8);
+ expect(mark?.querySelector("text")).toHaveAttribute("text-anchor", "start");
+ });
+
+ it("says why when unparsed values leave a chart empty", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const rows = [{ day: "a", pages: "12.5" }] as unknown as {
+ day: string;
+ pages: number;
+ }[];
+ const series = [{ key: "pages", label: "Pages" }] as const;
+ const expected =
+ 'Chart series "pages" has values that are not numbers; the chart reads them as gaps. Parse numeric strings before passing them.';
+ const line = renderV1(
+ ,
+ );
+ expect(
+ line.container.querySelector("[data-slot=line-chart]"),
+ ).toHaveAttribute("data-state", "empty");
+ expect(warn).toHaveBeenCalledWith(expected);
+ line.unmount();
+ warn.mockClear();
+ renderV1(
+ ,
+ );
+ expect(warn).toHaveBeenCalledWith(expected);
+ });
+
+ it("shows the empty state when every series value is a gap", () => {
+ const rows: { day: string; pages: number | null }[] = [
+ { day: "a", pages: null },
+ { day: "b", pages: null },
+ ];
+ const series = [{ key: "pages", label: "Pages" }] as const;
+ const { container, unmount } = renderV1(
+ ,
+ );
+ expect(container.querySelector("[data-slot=line-chart]")).toHaveAttribute(
+ "data-state",
+ "empty",
+ );
+ expect(screen.getByText("No data for this period")).toBeInTheDocument();
+ unmount();
+ const spark = renderV1(
+ ,
+ );
+ expect(
+ spark.container.querySelector("[data-slot=sparkline]"),
+ ).toHaveAttribute("data-state", "empty");
+ });
+
+ it("breaks a line, an area and a sparkline at a gap rather than joining across it", () => {
+ const rows = [
+ { day: "1", pages: 5 },
+ { day: "2", pages: null },
+ { day: "3", pages: 7 },
+ { day: "4", pages: 8 },
+ ];
+ const series = [{ key: "pages", label: "Pages" }] as const;
+ const subpaths = (container: HTMLElement, selector: string) =>
+ (container.querySelector(selector)?.getAttribute("d") ?? "").match(/M/g)
+ ?.length ?? 0;
+ for (const [Chart, selector] of [
+ [LineChart, ".recharts-line-curve"],
+ [AreaChart, ".recharts-area-curve"],
+ ] as const) {
+ const { container, unmount } = renderV1(
+ ,
+ );
+ expect(subpaths(container, selector), selector).toBe(2);
+ unmount();
+ }
+ const { container } = renderV1(
+ ,
+ );
+ expect(subpaths(container, ".recharts-line-curve")).toBe(2);
+ });
+
+ it("puts the caller's className on the chart root in every state", () => {
+ for (const state of ["ready", "loading", "empty"] as const) {
+ const { container, unmount } = renderV1(
+ ,
+ );
+ expect(
+ container.querySelector("[data-slot=line-chart]"),
+ state,
+ ).toHaveClass("ui:mt-4");
+ unmount();
+ const spark = renderV1(
+ ,
+ );
+ expect(
+ spark.container.querySelector("[data-slot=sparkline]"),
+ state,
+ ).toHaveClass("ui:mt-4");
+ spark.unmount();
+ }
+ });
+
+ it("draws the grid by default and none with grid={false}", () => {
+ const { container, rerender } = renderV1();
+ expect(container.querySelector(".recharts-cartesian-grid")).not.toBeNull();
+ rerender();
+ expect(container.querySelector(".recharts-cartesian-grid")).toBeNull();
+ });
+});
diff --git a/frontend/src/v1/components/ui/chart/describe.test.ts b/frontend/src/v1/components/ui/chart/describe.test.ts
new file mode 100644
index 0000000000..c675a24ca9
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/describe.test.ts
@@ -0,0 +1,355 @@
+import { expect, it } from "vitest";
+import {
+ describeChart,
+ type Formatters,
+ hasValues,
+ numberAt,
+ numberFormats,
+ tableRows,
+ unparsedWarnings,
+} from "./describe";
+import {
+ chartRows,
+ droppedWarnings,
+ keptLabels,
+ keptMarkers,
+ markerKey,
+ markerLabelKey,
+ reservedKeyWarnings,
+} from "./markers";
+import type {
+ CategoryKey,
+ ChartMarker,
+ ChartSeries,
+ NumericKey,
+} from "./types";
+
+// A fixed locale, so expectations hold on any machine; the components default
+// to the viewer's.
+const EN = numberFormats("en-US");
+const fmt: Formatters = { value: EN.value, x: String };
+type Row = { day: string; pages: number; cost: number | null };
+const data: Row[] = [
+ { day: "9 Aug", pages: 10, cost: 0.5 },
+ { day: "10 Aug", pages: 2981, cost: null },
+ { day: "11 Aug", pages: 20, cost: 1.25 },
+];
+
+it("formats values grouped and axis ticks compact, in the locale given", () => {
+ expect(EN.value(2981)).toBe("2,981");
+ expect(EN.axis(2300)).toBe("2.3K");
+ expect(EN.axis(750)).toBe("750");
+ expect(numberFormats("de-DE").value(2981)).toBe("2.981");
+});
+
+it("reads only finite numbers; anything else is a gap", () => {
+ expect(numberAt({ v: 3 }, "v")).toBe(3);
+ expect(numberAt({ v: null }, "v")).toBeNull();
+ expect(numberAt({ v: "3" }, "v")).toBeNull();
+ expect(numberAt({ v: Number.NaN }, "v")).toBeNull();
+ expect(numberAt({}, "v")).toBeNull();
+});
+
+it("summarises the range and each series' extremes, skipping gaps and using the series format", () => {
+ expect(
+ describeChart({
+ kind: "line",
+ title: "Throughput",
+ data,
+ xKey: "day",
+ series: [
+ { key: "pages", label: "Pages" },
+ { key: "cost", label: "Cost", format: (v) => `$${v.toFixed(2)}` },
+ ],
+ markerLabels: ["10 Aug · 2,981 pages"],
+ fmt,
+ }),
+ ).toBe(
+ "Throughput. Line chart, 3 points from 9 Aug to 11 Aug. " +
+ "Pages: highest 2,981 at 10 Aug, lowest 10 at 9 Aug. " +
+ "Cost: highest $1.25 at 11 Aug, lowest $0.50 at 9 Aug. " +
+ "Marked: 10 Aug · 2,981 pages.",
+ );
+});
+
+it("says so when there is one point, or a series has no values", () => {
+ expect(
+ describeChart({
+ kind: "stacked-bar",
+ title: "Pages by mode",
+ data: [{ day: "1 Sep", a: null }],
+ xKey: "day",
+ series: [{ key: "a", label: "Native" }],
+ fmt: { value: EN.value, x: (x) => `on ${x}` },
+ }),
+ ).toBe(
+ "Pages by mode. Stacked bar chart, 1 point, on 1 Sep. Native: no values.",
+ );
+});
+
+it("builds one table row per datum, a dash for a gap", () => {
+ expect(
+ tableRows(
+ data,
+ "day",
+ [
+ { key: "pages", label: "Pages" },
+ { key: "cost", label: "Cost" },
+ ],
+ fmt,
+ ),
+ ).toEqual([
+ ["9 Aug", "10", "0.5"],
+ ["10 Aug", "2,981", "–"],
+ ["11 Aug", "20", "1.25"],
+ ]);
+});
+
+it("keeps only markers that will be drawn: a charted series, an integer row in range, a value there, the first per point", () => {
+ const series = [
+ { key: "pages", label: "Pages" },
+ { key: "cost", label: "Cost" },
+ ] as const;
+ const { kept, dropped } = keptMarkers(data, series, [
+ { index: 2, series: "pages", label: "late" },
+ { index: 0, series: "pages", label: "early" },
+ { index: 1, series: "cost", label: "gap" }, // cost is null on row 1
+ { index: 0, series: "cost", label: "cost" },
+ { index: -1, series: "pages", label: "negative" },
+ { index: 3, series: "pages", label: "past the end" },
+ { index: 1.5, series: "pages", label: "fractional" },
+ { index: 2, series: "pages", label: "second on row 2" },
+ ]);
+ expect([...kept].map(([s, byRow]) => [s, [...byRow]])).toEqual([
+ [
+ 0,
+ [
+ [2, "late"],
+ [0, "early"],
+ ],
+ ],
+ [1, [[0, "cost"]]],
+ ]);
+ expect(dropped).toEqual([
+ { label: "gap", reason: "the series has no value on that row" },
+ { label: "negative", reason: "row -1 is not a row of data (3 rows)" },
+ { label: "past the end", reason: "row 3 is not a row of data (3 rows)" },
+ { label: "fractional", reason: "row 1.5 is not a row of data (3 rows)" },
+ {
+ label: "second on row 2",
+ reason: "an earlier marker already labels that point",
+ },
+ ]);
+});
+
+it("drops a marker on a series that is not charted", () => {
+ expect(
+ keptMarkers(
+ data,
+ [{ key: "pages", label: "Pages" }],
+ [{ index: 0, series: "cost", label: "not charted" }],
+ ),
+ ).toEqual({
+ kept: new Map(),
+ dropped: [{ label: "not charted", reason: "its series is not charted" }],
+ });
+});
+
+it("words a warning per dropped marker and per reserved series key", () => {
+ expect(
+ reservedKeyWarnings([
+ { key: "pages", label: "Pages" },
+ { key: "__marker_x", label: "Clash" },
+ ]),
+ ).toEqual([
+ 'Chart series key "__marker_x" starts with "__marker", which the chart uses for markers; rename the field.',
+ ]);
+ expect(
+ droppedWarnings([{ label: "A", reason: "its series is not charted" }]),
+ ).toEqual(['Chart marker "A" not drawn: its series is not charted.']);
+});
+
+it("draws no markers when a series key is reserved, since marker fields could overwrite it", () => {
+ type Pivot = Record;
+ const rows: Pivot[] = [{ a: 1, __marker_x: 2 }];
+ expect(
+ keptMarkers(
+ rows,
+ [
+ { key: "a", label: "A" },
+ { key: "__marker_x", label: "X" },
+ ],
+ [{ index: 0, series: "a", label: "peak" }],
+ ),
+ ).toEqual({
+ kept: new Map(),
+ dropped: [
+ {
+ label: "peak",
+ reason: 'a series key starts with "__marker", which markers reserve',
+ },
+ ],
+ });
+});
+
+it("orders the summary's markers by series, then row, whatever order they were given in", () => {
+ const series = [
+ { key: "pages", label: "Pages" },
+ { key: "cost", label: "Cost" },
+ ] as const;
+ const { kept } = keptMarkers(data, series, [
+ { index: 0, series: "cost", label: "cost first given" },
+ { index: 2, series: "pages", label: "pages late" },
+ { index: 0, series: "pages", label: "pages early" },
+ ]);
+ expect(keptLabels(kept)).toEqual([
+ "pages early",
+ "pages late",
+ "cost first given",
+ ]);
+});
+
+it("returns the data itself when nothing is marked", () => {
+ const series = [{ key: "pages", label: "P" }] as const;
+ expect(chartRows(data, series, new Map(), false)).toBe(data);
+ expect(chartRows(data, series, new Map(), true)).toBe(data);
+});
+
+it("carries each marker's value (line and area) and label in its own row", () => {
+ const series = [
+ { key: "pages", label: "Pages" },
+ { key: "cost", label: "Cost" },
+ ] as const;
+ const { kept } = keptMarkers(data, series, [
+ { index: 1, series: "pages", label: "peak" },
+ ]);
+ const line = chartRows(data, series, kept, false) as Record<
+ string,
+ unknown
+ >[];
+ expect(line.map((r) => r[markerKey("pages")])).toEqual([null, 2981, null]);
+ expect(line.map((r) => r[markerLabelKey("pages")])).toEqual([
+ undefined,
+ "peak",
+ undefined,
+ ]);
+ expect(line.map((r) => markerKey("cost") in r)).toEqual([
+ false,
+ false,
+ false,
+ ]);
+ expect(line[1]).toMatchObject(data[1]);
+ // Named by series key, so a series keeps its own fields if another is
+ // removed or reordered.
+ expect(Object.keys(line[1])).toEqual(
+ expect.arrayContaining(["__marker:pages", "__marker_label:pages"]),
+ );
+ // Bars read only the label: the bar itself is the point.
+ const bars = chartRows(data, series, kept, true) as Record[];
+ expect(bars.map((r) => r[markerLabelKey("pages")])).toEqual([
+ undefined,
+ "peak",
+ undefined,
+ ]);
+ expect(bars.map((r) => markerKey("pages") in r)).toEqual([
+ false,
+ false,
+ false,
+ ]);
+});
+
+/*
+ * The key types are the contract other pages build against; these lines are
+ * checked by `typecheck:v1` (test files are in the v1 tsconfig), so loosening
+ * a key type fails the typecheck.
+ */
+it("types series, marker and category keys by what their field holds", () => {
+ interface ApiRow {
+ day: string;
+ pages: number;
+ failed?: number | null;
+ cost: string; // a DRF Decimal, still a string
+ at: Date;
+ }
+ const rows: ApiRow[] = [];
+ const ok: ChartSeries[] = [
+ { key: "pages", label: "Pages" },
+ { key: "failed", label: "Failed" },
+ ];
+ // @ts-expect-error a numeric string must be parsed before charting
+ const stringSeries: ChartSeries = { key: "cost", label: "Cost" };
+ // @ts-expect-error not a field of the row
+ const unknownSeries: ChartSeries = { key: "nope", label: "Nope" };
+ // @ts-expect-error the category must be a string or a number
+ const dateCategory: CategoryKey = "at";
+ const marker: ChartMarker = {
+ index: 0,
+ series: "pages",
+ label: "peak",
+ };
+ // @ts-expect-error markers sit on numeric series only
+ const stringMarker: ChartMarker = { ...marker, series: "day" };
+ // Pivoted rows, one field per dynamic series, cannot be checked per key.
+ const pivoted: NumericKey> = "model-a";
+ const pivotedCategory: CategoryKey> =
+ "day";
+ // @ts-expect-error an index that can never hold a number has no series key
+ const allStrings: NumericKey> = "cost";
+ // An index that can only hold numbers, however narrowly, has any key.
+ const flags: NumericKey> = "on";
+ // Loosely typed JSON rows: nothing is known, so nothing is refused.
+ const looseSeries: NumericKey> = "pages";
+ const looseCategory: CategoryKey & { day: string }> =
+ "day";
+ // @ts-expect-error an index of objects has no category key
+ const objectCategory: CategoryKey> = "x";
+ // @ts-expect-error an untyped `[]` infers never, which accepts no key
+ const fromEmpty: NumericKey = "pages";
+ // @ts-expect-error likewise for the category
+ const categoryFromEmpty: CategoryKey = "day";
+ expect([
+ rows,
+ ok,
+ stringSeries,
+ unknownSeries,
+ dateCategory,
+ stringMarker,
+ pivoted,
+ pivotedCategory,
+ allStrings,
+ objectCategory,
+ fromEmpty,
+ categoryFromEmpty,
+ flags,
+ looseSeries,
+ looseCategory,
+ ]).toHaveLength(15);
+});
+
+it("finds values only in the series' own keys, and reads null, missing, non-finite and strings as gaps", () => {
+ const pages = [{ key: "pages" }];
+ // A numeric category or extra column is not a series value.
+ expect(hasValues([{ t: 1, other: 5, pages: null }], pages)).toBe(false);
+ expect(hasValues([{ t: 1 }], pages)).toBe(false);
+ expect(hasValues([{ pages: Number.NaN }], pages)).toBe(false);
+ expect(hasValues([{ pages: 0 }], pages)).toBe(true);
+ // An unparsed numeric string is a gap, as everywhere else in the chart.
+ expect(hasValues([{ pages: "12.5" }], pages)).toBe(false);
+ expect(hasValues([{ pages: 3 }], [])).toBe(false);
+ expect(hasValues([], pages)).toBe(false);
+});
+
+it("warns once per series that holds a value that is not a number, and never for gaps", () => {
+ const series = [{ key: "pages" }, { key: "cost" }, { key: "failed" }];
+ expect(
+ unparsedWarnings(
+ [
+ { pages: "1", cost: 2, failed: null },
+ { pages: "3", cost: undefined, failed: Number.NaN },
+ ],
+ series,
+ ),
+ ).toEqual([
+ 'Chart series "pages" has values that are not numbers; the chart reads them as gaps. Parse numeric strings before passing them.',
+ ]);
+});
diff --git a/frontend/src/v1/components/ui/chart/describe.ts b/frontend/src/v1/components/ui/chart/describe.ts
new file mode 100644
index 0000000000..8c7ab2cfef
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/describe.ts
@@ -0,0 +1,176 @@
+import type { ChartDatum, ChartSeries } from "./types";
+
+/** What the summary calls each chart. */
+export type ChartKind = "line" | "area" | "bar" | "stacked-bar" | "sparkline";
+
+const KIND_NAMES: Readonly> = {
+ line: "Line chart",
+ area: "Area chart",
+ bar: "Bar chart",
+ "stacked-bar": "Stacked bar chart",
+ sparkline: "Sparkline",
+};
+
+/**
+ * The default value and axis formatters for a locale; leave it out for the
+ * viewer's own. Tests pass a fixed one, so their expectations do not depend
+ * on the machine they run on.
+ */
+export function numberFormats(locale?: string): Readonly<{
+ value: (value: number) => string;
+ axis: (value: number) => string;
+}> {
+ const grouped = new Intl.NumberFormat(locale);
+ const compact = new Intl.NumberFormat(locale, {
+ notation: "compact",
+ maximumFractionDigits: 1,
+ });
+ return {
+ value: (value) => grouped.format(value),
+ axis: (value) => compact.format(value),
+ };
+}
+
+const VIEWER = numberFormats();
+
+/** Grouped, in the viewer's locale: 2,981. */
+export const formatValue = VIEWER.value;
+
+/** Compact, in the viewer's locale, for axis ticks: 3K, 2.3K. */
+export const formatAxisValue = VIEWER.axis;
+
+export function formatCategory(value: string | number): string {
+ return String(value);
+}
+
+/** The row's value for `key` as a finite number, or null for a gap. */
+export function numberAt(row: ChartDatum, key: string): number | null {
+ const value: unknown = (row as Record)[key];
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+/**
+ * True when some series has a value on some row. A gap is what `numberAt`
+ * reads as one (null, missing, not a finite number), the same rule as the
+ * tooltip, table, summary and markers, so a chart whose series are all gaps
+ * (a period with no activity, bucketed by day) shows the empty state, not a
+ * blank plot. An unparsed numeric string is a gap too: parse it first.
+ * Only the series' own keys are read.
+ */
+export function hasValues(
+ data: readonly ChartDatum[],
+ series: readonly Readonly<{ key: string }>[],
+): boolean {
+ return data.some((row) => series.some((s) => numberAt(row, s.key) !== null));
+}
+
+/**
+ * A console warning for each series holding something that is present but
+ * not a number (an unparsed DRF Decimal string): the chart reads it as a
+ * gap, so a payload the page forgot to parse would otherwise read as "no
+ * data" with nothing to say why.
+ */
+export function unparsedWarnings(
+ data: readonly ChartDatum[],
+ series: readonly Readonly<{ key: string }>[],
+): string[] {
+ return series
+ .filter((s) =>
+ data.some((row) => {
+ const value: unknown = (row as Record)[s.key];
+ return (
+ value !== null && value !== undefined && typeof value !== "number"
+ );
+ }),
+ )
+ .map(
+ (s) =>
+ `Chart series "${s.key}" has values that are not numbers; the chart reads them as gaps. Parse numeric strings before passing them.`,
+ );
+}
+
+export type Formatters = Readonly<{
+ value: (value: number) => string;
+ x: (value: string | number) => string;
+}>;
+
+function categoryAt(
+ row: ChartDatum | undefined,
+ xKey: string,
+ fmt: Formatters,
+): string {
+ const value: unknown =
+ row === undefined ? undefined : (row as Record)[xKey];
+ return typeof value === "string" || typeof value === "number"
+ ? fmt.x(value)
+ : "";
+}
+
+/**
+ * The fallback text alternative: the chart kind, the category range, and each
+ * series' highest and lowest point, then the labels of the markers that are
+ * drawn (`keptLabels`). Pages with a better sentence pass `summary`.
+ */
+export function describeChart(
+ input: Readonly<{
+ kind: ChartKind;
+ title: string;
+ data: readonly Row[];
+ xKey: string;
+ series: readonly ChartSeries[];
+ markerLabels?: readonly string[];
+ fmt: Formatters;
+ }>,
+): string {
+ const { kind, title, data, xKey, series, markerLabels = [], fmt } = input;
+ const first = categoryAt(data[0], xKey, fmt);
+ const last = categoryAt(data[data.length - 1], xKey, fmt);
+ const range =
+ data.length === 1
+ ? `1 point, ${first}`
+ : `${data.length} points from ${first} to ${last}`;
+ const parts = [`${title}. ${KIND_NAMES[kind]}, ${range}.`];
+ for (const s of series) {
+ const format = s.format ?? fmt.value;
+ let max: { value: number; at: string } | null = null;
+ let min: { value: number; at: string } | null = null;
+ for (const row of data) {
+ const value = numberAt(row, s.key);
+ if (value === null) {
+ continue;
+ }
+ const at = categoryAt(row, xKey, fmt);
+ if (max === null || value > max.value) {
+ max = { value, at };
+ }
+ if (min === null || value < min.value) {
+ min = { value, at };
+ }
+ }
+ parts.push(
+ max === null || min === null
+ ? `${s.label}: no values.`
+ : `${s.label}: highest ${format(max.value)} at ${max.at}, lowest ${format(min.value)} at ${min.at}.`,
+ );
+ }
+ if (markerLabels.length > 0) {
+ parts.push(`Marked: ${markerLabels.join("; ")}.`);
+ }
+ return parts.join(" ");
+}
+
+/** The data table's cells: one row per datum, the category then each series. */
+export function tableRows(
+ data: readonly Row[],
+ xKey: string,
+ series: readonly ChartSeries[],
+ fmt: Formatters,
+): string[][] {
+ return data.map((row) => [
+ categoryAt(row, xKey, fmt),
+ ...series.map((s) => {
+ const value = numberAt(row, s.key);
+ return value === null ? "–" : (s.format ?? fmt.value)(value);
+ }),
+ ]);
+}
diff --git a/frontend/src/v1/components/ui/chart/index.ts b/frontend/src/v1/components/ui/chart/index.ts
new file mode 100644
index 0000000000..4e91a885ae
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/index.ts
@@ -0,0 +1,24 @@
+export { AreaChart, type AreaChartProps } from "./AreaChart";
+export { BarChart, type BarChartProps } from "./BarChart";
+export { DEFAULT_CHART_HEIGHT } from "./CartesianChart";
+export { DEFAULT_EMPTY_MESSAGE } from "./ChartFrame";
+export { formatAxisValue, formatValue, numberFormats } from "./describe";
+export { LineChart, type LineChartProps } from "./LineChart";
+export { CHART_RAMP_SIZE, rampColor, seriesColors } from "./palette";
+export {
+ DEFAULT_SPARKLINE_HEIGHT,
+ Sparkline,
+ type SparklineProps,
+} from "./Sparkline";
+export { StackedBarChart, type StackedBarChartProps } from "./StackedBarChart";
+export type {
+ BaseChartProps,
+ CartesianChartProps,
+ CategoryKey,
+ ChartDatum,
+ ChartMarker,
+ ChartSeries,
+ ChartState,
+ ChartTone,
+ NumericKey,
+} from "./types";
diff --git a/frontend/src/v1/components/ui/chart/markers.ts b/frontend/src/v1/components/ui/chart/markers.ts
new file mode 100644
index 0000000000..e3611e22df
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/markers.ts
@@ -0,0 +1,153 @@
+import { numberAt } from "./describe";
+import type { ChartDatum, ChartMarker, ChartSeries } from "./types";
+
+/*
+ * Markers are drawn from the data, never with Recharts' ReferenceLine or
+ * ReferenceDot. Those address a point by its category value, and on a
+ * categorical axis that is the known defect: when a category label repeats
+ * (the default `allowDuplicatedCategory`, e.g. "Mon" across two weeks or
+ * "10 Aug" across two years) recharts 3.7.0 silently draws nothing, and even
+ * when it draws, the marker cannot say which of two equal labels it means. A
+ * marker here is indexed by row, and its value and label travel in the row
+ * itself, so it always lands on the point it names: on a line or area as a
+ * dot-only series aligned to the row, on a bar from the bar itself (so it
+ * sits on its own bar's tip in a group, and on its own segment's tip in a
+ * stack). `charts.test.tsx` asserts no reference element is ever rendered.
+ */
+
+/** Fields the chart adds to marked rows start with this; series keys may not. */
+export const RESERVED_PREFIX = "__marker";
+
+/*
+ * The marker fields are named by series key, not position, so a chart that
+ * keeps a series across a change (another series removed or reordered)
+ * reads its own fields, in the previous rows as in the new ones.
+ */
+
+/** The field holding a series' marker value on a marked row (line and area). */
+export function markerKey(seriesKey: string): string {
+ return `${RESERVED_PREFIX}:${seriesKey}`;
+}
+
+/** The field holding a series' marker label on a marked row. */
+export function markerLabelKey(seriesKey: string): string {
+ return `${RESERVED_PREFIX}_label:${seriesKey}`;
+}
+
+/** True when a series key could collide with the marker fields. */
+export function isReservedKey(seriesKey: string): boolean {
+ return seriesKey.startsWith(RESERVED_PREFIX);
+}
+
+/** Marker labels by row index, for each series that has at least one. */
+export type MarkerLabels = ReadonlyMap>;
+
+/** A marker that will not be drawn, and why; the chart warns about these. */
+export type DroppedMarker = Readonly<{ label: string; reason: string }>;
+
+/**
+ * The markers that will be drawn, by series index then row index, and the
+ * ones that will not. The one rule for which markers count, used by the plot
+ * and the summary alike: no series key is reserved (`isReservedKey`), the
+ * series is charted, the index is an integer row of `data`, the series has a
+ * value on that row, and no earlier marker already labels that point.
+ * Anything else is dropped, never drawn and never read out; the chart warns
+ * about each drop when it is showing data.
+ */
+export function keptMarkers(
+ data: readonly Row[],
+ series: readonly ChartSeries[],
+ markers: readonly ChartMarker[],
+): Readonly<{ kept: MarkerLabels; dropped: readonly DroppedMarker[] }> {
+ const kept = new Map>();
+ const dropped: DroppedMarker[] = [];
+ // A reserved series key could be overwritten by the marker fields, so a
+ // chart with one draws no markers at all (and warns about the key).
+ const reserved = series.some((s) => isReservedKey(s.key));
+ for (const marker of markers) {
+ const seriesIndex = series.findIndex((s) => s.key === marker.series);
+ const reason = reserved
+ ? `a series key starts with "${RESERVED_PREFIX}", which markers reserve`
+ : seriesIndex === -1
+ ? "its series is not charted"
+ : !Number.isInteger(marker.index) ||
+ marker.index < 0 ||
+ marker.index >= data.length
+ ? `row ${marker.index} is not a row of data (${data.length} rows)`
+ : numberAt(data[marker.index], marker.series) === null
+ ? "the series has no value on that row"
+ : kept.get(seriesIndex)?.has(marker.index)
+ ? "an earlier marker already labels that point"
+ : null;
+ if (reason !== null) {
+ dropped.push({ label: marker.label, reason });
+ continue;
+ }
+ const forSeries = kept.get(seriesIndex) ?? new Map();
+ forSeries.set(marker.index, marker.label);
+ kept.set(seriesIndex, forSeries);
+ }
+ return { kept, dropped };
+}
+
+/** Every kept marker's label, in series then row order, for the summary. */
+export function keptLabels(kept: MarkerLabels): string[] {
+ return [...kept]
+ .sort(([a], [b]) => a - b)
+ .flatMap(([, byRow]) =>
+ [...byRow].sort(([a], [b]) => a - b).map(([, label]) => label),
+ );
+}
+
+/** The console warning for each series key the chart reserves for markers. */
+export function reservedKeyWarnings(
+ series: readonly ChartSeries[],
+): string[] {
+ return series
+ .filter((s) => isReservedKey(s.key))
+ .map(
+ (s) =>
+ `Chart series key "${s.key}" starts with "${RESERVED_PREFIX}", which the chart uses for markers; rename the field.`,
+ );
+}
+
+/** The console warning for each dropped marker. */
+export function droppedWarnings(dropped: readonly DroppedMarker[]): string[] {
+ return dropped.map(
+ (d) => `Chart marker "${d.label}" not drawn: ${d.reason}.`,
+ );
+}
+
+/**
+ * The rows Recharts plots: `data` itself (same array) when nothing is
+ * marked; otherwise each row plus, for each marked series, on a line or area
+ * its value field (`markerKey`: the series value on rows it marks, null on
+ * the rest, so the marker series draws nothing there) and, on rows it marks,
+ * its label (`markerLabelKey`). Carrying both in the row keeps a marker paired with its point in
+ * every frame: Recharts updates its data store after commit, so anything
+ * the renderers took from props instead could meet the previous rows.
+ */
+export function chartRows(
+ data: readonly Row[],
+ series: readonly ChartSeries[],
+ kept: MarkerLabels,
+ bars: boolean,
+): readonly ChartDatum[] {
+ if (kept.size === 0) {
+ return data;
+ }
+ return data.map((row, rowIndex) => {
+ const extra: Record = {};
+ for (const [seriesIndex, byRow] of kept) {
+ const key = series[seriesIndex].key;
+ const label = byRow.get(rowIndex);
+ if (!bars) {
+ extra[markerKey(key)] = label === undefined ? null : numberAt(row, key);
+ }
+ if (label !== undefined) {
+ extra[markerLabelKey(key)] = label;
+ }
+ }
+ return { ...row, ...extra };
+ });
+}
diff --git a/frontend/src/v1/components/ui/chart/marks.test.tsx b/frontend/src/v1/components/ui/chart/marks.test.tsx
new file mode 100644
index 0000000000..00c4a50eaf
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/marks.test.tsx
@@ -0,0 +1,157 @@
+import { render } from "@testing-library/react";
+import type { ReactElement } from "react";
+import { expect, it } from "vitest";
+import { BarMarker, isolatedDot, markerDot } from "./marks";
+
+/*
+ * The renderers decide only from what Recharts hands them. That is what
+ * keeps a marker paired with its point in the frame after a data change,
+ * when Recharts still plots the previous rows (it updates its store after
+ * commit). happy-dom flushes that update inside act(), so the frame itself
+ * cannot be observed here; these tests pin the mechanism instead: the label
+ * comes from the plotted row (`payload`) or the label's `value`.
+ */
+
+function svg(element: ReactElement): SVGSVGElement {
+ const { container } = render();
+ return container.querySelector("svg") as SVGSVGElement;
+}
+
+it("draws a line marker from the label in the plotted row, nothing on rows without one", () => {
+ const Dot = markerDot("var(--chart-1)", "__marker_label:pages");
+ const marked = svg(
+ ,
+ );
+ expect(marked.querySelector("[data-slot=chart-marker]")).toHaveTextContent(
+ "peak",
+ );
+ expect(marked.querySelector("circle")).toHaveAttribute("cx", "10");
+ expect(
+ svg().querySelector(
+ "[data-slot=chart-marker]",
+ ),
+ ).toBeNull();
+ expect(
+ svg().querySelector(
+ "[data-slot=chart-marker]",
+ ),
+ ).toBeNull();
+});
+
+it("draws a bar marker from the label value, centred over the bar", () => {
+ const marked = svg(
+ ,
+ );
+ expect(marked.querySelector("[data-slot=chart-marker]")).toHaveTextContent(
+ "spike",
+ );
+ expect(marked.querySelector("circle")).toHaveAttribute("cx", "20");
+ expect(marked.querySelector("circle")).toHaveAttribute(
+ "fill",
+ "var(--chart-2)",
+ );
+ expect(
+ svg(
+ ,
+ ).querySelector("[data-slot=chart-marker]"),
+ ).toBeNull();
+});
+
+it("draws an isolated point only where neither plotted neighbour has a value", () => {
+ const Dot = isolatedDot("var(--chart-1)");
+ const at = (points: readonly { y: number | null }[], index: number) =>
+ svg().querySelector(
+ "[data-slot=chart-point]",
+ );
+ const gappy = [{ y: 1 }, { y: null }, { y: 3 }, { y: 4 }];
+ expect(at(gappy, 0)).not.toBeNull(); // nothing left, a gap right
+ expect(at(gappy, 2)).toBeNull(); // joined to row 3
+ expect(at([{ y: 5 }], 0)).not.toBeNull(); // a single row
+});
+
+it("reads a line marker's label from wherever leaves least of it outside the plot", () => {
+ const Dot = markerDot("var(--chart-1)", "l");
+ // Plot edges at x 0 and 300; "peak" is about 28 wide.
+ const points = [
+ { x: 0, y: 1 },
+ { x: 150, y: 2 },
+ { x: 300, y: 3 },
+ ];
+ const text = (cx: number, label = "peak", of = points) =>
+ svg(
+ ,
+ ).querySelector("text");
+ const anchor = (cx: number, label?: string, of?: typeof points) =>
+ text(cx, label, of)?.getAttribute("text-anchor");
+ expect(anchor(0)).toBe("start");
+ expect(text(0)?.getAttribute("x")).toBe("-4");
+ expect(anchor(150)).toBe("middle");
+ expect(text(150)?.getAttribute("x")).toBe("150");
+ expect(anchor(300)).toBe("end");
+ expect(text(300)?.getAttribute("x")).toBe("304");
+ // A long label, about 140 wide: centred it fits from 70 to 230.
+ const long = "10 Aug · 2,981 pages";
+ expect(anchor(40, long)).toBe("start");
+ expect(anchor(69, long)).toBe("start");
+ expect(anchor(70, long)).toBe("middle"); // fits exactly: centred wins the tie
+ expect(anchor(100, long)).toBe("middle");
+ expect(anchor(230, long)).toBe("middle");
+ expect(anchor(231, long)).toBe("end");
+ expect(anchor(260, long)).toBe("end");
+ // On a narrow plot, reading inward would overflow the far edge more than
+ // centring: centred spills 1 on the left, rightward would spill 55.
+ const narrow = [
+ { x: 0, y: 1 },
+ { x: 150, y: 2 },
+ ];
+ expect(anchor(69, long, narrow)).toBe("middle");
+ // ...but close to an edge, reading inward still spills less.
+ expect(
+ anchor(10, long, [
+ { x: 0, y: 1 },
+ { x: 100, y: 2 },
+ ]),
+ ).toBe("start");
+ // Wider than the plot and dead centre: centring spills least.
+ expect(
+ anchor(50, long, [
+ { x: 0, y: 1 },
+ { x: 100, y: 2 },
+ ]),
+ ).toBe("middle");
+ // The 4 nudge counts: on a 20-wide plot at x 3, "peak" read rightward
+ // from x -1 spills 8, centred spills 11.
+ expect(
+ anchor(3, "peak", [
+ { x: 0, y: 1 },
+ { x: 20, y: 2 },
+ ]),
+ ).toBe("start");
+ // ...and so does the mirror: at x 17, read leftward from x 21, it spills 8.
+ expect(
+ anchor(17, "peak", [
+ { x: 0, y: 1 },
+ { x: 20, y: 2 },
+ ]),
+ ).toBe("end");
+ // A single row sits mid-plot.
+ expect(anchor(150, "peak", [{ x: 150, y: 1 }])).toBe("middle");
+});
+
+it("centres a bar marker's label outside a chart, where there is no plot area", () => {
+ // Inside a chart the bounds come from Recharts' plot area; see the bar
+ // chart tests in charts.test.tsx.
+ expect(
+ svg(
+ ,
+ )
+ .querySelector("text")
+ ?.getAttribute("text-anchor"),
+ ).toBe("middle");
+});
diff --git a/frontend/src/v1/components/ui/chart/marks.tsx b/frontend/src/v1/components/ui/chart/marks.tsx
new file mode 100644
index 0000000000..5e70212190
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/marks.tsx
@@ -0,0 +1,267 @@
+import type { ReactElement } from "react";
+import { usePlotArea } from "recharts";
+
+/*
+ * Renderers Recharts calls per point or per bar. Each decides only from what
+ * Recharts hands it (the plotted `points`, the point's row as `payload`, the
+ * label's `value`), never from the chart's props: Recharts updates its data
+ * store after commit, so for one frame after a data change the points still
+ * belong to the old rows, and anything taken from the new props would land
+ * on the old geometry. Marker labels therefore travel in the rows
+ * (`chartRows`).
+ */
+
+type Anchor = "start" | "middle" | "end";
+
+/**
+ * A marker: a dot ringed in the card colour and its label, above the dot
+ * unless the caller places it (`labelX`, `labelY`); where the label reads
+ * from is the `anchor`.
+ */
+function MarkerMark({
+ x,
+ y,
+ color,
+ label,
+ anchor = "middle",
+ labelX,
+ labelY = y - 10,
+}: Readonly<{
+ x: number;
+ y: number;
+ color: string;
+ label: string;
+ anchor?: Anchor;
+ /** The label's x; by default nudged from the dot by its anchor. */
+ labelX?: number;
+ /** The label's baseline; above the dot unless the caller moves it. */
+ labelY?: number;
+}>) {
+ return (
+
+
+
+ {label}
+
+
+ );
+}
+
+type Point = Readonly<{ x?: number | null; y?: number | null }>;
+
+type PointProps = Readonly<{
+ cx?: number;
+ cy?: number;
+ index?: number;
+ points?: readonly Point[];
+ payload?: unknown;
+}>;
+
+// Caption type is 12px and averages about 0.6em a character. An estimate is
+// enough to tell whether a centred label would cross the plot's edge.
+const LABEL_CHAR_WIDTH = 7;
+
+/** How far `MarkerMark` shifts a label read from one side of its dot. */
+const LABEL_NUDGE = 4;
+
+/** From a negative bar's tip to its label's baseline: past the dot, one line down. */
+const LABEL_BELOW = 16;
+
+/** From the dot's centre to a label read beside it: past the dot and its ring. */
+const LABEL_BESIDE = 8;
+
+/** Lowers a label read beside the dot so the dot sits at its middle. */
+const LABEL_MIDLINE = 4;
+
+/**
+ * Where a marker's label reads from, given the plot's left and right bounds:
+ * whichever of `middle` (centred), `start` (rightward from the dot) or `end`
+ * (leftward) leaves the least of the estimated label outside the bounds,
+ * preferring `middle` on a tie, so a label that fits stays centred. Without
+ * bounds it stays centred.
+ */
+function labelAnchor(
+ cx: number,
+ label: string,
+ left: number | null | undefined,
+ right: number | null | undefined,
+): Anchor {
+ if (typeof left !== "number" || typeof right !== "number") {
+ return "middle";
+ }
+ const width = label.length * LABEL_CHAR_WIDTH;
+ const overflow = (from: number) =>
+ Math.max(0, left - from) + Math.max(0, from + width - right);
+ const candidates: [Anchor, number][] = [
+ ["middle", overflow(cx - width / 2)],
+ ["start", overflow(cx - LABEL_NUDGE)],
+ ["end", overflow(cx + LABEL_NUDGE - width)],
+ ];
+ let best = candidates[0];
+ for (const candidate of candidates) {
+ if (candidate[1] < best[1]) {
+ best = candidate;
+ }
+ }
+ return best[0];
+}
+
+/**
+ * `dot` for a line or area's marker series: a marker on each marked row,
+ * labelled from the row's `labelKey` field. Unmarked rows hold null for the
+ * marker value, so they have no point.
+ */
+export function markerDot(color: string, labelKey: string) {
+ return function MarkerDot({
+ cx,
+ cy,
+ points,
+ payload,
+ }: PointProps): ReactElement {
+ const label =
+ typeof payload === "object" && payload !== null
+ ? (payload as Record)[labelKey]
+ : undefined;
+ if (cx === undefined || cy === undefined || typeof label !== "string") {
+ return ;
+ }
+ // A category axis without bars puts the first and last row on the plot's
+ // bounds. A single row sits mid-plot, with no bounds to read from.
+ const anchor =
+ points === undefined || points.length < 2
+ ? "middle"
+ : labelAnchor(cx, label, points[0]?.x, points[points.length - 1]?.x);
+ return (
+
+ );
+ };
+}
+
+/**
+ * `dot` for a line or area series: draws a point only where it has no
+ * plotted neighbour (a single row, or a value between two gaps), which a
+ * line cannot draw on its own: a one-point path paints nothing.
+ */
+export function isolatedDot(color: string) {
+ return function IsolatedDot({
+ cx,
+ cy,
+ index,
+ points,
+ }: PointProps): ReactElement {
+ const plotted = (i: number) => points?.[i]?.y != null;
+ if (
+ cx === undefined ||
+ cy === undefined ||
+ index === undefined ||
+ plotted(index - 1) ||
+ plotted(index + 1)
+ ) {
+ return ;
+ }
+ return (
+
+ );
+ };
+}
+
+type BarMarkerProps = Readonly<{
+ color: string;
+ // Set by Recharts, which clones this element per bar.
+ x?: number | string;
+ y?: number | string;
+ width?: number | string;
+ /** Negative for a bar below zero, whose `y` is then its lower tip. */
+ height?: number | string;
+ value?: unknown;
+}>;
+
+/**
+ * `LabelList` content for a bar's markers, passed as an element so Recharts
+ * clones it rather than mounting a new component type each render. Its
+ * `dataKey` is the series' `markerLabelKey`, so `value` is the label carried
+ * in that bar's own row (unmarked rows have none). Centred on the bar it
+ * names, at its tip (the top, or the bottom below zero; its own segment's
+ * in a stack), since Recharts gives the bar's own geometry. Near the plot's
+ * left and right edges (Recharts' own plot area, so no layout is restated
+ * here) the label reads inward; at the bottom edge see the placement below.
+ */
+export function BarMarker({
+ color,
+ x,
+ y,
+ width,
+ height,
+ value,
+}: BarMarkerProps): ReactElement {
+ const plot = usePlotArea();
+ if (
+ typeof x !== "number" ||
+ typeof y !== "number" ||
+ typeof width !== "number" ||
+ typeof value !== "string"
+ ) {
+ return ;
+ }
+ const cx = x + width / 2;
+ const anchor = labelAnchor(
+ cx,
+ value,
+ plot?.x,
+ plot === undefined ? undefined : plot.x + plot.width,
+ );
+ // A bar below zero has its tip at the bottom: read the label under the
+ // tip, clear of the bar. Where the plot has no room under the tip (a tip
+ // within LABEL_BELOW of the plot's bottom edge, as the lowest bar's often
+ // is) the label reads beside the dot instead, on the side where less of it
+ // leaves the plot, its baseline kept within the plot's bottom.
+ const below = typeof height === "number" && height < 0;
+ const bottom =
+ plot === undefined ? Number.POSITIVE_INFINITY : plot.y + plot.height;
+ let place: Readonly<{ anchor: Anchor; x?: number; y?: number }> = { anchor };
+ if (below && y + LABEL_BELOW <= bottom) {
+ place = { anchor, y: y + LABEL_BELOW };
+ } else if (below) {
+ const left = plot === undefined ? Number.NEGATIVE_INFINITY : plot.x;
+ const right =
+ plot === undefined ? Number.POSITIVE_INFINITY : plot.x + plot.width;
+ const width = value.length * LABEL_CHAR_WIDTH;
+ const overRight = Math.max(0, cx + LABEL_BESIDE + width - right);
+ const overLeft = Math.max(0, left - (cx - LABEL_BESIDE - width));
+ const labelY = Math.min(y + LABEL_MIDLINE, bottom);
+ place =
+ overRight <= overLeft
+ ? { anchor: "start", x: cx + LABEL_BESIDE, y: labelY }
+ : { anchor: "end", x: cx - LABEL_BESIDE, y: labelY };
+ }
+ return (
+
+ );
+}
diff --git a/frontend/src/v1/components/ui/chart/markup.test.tsx b/frontend/src/v1/components/ui/chart/markup.test.tsx
new file mode 100644
index 0000000000..768f9ed7e7
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/markup.test.tsx
@@ -0,0 +1,122 @@
+import { render } from "@testing-library/react";
+import type { ReactElement } from "react";
+import { expect, it } from "vitest";
+import {
+ AreaChart,
+ BarChart,
+ LineChart,
+ numberFormats,
+ Sparkline,
+ StackedBarChart,
+} from "./index";
+
+// A fixed locale, so the snapshot holds on any machine.
+const EN = numberFormats("en-US");
+const data = [
+ { day: "9 Aug", pages: 10, failed: 1 },
+ { day: "10 Aug", pages: 2981, failed: 600 },
+ { day: "11 Aug", pages: 20, failed: 2 },
+];
+const series = [
+ { key: "pages", label: "Pages processed", tone: "primary" },
+ { key: "failed", label: "Failed pages", tone: "destructive" },
+] as const;
+const ramp = [
+ { key: "failed", label: "Failed" },
+ { key: "pages", label: "Pages" },
+] as const;
+const shared = {
+ title: "Throughput",
+ data,
+ xKey: "day",
+ width: 320,
+ height: 160,
+ valueFormat: EN.value,
+} as const;
+const axes = { axisFormat: EN.axis } as const;
+const marker = [{ index: 1, series: "pages", label: "Peak" }] as const;
+
+// One snapshot per variant and state, so a markup or class change shows in review.
+const VARIANTS: [string, ReactElement][] = [
+ [
+ "line",
+ ,
+ ],
+ ["area", ],
+ [
+ "bar",
+ ,
+ ],
+ [
+ "stacked bar",
+ ,
+ ],
+ ["sparkline", ],
+ [
+ "loading",
+ ,
+ ],
+ [
+ "empty",
+ ,
+ ],
+];
+
+/** useId and Recharts' generated clip-path ids change with render order; pin them. */
+function normalise(html: string): string {
+ const ids = new Map();
+ return html.replace(
+ /(_r_[0-9a-z]+_|recharts\d*-clip(?:-path)?[-\w]*)/g,
+ (id) => {
+ const known = ids.get(id);
+ if (known !== undefined) {
+ return known;
+ }
+ const next = `id-${ids.size + 1}`;
+ ids.set(id, next);
+ return next;
+ },
+ );
+}
+
+it("matches the committed markup of every variant", async () => {
+ const sections = VARIANTS.map(([name, element]) => {
+ const { container, unmount } = render(element);
+ const html = normalise((container.firstChild as HTMLElement).outerHTML);
+ unmount();
+ return [name, html] as const;
+ });
+ const doc = [
+ "# Chart markup",
+ "",
+ "Generated by `markup.test.tsx`; update with `vitest -u`.",
+ ...sections.flatMap(([name, html]) => [
+ "",
+ `## ${name}`,
+ "",
+ "```html",
+ html.replaceAll("><", ">\n<"),
+ "```",
+ ]),
+ "",
+ ].join("\n");
+ await expect(doc).toMatchFileSnapshot("./__snapshots__/markup.md");
+});
diff --git a/frontend/src/v1/components/ui/chart/palette.test.ts b/frontend/src/v1/components/ui/chart/palette.test.ts
new file mode 100644
index 0000000000..f640c5a563
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/palette.test.ts
@@ -0,0 +1,95 @@
+import { THEME_MODES, type ThemeMode } from "@v1/lib/theme";
+import { TOKENS } from "@v1/styles/tokens";
+import { expect, it } from "vitest";
+import { CHART_RAMP_SIZE, rampColor, seriesColors } from "./palette";
+
+function tokenValue(name: string, mode: ThemeMode): string {
+ const token = TOKENS.find((t) => t.name === name);
+ if (token === undefined) {
+ throw new Error(`no token ${name}`);
+ }
+ return token.values[mode];
+}
+
+/** WCAG 2 relative luminance of a #rrggbb colour. */
+function luminance(hex: string): number {
+ const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
+ if (match === null) {
+ throw new Error(`${hex} is not #rrggbb`);
+ }
+ const [r, g, b] = match.slice(1).map((part) => {
+ const c = Number.parseInt(part, 16) / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+/*
+ * The ramp is the LLMWhisperer design's --mode-* tokens, per mode, exactly as
+ * in Figma (`figq tokens llmw`: Midnight Bloom, modes Light | Dark | Unstract).
+ */
+const DESIGNED_RAMP: Record = {
+ light: ["#86b6ef", "#5598e7", "#2a78d6", "#1c5cab", "#104281"],
+ dark: ["#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"],
+ unstract: ["#86b6ef", "#5598e7", "#2a78d6", "#1c5cab", "#104281"],
+};
+
+it("the ramp is the designed --mode-* values in every mode", () => {
+ for (const mode of THEME_MODES) {
+ const ramp = Array.from({ length: CHART_RAMP_SIZE }, (_, i) =>
+ tokenValue(`chart-${i + 1}`, mode),
+ );
+ expect(ramp, mode).toEqual(DESIGNED_RAMP[mode]);
+ }
+});
+
+it("the luminance helper follows WCAG 2 and rejects anything but #rrggbb", () => {
+ expect(luminance("#ffffff")).toBeCloseTo(1, 5);
+ expect(luminance("#000000")).toBe(0);
+ // Each primary pins its own weight.
+ expect(luminance("#ff0000")).toBeCloseTo(0.2126, 4);
+ expect(luminance("#00ff00")).toBeCloseTo(0.7152, 4);
+ expect(luminance("#0000ff")).toBeCloseTo(0.0722, 4);
+ // Mid-grey is on the power segment of the sRGB curve. 5/255 is on the
+ // linear one, far enough below the knee that the power formula misses it.
+ expect(luminance("#777777")).toBeCloseTo(0.1845, 4);
+ expect(luminance("#050505")).toBeCloseTo(0.0015176, 7);
+ expect(luminance("#FFFFFF")).toBeCloseTo(1, 5);
+ for (const bad of ["var(--card)", "#ffffff80", "x#ffffff", "#fff"]) {
+ expect(() => luminance(bad)).toThrow(`${bad} is not #rrggbb`);
+ }
+});
+
+it("the ramp is ordered lightest to darkest in every mode", () => {
+ for (const mode of THEME_MODES) {
+ const lum = Array.from({ length: CHART_RAMP_SIZE }, (_, i) =>
+ luminance(tokenValue(`chart-${i + 1}`, mode)),
+ );
+ const sorted = [...lum].sort((a, b) => b - a);
+ expect(lum, mode).toEqual(sorted);
+ }
+});
+
+it("ramp step n reads --chart-(n+1) and wraps after the fifth", () => {
+ expect(rampColor(0)).toBe("var(--chart-1)");
+ expect(rampColor(4)).toBe("var(--chart-5)");
+ expect(rampColor(5)).toBe("var(--chart-1)");
+});
+
+it("untoned series take the ramp in order; toned series take their token and skip no step", () => {
+ expect(
+ seriesColors([
+ { key: "a", label: "A" },
+ { key: "b", label: "B", tone: "destructive" },
+ { key: "c", label: "C" },
+ { key: "d", label: "D", tone: "muted" },
+ { key: "e", label: "E", tone: "primary" },
+ ]),
+ ).toEqual([
+ "var(--chart-1)",
+ "var(--destructive)",
+ "var(--chart-2)",
+ "var(--muted-foreground)",
+ "var(--primary)",
+ ]);
+});
diff --git a/frontend/src/v1/components/ui/chart/palette.ts b/frontend/src/v1/components/ui/chart/palette.ts
new file mode 100644
index 0000000000..f0154a3fbe
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/palette.ts
@@ -0,0 +1,37 @@
+import type { ChartDatum, ChartSeries, ChartTone } from "./types";
+
+/** Steps in the ordinal ramp (`--chart-1` lightest to `--chart-5` darkest, in every mode). */
+export const CHART_RAMP_SIZE = 5;
+
+/** The token each tone reads. */
+export const TONE_TOKENS: Readonly> = {
+ primary: "primary",
+ destructive: "destructive",
+ success: "success",
+ warning: "warning",
+ muted: "muted-foreground",
+};
+
+/** `var(--chart-n)` for ramp step `step` (0-based); wraps after the fifth. */
+export function rampColor(step: number): string {
+ return `var(--chart-${(step % CHART_RAMP_SIZE) + 1})`;
+}
+
+/**
+ * One colour per series, in series order. A series with a `tone` takes that
+ * token; the rest take the ramp in order, so the first untoned series is
+ * `--chart-1` whatever comes before it.
+ */
+export function seriesColors(
+ series: readonly ChartSeries[],
+): string[] {
+ let step = 0;
+ return series.map((s) => {
+ if (s.tone !== undefined) {
+ return `var(--${TONE_TOKENS[s.tone]})`;
+ }
+ const color = rampColor(step);
+ step += 1;
+ return color;
+ });
+}
diff --git a/frontend/src/v1/components/ui/chart/types.ts b/frontend/src/v1/components/ui/chart/types.ts
new file mode 100644
index 0000000000..a184a20f0f
--- /dev/null
+++ b/frontend/src/v1/components/ui/chart/types.ts
@@ -0,0 +1,161 @@
+import type { ReactNode } from "react";
+
+/**
+ * Any row object: an `interface`, an API response type with fields the chart
+ * never reads, or a plain object. Only the keys a chart reads are constrained
+ * (see `CategoryKey` and `NumericKey`).
+ */
+export type ChartDatum = object;
+
+/** The value type of `Row`'s string index signature, where it has one. */
+type IndexValue = Row[string & keyof Row];
+
+/**
+ * Keys of `Row` holding a category: a string or a number. A row typed with a
+ * string index signature (`Record`, as pivoted rows
+ * with one field per dynamic series are) cannot be checked per key, so there
+ * any string is accepted when the index's value type includes a string or a
+ * number, or is `unknown` / `any` (nothing is known either way), and none
+ * otherwise (`Record`). An untyped `[]` infers `never`, whose
+ * index is `never`, so it accepts no key.
+ */
+export type CategoryKey = string extends keyof Row
+ ? unknown extends IndexValue
+ ? string
+ : [Extract, string | number>] extends [never]
+ ? never
+ : string
+ : {
+ [K in keyof Row]-?: Row[K] extends string | number ? K : never;
+ }[keyof Row] &
+ string;
+
+/**
+ * Keys of `Row` holding a series value: a number, or null / undefined for a
+ * gap. A numeric string ("12.5", as a DRF Decimal serialises) is rejected
+ * here: parse it before charting, or Recharts would draw it while the
+ * tooltip, table and summary treat it as a gap. A row with a string index
+ * signature accepts any string when the index's value type includes a number
+ * (`Record`, `Record`) or is
+ * `unknown` / `any`, and none otherwise (`Record`); `never`
+ * accepts none.
+ */
+export type NumericKey = string extends keyof Row
+ ? unknown extends IndexValue
+ ? string
+ : [Extract, number>] extends [never]
+ ? never
+ : string
+ : {
+ [K in keyof Row]-?: Row[K] extends number | null | undefined ? K : never;
+ }[keyof Row] &
+ string;
+
+/**
+ * A meaning-carrying colour that takes a series out of the ordinal ramp, e.g.
+ * failed pages in `destructive`. Each maps to the semantic token of that name
+ * (`muted` to `muted-foreground`).
+ */
+export type ChartTone =
+ | "primary"
+ | "destructive"
+ | "success"
+ | "warning"
+ | "muted";
+
+export type ChartSeries = Readonly<{
+ /**
+ * The field of each row that holds this series' value. Must not start with
+ * `__marker`, which the chart reserves for markers (a chart with such a key
+ * draws no markers, and warns). Checked at runtime rather than in this
+ * type, so that `Row` inference from `data` stays simple.
+ */
+ key: NumericKey;
+ /** Shown in the legend, the tooltip, the summary and the data table. */
+ label: string;
+ /** Leave out to take the next step of the ordinal ramp. */
+ tone?: ChartTone;
+ /** Which Y axis this series reads against. Default `left`. */
+ axis?: "left" | "right";
+ /** Formats this series' values in the tooltip, summary and table; overrides `valueFormat`. */
+ format?: (value: number) => string;
+}>;
+
+/**
+ * A highlighted point on a series (peak day, spike). Drawn from the series,
+ * never as a Recharts ReferenceLine or ReferenceDot. A marker is drawn and
+ * read out in the summary only when its series is charted, its index is an
+ * integer row of `data`, the series has a value on that row, no earlier
+ * marker labels that point, and no series key starts with `__marker`;
+ * otherwise it is dropped, with a console warning when the chart shows data.
+ */
+export type ChartMarker = Readonly<{
+ /** Row index in `data`, so repeated category labels stay unambiguous. */
+ index: number;
+ /** The series the marker sits on. */
+ series: NumericKey;
+ /** Drawn above the dot, and read out in the summary. */
+ label: string;
+}>;
+
+export type ChartState = "ready" | "loading" | "empty";
+
+/** Props every chart shares. */
+export type BaseChartProps = Readonly<{
+ /**
+ * `Row` is inferred from here. An untyped empty literal (`data={[]}`)
+ * infers `never`, which accepts no key, so type it: `data={[] as Row[]}`,
+ * or pass the type argument (` …>`).
+ */
+ data: readonly Row[];
+ /** The field holding each row's category (a date, a mode). */
+ xKey: CategoryKey;
+ /** Names the category column of the data table, e.g. "Date". Default "Category". */
+ xLabel?: string;
+ series: readonly ChartSeries[];
+ /**
+ * The chart's accessible name, e.g. "Throughput and failures". Not drawn:
+ * the card around the chart shows its own heading.
+ */
+ title: string;
+ /** A sentence describing what the chart shows; generated from the data when left out. */
+ summary?: string;
+ /**
+ * Default `ready`. Data with no rows, or whose every series value is a gap,
+ * shows the empty state too. There is no
+ * error state: on a failed request render the page's `InlineAlert` (with
+ * `onRetry`) instead of the chart, so a failure never reads as "no data".
+ */
+ state?: ChartState;
+ /**
+ * Replaces the default empty message. It is centred in a box of `height`
+ * that does not grow, so it must fit, and it replaces the screen-reader
+ * title prefix, so it should name the chart.
+ */
+ emptySlot?: ReactNode;
+ /** Default "No data for this period". */
+ emptyMessage?: string;
+ /** Plot height in pixels. */
+ height?: number;
+ /** Fixed width in pixels; leave out to fill the container. */
+ width?: number;
+ /** Formats values in the tooltip, summary and table. Default: grouped number (2,981). */
+ valueFormat?: (value: number) => string;
+ /** Formats categories on the X axis, the tooltip, summary and table. Default: `String`. */
+ xFormat?: (value: string | number) => string;
+ className?: string;
+}>;
+
+/** Props of the four charts with axes (all but the sparkline). */
+export type CartesianChartProps = BaseChartProps &
+ Readonly<{
+ markers?: readonly ChartMarker[];
+ /** Where the legend sits; `none` hides it. Default `bottom`. */
+ legend?: "top" | "bottom" | "none";
+ /** Formats left Y axis ticks. Default: compact number (2.3K). */
+ axisFormat?: (value: number) => string;
+ /** Formats right Y axis ticks (series with `axis: "right"`). Default: `axisFormat`. */
+ rightAxisFormat?: (value: number) => string;
+ /** Draws horizontal grid lines. Default true. */
+ grid?: boolean;
+ }>;
diff --git a/frontend/src/v1/styles/__snapshots__/token-table.md b/frontend/src/v1/styles/__snapshots__/token-table.md
index ca8127fd4d..327219bc7d 100644
--- a/frontend/src/v1/styles/__snapshots__/token-table.md
+++ b/frontend/src/v1/styles/__snapshots__/token-table.md
@@ -30,6 +30,11 @@
| colour | sidebar-muted-foreground | `--sidebar-muted-foreground` | `ui:text-sidebar-muted-foreground` | #737373 | #a1a1aa | #a9c3da |
| colour | header | `--header` | `ui:bg-header` | #fcfcfc | #0a0a0a | #072b4d |
| colour | header-foreground | `--header-foreground` | `ui:text-header-foreground` | #1a1a1a | #fafafa | #ffffff |
+| colour | chart-1 | `--chart-1` | `ui:fill-chart-1` | #86b6ef | #9ec5f4 | #86b6ef |
+| colour | chart-2 | `--chart-2` | `ui:fill-chart-2` | #5598e7 | #6da7ec | #5598e7 |
+| colour | chart-3 | `--chart-3` | `ui:fill-chart-3` | #2a78d6 | #3987e5 | #2a78d6 |
+| colour | chart-4 | `--chart-4` | `ui:fill-chart-4` | #1c5cab | #256abf | #1c5cab |
+| colour | chart-5 | `--chart-5` | `ui:fill-chart-5` | #104281 | #184f95 | #104281 |
| colour | code-key | `--code-key` | `ui:text-code-key` | #b41b30 | #fca5a5 | #b41b30 |
| colour | code-string | `--code-string` | `ui:text-code-string` | #5b5bc0 | #c1c5fb | #5d5dc4 |
| colour | code-number | `--code-number` | `ui:text-code-number` | #147739 | #4ade80 | #147839 |
diff --git a/frontend/src/v1/styles/index.css b/frontend/src/v1/styles/index.css
index 7d2f4c2e90..9481c4cc08 100644
--- a/frontend/src/v1/styles/index.css
+++ b/frontend/src/v1/styles/index.css
@@ -63,6 +63,11 @@
--color-header: var(--header);
--color-header-foreground: var(--header-foreground);
--color-scrim: var(--scrim);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
/* Syntax and diff colours (P1-T16): ui:text-code-*, ui:bg-diff-*. */
--color-code-key: var(--code-key);
--color-code-string: var(--code-string);
diff --git a/frontend/src/v1/styles/tokens.css b/frontend/src/v1/styles/tokens.css
index 4d95b8a56b..f6ab4557cf 100644
--- a/frontend/src/v1/styles/tokens.css
+++ b/frontend/src/v1/styles/tokens.css
@@ -31,6 +31,11 @@
--sidebar-muted-foreground: #737373;
--header: #fcfcfc;
--header-foreground: #1a1a1a;
+ --chart-1: #86b6ef;
+ --chart-2: #5598e7;
+ --chart-3: #2a78d6;
+ --chart-4: #1c5cab;
+ --chart-5: #104281;
--code-key: #b41b30;
--code-string: #5b5bc0;
--code-number: #147739;
@@ -119,6 +124,11 @@
--sidebar-muted-foreground: #a1a1aa;
--header: #0a0a0a;
--header-foreground: #fafafa;
+ --chart-1: #9ec5f4;
+ --chart-2: #6da7ec;
+ --chart-3: #3987e5;
+ --chart-4: #256abf;
+ --chart-5: #184f95;
--code-key: #fca5a5;
--code-string: #c1c5fb;
--code-number: #4ade80;
@@ -207,6 +217,11 @@
--sidebar-muted-foreground: #a9c3da;
--header: #072b4d;
--header-foreground: #ffffff;
+ --chart-1: #86b6ef;
+ --chart-2: #5598e7;
+ --chart-3: #2a78d6;
+ --chart-4: #1c5cab;
+ --chart-5: #104281;
--code-key: #b41b30;
--code-string: #5d5dc4;
--code-number: #147839;
diff --git a/frontend/src/v1/styles/tokens.json b/frontend/src/v1/styles/tokens.json
index eb35eefd3d..bcab0a7f8d 100644
--- a/frontend/src/v1/styles/tokens.json
+++ b/frontend/src/v1/styles/tokens.json
@@ -282,6 +282,53 @@
"unstract": "#ffffff"
}
},
+ {
+ "name": "chart-1",
+ "utility": "ui:fill-chart-1",
+ "values": {
+ "light": "#86b6ef",
+ "dark": "#9ec5f4",
+ "unstract": "#86b6ef"
+ },
+ "note": "chart ordinal ramp, lightest step; the LLMWhisperer design's --mode-* values (native-text, low-cost, high-quality, form, table) per mode, as in Figma"
+ },
+ {
+ "name": "chart-2",
+ "utility": "ui:fill-chart-2",
+ "values": {
+ "light": "#5598e7",
+ "dark": "#6da7ec",
+ "unstract": "#5598e7"
+ }
+ },
+ {
+ "name": "chart-3",
+ "utility": "ui:fill-chart-3",
+ "values": {
+ "light": "#2a78d6",
+ "dark": "#3987e5",
+ "unstract": "#2a78d6"
+ }
+ },
+ {
+ "name": "chart-4",
+ "utility": "ui:fill-chart-4",
+ "values": {
+ "light": "#1c5cab",
+ "dark": "#256abf",
+ "unstract": "#1c5cab"
+ }
+ },
+ {
+ "name": "chart-5",
+ "utility": "ui:fill-chart-5",
+ "values": {
+ "light": "#104281",
+ "dark": "#184f95",
+ "unstract": "#104281"
+ },
+ "note": "chart ordinal ramp, darkest step"
+ },
{
"name": "code-key",
"utility": "ui:text-code-key",
diff --git a/frontend/src/v1/styles/tokens.test.ts b/frontend/src/v1/styles/tokens.test.ts
index c8e9109802..7260a8b2b8 100644
--- a/frontend/src/v1/styles/tokens.test.ts
+++ b/frontend/src/v1/styles/tokens.test.ts
@@ -47,8 +47,8 @@ it("covers the families the board defines", () => {
]);
expect(byFamily("type")).toHaveLength(25); // eight roles × (size, line-height, weight) + overline tracking
// The Midnight Bloom collection minus its four palette entries (30), plus
- // the ten syntax and diff colours P1-T16 adds.
- expect(byFamily("colour")).toHaveLength(40);
+ // the ten syntax and diff colours P1-T16 adds and the five chart ramp steps.
+ expect(byFamily("colour")).toHaveLength(45);
for (const name of [
"background",
"foreground",
diff --git a/frontend/src/v1/styles/tokens.ts b/frontend/src/v1/styles/tokens.ts
index 831c1d17b9..97f4a78f31 100644
--- a/frontend/src/v1/styles/tokens.ts
+++ b/frontend/src/v1/styles/tokens.ts
@@ -10,8 +10,8 @@ import source from "./tokens.json";
*
* Colour is the Midnight Bloom collection minus its palette entries (sky-300,
* sky-500, openai-green, white), plus the code-* and diff-* syntax and diff
- * colours P1-T16 added. Spacing is one 4pt scale: a value not on the
- * list is a bug. Type is eight roles, each a size, a line height and a
+ * colours P1-T16 added and the chart ramp chart-1..chart-5. Spacing is one
+ * 4pt scale: a value not on the list is a bug. Type is eight roles, each a size, a line height and a
* weight. Elevation is one value per step. Focus has no offset: its colour
* is the `ring` token.
*/