@tangle-network/charts 0.4.0 → 0.7.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/README.md CHANGED
@@ -24,6 +24,26 @@ Five figures cover the decisions a benchmark reader makes:
24
24
  | `taskMatrix` | Where does each setup succeed and fail? |
25
25
  | `breakdown` | Why do attempts fail? |
26
26
 
27
+ Operational timings also have three static figures: `heatmap` for complete recorded matrices with producer supplied pass, breach and no-data states; `latencyRange` for p50 dots, p95 bars, a supplied SLO and a minimum observation count; and `phaseWaterfall` for ordered, disjoint phase durations with unaccounted wall time shown explicitly. They return the same `Figure | Refusal` contract.
28
+
29
+ ```ts
30
+ import { heatmap, latencyRange, phaseWaterfall } from "@tangle-network/charts";
31
+
32
+ heatmap(paths, harnesses.map(h => ({
33
+ id: h.id, label: h.label,
34
+ cells: paths.map(p => ({ status: h[p.id].status === "blocked" ? "no-data" : h[p.id].status,
35
+ value: h[p.id].p95Ms === null ? null : h[p.id].p95Ms / 1000 })),
36
+ })), { measure: "p95 latency", unit: "s", statusRule: "5 s SLO" });
37
+
38
+ latencyRange([{ id: "pi", label: "pi", p50Ms: 3572, p95Ms: 4102, n: 9 }],
39
+ { measure: "cold create", sloMs: 5000, minN: 5 });
40
+
41
+ phaseWaterfall([{ id: "route", label: "Route", ms: 5 }, { id: "restore", label: "Restore", ms: 9500 }],
42
+ { measure: "create", totalMs: 30398 });
43
+ ```
44
+
45
+ The producer maps its own blocked or absent states to `no-data`; Charts never assigns a status from a value. `latencyRange` refuses any row below `minN`; when the producer omits those rows, pass their reasons in `excluded` so the note names them. `phaseWaterfall` refuses phases whose sum exceeds the reported total because overlapping spans cannot be drawn as a cumulative waterfall. Its input order is retained, including zero-duration phases. The producer marks known unaccounted leaves with `kind: "unaccounted"`; Charts also marks any difference between their sum and `totalMs`.
46
+
27
47
  The library draws and never decides.
28
48
  Ranks, intervals, frontier membership, estimate labels and pass counts come from the producer: a board record, a VerticalBench report or an agent-eval lens.
29
49
  A figure function returns a `Refusal` in place of a figure it cannot draw honestly, for example a bar over too few units, an interval that names no method, or a frontier the producer did not compute.
@@ -74,13 +94,16 @@ Identity is carried by direct labels, never by hue.
74
94
  ## Develop
75
95
 
76
96
  ```sh
77
- pnpm --filter @tangle-network/charts gen:css # after editing src/figure.css or brand tokens.css
78
- pnpm --filter @tangle-network/charts build # fails when src/css.generated.ts is stale
97
+ npm ci
98
+ npm run gen:css # after editing src/figure.css or Brand tokens
99
+ npm run typecheck
100
+ npm test
101
+ npm run build # fails when src/css.generated.ts is stale
79
102
  ```
80
103
 
81
104
  ## Release
82
105
 
83
- Versions publish from the brand release workflow through npm trusted publishing, like `@tangle-network/brand` and `@tangle-network/ui`.
106
+ Versions publish from this repository's release workflow through npm trusted publishing. Merge the verified change, then push a `vX.Y.Z` tag matching `package.json`; the workflow runs frozen install, typecheck, tests, build, and `npm publish --provenance`.
84
107
 
85
108
  ### Chart-first vertical rates
86
109
 
@@ -135,6 +158,42 @@ Optional annotations carry independent recorded facts, such as all checks passin
135
158
  Hover or focus a mark for its value, observation count, interval and annotation.
136
159
  The data table retains missing positions and full labels.
137
160
 
