@tangle-network/charts 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/dist/index.js ADDED
@@ -0,0 +1,1771 @@
1
+ // src/text.ts
2
+ function esc(value) {
3
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
4
+ }
5
+ var REGULAR = [
6
+ 281,
7
+ 288,
8
+ 466,
9
+ 633,
10
+ 642,
11
+ 982,
12
+ 644,
13
+ 300,
14
+ 365,
15
+ 365,
16
+ 501,
17
+ 662,
18
+ 288,
19
+ 460,
20
+ 288,
21
+ 360,
22
+ 631,
23
+ 407,
24
+ 610,
25
+ 618,
26
+ 646,
27
+ 608,
28
+ 620,
29
+ 566,
30
+ 619,
31
+ 620,
32
+ 288,
33
+ 302,
34
+ 662,
35
+ 662,
36
+ 662,
37
+ 511,
38
+ 966,
39
+ 690,
40
+ 654,
41
+ 730,
42
+ 722,
43
+ 601,
44
+ 590,
45
+ 746,
46
+ 743,
47
+ 269,
48
+ 571,
49
+ 672,
50
+ 565,
51
+ 903,
52
+ 753,
53
+ 765,
54
+ 639,
55
+ 765,
56
+ 644,
57
+ 642,
58
+ 646,
59
+ 744,
60
+ 690,
61
+ 985,
62
+ 682,
63
+ 679,
64
+ 629,
65
+ 365,
66
+ 360,
67
+ 365,
68
+ 471,
69
+ 456,
70
+ 323,
71
+ 562,
72
+ 612,
73
+ 571,
74
+ 612,
75
+ 583,
76
+ 370,
77
+ 613,
78
+ 591,
79
+ 242,
80
+ 242,
81
+ 549,
82
+ 242,
83
+ 876,
84
+ 591,
85
+ 600,
86
+ 612,
87
+ 612,
88
+ 376,
89
+ 528,
90
+ 327,
91
+ 591,
92
+ 562,
93
+ 818,
94
+ 546,
95
+ 562,
96
+ 552,
97
+ 426,
98
+ 333,
99
+ 426,
100
+ 662
101
+ ];
102
+ var SEMIBOLD = [
103
+ 252,
104
+ 321,
105
+ 523,
106
+ 644,
107
+ 650,
108
+ 1004,
109
+ 663,
110
+ 326,
111
+ 373,
112
+ 373,
113
+ 540,
114
+ 673,
115
+ 319,
116
+ 465,
117
+ 319,
118
+ 379,
119
+ 660,
120
+ 423,
121
+ 623,
122
+ 636,
123
+ 666,
124
+ 629,
125
+ 640,
126
+ 576,
127
+ 640,
128
+ 640,
129
+ 319,
130
+ 329,
131
+ 673,
132
+ 673,
133
+ 673,
134
+ 543,
135
+ 999,
136
+ 728,
137
+ 659,
138
+ 737,
139
+ 722,
140
+ 605,
141
+ 588,
142
+ 749,
143
+ 746,
144
+ 277,
145
+ 580,
146
+ 703,
147
+ 565,
148
+ 922,
149
+ 759,
150
+ 769,
151
+ 645,
152
+ 773,
153
+ 652,
154
+ 650,
155
+ 660,
156
+ 736,
157
+ 728,
158
+ 1020,
159
+ 720,
160
+ 713,
161
+ 652,
162
+ 373,
163
+ 379,
164
+ 373,
165
+ 481,
166
+ 469,
167
+ 351,
168
+ 574,
169
+ 624,
170
+ 583,
171
+ 624,
172
+ 591,
173
+ 389,
174
+ 625,
175
+ 612,
176
+ 262,
177
+ 262,
178
+ 569,
179
+ 262,
180
+ 900,
181
+ 612,
182
+ 609,
183
+ 624,
184
+ 624,
185
+ 397,
186
+ 549,
187
+ 353,
188
+ 612,
189
+ 587,
190
+ 839,
191
+ 569,
192
+ 588,
193
+ 566,
194
+ 455,
195
+ 359,
196
+ 455,
197
+ 673
198
+ ];
199
+ var EXTRA = {
200
+ "\xB7": 300,
201
+ "\xD7": 670,
202
+ "\u2014": 1e3,
203
+ "\u2013": 500,
204
+ "\u2026": 900,
205
+ "\u2265": 670,
206
+ "\u2264": 670,
207
+ "\xB1": 670,
208
+ "\u2192": 954,
209
+ "\u2212": 662
210
+ };
211
+ var MARGIN = 1.04;
212
+ function textWidth(text2, size, face = "sans") {
213
+ if (face === "mono") return text2.length * 0.6 * size * MARGIN;
214
+ const table2 = face === "sans-semibold" ? SEMIBOLD : REGULAR;
215
+ let units = 0;
216
+ for (const ch of text2) {
217
+ const code = ch.codePointAt(0) ?? 0;
218
+ units += code >= 32 && code <= 126 ? table2[code - 32] : EXTRA[ch] ?? 620;
219
+ }
220
+ return units / 1e3 * size * MARGIN;
221
+ }
222
+ function wrap(text2, width, size, face = "sans", maxLines = 2) {
223
+ const fits = (s) => textWidth(s, size, face) <= width;
224
+ const lines = [];
225
+ const glue = [];
226
+ let line2 = "";
227
+ const breakLine = (next, joiner) => {
228
+ lines.push(line2);
229
+ glue.push(joiner);
230
+ line2 = next;
231
+ };
232
+ for (const word of text2.split(/\s+/).filter(Boolean)) {
233
+ const joined = line2 ? `${line2} ${word}` : word;
234
+ if (fits(joined)) {
235
+ line2 = joined;
236
+ } else if (fits(word)) {
237
+ if (line2) breakLine(word, " ");
238
+ else line2 = word;
239
+ } else {
240
+ const pieces = word.match(/[^\-/_·]+[\-/_·]?|[\-/_·]/g) ?? [word];
241
+ for (const [index, piece] of pieces.entries()) {
242
+ const joiner = index === 0 ? " " : "";
243
+ const candidate = line2 ? `${line2}${joiner}${piece}` : piece;
244
+ if (fits(candidate) || !line2) line2 = candidate;
245
+ else breakLine(piece, joiner);
246
+ }
247
+ }
248
+ }
249
+ if (line2) lines.push(line2);
250
+ if (lines.length <= maxLines && lines.every(fits)) {
251
+ return { lines, truncated: false };
252
+ }
253
+ const kept = lines.slice(0, maxLines);
254
+ let last = lines.slice(maxLines - 1).reduce((acc, l, i) => i === 0 ? l : `${acc}${glue[maxLines - 2 + i]}${l}`, "");
255
+ while (last.length > 1 && !fits(`${last}\u2026`)) last = last.slice(0, -1);
256
+ kept[maxLines - 1] = `${last.trimEnd()}\u2026`;
257
+ return { lines: kept, truncated: true };
258
+ }
259
+ function textBlock(lines, x, y, lineHeight, attrs) {
260
+ if (lines.length === 1) return `<text x="${x}" y="${y}" ${attrs}>${esc(lines[0])}</text>`;
261
+ const spans = lines.map((line2, i) => `<tspan x="${x}" dy="${i === 0 ? 0 : lineHeight}">${esc(line2)}</tspan>`).join("");
262
+ return `<text x="${x}" y="${y}" ${attrs}>${spans}</text>`;
263
+ }
264
+ function fitLines(parts, width, size, face = "sans") {
265
+ const lines = [];
266
+ for (const part of parts) {
267
+ const last = lines[lines.length - 1];
268
+ if (last !== void 0 && textWidth(`${last} \xB7 ${part}`, size, face) <= width) lines[lines.length - 1] = `${last} \xB7 ${part}`;
269
+ else lines.push(part);
270
+ }
271
+ return lines;
272
+ }
273
+ function plural(count, one, many = `${one}s`) {
274
+ return count === 1 ? one : many;
275
+ }
276
+ function listJoin(items) {
277
+ if (items.length <= 1) return items.join("");
278
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
279
+ }
280
+ function capitalize(text2) {
281
+ return text2.charAt(0).toUpperCase() + text2.slice(1);
282
+ }
283
+
284
+ // src/block.ts
285
+ var slug = (id) => id.replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
286
+ function renderFigure(f, noteNumber) {
287
+ const id = slug(f.id);
288
+ const plotLabel = `${f.finding} ${f.lede}`;
289
+ const narrow2 = f.svg.narrow ? `<div class="tgc-narrow">${f.svg.narrow}</div>` : `<div class="tgc-narrow tgc-narrow-table">${f.table}</div>`;
290
+ return [
291
+ `<figure class="tgc-figure" id="fig-${id}" aria-labelledby="fig-${id}-finding">`,
292
+ `<h3 class="tgc-finding" id="fig-${id}-finding">${esc(f.finding)}<sup><a class="tgc-noteref" href="#note-${noteNumber}" id="ref-${noteNumber}" role="doc-noteref" aria-label="Note ${noteNumber}">${noteNumber}</a></sup></h3>`,
293
+ `<p class="tgc-lede">${esc(f.lede)}</p>`,
294
+ `<div class="tgc-plot" role="img" aria-label="${esc(plotLabel)}">`,
295
+ `<div class="tgc-wide">${f.svg.wide}</div>`,
296
+ narrow2,
297
+ `</div>`,
298
+ `<p class="tgc-read">${esc(f.read)}</p>`,
299
+ `<details class="tgc-data"><summary>Data</summary><div class="tgc-scroll" role="region" aria-label="${esc(f.finding)} data" tabindex="0">${f.table}</div></details>`,
300
+ `</figure>`
301
+ ].join("");
302
+ }
303
+ function renderNotes(figures) {
304
+ const items = figures.map((f, i) => {
305
+ const n = i + 1;
306
+ if (!f.note.method.trim() || !f.note.n.trim()) {
307
+ throw new Error(`renderNotes: figure ${f.id} has no method or no n; a note needs both.`);
308
+ }
309
+ const text2 = [f.note.method, f.note.n, ...f.note.exclusions].map(esc).join(" ");
310
+ return `<li id="note-${n}">${text2} <a class="tgc-backlink" href="#ref-${n}" role="doc-backlink" aria-label="Back to figure ${n}">\u21A9</a></li>`;
311
+ });
312
+ return `<ol class="tgc-notes">${items.join("")}</ol>`;
313
+ }
314
+ function renderRefusals(refusals) {
315
+ if (refusals.length === 0) return "";
316
+ return `<ul class="tgc-refusals">${refusals.map((r) => `<li>${esc(r.refused)}</li>`).join("")}</ul>`;
317
+ }
318
+
319
+ // src/svg.ts
320
+ var WIDE = 720;
321
+ var NARROW = 358;
322
+ var r2 = (n) => Math.round(n * 100) / 100;
323
+ function svgRoot(width, height, inner) {
324
+ const h = Math.ceil(height);
325
+ 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>`;
326
+ }
327
+ function hbar(x, y, w, h, cls) {
328
+ if (w <= 0) return "";
329
+ const r = Math.min(4, h / 2, w);
330
+ 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"/>`;
331
+ }
332
+ function rect(x, y, w, h, cls, extra = "") {
333
+ if (w <= 0 || h <= 0) return "";
334
+ return `<rect class="${cls}" x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}"${extra ? ` ${extra}` : ""}/>`;
335
+ }
336
+ function line(x1, y1, x2, y2, cls) {
337
+ return `<line class="${cls}" x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}"/>`;
338
+ }
339
+ function circle(cx, cy, r, cls) {
340
+ return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
341
+ }
342
+ function polyline(points, cls) {
343
+ return `<polyline class="${cls}" points="${points.map(([x, y]) => `${r2(x)},${r2(y)}`).join(" ")}"/>`;
344
+ }
345
+ function text(x, y, value, o = {}) {
346
+ const anchor = o.anchor && o.anchor !== "start" ? ` text-anchor="${o.anchor}"` : "";
347
+ const weight = o.weight === 600 ? ` font-weight="600"` : "";
348
+ return `<text class="${o.cls ?? "tgc-ink"}" x="${r2(x)}" y="${r2(y)}" font-size="${o.size ?? 12}"${anchor}${weight}>${esc(value)}</text>`;
349
+ }
350
+ function titled(title, inner) {
351
+ return `<g><title>${esc(title)}</title>${inner}</g>`;
352
+ }
353
+
354
+ // src/table.ts
355
+ function table(caption, columns, rows) {
356
+ const head = columns.map((c) => `<th scope="col"${c.numeric ? ' class="tgc-n"' : ""}>${esc(c.label)}</th>`).join("");
357
+ const body = rows.map(
358
+ (row) => `<tr>${row.map(
359
+ (cell, i) => i === 0 ? `<th scope="row">${esc(cell)}</th>` : `<td${columns[i]?.numeric ? ' class="tgc-n"' : ""}>${esc(cell)}</td>`
360
+ ).join("")}</tr>`
361
+ ).join("");
362
+ return `<table class="tgc-table"><caption>${esc(caption)}</caption><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
363
+ }
364
+
365
+ // src/breakdown.ts
366
+ function breakdown(input, o) {
367
+ const id = o.id ?? "breakdown";
368
+ const { reasons, setups } = input;
369
+ if (reasons.length === 0 || setups.length === 0) return { id, refused: `No ${o.measure} to break down.` };
370
+ const count = /* @__PURE__ */ new Map();
371
+ for (const c of input.counts) {
372
+ if (!reasons.includes(c.reason) || !setups.some((s) => s.id === c.setup)) {
373
+ return { id, refused: `A count names ${c.reason} \xD7 ${c.setup}, which is not in the reason and setup lists.` };
374
+ }
375
+ const n = setups.find((s) => s.id === c.setup).n;
376
+ if (c.count !== null && n !== null && c.count > n) {
377
+ return { id, refused: `${c.reason} \xD7 ${c.setup}: ${c.count} exceeds the column's ${n} attempts.` };
378
+ }
379
+ count.set(`${c.reason}\0${c.setup}`, c.count);
380
+ }
381
+ const get = (reason, setup) => count.get(`${reason}\0${setup}`) ?? null;
382
+ const withN = setups.filter((s) => s.n !== null);
383
+ const total = withN.reduce((s, x) => s + x.n, 0);
384
+ const sums = reasons.map((r) => ({ r, k: withN.reduce((s, x) => s + (get(r, x.id) ?? 0), 0) }));
385
+ const top = Math.max(...sums.map((x) => x.k));
386
+ const leaders = sums.filter((x) => x.k === top).map((x) => x.r);
387
+ const finding = total === 0 || top === 0 ? `No ${o.measure} recorded.` : leaders.length === 1 ? `${capitalize(leaders[0])} stops the most attempts: ${top} of ${total}.` : `${capitalize(listJoin(leaders))} each stop ${top} of ${total} attempts.`;
388
+ const lede = `${total} ${plural(total, "attempt")} across ${setups.length} ${plural(setups.length, "setup")}, ${reasons.length} ${plural(reasons.length, "reason")}.`;
389
+ const read = "Each cell counts attempts stopped by that reason out of the column's n; the tint deepens with the share." + (input.overlapping ? " One attempt can count under more than one reason." : "");
390
+ const noN = setups.filter((s) => s.n === null);
391
+ const exclusions = [
392
+ ...input.excluded ? [`${input.excluded.count} ${plural(input.excluded.count, "attempt")} excluded: ${input.excluded.why}.`] : [],
393
+ ...noN.length ? [`No share for ${listJoin(noN.map((s) => s.label))}: the column has no n.`] : []
394
+ ];
395
+ const tableHtml = table(
396
+ `${capitalize(o.measure)} by setup`,
397
+ [{ label: "Reason" }, ...setups.map((s) => ({ label: `${s.label} (n=${s.n ?? "unknown"})`, numeric: true }))],
398
+ reasons.map((r) => [r, ...setups.map((s) => {
399
+ const k = get(r, s.id);
400
+ return k === null ? "not recorded" : String(k);
401
+ })])
402
+ );
403
+ return {
404
+ id,
405
+ finding,
406
+ lede,
407
+ read,
408
+ svg: { wide: wide(input, get), narrow: null },
409
+ table: tableHtml,
410
+ note: {
411
+ method: `Counts of ${o.measure} per setup${input.overlapping ? "; reasons overlap" : ""}. Tint is count over n, capped at half strength.`,
412
+ n: `${total} ${plural(total, "attempt")} over ${setups.length} ${plural(setups.length, "setup")}.`,
413
+ exclusions
414
+ }
415
+ };
416
+ }
417
+ function wide(input, get) {
418
+ const x0 = 208;
419
+ const colW = (WIDE - x0) / input.setups.length;
420
+ let head = "";
421
+ let headH = 0;
422
+ for (const [j, s] of input.setups.entries()) {
423
+ const label = wrap(s.label, colW - 8, 12, "sans", 3);
424
+ head += titled(s.label, textBlock(label.lines, x0 + j * colW, 14, 16, `class="tgc-ink" font-size="12"`));
425
+ head += text(x0 + j * colW, 14 + label.lines.length * 16, `n=${s.n ?? "?"}`, { cls: "tgc-ink-muted tgc-num" });
426
+ headH = Math.max(headH, label.lines.length * 16 + 16);
427
+ }
428
+ let y = headH + 8;
429
+ let body = "";
430
+ for (const reason of input.reasons) {
431
+ const label = wrap(reason, 196, 14, "sans", 2);
432
+ const h = Math.max(32, label.lines.length * 18 + 12);
433
+ body += textBlock(label.lines, 0, y + 20, 18, `class="tgc-ink" font-size="14"`);
434
+ for (const [j, s] of input.setups.entries()) {
435
+ const k = get(reason, s.id);
436
+ const x = x0 + j * colW;
437
+ const share = k !== null && s.n ? k / s.n : null;
438
+ let marks = share !== null ? rect(x, y + 2, colW - 8, h - 4, "tgc-tint", `rx="2" style="--s:${share.toFixed(3)}"`) : "";
439
+ marks += text(x + 8, y + 20, k === null ? "\xB7" : String(k), { cls: k === null ? "tgc-ink-muted tgc-num" : "tgc-ink tgc-num" });
440
+ body += titled(`${reason} \xB7 ${s.label}: ${k === null ? "not recorded" : `${k} of ${s.n ?? "?"}`}`, marks);
441
+ }
442
+ y += h;
443
+ }
444
+ return svgRoot(WIDE, y + 4, head + body);
445
+ }
446
+
447
+ // src/format.ts
448
+ var TIERS_BEST_FIRST = ["S", "A", "B", "C", "F"];
449
+ var LADDER = ["F", "C", "B", "A", "S"];
450
+ function tierAtLeast(tier, floor) {
451
+ return LADDER.indexOf(tier) >= LADDER.indexOf(floor);
452
+ }
453
+ function pct(value) {
454
+ return `${Math.round(value * 100)}%`;
455
+ }
456
+ function pts(value) {
457
+ const n = Math.round(value * 100);
458
+ return `${n > 0 ? "+" : n < 0 ? "\u2212" : ""}${Math.abs(n)} pts`;
459
+ }
460
+ function usd(value) {
461
+ if (value >= 100) return `$${Math.round(value).toLocaleString("en-US")}`;
462
+ if (value >= 1) return `$${value.toFixed(2)}`;
463
+ if (value >= 0.01) return `$${value.toFixed(3)}`;
464
+ return `$${value.toFixed(4)}`;
465
+ }
466
+ function usdTick(value) {
467
+ return `$${Number(value.toPrecision(2)).toLocaleString("en-US", { maximumFractionDigits: 4 })}`;
468
+ }
469
+ function duration(ms) {
470
+ const s = ms / 1e3;
471
+ if (s < 60) return `${Math.round(s)} s`;
472
+ if (s < 3600) return `${(s / 60).toFixed(1)} min`;
473
+ return `${(s / 3600).toFixed(1)} h`;
474
+ }
475
+ function kn(k, n) {
476
+ return `${k}/${n}`;
477
+ }
478
+ var METHOD_NAME = {
479
+ "task-cluster-bootstrap": "task-clustered bootstrap",
480
+ "mcnemar-exact": "exact McNemar",
481
+ wilson: "Wilson score"
482
+ };
483
+ function intervalName(interval) {
484
+ return `${Math.round((interval.level ?? 0) * 100)}% ${interval.method ? METHOD_NAME[interval.method] : ""}`.trim();
485
+ }
486
+ function intervalGap(interval) {
487
+ if (!interval) return "no interval";
488
+ const missing = [interval.level === null ? "level" : "", interval.method === null ? "method" : ""].filter(Boolean);
489
+ if (missing.length) return `the interval names no ${missing.join(" or ")}`;
490
+ if (!(interval.lower <= interval.upper)) return "the interval bounds are out of order";
491
+ return null;
492
+ }
493
+ function drawable(estimate) {
494
+ return estimate === "descriptive" || estimate === "bootstrap";
495
+ }
496
+ function estimateNote(estimate, units) {
497
+ if (estimate === "none") return units === 1 ? "1 unit" : `${units} units`;
498
+ if (estimate === "insufficient") return "too few units";
499
+ return "";
500
+ }
501
+ function costText(cost, solved) {
502
+ if (cost.perSolvedUsd === null) return solved === 0 ? "no pass" : "unknown";
503
+ return cost.basis === "receipts" ? usd(cost.perSolvedUsd) : `${usd(cost.perSolvedUsd)} est.`;
504
+ }
505
+ function tierSummary(tiers, unmeasured = 0, flagged = 0) {
506
+ const parts = tiers ? TIERS_BEST_FIRST.filter((t) => tiers[t] > 0).map((t) => `${tiers[t]}${t}`) : [];
507
+ if (flagged) parts.push(`${flagged} flagged`);
508
+ if (unmeasured) parts.push(`${unmeasured}U`);
509
+ return parts.join(" ");
510
+ }
511
+ function tierTotal(tiers) {
512
+ return tiers ? LADDER.reduce((sum, t) => sum + tiers[t], 0) : 0;
513
+ }
514
+
515
+ // src/scale.ts
516
+ function linear(domain, range) {
517
+ const [d0, d1] = domain;
518
+ const [r0, r1] = range;
519
+ const slope = (r1 - r0) / (d1 - d0 || 1);
520
+ const scale = ((value) => r0 + (value - d0) * slope);
521
+ scale.domain = domain;
522
+ scale.range = range;
523
+ return scale;
524
+ }
525
+ function log(domain, range) {
526
+ const inner = linear([Math.log10(domain[0]), Math.log10(domain[1])], range);
527
+ const scale = ((value) => inner(Math.log10(value)));
528
+ scale.domain = domain;
529
+ scale.range = range;
530
+ return scale;
531
+ }
532
+ function linearTicks(min, max, count) {
533
+ const span = max - min;
534
+ if (span <= 0) return [min];
535
+ const rough = span / count;
536
+ const exp = 10 ** Math.floor(Math.log10(rough));
537
+ const fraction = rough / exp;
538
+ const step = (fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10) * exp;
539
+ const out = [];
540
+ for (let v = Math.ceil(min / step) * step; v <= max + step * 1e-9; v += step) {
541
+ out.push(Number(v.toFixed(10)));
542
+ }
543
+ return out;
544
+ }
545
+ function logTicks(min, max) {
546
+ const sets = [[1], [1, 2, 5], [1, 2, 3, 5], [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8]];
547
+ let best = [];
548
+ for (const mantissas of sets) {
549
+ const out = [];
550
+ for (let e = Math.floor(Math.log10(min)) - 1; e <= Math.ceil(Math.log10(max)); e++) {
551
+ for (const m of mantissas) {
552
+ const v = Number((m * 10 ** e).toPrecision(6));
553
+ if (v >= min && v <= max) out.push(v);
554
+ }
555
+ }
556
+ best = out;
557
+ if (out.length >= 3) break;
558
+ }
559
+ return best;
560
+ }
561
+ function logDomain(lo, hi, minFactor = 3, pad = 1.25) {
562
+ let a = lo;
563
+ let b = hi;
564
+ if (b / a < minFactor) {
565
+ const centre = Math.sqrt(a * b);
566
+ const half = Math.sqrt(minFactor);
567
+ a = centre / half;
568
+ b = centre * half;
569
+ }
570
+ return [a / pad, b * pad];
571
+ }
572
+
573
+ // src/comparison-intervals.ts
574
+ function gaps(row) {
575
+ const out = [];
576
+ const gap = intervalGap(row.interval);
577
+ if (gap) out.push(gap);
578
+ if (row.pairs === null) out.push("no pair count");
579
+ if (row.minimumEffect === null) out.push("no registered minimum effect");
580
+ return out;
581
+ }
582
+ function comparisonIntervals(rows, setups, o) {
583
+ const id = o.id ?? "comparison-intervals";
584
+ const name = (setupId) => setups.find((s) => s.id === setupId)?.label ?? setupId;
585
+ const title = (row) => `${name(row.favored)} over ${name(row.other)}`;
586
+ if (rows.length === 0) return { id, refused: "No comparison was registered." };
587
+ const drawn = [];
588
+ const refused = [];
589
+ for (const row of rows) {
590
+ const missing = gaps(row);
591
+ if (missing.length) refused.push(`${title(row)}: ${listJoin(missing)}`);
592
+ else drawn.push({ row, title: title(row) });
593
+ }
594
+ if (drawn.length === 0) {
595
+ return {
596
+ id,
597
+ refused: rows.every((r) => r.interval === null) ? "No comparison carries an interval." : `No comparison can be drawn. ${refused.join("; ")}.`
598
+ };
599
+ }
600
+ const clears = drawn.filter(
601
+ ({ row }) => row.interval.lower > row.minimumEffect && (intervalGap(row.exactInterval) !== null || row.exactInterval.lower > row.minimumEffect)
602
+ );
603
+ const finding = clears.length === drawn.length ? drawn.length === 1 ? "The step clears its registered minimum effect." : `Each of the ${drawn.length} steps clears its registered minimum effect.` : clears.length === 0 ? `No step clears its registered minimum effect.` : `${clears.length} of ${drawn.length} steps clear their registered minimum effect.`;
604
+ const pairs = drawn.reduce((s, d) => s + d.row.pairs, 0);
605
+ const lede = `${drawn.length} ${plural(drawn.length, "comparison")} over ${pairs} paired ${plural(pairs, "attempt")}${refused.length ? `; ${refused.length} not drawn` : ""}.`;
606
+ const names = [...new Set(drawn.map((d) => intervalName(d.row.interval)))];
607
+ const exactNames = [
608
+ ...new Set(drawn.filter((d) => intervalGap(d.row.exactInterval) === null).map((d) => intervalName(d.row.exactInterval)))
609
+ ];
610
+ const read = `Each bar is the ${names.join(" or ")} interval on the difference in ${o.measure}` + (exactNames.length ? `; the thin bar under it is the ${exactNames.join(" or ")} interval` : "") + ". A step is backed when every bar lies wholly right of the dashed minimum effect.";
611
+ const columns = [
612
+ { label: "Comparison" },
613
+ { label: "Pairs", numeric: true },
614
+ { label: "Minimum effect", numeric: true },
615
+ { label: "Interval", numeric: true },
616
+ { label: "Exact interval", numeric: true }
617
+ ];
618
+ const fmt = (i) => i && intervalGap(i) === null ? `${pts(i.lower)} to ${pts(i.upper)} (${intervalName(i)})` : i ? `${pts(i.lower)} to ${pts(i.upper)} (${intervalGap(i)})` : "none";
619
+ const tableRows = rows.map((row) => [
620
+ title(row),
621
+ row.pairs === null ? "not recorded" : String(row.pairs),
622
+ row.minimumEffect === null ? "none registered" : pts(row.minimumEffect),
623
+ fmt(row.interval),
624
+ fmt(row.exactInterval)
625
+ ]);
626
+ const values = drawn.flatMap(({ row }) => [
627
+ row.interval.lower,
628
+ row.interval.upper,
629
+ row.minimumEffect,
630
+ 0,
631
+ ...row.exactInterval ? [row.exactInterval.lower, row.exactInterval.upper] : []
632
+ ]);
633
+ const span = Math.max(...values) - Math.min(...values) || 0.1;
634
+ const domain = [Math.min(...values) - span * 0.08, Math.max(...values) + span * 0.08];
635
+ return {
636
+ id,
637
+ finding,
638
+ lede,
639
+ read,
640
+ svg: { wide: draw(drawn, domain, WIDE, 272, 264), narrow: draw(drawn, domain, NARROW, 0, 0) },
641
+ table: table(`Comparisons on ${o.measure}`, columns, tableRows),
642
+ note: {
643
+ method: `${capitalize(o.measure)} difference, favored minus other, in points. Intervals: ${[...names, ...exactNames].join(", ")}.`,
644
+ n: `${pairs} paired ${plural(pairs, "attempt")} over ${drawn.length} ${plural(drawn.length, "comparison")}.`,
645
+ exclusions: refused.map((r) => `Not drawn: ${r}.`)
646
+ }
647
+ };
648
+ }
649
+ function draw(drawn, domain, width, plotX, labelW) {
650
+ const x = linear(domain, [plotX, width - 8]);
651
+ let body = "";
652
+ let y = 8;
653
+ const rowsTop = y;
654
+ for (const { row, title } of drawn) {
655
+ let marks = "";
656
+ let barY;
657
+ if (labelW > 0) {
658
+ const label = wrap(title, labelW, 14);
659
+ marks += textBlock(label.lines, 0, y + 14, 18, `class="tgc-ink" font-size="14"`);
660
+ marks += text(0, y + 14 + label.lines.length * 18, `${row.pairs} pairs`, { cls: "tgc-ink-muted tgc-num" });
661
+ barY = y + 8;
662
+ y += Math.max(label.lines.length * 18 + 22, 36);
663
+ } else {
664
+ const label = wrap(title, width, 14);
665
+ marks += textBlock(label.lines, 0, y + 14, 18, `class="tgc-ink" font-size="14"`);
666
+ marks += text(width, y + 14, `${row.pairs} pairs`, { cls: "tgc-ink-muted tgc-num", anchor: "end" });
667
+ barY = y + label.lines.length * 18 + 4;
668
+ y = barY + 24;
669
+ }
670
+ const lo = x(row.interval.lower);
671
+ const hi = x(row.interval.upper);
672
+ marks += rect(lo, barY, Math.max(hi - lo, 2), 6, "tgc-ci");
673
+ if (row.exactInterval && intervalGap(row.exactInterval) === null) {
674
+ const elo = x(row.exactInterval.lower);
675
+ const ehi = x(row.exactInterval.upper);
676
+ marks += rect(elo, barY + 9, Math.max(ehi - elo, 2), 2, "tgc-ci-exact");
677
+ }
678
+ const me = x(row.minimumEffect);
679
+ marks += line(me, barY - 8, me, barY + 20, "tgc-threshold");
680
+ body += titled(
681
+ `${title} \xB7 ${row.pairs} pairs \xB7 ${pts(row.interval.lower)} to ${pts(row.interval.upper)} \xB7 minimum effect ${pts(row.minimumEffect)}`,
682
+ marks
683
+ );
684
+ }
685
+ const zero = x(0);
686
+ let axis = line(zero, rowsTop, zero, y, "tgc-zero");
687
+ for (const t of linearTicks(domain[0], domain[1], width > 400 ? 6 : 3)) {
688
+ axis += text(x(t), y + 16, pts(t), { cls: "tgc-ink-muted tgc-num", anchor: "middle" });
689
+ }
690
+ return svgRoot(width, y + 24, axis + body);
691
+ }
692
+
693
+ // src/cost-frontier.ts
694
+ var overlaps = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
695
+ function costFrontier(rows, o) {
696
+ const id = o.id ?? "cost-frontier";
697
+ const costed = rows.filter((row) => row.cost.perSolvedUsd !== null && row.cost.perSolvedUsd > 0);
698
+ const unplaced = rows.filter((row) => !costed.includes(row));
699
+ if (costed.length === 0) return { id, refused: `No setup has a cost per pass, so cost is not drawn.` };
700
+ if (costed.length < 2) {
701
+ return {
702
+ id,
703
+ refused: `Fewer than two setups have a cost per pass (${costed.length} of ${rows.length}); ${unplacedText(unplaced)}.`
704
+ };
705
+ }
706
+ const front = costed.filter((row) => row.onFront === true);
707
+ const drawLine = front.length >= 2;
708
+ const cheapestFront = [...front].sort((a, b) => a.cost.perSolvedUsd - b.cost.perSolvedUsd)[0];
709
+ const top = rows.every((r) => r.rank !== null) ? [...rows].sort((a, b) => a.rank - b.rank)[0] : [...rows].sort((a, b) => b.rate - a.rate)[0];
710
+ const est = (row) => row.cost.basis === "receipts" ? "" : " (estimated)";
711
+ let finding;
712
+ if (cheapestFront && top.cost.perSolvedUsd !== null && cheapestFront.id !== top.id) {
713
+ const saving = 1 - cheapestFront.cost.perSolvedUsd / top.cost.perSolvedUsd;
714
+ finding = `${cheapestFront.label} costs ${usd(cheapestFront.cost.perSolvedUsd)} per pass${est(cheapestFront)}, ${pct(saving)} less than ${top.label}.`;
715
+ } else if (cheapestFront && cheapestFront.id === top.id) {
716
+ finding = `${top.label} leads on both ${o.measure} and cost per pass.`;
717
+ } else {
718
+ const cheapest = [...costed].sort((a, b) => a.cost.perSolvedUsd - b.cost.perSolvedUsd)[0];
719
+ finding = `${cheapest.label} has the lowest cost per pass, ${usd(cheapest.cost.perSolvedUsd)}${est(cheapest)}.`;
720
+ }
721
+ const lede = `${costed.length} of ${rows.length} setups have a cost per pass; ` + (drawLine ? `${front.length} sit on the producer's frontier.` : front.length === 1 ? "1 sits on the producer's frontier." : "the producer computed no frontier.");
722
+ const read = `Up is a higher ${o.measure}; right is cheaper per pass.` + (drawLine ? " Filled points sit on the frontier; the line joins them." : "") + (costed.some((r) => r.cost.basis !== "receipts") ? " A hollow accent ring is a frontier point with an estimated cost." : "");
723
+ const intervals = [...new Set(costed.filter((r) => intervalGap(r.interval) === null).map((r) => intervalName(r.interval)))];
724
+ const bases = [...new Set(costed.map((r) => r.cost.basis))];
725
+ const method = [
726
+ `Cost per pass is model spend over passed attempts, ${bases.map((b) => b === "receipts" ? "from router receipts" : b === "estimated" ? "estimated, not billed" : "from an unrecorded source").join(" or ")}.`,
727
+ "The frontier is the producer's: no setup off it is both cheaper and higher.",
728
+ intervals.length ? `Vertical lines: ${intervals.join(", ")} on the ${o.measure}.` : ""
729
+ ].filter(Boolean).join(" ");
730
+ const columns = [
731
+ { label: "Setup" },
732
+ { label: capitalize(o.measure), numeric: true },
733
+ { label: "Passed", numeric: true },
734
+ { label: "Cost per pass", numeric: true },
735
+ { label: "Cost basis" },
736
+ { label: "On frontier" }
737
+ ];
738
+ const sorted = [...rows].sort((a, b) => b.rate - a.rate);
739
+ const tableRows = sorted.map((row) => [
740
+ row.label,
741
+ pct(row.rate),
742
+ kn(row.solved, row.attempts),
743
+ costText(row.cost, row.solved),
744
+ row.cost.basis,
745
+ row.onFront === null ? "not computed" : row.onFront ? "yes" : "no"
746
+ ]);
747
+ const { svg, numbered } = plot(costed, unplaced, drawLine, o);
748
+ const tableHtml = table(
749
+ `${capitalize(o.measure)} and cost per pass`,
750
+ numbered.size ? [{ label: "Setup" }, { label: "Mark", numeric: true }, ...columns.slice(1)] : columns,
751
+ numbered.size ? sorted.map((row, i) => [tableRows[i][0], numbered.get(row.id) ?? "", ...tableRows[i].slice(1)]) : tableRows
752
+ );
753
+ return {
754
+ id,
755
+ finding,
756
+ lede,
757
+ read,
758
+ svg: { wide: svg, narrow: narrowList(sorted) },
759
+ table: tableHtml,
760
+ note: {
761
+ method,
762
+ n: `${rows.reduce((s, r) => s + r.attempts, 0)} attempts over ${rows.length} setups.`,
763
+ exclusions: unplaced.length ? [`Not placed: ${unplacedText(unplaced)}.`] : []
764
+ }
765
+ };
766
+ }
767
+ function narrowList(rows) {
768
+ const x1 = 300;
769
+ let out = "";
770
+ let y = 4;
771
+ for (const [i, row] of rows.entries()) {
772
+ const placed = row.cost.perSolvedUsd !== null && row.cost.perSolvedUsd > 0;
773
+ const label = wrap(row.label, NARROW - 16, 14);
774
+ let marks = placed ? circle(4, y + 10, 4, row.onFront ? row.cost.basis === "receipts" ? "tgc-pt-front" : "tgc-pt-est" : "tgc-pt") : "";
775
+ marks += textBlock(label.lines, 16, y + 14, 18, `class="tgc-ink" font-size="14"`);
776
+ let cy = y + label.lines.length * 18 + 6;
777
+ if (drawable(row.estimate)) {
778
+ let end = row.rate * x1;
779
+ marks += hbar(0, cy, end, 10, "tgc-bar");
780
+ if (intervalGap(row.interval) === null) {
781
+ const lo = row.interval.lower * x1;
782
+ const hi = row.interval.upper * x1;
783
+ marks += line(lo, cy + 5, hi, cy + 5, "tgc-whisker") + line(lo, cy + 1, lo, cy + 9, "tgc-whisker") + line(hi, cy + 1, hi, cy + 9, "tgc-whisker");
784
+ end = Math.max(end, hi);
785
+ }
786
+ marks += text(end + 6, cy + 9.5, pct(row.rate), { cls: "tgc-ink tgc-num" });
787
+ cy += 16;
788
+ }
789
+ const count = `${kn(row.solved, row.attempts)} passed${drawable(row.estimate) ? "" : `, ${estimateNote(row.estimate, row.attempts)}`}`;
790
+ const fact = placed ? `${count} \xB7 ${costText(row.cost, row.solved)} per pass${row.onFront ? " \xB7 on the frontier" : ""}` : `${count} \xB7 not placed`;
791
+ const facts = fitLines(fact.split(" \xB7 "), NARROW, 12, "mono");
792
+ marks += textBlock(facts, 0, cy + 12, 16, `class="tgc-ink-muted tgc-num" font-size="12"`);
793
+ out += titled(`${row.label} \xB7 ${fact}`, marks);
794
+ y = cy + 4 + facts.length * 16;
795
+ if (i < rows.length - 1) out += line(0, y + 2, NARROW, y + 2, "tgc-rule");
796
+ y += 10;
797
+ }
798
+ if (rows.some((r) => drawable(r.estimate))) {
799
+ for (const t of [0, 0.5, 1]) {
800
+ out += text(t * x1, y + 6, pct(t), { cls: "tgc-ink-muted tgc-num", anchor: t === 0 ? "start" : t === 1 ? "end" : "middle" });
801
+ }
802
+ y += 12;
803
+ }
804
+ return svgRoot(NARROW, y, out);
805
+ }
806
+ function unplacedText(rows) {
807
+ return listJoin(rows.map((r) => `${r.label}, ${r.solved === 0 ? "0 passed" : "cost unknown"}`));
808
+ }
809
+ function plot(costed, unplaced, drawLine, o) {
810
+ const left = 48;
811
+ const right = WIDE - 24;
812
+ const top = 32;
813
+ const bottom = 272;
814
+ const costs = costed.map((r) => r.cost.perSolvedUsd);
815
+ const [lo, hi] = logDomain(Math.min(...costs), Math.max(...costs));
816
+ const x = log([lo, hi], [left, right].reverse());
817
+ const y = (v) => bottom - v * (bottom - top);
818
+ let out = text(0, 14, capitalize(o.measure), { cls: "tgc-ink-muted" });
819
+ for (const v of [0, 0.25, 0.5, 0.75, 1]) {
820
+ out += line(left, y(v), right, y(v), v === 0 ? "tgc-axis" : "tgc-gridline");
821
+ out += text(left - 8, y(v) + 4, pct(v), { cls: "tgc-ink-muted tgc-num", anchor: "end" });
822
+ }
823
+ for (const t of logTicks(lo, hi)) {
824
+ out += line(x(t), bottom, x(t), bottom + 4, "tgc-axis");
825
+ out += text(x(t), bottom + 18, usdTick(t), { cls: "tgc-ink-muted tgc-num", anchor: "middle" });
826
+ }
827
+ out += text((left + right) / 2, bottom + 40, "Cost per pass, log scale, cheaper to the right", {
828
+ cls: "tgc-ink-muted",
829
+ anchor: "middle"
830
+ });
831
+ const placed = costed.map((row) => ({
832
+ row,
833
+ x: x(row.cost.perSolvedUsd),
834
+ y: y(row.rate),
835
+ estimated: row.cost.basis !== "receipts"
836
+ }));
837
+ if (drawLine) {
838
+ const pts2 = placed.filter((p) => p.row.onFront).sort((a, b) => a.x - b.x);
839
+ out += polyline(pts2.map((p) => [p.x, p.y]), "tgc-front-line");
840
+ }
841
+ for (const p of placed) {
842
+ if (intervalGap(p.row.interval) === null) {
843
+ out += line(p.x, y(p.row.interval.lower), p.x, y(p.row.interval.upper), "tgc-pt-ci");
844
+ }
845
+ }
846
+ const obstacles = placed.map((p) => ({ x0: p.x - 6, y0: p.y - 6, x1: p.x + 6, y1: p.y + 6 }));
847
+ for (const p of placed) {
848
+ if (intervalGap(p.row.interval) === null) {
849
+ obstacles.push({ x0: p.x - 2, y0: y(p.row.interval.upper), x1: p.x + 2, y1: y(p.row.interval.lower) });
850
+ }
851
+ }
852
+ const bounds = { x0: 0, y0: top - 8, x1: WIDE, y1: bottom - 2 };
853
+ const labels = [];
854
+ const numbered = /* @__PURE__ */ new Map();
855
+ for (const p of placed) {
856
+ const w = textWidth(p.row.label, 12);
857
+ const near = [
858
+ [p.x + 9, p.y + 4, "start", false],
859
+ [p.x - 9, p.y + 4, "end", false],
860
+ [p.x, p.y - 10, "middle", false],
861
+ [p.x, p.y + 18, "middle", false],
862
+ [p.x + 26, p.y - 20, "start", true],
863
+ [p.x - 26, p.y - 20, "end", true],
864
+ [p.x + 26, p.y + 26, "start", true],
865
+ [p.x - 26, p.y + 26, "end", true]
866
+ ];
867
+ let done = false;
868
+ for (const [lx, ly, anchor, leader] of near) {
869
+ const x0 = anchor === "start" ? lx : anchor === "end" ? lx - w : lx - w / 2;
870
+ const box = { x0, y0: ly - 11, x1: x0 + w, y1: ly + 3 };
871
+ const inside = box.x0 >= bounds.x0 && box.x1 <= bounds.x1 && box.y0 >= bounds.y0 && box.y1 <= bounds.y1;
872
+ if (!inside || obstacles.some((b) => overlaps(b, box))) continue;
873
+ obstacles.push(box);
874
+ if (leader) labels.push(line(p.x, p.y, anchor === "start" ? lx - 3 : lx + 3, ly - 4, "tgc-leader"));
875
+ labels.push(text(lx, ly, p.row.label, { cls: "tgc-ink", anchor }));
876
+ done = true;
877
+ break;
878
+ }
879
+ if (!done) {
880
+ const mark = String(numbered.size + 1);
881
+ numbered.set(p.row.id, mark);
882
+ labels.push(text(p.x + 8, p.y - 6, mark, { cls: "tgc-ink tgc-num" }));
883
+ }
884
+ }
885
+ for (const p of placed) {
886
+ const cls = p.row.onFront ? p.estimated ? "tgc-pt-est" : "tgc-pt-front" : "tgc-pt";
887
+ out += titled(
888
+ `${p.row.label} \xB7 ${pct(p.row.rate)} ${o.measure} \xB7 ${costText(p.row.cost, p.row.solved)} per pass${p.row.onFront ? " \xB7 on the frontier" : ""}`,
889
+ circle(p.x, p.y, 4, cls)
890
+ );
891
+ }
892
+ out += labels.join("");
893
+ let h = bottom + 48;
894
+ if (unplaced.length) {
895
+ const strip = wrap(`Not placed: ${unplacedText(unplaced)}.`, WIDE, 12, "sans", 4);
896
+ for (const [i, lineText] of strip.lines.entries()) {
897
+ out += text(0, h + 12 + i * 16, lineText, { cls: "tgc-ink-muted" });
898
+ }
899
+ h += strip.lines.length * 16 + 8;
900
+ }
901
+ return { svg: svgRoot(WIDE, h, out), numbered };
902
+ }
903
+
904
+ // src/css.generated.ts
905
+ var figureCss = `/* @tangle-network/charts \u2014 figure block and mark styles.
906
+ *
907
+ * Every colour is a --chart-* token from @tangle-network/brand tokens.css, so a
908
+ * figure follows the page theme. A standalone document without brand tokens
909
+ * embeds \`chartTokensCss\` first. Fonts fall back to Inter and JetBrains Mono.
910
+ *
911
+ * Each figure carries a 720 px and a 358 px render. The swap follows the
912
+ * figure's own width (a container query), so a narrow column shows the phone
913
+ * render even on a wide screen. */
914
+
915
+ .tgc-figure {
916
+ margin: 32px 0 0;
917
+ padding: 0;
918
+ max-width: 720px;
919
+ color: var(--chart-ink);
920
+ font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);
921
+ }
922
+
923
+ .tgc-finding {
924
+ margin: 0 0 8px;
925
+ font-size: 16px;
926
+ line-height: 24px;
927
+ font-weight: 600;
928
+ color: var(--chart-ink);
929
+ }
930
+
931
+ .tgc-noteref {
932
+ margin-left: 2px;
933
+ font-size: 12px;
934
+ font-weight: 400;
935
+ color: var(--chart-ink-muted);
936
+ text-decoration: none;
937
+ font-family: var(--font-mono, "JetBrains Mono", ui-monospace, monospace);
938
+ }
939
+ .tgc-noteref:hover,
940
+ .tgc-noteref:focus-visible {
941
+ color: var(--chart-accent);
942
+ text-decoration: underline;
943
+ }
944
+
945
+ .tgc-lede {
946
+ margin: 0 0 16px;
947
+ font-size: 14px;
948
+ line-height: 22px;
949
+ color: var(--text-secondary, var(--chart-ink));
950
+ }
951
+
952
+ .tgc-plot {
953
+ container-type: inline-size;
954
+ }
955
+ .tgc-wide svg,
956
+ .tgc-narrow svg {
957
+ display: block;
958
+ overflow: visible;
959
+ }
960
+ .tgc-narrow {
961
+ display: none;
962
+ }
963
+ .tgc-narrow svg {
964
+ max-width: 100%;
965
+ height: auto;
966
+ }
967
+ @container (max-width: 719.98px) {
968
+ .tgc-wide {
969
+ display: none;
970
+ }
971
+ .tgc-narrow {
972
+ display: block;
973
+ }
974
+ }
975
+
976
+ .tgc-read {
977
+ margin: 8px 0 0;
978
+ font-size: 12px;
979
+ line-height: 16px;
980
+ color: var(--chart-ink-muted);
981
+ }
982
+
983
+ .tgc-data {
984
+ margin: 8px 0 0;
985
+ font-size: 12px;
986
+ line-height: 16px;
987
+ }
988
+ .tgc-data > summary {
989
+ cursor: pointer;
990
+ color: var(--chart-ink-muted);
991
+ width: fit-content;
992
+ }
993
+ .tgc-data > summary:hover {
994
+ color: var(--chart-ink);
995
+ }
996
+ .tgc-data[open] > summary {
997
+ margin-bottom: 8px;
998
+ }
999
+
1000
+ .tgc-scroll {
1001
+ overflow-x: auto;
1002
+ }
1003
+ .tgc-scroll:focus-visible {
1004
+ outline: 2px solid var(--chart-accent);
1005
+ outline-offset: 2px;
1006
+ }
1007
+
1008
+ .tgc-table {
1009
+ border-collapse: collapse;
1010
+ min-width: 100%;
1011
+ font-size: 12px;
1012
+ line-height: 16px;
1013
+ color: var(--chart-ink);
1014
+ /* A host page's table rules must not restyle the figure's table. */
1015
+ border: 0;
1016
+ border-radius: 0;
1017
+ background: none;
1018
+ }
1019
+ .tgc-table caption {
1020
+ text-align: left;
1021
+ padding: 0 0 8px;
1022
+ color: var(--chart-ink-muted);
1023
+ }
1024
+ .tgc-table th,
1025
+ .tgc-table td {
1026
+ padding: 6px 12px 6px 0;
1027
+ border-bottom: 1px solid var(--chart-rule);
1028
+ text-align: left;
1029
+ vertical-align: top;
1030
+ font-weight: 400;
1031
+ font-size: inherit;
1032
+ color: inherit;
1033
+ background: none;
1034
+ text-transform: none;
1035
+ letter-spacing: normal;
1036
+ }
1037
+ .tgc-table thead th {
1038
+ color: var(--chart-ink-muted);
1039
+ white-space: nowrap;
1040
+ }
1041
+ .tgc-table tbody th {
1042
+ position: sticky;
1043
+ left: 0;
1044
+ background: var(--chart-canvas, var(--depth-1, transparent));
1045
+ }
1046
+ .tgc-table .tgc-n {
1047
+ font-family: var(--font-mono, "JetBrains Mono", ui-monospace, monospace);
1048
+ font-variant-numeric: tabular-nums;
1049
+ white-space: nowrap;
1050
+ }
1051
+ .tgc-table thead .tgc-n {
1052
+ font-family: inherit;
1053
+ }
1054
+ .tgc-table tbody tr:hover {
1055
+ background: var(--chart-shade);
1056
+ }
1057
+ .tgc-narrow-table .tgc-table {
1058
+ display: block;
1059
+ overflow-x: auto;
1060
+ }
1061
+
1062
+ .tgc-notes {
1063
+ margin: 16px 0 0;
1064
+ padding-left: 20px;
1065
+ font-size: 12px;
1066
+ line-height: 16px;
1067
+ color: var(--chart-ink-muted);
1068
+ }
1069
+ .tgc-notes li {
1070
+ margin: 0 0 8px;
1071
+ padding-left: 4px;
1072
+ }
1073
+ .tgc-notes li:target {
1074
+ color: var(--chart-ink);
1075
+ }
1076
+ .tgc-backlink {
1077
+ color: var(--chart-ink-muted);
1078
+ text-decoration: none;
1079
+ }
1080
+
1081
+ .tgc-refusals {
1082
+ margin: 8px 0 0;
1083
+ padding-left: 20px;
1084
+ font-size: 14px;
1085
+ line-height: 22px;
1086
+ color: var(--chart-ink-muted);
1087
+ }
1088
+
1089
+ /* Marks */
1090
+ .tgc-svg {
1091
+ font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);
1092
+ }
1093
+ .tgc-svg .tgc-num {
1094
+ font-family: var(--font-mono, "JetBrains Mono", ui-monospace, monospace);
1095
+ font-variant-numeric: tabular-nums;
1096
+ }
1097
+ .tgc-ink {
1098
+ fill: var(--chart-ink);
1099
+ }
1100
+ .tgc-ink-muted {
1101
+ fill: var(--chart-ink-muted);
1102
+ }
1103
+ .tgc-bar,
1104
+ .tgc-ci,
1105
+ .tgc-pt-front {
1106
+ fill: var(--chart-accent);
1107
+ }
1108
+ .tgc-gridline {
1109
+ stroke: var(--chart-grid);
1110
+ stroke-width: 1;
1111
+ }
1112
+ .tgc-rule {
1113
+ stroke: var(--chart-rule);
1114
+ stroke-width: 1;
1115
+ }
1116
+ .tgc-axis,
1117
+ .tgc-zero {
1118
+ stroke: var(--chart-muted);
1119
+ stroke-width: 1;
1120
+ }
1121
+ .tgc-whisker,
1122
+ .tgc-tick {
1123
+ stroke: var(--chart-ink);
1124
+ stroke-width: 1.5;
1125
+ stroke-linecap: round;
1126
+ }
1127
+ .tgc-threshold {
1128
+ stroke: var(--chart-ink);
1129
+ stroke-width: 1.5;
1130
+ stroke-dasharray: 3 3;
1131
+ }
1132
+ .tgc-front-line {
1133
+ fill: none;
1134
+ stroke: var(--chart-ink);
1135
+ stroke-width: 1.5;
1136
+ stroke-linejoin: round;
1137
+ }
1138
+ .tgc-pt,
1139
+ .tgc-open {
1140
+ fill: none;
1141
+ stroke: var(--chart-muted);
1142
+ stroke-width: 1.5;
1143
+ }
1144
+ .tgc-pt-est {
1145
+ fill: none;
1146
+ stroke: var(--chart-accent);
1147
+ stroke-width: 2;
1148
+ }
1149
+ .tgc-pt-ci,
1150
+ .tgc-strike {
1151
+ stroke: var(--chart-muted);
1152
+ stroke-width: 1.5;
1153
+ }
1154
+ .tgc-leader {
1155
+ stroke: var(--chart-muted);
1156
+ stroke-width: 1;
1157
+ }
1158
+ .tgc-ci-exact,
1159
+ .tgc-dot {
1160
+ fill: var(--chart-muted);
1161
+ }
1162
+ .tgc-dashed {
1163
+ stroke-dasharray: 2 2;
1164
+ }
1165
+ .tgc-tier-c {
1166
+ fill: var(--chart-tier-c);
1167
+ }
1168
+ .tgc-tier-b {
1169
+ fill: var(--chart-tier-b);
1170
+ }
1171
+ .tgc-tier-a {
1172
+ fill: var(--chart-tier-a);
1173
+ }
1174
+ .tgc-tier-s {
1175
+ fill: var(--chart-tier-s);
1176
+ }
1177
+ /* Neutral tint for counts: never the accent, capped at half strength so ink keeps 6:1. */
1178
+ .tgc-tint {
1179
+ fill: color-mix(in srgb, var(--chart-muted) calc(var(--s, 0) * 50%), transparent);
1180
+ }
1181
+
1182
+ @media print {
1183
+ .tgc-plot {
1184
+ container-type: normal;
1185
+ }
1186
+ .tgc-wide {
1187
+ display: block;
1188
+ }
1189
+ .tgc-narrow {
1190
+ display: none;
1191
+ }
1192
+ .tgc-wide svg {
1193
+ width: 100%;
1194
+ height: auto;
1195
+ }
1196
+ .tgc-figure {
1197
+ break-inside: avoid;
1198
+ }
1199
+ }
1200
+ `;
1201
+ var chartTokensCss = ':root{--chart-ink:#2b2c3e;--chart-ink-muted:#65677b;--chart-muted:#737696;--chart-grid:#f2f2f7;--chart-rule:#cecfde;--chart-shade:rgba(76, 63, 198, 0.06);--chart-accent:hsl(246 54% 51%);--chart-tier-c:#9b9ef0;--chart-tier-b:#7c7fe3;--chart-tier-a:#5b5bd6;--chart-tier-s:#272962;--chart-canvas:#ffffff;color-scheme:light}\n@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--chart-ink:#e3e6ed;--chart-ink-muted:#9ea5b9;--chart-muted:#898da9;--chart-grid:#32324e;--chart-rule:#474a6b;--chart-shade:rgba(165, 180, 252, 0.07);--chart-accent:#818CF8;--chart-tier-c:#4a4a95;--chart-tier-b:#5b5bd6;--chart-tier-a:#8b87f0;--chart-tier-s:#b1a9ff;--chart-canvas:#151523;color-scheme:dark}}\n:root[data-theme="dark"]{--chart-ink:#e3e6ed;--chart-ink-muted:#9ea5b9;--chart-muted:#898da9;--chart-grid:#32324e;--chart-rule:#474a6b;--chart-shade:rgba(165, 180, 252, 0.07);--chart-accent:#818CF8;--chart-tier-c:#4a4a95;--chart-tier-b:#5b5bd6;--chart-tier-a:#8b87f0;--chart-tier-s:#b1a9ff;--chart-canvas:#151523;color-scheme:dark}\n@media print{:root,:root:not([data-theme="light"]),:root[data-theme="dark"]{--chart-ink:#2b2c3e;--chart-ink-muted:#65677b;--chart-muted:#737696;--chart-grid:#f2f2f7;--chart-rule:#cecfde;--chart-shade:rgba(76, 63, 198, 0.06);--chart-accent:hsl(246 54% 51%);--chart-tier-c:#9b9ef0;--chart-tier-b:#7c7fe3;--chart-tier-a:#5b5bd6;--chart-tier-s:#272962;--chart-canvas:#ffffff;color-scheme:light}}';
1202
+
1203
+ // src/ranked-rates.ts
1204
+ var TICKS = [0, 0.25, 0.5, 0.75, 1];
1205
+ function rankedRates(input, o) {
1206
+ const id = o.id ?? "ranked-rates";
1207
+ const noun = o.nouns ?? { attempt: "attempt", setup: "setup" };
1208
+ if (input.length === 0) return { id, refused: `No ${noun.setup} has an ${noun.attempt}.` };
1209
+ for (const row of input) {
1210
+ const expected = row.attempts > 0 ? row.solved / row.attempts : 0;
1211
+ if (row.solved > row.attempts || Math.abs(row.rate - expected) > 1e-6) {
1212
+ return { id, refused: `${row.label}: rate ${row.rate} disagrees with ${kn(row.solved, row.attempts)} passed.` };
1213
+ }
1214
+ }
1215
+ const ranked = input.every((row) => row.rank !== null);
1216
+ const rows = ranked ? [...input].sort((a, b) => a.rank - b.rank) : [...input].sort((a, b) => b.rate - a.rate);
1217
+ const layout = {
1218
+ ranked,
1219
+ cost: rows.some((r) => r.cost.perSolvedUsd !== null || r.cost.basis !== "unknown" || r.cost.receipts !== null),
1220
+ median: rows.some((r) => r.medianWallMs !== null)
1221
+ };
1222
+ const bars = rows.filter((row) => drawable(row.estimate));
1223
+ const noBar = rows.filter((row) => !drawable(row.estimate));
1224
+ const whiskers = bars.filter((row) => intervalGap(row.interval) === null);
1225
+ const noWhisker = bars.filter((row) => intervalGap(row.interval) !== null);
1226
+ const tiered = rows.some((row) => row.tiers !== null);
1227
+ const passed = rows.reduce((sum, row) => sum + row.solved, 0);
1228
+ const attempts = rows.reduce((sum, row) => sum + row.attempts, 0);
1229
+ const setupsN = `${rows.length} ${plural(rows.length, noun.setup)}`;
1230
+ const top = rows[0];
1231
+ const uniqueTop = rows.length === 1 || rows[1].rate < top.rate;
1232
+ const finding = ranked ? `${top.label} ranks first, passing ${pct(top.rate)} of ${plural(2, noun.attempt)}.` : rows.length === 1 && drawable(top.estimate) ? `${top.label} passes ${pct(top.rate)} of ${top.attempts} ${plural(top.attempts, noun.attempt)}.` : drawable(top.estimate) && uniqueTop ? `${top.label} has the highest ${o.measure}, ${pct(top.rate)} of ${top.attempts} ${plural(top.attempts, noun.attempt)}; no comparison backs the order.` : `Sorted by ${o.measure}; no comparison backs the order.`;
1233
+ const lede = `${passed} of ${attempts} ${plural(attempts, noun.attempt)} passed across ${setupsN}.` + (noBar.length === rows.length ? ` No ${noun.setup} has enough units to draw a rate.` : noBar.length ? ` ${noBar.length} of ${setupsN} have too few units to draw a rate.` : "");
1234
+ const intervalNames = [...new Set(whiskers.map((row) => intervalName(row.interval)))];
1235
+ const read = bars.length ? [
1236
+ `Bars show the share of ${plural(2, noun.attempt)} that passed${whiskers.length ? `; whiskers show the ${intervalNames.join(" or ")} interval` : ""}.`,
1237
+ tiered ? `The strip under a bar splits ${plural(2, noun.attempt)} by App Grade tier${o.passTier ? `, and the tick marks ${o.passTier} or better` : ""}.` : ""
1238
+ ].filter(Boolean).join(" ") : `Counts show ${plural(2, noun.attempt)} passed; no ${noun.setup} has enough units to draw a rate.`;
1239
+ const bases = [...new Set(rows.filter((r) => r.cost.perSolvedUsd !== null).map((r) => r.cost.basis))];
1240
+ const method = [
1241
+ `${capitalize(o.measure)} is the share of ${plural(2, noun.attempt)} that passed${o.passTier ? `; a pass is App Grade ${o.passTier} or better` : ""}.`,
1242
+ intervalNames.length ? `Intervals: ${intervalNames.join(", ")}, per ${noun.setup}.` : "",
1243
+ bases.length ? `Cost per pass is model spend over passes, ${bases.map(basisPhrase).join(" or ")}.` : "",
1244
+ layout.median ? `Median run is the median wall time of one ${noun.attempt}.` : ""
1245
+ ].filter(Boolean).join(" ");
1246
+ const exclusions = [
1247
+ ...(o.excluded ?? []).map((e) => `${e.count} ${plural(e.count, noun.attempt)} excluded: ${e.why}.`),
1248
+ ...noBar.length ? [`No bar for ${noBar.map((r) => `${r.label} (${estimateNote(r.estimate, r.attempts)})`).join(", ")}.`] : [],
1249
+ ...noWhisker.length ? [`No whisker for ${noWhisker.map((r) => `${r.label} (${intervalGap(r.interval)})`).join(", ")}.`] : []
1250
+ ];
1251
+ const columns = [
1252
+ { label: capitalize(noun.setup) },
1253
+ ...ranked ? [{ label: "Rank", numeric: true }] : [],
1254
+ { label: "Passed", numeric: true },
1255
+ { label: capitalize(plural(2, noun.attempt)), numeric: true },
1256
+ { label: capitalize(o.measure), numeric: true },
1257
+ { label: "Interval", numeric: true },
1258
+ ...tiered ? [{ label: "Tiers" }] : [],
1259
+ ...layout.cost ? [{ label: "Cost per pass", numeric: true }, { label: "Cost basis" }] : [],
1260
+ ...layout.median ? [{ label: "Median run", numeric: true }] : []
1261
+ ];
1262
+ const tableRows = rows.map((row) => {
1263
+ const offLadder = row.tiers ? row.attempts - tierTotal(row.tiers) : 0;
1264
+ return [
1265
+ row.label,
1266
+ ...ranked ? [String(row.rank)] : [],
1267
+ String(row.solved),
1268
+ String(row.attempts),
1269
+ drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts),
1270
+ row.interval ? `${pct(row.interval.lower)}\u2013${pct(row.interval.upper)} (${intervalGap(row.interval) ?? intervalName(row.interval)})` : "none recorded",
1271
+ ...tiered ? [row.tiers ? [tierSummary(row.tiers), offLadder ? `${offLadder} off the ladder` : ""].filter(Boolean).join(" \xB7 ") || "none" : "not graded"] : [],
1272
+ ...layout.cost ? [costText(row.cost, row.solved), row.cost.basis + (row.cost.receipts !== null ? `, ${row.cost.receipts} receipts` : "")] : [],
1273
+ ...layout.median ? [row.medianWallMs === null ? "unknown" : duration(row.medianWallMs)] : []
1274
+ ];
1275
+ });
1276
+ return {
1277
+ id,
1278
+ finding,
1279
+ lede,
1280
+ read,
1281
+ svg: { wide: wide2(rows, layout, noun, o), narrow: narrow(rows, layout, o) },
1282
+ table: table(`${capitalize(o.measure)} by ${noun.setup}`, columns, tableRows),
1283
+ note: {
1284
+ method,
1285
+ n: `${attempts} ${plural(attempts, noun.attempt)} over ${setupsN}.`,
1286
+ exclusions
1287
+ }
1288
+ };
1289
+ }
1290
+ function basisPhrase(basis) {
1291
+ if (basis === "receipts") return "from router receipts";
1292
+ if (basis === "estimated") return "estimated, not billed (marked est.)";
1293
+ return "from an unrecorded source";
1294
+ }
1295
+ function barMarks(row, x0, x1, y, o) {
1296
+ const w = x1 - x0;
1297
+ const at = (v) => x0 + Math.max(0, Math.min(1, v)) * w;
1298
+ let out = hbar(x0, y, at(row.rate) - x0, 12, "tgc-bar");
1299
+ let end = at(row.rate);
1300
+ if (row.interval && intervalGap(row.interval) === null) {
1301
+ const lo = at(row.interval.lower);
1302
+ const hi = at(row.interval.upper);
1303
+ out += line(lo, y + 6, hi, y + 6, "tgc-whisker");
1304
+ out += line(lo, y + 2, lo, y + 10, "tgc-whisker") + line(hi, y + 2, hi, y + 10, "tgc-whisker");
1305
+ end = Math.max(end, hi);
1306
+ }
1307
+ out += text(end + 6, y + 10.5, pct(row.rate), { cls: "tgc-ink tgc-num" });
1308
+ if (row.tiers && row.attempts > 0) {
1309
+ const sy = y + 16;
1310
+ let cursor = x0;
1311
+ for (const tier of TIERS_BEST_FIRST) {
1312
+ const count = row.tiers[tier];
1313
+ if (!count) continue;
1314
+ const segment = count / row.attempts * w;
1315
+ out += tier === "F" ? rect(cursor + 0.75, sy + 0.75, Math.max(0, segment - 3.5), 4.5, "tgc-open") : rect(cursor, sy, Math.max(0, segment - 2), 6, `tgc-tier-${tier.toLowerCase()}`);
1316
+ cursor += segment;
1317
+ }
1318
+ if (o.passTier) {
1319
+ const passing = TIERS_BEST_FIRST.filter((t) => tierAtLeast(t, o.passTier)).reduce((s, t) => s + row.tiers[t], 0);
1320
+ const tx = x0 + passing / row.attempts * w;
1321
+ out += line(tx, sy - 3, tx, sy + 9, "tgc-tick");
1322
+ }
1323
+ }
1324
+ return out;
1325
+ }
1326
+ function hoverText(row) {
1327
+ const parts = [
1328
+ row.label,
1329
+ `${kn(row.solved, row.attempts)} passed`,
1330
+ drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts)
1331
+ ];
1332
+ if (row.interval && intervalGap(row.interval) === null) {
1333
+ parts.push(`${intervalName(row.interval)} ${pct(row.interval.lower)}\u2013${pct(row.interval.upper)}`);
1334
+ }
1335
+ if (row.tiers) parts.push(tierSummary(row.tiers));
1336
+ if (row.cost.perSolvedUsd !== null) parts.push(`${costText(row.cost, row.solved)} per pass`);
1337
+ return parts.join(" \xB7 ");
1338
+ }
1339
+ function wide2(rows, layout, noun, o) {
1340
+ const labelX = layout.ranked ? 32 : 0;
1341
+ const labelW = layout.ranked ? 200 : 232;
1342
+ const x0 = 248;
1343
+ const extra = (layout.cost ? 104 : 0) + (layout.median ? 80 : 0);
1344
+ const knRight = WIDE - extra;
1345
+ const x1 = knRight - 96;
1346
+ const costRight = knRight + 104;
1347
+ const top = 34;
1348
+ let body = "";
1349
+ let y = top;
1350
+ for (const [i, row] of rows.entries()) {
1351
+ const label = wrap(row.label, labelW, 14);
1352
+ const h = Math.max(label.lines.length * 18 + 14, row.tiers && drawable(row.estimate) ? 40 : 32);
1353
+ let marks = "";
1354
+ if (layout.ranked) marks += text(20, y + 18, String(row.rank), { cls: "tgc-ink-muted tgc-num", anchor: "end" });
1355
+ marks += textBlock(label.lines, labelX, y + 18, 18, `class="tgc-ink" font-size="14"`);
1356
+ marks += drawable(row.estimate) ? barMarks(row, x0, x1, y + 7, o) : text(x0, y + 18, estimateNote(row.estimate, row.attempts), { cls: "tgc-ink-muted" });
1357
+ marks += text(knRight, y + 18, kn(row.solved, row.attempts), { cls: "tgc-ink tgc-num", anchor: "end" });
1358
+ if (layout.cost) {
1359
+ const billed = row.cost.perSolvedUsd !== null && row.cost.basis === "receipts";
1360
+ marks += text(costRight, y + 18, costText(row.cost, row.solved), {
1361
+ cls: billed ? "tgc-ink tgc-num" : "tgc-ink-muted tgc-num",
1362
+ anchor: "end"
1363
+ });
1364
+ }
1365
+ if (layout.median) {
1366
+ marks += text(WIDE, y + 18, row.medianWallMs === null ? "unknown" : duration(row.medianWallMs), {
1367
+ cls: row.medianWallMs === null ? "tgc-ink-muted tgc-num" : "tgc-ink tgc-num",
1368
+ anchor: "end"
1369
+ });
1370
+ }
1371
+ body += titled(hoverText(row), marks);
1372
+ if (i < rows.length - 1) body += line(0, y + h, WIDE, y + h, "tgc-rule");
1373
+ y += h;
1374
+ }
1375
+ const bottom = y;
1376
+ let grid = "";
1377
+ if (rows.some((r) => drawable(r.estimate))) {
1378
+ for (const t of TICKS) grid += line(x0 + t * (x1 - x0), top - 4, x0 + t * (x1 - x0), bottom, "tgc-gridline");
1379
+ }
1380
+ let head = text(labelX, 16, capitalize(noun.setup), { cls: "tgc-ink-muted" });
1381
+ head += text(x0, 16, capitalize(o.measure), { cls: "tgc-ink-muted" });
1382
+ head += text(knRight, 16, "Passed", { cls: "tgc-ink-muted", anchor: "end" });
1383
+ if (layout.cost) head += text(costRight, 16, "Per pass", { cls: "tgc-ink-muted", anchor: "end" });
1384
+ if (layout.median) head += text(WIDE, 16, "Median run", { cls: "tgc-ink-muted", anchor: "end" });
1385
+ head += line(0, 26, WIDE, 26, "tgc-rule");
1386
+ let axis = "";
1387
+ if (rows.some((r) => drawable(r.estimate))) {
1388
+ for (const t of TICKS) {
1389
+ axis += text(x0 + t * (x1 - x0), bottom + 16, pct(t), {
1390
+ cls: "tgc-ink-muted tgc-num",
1391
+ anchor: t === 0 ? "start" : t === 1 ? "end" : "middle"
1392
+ });
1393
+ }
1394
+ }
1395
+ return svgRoot(WIDE, bottom + 24, grid + head + body + axis);
1396
+ }
1397
+ function narrow(rows, layout, o) {
1398
+ const x0 = 0;
1399
+ const x1 = 300;
1400
+ let body = "";
1401
+ let y = 8;
1402
+ for (const [i, row] of rows.entries()) {
1403
+ const labelX = layout.ranked ? 24 : 0;
1404
+ const label = wrap(row.label, NARROW - labelX, 14);
1405
+ let marks = "";
1406
+ if (layout.ranked) marks += text(0, y + 14, String(row.rank), { cls: "tgc-ink-muted tgc-num" });
1407
+ marks += textBlock(label.lines, labelX, y + 14, 18, `class="tgc-ink" font-size="14"`);
1408
+ let cy = y + (label.lines.length - 1) * 18 + 22;
1409
+ if (drawable(row.estimate)) {
1410
+ marks += barMarks(row, x0, x1, cy, o);
1411
+ cy += row.tiers ? 30 : 20;
1412
+ }
1413
+ const facts = [
1414
+ `${kn(row.solved, row.attempts)} passed`,
1415
+ ...drawable(row.estimate) ? [] : [estimateNote(row.estimate, row.attempts)],
1416
+ ...layout.cost && !(row.solved === 0 && row.cost.perSolvedUsd === null) ? [row.cost.perSolvedUsd === null ? "cost unknown" : `${costText(row.cost, row.solved)} per pass`] : [],
1417
+ ...layout.median ? [row.medianWallMs === null ? "run time unknown" : `${duration(row.medianWallMs)} run`] : []
1418
+ ];
1419
+ const factLines = fitLines(facts, NARROW, 12, "mono");
1420
+ marks += textBlock(factLines, 0, cy + 12, 16, `class="tgc-ink-muted tgc-num" font-size="12"`);
1421
+ body += titled(hoverText(row), marks);
1422
+ y = cy + factLines.length * 16 + 4;
1423
+ if (i < rows.length - 1) body += line(0, y + 4, NARROW, y + 4, "tgc-rule");
1424
+ y += 12;
1425
+ }
1426
+ let axis = "";
1427
+ if (rows.some((row) => drawable(row.estimate))) {
1428
+ for (const t of [0, 0.5, 1]) {
1429
+ axis += text(x0 + t * (x1 - x0), y + 4, pct(t), {
1430
+ cls: "tgc-ink-muted tgc-num",
1431
+ anchor: t === 0 ? "start" : t === 1 ? "end" : "middle"
1432
+ });
1433
+ }
1434
+ y += 12;
1435
+ }
1436
+ return svgRoot(NARROW, y, body + axis);
1437
+ }
1438
+
1439
+ // src/task-matrix.ts
1440
+ var TILE = 12;
1441
+ var STEP = 14;
1442
+ var KIND_LABEL = {
1443
+ S: "S",
1444
+ A: "A",
1445
+ B: "B",
1446
+ C: "C",
1447
+ F: "F",
1448
+ flagged: "flagged",
1449
+ unmeasured: "U, not measured",
1450
+ ungraded: "not graded",
1451
+ solved: "passed",
1452
+ open: "did not pass"
1453
+ };
1454
+ function tilesOf(cell) {
1455
+ const attempts = cell.attempts ?? 0;
1456
+ if (cell.tiers) {
1457
+ const out = [];
1458
+ for (const tier of TIERS_BEST_FIRST) for (let i = 0; i < cell.tiers[tier]; i++) out.push(tier);
1459
+ for (let i = 0; i < (cell.flagged ?? 0); i++) out.push("flagged");
1460
+ for (let i = 0; i < (cell.unmeasured ?? 0); i++) out.push("unmeasured");
1461
+ while (out.length < attempts) out.push("ungraded");
1462
+ return out;
1463
+ }
1464
+ if (cell.solved !== null) {
1465
+ return [...Array(cell.solved).fill("solved"), ...Array(attempts - cell.solved).fill("open")];
1466
+ }
1467
+ return Array(attempts).fill("ungraded");
1468
+ }
1469
+ function tile(kind, x, y) {
1470
+ switch (kind) {
1471
+ case "S":
1472
+ case "A":
1473
+ case "B":
1474
+ case "C":
1475
+ return rect(x, y, TILE, TILE, `tgc-tier-${kind.toLowerCase()}`, 'rx="2"');
1476
+ case "solved":
1477
+ return rect(x, y, TILE, TILE, "tgc-bar", 'rx="2"');
1478
+ case "F":
1479
+ case "open":
1480
+ return rect(x + 0.75, y + 0.75, TILE - 1.5, TILE - 1.5, "tgc-open", 'rx="2"');
1481
+ case "flagged":
1482
+ return rect(x + 0.75, y + 0.75, TILE - 1.5, TILE - 1.5, "tgc-open", 'rx="2"') + line(x + 2, y + TILE - 2, x + TILE - 2, y + 2, "tgc-strike");
1483
+ case "unmeasured":
1484
+ return rect(x + 0.75, y + 0.75, TILE - 1.5, TILE - 1.5, "tgc-open tgc-dashed", 'rx="2"');
1485
+ case "ungraded":
1486
+ return rect(x + 0.75, y + 0.75, TILE - 1.5, TILE - 1.5, "tgc-open", 'rx="2"') + circle(x + TILE / 2, y + TILE / 2, 1.5, "tgc-dot");
1487
+ }
1488
+ }
1489
+ function cellText(cell, means) {
1490
+ if (!cell || (means ? cell.mean === null : cell.attempts === null)) return "\xB7";
1491
+ if (means) return cell.mean.toFixed(2);
1492
+ if (cell.attempts === 0) return "none counted";
1493
+ if (cell.solved !== null) return kn(cell.solved, cell.attempts);
1494
+ if (cell.tiers) return tierSummary(cell.tiers, cell.unmeasured, cell.flagged);
1495
+ return `${cell.attempts}`;
1496
+ }
1497
+ function describe(task, setup, cell, means) {
1498
+ if (!cell || (means ? cell.mean === null : cell.attempts === null)) return `${task} \xB7 ${setup.label}: not run`;
1499
+ if (means) return `${task} \xB7 ${setup.label}: mean ${cell.mean.toFixed(2)}`;
1500
+ const parts = [`${task} \xB7 ${setup.label}`];
1501
+ if (cell.attempts === 0) parts.push("no attempt counts");
1502
+ else if (cell.solved !== null) parts.push(`${cell.solved} of ${cell.attempts} passed`);
1503
+ else parts.push(`${cell.attempts} ${plural(cell.attempts, "attempt")}`);
1504
+ const tiers = tierSummary(cell.tiers, cell.unmeasured, cell.flagged);
1505
+ if (tiers) parts.push(tiers);
1506
+ if (cell.costUsd !== null) parts.push(usd(cell.costUsd));
1507
+ return parts.join(" \xB7 ").replace(`${setup.label} \xB7 `, `${setup.label}: `);
1508
+ }
1509
+ function signature(cell) {
1510
+ if (!cell || cell.attempts === null) return "not run";
1511
+ return `${cell.attempts}|${tierSummary(cell.tiers, cell.unmeasured, cell.flagged)}`;
1512
+ }
1513
+ function taskMatrix(tasks, setupsIn, cells, o = {}) {
1514
+ const id = o.id ?? "task-matrix";
1515
+ const means = o.show === "means";
1516
+ const noun = { task: "task", setup: "setup", attempt: "attempt", ...o.nouns };
1517
+ if (tasks.length === 0 || setupsIn.length === 0) return { id, refused: `No ${noun.task} or no ${noun.setup} to draw.` };
1518
+ const setups = setupsIn.every((s) => s.rank !== null) ? [...setupsIn].sort((a, b) => a.rank - b.rank) : setupsIn;
1519
+ const byKey = /* @__PURE__ */ new Map();
1520
+ for (const cell of cells) {
1521
+ const key = `${cell.task}\0${cell.setup}`;
1522
+ if (!tasks.includes(cell.task) || !setups.some((s) => s.id === cell.setup)) {
1523
+ return { id, refused: `A cell names ${cell.task} \xD7 ${cell.setup}, which is not in the ${noun.task} and ${noun.setup} lists.` };
1524
+ }
1525
+ if (byKey.has(key)) return { id, refused: `Two cells for ${cell.task} \xD7 ${cell.setup}.` };
1526
+ if (!means && cell.attempts !== null) {
1527
+ const graded = tierTotal(cell.tiers) + (cell.flagged ?? 0) + (cell.unmeasured ?? 0);
1528
+ if (graded > cell.attempts || cell.solved !== null && cell.solved > cell.attempts) {
1529
+ return { id, refused: `${cell.task} \xD7 ${cell.setup}: grades or passes exceed its ${cell.attempts} attempts.` };
1530
+ }
1531
+ }
1532
+ byKey.set(key, cell);
1533
+ }
1534
+ const at = (task, setup) => byKey.get(`${task}\0${setup.id}`);
1535
+ const tasksN = `${tasks.length} ${plural(tasks.length, noun.task)}`;
1536
+ let finding;
1537
+ const passRule = cells.some((c) => c.solved !== null);
1538
+ if (means) {
1539
+ const leads = /* @__PURE__ */ new Map();
1540
+ for (const task of tasks) {
1541
+ const scored = setups.map((s) => ({ s, m: at(task, s)?.mean ?? null })).filter((x) => x.m !== null);
1542
+ if (scored.length < 2) continue;
1543
+ const top = Math.max(...scored.map((x) => x.m));
1544
+ const leaders = scored.filter((x) => x.m === top);
1545
+ if (leaders.length === 1) leads.set(leaders[0].s.id, (leads.get(leaders[0].s.id) ?? 0) + 1);
1546
+ }
1547
+ const [leaderId, count] = [...leads.entries()].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
1548
+ finding = leaderId ? `${setups.find((s) => s.id === leaderId).label} has the highest mean on ${count} of ${tasksN}.` : `No ${noun.setup} has the highest mean on any ${noun.task}.`;
1549
+ } else if (o.agreement && setups.length > 1) {
1550
+ const k = tasks.filter((task) => new Set(setups.map((s) => signature(at(task, s)))).size === 1).length;
1551
+ finding = k === tasks.length ? `Every ${noun.setup} gave the same grades on all ${tasksN}.` : `Every ${noun.setup} gave the same grades on ${k} of ${tasksN}.`;
1552
+ } else if (passRule) {
1553
+ const everywhere = tasks.filter((task) => setups.every((s) => (at(task, s)?.solved ?? 0) > 0)).length;
1554
+ const perSetup = setups.map((s) => ({ s, k: tasks.filter((task) => (at(task, s)?.solved ?? 0) > 0).length }));
1555
+ const most = Math.max(...perSetup.map((x) => x.k));
1556
+ const leaders = perSetup.filter((x) => x.k === most).map((x) => x.s.label);
1557
+ const rest = perSetup.filter((x) => x.k !== most);
1558
+ if (setups.length > 1 && everywhere > 0) {
1559
+ finding = `${everywhere} of ${tasksN} passed at least once for every ${noun.setup}.`;
1560
+ } else if (most === 0) {
1561
+ finding = `No ${noun.task} passed for any ${noun.setup}.`;
1562
+ } else {
1563
+ const tail = rest.length && rest.every((x) => x.k === 0) ? `; ${rest.length === 1 ? rest[0].s.label : `every other ${noun.setup}`} on none` : "";
1564
+ finding = `${listJoin(leaders)} passed at least once on ${most} of ${tasksN}${tail}.`;
1565
+ }
1566
+ } else {
1567
+ const all = cells.reduce(
1568
+ (acc, c) => {
1569
+ for (const t of TIERS_BEST_FIRST) acc.tiers[t] += c.tiers?.[t] ?? 0;
1570
+ acc.u += c.unmeasured ?? 0;
1571
+ acc.f += c.flagged ?? 0;
1572
+ return acc;
1573
+ },
1574
+ { tiers: { S: 0, A: 0, B: 0, C: 0, F: 0 }, u: 0, f: 0 }
1575
+ );
1576
+ const graded = tierTotal(all.tiers) + all.u + all.f;
1577
+ finding = graded ? `${graded} graded ${plural(graded, noun.attempt)}: ${tierSummary(all.tiers, all.u, all.f)}.` : `No ${noun.attempt} in this matrix is graded.`;
1578
+ }
1579
+ const attempts = cells.reduce((s, c) => s + (c.attempts ?? 0), 0);
1580
+ const ran = cells.filter((c) => means ? c.mean !== null : c.attempts !== null).length;
1581
+ const excluded = (o.excluded ?? []).reduce((s, e) => s + e.count, 0);
1582
+ const lede = means ? `${ran} of ${tasks.length * setups.length} cells scored, over ${tasksN} and ${setups.length} ${plural(setups.length, noun.setup)}.` : `${attempts} ${plural(attempts, noun.attempt)} over ${tasksN} and ${setups.length} ${plural(setups.length, noun.setup)}${excluded ? `; ${excluded} more excluded` : ""}.`;
1583
+ const kinds = /* @__PURE__ */ new Set();
1584
+ if (!means) for (const cell of cells) for (const k of tilesOf(cell)) kinds.add(k);
1585
+ const tiered = cells.some((c) => c.tiers !== null);
1586
+ const read = means ? `Each cell is one ${noun.setup}'s mean on one ${noun.task}, printed and drawn as a bar on a shared scale; \xB7 marks a pair never scored.` : tiered ? `One tile per ${noun.attempt}, best first: tiles fill by App Grade tier and an outline is F${o.passTier ? `; ${o.passTier} or better passes` : ""}. Tile order is not run order.` : `One tile per ${noun.attempt}: a filled tile passed and an outline did not. Tile order is not run order.`;
1587
+ const method = means ? `Each cell is the producer's mean score for one ${noun.setup} on one ${noun.task}.` : [
1588
+ tiered ? `Tiles show the App Grade tier of each ${noun.attempt}${o.passTier ? `, and ${o.passTier} or better counts as a pass` : ""}.` : `Filled tiles are the ${plural(2, noun.attempt)} the producer marks solved.`,
1589
+ kinds.has("unmeasured") ? `U marks ${noun.attempt === "attempt" ? "an attempt" : `a ${noun.attempt}`} the grader could not measure.` : "",
1590
+ kinds.has("flagged") ? "A struck tile is a grade that failed an integrity check and awaits human review; it never counts as a pass." : "",
1591
+ kinds.has("ungraded") ? `A dotted outline is ${noun.attempt === "attempt" ? "an attempt" : `a ${noun.attempt}`} with no grade.` : ""
1592
+ ].filter(Boolean).join(" ");
1593
+ const tableHtml = table(
1594
+ `${capitalize(noun.task)} by ${noun.setup}`,
1595
+ [{ label: capitalize(noun.task) }, ...setups.map((s) => ({ label: s.label, numeric: true }))],
1596
+ tasks.map((task) => [
1597
+ task,
1598
+ ...setups.map((s) => {
1599
+ const cell = at(task, s);
1600
+ const main = cellText(cell, means);
1601
+ const extra = !means && cell && cell.solved !== null && cell.tiers ? ` \xB7 ${tierSummary(cell.tiers, cell.unmeasured, cell.flagged)}` : "";
1602
+ const cost = cell && cell.costUsd !== null ? ` \xB7 ${usd(cell.costUsd)}` : "";
1603
+ return main === "\xB7" ? "not run" : `${main}${extra}${cost}`;
1604
+ })
1605
+ ])
1606
+ );
1607
+ const scaleMax = Math.max(1, ...cells.map((c) => c.mean ?? 0));
1608
+ const legendKinds = [...kinds].sort(
1609
+ (a, b) => ["S", "A", "B", "C", "solved", "F", "open", "flagged", "unmeasured", "ungraded"].indexOf(a) - ["S", "A", "B", "C", "solved", "F", "open", "flagged", "unmeasured", "ungraded"].indexOf(b)
1610
+ );
1611
+ return {
1612
+ id,
1613
+ finding,
1614
+ lede,
1615
+ read,
1616
+ svg: {
1617
+ wide: wideMatrix(tasks, setups, at, means, legendKinds, o, scaleMax),
1618
+ narrow: narrowMatrix(tasks, setups, at, means, legendKinds, o, scaleMax)
1619
+ },
1620
+ table: tableHtml,
1621
+ note: {
1622
+ method,
1623
+ n: means ? `${ran} scored cells.` : `${attempts} ${plural(attempts, noun.attempt)} in ${ran} cells.`,
1624
+ exclusions: (o.excluded ?? []).map((e) => `${e.count} ${plural(e.count, noun.attempt)} excluded: ${e.why}.`)
1625
+ }
1626
+ };
1627
+ }
1628
+ function cellMarks(cell, x, y, width, means, scaleMax = 1) {
1629
+ const label = cellText(cell, means);
1630
+ if (means) {
1631
+ if (!cell || cell.mean === null) return { svg: text(x, y + 16, "\xB7", { cls: "tgc-ink-muted tgc-num" }), h: 24 };
1632
+ const barX = x + 44;
1633
+ const barW = Math.max(0, Math.max(0, cell.mean) / scaleMax * (width - 52));
1634
+ return {
1635
+ svg: text(x, y + 16, label, { cls: "tgc-ink tgc-num" }) + hbar(barX, y + 8, barW, 10, "tgc-bar"),
1636
+ h: 24
1637
+ };
1638
+ }
1639
+ if (!cell || cell.attempts === null || cell.attempts === 0) {
1640
+ return { svg: text(x, y + 16, label, { cls: "tgc-ink-muted tgc-num" }), h: 24 };
1641
+ }
1642
+ const tiles = tilesOf(cell);
1643
+ const labelW = textWidth(label, 12, "mono");
1644
+ const perLine = Math.max(1, Math.floor((width - labelW - 8) / STEP));
1645
+ let svg = "";
1646
+ tiles.forEach((kind, i) => {
1647
+ svg += tile(kind, x + i % perLine * STEP, y + 6 + Math.floor(i / perLine) * STEP);
1648
+ });
1649
+ const lines = Math.ceil(tiles.length / perLine);
1650
+ const labelX = x + Math.min(tiles.length, perLine) * STEP + 4;
1651
+ svg += text(labelX, y + 16, label, { cls: cell.solved === 0 ? "tgc-ink-muted tgc-num" : "tgc-ink tgc-num" });
1652
+ return { svg, h: Math.max(24, lines * STEP + 10) };
1653
+ }
1654
+ function legend(kinds, width, y, o) {
1655
+ if (kinds.length === 0) return { svg: "", h: 0 };
1656
+ let svg = "";
1657
+ let x = 0;
1658
+ let row = 0;
1659
+ const items = kinds.map((kind) => ({
1660
+ kind,
1661
+ label: KIND_LABEL[kind] + (o.passTier && ["S", "A", "B", "C", "F"].includes(kind) && kind === o.passTier ? " (pass)" : "")
1662
+ }));
1663
+ for (const item of items) {
1664
+ const w = STEP + textWidth(item.label, 12) + 16;
1665
+ if (x > 0 && x + w > width) {
1666
+ x = 0;
1667
+ row += 1;
1668
+ }
1669
+ svg += tile(item.kind, x, y + row * 20 + 2) + text(x + STEP + 2, y + row * 20 + 12, item.label, { cls: "tgc-ink-muted" });
1670
+ x += w;
1671
+ }
1672
+ return { svg, h: (row + 1) * 20 };
1673
+ }
1674
+ var MIN_COL = 76;
1675
+ var GRID_X0 = 208;
1676
+ var BLOCK_GAP = 24;
1677
+ function wideMatrix(tasks, setups, at, means, kinds, o, scaleMax) {
1678
+ if ((WIDE - GRID_X0) / setups.length < MIN_COL) return blockColumns(tasks, setups, at, means, kinds, o, scaleMax);
1679
+ const labelW = 196;
1680
+ const x0 = GRID_X0;
1681
+ const colW = (WIDE - x0) / setups.length;
1682
+ let head = "";
1683
+ let headH = 0;
1684
+ for (const [j, s] of setups.entries()) {
1685
+ const label = wrap(s.label, colW - 8, 12, "sans", 3);
1686
+ headH = Math.max(headH, label.lines.length * 16);
1687
+ head += titled(s.label, textBlock(label.lines, x0 + j * colW, 14, 16, `class="tgc-ink" font-size="12"`));
1688
+ }
1689
+ let y = headH + 12;
1690
+ head += line(0, y - 4, WIDE, y - 4, "tgc-rule");
1691
+ let body = "";
1692
+ for (const [i, task] of tasks.entries()) {
1693
+ const label = wrap(task, labelW, 12, "sans", 2);
1694
+ let h = label.lines.length * 16 + 8;
1695
+ let marks = "";
1696
+ for (const [j, s] of setups.entries()) {
1697
+ const cell = at(task, s);
1698
+ const m = cellMarks(cell, x0 + j * colW, y, colW - 8, means, scaleMax);
1699
+ h = Math.max(h, m.h);
1700
+ marks += titled(describe(task, s, cell, means), m.svg);
1701
+ }
1702
+ body += titled(task, textBlock(label.lines, 0, y + 16, 16, `class="tgc-ink" font-size="12"`)) + marks;
1703
+ y += h + 4;
1704
+ if (i < tasks.length - 1) body += line(0, y - 2, WIDE, y - 2, "tgc-gridline");
1705
+ }
1706
+ const lg = legend(kinds, WIDE, y + 10, o);
1707
+ return svgRoot(WIDE, y + 10 + lg.h + 4, head + body + lg.svg);
1708
+ }
1709
+ function setupBlock(tasks, s, at, means, width, scaleMax) {
1710
+ const cellW = 128;
1711
+ const labelW = width - cellW - 8;
1712
+ let y = 0;
1713
+ const head = wrap(s.label, width, 14, "sans-semibold", 2);
1714
+ let svg = textBlock(head.lines, 0, y + 16, 18, `class="tgc-ink" font-size="14" font-weight="600"`);
1715
+ y += head.lines.length * 18 + 8;
1716
+ svg += line(0, y, width, y, "tgc-rule");
1717
+ y += 2;
1718
+ for (const task of tasks) {
1719
+ const label = wrap(task, labelW, 12, "sans", 2);
1720
+ const cell = at(task, s);
1721
+ const m = cellMarks(cell, width - cellW, y, cellW, means, scaleMax);
1722
+ const h = Math.max(label.lines.length * 16 + 8, m.h);
1723
+ svg += titled(describe(task, s, cell, means), textBlock(label.lines, 0, y + 16, 16, `class="tgc-ink" font-size="12"`) + m.svg);
1724
+ y += h + 2;
1725
+ }
1726
+ return { svg, h: y };
1727
+ }
1728
+ function blockColumns(tasks, setups, at, means, kinds, o, scaleMax) {
1729
+ const width = (WIDE - BLOCK_GAP) / 2;
1730
+ const blocks = setups.map((s) => setupBlock(tasks, s, at, means, width, scaleMax));
1731
+ let body = "";
1732
+ let y = 0;
1733
+ for (let i = 0; i < blocks.length; i += 2) {
1734
+ const pair = blocks.slice(i, i + 2);
1735
+ pair.forEach((b, j) => {
1736
+ body += `<g transform="translate(${j * (width + BLOCK_GAP)} ${y})">${b.svg}</g>`;
1737
+ });
1738
+ y += Math.max(...pair.map((b) => b.h)) + 16;
1739
+ }
1740
+ const lg = legend(kinds, WIDE, y, o);
1741
+ return svgRoot(WIDE, y + lg.h + 4, body + lg.svg);
1742
+ }
1743
+ function narrowMatrix(tasks, setups, at, means, kinds, o, scaleMax) {
1744
+ let y = 0;
1745
+ let body = "";
1746
+ for (const s of setups) {
1747
+ const b = setupBlock(tasks, s, at, means, NARROW, scaleMax);
1748
+ body += `<g transform="translate(0 ${y})">${b.svg}</g>`;
1749
+ y += b.h + 16;
1750
+ }
1751
+ const lg = legend(kinds, NARROW, y, o);
1752
+ return svgRoot(NARROW, y + lg.h + 4, body + lg.svg);
1753
+ }
1754
+
1755
+ // src/types.ts
1756
+ function isRefusal(value) {
1757
+ return "refused" in value;
1758
+ }
1759
+ export {
1760
+ breakdown,
1761
+ chartTokensCss,
1762
+ comparisonIntervals,
1763
+ costFrontier,
1764
+ figureCss,
1765
+ isRefusal,
1766
+ rankedRates,
1767
+ renderFigure,
1768
+ renderNotes,
1769
+ renderRefusals,
1770
+ taskMatrix
1771
+ };