// --------------------------------------------------------------------------- // viz / SeriesChart.tsx — EXIT wave 2 (W2-7, contracts Y1 + Y3). // // The Y1 `chart` block's renderer: ONE category axis, N SERIES, hand-rolled SVG. // // WHY THIS EXISTS RATHER THAN ``. S1 measured it and it is worth // restating, because the spec looks like it says otherwise: **the chart engine // has no series dimension.** `ChartSpec.splitBy` is persisted, validated by // `cleanCharts`, offered in the grid's chart editor and covered by four gate // checks — and it never reaches `chartData()`'s bucketing or `DashboardView`'s // renderer. `ChartCard` draws exactly one flat `buckets[]`, so it structurally // cannot draw revenue-vs-last-year. Y1 amendment 5's shape is therefore the // only honest one: a series is one `chartData` call with its own `y` over the // SAME wide rows. That needs no change to `chartData` — which is also what // keeps W2-5's pure-move proof intact, since touching the engine would move // `verify_charts.py`'s verdict. // // ⛔ THE SERVER OWNS THE CHRONOLOGY (Y1 amendment 7). `chartData` sorts a // non-date category axis by VALUE DESCENDING, which would render any trend as a // revenue-ordered sawtooth; and typing the axis `date` is not an escape, since // `monthOf()` buckets to `YYYY-MM` and would collapse twelve weeks into four // months. So the x field stays `text` and the block carries `x_order` — the // re-ordering below is the whole fix, and it touches neither `chartData` nor // its gate. // // Colour comes from CSS classes reading `--lp-*`, never from a literal in this // file: an SVG `fill` written as a hex is exactly as invisible to a palette // change as a hex in a stylesheet. // --------------------------------------------------------------------------- import { useMemo } from "react"; import { chartData } from "./chartData"; import type { Bucket, ChartSpec } from "./chartData"; // The data prep lives next door, pure and React-free, so `verify_ui.py` can run // the two rules that matter (the server's chronology, the field vocabulary) // under bare node instead of inferring them from a screenshot. import { asFields, inServerOrder } from "./seriesData"; import type { WireField } from "./seriesData"; import type { Row } from "./types"; /** The palette slots a series cycles through. CLASS names, not colours — the * `.pg-ser-N` rules in index.css read the tokens. Four is deliberate: past * four series a grouped bar chart is unreadable and the answer is small * multiples, not a fifth hue. */ export const SERIES_SLOTS = 4; const W = 720; const H = 230; const PAD_L = 52; const PAD_R = 10; const PAD_T = 12; const PAD_B = 34; export interface SeriesDef { y: string; label: string; } function niceNumber(v: number): string { if (!Number.isFinite(v)) return "—"; const abs = Math.abs(v); if (abs >= 1e9) return (v / 1e9).toFixed(1) + "B"; if (abs >= 1e6) return (v / 1e6).toFixed(1) + "M"; if (abs >= 1e3) return (v / 1e3).toFixed(1) + "k"; return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(2); } /** The printed delta: signed, 1dp under 10, whole above, and the big-ratio * rule the KPI formatter also applies — a tiny LY base makes "+14,975%" read * as noise, so ≥999 renders as a multiple. (Local, not ../ui/fmt: viz sits * below ui in the layering and must not import up.) */ function deltaLabel(v: number): string { const sign = v > 0 ? "+" : v < 0 ? "−" : ""; const abs = Math.abs(v); if (abs >= 999) return `${sign}${Math.round(abs / 100)}×`; return `${sign}${abs.toFixed(abs < 10 ? 1 : 0)}%`; } export type { WireField } from "./seriesData"; export interface SeriesChartProps { spec: ChartSpec; series: SeriesDef[]; fields: WireField[]; rows: Row[]; xOrder?: string[]; /** SERVER-computed per-bucket delta % (0–100 scale) to print over each * group — the "YoY % on the chart" rule. Absent key = no label (the * server's partial-period rule); this component never derives one. */ deltaByKey?: Map; /** Fired with the x BUCKET KEY when a group is clicked; the caller resolves it * to the row's own drill descriptor. */ onPick?: (xKey: string) => void; /** Whether a pick actually opens anything — a bar must not invite a click it * cannot honour. */ pickable?: boolean; } export function SeriesChart({ spec, series, fields, rows, xOrder, deltaByKey, onPick, pickable, }: SeriesChartProps) { const vizFields = useMemo(() => asFields(fields), [fields]); const fieldByKey = useMemo( () => new Map(vizFields.map((f) => [f.key, f])), [vizFields] ); const computed = useMemo( () => series.map((s) => ({ def: s, data: chartData({ ...spec, y: s.y }, rows, fieldByKey), })), [series, spec, rows, fieldByKey] ); // One x axis for every series. They bucket the SAME rows by the SAME x, so the // key sets agree; the union is belt-and-braces against a series whose measure // is absent for a whole bucket. const axis = useMemo(() => { const seen = new Map(); for (const c of computed) for (const b of c.data.buckets) if (!seen.has(b.key)) seen.set(b.key, b.label); const merged: Bucket[] = [...seen].map(([key, label]) => ({ key, label, value: 0, n: 0 })); return inServerOrder(merged, xOrder); }, [computed, xOrder]); const problem = computed.find((c) => c.data.problem)?.data.problem; if (problem) return

