@tangle-network/charts 0.1.1 → 0.1.2

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 CHANGED
@@ -1,1895 +1,1984 @@
1
- // src/text.ts
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. */
2
11
  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
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
101
110
  ];
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
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
198
207
  ];
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
208
+ const EXTRA = {
209
+ "·": 300,
210
+ "×": 670,
211
+ "—": 1e3,
212
+ "–": 500,
213
+ "…": 900,
214
+ "≥": 670,
215
+ "≤": 670,
216
+ "±": 670,
217
+ "→": 954,
218
+ "−": 662
210
219
  };
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
- }
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>`. */
259
278
  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>`;
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>`;
263
281
  }
282
+ /** Joins `parts` with " · ", starting a new line wherever the next part would pass `width`. */
264
283
  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;
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;
272
291
  }
273
292
  function plural(count, one, many = `${one}s`) {
274
- return count === 1 ? one : many;
293
+ return count === 1 ? one : many;
275
294
  }
295
+ /** "a, b and c" */
276
296
  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();
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/block.ts
305
+ /**
306
+ * The figure block: one markup for every figure on every surface.
307
+ *
308
+ * In order: the finding as a heading with a note reference, a one-sentence
309
+ * lede, the plot (both widths, swapped by `figure.css` on the figure's own
310
+ * width), a reading line, and the data table in a disclosure. Notes render
311
+ * once per page, numbered, each with a way back to its figure.
312
+ */
313
+ const slug = (id) => id.replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
286
314
  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
- }
315
+ const id = slug(f.id);
316
+ const plotLabel = `${f.finding} ${f.lede}`;
317
+ const narrow = f.svg.narrow ? `<div class="tgc-narrow">${f.svg.narrow}</div>` : `<div class="tgc-narrow tgc-narrow-table">${f.table}</div>`;
318
+ return [
319
+ `<figure class="tgc-figure" id="fig-${id}" aria-labelledby="fig-${id}-finding">`,
320
+ `<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>`,
321
+ `<p class="tgc-lede">${esc(f.lede)}</p>`,
322
+ `<div class="tgc-plot" role="img" aria-label="${esc(plotLabel)}">`,
323
+ `<div class="tgc-wide">${f.svg.wide}</div>`,
324
+ narrow,
325
+ `</div>`,
326
+ `<p class="tgc-read">${esc(f.read)}</p>`,
327
+ `<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>`,
328
+ `</figure>`
329
+ ].join("");
330
+ }
331
+ /** Numbered notes for `figures`, in the order their blocks were numbered (1-based). */
303
332
  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
- }
333
+ return `<ol class="tgc-notes">${figures.map((f, i) => {
334
+ const n = i + 1;
335
+ if (!f.note.method.trim() || !f.note.n.trim()) throw new Error(`renderNotes: figure ${f.id} has no method or no n; a note needs both.`);
336
+ return `<li id="note-${n}">${[
337
+ f.note.method,
338
+ f.note.n,
339
+ ...f.note.exclusions
340
+ ].map(esc).join(" ")} <a class="tgc-backlink" href="#ref-${n}" role="doc-backlink" aria-label="Back to figure ${n}">↩</a></li>`;
341
+ }).join("")}</ol>`;
342
+ }
343
+ /** What a page leaves out, and why. */
314
344
  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;
345
+ if (refusals.length === 0) return "";
346
+ return `<ul class="tgc-refusals">${refusals.map((r) => `<li>${esc(r.refused)}</li>`).join("")}</ul>`;
347
+ }
348
+ //#endregion
349
+ //#region src/svg.ts
350
+ /**
351
+ * SVG marks. Every mark carries a class and `figure.css` maps classes to the
352
+ * `--chart-*` tokens, so a render holds no colour and follows the page theme.
353
+ * Geometry stays in attributes, so an SVG shown without the stylesheet keeps
354
+ * its layout.
355
+ */
356
+ const r2 = (n) => Math.round(n * 100) / 100;
323
357
  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>`;
358
+ const h = Math.ceil(height);
359
+ 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
360
  }
361
+ /** A horizontal bar anchored at `x`, square at the baseline, 4 px round at the data end. */
327
362
  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"/>`;
363
+ if (w <= 0) return "";
364
+ const r = Math.min(4, h / 2, w);
365
+ 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
366
  }
332
367
  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}` : ""}/>`;
368
+ if (w <= 0 || h <= 0) return "";
369
+ return `<rect class="${cls}" x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}"${extra ? ` ${extra}` : ""}/>`;
335
370
  }
336
371
  function line(x1, y1, x2, y2, cls) {
337
- return `<line class="${cls}" x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}"/>`;
372
+ return `<line class="${cls}" x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}"/>`;
338
373
  }
339
374
  function circle(cx, cy, r, cls) {
340
- return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
375
+ return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
341
376
  }
342
377
  function polyline(points, cls) {
343
- return `<polyline class="${cls}" points="${points.map(([x, y]) => `${r2(x)},${r2(y)}`).join(" ")}"/>`;
378
+ return `<polyline class="${cls}" points="${points.map(([x, y]) => `${r2(x)},${r2(y)}`).join(" ")}"/>`;
344
379
  }
345
380
  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>`;
381
+ const anchor = o.anchor && o.anchor !== "start" ? ` text-anchor="${o.anchor}"` : "";
382
+ const weight = o.weight === 600 ? ` font-weight="600"` : "";
383
+ return `<text class="${o.cls ?? "tgc-ink"}" x="${r2(x)}" y="${r2(y)}" font-size="${o.size ?? 12}"${anchor}${weight}>${esc(value)}</text>`;
349
384
  }
385
+ /** A group whose `<title>` is the hover text for everything inside it. */
350
386
  function titled(title, inner) {
351
- return `<g><title>${esc(title)}</title>${inner}</g>`;
387
+ return `<g><title>${esc(title)}</title>${inner}</g>`;
352
388
  }
353
-
354
- // src/table.ts
389
+ //#endregion
390
+ //#region src/table.ts
391
+ /** A plain HTML table: the accessible form of a figure and its phone view. */
355
392
  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
393
+ const head = columns.map((c) => `<th scope="col"${c.numeric ? " class=\"tgc-n\"" : ""}>${esc(c.label)}</th>`).join("");
394
+ 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("");
395
+ return `<table class="tgc-table"><caption>${esc(caption)}</caption><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
396
+ }
397
+ //#endregion
398
+ //#region src/breakdown.ts
399
+ /**
400
+ * `breakdown`: why attempts fail.
401
+ *
402
+ * Rows are reasons (a failure layer, cause or check); columns are setups with
403
+ * `n=` under each name. Each cell prints its count over a neutral tint of
404
+ * count / n, capped at 50% so ink keeps its contrast. The accent never marks a
405
+ * failure. On a phone the table is the view.
406
+ *
407
+ * Refuses: a share without its column n (the count prints with no tint);
408
+ * counting excluded attempts (they are stated in the note); hiding a zero.
409
+ */
366
410
  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
