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