@tangle-network/charts 0.7.0 → 0.9.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,1185 @@
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/format.ts
396
+ const TIERS_BEST_FIRST = [
397
+ "S",
398
+ "A",
399
+ "B",
400
+ "C",
401
+ "F"
402
+ ];
403
+ const LADDER = [
404
+ "F",
405
+ "C",
406
+ "B",
407
+ "A",
408
+ "S"
409
+ ];
410
+ function tierAtLeast(tier, floor) {
411
+ return LADDER.indexOf(tier) >= LADDER.indexOf(floor);
412
+ }
413
+ function pct(value) {
414
+ return `${Math.round(value * 100)}%`;
415
+ }
416
+ /** A difference in points: "+25 pts". */
417
+ function pts(value) {
418
+ const n = Math.round(value * 100);
419
+ return `${n > 0 ? "+" : n < 0 ? "−" : ""}${Math.abs(n)} pts`;
420
+ }
421
+ function usd(value) {
422
+ if (value >= 100) return `$${Math.round(value).toLocaleString("en-US")}`;
423
+ if (value >= 1) return `$${value.toFixed(2)}`;
424
+ if (value >= .01) return `$${value.toFixed(3)}`;
425
+ return `$${value.toFixed(4)}`;
426
+ }
427
+ /** Axis ticks: "$30", "$2.5", "$0.05". */
428
+ function usdTick(value) {
429
+ return `$${Number(value.toPrecision(2)).toLocaleString("en-US", { maximumFractionDigits: 4 })}`;
430
+ }
431
+ function duration(ms) {
432
+ const s = ms / 1e3;
433
+ if (s < 60) return `${Math.round(s)} s`;
434
+ if (s < 3600) return `${(s / 60).toFixed(1)} min`;
435
+ return `${(s / 3600).toFixed(1)} h`;
436
+ }
437
+ function kn(k, n) {
438
+ return `${k}/${n}`;
439
+ }
440
+ const METHOD_NAME = {
441
+ "task-cluster-bootstrap": "task-clustered bootstrap",
442
+ "mcnemar-exact": "exact McNemar",
443
+ wilson: "Wilson score"
444
+ };
445
+ /** "95% Wilson score" */
446
+ function intervalName(interval) {
447
+ return `${Math.round((interval.level ?? 0) * 100)}% ${interval.method ? METHOD_NAME[interval.method] : ""}`.trim();
448
+ }
449
+ /** Why an interval cannot be drawn, or null when it can. */
450
+ function intervalGap(interval) {
451
+ if (!interval) return "no interval";
452
+ const missing = [interval.level === null ? "level" : "", interval.method === null ? "method" : ""].filter(Boolean);
453
+ if (missing.length) return `the interval names no ${missing.join(" or ")}`;
454
+ if (!(interval.method in METHOD_NAME)) return "the interval method is unknown";
455
+ if (!Number.isFinite(interval.level) || interval.level <= 0 || interval.level >= 1) return "the interval level is outside 0–1";
456
+ if (!Number.isFinite(interval.lower) || !Number.isFinite(interval.upper)) return "the interval bounds are not finite";
457
+ if (!(interval.lower <= interval.upper)) return "the interval bounds are out of order";
458
+ return null;
459
+ }
460
+ /** Whether a rate bar may be drawn for this estimate. */
461
+ function drawable(estimate) {
462
+ return estimate === "descriptive" || estimate === "bootstrap";
463
+ }
464
+ function estimateNote(estimate, units) {
465
+ if (estimate === "none") return units === 1 ? "1 unit" : `${units} units`;
466
+ if (estimate === "insufficient") return "too few units";
467
+ return "";
468
+ }
469
+ /**
470
+ * The positive cost per pass this figure can print or place, or null.
471
+ * A zero value needs a separate verified-free representation; this field alone
472
+ * cannot distinguish a free run from missing spend, so it prints "unknown".
473
+ */
474
+ function perPass(cost) {
475
+ return cost.basis !== "unknown" && cost.perSolvedUsd !== null && Number.isFinite(cost.perSolvedUsd) && cost.perSolvedUsd > 0 ? cost.perSolvedUsd : null;
476
+ }
477
+ /** "$23.76", "$23.76 est.", "unknown", "no pass" */
478
+ function costText(cost, solved) {
479
+ if (solved === 0) return "no pass";
480
+ const value = perPass(cost);
481
+ if (value === null) return "unknown";
482
+ return cost.basis === "receipts" ? usd(value) : `${usd(value)} est.`;
483
+ }
484
+ /** Wilson can describe an eligible rate; clustered bootstrap needs the producer's bootstrap class. */
485
+ function rateIntervalGap(row) {
486
+ if (!drawable(row.estimate)) return `${row.estimate} estimate`;
487
+ if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0–1";
488
+ const gap = intervalGap(row.interval);
489
+ if (gap) return gap;
490
+ if (row.interval.method === "mcnemar-exact") return "exact McNemar applies to paired comparisons, not rates";
491
+ if (row.estimate === "descriptive" && row.interval.method !== "wilson") return `descriptive estimate does not support ${intervalName(row.interval)}`;
492
+ if (row.interval.lower < 0 || row.interval.upper > 1) return "the rate interval is outside 0–1";
493
+ if (row.rate < row.interval.lower - 1e-6 || row.rate > row.interval.upper + 1e-6) return "the rate falls outside its interval";
494
+ return null;
495
+ }
496
+ /** Check the source arithmetic before using a rate in a finding or a mark. */
497
+ function rateDataGap(row) {
498
+ if (!Number.isInteger(row.attempts) || row.attempts < 0 || !Number.isInteger(row.solved) || row.solved < 0 || row.solved > row.attempts) return "passed and attempt counts are invalid";
499
+ if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0–1";
500
+ if (row.attempts > 0 && Math.abs(row.rate - row.solved / row.attempts) > 1e-6) return "rate disagrees with passed/attempts";
501
+ if (row.attempts === 0 && row.rate !== 0) return "rate has no attempts";
502
+ if (drawable(row.estimate) && row.attempts === 0) return "drawable estimate has no attempts";
503
+ return null;
504
+ }
505
+ /**
506
+ * The order rows are drawn in. Ranked rows keep their rank. Otherwise rows
507
+ * with enough units to draw a rate come first, highest rate first; rows with
508
+ * too few units follow, most units first, so a 1/1 row never heads the list.
509
+ */
510
+ function rateOrder(rows) {
511
+ if (rows.length > 0 && rows.every((row) => row.rank !== null && row.estimate === "bootstrap")) return [...rows].sort((a, b) => a.rank - b.rank);
512
+ const drawn = rows.filter((row) => drawable(row.estimate)).sort((a, b) => b.rate - a.rate);
513
+ const counted = rows.filter((row) => !drawable(row.estimate)).sort((a, b) => b.attempts - a.attempts);
514
+ return [...drawn, ...counted];
515
+ }
516
+ /** "2A 1F" */
517
+ function tierSummary(tiers, unmeasured = 0, flagged = 0) {
518
+ const parts = tiers ? TIERS_BEST_FIRST.filter((t) => tiers[t] > 0).map((t) => `${tiers[t]}${t}`) : [];
519
+ if (flagged) parts.push(`${flagged} flagged`);
520
+ if (unmeasured) parts.push(`${unmeasured}U`);
521
+ return parts.join(" ");
522
+ }
523
+ function tierTotal(tiers) {
524
+ return tiers ? LADDER.reduce((sum, t) => sum + tiers[t], 0) : 0;
525
+ }
526
+ //#endregion
527
+ //#region src/types.ts
528
+ function isRefusal(value) {
529
+ return "refused" in value;
530
+ }
531
+ //#endregion
532
+ //#region src/events.ts
533
+ /**
534
+ * Recorded events and spans drawn over a continuous x axis: vertical marks
535
+ * (first pass, deadline, death, release, steer, ruling, wake) and shaded bands
536
+ * (time after the first pass, idle time, no data, a system-wide wall). The
537
+ * producer records every position; the library never places an event.
538
+ */
539
+ const MARK_KINDS = [
540
+ "pass",
541
+ "deadline",
542
+ "died",
543
+ "event",
544
+ "release",
545
+ "steer",
546
+ "ruling",
547
+ "wake"
548
+ ];
549
+ const MARK_VERDICTS = [
550
+ "pass",
551
+ "fail",
552
+ "unknown"
553
+ ];
554
+ const BAND_KINDS = [
555
+ "after-pass",
556
+ "idle",
557
+ "missing",
558
+ "blocked",
559
+ "group"
560
+ ];
561
+ /** The head shape of each kind that has one, shared with the React marks. */
562
+ const MARK_HEADS = {
563
+ release: "diamond",
564
+ steer: "triangle",
565
+ ruling: "square",
566
+ wake: "circle"
567
+ };
568
+ /** Why the marks or bands cannot be drawn on `domain` (null when they can). Omit `domain` to check shape only. */
569
+ function eventsGap(marks = [], bands = [], domain) {
570
+ const inside = (value) => !domain || value >= domain[0] && value <= domain[1];
571
+ for (const mark of marks) {
572
+ if (!mark || typeof mark.label !== "string" || !mark.label.trim()) return "Every mark needs a label.";
573
+ if (!MARK_KINDS.includes(mark.kind)) return `${mark.label}: unknown mark kind.`;
574
+ if (mark.verdict !== void 0 && !MARK_VERDICTS.includes(mark.verdict)) return `${mark.label}: a verdict is pass, fail or unknown.`;
575
+ if (mark.source !== void 0 && (typeof mark.source !== "string" || !mark.source.trim())) return `${mark.label}: a source needs a name.`;
576
+ if (!Number.isFinite(mark.at) || !inside(mark.at)) return `${mark.label}: mark position must be finite and inside the axis domain.`;
577
+ }
578
+ for (const band of bands) {
579
+ if (!band || typeof band.label !== "string" || !band.label.trim()) return "Every band needs a label.";
580
+ if (!BAND_KINDS.includes(band.kind)) return `${band.label}: unknown band kind.`;
581
+ if (!Number.isFinite(band.from) || !Number.isFinite(band.to) || (band.kind === "group" ? band.from > 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.`;
582
+ }
583
+ return null;
584
+ }
585
+ /** An `event` whose source is `gap`: a break in time between positions of an ordinal axis. It never crosses the data. */
586
+ function isGap(mark) {
587
+ return mark.kind === "event" && mark.source === "gap";
588
+ }
589
+ /** A mark's label with its source, as drawn and listed. */
590
+ function markName(mark) {
591
+ return mark.source ? `${mark.label} · ${mark.source}` : mark.label;
592
+ }
593
+ /** The kind cell of the events table: the kind, then the verdict and the lane when recorded. */
594
+ function markKindLabel(mark) {
595
+ return [
596
+ mark.kind,
597
+ mark.verdict,
598
+ mark.lane && `lane ${mark.lane}`
599
+ ].filter(Boolean).join(" · ");
600
+ }
601
+ /**
602
+ * Bands sit under the data; a missing band is hatched over a neutral wash. A band is clipped to `clip` when given.
603
+ * `extent` maps a band's bounds to pixels when a bound covers more than a point, such as a column of an ordinal axis.
604
+ * Group bands are drawn by `drawGroups`, not here.
605
+ */
606
+ function drawBands(bands, px, top, bottom, format, clip, extent) {
607
+ return bands.map((band) => {
608
+ if (band.kind === "group") return "";
609
+ const from = clip ? Math.max(band.from, clip[0]) : band.from, to = clip ? Math.min(band.to, clip[1]) : band.to;
610
+ if (to <= from) return "";
611
+ const [x0, x1] = extent ? extent(from, to) : [px(from), px(to)];
612
+ const x = x0, w = x1 - x0, cls = `tgc-band tgc-band-${band.kind}`;
613
+ const mark = band.kind === "missing" ? hatch(x, top, w, bottom - top, cls) : rect(x, top, w, bottom - top, cls) + (band.kind === "blocked" ? line(x, top, x + w, top, "tgc-band-wall") : "");
614
+ return titled(`${band.label}: ${format(band.from)} to ${format(band.to)}`, `<g data-band="${band.kind}">${mark}</g>`);
615
+ }).join("");
616
+ }
617
+ /** The header row height `drawGroups` needs, or 0 without group bands. */
618
+ function groupRowHeight(bands = []) {
619
+ return bands.some((band) => band.kind === "group") ? 18 : 0;
620
+ }
621
+ /**
622
+ * Group bands as labelled brackets in the header row that ends at `top`, with a rule at each inner edge through the
623
+ * data. A label that does not fit its bracket is cut with an ellipsis; the full label is the bracket's hover.
624
+ */
625
+ function drawGroups(bands, extent, top, bottom, left, right, format, clip) {
626
+ return bands.filter((band) => band.kind === "group").map((band) => {
627
+ const from = clip ? Math.max(band.from, clip[0]) : band.from, to = clip ? Math.min(band.to, clip[1]) : band.to;
628
+ if (to < from) return "";
629
+ const [x0, x1] = extent(from, to), w = x1 - x0;
630
+ const label = w >= 24 ? wrap(band.label, w - 10, 11, "sans", 1).lines[0] : "";
631
+ const edges = [x0, x1].filter((x) => x > left + .5 && x < right - .5).map((x) => line(x, top - 16, x, bottom, "tgc-group-edge")).join("");
632
+ const bracket = line(x0 + 2, top - 3, x1 - 2, top - 3, "tgc-group-rule") + (label ? text(x0 + 3, top - 7, label, {
633
+ size: 11,
634
+ cls: "tgc-group-label"
635
+ }) : "");
636
+ return titled(`${band.label}: ${format(band.from)} to ${format(band.to)}`, `<g data-band="group">${edges}${bracket}</g>`);
637
+ }).join("");
638
+ }
639
+ /** Marks of one kind and verdict merge when under 3 px apart, or under 12 px apart with the same label (a re-checked tag). */
640
+ function groupMarks(marks, px) {
641
+ const groups = [];
642
+ for (const mark of [...marks].sort((a, b) => a.at - b.at)) {
643
+ const x = px(mark.at), last = groups.at(-1), lead = last?.marks[0];
644
+ const near = last && px(last.marks.at(-1).at);
645
+ if (last && lead.kind === mark.kind && lead.verdict === mark.verdict && (x - near < 3 || x - near < 12 && markName(lead) === markName(mark))) last.marks.push(mark);
646
+ else groups.push({
647
+ x,
648
+ marks: [mark]
649
+ });
650
+ }
651
+ return groups;
652
+ }
653
+ /**
654
+ * Places labels in up to `rows` rows without overlap, measured from their text. Edge labels go first so an event
655
+ * past the window is always named; a mark label that fits no row is left to its rule's hover and the table.
656
+ */
657
+ function layoutLabels(groups, left, right, o) {
658
+ const rows = o.rows ?? 3, maxW = Math.max(48, Math.min(200, (right - left) * .42)), gap = 6;
659
+ const taken = Array.from({ length: rows }, () => []);
660
+ const fits = (row, a, b) => taken[row].every(([p, q]) => b + gap <= p || a >= q + gap);
661
+ const place = (value, start, end, prefer, flexible) => {
662
+ const label = wrap(value, maxW, 11, "sans", 1).lines[0], w = textWidth(label, 11);
663
+ for (const anchor of flexible ? [prefer, prefer === "start" ? "end" : "start"] : [prefer]) {
664
+ const [a, b] = anchor === "start" ? [start, start + w] : [end - w, end];
665
+ if (a < left - 4 || b > right + 4) continue;
666
+ for (let row = 0; row < rows; row++) if (fits(row, a, b)) {
667
+ taken[row].push([a, b]);
668
+ return {
669
+ row,
670
+ x: anchor === "start" ? start : end,
671
+ anchor,
672
+ text: label
673
+ };
674
+ }
675
+ }
676
+ return null;
677
+ };
678
+ const [w0, w1] = o.window ?? [-Infinity, Infinity];
679
+ const edges = [...(o.after ?? []).map((mark) => place(`${markName(mark)} +${duration(mark.at - w1)} →`, right, right, "end", false)), ...(o.before ?? []).map((mark) => place(`← ${markName(mark)} −${duration(w0 - mark.at)}`, left, left, "start", false))].filter((p) => p !== null);
680
+ const placed = /* @__PURE__ */ new Map();
681
+ for (const group of [...groups.filter((g) => !isGap(g.marks[0])), ...groups.filter((g) => isGap(g.marks[0]))]) {
682
+ const flip = group.x > left + (right - left) * .62;
683
+ const spot = place(markName(group.marks[0]) + (group.marks.length > 1 ? ` (+${group.marks.length - 1})` : ""), group.x + 4, group.x - 4, flip ? "end" : "start", true);
684
+ if (spot) placed.set(group, spot);
685
+ }
686
+ return {
687
+ placed,
688
+ edges,
689
+ rows: Math.max(0, ...[...placed.values(), ...edges].map((p) => p.row + 1))
690
+ };
691
+ }
692
+ /** How many label rows `drawMarks` uses for these marks, so a figure can reserve them above its data. */
693
+ function markLabelRows(marks, px, left, right, o = {}) {
694
+ return layoutLabels(groupMarks(marks, px), left, right, o).rows;
695
+ }
696
+ /** The head shape of a release, steer, ruling or wake, centred on (x, y). Empty for the plain rule kinds. */
697
+ function markHead(kind, x, y, r = 3.5) {
698
+ const shape = MARK_HEADS[kind];
699
+ return shape ? `<path class="tgc-mark-head" d="${markerPath(shape, x, y, r)}"/>` : "";
700
+ }
701
+ /**
702
+ * Marks and their labels. Labels go in rows from `labelTop`, placed by measured width so none overlap, and are drawn
703
+ * over every rule; marks of one kind and verdict at one place share a rule and a label. Rules run from `labelTop` to
704
+ * `bottom`, except that release, steer and wake stems stop at their head, just below `top`.
705
+ */
706
+ function drawMarks(marks, px, top, bottom, left, right, format, labelTop = top, o = {}) {
707
+ const groups = groupMarks(marks, px);
708
+ const { placed, edges } = layoutLabels(groups, left, right, o);
709
+ const rowY = (row) => labelTop + 12 + row * 14;
710
+ let out = groups.map((group) => {
711
+ const { x, marks: members } = group, mark = members[0];
712
+ const stem = MARK_HEADS[mark.kind] !== void 0 && mark.kind !== "ruling" || isGap(mark);
713
+ let glyph = line(x, Math.min(top, labelTop), x, stem ? top + 4 : bottom, `tgc-mark tgc-mark-${mark.kind}`);
714
+ 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");
715
+ glyph += markHead(mark.kind, x, top + 4);
716
+ const verdict = mark.verdict ? ` data-verdict="${mark.verdict}" class="tgc-verdict-${mark.verdict}"` : "";
717
+ return titled(members.map((m) => `${markName(m)}: ${format(m.at)}${m.verdict ? ` · ${m.verdict}` : ""}`).join("\n"), `<g data-mark="${mark.kind}"${members.length > 1 ? ` data-merged="${members.length}"` : ""}${verdict}>${glyph}</g>`);
718
+ }).join("");
719
+ for (const group of groups) {
720
+ const spot = placed.get(group);
721
+ if (spot) out += text(spot.x, rowY(spot.row), spot.text, {
722
+ size: 11,
723
+ anchor: spot.anchor,
724
+ cls: "tgc-mark-label"
725
+ });
726
+ }
727
+ for (const edge of edges) out += `<g data-edge="${edge.anchor === "end" ? "after" : "before"}">${text(edge.x, rowY(edge.row), edge.text, {
728
+ size: 11,
729
+ anchor: edge.anchor,
730
+ cls: "tgc-mark-label tgc-mark-edge"
731
+ })}</g>`;
732
+ return out;
733
+ }
734
+ /** The table form of marks and bands, in axis order. Empty when there are none. */
735
+ function eventsTable(marks, bands, axisLabel, format) {
736
+ if (!marks.length && !bands.length) return "";
737
+ const rows = [...marks.map((mark) => ({
738
+ at: mark.at,
739
+ cells: [
740
+ markName(mark),
741
+ markKindLabel(mark),
742
+ format(mark.at),
743
+ ""
744
+ ]
745
+ })), ...bands.map((band) => ({
746
+ at: band.from,
747
+ cells: [
748
+ band.label,
749
+ band.kind === "missing" ? "no data" : band.kind,
750
+ format(band.from),
751
+ format(band.to)
752
+ ]
753
+ }))].sort((a, b) => a.at - b.at);
754
+ return table(`Recorded events by ${axisLabel}`, [
755
+ { label: "Event" },
756
+ { label: "Kind" },
757
+ {
758
+ label: `${axisLabel} from`,
759
+ numeric: true
760
+ },
761
+ {
762
+ label: `${axisLabel} to`,
763
+ numeric: true
764
+ }
765
+ ], rows.map((row) => row.cells));
766
+ }
767
+ const EVENTS_READ = "Vertical rules mark recorded events (solid: pass, dashed: deadline, cross: died, dotted: other; a diamond is a release, a triangle a steer, a square a ruling and a circle a wake, coloured by verdict when one is recorded); tinted spans are recorded periods, a wall-topped span is a system-wide block, and hatched spans have no data.";
768
+ //#endregion
769
+ //#region src/outcomes.ts
770
+ /**
771
+ * Outcomes in stack order. Adjacent outcomes pass the colour-vision checks in both themes in this order, so a stacked
772
+ * bar keeps it; `aborted` and `other` share the neutral tone and always carry a label. An attempt still running is
773
+ * `open` and is drawn hatched, never as an outcome.
774
+ */
775
+ const OUTCOME_ORDER = [
776
+ "ok",
777
+ "quota",
778
+ "lost",
779
+ "rate-limit",
780
+ "oom",
781
+ "refused",
782
+ "aborted",
783
+ "other"
784
+ ];
785
+ /** The CSS colour of each outcome, for a caller that paints its own marks (a legend, a stacked bar). */
786
+ const OUTCOME_COLORS = Object.freeze(Object.fromEntries(OUTCOME_ORDER.map((outcome) => [outcome, `var(--chart-outcome-${outcome === "aborted" || outcome === "other" ? "neutral" : outcome})`])));
787
+ /** How many checkers have their own hue. Index 0 is the current checker; later indexes are older checkers. */
788
+ const CHECKER_HUES = 4;
789
+ /** The CSS colour of checker `index` (0 is current). Indexes past the fourth hue fold into the muted tone. */
790
+ function checkerColor(index) {
791
+ return Number.isInteger(index) && index >= 0 && index < 4 ? `var(--chart-checker-${index})` : "var(--chart-muted)";
792
+ }
793
+ /** The class a figure puts on a series drawn in checker `index`'s colour. */
794
+ function checkerClass(index) {
795
+ if (index === void 0) return "";
796
+ return Number.isInteger(index) && index >= 0 && index < 4 ? `tgc-checker-${index}` : "tgc-checker-other";
797
+ }
798
+ //#endregion
799
+ //#region src/timeline.ts
800
+ /**
801
+ * Lanes of recorded spans on one time axis, with recorded marks; an open span runs to the supplied `now`. Lanes can
802
+ * form a tree (`parent`), carry attempt ticks coloured by outcome with ok and failed counts, and fold by subtree.
803
+ */
804
+ const TICK_OUTCOMES = OUTCOME_ORDER;
805
+ const utcClock = (ms) => new Date(ms).toISOString().slice(11, 16);
806
+ const MIN = 6e4;
807
+ const DAY = 864e5;
808
+ const MONTHS = [
809
+ "Jan",
810
+ "Feb",
811
+ "Mar",
812
+ "Apr",
813
+ "May",
814
+ "Jun",
815
+ "Jul",
816
+ "Aug",
817
+ "Sep",
818
+ "Oct",
819
+ "Nov",
820
+ "Dec"
821
+ ];
822
+ /** Axis steps: the smallest whose labels fit without overlap is used. */
823
+ const STEPS = [
824
+ 1,
825
+ 2,
826
+ 5,
827
+ 10,
828
+ 15,
829
+ 30,
830
+ 60,
831
+ 120,
832
+ 180,
833
+ 360,
834
+ 720,
835
+ 1440,
836
+ 2880,
837
+ 10080
838
+ ].map((m) => m * MIN);
839
+ function timeline(lanes, o) {
840
+ const id = o.id ?? "timeline";
841
+ if (!lanes.length) return {
842
+ id,
843
+ refused: "No lanes recorded."
844
+ };
845
+ if (!o.measure?.trim()) return {
846
+ id,
847
+ refused: "A timeline needs a measure."
848
+ };
849
+ if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length) return {
850
+ id,
851
+ refused: "Duplicate lane ids."
852
+ };
853
+ if (o.now !== void 0 && !Number.isFinite(o.now)) return {
854
+ id,
855
+ refused: "now must be a finite time."
856
+ };
857
+ const byId = new Map(lanes.map((lane) => [lane.id, lane]));
858
+ for (const lane of lanes) {
859
+ const seen = /* @__PURE__ */ new Set();
860
+ for (let at = lane; at.parent !== void 0; at = byId.get(at.parent)) {
861
+ if (!byId.has(at.parent)) return {
862
+ id,
863
+ refused: `${at.label}: parent ${at.parent} is not a lane.`
864
+ };
865
+ if (seen.has(at.id)) return {
866
+ id,
867
+ refused: `${lane.label}: its parent chain loops back to ${at.label}.`
868
+ };
869
+ seen.add(at.id);
870
+ }
871
+ if (lane.depth !== void 0 && (!Number.isInteger(lane.depth) || lane.depth < 0)) return {
872
+ id,
873
+ refused: `${lane.label}: depth must be a whole number of at least 0.`
874
+ };
875
+ }
876
+ const depthOf = /* @__PURE__ */ new Map();
877
+ const depth = (lane) => depthOf.get(lane.id) ?? depthOf.set(lane.id, lane.parent === void 0 ? lane.depth ?? 0 : depth(byId.get(lane.parent)) + 1).get(lane.id);
878
+ for (const lane of lanes) {
879
+ const at = depth(lane);
880
+ if (lane.depth !== void 0 && lane.depth !== at) return {
881
+ id,
882
+ refused: `${lane.label}: depth ${lane.depth} is not one below its parent's ${at - 1}.`
883
+ };
884
+ }
885
+ const spans = lanes.flatMap((lane) => lane.spans.map((span) => ({
886
+ lane,
887
+ span
888
+ })));
889
+ for (const { lane, span } of spans) {
890
+ if (typeof span.state !== "string" || !span.state.trim()) return {
891
+ id,
892
+ refused: `${lane.label}: every span needs a state.`
893
+ };
894
+ if (!Number.isFinite(span.start) || span.end !== null && (!Number.isFinite(span.end) || span.end < span.start)) return {
895
+ id,
896
+ refused: `${lane.label}: span times must be finite and end at or after their start.`
897
+ };
898
+ if (span.end === null && o.now === void 0) return {
899
+ id,
900
+ refused: `${lane.label}: an open span needs now.`
901
+ };
902
+ if (span.end === null && o.now < span.start) return {
903
+ id,
904
+ refused: `${lane.label}: an open span starts after now.`
905
+ };
906
+ }
907
+ for (const lane of lanes) for (const tick of lane.ticks ?? []) {
908
+ if (!tick || !Number.isFinite(tick.at)) return {
909
+ id,
910
+ refused: `${lane.label}: every tick needs a finite time.`
911
+ };
912
+ if (!TICK_OUTCOMES.includes(tick.outcome)) return {
913
+ id,
914
+ refused: `${lane.label}: tick outcome ${String(tick.outcome)} is not one of ${TICK_OUTCOMES.join(", ")}.`
915
+ };
916
+ }
917
+ const shape = eventsGap(o.marks, o.bands);
918
+ if (shape) return {
919
+ id,
920
+ refused: shape
921
+ };
922
+ const marks = o.marks ?? [], bands = o.bands ?? [];
923
+ const stray = marks.find((mark) => mark.lane !== void 0 && !byId.has(mark.lane));
924
+ if (stray) return {
925
+ id,
926
+ refused: `${stray.label}: lane ${stray.lane} is not a lane.`
927
+ };
928
+ const ends = (span) => span.end ?? o.now;
929
+ const ticks = lanes.flatMap((lane) => lane.ticks ?? []);
930
+ const data = [
931
+ ...spans.flatMap(({ span }) => [span.start, ends(span)]),
932
+ ...ticks.map((tick) => tick.at),
933
+ ...o.now === void 0 ? [] : [o.now]
934
+ ];
935
+ const times = data.length ? data : [...marks.map((m) => m.at), ...bands.flatMap((b) => [b.from, b.to])];
936
+ if (!times.length) return {
937
+ id,
938
+ refused: "No spans, ticks, marks or bands recorded."
939
+ };
940
+ const t0 = o.start ?? Math.min(...times), t1 = o.end ?? Math.max(...times);
941
+ if (!Number.isFinite(t0) || !Number.isFinite(t1) || t1 <= t0) return {
942
+ id,
943
+ refused: "The window needs two finite, increasing times."
944
+ };
945
+ if (data.some((t) => t < t0 || t > t1)) return {
946
+ id,
947
+ refused: "A span, tick or now falls outside the recorded window."
948
+ };
949
+ const fmt = o.formatTime ?? utcClock;
950
+ const axisLabel = o.formatTime ?? ((ms) => ms % DAY === 0 ? `${MONTHS[new Date(ms).getUTCMonth()]} ${new Date(ms).getUTCDate()}` : utcClock(ms));
951
+ const hatched = new Set(o.hatch ?? ["missing", "untraced"]), muted = new Set(o.muted ?? ["idle"]);
952
+ const foldable = o.collapsed !== void 0, folded = new Set(o.collapsed ?? []);
953
+ const kids = /* @__PURE__ */ new Map();
954
+ for (const lane of lanes) kids.set(lane.parent, [...kids.get(lane.parent) ?? [], lane]);
955
+ const subtree = (lane) => (kids.get(lane.id) ?? []).flatMap((kid) => [kid, ...subtree(kid)]);
956
+ const rows = [];
957
+ const visit = (lane) => {
958
+ const below = subtree(lane), fold = folded.has(lane.id) && below.length > 0;
959
+ const own = lane.ticks, all = fold && (own || below.some((kid) => kid.ticks)) ? [...own ?? [], ...below.flatMap((kid) => kid.ticks ?? [])] : own;
960
+ rows.push({
961
+ lane,
962
+ depth: depthOf.get(lane.id),
963
+ children: (kids.get(lane.id) ?? []).length,
964
+ hidden: fold ? below.length : 0,
965
+ folded: fold,
966
+ ticks: all
967
+ });
968
+ if (!fold) (kids.get(lane.id) ?? []).forEach(visit);
969
+ };
970
+ (kids.get(void 0) ?? []).forEach(visit);
971
+ const rowOf = new Map(rows.map((row, i) => [row.lane.id, i]));
972
+ const shownAt = (laneId) => {
973
+ for (let at = byId.get(laneId); at; at = at.parent === void 0 ? void 0 : byId.get(at.parent)) if (rowOf.has(at.id)) return rowOf.get(at.id);
974
+ return 0;
975
+ };
976
+ const inWindow = (mark) => mark.at >= t0 && mark.at <= t1;
977
+ const wide = marks.filter((mark) => mark.lane === void 0 && inWindow(mark));
978
+ const onLane = marks.filter((mark) => mark.lane !== void 0 && inWindow(mark));
979
+ const before = marks.filter((mark) => mark.at < t0), after = marks.filter((mark) => mark.at > t1);
980
+ const open = spans.filter(({ span }) => span.end === null).length;
981
+ const noData = spans.filter(({ span }) => hatched.has(span.state));
982
+ const noDataMs = noData.reduce((sum, { span }) => sum + ends(span) - span.start, 0);
983
+ const counts = (list) => {
984
+ const by = /* @__PURE__ */ new Map();
985
+ for (const tick of list) by.set(tick.outcome, (by.get(tick.outcome) ?? 0) + 1);
986
+ const ok = by.get("ok") ?? 0, failed = list.length - ok;
987
+ const why = [...by].filter(([outcome]) => outcome !== "ok").sort((a, b) => b[1] - a[1]).map(([outcome, n]) => `${n} ${outcome}`).join(", ");
988
+ return {
989
+ ok,
990
+ failed,
991
+ why,
992
+ text: `${ok}✓ ${failed}✗`,
993
+ sentence: `${ok} ok, ${failed} failed${why ? ` (${why})` : ""}`
994
+ };
995
+ };
996
+ const detail = (lane) => (lane.detail ?? []).filter((fact) => typeof fact === "string" && fact.trim());
997
+ const spanLabel = (lane, span) => [`${lane.label}: ${span.state} ${fmt(span.start)} to ${span.end === null ? `now (${fmt(o.now)}, open)` : fmt(span.end)}, ${duration(ends(span) - span.start)}${span.note ? ` · ${span.note}` : ""}`, ...detail(lane)].join("\n");
998
+ const hasTicks = rows.some((row) => row.ticks !== void 0);
999
+ const countWidth = hasTicks ? Math.max(...rows.filter((row) => row.ticks).map((row) => textWidth(counts(row.ticks).text, 11, "mono"))) : 0;
1000
+ const draw = (width) => {
1001
+ const phone = width === 358, indent = phone ? 10 : 12;
1002
+ const caretX = (depth) => 4 + depth * indent;
1003
+ const labelX = (depth) => caretX(depth) + 11;
1004
+ const tail = (row) => row.hidden ? ` +${row.hidden}` : "";
1005
+ const left = phone ? 12 : Math.max(120, Math.min(Math.round(width * .34), Math.ceil(Math.max(...rows.map((row) => labelX(row.depth) + textWidth(row.lane.label + tail(row), 12))) + 10)));
1006
+ const right = phone ? width - 12 : width - 16 - (countWidth ? countWidth + 10 : 0);
1007
+ const px = (t) => left + (t - t0) / (t1 - t0) * (right - left);
1008
+ const labelRows = markLabelRows(wide, px, left, right, {
1009
+ before,
1010
+ after,
1011
+ window: [t0, t1]
1012
+ });
1013
+ const top = 8 + (labelRows ? labelRows * 14 + 6 : 0) + groupRowHeight(bands);
1014
+ const rowH = phone ? 40 : 24, bottom = top + rows.length * rowH;
1015
+ const barTop = (y) => y + (phone ? 19 : 5), barH = 14;
1016
+ let body = drawBands(bands, px, top, bottom, fmt, [t0, t1]) + drawGroups(bands, (from, to) => [px(from), px(to)], top, bottom, left, right, fmt, [t0, t1]);
1017
+ for (const t of axisTicks(t0, t1, px, left, right, axisLabel)) body += line(px(t), top, px(t), bottom, "tgc-gridline") + text(px(t), bottom + 16, axisLabel(t), {
1018
+ size: 11,
1019
+ cls: "tgc-ink-muted tgc-num",
1020
+ anchor: "middle"
1021
+ });
1022
+ body += drawMarks(wide, px, top - 8, bottom, left, right, fmt, 4, {
1023
+ before,
1024
+ after,
1025
+ window: [t0, t1]
1026
+ });
1027
+ rows.forEach((row, i) => {
1028
+ const { lane } = row, y = top + i * rowH, mid = y + (phone ? 9 : 12), baseline = y + (phone ? 13 : 16);
1029
+ const parentRow = lane.parent === void 0 ? void 0 : rowOf.get(lane.parent);
1030
+ if (parentRow !== void 0) {
1031
+ const x = caretX(row.depth - 1) + 3, from = phone ? y + 2 : top + parentRow * rowH + 19;
1032
+ body += `<path class="tgc-tree" d="M${x} ${from}V${mid}H${labelX(row.depth) - (row.children ? 13 : 3)}"/>`;
1033
+ }
1034
+ const countText = row.ticks ? counts(row.ticks) : void 0;
1035
+ const room = (phone ? width - 12 - (countText ? textWidth(countText.text, 11, "mono") + 8 : 0) : left - 8) - labelX(row.depth) - textWidth(tail(row), 12);
1036
+ const label = wrap(lane.label, Math.max(24, room), phone ? 11 : 12, "sans", 1).lines[0];
1037
+ const name = `<text class="tgc-ink tgc-lane-label" x="${labelX(row.depth)}" y="${baseline}" font-size="${phone ? 11 : 12}">${esc(label)}${row.hidden ? `<tspan class="tgc-ink-muted">${esc(tail(row))}</tspan>` : ""}</text>`;
1038
+ const caret = row.children ? `<path class="tgc-caret" d="${row.folded ? `M${caretX(row.depth)} ${mid - 4}l6 4l-6 4z` : `M${caretX(row.depth) - 1} ${mid - 3}h8l-4 6z`}"/>` : "";
1039
+ const facts = [
1040
+ lane.label,
1041
+ ...detail(lane),
1042
+ ...countText ? [`Attempts: ${countText.sentence}${row.hidden ? `, including ${row.hidden} folded ${plural(row.hidden, "lane")}` : ""}`] : []
1043
+ ].join("\n");
1044
+ const toggle = foldable && row.children ? ` data-toggle="${esc(lane.id)}" class="tgc-lane-toggle" role="button" tabindex="0" aria-expanded="${!row.folded}" aria-label="${esc(`${row.folded ? "Unfold" : "Fold"} ${lane.label}`)}"` : "";
1045
+ const hit = toggle ? rect(caretX(row.depth) - 3, y + 1, (phone ? width - 12 : left - 4) - caretX(row.depth) + 3, phone ? 16 : rowH - 2, "tgc-lane-hit", "rx=\"3\"") : "";
1046
+ body += `<g data-lane="${esc(lane.id)}" data-depth="${row.depth}"${row.folded ? ` data-folded="${row.hidden}"` : ""}><g${toggle}><title>${esc(facts)}</title>${hit}${caret}${name}</g>`;
1047
+ for (const span of lane.spans) {
1048
+ const x = px(span.start), w = Math.max(1, px(ends(span)) - x);
1049
+ const mark = hatched.has(span.state) ? hatch(x, barTop(y), w, barH, "tgc-span tgc-span-missing") : rect(x, barTop(y), w, barH, `tgc-span${muted.has(span.state) ? " tgc-span-muted" : ""}${span.end === null ? " tgc-span-open" : ""}`, "rx=\"2\"");
1050
+ body += titled(spanLabel(lane, span), `<g data-state="${span.state.replace(/[^a-z0-9-]/gi, "")}"${span.end === null ? " data-open=\"true\"" : ""}>${mark}</g>`);
1051
+ }
1052
+ if (row.ticks?.length) body += drawTicks(row.ticks, lane.label, px, barTop(y), barH, fmt);
1053
+ if (countText) {
1054
+ const x = phone ? width - 12 : width - 16;
1055
+ body += titled(`${lane.label}: ${countText.sentence}${row.hidden ? ` across ${row.hidden + 1} lanes` : ""}`, `<text class="tgc-num tgc-count" x="${x}" y="${baseline}" font-size="11" text-anchor="end">${countText.ok}<tspan class="tgc-count-ok">✓</tspan> ${countText.failed}<tspan class="tgc-count-failed">✗</tspan></text>`);
1056
+ }
1057
+ body += "</g>";
1058
+ });
1059
+ for (const mark of onLane) {
1060
+ const y = top + shownAt(mark.lane) * rowH, cy = barTop(y) + barH / 2, x = px(mark.at);
1061
+ const glyph = markHead(mark.kind, x, cy, phone ? 3 : 3.5) || line(x, barTop(y) - 3, x, barTop(y) + barH + 3, `tgc-mark tgc-mark-${mark.kind}`);
1062
+ const verdict = mark.verdict ? ` data-verdict="${mark.verdict}" class="tgc-verdict-${mark.verdict}"` : "";
1063
+ body += titled(`${markName(mark)}: ${fmt(mark.at)}${mark.verdict ? ` · ${mark.verdict}` : ""}`, `<g data-mark="${mark.kind}" data-lane-mark="${esc(mark.lane)}"${verdict}>${glyph}</g>`);
1064
+ }
1065
+ if (o.now !== void 0) body += line(px(o.now), top, px(o.now), bottom, "tgc-now") + text(px(o.now), bottom + 30, `now ${fmt(o.now)}`, {
1066
+ size: 11,
1067
+ cls: "tgc-ink tgc-num",
1068
+ anchor: px(o.now) > (left + right) / 2 ? "end" : "start"
1069
+ });
1070
+ return svgRoot(width, bottom + (o.now === void 0 ? 22 : 36), body);
1071
+ };
1072
+ const all = counts(ticks);
1073
+ const tree = lanes.some((lane) => lane.parent !== void 0 || lane.depth);
1074
+ const folds = foldable && rows.some((row) => row.children);
1075
+ const laneTable = tree || ticks.length || lanes.some((lane) => lane.ticks || detail(lane).length) ? table(`${capitalize(o.measure)}: lanes`, [
1076
+ { label: "Lane" },
1077
+ { label: "Started by" },
1078
+ {
1079
+ label: "Depth",
1080
+ numeric: true
1081
+ },
1082
+ {
1083
+ label: "Attempts ok",
1084
+ numeric: true
1085
+ },
1086
+ {
1087
+ label: "Attempts failed",
1088
+ numeric: true
1089
+ },
1090
+ { label: "Failed by outcome" },
1091
+ { label: "Detail" }
1092
+ ], lanes.map((lane) => {
1093
+ const c = lane.ticks ? counts(lane.ticks) : void 0;
1094
+ return [
1095
+ lane.label,
1096
+ lane.parent === void 0 ? "" : byId.get(lane.parent).label,
1097
+ String(depthOf.get(lane.id)),
1098
+ c ? String(c.ok) : "not recorded",
1099
+ c ? String(c.failed) : "not recorded",
1100
+ c?.why ?? "",
1101
+ detail(lane).join(" · ")
1102
+ ];
1103
+ })) : "";
1104
+ return {
1105
+ id,
1106
+ finding: `${capitalize(o.measure)} across ${lanes.length} ${plural(lanes.length, "lane")}, ${fmt(t0)} to ${fmt(t1)}.`,
1107
+ lede: `${spans.length} recorded ${plural(spans.length, "span")}; ${open} open${o.now === void 0 ? "" : ` at ${fmt(o.now)}`}; ${noData.length} without data (${duration(noDataMs)}).${ticks.length ? ` ${ticks.length} recorded ${plural(ticks.length, "attempt")}: ${all.sentence}.` : ""}`,
1108
+ read: [
1109
+ `Each lane shows its recorded spans on one time axis. Muted spans are ${[...muted].join(", ") || "none"}; hatched spans have no data; an open span runs to now.`,
1110
+ ...tree ? [`An indented lane was started by the lane its connector leads to${folds ? "; a caret folds a lane's subtree, and a folded lane's ticks and counts include its subtree" : ""}.`] : [],
1111
+ ...hasTicks ? ["Each tick is one recorded attempt: ok ticks rise above the bar and failed ticks drop below it in their outcome's colour; the counts at the right are ok (✓) and failed (✗)."] : [],
1112
+ ...marks.length + bands.length ? [EVENTS_READ] : []
1113
+ ].join(" "),
1114
+ svg: {
1115
+ wide: draw(720),
1116
+ narrow: draw(358),
1117
+ ...folds ? { interactive: true } : {}
1118
+ },
1119
+ table: laneTable + table(`${capitalize(o.measure)} by lane`, [
1120
+ { label: "Lane" },
1121
+ { label: "State" },
1122
+ {
1123
+ label: "Start",
1124
+ numeric: true
1125
+ },
1126
+ {
1127
+ label: "End",
1128
+ numeric: true
1129
+ },
1130
+ {
1131
+ label: "Duration",
1132
+ numeric: true
1133
+ },
1134
+ { label: "Note" }
1135
+ ], spans.map(({ lane, span }) => [
1136
+ lane.label,
1137
+ span.state,
1138
+ fmt(span.start),
1139
+ span.end === null ? "open" : fmt(span.end),
1140
+ duration(ends(span) - span.start),
1141
+ span.note ?? ""
1142
+ ])) + eventsTable(marks, bands, "time", fmt),
1143
+ note: {
1144
+ method: `Recorded span start and end times as supplied by the producer; open spans are drawn to the supplied now. No span is inferred, merged or reordered.${ticks.length ? " Ticks are recorded attempts at their recorded times; ticks of one outcome within 2 px are drawn as one, with the count in its hover." : ""}`,
1145
+ n: `${spans.length} spans${ticks.length ? ` and ${ticks.length} attempts` : ""} in ${lanes.length} lanes.`,
1146
+ exclusions: [...noData.map(({ lane, span }) => `${lane.label}: ${span.state} ${fmt(span.start)} to ${span.end === null ? "now" : fmt(span.end)} has no data.`), ...[...before, ...after].map((mark) => `${markName(mark)} falls ${mark.at < t0 ? `${duration(t0 - mark.at)} before` : `${duration(mark.at - t1)} after`} the recorded window; it is named at the edge, not drawn.`)]
1147
+ }
1148
+ };
1149
+ }
1150
+ /** Ticks of one outcome within 2 px merge into one stroke whose hover gives the count and time range. */
1151
+ function drawTicks(ticks, label, px, barTop, barH, fmt) {
1152
+ const buckets = /* @__PURE__ */ new Map();
1153
+ for (const tick of [...ticks].sort((a, b) => a.at - b.at)) {
1154
+ const key = `${tick.outcome}|${Math.round(px(tick.at) / 2)}`;
1155
+ buckets.set(key, [...buckets.get(key) ?? [], tick]);
1156
+ }
1157
+ return `<g class="tgc-ticks">${[...buckets.values()].sort((a, b) => Number(a[0].outcome === "ok") - Number(b[0].outcome === "ok")).map((bucket) => {
1158
+ const first = bucket[0], last = bucket.at(-1), ok = first.outcome === "ok";
1159
+ const x = px(bucket.reduce((sum, tick) => sum + tick.at, 0) / bucket.length);
1160
+ const notes = /* @__PURE__ */ new Map();
1161
+ for (const tick of bucket) if (tick.note) notes.set(tick.note, (notes.get(tick.note) ?? 0) + 1);
1162
+ const note = [...notes].sort((a, b) => b[1] - a[1])[0]?.[0];
1163
+ return titled(`${label}: ${bucket.length > 1 ? `${bucket.length} × ` : ""}${first.outcome} ${fmt(first.at)}${bucket.length > 1 ? ` to ${fmt(last.at)}` : ""}${note ? ` · ${note.length > 160 ? `${note.slice(0, 159)}…` : note}` : ""}`, line(x, ok ? barTop - 4 : barTop + barH / 2, x, ok ? barTop + barH / 2 : barTop + barH + 4, `tgc-tick tgc-tick-${first.outcome}`));
1164
+ }).join("")}</g>`;
1165
+ }
1166
+ /** Clock-aligned ticks at the smallest step whose labels fit between `left` and `right` without overlapping. */
1167
+ function axisTicks(t0, t1, px, left, right, label) {
1168
+ const fit = (list) => {
1169
+ let end = -Infinity;
1170
+ for (const t of list) {
1171
+ const w = textWidth(label(t), 11, "mono"), a = px(t) - w / 2, b = px(t) + w / 2;
1172
+ if (a < left - 14 || b > right + 14 || a < end + 10) return false;
1173
+ end = b;
1174
+ }
1175
+ return true;
1176
+ };
1177
+ for (const step of STEPS) {
1178
+ const list = [];
1179
+ for (let t = Math.ceil(t0 / step) * step; t <= t1 && list.length <= 12; t += step) list.push(t);
1180
+ if (list.length >= 2 && list.length <= 12 && fit(list)) return list;
1181
+ }
1182
+ return fit([t0, t1]) ? [t0, t1] : [t0];
1183
+ }
1184
+ //#endregion
1185
+ export { esc as $, pts as A, MARKER_SHAPES as B, duration as C, kn as D, intervalName as E, tierSummary as F, line as G, hatch as H, tierTotal as I, rect as J, markerPath as K, usd as L, rateIntervalGap as M, rateOrder as N, pct as O, tierAtLeast as P, capitalize as Q, usdTick as R, drawable as S, intervalGap as T, hatchPath as U, circle as V, hbar as W, text as X, svgRoot as Y, titled as Z, markName as _, checkerClass as a, wrap as at, TIERS_BEST_FIRST as b, MARK_HEADS as c, drawGroups as d, fitLines as et, drawMarks as f, markLabelRows as g, groupRowHeight as h, OUTCOME_ORDER as i, textWidth as it, rateDataGap as j, perPass as k, MARK_VERDICTS as l, eventsTable as m, CHECKER_HUES as n, plural as nt, checkerColor as o, eventsGap as p, polyline as q, OUTCOME_COLORS as r, textBlock as rt, EVENTS_READ as s, timeline as t, listJoin as tt, drawBands as u, isRefusal as v, estimateNote as w, costText as x, METHOD_NAME as y, table as z };