@tangle-network/charts 0.8.0 → 0.9.1
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 +40 -2
- package/dist/figure.css +64 -6
- package/dist/index.d.ts +39 -4
- package/dist/index.js +737 -124
- package/dist/react/index.d.ts +2 -6
- package/dist/react/index.js +1 -1
- package/dist/react/live.css +2 -0
- package/dist/{timeline-CCWFvLxg.js → timeline-BAwebuqb.js} +69 -14
- package/dist/{timeline-CCEJug7n.d.ts → timeline-CNHABOS1.d.ts} +80 -5
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A 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.
|
|
@@ -1136,7 +1136,7 @@ function plot$1(costed, unplaced, drawLine, o) {
|
|
|
1136
1136
|
//#endregion
|
|
1137
1137
|
//#region src/css.generated.ts
|
|
1138
1138
|
/** The figure block and mark styles (src/figure.css), for documents that inline their CSS. */
|
|
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(--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-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-pass, var(--status-running)); }\n.tgc-tick-quota, .tgc-tick-rate-limit { stroke: color-mix(in srgb, var(--chart-breach, var(--status-error)) 55%, var(--chart-canvas)); }\n.tgc-tick-oom, .tgc-tick-refused, .tgc-tick-other { stroke: var(--chart-breach, var(--status-error)); }\n.tgc-tick-aborted { stroke: var(--chart-ink); }\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";
|
|
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";
|
|
1140
1140
|
/** --chart-* resolved per theme, for documents without brand tokens: light, dark by preference or [data-theme="dark"], light in print. */
|
|
1141
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}}";
|
|
1142
1142
|
//#endregion
|
|
@@ -2172,11 +2172,433 @@ function metricBars(rows, o) {
|
|
|
2172
2172
|
};
|
|
2173
2173
|
}
|
|
2174
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
|
|
2175
2597
|
//#region src/time-series.ts
|
|
2176
2598
|
/** Recorded trajectories. Explicit null observations break lines without interpolation. */
|
|
2177
2599
|
function timeSeries(rows, o) {
|
|
2178
2600
|
const id = o.id ?? "time-series";
|
|
2179
|
-
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);
|
|
2180
2602
|
if (gap) return {
|
|
2181
2603
|
id,
|
|
2182
2604
|
refused: gap
|
|
@@ -2194,167 +2616,240 @@ function timeSeries(rows, o) {
|
|
|
2194
2616
|
id,
|
|
2195
2617
|
refused: "Duplicate trajectory ids."
|
|
2196
2618
|
};
|
|
2619
|
+
const columns = o.x.categories?.length ? new Set(o.x.categories.map((c) => c.at)) : null;
|
|
2197
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
|
+
};
|
|
2198
2625
|
let previous = -Infinity;
|
|
2199
2626
|
for (const point of row.points) {
|
|
2200
|
-
|
|
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 {
|
|
2201
2629
|
id,
|
|
2202
|
-
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.`
|
|
2203
2631
|
};
|
|
2204
2632
|
previous = point.x;
|
|
2205
|
-
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);
|
|
2206
2634
|
if (problem) return {
|
|
2207
2635
|
id,
|
|
2208
2636
|
refused: `${row.label} at ${point.x}: ${problem}`
|
|
2209
2637
|
};
|
|
2210
2638
|
}
|
|
2211
2639
|
}
|
|
2212
|
-
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);
|
|
2213
2642
|
const yLabel = (y) => scalarLabel(y, o.y.unit, o.formatY);
|
|
2214
|
-
|
|
2215
|
-
id,
|
|
2216
|
-
refused: "Formatted y-axis labels leave no room for the phone chart. Use a shorter formatter or unit."
|
|
2217
|
-
};
|
|
2643
|
+
const grid = yGridlines(o.y, yLabel);
|
|
2218
2644
|
const flat = rows.flatMap((row) => row.points.map((point) => ({
|
|
2219
2645
|
row,
|
|
2220
2646
|
point
|
|
2221
2647
|
})));
|
|
2222
2648
|
const measured = flat.filter(({ point }) => point.y !== null);
|
|
2649
|
+
const stripped = flat.filter(({ point }) => point.y === null && point.verdict !== void 0);
|
|
2650
|
+
const recorded = hasPointFields(flat.map(({ point }) => point));
|
|
2223
2651
|
const marks = o.marks ?? [], bands = o.bands ?? [];
|
|
2224
2652
|
const hasEvents = marks.length + bands.length > 0;
|
|
2653
|
+
const groups = bands.some((band) => band.kind === "group");
|
|
2654
|
+
const stripName = o.y.strip?.trim() || "no value";
|
|
2655
|
+
if (axisGutter(grid) >= 270) return {
|
|
2656
|
+
id,
|
|
2657
|
+
refused: "Formatted y-axis labels leave no room for the phone chart. Use a shorter formatter or unit."
|
|
2658
|
+
};
|
|
2659
|
+
if (stripped.length && axisGutter(grid, stripName) >= 270) return {
|
|
2660
|
+
id,
|
|
2661
|
+
refused: "The strip name leaves no room for the phone chart. Use a shorter y.strip."
|
|
2662
|
+
};
|
|
2663
|
+
const valueText = (point) => point.y !== null ? yLabel(point.y) : point.verdict !== void 0 ? `${stripName} (${VERDICT_WORD[point.verdict]})` : "not measured";
|
|
2225
2664
|
const facts = (row, point) => [
|
|
2226
2665
|
row.label,
|
|
2227
2666
|
`${o.x.label}: ${xLabel(point.x)}`,
|
|
2228
|
-
`${o.y.label}: ${
|
|
2229
|
-
|
|
2667
|
+
`${o.y.label}: ${valueText(point)}`,
|
|
2668
|
+
...point.label ? [point.label] : [],
|
|
2669
|
+
...pointFacts(point),
|
|
2670
|
+
...point.observations || !hasPointFields([point]) ? [observationLabel(point.observations)] : [],
|
|
2230
2671
|
...point.interval ? [`Interval: ${scalarIntervalLabel(point.interval, o.y.unit, o.formatY)}`] : [],
|
|
2231
2672
|
...point.annotation ? [point.annotation] : []
|
|
2232
2673
|
];
|
|
2674
|
+
const head = [
|
|
2675
|
+
{ label: "Setup" },
|
|
2676
|
+
{
|
|
2677
|
+
label: o.x.label,
|
|
2678
|
+
numeric: true
|
|
2679
|
+
},
|
|
2680
|
+
{
|
|
2681
|
+
label: o.y.label,
|
|
2682
|
+
numeric: true
|
|
2683
|
+
},
|
|
2684
|
+
{ label: "Observations" },
|
|
2685
|
+
{ label: "Recorded interval" },
|
|
2686
|
+
{ label: "Recorded annotation" },
|
|
2687
|
+
...recorded ? [{ label: "Label" }, ...POINT_COLUMNS] : []
|
|
2688
|
+
];
|
|
2689
|
+
const cells = (row, point) => [
|
|
2690
|
+
row.label,
|
|
2691
|
+
xLabel(point.x),
|
|
2692
|
+
point.y === null ? point.verdict !== void 0 ? `${stripName} (${VERDICT_WORD[point.verdict]})` : "not measured" : yLabel(point.y),
|
|
2693
|
+
observationLabel(point.observations),
|
|
2694
|
+
scalarIntervalLabel(point.interval, o.y.unit, o.formatY),
|
|
2695
|
+
point.annotation ?? "",
|
|
2696
|
+
...recorded ? [point.label ?? "", ...pointCells(point)] : []
|
|
2697
|
+
];
|
|
2233
2698
|
return {
|
|
2234
2699
|
id,
|
|
2235
2700
|
finding: `${capitalize(o.y.label)} by ${o.x.label}.`,
|
|
2236
|
-
lede: `${measured.length} of ${flat.length} positions have a recorded value
|
|
2237
|
-
read: `Points show recorded values. Lines connect adjacent measured positions; missing positions break the line. Whiskers show recorded intervals.${hasEvents ? ` ${EVENTS_READ}` : ""}`,
|
|
2701
|
+
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.` : ""}`,
|
|
2702
|
+
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}` : ""}`,
|
|
2238
2703
|
svg: {
|
|
2239
|
-
wide: plot(rows, o, 720, xLabel, yLabel, facts),
|
|
2240
|
-
narrow: plot(rows, o, 358, xLabel, yLabel, facts),
|
|
2704
|
+
wide: plot(rows, o, 720, xLabel, yLabel, grid, facts, stripName),
|
|
2705
|
+
narrow: plot(rows, o, 358, xLabel, yLabel, grid, facts, stripName),
|
|
2241
2706
|
interactive: true
|
|
2242
2707
|
},
|
|
2243
|
-
table: table(`${capitalize(o.y.label)} by ${o.x.label}`, [
|
|
2244
|
-
{ label: "Setup" },
|
|
2245
|
-
{
|
|
2246
|
-
label: o.x.label,
|
|
2247
|
-
numeric: true
|
|
2248
|
-
},
|
|
2249
|
-
{
|
|
2250
|
-
label: o.y.label,
|
|
2251
|
-
numeric: true
|
|
2252
|
-
},
|
|
2253
|
-
{ label: "Observations" },
|
|
2254
|
-
{ label: "Recorded interval" },
|
|
2255
|
-
{ label: "Recorded annotation" }
|
|
2256
|
-
], flat.map(({ row, point }) => [
|
|
2257
|
-
row.label,
|
|
2258
|
-
xLabel(point.x),
|
|
2259
|
-
point.y === null ? "not measured" : yLabel(point.y),
|
|
2260
|
-
observationLabel(point.observations),
|
|
2261
|
-
scalarIntervalLabel(point.interval, o.y.unit, o.formatY),
|
|
2262
|
-
point.annotation ?? ""
|
|
2263
|
-
])).replace("class=\"tgc-table\"", "class=\"tgc-table tgc-scalar-table\"") + eventsTable(marks, bands, o.x.label, xLabel),
|
|
2708
|
+
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),
|
|
2264
2709
|
note: {
|
|
2265
2710
|
method: "Recorded scalar observations. Missing positions are not interpolated; no uncertainty or statistical comparison is inferred.",
|
|
2266
2711
|
n: "Observation counts are recorded separately at each position.",
|
|
2267
|
-
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)}.`)]
|
|
2712
|
+
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)}.`)]
|
|
2268
2713
|
}
|
|
2269
2714
|
};
|
|
2270
2715
|
}
|
|
2271
|
-
function plot(rows, o, width, xLabel, yLabel, facts) {
|
|
2272
|
-
const
|
|
2273
|
-
const left = axisGutter(
|
|
2716
|
+
function plot(rows, o, width, xLabel, yLabel, grid, facts, stripName) {
|
|
2717
|
+
const strip = rows.some((row) => row.points.some((point) => point.y === null && point.verdict !== void 0)) ? 24 : 0;
|
|
2718
|
+
const left = axisGutter(grid, strip ? stripName : void 0);
|
|
2274
2719
|
const right = width - 24;
|
|
2275
|
-
const
|
|
2720
|
+
const axis = xAxis(o.x.domain, o.x.categories, left, right);
|
|
2721
|
+
const px = axis.px;
|
|
2276
2722
|
const labelRows = markLabelRows(o.marks ?? [], px, left, right);
|
|
2277
|
-
const top = 36 + (labelRows ? labelRows * 14 + 2 : 0), bottom = top + 224;
|
|
2278
|
-
const py = (
|
|
2723
|
+
const top = 36 + (labelRows ? labelRows * 14 + 2 : 0) + groupRowHeight(o.bands), bottom = top + 224;
|
|
2724
|
+
const py = yScale(o.y.domain, o.y.scale, top, bottom);
|
|
2279
2725
|
let marks = text(left, 18, o.y.label, { cls: "tgc-ink-muted" });
|
|
2280
|
-
for (const [i,
|
|
2726
|
+
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, {
|
|
2281
2727
|
anchor: "end",
|
|
2282
2728
|
cls: "tgc-ink-muted tgc-num",
|
|
2283
2729
|
size: 12
|
|
2284
2730
|
});
|
|
2285
|
-
|
|
2286
|
-
|
|
2731
|
+
if (o.y.ticks?.length) marks += line(left, bottom, right, bottom, "tgc-axis");
|
|
2732
|
+
marks += drawTargets(o.y.targets ?? [], py, left, right, yLabel);
|
|
2733
|
+
const recordedXs = [...new Set(rows.flatMap((row) => row.points.map((point) => point.x)))].sort((a, b) => a - b);
|
|
2734
|
+
const full = (o.x.categories ?? []).filter((c) => (c.weight ?? 1) >= 1).map((c) => c.at);
|
|
2735
|
+
const xs = (axis.ats ? full.length ? full : axis.ats : recordedXs).filter((x) => xLabel(x) !== "");
|
|
2736
|
+
let turn = 0;
|
|
2737
|
+
if (axis.ats) {
|
|
2738
|
+
const fit = columnLabelFit(xs.map(xLabel), Math.min(...xs.map((x) => axis.column(x))), 12);
|
|
2739
|
+
turn = fit.extra;
|
|
2740
|
+
marks += columnLabels(xs, px, fit.fit, left, right, width, xLabel, 12, bottom + strip + 20);
|
|
2741
|
+
} else for (const x of xTicks(xs, px, left, right, width, xLabel)) marks += text(px(x), bottom + strip + 20, xLabel(x), {
|
|
2287
2742
|
anchor: "middle",
|
|
2288
2743
|
cls: "tgc-ink-muted tgc-num"
|
|
2289
2744
|
});
|
|
2290
|
-
marks += text((left + right) / 2, bottom + 43, o.x.label, {
|
|
2745
|
+
marks += text((left + right) / 2, bottom + strip + turn + 43, o.x.label, {
|
|
2291
2746
|
anchor: "middle",
|
|
2292
2747
|
cls: "tgc-ink-muted"
|
|
2293
2748
|
});
|
|
2294
|
-
marks += drawBands(o.bands ?? [], px, top, bottom, xLabel);
|
|
2295
|
-
|
|
2749
|
+
marks += drawBands(o.bands ?? [], px, top, bottom, xLabel, void 0, axis.extent);
|
|
2750
|
+
marks += drawGroups(o.bands ?? [], axis.extent, top, bottom, left, right, xLabel);
|
|
2751
|
+
if (strip) marks += text(left - 8, bottom + 16, stripName, {
|
|
2752
|
+
anchor: "end",
|
|
2753
|
+
cls: "tgc-ink-muted",
|
|
2754
|
+
size: 11
|
|
2755
|
+
}) + line(left, bottom + strip, right, bottom + strip, "tgc-gridline");
|
|
2756
|
+
const spread = (inStrip) => {
|
|
2757
|
+
const byX = /* @__PURE__ */ new Map();
|
|
2758
|
+
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]);
|
|
2759
|
+
const at = /* @__PURE__ */ new Map();
|
|
2760
|
+
for (const [x, list] of byX) {
|
|
2761
|
+
const room = axis.column(x);
|
|
2762
|
+
const step = room === null ? 0 : Math.min(10, room / (list.length + 1));
|
|
2763
|
+
list.forEach((point, k) => at.set(point, px(x) + (k - (list.length - 1) / 2) * step));
|
|
2764
|
+
}
|
|
2765
|
+
return at;
|
|
2766
|
+
};
|
|
2767
|
+
const plotX = spread(false), stripX = spread(true);
|
|
2768
|
+
let legendY = bottom + strip + turn + 72;
|
|
2296
2769
|
let points = "", tips = "", rules = "";
|
|
2770
|
+
const placed = [];
|
|
2771
|
+
const pending = [];
|
|
2297
2772
|
let pointId = 0;
|
|
2773
|
+
const legend = /* @__PURE__ */ new Set();
|
|
2298
2774
|
for (const [seriesIndex, row] of rows.entries()) {
|
|
2299
|
-
const
|
|
2775
|
+
const ord = row.colorIndex ?? seriesIndex;
|
|
2776
|
+
const dash = ord === 0 ? "" : `${2 + ord * 2} 3`;
|
|
2777
|
+
const group = checkerClass(row.colorIndex);
|
|
2778
|
+
const wrapSeries = (inner) => group ? `<g class="${group}">${inner}</g>` : inner;
|
|
2300
2779
|
let segment = [];
|
|
2780
|
+
let lines = "";
|
|
2301
2781
|
const flush = () => {
|
|
2302
|
-
if (segment.length > 1)
|
|
2782
|
+
if (segment.length > 1) lines += `<g data-series="${esc(row.id)}" style="stroke-dasharray:${dash}">${polyline(segment, "tgc-series-line")}</g>`;
|
|
2303
2783
|
segment = [];
|
|
2304
2784
|
};
|
|
2785
|
+
let own = "";
|
|
2305
2786
|
for (const point of row.points) {
|
|
2306
2787
|
if (point.y === null) {
|
|
2307
2788
|
flush();
|
|
2789
|
+
if (point.verdict === void 0) continue;
|
|
2790
|
+
const x = stripX.get(point), y = bottom + 12, size = Math.min(9, Math.max(3, (axis.column(point.x) ?? 11) - 2));
|
|
2791
|
+
const title = facts(row, point).join(" · ");
|
|
2792
|
+
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>`);
|
|
2793
|
+
placed.push([
|
|
2794
|
+
x - size / 2,
|
|
2795
|
+
x + size / 2,
|
|
2796
|
+
y - size / 2,
|
|
2797
|
+
y + size / 2
|
|
2798
|
+
]);
|
|
2799
|
+
pending.push({
|
|
2800
|
+
x,
|
|
2801
|
+
y,
|
|
2802
|
+
r: size / 2,
|
|
2803
|
+
point
|
|
2804
|
+
});
|
|
2308
2805
|
continue;
|
|
2309
2806
|
}
|
|
2310
|
-
const x =
|
|
2807
|
+
const x = plotX.get(point), y = py(point.y);
|
|
2311
2808
|
segment.push([x, y]);
|
|
2312
2809
|
const pointFacts = facts(row, point);
|
|
2313
2810
|
const key = pointId++;
|
|
2314
|
-
|
|
2811
|
+
const recorded = hasPointFields([point]) || point.marker !== void 0;
|
|
2812
|
+
const r = recorded ? 4.5 : 3.5;
|
|
2813
|
+
const shape = point.marker ?? (ord === 0 ? "circle" : MARKER_SHAPES[ord % MARKER_SHAPES.length]);
|
|
2814
|
+
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)}"/>`);
|
|
2315
2815
|
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");
|
|
2316
|
-
|
|
2816
|
+
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>`;
|
|
2817
|
+
placed.push([
|
|
2818
|
+
x - r,
|
|
2819
|
+
x + r,
|
|
2820
|
+
y - r,
|
|
2821
|
+
y + r
|
|
2822
|
+
]);
|
|
2823
|
+
if (recorded) pending.push({
|
|
2824
|
+
x,
|
|
2825
|
+
y,
|
|
2826
|
+
r,
|
|
2827
|
+
point
|
|
2828
|
+
});
|
|
2317
2829
|
const tipWidth = Math.min(280, width - 24), tipX = Math.max(12, Math.min(width - tipWidth - 12, x - tipWidth / 2));
|
|
2318
|
-
const
|
|
2319
|
-
tips += `<g class="tgc-series-tip" data-series-tip="${key}" aria-hidden="true">${rect(tipX, top, tipWidth,
|
|
2830
|
+
const tipLines = pointFacts.flatMap((fact) => wrap(fact, tipWidth - 24, 12, "sans", 8).lines);
|
|
2831
|
+
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>`;
|
|
2320
2832
|
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}`;
|
|
2321
2833
|
}
|
|
2322
2834
|
flush();
|
|
2835
|
+
marks += wrapSeries(lines);
|
|
2836
|
+
points += wrapSeries(own);
|
|
2837
|
+
const legendKey = `${row.label}\u0000${row.colorIndex ?? `s${seriesIndex}`}`;
|
|
2838
|
+
if (legend.has(legendKey)) continue;
|
|
2839
|
+
legend.add(legendKey);
|
|
2323
2840
|
const label = wrap(row.label, right - left - 32, 12, "sans", 8);
|
|
2324
|
-
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[
|
|
2841
|
+
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\"");
|
|
2325
2842
|
legendY += label.lines.length * 16 + 8;
|
|
2326
2843
|
}
|
|
2327
|
-
const
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
*/
|
|
2334
|
-
function xTicks(xs, px, left, right, width, label) {
|
|
2335
|
-
if (!xs.length) return [];
|
|
2336
|
-
const span = (x) => {
|
|
2337
|
-
const w = textWidth(label(x), 12, "mono");
|
|
2338
|
-
return [px(x) - w / 2, px(x) + w / 2];
|
|
2339
|
-
};
|
|
2340
|
-
const widest = Math.max(...xs.map((x) => textWidth(label(x), 12, "mono")));
|
|
2341
|
-
const slots = Math.max(1, Math.min(xs.length, Math.floor((right - left) / (widest + 12)) + 1));
|
|
2342
|
-
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]));
|
|
2343
|
-
const kept = [];
|
|
2344
|
-
for (const x of [...new Set(anchors)]) {
|
|
2345
|
-
const [a, b] = span(x);
|
|
2346
|
-
if (a < 2 || b > width - 2) continue;
|
|
2347
|
-
const previous = kept.at(-1);
|
|
2348
|
-
if (previous !== void 0 && a < span(previous)[1] + 10) {
|
|
2349
|
-
if (x !== xs.at(-1) || kept.length === 1) continue;
|
|
2350
|
-
kept.pop();
|
|
2351
|
-
}
|
|
2352
|
-
kept.push(x);
|
|
2353
|
-
}
|
|
2354
|
-
return kept;
|
|
2844
|
+
const badges = placeBadges(pending, placed, left - 4, width - 2);
|
|
2845
|
+
const key = verdictKey(rows.flatMap((row) => row.points), o.markers, left, legendY + 4, right - left);
|
|
2846
|
+
legendY += key.height;
|
|
2847
|
+
marks += key.svg;
|
|
2848
|
+
const events = drawMarks(o.marks ?? [], px, top - groupRowHeight(o.bands), bottom, left, right, xLabel, 22);
|
|
2849
|
+
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>`;
|
|
2355
2850
|
}
|
|
2356
|
-
function axisGutter(ticks,
|
|
2357
|
-
return Math.max(64, ...ticks.map((
|
|
2851
|
+
function axisGutter(ticks, stripName) {
|
|
2852
|
+
return Math.max(64, ...ticks.map((tick) => Math.ceil(textWidth(tick.label, 12, "mono")) + 20), stripName ? Math.ceil(textWidth(stripName, 11)) + 14 : 0);
|
|
2358
2853
|
}
|
|
2359
2854
|
//#endregion
|
|
2360
2855
|
//#region src/small-multiples.ts
|
|
@@ -2369,15 +2864,16 @@ function smallMultiples(panels, o) {
|
|
|
2369
2864
|
id,
|
|
2370
2865
|
refused: "Small multiples need shared axes: both or x."
|
|
2371
2866
|
};
|
|
2372
|
-
const axis = domainGap(o.x.domain) ?? domainGap(o.y.domain);
|
|
2867
|
+
const axis = domainGap(o.x.domain) ?? domainGap(o.y.domain) ?? categoriesGap(o.x.categories, o.x.domain);
|
|
2373
2868
|
if (axis) return {
|
|
2374
2869
|
id,
|
|
2375
2870
|
refused: axis
|
|
2376
2871
|
};
|
|
2377
|
-
if (
|
|
2872
|
+
if (o.columns !== void 0 && (!Number.isInteger(o.columns) || o.columns < 1)) return {
|
|
2378
2873
|
id,
|
|
2379
|
-
refused: "
|
|
2874
|
+
refused: "Columns must be a whole number of at least 1."
|
|
2380
2875
|
};
|
|
2876
|
+
const columns = o.x.categories?.length ? new Set(o.x.categories.map((c) => c.at)) : null;
|
|
2381
2877
|
if (!panels.length) return {
|
|
2382
2878
|
id,
|
|
2383
2879
|
refused: "No panels recorded."
|
|
@@ -2386,11 +2882,28 @@ function smallMultiples(panels, o) {
|
|
|
2386
2882
|
id,
|
|
2387
2883
|
refused: "Duplicate panel ids."
|
|
2388
2884
|
};
|
|
2885
|
+
const ownUnits = o.shared === "x";
|
|
2886
|
+
if (!o.y.unit?.trim() && !(ownUnits && panels.every((p) => p.unit?.trim()))) return {
|
|
2887
|
+
id,
|
|
2888
|
+
refused: "Small multiples need a y unit."
|
|
2889
|
+
};
|
|
2389
2890
|
for (const panel of panels) {
|
|
2390
|
-
if (panel.unit !== void 0 && panel.unit !== o.y.unit) return {
|
|
2891
|
+
if (!ownUnits && panel.unit !== void 0 && panel.unit !== o.y.unit) return {
|
|
2391
2892
|
id,
|
|
2392
2893
|
refused: `Mixed units: ${panel.label} records ${panel.unit}, the figure draws ${o.y.unit}.`
|
|
2393
2894
|
};
|
|
2895
|
+
if (ownUnits && panel.unit !== void 0 && !panel.unit.trim()) return {
|
|
2896
|
+
id,
|
|
2897
|
+
refused: `${panel.label}: a panel unit needs text.`
|
|
2898
|
+
};
|
|
2899
|
+
if (panel.format !== void 0 && !ownUnits) return {
|
|
2900
|
+
id,
|
|
2901
|
+
refused: `${panel.label}: a panel format needs shared: "x"; panels on a shared y axis use formatY.`
|
|
2902
|
+
};
|
|
2903
|
+
if (panel.format !== void 0 && typeof panel.format !== "function") return {
|
|
2904
|
+
id,
|
|
2905
|
+
refused: `${panel.label}: a panel format is a function.`
|
|
2906
|
+
};
|
|
2394
2907
|
if (panel.missing) {
|
|
2395
2908
|
if (!panel.missing.why?.trim()) return {
|
|
2396
2909
|
id,
|
|
@@ -2411,19 +2924,24 @@ function smallMultiples(panels, o) {
|
|
|
2411
2924
|
id,
|
|
2412
2925
|
refused: `${panel.label}: an x-only shared figure needs each panel's y domain.`
|
|
2413
2926
|
};
|
|
2414
|
-
const own = domainGap(domain)
|
|
2927
|
+
const own = domainGap(domain) ?? yAxisGap({
|
|
2928
|
+
domain,
|
|
2929
|
+
scale: panel.scale,
|
|
2930
|
+
targets: panel.targets
|
|
2931
|
+
}, panel.label);
|
|
2415
2932
|
if (own) return {
|
|
2416
2933
|
id,
|
|
2417
2934
|
refused: `${panel.label}: ${own}`
|
|
2418
2935
|
};
|
|
2419
2936
|
let previous = -Infinity;
|
|
2420
2937
|
for (const point of panel.points) {
|
|
2421
|
-
|
|
2938
|
+
const ordered = columns ? point.x >= previous && columns.has(point.x) : point.x > previous;
|
|
2939
|
+
if (!Number.isFinite(point.x) || !ordered || point.x < o.x.domain[0] || point.x > o.x.domain[1]) return {
|
|
2422
2940
|
id,
|
|
2423
|
-
refused: `${panel.label}: x values must be finite, strictly increasing and inside the shared domain.`
|
|
2941
|
+
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.`
|
|
2424
2942
|
};
|
|
2425
2943
|
previous = point.x;
|
|
2426
|
-
const problem = scalarGap(point.y, point.interval, point.observations, domain);
|
|
2944
|
+
const problem = scalarGap(point.y, point.interval, point.observations, domain) ?? logValueGap(point.y, panel.scale) ?? pointFieldsGap(point);
|
|
2427
2945
|
if (problem) return {
|
|
2428
2946
|
id,
|
|
2429
2947
|
refused: `${panel.label} at ${point.x}: ${problem}`
|
|
@@ -2451,8 +2969,11 @@ function smallMultiples(panels, o) {
|
|
|
2451
2969
|
};
|
|
2452
2970
|
}
|
|
2453
2971
|
const noun = o.noun?.trim() || "panel";
|
|
2454
|
-
const
|
|
2455
|
-
const
|
|
2972
|
+
const category = new Map((o.x.categories ?? []).map((c) => [c.at, c.label]));
|
|
2973
|
+
const xLabel = (x) => o.formatX ? o.formatX(x) : category.get(x) ?? String(x);
|
|
2974
|
+
const recorded = hasPointFields(panels.flatMap((p) => p.points ?? []));
|
|
2975
|
+
const targets = panels.filter((p) => p.targets?.length);
|
|
2976
|
+
const yLabelOf = (p) => (y) => scalarLabel(y, (ownUnits ? p.unit : void 0) ?? o.y.unit, (ownUnits ? p.format : void 0) ?? o.formatY);
|
|
2456
2977
|
const withData = panels.filter((p) => !p.missing), ended = withData.filter((p) => p.end);
|
|
2457
2978
|
const marksOf = (p) => [...p.marks ?? [], ...p.end ? [{
|
|
2458
2979
|
at: p.end.at,
|
|
@@ -2463,12 +2984,26 @@ function smallMultiples(panels, o) {
|
|
|
2463
2984
|
p.label,
|
|
2464
2985
|
"",
|
|
2465
2986
|
`no data: ${p.missing.why}`,
|
|
2466
|
-
p.missing.detail ?? ""
|
|
2987
|
+
p.missing.detail ?? "",
|
|
2988
|
+
...recorded ? [
|
|
2989
|
+
"",
|
|
2990
|
+
"",
|
|
2991
|
+
"",
|
|
2992
|
+
"",
|
|
2993
|
+
"",
|
|
2994
|
+
"",
|
|
2995
|
+
""
|
|
2996
|
+
] : []
|
|
2467
2997
|
]] : p.points.map((pt) => [
|
|
2468
2998
|
p.label,
|
|
2469
2999
|
xLabel(pt.x),
|
|
2470
|
-
pt.y === null ? "not measured" :
|
|
2471
|
-
observationLabel(pt.observations)
|
|
3000
|
+
pt.y === null ? "not measured" : yLabelOf(p)(pt.y),
|
|
3001
|
+
observationLabel(pt.observations),
|
|
3002
|
+
...recorded ? [
|
|
3003
|
+
pt.label ?? "",
|
|
3004
|
+
...pointCells(pt),
|
|
3005
|
+
pt.annotation ?? ""
|
|
3006
|
+
] : []
|
|
2472
3007
|
]));
|
|
2473
3008
|
const events = panels.flatMap((p) => marksOf(p).map((m) => ({
|
|
2474
3009
|
...m,
|
|
@@ -2478,10 +3013,10 @@ function smallMultiples(panels, o) {
|
|
|
2478
3013
|
id,
|
|
2479
3014
|
finding: `${capitalize(o.y.label)} by ${o.x.label}, one panel per ${noun}, on ${o.shared === "both" ? "shared axes" : "a shared x axis"}.`,
|
|
2480
3015
|
lede: `${withData.length} of ${panels.length} ${plural(panels.length, noun)} have data${ended.length ? `; ${ended.length} ended before the window closed` : ""}.`,
|
|
2481
|
-
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
|
|
3016
|
+
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." : ""}`,
|
|
2482
3017
|
svg: {
|
|
2483
|
-
wide: draw(panels, o, 720, xLabel,
|
|
2484
|
-
narrow: draw(panels, o, 358, xLabel,
|
|
3018
|
+
wide: draw(panels, o, 720, xLabel, yLabelOf, marksOf),
|
|
3019
|
+
narrow: draw(panels, o, 358, xLabel, yLabelOf, marksOf)
|
|
2485
3020
|
},
|
|
2486
3021
|
table: table(`${capitalize(o.y.label)} by ${o.x.label} and ${noun}`, [
|
|
2487
3022
|
{ label: capitalize(noun) },
|
|
@@ -2493,8 +3028,13 @@ function smallMultiples(panels, o) {
|
|
|
2493
3028
|
label: o.y.label,
|
|
2494
3029
|
numeric: true
|
|
2495
3030
|
},
|
|
2496
|
-
{ label: "Observations or detail" }
|
|
2497
|
-
|
|
3031
|
+
{ label: "Observations or detail" },
|
|
3032
|
+
...recorded ? [
|
|
3033
|
+
{ label: "Label" },
|
|
3034
|
+
...POINT_COLUMNS,
|
|
3035
|
+
{ label: "Recorded annotation" }
|
|
3036
|
+
] : []
|
|
3037
|
+
], 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),
|
|
2498
3038
|
note: {
|
|
2499
3039
|
method: "Recorded values per panel on explicit axis domains. Missing panels and positions are not interpolated or drawn as zero; no ranking is made.",
|
|
2500
3040
|
n: `${withData.length} of ${panels.length} ${plural(panels.length, noun)} with data.`,
|
|
@@ -2505,22 +3045,49 @@ function smallMultiples(panels, o) {
|
|
|
2505
3045
|
function domainOf(panel, o) {
|
|
2506
3046
|
return o.shared === "both" ? o.y.domain : panel.domain ?? null;
|
|
2507
3047
|
}
|
|
2508
|
-
function draw(panels, o, width, xLabel,
|
|
2509
|
-
const cols = width === 358 ? 1 : Math.min(3, panels.length), gap = 16;
|
|
2510
|
-
const panelW = (width - gap * (cols - 1)) / cols
|
|
3048
|
+
function draw(panels, o, width, xLabel, yLabelOf, marksOf) {
|
|
3049
|
+
const cols = width === 358 ? 1 : Math.min(o.columns ?? 3, panels.length), gap = 16;
|
|
3050
|
+
const panelW = (width - gap * (cols - 1)) / cols;
|
|
3051
|
+
const labelled = (o.x.categories ?? []).filter((c) => xLabel(c.at) !== "" && (c.weight ?? 1) >= 1).map((c) => c.at);
|
|
3052
|
+
const gutterOf = (panel) => {
|
|
3053
|
+
const domain = domainOf(panel, o) ?? o.y.domain, yLabel = yLabelOf(panel);
|
|
3054
|
+
const logged = panel.scale === "log" ? logTicks(domain[0], domain[1]) : [];
|
|
3055
|
+
const decades = logged.filter((v) => Number.isInteger(Math.log10(v)));
|
|
3056
|
+
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]];
|
|
3057
|
+
return {
|
|
3058
|
+
ticks,
|
|
3059
|
+
gutter: Math.max(...ticks.map((v) => Math.ceil(textWidth(yLabel(v), 10, "mono")) + 10), 40)
|
|
3060
|
+
};
|
|
3061
|
+
};
|
|
3062
|
+
const narrowestPlot = panelW - 6 - Math.max(...panels.map((p) => gutterOf(p).gutter));
|
|
3063
|
+
const totalWeight = (o.x.categories ?? []).reduce((sum, c) => sum + (c.weight ?? 1), 0);
|
|
3064
|
+
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) : {
|
|
3065
|
+
fit: "level",
|
|
3066
|
+
extra: 0
|
|
3067
|
+
};
|
|
3068
|
+
const panelH = (width === 358 ? 150 : 170) + labelFit.extra;
|
|
2511
3069
|
let body = "";
|
|
2512
3070
|
panels.forEach((panel, i) => {
|
|
2513
3071
|
const ox = i % cols * (panelW + gap), oy = Math.floor(i / cols) * (panelH + gap);
|
|
2514
3072
|
const domain = domainOf(panel, o) ?? o.y.domain;
|
|
2515
|
-
const
|
|
2516
|
-
const
|
|
2517
|
-
const
|
|
2518
|
-
const
|
|
2519
|
-
const
|
|
3073
|
+
const yLabel = yLabelOf(panel);
|
|
3074
|
+
const log = panel.scale === "log";
|
|
3075
|
+
const { ticks: yTicks, gutter } = gutterOf(panel);
|
|
3076
|
+
const left = ox + gutter, right = ox + panelW - 6;
|
|
3077
|
+
const top = oy + 40, bottom = oy + panelH - 24 - labelFit.extra;
|
|
3078
|
+
const axis = xAxis(o.x.domain, o.x.categories, left, right);
|
|
3079
|
+
const px = axis.px;
|
|
3080
|
+
const py = yScale(domain, panel.scale, top, bottom);
|
|
3081
|
+
const name = wrap(panel.label, panelW - 4 - (log ? 52 : 0), 12, "sans-semibold", 1);
|
|
2520
3082
|
body += titled(panel.label, text(ox + 2, oy + 14, name.lines[0], {
|
|
2521
3083
|
size: 12,
|
|
2522
3084
|
weight: 600
|
|
2523
3085
|
}));
|
|
3086
|
+
if (log) body += text(right, oy + 14, "log scale", {
|
|
3087
|
+
size: 10,
|
|
3088
|
+
anchor: "end",
|
|
3089
|
+
cls: "tgc-ink-muted"
|
|
3090
|
+
});
|
|
2524
3091
|
if (panel.missing) {
|
|
2525
3092
|
body += `<g data-panel="${esc(panel.id)}" data-missing="true">${hatch(ox, top - 12, panelW, bottom - top + 12, "tgc-panel-missing")}`;
|
|
2526
3093
|
const why = wrap(`no data: ${panel.missing.why}`, panelW - 24, 12, "sans", 3);
|
|
@@ -2528,8 +3095,15 @@ function draw(panels, o, width, xLabel, yLabel, marksOf) {
|
|
|
2528
3095
|
body += "</g>";
|
|
2529
3096
|
return;
|
|
2530
3097
|
}
|
|
2531
|
-
body += `<g data-panel="${esc(panel.id)}">`;
|
|
2532
|
-
|
|
3098
|
+
body += `<g data-panel="${esc(panel.id)}"${log ? " data-scale=\"log\"" : ""}>`;
|
|
3099
|
+
if (log) {
|
|
3100
|
+
body += line(left, bottom, right, bottom, "tgc-axis");
|
|
3101
|
+
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), {
|
|
3102
|
+
size: 10,
|
|
3103
|
+
anchor: "end",
|
|
3104
|
+
cls: "tgc-ink-muted tgc-num"
|
|
3105
|
+
});
|
|
3106
|
+
} else for (const [j, y] of [
|
|
2533
3107
|
domain[0],
|
|
2534
3108
|
(domain[0] + domain[1]) / 2,
|
|
2535
3109
|
domain[1]
|
|
@@ -2541,7 +3115,9 @@ function draw(panels, o, width, xLabel, yLabel, marksOf) {
|
|
|
2541
3115
|
cls: "tgc-ink-muted tgc-num"
|
|
2542
3116
|
});
|
|
2543
3117
|
}
|
|
2544
|
-
body +=
|
|
3118
|
+
body += drawTargets(panel.targets ?? [], py, left, right, yLabel, false) + targetKey(panel.targets ?? [], ox + 2, oy + 30, panelW - 4);
|
|
3119
|
+
if (labelled.length) body += columnLabels(labelled, px, labelFit.fit, left, right, right + 4, xLabel, 10, bottom + 16);
|
|
3120
|
+
else body += text(left, bottom + 16, xLabel(o.x.domain[0]), {
|
|
2545
3121
|
size: 10,
|
|
2546
3122
|
cls: "tgc-ink-muted tgc-num"
|
|
2547
3123
|
}) + text(right, bottom + 16, xLabel(o.x.domain[1]), {
|
|
@@ -2549,9 +3125,18 @@ function draw(panels, o, width, xLabel, yLabel, marksOf) {
|
|
|
2549
3125
|
anchor: "end",
|
|
2550
3126
|
cls: "tgc-ink-muted tgc-num"
|
|
2551
3127
|
});
|
|
3128
|
+
const shared = /* @__PURE__ */ new Map();
|
|
3129
|
+
for (const point of panel.points) if (point.y !== null) shared.set(point.x, [...shared.get(point.x) ?? [], point]);
|
|
3130
|
+
const xOf = (point) => {
|
|
3131
|
+
const list = shared.get(point.x), room = axis.column(point.x);
|
|
3132
|
+
const step = room === null ? 0 : Math.min(9, room / (list.length + 1));
|
|
3133
|
+
return px(point.x) + (list.indexOf(point) - (list.length - 1) / 2) * step;
|
|
3134
|
+
};
|
|
3135
|
+
const placed = [];
|
|
3136
|
+
const pending = [];
|
|
2552
3137
|
let segment = [];
|
|
2553
3138
|
const flush = () => {
|
|
2554
|
-
if (segment.length > 1) body += polyline(segment, "tgc-series-line");
|
|
3139
|
+
if (segment.length > 1 && o.connect !== false) body += polyline(segment, "tgc-series-line");
|
|
2555
3140
|
segment = [];
|
|
2556
3141
|
};
|
|
2557
3142
|
for (const point of panel.points) {
|
|
@@ -2559,15 +3144,43 @@ function draw(panels, o, width, xLabel, yLabel, marksOf) {
|
|
|
2559
3144
|
flush();
|
|
2560
3145
|
continue;
|
|
2561
3146
|
}
|
|
2562
|
-
|
|
2563
|
-
|
|
3147
|
+
const x = xOf(point), y = py(point.y);
|
|
3148
|
+
segment.push([x, y]);
|
|
3149
|
+
if (!hasPointFields([point]) && point.marker === void 0) {
|
|
3150
|
+
body += titled(`${panel.label} · ${o.x.label} ${xLabel(point.x)} · ${yLabel(point.y)} · ${observationLabel(point.observations)}`, circle(x, y, 2.5, "tgc-series-point"));
|
|
3151
|
+
continue;
|
|
3152
|
+
}
|
|
3153
|
+
const facts = [
|
|
3154
|
+
panel.label,
|
|
3155
|
+
`${o.x.label} ${xLabel(point.x)}`,
|
|
3156
|
+
yLabel(point.y),
|
|
3157
|
+
...point.label ? [point.label] : [],
|
|
3158
|
+
...pointFacts(point),
|
|
3159
|
+
...point.observations ? [observationLabel(point.observations)] : [],
|
|
3160
|
+
...point.annotation ? [point.annotation] : []
|
|
3161
|
+
];
|
|
3162
|
+
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>`);
|
|
3163
|
+
placed.push([
|
|
3164
|
+
x - 4,
|
|
3165
|
+
x + 4,
|
|
3166
|
+
y - 4,
|
|
3167
|
+
y + 4
|
|
3168
|
+
]);
|
|
3169
|
+
pending.push({
|
|
3170
|
+
x,
|
|
3171
|
+
y,
|
|
3172
|
+
r: 4,
|
|
3173
|
+
point
|
|
3174
|
+
});
|
|
2564
3175
|
}
|
|
2565
3176
|
flush();
|
|
3177
|
+
body += placeBadges(pending, placed, left - 4, right + 4);
|
|
2566
3178
|
body += drawMarks(marksOf(panel), px, top - 12, bottom, left, right, xLabel);
|
|
2567
3179
|
body += "</g>";
|
|
2568
3180
|
});
|
|
2569
|
-
const rows = Math.ceil(panels.length / cols);
|
|
2570
|
-
|
|
3181
|
+
const rows = Math.ceil(panels.length / cols), height = rows * panelH + (rows - 1) * gap;
|
|
3182
|
+
const key = verdictKey(panels.flatMap((p) => p.points ?? []), o.markers, 2, height + 4, width - 4);
|
|
3183
|
+
return svgRoot(width, height + key.height, body + key.svg);
|
|
2571
3184
|
}
|
|
2572
3185
|
//#endregion
|
|
2573
|
-
export { breakdown, chartTokensCss, comparisonIntervals, costFrontier, figureCss, heatmap, isRefusal, latencyRange, metricBars, phaseWaterfall, rankedRates, rateOrder, renderFigure, renderNotes, renderRefusals, smallMultiples, taskMatrix, timeSeries, timeline };
|
|
3186
|
+
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 };
|