@tangle-network/charts 0.1.9 → 0.1.11
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 +53 -0
- package/dist/figure.css +26 -0
- package/dist/index.d.ts +81 -3
- package/dist/index.js +449 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,3 +68,56 @@ pnpm --filter @tangle-network/charts build # fails when src/css.generated.ts
|
|
|
68
68
|
## Release
|
|
69
69
|
|
|
70
70
|
Versions publish from the brand release workflow through npm trusted publishing, like `@tangle-network/brand` and `@tangle-network/ui`.
|
|
71
|
+
|
|
72
|
+
### Chart-first vertical rates
|
|
73
|
+
|
|
74
|
+
`rankedRates(rows, { measure: 'pass rate', orientation: 'vertical', order: 'input', identities })` draws vertical bars on a full 0–100% axis.
|
|
75
|
+
`order: 'input'` keeps unranked producer order; omit it to keep the existing ordering behavior.
|
|
76
|
+
Optional `identities` maps setup IDs to `model` and `harness` marks, each with an explicit `{ label, src }`.
|
|
77
|
+
Images must use same-origin absolute paths or HTTPS URLs; the library never infers a provider from a model name.
|
|
78
|
+
Hover or focus a column to read its actual count, eligible interval, known cost and run time.
|
|
79
|
+
Zero has no positive bar, and ineligible estimates keep their count without a bar.
|
|
80
|
+
|
|
81
|
+
Use `renderFigure(figure, noteNumber, { presentation: 'chart' })` for a chart-first surface.
|
|
82
|
+
The plot fills its container; data, reading instructions and method stay in a closed disclosure.
|
|
83
|
+
The default report presentation, underlying table and note links remain available.
|
|
84
|
+
Many columns keep a readable minimum width and scroll horizontally.
|
|
85
|
+
|
|
86
|
+
### Recorded scalar values and histories
|
|
87
|
+
|
|
88
|
+
`metricBars` draws continuous scores or counts on an explicit zero-based axis.
|
|
89
|
+
It preserves input order and draws no rank.
|
|
90
|
+
A known zero has no positive bar; a null value prints `not measured`.
|
|
91
|
+
Observation counts use the caller's explicit unit.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { isRefusal, metricBars, renderFigure, renderRefusals } from "@tangle-network/charts";
|
|
95
|
+
|
|
96
|
+
const figure = metricBars([
|
|
97
|
+
{ id: "a", label: "Setup A", value: 0, observations: { count: 13, label: "tasks" } },
|
|
98
|
+
{ id: "b", label: "Setup B", value: null, observations: { count: 0, label: "tasks" } },
|
|
99
|
+
], { measure: "mean composite score", unit: "score", domain: [0, 1] });
|
|
100
|
+
const html = isRefusal(figure) ? renderRefusals([figure]) : renderFigure(figure, 1, { presentation: "chart" });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`timeSeries` draws recorded scalar values at strictly increasing, finite positions.
|
|
104
|
+
The caller includes every configured position, with null for a missing observation.
|
|
105
|
+
Null breaks the line; the library never interpolates a missing value.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { isRefusal, renderFigure, renderRefusals, timeSeries } from "@tangle-network/charts";
|
|
109
|
+
|
|
110
|
+
const figure = timeSeries([{ id: "a", label: "Setup A", points: [
|
|
111
|
+
{ x: 1, y: 0, observations: { count: 13, label: "tasks" } },
|
|
112
|
+
{ x: 2, y: null, observations: { count: 0, label: "tasks" } },
|
|
113
|
+
{ x: 3, y: 0.5, observations: { count: 5, label: "tasks" } },
|
|
114
|
+
] }], { x: { label: "shot", domain: [1, 3] }, y: { label: "mean composite score", unit: "score", domain: [0, 1] } });
|
|
115
|
+
const html = isRefusal(figure) ? renderRefusals([figure]) : renderFigure(figure, 1, { presentation: "chart" });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Both examples show behavior controls, not benchmark results.
|
|
119
|
+
Both functions refuse out-of-domain values, invalid intervals and measured values with zero observations.
|
|
120
|
+
Intervals arrive with their recorded bounds, confidence level and method; neither function computes them.
|
|
121
|
+
Optional annotations carry independent recorded facts, such as all checks passing.
|
|
122
|
+
Hover or focus a mark for its value, observation count, interval and annotation.
|
|
123
|
+
The data table retains missing positions and full labels.
|
package/dist/figure.css
CHANGED
|
@@ -293,3 +293,29 @@
|
|
|
293
293
|
break-inside: avoid;
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
|
+
|
|
297
|
+
/* Chart-led surfaces keep supporting text in the native disclosure. */
|
|
298
|
+
.tgc-figure-chart { max-width: none; margin: 0; }
|
|
299
|
+
.tgc-figure-chart .tgc-wide svg { width: 100%; height: auto; }
|
|
300
|
+
.tgc-wide:has(.tgc-vertical), .tgc-narrow:has(.tgc-vertical) { overflow-x: auto; }
|
|
301
|
+
.tgc-vertical { width: 100%; }
|
|
302
|
+
.tgc-narrow .tgc-vertical { max-width: none; }
|
|
303
|
+
.tgc-column-hit { fill: transparent; stroke: transparent; stroke-width: 2; }
|
|
304
|
+
.tgc-column:focus-visible { outline: none; }
|
|
305
|
+
.tgc-column:focus-visible .tgc-column-hit { stroke: var(--chart-accent); }
|
|
306
|
+
.tgc-vertical-bar { fill: var(--chart-accent); }
|
|
307
|
+
/* Identity assets keep their approved colors on a white plate in either theme. */
|
|
308
|
+
.tgc-logo-plate { fill: #fff; stroke: var(--chart-rule); }
|
|
309
|
+
.tgc-column-tip { opacity: 0; pointer-events: none; }
|
|
310
|
+
.tgc-tip-bg { fill: var(--chart-ink); }
|
|
311
|
+
.tgc-tip-ink { fill: var(--chart-canvas); }
|
|
312
|
+
|
|
313
|
+
.tgc-series-line { fill: none; stroke: var(--chart-accent); stroke-width: 2; stroke-linejoin: round; }
|
|
314
|
+
.tgc-series-point { fill: var(--chart-accent); stroke: var(--chart-canvas); stroke-width: 1; }
|
|
315
|
+
.tgc-series-hit { fill: transparent; stroke: transparent; }
|
|
316
|
+
.tgc-series-observation:focus-visible { outline: none; }
|
|
317
|
+
.tgc-series-observation:focus-visible .tgc-series-hit { stroke: var(--chart-ink); stroke-width: 2; }
|
|
318
|
+
.tgc-series-tip { opacity: 0; pointer-events: none; }
|
|
319
|
+
|
|
320
|
+
.tgc-scalar-table { min-width: 620px; }
|
|
321
|
+
.tgc-scalar-table tbody th { min-width: 180px; max-width: 260px; }
|
package/dist/index.d.ts
CHANGED
|
@@ -112,10 +112,11 @@ interface Figure {
|
|
|
112
112
|
lede: string;
|
|
113
113
|
/** How to read the marks. */
|
|
114
114
|
read: string;
|
|
115
|
-
/**
|
|
115
|
+
/** Wide and phone renders; vertical columns retain a readable minimum width. Null means the phone view uses the table. */
|
|
116
116
|
svg: {
|
|
117
117
|
wide: string;
|
|
118
118
|
narrow: string | null;
|
|
119
|
+
interactive?: boolean;
|
|
119
120
|
};
|
|
120
121
|
/** An HTML table with every number the plot shows. */
|
|
121
122
|
table: string;
|
|
@@ -132,7 +133,11 @@ interface Refusal {
|
|
|
132
133
|
export declare function isRefusal(value: Figure | Refusal): value is Refusal;
|
|
133
134
|
//#endregion
|
|
134
135
|
//#region src/block.d.ts
|
|
135
|
-
|
|
136
|
+
interface RenderFigureOptions {
|
|
137
|
+
/** Plot first, with interpretation and source details in a closed disclosure. */
|
|
138
|
+
presentation?: "chart";
|
|
139
|
+
}
|
|
140
|
+
export declare function renderFigure(f: Figure, noteNumber: number, options?: RenderFigureOptions): string;
|
|
136
141
|
/** Numbered notes for `figures`, in the order their blocks were numbered (1-based). */
|
|
137
142
|
export declare function renderNotes(figures: Figure[]): string;
|
|
138
143
|
/** What a page leaves out, and why. */
|
|
@@ -176,8 +181,25 @@ export declare const chartTokensCss: string;
|
|
|
176
181
|
*/
|
|
177
182
|
export declare function rateOrder<T extends Pick<RateRow, "rank" | "rate" | "estimate" | "attempts">>(rows: readonly T[]): T[];
|
|
178
183
|
//#endregion
|
|
184
|
+
//#region src/identity.d.ts
|
|
185
|
+
/** Model and harness marks supplied explicitly by the consumer. */
|
|
186
|
+
interface RateIdentity {
|
|
187
|
+
model?: {
|
|
188
|
+
label: string;
|
|
189
|
+
src: string;
|
|
190
|
+
};
|
|
191
|
+
harness?: {
|
|
192
|
+
label: string;
|
|
193
|
+
src: string;
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
179
197
|
//#region src/ranked-rates.d.ts
|
|
180
198
|
interface RankedRatesOptions {
|
|
199
|
+
/** Vertical columns on the same full 0–100% scale; horizontal is the default. */
|
|
200
|
+
orientation?: "vertical";
|
|
201
|
+
/** Explicit display identities keyed by setup id; never inferred from labels. */
|
|
202
|
+
identities?: Readonly<Record<string, RateIdentity>>;
|
|
181
203
|
/** What the rate measures, lower case: "solve rate". */
|
|
182
204
|
measure: string;
|
|
183
205
|
/** Preserve unranked input order instead of sorting observed rates. */
|
|
@@ -213,4 +235,60 @@ interface TaskMatrixOptions {
|
|
|
213
235
|
}
|
|
214
236
|
export declare function taskMatrix(tasks: string[], setupsIn: Setup[], cells: MatrixCell[], o?: TaskMatrixOptions): Figure | Refusal;
|
|
215
237
|
//#endregion
|
|
216
|
-
|
|
238
|
+
//#region src/scalar.d.ts
|
|
239
|
+
interface Observations {
|
|
240
|
+
count: number | null;
|
|
241
|
+
/** Explicit plural noun, such as tasks, traces or findings. */
|
|
242
|
+
label: string;
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/metric-bars.d.ts
|
|
246
|
+
interface MetricRow {
|
|
247
|
+
id: string;
|
|
248
|
+
label: string;
|
|
249
|
+
value: number | null;
|
|
250
|
+
interval?: Interval | null;
|
|
251
|
+
observations?: Observations;
|
|
252
|
+
}
|
|
253
|
+
interface MetricBarsOptions {
|
|
254
|
+
measure: string;
|
|
255
|
+
unit: string;
|
|
256
|
+
/** Zero is the baseline; out-of-domain values are refused rather than clipped. */
|
|
257
|
+
domain: [number, number];
|
|
258
|
+
formatValue?: (value: number) => string;
|
|
259
|
+
identities?: Readonly<Record<string, RateIdentity>>;
|
|
260
|
+
id?: string;
|
|
261
|
+
}
|
|
262
|
+
export declare function metricBars(rows: MetricRow[], o: MetricBarsOptions): Figure | Refusal;
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/time-series.d.ts
|
|
265
|
+
interface TimePoint {
|
|
266
|
+
x: number;
|
|
267
|
+
y: number | null;
|
|
268
|
+
observations?: Observations;
|
|
269
|
+
interval?: Interval | null;
|
|
270
|
+
/** A recorded fact independent of the scalar, such as all checks passed. */
|
|
271
|
+
annotation?: string;
|
|
272
|
+
}
|
|
273
|
+
interface TimeSeriesRow {
|
|
274
|
+
id: string;
|
|
275
|
+
label: string;
|
|
276
|
+
points: TimePoint[];
|
|
277
|
+
}
|
|
278
|
+
interface TimeSeriesOptions {
|
|
279
|
+
x: {
|
|
280
|
+
label: string;
|
|
281
|
+
domain: [number, number];
|
|
282
|
+
};
|
|
283
|
+
y: {
|
|
284
|
+
label: string;
|
|
285
|
+
unit: string;
|
|
286
|
+
domain: [number, number];
|
|
287
|
+
};
|
|
288
|
+
formatX?: (value: number) => string;
|
|
289
|
+
formatY?: (value: number) => string;
|
|
290
|
+
id?: string;
|
|
291
|
+
}
|
|
292
|
+
export declare function timeSeries(rows: TimeSeriesRow[], o: TimeSeriesOptions): Figure | Refusal;
|
|
293
|
+
//#endregion
|
|
294
|
+
export type { BreakdownInput, BreakdownOptions, ComparisonIntervalsOptions, ComparisonRow, CostBasis, CostFrontierOptions, Estimate, Exclusion, Figure, Interval, IntervalMethod, MatrixCell, MetricBarsOptions, MetricRow, Observations, RankedRatesOptions, RateIdentity, RateRow, Refusal, RenderFigureOptions, Setup, TaskMatrixOptions, Tier, TierCounts, TimePoint, TimeSeriesOptions, TimeSeriesRow };
|
package/dist/index.js
CHANGED
|
@@ -311,15 +311,28 @@ function capitalize(text) {
|
|
|
311
311
|
* once per page, numbered, each with a way back to its figure.
|
|
312
312
|
*/
|
|
313
313
|
const slug = (id) => id.replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
|
|
314
|
-
function renderFigure(f, noteNumber) {
|
|
314
|
+
function renderFigure(f, noteNumber, options = {}) {
|
|
315
|
+
if (options.presentation !== void 0 && options.presentation !== "chart") throw new Error("renderFigure: unknown presentation.");
|
|
315
316
|
const id = slug(f.id);
|
|
316
317
|
const plotLabel = `${f.finding} ${f.lede}`;
|
|
317
318
|
const narrow = f.svg.narrow ? `<div class="tgc-narrow">${f.svg.narrow}</div>` : `<div class="tgc-narrow tgc-narrow-table">${f.table}</div>`;
|
|
319
|
+
if (options.presentation === "chart") return [
|
|
320
|
+
`<figure class="tgc-figure tgc-figure-chart" id="fig-${id}" aria-label="${esc(f.finding)}">`,
|
|
321
|
+
`<div class="tgc-plot" id="ref-${noteNumber}" role="group" aria-label="${esc(plotLabel)}"><div class="tgc-wide">${f.svg.wide}</div>${narrow}</div>`,
|
|
322
|
+
`<details class="tgc-data"><summary>Data and method</summary>`,
|
|
323
|
+
`<p class="tgc-lede">${esc(f.lede)}</p><p class="tgc-read">${esc(f.read)}</p>`,
|
|
324
|
+
`<p class="tgc-read">${esc([
|
|
325
|
+
f.note.method,
|
|
326
|
+
f.note.n,
|
|
327
|
+
...f.note.exclusions
|
|
328
|
+
].join(" "))}</p>`,
|
|
329
|
+
`<div class="tgc-scroll" role="region" aria-label="${esc(f.finding)} data" tabindex="0">${f.table}</div></details></figure>`
|
|
330
|
+
].join("");
|
|
318
331
|
return [
|
|
319
332
|
`<figure class="tgc-figure" id="fig-${id}" aria-labelledby="fig-${id}-finding">`,
|
|
320
333
|
`<h3 class="tgc-finding" id="fig-${id}-finding">${esc(f.finding)}<sup><a class="tgc-noteref" href="#note-${noteNumber}" id="ref-${noteNumber}" role="doc-noteref" aria-label="Note ${noteNumber}">${noteNumber}</a></sup></h3>`,
|
|
321
334
|
`<p class="tgc-lede">${esc(f.lede)}</p>`,
|
|
322
|
-
`<div class="tgc-plot" role="img" aria-label="${esc(plotLabel)}">`,
|
|
335
|
+
`<div class="tgc-plot" role="${f.svg.interactive ? "group" : "img"}" aria-label="${esc(plotLabel)}">`,
|
|
323
336
|
`<div class="tgc-wide">${f.svg.wide}</div>`,
|
|
324
337
|
narrow,
|
|
325
338
|
`</div>`,
|
|
@@ -1009,7 +1022,7 @@ function costFrontier(rows, o) {
|
|
|
1009
1022
|
row.cost.basis,
|
|
1010
1023
|
row.onFront === null ? "not computed" : row.onFront ? "yes" : "no"
|
|
1011
1024
|
]);
|
|
1012
|
-
const { svg, numbered } = plot(costed, unplaced, drawLine, o);
|
|
1025
|
+
const { svg, numbered } = plot$1(costed, unplaced, drawLine, o);
|
|
1013
1026
|
const tableHtml = table(`${capitalize(o.measure)} and cost per pass`, numbered.size ? [
|
|
1014
1027
|
{ label: "Setup" },
|
|
1015
1028
|
{
|
|
@@ -1092,7 +1105,7 @@ function narrowList(rows) {
|
|
|
1092
1105
|
function unplacedText(rows) {
|
|
1093
1106
|
return listJoin(rows.map((r) => `${r.label} (${unplacedReason(r)})`));
|
|
1094
1107
|
}
|
|
1095
|
-
function plot(costed, unplaced, drawLine, o) {
|
|
1108
|
+
function plot$1(costed, unplaced, drawLine, o) {
|
|
1096
1109
|
const left = 48;
|
|
1097
1110
|
const right = 696;
|
|
1098
1111
|
const bottom = 272;
|
|
@@ -1250,10 +1263,164 @@ function plot(costed, unplaced, drawLine, o) {
|
|
|
1250
1263
|
//#endregion
|
|
1251
1264
|
//#region src/css.generated.ts
|
|
1252
1265
|
/** The figure block and mark styles (src/figure.css), for documents that inline their CSS. */
|
|
1253
|
-
const figureCss = "/* @tangle-network/charts — figure block and mark styles.\n *\n * Every colour is a --chart-* token from @tangle-network/brand tokens.css, so a\n * figure follows the page theme. A standalone document without brand tokens\n * embeds `chartTokensCss` first. Fonts fall back to Inter and JetBrains Mono.\n *\n * Each figure carries a 720 px and a 358 px render. The swap follows the\n * figure's own width (a container query), so a narrow column shows the phone\n * render even on a wide screen. */\n\n.tgc-figure {\n margin: 32px 0 0;\n padding: 0;\n max-width: 720px;\n color: var(--chart-ink);\n font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);\n}\n\n.tgc-finding {\n margin: 0 0 8px;\n font-size: 16px;\n line-height: 24px;\n font-weight: 600;\n color: var(--chart-ink);\n}\n\n.tgc-noteref {\n margin-left: 2px;\n font-size: 12px;\n font-weight: 400;\n color: var(--chart-ink-muted);\n text-decoration: none;\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n}\n.tgc-noteref:hover,\n.tgc-noteref:focus-visible {\n color: var(--chart-accent);\n text-decoration: underline;\n}\n\n.tgc-lede {\n margin: 0 0 16px;\n font-size: 14px;\n line-height: 22px;\n color: var(--text-secondary, var(--chart-ink));\n}\n\n.tgc-plot {\n container-type: inline-size;\n}\n.tgc-wide svg,\n.tgc-narrow svg {\n display: block;\n overflow: visible;\n}\n.tgc-narrow {\n display: none;\n}\n.tgc-narrow svg {\n max-width: 100%;\n height: auto;\n}\n@container (max-width: 719.98px) {\n .tgc-wide {\n display: none;\n }\n .tgc-narrow {\n display: block;\n }\n}\n\n.tgc-read {\n margin: 8px 0 0;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink-muted);\n}\n\n.tgc-data {\n margin: 8px 0 0;\n font-size: 12px;\n line-height: 16px;\n}\n.tgc-data > summary {\n cursor: pointer;\n color: var(--chart-ink-muted);\n width: fit-content;\n}\n.tgc-data > summary:hover {\n color: var(--chart-ink);\n}\n.tgc-data[open] > summary {\n margin-bottom: 8px;\n}\n\n.tgc-scroll {\n overflow-x: auto;\n}\n.tgc-scroll:focus-visible {\n outline: 2px solid var(--chart-accent);\n outline-offset: 2px;\n}\n\n.tgc-table {\n border-collapse: collapse;\n min-width: 100%;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink);\n /* A host page's table rules must not restyle the figure's table. */\n border: 0;\n border-radius: 0;\n background: none;\n}\n.tgc-table caption {\n text-align: left;\n padding: 0 0 8px;\n color: var(--chart-ink-muted);\n}\n.tgc-table th,\n.tgc-table td {\n padding: 6px 12px 6px 0;\n border-bottom: 1px solid var(--chart-rule);\n text-align: left;\n vertical-align: top;\n font-weight: 400;\n font-size: inherit;\n color: inherit;\n background: none;\n text-transform: none;\n letter-spacing: normal;\n}\n.tgc-table thead th {\n color: var(--chart-ink-muted);\n white-space: nowrap;\n}\n.tgc-table tbody th {\n position: sticky;\n left: 0;\n background: var(--chart-canvas, var(--depth-1, transparent));\n}\n.tgc-table .tgc-n {\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n font-variant-numeric: tabular-nums;\n white-space: nowrap;\n}\n.tgc-table thead .tgc-n {\n font-family: inherit;\n}\n.tgc-table tbody tr:hover {\n background: var(--chart-shade);\n}\n.tgc-narrow-table .tgc-table {\n display: block;\n overflow-x: auto;\n}\n\n.tgc-notes {\n margin: 16px 0 0;\n padding-left: 20px;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink-muted);\n}\n.tgc-notes li {\n margin: 0 0 8px;\n padding-left: 4px;\n}\n.tgc-notes li:target {\n color: var(--chart-ink);\n}\n.tgc-backlink {\n color: var(--chart-ink-muted);\n text-decoration: none;\n}\n\n.tgc-refusals {\n margin: 8px 0 0;\n padding-left: 20px;\n font-size: 14px;\n line-height: 22px;\n color: var(--chart-ink-muted);\n}\n\n/* Marks */\n.tgc-svg {\n font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);\n}\n.tgc-svg .tgc-num {\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n font-variant-numeric: tabular-nums;\n}\n.tgc-ink {\n fill: var(--chart-ink);\n}\n.tgc-ink-muted {\n fill: var(--chart-ink-muted);\n}\n.tgc-bar,\n.tgc-ci,\n.tgc-pt-front {\n fill: var(--chart-accent);\n}\n.tgc-gridline {\n stroke: var(--chart-grid);\n stroke-width: 1;\n}\n.tgc-rule {\n stroke: var(--chart-rule);\n stroke-width: 1;\n}\n.tgc-axis,\n.tgc-zero {\n stroke: var(--chart-muted);\n stroke-width: 1;\n}\n.tgc-whisker,\n.tgc-tick {\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n.tgc-threshold {\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-dasharray: 3 3;\n}\n.tgc-front-line {\n fill: none;\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-linejoin: round;\n}\n.tgc-pt,\n.tgc-open {\n fill: none;\n stroke: var(--chart-muted);\n stroke-width: 1.5;\n}\n.tgc-pt-est {\n fill: none;\n stroke: var(--chart-accent);\n stroke-width: 2;\n}\n.tgc-pt-ci,\n.tgc-strike {\n stroke: var(--chart-muted);\n stroke-width: 1.5;\n}\n.tgc-leader {\n stroke: var(--chart-muted);\n stroke-width: 1;\n}\n.tgc-ci-exact,\n.tgc-dot {\n fill: var(--chart-muted);\n}\n.tgc-dashed {\n stroke-dasharray: 2 2;\n}\n.tgc-tier-c {\n fill: var(--chart-tier-c);\n}\n.tgc-tier-b {\n fill: var(--chart-tier-b);\n}\n.tgc-tier-a {\n fill: var(--chart-tier-a);\n}\n.tgc-tier-s {\n fill: var(--chart-tier-s);\n}\n/* Neutral tint for counts: never the accent, capped at half strength so ink keeps 6:1. */\n.tgc-tint {\n fill: color-mix(in srgb, var(--chart-muted) calc(var(--s, 0) * 50%), transparent);\n}\n\n@media print {\n .tgc-plot {\n container-type: normal;\n }\n .tgc-wide {\n display: block;\n }\n .tgc-narrow {\n display: none;\n }\n .tgc-wide svg {\n width: 100%;\n height: auto;\n }\n .tgc-figure {\n break-inside: avoid;\n }\n}\n";
|
|
1266
|
+
const figureCss = "/* @tangle-network/charts — figure block and mark styles.\n *\n * Every colour is a --chart-* token from @tangle-network/brand tokens.css, so a\n * figure follows the page theme. A standalone document without brand tokens\n * embeds `chartTokensCss` first. Fonts fall back to Inter and JetBrains Mono.\n *\n * Each figure carries a 720 px and a 358 px render. The swap follows the\n * figure's own width (a container query), so a narrow column shows the phone\n * render even on a wide screen. */\n\n.tgc-figure {\n margin: 32px 0 0;\n padding: 0;\n max-width: 720px;\n color: var(--chart-ink);\n font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);\n}\n\n.tgc-finding {\n margin: 0 0 8px;\n font-size: 16px;\n line-height: 24px;\n font-weight: 600;\n color: var(--chart-ink);\n}\n\n.tgc-noteref {\n margin-left: 2px;\n font-size: 12px;\n font-weight: 400;\n color: var(--chart-ink-muted);\n text-decoration: none;\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n}\n.tgc-noteref:hover,\n.tgc-noteref:focus-visible {\n color: var(--chart-accent);\n text-decoration: underline;\n}\n\n.tgc-lede {\n margin: 0 0 16px;\n font-size: 14px;\n line-height: 22px;\n color: var(--text-secondary, var(--chart-ink));\n}\n\n.tgc-plot {\n container-type: inline-size;\n}\n.tgc-wide svg,\n.tgc-narrow svg {\n display: block;\n overflow: visible;\n}\n.tgc-narrow {\n display: none;\n}\n.tgc-narrow svg {\n max-width: 100%;\n height: auto;\n}\n@container (max-width: 719.98px) {\n .tgc-wide {\n display: none;\n }\n .tgc-narrow {\n display: block;\n }\n}\n\n.tgc-read {\n margin: 8px 0 0;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink-muted);\n}\n\n.tgc-data {\n margin: 8px 0 0;\n font-size: 12px;\n line-height: 16px;\n}\n.tgc-data > summary {\n cursor: pointer;\n color: var(--chart-ink-muted);\n width: fit-content;\n}\n.tgc-data > summary:hover {\n color: var(--chart-ink);\n}\n.tgc-data[open] > summary {\n margin-bottom: 8px;\n}\n\n.tgc-scroll {\n overflow-x: auto;\n}\n.tgc-scroll:focus-visible {\n outline: 2px solid var(--chart-accent);\n outline-offset: 2px;\n}\n\n.tgc-table {\n border-collapse: collapse;\n min-width: 100%;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink);\n /* A host page's table rules must not restyle the figure's table. */\n border: 0;\n border-radius: 0;\n background: none;\n}\n.tgc-table caption {\n text-align: left;\n padding: 0 0 8px;\n color: var(--chart-ink-muted);\n}\n.tgc-table th,\n.tgc-table td {\n padding: 6px 12px 6px 0;\n border-bottom: 1px solid var(--chart-rule);\n text-align: left;\n vertical-align: top;\n font-weight: 400;\n font-size: inherit;\n color: inherit;\n background: none;\n text-transform: none;\n letter-spacing: normal;\n}\n.tgc-table thead th {\n color: var(--chart-ink-muted);\n white-space: nowrap;\n}\n.tgc-table tbody th {\n position: sticky;\n left: 0;\n background: var(--chart-canvas, var(--depth-1, transparent));\n}\n.tgc-table .tgc-n {\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n font-variant-numeric: tabular-nums;\n white-space: nowrap;\n}\n.tgc-table thead .tgc-n {\n font-family: inherit;\n}\n.tgc-table tbody tr:hover {\n background: var(--chart-shade);\n}\n.tgc-narrow-table .tgc-table {\n display: block;\n overflow-x: auto;\n}\n\n.tgc-notes {\n margin: 16px 0 0;\n padding-left: 20px;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink-muted);\n}\n.tgc-notes li {\n margin: 0 0 8px;\n padding-left: 4px;\n}\n.tgc-notes li:target {\n color: var(--chart-ink);\n}\n.tgc-backlink {\n color: var(--chart-ink-muted);\n text-decoration: none;\n}\n\n.tgc-refusals {\n margin: 8px 0 0;\n padding-left: 20px;\n font-size: 14px;\n line-height: 22px;\n color: var(--chart-ink-muted);\n}\n\n/* Marks */\n.tgc-svg {\n font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);\n}\n.tgc-svg .tgc-num {\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n font-variant-numeric: tabular-nums;\n}\n.tgc-ink {\n fill: var(--chart-ink);\n}\n.tgc-ink-muted {\n fill: var(--chart-ink-muted);\n}\n.tgc-bar,\n.tgc-ci,\n.tgc-pt-front {\n fill: var(--chart-accent);\n}\n.tgc-gridline {\n stroke: var(--chart-grid);\n stroke-width: 1;\n}\n.tgc-rule {\n stroke: var(--chart-rule);\n stroke-width: 1;\n}\n.tgc-axis,\n.tgc-zero {\n stroke: var(--chart-muted);\n stroke-width: 1;\n}\n.tgc-whisker,\n.tgc-tick {\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n.tgc-threshold {\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-dasharray: 3 3;\n}\n.tgc-front-line {\n fill: none;\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-linejoin: round;\n}\n.tgc-pt,\n.tgc-open {\n fill: none;\n stroke: var(--chart-muted);\n stroke-width: 1.5;\n}\n.tgc-pt-est {\n fill: none;\n stroke: var(--chart-accent);\n stroke-width: 2;\n}\n.tgc-pt-ci,\n.tgc-strike {\n stroke: var(--chart-muted);\n stroke-width: 1.5;\n}\n.tgc-leader {\n stroke: var(--chart-muted);\n stroke-width: 1;\n}\n.tgc-ci-exact,\n.tgc-dot {\n fill: var(--chart-muted);\n}\n.tgc-dashed {\n stroke-dasharray: 2 2;\n}\n.tgc-tier-c {\n fill: var(--chart-tier-c);\n}\n.tgc-tier-b {\n fill: var(--chart-tier-b);\n}\n.tgc-tier-a {\n fill: var(--chart-tier-a);\n}\n.tgc-tier-s {\n fill: var(--chart-tier-s);\n}\n/* Neutral tint for counts: never the accent, capped at half strength so ink keeps 6:1. */\n.tgc-tint {\n fill: color-mix(in srgb, var(--chart-muted) calc(var(--s, 0) * 50%), transparent);\n}\n\n@media print {\n .tgc-plot {\n container-type: normal;\n }\n .tgc-wide {\n display: block;\n }\n .tgc-narrow {\n display: none;\n }\n .tgc-wide svg {\n width: 100%;\n height: auto;\n }\n .tgc-figure {\n break-inside: avoid;\n }\n}\n\n/* Chart-led surfaces keep supporting text in the native disclosure. */\n.tgc-figure-chart { max-width: none; margin: 0; }\n.tgc-figure-chart .tgc-wide svg { width: 100%; height: auto; }\n.tgc-wide:has(.tgc-vertical), .tgc-narrow:has(.tgc-vertical) { overflow-x: auto; }\n.tgc-vertical { width: 100%; }\n.tgc-narrow .tgc-vertical { max-width: none; }\n.tgc-column-hit { fill: transparent; stroke: transparent; stroke-width: 2; }\n.tgc-column:focus-visible { outline: none; }\n.tgc-column:focus-visible .tgc-column-hit { stroke: var(--chart-accent); }\n.tgc-vertical-bar { fill: var(--chart-accent); }\n/* Identity assets keep their approved colors on a white plate in either theme. */\n.tgc-logo-plate { fill: #fff; stroke: var(--chart-rule); }\n.tgc-column-tip { opacity: 0; pointer-events: none; }\n.tgc-tip-bg { fill: var(--chart-ink); }\n.tgc-tip-ink { fill: var(--chart-canvas); }\n\n.tgc-series-line { fill: none; stroke: var(--chart-accent); stroke-width: 2; stroke-linejoin: round; }\n.tgc-series-point { fill: var(--chart-accent); stroke: var(--chart-canvas); stroke-width: 1; }\n.tgc-series-hit { fill: transparent; stroke: transparent; }\n.tgc-series-observation:focus-visible { outline: none; }\n.tgc-series-observation:focus-visible .tgc-series-hit { stroke: var(--chart-ink); stroke-width: 2; }\n.tgc-series-tip { opacity: 0; pointer-events: none; }\n\n.tgc-scalar-table { min-width: 620px; }\n.tgc-scalar-table tbody th { min-width: 180px; max-width: 260px; }\n";
|
|
1254
1267
|
/** --chart-* resolved per theme, for documents without brand tokens: light, dark by preference or [data-theme="dark"], light in print. */
|
|
1255
1268
|
const chartTokensCss = ":root{--chart-ink:#2d2d2d;--chart-ink-muted:#686868;--chart-muted:#787878;--chart-grid:#f2f2f2;--chart-rule:#d0d0d0;--chart-shade:rgba(76, 63, 198, 0.06);--chart-accent:hsl(246 54% 51%);--chart-tier-c:#9b9ef0;--chart-tier-b:#7c7fe3;--chart-tier-a:#5b5bd6;--chart-tier-s:#272962;--chart-canvas:#ffffff;color-scheme:light}\n@media (prefers-color-scheme: dark){:root:not([data-theme=\"light\"]){--chart-ink:#e6e6e6;--chart-ink-muted:#a5a5a5;--chart-muted:#8e8e8e;--chart-grid:#353535;--chart-rule:#4c4c4c;--chart-shade:rgba(165, 180, 252, 0.07);--chart-accent:#818CF8;--chart-tier-c:#4a4a95;--chart-tier-b:#5b5bd6;--chart-tier-a:#8b87f0;--chart-tier-s:#b1a9ff;--chart-canvas:#161616;color-scheme:dark}}\n:root[data-theme=\"dark\"]{--chart-ink:#e6e6e6;--chart-ink-muted:#a5a5a5;--chart-muted:#8e8e8e;--chart-grid:#353535;--chart-rule:#4c4c4c;--chart-shade:rgba(165, 180, 252, 0.07);--chart-accent:#818CF8;--chart-tier-c:#4a4a95;--chart-tier-b:#5b5bd6;--chart-tier-a:#8b87f0;--chart-tier-s:#b1a9ff;--chart-canvas:#161616;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:#f2f2f2;--chart-rule:#d0d0d0;--chart-shade:rgba(76, 63, 198, 0.06);--chart-accent:hsl(246 54% 51%);--chart-tier-c:#9b9ef0;--chart-tier-b:#7c7fe3;--chart-tier-a:#5b5bd6;--chart-tier-s:#272962;--chart-canvas:#ffffff;color-scheme:light}}";
|
|
1256
1269
|
//#endregion
|
|
1270
|
+
//#region src/vertical-columns.ts
|
|
1271
|
+
function image(mark, x, y, size) {
|
|
1272
|
+
return `<image href="${esc(mark.src)}" x="${x}" y="${y}" width="${size}" height="${size}" preserveAspectRatio="xMidYMid meet"><title>${esc(mark.label)}</title></image>`;
|
|
1273
|
+
}
|
|
1274
|
+
function verticalColumns(rows, viewport, measure, domain, tickLabel, scalarLayout = false) {
|
|
1275
|
+
const width = Math.max(viewport, 64 + rows.length * 112);
|
|
1276
|
+
const left = 48;
|
|
1277
|
+
const top = 42;
|
|
1278
|
+
const bottom = 302;
|
|
1279
|
+
const band = 260;
|
|
1280
|
+
const slot = 112;
|
|
1281
|
+
const barWidth = Math.min(viewport < 720 ? 44 : 64, slot * .55);
|
|
1282
|
+
const labelWidth = Math.min(100, 170);
|
|
1283
|
+
const labels = rows.map((row) => wrap(row.identity?.model?.label ?? row.label, labelWidth, 14, "sans", 8));
|
|
1284
|
+
const labelLines = Math.max(...labels.map((label) => label.lines.length));
|
|
1285
|
+
const harnessLabels = rows.map((row) => wrap(row.identity?.harness?.label ?? "", labelWidth, 12, "sans", 8));
|
|
1286
|
+
const harnessLines = scalarLayout ? Math.max(1, ...harnessLabels.map((label) => label.lines.length)) : 1;
|
|
1287
|
+
const height = 408 + Math.max(0, labelLines - 1) * 18 + (harnessLines - 1) * 16;
|
|
1288
|
+
let grid = "";
|
|
1289
|
+
for (const tick of [
|
|
1290
|
+
0,
|
|
1291
|
+
.2,
|
|
1292
|
+
.4,
|
|
1293
|
+
.6,
|
|
1294
|
+
.8,
|
|
1295
|
+
1
|
|
1296
|
+
]) {
|
|
1297
|
+
const y = bottom - tick * band;
|
|
1298
|
+
grid += `<line class="${tick === 0 ? "tgc-axis" : "tgc-gridline"}" x1="${left}" y1="${y}" x2="100%" y2="${y}"/>`;
|
|
1299
|
+
grid += text(38, y + 4, tickLabel(domain[0] + tick * (domain[1] - domain[0])), {
|
|
1300
|
+
cls: "tgc-ink-muted tgc-num",
|
|
1301
|
+
anchor: "end"
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
grid += text(left, 18, measure, { cls: "tgc-ink-muted" });
|
|
1305
|
+
let columns = "";
|
|
1306
|
+
let tips = "";
|
|
1307
|
+
let rules = "";
|
|
1308
|
+
for (const [i, row] of rows.entries()) {
|
|
1309
|
+
const x = left + slot * (i + .5);
|
|
1310
|
+
const y = bottom - ((row.value ?? domain[0]) - domain[0]) / (domain[1] - domain[0]) * band;
|
|
1311
|
+
const identity = row.identity;
|
|
1312
|
+
const facts = row.facts;
|
|
1313
|
+
let marks = rect(x - slot / 2 + 3, 18, 106, height - top + 12, "tgc-column-hit", "rx=\"6\"");
|
|
1314
|
+
if (row.value !== null) {
|
|
1315
|
+
marks += rect(x - barWidth / 2, y, barWidth, (row.value - domain[0]) / (domain[1] - domain[0]) * band, "tgc-vertical-bar", "rx=\"5\"");
|
|
1316
|
+
if (row.interval) {
|
|
1317
|
+
const lo = bottom - (row.interval.lower - domain[0]) / (domain[1] - domain[0]) * band;
|
|
1318
|
+
const hi = bottom - (row.interval.upper - domain[0]) / (domain[1] - domain[0]) * band;
|
|
1319
|
+
marks += line(x, lo, x, hi, "tgc-whisker");
|
|
1320
|
+
marks += line(x - 5, lo, x + 5, lo, "tgc-whisker") + line(x - 5, hi, x + 5, hi, "tgc-whisker");
|
|
1321
|
+
}
|
|
1322
|
+
marks += text(scalarLayout ? x : x + barWidth / 2 + 7, Math.max(34, y - 9), scalarLayout ? wrap(row.valueLabel, 100, 15, "mono", 1).lines[0] : row.valueLabel, {
|
|
1323
|
+
size: 15,
|
|
1324
|
+
weight: 600,
|
|
1325
|
+
anchor: "middle",
|
|
1326
|
+
cls: "tgc-ink tgc-num"
|
|
1327
|
+
});
|
|
1328
|
+
} else marks += text(x, 290, row.valueLabel, {
|
|
1329
|
+
anchor: "middle",
|
|
1330
|
+
cls: "tgc-ink-muted"
|
|
1331
|
+
});
|
|
1332
|
+
if (row.belowBar) marks += row.belowBar(x, barWidth, bottom);
|
|
1333
|
+
if (identity?.model) {
|
|
1334
|
+
marks += rect(x - 18, 325, 36, 36, "tgc-logo-plate", "rx=\"6\"");
|
|
1335
|
+
marks += image(identity.model, x - 16, 327, 32);
|
|
1336
|
+
}
|
|
1337
|
+
if (identity?.harness) {
|
|
1338
|
+
marks += rect(x + 3, 345, 22, 22, "tgc-logo-plate", "rx=\"5\"");
|
|
1339
|
+
marks += image(identity.harness, x + 6, 348, 16);
|
|
1340
|
+
}
|
|
1341
|
+
marks += textBlock(labels[i].lines, x, 386, 18, "class=\"tgc-ink\" font-size=\"14\" text-anchor=\"middle\"");
|
|
1342
|
+
if (identity?.harness) {
|
|
1343
|
+
const harnessY = 405 + (labels[i].lines.length - 1) * 18;
|
|
1344
|
+
marks += scalarLayout ? textBlock(harnessLabels[i].lines, x, harnessY, 16, "class=\"tgc-ink-muted\" font-size=\"12\" text-anchor=\"middle\"") : text(x, harnessY, identity.harness.label, {
|
|
1345
|
+
anchor: "middle",
|
|
1346
|
+
cls: "tgc-ink-muted"
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
if (row.rankLabel) marks += text(x, 33, row.rankLabel, {
|
|
1350
|
+
anchor: "middle",
|
|
1351
|
+
cls: "tgc-ink-muted tgc-num"
|
|
1352
|
+
});
|
|
1353
|
+
columns += `<g class="tgc-column" data-column="${i}" tabindex="0" role="img" aria-label="${esc(facts.join(" · "))}"><title>${esc(facts.join(" · "))}</title>${marks}</g>`;
|
|
1354
|
+
const tipWidth = Math.min(280, width - 24);
|
|
1355
|
+
const tipX = Math.max(12, Math.min(width - tipWidth - 12, x - tipWidth / 2));
|
|
1356
|
+
const tipLines = facts.flatMap((fact) => wrap(fact, tipWidth - 24, 12, "sans", 8).lines);
|
|
1357
|
+
const tipHeight = tipLines.length * 17 + 24;
|
|
1358
|
+
tips += `<g class="tgc-column-tip" data-tip="${i}" aria-hidden="true">${rect(tipX, top, tipWidth, tipHeight, "tgc-tip-bg", "rx=\"7\"")}${textBlock(tipLines, tipX + 12, 62, 17, "class=\"tgc-tip-ink\" font-size=\"12\"")}</g>`;
|
|
1359
|
+
rules += `.tgc-vertical:has(.tgc-column[data-column="${i}"]:hover) [data-tip="${i}"],.tgc-vertical:not(:has(.tgc-column:hover)):has(.tgc-column[data-column="${i}"]:focus-visible) [data-tip="${i}"]{opacity:1}`;
|
|
1360
|
+
}
|
|
1361
|
+
return `<svg class="tgc-svg tgc-vertical" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" style="height:${height}px;min-width:${64 + rows.length * 112}px" role="group" aria-label="${esc(measure)} by setup"><style>${rules}</style>${grid}${columns}${tips}</svg>`;
|
|
1362
|
+
}
|
|
1363
|
+
//#endregion
|
|
1364
|
+
//#region src/vertical-rates.ts
|
|
1365
|
+
/** Rate-specific annotations for the shared vertical columns. */
|
|
1366
|
+
const rateLabel = (rate) => `${Number((rate * 100).toFixed(1))}%`;
|
|
1367
|
+
function verticalRates(rows, viewport, ranked, o) {
|
|
1368
|
+
return verticalColumns(rows.map((row) => {
|
|
1369
|
+
const facts = [
|
|
1370
|
+
row.label,
|
|
1371
|
+
`${kn(row.solved, row.attempts)} passed`,
|
|
1372
|
+
drawable(row.estimate) ? rateLabel(row.rate) : estimateNote(row.estimate, row.attempts)
|
|
1373
|
+
];
|
|
1374
|
+
if (row.interval && rateIntervalGap(row) === null) facts.push(`${intervalName(row.interval)}: ${rateLabel(row.interval.lower)}–${rateLabel(row.interval.upper)}`);
|
|
1375
|
+
if (row.solved > 0 && perPass(row.cost) !== null) facts.push(`${costText(row.cost, row.solved)} per pass`);
|
|
1376
|
+
if (row.medianWallMs !== null) facts.push(`${duration(row.medianWallMs)} median run`);
|
|
1377
|
+
return {
|
|
1378
|
+
id: row.id,
|
|
1379
|
+
label: row.label,
|
|
1380
|
+
value: drawable(row.estimate) ? row.rate : null,
|
|
1381
|
+
valueLabel: drawable(row.estimate) ? rateLabel(row.rate) : estimateNote(row.estimate, row.attempts),
|
|
1382
|
+
interval: rateIntervalGap(row) === null ? row.interval : null,
|
|
1383
|
+
facts,
|
|
1384
|
+
identity: o.identities?.[row.id],
|
|
1385
|
+
...ranked ? { rankLabel: `Rank ${row.rank}` } : {},
|
|
1386
|
+
belowBar: (x, barWidth, bottom) => {
|
|
1387
|
+
let marks = "";
|
|
1388
|
+
if (row.tiers && row.attempts > 0 && drawable(row.estimate)) {
|
|
1389
|
+
let cursor = x - barWidth / 2;
|
|
1390
|
+
for (const tier of TIERS_BEST_FIRST) {
|
|
1391
|
+
const segment = row.tiers[tier] / row.attempts * barWidth;
|
|
1392
|
+
if (segment > 0) marks += rect(cursor, bottom + 9, segment, 5, tier === "F" ? "tgc-open" : `tgc-tier-${tier.toLowerCase()}`);
|
|
1393
|
+
cursor += segment;
|
|
1394
|
+
}
|
|
1395
|
+
if (o.passTier) {
|
|
1396
|
+
const count = TIERS_BEST_FIRST.filter((tier) => tierAtLeast(tier, o.passTier)).reduce((sum, tier) => sum + row.tiers[tier], 0);
|
|
1397
|
+
const tx = x - barWidth / 2 + count / row.attempts * barWidth;
|
|
1398
|
+
marks += line(tx, bottom + 6, tx, bottom + 17, "tgc-tick");
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return marks;
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
}), viewport, o.measure, [0, 1], pct);
|
|
1405
|
+
}
|
|
1406
|
+
//#endregion
|
|
1407
|
+
//#region src/identity.ts
|
|
1408
|
+
function identityGap(rows, identities) {
|
|
1409
|
+
if (!identities) return null;
|
|
1410
|
+
for (const [id, identity] of Object.entries(identities)) {
|
|
1411
|
+
if (!rows.some((row) => row.id === id)) return `Identity names unknown setup ${id}.`;
|
|
1412
|
+
if (!identity || typeof identity !== "object") return `Invalid display identity for ${id}.`;
|
|
1413
|
+
for (const mark of [identity.model, identity.harness]) {
|
|
1414
|
+
if (!mark) continue;
|
|
1415
|
+
if (typeof mark.label !== "string" || !mark.label.trim() || typeof mark.src !== "string") return `Invalid display identity for ${id}.`;
|
|
1416
|
+
const local = /^\/(?!\/)[^\s<>"']+$/.test(mark.src);
|
|
1417
|
+
const https = /^https:\/\/[a-z0-9.-]+(?::\d+)?\/[^\s<>"']*$/i.test(mark.src);
|
|
1418
|
+
if (!local && !https) return `Invalid identity image for ${id}.`;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return null;
|
|
1422
|
+
}
|
|
1423
|
+
//#endregion
|
|
1257
1424
|
//#region src/ranked-rates.ts
|
|
1258
1425
|
/**
|
|
1259
1426
|
* `rankedRates`: which setup passes most, and how uncertain is each rate.
|
|
@@ -1280,6 +1447,15 @@ const TICKS = [
|
|
|
1280
1447
|
];
|
|
1281
1448
|
function rankedRates(input, o) {
|
|
1282
1449
|
const id = o.id ?? "ranked-rates";
|
|
1450
|
+
if (o.orientation !== void 0 && o.orientation !== "vertical") return {
|
|
1451
|
+
id,
|
|
1452
|
+
refused: "Unknown bar orientation."
|
|
1453
|
+
};
|
|
1454
|
+
const identityProblem = identityGap(input, o.identities);
|
|
1455
|
+
if (identityProblem) return {
|
|
1456
|
+
id,
|
|
1457
|
+
refused: identityProblem
|
|
1458
|
+
};
|
|
1283
1459
|
const noun = o.nouns ?? {
|
|
1284
1460
|
attempt: "attempt",
|
|
1285
1461
|
setup: "setup"
|
|
@@ -1404,7 +1580,11 @@ function rankedRates(input, o) {
|
|
|
1404
1580
|
finding,
|
|
1405
1581
|
lede,
|
|
1406
1582
|
read,
|
|
1407
|
-
svg: {
|
|
1583
|
+
svg: o.orientation === "vertical" ? {
|
|
1584
|
+
wide: verticalRates(rows, 720, layout.ranked, o),
|
|
1585
|
+
narrow: verticalRates(rows, 358, layout.ranked, o),
|
|
1586
|
+
interactive: true
|
|
1587
|
+
} : {
|
|
1408
1588
|
wide: wide(rows, layout, noun, o),
|
|
1409
1589
|
narrow: narrow(rows, layout, o)
|
|
1410
1590
|
},
|
|
@@ -2004,4 +2184,266 @@ function isRefusal(value) {
|
|
|
2004
2184
|
return "refused" in value;
|
|
2005
2185
|
}
|
|
2006
2186
|
//#endregion
|
|
2007
|
-
|
|
2187
|
+
//#region src/scalar.ts
|
|
2188
|
+
/** Validation and display of recorded scalar observations; no statistics are computed here. */
|
|
2189
|
+
function domainGap(domain) {
|
|
2190
|
+
return domain.length !== 2 || !domain.every(Number.isFinite) || domain[0] >= domain[1] || !Number.isFinite(domain[1] - domain[0]) ? "The axis needs two finite, increasing bounds." : null;
|
|
2191
|
+
}
|
|
2192
|
+
function scalarGap(value, interval, observations, domain) {
|
|
2193
|
+
if (value !== null && (!Number.isFinite(value) || value < domain[0] || value > domain[1])) return "The value is outside the recorded axis domain.";
|
|
2194
|
+
if (observations) {
|
|
2195
|
+
if (!observations.label.trim()) return "An observation count needs its unit.";
|
|
2196
|
+
if (observations.count !== null && (!Number.isInteger(observations.count) || observations.count < 0)) return "Observation counts must be nonnegative whole numbers or null.";
|
|
2197
|
+
if (observations.count === 0 && value !== null) return "A measured value cannot have zero observations.";
|
|
2198
|
+
}
|
|
2199
|
+
if (interval) {
|
|
2200
|
+
if (value === null) return "An unmeasured value cannot have an interval.";
|
|
2201
|
+
if (interval.method !== null && !Object.hasOwn(METHOD_NAME, interval.method)) return "The interval method is unknown.";
|
|
2202
|
+
const gap = intervalGap(interval);
|
|
2203
|
+
if (gap) return gap;
|
|
2204
|
+
if (interval.lower < domain[0] || interval.upper > domain[1]) return "The interval is outside the axis domain.";
|
|
2205
|
+
}
|
|
2206
|
+
return null;
|
|
2207
|
+
}
|
|
2208
|
+
function scalarLabel(value, unit, format) {
|
|
2209
|
+
return `${format ? format(value) : String(value)}${unit ? ` ${unit}` : ""}`;
|
|
2210
|
+
}
|
|
2211
|
+
function observationLabel(observations) {
|
|
2212
|
+
return observations ? `${observations.count ?? "unknown"} ${observations.label}` : "observation count unknown";
|
|
2213
|
+
}
|
|
2214
|
+
/** Shift the recorded decimal level, without rounding it to a different confidence. */
|
|
2215
|
+
function confidencePercent(level) {
|
|
2216
|
+
const [coefficient, exponent] = String(level).split("e-");
|
|
2217
|
+
const digits = (exponent ? `0.${"0".repeat(Number(exponent) - 1)}${coefficient.replace(".", "")}` : coefficient).slice(2).padEnd(2, "0");
|
|
2218
|
+
const fraction = digits.slice(2).replace(/0+$/, "");
|
|
2219
|
+
return `${Number(digits.slice(0, 2))}${fraction ? `.${fraction}` : ""}%`;
|
|
2220
|
+
}
|
|
2221
|
+
function scalarIntervalLabel(interval, unit, format) {
|
|
2222
|
+
return interval ? `${scalarLabel(interval.lower, unit, format)}–${scalarLabel(interval.upper, unit, format)} (${confidencePercent(interval.level)} ${METHOD_NAME[interval.method]})` : "not recorded";
|
|
2223
|
+
}
|
|
2224
|
+
//#endregion
|
|
2225
|
+
//#region src/metric-bars.ts
|
|
2226
|
+
/** Scalar bars preserve input order and draw only producer-recorded values and intervals. */
|
|
2227
|
+
function metricBars(rows, o) {
|
|
2228
|
+
const id = o.id ?? "metric-bars";
|
|
2229
|
+
const gap = domainGap(o.domain);
|
|
2230
|
+
if (gap) return {
|
|
2231
|
+
id,
|
|
2232
|
+
refused: gap
|
|
2233
|
+
};
|
|
2234
|
+
if (o.domain[0] !== 0) return {
|
|
2235
|
+
id,
|
|
2236
|
+
refused: "Bars require a zero baseline."
|
|
2237
|
+
};
|
|
2238
|
+
if (!rows.length) return {
|
|
2239
|
+
id,
|
|
2240
|
+
refused: `No ${o.measure} recorded.`
|
|
2241
|
+
};
|
|
2242
|
+
if (new Set(rows.map((row) => row.id)).size !== rows.length) return {
|
|
2243
|
+
id,
|
|
2244
|
+
refused: "Duplicate scalar row ids."
|
|
2245
|
+
};
|
|
2246
|
+
const identityProblem = identityGap(rows, o.identities);
|
|
2247
|
+
if (identityProblem) return {
|
|
2248
|
+
id,
|
|
2249
|
+
refused: identityProblem
|
|
2250
|
+
};
|
|
2251
|
+
for (const row of rows) {
|
|
2252
|
+
const problem = scalarGap(row.value, row.interval, row.observations, o.domain);
|
|
2253
|
+
if (problem) return {
|
|
2254
|
+
id,
|
|
2255
|
+
refused: `${row.label}: ${problem}`
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
const label = (value) => scalarLabel(value, o.unit, o.formatValue);
|
|
2259
|
+
const columns = rows.map((row) => ({
|
|
2260
|
+
id: row.id,
|
|
2261
|
+
label: row.label,
|
|
2262
|
+
value: row.value,
|
|
2263
|
+
valueLabel: row.value === null ? "not measured" : label(row.value),
|
|
2264
|
+
interval: row.interval ?? null,
|
|
2265
|
+
identity: o.identities?.[row.id],
|
|
2266
|
+
facts: [
|
|
2267
|
+
row.label,
|
|
2268
|
+
`${o.measure}: ${row.value === null ? "not measured" : label(row.value)}`,
|
|
2269
|
+
observationLabel(row.observations),
|
|
2270
|
+
...row.interval ? [`Interval: ${scalarIntervalLabel(row.interval, o.unit, o.formatValue)}`] : []
|
|
2271
|
+
]
|
|
2272
|
+
}));
|
|
2273
|
+
const measured = rows.filter((row) => row.value !== null);
|
|
2274
|
+
return {
|
|
2275
|
+
id,
|
|
2276
|
+
finding: `${capitalize(o.measure)} by setup.`,
|
|
2277
|
+
lede: `${measured.length} of ${rows.length} recorded values.`,
|
|
2278
|
+
read: "Bars start at zero. Missing values have no bar; whiskers show recorded intervals. Input order is preserved.",
|
|
2279
|
+
svg: {
|
|
2280
|
+
wide: verticalColumns(columns, 720, o.measure, o.domain, (value) => scalarLabel(value, "", o.formatValue), true),
|
|
2281
|
+
narrow: verticalColumns(columns, 358, o.measure, o.domain, (value) => scalarLabel(value, "", o.formatValue), true),
|
|
2282
|
+
interactive: true
|
|
2283
|
+
},
|
|
2284
|
+
table: table(`${capitalize(o.measure)} by setup`, [
|
|
2285
|
+
{ label: "Setup" },
|
|
2286
|
+
{
|
|
2287
|
+
label: o.measure,
|
|
2288
|
+
numeric: true
|
|
2289
|
+
},
|
|
2290
|
+
{ label: "Observations" },
|
|
2291
|
+
{ label: "Recorded interval" }
|
|
2292
|
+
], rows.map((row) => [
|
|
2293
|
+
row.label,
|
|
2294
|
+
row.value === null ? "not measured" : label(row.value),
|
|
2295
|
+
observationLabel(row.observations),
|
|
2296
|
+
scalarIntervalLabel(row.interval, o.unit, o.formatValue)
|
|
2297
|
+
])).replace("class=\"tgc-table\"", "class=\"tgc-table tgc-scalar-table\""),
|
|
2298
|
+
note: {
|
|
2299
|
+
method: "Recorded scalar values, in input order. No rank, interval or statistical comparison is inferred.",
|
|
2300
|
+
n: rows.map((row) => `${row.label}: ${observationLabel(row.observations)}`).join("; "),
|
|
2301
|
+
exclusions: rows.filter((row) => row.value === null).map((row) => `${row.label}: not measured.`)
|
|
2302
|
+
}
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
//#endregion
|
|
2306
|
+
//#region src/time-series.ts
|
|
2307
|
+
/** Recorded trajectories. Explicit null observations break lines without interpolation. */
|
|
2308
|
+
function timeSeries(rows, o) {
|
|
2309
|
+
const id = o.id ?? "time-series";
|
|
2310
|
+
const gap = domainGap(o.x.domain) ?? domainGap(o.y.domain);
|
|
2311
|
+
if (gap) return {
|
|
2312
|
+
id,
|
|
2313
|
+
refused: gap
|
|
2314
|
+
};
|
|
2315
|
+
if (!rows.length || rows.every((row) => !row.points.length)) return {
|
|
2316
|
+
id,
|
|
2317
|
+
refused: "No trajectory observations recorded."
|
|
2318
|
+
};
|
|
2319
|
+
if (new Set(rows.map((row) => row.id)).size !== rows.length) return {
|
|
2320
|
+
id,
|
|
2321
|
+
refused: "Duplicate trajectory ids."
|
|
2322
|
+
};
|
|
2323
|
+
for (const row of rows) {
|
|
2324
|
+
let previous = -Infinity;
|
|
2325
|
+
for (const point of row.points) {
|
|
2326
|
+
if (!Number.isFinite(point.x) || point.x <= previous || point.x < o.x.domain[0] || point.x > o.x.domain[1]) return {
|
|
2327
|
+
id,
|
|
2328
|
+
refused: `${row.label}: x values must be finite, strictly increasing and inside the axis domain.`
|
|
2329
|
+
};
|
|
2330
|
+
previous = point.x;
|
|
2331
|
+
const problem = scalarGap(point.y, point.interval, point.observations, o.y.domain);
|
|
2332
|
+
if (problem) return {
|
|
2333
|
+
id,
|
|
2334
|
+
refused: `${row.label} at ${point.x}: ${problem}`
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
const xLabel = (x) => o.formatX ? o.formatX(x) : String(x);
|
|
2339
|
+
const yLabel = (y) => scalarLabel(y, o.y.unit, o.formatY);
|
|
2340
|
+
const flat = rows.flatMap((row) => row.points.map((point) => ({
|
|
2341
|
+
row,
|
|
2342
|
+
point
|
|
2343
|
+
})));
|
|
2344
|
+
const measured = flat.filter(({ point }) => point.y !== null);
|
|
2345
|
+
const facts = (row, point) => [
|
|
2346
|
+
row.label,
|
|
2347
|
+
`${o.x.label}: ${xLabel(point.x)}`,
|
|
2348
|
+
`${o.y.label}: ${point.y === null ? "not measured" : yLabel(point.y)}`,
|
|
2349
|
+
observationLabel(point.observations),
|
|
2350
|
+
...point.interval ? [`Interval: ${scalarIntervalLabel(point.interval, o.y.unit, o.formatY)}`] : [],
|
|
2351
|
+
...point.annotation ? [point.annotation] : []
|
|
2352
|
+
];
|
|
2353
|
+
return {
|
|
2354
|
+
id,
|
|
2355
|
+
finding: `${capitalize(o.y.label)} by ${o.x.label}.`,
|
|
2356
|
+
lede: `${measured.length} of ${flat.length} positions have a recorded value.`,
|
|
2357
|
+
read: "Points show recorded values. Lines connect adjacent measured positions; missing positions break the line. Whiskers show recorded intervals.",
|
|
2358
|
+
svg: {
|
|
2359
|
+
wide: plot(rows, o, 720, xLabel, yLabel, facts),
|
|
2360
|
+
narrow: plot(rows, o, 358, xLabel, yLabel, facts),
|
|
2361
|
+
interactive: true
|
|
2362
|
+
},
|
|
2363
|
+
table: table(`${capitalize(o.y.label)} by ${o.x.label}`, [
|
|
2364
|
+
{ label: "Setup" },
|
|
2365
|
+
{
|
|
2366
|
+
label: o.x.label,
|
|
2367
|
+
numeric: true
|
|
2368
|
+
},
|
|
2369
|
+
{
|
|
2370
|
+
label: o.y.label,
|
|
2371
|
+
numeric: true
|
|
2372
|
+
},
|
|
2373
|
+
{ label: "Observations" },
|
|
2374
|
+
{ label: "Recorded interval" },
|
|
2375
|
+
{ label: "Recorded annotation" }
|
|
2376
|
+
], flat.map(({ row, point }) => [
|
|
2377
|
+
row.label,
|
|
2378
|
+
xLabel(point.x),
|
|
2379
|
+
point.y === null ? "not measured" : yLabel(point.y),
|
|
2380
|
+
observationLabel(point.observations),
|
|
2381
|
+
scalarIntervalLabel(point.interval, o.y.unit, o.formatY),
|
|
2382
|
+
point.annotation ?? ""
|
|
2383
|
+
])).replace("class=\"tgc-table\"", "class=\"tgc-table tgc-scalar-table\""),
|
|
2384
|
+
note: {
|
|
2385
|
+
method: "Recorded scalar observations. Missing positions are not interpolated; no uncertainty or statistical comparison is inferred.",
|
|
2386
|
+
n: "Observation counts are recorded separately at each position.",
|
|
2387
|
+
exclusions: flat.filter(({ point }) => point.y === null).map(({ row, point }) => `${row.label}, ${o.x.label} ${xLabel(point.x)}: not measured.`)
|
|
2388
|
+
}
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
function plot(rows, o, width, xLabel, yLabel, facts) {
|
|
2392
|
+
const left = 64, right = width - 24, top = 36, bottom = 260;
|
|
2393
|
+
const px = (x) => left + (x - o.x.domain[0]) / (o.x.domain[1] - o.x.domain[0]) * (right - left);
|
|
2394
|
+
const py = (y) => bottom - (y - o.y.domain[0]) / (o.y.domain[1] - o.y.domain[0]) * 224;
|
|
2395
|
+
let marks = text(left, 18, o.y.label, { cls: "tgc-ink-muted" });
|
|
2396
|
+
for (let i = 0; i <= 4; i++) {
|
|
2397
|
+
const y = o.y.domain[0] + i / 4 * (o.y.domain[1] - o.y.domain[0]);
|
|
2398
|
+
marks += line(left, py(y), right, py(y), i === 0 ? "tgc-axis" : "tgc-gridline") + text(56, py(y) + 4, yLabel(y), {
|
|
2399
|
+
anchor: "end",
|
|
2400
|
+
cls: "tgc-ink-muted tgc-num",
|
|
2401
|
+
size: 11
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
const xs = [...new Set(rows.flatMap((row) => row.points.map((point) => point.x)))].sort((a, b) => a - b);
|
|
2405
|
+
const tickStep = Math.max(1, Math.ceil(xs.length / (width < 720 ? 5 : 10)));
|
|
2406
|
+
for (const [i, x] of xs.entries()) if (i % tickStep === 0 || i === xs.length - 1) marks += text(px(x), 280, xLabel(x), {
|
|
2407
|
+
anchor: "middle",
|
|
2408
|
+
cls: "tgc-ink-muted tgc-num"
|
|
2409
|
+
});
|
|
2410
|
+
marks += text((left + right) / 2, 303, o.x.label, {
|
|
2411
|
+
anchor: "middle",
|
|
2412
|
+
cls: "tgc-ink-muted"
|
|
2413
|
+
});
|
|
2414
|
+
let legendY = 332;
|
|
2415
|
+
let points = "", tips = "", rules = "";
|
|
2416
|
+
let pointId = 0;
|
|
2417
|
+
for (const [seriesIndex, row] of rows.entries()) {
|
|
2418
|
+
const dash = seriesIndex === 0 ? "" : `${2 + seriesIndex * 2} 3`;
|
|
2419
|
+
let segment = [];
|
|
2420
|
+
const flush = () => {
|
|
2421
|
+
if (segment.length > 1) marks += `<g data-series="${esc(row.id)}" style="stroke-dasharray:${dash}">${polyline(segment, "tgc-series-line")}</g>`;
|
|
2422
|
+
segment = [];
|
|
2423
|
+
};
|
|
2424
|
+
for (const point of row.points) {
|
|
2425
|
+
if (point.y === null) {
|
|
2426
|
+
flush();
|
|
2427
|
+
continue;
|
|
2428
|
+
}
|
|
2429
|
+
const x = px(point.x), y = py(point.y);
|
|
2430
|
+
segment.push([x, y]);
|
|
2431
|
+
const pointFacts = facts(row, point);
|
|
2432
|
+
const key = pointId++;
|
|
2433
|
+
let dot = circle(x, y, 10, "tgc-series-hit") + circle(x, y, 3.5, "tgc-series-point");
|
|
2434
|
+
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");
|
|
2435
|
+
points += `<g class="tgc-series-observation" data-point="${key}" tabindex="0" role="img" aria-label="${esc(pointFacts.join(" · "))}"><title>${esc(pointFacts.join(" · "))}</title>${dot}</g>`;
|
|
2436
|
+
const tipWidth = Math.min(280, width - 24), tipX = Math.max(12, Math.min(width - tipWidth - 12, x - tipWidth / 2));
|
|
2437
|
+
const lines = pointFacts.flatMap((fact) => wrap(fact, tipWidth - 24, 12, "sans", 8).lines);
|
|
2438
|
+
tips += `<g class="tgc-series-tip" data-series-tip="${key}" aria-hidden="true">${rect(tipX, top, tipWidth, lines.length * 17 + 24, "tgc-tip-bg", "rx=\"7\"")}${textBlock(lines, tipX + 12, 56, 17, "class=\"tgc-tip-ink\" font-size=\"12\"")}</g>`;
|
|
2439
|
+
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}`;
|
|
2440
|
+
}
|
|
2441
|
+
flush();
|
|
2442
|
+
const label = wrap(row.label, right - left - 32, 12, "sans", 8);
|
|
2443
|
+
marks += `<g style="stroke-dasharray:${dash}">${line(left, legendY - 4, 88, legendY - 4, "tgc-series-line")}</g>` + textBlock(label.lines, 96, legendY, 16, "class=\"tgc-ink\" font-size=\"12\"");
|
|
2444
|
+
legendY += label.lines.length * 16 + 8;
|
|
2445
|
+
}
|
|
2446
|
+
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}${points}${tips}</svg>`;
|
|
2447
|
+
}
|
|
2448
|
+
//#endregion
|
|
2449
|
+
export { breakdown, chartTokensCss, comparisonIntervals, costFrontier, figureCss, isRefusal, metricBars, rankedRates, rateOrder, renderFigure, renderNotes, renderRefusals, taskMatrix, timeSeries };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/charts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Benchmark figures as static SVG and HTML tables: ranked rates, cost frontier, comparison intervals, task matrix and breakdown. Draws from producer data and refuses what it cannot draw honestly.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|