- for (const setup of setups) {
371
- if (setup.n !== null && (!Number.isInteger(setup.n) || setup.n < 0)) return { id, refused: `${setup.label}: invalid denominator.` };
372
- }
373
- const count = /* @__PURE__ */ new Map();
374
- for (const c of input.counts) {
375
- if (!reasons.includes(c.reason) || !setups.some((s) => s.id === c.setup)) {
376
- return { id, refused: `A count names ${c.reason} \xD7 ${c.setup}, which is not in the reason and setup lists.` };
377
- }
378
- const key = `${c.reason}\0${c.setup}`;
379
- if (count.has(key)) return { id, refused: `Two counts for ${c.reason} \xD7 ${c.setup}.` };
380
- if (c.count !== null && (!Number.isInteger(c.count) || c.count < 0)) return { id, refused: `${c.reason} \xD7 ${c.setup}: invalid count.` };
381
- const n = setups.find((s) => s.id === c.setup).n;
382
- if (c.count !== null && n !== null && c.count > n) {
383
- return { id, refused: `${c.reason} \xD7 ${c.setup}: ${c.count} exceeds the column's ${n} attempts.` };
384
- }
385
- count.set(key, c.count);
386
- }
387
- const get = (reason, setup) => count.get(`${reason}\0${setup}`) ?? null;
388
- const withN = setups.filter((s) => s.n !== null);
389
- const total = withN.reduce((s, x) => s + x.n, 0);
390
- const sums = reasons.map((r) => ({ r, k: withN.reduce((s, x) => s + (get(r, x.id) ?? 0), 0) }));
391
- const top = Math.max(...sums.map((x) => x.k));
392
- const leaders = sums.filter((x) => x.k === top).map((x) => x.r);
393
- 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.`;
394
- const lede = `${total} ${plural(total, "attempt")} across ${setups.length} ${plural(setups.length, "setup")}, ${reasons.length} ${plural(reasons.length, "reason")}.`;
395
- 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." : "");
396
- const noN = setups.filter((s) => s.n === null);
397
- const exclusions = [
398
- ...input.excluded ? [`${input.excluded.count} ${plural(input.excluded.count, "attempt")} excluded: ${input.excluded.why}.`] : [],
399
- ...noN.length ? [`No share for ${listJoin(noN.map((s) => s.label))}: the column has no n.`] : []
400
- ];
401
- const tableHtml = table(
402
- `${capitalize(o.measure)} by setup`,
403
- [{ label: "Reason" }, ...setups.map((s) => ({ label: `${s.label} (n=${s.n ?? "unknown"})`, numeric: true }))],
404
- reasons.map((r) => [r, ...setups.map((s) => {
405
- const k = get(r, s.id);
406
- return k === null ? "not recorded" : String(k);
407
- })])
408
- );
409
- return {
410
- id,
411
- finding,
412
- lede,
413
- read,
414
- svg: { wide: wide(input, get), narrow: null },
415
- table: tableHtml,
416
- note: {
417
- method: `Counts of ${o.measure} per setup${input.overlapping ? "; reasons overlap" : ""}. Tint is count over n, capped at half strength.`,
418
- n: `${total} ${plural(total, "attempt")} over ${setups.length} ${plural(setups.length, "setup")}.`,
419
- exclusions
420
- }
421
- };
422
- }
423
- function wide(input, get) {
424
- const x0 = 208;
425
- const colW = (WIDE - x0) / input.setups.length;
426
- let head = "";
427
- let headH = 0;
428
- for (const [j, s] of input.setups.entries()) {
429
- const label = wrap(s.label, colW - 8, 12, "sans", 3);
430
- head += titled(s.label, textBlock(label.lines, x0 + j * colW, 14, 16, `class="tgc-ink" font-size="12"`));
431
- head += text(x0 + j * colW, 14 + label.lines.length * 16, `n=${s.n ?? "?"}`, { cls: "tgc-ink-muted tgc-num" });
432
- headH = Math.max(headH, label.lines.length * 16 + 16);
433
- }
434
- let y = headH + 8;
435
- let body = "";
436
- for (const reason of input.reasons) {
437
- const label = wrap(reason, 196, 14, "sans", 2);
438
- const h = Math.max(32, label.lines.length * 18 + 12);
439
- body += textBlock(label.lines, 0, y + 20, 18, `class="tgc-ink" font-size="14"`);
440
- for (const [j, s] of input.setups.entries()) {
441
- const k = get(reason, s.id);
442
- const x = x0 + j * colW;
443
- const share = k !== null && s.n ? k / s.n : null;
444
- let marks = share !== null ? rect(x, y + 2, colW - 8, h - 4, "tgc-tint", `rx="2" style="--s:${share.toFixed(3)}"`) : "";
445
- marks += text(x + 8, y + 20, k === null ? "\xB7" : String(k), { cls: k === null ? "tgc-ink-muted tgc-num" : "tgc-ink tgc-num" });
446
- body += titled(`${reason} \xB7 ${s.label}: ${k === null ? "not recorded" : `${k} of ${s.n ?? "?"}`}`, marks);
447
- }
448
- y += h;
449
- }
450
- return svgRoot(WIDE, y + 4, head + body);
451
- }
452
-
453
- // src/format.ts
454
- var TIERS_BEST_FIRST = ["S", "A", "B", "C", "F"];
455
- var LADDER = ["F", "C", "B", "A", "S"];
411
+ const id = o.id ?? "breakdown";
412
+ const { reasons, setups } = input;
413
+ if (reasons.length === 0 || setups.length === 0) return {
414
+ id,
415
+ refused: `No ${o.measure} to break down.`
416
+ };
417
+ for (const setup of setups) if (setup.n !== null && (!Number.isInteger(setup.n) || setup.n < 0)) return {
418
+ id,
419
+ refused: `${setup.label}: invalid denominator.`
420
+ };
421
+ const count = /* @__PURE__ */ new Map();
422
+ for (const c of input.counts) {
423
+ if (!reasons.includes(c.reason) || !setups.some((s) => s.id === c.setup)) return {
424
+ id,
425
+ refused: `A count names ${c.reason} × ${c.setup}, which is not in the reason and setup lists.`
426
+ };
427
+ const key = `${c.reason}\u0000${c.setup}`;
428
+ if (count.has(key)) return {
429
+ id,
430
+ refused: `Two counts for ${c.reason} × ${c.setup}.`
431
+ };
432
+ if (c.count !== null && (!Number.isInteger(c.count) || c.count < 0)) return {
433
+ id,
434
+ refused: `${c.reason} × ${c.setup}: invalid count.`
435
+ };
436
+ const n = setups.find((s) => s.id === c.setup).n;
437
+ if (c.count !== null && n !== null && c.count > n) return {
438
+ id,
439
+ refused: `${c.reason} × ${c.setup}: ${c.count} exceeds the column's ${n} attempts.`
440
+ };
441
+ count.set(key, c.count);
442
+ }
443
+ const get = (reason, setup) => count.get(`${reason}\u0000${setup}`) ?? null;
444
+ const withN = setups.filter((s) => s.n !== null);
445
+ const total = withN.reduce((s, x) => s + x.n, 0);
446
+ const sums = reasons.map((r) => ({
447
+ r,
448
+ k: withN.reduce((s, x) => s + (get(r, x.id) ?? 0), 0)
449
+ }));
450
+ const top = Math.max(...sums.map((x) => x.k));
451
+ const leaders = sums.filter((x) => x.k === top).map((x) => x.r);
452
+ 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.`;
453
+ const lede = `${total} ${plural(total, "attempt")} across ${setups.length} ${plural(setups.length, "setup")}, ${reasons.length} ${plural(reasons.length, "reason")}.`;
454
+ 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." : "");
455
+ const noN = setups.filter((s) => s.n === null);
456
+ const exclusions = [...input.excluded ? [`${input.excluded.count} ${plural(input.excluded.count, "attempt")} excluded: ${input.excluded.why}.`] : [], ...noN.length ? [`No share for ${listJoin(noN.map((s) => s.label))}: the column has no n.`] : []];
457
+ const tableHtml = table(`${capitalize(o.measure)} by setup`, [{ label: "Reason" }, ...setups.map((s) => ({
458
+ label: `${s.label} (n=${s.n ?? "unknown"})`,
459
+ numeric: true
460
+ }))], reasons.map((r) => [r, ...setups.map((s) => {
461
+ const k = get(r, s.id);
462
+ return k === null ? "not recorded" : String(k);
463
+ })]));
464
+ return {
465
+ id,
466
+ finding,
467
+ lede,
468
+ read,
469
+ svg: {
470
+ wide: wide$1(input, get),
471
+ narrow: null
472
+ },
473
+ table: tableHtml,
474
+ note: {
475
+ method: `Counts of ${o.measure} per setup${input.overlapping ? "; reasons overlap" : ""}. Tint is count over n, capped at half strength.`,
476
+ n: `${total} ${plural(total, "attempt")} over ${setups.length} ${plural(setups.length, "setup")}.`,
477
+ exclusions
478
+ }
479
+ };
480
+ }
481
+ function wide$1(input, get) {
482
+ const x0 = 208;
483
+ const colW = 512 / input.setups.length;
484
+ let head = "";
485
+ let headH = 0;
486
+ for (const [j, s] of input.setups.entries()) {
487
+ const label = wrap(s.label, colW - 8, 12, "sans", 3);
488
+ head += titled(s.label, textBlock(label.lines, x0 + j * colW, 14, 16, `class="tgc-ink" font-size="12"`));
489
+ head += text(x0 + j * colW, 14 + label.lines.length * 16, `n=${s.n ?? "?"}`, { cls: "tgc-ink-muted tgc-num" });
490
+ headH = Math.max(headH, label.lines.length * 16 + 16);
491
+ }
492
+ let y = headH + 8;
493
+ let body = "";
494
+ for (const reason of input.reasons) {
495
+ const label = wrap(reason, 196, 14, "sans", 2);
496
+ const h = Math.max(32, label.lines.length * 18 + 12);
497
+ body += textBlock(label.lines, 0, y + 20, 18, `class="tgc-ink" font-size="14"`);
498
+ for (const [j, s] of input.setups.entries()) {
499
+ const k = get(reason, s.id);
500
+ const x = x0 + j * colW;
501
+ const share = k !== null && s.n ? k / s.n : null;
502
+ let marks = share !== null ? rect(x, y + 2, colW - 8, h - 4, "tgc-tint", `rx="2" style="--s:${share.toFixed(3)}"`) : "";
503
+ marks += text(x + 8, y + 20, k === null ? "·" : String(k), { cls: k === null ? "tgc-ink-muted tgc-num" : "tgc-ink tgc-num" });
504
+ body += titled(`${reason} · ${s.label}: ${k === null ? "not recorded" : `${k} of ${s.n ?? "?"}`}`, marks);
505
+ }
506
+ y += h;
507
+ }
508
+ return svgRoot(720, y + 4, head + body);
509
+ }
510
+ //#endregion
511
+ //#region src/format.ts
512
+ const TIERS_BEST_FIRST = [
513
+ "S",
514
+ "A",
515
+ "B",
516
+ "C",
517
+ "F"
518
+ ];
519
+ const LADDER = [
520
+ "F",
521
+ "C",
522
+ "B",
523
+ "A",
524
+ "S"
525
+ ];
456
526
  function tierAtLeast(tier, floor) {
457
- return LADDER.indexOf(tier) >= LADDER.indexOf(floor);
527
+ return LADDER.indexOf(tier) >= LADDER.indexOf(floor);
458
528
  }
459
529
  function pct(value) {
460
- return `${Math.round(value * 100)}%`;
530
+ return `${Math.round(value * 100)}%`;
461
531
  }
532
+ /** A difference in points: "+25 pts". */
462
533
  function pts(value) {
463
- const n = Math.round(value * 100);
464
- return `${n > 0 ? "+" : n < 0 ? "\u2212" : ""}${Math.abs(n)} pts`;
534
+ const n = Math.round(value * 100);
535
+ return `${n > 0 ? "+" : n < 0 ? "−" : ""}${Math.abs(n)} pts`;
465
536
  }
466
537
  function usd(value) {
467
- if (value >= 100) return `$${Math.round(value).toLocaleString("en-US")}`;
468
- if (value >= 1) return `$${value.toFixed(2)}`;
469
- if (value >= 0.01) return `$${value.toFixed(3)}`;
470
- return `$${value.toFixed(4)}`;
538
+ if (value >= 100) return `$${Math.round(value).toLocaleString("en-US")}`;
539
+ if (value >= 1) return `$${value.toFixed(2)}`;
540
+ if (value >= .01) return `$${value.toFixed(3)}`;
541
+ return `$${value.toFixed(4)}`;
471
542
  }
543
+ /** Axis ticks: "$30", "$2.5", "$0.05". */
472
544
  function usdTick(value) {
473
- return `$${Number(value.toPrecision(2)).toLocaleString("en-US", { maximumFractionDigits: 4 })}`;
545
+ return `$${Number(value.toPrecision(2)).toLocaleString("en-US", { maximumFractionDigits: 4 })}`;
474
546
  }
475
547
  function duration(ms) {
476
- const s = ms / 1e3;
477
- if (s < 60) return `${Math.round(s)} s`;
478
- if (s < 3600) return `${(s / 60).toFixed(1)} min`;
479
- return `${(s / 3600).toFixed(1)} h`;
548
+ const s = ms / 1e3;
549
+ if (s < 60) return `${Math.round(s)} s`;
550
+ if (s < 3600) return `${(s / 60).toFixed(1)} min`;
551
+ return `${(s / 3600).toFixed(1)} h`;
480
552
  }
481
553
  function kn(k, n) {
482
- return `${k}/${n}`;
554
+ return `${k}/${n}`;
483
555
  }
484
- var METHOD_NAME = {
485
- "task-cluster-bootstrap": "task-clustered bootstrap",
486
- "mcnemar-exact": "exact McNemar",
487
- wilson: "Wilson score"
556
+ const METHOD_NAME = {
557
+ "task-cluster-bootstrap": "task-clustered bootstrap",
558
+ "mcnemar-exact": "exact McNemar",
559
+ wilson: "Wilson score"
488
560
  };
561
+ /** "95% Wilson score" */
489
562
  function intervalName(interval) {
490
- return `${Math.round((interval.level ?? 0) * 100)}% ${interval.method ? METHOD_NAME[interval.method] : ""}`.trim();
563
+ return `${Math.round((interval.level ?? 0) * 100)}% ${interval.method ? METHOD_NAME[interval.method] : ""}`.trim();
491
564
  }
565
+ /** Why an interval cannot be drawn, or null when it can. */
492
566
  function intervalGap(interval) {
493
- if (!interval) return "no interval";
494
- const missing = [interval.level === null ? "level" : "", interval.method === null ? "method" : ""].filter(Boolean);
495
- if (missing.length) return `the interval names no ${missing.join(" or ")}`;
496
- if (!(interval.method in METHOD_NAME)) return "the interval method is unknown";
497
- if (!Number.isFinite(interval.level) || interval.level <= 0 || interval.level >= 1) return "the interval level is outside 0\u20131";
498
- if (!Number.isFinite(interval.lower) || !Number.isFinite(interval.upper)) return "the interval bounds are not finite";
499
- if (!(interval.lower <= interval.upper)) return "the interval bounds are out of order";
500
- return null;
501
- }
567
+ if (!interval) return "no interval";
568
+ const missing = [interval.level === null ? "level" : "", interval.method === null ? "method" : ""].filter(Boolean);
569
+ if (missing.length) return `the interval names no ${missing.join(" or ")}`;
570
+ if (!(interval.method in METHOD_NAME)) return "the interval method is unknown";
571
+ if (!Number.isFinite(interval.level) || interval.level <= 0 || interval.level >= 1) return "the interval level is outside 0–1";
572
+ if (!Number.isFinite(interval.lower) || !Number.isFinite(interval.upper)) return "the interval bounds are not finite";
573
+ if (!(interval.lower <= interval.upper)) return "the interval bounds are out of order";
574
+ return null;
575
+ }
576
+ /** Whether a rate bar may be drawn for this estimate. */
502
577
  function drawable(estimate) {
503
- return estimate === "descriptive" || estimate === "bootstrap";
578
+ return estimate === "descriptive" || estimate === "bootstrap";
504
579
  }
505
580
  function estimateNote(estimate, units) {
506
- if (estimate === "none") return units === 1 ? "1 unit" : `${units} units`;
507
- if (estimate === "insufficient") return "too few units";
508
- return "";
509
- }
581
+ if (estimate === "none") return units === 1 ? "1 unit" : `${units} units`;
582
+ if (estimate === "insufficient") return "too few units";
583
+ return "";
584
+ }
585
+ /**
586
+ * The positive cost per pass this figure can print or place, or null.
587
+ * A zero value needs a separate verified-free representation; this field alone
588
+ * cannot distinguish a free run from missing spend, so it prints "unknown".
589
+ */
510
590
  function perPass(cost) {
511
- return cost.basis !== "unknown" && cost.perSolvedUsd !== null && Number.isFinite(cost.perSolvedUsd) && cost.perSolvedUsd > 0 ? cost.perSolvedUsd : null;
591
+ return cost.basis !== "unknown" && cost.perSolvedUsd !== null && Number.isFinite(cost.perSolvedUsd) && cost.perSolvedUsd > 0 ? cost.perSolvedUsd : null;
512
592
  }
593
+ /** "$23.76", "$23.76 est.", "unknown", "no pass" */
513
594
  function costText(cost, solved) {
514
- if (solved === 0) return "no pass";
515
- const value = perPass(cost);
516
- if (value === null) return "unknown";
517
- return cost.basis === "receipts" ? usd(value) : `${usd(value)} est.`;
595
+ if (solved === 0) return "no pass";
596
+ const value = perPass(cost);
597
+ if (value === null) return "unknown";
598
+ return cost.basis === "receipts" ? usd(value) : `${usd(value)} est.`;
518
599
  }
600
+ /** Wilson can describe an eligible rate; clustered bootstrap needs the producer's bootstrap class. */
519
601
  function rateIntervalGap(row) {
520
- if (!drawable(row.estimate)) return `${row.estimate} estimate`;
521
- if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0\u20131";
522
- const gap = intervalGap(row.interval);
523
- if (gap) return gap;
524
- if (row.interval.method === "mcnemar-exact") return "exact McNemar applies to paired comparisons, not rates";
525
- if (row.estimate === "descriptive" && row.interval.method !== "wilson") {
526
- return `descriptive estimate does not support ${intervalName(row.interval)}`;
527
- }
528
- if (row.interval.lower < 0 || row.interval.upper > 1) return "the rate interval is outside 0\u20131";
529
- if (row.rate < row.interval.lower - 1e-6 || row.rate > row.interval.upper + 1e-6) {
530
- return "the rate falls outside its interval";
531
- }
532
- return null;
533
- }
602
+ if (!drawable(row.estimate)) return `${row.estimate} estimate`;
603
+ if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0–1";
604
+ const gap = intervalGap(row.interval);
605
+ if (gap) return gap;
606
+ if (row.interval.method === "mcnemar-exact") return "exact McNemar applies to paired comparisons, not rates";
607
+ if (row.estimate === "descriptive" && row.interval.method !== "wilson") return `descriptive estimate does not support ${intervalName(row.interval)}`;
608
+ if (row.interval.lower < 0 || row.interval.upper > 1) return "the rate interval is outside 0–1";
609
+ if (row.rate < row.interval.lower - 1e-6 || row.rate > row.interval.upper + 1e-6) return "the rate falls outside its interval";
610
+ return null;
611
+ }
612
+ /** Check the source arithmetic before using a rate in a finding or a mark. */
534
613
  function rateDataGap(row) {
535
- if (!Number.isInteger(row.attempts) || row.attempts < 0 || !Number.isInteger(row.solved) || row.solved < 0 || row.solved > row.attempts) {
536
- return "passed and attempt counts are invalid";
537
- }
538
- if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0\u20131";
539
- if (row.attempts > 0 && Math.abs(row.rate - row.solved / row.attempts) > 1e-6) return "rate disagrees with passed/attempts";
540
- if (row.attempts === 0 && row.rate !== 0) return "rate has no attempts";
541
- if (drawable(row.estimate) && row.attempts === 0) return "drawable estimate has no attempts";
542
- return null;
543
- }
614
+ 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";
615
+ if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0–1";
616
+ if (row.attempts > 0 && Math.abs(row.rate - row.solved / row.attempts) > 1e-6) return "rate disagrees with passed/attempts";
617
+ if (row.attempts === 0 && row.rate !== 0) return "rate has no attempts";
618
+ if (drawable(row.estimate) && row.attempts === 0) return "drawable estimate has no attempts";
619
+ return null;
620
+ }
621
+ /**
622
+ * The order rows are drawn in. Ranked rows keep their rank. Otherwise rows
623
+ * with enough units to draw a rate come first, highest rate first; rows with
624
+ * too few units follow, most units first, so a 1/1 row never heads the list.
625
+ */
544
626
  function rateOrder(rows) {
545
- if (rows.length > 0 && rows.every((row) => row.rank !== null && row.estimate === "bootstrap")) {
546
- return [...rows].sort((a, b) => a.rank - b.rank);
547
- }
548
- const drawn = rows.filter((row) => drawable(row.estimate)).sort((a, b) => b.rate - a.rate);
549
- const counted = rows.filter((row) => !drawable(row.estimate)).sort((a, b) => b.attempts - a.attempts);
550
- return [...drawn, ...counted];
627
+ if (rows.length > 0 && rows.every((row) => row.rank !== null && row.estimate === "bootstrap")) return [...rows].sort((a, b) => a.rank - b.rank);
628
+ const drawn = rows.filter((row) => drawable(row.estimate)).sort((a, b) => b.rate - a.rate);
629
+ const counted = rows.filter((row) => !drawable(row.estimate)).sort((a, b) => b.attempts - a.attempts);
630
+ return [...drawn, ...counted];
551
631
  }
632
+ /** "2A 1F" */
552
633
  function tierSummary(tiers, unmeasured = 0, flagged = 0) {
553
- const parts = tiers ? TIERS_BEST_FIRST.filter((t) => tiers[t] > 0).map((t) => `${tiers[t]}${t}`) : [];
554
- if (flagged) parts.push(`${flagged} flagged`);
555
- if (unmeasured) parts.push(`${unmeasured}U`);
556
- return parts.join(" ");
634
+ const parts = tiers ? TIERS_BEST_FIRST.filter((t) => tiers[t] > 0).map((t) => `${tiers[t]}${t}`) : [];
635
+ if (flagged) parts.push(`${flagged} flagged`);
636
+ if (unmeasured) parts.push(`${unmeasured}U`);
637
+ return parts.join(" ");
557
638
  }
558
639
  function tierTotal(tiers) {
559
- return tiers ? LADDER.reduce((sum, t) => sum + tiers[t], 0) : 0;
640
+ return tiers ? LADDER.reduce((sum, t) => sum + tiers[t], 0) : 0;
560
641
  }
561
-
562
- // src/scale.ts
642
+ //#endregion
643
+ //#region src/scale.ts
563
644
  function linear(domain, range) {
564
- const [d0, d1] = domain;
565
- const [r0, r1] = range;
566
- const slope = (r1 - r0) / (d1 - d0 || 1);
567
- const scale = ((value) => r0 + (value - d0) * slope);
568
- scale.domain = domain;
569
- scale.range = range;
570
- return scale;
571
- }
645
+ const [d0, d1] = domain;
646
+ const [r0, r1] = range;
647
+ const slope = (r1 - r0) / (d1 - d0 || 1);
648
+ const scale = ((value) => r0 + (value - d0) * slope);
649
+ scale.domain = domain;
650
+ scale.range = range;
651
+ return scale;
652
+ }
653
+ /** A log10 scale; `domain` must be positive. Pass a reversed range to put small values right. */
572
654
  function log(domain, range) {
573
- const inner = linear([Math.log10(domain[0]), Math.log10(domain[1])], range);
574
- const scale = ((value) => inner(Math.log10(value)));
575
- scale.domain = domain;
576
- scale.range = range;
577
- return scale;
655
+ const inner = linear([Math.log10(domain[0]), Math.log10(domain[1])], range);
656
+ const scale = ((value) => inner(Math.log10(value)));
657
+ scale.domain = domain;
658
+ scale.range = range;
659
+ return scale;
578
660
  }
661
+ /** Evenly spaced ticks at 1, 2 or 5 × 10^k, about `count` of them. */
579
662
  function linearTicks(min, max, count) {
580
- const span = max - min;
581
- if (span <= 0) return [min];
582
- const rough = span / count;
583
- const exp = 10 ** Math.floor(Math.log10(rough));
584
- const fraction = rough / exp;
585
- const step = (fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10) * exp;
586
- const out = [];
587
- for (let v = Math.ceil(min / step) * step; v <= max + step * 1e-9; v += step) {
588
- out.push(Number(v.toFixed(10)));
589
- }
590
- return out;
591
- }
663
+ const span = max - min;
664
+ if (span <= 0) return [min];
665
+ const rough = span / count;
666
+ const exp = 10 ** Math.floor(Math.log10(rough));
667
+ const fraction = rough / exp;
668
+ const step = (fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10) * exp;
669
+ const out = [];
670
+ for (let v = Math.ceil(min / step) * step; v <= max + step * 1e-9; v += step) out.push(Number(v.toFixed(10)));
671
+ return out;
672
+ }
673
+ /**
674
+ * Log ticks: the coarsest mantissa set that puts at least three ticks inside
675
+ * the domain, so a narrow domain still reads.
676
+ */
592
677
  function logTicks(min, max) {
593
- const sets = [[1], [1, 2, 5], [1, 2, 3, 5], [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8]];
594
- let best = [];
595
- for (const mantissas of sets) {
596
- const out = [];
597
- for (let e = Math.floor(Math.log10(min)) - 1; e <= Math.ceil(Math.log10(max)); e++) {
598
- for (const m of mantissas) {
599
- const v = Number((m * 10 ** e).toPrecision(6));
600
- if (v >= min && v <= max) out.push(v);
601
- }
602
- }
603
- best = out;
604
- if (out.length >= 3) break;
605
- }
606
- return best;
607
- }
678
+ const sets = [
679
+ [1],
680
+ [
681
+ 1,
682
+ 2,
683
+ 5
684
+ ],
685
+ [
686
+ 1,
687
+ 2,
688
+ 3,
689
+ 5
690
+ ],
691
+ [
692
+ 1,
693
+ 1.5,
694
+ 2,
695
+ 2.5,
696
+ 3,
697
+ 4,
698
+ 5,
699
+ 6,
700
+ 8
701
+ ]
702
+ ];
703
+ let best = [];
704
+ for (const mantissas of sets) {
705
+ const out = [];
706
+ for (let e = Math.floor(Math.log10(min)) - 1; e <= Math.ceil(Math.log10(max)); e++) for (const m of mantissas) {
707
+ const v = Number((m * 10 ** e).toPrecision(6));
708
+ if (v >= min && v <= max) out.push(v);
709
+ }
710
+ best = out;
711
+ if (out.length >= 3) break;
712
+ }
713
+ return best;
714
+ }
715
+ /** Widens [lo, hi] geometrically to span at least `minFactor`, then pads each side by `pad`. */
608
716
  function logDomain(lo, hi, minFactor = 3, pad = 1.25) {
609
- let a = lo;
610
- let b = hi;
611
- if (b / a < minFactor) {
612
- const centre = Math.sqrt(a * b);
613
- const half = Math.sqrt(minFactor);
614
- a = centre / half;
615
- b = centre * half;
616
- }
617
- return [a / pad, b * pad];
618
- }
619
-
620
- // src/comparison-intervals.ts
717
+ let a = lo;
718
+ let b = hi;
719
+ if (b / a < minFactor) {
720
+ const centre = Math.sqrt(a * b);
721
+ const half = Math.sqrt(minFactor);
722
+ a = centre / half;
723
+ b = centre * half;
724
+ }
725
+ return [a / pad, b * pad];
726
+ }
727
+ //#endregion
728
+ //#region src/comparison-intervals.ts
729
+ /**
730
+ * `comparisonIntervals`: is each step in an order real.
731
+ *
732
+ * One row per comparison, "{favored} over {other}", with the pair count. x is
733
+ * the difference in points with a muted zero line. The clustered interval is a
734
+ * 6 px accent bar, the exact interval a 2 px muted bar under it, and a dashed
735
+ * ink tick marks the row's registered minimum effect. There is no point
736
+ * estimate, because the record carries none.
737
+ *
738
+ * Refuses: a row whose interval names no level or method, or that has no pair
739
+ * count or no registered minimum effect (each is listed in the note); the
740
+ * whole figure when no row can be drawn.
741
+ */
621
742
  function gaps(row) {
622
- const out = [];
623
- const gap = intervalGap(row.interval);
624
- if (gap) out.push(gap);
625
- if (row.exactInterval !== null) {
626
- const exactGap = intervalGap(row.exactInterval);
627
- if (exactGap) out.push(`exact interval: ${exactGap}`);
628
- }
629
- if (row.pairs === null) out.push("no pair count");
630
- else if (!Number.isInteger(row.pairs) || row.pairs <= 0) out.push("invalid pair count");
631
- if (row.minimumEffect === null) out.push("no registered minimum effect");
632
- else if (!Number.isFinite(row.minimumEffect)) out.push("invalid registered minimum effect");
633
- return out;
743
+ const out = [];
744
+ const gap = intervalGap(row.interval);
745
+ if (gap) out.push(gap);
746
+ if (row.exactInterval !== null) {
747
+ const exactGap = intervalGap(row.exactInterval);
748
+ if (exactGap) out.push(`exact interval: ${exactGap}`);
749
+ }
750
+ if (row.pairs === null) out.push("no pair count");
751
+ else if (!Number.isInteger(row.pairs) || row.pairs <= 0) out.push("invalid pair count");
752
+ if (row.minimumEffect === null) out.push("no registered minimum effect");
753
+ else if (!Number.isFinite(row.minimumEffect)) out.push("invalid registered minimum effect");
754
+ return out;
634
755
  }
635
756
  function comparisonIntervals(rows, setups, o) {
636
- const id = o.id ?? "comparison-intervals";
637
- const name = (setupId) => setups.find((s) => s.id === setupId)?.label ?? setupId;
638
- const title = (row) => `${name(row.favored)} over ${name(row.other)}`;
639
- if (rows.length === 0) return { id, refused: "No comparison was registered." };
640
- const drawn = [];
641
- const refused = [];
642
- for (const row of rows) {
643
- const missing = gaps(row);
644
- if (missing.length) refused.push(`${title(row)}: ${listJoin(missing)}`);
645
- else drawn.push({ row, title: title(row) });
646
- }
647
- if (drawn.length === 0) {
648
- return {
649
- id,
650
- refused: rows.every((r) => r.interval === null) ? `${rows.length} ${plural(rows.length, "comparison")} listed; none carries an interval.` : `No comparison can be drawn. ${refused.join("; ")}.`
651
- };
652
- }
653
- const clears = drawn.filter(
654
- ({ row }) => row.interval.lower > row.minimumEffect && (intervalGap(row.exactInterval) !== null || row.exactInterval.lower > row.minimumEffect)
655
- );
656
- 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.`;
657
- const pairs = drawn.reduce((s, d) => s + d.row.pairs, 0);
658
- const lede = `${drawn.length} ${plural(drawn.length, "comparison")} over ${pairs} paired ${plural(pairs, "attempt")}${refused.length ? `; ${refused.length} not drawn` : ""}.`;
659
- const names = [...new Set(drawn.map((d) => intervalName(d.row.interval)))];
660
- const exactNames = [
661
- ...new Set(drawn.filter((d) => intervalGap(d.row.exactInterval) === null).map((d) => intervalName(d.row.exactInterval)))
662
- ];
663
- 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.";
664
- const columns = [
665
- { label: "Comparison" },
666
- { label: "Pairs", numeric: true },
667
- { label: "Minimum effect", numeric: true },
668
- { label: "Interval", numeric: true },
669
- { label: "Exact interval", numeric: true }
670
- ];
671
- const fmt = (i, n) => i && intervalGap(i) === null ? `${pts(i.lower)} to ${pts(i.upper)} (${intervalName(i)}, n=${n})` : i ? `not drawn (${intervalGap(i)})` : "none";
672
- const tableRows = rows.map((row) => [
673
- title(row),
674
- row.pairs === null ? "not recorded" : String(row.pairs),
675
- row.minimumEffect === null ? "none registered" : pts(row.minimumEffect),
676
- fmt(row.interval, row.pairs),
677
- fmt(row.exactInterval, row.pairs)
678
- ]);
679
- const values = drawn.flatMap(({ row }) => [
680
- row.interval.lower,
681
- row.interval.upper,
682
- row.minimumEffect,
683
- 0,
684
- ...intervalGap(row.exactInterval) === null ? [row.exactInterval.lower, row.exactInterval.upper] : []
685
- ]);
686
- const span = Math.max(...values) - Math.min(...values) || 0.1;
687
- const domain = [Math.min(...values) - span * 0.08, Math.max(...values) + span * 0.08];
688
- return {
689
- id,
690
- finding,
691
- lede,
692
- read,
693
- svg: { wide: draw(drawn, domain, WIDE, 272, 264), narrow: draw(drawn, domain, NARROW, 0, 0) },
694
- table: table(`Comparisons on ${o.measure}`, columns, tableRows),
695
- note: {
696
- method: `${capitalize(o.measure)} difference, favored minus other, in points. Intervals: ${[...names, ...exactNames].join(", ")}.`,
697
- n: `${pairs} paired ${plural(pairs, "attempt")} over ${drawn.length} ${plural(drawn.length, "comparison")}.`,
698
- exclusions: refused.map((r) => `Not drawn: ${r}.`)
699
- }
700
- };
701
- }
757
+ const id = o.id ?? "comparison-intervals";
758
+ const name = (setupId) => setups.find((s) => s.id === setupId)?.label ?? setupId;
759
+ const title = (row) => `${name(row.favored)} over ${name(row.other)}`;
760
+ if (rows.length === 0) return {
761
+ id,
762
+ refused: "No comparison was registered."
763
+ };
764
+ const drawn = [];
765
+ const refused = [];
766
+ for (const row of rows) {
767
+ const missing = gaps(row);
768
+ if (missing.length) refused.push(`${title(row)}: ${listJoin(missing)}`);
769
+ else drawn.push({
770
+ row,
771
+ title: title(row)
772
+ });
773
+ }
774
+ if (drawn.length === 0) return {
775
+ id,
776
+ refused: rows.every((r) => r.interval === null) ? `${rows.length} ${plural(rows.length, "comparison")} listed; none carries an interval.` : `No comparison can be drawn. ${refused.join("; ")}.`
777
+ };
778
+ const clears = drawn.filter(({ row }) => row.interval.lower > row.minimumEffect && (intervalGap(row.exactInterval) !== null || row.exactInterval.lower > row.minimumEffect));
779
+ 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.`;
780
+ const pairs = drawn.reduce((s, d) => s + d.row.pairs, 0);
781
+ const lede = `${drawn.length} ${plural(drawn.length, "comparison")} over ${pairs} paired ${plural(pairs, "attempt")}${refused.length ? `; ${refused.length} not drawn` : ""}.`;
782
+ const names = [...new Set(drawn.map((d) => intervalName(d.row.interval)))];
783
+ const exactNames = [...new Set(drawn.filter((d) => intervalGap(d.row.exactInterval) === null).map((d) => intervalName(d.row.exactInterval)))];
784
+ 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.";
785
+ const columns = [
786
+ { label: "Comparison" },
787
+ {
788
+ label: "Pairs",
789
+ numeric: true
790
+ },
791
+ {
792
+ label: "Minimum effect",
793
+ numeric: true
794
+ },
795
+ {
796
+ label: "Interval",
797
+ numeric: true
798
+ },
799
+ {
800
+ label: "Exact interval",
801
+ numeric: true
802
+ }
803
+ ];
804
+ const fmt = (i, n) => i && intervalGap(i) === null ? `${pts(i.lower)} to ${pts(i.upper)} (${intervalName(i)}, n=${n})` : i ? `not drawn (${intervalGap(i)})` : "none";
805
+ const tableRows = rows.map((row) => [
806
+ title(row),
807
+ row.pairs === null ? "not recorded" : String(row.pairs),
808
+ row.minimumEffect === null ? "none registered" : pts(row.minimumEffect),
809
+ fmt(row.interval, row.pairs),
810
+ fmt(row.exactInterval, row.pairs)
811
+ ]);
812
+ const values = drawn.flatMap(({ row }) => [
813
+ row.interval.lower,
814
+ row.interval.upper,
815
+ row.minimumEffect,
816
+ 0,
817
+ ...intervalGap(row.exactInterval) === null ? [row.exactInterval.lower, row.exactInterval.upper] : []
818
+ ]);
819
+ const span = Math.max(...values) - Math.min(...values) || .1;
820
+ const domain = [Math.min(...values) - span * .08, Math.max(...values) + span * .08];
821
+ return {
822
+ id,
823
+ finding,
824
+ lede,
825
+ read,
826
+ svg: {
827
+ wide: draw(drawn, domain, 720, 272, 264),
828
+ narrow: draw(drawn, domain, 358, 0, 0)
829
+ },
830
+ table: table(`Comparisons on ${o.measure}`, columns, tableRows),
831
+ note: {
832
+ method: `${capitalize(o.measure)} difference, favored minus other, in points. Intervals: ${[...names, ...exactNames].join(", ")}.`,
833
+ n: `${pairs} paired ${plural(pairs, "attempt")} over ${drawn.length} ${plural(drawn.length, "comparison")}.`,
834
+ exclusions: refused.map((r) => `Not drawn: ${r}.`)
835
+ }
836
+ };
837
+ }
838
+ /** labelW 0 stacks the label above the bar (phone). */
702
839
  function draw(drawn, domain, width, plotX, labelW) {
703
- const x = linear(domain, [plotX, width - 8]);
704
- let body = "";
705
- let y = 8;
706
- const rowsTop = y;
707
- for (const { row, title } of drawn) {
708
- let marks = "";
709
- let barY;
710
- if (labelW > 0) {
711
- const label = wrap(title, labelW, 14);
712
- marks += textBlock(label.lines, 0, y + 14, 18, `class="tgc-ink" font-size="14"`);
713
- marks += text(0, y + 14 + label.lines.length * 18, `${row.pairs} pairs`, { cls: "tgc-ink-muted tgc-num" });
714
- barY = y + 8;
715
- y += Math.max(label.lines.length * 18 + 22, 36);
716
- } else {
717
- const label = wrap(title, width, 14);
718
- marks += textBlock(label.lines, 0, y + 14, 18, `class="tgc-ink" font-size="14"`);
719
- marks += text(width, y + 14, `${row.pairs} pairs`, { cls: "tgc-ink-muted tgc-num", anchor: "end" });
720
- barY = y + label.lines.length * 18 + 4;
721
- y = barY + 24;
722
- }
723
- const lo = x(row.interval.lower);
724
- const hi = x(row.interval.upper);
725
- marks += rect(lo, barY, Math.max(hi - lo, 2), 6, "tgc-ci");
726
- if (row.exactInterval && intervalGap(row.exactInterval) === null) {
727
- const elo = x(row.exactInterval.lower);
728
- const ehi = x(row.exactInterval.upper);
729
- marks += rect(elo, barY + 9, Math.max(ehi - elo, 2), 2, "tgc-ci-exact");
730
- }
731
- const me = x(row.minimumEffect);
732
- marks += line(me, barY - 8, me, barY + 20, "tgc-threshold");
733
- body += titled(
734
- `${title} \xB7 ${row.pairs} pairs \xB7 ${intervalName(row.interval)} interval ${pts(row.interval.lower)} to ${pts(row.interval.upper)}${rateExactTitle(row)} \xB7 minimum effect ${pts(row.minimumEffect)}`,
735
- marks
736
- );
737
- }
738
- const zero = x(0);
739
- let axis = line(zero, rowsTop, zero, y, "tgc-zero");
740
- for (const t of linearTicks(domain[0], domain[1], width > 400 ? 6 : 3)) {
741
- axis += text(x(t), y + 16, pts(t), { cls: "tgc-ink-muted tgc-num", anchor: "middle" });
742
- }
743
- return svgRoot(width, y + 24, axis + body);
840
+ const x = linear(domain, [plotX, width - 8]);
841
+ let body = "";
842
+ let y = 8;
843
+ const rowsTop = y;
844
+ for (const { row, title } of drawn) {
845
+ let marks = "";
846
+ let barY;
847
+ if (labelW > 0) {
848
+ const label = wrap(title, labelW, 14);
849
+ marks += textBlock(label.lines, 0, y + 14, 18, `class="tgc-ink" font-size="14"`);
850
+ marks += text(0, y + 14 + label.lines.length * 18, `${row.pairs} pairs`, { cls: "tgc-ink-muted tgc-num" });
851
+ barY = y + 8;
852
+ y += Math.max(label.lines.length * 18 + 22, 36);
853
+ } else {
854
+ const label = wrap(title, width, 14);
855
+ marks += textBlock(label.lines, 0, y + 14, 18, `class="tgc-ink" font-size="14"`);
856
+ marks += text(width, y + 14, `${row.pairs} pairs`, {
857
+ cls: "tgc-ink-muted tgc-num",
858
+ anchor: "end"
859
+ });
860
+ barY = y + label.lines.length * 18 + 4;
861
+ y = barY + 24;
862
+ }
863
+ const lo = x(row.interval.lower);
864
+ const hi = x(row.interval.upper);
865
+ marks += rect(lo, barY, Math.max(hi - lo, 2), 6, "tgc-ci");
866
+ if (row.exactInterval && intervalGap(row.exactInterval) === null) {
867
+ const elo = x(row.exactInterval.lower);
868
+ const ehi = x(row.exactInterval.upper);
869
+ marks += rect(elo, barY + 9, Math.max(ehi - elo, 2), 2, "tgc-ci-exact");
870
+ }
871
+ const me = x(row.minimumEffect);
872
+ marks += line(me, barY - 8, me, barY + 20, "tgc-threshold");
873
+ body += titled(`${title} · ${row.pairs} pairs · ${intervalName(row.interval)} interval ${pts(row.interval.lower)} to ${pts(row.interval.upper)}${rateExactTitle(row)} · minimum effect ${pts(row.minimumEffect)}`, marks);
874
+ }
875
+ const zero = x(0);
876
+ let axis = line(zero, rowsTop, zero, y, "tgc-zero");
877
+ for (const t of linearTicks(domain[0], domain[1], width > 400 ? 6 : 3)) axis += text(x(t), y + 16, pts(t), {
878
+ cls: "tgc-ink-muted tgc-num",
879
+ anchor: "middle"
880
+ });
881
+ return svgRoot(width, y + 24, axis + body);
744
882
  }
745
883
  function rateExactTitle(row) {
746
- return intervalGap(row.exactInterval) === null ? ` \xB7 ${intervalName(row.exactInterval)} exact interval ${pts(row.exactInterval.lower)} to ${pts(row.exactInterval.upper)}` : "";
747
- }
748
-
749
- // src/cost-frontier.ts
750
- var overlaps = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
884
+ return intervalGap(row.exactInterval) === null ? ` · ${intervalName(row.exactInterval)} exact interval ${pts(row.exactInterval.lower)} to ${pts(row.exactInterval.upper)}` : "";
885
+ }
886
+ //#endregion
887
+ //#region src/cost-frontier.ts
888
+ /**
889
+ * `costFrontier`: which setup gives the most passes per dollar.
890
+ *
891
+ * y is the measure on a fixed 0–1 axis. x is cost per pass on a log
892
+ * scale, reversed so cheaper sits right and better is always up and right. A
893
+ * point on the producer's frontier is an accent disc; any other point is a
894
+ * hollow muted circle, and an estimated cost is never a filled disc. An ink
895
+ * line joins frontier points. Every point is labelled directly; a label that
896
+ * collides everywhere becomes a number listed in the table. A setup with no
897
+ * pass, an unknown cost or too few units for a rate is listed under the axis,
898
+ * never placed: a log axis has no origin, and a point's height is a rate. On a
899
+ * phone the plot becomes a list in rate order, with the same point marks, a
900
+ * rate bar and the cost as text.
901
+ *
902
+ * Refuses: the whole figure when fewer than two setups have both a cost per
903
+ * pass and enough units for a rate; the frontier line unless two or more
904
+ * plotted rows carry `onFront: true` (it never computes dominance itself).
905
+ */
906
+ const overlaps = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
907
+ /** Whether the segment a→b crosses `box` (Liang–Barsky clipping). */
751
908
  function crosses(box, a, b) {
752
- const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
753
- let t0 = 0;
754
- let t1 = 1;
755
- for (const [p, q] of [
756
- [-dx, a[0] - box.x0],
757
- [dx, box.x1 - a[0]],
758
- [-dy, a[1] - box.y0],
759
- [dy, box.y1 - a[1]]
760
- ]) {
761
- if (p === 0) {
762
- if (q < 0) return false;
763
- continue;
764
- }
765
- const t = q / p;
766
- if (p < 0) t0 = Math.max(t0, t);
767
- else t1 = Math.min(t1, t);
768
- if (t0 > t1) return false;
769
- }
770
- return true;
771
- }
772
- var plottable = (row) => row.solved > 0 && perPass(row.cost) !== null && drawable(row.estimate);
909
+ const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
910
+ let t0 = 0;
911
+ let t1 = 1;
912
+ for (const [p, q] of [
913
+ [-dx, a[0] - box.x0],
914
+ [dx, box.x1 - a[0]],
915
+ [-dy, a[1] - box.y0],
916
+ [dy, box.y1 - a[1]]
917
+ ]) {
918
+ if (p === 0) {
919
+ if (q < 0) return false;
920
+ continue;
921
+ }
922
+ const t = q / p;
923
+ if (p < 0) t0 = Math.max(t0, t);
924
+ else t1 = Math.min(t1, t);
925
+ if (t0 > t1) return false;
926
+ }
927
+ return true;
928
+ }
929
+ /** A row the plot can place: a cost per pass and enough units for its height. */
930
+ const plottable = (row) => row.solved > 0 && perPass(row.cost) !== null && drawable(row.estimate);
931
+ /** Why a row is listed under the axis instead of placed. */
773
932
  function unplacedReason(row) {
774
- if (row.solved === 0) return "0 passed";
775
- if (!drawable(row.estimate)) return estimateNote(row.estimate, row.attempts);
776
- return "cost unknown";
933
+ if (row.solved === 0) return "0 passed";
934
+ if (!drawable(row.estimate)) return estimateNote(row.estimate, row.attempts);
935
+ return "cost unknown";
777
936
  }
778
937
  function costFrontier(rows, o) {
779
- const id = o.id ?? "cost-frontier";
780
- for (const row of rows) {
781
- const gap = rateDataGap(row);
782
- if (gap) return { id, refused: `${row.label}: ${gap}.` };
783
- }
784
- const costed = rows.filter(plottable);
785
- const unplaced = rateOrder(rows.filter((row) => !plottable(row)));
786
- if (!rows.some((row) => row.solved > 0 && perPass(row.cost) !== null)) return { id, refused: `No setup has a cost per pass, so cost is not drawn.` };
787
- if (costed.length < 2) {
788
- return {
789
- id,
790
- refused: `Fewer than two setups have both a cost per pass and enough units for a rate (${costed.length} of ${rows.length}); ${unplacedText(unplaced)}.`
791
- };
792
- }
793
- const front = costed.filter((row) => row.onFront === true);
794
- const drawLine = front.length >= 2;
795
- const cost = (row) => perPass(row.cost);
796
- const cheapestFront = [...front].sort((a, b) => cost(a) - cost(b))[0];
797
- const top = rateOrder(rows)[0];
798
- const est = (row) => row.cost.basis === "receipts" ? "" : " (estimated)";
799
- let finding;
800
- if (cheapestFront && costed.includes(top) && cheapestFront.id !== top.id) {
801
- const saving = 1 - cost(cheapestFront) / cost(top);
802
- finding = `${cheapestFront.label} costs ${usd(cost(cheapestFront))} per pass${est(cheapestFront)}, ${pct(saving)} less than ${top.label}.`;
803
- } else if (cheapestFront && cheapestFront.id === top.id) {
804
- finding = `${top.label} leads on both ${o.measure} and cost per pass.`;
805
- } else {
806
- const cheapest = [...costed].sort((a, b) => cost(a) - cost(b))[0];
807
- finding = `${cheapest.label} has the lowest cost per pass, ${usd(cost(cheapest))}${est(cheapest)}.`;
808
- }
809
- const lede = `${costed.length} of ${rows.length} setups are plotted; ` + (drawLine ? `${front.length} sit on the producer's frontier.` : front.length === 1 ? "1 sits on the producer's frontier." : "the producer computed no frontier.");
810
- const read = `Higher ${o.measure} and lower cost per pass are better; on the wide plot, better is up and right.` + (drawLine ? " Filled points sit on the frontier; the line joins them." : "") + (costed.some((r) => r.onFront && r.cost.basis !== "receipts") ? " A hollow accent ring is a frontier point with an estimated cost." : "");
811
- const intervals = [...new Set(costed.filter((r) => rateIntervalGap(r) === null).map((r) => intervalName(r.interval)))];
812
- const bases = [...new Set(costed.map((r) => r.cost.basis))];
813
- const method = [
814
- `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 ")}.`,
815
- front.length ? "The frontier is the producer's: no setup off it is both cheaper and higher." : "The producer did not compute a frontier.",
816
- intervals.length ? `Vertical lines: ${intervals.join(", ")} on the ${o.measure}.` : ""
817
- ].filter(Boolean).join(" ");
818
- const columns = [
819
- { label: "Setup" },
820
- { label: capitalize(o.measure), numeric: true },
821
- { label: "Passed", numeric: true },
822
- { label: "Interval" },
823
- { label: "Cost per pass", numeric: true },
824
- { label: "Cost basis" },
825
- { label: "On frontier" }
826
- ];
827
- const sorted = rateOrder(rows);
828
- const tableRows = sorted.map((row) => [
829
- row.label,
830
- drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts),
831
- kn(row.solved, row.attempts),
832
- rateIntervalGap(row) === null ? `${pct(row.interval.lower)}\u2013${pct(row.interval.upper)} (${intervalName(row.interval)}, n=${row.attempts})` : `not drawn (${rateIntervalGap(row)})`,
833
- costText(row.cost, row.solved),
834
- row.cost.basis,
835
- row.onFront === null ? "not computed" : row.onFront ? "yes" : "no"
836
- ]);
837
- const { svg, numbered } = plot(costed, unplaced, drawLine, o);
838
- const tableHtml = table(
839
- `${capitalize(o.measure)} and cost per pass`,
840
- numbered.size ? [{ label: "Setup" }, { label: "Mark", numeric: true }, ...columns.slice(1)] : columns,
841
- numbered.size ? sorted.map((row, i) => [tableRows[i][0], numbered.get(row.id) ?? "", ...tableRows[i].slice(1)]) : tableRows
842
- );
843
- return {
844
- id,
845
- finding,
846
- lede,
847
- read,
848
- svg: { wide: svg, narrow: narrowList(sorted) },
849
- table: tableHtml,
850
- note: {
851
- method,
852
- n: `${rows.reduce((s, r) => s + r.attempts, 0)} attempts over ${rows.length} setups.`,
853
- exclusions: [
854
- ...unplaced.length ? [`Not placed: ${unplacedText(unplaced)}.`] : [],
855
- ...costed.filter((r) => rateIntervalGap(r) !== null).map((r) => `No interval for ${r.label}: ${rateIntervalGap(r)}.`)
856
- ]
857
- }
858
- };
859
- }
938
+ const id = o.id ?? "cost-frontier";
939
+ for (const row of rows) {
940
+ const gap = rateDataGap(row);
941
+ if (gap) return {
942
+ id,
943
+ refused: `${row.label}: ${gap}.`
944
+ };
945
+ }
946
+ const costed = rows.filter(plottable);
947
+ const unplaced = rateOrder(rows.filter((row) => !plottable(row)));
948
+ if (!rows.some((row) => row.solved > 0 && perPass(row.cost) !== null)) return {
949
+ id,
950
+ refused: `No setup has a cost per pass, so cost is not drawn.`
951
+ };
952
+ if (costed.length < 2) return {
953
+ id,
954
+ refused: `Fewer than two setups have both a cost per pass and enough units for a rate (${costed.length} of ${rows.length}); ${unplacedText(unplaced)}.`
955
+ };
956
+ const front = costed.filter((row) => row.onFront === true);
957
+ const drawLine = front.length >= 2;
958
+ const cost = (row) => perPass(row.cost);
959
+ const cheapestFront = [...front].sort((a, b) => cost(a) - cost(b))[0];
960
+ const top = rateOrder(rows)[0];
961
+ const est = (row) => row.cost.basis === "receipts" ? "" : " (estimated)";
962
+ let finding;
963
+ if (cheapestFront && costed.includes(top) && cheapestFront.id !== top.id) {
964
+ const saving = 1 - cost(cheapestFront) / cost(top);
965
+ finding = `${cheapestFront.label} costs ${usd(cost(cheapestFront))} per pass${est(cheapestFront)}, ${pct(saving)} less than ${top.label}.`;
966
+ } else if (cheapestFront && cheapestFront.id === top.id) finding = `${top.label} leads on both ${o.measure} and cost per pass.`;
967
+ else {
968
+ const cheapest = [...costed].sort((a, b) => cost(a) - cost(b))[0];
969
+ finding = `${cheapest.label} has the lowest cost per pass, ${usd(cost(cheapest))}${est(cheapest)}.`;
970
+ }
971
+ const lede = `${costed.length} of ${rows.length} setups are plotted; ` + (drawLine ? `${front.length} sit on the producer's frontier.` : front.length === 1 ? "1 sits on the producer's frontier." : "the producer computed no frontier.");
972
+ const read = `Higher ${o.measure} and lower cost per pass are better; on the wide plot, better is up and right.` + (drawLine ? " Filled points sit on the frontier; the line joins them." : "") + (costed.some((r) => r.onFront && r.cost.basis !== "receipts") ? " A hollow accent ring is a frontier point with an estimated cost." : "");
973
+ const intervals = [...new Set(costed.filter((r) => rateIntervalGap(r) === null).map((r) => intervalName(r.interval)))];
974
+ const method = [
975
+ `Cost per pass is model spend over passed attempts, ${[...new Set(costed.map((r) => r.cost.basis))].map((b) => b === "receipts" ? "from router receipts" : b === "estimated" ? "estimated, not billed" : "from an unrecorded source").join(" or ")}.`,
976
+ front.length ? "The frontier is the producer's: no setup off it is both cheaper and higher." : "The producer did not compute a frontier.",
977
+ intervals.length ? `Vertical lines: ${intervals.join(", ")} on the ${o.measure}.` : ""
978
+ ].filter(Boolean).join(" ");
979
+ const columns = [
980
+ { label: "Setup" },
981
+ {
982
+ label: capitalize(o.measure),
983
+ numeric: true
984
+ },
985
+ {
986
+ label: "Passed",
987
+ numeric: true
988
+ },
989
+ { label: "Interval" },
990
+ {
991
+ label: "Cost per pass",
992
+ numeric: true
993
+ },
994
+ { label: "Cost basis" },
995
+ { label: "On frontier" }
996
+ ];
997
+ const sorted = rateOrder(rows);
998
+ const tableRows = sorted.map((row) => [
999
+ row.label,
1000
+ drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts),
1001
+ kn(row.solved, row.attempts),
1002
+ rateIntervalGap(row) === null ? `${pct(row.interval.lower)}–${pct(row.interval.upper)} (${intervalName(row.interval)}, n=${row.attempts})` : `not drawn (${rateIntervalGap(row)})`,
1003
+ costText(row.cost, row.solved),
1004
+ row.cost.basis,
1005
+ row.onFront === null ? "not computed" : row.onFront ? "yes" : "no"
1006
+ ]);
1007
+ const { svg, numbered } = plot(costed, unplaced, drawLine, o);
1008
+ const tableHtml = table(`${capitalize(o.measure)} and cost per pass`, numbered.size ? [
1009
+ { label: "Setup" },
1010
+ {
1011
+ label: "Mark",
1012
+ numeric: true
1013
+ },
1014
+ ...columns.slice(1)
1015
+ ] : columns, numbered.size ? sorted.map((row, i) => [
1016
+ tableRows[i][0],
1017
+ numbered.get(row.id) ?? "",
1018
+ ...tableRows[i].slice(1)
1019
+ ]) : tableRows);
1020
+ return {
1021
+ id,
1022
+ finding,
1023
+ lede,
1024
+ read,
1025
+ svg: {
1026
+ wide: svg,
1027
+ narrow: narrowList(sorted)
1028
+ },
1029
+ table: tableHtml,
1030
+ note: {
1031
+ method,
1032
+ n: `${rows.reduce((s, r) => s + r.attempts, 0)} attempts over ${rows.length} setups.`,
1033
+ exclusions: [...unplaced.length ? [`Not placed: ${unplacedText(unplaced)}.`] : [], ...costed.filter((r) => rateIntervalGap(r) !== null).map((r) => `No interval for ${r.label}: ${rateIntervalGap(r)}.`)]
1034
+ }
1035
+ };
1036
+ }
1037
+ /**
1038
+ * The phone view: a list sorted by the measure. Each row keeps the plot's
1039
+ * point mark, a rate bar and the cost as text; a log axis does not fit 358 px.
1040
+ */
860
1041
  function narrowList(rows) {
861
- const x1 = 300;
862
- let out = "";
863
- let y = 4;
864
- for (const [i, row] of rows.entries()) {
865
- const placed = plottable(row);
866
- const label = wrap(row.label, NARROW - 16, 14);
867
- let marks = placed ? circle(4, y + 10, 4, row.onFront ? row.cost.basis === "receipts" ? "tgc-pt-front" : "tgc-pt-est" : "tgc-pt") : "";
868
- marks += textBlock(label.lines, 16, y + 14, 18, `class="tgc-ink" font-size="14"`);
869
- let cy = y + label.lines.length * 18 + 6;
870
- if (drawable(row.estimate)) {
871
- let end = row.rate * x1;
872
- marks += hbar(0, cy, end, 10, "tgc-bar");
873
- if (rateIntervalGap(row) === null) {
874
- const lo = row.interval.lower * x1;
875
- const hi = row.interval.upper * x1;
876
- 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");
877
- end = Math.max(end, hi);
878
- }
879
- marks += text(end + 6, cy + 9.5, pct(row.rate), { cls: "tgc-ink tgc-num" });
880
- cy += 16;
881
- }
882
- const count = `${kn(row.solved, row.attempts)} passed${drawable(row.estimate) ? "" : `, ${estimateNote(row.estimate, row.attempts)}`}`;
883
- const reason = row.solved > 0 && drawable(row.estimate) ? "cost unknown \xB7 " : "";
884
- const fact = placed ? `${count} \xB7 ${costText(row.cost, row.solved)} per pass${row.onFront ? " \xB7 on the frontier" : ""}` : `${count} \xB7 ${reason}not plotted`;
885
- const facts = fitLines(fact.split(" \xB7 "), NARROW, 12, "mono");
886
- marks += textBlock(facts, 0, cy + 12, 16, `class="tgc-ink-muted tgc-num" font-size="12"`);
887
- const interval = rateIntervalGap(row) === null ? ` \xB7 ${intervalName(row.interval)} interval ${pct(row.interval.lower)}\u2013${pct(row.interval.upper)}, n=${row.attempts}` : "";
888
- out += titled(`${row.label} \xB7 ${fact}${interval}`, marks);
889
- y = cy + 4 + facts.length * 16;
890
- if (i < rows.length - 1) out += line(0, y + 2, NARROW, y + 2, "tgc-rule");
891
- y += 10;
892
- }
893
- if (rows.some((r) => drawable(r.estimate))) {
894
- for (const t of [0, 0.5, 1]) {
895
- out += text(t * x1, y + 6, pct(t), { cls: "tgc-ink-muted tgc-num", anchor: t === 0 ? "start" : t === 1 ? "end" : "middle" });
896
- }
897
- y += 12;
898
- }
899
- return svgRoot(NARROW, y, out);
1042
+ const x1 = 300;
1043
+ let out = "";
1044
+ let y = 4;
1045
+ for (const [i, row] of rows.entries()) {
1046
+ const placed = plottable(row);
1047
+ const label = wrap(row.label, 342, 14);
1048
+ let marks = placed ? circle(4, y + 10, 4, row.onFront ? row.cost.basis === "receipts" ? "tgc-pt-front" : "tgc-pt-est" : "tgc-pt") : "";
1049
+ marks += textBlock(label.lines, 16, y + 14, 18, `class="tgc-ink" font-size="14"`);
1050
+ let cy = y + label.lines.length * 18 + 6;
1051
+ if (drawable(row.estimate)) {
1052
+ let end = row.rate * x1;
1053
+ marks += hbar(0, cy, end, 10, "tgc-bar");
1054
+ if (rateIntervalGap(row) === null) {
1055
+ const lo = row.interval.lower * x1;
1056
+ const hi = row.interval.upper * x1;
1057
+ 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");
1058
+ end = Math.max(end, hi);
1059
+ }
1060
+ marks += text(end + 6, cy + 9.5, pct(row.rate), { cls: "tgc-ink tgc-num" });
1061
+ cy += 16;
1062
+ }
1063
+ const count = `${kn(row.solved, row.attempts)} passed${drawable(row.estimate) ? "" : `, ${estimateNote(row.estimate, row.attempts)}`}`;
1064
+ const reason = row.solved > 0 && drawable(row.estimate) ? "cost unknown · " : "";
1065
+ const fact = placed ? `${count} · ${costText(row.cost, row.solved)} per pass${row.onFront ? " · on the frontier" : ""}` : `${count} · ${reason}not plotted`;
1066
+ const facts = fitLines(fact.split(" · "), 358, 12, "mono");
1067
+ marks += textBlock(facts, 0, cy + 12, 16, `class="tgc-ink-muted tgc-num" font-size="12"`);
1068
+ const interval = rateIntervalGap(row) === null ? ` · ${intervalName(row.interval)} interval ${pct(row.interval.lower)}–${pct(row.interval.upper)}, n=${row.attempts}` : "";
1069
+ out += titled(`${row.label} · ${fact}${interval}`, marks);
1070
+ y = cy + 4 + facts.length * 16;
1071
+ if (i < rows.length - 1) out += line(0, y + 2, 358, y + 2, "tgc-rule");
1072
+ y += 10;
1073
+ }
1074
+ if (rows.some((r) => drawable(r.estimate))) {
1075
+ for (const t of [
1076
+ 0,
1077
+ .5,
1078
+ 1
1079
+ ]) out += text(t * x1, y + 6, pct(t), {
1080
+ cls: "tgc-ink-muted tgc-num",
1081
+ anchor: t === 0 ? "start" : t === 1 ? "end" : "middle"
1082
+ });
1083
+ y += 12;
1084
+ }
1085
+ return svgRoot(358, y, out);
900
1086
  }
