loopable / web /src /viz /SeriesChart.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
11 kB
// ---------------------------------------------------------------------------
// 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 `<ChartCard>`. 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<string, number>;
/** 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<string, string>();
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 <p className="pg-empty">{problem}</p>;
if (!axis.length)
return <p className="pg-empty">No periods in this scope yet, so there is nothing to plot.</p>;
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 (
<div className="pg-charts">
<div className="pg-legend">
{byKey.map((s, i) => (
<span key={s.def.y} className="pg-legend-key">
<span className={`pg-legend-swatch pg-ser-${i % SERIES_SLOTS}`} />
{s.def.label}
</span>
))}
</div>
<svg
className="pg-chart-svg"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={spec.title ?? "Chart"}
>
{/* The baseline and the top gridline — two rules, not a grid. A chart
that needs five gridlines to be read is a table. */}
<line className="pg-ax" x1={PAD_L} y1={base} x2={W - PAD_R} y2={base} />
<line className="pg-ax pg-ax-soft" x1={PAD_L} y1={PAD_T} x2={W - PAD_R} y2={PAD_T} />
<text className="pg-ax-lab" x={PAD_L - 6} y={PAD_T + 4} textAnchor="end">
{niceNumber(max)}
</text>
<text className="pg-ax-lab" x={PAD_L - 6} y={base} textAnchor="end">
0
</text>
{axis.map((slot, xi) => {
const x0 = PAD_L + xi * slotW;
const groupW = barW * byKey.length;
const left = x0 + (slotW - groupW) / 2;
return (
<g key={slot.key || `(blank)-${xi}`}>
{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.
<rect
className="pg-slot"
x={x0}
y={PAD_T}
width={slotW}
height={base - PAD_T}
onClick={() => onPick(slot.key)}
>
<title>{`Open ${slot.label}`}</title>
</rect>
) : 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 (
<rect
key={s.def.y}
className={`pg-bar pg-ser-${si % SERIES_SLOTS}`}
x={left + si * barW}
y={base - h}
width={Math.max(1, barW - 1.5)}
height={h}
>
<title>{`${slot.label} · ${s.def.label}: ${niceNumber(v)}`}</title>
</rect>
);
})}
{(() => {
// 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 (
<text
className={`pg-bar-delta ${d > 0 ? "is-up" : d < 0 ? "is-down" : ""}`}
x={x0 + slotW / 2}
y={Math.max(8, base - tallest - 4)}
textAnchor="middle"
>
{deltaLabel(d)}
</text>
);
})()}
{xi % labelEvery === 0 ? (
<text className="pg-ax-lab" x={x0 + slotW / 2} y={base + 14} textAnchor="middle">
{slot.label}
</text>
) : null}
</g>
);
})}
</svg>
{/* 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. */}
<p className="pg-chart-note">
{`${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" : ""}
</p>
</div>
);
}