161
+ ### Run events, lanes, parts of a whole and small multiples
162
+
163
+ A run page draws recorded events, never inferred ones. Every addition below has a static form (`Figure | Refusal`, no DOM) and a React form, takes its colours from Brand tokens through `var()`, and hatches missing data instead of drawing zero.
164
+
165
+ | Need | Static | React |
166
+ |---|---|---|
167
+ | Vertical events (first pass, deadline, died) and shaded spans (after pass, idle, no data) | `timeSeries(rows, { ..., marks, bands })` | `<Lines marks bands>` |
168
+ | Lanes of spans with marks; an open span runs to `now` | `timeline(lanes, { measure, now, marks })` | `<Gantt lanes now marks>` |
169
+ | Part of a whole with untraced time hatched | `phaseWaterfall` (unaccounted leaves and the remainder are hatched) | `<Meter total segments>` |
170
+ | One panel per arm on shared axes, with no-data panels and early ends | `smallMultiples(panels, { x, y, shared })` | `<FigureView figure>` |
171
+ | Series that stay distinct without colour | later series add a dash and a marker shape | `series[].dash`, `series[].marker` |
172
+
173
+ ```ts
174
+ type ChartMark = { at: number; label: string; kind: "pass" | "deadline" | "died" | "event" };
175
+ type ChartBand = { from: number; to: number; label: string; kind: "after-pass" | "idle" | "missing" };
176
+ type TimelineLane = { id: string; label: string; spans: Array<{ start: number; end: number | null; state: string; note?: string }> };
177
+ type MultiplePanel = { id: string; label: string; unit?: string; domain?: [number, number]; points?: TimePoint[];
178
+ missing?: { why: string; detail?: string }; end?: { at: number; kind: ChartMark["kind"]; label?: string }; marks?: ChartMark[] };
179
+ type MeterSegment = { label: string; value: number | null; color?: string; kind?: "measured" | "untraced" };
180
+ ```
181
+
182
+ Mark kinds differ by stroke as well as colour: pass is solid, deadline dashed, died ends in a cross, and other events are dotted.
183
+ Marks and bands appear in the figure's table (`Recorded events by …`) and, for React, in a text list under the plot.
184
+ Static figures refuse marks or bands outside the x domain; React components throw `RangeError` for the same input, as `StackedBarChart` does.
185
+
186
+ `timeline` refuses an open span without `now`, a span outside a supplied `start`/`end` window and reversed times. States named in `hatch` (default `missing`, `untraced`) are hatched; states in `muted` (default `idle`) use the muted tone.
187
+ `Gantt` keeps its single-span `rows` input; `lanes` takes the same shape as `timeline`.
188
+
189
+ `Meter` with `segments` draws each recorded part of `total`; the remainder no segment accounts for is hatched and labelled `untraced` (rename it with `untraced`). A null segment is listed as `not recorded` and takes no width; a missing total hatches the whole track. Parts that sum past the total throw.
190
+
191
+ `smallMultiples` draws one panel per arm. A `missing` panel is hatched and reads `no data: <why>`; an `end` stops the line at a mark of its kind. It refuses mixed units, values outside the shared domain, points after an end and a missing panel that also carries data. `shared: "x"` keeps one x domain and requires each panel's own y `domain`. The lede counts `n of m <noun>s have data`.
192
+
193
+ `FigureView` embeds any static `Figure | Refusal` in React: the wide render in containers of 720 px or more, the phone render (or the table, when a figure has none) below that, and the data, reading and method in a closed disclosure. A refusal renders as its sentence. Import `figure.css` for its styles.
194
+
195
+ `node scripts/capture-run-page.mjs [dir]` (after `npm run build`) captures each addition at 1440 px light and 390 px dark; the reviewed images are `docs/screenshots/run-page-*.png`.
196
+
138
197
  ### Plots without visible report controls
139
198
 
140
199
  Use `renderFigure(figure, noteNumber, { presentation: "plot" })` to show the responsive plot alone.
@@ -189,30 +248,16 @@ Selection is controlled by bucket ID. Hover, focus, click, Enter, Space and touc
189
248
  ### React and package-boundary checks
190
249
 
