@tangle-network/charts 0.7.0 → 0.9.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 +64 -7
- package/dist/figure.css +96 -2
- package/dist/index.d.ts +39 -4
- package/dist/index.js +732 -377
- package/dist/react/index.d.ts +12 -6
- package/dist/react/index.js +57 -7
- package/dist/react/live.css +20 -1
- package/dist/timeline-BAwebuqb.js +1185 -0
- package/dist/timeline-CNHABOS1.d.ts +293 -0
- package/package.json +1 -1
- package/dist/events-Cw6abvgR.js +0 -491
- package/dist/timeline-BAjzF-wL.d.ts +0 -183
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as
|
|
1
|
+
import { $ as esc, A as pts, B as MARKER_SHAPES, C as duration, D as kn, E as intervalName, F as tierSummary, G as line, H as hatch, I as tierTotal, J as rect, K as markerPath, L as usd, M as rateIntervalGap, N as rateOrder, O as pct, P as tierAtLeast, Q as capitalize, R as usdTick, S as drawable, T as intervalGap, V as circle, W as hbar, X as text, Y as svgRoot, Z as titled, a as checkerClass, at as wrap, b as TIERS_BEST_FIRST, d as drawGroups, et as fitLines, f as drawMarks, g as markLabelRows, h as groupRowHeight, i as OUTCOME_ORDER, it as textWidth, j as rateDataGap, k as perPass, l as MARK_VERDICTS, m as eventsTable, n as CHECKER_HUES, nt as plural, o as checkerColor, p as eventsGap, q as polyline, r as OUTCOME_COLORS, rt as textBlock, s as EVENTS_READ, t as timeline, tt as listJoin, u as drawBands, v as isRefusal, w as estimateNote, x as costText, y as METHOD_NAME, z as table } from "./timeline-BAwebuqb.js";
|
|
2
2
|
//#region src/block.ts
|
|
3
3
|
/**
|
|
4
4
|
* The figure block: one markup for every figure on every surface.
|
|
@@ -526,138 +526,6 @@ function draw$2(rows, total, width, short) {
|
|
|
526
526
|
return svgRoot(width, top + rows.length * rowH + 2, body);
|
|
527
527
|
}
|
|
528
528
|
//#endregion
|
|
529
|
-
//#region src/format.ts
|
|
530
|
-
const TIERS_BEST_FIRST = [
|
|
531
|
-
"S",
|
|
532
|
-
"A",
|
|
533
|
-
"B",
|
|
534
|
-
"C",
|
|
535
|
-
"F"
|
|
536
|
-
];
|
|
537
|
-
const LADDER = [
|
|
538
|
-
"F",
|
|
539
|
-
"C",
|
|
540
|
-
"B",
|
|
541
|
-
"A",
|
|
542
|
-
"S"
|
|
543
|
-
];
|
|
544
|
-
function tierAtLeast(tier, floor) {
|
|
545
|
-
return LADDER.indexOf(tier) >= LADDER.indexOf(floor);
|
|
546
|
-
}
|
|
547
|
-
function pct(value) {
|
|
548
|
-
return `${Math.round(value * 100)}%`;
|
|
549
|
-
}
|
|
550
|
-
/** A difference in points: "+25 pts". */
|
|
551
|
-
function pts(value) {
|
|
552
|
-
const n = Math.round(value * 100);
|
|
553
|
-
return `${n > 0 ? "+" : n < 0 ? "−" : ""}${Math.abs(n)} pts`;
|
|
554
|
-
}
|
|
555
|
-
function usd(value) {
|
|
556
|
-
if (value >= 100) return `$${Math.round(value).toLocaleString("en-US")}`;
|
|
557
|
-
if (value >= 1) return `$${value.toFixed(2)}`;
|
|
558
|
-
if (value >= .01) return `$${value.toFixed(3)}`;
|
|
559
|
-
return `$${value.toFixed(4)}`;
|
|
560
|
-
}
|
|
561
|
-
/** Axis ticks: "$30", "$2.5", "$0.05". */
|
|
562
|
-
function usdTick(value) {
|
|
563
|
-
return `$${Number(value.toPrecision(2)).toLocaleString("en-US", { maximumFractionDigits: 4 })}`;
|
|
564
|
-
}
|
|
565
|
-
function duration(ms) {
|
|
566
|
-
const s = ms / 1e3;
|
|
567
|
-
if (s < 60) return `${Math.round(s)} s`;
|
|
568
|
-
if (s < 3600) return `${(s / 60).toFixed(1)} min`;
|
|
569
|
-
return `${(s / 3600).toFixed(1)} h`;
|
|
570
|
-
}
|
|
571
|
-
function kn(k, n) {
|
|
572
|
-
return `${k}/${n}`;
|
|
573
|
-
}
|
|
574
|
-
const METHOD_NAME = {
|
|
575
|
-
"task-cluster-bootstrap": "task-clustered bootstrap",
|
|
576
|
-
"mcnemar-exact": "exact McNemar",
|
|
577
|
-
wilson: "Wilson score"
|
|
578
|
-
};
|
|
579
|
-
/** "95% Wilson score" */
|
|
580
|
-
function intervalName(interval) {
|
|
581
|
-
return `${Math.round((interval.level ?? 0) * 100)}% ${interval.method ? METHOD_NAME[interval.method] : ""}`.trim();
|
|
582
|
-
}
|
|
583
|
-
/** Why an interval cannot be drawn, or null when it can. */
|
|
584
|
-
function intervalGap(interval) {
|
|
585
|
-
if (!interval) return "no interval";
|
|
586
|
-
const missing = [interval.level === null ? "level" : "", interval.method === null ? "method" : ""].filter(Boolean);
|
|
587
|
-
if (missing.length) return `the interval names no ${missing.join(" or ")}`;
|
|
588
|
-
if (!(interval.method in METHOD_NAME)) return "the interval method is unknown";
|
|
589
|
-
if (!Number.isFinite(interval.level) || interval.level <= 0 || interval.level >= 1) return "the interval level is outside 0–1";
|
|
590
|
-
if (!Number.isFinite(interval.lower) || !Number.isFinite(interval.upper)) return "the interval bounds are not finite";
|
|
591
|
-
if (!(interval.lower <= interval.upper)) return "the interval bounds are out of order";
|
|
592
|
-
return null;
|
|
593
|
-
}
|
|
594
|
-
/** Whether a rate bar may be drawn for this estimate. */
|
|
595
|
-
function drawable(estimate) {
|
|
596
|
-
return estimate === "descriptive" || estimate === "bootstrap";
|
|
597
|
-
}
|
|
598
|
-
function estimateNote(estimate, units) {
|
|
599
|
-
if (estimate === "none") return units === 1 ? "1 unit" : `${units} units`;
|
|
600
|
-
if (estimate === "insufficient") return "too few units";
|
|
601
|
-
return "";
|
|
602
|
-
}
|
|
603
|
-
/**
|
|
604
|
-
* The positive cost per pass this figure can print or place, or null.
|
|
605
|
-
* A zero value needs a separate verified-free representation; this field alone
|
|
606
|
-
* cannot distinguish a free run from missing spend, so it prints "unknown".
|
|
607
|
-
*/
|
|
608
|
-
function perPass(cost) {
|
|
609
|
-
return cost.basis !== "unknown" && cost.perSolvedUsd !== null && Number.isFinite(cost.perSolvedUsd) && cost.perSolvedUsd > 0 ? cost.perSolvedUsd : null;
|
|
610
|
-
}
|
|
611
|
-
/** "$23.76", "$23.76 est.", "unknown", "no pass" */
|
|
612
|
-
function costText(cost, solved) {
|
|
613
|
-
if (solved === 0) return "no pass";
|
|
614
|
-
const value = perPass(cost);
|
|
615
|
-
if (value === null) return "unknown";
|
|
616
|
-
return cost.basis === "receipts" ? usd(value) : `${usd(value)} est.`;
|
|
617
|
-
}
|
|
618
|
-
/** Wilson can describe an eligible rate; clustered bootstrap needs the producer's bootstrap class. */
|
|
619
|
-
function rateIntervalGap(row) {
|
|
620
|
-
if (!drawable(row.estimate)) return `${row.estimate} estimate`;
|
|
621
|
-
if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0–1";
|
|
622
|
-
const gap = intervalGap(row.interval);
|
|
623
|
-
if (gap) return gap;
|
|
624
|
-
if (row.interval.method === "mcnemar-exact") return "exact McNemar applies to paired comparisons, not rates";
|
|
625
|
-
if (row.estimate === "descriptive" && row.interval.method !== "wilson") return `descriptive estimate does not support ${intervalName(row.interval)}`;
|
|
626
|
-
if (row.interval.lower < 0 || row.interval.upper > 1) return "the rate interval is outside 0–1";
|
|
627
|
-
if (row.rate < row.interval.lower - 1e-6 || row.rate > row.interval.upper + 1e-6) return "the rate falls outside its interval";
|
|
628
|
-
return null;
|
|
629
|
-
}
|
|
630
|
-
/** Check the source arithmetic before using a rate in a finding or a mark. */
|
|
631
|
-
function rateDataGap(row) {
|
|
632
|
-
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";
|
|
633
|
-
if (!Number.isFinite(row.rate) || row.rate < 0 || row.rate > 1) return "rate is outside 0–1";
|
|
634
|
-
if (row.attempts > 0 && Math.abs(row.rate - row.solved / row.attempts) > 1e-6) return "rate disagrees with passed/attempts";
|
|
635
|
-
if (row.attempts === 0 && row.rate !== 0) return "rate has no attempts";
|
|
636
|
-
if (drawable(row.estimate) && row.attempts === 0) return "drawable estimate has no attempts";
|
|
637
|
-
return null;
|
|
638
|
-
}
|
|
639
|
-
/**
|
|
640
|
-
* The order rows are drawn in. Ranked rows keep their rank. Otherwise rows
|
|
641
|
-
* with enough units to draw a rate come first, highest rate first; rows with
|
|
642
|
-
* too few units follow, most units first, so a 1/1 row never heads the list.
|
|
643
|
-
*/
|
|
644
|
-
function rateOrder(rows) {
|
|
645
|
-
if (rows.length > 0 && rows.every((row) => row.rank !== null && row.estimate === "bootstrap")) return [...rows].sort((a, b) => a.rank - b.rank);
|
|
646
|
-
const drawn = rows.filter((row) => drawable(row.estimate)).sort((a, b) => b.rate - a.rate);
|
|
647
|
-
const counted = rows.filter((row) => !drawable(row.estimate)).sort((a, b) => b.attempts - a.attempts);
|
|
648
|
-
return [...drawn, ...counted];
|
|
649
|
-
}
|
|
650
|
-
/** "2A 1F" */
|
|
651
|
-
function tierSummary(tiers, unmeasured = 0, flagged = 0) {
|
|
652
|
-
const parts = tiers ? TIERS_BEST_FIRST.filter((t) => tiers[t] > 0).map((t) => `${tiers[t]}${t}`) : [];
|
|
653
|
-
if (flagged) parts.push(`${flagged} flagged`);
|
|
654
|
-
if (unmeasured) parts.push(`${unmeasured}U`);
|
|
655
|
-
return parts.join(" ");
|
|
656
|
-
}
|
|
657
|
-
function tierTotal(tiers) {
|
|
658
|
-
return tiers ? LADDER.reduce((sum, t) => sum + tiers[t], 0) : 0;
|
|
659
|
-
}
|
|
660
|
-
//#endregion
|
|
661
529
|
//#region src/scale.ts
|
|
662
530
|
function linear(domain, range) {
|
|
663
531
|
const [d0, d1] = domain;
|
|
@@ -1268,7 +1136,7 @@ function plot$1(costed, unplaced, drawLine, o) {
|
|
|
1268
1136
|
//#endregion
|
|
1269
1137
|
//#region src/css.generated.ts
|
|
1270
1138
|
/** The figure block and mark styles (src/figure.css), for documents that inline their CSS. */
|
|
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";
|
|
1139
|
+
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(--tgc-series, var(--chart-accent)); stroke-width: 2; stroke-linejoin: round; }\n.tgc-series-point, .tgc-series-key { fill: var(--tgc-series, 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-release { stroke: var(--chart-ink-muted); }\n.tgc-mark-steer { stroke: var(--chart-accent); stroke-dasharray: 1 3; stroke-linecap: round; }\n.tgc-mark-ruling { stroke: var(--chart-ink); stroke-dasharray: 6 2 1 2; }\n.tgc-mark-wake { stroke: var(--chart-ink-muted); stroke-dasharray: 1 3; stroke-linecap: round; }\n.tgc-mark-head { fill: var(--chart-ink-muted); stroke: var(--chart-canvas); stroke-width: 1; }\n[data-mark=\"steer\"] .tgc-mark-head { fill: var(--chart-accent); }\n[data-mark=\"ruling\"] .tgc-mark-head { fill: var(--chart-ink); }\n/* A recorded verdict colours the rule and head: pass and fail take the status tones, unknown the muted tone. */\n.tgc-verdict-pass .tgc-mark { stroke: var(--chart-pass, var(--status-running)); }\n.tgc-verdict-pass .tgc-mark-head { fill: var(--chart-pass, var(--status-running)); }\n.tgc-verdict-fail .tgc-mark { stroke: var(--chart-breach, var(--status-error)); }\n.tgc-verdict-fail .tgc-mark-head { fill: var(--chart-breach, var(--status-error)); }\n.tgc-verdict-unknown .tgc-mark { stroke: var(--chart-muted); }\n.tgc-verdict-unknown .tgc-mark-head { fill: var(--chart-muted); }\n.tgc-mark-label { fill: var(--chart-ink); paint-order: stroke; stroke: var(--chart-canvas); stroke-width: 3px; stroke-linejoin: round; }\n.tgc-mark-edge { fill: var(--chart-ink-muted); }\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/* A system-wide wall, such as a quota window: a breach wash across every lane, topped by a solid edge. */\n.tgc-band-blocked { fill: color-mix(in srgb, var(--chart-breach, var(--status-error)) 9%, transparent); }\n.tgc-band-wall { stroke: var(--chart-breach, var(--status-error)); stroke-width: 2; }\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/* Timeline trees and attempts. Ok ticks rise above the bar and failed ticks drop below it, so they read without colour. */\n.tgc-tree { fill: none; stroke: var(--chart-muted); stroke-width: 1; }\n.tgc-caret { fill: var(--chart-ink-muted); }\n.tgc-lane-toggle { cursor: pointer; }\n.tgc-lane-toggle:focus-visible { outline: none; }\n.tgc-lane-hit { fill: transparent; stroke: transparent; stroke-width: 2; }\n.tgc-lane-toggle:hover .tgc-lane-hit { fill: var(--chart-shade); }\n.tgc-lane-toggle:focus-visible .tgc-lane-hit { stroke: var(--chart-accent); }\n.tgc-tick { stroke-width: 1.5; }\n.tgc-tick-ok { stroke: var(--chart-outcome-ok); }\n.tgc-tick-quota { stroke: var(--chart-outcome-quota); }\n.tgc-tick-lost { stroke: var(--chart-outcome-lost); }\n.tgc-tick-rate-limit { stroke: var(--chart-outcome-rate-limit); }\n.tgc-tick-oom { stroke: var(--chart-outcome-oom); }\n.tgc-tick-refused { stroke: var(--chart-outcome-refused); }\n.tgc-tick-aborted, .tgc-tick-other { stroke: var(--chart-outcome-neutral); }\n/* Lane labels and counts keep a canvas halo, so rules and ticks under them never strike through the text. */\n.tgc-lane-label, .tgc-count { paint-order: stroke; stroke: var(--chart-canvas); stroke-width: 3px; stroke-linejoin: round; }\n.tgc-count { fill: var(--chart-ink); }\n.tgc-count-ok { fill: var(--chart-pass, var(--status-running)); }\n.tgc-count-failed { fill: var(--chart-breach, var(--status-error)); }\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\n/*\n * One fixed palette for attempt outcomes and check versions in every view (src/outcomes.ts names them). Each is a\n * light-dark() pair picked by the page's color-scheme. Outcomes pass the dataviz colour-vision checks as adjacent\n * pairs in stack order (ok, quota, lost, rate-limit, oom, refused) on the light surface and on #161616; checkers pass\n * as adjacent series (current, then older). `scripts/palette-check.mjs` re-runs the validator. A page may override\n * any of these properties; :where() keeps them at zero specificity.\n */\n:where(:root) {\n --chart-outcome-ok: light-dark(#2a78d6, #3987e5);\n --chart-outcome-quota: light-dark(#eb6834, #d95926);\n --chart-outcome-lost: light-dark(#1baf7a, #199e70);\n --chart-outcome-rate-limit: light-dark(#eda100, #c98500);\n --chart-outcome-oom: light-dark(#e87ba4, #d55181);\n --chart-outcome-refused: light-dark(#6250d6, #9085e9);\n --chart-outcome-neutral: #898781;\n --chart-checker-0: light-dark(#2a78d6, #3987e5);\n --chart-checker-1: light-dark(#1baf7a, #199e70);\n --chart-checker-2: light-dark(#6250d6, #9085e9);\n --chart-checker-3: light-dark(#eda100, #c98500);\n}\n/* A series drawn in a checker's hue; past the fourth hue it is muted and named by its label. */\n.tgc-checker-0 { --tgc-series: var(--chart-checker-0); }\n.tgc-checker-1 { --tgc-series: var(--chart-checker-1); }\n.tgc-checker-2 { --tgc-series: var(--chart-checker-2); }\n.tgc-checker-3 { --tgc-series: var(--chart-checker-3); }\n.tgc-checker-other { --tgc-series: var(--chart-muted); }\n/* Recorded verdicts: a pass is filled, a fail or an incomparable value is an outline, an undecided one dashed. */\n.tgc-series-point.tgc-point-outline { fill: var(--chart-canvas); stroke: var(--tgc-series, var(--chart-accent)); stroke-width: 1.75; }\n.tgc-series-point.tgc-point-undecided { fill: var(--chart-canvas); stroke: var(--tgc-series, var(--chart-accent)); stroke-width: 1.5; stroke-dasharray: 2 1.5; }\n.tgc-strip-hatch .tgc-hatch { stroke: var(--tgc-series, var(--chart-muted)); }\n.tgc-strip-edge { fill: none; stroke: var(--tgc-series, var(--chart-muted)); stroke-width: 1.25; }\n.tgc-strip-observation:focus-visible { outline: none; }\n.tgc-strip-observation:focus-visible .tgc-series-hit { stroke: var(--chart-ink); stroke-width: 2; }\n.tgc-point-badge { paint-order: stroke; stroke: var(--chart-canvas); stroke-width: 3px; stroke-linejoin: round; font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace); }\n.tgc-point-label { fill: var(--chart-ink-muted); }\n.tgc-glyph-note { fill: var(--chart-ink); font-weight: 600; }\n/* Status colours appear only on these glyphs. */\n.tgc-glyph-check { fill: var(--chart-pass, var(--status-running)); font-weight: 600; }\n.tgc-glyph-cross { fill: var(--chart-breach, var(--status-error)); font-weight: 600; }\n/* Recorded targets sit under the data: a target is a soft band between solid edges, a stretch a dashed outline. */\n.tgc-target-target { fill: color-mix(in srgb, var(--chart-ink) 7%, transparent); }\n.tgc-target-stretch { fill: none; }\n.tgc-target-target-edge, .tgc-target-target.tgc-target-line { stroke: var(--chart-ink-muted); stroke-width: 1; }\n.tgc-target-stretch-edge, .tgc-target-stretch.tgc-target-line { stroke: var(--chart-ink-muted); stroke-width: 1; stroke-dasharray: 4 3; }\n.tgc-target-label { fill: var(--chart-ink-muted); paint-order: stroke; stroke: var(--chart-canvas); stroke-width: 3px; stroke-linejoin: round; }\n/* Group headers: a labelled bracket above the plot and a light rule at each inner edge. */\n.tgc-group-rule { stroke: var(--chart-ink-muted); stroke-width: 1.5; }\n.tgc-group-edge { stroke: var(--chart-rule); stroke-width: 1; }\n.tgc-group-label { fill: var(--chart-ink); paint-order: stroke; stroke: var(--chart-canvas); stroke-width: 3px; stroke-linejoin: round; }\n/* The verdict key: glyphs in the neutral ink, so they describe a mark's form, not a series. */\n.tgc-key-mark { stroke-width: 1.5; }\n.tgc-key-filled { fill: var(--chart-ink-muted); stroke: none; }\n.tgc-key-outline { fill: none; stroke: var(--chart-ink-muted); }\n.tgc-key-dashed { fill: none; stroke: var(--chart-ink-muted); stroke-dasharray: 2 1.5; }\n";
|
|
1272
1140
|
/** --chart-* resolved per theme, for documents without brand tokens: light, dark by preference or [data-theme="dark"], light in print. */
|
|
1273
1141
|
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}}";
|
|
1274
1142
|
//#endregion
|
|
@@ -2304,11 +2172,433 @@ function metricBars(rows, o) {
|
|
|
2304
2172
|
};
|
|
2305
2173
|
}
|
|
2306
2174
|
//#endregion
|
|
2175
|
+
//#region src/axis.ts
|
|
2176
|
+
/**
|
|
2177
|
+
* Shared x and y axes for the recorded-scalar figures: an ordinal x of weighted categories (release tags grouped by
|
|
2178
|
+
* run) or a continuous one, and a linear or log y with caller-named gridlines and target bands. Validation lives
|
|
2179
|
+
* here so `timeSeries` and `smallMultiples` refuse the same inputs with the same sentences.
|
|
2180
|
+
*/
|
|
2181
|
+
const TARGET_KINDS = ["target", "stretch"];
|
|
2182
|
+
/** Why categories cannot be drawn on `domain`, or null. */
|
|
2183
|
+
function categoriesGap(categories, domain) {
|
|
2184
|
+
if (categories === void 0) return null;
|
|
2185
|
+
if (!Array.isArray(categories) || !categories.length) return "An ordinal axis needs at least one category.";
|
|
2186
|
+
let previous = -Infinity;
|
|
2187
|
+
for (const category of categories) {
|
|
2188
|
+
if (!category || typeof category.label !== "string") return "Every category needs a label.";
|
|
2189
|
+
if (!Number.isFinite(category.at) || category.at <= previous || category.at < domain[0] || category.at > domain[1]) return `${category.label}: category positions must be finite, strictly increasing and inside the axis domain.`;
|
|
2190
|
+
if (category.weight !== void 0 && (!Number.isFinite(category.weight) || category.weight <= 0)) return `${category.label}: a category weight must be a positive number.`;
|
|
2191
|
+
previous = category.at;
|
|
2192
|
+
}
|
|
2193
|
+
return null;
|
|
2194
|
+
}
|
|
2195
|
+
/** Why a y axis (scale, gridlines and targets) cannot be drawn on `domain`, or null. */
|
|
2196
|
+
function yAxisGap(y, label = "The y axis") {
|
|
2197
|
+
if (y.scale !== void 0 && y.scale !== "linear" && y.scale !== "log") return `${label}: the scale is linear or log.`;
|
|
2198
|
+
if (y.scale === "log" && !(y.domain[0] > 0)) return `${label}: a log axis needs a positive domain.`;
|
|
2199
|
+
const inside = (v) => Number.isFinite(v) && v >= y.domain[0] && v <= y.domain[1];
|
|
2200
|
+
for (const tick of y.ticks ?? []) {
|
|
2201
|
+
if (!tick || typeof tick.label !== "string" || !tick.label.trim()) return `${label}: every gridline needs a label.`;
|
|
2202
|
+
if (!inside(tick.at)) return `${label}: gridline ${tick.label} is outside the axis domain.`;
|
|
2203
|
+
}
|
|
2204
|
+
for (const target of y.targets ?? []) {
|
|
2205
|
+
if (!target || typeof target.label !== "string" || !target.label.trim()) return `${label}: every target needs a label.`;
|
|
2206
|
+
if (!TARGET_KINDS.includes(target.kind)) return `${target.label}: a target kind is target or stretch.`;
|
|
2207
|
+
if (!inside(target.from) || !inside(target.to) || target.from > target.to) return `${target.label}: a target needs finite, ordered bounds inside the axis domain.`;
|
|
2208
|
+
}
|
|
2209
|
+
return null;
|
|
2210
|
+
}
|
|
2211
|
+
/** Why a value cannot sit on this y scale, beyond the domain check. */
|
|
2212
|
+
function logValueGap(value, scale) {
|
|
2213
|
+
return scale === "log" && value !== null && !(value > 0) ? "A log axis cannot place zero or a negative value." : null;
|
|
2214
|
+
}
|
|
2215
|
+
/** A continuous x, or an ordinal x whose columns divide [left, right] by weight; values between categories interpolate. */
|
|
2216
|
+
function xAxis(domain, categories, left, right) {
|
|
2217
|
+
if (!categories?.length) {
|
|
2218
|
+
const px = (x) => left + (x - domain[0]) / (domain[1] - domain[0]) * (right - left);
|
|
2219
|
+
return {
|
|
2220
|
+
px,
|
|
2221
|
+
extent: (from, to) => [px(from), px(to)],
|
|
2222
|
+
column: () => null,
|
|
2223
|
+
category: () => void 0,
|
|
2224
|
+
ats: null
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
const total = categories.reduce((sum, c) => sum + (c.weight ?? 1), 0);
|
|
2228
|
+
const edges = [left];
|
|
2229
|
+
for (const c of categories) edges.push(edges.at(-1) + (c.weight ?? 1) / total * (right - left));
|
|
2230
|
+
const centres = categories.map((_, i) => (edges[i] + edges[i + 1]) / 2);
|
|
2231
|
+
const index = new Map(categories.map((c, i) => [c.at, i]));
|
|
2232
|
+
const px = (x) => {
|
|
2233
|
+
const exact = index.get(x);
|
|
2234
|
+
if (exact !== void 0) return centres[exact];
|
|
2235
|
+
const first = categories[0], last = categories.at(-1);
|
|
2236
|
+
if (x < first.at) return first.at === domain[0] ? centres[0] : left + (x - domain[0]) / (first.at - domain[0]) * (centres[0] - left);
|
|
2237
|
+
if (x > last.at) return last.at === domain[1] ? centres.at(-1) : centres.at(-1) + (x - last.at) / (domain[1] - last.at) * (right - centres.at(-1));
|
|
2238
|
+
let i = 0;
|
|
2239
|
+
while (categories[i + 1].at < x) i++;
|
|
2240
|
+
return centres[i] + (x - categories[i].at) / (categories[i + 1].at - categories[i].at) * (centres[i + 1] - centres[i]);
|
|
2241
|
+
};
|
|
2242
|
+
return {
|
|
2243
|
+
px,
|
|
2244
|
+
extent: (from, to) => [index.has(from) ? edges[index.get(from)] : px(from), index.has(to) ? edges[index.get(to) + 1] : px(to)],
|
|
2245
|
+
column: (at) => index.has(at) ? edges[index.get(at) + 1] - edges[index.get(at)] : null,
|
|
2246
|
+
category: (at) => index.has(at) ? categories[index.get(at)] : void 0,
|
|
2247
|
+
ats: categories.map((c) => c.at)
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
/** A linear or log10 y between `bottom` (domain start) and `top`. */
|
|
2251
|
+
function yScale(domain, scale, top, bottom) {
|
|
2252
|
+
if (scale === "log") {
|
|
2253
|
+
const [a, b] = [Math.log10(domain[0]), Math.log10(domain[1])];
|
|
2254
|
+
return (y) => bottom - (Math.log10(y) - a) / (b - a) * (bottom - top);
|
|
2255
|
+
}
|
|
2256
|
+
return (y) => bottom - (y - domain[0]) / (domain[1] - domain[0]) * (bottom - top);
|
|
2257
|
+
}
|
|
2258
|
+
/** The y gridlines: the caller's, else log ticks (1, 2, 5 × 10^k) or `count` even steps. */
|
|
2259
|
+
function yGridlines(y, label, count = 5) {
|
|
2260
|
+
if (y.ticks?.length) return [...y.ticks];
|
|
2261
|
+
if (y.scale === "log") {
|
|
2262
|
+
const ticks = logTicks(y.domain[0], y.domain[1]);
|
|
2263
|
+
return (ticks.length ? ticks : [y.domain[0], y.domain[1]]).map((at) => ({
|
|
2264
|
+
at,
|
|
2265
|
+
label: label(at)
|
|
2266
|
+
}));
|
|
2267
|
+
}
|
|
2268
|
+
return Array.from({ length: count }, (_, i) => y.domain[0] + i / (count - 1) * (y.domain[1] - y.domain[0])).map((at) => ({
|
|
2269
|
+
at,
|
|
2270
|
+
label: label(at)
|
|
2271
|
+
}));
|
|
2272
|
+
}
|
|
2273
|
+
/** Target bands under the data, labelled above their upper edge: a target at the right end, a stretch at the left. */
|
|
2274
|
+
function drawTargets(targets, py, left, right, yLabel, labels = true) {
|
|
2275
|
+
return targets.map((target) => {
|
|
2276
|
+
const y0 = py(target.to), y1 = py(target.from);
|
|
2277
|
+
const cls = `tgc-target tgc-target-${target.kind}`;
|
|
2278
|
+
const mark = y1 - y0 < 1.5 ? line(left, y0, right, y0, `${cls} tgc-target-line`) : rect(left, y0, right - left, y1 - y0, cls) + line(left, y0, right, y0, `${cls}-edge`) + line(left, y1, right, y1, `${cls}-edge`);
|
|
2279
|
+
return titled(`${target.label}: ${target.from === target.to ? yLabel(target.from) : `${yLabel(target.from)} to ${yLabel(target.to)}`}`, `<g data-target="${target.kind}">${mark}</g>`);
|
|
2280
|
+
}).join("") + (labels ? targets.map((target) => text(target.kind === "stretch" ? left + 4 : right - 4, py(target.to) - 4, target.label, {
|
|
2281
|
+
size: 11,
|
|
2282
|
+
anchor: target.kind === "stretch" ? "start" : "end",
|
|
2283
|
+
cls: "tgc-target-label"
|
|
2284
|
+
})).join("") : "");
|
|
2285
|
+
}
|
|
2286
|
+
/** A key row naming each target by its line style, for a panel too small to label bands in place. Returns its width. */
|
|
2287
|
+
function targetKey(targets, x, y, maxWidth) {
|
|
2288
|
+
let out = "", at = x;
|
|
2289
|
+
for (const target of targets) {
|
|
2290
|
+
const w = textWidth(target.label, 10) * 1.1 + 34;
|
|
2291
|
+
if (at + w - x > maxWidth) break;
|
|
2292
|
+
out += line(at, y - 3.5, at + 14, y - 3.5, `tgc-target-${target.kind}-edge`) + text(at + 18, y, target.label, {
|
|
2293
|
+
size: 10,
|
|
2294
|
+
cls: "tgc-target-label"
|
|
2295
|
+
});
|
|
2296
|
+
at += w;
|
|
2297
|
+
}
|
|
2298
|
+
return out;
|
|
2299
|
+
}
|
|
2300
|
+
/** The table form of target bands. Empty when there are none. */
|
|
2301
|
+
function targetsTable(targets, yLabel) {
|
|
2302
|
+
if (!targets.length) return "";
|
|
2303
|
+
return table("Recorded targets", [
|
|
2304
|
+
{ label: "Target" },
|
|
2305
|
+
{ label: "Kind" },
|
|
2306
|
+
{
|
|
2307
|
+
label: "From",
|
|
2308
|
+
numeric: true
|
|
2309
|
+
},
|
|
2310
|
+
{
|
|
2311
|
+
label: "To",
|
|
2312
|
+
numeric: true
|
|
2313
|
+
}
|
|
2314
|
+
], targets.map((t) => [
|
|
2315
|
+
t.label,
|
|
2316
|
+
t.kind,
|
|
2317
|
+
yLabel(t.from),
|
|
2318
|
+
yLabel(t.to)
|
|
2319
|
+
]));
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* X labels at recorded positions, spread evenly across the plot and measured so none overlap: at most as many as fit,
|
|
2323
|
+
* each snapped to the nearest recorded x, always keeping the first and the last that fit.
|
|
2324
|
+
*/
|
|
2325
|
+
function xTicks(xs, px, left, right, width, label, size = 12) {
|
|
2326
|
+
if (!xs.length) return [];
|
|
2327
|
+
const span = (x) => {
|
|
2328
|
+
const w = textWidth(label(x), size, "mono");
|
|
2329
|
+
return [px(x) - w / 2, px(x) + w / 2];
|
|
2330
|
+
};
|
|
2331
|
+
const widest = Math.max(...xs.map((x) => textWidth(label(x), size, "mono")));
|
|
2332
|
+
const slots = Math.max(1, Math.min(xs.length, Math.floor((right - left) / (widest + 12)) + 1));
|
|
2333
|
+
const anchors = slots === 1 ? [xs[0]] : Array.from({ length: slots }, (_, i) => left + (right - left) * i / (slots - 1)).map((target) => xs.reduce((best, x) => Math.abs(px(x) - target) < Math.abs(px(best) - target) ? x : best, xs[0]));
|
|
2334
|
+
const kept = [];
|
|
2335
|
+
for (const x of [...new Set(anchors)]) {
|
|
2336
|
+
const [a, b] = span(x);
|
|
2337
|
+
if (a < 2 || b > width - 2) continue;
|
|
2338
|
+
const previous = kept.at(-1);
|
|
2339
|
+
if (previous !== void 0 && a < span(previous)[1] + 10) {
|
|
2340
|
+
if (x !== xs.at(-1) || kept.length === 1) continue;
|
|
2341
|
+
kept.pop();
|
|
2342
|
+
}
|
|
2343
|
+
kept.push(x);
|
|
2344
|
+
}
|
|
2345
|
+
return kept;
|
|
2346
|
+
}
|
|
2347
|
+
const TURN = 40;
|
|
2348
|
+
const SIN = Math.sin(TURN * Math.PI / 180);
|
|
2349
|
+
const COS = Math.cos(TURN * Math.PI / 180);
|
|
2350
|
+
/** The fit for labels of `size` px in columns no narrower than `narrowest` px, and the extra height turned labels take. */
|
|
2351
|
+
function columnLabelFit(labels, narrowest, size) {
|
|
2352
|
+
if (!labels.length) return {
|
|
2353
|
+
fit: "level",
|
|
2354
|
+
extra: 0
|
|
2355
|
+
};
|
|
2356
|
+
const widest = Math.max(...labels.map((l) => textWidth(l, size, "mono")));
|
|
2357
|
+
if (widest + 4 <= narrowest) return {
|
|
2358
|
+
fit: "level",
|
|
2359
|
+
extra: 0
|
|
2360
|
+
};
|
|
2361
|
+
if (narrowest >= size + 2) return {
|
|
2362
|
+
fit: "turned",
|
|
2363
|
+
extra: Math.ceil(widest * SIN + size * COS - size)
|
|
2364
|
+
};
|
|
2365
|
+
return {
|
|
2366
|
+
fit: "thin",
|
|
2367
|
+
extra: 0
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Ordinal column labels with their baseline at `y`: every column level when they fit, every column turned 40° when
|
|
2372
|
+
* the columns are at least a line high, otherwise as many level labels as fit.
|
|
2373
|
+
*/
|
|
2374
|
+
function columnLabels(xs, px, fit, left, right, width, label, size, y) {
|
|
2375
|
+
const level = (x) => text(px(x), y, label(x), {
|
|
2376
|
+
anchor: "middle",
|
|
2377
|
+
size,
|
|
2378
|
+
cls: "tgc-ink-muted tgc-num"
|
|
2379
|
+
});
|
|
2380
|
+
if (fit === "level") return xs.map(level).join("");
|
|
2381
|
+
if (fit === "thin") return xTicks(xs, px, left, right, width, label, size).map(level).join("");
|
|
2382
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
2383
|
+
return xs.map((x) => `<text class="tgc-ink-muted tgc-num tgc-turned" x="${r2(px(x) + 3)}" y="${r2(y - size / 2)}" font-size="${size}" text-anchor="end" transform="rotate(-${TURN} ${r2(px(x) + 3)} ${r2(y - size / 2)})">${esc(label(x))}</text>`).join("");
|
|
2384
|
+
}
|
|
2385
|
+
//#endregion
|
|
2386
|
+
//#region src/points.ts
|
|
2387
|
+
/**
|
|
2388
|
+
* Recorded marks on a scalar axis that carry more than a value: a verdict (filled, outlined or hatched), how many
|
|
2389
|
+
* recorded rows the mark stands for, when they were first and last recorded, whether the value is comparable with
|
|
2390
|
+
* the goal, a status glyph and a short direct label. The producer records every field; nothing here is inferred.
|
|
2391
|
+
*/
|
|
2392
|
+
const POINT_GLYPHS = ["check", "cross"];
|
|
2393
|
+
/** Why a point's recorded fields cannot be drawn, or null. */
|
|
2394
|
+
function pointFieldsGap(p) {
|
|
2395
|
+
if (p.verdict !== void 0 && !MARK_VERDICTS.includes(p.verdict)) return "A verdict is pass, fail or unknown.";
|
|
2396
|
+
if (p.count !== void 0 && (!Number.isInteger(p.count) || p.count < 1)) return "A count is a whole number of at least 1.";
|
|
2397
|
+
for (const at of [p.firstAt, p.lastAt]) if (at !== void 0 && !Number.isFinite(at)) return "First and last times must be finite.";
|
|
2398
|
+
if (p.firstAt !== void 0 && p.lastAt !== void 0 && p.firstAt > p.lastAt) return "The first time is after the last.";
|
|
2399
|
+
if (p.comparable !== void 0 && p.comparable !== null && typeof p.comparable !== "boolean") return "Comparable is true, false or null.";
|
|
2400
|
+
if (p.glyph !== void 0 && !POINT_GLYPHS.includes(p.glyph)) return "A glyph is check or cross.";
|
|
2401
|
+
if (p.marker !== void 0 && !MARKER_SHAPES.includes(p.marker)) return "A marker is circle, square, triangle or diamond.";
|
|
2402
|
+
if (p.label !== void 0 && (typeof p.label !== "string" || !p.label.trim())) return "A point label needs text.";
|
|
2403
|
+
return null;
|
|
2404
|
+
}
|
|
2405
|
+
/** Whether any point uses the recorded fields, so a figure adds their table columns only when they carry data. */
|
|
2406
|
+
function hasPointFields(points) {
|
|
2407
|
+
return points.some((p) => p.verdict !== void 0 || p.count !== void 0 || p.firstAt !== void 0 || p.lastAt !== void 0 || p.comparable !== void 0 || p.glyph !== void 0 || p.label !== void 0);
|
|
2408
|
+
}
|
|
2409
|
+
const utc = (ms) => `${new Date(ms).toISOString().slice(5, 16).replace("T", " ")}Z`;
|
|
2410
|
+
const VERDICT_WORD = {
|
|
2411
|
+
pass: "pass",
|
|
2412
|
+
fail: "fail",
|
|
2413
|
+
unknown: "undecided"
|
|
2414
|
+
};
|
|
2415
|
+
/** The recorded fields as hover lines. */
|
|
2416
|
+
function pointFacts(p) {
|
|
2417
|
+
return [
|
|
2418
|
+
...p.verdict ? [`Verdict: ${VERDICT_WORD[p.verdict]}`] : [],
|
|
2419
|
+
...p.count !== void 0 && p.count > 1 ? [`${p.count} recorded rows`] : [],
|
|
2420
|
+
...p.firstAt !== void 0 || p.lastAt !== void 0 ? [`${p.firstAt !== void 0 ? `First ${utc(p.firstAt)}` : ""}${p.firstAt !== void 0 && p.lastAt !== void 0 ? ", " : ""}${p.lastAt !== void 0 ? `last ${utc(p.lastAt)}` : ""}`] : [],
|
|
2421
|
+
...p.comparable === false ? ["Not comparable with the goal (*)"] : p.comparable === null ? ["Comparability unknown (?)"] : [],
|
|
2422
|
+
...p.glyph === "check" ? ["✓ solved"] : p.glyph === "cross" ? ["✕ failing"] : []
|
|
2423
|
+
];
|
|
2424
|
+
}
|
|
2425
|
+
/** The table cells for the recorded fields: verdict, count, first, last, comparable. */
|
|
2426
|
+
function pointCells(p) {
|
|
2427
|
+
return [
|
|
2428
|
+
p.verdict ? VERDICT_WORD[p.verdict] + (p.glyph === "check" ? " ✓" : p.glyph === "cross" ? " ✕" : "") : "",
|
|
2429
|
+
p.count === void 0 ? "" : String(p.count),
|
|
2430
|
+
p.firstAt === void 0 ? "" : utc(p.firstAt),
|
|
2431
|
+
p.lastAt === void 0 ? "" : utc(p.lastAt),
|
|
2432
|
+
p.comparable === void 0 ? "" : p.comparable === null ? "unknown" : p.comparable ? "yes" : "no"
|
|
2433
|
+
];
|
|
2434
|
+
}
|
|
2435
|
+
const POINT_COLUMNS = [
|
|
2436
|
+
{ label: "Verdict" },
|
|
2437
|
+
{
|
|
2438
|
+
label: "Rows",
|
|
2439
|
+
numeric: true
|
|
2440
|
+
},
|
|
2441
|
+
{
|
|
2442
|
+
label: "First",
|
|
2443
|
+
numeric: true
|
|
2444
|
+
},
|
|
2445
|
+
{
|
|
2446
|
+
label: "Last",
|
|
2447
|
+
numeric: true
|
|
2448
|
+
},
|
|
2449
|
+
{ label: "Comparable" }
|
|
2450
|
+
];
|
|
2451
|
+
/**
|
|
2452
|
+
* The mark: filled for a pass (or no verdict), outlined for a fail, a not-comparable value or an unknown
|
|
2453
|
+
* comparability, dashed for an undecided verdict.
|
|
2454
|
+
*/
|
|
2455
|
+
function pointMark(x, y, shape, r, p) {
|
|
2456
|
+
const outline = p.verdict === "fail" || p.comparable === false || p.comparable === null;
|
|
2457
|
+
return `<path class="${p.verdict === "unknown" ? "tgc-series-point tgc-point-undecided" : outline ? "tgc-series-point tgc-point-outline" : "tgc-series-point"}" d="${markerPath(shape, x, y, r)}"/>`;
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* Text beside a mark: the status glyph, `*` or `?`, the label and `×n`. Drawn to the right, or to the left when it
|
|
2461
|
+
* would cross `limit`; returns the markup and its width so a caller can skip it when the column has no room.
|
|
2462
|
+
*/
|
|
2463
|
+
function pointBadge(x, y, r, p, limit) {
|
|
2464
|
+
const parts = [];
|
|
2465
|
+
if (p.glyph === "check") parts.push(["✓", "tgc-glyph-check"]);
|
|
2466
|
+
if (p.glyph === "cross") parts.push(["✕", "tgc-glyph-cross"]);
|
|
2467
|
+
if (p.comparable === false) parts.push(["*", "tgc-glyph-note"]);
|
|
2468
|
+
if (p.comparable === null) parts.push(["?", "tgc-glyph-note"]);
|
|
2469
|
+
if (p.label) parts.push([p.label, "tgc-point-label"]);
|
|
2470
|
+
if (p.count !== void 0 && p.count > 1) parts.push([`×${p.count}`, "tgc-point-label"]);
|
|
2471
|
+
if (!parts.length) return {
|
|
2472
|
+
svg: "",
|
|
2473
|
+
width: 0
|
|
2474
|
+
};
|
|
2475
|
+
const width = textWidth(parts.map(([t]) => t).join(" "), 10, "mono");
|
|
2476
|
+
const left = x + r + 3 + width > limit;
|
|
2477
|
+
const tx = left ? x - r - 3 : x + r + 3;
|
|
2478
|
+
const spans = parts.map(([t, cls], i) => `<tspan class="${cls}">${i ? " " : ""}${esc(t)}</tspan>`).join("");
|
|
2479
|
+
return {
|
|
2480
|
+
svg: `<text class="tgc-point-badge" x="${Math.round(tx * 100) / 100}" y="${Math.round((y + 3.5) * 100) / 100}" font-size="10"${left ? " text-anchor=\"end\"" : ""}>${spans}</text>`,
|
|
2481
|
+
width
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
/** A verdict recorded without a value: a square in the strip under the plot, hatched when undecided. */
|
|
2485
|
+
function stripMark(x, y, size, p) {
|
|
2486
|
+
const h = size / 2;
|
|
2487
|
+
if (p.verdict === "unknown") return `<g class="tgc-strip-mark tgc-point-undecided">${hatch(x - h, y - h, size, size, "tgc-strip-hatch")}${rect(x - h, y - h, size, size, "tgc-strip-edge")}</g>`;
|
|
2488
|
+
return rect(x - h, y - h, size, size, p.verdict === "fail" ? "tgc-series-point tgc-point-outline" : "tgc-series-point");
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* Places each mark's badge to its right, or to its left at the plot's edge, skipping any that would cover a mark or
|
|
2492
|
+
* an earlier badge: a skipped badge stays in the mark's hover and the table. Solves and failing candidates go first.
|
|
2493
|
+
*/
|
|
2494
|
+
function placeBadges(pending, marks, left, right) {
|
|
2495
|
+
const placed = [...marks];
|
|
2496
|
+
const free = (box) => box[0] >= left && box[1] <= right && placed.every((b) => box[1] < b[0] || box[0] > b[1] || box[3] < b[2] || box[2] > b[3]);
|
|
2497
|
+
const rank = (p) => p.glyph ? 0 : p.comparable !== void 0 && p.comparable !== true ? 1 : p.label ? 2 : 3;
|
|
2498
|
+
let out = "";
|
|
2499
|
+
for (const { x, y, r, point } of [...pending].sort((a, b) => rank(a.point) - rank(b.point))) {
|
|
2500
|
+
const badge = pointBadge(x, y, r, point, right);
|
|
2501
|
+
if (!badge.svg) continue;
|
|
2502
|
+
const box = badge.svg.includes("text-anchor=\"end\"") ? [
|
|
2503
|
+
x - r - 3 - badge.width,
|
|
2504
|
+
x - r - 3,
|
|
2505
|
+
y - 6,
|
|
2506
|
+
y + 6
|
|
2507
|
+
] : [
|
|
2508
|
+
x + r + 3,
|
|
2509
|
+
x + r + 3 + badge.width,
|
|
2510
|
+
y - 6,
|
|
2511
|
+
y + 6
|
|
2512
|
+
];
|
|
2513
|
+
if (!free(box)) continue;
|
|
2514
|
+
placed.push(box);
|
|
2515
|
+
out += badge.svg;
|
|
2516
|
+
}
|
|
2517
|
+
return out;
|
|
2518
|
+
}
|
|
2519
|
+
/**
|
|
2520
|
+
* A key row for the recorded fields the points use: shapes the caller named, filled and outlined verdicts, the
|
|
2521
|
+
* strip, the status glyphs, `*`, `?` and `×n`. Only what the data contains is listed; items wrap to `width`.
|
|
2522
|
+
*/
|
|
2523
|
+
function verdictKey(points, markers, x, y, width) {
|
|
2524
|
+
if (!hasPointFields(points)) return {
|
|
2525
|
+
svg: "",
|
|
2526
|
+
height: 0
|
|
2527
|
+
};
|
|
2528
|
+
const has = (test) => points.some(test);
|
|
2529
|
+
const shape = (s, cls) => (gx, gy) => `<path class="tgc-key-mark ${cls}" d="${markerPath(s, gx + 5, gy - 4, 4)}"/>`;
|
|
2530
|
+
const sign = (t, cls) => (gx, gy) => text(gx + 1, gy, t, {
|
|
2531
|
+
size: 11,
|
|
2532
|
+
cls
|
|
2533
|
+
});
|
|
2534
|
+
const items = [
|
|
2535
|
+
...MARKER_SHAPES.filter((s) => markers?.[s] && has((p) => p.marker === s)).map((s) => ({
|
|
2536
|
+
glyph: shape(s, "tgc-key-filled"),
|
|
2537
|
+
label: markers[s]
|
|
2538
|
+
})),
|
|
2539
|
+
...has((p) => p.y !== null && p.verdict === "pass") ? [{
|
|
2540
|
+
glyph: shape("circle", "tgc-key-filled"),
|
|
2541
|
+
label: "filled: pass"
|
|
2542
|
+
}] : [],
|
|
2543
|
+
...has((p) => p.y !== null && (p.verdict === "fail" || p.comparable !== void 0 && p.comparable !== true)) ? [{
|
|
2544
|
+
glyph: shape("circle", "tgc-key-outline"),
|
|
2545
|
+
label: "outline: fail or not comparable"
|
|
2546
|
+
}] : [],
|
|
2547
|
+
...has((p) => p.y !== null && p.verdict === "unknown") ? [{
|
|
2548
|
+
glyph: shape("circle", "tgc-key-dashed"),
|
|
2549
|
+
label: "dashed: undecided"
|
|
2550
|
+
}] : [],
|
|
2551
|
+
...has((p) => p.y === null && p.verdict !== void 0) ? [{
|
|
2552
|
+
glyph: (gx, gy) => `<g class="tgc-key-strip">${hatch(gx + 1, gy - 8, 8, 8, "tgc-strip-hatch")}${rect(gx + 1, gy - 8, 8, 8, "tgc-strip-edge")}</g>`,
|
|
2553
|
+
label: "square: verdict without a value"
|
|
2554
|
+
}] : [],
|
|
2555
|
+
...has((p) => p.glyph === "check") ? [{
|
|
2556
|
+
glyph: sign("✓", "tgc-glyph-check"),
|
|
2557
|
+
label: "solved"
|
|
2558
|
+
}] : [],
|
|
2559
|
+
...has((p) => p.glyph === "cross") ? [{
|
|
2560
|
+
glyph: sign("✕", "tgc-glyph-cross"),
|
|
2561
|
+
label: "failing candidate"
|
|
2562
|
+
}] : [],
|
|
2563
|
+
...has((p) => p.comparable === false) ? [{
|
|
2564
|
+
glyph: sign("*", "tgc-glyph-note"),
|
|
2565
|
+
label: "not comparable"
|
|
2566
|
+
}] : [],
|
|
2567
|
+
...has((p) => p.comparable === null) ? [{
|
|
2568
|
+
glyph: sign("?", "tgc-glyph-note"),
|
|
2569
|
+
label: "comparability unknown"
|
|
2570
|
+
}] : [],
|
|
2571
|
+
...has((p) => (p.count ?? 1) > 1) ? [{
|
|
2572
|
+
glyph: sign("×n", "tgc-point-label"),
|
|
2573
|
+
label: "recorded rows"
|
|
2574
|
+
}] : []
|
|
2575
|
+
];
|
|
2576
|
+
let svg = "", at = x, line = 0;
|
|
2577
|
+
for (const item of items) {
|
|
2578
|
+
const lead = item.label === "recorded rows" ? 24 : 16;
|
|
2579
|
+
const w = lead + textWidth(item.label, 11) * 1.1 + 16;
|
|
2580
|
+
if (at > x && at + w - x > width) {
|
|
2581
|
+
at = x;
|
|
2582
|
+
line++;
|
|
2583
|
+
}
|
|
2584
|
+
const gy = y + 12 + line * 16;
|
|
2585
|
+
svg += item.glyph(at, gy) + text(at + lead, gy, item.label, {
|
|
2586
|
+
size: 11,
|
|
2587
|
+
cls: "tgc-ink-muted"
|
|
2588
|
+
});
|
|
2589
|
+
at += w;
|
|
2590
|
+
}
|
|
2591
|
+
return {
|
|
2592
|
+
svg: items.length ? `<g class="tgc-verdict-key">${svg}</g>` : "",
|
|
2593
|
+
height: items.length ? (line + 1) * 16 + 8 : 0
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
//#endregion
|
|
2307
2597
|
//#region src/time-series.ts
|
|
2308
2598
|
/** Recorded trajectories. Explicit null observations break lines without interpolation. */
|
|
2309
2599
|
function timeSeries(rows, o) {
|
|
2310
2600
|
const id = o.id ?? "time-series";
|
|
2311
|
-
const gap = domainGap(o.x.domain) ?? domainGap(o.y.domain);
|
|
2601
|
+
const gap = domainGap(o.x.domain) ?? domainGap(o.y.domain) ?? categoriesGap(o.x.categories, o.x.domain) ?? yAxisGap(o.y);
|
|
2312
2602
|
if (gap) return {
|
|
2313
2603
|
id,
|
|
2314
2604
|
refused: gap
|
|
@@ -2326,24 +2616,32 @@ function timeSeries(rows, o) {
|
|
|
2326
2616
|
id,
|
|
2327
2617
|
refused: "Duplicate trajectory ids."
|
|
2328
2618
|
};
|
|
2619
|
+
const columns = o.x.categories?.length ? new Set(o.x.categories.map((c) => c.at)) : null;
|
|
2329
2620
|
for (const row of rows) {
|
|
2621
|
+
if (row.colorIndex !== void 0 && (!Number.isInteger(row.colorIndex) || row.colorIndex < 0)) return {
|
|
2622
|
+
id,
|
|
2623
|
+
refused: `${row.label}: a colour index is a whole number of at least 0.`
|
|
2624
|
+
};
|
|
2330
2625
|
let previous = -Infinity;
|
|
2331
2626
|
for (const point of row.points) {
|
|
2332
|
-
|
|
2627
|
+
const ordered = columns ? point.x >= previous && columns.has(point.x) : point.x > previous;
|
|
2628
|
+
if (!Number.isFinite(point.x) || !ordered || point.x < o.x.domain[0] || point.x > o.x.domain[1]) return {
|
|
2333
2629
|
id,
|
|
2334
|
-
refused: `${row.label}: x values must be finite, strictly increasing and inside the axis domain.`
|
|
2630
|
+
refused: columns ? `${row.label}: x values must be categories, in order.` : `${row.label}: x values must be finite, strictly increasing and inside the axis domain.`
|
|
2335
2631
|
};
|
|
2336
2632
|
previous = point.x;
|
|
2337
|
-
const problem = scalarGap(point.y, point.interval, point.observations, o.y.domain);
|
|
2633
|
+
const problem = scalarGap(point.y, point.interval, point.observations, o.y.domain) ?? logValueGap(point.y, o.y.scale) ?? logValueGap(point.interval?.lower ?? null, o.y.scale) ?? pointFieldsGap(point);
|
|
2338
2634
|
if (problem) return {
|
|
2339
2635
|
id,
|
|
2340
2636
|
refused: `${row.label} at ${point.x}: ${problem}`
|
|
2341
2637
|
};
|
|
2342
2638
|
}
|
|
2343
2639
|
}
|
|
2344
|
-
const
|
|
2640
|
+
const category = new Map((o.x.categories ?? []).map((c) => [c.at, c.label]));
|
|
2641
|
+
const xLabel = (x) => o.formatX ? o.formatX(x) : category.get(x) ?? String(x);
|
|
2345
2642
|
const yLabel = (y) => scalarLabel(y, o.y.unit, o.formatY);
|
|
2346
|
-
|
|
2643
|
+
const grid = yGridlines(o.y, yLabel);
|
|
2644
|
+
if (axisGutter(grid) >= 270) return {
|
|
2347
2645
|
id,
|
|
2348
2646
|
refused: "Formatted y-axis labels leave no room for the phone chart. Use a shorter formatter or unit."
|
|
2349
2647
|
};
|
|
@@ -2352,263 +2650,202 @@ function timeSeries(rows, o) {
|
|
|
2352
2650
|
point
|
|
2353
2651
|
})));
|
|
2354
2652
|
const measured = flat.filter(({ point }) => point.y !== null);
|
|
2653
|
+
const stripped = flat.filter(({ point }) => point.y === null && point.verdict !== void 0);
|
|
2654
|
+
const recorded = hasPointFields(flat.map(({ point }) => point));
|
|
2355
2655
|
const marks = o.marks ?? [], bands = o.bands ?? [];
|
|
2356
2656
|
const hasEvents = marks.length + bands.length > 0;
|
|
2657
|
+
const groups = bands.some((band) => band.kind === "group");
|
|
2658
|
+
const stripName = o.y.strip?.trim() || "no value";
|
|
2659
|
+
const valueText = (point) => point.y !== null ? yLabel(point.y) : point.verdict !== void 0 ? `${stripName} (${VERDICT_WORD[point.verdict]})` : "not measured";
|
|
2357
2660
|
const facts = (row, point) => [
|
|
2358
2661
|
row.label,
|
|
2359
2662
|
`${o.x.label}: ${xLabel(point.x)}`,
|
|
2360
|
-
`${o.y.label}: ${
|
|
2361
|
-
|
|
2663
|
+
`${o.y.label}: ${valueText(point)}`,
|
|
2664
|
+
...point.label ? [point.label] : [],
|
|
2665
|
+
...pointFacts(point),
|
|
2666
|
+
...point.observations || !hasPointFields([point]) ? [observationLabel(point.observations)] : [],
|
|
2362
2667
|
...point.interval ? [`Interval: ${scalarIntervalLabel(point.interval, o.y.unit, o.formatY)}`] : [],
|
|
2363
2668
|
...point.annotation ? [point.annotation] : []
|
|
2364
2669
|
];
|
|
2670
|
+
const head = [
|
|
2671
|
+
{ label: "Setup" },
|
|
2672
|
+
{
|
|
2673
|
+
label: o.x.label,
|
|
2674
|
+
numeric: true
|
|
2675
|
+
},
|
|
2676
|
+
{
|
|
2677
|
+
label: o.y.label,
|
|
2678
|
+
numeric: true
|
|
2679
|
+
},
|
|
2680
|
+
{ label: "Observations" },
|
|
2681
|
+
{ label: "Recorded interval" },
|
|
2682
|
+
{ label: "Recorded annotation" },
|
|
2683
|
+
...recorded ? [{ label: "Label" }, ...POINT_COLUMNS] : []
|
|
2684
|
+
];
|
|
2685
|
+
const cells = (row, point) => [
|
|
2686
|
+
row.label,
|
|
2687
|
+
xLabel(point.x),
|
|
2688
|
+
point.y === null ? point.verdict !== void 0 ? `${stripName} (${VERDICT_WORD[point.verdict]})` : "not measured" : yLabel(point.y),
|
|
2689
|
+
observationLabel(point.observations),
|
|
2690
|
+
scalarIntervalLabel(point.interval, o.y.unit, o.formatY),
|
|
2691
|
+
point.annotation ?? "",
|
|
2692
|
+
...recorded ? [point.label ?? "", ...pointCells(point)] : []
|
|
2693
|
+
];
|
|
2365
2694
|
return {
|
|
2366
2695
|
id,
|
|
2367
2696
|
finding: `${capitalize(o.y.label)} by ${o.x.label}.`,
|
|
2368
|
-
lede: `${measured.length} of ${flat.length} positions have a recorded value
|
|
2369
|
-
read: `Points show recorded values. Lines connect adjacent measured positions; missing positions break the line. Whiskers show recorded intervals.${hasEvents ? ` ${EVENTS_READ}` : ""}`,
|
|
2697
|
+
lede: `${measured.length} of ${flat.length} positions have a recorded value.${stripped.length ? ` ${stripped.length} recorded ${plural(stripped.length, "verdict")} without a value ${stripped.length === 1 ? "sits" : "sit"} in the strip under the plot.` : ""}`,
|
|
2698
|
+
read: `Points show recorded values. Lines connect adjacent measured positions; missing positions break the line. Whiskers show recorded intervals.${recorded ? " A filled mark is a pass, an outline a fail, a dashed outline undecided; * is not comparable with the goal and ? of unknown comparability; ×n counts the recorded rows a mark stands for." : ""}${stripped.length ? ` Hatched squares under the plot are verdicts recorded without a value (${stripName}).` : ""}${o.y.targets?.length ? " Shaded bands are recorded targets; dashed bands are stretch targets." : ""}${groups ? " Brackets above the plot group positions, such as one run's releases." : ""}${hasEvents ? ` ${EVENTS_READ}` : ""}`,
|
|
2370
2699
|
svg: {
|
|
2371
|
-
wide: plot(rows, o, 720, xLabel, yLabel, facts),
|
|
2372
|
-
narrow: plot(rows, o, 358, xLabel, yLabel, facts),
|
|
2700
|
+
wide: plot(rows, o, 720, xLabel, yLabel, grid, facts, stripName),
|
|
2701
|
+
narrow: plot(rows, o, 358, xLabel, yLabel, grid, facts, stripName),
|
|
2373
2702
|
interactive: true
|
|
2374
2703
|
},
|
|
2375
|
-
table: table(`${capitalize(o.y.label)} by ${o.x.label}`, [
|
|
2376
|
-
{ label: "Setup" },
|
|
2377
|
-
{
|
|
2378
|
-
label: o.x.label,
|
|
2379
|
-
numeric: true
|
|
2380
|
-
},
|
|
2381
|
-
{
|
|
2382
|
-
label: o.y.label,
|
|
2383
|
-
numeric: true
|
|
2384
|
-
},
|
|
2385
|
-
{ label: "Observations" },
|
|
2386
|
-
{ label: "Recorded interval" },
|
|
2387
|
-
{ label: "Recorded annotation" }
|
|
2388
|
-
], flat.map(({ row, point }) => [
|
|
2389
|
-
row.label,
|
|
2390
|
-
xLabel(point.x),
|
|
2391
|
-
point.y === null ? "not measured" : yLabel(point.y),
|
|
2392
|
-
observationLabel(point.observations),
|
|
2393
|
-
scalarIntervalLabel(point.interval, o.y.unit, o.formatY),
|
|
2394
|
-
point.annotation ?? ""
|
|
2395
|
-
])).replace("class=\"tgc-table\"", "class=\"tgc-table tgc-scalar-table\"") + eventsTable(marks, bands, o.x.label, xLabel),
|
|
2704
|
+
table: table(`${capitalize(o.y.label)} by ${o.x.label}`, head, flat.map(({ row, point }) => cells(row, point))).replace("class=\"tgc-table\"", "class=\"tgc-table tgc-scalar-table\"") + targetsTable(o.y.targets ?? [], yLabel) + eventsTable(marks, bands, o.x.label, xLabel),
|
|
2396
2705
|
note: {
|
|
2397
2706
|
method: "Recorded scalar observations. Missing positions are not interpolated; no uncertainty or statistical comparison is inferred.",
|
|
2398
2707
|
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.`), ...bands.filter((band) => band.kind === "missing").map((band) => `${band.label}: no data from ${o.x.label} ${xLabel(band.from)} to ${xLabel(band.to)}.`)]
|
|
2708
|
+
exclusions: [...flat.filter(({ point }) => point.y === null).map(({ row, point }) => `${row.label}, ${o.x.label} ${xLabel(point.x)}: ${point.verdict !== void 0 ? `${VERDICT_WORD[point.verdict]}${point.count && point.count > 1 ? ` ×${point.count}` : ""}, no value` : "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
2709
|
}
|
|
2401
2710
|
};
|
|
2402
2711
|
}
|
|
2403
|
-
function plot(rows, o, width, xLabel, yLabel, facts) {
|
|
2404
|
-
const
|
|
2405
|
-
const
|
|
2406
|
-
const
|
|
2407
|
-
const px =
|
|
2408
|
-
const
|
|
2712
|
+
function plot(rows, o, width, xLabel, yLabel, grid, facts, stripName) {
|
|
2713
|
+
const left = axisGutter(grid);
|
|
2714
|
+
const right = width - 24;
|
|
2715
|
+
const axis = xAxis(o.x.domain, o.x.categories, left, right);
|
|
2716
|
+
const px = axis.px;
|
|
2717
|
+
const labelRows = markLabelRows(o.marks ?? [], px, left, right);
|
|
2718
|
+
const top = 36 + (labelRows ? labelRows * 14 + 2 : 0) + groupRowHeight(o.bands), bottom = top + 224;
|
|
2719
|
+
const py = yScale(o.y.domain, o.y.scale, top, bottom);
|
|
2720
|
+
const strip = rows.some((row) => row.points.some((point) => point.y === null && point.verdict !== void 0)) ? 24 : 0;
|
|
2409
2721
|
let marks = text(left, 18, o.y.label, { cls: "tgc-ink-muted" });
|
|
2410
|
-
for (const [i,
|
|
2722
|
+
for (const [i, tick] of grid.entries()) marks += line(left, py(tick.at), right, py(tick.at), i === 0 && !o.y.ticks?.length ? "tgc-axis" : "tgc-gridline") + text(left - 8, py(tick.at) + 4, tick.label, {
|
|
2411
2723
|
anchor: "end",
|
|
2412
2724
|
cls: "tgc-ink-muted tgc-num",
|
|
2413
2725
|
size: 12
|
|
2414
2726
|
});
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2727
|
+
if (o.y.ticks?.length) marks += line(left, bottom, right, bottom, "tgc-axis");
|
|
2728
|
+
marks += drawTargets(o.y.targets ?? [], py, left, right, yLabel);
|
|
2729
|
+
const recordedXs = [...new Set(rows.flatMap((row) => row.points.map((point) => point.x)))].sort((a, b) => a - b);
|
|
2730
|
+
const full = (o.x.categories ?? []).filter((c) => (c.weight ?? 1) >= 1).map((c) => c.at);
|
|
2731
|
+
const xs = (axis.ats ? full.length ? full : axis.ats : recordedXs).filter((x) => xLabel(x) !== "");
|
|
2732
|
+
let turn = 0;
|
|
2733
|
+
if (axis.ats) {
|
|
2734
|
+
const fit = columnLabelFit(xs.map(xLabel), Math.min(...xs.map((x) => axis.column(x))), 12);
|
|
2735
|
+
turn = fit.extra;
|
|
2736
|
+
marks += columnLabels(xs, px, fit.fit, left, right, width, xLabel, 12, bottom + strip + 20);
|
|
2737
|
+
} else for (const x of xTicks(xs, px, left, right, width, xLabel)) marks += text(px(x), bottom + strip + 20, xLabel(x), {
|
|
2418
2738
|
anchor: "middle",
|
|
2419
2739
|
cls: "tgc-ink-muted tgc-num"
|
|
2420
2740
|
});
|
|
2421
|
-
marks += text((left + right) / 2,
|
|
2741
|
+
marks += text((left + right) / 2, bottom + strip + turn + 43, o.x.label, {
|
|
2422
2742
|
anchor: "middle",
|
|
2423
2743
|
cls: "tgc-ink-muted"
|
|
2424
2744
|
});
|
|
2425
|
-
marks += drawBands(o.bands ?? [], px, top, bottom, xLabel);
|
|
2426
|
-
|
|
2745
|
+
marks += drawBands(o.bands ?? [], px, top, bottom, xLabel, void 0, axis.extent);
|
|
2746
|
+
marks += drawGroups(o.bands ?? [], axis.extent, top, bottom, left, right, xLabel);
|
|
2747
|
+
if (strip) marks += text(left - 8, bottom + 16, stripName, {
|
|
2748
|
+
anchor: "end",
|
|
2749
|
+
cls: "tgc-ink-muted",
|
|
2750
|
+
size: 11
|
|
2751
|
+
}) + line(left, bottom + strip, right, bottom + strip, "tgc-gridline");
|
|
2752
|
+
const spread = (inStrip) => {
|
|
2753
|
+
const byX = /* @__PURE__ */ new Map();
|
|
2754
|
+
for (const row of rows) for (const point of row.points) if (point.y === null === inStrip && (!inStrip || point.verdict !== void 0)) byX.set(point.x, [...byX.get(point.x) ?? [], point]);
|
|
2755
|
+
const at = /* @__PURE__ */ new Map();
|
|
2756
|
+
for (const [x, list] of byX) {
|
|
2757
|
+
const room = axis.column(x);
|
|
2758
|
+
const step = room === null ? 0 : Math.min(10, room / (list.length + 1));
|
|
2759
|
+
list.forEach((point, k) => at.set(point, px(x) + (k - (list.length - 1) / 2) * step));
|
|
2760
|
+
}
|
|
2761
|
+
return at;
|
|
2762
|
+
};
|
|
2763
|
+
const plotX = spread(false), stripX = spread(true);
|
|
2764
|
+
let legendY = bottom + strip + turn + 72;
|
|
2427
2765
|
let points = "", tips = "", rules = "";
|
|
2766
|
+
const placed = [];
|
|
2767
|
+
const pending = [];
|
|
2428
2768
|
let pointId = 0;
|
|
2769
|
+
const legend = /* @__PURE__ */ new Set();
|
|
2429
2770
|
for (const [seriesIndex, row] of rows.entries()) {
|
|
2430
|
-
const
|
|
2771
|
+
const ord = row.colorIndex ?? seriesIndex;
|
|
2772
|
+
const dash = ord === 0 ? "" : `${2 + ord * 2} 3`;
|
|
2773
|
+
const group = checkerClass(row.colorIndex);
|
|
2774
|
+
const wrapSeries = (inner) => group ? `<g class="${group}">${inner}</g>` : inner;
|
|
2431
2775
|
let segment = [];
|
|
2776
|
+
let lines = "";
|
|
2432
2777
|
const flush = () => {
|
|
2433
|
-
if (segment.length > 1)
|
|
2778
|
+
if (segment.length > 1) lines += `<g data-series="${esc(row.id)}" style="stroke-dasharray:${dash}">${polyline(segment, "tgc-series-line")}</g>`;
|
|
2434
2779
|
segment = [];
|
|
2435
2780
|
};
|
|
2781
|
+
let own = "";
|
|
2436
2782
|
for (const point of row.points) {
|
|
2437
2783
|
if (point.y === null) {
|
|
2438
2784
|
flush();
|
|
2785
|
+
if (point.verdict === void 0) continue;
|
|
2786
|
+
const x = stripX.get(point), y = bottom + 12, size = Math.min(9, Math.max(3, (axis.column(point.x) ?? 11) - 2));
|
|
2787
|
+
const title = facts(row, point).join(" · ");
|
|
2788
|
+
own += titled(title, `<g class="tgc-strip-observation" data-strip="${esc(row.id)}" tabindex="0" role="img" aria-label="${esc(title)}">${rect(x - 8, y - 8, 16, 16, "tgc-series-hit")}${stripMark(x, y, size, point)}</g>`);
|
|
2789
|
+
placed.push([
|
|
2790
|
+
x - size / 2,
|
|
2791
|
+
x + size / 2,
|
|
2792
|
+
y - size / 2,
|
|
2793
|
+
y + size / 2
|
|
2794
|
+
]);
|
|
2795
|
+
pending.push({
|
|
2796
|
+
x,
|
|
2797
|
+
y,
|
|
2798
|
+
r: size / 2,
|
|
2799
|
+
point
|
|
2800
|
+
});
|
|
2439
2801
|
continue;
|
|
2440
2802
|
}
|
|
2441
|
-
const x =
|
|
2803
|
+
const x = plotX.get(point), y = py(point.y);
|
|
2442
2804
|
segment.push([x, y]);
|
|
2443
2805
|
const pointFacts = facts(row, point);
|
|
2444
2806
|
const key = pointId++;
|
|
2445
|
-
|
|
2807
|
+
const recorded = hasPointFields([point]) || point.marker !== void 0;
|
|
2808
|
+
const r = recorded ? 4.5 : 3.5;
|
|
2809
|
+
const shape = point.marker ?? (ord === 0 ? "circle" : MARKER_SHAPES[ord % MARKER_SHAPES.length]);
|
|
2810
|
+
let dot = circle(x, y, 10, "tgc-series-hit") + (recorded ? pointMark(x, y, shape, r, point) : seriesIndex === 0 && ord === 0 ? circle(x, y, 3.5, "tgc-series-point") : `<path class="tgc-series-point" d="${markerPath(shape, x, y, 3.5)}"/>`);
|
|
2446
2811
|
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");
|
|
2447
|
-
|
|
2812
|
+
own += `<g class="tgc-series-observation" data-point="${key}" tabindex="0" role="img" aria-label="${esc(pointFacts.join(" · "))}"><title>${esc(pointFacts.join(" · "))}</title>${dot}</g>`;
|
|
2813
|
+
placed.push([
|
|
2814
|
+
x - r,
|
|
2815
|
+
x + r,
|
|
2816
|
+
y - r,
|
|
2817
|
+
y + r
|
|
2818
|
+
]);
|
|
2819
|
+
if (recorded) pending.push({
|
|
2820
|
+
x,
|
|
2821
|
+
y,
|
|
2822
|
+
r,
|
|
2823
|
+
point
|
|
2824
|
+
});
|
|
2448
2825
|
const tipWidth = Math.min(280, width - 24), tipX = Math.max(12, Math.min(width - tipWidth - 12, x - tipWidth / 2));
|
|
2449
|
-
const
|
|
2450
|
-
tips += `<g class="tgc-series-tip" data-series-tip="${key}" aria-hidden="true">${rect(tipX, top, tipWidth,
|
|
2826
|
+
const tipLines = pointFacts.flatMap((fact) => wrap(fact, tipWidth - 24, 12, "sans", 8).lines);
|
|
2827
|
+
tips += `<g class="tgc-series-tip" data-series-tip="${key}" aria-hidden="true">${rect(tipX, top, tipWidth, tipLines.length * 17 + 24, "tgc-tip-bg", "rx=\"7\"")}${textBlock(tipLines, tipX + 12, top + 20, 17, "class=\"tgc-tip-ink\" font-size=\"12\"")}</g>`;
|
|
2451
2828
|
rules += `.tgc-trajectories:has([data-point="${key}"]:hover) [data-series-tip="${key}"],.tgc-trajectories:not(:has(.tgc-series-observation:hover)):has([data-point="${key}"]:focus-visible) [data-series-tip="${key}"]{opacity:1}`;
|
|
2452
2829
|
}
|
|
2453
2830
|
flush();
|
|
2831
|
+
marks += wrapSeries(lines);
|
|
2832
|
+
points += wrapSeries(own);
|
|
2833
|
+
const legendKey = `${row.label}\u0000${row.colorIndex ?? `s${seriesIndex}`}`;
|
|
2834
|
+
if (legend.has(legendKey)) continue;
|
|
2835
|
+
legend.add(legendKey);
|
|
2454
2836
|
const label = wrap(row.label, right - left - 32, 12, "sans", 8);
|
|
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[
|
|
2837
|
+
marks += wrapSeries(`<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(row.colorIndex !== void 0 ? "circle" : MARKER_SHAPES[ord % MARKER_SHAPES.length], left + 12, legendY - 4, 3.5)}"/>` : "")) + textBlock(label.lines, left + 32, legendY, 16, "class=\"tgc-ink\" font-size=\"12\"");
|
|
2456
2838
|
legendY += label.lines.length * 16 + 8;
|
|
2457
2839
|
}
|
|
2458
|
-
const
|
|
2459
|
-
|
|
2840
|
+
const badges = placeBadges(pending, placed, left - 4, width - 2);
|
|
2841
|
+
const key = verdictKey(rows.flatMap((row) => row.points), o.markers, left, legendY + 4, right - left);
|
|
2842
|
+
legendY += key.height;
|
|
2843
|
+
marks += key.svg;
|
|
2844
|
+
const events = drawMarks(o.marks ?? [], px, top - groupRowHeight(o.bands), bottom, left, right, xLabel, 22);
|
|
2845
|
+
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}${badges}${tips}</svg>`;
|
|
2460
2846
|
}
|
|
2461
|
-
function axisGutter(ticks
|
|
2462
|
-
return Math.max(64, ...ticks.map((
|
|
2463
|
-
}
|
|
2464
|
-
//#endregion
|
|
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
|
-
};
|
|
2847
|
+
function axisGutter(ticks) {
|
|
2848
|
+
return Math.max(64, ...ticks.map((tick) => Math.ceil(textWidth(tick.label, 12, "mono")) + 20));
|
|
2612
2849
|
}
|
|
2613
2850
|
//#endregion
|
|
2614
2851
|
//#region src/small-multiples.ts
|
|
@@ -2623,15 +2860,16 @@ function smallMultiples(panels, o) {
|
|
|
2623
2860
|
id,
|
|
2624
2861
|
refused: "Small multiples need shared axes: both or x."
|
|
2625
2862
|
};
|
|
2626
|
-
const axis = domainGap(o.x.domain) ?? domainGap(o.y.domain);
|
|
2863
|
+
const axis = domainGap(o.x.domain) ?? domainGap(o.y.domain) ?? categoriesGap(o.x.categories, o.x.domain);
|
|
2627
2864
|
if (axis) return {
|
|
2628
2865
|
id,
|
|
2629
2866
|
refused: axis
|
|
2630
2867
|
};
|
|
2631
|
-
if (
|
|
2868
|
+
if (o.columns !== void 0 && (!Number.isInteger(o.columns) || o.columns < 1)) return {
|
|
2632
2869
|
id,
|
|
2633
|
-
refused: "
|
|
2870
|
+
refused: "Columns must be a whole number of at least 1."
|
|
2634
2871
|
};
|
|
2872
|
+
const columns = o.x.categories?.length ? new Set(o.x.categories.map((c) => c.at)) : null;
|
|
2635
2873
|
if (!panels.length) return {
|
|
2636
2874
|
id,
|
|
2637
2875
|
refused: "No panels recorded."
|
|
@@ -2640,11 +2878,28 @@ function smallMultiples(panels, o) {
|
|
|
2640
2878
|
id,
|
|
2641
2879
|
refused: "Duplicate panel ids."
|
|
2642
2880
|
};
|
|
2881
|
+
const ownUnits = o.shared === "x";
|
|
2882
|
+
if (!o.y.unit?.trim() && !(ownUnits && panels.every((p) => p.unit?.trim()))) return {
|
|
2883
|
+
id,
|
|
2884
|
+
refused: "Small multiples need a y unit."
|
|
2885
|
+
};
|
|
2643
2886
|
for (const panel of panels) {
|
|
2644
|
-
if (panel.unit !== void 0 && panel.unit !== o.y.unit) return {
|
|
2887
|
+
if (!ownUnits && panel.unit !== void 0 && panel.unit !== o.y.unit) return {
|
|
2645
2888
|
id,
|
|
2646
2889
|
refused: `Mixed units: ${panel.label} records ${panel.unit}, the figure draws ${o.y.unit}.`
|
|
2647
2890
|
};
|
|
2891
|
+
if (ownUnits && panel.unit !== void 0 && !panel.unit.trim()) return {
|
|
2892
|
+
id,
|
|
2893
|
+
refused: `${panel.label}: a panel unit needs text.`
|
|
2894
|
+
};
|
|
2895
|
+
if (panel.format !== void 0 && !ownUnits) return {
|
|
2896
|
+
id,
|
|
2897
|
+
refused: `${panel.label}: a panel format needs shared: "x"; panels on a shared y axis use formatY.`
|
|
2898
|
+
};
|
|
2899
|
+
if (panel.format !== void 0 && typeof panel.format !== "function") return {
|
|
2900
|
+
id,
|
|
2901
|
+
refused: `${panel.label}: a panel format is a function.`
|
|
2902
|
+
};
|
|
2648
2903
|
if (panel.missing) {
|
|
2649
2904
|
if (!panel.missing.why?.trim()) return {
|
|
2650
2905
|
id,
|
|
@@ -2665,19 +2920,24 @@ function smallMultiples(panels, o) {
|
|
|
2665
2920
|
id,
|
|
2666
2921
|
refused: `${panel.label}: an x-only shared figure needs each panel's y domain.`
|
|
2667
2922
|
};
|
|
2668
|
-
const own = domainGap(domain)
|
|
2923
|
+
const own = domainGap(domain) ?? yAxisGap({
|
|
2924
|
+
domain,
|
|
2925
|
+
scale: panel.scale,
|
|
2926
|
+
targets: panel.targets
|
|
2927
|
+
}, panel.label);
|
|
2669
2928
|
if (own) return {
|
|
2670
2929
|
id,
|
|
2671
2930
|
refused: `${panel.label}: ${own}`
|
|
2672
2931
|
};
|
|
2673
2932
|
let previous = -Infinity;
|
|
2674
2933
|
for (const point of panel.points) {
|
|
2675
|
-
|
|
2934
|
+
const ordered = columns ? point.x >= previous && columns.has(point.x) : point.x > previous;
|
|
2935
|
+
if (!Number.isFinite(point.x) || !ordered || point.x < o.x.domain[0] || point.x > o.x.domain[1]) return {
|
|
2676
2936
|
id,
|
|
2677
|
-
refused: `${panel.label}: x values must be finite, strictly increasing and inside the shared domain.`
|
|
2937
|
+
refused: columns ? `${panel.label}: x values must be categories, in order.` : `${panel.label}: x values must be finite, strictly increasing and inside the shared domain.`
|
|
2678
2938
|
};
|
|
2679
2939
|
previous = point.x;
|
|
2680
|
-
const problem = scalarGap(point.y, point.interval, point.observations, domain);
|
|
2940
|
+
const problem = scalarGap(point.y, point.interval, point.observations, domain) ?? logValueGap(point.y, panel.scale) ?? pointFieldsGap(point);
|
|
2681
2941
|
if (problem) return {
|
|
2682
2942
|
id,
|
|
2683
2943
|
refused: `${panel.label} at ${point.x}: ${problem}`
|
|
@@ -2705,8 +2965,11 @@ function smallMultiples(panels, o) {
|
|
|
2705
2965
|
};
|
|
2706
2966
|
}
|
|
2707
2967
|
const noun = o.noun?.trim() || "panel";
|
|
2708
|
-
const
|
|
2709
|
-
const
|
|
2968
|
+
const category = new Map((o.x.categories ?? []).map((c) => [c.at, c.label]));
|
|
2969
|
+
const xLabel = (x) => o.formatX ? o.formatX(x) : category.get(x) ?? String(x);
|
|
2970
|
+
const recorded = hasPointFields(panels.flatMap((p) => p.points ?? []));
|
|
2971
|
+
const targets = panels.filter((p) => p.targets?.length);
|
|
2972
|
+
const yLabelOf = (p) => (y) => scalarLabel(y, (ownUnits ? p.unit : void 0) ?? o.y.unit, (ownUnits ? p.format : void 0) ?? o.formatY);
|
|
2710
2973
|
const withData = panels.filter((p) => !p.missing), ended = withData.filter((p) => p.end);
|
|
2711
2974
|
const marksOf = (p) => [...p.marks ?? [], ...p.end ? [{
|
|
2712
2975
|
at: p.end.at,
|
|
@@ -2717,12 +2980,26 @@ function smallMultiples(panels, o) {
|
|
|
2717
2980
|
p.label,
|
|
2718
2981
|
"",
|
|
2719
2982
|
`no data: ${p.missing.why}`,
|
|
2720
|
-
p.missing.detail ?? ""
|
|
2983
|
+
p.missing.detail ?? "",
|
|
2984
|
+
...recorded ? [
|
|
2985
|
+
"",
|
|
2986
|
+
"",
|
|
2987
|
+
"",
|
|
2988
|
+
"",
|
|
2989
|
+
"",
|
|
2990
|
+
"",
|
|
2991
|
+
""
|
|
2992
|
+
] : []
|
|
2721
2993
|
]] : p.points.map((pt) => [
|
|
2722
2994
|
p.label,
|
|
2723
2995
|
xLabel(pt.x),
|
|
2724
|
-
pt.y === null ? "not measured" :
|
|
2725
|
-
observationLabel(pt.observations)
|
|
2996
|
+
pt.y === null ? "not measured" : yLabelOf(p)(pt.y),
|
|
2997
|
+
observationLabel(pt.observations),
|
|
2998
|
+
...recorded ? [
|
|
2999
|
+
pt.label ?? "",
|
|
3000
|
+
...pointCells(pt),
|
|
3001
|
+
pt.annotation ?? ""
|
|
3002
|
+
] : []
|
|
2726
3003
|
]));
|
|
2727
3004
|
const events = panels.flatMap((p) => marksOf(p).map((m) => ({
|
|
2728
3005
|
...m,
|
|
@@ -2732,10 +3009,10 @@ function smallMultiples(panels, o) {
|
|
|
2732
3009
|
id,
|
|
2733
3010
|
finding: `${capitalize(o.y.label)} by ${o.x.label}, one panel per ${noun}, on ${o.shared === "both" ? "shared axes" : "a shared x axis"}.`,
|
|
2734
3011
|
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
|
|
3012
|
+
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.${recorded ? " A filled mark is a pass, an outline a fail; * is not comparable with the goal and ? of unknown comparability; ✓ marks a solve and ✕ a failing candidate." : ""}${targets.length ? " Shaded bands are recorded targets; dashed bands are stretch targets." : ""}${panels.some((p) => p.scale === "log") ? " Log-scale panels say so in their y labels." : ""}`,
|
|
2736
3013
|
svg: {
|
|
2737
|
-
wide: draw(panels, o, 720, xLabel,
|
|
2738
|
-
narrow: draw(panels, o, 358, xLabel,
|
|
3014
|
+
wide: draw(panels, o, 720, xLabel, yLabelOf, marksOf),
|
|
3015
|
+
narrow: draw(panels, o, 358, xLabel, yLabelOf, marksOf)
|
|
2739
3016
|
},
|
|
2740
3017
|
table: table(`${capitalize(o.y.label)} by ${o.x.label} and ${noun}`, [
|
|
2741
3018
|
{ label: capitalize(noun) },
|
|
@@ -2747,8 +3024,13 @@ function smallMultiples(panels, o) {
|
|
|
2747
3024
|
label: o.y.label,
|
|
2748
3025
|
numeric: true
|
|
2749
3026
|
},
|
|
2750
|
-
{ label: "Observations or detail" }
|
|
2751
|
-
|
|
3027
|
+
{ label: "Observations or detail" },
|
|
3028
|
+
...recorded ? [
|
|
3029
|
+
{ label: "Label" },
|
|
3030
|
+
...POINT_COLUMNS,
|
|
3031
|
+
{ label: "Recorded annotation" }
|
|
3032
|
+
] : []
|
|
3033
|
+
], rows) + targets.map((p) => targetsTable(p.targets, yLabelOf(p)).replace("<caption>Recorded targets</caption>", `<caption>${esc(p.label)}: recorded targets</caption>`)).join("") + eventsTable(events, [], o.x.label, xLabel),
|
|
2752
3034
|
note: {
|
|
2753
3035
|
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
3036
|
n: `${withData.length} of ${panels.length} ${plural(panels.length, noun)} with data.`,
|
|
@@ -2759,22 +3041,49 @@ function smallMultiples(panels, o) {
|
|
|
2759
3041
|
function domainOf(panel, o) {
|
|
2760
3042
|
return o.shared === "both" ? o.y.domain : panel.domain ?? null;
|
|
2761
3043
|
}
|
|
2762
|
-
function draw(panels, o, width, xLabel,
|
|
2763
|
-
const cols = width === 358 ? 1 : Math.min(3, panels.length), gap = 16;
|
|
2764
|
-
const panelW = (width - gap * (cols - 1)) / cols
|
|
3044
|
+
function draw(panels, o, width, xLabel, yLabelOf, marksOf) {
|
|
3045
|
+
const cols = width === 358 ? 1 : Math.min(o.columns ?? 3, panels.length), gap = 16;
|
|
3046
|
+
const panelW = (width - gap * (cols - 1)) / cols;
|
|
3047
|
+
const labelled = (o.x.categories ?? []).filter((c) => xLabel(c.at) !== "" && (c.weight ?? 1) >= 1).map((c) => c.at);
|
|
3048
|
+
const gutterOf = (panel) => {
|
|
3049
|
+
const domain = domainOf(panel, o) ?? o.y.domain, yLabel = yLabelOf(panel);
|
|
3050
|
+
const logged = panel.scale === "log" ? logTicks(domain[0], domain[1]) : [];
|
|
3051
|
+
const decades = logged.filter((v) => Number.isInteger(Math.log10(v)));
|
|
3052
|
+
const ticks = panel.scale === "log" ? decades.length >= 2 ? decades : logged.length >= 2 ? [logged[0], logged.at(-1)] : [domain[0], domain[1]] : [domain[0], domain[1]];
|
|
3053
|
+
return {
|
|
3054
|
+
ticks,
|
|
3055
|
+
gutter: Math.max(...ticks.map((v) => Math.ceil(textWidth(yLabel(v), 10, "mono")) + 10), 40)
|
|
3056
|
+
};
|
|
3057
|
+
};
|
|
3058
|
+
const narrowestPlot = panelW - 6 - Math.max(...panels.map((p) => gutterOf(p).gutter));
|
|
3059
|
+
const totalWeight = (o.x.categories ?? []).reduce((sum, c) => sum + (c.weight ?? 1), 0);
|
|
3060
|
+
const labelFit = labelled.length ? columnLabelFit(labelled.map(xLabel), Math.min(...labelled.map((x) => (o.x.categories.find((c) => c.at === x).weight ?? 1) / totalWeight * narrowestPlot)), 10) : {
|
|
3061
|
+
fit: "level",
|
|
3062
|
+
extra: 0
|
|
3063
|
+
};
|
|
3064
|
+
const panelH = (width === 358 ? 150 : 170) + labelFit.extra;
|
|
2765
3065
|
let body = "";
|
|
2766
3066
|
panels.forEach((panel, i) => {
|
|
2767
3067
|
const ox = i % cols * (panelW + gap), oy = Math.floor(i / cols) * (panelH + gap);
|
|
2768
3068
|
const domain = domainOf(panel, o) ?? o.y.domain;
|
|
2769
|
-
const
|
|
2770
|
-
const
|
|
2771
|
-
const
|
|
2772
|
-
const
|
|
2773
|
-
const
|
|
3069
|
+
const yLabel = yLabelOf(panel);
|
|
3070
|
+
const log = panel.scale === "log";
|
|
3071
|
+
const { ticks: yTicks, gutter } = gutterOf(panel);
|
|
3072
|
+
const left = ox + gutter, right = ox + panelW - 6;
|
|
3073
|
+
const top = oy + 40, bottom = oy + panelH - 24 - labelFit.extra;
|
|
3074
|
+
const axis = xAxis(o.x.domain, o.x.categories, left, right);
|
|
3075
|
+
const px = axis.px;
|
|
3076
|
+
const py = yScale(domain, panel.scale, top, bottom);
|
|
3077
|
+
const name = wrap(panel.label, panelW - 4 - (log ? 52 : 0), 12, "sans-semibold", 1);
|
|
2774
3078
|
body += titled(panel.label, text(ox + 2, oy + 14, name.lines[0], {
|
|
2775
3079
|
size: 12,
|
|
2776
3080
|
weight: 600
|
|
2777
3081
|
}));
|
|
3082
|
+
if (log) body += text(right, oy + 14, "log scale", {
|
|
3083
|
+
size: 10,
|
|
3084
|
+
anchor: "end",
|
|
3085
|
+
cls: "tgc-ink-muted"
|
|
3086
|
+
});
|
|
2778
3087
|
if (panel.missing) {
|
|
2779
3088
|
body += `<g data-panel="${esc(panel.id)}" data-missing="true">${hatch(ox, top - 12, panelW, bottom - top + 12, "tgc-panel-missing")}`;
|
|
2780
3089
|
const why = wrap(`no data: ${panel.missing.why}`, panelW - 24, 12, "sans", 3);
|
|
@@ -2782,8 +3091,15 @@ function draw(panels, o, width, xLabel, yLabel, marksOf) {
|
|
|
2782
3091
|
body += "</g>";
|
|
2783
3092
|
return;
|
|
2784
3093
|
}
|
|
2785
|
-
body += `<g data-panel="${esc(panel.id)}">`;
|
|
2786
|
-
|
|
3094
|
+
body += `<g data-panel="${esc(panel.id)}"${log ? " data-scale=\"log\"" : ""}>`;
|
|
3095
|
+
if (log) {
|
|
3096
|
+
body += line(left, bottom, right, bottom, "tgc-axis");
|
|
3097
|
+
for (const y of yTicks) body += (y === domain[0] ? "" : line(left, py(y), right, py(y), "tgc-gridline")) + text(left - 6, py(y) + 4, yLabel(y), {
|
|
3098
|
+
size: 10,
|
|
3099
|
+
anchor: "end",
|
|
3100
|
+
cls: "tgc-ink-muted tgc-num"
|
|
3101
|
+
});
|
|
3102
|
+
} else for (const [j, y] of [
|
|
2787
3103
|
domain[0],
|
|
2788
3104
|
(domain[0] + domain[1]) / 2,
|
|
2789
3105
|
domain[1]
|
|
@@ -2795,7 +3111,9 @@ function draw(panels, o, width, xLabel, yLabel, marksOf) {
|
|
|
2795
3111
|
cls: "tgc-ink-muted tgc-num"
|
|
2796
3112
|
});
|
|
2797
3113
|
}
|
|
2798
|
-
body +=
|
|
3114
|
+
body += drawTargets(panel.targets ?? [], py, left, right, yLabel, false) + targetKey(panel.targets ?? [], ox + 2, oy + 30, panelW - 4);
|
|
3115
|
+
if (labelled.length) body += columnLabels(labelled, px, labelFit.fit, left, right, right + 4, xLabel, 10, bottom + 16);
|
|
3116
|
+
else body += text(left, bottom + 16, xLabel(o.x.domain[0]), {
|
|
2799
3117
|
size: 10,
|
|
2800
3118
|
cls: "tgc-ink-muted tgc-num"
|
|
2801
3119
|
}) + text(right, bottom + 16, xLabel(o.x.domain[1]), {
|
|
@@ -2803,9 +3121,18 @@ function draw(panels, o, width, xLabel, yLabel, marksOf) {
|
|
|
2803
3121
|
anchor: "end",
|
|
2804
3122
|
cls: "tgc-ink-muted tgc-num"
|
|
2805
3123
|
});
|
|
3124
|
+
const shared = /* @__PURE__ */ new Map();
|
|
3125
|
+
for (const point of panel.points) if (point.y !== null) shared.set(point.x, [...shared.get(point.x) ?? [], point]);
|
|
3126
|
+
const xOf = (point) => {
|
|
3127
|
+
const list = shared.get(point.x), room = axis.column(point.x);
|
|
3128
|
+
const step = room === null ? 0 : Math.min(9, room / (list.length + 1));
|
|
3129
|
+
return px(point.x) + (list.indexOf(point) - (list.length - 1) / 2) * step;
|
|
3130
|
+
};
|
|
3131
|
+
const placed = [];
|
|
3132
|
+
const pending = [];
|
|
2806
3133
|
let segment = [];
|
|
2807
3134
|
const flush = () => {
|
|
2808
|
-
if (segment.length > 1) body += polyline(segment, "tgc-series-line");
|
|
3135
|
+
if (segment.length > 1 && o.connect !== false) body += polyline(segment, "tgc-series-line");
|
|
2809
3136
|
segment = [];
|
|
2810
3137
|
};
|
|
2811
3138
|
for (const point of panel.points) {
|
|
@@ -2813,15 +3140,43 @@ function draw(panels, o, width, xLabel, yLabel, marksOf) {
|
|
|
2813
3140
|
flush();
|
|
2814
3141
|
continue;
|
|
2815
3142
|
}
|
|
2816
|
-
|
|
2817
|
-
|
|
3143
|
+
const x = xOf(point), y = py(point.y);
|
|
3144
|
+
segment.push([x, y]);
|
|
3145
|
+
if (!hasPointFields([point]) && point.marker === void 0) {
|
|
3146
|
+
body += titled(`${panel.label} · ${o.x.label} ${xLabel(point.x)} · ${yLabel(point.y)} · ${observationLabel(point.observations)}`, circle(x, y, 2.5, "tgc-series-point"));
|
|
3147
|
+
continue;
|
|
3148
|
+
}
|
|
3149
|
+
const facts = [
|
|
3150
|
+
panel.label,
|
|
3151
|
+
`${o.x.label} ${xLabel(point.x)}`,
|
|
3152
|
+
yLabel(point.y),
|
|
3153
|
+
...point.label ? [point.label] : [],
|
|
3154
|
+
...pointFacts(point),
|
|
3155
|
+
...point.observations ? [observationLabel(point.observations)] : [],
|
|
3156
|
+
...point.annotation ? [point.annotation] : []
|
|
3157
|
+
];
|
|
3158
|
+
body += titled(facts.join(" · "), `<g data-point-verdict="${point.verdict ?? "none"}">${circle(x, y, 9, "tgc-series-hit")}${pointMark(x, y, point.marker ?? "circle", 4, point)}</g>`);
|
|
3159
|
+
placed.push([
|
|
3160
|
+
x - 4,
|
|
3161
|
+
x + 4,
|
|
3162
|
+
y - 4,
|
|
3163
|
+
y + 4
|
|
3164
|
+
]);
|
|
3165
|
+
pending.push({
|
|
3166
|
+
x,
|
|
3167
|
+
y,
|
|
3168
|
+
r: 4,
|
|
3169
|
+
point
|
|
3170
|
+
});
|
|
2818
3171
|
}
|
|
2819
3172
|
flush();
|
|
3173
|
+
body += placeBadges(pending, placed, left - 4, right + 4);
|
|
2820
3174
|
body += drawMarks(marksOf(panel), px, top - 12, bottom, left, right, xLabel);
|
|
2821
3175
|
body += "</g>";
|
|
2822
3176
|
});
|
|
2823
|
-
const rows = Math.ceil(panels.length / cols);
|
|
2824
|
-
|
|
3177
|
+
const rows = Math.ceil(panels.length / cols), height = rows * panelH + (rows - 1) * gap;
|
|
3178
|
+
const key = verdictKey(panels.flatMap((p) => p.points ?? []), o.markers, 2, height + 4, width - 4);
|
|
3179
|
+
return svgRoot(width, height + key.height, body + key.svg);
|
|
2825
3180
|
}
|
|
2826
3181
|
//#endregion
|
|
2827
|
-
export { breakdown, chartTokensCss, comparisonIntervals, costFrontier, figureCss, heatmap, isRefusal, latencyRange, metricBars, phaseWaterfall, rankedRates, rateOrder, renderFigure, renderNotes, renderRefusals, smallMultiples, taskMatrix, timeSeries, timeline };
|
|
3182
|
+
export { CHECKER_HUES, OUTCOME_COLORS, OUTCOME_ORDER, breakdown, chartTokensCss, checkerColor, comparisonIntervals, costFrontier, figureCss, heatmap, isRefusal, latencyRange, metricBars, phaseWaterfall, rankedRates, rateOrder, renderFigure, renderNotes, renderRefusals, smallMultiples, taskMatrix, timeSeries, timeline };
|