@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/LICENSE +21 -0
- package/README.md +9 -2
- package/dist/index.d.ts +155 -222
- package/dist/index.js +1880 -1667
- package/package.json +10 -10
package/dist/index.js
CHANGED
|
@@ -1,1771 +1,1984 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
12
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
function textWidth(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
293
|
+
return count === 1 ? one : many;
|
|
275
294
|
}
|
|
295
|
+
/** "a, b and c" */
|
|
276
296
|
function listJoin(items) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
function capitalize(
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
+
return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
|
|
341
376
|
}
|
|
342
377
|
function polyline(points, cls) {
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
387
|
+
return `<g><title>${esc(title)}</title>${inner}</g>`;
|
|
352
388
|
}
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
527
|
+
return LADDER.indexOf(tier) >= LADDER.indexOf(floor);
|
|
452
528
|
}
|
|
453
529
|
function pct(value) {
|
|
454
|
-
|
|
530
|
+
return `${Math.round(value * 100)}%`;
|
|
455
531
|
}
|
|
532
|
+
/** A difference in points: "+25 pts". */
|
|
456
533
|
function pts(value) {
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
545
|
+
return `$${Number(value.toPrecision(2)).toLocaleString("en-US", { maximumFractionDigits: 4 })}`;
|
|
468
546
|
}
|
|
469
547
|
function duration(ms) {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
554
|
+
return `${k}/${n}`;
|
|
477
555
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
578
|
+
return estimate === "descriptive" || estimate === "bootstrap";
|
|
495
579
|
}
|
|
496
580
|
function estimateNote(estimate, units) {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
640
|
+
return tiers ? LADDER.reduce((sum, t) => sum + tiers[t], 0) : 0;
|
|
513
641
|
}
|
|
514
|
-
|
|
515
|
-
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region src/scale.ts
|
|
516
644
|
function linear(domain, range) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
1088
|
+
return listJoin(rows.map((r) => `${r.label} (${unplacedReason(r)})`));
|
|
808
1089
|
}
|
|
809
1090
|
function plot(costed, unplaced, drawLine, o) {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
.
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
.
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
.tgc-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
.
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
.tgc-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
@container (max-width: 719.98px) {
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
|
|
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 };
|