@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.
- package/ATTRIBUTION.md +59 -0
- package/README.md +191 -0
- package/dist/uir-renderer-web.css +1 -0
- package/dist/uir-renderer-web.js +2213 -0
- package/package.json +63 -0
- package/src/UIResponsePage.jsx +576 -0
- package/src/blocks/containers.jsx +150 -0
- package/src/blocks/content.jsx +292 -0
- package/src/blocks/context.js +27 -0
- package/src/blocks/data.jsx +551 -0
- package/src/blocks/primitives.jsx +290 -0
- package/src/blocks/viz.jsx +616 -0
- package/src/core/actions.js +201 -0
- package/src/core/condition.js +90 -0
- package/src/core/empty.js +44 -0
- package/src/core/format.js +225 -0
- package/src/core/presentation.js +58 -0
- package/src/core/record.js +160 -0
- package/src/core/store.js +357 -0
- package/src/core/value.js +211 -0
- package/src/index.js +22 -0
- package/src/theme/tokens.js +364 -0
- package/src/theme/uir.css +1070 -0
|
@@ -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]} {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
|
+
}
|