diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 405387cbda..59a7ae7f19 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -96,6 +96,12 @@ is convention. `Spinner` sits only in buttons and running text. Errors on a screen are an `InlineAlert` above the content, with `onRetry`. +## Charts + +- Line, area, bar, stacked bar and sparkline live in `components/ui/chart/`; + its README has the props, colour ramp, marker rule and text alternative. + Colours come from `--chart-1`…`--chart-5` and the semantic tokens only. + ## Code and data - Code, JSON, diffs, Markdown, copy controls and code editors come from diff --git a/frontend/src/v1/components/ui/chart/AreaChart.tsx b/frontend/src/v1/components/ui/chart/AreaChart.tsx new file mode 100644 index 0000000000..bf966d205e --- /dev/null +++ b/frontend/src/v1/components/ui/chart/AreaChart.tsx @@ -0,0 +1,12 @@ +import type { JSX } from "react"; +import { CartesianChart } from "./CartesianChart"; +import type { CartesianChartProps, ChartDatum } from "./types"; + +export type AreaChartProps = CartesianChartProps; + +/** A line chart with a translucent fill under each series. Series overlap; they do not stack. */ +export function AreaChart( + props: AreaChartProps, +): JSX.Element { + return ; +} diff --git a/frontend/src/v1/components/ui/chart/BarChart.tsx b/frontend/src/v1/components/ui/chart/BarChart.tsx new file mode 100644 index 0000000000..95870deaee --- /dev/null +++ b/frontend/src/v1/components/ui/chart/BarChart.tsx @@ -0,0 +1,12 @@ +import type { JSX } from "react"; +import { CartesianChart } from "./CartesianChart"; +import type { CartesianChartProps, ChartDatum } from "./types"; + +export type BarChartProps = CartesianChartProps; + +/** One bar per series per category, side by side. */ +export function BarChart( + props: BarChartProps, +): JSX.Element { + return ; +} diff --git a/frontend/src/v1/components/ui/chart/CartesianChart.tsx b/frontend/src/v1/components/ui/chart/CartesianChart.tsx new file mode 100644 index 0000000000..006bda2fd5 --- /dev/null +++ b/frontend/src/v1/components/ui/chart/CartesianChart.tsx @@ -0,0 +1,325 @@ +import { type JSX, useEffect, useId, useMemo, useState } from "react"; +import { + Area, + Bar, + CartesianGrid, + ComposedChart, + LabelList, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { + ChartFrame, + DEFAULT_EMPTY_MESSAGE, + DEFAULT_X_LABEL, +} from "./ChartFrame"; +import { ChartTooltip } from "./ChartTooltip"; +import { + type ChartKind, + describeChart, + type Formatters, + formatAxisValue, + formatCategory, + formatValue, + hasValues, + tableRows, + unparsedWarnings, +} from "./describe"; +import { + chartRows, + droppedWarnings, + keptLabels, + keptMarkers, + markerKey, + markerLabelKey, + reservedKeyWarnings, +} from "./markers"; +import { BarMarker, isolatedDot, markerDot } from "./marks"; +import { seriesColors } from "./palette"; +import type { CartesianChartProps, ChartDatum } from "./types"; + +export const DEFAULT_CHART_HEIGHT = 240; + +const NO_MARKERS: readonly never[] = []; + +const AXIS_TICK = { fill: "var(--muted-foreground)" }; +// Room for a marker label above the highest point. +const MARGIN = { top: 20, right: 8, bottom: 0, left: 0 }; +const Y_AXIS_WIDTH = 40; + +type Props = CartesianChartProps & + Readonly<{ kind: Exclude; slot: string }>; + +/** The shared body of LineChart, AreaChart, BarChart and StackedBarChart. */ +export function CartesianChart({ + kind, + slot, + data, + xKey, + xLabel = DEFAULT_X_LABEL, + series, + title, + summary, + state = "ready", + emptySlot, + emptyMessage = DEFAULT_EMPTY_MESSAGE, + height = DEFAULT_CHART_HEIGHT, + width, + valueFormat = formatValue, + xFormat = formatCategory, + axisFormat = formatAxisValue, + rightAxisFormat = axisFormat, + markers = NO_MARKERS, + legend = "bottom", + grid = true, + className, +}: Props): 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 stacked = kind === "stacked-bar"; + const bars = kind === "bar" || stacked; + const resolved = + state === "ready" && !hasValues(data, series) ? "empty" : state; + // Recharts resets its data store whenever the rows it gets change + // identity, so the rows are `data` itself unless something is marked, and + // are memoised on what the series and markers say rather than on their + // identity: pages pass both as inline literals, new on every render. + const seriesKeys = series.map((s) => s.key).join("\n"); + const markerSpec = markers + .map((m) => `${m.index}\t${m.series}\t${m.label}`) + .join("\n"); + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the content of series and markers (seriesKeys, markerSpec), not their identity. + const { kept, dropped } = useMemo( + () => keptMarkers(data, series, markers), + [data, seriesKeys, markerSpec], + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: series only matters through its keys, and kept changes whenever they do. + const rows = useMemo( + () => chartRows(data, series, kept, bars), + [data, kept, bars], + ); + // Logged whenever the set of problems changes (and, as with every effect, + // twice on mount under StrictMode in development). A reserved series key + // or an unparsed value is a mistake in any state (the latter is why a + // chart with data may show its empty state); a dropped marker only while + // showing data, since a marker computed for rows still loading is not one. + const warnings = JSON.stringify([ + ...reservedKeyWarnings(series), + ...unparsedWarnings(data, series), + ...(resolved === "ready" ? droppedWarnings(dropped) : []), + ]); + useEffect(() => { + for (const warning of JSON.parse(warnings) as string[]) { + console.warn(warning); + } + }, [warnings]); + const hasRight = series.some((s) => s.axis === "right"); + const text = + summary ?? + describeChart({ + kind, + title, + data, + xKey, + series, + markerLabels: keptLabels(kept), + fmt, + }); + const tooltipSeries = series.map((s, i) => ({ + key: s.key, + label: s.label, + color: colors[i], + format: s.format ?? valueFormat, + })); + + const marks = series.map((s, i) => { + const axis = s.axis ?? "left"; + const common = { + dataKey: s.key, + name: s.label, + yAxisId: axis, + isAnimationActive: false, + }; + switch (kind) { + case "line": + return ( + + ); + case "area": + return ( + + ); + default: + // Stacked segments are split by a 1px card-coloured stroke, so + // neighbouring ramp steps read apart. + return ( + + {kept.has(i) ? ( + } + /> + ) : null} + + ); + } + }); + + const markerMarks = bars + ? [] + : [...kept.keys()].map((seriesIndex) => ( + + )); + + const chart = ( + + {grid ? ( + + ) : null} + xFormat(value)} + tick={AXIS_TICK} + axisLine={false} + tickLine={false} + minTickGap={16} + /> + axisFormat(value)} + tick={AXIS_TICK} + axisLine={false} + tickLine={false} + width={Y_AXIS_WIDTH} + /> + {hasRight ? ( + rightAxisFormat(value)} + tick={AXIS_TICK} + axisLine={false} + tickLine={false} + width={Y_AXIS_WIDTH} + /> + ) : null} + + } + /> + {marks} + {markerMarks} + + ); + + return ( + s.label)], + rows: tableRows(data, xKey, series, fmt), + }} + legend={ + legend === "none" + ? undefined + : { + position: legend, + items: series.map((s, i) => ({ + key: s.key, + label: s.label, + color: colors[i], + })), + } + } + className={className} + > + {width === undefined ? ( + + {chart} + + ) : ( + chart + )} + + ); +} diff --git a/frontend/src/v1/components/ui/chart/ChartFrame.tsx b/frontend/src/v1/components/ui/chart/ChartFrame.tsx new file mode 100644 index 0000000000..b0e53cdaa5 --- /dev/null +++ b/frontend/src/v1/components/ui/chart/ChartFrame.tsx @@ -0,0 +1,193 @@ +import { Skeleton, SkeletonBlock } from "@v1/components/ui/feedback"; +import { cn } from "@v1/lib/cn"; +import type { JSX, ReactNode } from "react"; +import type { ChartState } from "./types"; + +export const DEFAULT_EMPTY_MESSAGE = "No data for this period"; +export const DEFAULT_X_LABEL = "Category"; + +export type LegendItem = Readonly<{ + key: string; + label: string; + color: string; +}>; + +type ChartFrameProps = Readonly<{ + slot: string; + title: string; + state: ChartState; + height: number; + width?: number; + emptySlot?: ReactNode; + emptyMessage: string; + /** The text alternative: a sentence, and the data as a table. */ + summary: string; + summaryId: string; + table: Readonly<{ head: readonly string[]; rows: readonly string[][] }>; + legend?: Readonly<{ + position: "top" | "bottom"; + items: readonly LegendItem[]; + }>; + className?: string; + /** The active point, read out by a polite live region (see ChartTooltip). */ + live?: string; + children: ReactNode; +}>; + +/** + * What every chart shares around its plot: the loading and empty states, the + * legend, and the text alternative (a summary sentence the plot points at + * through aria-describedby, and a visually hidden table of every value). + */ +export function ChartFrame({ + slot, + title, + state, + height, + width, + emptySlot, + emptyMessage, + summary, + summaryId, + table, + legend, + className, + live, + children, +}: ChartFrameProps): JSX.Element { + // Runtime geometry reaches the classes through custom properties (styling rule S1). + const sized = cn( + "ui:h-(--chart-height)", + width === undefined ? "ui:w-full" : "ui:w-(--chart-width)", + ); + const outer = cn( + "ui:flex ui:flex-col ui:gap-3", + width === undefined ? "ui:w-full" : "ui:w-(--chart-width)", + className, + ); + // The legend's items come from `series`, not the data, so when the series + // are known before the data, loading and empty draw the legend too, + // invisibly, and the chart keeps one footprint across its states (the card + // does not jump when data arrives, wrapping included). Series built from + // the response are empty until it arrives, so nothing is reserved then. + const legendList = (reserveOnly: boolean) => + legend === undefined || legend.items.length === 0 ? null : ( +
    + {legend.items.map((item) => ( +
  • + + {item.label} +
  • + ))} +
