@tangle-network/charts 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- package/dist/figure.css +36 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +37 -291
- package/dist/react/index.d.ts +16 -6
- package/dist/react/index.js +57 -7
- package/dist/react/live.css +18 -1
- package/dist/{timeline-BAjzF-wL.d.ts → timeline-CCEJug7n.d.ts} +41 -6
- package/dist/timeline-CCWFvLxg.js +1130 -0
- package/package.json +1 -1
- package/dist/events-Cw6abvgR.js +0 -491
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
//#region src/text.ts
|
|
2
|
+
/**
|
|
3
|
+
* Text: escaping, measurement and wrapping.
|
|
4
|
+
*
|
|
5
|
+
* Layout runs with no DOM (a Worker, a build step), so text width comes from
|
|
6
|
+
* Inter's advance widths, the face tangle.tools and the reports load. Numbers
|
|
7
|
+
* render in a monospace face at 0.6 em per glyph. Widths carry a 4% margin so a
|
|
8
|
+
* fallback face does not collide with its neighbour.
|
|
9
|
+
*/
|
|
10
|
+
/** Every dynamic string in markup passes through here. */
|
|
11
|
+
function esc(value) {
|
|
12
|
+
return String(value).replace(/&/g, "&").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
|
|
110
|
+
];
|
|
111
|
+
const SEMIBOLD = [
|
|
112
|
+
252,
|
|
113
|
+
321,
|
|
114
|
+
523,
|
|
115
|
+
644,
|
|
116
|
+
650,
|
|
117
|
+
1004,
|
|
118
|
+
663,
|
|
119
|
+
326,
|
|
120
|
+
373,
|
|
121
|
+
373,
|
|
122
|
+
540,
|
|
123
|
+
673,
|
|
124
|
+
319,
|
|
125
|
+
465,
|
|
126
|
+
319,
|
|
127
|
+
379,
|
|
128
|
+
660,
|
|
129
|
+
423,
|
|
130
|
+
623,
|
|
131
|
+
636,
|
|
132
|
+
666,
|
|
133
|
+
629,
|
|
134
|
+
640,
|
|
135
|
+
576,
|
|
136
|
+
640,
|
|
137
|
+
640,
|
|
138
|
+
319,
|
|
139
|
+
329,
|
|
140
|
+
673,
|
|
141
|
+
673,
|
|
142
|
+
673,
|
|
143
|
+
543,
|
|
144
|
+
999,
|
|
145
|
+
728,
|
|
146
|
+
659,
|
|
147
|
+
737,
|
|
148
|
+
722,
|
|
149
|
+
605,
|
|
150
|
+
588,
|
|
151
|
+
749,
|
|
152
|
+
746,
|
|
153
|
+
277,
|
|
154
|
+
580,
|
|
155
|
+
703,
|
|
156
|
+
565,
|
|
157
|
+
922,
|
|
158
|
+
759,
|
|
159
|
+
769,
|
|
160
|
+
645,
|
|
161
|
+
773,
|
|
162
|
+
652,
|
|
163
|
+
650,
|
|
164
|
+
660,
|
|
165
|
+
736,
|
|
166
|
+
728,
|
|
167
|
+
1020,
|
|
168
|
+
720,
|
|
169
|
+
713,
|
|
170
|
+
652,
|
|
171
|
+
373,
|
|
172
|
+
379,
|
|
173
|
+
373,
|
|
174
|
+
481,
|
|
175
|
+
469,
|
|
176
|
+
351,
|
|
177
|
+
574,
|
|
178
|
+
624,
|
|
179
|
+
583,
|
|
180
|
+
624,
|
|
181
|
+
591,
|
|
182
|
+
389,
|
|
183
|
+
625,
|
|
184
|
+
612,
|
|
185
|
+
262,
|
|
186
|
+
262,
|
|
187
|
+
569,
|
|
188
|
+
262,
|
|
189
|
+
900,
|
|
190
|
+
612,
|
|
191
|
+
609,
|
|
192
|
+
624,
|
|
193
|
+
624,
|
|
194
|
+
397,
|
|
195
|
+
549,
|
|
196
|
+
353,
|
|
197
|
+
612,
|
|
198
|
+
587,
|
|
199
|
+
839,
|
|
200
|
+
569,
|
|
201
|
+
588,
|
|
202
|
+
566,
|
|
203
|
+
455,
|
|
204
|
+
359,
|
|
205
|
+
455,
|
|
206
|
+
673
|
|
207
|
+
];
|
|
208
|
+
const EXTRA = {
|
|
209
|
+
"·": 300,
|
|
210
|
+
"×": 670,
|
|
211
|
+
"—": 1e3,
|
|
212
|
+
"–": 500,
|
|
213
|
+
"…": 900,
|
|
214
|
+
"≥": 670,
|
|
215
|
+
"≤": 670,
|
|
216
|
+
"±": 670,
|
|
217
|
+
"→": 954,
|
|
218
|
+
"−": 662
|
|
219
|
+
};
|
|
220
|
+
const MARGIN = 1.04;
|
|
221
|
+
function textWidth(text, size, face = "sans") {
|
|
222
|
+
if (face === "mono") return text.length * .6 * size * MARGIN;
|
|
223
|
+
const table = face === "sans-semibold" ? SEMIBOLD : REGULAR;
|
|
224
|
+
let units = 0;
|
|
225
|
+
for (const ch of text) {
|
|
226
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
227
|
+
units += code >= 32 && code <= 126 ? table[code - 32] : EXTRA[ch] ?? 620;
|
|
228
|
+
}
|
|
229
|
+
return units / 1e3 * size * MARGIN;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Breaks `text` into at most `maxLines` lines no wider than `width`. Breaks at
|
|
233
|
+
* spaces, and after `-`, `/`, `_` or `·` when a word alone is too wide. A line
|
|
234
|
+
* that still overflows the last allowed line ends with an ellipsis; callers put
|
|
235
|
+
* the full text in a `<title>` and in the table.
|
|
236
|
+
*/
|
|
237
|
+
function wrap(text, width, size, face = "sans", maxLines = 2) {
|
|
238
|
+
const fits = (s) => textWidth(s, size, face) <= width;
|
|
239
|
+
const lines = [];
|
|
240
|
+
const glue = [];
|
|
241
|
+
let line = "";
|
|
242
|
+
const breakLine = (next, joiner) => {
|
|
243
|
+
lines.push(line);
|
|
244
|
+
glue.push(joiner);
|
|
245
|
+
line = next;
|
|
246
|
+
};
|
|
247
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
248
|
+
const joined = line ? `${line} ${word}` : word;
|
|
249
|
+
if (fits(joined)) line = joined;
|
|
250
|
+
else if (fits(word)) {
|
|
251
|
+
if (line) breakLine(word, " ");
|
|
252
|
+
else line = word;
|
|
253
|
+
} else {
|
|
254
|
+
const pieces = word.match(/[^\-/_·]+[\-/_·]?|[\-/_·]/g) ?? [word];
|
|
255
|
+
for (const [index, piece] of pieces.entries()) {
|
|
256
|
+
const joiner = index === 0 ? " " : "";
|
|
257
|
+
const candidate = line ? `${line}${joiner}${piece}` : piece;
|
|
258
|
+
if (fits(candidate) || !line) line = candidate;
|
|
259
|
+
else breakLine(piece, joiner);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (line) lines.push(line);
|
|
264
|
+
if (lines.length <= maxLines && lines.every(fits)) return {
|
|
265
|
+
lines,
|
|
266
|
+
truncated: false
|
|
267
|
+
};
|
|
268
|
+
const kept = lines.slice(0, maxLines);
|
|
269
|
+
let last = lines.slice(maxLines - 1).reduce((acc, l, i) => i === 0 ? l : `${acc}${glue[maxLines - 2 + i]}${l}`, "");
|
|
270
|
+
while (last.length > 1 && !fits(`${last}…`)) last = last.slice(0, -1);
|
|
271
|
+
kept[maxLines - 1] = `${last.trimEnd()}…`;
|
|
272
|
+
return {
|
|
273
|
+
lines: kept,
|
|
274
|
+
truncated: true
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/** One `<text>` element with a line per `<tspan>`. */
|
|
278
|
+
function textBlock(lines, x, y, lineHeight, attrs) {
|
|
279
|
+
if (lines.length === 1) return `<text x="${x}" y="${y}" ${attrs}>${esc(lines[0])}</text>`;
|
|
280
|
+
return `<text x="${x}" y="${y}" ${attrs}>${lines.map((line, i) => `<tspan x="${x}" dy="${i === 0 ? 0 : lineHeight}">${esc(line)}</tspan>`).join("")}</text>`;
|
|
281
|
+
}
|
|
282
|
+
/** Joins `parts` with " · ", starting a new line wherever the next part would pass `width`. */
|
|
283
|
+
function fitLines(parts, width, size, face = "sans") {
|
|
284
|
+
const lines = [];
|
|
285
|
+
for (const part of parts) {
|
|
286
|
+
const last = lines[lines.length - 1];
|
|
287
|
+
if (last !== void 0 && textWidth(`${last} · ${part}`, size, face) <= width) lines[lines.length - 1] = `${last} · ${part}`;
|
|
288
|
+
else lines.push(part);
|
|
289
|
+
}
|
|
290
|
+
return lines;
|
|
291
|
+
}
|
|
292
|
+
function plural(count, one, many = `${one}s`) {
|
|
293
|
+
return count === 1 ? one : many;
|
|
294
|
+
}
|
|
295
|
+
/** "a, b and c" */
|
|
296
|
+
function listJoin(items) {
|
|
297
|
+
if (items.length <= 1) return items.join("");
|
|
298
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
299
|
+
}
|
|
300
|
+
function capitalize(text) {
|
|
301
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/svg.ts
|
|
305
|
+
/**
|
|
306
|
+
* SVG marks. Every mark carries a class and `figure.css` maps classes to the
|
|
307
|
+
* `--chart-*` tokens, so a render holds no colour and follows the page theme.
|
|
308
|
+
* Geometry stays in attributes, so an SVG shown without the stylesheet keeps
|
|
309
|
+
* its layout.
|
|
310
|
+
*/
|
|
311
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
312
|
+
function svgRoot(width, height, inner) {
|
|
313
|
+
const h = Math.ceil(height);
|
|
314
|
+
return `<svg class="tgc-svg" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}" aria-hidden="true" focusable="false">${inner}</svg>`;
|
|
315
|
+
}
|
|
316
|
+
/** A horizontal bar anchored at `x`, square at the baseline, 4 px round at the data end. */
|
|
317
|
+
function hbar(x, y, w, h, cls) {
|
|
318
|
+
if (w <= 0) return "";
|
|
319
|
+
const r = Math.min(4, h / 2, w);
|
|
320
|
+
return `<path class="${cls}" d="M${r2(x)} ${r2(y)}h${r2(w - r)}a${r} ${r} 0 0 1 ${r} ${r}v${r2(h - 2 * r)}a${r} ${r} 0 0 1 -${r} ${r}h-${r2(w - r)}z"/>`;
|
|
321
|
+
}
|
|
322
|
+
function rect(x, y, w, h, cls, extra = "") {
|
|
323
|
+
if (w <= 0 || h <= 0) return "";
|
|
324
|
+
return `<rect class="${cls}" x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}"${extra ? ` ${extra}` : ""}/>`;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* 45° hatch lines clipped to a rectangle, as one path `d`. Pure geometry with
|
|
328
|
+
* no pattern id, so a hatch survives duplicate SVGs on one page and hidden
|
|
329
|
+
* wide/narrow renders. Shared with the React renderers.
|
|
330
|
+
*/
|
|
331
|
+
function hatchPath(x, y, w, h, step = 6) {
|
|
332
|
+
if (!(w > 0) || !(h > 0)) return "";
|
|
333
|
+
let d = "";
|
|
334
|
+
for (let k = step / 2; k < w + h; k += step) {
|
|
335
|
+
const x0 = Math.max(0, k - h), x1 = Math.min(w, k);
|
|
336
|
+
d += `M${r2(x + x0)} ${r2(y + k - x0)}L${r2(x + x1)} ${r2(y + k - x1)}`;
|
|
337
|
+
}
|
|
338
|
+
return d;
|
|
339
|
+
}
|
|
340
|
+
/** A hatched rectangle: a neutral wash plus hatch lines. Missing data is hatched, never drawn as zero. */
|
|
341
|
+
function hatch(x, y, w, h, cls) {
|
|
342
|
+
if (w <= 0 || h <= 0) return "";
|
|
343
|
+
return `<g class="${cls}" data-hatched="true">${rect(x, y, w, h, "tgc-hatch-bg")}<path class="tgc-hatch" d="${hatchPath(x, y, w, h)}"/></g>`;
|
|
344
|
+
}
|
|
345
|
+
function line(x1, y1, x2, y2, cls) {
|
|
346
|
+
return `<line class="${cls}" x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}"/>`;
|
|
347
|
+
}
|
|
348
|
+
function circle(cx, cy, r, cls) {
|
|
349
|
+
return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
|
|
350
|
+
}
|
|
351
|
+
const MARKER_SHAPES = [
|
|
352
|
+
"circle",
|
|
353
|
+
"square",
|
|
354
|
+
"triangle",
|
|
355
|
+
"diamond"
|
|
356
|
+
];
|
|
357
|
+
/** A marker outline as a path `d`, centred on (cx, cy) with radius-like size `r`. Shared with the React renderers. */
|
|
358
|
+
function markerPath(shape, cx, cy, r) {
|
|
359
|
+
const [x, y] = [r2(cx), r2(cy)];
|
|
360
|
+
if (shape === "square") {
|
|
361
|
+
const s = r2(r * .9);
|
|
362
|
+
return `M${r2(x - s)} ${r2(y - s)}h${r2(2 * s)}v${r2(2 * s)}h${r2(-2 * s)}z`;
|
|
363
|
+
}
|
|
364
|
+
if (shape === "triangle") {
|
|
365
|
+
const s = r2(r * 1.2);
|
|
366
|
+
return `M${x} ${r2(y - s)}L${r2(x + s)} ${r2(y + s * .8)}L${r2(x - s)} ${r2(y + s * .8)}z`;
|
|
367
|
+
}
|
|
368
|
+
if (shape === "diamond") {
|
|
369
|
+
const s = r2(r * 1.3);
|
|
370
|
+
return `M${x} ${r2(y - s)}L${r2(x + s)} ${y}L${x} ${r2(y + s)}L${r2(x - s)} ${y}z`;
|
|
371
|
+
}
|
|
372
|
+
return `M${r2(x - r)} ${y}a${r} ${r} 0 1 0 ${r2(2 * r)} 0a${r} ${r} 0 1 0 ${r2(-2 * r)} 0`;
|
|
373
|
+
}
|
|
374
|
+
function polyline(points, cls) {
|
|
375
|
+
return `<polyline class="${cls}" points="${points.map(([x, y]) => `${r2(x)},${r2(y)}`).join(" ")}"/>`;
|
|
376
|
+
}
|
|
377
|
+
function text(x, y, value, o = {}) {
|
|
378
|
+
const anchor = o.anchor && o.anchor !== "start" ? ` text-anchor="${o.anchor}"` : "";
|
|
379
|
+
const weight = o.weight === 600 ? ` font-weight="600"` : "";
|
|
380
|
+
return `<text class="${o.cls ?? "tgc-ink"}" x="${r2(x)}" y="${r2(y)}" font-size="${o.size ?? 12}"${anchor}${weight}>${esc(value)}</text>`;
|
|
381
|
+
}
|
|
382
|
+
/** A group whose `<title>` is the hover text for everything inside it. */
|
|
383
|
+
function titled(title, inner) {
|
|
384
|
+
return `<g><title>${esc(title)}</title>${inner}</g>`;
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/table.ts
|
|
388
|
+
/** A plain HTML table: the accessible form of a figure and its phone view. */
|
|
389
|
+
function table(caption, columns, rows) {
|
|
390
|
+
const head = columns.map((c) => `<th scope="col"${c.numeric ? " class=\"tgc-n\"" : ""}>${esc(c.label)}</th>`).join("");
|
|
391
|
+
const body = rows.map((row) => `<tr>${row.map((cell, i) => i === 0 ? `<th scope="row">${esc(cell)}</th>` : `<td${columns[i]?.numeric ? " class=\"tgc-n\"" : ""}>${esc(cell)}</td>`).join("")}</tr>`).join("");
|
|
392
|
+
return `<table class="tgc-table"><caption>${esc(caption)}</caption><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/format.ts
|
|
396
|
+
const TIERS_BEST_FIRST = [
|
|
397
|
+
"S",
|
|
398
|
+
"A",
|
|
399
|
+
"B",
|
|
400
|
+
"C",
|
|
401
|
+
"F"
|
|
402
|
+
];
|
|
403
|
+
const LADDER = [
|
|
404
|
+
"F",
|
|
405
|
+
"C",
|
|
406
|
+
"B",
|
|
407
|
+
"A",
|
|
408
|
+
"S"
|
|
409
|
+
];
|
|
410
|
+
function tierAtLeast(tier, floor) {
|
|
411
|
+
return LADDER.indexOf(tier) >= LADDER.indexOf(floor);
|
|
412
|
+
}
|
|
413
|
+
function pct(value) {
|
|
414
|
+
return `${Math.round(value * 100)}%`;
|
|
415
|
+
}
|
|
416
|
+
/** A difference in points: "+25 pts". */
|
|
417
|
+
function pts(value) {
|
|
418
|
+
const n = Math.round(value * 100);
|
|
419
|
+
return `${n > 0 ? "+" : n < 0 ? "−" : ""}${Math.abs(n)} pts`;
|
|
420
|
+
}
|
|
421
|
+
function usd(value) {
|
|
422
|
+
if (value >= 100) return `$${Math.round(value).toLocaleString("en-US")}`;
|
|
423
|
+
if (value >= 1) return `$${value.toFixed(2)}`;
|
|
424
|
+
if (value >= .01) return `$${value.toFixed(3)}`;
|
|
425
|
+
return `$${value.toFixed(4)}`;
|
|
426
|
+
}
|
|
427
|
+
/** Axis ticks: "$30", "$2.5", "$0.05". */
|
|
428
|
+
function usdTick(value) {
|
|
429
|
+
return `$${Number(value.toPrecision(2)).toLocaleString("en-US", { maximumFractionDigits: 4 })}`;
|
|
430
|
+
}
|
|
431
|
+
function duration(ms) {
|
|
432
|
+
const s = ms / 1e3;
|
|
433
|
+
if (s < 60) return `${Math.round(s)} s`;
|
|
434
|
+
if (s < 3600) return `${(s / 60).toFixed(1)} min`;
|
|
435
|
+
return `${(s / 3600).toFixed(1)} h`;
|
|
436
|
+
}
|
|
437
|
+
function kn(k, n) {
|
|
438
|
+
return `${k}/${n}`;
|
|
439
|
+
}
|
|
440
|
+
const METHOD_NAME = {
|
|
441
|
+
"task-cluster-bootstrap": "task-clustered bootstrap",
|
|
442
|
+
"mcnemar-exact": "exact McNemar",
|
|
443
|
+
wilson: "Wilson score"
|
|
444
|
+
};
|
|
445
|
+
/** "95% Wilson score" */
|
|
446
|
+
function intervalName(interval) {
|
|
447
|
+
return `${Math.round((interval.level ?? 0) * 100)}% ${interval.method ? METHOD_NAME[interval.method] : ""}`.trim();
|
|
448
|
+
}
|
|
449
|
+
/** Why an interval cannot be drawn, or null when it can. */
|
|
450
|
+
function intervalGap(interval) {
|
|
451
|
+
if (!interval) return "no interval";
|
|
452
|
+
const missing = [interval.level === null ? "level" : "", interval.method === null ? "method" : ""].filter(Boolean);
|
|
453
|
+
if (missing.length) return `the interval names no ${missing.join(" or ")}`;
|
|
454
|
+
if (!(interval.method in METHOD_NAME)) return "the interval method is unknown";
|
|
455
|
+
if (!Number.isFinite(interval.level) || interval.level <= 0 || interval.level >= 1) return "the interval level is outside 0–1";
|
|
456
|
+
if (!Number.isFinite(interval.lower) || !Number.isFinite(interval.upper)) return "the interval bounds are not finite";
|
|
457
|
+
if (!(interval.lower <= interval.upper)) return "the interval bounds are out of order";
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
/** Whether a rate bar may be drawn for this estimate. */
|
|
461
|
+
function drawable(estimate) {
|
|
462
|
+
return estimate === "descriptive" || estimate === "bootstrap";
|
|
463
|
+
}
|
|
464
|
+
function estimateNote(estimate, units) {
|
|
465
|
+
if (estimate === "none") return units === 1 ? "1 unit" : `${units} units`;
|
|
466
|
+
if (estimate === "insufficient") return "too few units";
|
|
467
|
+
return "";
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* The positive cost per pass this figure can print or place, or null.
|
|
471
|
+
* A zero value needs a separate verified-free representation; this field alone
|
|
472
|
+
* cannot distinguish a free run from missing spend, so it prints "unknown".
|
|
473
|
+
*/
|
|
474
|
+
function perPass(cost) {
|
|
475
|
+
return cost.basis !== "unknown" && cost.perSolvedUsd !== null && Number.isFinite(cost.perSolvedUsd) && cost.perSolvedUsd > 0 ? cost.perSolvedUsd : null;
|
|
476
|
+
}
|
|
477
|
+
/** "$23.76", "$23.76 est.", "unknown", "no pass" */
|
|
478
|
+
function costText(cost, solved) {
|
|
479
|
+
if (solved === 0) return "no pass";
|
|
480
|
+
const value = perPass(cost);
|
|
481
|
+
if (value === null) return "unknown";
|
|
482
|
+
return cost.basis === "receipts" ? usd(value) : `${usd(value)} est.`;
|
|
483
|
+
}
|
|
484
|
+
/** Wilson can describe an eligible rate; clustered bootstrap needs the producer's bootstrap class. */
|
|
485
|
+
function rateIntervalGap(row) {
|
|
486
|
+
if (!drawable(row.estimate)) return `${row.estimate} estimate`;
|
|
487
|
+
if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0–1";
|
|
488
|
+
const gap = intervalGap(row.interval);
|
|
489
|
+
if (gap) return gap;
|
|
490
|
+
if (row.interval.method === "mcnemar-exact") return "exact McNemar applies to paired comparisons, not rates";
|
|
491
|
+
if (row.estimate === "descriptive" && row.interval.method !== "wilson") return `descriptive estimate does not support ${intervalName(row.interval)}`;
|
|
492
|
+
if (row.interval.lower < 0 || row.interval.upper > 1) return "the rate interval is outside 0–1";
|
|
493
|
+
if (row.rate < row.interval.lower - 1e-6 || row.rate > row.interval.upper + 1e-6) return "the rate falls outside its interval";
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
/** Check the source arithmetic before using a rate in a finding or a mark. */
|
|
497
|
+
function rateDataGap(row) {
|
|
498
|
+
if (!Number.isInteger(row.attempts) || row.attempts < 0 || !Number.isInteger(row.solved) || row.solved < 0 || row.solved > row.attempts) return "passed and attempt counts are invalid";
|
|
499
|
+
if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0–1";
|
|
500
|
+
if (row.attempts > 0 && Math.abs(row.rate - row.solved / row.attempts) > 1e-6) return "rate disagrees with passed/attempts";
|
|
501
|
+
if (row.attempts === 0 && row.rate !== 0) return "rate has no attempts";
|
|
502
|
+
if (drawable(row.estimate) && row.attempts === 0) return "drawable estimate has no attempts";
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* The order rows are drawn in. Ranked rows keep their rank. Otherwise rows
|
|
507
|
+
* with enough units to draw a rate come first, highest rate first; rows with
|
|
508
|
+
* too few units follow, most units first, so a 1/1 row never heads the list.
|
|
509
|
+
*/
|
|
510
|
+
function rateOrder(rows) {
|
|
511
|
+
if (rows.length > 0 && rows.every((row) => row.rank !== null && row.estimate === "bootstrap")) return [...rows].sort((a, b) => a.rank - b.rank);
|
|
512
|
+
const drawn = rows.filter((row) => drawable(row.estimate)).sort((a, b) => b.rate - a.rate);
|
|
513
|
+
const counted = rows.filter((row) => !drawable(row.estimate)).sort((a, b) => b.attempts - a.attempts);
|
|
514
|
+
return [...drawn, ...counted];
|
|
515
|
+
}
|
|
516
|
+
/** "2A 1F" */
|
|
517
|
+
function tierSummary(tiers, unmeasured = 0, flagged = 0) {
|
|
518
|
+
const parts = tiers ? TIERS_BEST_FIRST.filter((t) => tiers[t] > 0).map((t) => `${tiers[t]}${t}`) : [];
|
|
519
|
+
if (flagged) parts.push(`${flagged} flagged`);
|
|
520
|
+
if (unmeasured) parts.push(`${unmeasured}U`);
|
|
521
|
+
return parts.join(" ");
|
|
522
|
+
}
|
|
523
|
+
function tierTotal(tiers) {
|
|
524
|
+
return tiers ? LADDER.reduce((sum, t) => sum + tiers[t], 0) : 0;
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/types.ts
|
|
528
|
+
function isRefusal(value) {
|
|
529
|
+
return "refused" in value;
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/events.ts
|
|
533
|
+
/**
|
|
534
|
+
* Recorded events and spans drawn over a continuous x axis: vertical marks
|
|
535
|
+
* (first pass, deadline, death, release, steer, ruling, wake) and shaded bands
|
|
536
|
+
* (time after the first pass, idle time, no data, a system-wide wall). The
|
|
537
|
+
* producer records every position; the library never places an event.
|
|
538
|
+
*/
|
|
539
|
+
const MARK_KINDS = [
|
|
540
|
+
"pass",
|
|
541
|
+
"deadline",
|
|
542
|
+
"died",
|
|
543
|
+
"event",
|
|
544
|
+
"release",
|
|
545
|
+
"steer",
|
|
546
|
+
"ruling",
|
|
547
|
+
"wake"
|
|
548
|
+
];
|
|
549
|
+
const MARK_VERDICTS = [
|
|
550
|
+
"pass",
|
|
551
|
+
"fail",
|
|
552
|
+
"unknown"
|
|
553
|
+
];
|
|
554
|
+
const BAND_KINDS = [
|
|
555
|
+
"after-pass",
|
|
556
|
+
"idle",
|
|
557
|
+
"missing",
|
|
558
|
+
"blocked"
|
|
559
|
+
];
|
|
560
|
+
/** The head shape of each kind that has one, shared with the React marks. */
|
|
561
|
+
const MARK_HEADS = {
|
|
562
|
+
release: "diamond",
|
|
563
|
+
steer: "triangle",
|
|
564
|
+
ruling: "square",
|
|
565
|
+
wake: "circle"
|
|
566
|
+
};
|
|
567
|
+
/** Why the marks or bands cannot be drawn on `domain` (null when they can). Omit `domain` to check shape only. */
|
|
568
|
+
function eventsGap(marks = [], bands = [], domain) {
|
|
569
|
+
const inside = (value) => !domain || value >= domain[0] && value <= domain[1];
|
|
570
|
+
for (const mark of marks) {
|
|
571
|
+
if (!mark || typeof mark.label !== "string" || !mark.label.trim()) return "Every mark needs a label.";
|
|
572
|
+
if (!MARK_KINDS.includes(mark.kind)) return `${mark.label}: unknown mark kind.`;
|
|
573
|
+
if (mark.verdict !== void 0 && !MARK_VERDICTS.includes(mark.verdict)) return `${mark.label}: a verdict is pass, fail or unknown.`;
|
|
574
|
+
if (mark.source !== void 0 && (typeof mark.source !== "string" || !mark.source.trim())) return `${mark.label}: a source needs a name.`;
|
|
575
|
+
if (!Number.isFinite(mark.at) || !inside(mark.at)) return `${mark.label}: mark position must be finite and inside the axis domain.`;
|
|
576
|
+
}
|
|
577
|
+
for (const band of bands) {
|
|
578
|
+
if (!band || typeof band.label !== "string" || !band.label.trim()) return "Every band needs a label.";
|
|
579
|
+
if (!BAND_KINDS.includes(band.kind)) return `${band.label}: unknown band kind.`;
|
|
580
|
+
if (!Number.isFinite(band.from) || !Number.isFinite(band.to) || band.from >= band.to || !inside(band.from) || !inside(band.to)) return `${band.label}: a band needs finite, increasing bounds inside the axis domain.`;
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
/** A mark's label with its source, as drawn and listed. */
|
|
585
|
+
function markName(mark) {
|
|
586
|
+
return mark.source ? `${mark.label} · ${mark.source}` : mark.label;
|
|
587
|
+
}
|
|
588
|
+
/** The kind cell of the events table: the kind, then the verdict and the lane when recorded. */
|
|
589
|
+
function markKindLabel(mark) {
|
|
590
|
+
return [
|
|
591
|
+
mark.kind,
|
|
592
|
+
mark.verdict,
|
|
593
|
+
mark.lane && `lane ${mark.lane}`
|
|
594
|
+
].filter(Boolean).join(" · ");
|
|
595
|
+
}
|
|
596
|
+
/** Bands sit under the data; a missing band is hatched over a neutral wash. A band is clipped to `clip` when given. */
|
|
597
|
+
function drawBands(bands, px, top, bottom, format, clip) {
|
|
598
|
+
return bands.map((band) => {
|
|
599
|
+
const from = clip ? Math.max(band.from, clip[0]) : band.from, to = clip ? Math.min(band.to, clip[1]) : band.to;
|
|
600
|
+
if (to <= from) return "";
|
|
601
|
+
const x = px(from), w = px(to) - x, cls = `tgc-band tgc-band-${band.kind}`;
|
|
602
|
+
const mark = band.kind === "missing" ? hatch(x, top, w, bottom - top, cls) : rect(x, top, w, bottom - top, cls) + (band.kind === "blocked" ? line(x, top, x + w, top, "tgc-band-wall") : "");
|
|
603
|
+
return titled(`${band.label}: ${format(band.from)} to ${format(band.to)}`, `<g data-band="${band.kind}">${mark}</g>`);
|
|
604
|
+
}).join("");
|
|
605
|
+
}
|
|
606
|
+
/** Marks of one kind and verdict merge when under 3 px apart, or under 12 px apart with the same label (a re-checked tag). */
|
|
607
|
+
function groupMarks(marks, px) {
|
|
608
|
+
const groups = [];
|
|
609
|
+
for (const mark of [...marks].sort((a, b) => a.at - b.at)) {
|
|
610
|
+
const x = px(mark.at), last = groups.at(-1), lead = last?.marks[0];
|
|
611
|
+
const near = last && px(last.marks.at(-1).at);
|
|
612
|
+
if (last && lead.kind === mark.kind && lead.verdict === mark.verdict && (x - near < 3 || x - near < 12 && markName(lead) === markName(mark))) last.marks.push(mark);
|
|
613
|
+
else groups.push({
|
|
614
|
+
x,
|
|
615
|
+
marks: [mark]
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return groups;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Places labels in up to `rows` rows without overlap, measured from their text. Edge labels go first so an event
|
|
622
|
+
* past the window is always named; a mark label that fits no row is left to its rule's hover and the table.
|
|
623
|
+
*/
|
|
624
|
+
function layoutLabels(groups, left, right, o) {
|
|
625
|
+
const rows = o.rows ?? 3, maxW = Math.max(48, Math.min(200, (right - left) * .42)), gap = 6;
|
|
626
|
+
const taken = Array.from({ length: rows }, () => []);
|
|
627
|
+
const fits = (row, a, b) => taken[row].every(([p, q]) => b + gap <= p || a >= q + gap);
|
|
628
|
+
const place = (value, start, end, prefer, flexible) => {
|
|
629
|
+
const label = wrap(value, maxW, 11, "sans", 1).lines[0], w = textWidth(label, 11);
|
|
630
|
+
for (const anchor of flexible ? [prefer, prefer === "start" ? "end" : "start"] : [prefer]) {
|
|
631
|
+
const [a, b] = anchor === "start" ? [start, start + w] : [end - w, end];
|
|
632
|
+
if (a < left - 4 || b > right + 4) continue;
|
|
633
|
+
for (let row = 0; row < rows; row++) if (fits(row, a, b)) {
|
|
634
|
+
taken[row].push([a, b]);
|
|
635
|
+
return {
|
|
636
|
+
row,
|
|
637
|
+
x: anchor === "start" ? start : end,
|
|
638
|
+
anchor,
|
|
639
|
+
text: label
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
};
|
|
645
|
+
const [w0, w1] = o.window ?? [-Infinity, Infinity];
|
|
646
|
+
const edges = [...(o.after ?? []).map((mark) => place(`${markName(mark)} +${duration(mark.at - w1)} →`, right, right, "end", false)), ...(o.before ?? []).map((mark) => place(`← ${markName(mark)} −${duration(w0 - mark.at)}`, left, left, "start", false))].filter((p) => p !== null);
|
|
647
|
+
const placed = /* @__PURE__ */ new Map();
|
|
648
|
+
for (const group of groups) {
|
|
649
|
+
const flip = group.x > left + (right - left) * .62;
|
|
650
|
+
const spot = place(markName(group.marks[0]) + (group.marks.length > 1 ? ` (+${group.marks.length - 1})` : ""), group.x + 4, group.x - 4, flip ? "end" : "start", true);
|
|
651
|
+
if (spot) placed.set(group, spot);
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
placed,
|
|
655
|
+
edges,
|
|
656
|
+
rows: Math.max(0, ...[...placed.values(), ...edges].map((p) => p.row + 1))
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/** How many label rows `drawMarks` uses for these marks, so a figure can reserve them above its data. */
|
|
660
|
+
function markLabelRows(marks, px, left, right, o = {}) {
|
|
661
|
+
return layoutLabels(groupMarks(marks, px), left, right, o).rows;
|
|
662
|
+
}
|
|
663
|
+
/** The head shape of a release, steer, ruling or wake, centred on (x, y). Empty for the plain rule kinds. */
|
|
664
|
+
function markHead(kind, x, y, r = 3.5) {
|
|
665
|
+
const shape = MARK_HEADS[kind];
|
|
666
|
+
return shape ? `<path class="tgc-mark-head" d="${markerPath(shape, x, y, r)}"/>` : "";
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Marks and their labels. Labels go in rows from `labelTop`, placed by measured width so none overlap, and are drawn
|
|
670
|
+
* over every rule; marks of one kind and verdict at one place share a rule and a label. Rules run from `labelTop` to
|
|
671
|
+
* `bottom`, except that release, steer and wake stems stop at their head, just below `top`.
|
|
672
|
+
*/
|
|
673
|
+
function drawMarks(marks, px, top, bottom, left, right, format, labelTop = top, o = {}) {
|
|
674
|
+
const groups = groupMarks(marks, px);
|
|
675
|
+
const { placed, edges } = layoutLabels(groups, left, right, o);
|
|
676
|
+
const rowY = (row) => labelTop + 12 + row * 14;
|
|
677
|
+
let out = groups.map((group) => {
|
|
678
|
+
const { x, marks: members } = group, mark = members[0];
|
|
679
|
+
const stem = MARK_HEADS[mark.kind] !== void 0 && mark.kind !== "ruling";
|
|
680
|
+
let glyph = line(x, Math.min(top, labelTop), x, stem ? top + 4 : bottom, `tgc-mark tgc-mark-${mark.kind}`);
|
|
681
|
+
if (mark.kind === "died") glyph += line(x - 4, bottom - 8, x + 4, bottom, "tgc-mark tgc-mark-died") + line(x - 4, bottom, x + 4, bottom - 8, "tgc-mark tgc-mark-died");
|
|
682
|
+
glyph += markHead(mark.kind, x, top + 4);
|
|
683
|
+
const verdict = mark.verdict ? ` data-verdict="${mark.verdict}" class="tgc-verdict-${mark.verdict}"` : "";
|
|
684
|
+
return titled(members.map((m) => `${markName(m)}: ${format(m.at)}${m.verdict ? ` · ${m.verdict}` : ""}`).join("\n"), `<g data-mark="${mark.kind}"${members.length > 1 ? ` data-merged="${members.length}"` : ""}${verdict}>${glyph}</g>`);
|
|
685
|
+
}).join("");
|
|
686
|
+
for (const group of groups) {
|
|
687
|
+
const spot = placed.get(group);
|
|
688
|
+
if (spot) out += text(spot.x, rowY(spot.row), spot.text, {
|
|
689
|
+
size: 11,
|
|
690
|
+
anchor: spot.anchor,
|
|
691
|
+
cls: "tgc-mark-label"
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
for (const edge of edges) out += `<g data-edge="${edge.anchor === "end" ? "after" : "before"}">${text(edge.x, rowY(edge.row), edge.text, {
|
|
695
|
+
size: 11,
|
|
696
|
+
anchor: edge.anchor,
|
|
697
|
+
cls: "tgc-mark-label tgc-mark-edge"
|
|
698
|
+
})}</g>`;
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
/** The table form of marks and bands, in axis order. Empty when there are none. */
|
|
702
|
+
function eventsTable(marks, bands, axisLabel, format) {
|
|
703
|
+
if (!marks.length && !bands.length) return "";
|
|
704
|
+
const rows = [...marks.map((mark) => ({
|
|
705
|
+
at: mark.at,
|
|
706
|
+
cells: [
|
|
707
|
+
markName(mark),
|
|
708
|
+
markKindLabel(mark),
|
|
709
|
+
format(mark.at),
|
|
710
|
+
""
|
|
711
|
+
]
|
|
712
|
+
})), ...bands.map((band) => ({
|
|
713
|
+
at: band.from,
|
|
714
|
+
cells: [
|
|
715
|
+
band.label,
|
|
716
|
+
band.kind === "missing" ? "no data" : band.kind,
|
|
717
|
+
format(band.from),
|
|
718
|
+
format(band.to)
|
|
719
|
+
]
|
|
720
|
+
}))].sort((a, b) => a.at - b.at);
|
|
721
|
+
return table(`Recorded events by ${axisLabel}`, [
|
|
722
|
+
{ label: "Event" },
|
|
723
|
+
{ label: "Kind" },
|
|
724
|
+
{
|
|
725
|
+
label: `${axisLabel} from`,
|
|
726
|
+
numeric: true
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
label: `${axisLabel} to`,
|
|
730
|
+
numeric: true
|
|
731
|
+
}
|
|
732
|
+
], rows.map((row) => row.cells));
|
|
733
|
+
}
|
|
734
|
+
const EVENTS_READ = "Vertical rules mark recorded events (solid: pass, dashed: deadline, cross: died, dotted: other; a diamond is a release, a triangle a steer, a square a ruling and a circle a wake, coloured by verdict when one is recorded); tinted spans are recorded periods, a wall-topped span is a system-wide block, and hatched spans have no data.";
|
|
735
|
+
//#endregion
|
|
736
|
+
//#region src/timeline.ts
|
|
737
|
+
/**
|
|
738
|
+
* Lanes of recorded spans on one time axis, with recorded marks; an open span runs to the supplied `now`. Lanes can
|
|
739
|
+
* form a tree (`parent`), carry attempt ticks coloured by outcome with ok and failed counts, and fold by subtree.
|
|
740
|
+
*/
|
|
741
|
+
const TICK_OUTCOMES = [
|
|
742
|
+
"ok",
|
|
743
|
+
"quota",
|
|
744
|
+
"rate-limit",
|
|
745
|
+
"oom",
|
|
746
|
+
"refused",
|
|
747
|
+
"aborted",
|
|
748
|
+
"other"
|
|
749
|
+
];
|
|
750
|
+
const utcClock = (ms) => new Date(ms).toISOString().slice(11, 16);
|
|
751
|
+
const MIN = 6e4;
|
|
752
|
+
const DAY = 864e5;
|
|
753
|
+
const MONTHS = [
|
|
754
|
+
"Jan",
|
|
755
|
+
"Feb",
|
|
756
|
+
"Mar",
|
|
757
|
+
"Apr",
|
|
758
|
+
"May",
|
|
759
|
+
"Jun",
|
|
760
|
+
"Jul",
|
|
761
|
+
"Aug",
|
|
762
|
+
"Sep",
|
|
763
|
+
"Oct",
|
|
764
|
+
"Nov",
|
|
765
|
+
"Dec"
|
|
766
|
+
];
|
|
767
|
+
/** Axis steps: the smallest whose labels fit without overlap is used. */
|
|
768
|
+
const STEPS = [
|
|
769
|
+
1,
|
|
770
|
+
2,
|
|
771
|
+
5,
|
|
772
|
+
10,
|
|
773
|
+
15,
|
|
774
|
+
30,
|
|
775
|
+
60,
|
|
776
|
+
120,
|
|
777
|
+
180,
|
|
778
|
+
360,
|
|
779
|
+
720,
|
|
780
|
+
1440,
|
|
781
|
+
2880,
|
|
782
|
+
10080
|
|
783
|
+
].map((m) => m * MIN);
|
|
784
|
+
function timeline(lanes, o) {
|
|
785
|
+
const id = o.id ?? "timeline";
|
|
786
|
+
if (!lanes.length) return {
|
|
787
|
+
id,
|
|
788
|
+
refused: "No lanes recorded."
|
|
789
|
+
};
|
|
790
|
+
if (!o.measure?.trim()) return {
|
|
791
|
+
id,
|
|
792
|
+
refused: "A timeline needs a measure."
|
|
793
|
+
};
|
|
794
|
+
if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length) return {
|
|
795
|
+
id,
|
|
796
|
+
refused: "Duplicate lane ids."
|
|
797
|
+
};
|
|
798
|
+
if (o.now !== void 0 && !Number.isFinite(o.now)) return {
|
|
799
|
+
id,
|
|
800
|
+
refused: "now must be a finite time."
|
|
801
|
+
};
|
|
802
|
+
const byId = new Map(lanes.map((lane) => [lane.id, lane]));
|
|
803
|
+
for (const lane of lanes) {
|
|
804
|
+
const seen = /* @__PURE__ */ new Set();
|
|
805
|
+
for (let at = lane; at.parent !== void 0; at = byId.get(at.parent)) {
|
|
806
|
+
if (!byId.has(at.parent)) return {
|
|
807
|
+
id,
|
|
808
|
+
refused: `${at.label}: parent ${at.parent} is not a lane.`
|
|
809
|
+
};
|
|
810
|
+
if (seen.has(at.id)) return {
|
|
811
|
+
id,
|
|
812
|
+
refused: `${lane.label}: its parent chain loops back to ${at.label}.`
|
|
813
|
+
};
|
|
814
|
+
seen.add(at.id);
|
|
815
|
+
}
|
|
816
|
+
if (lane.depth !== void 0 && (!Number.isInteger(lane.depth) || lane.depth < 0)) return {
|
|
817
|
+
id,
|
|
818
|
+
refused: `${lane.label}: depth must be a whole number of at least 0.`
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
const depthOf = /* @__PURE__ */ new Map();
|
|
822
|
+
const depth = (lane) => depthOf.get(lane.id) ?? depthOf.set(lane.id, lane.parent === void 0 ? lane.depth ?? 0 : depth(byId.get(lane.parent)) + 1).get(lane.id);
|
|
823
|
+
for (const lane of lanes) {
|
|
824
|
+
const at = depth(lane);
|
|
825
|
+
if (lane.depth !== void 0 && lane.depth !== at) return {
|
|
826
|
+
id,
|
|
827
|
+
refused: `${lane.label}: depth ${lane.depth} is not one below its parent's ${at - 1}.`
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
const spans = lanes.flatMap((lane) => lane.spans.map((span) => ({
|
|
831
|
+
lane,
|
|
832
|
+
span
|
|
833
|
+
})));
|
|
834
|
+
for (const { lane, span } of spans) {
|
|
835
|
+
if (typeof span.state !== "string" || !span.state.trim()) return {
|
|
836
|
+
id,
|
|
837
|
+
refused: `${lane.label}: every span needs a state.`
|
|
838
|
+
};
|
|
839
|
+
if (!Number.isFinite(span.start) || span.end !== null && (!Number.isFinite(span.end) || span.end < span.start)) return {
|
|
840
|
+
id,
|
|
841
|
+
refused: `${lane.label}: span times must be finite and end at or after their start.`
|
|
842
|
+
};
|
|
843
|
+
if (span.end === null && o.now === void 0) return {
|
|
844
|
+
id,
|
|
845
|
+
refused: `${lane.label}: an open span needs now.`
|
|
846
|
+
};
|
|
847
|
+
if (span.end === null && o.now < span.start) return {
|
|
848
|
+
id,
|
|
849
|
+
refused: `${lane.label}: an open span starts after now.`
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
for (const lane of lanes) for (const tick of lane.ticks ?? []) {
|
|
853
|
+
if (!tick || !Number.isFinite(tick.at)) return {
|
|
854
|
+
id,
|
|
855
|
+
refused: `${lane.label}: every tick needs a finite time.`
|
|
856
|
+
};
|
|
857
|
+
if (!TICK_OUTCOMES.includes(tick.outcome)) return {
|
|
858
|
+
id,
|
|
859
|
+
refused: `${lane.label}: tick outcome ${String(tick.outcome)} is not one of ${TICK_OUTCOMES.join(", ")}.`
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
const shape = eventsGap(o.marks, o.bands);
|
|
863
|
+
if (shape) return {
|
|
864
|
+
id,
|
|
865
|
+
refused: shape
|
|
866
|
+
};
|
|
867
|
+
const marks = o.marks ?? [], bands = o.bands ?? [];
|
|
868
|
+
const stray = marks.find((mark) => mark.lane !== void 0 && !byId.has(mark.lane));
|
|
869
|
+
if (stray) return {
|
|
870
|
+
id,
|
|
871
|
+
refused: `${stray.label}: lane ${stray.lane} is not a lane.`
|
|
872
|
+
};
|
|
873
|
+
const ends = (span) => span.end ?? o.now;
|
|
874
|
+
const ticks = lanes.flatMap((lane) => lane.ticks ?? []);
|
|
875
|
+
const data = [
|
|
876
|
+
...spans.flatMap(({ span }) => [span.start, ends(span)]),
|
|
877
|
+
...ticks.map((tick) => tick.at),
|
|
878
|
+
...o.now === void 0 ? [] : [o.now]
|
|
879
|
+
];
|
|
880
|
+
const times = data.length ? data : [...marks.map((m) => m.at), ...bands.flatMap((b) => [b.from, b.to])];
|
|
881
|
+
if (!times.length) return {
|
|
882
|
+
id,
|
|
883
|
+
refused: "No spans, ticks, marks or bands recorded."
|
|
884
|
+
};
|
|
885
|
+
const t0 = o.start ?? Math.min(...times), t1 = o.end ?? Math.max(...times);
|
|
886
|
+
if (!Number.isFinite(t0) || !Number.isFinite(t1) || t1 <= t0) return {
|
|
887
|
+
id,
|
|
888
|
+
refused: "The window needs two finite, increasing times."
|
|
889
|
+
};
|
|
890
|
+
if (data.some((t) => t < t0 || t > t1)) return {
|
|
891
|
+
id,
|
|
892
|
+
refused: "A span, tick or now falls outside the recorded window."
|
|
893
|
+
};
|
|
894
|
+
const fmt = o.formatTime ?? utcClock;
|
|
895
|
+
const axisLabel = o.formatTime ?? ((ms) => ms % DAY === 0 ? `${MONTHS[new Date(ms).getUTCMonth()]} ${new Date(ms).getUTCDate()}` : utcClock(ms));
|
|
896
|
+
const hatched = new Set(o.hatch ?? ["missing", "untraced"]), muted = new Set(o.muted ?? ["idle"]);
|
|
897
|
+
const foldable = o.collapsed !== void 0, folded = new Set(o.collapsed ?? []);
|
|
898
|
+
const kids = /* @__PURE__ */ new Map();
|
|
899
|
+
for (const lane of lanes) kids.set(lane.parent, [...kids.get(lane.parent) ?? [], lane]);
|
|
900
|
+
const subtree = (lane) => (kids.get(lane.id) ?? []).flatMap((kid) => [kid, ...subtree(kid)]);
|
|
901
|
+
const rows = [];
|
|
902
|
+
const visit = (lane) => {
|
|
903
|
+
const below = subtree(lane), fold = folded.has(lane.id) && below.length > 0;
|
|
904
|
+
const own = lane.ticks, all = fold && (own || below.some((kid) => kid.ticks)) ? [...own ?? [], ...below.flatMap((kid) => kid.ticks ?? [])] : own;
|
|
905
|
+
rows.push({
|
|
906
|
+
lane,
|
|
907
|
+
depth: depthOf.get(lane.id),
|
|
908
|
+
children: (kids.get(lane.id) ?? []).length,
|
|
909
|
+
hidden: fold ? below.length : 0,
|
|
910
|
+
folded: fold,
|
|
911
|
+
ticks: all
|
|
912
|
+
});
|
|
913
|
+
if (!fold) (kids.get(lane.id) ?? []).forEach(visit);
|
|
914
|
+
};
|
|
915
|
+
(kids.get(void 0) ?? []).forEach(visit);
|
|
916
|
+
const rowOf = new Map(rows.map((row, i) => [row.lane.id, i]));
|
|
917
|
+
const shownAt = (laneId) => {
|
|
918
|
+
for (let at = byId.get(laneId); at; at = at.parent === void 0 ? void 0 : byId.get(at.parent)) if (rowOf.has(at.id)) return rowOf.get(at.id);
|
|
919
|
+
return 0;
|
|
920
|
+
};
|
|
921
|
+
const inWindow = (mark) => mark.at >= t0 && mark.at <= t1;
|
|
922
|
+
const wide = marks.filter((mark) => mark.lane === void 0 && inWindow(mark));
|
|
923
|
+
const onLane = marks.filter((mark) => mark.lane !== void 0 && inWindow(mark));
|
|
924
|
+
const before = marks.filter((mark) => mark.at < t0), after = marks.filter((mark) => mark.at > t1);
|
|
925
|
+
const open = spans.filter(({ span }) => span.end === null).length;
|
|
926
|
+
const noData = spans.filter(({ span }) => hatched.has(span.state));
|
|
927
|
+
const noDataMs = noData.reduce((sum, { span }) => sum + ends(span) - span.start, 0);
|
|
928
|
+
const counts = (list) => {
|
|
929
|
+
const by = /* @__PURE__ */ new Map();
|
|
930
|
+
for (const tick of list) by.set(tick.outcome, (by.get(tick.outcome) ?? 0) + 1);
|
|
931
|
+
const ok = by.get("ok") ?? 0, failed = list.length - ok;
|
|
932
|
+
const why = [...by].filter(([outcome]) => outcome !== "ok").sort((a, b) => b[1] - a[1]).map(([outcome, n]) => `${n} ${outcome}`).join(", ");
|
|
933
|
+
return {
|
|
934
|
+
ok,
|
|
935
|
+
failed,
|
|
936
|
+
why,
|
|
937
|
+
text: `${ok}✓ ${failed}✗`,
|
|
938
|
+
sentence: `${ok} ok, ${failed} failed${why ? ` (${why})` : ""}`
|
|
939
|
+
};
|
|
940
|
+
};
|
|
941
|
+
const detail = (lane) => (lane.detail ?? []).filter((fact) => typeof fact === "string" && fact.trim());
|
|
942
|
+
const spanLabel = (lane, span) => [`${lane.label}: ${span.state} ${fmt(span.start)} to ${span.end === null ? `now (${fmt(o.now)}, open)` : fmt(span.end)}, ${duration(ends(span) - span.start)}${span.note ? ` · ${span.note}` : ""}`, ...detail(lane)].join("\n");
|
|
943
|
+
const hasTicks = rows.some((row) => row.ticks !== void 0);
|
|
944
|
+
const countWidth = hasTicks ? Math.max(...rows.filter((row) => row.ticks).map((row) => textWidth(counts(row.ticks).text, 11, "mono"))) : 0;
|
|
945
|
+
const draw = (width) => {
|
|
946
|
+
const phone = width === 358, indent = phone ? 10 : 12;
|
|
947
|
+
const caretX = (depth) => 4 + depth * indent;
|
|
948
|
+
const labelX = (depth) => caretX(depth) + 11;
|
|
949
|
+
const tail = (row) => row.hidden ? ` +${row.hidden}` : "";
|
|
950
|
+
const left = phone ? 12 : Math.max(120, Math.min(Math.round(width * .34), Math.ceil(Math.max(...rows.map((row) => labelX(row.depth) + textWidth(row.lane.label + tail(row), 12))) + 10)));
|
|
951
|
+
const right = phone ? width - 12 : width - 16 - (countWidth ? countWidth + 10 : 0);
|
|
952
|
+
const px = (t) => left + (t - t0) / (t1 - t0) * (right - left);
|
|
953
|
+
const labelRows = markLabelRows(wide, px, left, right, {
|
|
954
|
+
before,
|
|
955
|
+
after,
|
|
956
|
+
window: [t0, t1]
|
|
957
|
+
});
|
|
958
|
+
const top = 8 + (labelRows ? labelRows * 14 + 6 : 0);
|
|
959
|
+
const rowH = phone ? 40 : 24, bottom = top + rows.length * rowH;
|
|
960
|
+
const barTop = (y) => y + (phone ? 19 : 5), barH = 14;
|
|
961
|
+
let body = drawBands(bands, px, top, bottom, fmt, [t0, t1]);
|
|
962
|
+
for (const t of axisTicks(t0, t1, px, left, right, axisLabel)) body += line(px(t), top, px(t), bottom, "tgc-gridline") + text(px(t), bottom + 16, axisLabel(t), {
|
|
963
|
+
size: 11,
|
|
964
|
+
cls: "tgc-ink-muted tgc-num",
|
|
965
|
+
anchor: "middle"
|
|
966
|
+
});
|
|
967
|
+
body += drawMarks(wide, px, top - 8, bottom, left, right, fmt, 4, {
|
|
968
|
+
before,
|
|
969
|
+
after,
|
|
970
|
+
window: [t0, t1]
|
|
971
|
+
});
|
|
972
|
+
rows.forEach((row, i) => {
|
|
973
|
+
const { lane } = row, y = top + i * rowH, mid = y + (phone ? 9 : 12), baseline = y + (phone ? 13 : 16);
|
|
974
|
+
const parentRow = lane.parent === void 0 ? void 0 : rowOf.get(lane.parent);
|
|
975
|
+
if (parentRow !== void 0) {
|
|
976
|
+
const x = caretX(row.depth - 1) + 3, from = phone ? y + 2 : top + parentRow * rowH + 19;
|
|
977
|
+
body += `<path class="tgc-tree" d="M${x} ${from}V${mid}H${labelX(row.depth) - (row.children ? 13 : 3)}"/>`;
|
|
978
|
+
}
|
|
979
|
+
const countText = row.ticks ? counts(row.ticks) : void 0;
|
|
980
|
+
const room = (phone ? width - 12 - (countText ? textWidth(countText.text, 11, "mono") + 8 : 0) : left - 8) - labelX(row.depth) - textWidth(tail(row), 12);
|
|
981
|
+
const label = wrap(lane.label, Math.max(24, room), phone ? 11 : 12, "sans", 1).lines[0];
|
|
982
|
+
const name = `<text class="tgc-ink tgc-lane-label" x="${labelX(row.depth)}" y="${baseline}" font-size="${phone ? 11 : 12}">${esc(label)}${row.hidden ? `<tspan class="tgc-ink-muted">${esc(tail(row))}</tspan>` : ""}</text>`;
|
|
983
|
+
const caret = row.children ? `<path class="tgc-caret" d="${row.folded ? `M${caretX(row.depth)} ${mid - 4}l6 4l-6 4z` : `M${caretX(row.depth) - 1} ${mid - 3}h8l-4 6z`}"/>` : "";
|
|
984
|
+
const facts = [
|
|
985
|
+
lane.label,
|
|
986
|
+
...detail(lane),
|
|
987
|
+
...countText ? [`Attempts: ${countText.sentence}${row.hidden ? `, including ${row.hidden} folded ${plural(row.hidden, "lane")}` : ""}`] : []
|
|
988
|
+
].join("\n");
|
|
989
|
+
const toggle = foldable && row.children ? ` data-toggle="${esc(lane.id)}" class="tgc-lane-toggle" role="button" tabindex="0" aria-expanded="${!row.folded}" aria-label="${esc(`${row.folded ? "Unfold" : "Fold"} ${lane.label}`)}"` : "";
|
|
990
|
+
const hit = toggle ? rect(caretX(row.depth) - 3, y + 1, (phone ? width - 12 : left - 4) - caretX(row.depth) + 3, phone ? 16 : rowH - 2, "tgc-lane-hit", "rx=\"3\"") : "";
|
|
991
|
+
body += `<g data-lane="${esc(lane.id)}" data-depth="${row.depth}"${row.folded ? ` data-folded="${row.hidden}"` : ""}><g${toggle}><title>${esc(facts)}</title>${hit}${caret}${name}</g>`;
|
|
992
|
+
for (const span of lane.spans) {
|
|
993
|
+
const x = px(span.start), w = Math.max(1, px(ends(span)) - x);
|
|
994
|
+
const mark = hatched.has(span.state) ? hatch(x, barTop(y), w, barH, "tgc-span tgc-span-missing") : rect(x, barTop(y), w, barH, `tgc-span${muted.has(span.state) ? " tgc-span-muted" : ""}${span.end === null ? " tgc-span-open" : ""}`, "rx=\"2\"");
|
|
995
|
+
body += titled(spanLabel(lane, span), `<g data-state="${span.state.replace(/[^a-z0-9-]/gi, "")}"${span.end === null ? " data-open=\"true\"" : ""}>${mark}</g>`);
|
|
996
|
+
}
|
|
997
|
+
if (row.ticks?.length) body += drawTicks(row.ticks, lane.label, px, barTop(y), barH, fmt);
|
|
998
|
+
if (countText) {
|
|
999
|
+
const x = phone ? width - 12 : width - 16;
|
|
1000
|
+
body += titled(`${lane.label}: ${countText.sentence}${row.hidden ? ` across ${row.hidden + 1} lanes` : ""}`, `<text class="tgc-num tgc-count" x="${x}" y="${baseline}" font-size="11" text-anchor="end">${countText.ok}<tspan class="tgc-count-ok">✓</tspan> ${countText.failed}<tspan class="tgc-count-failed">✗</tspan></text>`);
|
|
1001
|
+
}
|
|
1002
|
+
body += "</g>";
|
|
1003
|
+
});
|
|
1004
|
+
for (const mark of onLane) {
|
|
1005
|
+
const y = top + shownAt(mark.lane) * rowH, cy = barTop(y) + barH / 2, x = px(mark.at);
|
|
1006
|
+
const glyph = markHead(mark.kind, x, cy, phone ? 3 : 3.5) || line(x, barTop(y) - 3, x, barTop(y) + barH + 3, `tgc-mark tgc-mark-${mark.kind}`);
|
|
1007
|
+
const verdict = mark.verdict ? ` data-verdict="${mark.verdict}" class="tgc-verdict-${mark.verdict}"` : "";
|
|
1008
|
+
body += titled(`${markName(mark)}: ${fmt(mark.at)}${mark.verdict ? ` · ${mark.verdict}` : ""}`, `<g data-mark="${mark.kind}" data-lane-mark="${esc(mark.lane)}"${verdict}>${glyph}</g>`);
|
|
1009
|
+
}
|
|
1010
|
+
if (o.now !== void 0) body += line(px(o.now), top, px(o.now), bottom, "tgc-now") + text(px(o.now), bottom + 30, `now ${fmt(o.now)}`, {
|
|
1011
|
+
size: 11,
|
|
1012
|
+
cls: "tgc-ink tgc-num",
|
|
1013
|
+
anchor: px(o.now) > (left + right) / 2 ? "end" : "start"
|
|
1014
|
+
});
|
|
1015
|
+
return svgRoot(width, bottom + (o.now === void 0 ? 22 : 36), body);
|
|
1016
|
+
};
|
|
1017
|
+
const all = counts(ticks);
|
|
1018
|
+
const tree = lanes.some((lane) => lane.parent !== void 0 || lane.depth);
|
|
1019
|
+
const folds = foldable && rows.some((row) => row.children);
|
|
1020
|
+
const laneTable = tree || ticks.length || lanes.some((lane) => lane.ticks || detail(lane).length) ? table(`${capitalize(o.measure)}: lanes`, [
|
|
1021
|
+
{ label: "Lane" },
|
|
1022
|
+
{ label: "Started by" },
|
|
1023
|
+
{
|
|
1024
|
+
label: "Depth",
|
|
1025
|
+
numeric: true
|
|
1026
|
+
},
|
|
1027
|
+
{
|
|
1028
|
+
label: "Attempts ok",
|
|
1029
|
+
numeric: true
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
label: "Attempts failed",
|
|
1033
|
+
numeric: true
|
|
1034
|
+
},
|
|
1035
|
+
{ label: "Failed by outcome" },
|
|
1036
|
+
{ label: "Detail" }
|
|
1037
|
+
], lanes.map((lane) => {
|
|
1038
|
+
const c = lane.ticks ? counts(lane.ticks) : void 0;
|
|
1039
|
+
return [
|
|
1040
|
+
lane.label,
|
|
1041
|
+
lane.parent === void 0 ? "" : byId.get(lane.parent).label,
|
|
1042
|
+
String(depthOf.get(lane.id)),
|
|
1043
|
+
c ? String(c.ok) : "not recorded",
|
|
1044
|
+
c ? String(c.failed) : "not recorded",
|
|
1045
|
+
c?.why ?? "",
|
|
1046
|
+
detail(lane).join(" · ")
|
|
1047
|
+
];
|
|
1048
|
+
})) : "";
|
|
1049
|
+
return {
|
|
1050
|
+
id,
|
|
1051
|
+
finding: `${capitalize(o.measure)} across ${lanes.length} ${plural(lanes.length, "lane")}, ${fmt(t0)} to ${fmt(t1)}.`,
|
|
1052
|
+
lede: `${spans.length} recorded ${plural(spans.length, "span")}; ${open} open${o.now === void 0 ? "" : ` at ${fmt(o.now)}`}; ${noData.length} without data (${duration(noDataMs)}).${ticks.length ? ` ${ticks.length} recorded ${plural(ticks.length, "attempt")}: ${all.sentence}.` : ""}`,
|
|
1053
|
+
read: [
|
|
1054
|
+
`Each lane shows its recorded spans on one time axis. Muted spans are ${[...muted].join(", ") || "none"}; hatched spans have no data; an open span runs to now.`,
|
|
1055
|
+
...tree ? [`An indented lane was started by the lane its connector leads to${folds ? "; a caret folds a lane's subtree, and a folded lane's ticks and counts include its subtree" : ""}.`] : [],
|
|
1056
|
+
...hasTicks ? ["Each tick is one recorded attempt: ok ticks rise above the bar and failed ticks drop below it in their outcome's colour; the counts at the right are ok (✓) and failed (✗)."] : [],
|
|
1057
|
+
...marks.length + bands.length ? [EVENTS_READ] : []
|
|
1058
|
+
].join(" "),
|
|
1059
|
+
svg: {
|
|
1060
|
+
wide: draw(720),
|
|
1061
|
+
narrow: draw(358),
|
|
1062
|
+
...folds ? { interactive: true } : {}
|
|
1063
|
+
},
|
|
1064
|
+
table: laneTable + table(`${capitalize(o.measure)} by lane`, [
|
|
1065
|
+
{ label: "Lane" },
|
|
1066
|
+
{ label: "State" },
|
|
1067
|
+
{
|
|
1068
|
+
label: "Start",
|
|
1069
|
+
numeric: true
|
|
1070
|
+
},
|
|
1071
|
+
{
|
|
1072
|
+
label: "End",
|
|
1073
|
+
numeric: true
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
label: "Duration",
|
|
1077
|
+
numeric: true
|
|
1078
|
+
},
|
|
1079
|
+
{ label: "Note" }
|
|
1080
|
+
], spans.map(({ lane, span }) => [
|
|
1081
|
+
lane.label,
|
|
1082
|
+
span.state,
|
|
1083
|
+
fmt(span.start),
|
|
1084
|
+
span.end === null ? "open" : fmt(span.end),
|
|
1085
|
+
duration(ends(span) - span.start),
|
|
1086
|
+
span.note ?? ""
|
|
1087
|
+
])) + eventsTable(marks, bands, "time", fmt),
|
|
1088
|
+
note: {
|
|
1089
|
+
method: `Recorded span start and end times as supplied by the producer; open spans are drawn to the supplied now. No span is inferred, merged or reordered.${ticks.length ? " Ticks are recorded attempts at their recorded times; ticks of one outcome within 2 px are drawn as one, with the count in its hover." : ""}`,
|
|
1090
|
+
n: `${spans.length} spans${ticks.length ? ` and ${ticks.length} attempts` : ""} in ${lanes.length} lanes.`,
|
|
1091
|
+
exclusions: [...noData.map(({ lane, span }) => `${lane.label}: ${span.state} ${fmt(span.start)} to ${span.end === null ? "now" : fmt(span.end)} has no data.`), ...[...before, ...after].map((mark) => `${markName(mark)} falls ${mark.at < t0 ? `${duration(t0 - mark.at)} before` : `${duration(mark.at - t1)} after`} the recorded window; it is named at the edge, not drawn.`)]
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
/** Ticks of one outcome within 2 px merge into one stroke whose hover gives the count and time range. */
|
|
1096
|
+
function drawTicks(ticks, label, px, barTop, barH, fmt) {
|
|
1097
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1098
|
+
for (const tick of [...ticks].sort((a, b) => a.at - b.at)) {
|
|
1099
|
+
const key = `${tick.outcome}|${Math.round(px(tick.at) / 2)}`;
|
|
1100
|
+
buckets.set(key, [...buckets.get(key) ?? [], tick]);
|
|
1101
|
+
}
|
|
1102
|
+
return `<g class="tgc-ticks">${[...buckets.values()].sort((a, b) => Number(a[0].outcome === "ok") - Number(b[0].outcome === "ok")).map((bucket) => {
|
|
1103
|
+
const first = bucket[0], last = bucket.at(-1), ok = first.outcome === "ok";
|
|
1104
|
+
const x = px(bucket.reduce((sum, tick) => sum + tick.at, 0) / bucket.length);
|
|
1105
|
+
const notes = /* @__PURE__ */ new Map();
|
|
1106
|
+
for (const tick of bucket) if (tick.note) notes.set(tick.note, (notes.get(tick.note) ?? 0) + 1);
|
|
1107
|
+
const note = [...notes].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
1108
|
+
return titled(`${label}: ${bucket.length > 1 ? `${bucket.length} × ` : ""}${first.outcome} ${fmt(first.at)}${bucket.length > 1 ? ` to ${fmt(last.at)}` : ""}${note ? ` · ${note.length > 160 ? `${note.slice(0, 159)}…` : note}` : ""}`, line(x, ok ? barTop - 4 : barTop + barH / 2, x, ok ? barTop + barH / 2 : barTop + barH + 4, `tgc-tick tgc-tick-${first.outcome}`));
|
|
1109
|
+
}).join("")}</g>`;
|
|
1110
|
+
}
|
|
1111
|
+
/** Clock-aligned ticks at the smallest step whose labels fit between `left` and `right` without overlapping. */
|
|
1112
|
+
function axisTicks(t0, t1, px, left, right, label) {
|
|
1113
|
+
const fit = (list) => {
|
|
1114
|
+
let end = -Infinity;
|
|
1115
|
+
for (const t of list) {
|
|
1116
|
+
const w = textWidth(label(t), 11, "mono"), a = px(t) - w / 2, b = px(t) + w / 2;
|
|
1117
|
+
if (a < left - 14 || b > right + 14 || a < end + 10) return false;
|
|
1118
|
+
end = b;
|
|
1119
|
+
}
|
|
1120
|
+
return true;
|
|
1121
|
+
};
|
|
1122
|
+
for (const step of STEPS) {
|
|
1123
|
+
const list = [];
|
|
1124
|
+
for (let t = Math.ceil(t0 / step) * step; t <= t1 && list.length <= 12; t += step) list.push(t);
|
|
1125
|
+
if (list.length >= 2 && list.length <= 12 && fit(list)) return list;
|
|
1126
|
+
}
|
|
1127
|
+
return fit([t0, t1]) ? [t0, t1] : [t0];
|
|
1128
|
+
}
|
|
1129
|
+
//#endregion
|
|
1130
|
+
export { usdTick as A, rect as B, rateDataGap as C, tierSummary as D, tierAtLeast as E, hatchPath as F, esc as G, text as H, hbar as I, plural as J, fitLines as K, line as L, MARKER_SHAPES as M, circle as N, tierTotal as O, hatch as P, markerPath as R, pts as S, rateOrder as T, titled as U, svgRoot as V, capitalize as W, textWidth as X, textBlock as Y, wrap as Z, intervalGap as _, drawMarks as a, pct as b, markLabelRows as c, METHOD_NAME as d, TIERS_BEST_FIRST as f, estimateNote as g, duration as h, drawBands as i, table as j, usd as k, markName as l, drawable as m, EVENTS_READ as n, eventsGap as o, costText as p, listJoin as q, MARK_HEADS as r, eventsTable as s, timeline as t, isRefusal as u, intervalName as v, rateIntervalGap as w, perPass as x, kn as y, polyline as z };
|