901
1087
  function unplacedText(rows) {
902
- return listJoin(rows.map((r) => `${r.label} (${unplacedReason(r)})`));
1088
+ return listJoin(rows.map((r) => `${r.label} (${unplacedReason(r)})`));
903
1089
  }
904
1090
  function plot(costed, unplaced, drawLine, o) {
905
- const left = 48;
906
- const right = WIDE - 24;
907
- const top = 32;
908
- const bottom = 272;
909
- const costs = costed.map((r) => perPass(r.cost));
910
- const [lo, hi] = logDomain(Math.min(...costs), Math.max(...costs));
911
- const x = log([lo, hi], [left, right].reverse());
912
- const highest = Math.max(
913
- ...costed.map((r) => Math.max(r.rate, rateIntervalGap(r) === null ? r.interval.upper : 0))
914
- );
915
- const yMax = Math.min(1, Math.max(0.2, Math.ceil(highest * 5 - 1e-9) / 5));
916
- const yStep = yMax <= 0.4 ? 0.1 : 0.2;
917
- const y = (v) => bottom - v / yMax * (bottom - top);
918
- let out = text(0, 14, capitalize(o.measure), { cls: "tgc-ink-muted" });
919
- for (let i = 0; i <= Math.round(yMax / yStep); i++) {
920
- const v = Number((i * yStep).toFixed(10));
921
- out += line(left, y(v), right, y(v), v === 0 ? "tgc-axis" : "tgc-gridline");
922
- out += text(left - 8, y(v) + 4, pct(v), { cls: "tgc-ink-muted tgc-num", anchor: "end" });
923
- }
924
- for (const t of logTicks(lo, hi)) {
925
- out += line(x(t), bottom, x(t), bottom + 4, "tgc-axis");
926
- out += text(x(t), bottom + 18, usdTick(t), { cls: "tgc-ink-muted tgc-num", anchor: "middle" });
927
- }
928
- out += text((left + right) / 2, bottom + 40, "Cost per pass, log scale, cheaper to the right", {
929
- cls: "tgc-ink-muted",
930
- anchor: "middle"
931
- });
932
- const placed = costed.map((row) => ({
933
- row,
934
- x: x(perPass(row.cost)),
935
- y: y(row.rate),
936
- estimated: row.cost.basis !== "receipts"
937
- }));
938
- const frontLine = drawLine ? placed.filter((p) => p.row.onFront).sort((a, b) => a.x - b.x).map((p) => [p.x, p.y]) : [];
939
- if (drawLine) out += polyline(frontLine, "tgc-front-line");
940
- for (const p of placed) {
941
- if (rateIntervalGap(p.row) === null) {
942
- out += line(p.x, y(p.row.interval.lower), p.x, y(p.row.interval.upper), "tgc-pt-ci");
943
- }
944
- }
945
- const obstacles = placed.map((p) => ({ x0: p.x - 6, y0: p.y - 6, x1: p.x + 6, y1: p.y + 6 }));
946
- for (const p of placed) {
947
- if (rateIntervalGap(p.row) === null) {
948
- obstacles.push({ x0: p.x - 2, y0: y(p.row.interval.upper), x1: p.x + 2, y1: y(p.row.interval.lower) });
949
- }
950
- }
951
- const bounds = { x0: 0, y0: top - 8, x1: WIDE, y1: bottom - 2 };
952
- const labels = [];
953
- const numbered = /* @__PURE__ */ new Map();
954
- for (const p of placed) {
955
- const w = textWidth(p.row.label, 12);
956
- const near = [
957
- [p.x + 9, p.y + 4, "start", false],
958
- [p.x - 9, p.y + 4, "end", false],
959
- [p.x, p.y - 10, "middle", false],
960
- [p.x, p.y + 18, "middle", false],
961
- [p.x + 26, p.y - 20, "start", true],
962
- [p.x - 26, p.y - 20, "end", true],
963
- [p.x + 26, p.y + 26, "start", true],
964
- [p.x - 26, p.y + 26, "end", true]
965
- ];
966
- let done = false;
967
- for (const [lx, ly, anchor, leader] of near) {
968
- const x0 = anchor === "start" ? lx : anchor === "end" ? lx - w : lx - w / 2;
969
- const box = { x0, y0: ly - 11, x1: x0 + w, y1: ly + 3 };
970
- const inside = box.x0 >= bounds.x0 && box.x1 <= bounds.x1 && box.y0 >= bounds.y0 && box.y1 <= bounds.y1;
971
- const onLine = frontLine.some((a, i) => i > 0 && crosses(box, frontLine[i - 1], a));
972
- if (!inside || onLine || obstacles.some((b) => overlaps(b, box))) continue;
973
- obstacles.push(box);
974
- if (leader) labels.push(line(p.x, p.y, anchor === "start" ? lx - 3 : lx + 3, ly - 4, "tgc-leader"));
975
- labels.push(text(lx, ly, p.row.label, { cls: "tgc-ink", anchor }));
976
- done = true;
977
- break;
978
- }
979
- if (!done) {
980
- const mark = String(numbered.size + 1);
981
- numbered.set(p.row.id, mark);
982
- labels.push(text(p.x + 8, p.y - 6, mark, { cls: "tgc-ink tgc-num" }));
983
- }
984
- }
985
- for (const p of placed) {
986
- const cls = p.row.onFront ? p.estimated ? "tgc-pt-est" : "tgc-pt-front" : "tgc-pt";
987
- out += titled(
988
- `${p.row.label} \xB7 ${pct(p.row.rate)} ${o.measure} \xB7 ${kn(p.row.solved, p.row.attempts)} passed \xB7 ${costText(p.row.cost, p.row.solved)} per pass${rateIntervalGap(p.row) === null ? ` \xB7 ${intervalName(p.row.interval)} interval ${pct(p.row.interval.lower)}\u2013${pct(p.row.interval.upper)}, n=${p.row.attempts}` : ""}${p.row.onFront ? " \xB7 on the frontier" : ""}`,
989
- circle(p.x, p.y, 4, cls)
990
- );
991
- }
992
- out += labels.join("");
993
- let h = bottom + 48;
994
- if (unplaced.length) {
995
- const strip = wrap(`Not placed: ${unplacedText(unplaced)}.`, WIDE, 12, "sans", 4);
996
- for (const [i, lineText] of strip.lines.entries()) {
997
- out += text(0, h + 12 + i * 16, lineText, { cls: "tgc-ink-muted" });
998
- }
999
- h += strip.lines.length * 16 + 8;
1000
- }
1001
- return { svg: svgRoot(WIDE, h, out), numbered };
1002
- }
1003
-
1004
- // src/css.generated.ts
1005
- var figureCss = `/* @tangle-network/charts \u2014 figure block and mark styles.
1006
- *
1007
- * Every colour is a --chart-* token from @tangle-network/brand tokens.css, so a
1008
- * figure follows the page theme. A standalone document without brand tokens
1009
- * embeds \`chartTokensCss\` first. Fonts fall back to Inter and JetBrains Mono.
1010
- *
1011
- * Each figure carries a 720 px and a 358 px render. The swap follows the
1012
- * figure's own width (a container query), so a narrow column shows the phone
1013
- * render even on a wide screen. */
1014
-
1015
- .tgc-figure {
1016
- margin: 32px 0 0;
1017
- padding: 0;
1018
- max-width: 720px;
1019
- color: var(--chart-ink);
1020
- font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);
1021
- }
1022
-
1023
- .tgc-finding {
1024
- margin: 0 0 8px;
1025
- font-size: 16px;
1026
- line-height: 24px;
1027
- font-weight: 600;
1028
- color: var(--chart-ink);
1029
- }
1030
-
1031
- .tgc-noteref {
1032
- margin-left: 2px;
1033
- font-size: 12px;
1034
- font-weight: 400;
1035
- color: var(--chart-ink-muted);
1036
- text-decoration: none;
1037
- font-family: var(--font-mono, "JetBrains Mono", ui-monospace, monospace);
1038
- }
1039
- .tgc-noteref:hover,
1040
- .tgc-noteref:focus-visible {
1041
- color: var(--chart-accent);
1042
- text-decoration: underline;
1043
- }
1044
-
1045
- .tgc-lede {
1046
- margin: 0 0 16px;
1047
- font-size: 14px;
1048
- line-height: 22px;
1049
- color: var(--text-secondary, var(--chart-ink));
1050
- }
1051
-
1052
- .tgc-plot {
1053
- container-type: inline-size;
1054
- }
1055
- .tgc-wide svg,
1056
- .tgc-narrow svg {
1057
- display: block;
1058
- overflow: visible;
1059
- }
1060
- .tgc-narrow {
1061
- display: none;
1062
- }
1063
- .tgc-narrow svg {
1064
- max-width: 100%;
1065
- height: auto;
1066
- }
1067
- @container (max-width: 719.98px) {
1068
- .tgc-wide {
1069
- display: none;
1070
- }
1071
- .tgc-narrow {
1072
- display: block;
1073
- }
1074
- }
1075
-
1076
- .tgc-read {
1077
- margin: 8px 0 0;
1078
- font-size: 12px;
1079
- line-height: 16px;
1080
- color: var(--chart-ink-muted);
1081
- }
1082
-
1083
- .tgc-data {
1084
- margin: 8px 0 0;
1085
- font-size: 12px;
1086
- line-height: 16px;
1087
- }
1088
- .tgc-data > summary {
1089
- cursor: pointer;
1090
- color: var(--chart-ink-muted);
1091
- width: fit-content;
1092
- }
1093
- .tgc-data > summary:hover {
1094
- color: var(--chart-ink);
1095
- }
1096
- .tgc-data[open] > summary {
1097
- margin-bottom: 8px;
1098
- }
1099
-
1100
- .tgc-scroll {
1101
- overflow-x: auto;
1102
- }
1103
- .tgc-scroll:focus-visible {
1104
- outline: 2px solid var(--chart-accent);
1105
- outline-offset: 2px;
1106
- }
1107
-
1108
- .tgc-table {
1109
- border-collapse: collapse;
1110
- min-width: 100%;
1111
- font-size: 12px;
1112
- line-height: 16px;
1113
- color: var(--chart-ink);
1114
- /* A host page's table rules must not restyle the figure's table. */
1115
- border: 0;
1116
- border-radius: 0;
1117
- background: none;
1118
- }
1119
- .tgc-table caption {
1120
- text-align: left;
1121
- padding: 0 0 8px;
1122
- color: var(--chart-ink-muted);
1123
- }
1124
- .tgc-table th,
1125
- .tgc-table td {
1126
- padding: 6px 12px 6px 0;
1127
- border-bottom: 1px solid var(--chart-rule);
1128
- text-align: left;
1129
- vertical-align: top;
1130
- font-weight: 400;
1131
- font-size: inherit;
1132
- color: inherit;
1133
- background: none;
1134
- text-transform: none;
1135
- letter-spacing: normal;
1136
- }
1137
- .tgc-table thead th {
1138
- color: var(--chart-ink-muted);
1139
- white-space: nowrap;
1140
- }
1141
- .tgc-table tbody th {
1142
- position: sticky;
1143
- left: 0;
1144
- background: var(--chart-canvas, var(--depth-1, transparent));
1145
- }
1146
- .tgc-table .tgc-n {
1147
- font-family: var(--font-mono, "JetBrains Mono", ui-monospace, monospace);
1148
- font-variant-numeric: tabular-nums;
1149
- white-space: nowrap;
1150
- }
1151
- .tgc-table thead .tgc-n {
1152
- font-family: inherit;
1153
- }
1154
- .tgc-table tbody tr:hover {
1155
- background: var(--chart-shade);
1156
- }
1157
- .tgc-narrow-table .tgc-table {
1158
- display: block;
1159
- overflow-x: auto;
1160
- }
1161
-
1162
- .tgc-notes {
1163
- margin: 16px 0 0;
1164
- padding-left: 20px;
1165
- font-size: 12px;
1166
- line-height: 16px;
1167
- color: var(--chart-ink-muted);
1168
- }
1169
- .tgc-notes li {
1170
- margin: 0 0 8px;
1171
- padding-left: 4px;
1172
- }
1173
- .tgc-notes li:target {
1174
- color: var(--chart-ink);
1175
- }
1176
- .tgc-backlink {
1177
- color: var(--chart-ink-muted);
1178
- text-decoration: none;
1179
- }
1180
-
1181
- .tgc-refusals {
1182
- margin: 8px 0 0;
1183
- padding-left: 20px;
1184
- font-size: 14px;
1185
- line-height: 22px;
1186
- color: var(--chart-ink-muted);
1187
- }
1188
-
1189
- /* Marks */
1190
- .tgc-svg {
1191
- font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);
1192
- }
1193
- .tgc-svg .tgc-num {
1194
- font-family: var(--font-mono, "JetBrains Mono", ui-monospace, monospace);
1195
- font-variant-numeric: tabular-nums;
1196
- }
1197
- .tgc-ink {
1198
- fill: var(--chart-ink);
1199
- }
1200
- .tgc-ink-muted {
1201
- fill: var(--chart-ink-muted);
1202
- }
1203
- .tgc-bar,
1204
- .tgc-ci,
1205
- .tgc-pt-front {
1206
- fill: var(--chart-accent);
1207
- }
1208
- .tgc-gridline {
1209
- stroke: var(--chart-grid);
1210
- stroke-width: 1;
1211
- }
1212
- .tgc-rule {
1213
- stroke: var(--chart-rule);
1214
- stroke-width: 1;
1215
- }
1216
- .tgc-axis,
1217
- .tgc-zero {
1218
- stroke: var(--chart-muted);
1219
- stroke-width: 1;
1220
- }
1221
- .tgc-whisker,
1222
- .tgc-tick {
1223
- stroke: var(--chart-ink);
1224
- stroke-width: 1.5;
1225
- stroke-linecap: round;
1226
- }
1227
- .tgc-threshold {
1228
- stroke: var(--chart-ink);
1229
- stroke-width: 1.5;
1230
- stroke-dasharray: 3 3;
1231
- }
1232
- .tgc-front-line {
1233
- fill: none;
1234
- stroke: var(--chart-ink);
1235
- stroke-width: 1.5;
1236
- stroke-linejoin: round;
1237
- }
1238
- .tgc-pt,
1239
- .tgc-open {
1240
- fill: none;
1241
- stroke: var(--chart-muted);
1242
- stroke-width: 1.5;
1243
- }
1244
- .tgc-pt-est {
1245
- fill: none;
1246
- stroke: var(--chart-accent);
1247
- stroke-width: 2;
1248
- }
1249
- .tgc-pt-ci,
1250
- .tgc-strike {
1251
- stroke: var(--chart-muted);
1252
- stroke-width: 1.5;
1253
- }
1254
- .tgc-leader {
1255
- stroke: var(--chart-muted);
1256
- stroke-width: 1;
1257
- }
1258
- .tgc-ci-exact,
1259
- .tgc-dot {
1260
- fill: var(--chart-muted);
1261
- }
1262
- .tgc-dashed {
1263
- stroke-dasharray: 2 2;
1264
- }
1265
- .tgc-tier-c {
1266
- fill: var(--chart-tier-c);
1267
- }
1268
- .tgc-tier-b {
1269
- fill: var(--chart-tier-b);
1270
- }
1271
- .tgc-tier-a {
1272
- fill: var(--chart-tier-a);
1273
- }
1274
- .tgc-tier-s {
1275
- fill: var(--chart-tier-s);
1276
- }
1277
- /* Neutral tint for counts: never the accent, capped at half strength so ink keeps 6:1. */
1278
- .tgc-tint {
1279
- fill: color-mix(in srgb, var(--chart-muted) calc(var(--s, 0) * 50%), transparent);
1280
- }
1281
-
1282
- @media print {
1283
- .tgc-plot {
1284
- container-type: normal;
1285
- }
1286
- .tgc-wide {
1287
- display: block;
1288
- }
1289
- .tgc-narrow {
1290
- display: none;
1291
- }
1292
- .tgc-wide svg {
1293
- width: 100%;
1294
- height: auto;
1295
- }
1296
- .tgc-figure {
1297
- break-inside: avoid;
1298
- }
1299
- }
1300
- `;
1301
- 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}}';
1302
-
1303
- // src/ranked-rates.ts
1304
- var TICKS = [0, 0.25, 0.5, 0.75, 1];
1091
+ const left = 48;
1092
+ const right = 696;
1093
+ const bottom = 272;
1094
+ const costs = costed.map((r) => perPass(r.cost));
1095
+ const [lo, hi] = logDomain(Math.min(...costs), Math.max(...costs));
1096
+ const x = log([lo, hi], [left, right].reverse());
1097
+ const highest = Math.max(...costed.map((r) => Math.max(r.rate, rateIntervalGap(r) === null ? r.interval.upper : 0)));
1098
+ const yMax = Math.min(1, Math.max(.2, Math.ceil(highest * 5 - 1e-9) / 5));
1099
+ const yStep = yMax <= .4 ? .1 : .2;
1100
+ const y = (v) => bottom - v / yMax * 240;
1101
+ let out = text(0, 14, capitalize(o.measure), { cls: "tgc-ink-muted" });
1102
+ for (let i = 0; i <= Math.round(yMax / yStep); i++) {
1103
+ const v = Number((i * yStep).toFixed(10));
1104
+ out += line(left, y(v), right, y(v), v === 0 ? "tgc-axis" : "tgc-gridline");
1105
+ out += text(40, y(v) + 4, pct(v), {
1106
+ cls: "tgc-ink-muted tgc-num",
1107
+ anchor: "end"
1108
+ });
1109
+ }
1110
+ for (const t of logTicks(lo, hi)) {
1111
+ out += line(x(t), bottom, x(t), 276, "tgc-axis");
1112
+ out += text(x(t), 290, usdTick(t), {
1113
+ cls: "tgc-ink-muted tgc-num",
1114
+ anchor: "middle"
1115
+ });
1116
+ }
1117
+ out += text(372, 312, "Cost per pass, log scale, cheaper to the right", {
1118
+ cls: "tgc-ink-muted",
1119
+ anchor: "middle"
1120
+ });
1121
+ const placed = costed.map((row) => ({
1122
+ row,
1123
+ x: x(perPass(row.cost)),
1124
+ y: y(row.rate),
1125
+ estimated: row.cost.basis !== "receipts"
1126
+ }));
1127
+ const frontLine = drawLine ? placed.filter((p) => p.row.onFront).sort((a, b) => a.x - b.x).map((p) => [p.x, p.y]) : [];
1128
+ if (drawLine) out += polyline(frontLine, "tgc-front-line");
1129
+ for (const p of placed) if (rateIntervalGap(p.row) === null) out += line(p.x, y(p.row.interval.lower), p.x, y(p.row.interval.upper), "tgc-pt-ci");
1130
+ const obstacles = placed.map((p) => ({
1131
+ x0: p.x - 6,
1132
+ y0: p.y - 6,
1133
+ x1: p.x + 6,
1134
+ y1: p.y + 6
1135
+ }));
1136
+ for (const p of placed) if (rateIntervalGap(p.row) === null) obstacles.push({
1137
+ x0: p.x - 2,
1138
+ y0: y(p.row.interval.upper),
1139
+ x1: p.x + 2,
1140
+ y1: y(p.row.interval.lower)
1141
+ });
1142
+ const bounds = {
1143
+ x0: 0,
1144
+ y0: 24,
1145
+ x1: 720,
1146
+ y1: 270
1147
+ };
1148
+ const labels = [];
1149
+ const numbered = /* @__PURE__ */ new Map();
1150
+ for (const p of placed) {
1151
+ const w = textWidth(p.row.label, 12);
1152
+ const near = [
1153
+ [
1154
+ p.x + 9,
1155
+ p.y + 4,
1156
+ "start",
1157
+ false
1158
+ ],
1159
+ [
1160
+ p.x - 9,
1161
+ p.y + 4,
1162
+ "end",
1163
+ false
1164
+ ],
1165
+ [
1166
+ p.x,
1167
+ p.y - 10,
1168
+ "middle",
1169
+ false
1170
+ ],
1171
+ [
1172
+ p.x,
1173
+ p.y + 18,
1174
+ "middle",
1175
+ false
1176
+ ],
1177
+ [
1178
+ p.x + 26,
1179
+ p.y - 20,
1180
+ "start",
1181
+ true
1182
+ ],
1183
+ [
1184
+ p.x - 26,
1185
+ p.y - 20,
1186
+ "end",
1187
+ true
1188
+ ],
1189
+ [
1190
+ p.x + 26,
1191
+ p.y + 26,
1192
+ "start",
1193
+ true
1194
+ ],
1195
+ [
1196
+ p.x - 26,
1197
+ p.y + 26,
1198
+ "end",
1199
+ true
1200
+ ]
1201
+ ];
1202
+ let done = false;
1203
+ for (const [lx, ly, anchor, leader] of near) {
1204
+ const x0 = anchor === "start" ? lx : anchor === "end" ? lx - w : lx - w / 2;
1205
+ const box = {
1206
+ x0,
1207
+ y0: ly - 11,
1208
+ x1: x0 + w,
1209
+ y1: ly + 3
1210
+ };
1211
+ const inside = box.x0 >= bounds.x0 && box.x1 <= bounds.x1 && box.y0 >= bounds.y0 && box.y1 <= bounds.y1;
1212
+ const onLine = frontLine.some((a, i) => i > 0 && crosses(box, frontLine[i - 1], a));
1213
+ if (!inside || onLine || obstacles.some((b) => overlaps(b, box))) continue;
1214
+ obstacles.push(box);
1215
+ if (leader) labels.push(line(p.x, p.y, anchor === "start" ? lx - 3 : lx + 3, ly - 4, "tgc-leader"));
1216
+ labels.push(text(lx, ly, p.row.label, {
1217
+ cls: "tgc-ink",
1218
+ anchor
1219
+ }));
1220
+ done = true;
1221
+ break;
1222
+ }
1223
+ if (!done) {
1224
+ const mark = String(numbered.size + 1);
1225
+ numbered.set(p.row.id, mark);
1226
+ labels.push(text(p.x + 8, p.y - 6, mark, { cls: "tgc-ink tgc-num" }));
1227
+ }
1228
+ }
1229
+ for (const p of placed) {
1230
+ const cls = p.row.onFront ? p.estimated ? "tgc-pt-est" : "tgc-pt-front" : "tgc-pt";
1231
+ out += titled(`${p.row.label} · ${pct(p.row.rate)} ${o.measure} · ${kn(p.row.solved, p.row.attempts)} passed · ${costText(p.row.cost, p.row.solved)} per pass${rateIntervalGap(p.row) === null ? ` · ${intervalName(p.row.interval)} interval ${pct(p.row.interval.lower)}–${pct(p.row.interval.upper)}, n=${p.row.attempts}` : ""}${p.row.onFront ? " · on the frontier" : ""}`, circle(p.x, p.y, 4, cls));
1232
+ }
1233
+ out += labels.join("");
1234
+ let h = 320;
1235
+ if (unplaced.length) {
1236
+ const strip = wrap(`Not placed: ${unplacedText(unplaced)}.`, 720, 12, "sans", 4);
1237
+ for (const [i, lineText] of strip.lines.entries()) out += text(0, h + 12 + i * 16, lineText, { cls: "tgc-ink-muted" });
1238
+ h += strip.lines.length * 16 + 8;
1239
+ }
1240
+ return {
1241
+ svg: svgRoot(720, h, out),
1242
+ numbered
1243
+ };
1244
+ }
1245
+ //#endregion
1246
+ //#region src/css.generated.ts
1247
+ /** The figure block and mark styles (src/figure.css), for documents that inline their CSS. */
1248
+ const figureCss = "/* @tangle-network/charts — figure block and mark styles.\n *\n * Every colour is a --chart-* token from @tangle-network/brand tokens.css, so a\n * figure follows the page theme. A standalone document without brand tokens\n * embeds `chartTokensCss` first. Fonts fall back to Inter and JetBrains Mono.\n *\n * Each figure carries a 720 px and a 358 px render. The swap follows the\n * figure's own width (a container query), so a narrow column shows the phone\n * render even on a wide screen. */\n\n.tgc-figure {\n margin: 32px 0 0;\n padding: 0;\n max-width: 720px;\n color: var(--chart-ink);\n font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);\n}\n\n.tgc-finding {\n margin: 0 0 8px;\n font-size: 16px;\n line-height: 24px;\n font-weight: 600;\n color: var(--chart-ink);\n}\n\n.tgc-noteref {\n margin-left: 2px;\n font-size: 12px;\n font-weight: 400;\n color: var(--chart-ink-muted);\n text-decoration: none;\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n}\n.tgc-noteref:hover,\n.tgc-noteref:focus-visible {\n color: var(--chart-accent);\n text-decoration: underline;\n}\n\n.tgc-lede {\n margin: 0 0 16px;\n font-size: 14px;\n line-height: 22px;\n color: var(--text-secondary, var(--chart-ink));\n}\n\n.tgc-plot {\n container-type: inline-size;\n}\n.tgc-wide svg,\n.tgc-narrow svg {\n display: block;\n overflow: visible;\n}\n.tgc-narrow {\n display: none;\n}\n.tgc-narrow svg {\n max-width: 100%;\n height: auto;\n}\n@container (max-width: 719.98px) {\n .tgc-wide {\n display: none;\n }\n .tgc-narrow {\n display: block;\n }\n}\n\n.tgc-read {\n margin: 8px 0 0;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink-muted);\n}\n\n.tgc-data {\n margin: 8px 0 0;\n font-size: 12px;\n line-height: 16px;\n}\n.tgc-data > summary {\n cursor: pointer;\n color: var(--chart-ink-muted);\n width: fit-content;\n}\n.tgc-data > summary:hover {\n color: var(--chart-ink);\n}\n.tgc-data[open] > summary {\n margin-bottom: 8px;\n}\n\n.tgc-scroll {\n overflow-x: auto;\n}\n.tgc-scroll:focus-visible {\n outline: 2px solid var(--chart-accent);\n outline-offset: 2px;\n}\n\n.tgc-table {\n border-collapse: collapse;\n min-width: 100%;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink);\n /* A host page's table rules must not restyle the figure's table. */\n border: 0;\n border-radius: 0;\n background: none;\n}\n.tgc-table caption {\n text-align: left;\n padding: 0 0 8px;\n color: var(--chart-ink-muted);\n}\n.tgc-table th,\n.tgc-table td {\n padding: 6px 12px 6px 0;\n border-bottom: 1px solid var(--chart-rule);\n text-align: left;\n vertical-align: top;\n font-weight: 400;\n font-size: inherit;\n color: inherit;\n background: none;\n text-transform: none;\n letter-spacing: normal;\n}\n.tgc-table thead th {\n color: var(--chart-ink-muted);\n white-space: nowrap;\n}\n.tgc-table tbody th {\n position: sticky;\n left: 0;\n background: var(--chart-canvas, var(--depth-1, transparent));\n}\n.tgc-table .tgc-n {\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n font-variant-numeric: tabular-nums;\n white-space: nowrap;\n}\n.tgc-table thead .tgc-n {\n font-family: inherit;\n}\n.tgc-table tbody tr:hover {\n background: var(--chart-shade);\n}\n.tgc-narrow-table .tgc-table {\n display: block;\n overflow-x: auto;\n}\n\n.tgc-notes {\n margin: 16px 0 0;\n padding-left: 20px;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink-muted);\n}\n.tgc-notes li {\n margin: 0 0 8px;\n padding-left: 4px;\n}\n.tgc-notes li:target {\n color: var(--chart-ink);\n}\n.tgc-backlink {\n color: var(--chart-ink-muted);\n text-decoration: none;\n}\n\n.tgc-refusals {\n margin: 8px 0 0;\n padding-left: 20px;\n font-size: 14px;\n line-height: 22px;\n color: var(--chart-ink-muted);\n}\n\n/* Marks */\n.tgc-svg {\n font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);\n}\n.tgc-svg .tgc-num {\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n font-variant-numeric: tabular-nums;\n}\n.tgc-ink {\n fill: var(--chart-ink);\n}\n.tgc-ink-muted {\n fill: var(--chart-ink-muted);\n}\n.tgc-bar,\n.tgc-ci,\n.tgc-pt-front {\n fill: var(--chart-accent);\n}\n.tgc-gridline {\n stroke: var(--chart-grid);\n stroke-width: 1;\n}\n.tgc-rule {\n stroke: var(--chart-rule);\n stroke-width: 1;\n}\n.tgc-axis,\n.tgc-zero {\n stroke: var(--chart-muted);\n stroke-width: 1;\n}\n.tgc-whisker,\n.tgc-tick {\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n.tgc-threshold {\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-dasharray: 3 3;\n}\n.tgc-front-line {\n fill: none;\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-linejoin: round;\n}\n.tgc-pt,\n.tgc-open {\n fill: none;\n stroke: var(--chart-muted);\n stroke-width: 1.5;\n}\n.tgc-pt-est {\n fill: none;\n stroke: var(--chart-accent);\n stroke-width: 2;\n}\n.tgc-pt-ci,\n.tgc-strike {\n stroke: var(--chart-muted);\n stroke-width: 1.5;\n}\n.tgc-leader {\n stroke: var(--chart-muted);\n stroke-width: 1;\n}\n.tgc-ci-exact,\n.tgc-dot {\n fill: var(--chart-muted);\n}\n.tgc-dashed {\n stroke-dasharray: 2 2;\n}\n.tgc-tier-c {\n fill: var(--chart-tier-c);\n}\n.tgc-tier-b {\n fill: var(--chart-tier-b);\n}\n.tgc-tier-a {\n fill: var(--chart-tier-a);\n}\n.tgc-tier-s {\n fill: var(--chart-tier-s);\n}\n/* Neutral tint for counts: never the accent, capped at half strength so ink keeps 6:1. */\n.tgc-tint {\n fill: color-mix(in srgb, var(--chart-muted) calc(var(--s, 0) * 50%), transparent);\n}\n\n@media print {\n .tgc-plot {\n container-type: normal;\n }\n .tgc-wide {\n display: block;\n }\n .tgc-narrow {\n display: none;\n }\n .tgc-wide svg {\n width: 100%;\n height: auto;\n }\n .tgc-figure {\n break-inside: avoid;\n }\n}\n";
1249
+ /** --chart-* resolved per theme, for documents without brand tokens: light, dark by preference or [data-theme="dark"], light in print. */
1250
+ const 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}}";
1251
+ //#endregion
1252
+ //#region src/ranked-rates.ts
1253
+ /**
1254
+ * `rankedRates`: which setup passes most, and how uncertain is each rate.
1255
+ *
1256
+ * One row per setup: rank (only when every row is ranked), label, an accent
1257
+ * bar on a shared 0–1 axis with an eligible interval as an ink whisker, `passed/n`,
1258
+ * cost per pass and median run. An App Grade grader adds a tier strip under
1259
+ * the bar with a tick at the pass tier. Bootstrap rows with ranks keep their order;
1260
+ * unranked rows sort by rate, rows with too few units last, and the finding
1261
+ * says no comparison backs the order.
1262
+ *
1263
+ * Refuses: a bar when the producer labels the estimate `none` or
1264
+ * `insufficient` (the row prints its count only); a whisker whose interval
1265
+ * names no level or method; a rank number unless every row carries one; any
1266
+ * cost it was not given. It never prints "winner", "best" or "#1".
1267
+ */
1268
+ const TICKS = [
1269
+ 0,
1270
+ .25,
1271
+ .5,
1272
+ .75,
1273
+ 1
1274
+ ];
1305
1275
  function rankedRates(input, o) {
1306
- const id = o.id ?? "ranked-rates";
1307
- const noun = o.nouns ?? { attempt: "attempt", setup: "setup" };
1308
- if (input.length === 0) return { id, refused: `No ${noun.setup} has an ${noun.attempt}.` };
1309
- for (const row of input) {
1310
- const gap = rateDataGap(row);
1311
- if (gap) return { id, refused: `${row.label}: ${gap}.` };
1312
- if (row.tiers) {
1313
- if (TIERS_BEST_FIRST.some((tier) => !Number.isInteger(row.tiers[tier]) || row.tiers[tier] < 0)) {
1314
- return { id, refused: `${row.label}: tier counts must be nonnegative whole numbers.` };
1315
- }
1316
- if (tierTotal(row.tiers) > row.attempts) return { id, refused: `${row.label}: tier counts exceed attempts.` };
1317
- if (o.passTier) {
1318
- const passing = TIERS_BEST_FIRST.filter((tier) => tierAtLeast(tier, o.passTier)).reduce((sum, tier) => sum + row.tiers[tier], 0);
1319
- if (passing !== row.solved) return { id, refused: `${row.label}: ${row.solved} passes disagree with ${passing} grades at ${o.passTier} or better.` };
1320
- }
1321
- }
1322
- }
1323
- const ranked = input.every((row) => row.rank !== null && row.estimate === "bootstrap");
1324
- const rows = rateOrder(input);
1325
- const layout = {
1326
- ranked,
1327
- cost: rows.some((r) => perPass(r.cost) !== null || r.cost.basis !== "unknown" || r.cost.receipts !== null),
1328
- median: rows.some((r) => r.medianWallMs !== null)
1329
- };
1330
- const bars = rows.filter((row) => drawable(row.estimate));
1331
- const noBar = rows.filter((row) => !drawable(row.estimate));
1332
- const whiskers = bars.filter((row) => rateIntervalGap(row) === null);
1333
- const noWhisker = bars.filter((row) => rateIntervalGap(row) !== null);
1334
- const tiered = rows.some((row) => row.tiers !== null);
1335
- const passed = rows.reduce((sum, row) => sum + row.solved, 0);
1336
- const attempts = rows.reduce((sum, row) => sum + row.attempts, 0);
1337
- const setupsN = `${rows.length} ${plural(rows.length, noun.setup)}`;
1338
- const top = rows[0];
1339
- const uniqueTop = bars.length === 1 || bars.length > 1 && bars[1].rate < top.rate;
1340
- const finding = ranked ? `${top.label} ranks first, passing ${pct(top.rate)} of ${plural(2, noun.attempt)}.` : bars.length === 0 ? `No ${noun.setup} has enough units to draw a ${o.measure}.` : rows.length === 1 ? `${top.label} passes ${pct(top.rate)} of ${top.attempts} ${plural(top.attempts, noun.attempt)}.` : 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.`;
1341
- const lede = `${passed} of ${attempts} ${plural(attempts, noun.attempt)} passed across ${setupsN}.` + (noBar.length && bars.length ? ` ${noBar.length} of ${setupsN} have too few units to draw a rate; they come last.` : "");
1342
- const intervalNames = [...new Set(whiskers.map((row) => intervalName(row.interval)))];
1343
- const read = bars.length ? [
1344
- `Bars show the share of ${plural(2, noun.attempt)} that passed${whiskers.length ? `; whiskers show the ${intervalNames.join(" or ")} interval` : ""}.`,
1345
- 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` : ""}.` : ""
1346
- ].filter(Boolean).join(" ") : `Counts show ${plural(2, noun.attempt)} passed; no ${noun.setup} has enough units to draw a rate.`;
1347
- const bases = [...new Set(rows.filter((r) => perPass(r.cost) !== null).map((r) => r.cost.basis))];
1348
- const method = [
1349
- `${capitalize(o.measure)} is the share of ${plural(2, noun.attempt)} that passed${o.passTier ? `; a pass is App Grade ${o.passTier} or better` : ""}.`,
1350
- intervalNames.length ? `Intervals: ${intervalNames.join(", ")}, per ${noun.setup}.` : "",
1351
- bases.length ? `Cost per pass is model spend over passes, ${bases.map(basisPhrase).join(" or ")}.` : "",
1352
- layout.median ? `Median run is the median wall time of one ${noun.attempt}.` : ""
1353
- ].filter(Boolean).join(" ");
1354
- const exclusions = [
1355
- ...(o.excluded ?? []).map((e) => `${e.count} ${plural(e.count, noun.attempt)} excluded: ${e.why}.`),
1356
- ...noBar.length ? [`No bar for ${noBar.map((r) => `${r.label} (${estimateNote(r.estimate, r.attempts)})`).join(", ")}.`] : [],
1357
- ...noWhisker.length ? [`No whisker for ${noWhisker.map((r) => `${r.label} (${rateIntervalGap(r)})`).join(", ")}.`] : []
1358
- ];
1359
- const columns = [
1360
- { label: capitalize(noun.setup) },
1361
- ...ranked ? [{ label: "Rank", numeric: true }] : [],
1362
- { label: "Passed", numeric: true },
1363
- { label: capitalize(plural(2, noun.attempt)), numeric: true },
1364
- { label: capitalize(o.measure), numeric: true },
1365
- { label: "Interval", numeric: true },
1366
- ...tiered ? [{ label: "Tiers" }] : [],
1367
- ...layout.cost ? [{ label: "Cost per pass", numeric: true }, { label: "Cost basis" }] : [],
1368
- ...layout.median ? [{ label: "Median run", numeric: true }] : []
1369
- ];
1370
- const tableRows = rows.map((row) => {
1371
- const offLadder = row.tiers ? row.attempts - tierTotal(row.tiers) : 0;
1372
- return [
1373
- row.label,
1374
- ...ranked ? [String(row.rank)] : [],
1375
- String(row.solved),
1376
- String(row.attempts),
1377
- drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts),
1378
- rateIntervalGap(row) === null ? `${pct(row.interval.lower)}\u2013${pct(row.interval.upper)} (${intervalName(row.interval)}, n=${row.attempts})` : `not drawn (${rateIntervalGap(row)})`,
1379
- ...tiered ? [row.tiers ? [tierSummary(row.tiers), offLadder ? `${offLadder} off the ladder` : ""].filter(Boolean).join(" \xB7 ") || "none" : "not graded"] : [],
1380
- ...layout.cost ? [costText(row.cost, row.solved), row.cost.basis + (row.cost.receipts !== null ? `, ${row.cost.receipts} receipts` : "")] : [],
1381
- ...layout.median ? [row.medianWallMs === null ? "unknown" : duration(row.medianWallMs)] : []
1382
- ];
1383
- });
1384
- return {
1385
- id,
1386
- finding,
1387
- lede,
1388
- read,
1389
- svg: { wide: wide2(rows, layout, noun, o), narrow: narrow(rows, layout, o) },
1390
- table: table(`${capitalize(o.measure)} by ${noun.setup}`, columns, tableRows),
1391
- note: {
1392
- method,
1393
- n: `${attempts} ${plural(attempts, noun.attempt)} over ${setupsN}.`,
1394
- exclusions
1395
- }
1396
- };
1276
+ const id = o.id ?? "ranked-rates";
1277
+ const noun = o.nouns ?? {
1278
+ attempt: "attempt",
1279
+ setup: "setup"
1280
+ };
1281
+ if (input.length === 0) return {
1282
+ id,
1283
+ refused: `No ${noun.setup} has an ${noun.attempt}.`
1284
+ };
1285
+ for (const row of input) {
1286
+ const gap = rateDataGap(row);
1287
+ if (gap) return {
1288
+ id,
1289
+ refused: `${row.label}: ${gap}.`
1290
+ };
1291
+ if (row.tiers) {
1292
+ if (TIERS_BEST_FIRST.some((tier) => !Number.isInteger(row.tiers[tier]) || row.tiers[tier] < 0)) return {
1293
+ id,
1294
+ refused: `${row.label}: tier counts must be nonnegative whole numbers.`
1295
+ };
1296
+ if (tierTotal(row.tiers) > row.attempts) return {
1297
+ id,
1298
+ refused: `${row.label}: tier counts exceed attempts.`
1299
+ };
1300
+ if (o.passTier) {
1301
+ const passing = TIERS_BEST_FIRST.filter((tier) => tierAtLeast(tier, o.passTier)).reduce((sum, tier) => sum + row.tiers[tier], 0);
1302
+ if (passing !== row.solved) return {
1303
+ id,
1304
+ refused: `${row.label}: ${row.solved} passes disagree with ${passing} grades at ${o.passTier} or better.`
1305
+ };
1306
+ }
1307
+ }
1308
+ }
1309
+ const ranked = input.every((row) => row.rank !== null && row.estimate === "bootstrap");
1310
+ const rows = rateOrder(input);
1311
+ const layout = {
1312
+ ranked,
1313
+ cost: rows.some((r) => perPass(r.cost) !== null || r.cost.basis !== "unknown" || r.cost.receipts !== null),
1314
+ median: rows.some((r) => r.medianWallMs !== null)
1315
+ };
1316
+ const bars = rows.filter((row) => drawable(row.estimate));
1317
+ const noBar = rows.filter((row) => !drawable(row.estimate));
1318
+ const whiskers = bars.filter((row) => rateIntervalGap(row) === null);
1319
+ const noWhisker = bars.filter((row) => rateIntervalGap(row) !== null);
1320
+ const tiered = rows.some((row) => row.tiers !== null);
1321
+ const passed = rows.reduce((sum, row) => sum + row.solved, 0);
1322
+ const attempts = rows.reduce((sum, row) => sum + row.attempts, 0);
1323
+ const setupsN = `${rows.length} ${plural(rows.length, noun.setup)}`;
1324
+ const top = rows[0];
1325
+ const uniqueTop = bars.length === 1 || bars.length > 1 && bars[1].rate < top.rate;
1326
+ const finding = ranked ? `${top.label} ranks first, passing ${pct(top.rate)} of ${plural(2, noun.attempt)}.` : bars.length === 0 ? `No ${noun.setup} has enough units to draw a ${o.measure}.` : rows.length === 1 ? `${top.label} passes ${pct(top.rate)} of ${top.attempts} ${plural(top.attempts, noun.attempt)}.` : 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.`;
1327
+ const lede = `${passed} of ${attempts} ${plural(attempts, noun.attempt)} passed across ${setupsN}.` + (noBar.length && bars.length ? ` ${noBar.length} of ${setupsN} have too few units to draw a rate; they come last.` : "");
1328
+ const intervalNames = [...new Set(whiskers.map((row) => intervalName(row.interval)))];
1329
+ const read = bars.length ? [`Bars show the share of ${plural(2, noun.attempt)} that passed${whiskers.length ? `; whiskers show the ${intervalNames.join(" or ")} interval` : ""}.`, 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` : ""}.` : ""].filter(Boolean).join(" ") : `Counts show ${plural(2, noun.attempt)} passed; no ${noun.setup} has enough units to draw a rate.`;
1330
+ const bases = [...new Set(rows.filter((r) => perPass(r.cost) !== null).map((r) => r.cost.basis))];
1331
+ const method = [
1332
+ `${capitalize(o.measure)} is the share of ${plural(2, noun.attempt)} that passed${o.passTier ? `; a pass is App Grade ${o.passTier} or better` : ""}.`,
1333
+ intervalNames.length ? `Intervals: ${intervalNames.join(", ")}, per ${noun.setup}.` : "",
1334
+ bases.length ? `Cost per pass is model spend over passes, ${bases.map(basisPhrase).join(" or ")}.` : "",
1335
+ layout.median ? `Median run is the median wall time of one ${noun.attempt}.` : ""
1336
+ ].filter(Boolean).join(" ");
1337
+ const exclusions = [
1338
+ ...(o.excluded ?? []).map((e) => `${e.count} ${plural(e.count, noun.attempt)} excluded: ${e.why}.`),
1339
+ ...noBar.length ? [`No bar for ${noBar.map((r) => `${r.label} (${estimateNote(r.estimate, r.attempts)})`).join(", ")}.`] : [],
1340
+ ...noWhisker.length ? [`No whisker for ${noWhisker.map((r) => `${r.label} (${rateIntervalGap(r)})`).join(", ")}.`] : []
1341
+ ];
1342
+ const columns = [
1343
+ { label: capitalize(noun.setup) },
1344
+ ...ranked ? [{
1345
+ label: "Rank",
1346
+ numeric: true
1347
+ }] : [],
1348
+ {
1349
+ label: "Passed",
1350
+ numeric: true
1351
+ },
1352
+ {
1353
+ label: capitalize(plural(2, noun.attempt)),
1354
+ numeric: true
1355
+ },
1356
+ {
1357
+ label: capitalize(o.measure),
1358
+ numeric: true
1359
+ },
1360
+ {
1361
+ label: "Interval",
1362
+ numeric: true
1363
+ },
1364
+ ...tiered ? [{ label: "Tiers" }] : [],
1365
+ ...layout.cost ? [{
1366
+ label: "Cost per pass",
1367
+ numeric: true
1368
+ }, { label: "Cost basis" }] : [],
1369
+ ...layout.median ? [{
1370
+ label: "Median run",
1371
+ numeric: true
1372
+ }] : []
1373
+ ];
1374
+ const tableRows = rows.map((row) => {
1375
+ const offLadder = row.tiers ? row.attempts - tierTotal(row.tiers) : 0;
1376
+ return [
1377
+ row.label,
1378
+ ...ranked ? [String(row.rank)] : [],
1379
+ String(row.solved),
1380
+ String(row.attempts),
1381
+ drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts),
1382
+ rateIntervalGap(row) === null ? `${pct(row.interval.lower)}–${pct(row.interval.upper)} (${intervalName(row.interval)}, n=${row.attempts})` : `not drawn (${rateIntervalGap(row)})`,
1383
+ ...tiered ? [row.tiers ? [tierSummary(row.tiers), offLadder ? `${offLadder} off the ladder` : ""].filter(Boolean).join(" · ") || "none" : "not graded"] : [],
1384
+ ...layout.cost ? [costText(row.cost, row.solved), row.cost.basis + (row.cost.receipts !== null ? `, ${row.cost.receipts} receipts` : "")] : [],
1385
+ ...layout.median ? [row.medianWallMs === null ? "unknown" : duration(row.medianWallMs)] : []
1386
+ ];
1387
+ });
1388
+ return {
1389
+ id,
1390
+ finding,
1391
+ lede,
1392
+ read,
1393
+ svg: {
1394
+ wide: wide(rows, layout, noun, o),
1395
+ narrow: narrow(rows, layout, o)
1396
+ },
1397
+ table: table(`${capitalize(o.measure)} by ${noun.setup}`, columns, tableRows),
1398
+ note: {
1399
+ method,
1400
+ n: `${attempts} ${plural(attempts, noun.attempt)} over ${setupsN}.`,
1401
+ exclusions
1402
+ }
1403
+ };
1397
1404
  }