+ ); + if (state === "loading" || state === "empty") { + return ( +
+ {legend?.position === "top" ? legendList(true) : null} + {state === "loading" ? ( +
+ + + +
+ ) : ( +
+ {emptySlot ?? ( +

+ {`${title}: `} + {emptyMessage} +

+ )} +
+ )} + {legend?.position === "bottom" ? legendList(true) : null} +
+ ); + } + return ( +
+ {legend?.position === "top" ? legendList(false) : null} +
{children}
+ {legend?.position === "bottom" ? legendList(false) : null} +

+ {summary} +

+ {/* 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. */} +
+ + + + + {table.head.map((cell, c) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: columns follow series order, and labels may repeat. + + ))} + + + + {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. + + ) : ( + // biome-ignore lint/suspicious/noArrayIndexKey: cells follow series order. + + ), + )} + + ))} + +
{title}
+ {cell} +
+ {cell} + {cell}
+
+
+ ); +} 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 +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Peak + + + + + + + + + + + + + + + +9 Aug + + + + +10 Aug + + + + +11 Aug + + + + + + +0 + + + + +750 + + + + +1.5K + + + + +2.3K + + + + +3K + + + + + +
+
+
    +
  • +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
CategoryPages processedFailed pages
9 Aug101
10 Aug2,981600
11 Aug202
+
+
+``` + +## area + +```html +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +9 Aug + + + + +10 Aug + + + + +11 Aug + + + + + + +0 + + + + +750 + + + + +1.5K + + + + +2.3K + + + + +3K + + + + + +
+
+
    +
  • +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
CategoryFailedPages
9 Aug110
10 Aug6002,981
11 Aug220
+
+
+``` + +## bar + +```html +
+
    +
  • +Failed
  • +
  • +Pages
  • +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +9 Aug + + + + +10 Aug + + + + +11 Aug + + + + + + +0 + + + + +750 + + + + +1.5K + + + + +2.3K + + + + +3K + + + + + +
+
+

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
CategoryFailedPages
9 Aug110
10 Aug6002,981
11 Aug220
+
+
+``` + +## stacked bar + +```html +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +9 Aug + + + + +10 Aug + + + + +11 Aug + + + + + + +0 + + + + +900 + + + + +1.8K + + + + +2.7K + + + + +3.6K + + + + + + + + + +Peak + + + + + + +
+
+
    +
  • +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
CategoryFailedPages
9 Aug110
10 Aug6002,981
11 Aug220
+
+
+``` + +## 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
CategoryPages processed
9 Aug10
10 Aug2,981
11 Aug20
+
+
+``` + +## loading + +```html +
+
+
+Loading Throughput + +
+
+ +
+``` + +## empty + +```html +
+
+

+Throughput: No data for this period

+
+ +
+``` 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. */