@tangle-network/charts 0.3.1 → 0.6.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 +71 -24
- package/dist/events-Cw6abvgR.js +491 -0
- package/dist/figure.css +45 -9
- package/dist/index.d.ts +104 -135
- package/dist/index.js +735 -371
- package/dist/react/index.d.ts +123 -1
- package/dist/react/index.js +931 -4
- package/dist/react/live.css +56 -0
- package/dist/timeline-BAjzF-wL.d.ts +183 -0
- package/package.json +28 -14
- package/LICENSE +0 -21
package/dist/index.js
CHANGED
|
@@ -1,347 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
function line(x1, y1, x2, y2, cls) {
|
|
327
|
-
return `<line class="${cls}" x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}"/>`;
|
|
328
|
-
}
|
|
329
|
-
function circle(cx, cy, r, cls) {
|
|
330
|
-
return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
|
|
331
|
-
}
|
|
332
|
-
function polyline(points, cls) {
|
|
333
|
-
return `<polyline class="${cls}" points="${points.map(([x, y]) => `${r2(x)},${r2(y)}`).join(" ")}"/>`;
|
|
334
|
-
}
|
|
335
|
-
function text(x, y, value, o = {}) {
|
|
336
|
-
const anchor = o.anchor && o.anchor !== "start" ? ` text-anchor="${o.anchor}"` : "";
|
|
337
|
-
const weight = o.weight === 600 ? ` font-weight="600"` : "";
|
|
338
|
-
return `<text class="${o.cls ?? "tgc-ink"}" x="${r2(x)}" y="${r2(y)}" font-size="${o.size ?? 12}"${anchor}${weight}>${esc(value)}</text>`;
|
|
339
|
-
}
|
|
340
|
-
/** A group whose `<title>` is the hover text for everything inside it. */
|
|
341
|
-
function titled(title, inner) {
|
|
342
|
-
return `<g><title>${esc(title)}</title>${inner}</g>`;
|
|
343
|
-
}
|
|
344
|
-
//#endregion
|
|
1
|
+
import { C as listJoin, D as wrap, E as textWidth, S as fitLines, T as textBlock, _ as svgRoot, a as eventsTable, b as capitalize, c as MARKER_SHAPES, f as hbar, g as rect, h as polyline, i as eventsGap, l as circle, m as markerPath, n as drawBands, o as isRefusal, p as line, r as drawMarks, s as table, t as EVENTS_READ, u as hatch, v as text, w as plural, x as esc, y as titled } from "./events-Cw6abvgR.js";
|
|
345
2
|
//#region src/block.ts
|
|
346
3
|
/**
|
|
347
4
|
* The figure block: one markup for every figure on every surface.
|
|
@@ -408,14 +65,6 @@ function renderRefusals(refusals) {
|
|
|
408
65
|
return `<ul class="tgc-refusals">${refusals.map((r) => `<li>${esc(r.refused)}</li>`).join("")}</ul>`;
|
|
409
66
|
}
|
|
410
67
|
//#endregion
|
|
411
|
-
//#region src/table.ts
|
|
412
|
-
/** A plain HTML table: the accessible form of a figure and its phone view. */
|
|
413
|
-
function table(caption, columns, rows) {
|
|
414
|
-
const head = columns.map((c) => `<th scope="col"${c.numeric ? " class=\"tgc-n\"" : ""}>${esc(c.label)}</th>`).join("");
|
|
415
|
-
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("");
|
|
416
|
-
return `<table class="tgc-table"><caption>${esc(caption)}</caption><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
|
417
|
-
}
|
|
418
|
-
//#endregion
|
|
419
68
|
//#region src/breakdown.ts
|
|
420
69
|
/**
|
|
421
70
|
* `breakdown`: why attempts fail.
|
|
@@ -529,6 +178,354 @@ function wide$1(input, get) {
|
|
|
529
178
|
return svgRoot(720, y + 4, head + body);
|
|
530
179
|
}
|
|
531
180
|
//#endregion
|
|
181
|
+
//#region src/heatmap.ts
|
|
182
|
+
/** Recorded values and producer-supplied states in a complete matrix. */
|
|
183
|
+
function heatmap(columns, rows, o) {
|
|
184
|
+
const id = o.id ?? "heatmap";
|
|
185
|
+
if (!columns.length || !rows.length) return {
|
|
186
|
+
id,
|
|
187
|
+
refused: "A heatmap needs rows and columns."
|
|
188
|
+
};
|
|
189
|
+
if (!o.measure?.trim() || !o.unit?.trim() || !o.statusRule?.trim()) return {
|
|
190
|
+
id,
|
|
191
|
+
refused: "A heatmap needs a measure, unit and status rule."
|
|
192
|
+
};
|
|
193
|
+
if (new Set(columns.map((c) => c.id)).size !== columns.length || new Set(rows.map((r) => r.id)).size !== rows.length) return {
|
|
194
|
+
id,
|
|
195
|
+
refused: "Duplicate heatmap row or column ids."
|
|
196
|
+
};
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
if (row.cells.length !== columns.length) return {
|
|
199
|
+
id,
|
|
200
|
+
refused: `${row.label}: expected ${columns.length} cells, got ${row.cells.length}.`
|
|
201
|
+
};
|
|
202
|
+
for (const [i, cell] of row.cells.entries()) {
|
|
203
|
+
if (![
|
|
204
|
+
"pass",
|
|
205
|
+
"breach",
|
|
206
|
+
"no-data"
|
|
207
|
+
].includes(cell.status)) return {
|
|
208
|
+
id,
|
|
209
|
+
refused: `${row.label} × ${columns[i].label}: unknown status.`
|
|
210
|
+
};
|
|
211
|
+
if (cell.status === "no-data" ? cell.value !== null : cell.value === null || !Number.isFinite(cell.value) || cell.value < 0) return {
|
|
212
|
+
id,
|
|
213
|
+
refused: `${row.label} × ${columns[i].label}: status and value disagree.`
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const fmt = (v) => o.formatValue?.(v) ?? new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }).format(v);
|
|
218
|
+
const counts = rows.flatMap((r) => r.cells).reduce((a, c) => (a[c.status]++, a), {
|
|
219
|
+
pass: 0,
|
|
220
|
+
breach: 0,
|
|
221
|
+
"no-data": 0
|
|
222
|
+
});
|
|
223
|
+
const measured = counts.pass + counts.breach;
|
|
224
|
+
const finding = `${counts.breach} of ${measured} measured ${plural(measured, "cell")} breach ${o.statusRule}.`;
|
|
225
|
+
const lede = `${counts.pass} pass · ${counts.breach} breach · ${counts["no-data"]} no data across ${rows.length} ${plural(rows.length, "row")}.`;
|
|
226
|
+
const cellLabel = (cell) => cell.value === null ? "No data" : `${fmt(cell.value)} ${o.unit} · ${cell.status}`;
|
|
227
|
+
const tableHtml = table(`${capitalize(o.measure)} by row and column`, [{ label: "Row" }, ...columns.map((c) => ({
|
|
228
|
+
label: c.label,
|
|
229
|
+
numeric: true
|
|
230
|
+
}))], rows.map((r) => [r.label, ...r.cells.map(cellLabel)]));
|
|
231
|
+
return {
|
|
232
|
+
id,
|
|
233
|
+
finding,
|
|
234
|
+
lede,
|
|
235
|
+
read: `Every cell shows its recorded ${o.measure}; colour shows the producer's pass or breach status. A dash means no data.`,
|
|
236
|
+
svg: {
|
|
237
|
+
wide: drawWide(columns, rows, fmt),
|
|
238
|
+
narrow: drawNarrow(columns, rows, fmt)
|
|
239
|
+
},
|
|
240
|
+
table: tableHtml,
|
|
241
|
+
note: {
|
|
242
|
+
method: `Producer-supplied status. ${o.statusRule}. Values are ${o.unit}; no data is never zero.`,
|
|
243
|
+
n: `${measured} measured of ${rows.length * columns.length} cells.`,
|
|
244
|
+
exclusions: [`${counts["no-data"]} cells have no data.`]
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function cellMark(cell, x, y, w, h, label, fmt) {
|
|
249
|
+
const value = cell.value === null ? "—" : fmt(cell.value);
|
|
250
|
+
const status = cell.status === "no-data" ? "no data" : cell.status;
|
|
251
|
+
return titled(`${label}: ${value} · ${status}`, rect(x, y, w, h, `tgc-heat-${cell.status}`, "rx=\"5\"") + text(x + w / 2, y + h / 2 + 5, value, {
|
|
252
|
+
cls: `tgc-heat-label tgc-num${cell.status === "no-data" ? " tgc-heat-missing" : ""}`,
|
|
253
|
+
anchor: "middle",
|
|
254
|
+
size: 13,
|
|
255
|
+
weight: 600
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
function drawWide(columns, rows, fmt) {
|
|
259
|
+
const left = 144, gap = 4, cellW = Math.max(58, (568 - gap * (columns.length - 1)) / columns.length);
|
|
260
|
+
const width = Math.ceil(left + columns.length * (cellW + gap));
|
|
261
|
+
let head = "", headHeight = 44;
|
|
262
|
+
columns.forEach((c, i) => {
|
|
263
|
+
const label = wrap(c.label, cellW - 5, 10, "sans", 3);
|
|
264
|
+
head += titled(c.label, textBlock(label.lines, left + i * (cellW + gap) + cellW / 2, 14, 12, "class=\"tgc-ink-muted\" font-size=\"10\" text-anchor=\"middle\""));
|
|
265
|
+
headHeight = Math.max(headHeight, 12 + label.lines.length * 12 + 10);
|
|
266
|
+
});
|
|
267
|
+
let body = "";
|
|
268
|
+
rows.forEach((r, j) => {
|
|
269
|
+
const y = headHeight + j * 32;
|
|
270
|
+
const name = wrap(r.label, 136, 12, "sans", 1);
|
|
271
|
+
body += titled(r.label, text(2, y + 19, name.lines[0], {
|
|
272
|
+
size: 12,
|
|
273
|
+
weight: 600
|
|
274
|
+
}));
|
|
275
|
+
r.cells.forEach((cell, i) => {
|
|
276
|
+
body += cellMark(cell, left + i * (cellW + gap), y, cellW, 28, `${r.label} · ${columns[i].label}`, fmt);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
return svgRoot(width, headHeight + rows.length * 32 + 2, head + body);
|
|
280
|
+
}
|
|
281
|
+
function drawNarrow(columns, rows, fmt) {
|
|
282
|
+
const width = 358, cols = 3, cellW = 322 / cols;
|
|
283
|
+
const groups = Math.ceil(columns.length / cols), groupH = 49, rowH = 29 + groups * groupH + 12;
|
|
284
|
+
let body = "";
|
|
285
|
+
rows.forEach((r, j) => {
|
|
286
|
+
const top = j * rowH;
|
|
287
|
+
body += text(12, top + 18, r.label, {
|
|
288
|
+
size: 14,
|
|
289
|
+
weight: 600
|
|
290
|
+
});
|
|
291
|
+
columns.forEach((c, i) => {
|
|
292
|
+
const group = Math.floor(i / cols), x = 12 + i % cols * 113.33333333333333, y = top + 29 + group * groupH;
|
|
293
|
+
const label = wrap(c.label, 104.33333333333333, 10, "sans", 1);
|
|
294
|
+
body += titled(c.label, text(x + 2, y + 9, label.lines[0], {
|
|
295
|
+
size: 10,
|
|
296
|
+
cls: "tgc-ink-muted"
|
|
297
|
+
}));
|
|
298
|
+
body += cellMark(r.cells[i], x, y + 13, cellW, 29, `${r.label} · ${c.label}`, fmt);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
return svgRoot(width, rows.length * rowH, body);
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/latency-range.ts
|
|
305
|
+
/** p50 point, p95 extent, and a caller-supplied SLO. */
|
|
306
|
+
function latencyRange(rows, o) {
|
|
307
|
+
const id = o.id ?? "latency-range";
|
|
308
|
+
if (!rows.length) return {
|
|
309
|
+
id,
|
|
310
|
+
refused: "No latency ranges recorded."
|
|
311
|
+
};
|
|
312
|
+
if (!o.measure?.trim() || !Number.isFinite(o.sloMs) || o.sloMs <= 0 || !Number.isInteger(o.minN) || o.minN < 1) return {
|
|
313
|
+
id,
|
|
314
|
+
refused: "A latency range needs a measure, positive SLO and minimum n."
|
|
315
|
+
};
|
|
316
|
+
if (new Set(rows.map((r) => r.id)).size !== rows.length) return {
|
|
317
|
+
id,
|
|
318
|
+
refused: "Duplicate latency row ids."
|
|
319
|
+
};
|
|
320
|
+
for (const r of rows) {
|
|
321
|
+
if (!Number.isInteger(r.n) || r.n < o.minN) return {
|
|
322
|
+
id,
|
|
323
|
+
refused: `${r.label}: n=${r.n} is below the stated minimum n=${o.minN}.`
|
|
324
|
+
};
|
|
325
|
+
if (!Number.isFinite(r.p50Ms) || !Number.isFinite(r.p95Ms) || r.p50Ms <= 0 || r.p95Ms < r.p50Ms) return {
|
|
326
|
+
id,
|
|
327
|
+
refused: `${r.label}: invalid p50 or p95 for a log axis.`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const breached = rows.filter((r) => r.p95Ms > o.sloMs);
|
|
331
|
+
const fmt = (ms) => `${(ms / 1e3).toFixed(1)} s`;
|
|
332
|
+
const min = Math.min(o.sloMs, ...rows.map((r) => r.p50Ms)) / 1.5;
|
|
333
|
+
const max = Math.max(o.sloMs, ...rows.map((r) => r.p95Ms)) * 1.12;
|
|
334
|
+
return {
|
|
335
|
+
id,
|
|
336
|
+
finding: `${breached.length} of ${rows.length} ${plural(rows.length, "range")} exceed the ${fmt(o.sloMs)} p95 SLO.`,
|
|
337
|
+
lede: `${rows.length} of ${rows.length + (o.excluded?.length ?? 0)} ${plural(rows.length + (o.excluded?.length ?? 0), "setup")} have at least ${o.minN} observations.`,
|
|
338
|
+
read: "The dot is p50; the bar runs from p50 to p95 on a logarithmic time axis. The dashed rule is the p95 SLO. Input order is preserved.",
|
|
339
|
+
svg: {
|
|
340
|
+
wide: draw$3(rows, o, min, max, 720),
|
|
341
|
+
narrow: draw$3(rows, o, min, max, 358)
|
|
342
|
+
},
|
|
343
|
+
table: table(`${capitalize(o.measure)} by setup`, [
|
|
344
|
+
{ label: "Setup" },
|
|
345
|
+
{
|
|
346
|
+
label: "p50",
|
|
347
|
+
numeric: true
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
label: "p95",
|
|
351
|
+
numeric: true
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
label: "n",
|
|
355
|
+
numeric: true
|
|
356
|
+
},
|
|
357
|
+
{ label: "p95 SLO" }
|
|
358
|
+
], rows.map((r) => [
|
|
359
|
+
r.label,
|
|
360
|
+
fmt(r.p50Ms),
|
|
361
|
+
fmt(r.p95Ms),
|
|
362
|
+
String(r.n),
|
|
363
|
+
r.p95Ms > o.sloMs ? "breach" : "pass"
|
|
364
|
+
])),
|
|
365
|
+
note: {
|
|
366
|
+
method: `Recorded p50 and p95 in milliseconds on a logarithmic time axis; producer supplies quantiles. The p95 SLO is ${fmt(o.sloMs)}.`,
|
|
367
|
+
n: `${rows.reduce((s, r) => s + r.n, 0)} observations across ${rows.length} rows; minimum n=${o.minN} per row.`,
|
|
368
|
+
exclusions: o.excluded ?? []
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function draw$3(rows, o, min, max, width) {
|
|
373
|
+
const phone = width === 358, left = phone ? 122 : 148, right = phone ? 69 : 92;
|
|
374
|
+
const plotW = width - left - right, top = 48, rowH = phone ? 49 : 39;
|
|
375
|
+
const scale = (ms) => left + (Math.log(ms) - Math.log(min)) / (Math.log(max) - Math.log(min)) * plotW;
|
|
376
|
+
let body = line(scale(o.sloMs), 37, scale(o.sloMs), top + rows.length * rowH, "tgc-threshold");
|
|
377
|
+
body += text(scale(o.sloMs) + 3, 15, `SLO ${(o.sloMs / 1e3).toFixed(0)} s`, {
|
|
378
|
+
size: 11,
|
|
379
|
+
cls: "tgc-ink-muted tgc-num"
|
|
380
|
+
});
|
|
381
|
+
for (let exponent = Math.floor(Math.log10(min)); exponent <= Math.ceil(Math.log10(max)); exponent++) for (const step of [1, 5]) {
|
|
382
|
+
const tick = step * 10 ** exponent;
|
|
383
|
+
if (tick <= min || tick >= max || Math.abs(scale(tick) - scale(o.sloMs)) < 19) continue;
|
|
384
|
+
body += line(scale(tick), 40, scale(tick), top + rows.length * rowH, "tgc-gridline");
|
|
385
|
+
body += text(scale(tick), 35, `${Number((tick / 1e3).toPrecision(2))} s`, {
|
|
386
|
+
size: 10,
|
|
387
|
+
cls: "tgc-ink-muted tgc-num",
|
|
388
|
+
anchor: "middle"
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
rows.forEach((r, i) => {
|
|
392
|
+
const y = top + i * rowH;
|
|
393
|
+
const label = wrap(r.label, left - 10, phone ? 11 : 13, "sans", phone ? 2 : 1);
|
|
394
|
+
body += titled(r.label, label.lines.map((part, j) => text(2, y + 5 + j * 14, part, {
|
|
395
|
+
size: phone ? 11 : 13,
|
|
396
|
+
weight: 600
|
|
397
|
+
})).join(""));
|
|
398
|
+
const cy = y + (phone ? 7 : 0), status = r.p95Ms > o.sloMs ? "breach" : "pass";
|
|
399
|
+
body += titled(`${r.label}: p50 ${(r.p50Ms / 1e3).toFixed(1)} s; p95 ${(r.p95Ms / 1e3).toFixed(1)} s; n=${r.n}; ${status}`, rect(scale(r.p50Ms), cy - 5, Math.max(2, scale(r.p95Ms) - scale(r.p50Ms)), 10, `tgc-range-${status}`, "rx=\"5\"") + circle(scale(r.p50Ms), cy, 6, `tgc-range-dot-${status}`));
|
|
400
|
+
body += text(width - 4, cy + 4, `${(r.p95Ms / 1e3).toFixed(1)} · ${r.n}`, {
|
|
401
|
+
cls: "tgc-ink tgc-num",
|
|
402
|
+
anchor: "end",
|
|
403
|
+
size: phone ? 10 : 12
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
return svgRoot(width, top + rows.length * rowH + 4, body);
|
|
407
|
+
}
|
|
408
|
+
//#endregion
|
|
409
|
+
//#region src/phase-waterfall.ts
|
|
410
|
+
/** Static cumulative timing from ordered, disjoint producer phases. */
|
|
411
|
+
function phaseWaterfall(phases, o) {
|
|
412
|
+
const id = o.id ?? "phase-waterfall";
|
|
413
|
+
if (!phases.length) return {
|
|
414
|
+
id,
|
|
415
|
+
refused: "No phases recorded."
|
|
416
|
+
};
|
|
417
|
+
if (!o.measure?.trim() || !Number.isFinite(o.totalMs) || o.totalMs <= 0) return {
|
|
418
|
+
id,
|
|
419
|
+
refused: "A waterfall needs a measure and positive total time."
|
|
420
|
+
};
|
|
421
|
+
if (new Set(phases.map((p) => p.id)).size !== phases.length) return {
|
|
422
|
+
id,
|
|
423
|
+
refused: "Duplicate phase ids."
|
|
424
|
+
};
|
|
425
|
+
if (phases.some((p) => p.id === "__unaccounted")) return {
|
|
426
|
+
id,
|
|
427
|
+
refused: "Phase id __unaccounted is reserved."
|
|
428
|
+
};
|
|
429
|
+
for (const phase of phases) {
|
|
430
|
+
if (!Number.isFinite(phase.ms) || phase.ms < 0) return {
|
|
431
|
+
id,
|
|
432
|
+
refused: `${phase.label}: invalid duration.`
|
|
433
|
+
};
|
|
434
|
+
if (phase.kind !== void 0 && phase.kind !== "measured" && phase.kind !== "unaccounted") return {
|
|
435
|
+
id,
|
|
436
|
+
refused: `${phase.label}: unknown phase kind.`
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
const accounted = phases.reduce((s, p) => s + p.ms, 0);
|
|
440
|
+
if (accounted > o.totalMs + .001) return {
|
|
441
|
+
id,
|
|
442
|
+
refused: `Phase durations ${accounted} ms exceed the reported total ${o.totalMs} ms; phases may overlap.`
|
|
443
|
+
};
|
|
444
|
+
const unassigned = Math.max(0, o.totalMs - accounted);
|
|
445
|
+
const namedUnaccounted = phases.filter((p) => p.kind === "unaccounted").reduce((s, p) => s + p.ms, 0);
|
|
446
|
+
const fmt = (ms) => `${(ms / 1e3).toFixed(2)} s`;
|
|
447
|
+
const short = (ms) => ms < 1e3 ? `${Number(ms.toPrecision(3))} ms` : fmt(ms);
|
|
448
|
+
const largest = phases.reduce((a, p) => p.ms > a.ms ? p : a, phases[0]);
|
|
449
|
+
const all = unassigned > .001 ? [...phases, {
|
|
450
|
+
id: "__unaccounted",
|
|
451
|
+
label: "Unassigned remainder",
|
|
452
|
+
ms: unassigned,
|
|
453
|
+
kind: "unaccounted"
|
|
454
|
+
}] : phases;
|
|
455
|
+
let cumulative = 0;
|
|
456
|
+
const rows = all.map((p) => {
|
|
457
|
+
const start = cumulative;
|
|
458
|
+
cumulative += p.ms;
|
|
459
|
+
return {
|
|
460
|
+
...p,
|
|
461
|
+
start
|
|
462
|
+
};
|
|
463
|
+
});
|
|
464
|
+
return {
|
|
465
|
+
id,
|
|
466
|
+
finding: largest.kind === "unaccounted" ? `${capitalize(largest.label)} is the largest leaf, but its work is unaccounted: ${fmt(largest.ms)} of ${fmt(o.totalMs)}.` : `${capitalize(largest.label)} takes the longest recorded phase: ${fmt(largest.ms)} of ${fmt(o.totalMs)}.`,
|
|
467
|
+
lede: `${fmt(accounted)} in ${phases.length} ordered ${plural(phases.length, "leaf")}; ${fmt(namedUnaccounted)} producer-labelled unaccounted and ${fmt(unassigned)} unassigned remainder.`,
|
|
468
|
+
read: "Each bar begins where the previous phase ends. Hatched bars are producer-labelled unaccounted leaves or the unassigned remainder; zero phases remain listed without a positive bar.",
|
|
469
|
+
svg: {
|
|
470
|
+
wide: draw$2(rows, o.totalMs, 720, short),
|
|
471
|
+
narrow: draw$2(rows, o.totalMs, 358, short)
|
|
472
|
+
},
|
|
473
|
+
table: table(`${capitalize(o.measure)} by phase`, [
|
|
474
|
+
{ label: "Phase" },
|
|
475
|
+
{
|
|
476
|
+
label: "Duration",
|
|
477
|
+
numeric: true
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
label: "Cumulative",
|
|
481
|
+
numeric: true
|
|
482
|
+
},
|
|
483
|
+
{ label: "Kind" }
|
|
484
|
+
], rows.map((p) => [
|
|
485
|
+
p.label,
|
|
486
|
+
short(p.ms),
|
|
487
|
+
fmt(p.start + p.ms),
|
|
488
|
+
p.kind === "unaccounted" ? "unaccounted" : "measured"
|
|
489
|
+
])),
|
|
490
|
+
note: {
|
|
491
|
+
method: "Ordered disjoint leaf durations as supplied by the producer; no overlap or critical-path inference. Hatching marks producer-labelled unaccounted leaves and the calculated remainder; the table names which is which.",
|
|
492
|
+
n: `${phases.length} phases, ${fmt(o.totalMs)} reported total.`,
|
|
493
|
+
exclusions: []
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function draw$2(rows, total, width, short) {
|
|
498
|
+
const phone = width === 358, left = phone ? 12 : 190, right = phone ? 52 : 64;
|
|
499
|
+
const plotW = width - left - right, rowH = phone ? 42 : 24, top = 27;
|
|
500
|
+
let body = "";
|
|
501
|
+
for (let i = 0; i <= 4; i++) {
|
|
502
|
+
const x = left + plotW * i / 4;
|
|
503
|
+
body += line(x, 23, x, top + rows.length * rowH, "tgc-gridline");
|
|
504
|
+
body += text(x, 15, `${(total * i / 4e3).toFixed(0)} s`, {
|
|
505
|
+
size: 10,
|
|
506
|
+
cls: "tgc-ink-muted tgc-num",
|
|
507
|
+
anchor: i === 0 ? "start" : i === 4 ? "end" : "middle"
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
rows.forEach((p, i) => {
|
|
511
|
+
const y = top + i * rowH, start = left + p.start / total * plotW, w = p.ms / total * plotW;
|
|
512
|
+
const label = wrap(p.label, phone ? width - 65 : left - 6, phone ? 11 : 12, "sans", 1);
|
|
513
|
+
body += titled(p.label, text(phone ? 12 : 2, y + (phone ? 13 : 18), label.lines[0], {
|
|
514
|
+
size: phone ? 11 : 12,
|
|
515
|
+
cls: p.id === "__unaccounted" ? "tgc-ink-muted" : "tgc-ink"
|
|
516
|
+
}));
|
|
517
|
+
const barY = y + (phone ? 21 : 5), barW = Math.max(p.ms > 0 ? 1 : 0, w);
|
|
518
|
+
const bar = p.kind === "unaccounted" ? hatch(start, barY, barW, 13, "tgc-waterfall-unaccounted") : rect(start, barY, barW, 13, "tgc-waterfall-bar", "rx=\"2\"");
|
|
519
|
+
body += titled(`${p.label}: ${short(p.ms)}; cumulative ${((p.start + p.ms) / 1e3).toFixed(2)} s`, bar);
|
|
520
|
+
body += text(width - 2, barY + 11, short(p.ms), {
|
|
521
|
+
size: phone ? 10 : 11,
|
|
522
|
+
cls: "tgc-ink tgc-num",
|
|
523
|
+
anchor: "end"
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
return svgRoot(width, top + rows.length * rowH + 2, body);
|
|
527
|
+
}
|
|
528
|
+
//#endregion
|
|
532
529
|
//#region src/format.ts
|
|
533
530
|
const TIERS_BEST_FIRST = [
|
|
534
531
|
"S",
|
|
@@ -850,8 +847,8 @@ function comparisonIntervals(rows, setups, o) {
|
|
|
850
847
|
lede,
|
|
851
848
|
read,
|
|
852
849
|
svg: {
|
|
853
|
-
wide: draw(drawn, domain, 720, 272, 264),
|
|
854
|
-
narrow: draw(drawn, domain, 358, 0, 0)
|
|
850
|
+
wide: draw$1(drawn, domain, 720, 272, 264),
|
|
851
|
+
narrow: draw$1(drawn, domain, 358, 0, 0)
|
|
855
852
|
},
|
|
856
853
|
table: table(`Comparisons on ${o.measure}`, columns, tableRows),
|
|
857
854
|
note: {
|
|
@@ -862,7 +859,7 @@ function comparisonIntervals(rows, setups, o) {
|
|
|
862
859
|
};
|
|
863
860
|
}
|
|
864
861
|
/** labelW 0 stacks the label above the bar (phone). */
|
|
865
|
-
function draw(drawn, domain, width, plotX, labelW) {
|
|
862
|
+
function draw$1(drawn, domain, width, plotX, labelW) {
|
|
866
863
|
const x = linear(domain, [plotX, width - 8]);
|
|
867
864
|
let body = "";
|
|
868
865
|
let y = 8;
|
|
@@ -1271,9 +1268,9 @@ function plot$1(costed, unplaced, drawLine, o) {
|
|
|
1271
1268
|
//#endregion
|
|
1272
1269
|
//#region src/css.generated.ts
|
|
1273
1270
|
/** The figure block and mark styles (src/figure.css), for documents that inline their CSS. */
|
|
1274
|
-
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\n/* Chart-led surfaces keep supporting text in the native disclosure. */\n.tgc-figure-chart { max-width: none; margin: 0; }\n.tgc-figure-chart .tgc-wide svg { width: 100%; height: auto; }\n.tgc-wide:has(.tgc-vertical), .tgc-narrow:has(.tgc-vertical) { overflow-x: auto; }\n.tgc-vertical { width: 100%; }\n.tgc-narrow .tgc-vertical { max-width: none; }\n.tgc-column-hit { fill: transparent; stroke: transparent; stroke-width: 2; }\n.tgc-column:focus-visible { outline: none; }\n.tgc-column:focus-visible .tgc-column-hit { stroke: var(--chart-accent); }\n.tgc-vertical-bar { fill: var(--chart-accent); }\n/* Identity assets keep their approved colors on a white plate in either theme. */\n.tgc-logo-plate { fill: #fff; stroke: var(--chart-rule); }\n.tgc-column-tip { opacity: 0; pointer-events: none; }\n.tgc-tip-bg { fill: var(--chart-ink); }\n.tgc-tip-ink { fill: var(--chart-canvas); }\n\n.tgc-series-line { fill: none; stroke: var(--chart-accent); stroke-width: 2; stroke-linejoin: round; }\n.tgc-series-point { fill: var(--chart-accent); stroke: var(--chart-canvas); stroke-width: 1; }\n.tgc-series-hit { fill: transparent; stroke: transparent; }\n.tgc-series-observation:focus-visible { outline: none; }\n.tgc-series-observation:focus-visible .tgc-series-hit { stroke: var(--chart-ink); stroke-width: 2; }\n.tgc-series-tip { opacity: 0; pointer-events: none; }\n\n.tgc-scalar-table { min-width: 620px; }\n.tgc-scalar-table tbody th { min-width: 180px; max-width: 260px; }\n\n/* Plot presentation keeps the same complete data available to assistive technology. */\n.tgc-accessible-data { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }\n.tgc-figure-plot .tgc-wide svg { box-sizing: content-box; width: calc(100% - 48px); padding: 0 24px; }\n.tgc-figure-plot svg text { font-size: 14px; }\n.tgc-figure-plot svg text.tgc-num { font-size: 15px; }\n/* Larger tooltip text needs padding outside the original report backdrop. */\n.tgc-figure-plot .tgc-tip-bg { stroke: var(--chart-ink); stroke-width: 24px; }\n.tgc-figure-plot .tgc-phone-plot svg { min-width: var(--tgc-phone-width); }\n.tgc-figure-plot .tgc-wide, .tgc-figure-plot .tgc-narrow { overflow-x: auto; }\n\n.tgc-figure-plot .tgc-wide-fallback svg { min-width: var(--tgc-fallback-width); }\n.tgc-phone-plot:focus-visible, .tgc-wide-fallback:focus-visible { outline: 2px solid var(--chart-accent); outline-offset: 2px; }\n";
|
|
1271
|
+
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: 18px;\n line-height: 25px;\n font-weight: 650;\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: 15px;\n line-height: 23px;\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: 13px;\n line-height: 18px;\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: 2;\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\n/* Chart-led surfaces keep supporting text in the native disclosure. */\n.tgc-figure-chart { max-width: none; margin: 0; }\n.tgc-figure-chart .tgc-wide svg { width: 100%; height: auto; }\n.tgc-wide:has(.tgc-vertical), .tgc-narrow:has(.tgc-vertical) { overflow-x: auto; }\n.tgc-vertical { width: 100%; }\n.tgc-narrow .tgc-vertical { max-width: none; }\n.tgc-column-hit { fill: transparent; stroke: transparent; stroke-width: 2; }\n.tgc-column:focus-visible { outline: none; }\n.tgc-column:focus-visible .tgc-column-hit { stroke: var(--chart-accent); }\n.tgc-vertical-bar { fill: var(--chart-accent); }\n/* Identity assets keep their approved colors on a white plate in either theme. */\n.tgc-logo-plate { fill: #fff; stroke: var(--chart-rule); }\n.tgc-column-tip { opacity: 0; pointer-events: none; }\n.tgc-tip-bg { fill: var(--chart-ink); }\n.tgc-tip-ink { fill: var(--chart-canvas); }\n\n.tgc-series-line { fill: none; stroke: var(--chart-accent); stroke-width: 2; stroke-linejoin: round; }\n.tgc-series-point, .tgc-series-key { fill: var(--chart-accent); stroke: var(--chart-canvas); stroke-width: 2; }\n\n/* Semantic states stay tied to Brand status tokens. No-data has its own neutral mark. */\n.tgc-heat-pass { fill: color-mix(in srgb, var(--chart-pass, var(--status-running)) 19%, var(--chart-canvas)); stroke: var(--chart-pass, var(--status-running)); stroke-width: .8; }\n.tgc-heat-breach { fill: color-mix(in srgb, var(--chart-breach, var(--status-error)) 17%, var(--chart-canvas)); stroke: var(--chart-breach, var(--status-error)); stroke-width: .8; }\n.tgc-heat-no-data { fill: var(--chart-grid); stroke: var(--chart-rule); stroke-width: .8; }\n.tgc-heat-label { fill: var(--chart-ink); }\n.tgc-heat-missing { fill: var(--chart-ink-muted); }\n.tgc-wide:has(.tgc-heat-pass), .tgc-wide:has(.tgc-heat-breach) { overflow-x: auto; }\n.tgc-range-pass { fill: var(--chart-pass, var(--status-running)); }\n.tgc-range-breach { fill: var(--chart-breach, var(--status-error)); }\n.tgc-range-dot-pass { fill: var(--chart-canvas); stroke: var(--chart-pass, var(--status-running)); stroke-width: 3; }\n.tgc-range-dot-breach { fill: var(--chart-canvas); stroke: var(--chart-breach, var(--status-error)); stroke-width: 3; }\n.tgc-waterfall-bar { fill: var(--chart-accent); }\n.tgc-series-hit { fill: transparent; stroke: transparent; }\n.tgc-series-observation:focus-visible { outline: none; }\n.tgc-series-observation:focus-visible .tgc-series-hit { stroke: var(--chart-ink); stroke-width: 2; }\n.tgc-series-tip { opacity: 0; pointer-events: none; }\n\n.tgc-scalar-table { min-width: 620px; }\n.tgc-scalar-table tbody th { min-width: 180px; max-width: 260px; }\n\n/* Plot presentation keeps the same complete data available to assistive technology. */\n.tgc-accessible-data { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }\n.tgc-figure-plot .tgc-wide svg { box-sizing: content-box; width: calc(100% - 48px); padding: 0 24px; }\n.tgc-figure-plot svg text { font-size: 14px; }\n.tgc-figure-plot svg text.tgc-num { font-size: 15px; }\n/* Larger tooltip text needs padding outside the original report backdrop. */\n.tgc-figure-plot .tgc-tip-bg { stroke: var(--chart-ink); stroke-width: 24px; }\n.tgc-figure-plot .tgc-phone-plot svg { min-width: var(--tgc-phone-width); }\n.tgc-figure-plot .tgc-wide, .tgc-figure-plot .tgc-narrow { overflow-x: auto; }\n\n.tgc-figure-plot .tgc-wide-fallback svg { min-width: var(--tgc-fallback-width); }\n.tgc-phone-plot:focus-visible, .tgc-wide-fallback:focus-visible { outline: 2px solid var(--chart-accent); outline-offset: 2px; }\n\n/* Recorded events and spans. Kinds differ by stroke pattern as well as token, so they read without colour. */\n.tgc-mark { stroke-width: 1.5; }\n.tgc-mark-pass { stroke: var(--chart-pass, var(--status-running)); }\n.tgc-mark-deadline { stroke: var(--chart-ink); stroke-dasharray: 5 3; }\n.tgc-mark-died { stroke: var(--chart-breach, var(--status-error)); stroke-width: 2; }\n.tgc-mark-event { stroke: var(--chart-ink-muted); stroke-dasharray: 1 3; stroke-linecap: round; }\n.tgc-mark-label { fill: var(--chart-ink); paint-order: stroke; stroke: var(--chart-canvas); stroke-width: 3px; stroke-linejoin: round; }\n.tgc-band-after-pass { fill: color-mix(in srgb, var(--chart-pass, var(--status-running)) 12%, transparent); }\n.tgc-band-idle { fill: var(--chart-shade); }\n/* Missing data: a neutral wash under hatch lines, never a zero-height mark. */\n.tgc-hatch-bg { fill: color-mix(in srgb, var(--chart-muted) 10%, transparent); }\n.tgc-hatch { fill: none; stroke: var(--chart-muted); stroke-width: 1; }\n.tgc-span { fill: var(--chart-accent); }\n.tgc-span-muted { fill: var(--chart-muted); }\n.tgc-span-open { stroke: var(--chart-ink); stroke-width: 1.5; stroke-dasharray: 3 2; }\n.tgc-now { stroke: var(--chart-ink); stroke-width: 1.5; }\n\n/* FigureView (React) picks the render by measured width; the wide plot fills its container. */\n.tgc-figure-view { max-width: none; margin: 0; }\n.tgc-view-plot svg { display: block; overflow: visible; }\n.tgc-view-wide svg { width: 100%; height: auto; }\n.tgc-view-narrow svg { max-width: 100%; height: auto; }\n";
|
|
1275
1272
|
/** --chart-* resolved per theme, for documents without brand tokens: light, dark by preference or [data-theme="dark"], light in print. */
|
|
1276
|
-
const chartTokensCss = ":root{--chart-ink:#2d2d2d;--chart-ink-muted:#686868;--chart-muted:#787878;--chart-grid:#f1f2f7;--chart-rule:#c7c6d6;--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:#e6e6e6;--chart-ink-muted:#a5a5a5;--chart-muted:#8e8e8e;--chart-grid:#221f33;--chart-rule:#2a293d;--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:#0a0a14;color-scheme:dark}}\n:root[data-theme=\"dark\"]{--chart-ink:#e6e6e6;--chart-ink-muted:#a5a5a5;--chart-muted:#8e8e8e;--chart-grid:#221f33;--chart-rule:#2a293d;--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:#0a0a14;color-scheme:dark}\n@media print{:root,:root:not([data-theme=\"light\"]),:root[data-theme=\"dark\"]{--chart-ink:#2d2d2d;--chart-ink-muted:#686868;--chart-muted:#787878;--chart-grid:#f1f2f7;--chart-rule:#c7c6d6;--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}}";
|
|
1273
|
+
const chartTokensCss = ":root{--chart-ink:#2d2d2d;--chart-ink-muted:#686868;--chart-muted:#787878;--chart-grid:#f1f2f7;--chart-rule:#c7c6d6;--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-pass:#059669;--chart-breach:#ba1a1a;--chart-canvas:#ffffff;color-scheme:light}\n@media (prefers-color-scheme: dark){:root:not([data-theme=\"light\"]){--chart-ink:#e6e6e6;--chart-ink-muted:#a5a5a5;--chart-muted:#8e8e8e;--chart-grid:#221f33;--chart-rule:#2a293d;--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-pass:#10B981;--chart-breach:#FF4D6D;--chart-canvas:#0a0a14;color-scheme:dark}}\n:root[data-theme=\"dark\"]{--chart-ink:#e6e6e6;--chart-ink-muted:#a5a5a5;--chart-muted:#8e8e8e;--chart-grid:#221f33;--chart-rule:#2a293d;--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-pass:#10B981;--chart-breach:#FF4D6D;--chart-canvas:#0a0a14;color-scheme:dark}\n@media print{:root,:root:not([data-theme=\"light\"]),:root[data-theme=\"dark\"]{--chart-ink:#2d2d2d;--chart-ink-muted:#686868;--chart-muted:#787878;--chart-grid:#f1f2f7;--chart-rule:#c7c6d6;--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-pass:#059669;--chart-breach:#ba1a1a;--chart-canvas:#ffffff;color-scheme:light}}";
|
|
1277
1274
|
//#endregion
|
|
1278
1275
|
//#region src/vertical-columns.ts
|
|
1279
1276
|
function image(mark, x, y, size) {
|
|
@@ -1731,12 +1728,13 @@ function narrow(rows, layout, o) {
|
|
|
1731
1728
|
marks += barMarks(row, x0, x1, cy, o);
|
|
1732
1729
|
cy += row.tiers ? 30 : 20;
|
|
1733
1730
|
}
|
|
1734
|
-
const
|
|
1731
|
+
const facts = [
|
|
1735
1732
|
`${kn(row.solved, row.attempts)} passed`,
|
|
1736
1733
|
...drawable(row.estimate) ? [] : [estimateNote(row.estimate, row.attempts)],
|
|
1737
1734
|
...layout.cost && !(row.solved === 0 && perPass(row.cost) === null) ? [perPass(row.cost) === null ? "cost unknown" : `${costText(row.cost, row.solved)} per pass`] : [],
|
|
1738
1735
|
...layout.median ? [row.medianWallMs === null ? "run time unknown" : `${duration(row.medianWallMs)} run`] : []
|
|
1739
|
-
]
|
|
1736
|
+
];
|
|
1737
|
+
const factLines = fitLines(facts, 358, 12, "mono");
|
|
1740
1738
|
marks += textBlock(factLines, 0, cy + 12, 16, `class="tgc-ink-muted tgc-num" font-size="12"`);
|
|
1741
1739
|
body += titled(hoverText(row), marks);
|
|
1742
1740
|
y = cy + factLines.length * 16 + 4;
|
|
@@ -2187,11 +2185,6 @@ function narrowMatrix(tasks, setups, at, means, kinds, o, scaleMax) {
|
|
|
2187
2185
|
return svgRoot(358, y + lg.h + 4, body + lg.svg);
|
|
2188
2186
|
}
|
|
2189
2187
|
//#endregion
|
|
2190
|
-
//#region src/types.ts
|
|
2191
|
-
function isRefusal(value) {
|
|
2192
|
-
return "refused" in value;
|
|
2193
|
-
}
|
|
2194
|
-
//#endregion
|
|
2195
2188
|
//#region src/scalar.ts
|
|
2196
2189
|
/** Validation and display of recorded scalar observations; no statistics are computed here. */
|
|
2197
2190
|
function domainGap(domain) {
|
|
@@ -2320,6 +2313,11 @@ function timeSeries(rows, o) {
|
|
|
2320
2313
|
id,
|
|
2321
2314
|
refused: gap
|
|
2322
2315
|
};
|
|
2316
|
+
const events = eventsGap(o.marks, o.bands, o.x.domain);
|
|
2317
|
+
if (events) return {
|
|
2318
|
+
id,
|
|
2319
|
+
refused: events
|
|
2320
|
+
};
|
|
2323
2321
|
if (!rows.length || rows.every((row) => !row.points.length)) return {
|
|
2324
2322
|
id,
|
|
2325
2323
|
refused: "No trajectory observations recorded."
|
|
@@ -2354,6 +2352,8 @@ function timeSeries(rows, o) {
|
|
|
2354
2352
|
point
|
|
2355
2353
|
})));
|
|
2356
2354
|
const measured = flat.filter(({ point }) => point.y !== null);
|
|
2355
|
+
const marks = o.marks ?? [], bands = o.bands ?? [];
|
|
2356
|
+
const hasEvents = marks.length + bands.length > 0;
|
|
2357
2357
|
const facts = (row, point) => [
|
|
2358
2358
|
row.label,
|
|
2359
2359
|
`${o.x.label}: ${xLabel(point.x)}`,
|
|
@@ -2366,7 +2366,7 @@ function timeSeries(rows, o) {
|
|
|
2366
2366
|
id,
|
|
2367
2367
|
finding: `${capitalize(o.y.label)} by ${o.x.label}.`,
|
|
2368
2368
|
lede: `${measured.length} of ${flat.length} positions have a recorded value.`,
|
|
2369
|
-
read:
|
|
2369
|
+
read: `Points show recorded values. Lines connect adjacent measured positions; missing positions break the line. Whiskers show recorded intervals.${hasEvents ? ` ${EVENTS_READ}` : ""}`,
|
|
2370
2370
|
svg: {
|
|
2371
2371
|
wide: plot(rows, o, 720, xLabel, yLabel, facts),
|
|
2372
2372
|
narrow: plot(rows, o, 358, xLabel, yLabel, facts),
|
|
@@ -2392,11 +2392,11 @@ function timeSeries(rows, o) {
|
|
|
2392
2392
|
observationLabel(point.observations),
|
|
2393
2393
|
scalarIntervalLabel(point.interval, o.y.unit, o.formatY),
|
|
2394
2394
|
point.annotation ?? ""
|
|
2395
|
-
])).replace("class=\"tgc-table\"", "class=\"tgc-table tgc-scalar-table\""),
|
|
2395
|
+
])).replace("class=\"tgc-table\"", "class=\"tgc-table tgc-scalar-table\"") + eventsTable(marks, bands, o.x.label, xLabel),
|
|
2396
2396
|
note: {
|
|
2397
2397
|
method: "Recorded scalar observations. Missing positions are not interpolated; no uncertainty or statistical comparison is inferred.",
|
|
2398
2398
|
n: "Observation counts are recorded separately at each position.",
|
|
2399
|
-
exclusions: flat.filter(({ point }) => point.y === null).map(({ row, point }) => `${row.label}, ${o.x.label} ${xLabel(point.x)}: not measured.`)
|
|
2399
|
+
exclusions: [...flat.filter(({ point }) => point.y === null).map(({ row, point }) => `${row.label}, ${o.x.label} ${xLabel(point.x)}: not measured.`), ...bands.filter((band) => band.kind === "missing").map((band) => `${band.label}: no data from ${o.x.label} ${xLabel(band.from)} to ${xLabel(band.to)}.`)]
|
|
2400
2400
|
}
|
|
2401
2401
|
};
|
|
2402
2402
|
}
|
|
@@ -2422,6 +2422,7 @@ function plot(rows, o, width, xLabel, yLabel, facts) {
|
|
|
2422
2422
|
anchor: "middle",
|
|
2423
2423
|
cls: "tgc-ink-muted"
|
|
2424
2424
|
});
|
|
2425
|
+
marks += drawBands(o.bands ?? [], px, top, bottom, xLabel);
|
|
2425
2426
|
let legendY = 332;
|
|
2426
2427
|
let points = "", tips = "", rules = "";
|
|
2427
2428
|
let pointId = 0;
|
|
@@ -2441,7 +2442,7 @@ function plot(rows, o, width, xLabel, yLabel, facts) {
|
|
|
2441
2442
|
segment.push([x, y]);
|
|
2442
2443
|
const pointFacts = facts(row, point);
|
|
2443
2444
|
const key = pointId++;
|
|
2444
|
-
let dot = circle(x, y, 10, "tgc-series-hit") + circle(x, y, 3.5, "tgc-series-point");
|
|
2445
|
+
let dot = circle(x, y, 10, "tgc-series-hit") + (seriesIndex === 0 ? circle(x, y, 3.5, "tgc-series-point") : `<path class="tgc-series-point" d="${markerPath(MARKER_SHAPES[seriesIndex % MARKER_SHAPES.length], x, y, 3.5)}"/>`);
|
|
2445
2446
|
if (point.interval) dot += line(x, py(point.interval.lower), x, py(point.interval.upper), "tgc-whisker") + line(x - 4, py(point.interval.lower), x + 4, py(point.interval.lower), "tgc-whisker") + line(x - 4, py(point.interval.upper), x + 4, py(point.interval.upper), "tgc-whisker");
|
|
2446
2447
|
points += `<g class="tgc-series-observation" data-point="${key}" tabindex="0" role="img" aria-label="${esc(pointFacts.join(" · "))}"><title>${esc(pointFacts.join(" · "))}</title>${dot}</g>`;
|
|
2447
2448
|
const tipWidth = Math.min(280, width - 24), tipX = Math.max(12, Math.min(width - tipWidth - 12, x - tipWidth / 2));
|
|
@@ -2451,13 +2452,376 @@ function plot(rows, o, width, xLabel, yLabel, facts) {
|
|
|
2451
2452
|
}
|
|
2452
2453
|
flush();
|
|
2453
2454
|
const label = wrap(row.label, right - left - 32, 12, "sans", 8);
|
|
2454
|
-
marks += `<g style="stroke-dasharray:${dash}">${line(left, legendY - 4, left + 24, legendY - 4, "tgc-series-line")}</g>` + textBlock(label.lines, left + 32, legendY, 16, "class=\"tgc-ink\" font-size=\"12\"");
|
|
2455
|
+
marks += `<g style="stroke-dasharray:${dash}">${line(left, legendY - 4, left + 24, legendY - 4, "tgc-series-line")}</g>` + (rows.length > 1 ? `<path class="tgc-series-key" d="${markerPath(MARKER_SHAPES[seriesIndex % MARKER_SHAPES.length], left + 12, legendY - 4, 3.5)}"/>` : "") + textBlock(label.lines, left + 32, legendY, 16, "class=\"tgc-ink\" font-size=\"12\"");
|
|
2455
2456
|
legendY += label.lines.length * 16 + 8;
|
|
2456
2457
|
}
|
|
2457
|
-
|
|
2458
|
+
const events = drawMarks(o.marks ?? [], px, top, bottom, left, right, xLabel);
|
|
2459
|
+
return `<svg class="tgc-svg tgc-trajectories" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${legendY + 12}" viewBox="0 0 ${width} ${legendY + 12}" role="group" aria-label="${esc(o.y.label)} by ${esc(o.x.label)}"><style>${rules}</style>${marks}${events}${points}${tips}</svg>`;
|
|
2458
2460
|
}
|
|
2459
2461
|
function axisGutter(ticks, label) {
|
|
2460
2462
|
return Math.max(64, ...ticks.map((value) => Math.ceil(textWidth(label(value), 12, "mono")) + 20));
|
|
2461
2463
|
}
|
|
2462
2464
|
//#endregion
|
|
2463
|
-
|
|
2465
|
+
//#region src/timeline.ts
|
|
2466
|
+
/** Lanes of recorded spans on one time axis, with recorded marks; an open span runs to the supplied `now`. */
|
|
2467
|
+
const utcClock = (ms) => new Date(ms).toISOString().slice(11, 16);
|
|
2468
|
+
function timeline(lanes, o) {
|
|
2469
|
+
const id = o.id ?? "timeline";
|
|
2470
|
+
if (!lanes.length) return {
|
|
2471
|
+
id,
|
|
2472
|
+
refused: "No lanes recorded."
|
|
2473
|
+
};
|
|
2474
|
+
if (!o.measure?.trim()) return {
|
|
2475
|
+
id,
|
|
2476
|
+
refused: "A timeline needs a measure."
|
|
2477
|
+
};
|
|
2478
|
+
if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length) return {
|
|
2479
|
+
id,
|
|
2480
|
+
refused: "Duplicate lane ids."
|
|
2481
|
+
};
|
|
2482
|
+
if (o.now !== void 0 && !Number.isFinite(o.now)) return {
|
|
2483
|
+
id,
|
|
2484
|
+
refused: "now must be a finite time."
|
|
2485
|
+
};
|
|
2486
|
+
const spans = lanes.flatMap((lane) => lane.spans.map((span) => ({
|
|
2487
|
+
lane,
|
|
2488
|
+
span
|
|
2489
|
+
})));
|
|
2490
|
+
for (const { lane, span } of spans) {
|
|
2491
|
+
if (typeof span.state !== "string" || !span.state.trim()) return {
|
|
2492
|
+
id,
|
|
2493
|
+
refused: `${lane.label}: every span needs a state.`
|
|
2494
|
+
};
|
|
2495
|
+
if (!Number.isFinite(span.start) || span.end !== null && (!Number.isFinite(span.end) || span.end < span.start)) return {
|
|
2496
|
+
id,
|
|
2497
|
+
refused: `${lane.label}: span times must be finite and end at or after their start.`
|
|
2498
|
+
};
|
|
2499
|
+
if (span.end === null && o.now === void 0) return {
|
|
2500
|
+
id,
|
|
2501
|
+
refused: `${lane.label}: an open span needs now.`
|
|
2502
|
+
};
|
|
2503
|
+
if (span.end === null && o.now < span.start) return {
|
|
2504
|
+
id,
|
|
2505
|
+
refused: `${lane.label}: an open span starts after now.`
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
const shape = eventsGap(o.marks, o.bands);
|
|
2509
|
+
if (shape) return {
|
|
2510
|
+
id,
|
|
2511
|
+
refused: shape
|
|
2512
|
+
};
|
|
2513
|
+
const marks = o.marks ?? [], bands = o.bands ?? [];
|
|
2514
|
+
const ends = (span) => span.end ?? o.now;
|
|
2515
|
+
const times = [
|
|
2516
|
+
...spans.flatMap(({ span }) => [span.start, ends(span)]),
|
|
2517
|
+
...marks.map((m) => m.at),
|
|
2518
|
+
...bands.flatMap((b) => [b.from, b.to]),
|
|
2519
|
+
...o.now === void 0 ? [] : [o.now]
|
|
2520
|
+
];
|
|
2521
|
+
if (!times.length) return {
|
|
2522
|
+
id,
|
|
2523
|
+
refused: "No spans, marks or bands recorded."
|
|
2524
|
+
};
|
|
2525
|
+
const t0 = o.start ?? Math.min(...times), t1 = o.end ?? Math.max(...times);
|
|
2526
|
+
if (!Number.isFinite(t0) || !Number.isFinite(t1) || t1 <= t0) return {
|
|
2527
|
+
id,
|
|
2528
|
+
refused: "The window needs two finite, increasing times."
|
|
2529
|
+
};
|
|
2530
|
+
if (times.some((t) => t < t0 || t > t1)) return {
|
|
2531
|
+
id,
|
|
2532
|
+
refused: "A span, mark, band or now falls outside the recorded window."
|
|
2533
|
+
};
|
|
2534
|
+
const fmt = o.formatTime ?? utcClock;
|
|
2535
|
+
const hatched = new Set(o.hatch ?? ["missing", "untraced"]), muted = new Set(o.muted ?? ["idle"]);
|
|
2536
|
+
const open = spans.filter(({ span }) => span.end === null).length;
|
|
2537
|
+
const noData = spans.filter(({ span }) => hatched.has(span.state));
|
|
2538
|
+
const noDataMs = noData.reduce((sum, { span }) => sum + ends(span) - span.start, 0);
|
|
2539
|
+
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}` : ""}`;
|
|
2540
|
+
const draw = (width) => {
|
|
2541
|
+
const phone = width === 358, left = phone ? 12 : 150, right = width - 16;
|
|
2542
|
+
const top = marks.length ? 8 + Math.min(3, marks.length) * 14 + 4 : 8;
|
|
2543
|
+
const rowH = phone ? 38 : 24, bottom = top + lanes.length * rowH;
|
|
2544
|
+
const px = (t) => left + (t - t0) / (t1 - t0) * (right - left);
|
|
2545
|
+
let body = drawBands(bands, px, top, bottom, fmt);
|
|
2546
|
+
for (let i = 0; i <= 4; i++) {
|
|
2547
|
+
const x = left + (right - left) * i / 4;
|
|
2548
|
+
body += line(x, top, x, bottom, "tgc-gridline") + text(x, bottom + 16, fmt(t0 + (t1 - t0) * i / 4), {
|
|
2549
|
+
size: 11,
|
|
2550
|
+
cls: "tgc-ink-muted tgc-num",
|
|
2551
|
+
anchor: i === 0 ? "start" : i === 4 ? "end" : "middle"
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
lanes.forEach((lane, i) => {
|
|
2555
|
+
const y = top + i * rowH;
|
|
2556
|
+
const label = wrap(lane.label, phone ? width - 24 : left - 10, phone ? 11 : 12, "sans", 1);
|
|
2557
|
+
body += titled(lane.label, text(phone ? 12 : 2, y + (phone ? 12 : 16), label.lines[0], { size: phone ? 11 : 12 }));
|
|
2558
|
+
const barY = y + (phone ? 18 : 5), barH = phone ? 14 : 14;
|
|
2559
|
+
for (const span of lane.spans) {
|
|
2560
|
+
const x = px(span.start), w = Math.max(1, px(ends(span)) - x);
|
|
2561
|
+
const mark = hatched.has(span.state) ? hatch(x, barY, w, barH, "tgc-span tgc-span-missing") : rect(x, barY, w, barH, `tgc-span${muted.has(span.state) ? " tgc-span-muted" : ""}${span.end === null ? " tgc-span-open" : ""}`, "rx=\"2\"");
|
|
2562
|
+
body += titled(spanLabel(lane, span), `<g data-state="${span.state.replace(/[^a-z0-9-]/gi, "")}"${span.end === null ? " data-open=\"true\"" : ""}>${mark}</g>`);
|
|
2563
|
+
}
|
|
2564
|
+
});
|
|
2565
|
+
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)}`, {
|
|
2566
|
+
size: 11,
|
|
2567
|
+
cls: "tgc-ink tgc-num",
|
|
2568
|
+
anchor: px(o.now) > (left + right) / 2 ? "end" : "start"
|
|
2569
|
+
});
|
|
2570
|
+
body += drawMarks(marks, px, 4, bottom, left, right, fmt);
|
|
2571
|
+
return svgRoot(width, bottom + (o.now === void 0 ? 22 : 36), body);
|
|
2572
|
+
};
|
|
2573
|
+
return {
|
|
2574
|
+
id,
|
|
2575
|
+
finding: `${capitalize(o.measure)} across ${lanes.length} ${plural(lanes.length, "lane")}, ${fmt(t0)} to ${fmt(t1)}.`,
|
|
2576
|
+
lede: `${spans.length} recorded ${plural(spans.length, "span")}; ${open} open${o.now === void 0 ? "" : ` at ${fmt(o.now)}`}; ${noData.length} without data (${duration(noDataMs)}).`,
|
|
2577
|
+
read: `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.${marks.length + bands.length ? ` ${EVENTS_READ}` : ""}`,
|
|
2578
|
+
svg: {
|
|
2579
|
+
wide: draw(720),
|
|
2580
|
+
narrow: draw(358)
|
|
2581
|
+
},
|
|
2582
|
+
table: table(`${capitalize(o.measure)} by lane`, [
|
|
2583
|
+
{ label: "Lane" },
|
|
2584
|
+
{ label: "State" },
|
|
2585
|
+
{
|
|
2586
|
+
label: "Start",
|
|
2587
|
+
numeric: true
|
|
2588
|
+
},
|
|
2589
|
+
{
|
|
2590
|
+
label: "End",
|
|
2591
|
+
numeric: true
|
|
2592
|
+
},
|
|
2593
|
+
{
|
|
2594
|
+
label: "Duration",
|
|
2595
|
+
numeric: true
|
|
2596
|
+
},
|
|
2597
|
+
{ label: "Note" }
|
|
2598
|
+
], spans.map(({ lane, span }) => [
|
|
2599
|
+
lane.label,
|
|
2600
|
+
span.state,
|
|
2601
|
+
fmt(span.start),
|
|
2602
|
+
span.end === null ? "open" : fmt(span.end),
|
|
2603
|
+
duration(ends(span) - span.start),
|
|
2604
|
+
span.note ?? ""
|
|
2605
|
+
])) + eventsTable(marks, bands, "time", fmt),
|
|
2606
|
+
note: {
|
|
2607
|
+
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.",
|
|
2608
|
+
n: `${spans.length} spans in ${lanes.length} lanes.`,
|
|
2609
|
+
exclusions: noData.map(({ lane, span }) => `${lane.label}: ${span.state} ${fmt(span.start)} to ${span.end === null ? "now" : fmt(span.end)} has no data.`)
|
|
2610
|
+
}
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
//#endregion
|
|
2614
|
+
//#region src/small-multiples.ts
|
|
2615
|
+
/**
|
|
2616
|
+
* One small trajectory per arm on shared axes. A panel without data is a
|
|
2617
|
+
* hatched "no data: <reason>" panel, never a zero line; a panel that stopped
|
|
2618
|
+
* early ends at its recorded end mark.
|
|
2619
|
+
*/
|
|
2620
|
+
function smallMultiples(panels, o) {
|
|
2621
|
+
const id = o.id ?? "small-multiples";
|
|
2622
|
+
if (o.shared !== "both" && o.shared !== "x") return {
|
|
2623
|
+
id,
|
|
2624
|
+
refused: "Small multiples need shared axes: both or x."
|
|
2625
|
+
};
|
|
2626
|
+
const axis = domainGap(o.x.domain) ?? domainGap(o.y.domain);
|
|
2627
|
+
if (axis) return {
|
|
2628
|
+
id,
|
|
2629
|
+
refused: axis
|
|
2630
|
+
};
|
|
2631
|
+
if (!o.y.unit?.trim()) return {
|
|
2632
|
+
id,
|
|
2633
|
+
refused: "Small multiples need a y unit."
|
|
2634
|
+
};
|
|
2635
|
+
if (!panels.length) return {
|
|
2636
|
+
id,
|
|
2637
|
+
refused: "No panels recorded."
|
|
2638
|
+
};
|
|
2639
|
+
if (new Set(panels.map((p) => p.id)).size !== panels.length) return {
|
|
2640
|
+
id,
|
|
2641
|
+
refused: "Duplicate panel ids."
|
|
2642
|
+
};
|
|
2643
|
+
for (const panel of panels) {
|
|
2644
|
+
if (panel.unit !== void 0 && panel.unit !== o.y.unit) return {
|
|
2645
|
+
id,
|
|
2646
|
+
refused: `Mixed units: ${panel.label} records ${panel.unit}, the figure draws ${o.y.unit}.`
|
|
2647
|
+
};
|
|
2648
|
+
if (panel.missing) {
|
|
2649
|
+
if (!panel.missing.why?.trim()) return {
|
|
2650
|
+
id,
|
|
2651
|
+
refused: `${panel.label}: a missing panel needs its reason.`
|
|
2652
|
+
};
|
|
2653
|
+
if (panel.points?.length || panel.end) return {
|
|
2654
|
+
id,
|
|
2655
|
+
refused: `${panel.label}: a missing panel cannot also carry points or an end.`
|
|
2656
|
+
};
|
|
2657
|
+
continue;
|
|
2658
|
+
}
|
|
2659
|
+
if (!panel.points?.length) return {
|
|
2660
|
+
id,
|
|
2661
|
+
refused: `${panel.label}: a panel needs points or a missing reason.`
|
|
2662
|
+
};
|
|
2663
|
+
const domain = domainOf(panel, o);
|
|
2664
|
+
if (!domain) return {
|
|
2665
|
+
id,
|
|
2666
|
+
refused: `${panel.label}: an x-only shared figure needs each panel's y domain.`
|
|
2667
|
+
};
|
|
2668
|
+
const own = domainGap(domain);
|
|
2669
|
+
if (own) return {
|
|
2670
|
+
id,
|
|
2671
|
+
refused: `${panel.label}: ${own}`
|
|
2672
|
+
};
|
|
2673
|
+
let previous = -Infinity;
|
|
2674
|
+
for (const point of panel.points) {
|
|
2675
|
+
if (!Number.isFinite(point.x) || point.x <= previous || point.x < o.x.domain[0] || point.x > o.x.domain[1]) return {
|
|
2676
|
+
id,
|
|
2677
|
+
refused: `${panel.label}: x values must be finite, strictly increasing and inside the shared domain.`
|
|
2678
|
+
};
|
|
2679
|
+
previous = point.x;
|
|
2680
|
+
const problem = scalarGap(point.y, point.interval, point.observations, domain);
|
|
2681
|
+
if (problem) return {
|
|
2682
|
+
id,
|
|
2683
|
+
refused: `${panel.label} at ${point.x}: ${problem}`
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
if (panel.end) {
|
|
2687
|
+
const endGap = eventsGap([{
|
|
2688
|
+
at: panel.end.at,
|
|
2689
|
+
kind: panel.end.kind,
|
|
2690
|
+
label: panel.end.label ?? panel.end.kind
|
|
2691
|
+
}], [], o.x.domain);
|
|
2692
|
+
if (endGap) return {
|
|
2693
|
+
id,
|
|
2694
|
+
refused: `${panel.label}: ${endGap}`
|
|
2695
|
+
};
|
|
2696
|
+
if (previous > panel.end.at) return {
|
|
2697
|
+
id,
|
|
2698
|
+
refused: `${panel.label}: points are recorded after the panel's end.`
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
const markGap = eventsGap(panel.marks, [], o.x.domain);
|
|
2702
|
+
if (markGap) return {
|
|
2703
|
+
id,
|
|
2704
|
+
refused: `${panel.label}: ${markGap}`
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
const noun = o.noun?.trim() || "panel";
|
|
2708
|
+
const xLabel = (x) => o.formatX ? o.formatX(x) : String(x);
|
|
2709
|
+
const yLabel = (y) => scalarLabel(y, o.y.unit, o.formatY);
|
|
2710
|
+
const withData = panels.filter((p) => !p.missing), ended = withData.filter((p) => p.end);
|
|
2711
|
+
const marksOf = (p) => [...p.marks ?? [], ...p.end ? [{
|
|
2712
|
+
at: p.end.at,
|
|
2713
|
+
kind: p.end.kind,
|
|
2714
|
+
label: p.end.label ?? p.end.kind
|
|
2715
|
+
}] : []];
|
|
2716
|
+
const rows = panels.flatMap((p) => p.missing ? [[
|
|
2717
|
+
p.label,
|
|
2718
|
+
"",
|
|
2719
|
+
`no data: ${p.missing.why}`,
|
|
2720
|
+
p.missing.detail ?? ""
|
|
2721
|
+
]] : p.points.map((pt) => [
|
|
2722
|
+
p.label,
|
|
2723
|
+
xLabel(pt.x),
|
|
2724
|
+
pt.y === null ? "not measured" : yLabel(pt.y),
|
|
2725
|
+
observationLabel(pt.observations)
|
|
2726
|
+
]));
|
|
2727
|
+
const events = panels.flatMap((p) => marksOf(p).map((m) => ({
|
|
2728
|
+
...m,
|
|
2729
|
+
label: `${p.label}: ${m.label}`
|
|
2730
|
+
})));
|
|
2731
|
+
return {
|
|
2732
|
+
id,
|
|
2733
|
+
finding: `${capitalize(o.y.label)} by ${o.x.label}, one panel per ${noun}, on ${o.shared === "both" ? "shared axes" : "a shared x axis"}.`,
|
|
2734
|
+
lede: `${withData.length} of ${panels.length} ${plural(panels.length, noun)} have data${ended.length ? `; ${ended.length} ended before the window closed` : ""}.`,
|
|
2735
|
+
read: `Each panel is one ${noun} on ${o.shared === "both" ? "the same x and y axes" : "the same x axis; y axes differ per panel"}. Hatched panels have no data and name the reason. A vertical rule with a cross marks where a line ended.`,
|
|
2736
|
+
svg: {
|
|
2737
|
+
wide: draw(panels, o, 720, xLabel, yLabel, marksOf),
|
|
2738
|
+
narrow: draw(panels, o, 358, xLabel, yLabel, marksOf)
|
|
2739
|
+
},
|
|
2740
|
+
table: table(`${capitalize(o.y.label)} by ${o.x.label} and ${noun}`, [
|
|
2741
|
+
{ label: capitalize(noun) },
|
|
2742
|
+
{
|
|
2743
|
+
label: o.x.label,
|
|
2744
|
+
numeric: true
|
|
2745
|
+
},
|
|
2746
|
+
{
|
|
2747
|
+
label: o.y.label,
|
|
2748
|
+
numeric: true
|
|
2749
|
+
},
|
|
2750
|
+
{ label: "Observations or detail" }
|
|
2751
|
+
], rows) + eventsTable(events, [], o.x.label, xLabel),
|
|
2752
|
+
note: {
|
|
2753
|
+
method: "Recorded values per panel on explicit axis domains. Missing panels and positions are not interpolated or drawn as zero; no ranking is made.",
|
|
2754
|
+
n: `${withData.length} of ${panels.length} ${plural(panels.length, noun)} with data.`,
|
|
2755
|
+
exclusions: [...panels.filter((p) => p.missing).map((p) => `${p.label}: no data: ${p.missing.why}${p.missing.detail ? ` (${p.missing.detail})` : ""}.`), ...ended.map((p) => `${p.label}: ${p.end.label ?? p.end.kind} at ${o.x.label} ${xLabel(p.end.at)}.`)]
|
|
2756
|
+
}
|
|
2757
|
+
};
|
|
2758
|
+
}
|
|
2759
|
+
function domainOf(panel, o) {
|
|
2760
|
+
return o.shared === "both" ? o.y.domain : panel.domain ?? null;
|
|
2761
|
+
}
|
|
2762
|
+
function draw(panels, o, width, xLabel, yLabel, marksOf) {
|
|
2763
|
+
const cols = width === 358 ? 1 : Math.min(3, panels.length), gap = 16;
|
|
2764
|
+
const panelW = (width - gap * (cols - 1)) / cols, panelH = width === 358 ? 150 : 170;
|
|
2765
|
+
let body = "";
|
|
2766
|
+
panels.forEach((panel, i) => {
|
|
2767
|
+
const ox = i % cols * (panelW + gap), oy = Math.floor(i / cols) * (panelH + gap);
|
|
2768
|
+
const domain = domainOf(panel, o) ?? o.y.domain;
|
|
2769
|
+
const left = ox + Math.max(...[domain[0], domain[1]].map((v) => Math.ceil(textWidth(yLabel(v), 10, "mono")) + 10), 40), right = ox + panelW - 6;
|
|
2770
|
+
const top = oy + 40, bottom = oy + panelH - 24;
|
|
2771
|
+
const px = (x) => left + (x - o.x.domain[0]) / (o.x.domain[1] - o.x.domain[0]) * (right - left);
|
|
2772
|
+
const py = (y) => bottom - (y - domain[0]) / (domain[1] - domain[0]) * (bottom - top);
|
|
2773
|
+
const name = wrap(panel.label, panelW - 4, 12, "sans-semibold", 1);
|
|
2774
|
+
body += titled(panel.label, text(ox + 2, oy + 14, name.lines[0], {
|
|
2775
|
+
size: 12,
|
|
2776
|
+
weight: 600
|
|
2777
|
+
}));
|
|
2778
|
+
if (panel.missing) {
|
|
2779
|
+
body += `<g data-panel="${esc(panel.id)}" data-missing="true">${hatch(ox, top - 12, panelW, bottom - top + 12, "tgc-panel-missing")}`;
|
|
2780
|
+
const why = wrap(`no data: ${panel.missing.why}`, panelW - 24, 12, "sans", 3);
|
|
2781
|
+
body += titled(`${panel.label}: no data: ${panel.missing.why}${panel.missing.detail ? ` (${panel.missing.detail})` : ""}`, textBlock(why.lines, ox + panelW / 2, (top + bottom) / 2 - (why.lines.length - 1) * 8, 16, "class=\"tgc-mark-label\" font-size=\"12\" text-anchor=\"middle\""));
|
|
2782
|
+
body += "</g>";
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
body += `<g data-panel="${esc(panel.id)}">`;
|
|
2786
|
+
for (const [j, y] of [
|
|
2787
|
+
domain[0],
|
|
2788
|
+
(domain[0] + domain[1]) / 2,
|
|
2789
|
+
domain[1]
|
|
2790
|
+
].entries()) {
|
|
2791
|
+
body += line(left, py(y), right, py(y), j === 0 ? "tgc-axis" : "tgc-gridline");
|
|
2792
|
+
if (j !== 1) body += text(left - 6, py(y) + 4, yLabel(y), {
|
|
2793
|
+
size: 10,
|
|
2794
|
+
anchor: "end",
|
|
2795
|
+
cls: "tgc-ink-muted tgc-num"
|
|
2796
|
+
});
|
|
2797
|
+
}
|
|
2798
|
+
body += text(left, bottom + 16, xLabel(o.x.domain[0]), {
|
|
2799
|
+
size: 10,
|
|
2800
|
+
cls: "tgc-ink-muted tgc-num"
|
|
2801
|
+
}) + text(right, bottom + 16, xLabel(o.x.domain[1]), {
|
|
2802
|
+
size: 10,
|
|
2803
|
+
anchor: "end",
|
|
2804
|
+
cls: "tgc-ink-muted tgc-num"
|
|
2805
|
+
});
|
|
2806
|
+
let segment = [];
|
|
2807
|
+
const flush = () => {
|
|
2808
|
+
if (segment.length > 1) body += polyline(segment, "tgc-series-line");
|
|
2809
|
+
segment = [];
|
|
2810
|
+
};
|
|
2811
|
+
for (const point of panel.points) {
|
|
2812
|
+
if (point.y === null) {
|
|
2813
|
+
flush();
|
|
2814
|
+
continue;
|
|
2815
|
+
}
|
|
2816
|
+
segment.push([px(point.x), py(point.y)]);
|
|
2817
|
+
body += titled(`${panel.label} · ${o.x.label} ${xLabel(point.x)} · ${yLabel(point.y)} · ${observationLabel(point.observations)}`, circle(px(point.x), py(point.y), 2.5, "tgc-series-point"));
|
|
2818
|
+
}
|
|
2819
|
+
flush();
|
|
2820
|
+
body += drawMarks(marksOf(panel), px, top - 12, bottom, left, right, xLabel);
|
|
2821
|
+
body += "</g>";
|
|
2822
|
+
});
|
|
2823
|
+
const rows = Math.ceil(panels.length / cols);
|
|
2824
|
+
return svgRoot(width, rows * panelH + (rows - 1) * gap, body);
|
|
2825
|
+
}
|
|
2826
|
+
//#endregion
|
|
2827
|
+
export { breakdown, chartTokensCss, comparisonIntervals, costFrontier, figureCss, heatmap, isRefusal, latencyRange, metricBars, phaseWaterfall, rankedRates, rateOrder, renderFigure, renderNotes, renderRefusals, smallMultiples, taskMatrix, timeSeries, timeline };
|