1398
1405
  function basisPhrase(basis) {
1399
- if (basis === "receipts") return "from router receipts";
1400
- if (basis === "estimated") return "estimated, not billed (marked est.)";
1401
- return "from an unrecorded source";
1406
+ if (basis === "receipts") return "from router receipts";
1407
+ if (basis === "estimated") return "estimated, not billed (marked est.)";
1408
+ return "from an unrecorded source";
1402
1409
  }
1410
+ /** The bar, whisker, rate label and tier strip for one row between x0 and x1. */
1403
1411
  function barMarks(row, x0, x1, y, o) {
1404
- const w = x1 - x0;
1405
- const at = (v) => x0 + Math.max(0, Math.min(1, v)) * w;
1406
- let out = hbar(x0, y, at(row.rate) - x0, 12, "tgc-bar");
1407
- let end = at(row.rate);
1408
- if (row.interval && rateIntervalGap(row) === null) {
1409
- const lo = at(row.interval.lower);
1410
- const hi = at(row.interval.upper);
1411
- out += line(lo, y + 6, hi, y + 6, "tgc-whisker");
1412
- out += line(lo, y + 2, lo, y + 10, "tgc-whisker") + line(hi, y + 2, hi, y + 10, "tgc-whisker");
1413
- end = Math.max(end, hi);
1414
- }
1415
- out += text(end + 6, y + 10.5, pct(row.rate), { cls: "tgc-ink tgc-num" });
1416
- if (row.tiers && row.attempts > 0) {
1417
- const sy = y + 16;
1418
- let cursor = x0;
1419
- for (const tier of TIERS_BEST_FIRST) {
1420
- const count = row.tiers[tier];
1421
- if (!count) continue;
1422
- const segment = count / row.attempts * w;
1423
- 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()}`);
1424
- cursor += segment;
1425
- }
1426
- if (o.passTier) {
1427
- const passing = TIERS_BEST_FIRST.filter((t) => tierAtLeast(t, o.passTier)).reduce((s, t) => s + row.tiers[t], 0);
1428
- const tx = x0 + passing / row.attempts * w;
1429
- out += line(tx, sy - 3, tx, sy + 9, "tgc-tick");
1430
- }
1431
- }
1432
- return out;
1412
+ const w = x1 - x0;
1413
+ const at = (v) => x0 + Math.max(0, Math.min(1, v)) * w;
1414
+ let out = hbar(x0, y, at(row.rate) - x0, 12, "tgc-bar");
1415
+ let end = at(row.rate);
1416
+ if (row.interval && rateIntervalGap(row) === null) {
1417
+ const lo = at(row.interval.lower);
1418
+ const hi = at(row.interval.upper);
1419
+ out += line(lo, y + 6, hi, y + 6, "tgc-whisker");
1420
+ out += line(lo, y + 2, lo, y + 10, "tgc-whisker") + line(hi, y + 2, hi, y + 10, "tgc-whisker");
1421
+ end = Math.max(end, hi);
1422
+ }
1423
+ out += text(end + 6, y + 10.5, pct(row.rate), { cls: "tgc-ink tgc-num" });
1424
+ if (row.tiers && row.attempts > 0) {
1425
+ const sy = y + 16;
1426
+ let cursor = x0;
1427
+ for (const tier of TIERS_BEST_FIRST) {
1428
+ const count = row.tiers[tier];
1429
+ if (!count) continue;
1430
+ const segment = count / row.attempts * w;
1431
+ out += tier === "F" ? rect(cursor + .75, sy + .75, Math.max(0, segment - 3.5), 4.5, "tgc-open") : rect(cursor, sy, Math.max(0, segment - 2), 6, `tgc-tier-${tier.toLowerCase()}`);
1432
+ cursor += segment;
1433
+ }
1434
+ if (o.passTier) {
1435
+ const tx = x0 + TIERS_BEST_FIRST.filter((t) => tierAtLeast(t, o.passTier)).reduce((s, t) => s + row.tiers[t], 0) / row.attempts * w;
1436
+ out += line(tx, sy - 3, tx, sy + 9, "tgc-tick");
1437
+ }
1438
+ }
1439
+ return out;
1433
1440
  }
1434
1441
  function hoverText(row) {
1435
- const parts = [
1436
- row.label,
1437
- `${kn(row.solved, row.attempts)} passed`,
1438
- drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts)
1439
- ];
1440
- if (row.interval && rateIntervalGap(row) === null) {
1441
- parts.push(`${intervalName(row.interval)} ${pct(row.interval.lower)}\u2013${pct(row.interval.upper)}`);
1442
- }
1443
- if (row.tiers) parts.push(tierSummary(row.tiers));
1444
- if (perPass(row.cost) !== null) parts.push(`${costText(row.cost, row.solved)} per pass`);
1445
- return parts.join(" \xB7 ");
1446
- }
1447
- function wide2(rows, layout, noun, o) {
1448
- const labelX = layout.ranked ? 32 : 0;
1449
- const labelW = layout.ranked ? 200 : 232;
1450
- const x0 = 248;
1451
- const extra = (layout.cost ? 104 : 0) + (layout.median ? 80 : 0);
1452
- const knRight = WIDE - extra;
1453
- const x1 = knRight - 96;
1454
- const costRight = knRight + 104;
1455
- const top = 34;
1456
- let body = "";
1457
- let y = top;
1458
- for (const [i, row] of rows.entries()) {
1459
- const label = wrap(row.label, labelW, 14);
1460
- const h = Math.max(label.lines.length * 18 + 14, row.tiers && drawable(row.estimate) ? 40 : 32);
1461
- let marks = "";
1462
- if (layout.ranked) marks += text(20, y + 18, String(row.rank), { cls: "tgc-ink-muted tgc-num", anchor: "end" });
1463
- marks += textBlock(label.lines, labelX, y + 18, 18, `class="tgc-ink" font-size="14"`);
1464
- marks += drawable(row.estimate) ? barMarks(row, x0, x1, y + 7, o) : text(x0, y + 18, estimateNote(row.estimate, row.attempts), { cls: "tgc-ink-muted" });
1465
- marks += text(knRight, y + 18, kn(row.solved, row.attempts), { cls: "tgc-ink tgc-num", anchor: "end" });
1466
- if (layout.cost) {
1467
- const billed = perPass(row.cost) !== null && row.cost.basis === "receipts";
1468
- marks += text(costRight, y + 18, costText(row.cost, row.solved), {
1469
- cls: billed ? "tgc-ink tgc-num" : "tgc-ink-muted tgc-num",
1470
- anchor: "end"
1471
- });
1472
- }
1473
- if (layout.median) {
1474
- marks += text(WIDE, y + 18, row.medianWallMs === null ? "unknown" : duration(row.medianWallMs), {
1475
- cls: row.medianWallMs === null ? "tgc-ink-muted tgc-num" : "tgc-ink tgc-num",
1476
- anchor: "end"
1477
- });
1478
- }
1479
- body += titled(hoverText(row), marks);
1480
- if (i < rows.length - 1) body += line(0, y + h, WIDE, y + h, "tgc-rule");
1481
- y += h;
1482
- }
1483
- const bottom = y;
1484
- let grid = "";
1485
- if (rows.some((r) => drawable(r.estimate))) {
1486
- for (const t of TICKS) grid += line(x0 + t * (x1 - x0), top - 4, x0 + t * (x1 - x0), bottom, "tgc-gridline");
1487
- }
1488
- let head = text(labelX, 16, capitalize(noun.setup), { cls: "tgc-ink-muted" });
1489
- head += text(x0, 16, capitalize(o.measure), { cls: "tgc-ink-muted" });
1490
- head += text(knRight, 16, "Passed", { cls: "tgc-ink-muted", anchor: "end" });
1491
- if (layout.cost) head += text(costRight, 16, "Per pass", { cls: "tgc-ink-muted", anchor: "end" });
1492
- if (layout.median) head += text(WIDE, 16, "Median run", { cls: "tgc-ink-muted", anchor: "end" });
1493
- head += line(0, 26, WIDE, 26, "tgc-rule");
1494
- let axis = "";
1495
- if (rows.some((r) => drawable(r.estimate))) {
1496
- for (const t of TICKS) {
1497
- axis += text(x0 + t * (x1 - x0), bottom + 16, pct(t), {
1498
- cls: "tgc-ink-muted tgc-num",
1499
- anchor: t === 0 ? "start" : t === 1 ? "end" : "middle"
1500
- });
1501
- }
1502
- }
1503
- return svgRoot(WIDE, bottom + 24, grid + head + body + axis);
1442
+ const parts = [
1443
+ row.label,
1444
+ `${kn(row.solved, row.attempts)} passed`,
1445
+ drawable(row.estimate) ? pct(row.rate) : estimateNote(row.estimate, row.attempts)
1446
+ ];
1447
+ if (row.interval && rateIntervalGap(row) === null) parts.push(`${intervalName(row.interval)} ${pct(row.interval.lower)}–${pct(row.interval.upper)}`);
1448
+ if (row.tiers) parts.push(tierSummary(row.tiers));
1449
+ if (perPass(row.cost) !== null) parts.push(`${costText(row.cost, row.solved)} per pass`);
1450
+ return parts.join(" · ");
1451
+ }
1452
+ function wide(rows, layout, noun, o) {
1453
+ const labelX = layout.ranked ? 32 : 0;
1454
+ const labelW = layout.ranked ? 200 : 232;
1455
+ const x0 = 248;
1456
+ const knRight = 720 - ((layout.cost ? 104 : 0) + (layout.median ? 80 : 0));
1457
+ const x1 = knRight - 96;
1458
+ const costRight = knRight + 104;
1459
+ const top = 34;
1460
+ let body = "";
1461
+ let y = top;
1462
+ for (const [i, row] of rows.entries()) {
1463
+ const label = wrap(row.label, labelW, 14);
1464
+ const h = Math.max(label.lines.length * 18 + 14, row.tiers && drawable(row.estimate) ? 40 : 32);
1465
+ let marks = "";
1466
+ if (layout.ranked) marks += text(20, y + 18, String(row.rank), {
1467
+ cls: "tgc-ink-muted tgc-num",
1468
+ anchor: "end"
1469
+ });
1470
+ marks += textBlock(label.lines, labelX, y + 18, 18, `class="tgc-ink" font-size="14"`);
1471
+ marks += drawable(row.estimate) ? barMarks(row, x0, x1, y + 7, o) : text(x0, y + 18, estimateNote(row.estimate, row.attempts), { cls: "tgc-ink-muted" });
1472
+ marks += text(knRight, y + 18, kn(row.solved, row.attempts), {
1473
+ cls: "tgc-ink tgc-num",
1474
+ anchor: "end"
1475
+ });
1476
+ if (layout.cost) {
1477
+ const billed = perPass(row.cost) !== null && row.cost.basis === "receipts";
1478
+ marks += text(costRight, y + 18, costText(row.cost, row.solved), {
1479
+ cls: billed ? "tgc-ink tgc-num" : "tgc-ink-muted tgc-num",
1480
+ anchor: "end"
1481
+ });
1482
+ }
1483
+ if (layout.median) marks += text(720, y + 18, row.medianWallMs === null ? "unknown" : duration(row.medianWallMs), {
1484
+ cls: row.medianWallMs === null ? "tgc-ink-muted tgc-num" : "tgc-ink tgc-num",
1485
+ anchor: "end"
1486
+ });
1487
+ body += titled(hoverText(row), marks);
1488
+ if (i < rows.length - 1) body += line(0, y + h, 720, y + h, "tgc-rule");
1489
+ y += h;
1490
+ }
1491
+ const bottom = y;
1492
+ let grid = "";
1493
+ if (rows.some((r) => drawable(r.estimate))) for (const t of TICKS) grid += line(x0 + t * (x1 - x0), 30, x0 + t * (x1 - x0), bottom, "tgc-gridline");
1494
+ let head = text(labelX, 16, capitalize(noun.setup), { cls: "tgc-ink-muted" });
1495
+ head += text(x0, 16, capitalize(o.measure), { cls: "tgc-ink-muted" });
1496
+ head += text(knRight, 16, "Passed", {
1497
+ cls: "tgc-ink-muted",
1498
+ anchor: "end"
1499
+ });
1500
+ if (layout.cost) head += text(costRight, 16, "Per pass", {
1501
+ cls: "tgc-ink-muted",
1502
+ anchor: "end"
1503
+ });
1504
+ if (layout.median) head += text(720, 16, "Median run", {
1505
+ cls: "tgc-ink-muted",
1506
+ anchor: "end"
1507
+ });
1508
+ head += line(0, 26, 720, 26, "tgc-rule");
1509
+ let axis = "";
1510
+ if (rows.some((r) => drawable(r.estimate))) for (const t of TICKS) axis += text(x0 + t * (x1 - x0), bottom + 16, pct(t), {
1511
+ cls: "tgc-ink-muted tgc-num",
1512
+ anchor: t === 0 ? "start" : t === 1 ? "end" : "middle"
1513
+ });
1514
+ return svgRoot(720, bottom + 24, grid + head + body + axis);
1504
1515
  }
1505
1516
  function narrow(rows, layout, o) {
1506
- const x0 = 0;
1507
- const x1 = 300;
1508
- let body = "";
1509
- let y = 8;
1510
- for (const [i, row] of rows.entries()) {
1511
- const labelX = layout.ranked ? 24 : 0;
1512
- const label = wrap(row.label, NARROW - labelX, 14);
1513
- let marks = "";
1514
- if (layout.ranked) marks += text(0, y + 14, String(row.rank), { cls: "tgc-ink-muted tgc-num" });
1515
- marks += textBlock(label.lines, labelX, y + 14, 18, `class="tgc-ink" font-size="14"`);
1516
- let cy = y + (label.lines.length - 1) * 18 + 22;
1517
- if (drawable(row.estimate)) {
1518
- marks += barMarks(row, x0, x1, cy, o);
1519
- cy += row.tiers ? 30 : 20;
1520
- }
1521
- const facts = [
1522
- `${kn(row.solved, row.attempts)} passed`,
1523
- ...drawable(row.estimate) ? [] : [estimateNote(row.estimate, row.attempts)],
1524
- ...layout.cost && !(row.solved === 0 && perPass(row.cost) === null) ? [perPass(row.cost) === null ? "cost unknown" : `${costText(row.cost, row.solved)} per pass`] : [],
1525
- ...layout.median ? [row.medianWallMs === null ? "run time unknown" : `${duration(row.medianWallMs)} run`] : []
1526
- ];
1527
- const factLines = fitLines(facts, NARROW, 12, "mono");
1528
- marks += textBlock(factLines, 0, cy + 12, 16, `class="tgc-ink-muted tgc-num" font-size="12"`);
1529
- body += titled(hoverText(row), marks);
1530
- y = cy + factLines.length * 16 + 4;
1531
- if (i < rows.length - 1) body += line(0, y + 4, NARROW, y + 4, "tgc-rule");
1532
- y += 12;
1533
- }
1534
- let axis = "";
1535
- if (rows.some((row) => drawable(row.estimate))) {
1536
- for (const t of [0, 0.5, 1]) {
1537
- axis += text(x0 + t * (x1 - x0), y + 4, pct(t), {
1538
- cls: "tgc-ink-muted tgc-num",
1539
- anchor: t === 0 ? "start" : t === 1 ? "end" : "middle"
1540
- });
1541
- }
1542
- y += 12;
1543
- }
1544
- return svgRoot(NARROW, y, body + axis);
1545
- }
1546
-
1547
- // src/task-matrix.ts
1548
- var TILE = 12;
1549
- var STEP = 14;
1550
- var KIND_LABEL = {
1551
- S: "S",
1552
- A: "A",
1553
- B: "B",
1554
- C: "C",
1555
- F: "F",
1556
- flagged: "flagged",
1557
- unmeasured: "U, not measured",
1558
- ungraded: "not graded",
1559
- solved: "passed",
1560
- open: "did not pass"
1517
+ const x0 = 0;
1518
+ const x1 = 300;
1519
+ let body = "";
1520
+ let y = 8;
1521
+ for (const [i, row] of rows.entries()) {
1522
+ const labelX = layout.ranked ? 24 : 0;
1523
+ const label = wrap(row.label, 358 - labelX, 14);
1524
+ let marks = "";
1525
+ if (layout.ranked) marks += text(0, y + 14, String(row.rank), { cls: "tgc-ink-muted tgc-num" });
1526
+ marks += textBlock(label.lines, labelX, y + 14, 18, `class="tgc-ink" font-size="14"`);
1527
+ let cy = y + (label.lines.length - 1) * 18 + 22;
1528
+ if (drawable(row.estimate)) {
1529
+ marks += barMarks(row, x0, x1, cy, o);
1530
+ cy += row.tiers ? 30 : 20;
1531
+ }
1532
+ const factLines = fitLines([
1533
+ `${kn(row.solved, row.attempts)} passed`,
1534
+ ...drawable(row.estimate) ? [] : [estimateNote(row.estimate, row.attempts)],
1535
+ ...layout.cost && !(row.solved === 0 && perPass(row.cost) === null) ? [perPass(row.cost) === null ? "cost unknown" : `${costText(row.cost, row.solved)} per pass`] : [],
1536
+ ...layout.median ? [row.medianWallMs === null ? "run time unknown" : `${duration(row.medianWallMs)} run`] : []
1537
+ ], 358, 12, "mono");
1538
+ marks += textBlock(factLines, 0, cy + 12, 16, `class="tgc-ink-muted tgc-num" font-size="12"`);
1539
+ body += titled(hoverText(row), marks);
1540
+ y = cy + factLines.length * 16 + 4;
1541
+ if (i < rows.length - 1) body += line(0, y + 4, 358, y + 4, "tgc-rule");
1542
+ y += 12;
1543
+ }
1544
+ let axis = "";
1545
+ if (rows.some((row) => drawable(row.estimate))) {
1546
+ for (const t of [
1547
+ 0,
1548
+ .5,
1549
+ 1
1550
+ ]) axis += text(x0 + t * 300, y + 4, pct(t), {
1551
+ cls: "tgc-ink-muted tgc-num",
1552
+ anchor: t === 0 ? "start" : t === 1 ? "end" : "middle"
1553
+ });
1554
+ y += 12;
1555
+ }
1556
+ return svgRoot(358, y, body + axis);
1557
+ }
1558
+ //#endregion
1559
+ //#region src/task-matrix.ts
1560
+ /**
1561
+ * `taskMatrix`: where each setup succeeds and fails.
1562
+ *
1563
+ * Rows are tasks in the given (registered) order; columns are setups in rank
1564
+ * order when every setup is ranked, otherwise in the given order. Each cell
1565
+ * draws one 12 px tile per attempt, best first, then `solved/attempts`.
1566
+ * App Grade tiles fill by tier and F is an outline; U (unable to measure) is a
1567
+ * dashed outline and an integrity-flagged grade is a struck outline. Runner
1568
+ * verification fills solved tiles with the accent. A lens matrix (`show:
1569
+ * "means"`) prints each mean with a bar on one shared scale.
1570
+ *
1571
+ * Refuses: a fill for an attempt without a grade (outline with a dot); a zero
1572
+ * for a cell that never ran ("·"); counts that do not add up; any column order
1573
+ * but the given rank.
1574
+ */
1575
+ const TILE = 12;
1576
+ const STEP = 14;
1577
+ const KIND_LABEL = {
1578
+ S: "S",
1579
+ A: "A",
1580
+ B: "B",
1581
+ C: "C",
1582
+ F: "F",
1583
+ flagged: "flagged",
1584
+ unmeasured: "U, not measured",
1585
+ ungraded: "not graded",
1586
+ solved: "passed",
1587
+ open: "did not pass"
1561
1588
  };
1562
1589
  function tilesOf(cell) {
1563
- const attempts = cell.attempts ?? 0;
1564
- if (cell.tiers) {
1565
- const out = [];
1566
- for (const tier of TIERS_BEST_FIRST) for (let i = 0; i < cell.tiers[tier]; i++) out.push(tier);
1567
- for (let i = 0; i < (cell.flagged ?? 0); i++) out.push("flagged");
1568
- for (let i = 0; i < (cell.unmeasured ?? 0); i++) out.push("unmeasured");
1569
- while (out.length < attempts) out.push("ungraded");
1570
- return out;
1571
- }
1572
- if (cell.solved !== null) {
1573
- return [...Array(cell.solved).fill("solved"), ...Array(attempts - cell.solved).fill("open")];
1574
- }
1575
- return Array(attempts).fill("ungraded");
1590
+ const attempts = cell.attempts ?? 0;
1591
+ if (cell.tiers) {
1592
+ const out = [];
1593
+ for (const tier of TIERS_BEST_FIRST) for (let i = 0; i < cell.tiers[tier]; i++) out.push(tier);
1594
+ for (let i = 0; i < (cell.flagged ?? 0); i++) out.push("flagged");
1595
+ for (let i = 0; i < (cell.unmeasured ?? 0); i++) out.push("unmeasured");
1596
+ while (out.length < attempts) out.push("ungraded");
1597
+ return out;
1598
+ }
1599
+ if (cell.solved !== null) return [...Array(cell.solved).fill("solved"), ...Array(attempts - cell.solved).fill("open")];
1600
+ return Array(attempts).fill("ungraded");
1576
1601
  }
1577
1602
  function tile(kind, x, y) {
1578
- switch (kind) {
1579
- case "S":
1580
- case "A":
1581
- case "B":
1582
- case "C":
1583
- return rect(x, y, TILE, TILE, `tgc-tier-${kind.toLowerCase()}`, 'rx="2"');
1584
- case "solved":
1585
- return rect(x, y, TILE, TILE, "tgc-bar", 'rx="2"');
1586
- case "F":
1587
- case "open":
1588
- return rect(x + 0.75, y + 0.75, TILE - 1.5, TILE - 1.5, "tgc-open", 'rx="2"');
1589
- case "flagged":
1590
- 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");
1591
- case "unmeasured":
1592
- return rect(x + 0.75, y + 0.75, TILE - 1.5, TILE - 1.5, "tgc-open tgc-dashed", 'rx="2"');
1593
- case "ungraded":
1594
- 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");
1595
- }
1603
+ switch (kind) {
1604
+ case "S":
1605
+ case "A":
1606
+ case "B":
1607
+ case "C": return rect(x, y, TILE, TILE, `tgc-tier-${kind.toLowerCase()}`, "rx=\"2\"");
1608
+ case "solved": return rect(x, y, TILE, TILE, "tgc-bar", "rx=\"2\"");
1609
+ case "F":
1610
+ case "open": return rect(x + .75, y + .75, 10.5, 10.5, "tgc-open", "rx=\"2\"");
1611
+ case "flagged": return rect(x + .75, y + .75, 10.5, 10.5, "tgc-open", "rx=\"2\"") + line(x + 2, y + TILE - 2, x + TILE - 2, y + 2, "tgc-strike");
1612
+ case "unmeasured": return rect(x + .75, y + .75, 10.5, 10.5, "tgc-open tgc-dashed", "rx=\"2\"");
1613
+ case "ungraded": return rect(x + .75, y + .75, 10.5, 10.5, "tgc-open", "rx=\"2\"") + circle(x + TILE / 2, y + TILE / 2, 1.5, "tgc-dot");
1614
+ }
1596
1615
  }
1597
1616
  function cellText(cell, means) {
1598
- if (!cell || (means ? cell.mean === null : cell.attempts === null)) return "\xB7";
1599
- if (means) return cell.mean.toFixed(2);
1600
- if (cell.attempts === 0) return "none counted";
1601
- if (cell.solved !== null) return kn(cell.solved, cell.attempts);
1602
- if (cell.tiers) return tierSummary(cell.tiers, cell.unmeasured, cell.flagged);
1603
- return `${cell.attempts}`;
1617
+ if (!cell || (means ? cell.mean === null : cell.attempts === null)) return "·";
1618
+ if (means) return cell.mean.toFixed(2);
1619
+ if (cell.attempts === 0) return "none counted";
1620
+ if (cell.solved !== null) return kn(cell.solved, cell.attempts);
1621
+ if (cell.tiers) return tierSummary(cell.tiers, cell.unmeasured, cell.flagged);
1622
+ return `${cell.attempts}`;
1604
1623
  }
1605
1624
  function describe(task, setup, cell, means) {
1606
- if (!cell || (means ? cell.mean === null : cell.attempts === null)) return `${task} \xB7 ${setup.label}: not run`;
1607
- if (means) return `${task} \xB7 ${setup.label}: mean ${cell.mean.toFixed(2)}`;
1608
- const parts = [`${task} \xB7 ${setup.label}`];
1609
- if (cell.attempts === 0) parts.push("no attempt counts");
1610
- else if (cell.solved !== null) parts.push(`${cell.solved} of ${cell.attempts} passed`);
1611
- else parts.push(`${cell.attempts} ${plural(cell.attempts, "attempt")}`);
1612
- const tiers = tierSummary(cell.tiers, cell.unmeasured, cell.flagged);
1613
- if (tiers) parts.push(tiers);
1614
- if (cell.costUsd !== null) parts.push(cell.costUsd > 0 ? usd(cell.costUsd) : "cost unknown");
1615
- return parts.join(" \xB7 ").replace(`${setup.label} \xB7 `, `${setup.label}: `);
1625
+ if (!cell || (means ? cell.mean === null : cell.attempts === null)) return `${task} · ${setup.label}: not run`;
1626
+ if (means) return `${task} · ${setup.label}: mean ${cell.mean.toFixed(2)}`;
1627
+ const parts = [`${task} · ${setup.label}`];
1628
+ if (cell.attempts === 0) parts.push("no attempt counts");
1629
+ else if (cell.solved !== null) parts.push(`${cell.solved} of ${cell.attempts} passed`);
1630
+ else parts.push(`${cell.attempts} ${plural(cell.attempts, "attempt")}`);
1631
+ const tiers = tierSummary(cell.tiers, cell.unmeasured, cell.flagged);
1632
+ if (tiers) parts.push(tiers);
1633
+ if (cell.costUsd !== null) parts.push(cell.costUsd > 0 ? usd(cell.costUsd) : "cost unknown");
1634
+ return parts.join(" · ").replace(`${setup.label} · `, `${setup.label}: `);
1616
1635
  }
1617
1636
  function signature(cell) {
1618
- if (!cell || cell.attempts === null) return "not run";
1619
- return `${cell.attempts}|${tierSummary(cell.tiers, cell.unmeasured, cell.flagged)}`;
1637
+ if (!cell || cell.attempts === null) return "not run";
1638
+ return `${cell.attempts}|${tierSummary(cell.tiers, cell.unmeasured, cell.flagged)}`;
1620
1639
  }
1621
1640
  function taskMatrix(tasks, setupsIn, cells, o = {}) {
1622
- const id = o.id ?? "task-matrix";
1623
- const means = o.show === "means";
1624
- const noun = { task: "task", setup: "setup", attempt: "attempt", ...o.nouns };
1625
- if (tasks.length === 0 || setupsIn.length === 0) return { id, refused: `No ${noun.task} or no ${noun.setup} to draw.` };
1626
- const setups = setupsIn.every((s) => s.rank !== null) ? [...setupsIn].sort((a, b) => a.rank - b.rank) : setupsIn;
1627
- const byKey = /* @__PURE__ */ new Map();
1628
- for (const cell of cells) {
1629
- const key = `${cell.task}\0${cell.setup}`;
1630
- if (!tasks.includes(cell.task) || !setups.some((s) => s.id === cell.setup)) {
1631
- return { id, refused: `A cell names ${cell.task} \xD7 ${cell.setup}, which is not in the ${noun.task} and ${noun.setup} lists.` };
1632
- }
1633
- if (byKey.has(key)) return { id, refused: `Two cells for ${cell.task} \xD7 ${cell.setup}.` };
1634
- const counts = [cell.attempts, cell.solved, cell.unmeasured, cell.flagged];
1635
- if (counts.some((n) => n !== null && n !== void 0 && (!Number.isInteger(n) || n < 0))) {
1636
- return { id, refused: `${cell.task} \xD7 ${cell.setup}: counts must be nonnegative whole numbers.` };
1637
- }
1638
- if (cell.tiers && TIERS_BEST_FIRST.some((tier) => !Number.isInteger(cell.tiers[tier]) || cell.tiers[tier] < 0)) {
1639
- return { id, refused: `${cell.task} \xD7 ${cell.setup}: tier counts must be nonnegative whole numbers.` };
1640
- }
1641
- if (cell.mean !== null && !Number.isFinite(cell.mean)) return { id, refused: `${cell.task} \xD7 ${cell.setup}: mean is not finite.` };
1642
- if (cell.costUsd !== null && (!Number.isFinite(cell.costUsd) || cell.costUsd < 0)) {
1643
- return { id, refused: `${cell.task} \xD7 ${cell.setup}: cost is invalid.` };
1644
- }
1645
- if (!means && cell.attempts !== null) {
1646
- const graded = tierTotal(cell.tiers) + (cell.flagged ?? 0) + (cell.unmeasured ?? 0);
1647
- if (graded > cell.attempts || cell.solved !== null && cell.solved > cell.attempts) {
1648
- return { id, refused: `${cell.task} \xD7 ${cell.setup}: grades or passes exceed its ${cell.attempts} attempts.` };
1649
- }
1650
- if (o.passTier && cell.tiers && cell.solved !== null) {
1651
- const passing = TIERS_BEST_FIRST.filter((tier) => tierAtLeast(tier, o.passTier)).reduce((sum, tier) => sum + cell.tiers[tier], 0);
1652
- if (passing !== cell.solved) return { id, refused: `${cell.task} \xD7 ${cell.setup}: ${cell.solved} passes disagree with ${passing} grades at ${o.passTier} or better.` };
1653
- }
1654
- }
1655
- byKey.set(key, cell);
1656
- }
1657
- const at = (task, setup) => byKey.get(`${task}\0${setup.id}`);
1658
- const tasksN = `${tasks.length} ${plural(tasks.length, noun.task)}`;
1659
- let finding;
1660
- const passRule = cells.some((c) => c.solved !== null);
1661
- if (means) {
1662
- const leads = /* @__PURE__ */ new Map();
1663
- for (const task of tasks) {
1664
- const scored = setups.map((s) => ({ s, m: at(task, s)?.mean ?? null })).filter((x) => x.m !== null);
1665
- if (scored.length < 2) continue;
1666
- const top = Math.max(...scored.map((x) => x.m));
1667
- const leaders = scored.filter((x) => x.m === top);
1668
- if (leaders.length === 1) leads.set(leaders[0].s.id, (leads.get(leaders[0].s.id) ?? 0) + 1);
1669
- }
1670
- const [leaderId, count] = [...leads.entries()].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
1671
- 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}.`;
1672
- } else if (o.agreement && setups.length > 1) {
1673
- const k = tasks.filter((task) => new Set(setups.map((s) => signature(at(task, s)))).size === 1).length;
1674
- 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}.`;
1675
- } else if (passRule) {
1676
- const everywhere = tasks.filter((task) => setups.every((s) => (at(task, s)?.solved ?? 0) > 0)).length;
1677
- const perSetup = setups.map((s) => ({ s, k: tasks.filter((task) => (at(task, s)?.solved ?? 0) > 0).length }));
1678
- const most = Math.max(...perSetup.map((x) => x.k));
1679
- const leaders = perSetup.filter((x) => x.k === most).map((x) => x.s.label);
1680
- const rest = perSetup.filter((x) => x.k !== most);
1681
- if (setups.length > 1 && everywhere > 0) {
1682
- finding = `${everywhere} of ${tasksN} passed at least once for every ${noun.setup}.`;
1683
- } else if (most === 0) {
1684
- finding = `No ${noun.task} passed for any ${noun.setup}.`;
1685
- } else {
1686
- const tail = rest.length && rest.every((x) => x.k === 0) ? `; ${rest.length === 1 ? rest[0].s.label : `every other ${noun.setup}`} on none` : "";
1687
- finding = `${listJoin(leaders)} passed at least once on ${most} of ${tasksN}${tail}.`;
1688
- }
1689
- } else {
1690
- const all = cells.reduce(
1691
- (acc, c) => {
1692
- for (const t of TIERS_BEST_FIRST) acc.tiers[t] += c.tiers?.[t] ?? 0;
1693
- acc.u += c.unmeasured ?? 0;
1694
- acc.f += c.flagged ?? 0;
1695
- return acc;
1696
- },
1697
- { tiers: { S: 0, A: 0, B: 0, C: 0, F: 0 }, u: 0, f: 0 }
1698
- );
1699
- const graded = tierTotal(all.tiers) + all.u + all.f;
1700
- finding = graded ? `${graded} graded ${plural(graded, noun.attempt)}: ${tierSummary(all.tiers, all.u, all.f)}.` : `No ${noun.attempt} in this matrix is graded.`;
1701
- }
1702
- const attempts = cells.reduce((s, c) => s + (c.attempts ?? 0), 0);
1703
- const ran = cells.filter((c) => means ? c.mean !== null : c.attempts !== null).length;
1704
- const excluded = (o.excluded ?? []).reduce((s, e) => s + e.count, 0);
1705
- 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` : ""}.`;
1706
- const kinds = /* @__PURE__ */ new Set();
1707
- if (!means) for (const cell of cells) for (const k of tilesOf(cell)) kinds.add(k);
1708
- const tiered = cells.some((c) => c.tiers !== null);
1709
- 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.`;
1710
- const method = means ? `Each cell is the producer's mean score for one ${noun.setup} on one ${noun.task}.` : [
1711
- 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.`,
1712
- kinds.has("unmeasured") ? `U marks ${noun.attempt === "attempt" ? "an attempt" : `a ${noun.attempt}`} the grader could not measure.` : "",
1713
- kinds.has("flagged") ? "A struck tile is a grade that failed an integrity check and awaits human review; it never counts as a pass." : "",
1714
- kinds.has("ungraded") ? `A dotted outline is ${noun.attempt === "attempt" ? "an attempt" : `a ${noun.attempt}`} with no grade.` : ""
1715
- ].filter(Boolean).join(" ");
1716
- const tableHtml = table(
1717
- `${capitalize(noun.task)} by ${noun.setup}`,
1718
- [{ label: capitalize(noun.task) }, ...setups.map((s) => ({ label: s.label, numeric: true }))],
1719
- tasks.map((task) => [
1720
- task,
1721
- ...setups.map((s) => {
1722
- const cell = at(task, s);
1723
- const main = cellText(cell, means);
1724
- const extra = !means && cell && cell.solved !== null && cell.tiers ? ` \xB7 ${tierSummary(cell.tiers, cell.unmeasured, cell.flagged)}` : "";
1725
- const cost = cell && cell.costUsd !== null ? ` \xB7 ${cell.costUsd > 0 ? usd(cell.costUsd) : "cost unknown"}` : "";
1726
- return main === "\xB7" ? "not run" : `${main}${extra}${cost}`;
1727
- })
1728
- ])
1729
- );
1730
- const scaleMax = Math.max(1, ...cells.map((c) => c.mean ?? 0));
1731
- const legendKinds = [...kinds].sort(
1732
- (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)
1733
- );
1734
- return {
1735
- id,
1736
- finding,
1737
- lede,
1738
- read,
1739
- svg: {
1740
- wide: wideMatrix(tasks, setups, at, means, legendKinds, o, scaleMax),
1741
- narrow: narrowMatrix(tasks, setups, at, means, legendKinds, o, scaleMax)
1742
- },
1743
- table: tableHtml,
1744
- note: {
1745
- method,
1746
- n: means ? `${ran} scored cells.` : `${attempts} ${plural(attempts, noun.attempt)} in ${ran} cells.`,
1747
- exclusions: (o.excluded ?? []).map((e) => `${e.count} ${plural(e.count, noun.attempt)} excluded: ${e.why}.`)
1748
- }
1749
- };
1750
- }
1641
+ const id = o.id ?? "task-matrix";
1642
+ const means = o.show === "means";
1643
+ const noun = {
1644
+ task: "task",
1645
+ setup: "setup",
1646
+ attempt: "attempt",
1647
+ ...o.nouns
1648
+ };
1649
+ if (tasks.length === 0 || setupsIn.length === 0) return {
1650
+ id,
1651
+ refused: `No ${noun.task} or no ${noun.setup} to draw.`
1652
+ };
1653
+ const setups = setupsIn.every((s) => s.rank !== null) ? [...setupsIn].sort((a, b) => a.rank - b.rank) : setupsIn;
1654
+ const byKey = /* @__PURE__ */ new Map();
1655
+ for (const cell of cells) {
1656
+ const key = `${cell.task}\u0000${cell.setup}`;
1657
+ if (!tasks.includes(cell.task) || !setups.some((s) => s.id === cell.setup)) return {
1658
+ id,
1659
+ refused: `A cell names ${cell.task} × ${cell.setup}, which is not in the ${noun.task} and ${noun.setup} lists.`
1660
+ };
1661
+ if (byKey.has(key)) return {
1662
+ id,
1663
+ refused: `Two cells for ${cell.task} × ${cell.setup}.`
1664
+ };
1665
+ if ([
1666
+ cell.attempts,
1667
+ cell.solved,
1668
+ cell.unmeasured,
1669
+ cell.flagged
1670
+ ].some((n) => n !== null && n !== void 0 && (!Number.isInteger(n) || n < 0))) return {
1671
+ id,
1672
+ refused: `${cell.task} × ${cell.setup}: counts must be nonnegative whole numbers.`
1673
+ };
1674
+ if (cell.tiers && TIERS_BEST_FIRST.some((tier) => !Number.isInteger(cell.tiers[tier]) || cell.tiers[tier] < 0)) return {
1675
+ id,
1676
+ refused: `${cell.task} × ${cell.setup}: tier counts must be nonnegative whole numbers.`
1677
+ };
1678
+ if (cell.mean !== null && !Number.isFinite(cell.mean)) return {
1679
+ id,
1680
+ refused: `${cell.task} × ${cell.setup}: mean is not finite.`
1681
+ };
1682
+ if (cell.costUsd !== null && (!Number.isFinite(cell.costUsd) || cell.costUsd < 0)) return {
1683
+ id,
1684
+ refused: `${cell.task} × ${cell.setup}: cost is invalid.`
1685
+ };
1686
+ if (!means && cell.attempts !== null) {
1687
+ if (tierTotal(cell.tiers) + (cell.flagged ?? 0) + (cell.unmeasured ?? 0) > cell.attempts || cell.solved !== null && cell.solved > cell.attempts) return {
1688
+ id,
1689
+ refused: `${cell.task} × ${cell.setup}: grades or passes exceed its ${cell.attempts} attempts.`
1690
+ };
1691
+ if (o.passTier && cell.tiers && cell.solved !== null) {
1692
+ const passing = TIERS_BEST_FIRST.filter((tier) => tierAtLeast(tier, o.passTier)).reduce((sum, tier) => sum + cell.tiers[tier], 0);
1693
+ if (passing !== cell.solved) return {
1694
+ id,
1695
+ refused: `${cell.task} × ${cell.setup}: ${cell.solved} passes disagree with ${passing} grades at ${o.passTier} or better.`
1696
+ };
1697
+ }
1698
+ }
1699
+ byKey.set(key, cell);
1700
+ }
1701
+ const at = (task, setup) => byKey.get(`${task}\u0000${setup.id}`);
1702
+ const tasksN = `${tasks.length} ${plural(tasks.length, noun.task)}`;
1703
+ let finding;
1704
+ const passRule = cells.some((c) => c.solved !== null);
1705
+ if (means) {
1706
+ const leads = /* @__PURE__ */ new Map();
1707
+ for (const task of tasks) {
1708
+ const scored = setups.map((s) => ({
1709
+ s,
1710
+ m: at(task, s)?.mean ?? null
1711
+ })).filter((x) => x.m !== null);
1712
+ if (scored.length < 2) continue;
1713
+ const top = Math.max(...scored.map((x) => x.m));
1714
+ const leaders = scored.filter((x) => x.m === top);
1715
+ if (leaders.length === 1) leads.set(leaders[0].s.id, (leads.get(leaders[0].s.id) ?? 0) + 1);
1716
+ }
1717
+ const [leaderId, count] = [...leads.entries()].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
1718
+ 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}.`;
1719
+ } else if (o.agreement && setups.length > 1) {
1720
+ const k = tasks.filter((task) => new Set(setups.map((s) => signature(at(task, s)))).size === 1).length;
1721
+ 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}.`;
1722
+ } else if (passRule) {
1723
+ const everywhere = tasks.filter((task) => setups.every((s) => (at(task, s)?.solved ?? 0) > 0)).length;
1724
+ const perSetup = setups.map((s) => ({
1725
+ s,
1726
+ k: tasks.filter((task) => (at(task, s)?.solved ?? 0) > 0).length
1727
+ }));
1728
+ const most = Math.max(...perSetup.map((x) => x.k));
1729
+ const leaders = perSetup.filter((x) => x.k === most).map((x) => x.s.label);
1730
+ const rest = perSetup.filter((x) => x.k !== most);
1731
+ if (setups.length > 1 && everywhere > 0) finding = `${everywhere} of ${tasksN} passed at least once for every ${noun.setup}.`;
1732
+ else if (most === 0) finding = `No ${noun.task} passed for any ${noun.setup}.`;
1733
+ else {
1734
+ const tail = rest.length && rest.every((x) => x.k === 0) ? `; ${rest.length === 1 ? rest[0].s.label : `every other ${noun.setup}`} on none` : "";
1735
+ finding = `${listJoin(leaders)} passed at least once on ${most} of ${tasksN}${tail}.`;
1736
+ }
1737
+ } else {
1738
+ const all = cells.reduce((acc, c) => {
1739
+ for (const t of TIERS_BEST_FIRST) acc.tiers[t] += c.tiers?.[t] ?? 0;
1740
+ acc.u += c.unmeasured ?? 0;
1741
+ acc.f += c.flagged ?? 0;
1742
+ return acc;
1743
+ }, {
1744
+ tiers: {
1745
+ S: 0,
1746
+ A: 0,
1747
+ B: 0,
1748
+ C: 0,
1749
+ F: 0
1750
+ },
1751
+ u: 0,
1752
+ f: 0
1753
+ });
1754
+ const graded = tierTotal(all.tiers) + all.u + all.f;
1755
+ finding = graded ? `${graded} graded ${plural(graded, noun.attempt)}: ${tierSummary(all.tiers, all.u, all.f)}.` : `No ${noun.attempt} in this matrix is graded.`;
1756
+ }
1757
+ const attempts = cells.reduce((s, c) => s + (c.attempts ?? 0), 0);
1758
+ const ran = cells.filter((c) => means ? c.mean !== null : c.attempts !== null).length;
1759
+ const excluded = (o.excluded ?? []).reduce((s, e) => s + e.count, 0);
1760
+ 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` : ""}.`;
1761
+ const kinds = /* @__PURE__ */ new Set();
1762
+ if (!means) for (const cell of cells) for (const k of tilesOf(cell)) kinds.add(k);
1763
+ const tiered = cells.some((c) => c.tiers !== null);
1764
+ 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; · 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.`;
1765
+ const method = means ? `Each cell is the producer's mean score for one ${noun.setup} on one ${noun.task}.` : [
1766
+ 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.`,
1767
+ kinds.has("unmeasured") ? `U marks ${noun.attempt === "attempt" ? "an attempt" : `a ${noun.attempt}`} the grader could not measure.` : "",
1768
+ kinds.has("flagged") ? "A struck tile is a grade that failed an integrity check and awaits human review; it never counts as a pass." : "",
1769
+ kinds.has("ungraded") ? `A dotted outline is ${noun.attempt === "attempt" ? "an attempt" : `a ${noun.attempt}`} with no grade.` : ""
1770
+ ].filter(Boolean).join(" ");
1771
+ const tableHtml = table(`${capitalize(noun.task)} by ${noun.setup}`, [{ label: capitalize(noun.task) }, ...setups.map((s) => ({
1772
+ label: s.label,
1773
+ numeric: true
1774
+ }))], tasks.map((task) => [task, ...setups.map((s) => {
1775
+ const cell = at(task, s);
1776
+ const main = cellText(cell, means);
1777
+ const extra = !means && cell && cell.solved !== null && cell.tiers ? ` · ${tierSummary(cell.tiers, cell.unmeasured, cell.flagged)}` : "";
1778
+ const cost = cell && cell.costUsd !== null ? ` · ${cell.costUsd > 0 ? usd(cell.costUsd) : "cost unknown"}` : "";
1779
+ return main === "·" ? "not run" : `${main}${extra}${cost}`;
1780
+ })]));
1781
+ const scaleMax = Math.max(1, ...cells.map((c) => c.mean ?? 0));
1782
+ const legendKinds = [...kinds].sort((a, b) => [
1783
+ "S",
1784
+ "A",
1785
+ "B",
1786
+ "C",
1787
+ "solved",
1788
+ "F",
1789
+ "open",
1790
+ "flagged",
1791
+ "unmeasured",
1792
+ "ungraded"
1793
+ ].indexOf(a) - [
1794
+ "S",
1795
+ "A",
1796
+ "B",
1797
+ "C",
1798
+ "solved",
1799
+ "F",
1800
+ "open",
1801
+ "flagged",
1802
+ "unmeasured",
1803
+ "ungraded"
1804
+ ].indexOf(b));
1805
+ return {
1806
+ id,
1807
+ finding,
1808
+ lede,
1809
+ read,
1810
+ svg: {
1811
+ wide: wideMatrix(tasks, setups, at, means, legendKinds, o, scaleMax),
1812
+ narrow: narrowMatrix(tasks, setups, at, means, legendKinds, o, scaleMax)
1813
+ },
1814
+ table: tableHtml,
1815
+ note: {
1816
+ method,
1817
+ n: means ? `${ran} scored cells.` : `${attempts} ${plural(attempts, noun.attempt)} in ${ran} cells.`,
1818
+ exclusions: (o.excluded ?? []).map((e) => `${e.count} ${plural(e.count, noun.attempt)} excluded: ${e.why}.`)
1819
+ }
1820
+ };
1821
+ }
1822
+ /** Tiles and the count for one cell, wrapped within `width`. Returns markup and height. */
1751
1823
  function cellMarks(cell, x, y, width, means, scaleMax = 1) {
1752
- const label = cellText(cell, means);
1753
- if (means) {
1754
- if (!cell || cell.mean === null) return { svg: text(x, y + 16, "\xB7", { cls: "tgc-ink-muted tgc-num" }), h: 24 };
1755
- const barX = x + 44;
1756
- const barW = Math.max(0, Math.max(0, cell.mean) / scaleMax * (width - 52));
1757
- return {
1758
- svg: text(x, y + 16, label, { cls: "tgc-ink tgc-num" }) + hbar(barX, y + 8, barW, 10, "tgc-bar"),
1759
- h: 24
1760
- };
1761
- }
1762
- if (!cell || cell.attempts === null || cell.attempts === 0) {
1763
- return { svg: text(x, y + 16, label, { cls: "tgc-ink-muted tgc-num" }), h: 24 };
1764
- }
1765
- const tiles = tilesOf(cell);
1766
- const labelW = textWidth(label, 12, "mono");
1767
- const perLine = Math.max(1, Math.floor((width - labelW - 8) / STEP));
1768
- let svg = "";
1769
- tiles.forEach((kind, i) => {
1770
- svg += tile(kind, x + i % perLine * STEP, y + 6 + Math.floor(i / perLine) * STEP);
1771
- });
1772
- const lines = Math.ceil(tiles.length / perLine);
1773
- const labelX = x + Math.min(tiles.length, perLine) * STEP + 4;
1774
- svg += text(labelX, y + 16, label, { cls: cell.solved === 0 ? "tgc-ink-muted tgc-num" : "tgc-ink tgc-num" });
1775
- return { svg, h: Math.max(24, lines * STEP + 10) };
1824
+ const label = cellText(cell, means);
1825
+ if (means) {
1826
+ if (!cell || cell.mean === null) return {
1827
+ svg: text(x, y + 16, "·", { cls: "tgc-ink-muted tgc-num" }),
1828
+ h: 24
1829
+ };
1830
+ const barX = x + 44;
1831
+ const barW = Math.max(0, Math.max(0, cell.mean) / scaleMax * (width - 52));
1832
+ return {
1833
+ svg: text(x, y + 16, label, { cls: "tgc-ink tgc-num" }) + hbar(barX, y + 8, barW, 10, "tgc-bar"),
1834
+ h: 24
1835
+ };
1836
+ }
1837
+ if (!cell || cell.attempts === null || cell.attempts === 0) return {
1838
+ svg: text(x, y + 16, label, { cls: "tgc-ink-muted tgc-num" }),
1839
+ h: 24
1840
+ };
1841
+ const tiles = tilesOf(cell);
1842
+ const labelW = textWidth(label, 12, "mono");
1843
+ const perLine = Math.max(1, Math.floor((width - labelW - 8) / STEP));
1844
+ let svg = "";
1845
+ tiles.forEach((kind, i) => {
1846
+ svg += tile(kind, x + i % perLine * STEP, y + 6 + Math.floor(i / perLine) * STEP);
1847
+ });
1848
+ const lines = Math.ceil(tiles.length / perLine);
1849
+ const labelX = x + Math.min(tiles.length, perLine) * STEP + 4;
1850
+ svg += text(labelX, y + 16, label, { cls: cell.solved === 0 ? "tgc-ink-muted tgc-num" : "tgc-ink tgc-num" });
1851
+ return {
1852
+ svg,
1853
+ h: Math.max(24, lines * STEP + 10)
1854
+ };
1776
1855
  }
1777
1856
  function legend(kinds, width, y, o) {
1778
- if (kinds.length === 0) return { svg: "", h: 0 };
1779
- let svg = "";
1780
- let x = 0;
1781
- let row = 0;
1782
- const items = kinds.map((kind) => ({
1783
- kind,
1784
- label: KIND_LABEL[kind] + (o.passTier && ["S", "A", "B", "C", "F"].includes(kind) && kind === o.passTier ? " (pass)" : "")
1785
- }));
1786
- for (const item of items) {
1787
- const w = STEP + textWidth(item.label, 12) + 16;
1788
- if (x > 0 && x + w > width) {
1789
- x = 0;
1790
- row += 1;
1791
- }
1792
- svg += tile(item.kind, x, y + row * 20 + 2) + text(x + STEP + 2, y + row * 20 + 12, item.label, { cls: "tgc-ink-muted" });
1793
- x += w;
1794
- }
1795
- return { svg, h: (row + 1) * 20 };
1796
- }
1797
- var MIN_COL = 76;
1798
- var GRID_X0 = 208;
1799
- var BLOCK_GAP = 24;
1857
+ if (kinds.length === 0) return {
1858
+ svg: "",
1859
+ h: 0
1860
+ };
1861
+ let svg = "";
1862
+ let x = 0;
1863
+ let row = 0;
1864
+ const items = kinds.map((kind) => ({
1865
+ kind,
1866
+ label: KIND_LABEL[kind] + (o.passTier && [
1867
+ "S",
1868
+ "A",
1869
+ "B",
1870
+ "C",
1871
+ "F"
1872
+ ].includes(kind) && kind === o.passTier ? " (pass)" : "")
1873
+ }));
1874
+ for (const item of items) {
1875
+ const w = STEP + textWidth(item.label, 12) + 16;
1876
+ if (x > 0 && x + w > width) {
1877
+ x = 0;
1878
+ row += 1;
1879
+ }
1880
+ svg += tile(item.kind, x, y + row * 20 + 2) + text(x + STEP + 2, y + row * 20 + 12, item.label, { cls: "tgc-ink-muted" });
1881
+ x += w;
1882
+ }
1883
+ return {
1884
+ svg,
1885
+ h: (row + 1) * 20
1886
+ };
1887
+ }
1888
+ /** Narrowest setup column the grid draws: room for three tiles and a count. */
1889
+ const MIN_COL = 76;
1890
+ const GRID_X0 = 208;
1891
+ /**
1892
+ * The wide render. Setups are columns while each column fits its tiles and
1893
+ * count; with more setups than that, the wide render draws one block per
1894
+ * setup, as the phone view does, two blocks to a row.
1895
+ */
1800
1896
  function wideMatrix(tasks, setups, at, means, kinds, o, scaleMax) {
1801
- if ((WIDE - GRID_X0) / setups.length < MIN_COL) return blockColumns(tasks, setups, at, means, kinds, o, scaleMax);
1802
- const labelW = 196;
1803
- const x0 = GRID_X0;
1804
- const colW = (WIDE - x0) / setups.length;
1805
- let head = "";
1806
- let headH = 0;
1807
- for (const [j, s] of setups.entries()) {
1808
- const label = wrap(s.label, colW - 8, 12, "sans", 3);
1809
- headH = Math.max(headH, label.lines.length * 16);
1810
- head += titled(s.label, textBlock(label.lines, x0 + j * colW, 14, 16, `class="tgc-ink" font-size="12"`));
1811
- }
1812
- let y = headH + 12;
1813
- head += line(0, y - 4, WIDE, y - 4, "tgc-rule");
1814
- let body = "";
1815
- for (const [i, task] of tasks.entries()) {
1816
- const label = wrap(task, labelW, 12, "sans", 2);
1817
- let h = label.lines.length * 16 + 8;
1818
- let marks = "";
1819
- for (const [j, s] of setups.entries()) {
1820
- const cell = at(task, s);
1821
- const m = cellMarks(cell, x0 + j * colW, y, colW - 8, means, scaleMax);
1822
- h = Math.max(h, m.h);
1823
- marks += titled(describe(task, s, cell, means), m.svg);
1824
- }
1825
- body += titled(task, textBlock(label.lines, 0, y + 16, 16, `class="tgc-ink" font-size="12"`)) + marks;
1826
- y += h + 4;
1827
- if (i < tasks.length - 1) body += line(0, y - 2, WIDE, y - 2, "tgc-gridline");
1828
- }
1829
- const lg = legend(kinds, WIDE, y + 10, o);
1830
- return svgRoot(WIDE, y + 10 + lg.h + 4, head + body + lg.svg);
1831
- }
1897
+ if (512 / setups.length < MIN_COL) return blockColumns(tasks, setups, at, means, kinds, o, scaleMax);
1898
+ const labelW = 196;
1899
+ const x0 = GRID_X0;
1900
+ const colW = 512 / setups.length;
1901
+ let head = "";
1902
+ let headH = 0;
1903
+ for (const [j, s] of setups.entries()) {
1904
+ const label = wrap(s.label, colW - 8, 12, "sans", 3);
1905
+ headH = Math.max(headH, label.lines.length * 16);
1906
+ head += titled(s.label, textBlock(label.lines, x0 + j * colW, 14, 16, `class="tgc-ink" font-size="12"`));
1907
+ }
1908
+ let y = headH + 12;
1909
+ head += line(0, y - 4, 720, y - 4, "tgc-rule");
1910
+ let body = "";
1911
+ for (const [i, task] of tasks.entries()) {
1912
+ const label = wrap(task, labelW, 12, "sans", 2);
1913
+ let h = label.lines.length * 16 + 8;
1914
+ let marks = "";
1915
+ for (const [j, s] of setups.entries()) {
1916
+ const cell = at(task, s);
1917
+ const m = cellMarks(cell, x0 + j * colW, y, colW - 8, means, scaleMax);
1918
+ h = Math.max(h, m.h);
1919
+ marks += titled(describe(task, s, cell, means), m.svg);
1920
+ }
1921
+ body += titled(task, textBlock(label.lines, 0, y + 16, 16, `class="tgc-ink" font-size="12"`)) + marks;
1922
+ y += h + 4;
1923
+ if (i < tasks.length - 1) body += line(0, y - 2, 720, y - 2, "tgc-gridline");
1924
+ }
1925
+ const lg = legend(kinds, 720, y + 10, o);
1926
+ return svgRoot(720, y + 10 + lg.h + 4, head + body + lg.svg);
1927
+ }
1928
+ /** One setup's block at `width`: its name, then one row per task. Drawn at the origin. */
1832
1929
  function setupBlock(tasks, s, at, means, width, scaleMax) {
1833
- const cellW = 128;
1834
- const labelW = width - cellW - 8;
1835
- let y = 0;
1836
- const head = wrap(s.label, width, 14, "sans-semibold", 2);
1837
- let svg = textBlock(head.lines, 0, y + 16, 18, `class="tgc-ink" font-size="14" font-weight="600"`);
1838
- y += head.lines.length * 18 + 8;
1839
- svg += line(0, y, width, y, "tgc-rule");
1840
- y += 2;
1841
- for (const task of tasks) {
1842
- const label = wrap(task, labelW, 12, "sans", 2);
1843
- const cell = at(task, s);
1844
- const m = cellMarks(cell, width - cellW, y, cellW, means, scaleMax);
1845
- const h = Math.max(label.lines.length * 16 + 8, m.h);
1846
- svg += titled(describe(task, s, cell, means), textBlock(label.lines, 0, y + 16, 16, `class="tgc-ink" font-size="12"`) + m.svg);
1847
- y += h + 2;
1848
- }
1849
- return { svg, h: y };
1850
- }
1930
+ const cellW = 128;
1931
+ const labelW = width - cellW - 8;
1932
+ let y = 0;
1933
+ const head = wrap(s.label, width, 14, "sans-semibold", 2);
1934
+ let svg = textBlock(head.lines, 0, y + 16, 18, `class="tgc-ink" font-size="14" font-weight="600"`);
1935
+ y += head.lines.length * 18 + 8;
1936
+ svg += line(0, y, width, y, "tgc-rule");
1937
+ y += 2;
1938
+ for (const task of tasks) {
1939
+ const label = wrap(task, labelW, 12, "sans", 2);
1940
+ const cell = at(task, s);
1941
+ const m = cellMarks(cell, width - cellW, y, cellW, means, scaleMax);
1942
+ const h = Math.max(label.lines.length * 16 + 8, m.h);
1943
+ svg += titled(describe(task, s, cell, means), textBlock(label.lines, 0, y + 16, 16, `class="tgc-ink" font-size="12"`) + m.svg);
1944
+ y += h + 2;
1945
+ }
1946
+ return {
1947
+ svg,
1948
+ h: y
1949
+ };
1950
+ }
1951
+ /** Setup blocks two to a row, in the given order: left then right, top to bottom. */
1851
1952
  function blockColumns(tasks, setups, at, means, kinds, o, scaleMax) {
1852
- const width = (WIDE - BLOCK_GAP) / 2;
1853
- const blocks = setups.map((s) => setupBlock(tasks, s, at, means, width, scaleMax));
1854
- let body = "";
1855
- let y = 0;
1856
- for (let i = 0; i < blocks.length; i += 2) {
1857
- const pair = blocks.slice(i, i + 2);
1858
- pair.forEach((b, j) => {
1859
- body += `<g transform="translate(${j * (width + BLOCK_GAP)} ${y})">${b.svg}</g>`;
1860
- });
1861
- y += Math.max(...pair.map((b) => b.h)) + 16;
1862
- }
1863
- const lg = legend(kinds, WIDE, y, o);
1864
- return svgRoot(WIDE, y + lg.h + 4, body + lg.svg);
1953
+ const width = 348;
1954
+ const blocks = setups.map((s) => setupBlock(tasks, s, at, means, width, scaleMax));
1955
+ let body = "";
1956
+ let y = 0;
1957
+ for (let i = 0; i < blocks.length; i += 2) {
1958
+ const pair = blocks.slice(i, i + 2);
1959
+ pair.forEach((b, j) => {
1960
+ body += `<g transform="translate(${j * 372} ${y})">${b.svg}</g>`;
1961
+ });
1962
+ y += Math.max(...pair.map((b) => b.h)) + 16;
1963
+ }
1964
+ const lg = legend(kinds, 720, y, o);
1965
+ return svgRoot(720, y + lg.h + 4, body + lg.svg);
1865
1966
  }
1866
1967
  function narrowMatrix(tasks, setups, at, means, kinds, o, scaleMax) {
1867
- let y = 0;
1868
- let body = "";
1869
- for (const s of setups) {
1870
- const b = setupBlock(tasks, s, at, means, NARROW, scaleMax);
1871
- body += `<g transform="translate(0 ${y})">${b.svg}</g>`;
1872
- y += b.h + 16;
1873
- }
1874
- const lg = legend(kinds, NARROW, y, o);
1875
- return svgRoot(NARROW, y + lg.h + 4, body + lg.svg);
1876
- }
1877
-
1878
- // src/types.ts
1968
+ let y = 0;
1969
+ let body = "";
1970
+ for (const s of setups) {
1971
+ const b = setupBlock(tasks, s, at, means, 358, scaleMax);
1972
+ body += `<g transform="translate(0 ${y})">${b.svg}</g>`;
1973
+ y += b.h + 16;
1974
+ }
1975
+ const lg = legend(kinds, 358, y, o);
1976
+ return svgRoot(358, y + lg.h + 4, body + lg.svg);
1977
+ }
1978
+ //#endregion
1979
+ //#region src/types.ts
1879
1980
  function isRefusal(value) {
1880
- return "refused" in value;
1881
- }
1882
- export {
1883
- breakdown,
1884
- chartTokensCss,
1885
- comparisonIntervals,
1886
- costFrontier,
1887
- figureCss,
1888
- isRefusal,
1889
- rankedRates,
1890
- rateOrder,
1891
- renderFigure,
1892
- renderNotes,
1893
- renderRefusals,
1894
- taskMatrix
1895
- };
1981
+ return "refused" in value;
1982
+ }
1983
+ //#endregion
1984
+ export { breakdown, chartTokensCss, comparisonIntervals, costFrontier, figureCss, isRefusal, rankedRates, rateOrder, renderFigure, renderNotes, renderRefusals, taskMatrix };