191
250
  ```sh
192
- pnpm exec vitest run packages/charts/src/react/sparkline.test.tsx packages/charts/src/react/stacked-bar-chart.test.tsx
193
- pnpm exec vitest run packages/charts/src/package-boundary.test.ts
194
- pnpm exec vitest run packages/charts
195
- pnpm --filter @tangle-network/charts exec tsc --noEmit
196
- pnpm --filter @tangle-network/charts build
251
+ npm ci
252
+ npm run typecheck
253
+ npm test
254
+ npm run build
197
255
  ```
198
256
 
199
- The package-boundary test builds and packs Charts, installs the tarball in an OS-temporary consumer with a normal offline npm install (no peer-omission flags), asserts React/React DOM/React types cannot resolve, and executes the static root without DOM globals. It compares root exports, static SVG/tables, Refusals, report/chart/plot HTML and CSS with the untouched source implementation, then compiles a static-only consumer with `lib: ["ES2022"]`, `types: []` and `skipLibCheck: false`. Only afterward does it supply the workspace React peer/types to verify the packed React runtime and declarations. It runs under the existing root `pnpm test`; no CI workflow or shared UI smoke-script changes are needed.
257
+ The package-boundary test builds and packs Charts, installs the tarball in an OS-temporary consumer with a normal offline npm install (no peer-omission flags), asserts React/React DOM/React types cannot resolve, and executes the static root without DOM globals. It compares root exports, static SVG/tables, Refusals, report/chart/plot HTML and CSS with the untouched source implementation, then compiles a static-only consumer with `lib: ["ES2022"]`, `types: []` and `skipLibCheck: false`. Only afterward does it supply the installed React peer/types to verify the packed React runtime and declarations. It runs under `npm test`.
200
258
 
201
259
  The component tests cover gap/zero/one/equal/negative cases, controlled identity, keyboard/touch selection, nonvisual values and resize contracts. The jsdom resize check verifies fluid/minimum-width attributes and preserved selection, **not browser-computed layout**; The retained packed-browser fixture checks Chromium at 1100px and 390px, keyboard selection, emulated touch, light/dark Brand tokens, reduced motion, and top-layer positioning inside a transformed, paint-contained panel. It passes with React 18 and React 19; the React 18 consumer also compiles the packed declarations with React 18 types. Physical touch hardware and screen-reader audio remain unchecked.
202
260
 
203
- ### Packed browser proof
204
-
205
- Build Charts, then run the reusable fixture from the repository root.
206
- Pass a tarball path to test a published artifact after downloading it with npm pack.
207
-
208
- ```sh
209
- pnpm --filter @tangle-network/charts build
210
- PORT=46489 node scripts/charts-browser-smoke.mjs /absolute/path/to/charts.tgz
211
- python3 scripts/charts-browser-proof.py http://127.0.0.1:46489 /tmp/charts-proof
212
- ```
261
+ ### Figure browser proof
213
262
 