{problem}

; if (!axis.length) return

No periods in this scope yet, so there is nothing to plot.

; const byKey = computed.map((c) => ({ def: c.def, data: c.data, lookup: new Map(c.data.buckets.map((b) => [b.key, b])), })); const max = Math.max( 0, ...byKey.flatMap((s) => s.data.buckets.map((b) => b.value)) ); const scale = max > 0 ? (H - PAD_T - PAD_B) / max : 0; const plotW = W - PAD_L - PAD_R; const slotW = plotW / axis.length; const barW = Math.max(2, (slotW * 0.72) / Math.max(1, byKey.length)); // Enough labels to read, never so many they collide. One every nth slot. const labelEvery = Math.max(1, Math.ceil(axis.length / 12)); const base = H - PAD_B; const rowsSeen = byKey[0]?.data.rows ?? 0; const missing = byKey.reduce((a, s) => a + s.data.missingY, 0); const omitted = byKey[0]?.data.omitted ?? 0; const omittedValue = byKey[0]?.data.omittedValue ?? 0; return (
{byKey.map((s, i) => ( {s.def.label} ))}
{/* The baseline and the top gridline — two rules, not a grid. A chart that needs five gridlines to be read is a table. */} {niceNumber(max)} 0 {axis.map((slot, xi) => { const x0 = PAD_L + xi * slotW; const groupW = barW * byKey.length; const left = x0 + (slotW - groupW) / 2; return ( {pickable && onPick ? ( // The whole slot is the target, not the 6px bar. A click target // narrower than a fingertip is a control that only works for // people who already know it is there. onPick(slot.key)} > {`Open ${slot.label}`} ) : null} {byKey.map((s, si) => { const b = s.lookup.get(slot.key); const v = b?.value ?? 0; const h = Math.max(v > 0 ? 1 : 0, v * scale); return ( {`${slot.label} · ${s.def.label}: ${niceNumber(v)}`} ); })} {(() => { // The server-sent delta over the group ("YoY % on the chart"). // Sits above the group's tallest bar; a slot the server sent // no value for (the partial period) stays honestly unlabelled. const d = deltaByKey?.get(slot.key); if (d == null) return null; const tallest = Math.max( 0, ...byKey.map((s) => { const v = s.lookup.get(slot.key)?.value ?? 0; return Math.max(v > 0 ? 1 : 0, v * scale); }) ); return ( 0 ? "is-up" : d < 0 ? "is-down" : ""}`} x={x0 + slotW / 2} y={Math.max(8, base - tallest - 4)} textAnchor="middle" > {deltaLabel(d)} ); })()} {xi % labelEvery === 0 ? ( {slot.label} ) : null} ); })} {/* The honesty footer — rule 8b, rendered rather than merely computed. Every claim here comes from `chartData`'s return value, so it cannot disagree with the picture above it. */}

{`${rowsSeen.toLocaleString()} period${rowsSeen === 1 ? "" : "s"} plotted`} {omitted > 0 ? ` · ${omitted} more not drawn, worth ${niceNumber(omittedValue)}` : ""} {missing > 0 ? ` · ${missing} value${missing === 1 ? "" : "s"} the measure could not use` : ""} {pickable ? " · click a period to decompose it" : ""}

); }