@uiresponse/renderer-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,616 @@
1
+ /**
2
+ * The visual leaves: `chart`, `metric`, `progress`, `badge`.
3
+ *
4
+ * `chart` is where we lean hardest on OpenUI's MIT component layer: their Recharts wrappers carry
5
+ * tooltips, legends, responsive sizing, scroll behaviour and axis ticks that would be months of work
6
+ * to rebuild, and they already look the way we want. So we do not restyle them and we do not hand
7
+ * them a palette — we format the DATA before it reaches them, because a `format` per binding, a
8
+ * currency code on a column and an `empty` policy are semantics their props cannot carry.
9
+ *
10
+ * Their pixels, our meaning (DECISIONS X4). The one exception is the donut, which we draw ourselves;
11
+ * the reason is written above `Donut`.
12
+ */
13
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
14
+ import { AreaChart, BarChart, LineChart, MiniLineChart } from "@openuidev/react-ui";
15
+ import { useUIResponse, scopeFor } from "./context.js";
16
+ import { Value, Absent, BlockError } from "./primitives.jsx";
17
+ import { formatValue } from "../core/format.js";
18
+ import { resolveValue, resolveBinding, boundColumn, isBinding } from "../core/value.js";
19
+ import { isEmptyValue } from "../core/empty.js";
20
+ import { runWithConfirm } from "../core/actions.js";
21
+
22
+ // ───────────────────────────────────────────────────────────────────────────────
23
+ // metric — the one number
24
+ // ───────────────────────────────────────────────────────────────────────────────
25
+ /**
26
+ * DELTA — `metric.delta` (DECISIONS MET4), drawn as the design's own status-tone chip.
27
+ *
28
+ * That treatment remains a STAND-IN, and it is worth saying plainly: `UIR-element-system.dc.html`
29
+ * contains no arrows, no triangles and no signed percentages, so there is no delta in the design to
30
+ * copy. The chip is the nearest thing the design does define. Before this reaches a user, the design
31
+ * file should say what a delta looks like — a signed number with a colour means something different
32
+ * in every product. Recorded in `design/NOTES-designsync.md` D3.
33
+ *
34
+ * Three things here are the SCHEMA's, not this renderer's, and may not drift (metric.schema.json):
35
+ * · direction is the sign of the resolved value; the page does no arithmetic (B9)
36
+ * · no `direction_good` means a NEUTRAL tone — a page that never said which way is good has not
37
+ * earned a green chip, and guessing "up is good" paints a rising cost as a win
38
+ * · a delta that resolves to null, or to something that is not a number, DRAWS NOTHING. Not a
39
+ * zero, not an empty chip. A tile that understates is honest; one that invents a flat trend is
40
+ * not, and that honesty is exactly what makes `delta` safe to ignore on an older renderer.
41
+ */
42
+ const DELTA_TONE = {
43
+ up: { up: "positive", down: "negative", flat: "neutral" },
44
+ down: { up: "negative", down: "positive", flat: "neutral" },
45
+ };
46
+ const DELTA_GLYPH = { up: "▲", down: "▼", flat: "–" };
47
+ const DELTA_WORD = { up: "up", down: "down", flat: "unchanged" };
48
+
49
+ function MetricDelta({ delta, scope, now }) {
50
+ if (!delta) return null;
51
+
52
+ let raw;
53
+ try {
54
+ raw = resolveValue(delta.value, scope);
55
+ } catch {
56
+ return null; // an unresolvable delta is an absent delta, never a zero
57
+ }
58
+ if (isEmptyValue(raw) || !Number.isFinite(Number(raw))) return null;
59
+
60
+ const n = Number(raw);
61
+ const direction = n > 0 ? "up" : n < 0 ? "down" : "flat";
62
+ const tone = DELTA_TONE[delta.direction_good]?.[direction] ?? "neutral";
63
+
64
+ const bind = isBinding(delta.value) ? delta.value.$bind : null;
65
+ // The glyph carries the sign, once. Letting `format.sign` carry it too reads as "+▲80%".
66
+ const text = formatValue(Math.abs(n), { ...(bind?.format ?? {}), sign: "never" }, { column: bind ? boundColumn(bind, scope) : null, now });
67
+ const spoken = `${DELTA_WORD[direction]} ${text}${delta.vs ? ` ${delta.vs}` : ""}`;
68
+
69
+ return (
70
+ <span className="uir-metric__delta" data-tone={tone} data-direction={direction} aria-label={spoken}>
71
+ <em aria-hidden="true">{DELTA_GLYPH[direction]}&thinsp;{text}</em>
72
+ {delta.vs && <span aria-hidden="true">{delta.vs}</span>}
73
+ </span>
74
+ );
75
+ }
76
+
77
+ /** Anyone who asked their system to stop moving things gets the number, immediately. */
78
+ const prefersReducedMotion = () =>
79
+ typeof window !== "undefined" && typeof window.matchMedia === "function"
80
+ ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
81
+ : false;
82
+
83
+ const COUNT_UP_MS = 750; // the chat prototype's `startCount` duration (`uir-chat.dc.html:411-421`), exactly.
84
+
85
+ /**
86
+ * Where a metric's LAST SHOWN value survives a close (behavior parity F4).
87
+ *
88
+ * `sessionStorage`, deliberately: the point is that reopening a saved view climbs from the number
89
+ * you last saw to the number that is now true — "the world moved while you were away", said in
90
+ * motion. That story only makes sense within a sitting, and a sitting is what sessionStorage is.
91
+ * Persisting it would be a durable-data commitment for a decoration; localStorage says no here.
92
+ */
93
+ const seenKey = (pageId, blockId) => (pageId && blockId ? `uir-count:${pageId}:${blockId}` : null);
94
+ function lastSeen(key) {
95
+ if (!key || typeof window === "undefined") return null;
96
+ try {
97
+ const raw = window.sessionStorage.getItem(key);
98
+ const n = raw === null ? NaN : Number(raw);
99
+ return Number.isFinite(n) ? n : null;
100
+ } catch {
101
+ return null; // storage denied is a browser mood, not an error
102
+ }
103
+ }
104
+ function rememberSeen(key, value) {
105
+ if (!key || typeof window === "undefined") return;
106
+ try {
107
+ window.sessionStorage.setItem(key, String(value));
108
+ } catch {
109
+ /* full or denied storage costs the seed, nothing else */
110
+ }
111
+ }
112
+
113
+ /**
114
+ * COUNT-UP — the number climbs to itself the first time its data lands.
115
+ *
116
+ * ADOPTED BY THE DESIGN. This began as a renderer invention (NOTES-designsync.md D7); the chat
117
+ * prototype now specifies it — 750ms, cubic ease-out, starting when the tile LANDS — so the
118
+ * duration above is extracted, not chosen.
119
+ *
120
+ * Three rules, and each of them is about not lying:
121
+ * · It runs ONCE, on the first non-empty numeric value. A metric that re-resolves after a filter
122
+ * change SNAPS. Re-animating would say "new information arrived" when the user caused it, and it
123
+ * would turn a filter into a slot machine.
124
+ * · It never runs on a non-number. A date counting up from 1970 is not charming.
125
+ * · `prefers-reduced-motion` skips it entirely — the number is information, the climb is not.
126
+ *
127
+ * And one memory: a REOPENED view climbs from the value it last showed, not from 0 (`seedKey`).
128
+ * From-zero says "here is a number"; from-last-seen says "here is what changed" — the second is
129
+ * the true one, and when nothing changed, nothing moves.
130
+ *
131
+ * The eased curve is `1 - (1 - t)³`, not the design's `cubic-bezier(0.2, 0.7, 0.3, 1)`: a bezier is a
132
+ * CSS timing function, and there is no CSS property here to time. The shapes are close and the
133
+ * difference is unobservable on a 750ms count. Said plainly rather than left for someone to discover.
134
+ */
135
+ function useCountUp(target, active, seedKey = null) {
136
+ const [shown, setShown] = useState(target);
137
+ const animated = useRef(false);
138
+
139
+ useEffect(() => {
140
+ if (!active || animated.current || prefersReducedMotion()) {
141
+ setShown(target);
142
+ if (active) {
143
+ animated.current = true;
144
+ rememberSeen(seedKey, target);
145
+ }
146
+ return undefined;
147
+ }
148
+ animated.current = true;
149
+ const from = lastSeen(seedKey) ?? 0;
150
+ rememberSeen(seedKey, target);
151
+ if (from === target) {
152
+ setShown(target); // the world did not move; neither does the number
153
+ return undefined;
154
+ }
155
+
156
+ let raf = 0;
157
+ const t0 = performance.now();
158
+ const tick = (t) => {
159
+ const p = Math.min(1, (t - t0) / COUNT_UP_MS);
160
+ setShown(from + (target - from) * (1 - (1 - p) ** 3));
161
+ if (p < 1) raf = requestAnimationFrame(tick);
162
+ else setShown(target); // land on the exact value, never on 999,999.4
163
+ };
164
+ raf = requestAnimationFrame(tick);
165
+ return () => cancelAnimationFrame(raf);
166
+ }, [target, active, seedKey]);
167
+
168
+ return shown;
169
+ }
170
+
171
+ /** `date` and `relative_time` are numbers a renderer must never treat as quantities. */
172
+ const NON_COUNTABLE = new Set(["date", "relative_time"]);
173
+
174
+ export function MetricBlock({ block, emphasis }) {
175
+ const uir = useUIResponse();
176
+ const scope = scopeFor(uir);
177
+ const column = isBinding(block.value) ? boundColumn(block.value.$bind, scope) : null;
178
+ const format = isBinding(block.value) ? block.value.$bind.format : undefined;
179
+ const empty = isBinding(block.value) ? block.value.$bind.empty : undefined;
180
+
181
+ // `row: "only"` over ≠1 row throws, by design (B6). It must not throw *between* hooks: a render
182
+ // that runs three hooks and a render that runs none is a hook-order crash, and it would take the
183
+ // whole page down to report one tile's assertion. Resolve, hold the error, keep the hooks.
184
+ let value = null;
185
+ let failed = null;
186
+ try {
187
+ value = resolveValue(block.value, scope);
188
+ } catch (e) {
189
+ failed = e;
190
+ }
191
+
192
+ const countable =
193
+ typeof value === "number" && Number.isFinite(value) &&
194
+ !NON_COUNTABLE.has(format?.as) && column?.type !== "date";
195
+ const shownValue = useCountUp(countable ? value : 0, countable, countable ? seenKey(uir.pageId, block.id) : null);
196
+
197
+ if (failed) return <BlockError message={failed.message} />;
198
+
199
+ const body = (
200
+ <div className="uir-metric" data-emphasis={emphasis}>
201
+ <span className="uir-eyebrow uir-metric__label">{block.label}</span>
202
+ <span className="uir-metric__value">
203
+ <Value value={countable ? shownValue : value} format={format} empty={empty} column={column} now={uir.now} />
204
+ {/* A unit is suppressed when the format already carries one — a voice renderer would
205
+ otherwise say "pounds" twice (VERSIONING §9, `metric.unit.redundant`). */}
206
+ {block.unit && !isEmptyValue(value) && !UNIT_BEARING.has(format?.as) && (
207
+ <span className="uir-metric__unit">{block.unit}</span>
208
+ )}
209
+ </span>
210
+ <MetricDelta delta={block.delta} scope={scope} now={uir.now} />
211
+ </div>
212
+ );
213
+
214
+ if (!block.on_click) return body;
215
+ return (
216
+ <button type="button" className="uir-metric--clickable" onClick={() => runWithConfirm(block.on_click, { ...uir.actionCtx, scope })}>
217
+ {body}
218
+ </button>
219
+ );
220
+ }
221
+ const UNIT_BEARING = new Set(["currency", "percent", "bytes", "duration"]);
222
+
223
+ // ───────────────────────────────────────────────────────────────────────────────
224
+ // progress — how much of a bounded thing is done
225
+ // ───────────────────────────────────────────────────────────────────────────────
226
+ export function ProgressBlock({ block }) {
227
+ const uir = useUIResponse();
228
+ const scope = scopeFor(uir);
229
+ const value = resolveValue(block.value, scope);
230
+ const total = resolveValue(block.total, scope);
231
+ const column = isBinding(block.value) ? boundColumn(block.value.$bind, scope) : null;
232
+ const format = isBinding(block.value) ? block.value.$bind.format : undefined;
233
+
234
+ const known = !isEmptyValue(value) && !isEmptyValue(total) && Number(total) > 0;
235
+ const pct = known ? Math.max(0, Math.min(100, (Number(value) / Number(total)) * 100)) : 0;
236
+
237
+ return (
238
+ <div className="uir-progress">
239
+ <div className="uir-progress__head">
240
+ {block.label && <span className="uir-eyebrow">{block.label}</span>}
241
+ <span className="uir-progress__value">
242
+ {known ? (
243
+ <>
244
+ {formatValue(value, format, { column, now: uir.now })}
245
+ <span className="uir-progress__ratio"> / {formatValue(total, format, { column, now: uir.now })}</span>
246
+ </>
247
+ ) : (
248
+ // Progress against an unknown total is not 0%. It is unknown, and it says so.
249
+ <Absent policy={isBinding(block.value) ? block.value.$bind.empty : undefined} />
250
+ )}
251
+ </span>
252
+ </div>
253
+ <div
254
+ className="uir-progress__track"
255
+ role="progressbar"
256
+ aria-valuenow={known ? Math.round(pct) : undefined}
257
+ aria-valuemin={0}
258
+ aria-valuemax={100}
259
+ aria-label={block.label ?? "Progress"}
260
+ >
261
+ <div className="uir-progress__fill" style={{ width: `${pct}%` }} />
262
+ </div>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ // ───────────────────────────────────────────────────────────────────────────────
268
+ // badge — a status, stated
269
+ // ───────────────────────────────────────────────────────────────────────────────
270
+ export function BadgeBlock({ block }) {
271
+ const uir = useUIResponse();
272
+ const scope = scopeFor(uir, uir.row);
273
+ const value = resolveValue(block.value, scope);
274
+ // `tone_map` is data-driven tone; `tone` is static. They are mutually exclusive in the schema.
275
+ const mapped = block.tone_map?.find((e) => e.value === value)?.tone;
276
+ const tone = block.tone ?? mapped ?? "neutral";
277
+
278
+ if (isEmptyValue(value)) return <Absent policy={isBinding(block.value) ? block.value.$bind.empty : undefined} />;
279
+
280
+ return (
281
+ <span className="uir-badge" data-tone={tone}>
282
+ {block.label && <span className="uir-badge__label">{block.label}</span>}
283
+ {String(value)}
284
+ </span>
285
+ );
286
+ }
287
+
288
+ // ───────────────────────────────────────────────────────────────────────────────
289
+ // stage_checklist — a position in an ordered process
290
+ // ───────────────────────────────────────────────────────────────────────────────
291
+ /**
292
+ * The block asserts ONE thing: where a record has got to in a fixed, ordered process. The stages are
293
+ * authored, so their order is the array's order and this renderer never reorders them.
294
+ *
295
+ * "Where you are" — the first not-done stage — is DERIVED here and is not in the document. It is a
296
+ * drawing of the data, not a claim about it: a voice renderer that reads "Approved: pending" has
297
+ * already said everything the marker says. Deriving it in the renderer is what keeps `current` out
298
+ * of the schema, where it would be a second source of truth that could disagree with `done`.
299
+ *
300
+ * A stage whose `done` cannot be resolved is drawn as PENDING, not as done. An unknown state is not
301
+ * an achievement.
302
+ */
303
+ export function StageChecklistBlock({ block }) {
304
+ const uir = useUIResponse();
305
+ const scope = scopeFor(uir, uir.row);
306
+
307
+ const stages = (block.stages ?? []).map((stage) => {
308
+ let done = false;
309
+ try {
310
+ done = resolveValue(stage.done, scope) === true;
311
+ } catch {
312
+ done = false;
313
+ }
314
+ return { label: stage.label, done };
315
+ });
316
+
317
+ const currentIndex = stages.findIndex((s) => !s.done);
318
+
319
+ return (
320
+ <ol className="uir-stages" aria-label={block.title ?? "Stages"}>
321
+ {stages.map((stage, i) => (
322
+ <li
323
+ className="uir-stages__stage"
324
+ key={i}
325
+ data-done={stage.done || undefined}
326
+ data-current={i === currentIndex || undefined}
327
+ >
328
+ {/* The tick is decoration over text that already says it. A screen reader hears the state
329
+ from the visually-hidden word, not from a glyph it would have to name. */}
330
+ <span className="uir-stages__mark" aria-hidden="true">{stage.done ? "✓" : ""}</span>
331
+ <span className="uir-stages__label">{stage.label}</span>
332
+ <span className="uir-stages__state">{stage.done ? "done" : "pending"}</span>
333
+ </li>
334
+ ))}
335
+ </ol>
336
+ );
337
+ }
338
+
339
+ // ───────────────────────────────────────────────────────────────────────────────
340
+ // chart — ONE block, kinds are props
341
+ // ───────────────────────────────────────────────────────────────────────────────
342
+ const CHART_HEIGHT = 300;
343
+
344
+ /**
345
+ * Their chart containers measure their own width ONCE, on mount, and never again. Inside a CSS grid
346
+ * whose track resolves after first layout — which is what the section rail is — they measure zero
347
+ * and draw a 36px chart in a 960px frame. Nothing errors; the chart is simply not there.
348
+ *
349
+ * So we own the measurement, and use it only as a GATE: nothing renders until the frame has a width,
350
+ * at which point their own observer measures correctly. We do not pass `width` — that prop is
351
+ * applied to the outer container while the plot still subtracts the sticky y-axis column, and the
352
+ * bars drift out from under their labels by exactly the axis's width.
353
+ *
354
+ * This is the tax on wrapping someone else's component. It is much cheaper than a fork.
355
+ */
356
+ function useMeasuredWidth() {
357
+ const ref = useRef(null);
358
+ const [width, setWidth] = useState(0);
359
+
360
+ useLayoutEffect(() => {
361
+ const el = ref.current;
362
+ if (!el) return undefined;
363
+ const measure = () => setWidth(el.clientWidth);
364
+ measure();
365
+ const ro = new ResizeObserver(measure);
366
+ ro.observe(el);
367
+ return () => ro.disconnect();
368
+ }, []);
369
+
370
+ return [ref, width];
371
+ }
372
+
373
+ /**
374
+ * The donut is OURS, not theirs — the one chart we draw ourselves.
375
+ *
376
+ * Their `PieChart` grows its ring to fill whatever box it is handed and stacks its legend beneath.
377
+ * On a tile that produced a bloated hoop with one dominant slice and the small categories bunched
378
+ * into slivers you could not read or name. A pie's whole job is comparing parts to a whole, so the
379
+ * category, its value and its share have to sit next to the arc that represents them.
380
+ *
381
+ * Sixty lines of trigonometry buys: a ring sized to the tile rather than to the box, a legend that
382
+ * reads `label · value · share` beside the arcs, slices ordered largest-first so the eye can compare
383
+ * them, and a `—` for a share of nothing rather than `0.0%`. Their area/line/bar charts stay theirs;
384
+ * this one was cheaper to own than to fight.
385
+ */
386
+ const TAU = Math.PI * 2;
387
+ const polar = (cx, cy, r, a) => [cx + r * Math.cos(a - Math.PI / 2), cy + r * Math.sin(a - Math.PI / 2)];
388
+
389
+ function arcPath(cx, cy, rOuter, rInner, start, end) {
390
+ // A single slice that is the entire circle cannot be drawn as an arc: its start and end points
391
+ // coincide and the path collapses. Two half-arcs draw the same ring.
392
+ if (end - start >= TAU - 1e-6) {
393
+ return [
394
+ `M ${cx - rOuter} ${cy}`, `A ${rOuter} ${rOuter} 0 1 1 ${cx + rOuter} ${cy}`, `A ${rOuter} ${rOuter} 0 1 1 ${cx - rOuter} ${cy}`,
395
+ `M ${cx - rInner} ${cy}`, `A ${rInner} ${rInner} 0 1 0 ${cx + rInner} ${cy}`, `A ${rInner} ${rInner} 0 1 0 ${cx - rInner} ${cy}`, "Z",
396
+ ].join(" ");
397
+ }
398
+ const large = end - start > Math.PI ? 1 : 0;
399
+ const [x1, y1] = polar(cx, cy, rOuter, start);
400
+ const [x2, y2] = polar(cx, cy, rOuter, end);
401
+ const [x3, y3] = polar(cx, cy, rInner, end);
402
+ const [x4, y4] = polar(cx, cy, rInner, start);
403
+ return `M ${x1} ${y1} A ${rOuter} ${rOuter} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${rInner} ${rInner} 0 ${large} 0 ${x4} ${y4} Z`;
404
+ }
405
+
406
+ function Donut({ slices, palette, format, column, now }) {
407
+ const total = slices.reduce((sum, s) => sum + s.value, 0);
408
+ const size = 148, cx = size / 2, cy = size / 2, rOuter = 68, rInner = 44;
409
+
410
+ let cursor = 0;
411
+ const arcs = slices.map((s, i) => {
412
+ const sweep = total > 0 ? (s.value / total) * TAU : 0;
413
+ const d = arcPath(cx, cy, rOuter, rInner, cursor, cursor + sweep);
414
+ cursor += sweep;
415
+ return { d, color: palette[i % palette.length], ...s };
416
+ });
417
+
418
+ const share = (v) => (total > 0 ? `${((v / total) * 100).toFixed(total > 0 && v / total < 0.1 ? 1 : 0)}%` : null);
419
+
420
+ return (
421
+ <div className="uir-pie">
422
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label={`${slices.length} categories`}>
423
+ {arcs.map((a, i) => (
424
+ <path key={i} d={a.d} fill={a.color} stroke="var(--uir-color-sheet)" strokeWidth="1.5">
425
+ <title>{`${a.label}: ${a.value}`}</title>
426
+ </path>
427
+ ))}
428
+ <text x={cx} y={cy - 3} textAnchor="middle" fill="var(--uir-color-ink)"
429
+ style={{ font: `600 20px var(--uir-font-body)`, fontVariantNumeric: "tabular-nums" }}>
430
+ {formatValue(total, format, { column, now })}
431
+ </text>
432
+ <text x={cx} y={cy + 14} textAnchor="middle" fill="var(--uir-color-faint)"
433
+ style={{ font: `400 10px var(--uir-font-body)` }}>total</text>
434
+ </svg>
435
+
436
+ <div className="uir-pie__legend">
437
+ {arcs.map((a, i) => (
438
+ <div className="uir-pie__row" key={i}>
439
+ <span className="uir-pie__swatch" style={{ background: a.color }} />
440
+ <span className="uir-pie__label" title={a.label}>{a.label}</span>
441
+ <span className="uir-pie__value">{formatValue(a.value, format, { column, now })}</span>
442
+ {/* A share of nothing is not 0%. It is unknown, and the block says so. */}
443
+ <span className="uir-pie__pct">{share(a.value) ?? <Absent />}</span>
444
+ </div>
445
+ ))}
446
+ </div>
447
+ </div>
448
+ );
449
+ }
450
+
451
+ /**
452
+ * The multi-series legend (`02 · Chart`, "Plays vs downloads"): swatch and label, in the order the
453
+ * series are drawn, so the colour a reader sees on the plot is the colour they can name.
454
+ *
455
+ * `y` is always series 0 — that is CH5's whole design — so its swatch is the palette's first entry
456
+ * and `series[]` continues from there. A single-series chart draws no legend: one entry would only
457
+ * repeat the tile's own label.
458
+ */
459
+ function ChartLegend({ names, values, palette }) {
460
+ if (names.length < 2) return null;
461
+ return (
462
+ <div className="uir-chart__legend">
463
+ {names.map((name, i) => (
464
+ <span className="uir-chart__legend-item" key={i}>
465
+ <span className="uir-chart__swatch" style={{ background: palette[i % palette.length] }} />
466
+ <span className="uir-chart__legend-label">{name}</span>
467
+ <span className="uir-chart__legend-value">{values[i]}</span>
468
+ </span>
469
+ ))}
470
+ </div>
471
+ );
472
+ }
473
+
474
+ export function ChartBlock({ block }) {
475
+ const uir = useUIResponse();
476
+ // `scopeFor` builds a fresh object, so memoising against it would memoise nothing: `data` would
477
+ // get a new identity every render, and Recharts restarts its enter animation whenever `data`
478
+ // changes identity. The area never finished drawing — it was permanently at frame zero.
479
+ // `uir` is itself memoised by <UIResponsePage>, so this is stable until the data or params actually move.
480
+ const scope = useMemo(() => scopeFor(uir), [uir]);
481
+
482
+ /**
483
+ * MULTI-SERIES (CH5). Their charts derive one series per non-category key of each row, so a chart
484
+ * of N quantities is N numeric keys. `y` is series 0 and `series[]` follows: a renderer that had
485
+ * never heard of `series[]` would draw `y` alone, which is the one true line CH5 chose this shape
486
+ * to preserve.
487
+ *
488
+ * Keys are the series' NAMES, so they must be distinct — two series bound to columns with the same
489
+ * label would silently collapse into one line. We suffix a collision rather than drop a series.
490
+ */
491
+ const { data, xKey, yKey, yValues, names, legendValues } = useMemo(() => {
492
+ const yBind = block.y.$bind;
493
+ const yCol = boundColumn(yBind, scope);
494
+ const ys = resolveBinding(yBind, scope);
495
+ const primary = yCol?.label ?? yBind.field;
496
+
497
+ if (!block.x) return { data: [], xKey: null, yKey: primary, yValues: ys.map(Number), names: [primary], legendValues: [] };
498
+
499
+ const xBind = block.x.$bind;
500
+ const xCol = boundColumn(xBind, scope);
501
+ const xs = resolveBinding(xBind, scope);
502
+ const xKeyName = xCol?.label ?? xBind.field;
503
+
504
+ // `label` is REQUIRED on every `series[]` entry precisely so this is never a guess.
505
+ const extra = (block.series ?? []).filter((s) => s?.y?.$bind).map((s) => ({
506
+ name: s.label,
507
+ values: resolveBinding(s.y.$bind, scope),
508
+ bind: s.y.$bind,
509
+ column: boundColumn(s.y.$bind, scope),
510
+ }));
511
+
512
+ const taken = new Set([xKeyName, primary]);
513
+ for (const s of extra) {
514
+ let name = s.name;
515
+ for (let n = 2; taken.has(name); n++) name = `${s.name} (${n})`;
516
+ taken.add(name);
517
+ s.name = name;
518
+ }
519
+
520
+ // The x axis is FORMATTED before it reaches Recharts: a date column drawn as "Jul 2026" is the
521
+ // page's `format.date_style` at work, and their axis has no idea OBL has one.
522
+ const rows = xs.map((x, i) => {
523
+ const row = {
524
+ [xKeyName]: formatValue(x, xBind.format, { column: xCol, now: uir.now }),
525
+ [primary]: Number(ys[i]),
526
+ };
527
+ for (const s of extra) row[s.name] = Number(s.values[i]);
528
+ return row;
529
+ });
530
+
531
+ const latest = (values) => [...values].reverse().find((value) => value !== null && value !== undefined);
532
+ const values = [
533
+ formatValue(latest(ys), yBind.format, { column: yCol, now: uir.now }),
534
+ ...extra.map((series) => formatValue(latest(series.values), series.bind.format, { column: series.column, now: uir.now })),
535
+ ];
536
+ return { data: rows, xKey: xKeyName, yKey: primary, yValues: ys.map(Number), names: [primary, ...extra.map((s) => s.name)], legendValues: values };
537
+ }, [block, scope, uir.now]);
538
+
539
+ /**
540
+ * A series may declare its own `kind`, and the observed dashboard chart genuinely mixes three lines
541
+ * with one bar. Their chart components draw every series in ONE shape — a `LineChart` has no bars.
542
+ *
543
+ * So we draw them all in the block's `kind` and SAY SO. Silently flattening a bar into a line is
544
+ * how a chart becomes a lie (VERSIONING §4 rule 3); refusing to draw it at all would be worse. The
545
+ * notice fires once, on mount, not on every render.
546
+ */
547
+ const mixed = (block.series ?? []).filter((s) => s.kind && s.kind !== block.kind).map((s) => s.label);
548
+ const notify = uir.actionCtx?.onNotice;
549
+ useEffect(() => {
550
+ if (!mixed.length) return;
551
+ notify?.(`This renderer draws every series in one shape. ${mixed.length === 1 ? `"${mixed[0]}" is` : `${mixed.length} series are`} drawn as ${block.kind} rather than the shape the page asked for.`);
552
+ // eslint-disable-next-line react-hooks/exhaustive-deps
553
+ }, [block.id, mixed.length, notify]);
554
+
555
+ const palette = uir.theme.chart;
556
+ const [frameRef, width] = useMeasuredWidth();
557
+
558
+ // A pie's slices are (category, value) pairs. Largest first: a pie exists to compare parts, and
559
+ // an unsorted ring makes the reader do the sorting.
560
+ const slices = useMemo(() => {
561
+ if (block.kind !== "pie" || !block.x) return [];
562
+ return data
563
+ .map((row) => ({ label: String(row[xKey]), value: Number(row[yKey]) }))
564
+ .filter((s) => Number.isFinite(s.value) && s.value > 0)
565
+ .sort((a, b) => b.value - a.value);
566
+ }, [block.kind, block.x, data, xKey, yKey]);
567
+
568
+ if (block.kind === "sparkline") {
569
+ return <MiniLineChart data={yValues} size="100%" />;
570
+ }
571
+
572
+ if (!data.length) return null;
573
+
574
+ if (block.kind === "pie") {
575
+ const yCol = boundColumn(block.y.$bind, scope);
576
+ return <Donut slices={slices} palette={uir.theme.pie} format={block.y.$bind.format} column={yCol} now={uir.now} />;
577
+ }
578
+
579
+ const inner = Math.max(0, width);
580
+ // We pass the measured frame width as `width`, and it is load-bearing for AXIS ALIGNMENT, not for
581
+ // sizing. Their charts give each category a fixed band (`ELEMENT_SPACING`, ~72px) and a "natural"
582
+ // content width of `categories × band`. When the frame is WIDER than that — three or eight bars in
583
+ // a 1000px tile — the plot stretches the bars to fill (its inner track is `minWidth:100%`) while
584
+ // the x-axis is centre-padded to the natural width instead. The result is the trial's defect: bars
585
+ // spread across the frame, labels bunched in the middle under the wrong bars. Handing them an
586
+ // explicit `width` makes the two agree — the chart renders at its natural width, centred, every
587
+ // label back beneath its bar. Verified in the preview harness for 3 and 8 categories, y-axis intact.
588
+ //
589
+ // An earlier note here claimed the OPPOSITE: that `width` drifts bars from labels by the axis's
590
+ // width. That was the behaviour BEFORE we gated rendering on a measured frame (`inner > 0`); a
591
+ // width handed to a not-yet-laid-out container was stale. With the gate, `width` is the fix, not
592
+ // the wound — measured before, corrected here.
593
+ const common = { data, categoryKey: xKey, height: CHART_HEIGHT, legend: false, isAnimationActive: true, width: inner };
594
+
595
+ return (
596
+ <div className="uir-chart-frame">
597
+ <ChartLegend names={names} values={legendValues} palette={palette} />
598
+ <div className="uir-chart-frame__inner" ref={frameRef}>
599
+ {/* Draw nothing until we have measured. A chart drawn at zero width and then corrected
600
+ flashes; a chart that appears once, correct, does not. */}
601
+ {inner === 0 ? (
602
+ <div style={{ height: CHART_HEIGHT }} />
603
+ ) : (
604
+ <>
605
+ {/* No `customPalette`. Their charts already look right; handing them our colours was
606
+ how they stopped looking like theirs. The one chart we colour is the donut below,
607
+ because we draw it. */}
608
+ {block.kind === "area" && <AreaChart {...common} grid showYAxis />}
609
+ {block.kind === "line" && <LineChart {...common} grid showYAxis />}
610
+ {block.kind === "bar" && <BarChart {...common} grid showYAxis radius={2} />}
611
+ </>
612
+ )}
613
+ </div>
614
+ </div>
615
+ );
616
+ }