214
- Set `CHARTS_REACT_VERSION=18` to install and check an isolated React 18 runtime and type consumer.
215
- The default fixture uses the installed workspace React peer and bundles only the packed Charts export.
216
- The fixture imports Brand token CSS and creates a transformed, paint-contained panel.
217
- It needs Python Playwright and Chromium at `/snap/bin/chromium`.
218
- Receipts and screenshots are in [the retained proof](../../docs/evidence/charts-react-189/README.md).
263
+ `node scripts/render-figures.mjs` writes a comparison page from the retained fixtures and approved speed reference. `node scripts/capture-figures.mjs` uses Playwright Chromium to capture all three figures at phone and desktop widths in light and dark themes. The reviewed images are in [`docs/screenshots`](docs/screenshots).
@@ -0,0 +1,491 @@
1
+ //#region src/text.ts
2
+ /**
3
+ * Text: escaping, measurement and wrapping.
4
+ *
5
+ * Layout runs with no DOM (a Worker, a build step), so text width comes from
6
+ * Inter's advance widths, the face tangle.tools and the reports load. Numbers
7
+ * render in a monospace face at 0.6 em per glyph. Widths carry a 4% margin so a
8
+ * fallback face does not collide with its neighbour.
9
+ */
10
+ /** Every dynamic string in markup passes through here. */
11
+ function esc(value) {
12
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
13
+ }
14
+ const REGULAR = [
15
+ 281,
16
+ 288,
17
+ 466,
18
+ 633,
19
+ 642,
20
+ 982,
21
+ 644,
22
+ 300,
23
+ 365,
24
+ 365,
25
+ 501,
26
+ 662,
27
+ 288,
28
+ 460,
29
+ 288,
30
+ 360,
31
+ 631,
32
+ 407,
33
+ 610,
34
+ 618,
35
+ 646,
36
+ 608,
37
+ 620,
38
+ 566,
39
+ 619,
40
+ 620,
41
+ 288,
42
+ 302,
43
+ 662,
44
+ 662,
45
+ 662,
46
+ 511,
47
+ 966,
48
+ 690,
49
+ 654,
50
+ 730,
51
+ 722,
52
+ 601,
53
+ 590,
54
+ 746,
55
+ 743,
56
+ 269,
57
+ 571,
58
+ 672,
59
+ 565,
60
+ 903,
61
+ 753,
62
+ 765,
63
+ 639,
64
+ 765,
65
+ 644,
66
+ 642,
67
+ 646,
68
+ 744,
69
+ 690,
70
+ 985,
71
+ 682,
72
+ 679,
73
+ 629,
74
+ 365,
75
+ 360,
76
+ 365,
77
+ 471,
78
+ 456,
79
+ 323,
80
+ 562,
81
+ 612,
82
+ 571,
83
+ 612,
84
+ 583,
85
+ 370,
86
+ 613,
87
+ 591,
88
+ 242,
89
+ 242,
90
+ 549,
91
+ 242,
92
+ 876,
93
+ 591,
94
+ 600,
95
+ 612,
96
+ 612,
97
+ 376,
98
+ 528,
99
+ 327,
100
+ 591,
101
+ 562,
102
+ 818,
103
+ 546,
104
+ 562,
105
+ 552,
106
+ 426,
107
+ 333,
108
+ 426,
109
+ 662
110
+ ];
111
+ const SEMIBOLD = [
112
+ 252,
113
+ 321,
114
+ 523,
115
+ 644,
116
+ 650,
117
+ 1004,
118
+ 663,
119
+ 326,
120
+ 373,
121
+ 373,
122
+ 540,
123
+ 673,
124
+ 319,
125
+ 465,
126
+ 319,
127
+ 379,
128
+ 660,
129
+ 423,
130
+ 623,
131
+ 636,
132
+ 666,
133
+ 629,
134
+ 640,
135
+ 576,
136
+ 640,
137
+ 640,
138
+ 319,
139
+ 329,
140
+ 673,
141
+ 673,
142
+ 673,
143
+ 543,
144
+ 999,
145
+ 728,
146
+ 659,
147
+ 737,
148
+ 722,
149
+ 605,
150
+ 588,
151
+ 749,
152
+ 746,
153
+ 277,
154
+ 580,
155
+ 703,
156
+ 565,
157
+ 922,
158
+ 759,
159
+ 769,
160
+ 645,
161
+ 773,
162
+ 652,
163
+ 650,
164
+ 660,
165
+ 736,
166
+ 728,
167
+ 1020,
168
+ 720,
169
+ 713,
170
+ 652,
171
+ 373,
172
+ 379,
173
+ 373,
174
+ 481,
175
+ 469,
176
+ 351,
177
+ 574,
178
+ 624,
179
+ 583,
180
+ 624,
181
+ 591,
182
+ 389,
183
+ 625,
184
+ 612,
185
+ 262,
186
+ 262,
187
+ 569,
188
+ 262,
189
+ 900,
190
+ 612,
191
+ 609,
192
+ 624,
193
+ 624,
194
+ 397,
195
+ 549,
196
+ 353,
197
+ 612,
198
+ 587,
199
+ 839,
200
+ 569,
201
+ 588,
202
+ 566,
203
+ 455,
204
+ 359,
205
+ 455,
206
+ 673
207
+ ];
208
+ const EXTRA = {
209
+ "·": 300,
210
+ "×": 670,
211
+ "—": 1e3,
212
+ "–": 500,
213
+ "…": 900,
214
+ "≥": 670,
215
+ "≤": 670,
216
+ "±": 670,
217
+ "→": 954,
218
+ "−": 662
219
+ };
220
+ const MARGIN = 1.04;
221
+ function textWidth(text, size, face = "sans") {
222
+ if (face === "mono") return text.length * .6 * size * MARGIN;
223
+ const table = face === "sans-semibold" ? SEMIBOLD : REGULAR;
224
+ let units = 0;
225
+ for (const ch of text) {
226
+ const code = ch.codePointAt(0) ?? 0;
227
+ units += code >= 32 && code <= 126 ? table[code - 32] : EXTRA[ch] ?? 620;
228
+ }
229
+ return units / 1e3 * size * MARGIN;
230
+ }
231
+ /**
232
+ * Breaks `text` into at most `maxLines` lines no wider than `width`. Breaks at
233
+ * spaces, and after `-`, `/`, `_` or `·` when a word alone is too wide. A line
234
+ * that still overflows the last allowed line ends with an ellipsis; callers put
235
+ * the full text in a `<title>` and in the table.
236
+ */
237
+ function wrap(text, width, size, face = "sans", maxLines = 2) {
238
+ const fits = (s) => textWidth(s, size, face) <= width;
239
+ const lines = [];
240
+ const glue = [];
241
+ let line = "";
242
+ const breakLine = (next, joiner) => {
243
+ lines.push(line);
244
+ glue.push(joiner);
245
+ line = next;
246
+ };
247
+ for (const word of text.split(/\s+/).filter(Boolean)) {
248
+ const joined = line ? `${line} ${word}` : word;
249
+ if (fits(joined)) line = joined;
250
+ else if (fits(word)) {
251
+ if (line) breakLine(word, " ");
252
+ else line = word;
253
+ } else {
254
+ const pieces = word.match(/[^\-/_·]+[\-/_·]?|[\-/_·]/g) ?? [word];
255
+ for (const [index, piece] of pieces.entries()) {
256
+ const joiner = index === 0 ? " " : "";
257
+ const candidate = line ? `${line}${joiner}${piece}` : piece;
258
+ if (fits(candidate) || !line) line = candidate;
259
+ else breakLine(piece, joiner);
260
+ }
261
+ }
262
+ }
263
+ if (line) lines.push(line);
264
+ if (lines.length <= maxLines && lines.every(fits)) return {
265
+ lines,
266
+ truncated: false
267
+ };
268
+ const kept = lines.slice(0, maxLines);
269
+ let last = lines.slice(maxLines - 1).reduce((acc, l, i) => i === 0 ? l : `${acc}${glue[maxLines - 2 + i]}${l}`, "");
270
+ while (last.length > 1 && !fits(`${last}…`)) last = last.slice(0, -1);
271
+ kept[maxLines - 1] = `${last.trimEnd()}…`;
272
+ return {
273
+ lines: kept,
274
+ truncated: true
275
+ };
276
+ }
277
+ /** One `<text>` element with a line per `<tspan>`. */
278
+ function textBlock(lines, x, y, lineHeight, attrs) {
279
+ if (lines.length === 1) return `<text x="${x}" y="${y}" ${attrs}>${esc(lines[0])}</text>`;
280
+ return `<text x="${x}" y="${y}" ${attrs}>${lines.map((line, i) => `<tspan x="${x}" dy="${i === 0 ? 0 : lineHeight}">${esc(line)}</tspan>`).join("")}</text>`;
281
+ }
282
+ /** Joins `parts` with " · ", starting a new line wherever the next part would pass `width`. */
283
+ function fitLines(parts, width, size, face = "sans") {
284
+ const lines = [];
285
+ for (const part of parts) {
286
+ const last = lines[lines.length - 1];
287
+ if (last !== void 0 && textWidth(`${last} · ${part}`, size, face) <= width) lines[lines.length - 1] = `${last} · ${part}`;
288
+ else lines.push(part);
289
+ }
290
+ return lines;
291
+ }
292
+ function plural(count, one, many = `${one}s`) {
293
+ return count === 1 ? one : many;
294
+ }
295
+ /** "a, b and c" */
296
+ function listJoin(items) {
297
+ if (items.length <= 1) return items.join("");
298
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
299
+ }
300
+ function capitalize(text) {
301
+ return text.charAt(0).toUpperCase() + text.slice(1);
302
+ }
303
+ //#endregion
304
+ //#region src/svg.ts
305
+ /**
306
+ * SVG marks. Every mark carries a class and `figure.css` maps classes to the
307
+ * `--chart-*` tokens, so a render holds no colour and follows the page theme.
308
+ * Geometry stays in attributes, so an SVG shown without the stylesheet keeps
309
+ * its layout.
310
+ */
311
+ const r2 = (n) => Math.round(n * 100) / 100;
312
+ function svgRoot(width, height, inner) {
313
+ const h = Math.ceil(height);
314
+ return `<svg class="tgc-svg" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}" aria-hidden="true" focusable="false">${inner}</svg>`;
315
+ }
316
+ /** A horizontal bar anchored at `x`, square at the baseline, 4 px round at the data end. */
317
+ function hbar(x, y, w, h, cls) {
318
+ if (w <= 0) return "";
319
+ const r = Math.min(4, h / 2, w);
320
+ return `<path class="${cls}" d="M${r2(x)} ${r2(y)}h${r2(w - r)}a${r} ${r} 0 0 1 ${r} ${r}v${r2(h - 2 * r)}a${r} ${r} 0 0 1 -${r} ${r}h-${r2(w - r)}z"/>`;
321
+ }
322
+ function rect(x, y, w, h, cls, extra = "") {
323
+ if (w <= 0 || h <= 0) return "";
324
+ return `<rect class="${cls}" x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}"${extra ? ` ${extra}` : ""}/>`;
325
+ }
326
+ /**
327
+ * 45° hatch lines clipped to a rectangle, as one path `d`. Pure geometry with
328
+ * no pattern id, so a hatch survives duplicate SVGs on one page and hidden
329
+ * wide/narrow renders. Shared with the React renderers.
330
+ */
331
+ function hatchPath(x, y, w, h, step = 6) {
332
+ if (!(w > 0) || !(h > 0)) return "";
333
+ let d = "";
334
+ for (let k = step / 2; k < w + h; k += step) {
335
+ const x0 = Math.max(0, k - h), x1 = Math.min(w, k);
336
+ d += `M${r2(x + x0)} ${r2(y + k - x0)}L${r2(x + x1)} ${r2(y + k - x1)}`;
337
+ }
338
+ return d;
339
+ }
340
+ /** A hatched rectangle: a neutral wash plus hatch lines. Missing data is hatched, never drawn as zero. */
341
+ function hatch(x, y, w, h, cls) {
342
+ if (w <= 0 || h <= 0) return "";
343
+ return `<g class="${cls}" data-hatched="true">${rect(x, y, w, h, "tgc-hatch-bg")}<path class="tgc-hatch" d="${hatchPath(x, y, w, h)}"/></g>`;
344
+ }
345
+ function line(x1, y1, x2, y2, cls) {
346
+ return `<line class="${cls}" x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}"/>`;
347
+ }
348
+ function circle(cx, cy, r, cls) {
349
+ return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
350
+ }
351
+ const MARKER_SHAPES = [
352
+ "circle",
353
+ "square",
354
+ "triangle",
355
+ "diamond"
356
+ ];
357
+ /** A marker outline as a path `d`, centred on (cx, cy) with radius-like size `r`. Shared with the React renderers. */
358
+ function markerPath(shape, cx, cy, r) {
359
+ const [x, y] = [r2(cx), r2(cy)];
360
+ if (shape === "square") {
361
+ const s = r2(r * .9);
362
+ return `M${r2(x - s)} ${r2(y - s)}h${r2(2 * s)}v${r2(2 * s)}h${r2(-2 * s)}z`;
363
+ }
364
+ if (shape === "triangle") {
365
+ const s = r2(r * 1.2);
366
+ return `M${x} ${r2(y - s)}L${r2(x + s)} ${r2(y + s * .8)}L${r2(x - s)} ${r2(y + s * .8)}z`;
367
+ }
368
+ if (shape === "diamond") {
369
+ const s = r2(r * 1.3);
370
+ return `M${x} ${r2(y - s)}L${r2(x + s)} ${y}L${x} ${r2(y + s)}L${r2(x - s)} ${y}z`;
371
+ }
372
+ return `M${r2(x - r)} ${y}a${r} ${r} 0 1 0 ${r2(2 * r)} 0a${r} ${r} 0 1 0 ${r2(-2 * r)} 0`;
373
+ }
374
+ function polyline(points, cls) {
375
+ return `<polyline class="${cls}" points="${points.map(([x, y]) => `${r2(x)},${r2(y)}`).join(" ")}"/>`;
376
+ }
377
+ function text(x, y, value, o = {}) {
378
+ const anchor = o.anchor && o.anchor !== "start" ? ` text-anchor="${o.anchor}"` : "";
379
+ const weight = o.weight === 600 ? ` font-weight="600"` : "";
380
+ return `<text class="${o.cls ?? "tgc-ink"}" x="${r2(x)}" y="${r2(y)}" font-size="${o.size ?? 12}"${anchor}${weight}>${esc(value)}</text>`;
381
+ }
382
+ /** A group whose `<title>` is the hover text for everything inside it. */
383
+ function titled(title, inner) {
384
+ return `<g><title>${esc(title)}</title>${inner}</g>`;
385
+ }
386
+ //#endregion
387
+ //#region src/table.ts
388
+ /** A plain HTML table: the accessible form of a figure and its phone view. */
389
+ function table(caption, columns, rows) {
390
+ const head = columns.map((c) => `<th scope="col"${c.numeric ? " class=\"tgc-n\"" : ""}>${esc(c.label)}</th>`).join("");
391
+ const body = rows.map((row) => `<tr>${row.map((cell, i) => i === 0 ? `<th scope="row">${esc(cell)}</th>` : `<td${columns[i]?.numeric ? " class=\"tgc-n\"" : ""}>${esc(cell)}</td>`).join("")}</tr>`).join("");
392
+ return `<table class="tgc-table"><caption>${esc(caption)}</caption><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
393
+ }
394
+ //#endregion
395
+ //#region src/types.ts
396
+ function isRefusal(value) {
397
+ return "refused" in value;
398
+ }
399
+ //#endregion
400
+ //#region src/events.ts
401
+ /**
402
+ * Recorded events and spans drawn over a continuous x axis: vertical marks
403
+ * (first pass, deadline, death) and shaded bands (time after the first pass,
404
+ * idle time, no data). The producer records every position; the library never
405
+ * places an event.
406
+ */
407
+ const MARK_KINDS = [
408
+ "pass",
409
+ "deadline",
410
+ "died",
411
+ "event"
412
+ ];
413
+ const BAND_KINDS = [
414
+ "after-pass",
415
+ "idle",
416
+ "missing"
417
+ ];
418
+ /** Why the marks or bands cannot be drawn on `domain` (null when they can). Omit `domain` to check shape only. */
419
+ function eventsGap(marks = [], bands = [], domain) {
420
+ const inside = (value) => !domain || value >= domain[0] && value <= domain[1];
421
+ for (const mark of marks) {
422
+ if (!mark || typeof mark.label !== "string" || !mark.label.trim()) return "Every mark needs a label.";
423
+ if (!MARK_KINDS.includes(mark.kind)) return `${mark.label}: unknown mark kind.`;
424
+ if (!Number.isFinite(mark.at) || !inside(mark.at)) return `${mark.label}: mark position must be finite and inside the axis domain.`;
425
+ }
426
+ for (const band of bands) {
427
+ if (!band || typeof band.label !== "string" || !band.label.trim()) return "Every band needs a label.";
428
+ if (!BAND_KINDS.includes(band.kind)) return `${band.label}: unknown band kind.`;
429
+ if (!Number.isFinite(band.from) || !Number.isFinite(band.to) || band.from >= band.to || !inside(band.from) || !inside(band.to)) return `${band.label}: a band needs finite, increasing bounds inside the axis domain.`;
430
+ }
431
+ return null;
432
+ }
433
+ /** Bands sit under the data; a missing band is hatched over a neutral wash. */
434
+ function drawBands(bands, px, top, bottom, format) {
435
+ return bands.map((band) => {
436
+ const x = px(band.from), w = px(band.to) - x, cls = `tgc-band tgc-band-${band.kind}`;
437
+ const mark = band.kind === "missing" ? hatch(x, top, w, bottom - top, cls) : rect(x, top, w, bottom - top, cls);
438
+ return titled(`${band.label}: ${format(band.from)} to ${format(band.to)}`, `<g data-band="${band.kind}">${mark}</g>`);
439
+ }).join("");
440
+ }
441
+ /** Marks sit over the data; labels stagger in three rows so adjacent events stay readable. */
442
+ function drawMarks(marks, px, top, bottom, left, right, format) {
443
+ return [...marks].sort((a, b) => a.at - b.at).map((mark, i) => {
444
+ const x = px(mark.at), y = top + 12 + i % 3 * 14;
445
+ const flip = x > left + (right - left) * .62;
446
+ let glyph = line(x, top, x, bottom, `tgc-mark tgc-mark-${mark.kind}`);
447
+ if (mark.kind === "died") glyph += line(x - 4, bottom - 8, x + 4, bottom, "tgc-mark tgc-mark-died") + line(x - 4, bottom, x + 4, bottom - 8, "tgc-mark tgc-mark-died");
448
+ glyph += text(flip ? x - 4 : x + 4, y, mark.label, {
449
+ size: 11,
450
+ anchor: flip ? "end" : "start",
451
+ cls: "tgc-mark-label"
452
+ });
453
+ return titled(`${mark.label}: ${format(mark.at)}`, `<g data-mark="${mark.kind}">${glyph}</g>`);
454
+ }).join("");
455
+ }
456
+ /** The table form of marks and bands, in axis order. Empty when there are none. */
457
+ function eventsTable(marks, bands, axisLabel, format) {
458
+ if (!marks.length && !bands.length) return "";
459
+ const rows = [...marks.map((mark) => ({
460
+ at: mark.at,
461
+ cells: [
462
+ mark.label,
463
+ mark.kind,
464
+ format(mark.at),
465
+ ""
466
+ ]
467
+ })), ...bands.map((band) => ({
468
+ at: band.from,
469
+ cells: [
470
+ band.label,
471
+ band.kind === "missing" ? "no data" : band.kind,
472
+ format(band.from),
473
+ format(band.to)
474
+ ]
475
+ }))].sort((a, b) => a.at - b.at);
476
+ return table(`Recorded events by ${axisLabel}`, [
477
+ { label: "Event" },
478
+ { label: "Kind" },
479
+ {
480
+ label: `${axisLabel} from`,
481
+ numeric: true
482
+ },
483
+ {
484
+ label: `${axisLabel} to`,
485
+ numeric: true
486
+ }
487
+ ], rows.map((row) => row.cells));
488
+ }
489
+ const EVENTS_READ = "Vertical rules mark recorded events (solid: pass, dashed: deadline, cross: died, dotted: other); tinted spans are recorded periods and hatched spans have no data.";
490
+ //#endregion
491
+ export { listJoin as C, wrap as D, textWidth as E, fitLines as S, textBlock as T, svgRoot as _, eventsTable as a, capitalize as b, MARKER_SHAPES as c, hatchPath as d, hbar as f, rect as g, polyline as h, eventsGap as i, circle as l, markerPath as m, drawBands as n, isRefusal as o, line as p, drawMarks as r, table as s, EVENTS_READ as t, hatch as u, text as v, plural as w, esc as x, titled as y };