@tangle-network/charts 0.1.10 → 0.1.12
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 +47 -0
- package/dist/figure.css +18 -0
- package/dist/index.d.ts +63 -4
- package/dist/index.js +404 -105
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,3 +82,50 @@ Use `renderFigure(figure, noteNumber, { presentation: 'chart' })` for a chart-fi
|
|
|
82
82
|
The plot fills its container; data, reading instructions and method stay in a closed disclosure.
|
|
83
83
|
The default report presentation, underlying table and note links remain available.
|
|
84
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.
|
|
124
|
+
|
|
125
|
+
### Plots without visible report controls
|
|
126
|
+
|
|
127
|
+
Use `renderFigure(figure, noteNumber, { presentation: "plot" })` to show the responsive plot alone.
|
|
128
|
+
The complete data table remains available to assistive technology.
|
|
129
|
+
This presentation has no visible finding, method text, disclosure or table control.
|
|
130
|
+
Vertical plot axes use larger tick labels.
|
|
131
|
+
The existing report and chart presentations keep their output and paper behavior.
|
package/dist/figure.css
CHANGED
|
@@ -309,3 +309,21 @@
|
|
|
309
309
|
.tgc-column-tip { opacity: 0; pointer-events: none; }
|
|
310
310
|
.tgc-tip-bg { fill: var(--chart-ink); }
|
|
311
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; }
|
|
322
|
+
|
|
323
|
+
/* Plot presentation keeps the same complete data available to assistive technology. */
|
|
324
|
+
.tgc-accessible-data { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
325
|
+
.tgc-figure-plot .tgc-vertical > text.tgc-num { font-size: 15px; }
|
|
326
|
+
.tgc-figure-plot .tgc-wide, .tgc-figure-plot .tgc-narrow { overflow-x: auto; }
|
|
327
|
+
|
|
328
|
+
.tgc-figure-plot .tgc-wide-fallback svg { min-width: var(--tgc-fallback-width); }
|
|
329
|
+
.tgc-wide-fallback:focus-visible { outline: 2px solid var(--chart-accent); outline-offset: 2px; }
|
package/dist/index.d.ts
CHANGED
|
@@ -134,8 +134,8 @@ export declare function isRefusal(value: Figure | Refusal): value is Refusal;
|
|
|
134
134
|
//#endregion
|
|
135
135
|
//#region src/block.d.ts
|
|
136
136
|
interface RenderFigureOptions {
|
|
137
|
-
/**
|
|
138
|
-
presentation?: "chart";
|
|
137
|
+
/** Chart uses a closed data disclosure; plot keeps only the SVG and accessible data. */
|
|
138
|
+
presentation?: "chart" | "plot";
|
|
139
139
|
}
|
|
140
140
|
export declare function renderFigure(f: Figure, noteNumber: number, options?: RenderFigureOptions): string;
|
|
141
141
|
/** Numbered notes for `figures`, in the order their blocks were numbered (1-based). */
|
|
@@ -181,7 +181,8 @@ export declare const chartTokensCss: string;
|
|
|
181
181
|
*/
|
|
182
182
|
export declare function rateOrder<T extends Pick<RateRow, "rank" | "rate" | "estimate" | "attempts">>(rows: readonly T[]): T[];
|
|
183
183
|
//#endregion
|
|
184
|
-
//#region src/
|
|
184
|
+
//#region src/identity.d.ts
|
|
185
|
+
/** Model and harness marks supplied explicitly by the consumer. */
|
|
185
186
|
interface RateIdentity {
|
|
186
187
|
model?: {
|
|
187
188
|
label: string;
|
|
@@ -192,6 +193,8 @@ interface RateIdentity {
|
|
|
192
193
|
src: string;
|
|
193
194
|
};
|
|
194
195
|
}
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/ranked-rates.d.ts
|
|
195
198
|
interface RankedRatesOptions {
|
|
196
199
|
/** Vertical columns on the same full 0–100% scale; horizontal is the default. */
|
|
197
200
|
orientation?: "vertical";
|
|
@@ -232,4 +235,60 @@ interface TaskMatrixOptions {
|
|
|
232
235
|
}
|
|
233
236
|
export declare function taskMatrix(tasks: string[], setupsIn: Setup[], cells: MatrixCell[], o?: TaskMatrixOptions): Figure | Refusal;
|
|
234
237
|
//#endregion
|
|
235
|
-
|
|
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
|
@@ -301,6 +301,47 @@ function capitalize(text) {
|
|
|
301
301
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
302
302
|
}
|
|
303
303
|
//#endregion
|
|
304
|
+
//#region src/svg.ts
|
|
305
|
+
/**
|
|
306
|
+
* SVG marks. Every mark carries a class and `figure.css` maps classes to the
|
|
307
|
+
* `--chart-*` tokens, so a render holds no colour and follows the page theme.
|
|
308
|
+
* Geometry stays in attributes, so an SVG shown without the stylesheet keeps
|
|
309
|
+
* its layout.
|
|
310
|
+
*/
|
|
311
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
312
|
+
function svgRoot(width, height, inner) {
|
|
313
|
+
const h = Math.ceil(height);
|
|
314
|
+
return `<svg class="tgc-svg" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}" aria-hidden="true" focusable="false">${inner}</svg>`;
|
|
315
|
+
}
|
|
316
|
+
/** A horizontal bar anchored at `x`, square at the baseline, 4 px round at the data end. */
|
|
317
|
+
function hbar(x, y, w, h, cls) {
|
|
318
|
+
if (w <= 0) return "";
|
|
319
|
+
const r = Math.min(4, h / 2, w);
|
|
320
|
+
return `<path class="${cls}" d="M${r2(x)} ${r2(y)}h${r2(w - r)}a${r} ${r} 0 0 1 ${r} ${r}v${r2(h - 2 * r)}a${r} ${r} 0 0 1 -${r} ${r}h-${r2(w - r)}z"/>`;
|
|
321
|
+
}
|
|
322
|
+
function rect(x, y, w, h, cls, extra = "") {
|
|
323
|
+
if (w <= 0 || h <= 0) return "";
|
|
324
|
+
return `<rect class="${cls}" x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}"${extra ? ` ${extra}` : ""}/>`;
|
|
325
|
+
}
|
|
326
|
+
function line(x1, y1, x2, y2, cls) {
|
|
327
|
+
return `<line class="${cls}" x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}"/>`;
|
|
328
|
+
}
|
|
329
|
+
function circle(cx, cy, r, cls) {
|
|
330
|
+
return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
|
|
331
|
+
}
|
|
332
|
+
function polyline(points, cls) {
|
|
333
|
+
return `<polyline class="${cls}" points="${points.map(([x, y]) => `${r2(x)},${r2(y)}`).join(" ")}"/>`;
|
|
334
|
+
}
|
|
335
|
+
function text(x, y, value, o = {}) {
|
|
336
|
+
const anchor = o.anchor && o.anchor !== "start" ? ` text-anchor="${o.anchor}"` : "";
|
|
337
|
+
const weight = o.weight === 600 ? ` font-weight="600"` : "";
|
|
338
|
+
return `<text class="${o.cls ?? "tgc-ink"}" x="${r2(x)}" y="${r2(y)}" font-size="${o.size ?? 12}"${anchor}${weight}>${esc(value)}</text>`;
|
|
339
|
+
}
|
|
340
|
+
/** A group whose `<title>` is the hover text for everything inside it. */
|
|
341
|
+
function titled(title, inner) {
|
|
342
|
+
return `<g><title>${esc(title)}</title>${inner}</g>`;
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
304
345
|
//#region src/block.ts
|
|
305
346
|
/**
|
|
306
347
|
* The figure block: one markup for every figure on every surface.
|
|
@@ -312,10 +353,18 @@ function capitalize(text) {
|
|
|
312
353
|
*/
|
|
313
354
|
const slug = (id) => id.replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
|
|
314
355
|
function renderFigure(f, noteNumber, options = {}) {
|
|
315
|
-
if (options.presentation !== void 0 && options.presentation !== "chart") throw new Error("renderFigure: unknown presentation.");
|
|
356
|
+
if (options.presentation !== void 0 && options.presentation !== "chart" && options.presentation !== "plot") throw new Error("renderFigure: unknown presentation.");
|
|
316
357
|
const id = slug(f.id);
|
|
317
358
|
const plotLabel = `${f.finding} ${f.lede}`;
|
|
318
359
|
const narrow = f.svg.narrow ? `<div class="tgc-narrow">${f.svg.narrow}</div>` : `<div class="tgc-narrow tgc-narrow-table">${f.table}</div>`;
|
|
360
|
+
if (options.presentation === "plot") {
|
|
361
|
+
const phonePlot = f.svg.narrow ? `<div class="tgc-narrow">${f.svg.narrow}</div>` : `<div class="tgc-narrow tgc-wide-fallback" role="region" aria-label="${esc(f.finding)} chart" tabindex="0" style="--tgc-fallback-width:720px">${f.svg.wide}</div>`;
|
|
362
|
+
return [
|
|
363
|
+
`<figure class="tgc-figure tgc-figure-chart tgc-figure-plot" id="fig-${id}" aria-label="${esc(f.finding)}">`,
|
|
364
|
+
`<div class="tgc-plot" role="${f.svg.interactive ? "group" : "img"}" aria-label="${esc(plotLabel)}"><div class="tgc-wide">${f.svg.wide}</div>${phonePlot}</div>`,
|
|
365
|
+
`<div class="tgc-accessible-data">${f.table}</div></figure>`
|
|
366
|
+
].join("");
|
|
367
|
+
}
|
|
319
368
|
if (options.presentation === "chart") return [
|
|
320
369
|
`<figure class="tgc-figure tgc-figure-chart" id="fig-${id}" aria-label="${esc(f.finding)}">`,
|
|
321
370
|
`<div class="tgc-plot" id="ref-${noteNumber}" role="group" aria-label="${esc(plotLabel)}"><div class="tgc-wide">${f.svg.wide}</div>${narrow}</div>`,
|
|
@@ -359,47 +408,6 @@ function renderRefusals(refusals) {
|
|
|
359
408
|
return `<ul class="tgc-refusals">${refusals.map((r) => `<li>${esc(r.refused)}</li>`).join("")}</ul>`;
|
|
360
409
|
}
|
|
361
410
|
//#endregion
|
|
362
|
-
//#region src/svg.ts
|
|
363
|
-
/**
|
|
364
|
-
* SVG marks. Every mark carries a class and `figure.css` maps classes to the
|
|
365
|
-
* `--chart-*` tokens, so a render holds no colour and follows the page theme.
|
|
366
|
-
* Geometry stays in attributes, so an SVG shown without the stylesheet keeps
|
|
367
|
-
* its layout.
|
|
368
|
-
*/
|
|
369
|
-
const r2 = (n) => Math.round(n * 100) / 100;
|
|
370
|
-
function svgRoot(width, height, inner) {
|
|
371
|
-
const h = Math.ceil(height);
|
|
372
|
-
return `<svg class="tgc-svg" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}" aria-hidden="true" focusable="false">${inner}</svg>`;
|
|
373
|
-
}
|
|
374
|
-
/** A horizontal bar anchored at `x`, square at the baseline, 4 px round at the data end. */
|
|
375
|
-
function hbar(x, y, w, h, cls) {
|
|
376
|
-
if (w <= 0) return "";
|
|
377
|
-
const r = Math.min(4, h / 2, w);
|
|
378
|
-
return `<path class="${cls}" d="M${r2(x)} ${r2(y)}h${r2(w - r)}a${r} ${r} 0 0 1 ${r} ${r}v${r2(h - 2 * r)}a${r} ${r} 0 0 1 -${r} ${r}h-${r2(w - r)}z"/>`;
|
|
379
|
-
}
|
|
380
|
-
function rect(x, y, w, h, cls, extra = "") {
|
|
381
|
-
if (w <= 0 || h <= 0) return "";
|
|
382
|
-
return `<rect class="${cls}" x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}"${extra ? ` ${extra}` : ""}/>`;
|
|
383
|
-
}
|
|
384
|
-
function line(x1, y1, x2, y2, cls) {
|
|
385
|
-
return `<line class="${cls}" x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}"/>`;
|
|
386
|
-
}
|
|
387
|
-
function circle(cx, cy, r, cls) {
|
|
388
|
-
return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
|
|
389
|
-
}
|
|
390
|
-
function polyline(points, cls) {
|
|
391
|
-
return `<polyline class="${cls}" points="${points.map(([x, y]) => `${r2(x)},${r2(y)}`).join(" ")}"/>`;
|
|
392
|
-
}
|
|
393
|
-
function text(x, y, value, o = {}) {
|
|
394
|
-
const anchor = o.anchor && o.anchor !== "start" ? ` text-anchor="${o.anchor}"` : "";
|
|
395
|
-
const weight = o.weight === 600 ? ` font-weight="600"` : "";
|
|
396
|
-
return `<text class="${o.cls ?? "tgc-ink"}" x="${r2(x)}" y="${r2(y)}" font-size="${o.size ?? 12}"${anchor}${weight}>${esc(value)}</text>`;
|
|
397
|
-
}
|
|
398
|
-
/** A group whose `<title>` is the hover text for everything inside it. */
|
|
399
|
-
function titled(title, inner) {
|
|
400
|
-
return `<g><title>${esc(title)}</title>${inner}</g>`;
|
|
401
|
-
}
|
|
402
|
-
//#endregion
|
|
403
411
|
//#region src/table.ts
|
|
404
412
|
/** A plain HTML table: the accessible form of a figure and its phone view. */
|
|
405
413
|
function table(caption, columns, rows) {
|
|
@@ -1022,7 +1030,7 @@ function costFrontier(rows, o) {
|
|
|
1022
1030
|
row.cost.basis,
|
|
1023
1031
|
row.onFront === null ? "not computed" : row.onFront ? "yes" : "no"
|
|
1024
1032
|
]);
|
|
1025
|
-
const { svg, numbered } = plot(costed, unplaced, drawLine, o);
|
|
1033
|
+
const { svg, numbered } = plot$1(costed, unplaced, drawLine, o);
|
|
1026
1034
|
const tableHtml = table(`${capitalize(o.measure)} and cost per pass`, numbered.size ? [
|
|
1027
1035
|
{ label: "Setup" },
|
|
1028
1036
|
{
|
|
@@ -1105,7 +1113,7 @@ function narrowList(rows) {
|
|
|
1105
1113
|
function unplacedText(rows) {
|
|
1106
1114
|
return listJoin(rows.map((r) => `${r.label} (${unplacedReason(r)})`));
|
|
1107
1115
|
}
|
|
1108
|
-
function plot(costed, unplaced, drawLine, o) {
|
|
1116
|
+
function plot$1(costed, unplaced, drawLine, o) {
|
|
1109
1117
|
const left = 48;
|
|
1110
1118
|
const right = 696;
|
|
1111
1119
|
const bottom = 272;
|
|
@@ -1263,32 +1271,15 @@ function plot(costed, unplaced, drawLine, o) {
|
|
|
1263
1271
|
//#endregion
|
|
1264
1272
|
//#region src/css.generated.ts
|
|
1265
1273
|
/** The figure block and mark styles (src/figure.css), for documents that inline their CSS. */
|
|
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";
|
|
1274
|
+
const figureCss = "/* @tangle-network/charts — figure block and mark styles.\n *\n * Every colour is a --chart-* token from @tangle-network/brand tokens.css, so a\n * figure follows the page theme. A standalone document without brand tokens\n * embeds `chartTokensCss` first. Fonts fall back to Inter and JetBrains Mono.\n *\n * Each figure carries a 720 px and a 358 px render. The swap follows the\n * figure's own width (a container query), so a narrow column shows the phone\n * render even on a wide screen. */\n\n.tgc-figure {\n margin: 32px 0 0;\n padding: 0;\n max-width: 720px;\n color: var(--chart-ink);\n font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);\n}\n\n.tgc-finding {\n margin: 0 0 8px;\n font-size: 16px;\n line-height: 24px;\n font-weight: 600;\n color: var(--chart-ink);\n}\n\n.tgc-noteref {\n margin-left: 2px;\n font-size: 12px;\n font-weight: 400;\n color: var(--chart-ink-muted);\n text-decoration: none;\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n}\n.tgc-noteref:hover,\n.tgc-noteref:focus-visible {\n color: var(--chart-accent);\n text-decoration: underline;\n}\n\n.tgc-lede {\n margin: 0 0 16px;\n font-size: 14px;\n line-height: 22px;\n color: var(--text-secondary, var(--chart-ink));\n}\n\n.tgc-plot {\n container-type: inline-size;\n}\n.tgc-wide svg,\n.tgc-narrow svg {\n display: block;\n overflow: visible;\n}\n.tgc-narrow {\n display: none;\n}\n.tgc-narrow svg {\n max-width: 100%;\n height: auto;\n}\n@container (max-width: 719.98px) {\n .tgc-wide {\n display: none;\n }\n .tgc-narrow {\n display: block;\n }\n}\n\n.tgc-read {\n margin: 8px 0 0;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink-muted);\n}\n\n.tgc-data {\n margin: 8px 0 0;\n font-size: 12px;\n line-height: 16px;\n}\n.tgc-data > summary {\n cursor: pointer;\n color: var(--chart-ink-muted);\n width: fit-content;\n}\n.tgc-data > summary:hover {\n color: var(--chart-ink);\n}\n.tgc-data[open] > summary {\n margin-bottom: 8px;\n}\n\n.tgc-scroll {\n overflow-x: auto;\n}\n.tgc-scroll:focus-visible {\n outline: 2px solid var(--chart-accent);\n outline-offset: 2px;\n}\n\n.tgc-table {\n border-collapse: collapse;\n min-width: 100%;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink);\n /* A host page's table rules must not restyle the figure's table. */\n border: 0;\n border-radius: 0;\n background: none;\n}\n.tgc-table caption {\n text-align: left;\n padding: 0 0 8px;\n color: var(--chart-ink-muted);\n}\n.tgc-table th,\n.tgc-table td {\n padding: 6px 12px 6px 0;\n border-bottom: 1px solid var(--chart-rule);\n text-align: left;\n vertical-align: top;\n font-weight: 400;\n font-size: inherit;\n color: inherit;\n background: none;\n text-transform: none;\n letter-spacing: normal;\n}\n.tgc-table thead th {\n color: var(--chart-ink-muted);\n white-space: nowrap;\n}\n.tgc-table tbody th {\n position: sticky;\n left: 0;\n background: var(--chart-canvas, var(--depth-1, transparent));\n}\n.tgc-table .tgc-n {\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n font-variant-numeric: tabular-nums;\n white-space: nowrap;\n}\n.tgc-table thead .tgc-n {\n font-family: inherit;\n}\n.tgc-table tbody tr:hover {\n background: var(--chart-shade);\n}\n.tgc-narrow-table .tgc-table {\n display: block;\n overflow-x: auto;\n}\n\n.tgc-notes {\n margin: 16px 0 0;\n padding-left: 20px;\n font-size: 12px;\n line-height: 16px;\n color: var(--chart-ink-muted);\n}\n.tgc-notes li {\n margin: 0 0 8px;\n padding-left: 4px;\n}\n.tgc-notes li:target {\n color: var(--chart-ink);\n}\n.tgc-backlink {\n color: var(--chart-ink-muted);\n text-decoration: none;\n}\n\n.tgc-refusals {\n margin: 8px 0 0;\n padding-left: 20px;\n font-size: 14px;\n line-height: 22px;\n color: var(--chart-ink-muted);\n}\n\n/* Marks */\n.tgc-svg {\n font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);\n}\n.tgc-svg .tgc-num {\n font-family: var(--font-mono, \"JetBrains Mono\", ui-monospace, monospace);\n font-variant-numeric: tabular-nums;\n}\n.tgc-ink {\n fill: var(--chart-ink);\n}\n.tgc-ink-muted {\n fill: var(--chart-ink-muted);\n}\n.tgc-bar,\n.tgc-ci,\n.tgc-pt-front {\n fill: var(--chart-accent);\n}\n.tgc-gridline {\n stroke: var(--chart-grid);\n stroke-width: 1;\n}\n.tgc-rule {\n stroke: var(--chart-rule);\n stroke-width: 1;\n}\n.tgc-axis,\n.tgc-zero {\n stroke: var(--chart-muted);\n stroke-width: 1;\n}\n.tgc-whisker,\n.tgc-tick {\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n.tgc-threshold {\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-dasharray: 3 3;\n}\n.tgc-front-line {\n fill: none;\n stroke: var(--chart-ink);\n stroke-width: 1.5;\n stroke-linejoin: round;\n}\n.tgc-pt,\n.tgc-open {\n fill: none;\n stroke: var(--chart-muted);\n stroke-width: 1.5;\n}\n.tgc-pt-est {\n fill: none;\n stroke: var(--chart-accent);\n stroke-width: 2;\n}\n.tgc-pt-ci,\n.tgc-strike {\n stroke: var(--chart-muted);\n stroke-width: 1.5;\n}\n.tgc-leader {\n stroke: var(--chart-muted);\n stroke-width: 1;\n}\n.tgc-ci-exact,\n.tgc-dot {\n fill: var(--chart-muted);\n}\n.tgc-dashed {\n stroke-dasharray: 2 2;\n}\n.tgc-tier-c {\n fill: var(--chart-tier-c);\n}\n.tgc-tier-b {\n fill: var(--chart-tier-b);\n}\n.tgc-tier-a {\n fill: var(--chart-tier-a);\n}\n.tgc-tier-s {\n fill: var(--chart-tier-s);\n}\n/* Neutral tint for counts: never the accent, capped at half strength so ink keeps 6:1. */\n.tgc-tint {\n fill: color-mix(in srgb, var(--chart-muted) calc(var(--s, 0) * 50%), transparent);\n}\n\n@media print {\n .tgc-plot {\n container-type: normal;\n }\n .tgc-wide {\n display: block;\n }\n .tgc-narrow {\n display: none;\n }\n .tgc-wide svg {\n width: 100%;\n height: auto;\n }\n .tgc-figure {\n break-inside: avoid;\n }\n}\n\n/* Chart-led surfaces keep supporting text in the native disclosure. */\n.tgc-figure-chart { max-width: none; margin: 0; }\n.tgc-figure-chart .tgc-wide svg { width: 100%; height: auto; }\n.tgc-wide:has(.tgc-vertical), .tgc-narrow:has(.tgc-vertical) { overflow-x: auto; }\n.tgc-vertical { width: 100%; }\n.tgc-narrow .tgc-vertical { max-width: none; }\n.tgc-column-hit { fill: transparent; stroke: transparent; stroke-width: 2; }\n.tgc-column:focus-visible { outline: none; }\n.tgc-column:focus-visible .tgc-column-hit { stroke: var(--chart-accent); }\n.tgc-vertical-bar { fill: var(--chart-accent); }\n/* Identity assets keep their approved colors on a white plate in either theme. */\n.tgc-logo-plate { fill: #fff; stroke: var(--chart-rule); }\n.tgc-column-tip { opacity: 0; pointer-events: none; }\n.tgc-tip-bg { fill: var(--chart-ink); }\n.tgc-tip-ink { fill: var(--chart-canvas); }\n\n.tgc-series-line { fill: none; stroke: var(--chart-accent); stroke-width: 2; stroke-linejoin: round; }\n.tgc-series-point { fill: var(--chart-accent); stroke: var(--chart-canvas); stroke-width: 1; }\n.tgc-series-hit { fill: transparent; stroke: transparent; }\n.tgc-series-observation:focus-visible { outline: none; }\n.tgc-series-observation:focus-visible .tgc-series-hit { stroke: var(--chart-ink); stroke-width: 2; }\n.tgc-series-tip { opacity: 0; pointer-events: none; }\n\n.tgc-scalar-table { min-width: 620px; }\n.tgc-scalar-table tbody th { min-width: 180px; max-width: 260px; }\n\n/* Plot presentation keeps the same complete data available to assistive technology. */\n.tgc-accessible-data { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }\n.tgc-figure-plot .tgc-vertical > text.tgc-num { font-size: 15px; }\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-wide-fallback:focus-visible { outline: 2px solid var(--chart-accent); outline-offset: 2px; }\n";
|
|
1267
1275
|
/** --chart-* resolved per theme, for documents without brand tokens: light, dark by preference or [data-theme="dark"], light in print. */
|
|
1268
1276
|
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}}";
|
|
1269
1277
|
//#endregion
|
|
1270
|
-
//#region src/vertical-
|
|
1271
|
-
/** Vertical rate geometry; values, eligibility and ordering stay with rankedRates. */
|
|
1272
|
-
function identityGap(rows, identities) {
|
|
1273
|
-
if (!identities) return null;
|
|
1274
|
-
for (const [id, identity] of Object.entries(identities)) {
|
|
1275
|
-
if (!rows.some((row) => row.id === id)) return `Identity names unknown setup ${id}.`;
|
|
1276
|
-
if (!identity || typeof identity !== "object") return `Invalid display identity for ${id}.`;
|
|
1277
|
-
for (const mark of [identity.model, identity.harness]) {
|
|
1278
|
-
if (!mark) continue;
|
|
1279
|
-
if (typeof mark.label !== "string" || !mark.label.trim() || typeof mark.src !== "string") return `Invalid display identity for ${id}.`;
|
|
1280
|
-
const local = /^\/(?!\/)[^\s<>"']+$/.test(mark.src);
|
|
1281
|
-
const https = /^https:\/\/[a-z0-9.-]+(?::\d+)?\/[^\s<>"']*$/i.test(mark.src);
|
|
1282
|
-
if (!local && !https) return `Invalid identity image for ${id}.`;
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
return null;
|
|
1286
|
-
}
|
|
1278
|
+
//#region src/vertical-columns.ts
|
|
1287
1279
|
function image(mark, x, y, size) {
|
|
1288
1280
|
return `<image href="${esc(mark.src)}" x="${x}" y="${y}" width="${size}" height="${size}" preserveAspectRatio="xMidYMid meet"><title>${esc(mark.label)}</title></image>`;
|
|
1289
1281
|
}
|
|
1290
|
-
|
|
1291
|
-
function verticalRates(rows, viewport, ranked, o) {
|
|
1282
|
+
function verticalColumns(rows, viewport, measure, domain, tickLabel, scalarLayout = false) {
|
|
1292
1283
|
const width = Math.max(viewport, 64 + rows.length * 112);
|
|
1293
1284
|
const left = 48;
|
|
1294
1285
|
const top = 42;
|
|
@@ -1297,9 +1288,11 @@ function verticalRates(rows, viewport, ranked, o) {
|
|
|
1297
1288
|
const slot = 112;
|
|
1298
1289
|
const barWidth = Math.min(viewport < 720 ? 44 : 64, slot * .55);
|
|
1299
1290
|
const labelWidth = Math.min(100, 170);
|
|
1300
|
-
const labels = rows.map((row) => wrap(
|
|
1291
|
+
const labels = rows.map((row) => wrap(row.identity?.model?.label ?? row.label, labelWidth, 14, "sans", 8));
|
|
1301
1292
|
const labelLines = Math.max(...labels.map((label) => label.lines.length));
|
|
1302
|
-
const
|
|
1293
|
+
const harnessLabels = rows.map((row) => wrap(row.identity?.harness?.label ?? "", labelWidth, 12, "sans", 8));
|
|
1294
|
+
const harnessLines = scalarLayout ? Math.max(1, ...harnessLabels.map((label) => label.lines.length)) : 1;
|
|
1295
|
+
const height = 408 + Math.max(0, labelLines - 1) * 18 + (harnessLines - 1) * 16;
|
|
1303
1296
|
let grid = "";
|
|
1304
1297
|
for (const tick of [
|
|
1305
1298
|
0,
|
|
@@ -1311,59 +1304,40 @@ function verticalRates(rows, viewport, ranked, o) {
|
|
|
1311
1304
|
]) {
|
|
1312
1305
|
const y = bottom - tick * band;
|
|
1313
1306
|
grid += `<line class="${tick === 0 ? "tgc-axis" : "tgc-gridline"}" x1="${left}" y1="${y}" x2="100%" y2="${y}"/>`;
|
|
1314
|
-
grid += text(38, y + 4,
|
|
1307
|
+
grid += text(38, y + 4, tickLabel(domain[0] + tick * (domain[1] - domain[0])), {
|
|
1315
1308
|
cls: "tgc-ink-muted tgc-num",
|
|
1316
1309
|
anchor: "end"
|
|
1317
1310
|
});
|
|
1318
1311
|
}
|
|
1319
|
-
grid += text(left, 18,
|
|
1312
|
+
grid += text(left, 18, measure, { cls: "tgc-ink-muted" });
|
|
1320
1313
|
let columns = "";
|
|
1321
1314
|
let tips = "";
|
|
1322
1315
|
let rules = "";
|
|
1323
1316
|
for (const [i, row] of rows.entries()) {
|
|
1324
1317
|
const x = left + slot * (i + .5);
|
|
1325
|
-
const y = bottom - row.
|
|
1326
|
-
const identity =
|
|
1327
|
-
const facts =
|
|
1328
|
-
row.label,
|
|
1329
|
-
`${kn(row.solved, row.attempts)} passed`,
|
|
1330
|
-
drawable(row.estimate) ? rateLabel(row.rate) : estimateNote(row.estimate, row.attempts)
|
|
1331
|
-
];
|
|
1332
|
-
if (row.interval && rateIntervalGap(row) === null) facts.push(`${intervalName(row.interval)}: ${rateLabel(row.interval.lower)}–${rateLabel(row.interval.upper)}`);
|
|
1333
|
-
if (row.solved > 0 && perPass(row.cost) !== null) facts.push(`${costText(row.cost, row.solved)} per pass`);
|
|
1334
|
-
if (row.medianWallMs !== null) facts.push(`${duration(row.medianWallMs)} median run`);
|
|
1318
|
+
const y = bottom - ((row.value ?? domain[0]) - domain[0]) / (domain[1] - domain[0]) * band;
|
|
1319
|
+
const identity = row.identity;
|
|
1320
|
+
const facts = row.facts;
|
|
1335
1321
|
let marks = rect(x - slot / 2 + 3, 18, 106, height - top + 12, "tgc-column-hit", "rx=\"6\"");
|
|
1336
|
-
if (
|
|
1337
|
-
marks += rect(x - barWidth / 2, y, barWidth, row.
|
|
1338
|
-
if (row.interval
|
|
1339
|
-
const lo = bottom - row.interval.lower * band;
|
|
1340
|
-
const hi = bottom - row.interval.upper * band;
|
|
1322
|
+
if (row.value !== null) {
|
|
1323
|
+
marks += rect(x - barWidth / 2, y, barWidth, (row.value - domain[0]) / (domain[1] - domain[0]) * band, "tgc-vertical-bar", "rx=\"5\"");
|
|
1324
|
+
if (row.interval) {
|
|
1325
|
+
const lo = bottom - (row.interval.lower - domain[0]) / (domain[1] - domain[0]) * band;
|
|
1326
|
+
const hi = bottom - (row.interval.upper - domain[0]) / (domain[1] - domain[0]) * band;
|
|
1341
1327
|
marks += line(x, lo, x, hi, "tgc-whisker");
|
|
1342
1328
|
marks += line(x - 5, lo, x + 5, lo, "tgc-whisker") + line(x - 5, hi, x + 5, hi, "tgc-whisker");
|
|
1343
1329
|
}
|
|
1344
|
-
marks += text(x + barWidth / 2 + 7, Math.max(34, y - 9),
|
|
1330
|
+
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, {
|
|
1345
1331
|
size: 15,
|
|
1346
1332
|
weight: 600,
|
|
1347
1333
|
anchor: "middle",
|
|
1348
1334
|
cls: "tgc-ink tgc-num"
|
|
1349
1335
|
});
|
|
1350
|
-
} else marks += text(x, 290,
|
|
1336
|
+
} else marks += text(x, 290, row.valueLabel, {
|
|
1351
1337
|
anchor: "middle",
|
|
1352
1338
|
cls: "tgc-ink-muted"
|
|
1353
1339
|
});
|
|
1354
|
-
if (row.
|
|
1355
|
-
let cursor = x - barWidth / 2;
|
|
1356
|
-
for (const tier of TIERS_BEST_FIRST) {
|
|
1357
|
-
const segment = row.tiers[tier] / row.attempts * barWidth;
|
|
1358
|
-
if (segment > 0) marks += rect(cursor, 311, segment, 5, tier === "F" ? "tgc-open" : `tgc-tier-${tier.toLowerCase()}`);
|
|
1359
|
-
cursor += segment;
|
|
1360
|
-
}
|
|
1361
|
-
if (o.passTier) {
|
|
1362
|
-
const count = TIERS_BEST_FIRST.filter((tier) => tierAtLeast(tier, o.passTier)).reduce((sum, tier) => sum + row.tiers[tier], 0);
|
|
1363
|
-
const tx = x - barWidth / 2 + count / row.attempts * barWidth;
|
|
1364
|
-
marks += line(tx, 308, tx, 319, "tgc-tick");
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1340
|
+
if (row.belowBar) marks += row.belowBar(x, barWidth, bottom);
|
|
1367
1341
|
if (identity?.model) {
|
|
1368
1342
|
marks += rect(x - 18, 325, 36, 36, "tgc-logo-plate", "rx=\"6\"");
|
|
1369
1343
|
marks += image(identity.model, x - 16, 327, 32);
|
|
@@ -1373,11 +1347,14 @@ function verticalRates(rows, viewport, ranked, o) {
|
|
|
1373
1347
|
marks += image(identity.harness, x + 6, 348, 16);
|
|
1374
1348
|
}
|
|
1375
1349
|
marks += textBlock(labels[i].lines, x, 386, 18, "class=\"tgc-ink\" font-size=\"14\" text-anchor=\"middle\"");
|
|
1376
|
-
if (identity?.harness)
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1350
|
+
if (identity?.harness) {
|
|
1351
|
+
const harnessY = 405 + (labels[i].lines.length - 1) * 18;
|
|
1352
|
+
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, {
|
|
1353
|
+
anchor: "middle",
|
|
1354
|
+
cls: "tgc-ink-muted"
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
if (row.rankLabel) marks += text(x, 33, row.rankLabel, {
|
|
1381
1358
|
anchor: "middle",
|
|
1382
1359
|
cls: "tgc-ink-muted tgc-num"
|
|
1383
1360
|
});
|
|
@@ -1389,7 +1366,67 @@ function verticalRates(rows, viewport, ranked, o) {
|
|
|
1389
1366
|
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>`;
|
|
1390
1367
|
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}`;
|
|
1391
1368
|
}
|
|
1392
|
-
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(
|
|
1369
|
+
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>`;
|
|
1370
|
+
}
|
|
1371
|
+
//#endregion
|
|
1372
|
+
//#region src/vertical-rates.ts
|
|
1373
|
+
/** Rate-specific annotations for the shared vertical columns. */
|
|
1374
|
+
const rateLabel = (rate) => `${Number((rate * 100).toFixed(1))}%`;
|
|
1375
|
+
function verticalRates(rows, viewport, ranked, o) {
|
|
1376
|
+
return verticalColumns(rows.map((row) => {
|
|
1377
|
+
const facts = [
|
|
1378
|
+
row.label,
|
|
1379
|
+
`${kn(row.solved, row.attempts)} passed`,
|
|
1380
|
+
drawable(row.estimate) ? rateLabel(row.rate) : estimateNote(row.estimate, row.attempts)
|
|
1381
|
+
];
|
|
1382
|
+
if (row.interval && rateIntervalGap(row) === null) facts.push(`${intervalName(row.interval)}: ${rateLabel(row.interval.lower)}–${rateLabel(row.interval.upper)}`);
|
|
1383
|
+
if (row.solved > 0 && perPass(row.cost) !== null) facts.push(`${costText(row.cost, row.solved)} per pass`);
|
|
1384
|
+
if (row.medianWallMs !== null) facts.push(`${duration(row.medianWallMs)} median run`);
|
|
1385
|
+
return {
|
|
1386
|
+
id: row.id,
|
|
1387
|
+
label: row.label,
|
|
1388
|
+
value: drawable(row.estimate) ? row.rate : null,
|
|
1389
|
+
valueLabel: drawable(row.estimate) ? rateLabel(row.rate) : estimateNote(row.estimate, row.attempts),
|
|
1390
|
+
interval: rateIntervalGap(row) === null ? row.interval : null,
|
|
1391
|
+
facts,
|
|
1392
|
+
identity: o.identities?.[row.id],
|
|
1393
|
+
...ranked ? { rankLabel: `Rank ${row.rank}` } : {},
|
|
1394
|
+
belowBar: (x, barWidth, bottom) => {
|
|
1395
|
+
let marks = "";
|
|
1396
|
+
if (row.tiers && row.attempts > 0 && drawable(row.estimate)) {
|
|
1397
|
+
let cursor = x - barWidth / 2;
|
|
1398
|
+
for (const tier of TIERS_BEST_FIRST) {
|
|
1399
|
+
const segment = row.tiers[tier] / row.attempts * barWidth;
|
|
1400
|
+
if (segment > 0) marks += rect(cursor, bottom + 9, segment, 5, tier === "F" ? "tgc-open" : `tgc-tier-${tier.toLowerCase()}`);
|
|
1401
|
+
cursor += segment;
|
|
1402
|
+
}
|
|
1403
|
+
if (o.passTier) {
|
|
1404
|
+
const count = TIERS_BEST_FIRST.filter((tier) => tierAtLeast(tier, o.passTier)).reduce((sum, tier) => sum + row.tiers[tier], 0);
|
|
1405
|
+
const tx = x - barWidth / 2 + count / row.attempts * barWidth;
|
|
1406
|
+
marks += line(tx, bottom + 6, tx, bottom + 17, "tgc-tick");
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return marks;
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
}), viewport, o.measure, [0, 1], pct);
|
|
1413
|
+
}
|
|
1414
|
+
//#endregion
|
|
1415
|
+
//#region src/identity.ts
|
|
1416
|
+
function identityGap(rows, identities) {
|
|
1417
|
+
if (!identities) return null;
|
|
1418
|
+
for (const [id, identity] of Object.entries(identities)) {
|
|
1419
|
+
if (!rows.some((row) => row.id === id)) return `Identity names unknown setup ${id}.`;
|
|
1420
|
+
if (!identity || typeof identity !== "object") return `Invalid display identity for ${id}.`;
|
|
1421
|
+
for (const mark of [identity.model, identity.harness]) {
|
|
1422
|
+
if (!mark) continue;
|
|
1423
|
+
if (typeof mark.label !== "string" || !mark.label.trim() || typeof mark.src !== "string") return `Invalid display identity for ${id}.`;
|
|
1424
|
+
const local = /^\/(?!\/)[^\s<>"']+$/.test(mark.src);
|
|
1425
|
+
const https = /^https:\/\/[a-z0-9.-]+(?::\d+)?\/[^\s<>"']*$/i.test(mark.src);
|
|
1426
|
+
if (!local && !https) return `Invalid identity image for ${id}.`;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return null;
|
|
1393
1430
|
}
|
|
1394
1431
|
//#endregion
|
|
1395
1432
|
//#region src/ranked-rates.ts
|
|
@@ -2155,4 +2192,266 @@ function isRefusal(value) {
|
|
|
2155
2192
|
return "refused" in value;
|
|
2156
2193
|
}
|
|
2157
2194
|
//#endregion
|
|
2158
|
-
|
|
2195
|
+
//#region src/scalar.ts
|
|
2196
|
+
/** Validation and display of recorded scalar observations; no statistics are computed here. */
|
|
2197
|
+
function domainGap(domain) {
|
|
2198
|
+
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;
|
|
2199
|
+
}
|
|
2200
|
+
function scalarGap(value, interval, observations, domain) {
|
|
2201
|
+
if (value !== null && (!Number.isFinite(value) || value < domain[0] || value > domain[1])) return "The value is outside the recorded axis domain.";
|
|
2202
|
+
if (observations) {
|
|
2203
|
+
if (!observations.label.trim()) return "An observation count needs its unit.";
|
|
2204
|
+
if (observations.count !== null && (!Number.isInteger(observations.count) || observations.count < 0)) return "Observation counts must be nonnegative whole numbers or null.";
|
|
2205
|
+
if (observations.count === 0 && value !== null) return "A measured value cannot have zero observations.";
|
|
2206
|
+
}
|
|
2207
|
+
if (interval) {
|
|
2208
|
+
if (value === null) return "An unmeasured value cannot have an interval.";
|
|
2209
|
+
if (interval.method !== null && !Object.hasOwn(METHOD_NAME, interval.method)) return "The interval method is unknown.";
|
|
2210
|
+
const gap = intervalGap(interval);
|
|
2211
|
+
if (gap) return gap;
|
|
2212
|
+
if (interval.lower < domain[0] || interval.upper > domain[1]) return "The interval is outside the axis domain.";
|
|
2213
|
+
}
|
|
2214
|
+
return null;
|
|
2215
|
+
}
|
|
2216
|
+
function scalarLabel(value, unit, format) {
|
|
2217
|
+
return `${format ? format(value) : String(value)}${unit ? ` ${unit}` : ""}`;
|
|
2218
|
+
}
|
|
2219
|
+
function observationLabel(observations) {
|
|
2220
|
+
return observations ? `${observations.count ?? "unknown"} ${observations.label}` : "observation count unknown";
|
|
2221
|
+
}
|
|
2222
|
+
/** Shift the recorded decimal level, without rounding it to a different confidence. */
|
|
2223
|
+
function confidencePercent(level) {
|
|
2224
|
+
const [coefficient, exponent] = String(level).split("e-");
|
|
2225
|
+
const digits = (exponent ? `0.${"0".repeat(Number(exponent) - 1)}${coefficient.replace(".", "")}` : coefficient).slice(2).padEnd(2, "0");
|
|
2226
|
+
const fraction = digits.slice(2).replace(/0+$/, "");
|
|
2227
|
+
return `${Number(digits.slice(0, 2))}${fraction ? `.${fraction}` : ""}%`;
|
|
2228
|
+
}
|
|
2229
|
+
function scalarIntervalLabel(interval, unit, format) {
|
|
2230
|
+
return interval ? `${scalarLabel(interval.lower, unit, format)}–${scalarLabel(interval.upper, unit, format)} (${confidencePercent(interval.level)} ${METHOD_NAME[interval.method]})` : "not recorded";
|
|
2231
|
+
}
|
|
2232
|
+
//#endregion
|
|
2233
|
+
//#region src/metric-bars.ts
|
|
2234
|
+
/** Scalar bars preserve input order and draw only producer-recorded values and intervals. */
|
|
2235
|
+
function metricBars(rows, o) {
|
|
2236
|
+
const id = o.id ?? "metric-bars";
|
|
2237
|
+
const gap = domainGap(o.domain);
|
|
2238
|
+
if (gap) return {
|
|
2239
|
+
id,
|
|
2240
|
+
refused: gap
|
|
2241
|
+
};
|
|
2242
|
+
if (o.domain[0] !== 0) return {
|
|
2243
|
+
id,
|
|
2244
|
+
refused: "Bars require a zero baseline."
|
|
2245
|
+
};
|
|
2246
|
+
if (!rows.length) return {
|
|
2247
|
+
id,
|
|
2248
|
+
refused: `No ${o.measure} recorded.`
|
|
2249
|
+
};
|
|
2250
|
+
if (new Set(rows.map((row) => row.id)).size !== rows.length) return {
|
|
2251
|
+
id,
|
|
2252
|
+
refused: "Duplicate scalar row ids."
|
|
2253
|
+
};
|
|
2254
|
+
const identityProblem = identityGap(rows, o.identities);
|
|
2255
|
+
if (identityProblem) return {
|
|
2256
|
+
id,
|
|
2257
|
+
refused: identityProblem
|
|
2258
|
+
};
|
|
2259
|
+
for (const row of rows) {
|
|
2260
|
+
const problem = scalarGap(row.value, row.interval, row.observations, o.domain);
|
|
2261
|
+
if (problem) return {
|
|
2262
|
+
id,
|
|
2263
|
+
refused: `${row.label}: ${problem}`
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2266
|
+
const label = (value) => scalarLabel(value, o.unit, o.formatValue);
|
|
2267
|
+
const columns = rows.map((row) => ({
|
|
2268
|
+
id: row.id,
|
|
2269
|
+
label: row.label,
|
|
2270
|
+
value: row.value,
|
|
2271
|
+
valueLabel: row.value === null ? "not measured" : label(row.value),
|
|
2272
|
+
interval: row.interval ?? null,
|
|
2273
|
+
identity: o.identities?.[row.id],
|
|
2274
|
+
facts: [
|
|
2275
|
+
row.label,
|
|
2276
|
+
`${o.measure}: ${row.value === null ? "not measured" : label(row.value)}`,
|
|
2277
|
+
observationLabel(row.observations),
|
|
2278
|
+
...row.interval ? [`Interval: ${scalarIntervalLabel(row.interval, o.unit, o.formatValue)}`] : []
|
|
2279
|
+
]
|
|
2280
|
+
}));
|
|
2281
|
+
const measured = rows.filter((row) => row.value !== null);
|
|
2282
|
+
return {
|
|
2283
|
+
id,
|
|
2284
|
+
finding: `${capitalize(o.measure)} by setup.`,
|
|
2285
|
+
lede: `${measured.length} of ${rows.length} recorded values.`,
|
|
2286
|
+
read: "Bars start at zero. Missing values have no bar; whiskers show recorded intervals. Input order is preserved.",
|
|
2287
|
+
svg: {
|
|
2288
|
+
wide: verticalColumns(columns, 720, o.measure, o.domain, (value) => scalarLabel(value, "", o.formatValue), true),
|
|
2289
|
+
narrow: verticalColumns(columns, 358, o.measure, o.domain, (value) => scalarLabel(value, "", o.formatValue), true),
|
|
2290
|
+
interactive: true
|
|
2291
|
+
},
|
|
2292
|
+
table: table(`${capitalize(o.measure)} by setup`, [
|
|
2293
|
+
{ label: "Setup" },
|
|
2294
|
+
{
|
|
2295
|
+
label: o.measure,
|
|
2296
|
+
numeric: true
|
|
2297
|
+
},
|
|
2298
|
+
{ label: "Observations" },
|
|
2299
|
+
{ label: "Recorded interval" }
|
|
2300
|
+
], rows.map((row) => [
|
|
2301
|
+
row.label,
|
|
2302
|
+
row.value === null ? "not measured" : label(row.value),
|
|
2303
|
+
observationLabel(row.observations),
|
|
2304
|
+
scalarIntervalLabel(row.interval, o.unit, o.formatValue)
|
|
2305
|
+
])).replace("class=\"tgc-table\"", "class=\"tgc-table tgc-scalar-table\""),
|
|
2306
|
+
note: {
|
|
2307
|
+
method: "Recorded scalar values, in input order. No rank, interval or statistical comparison is inferred.",
|
|
2308
|
+
n: rows.map((row) => `${row.label}: ${observationLabel(row.observations)}`).join("; "),
|
|
2309
|
+
exclusions: rows.filter((row) => row.value === null).map((row) => `${row.label}: not measured.`)
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
//#endregion
|
|
2314
|
+
//#region src/time-series.ts
|
|
2315
|
+
/** Recorded trajectories. Explicit null observations break lines without interpolation. */
|
|
2316
|
+
function timeSeries(rows, o) {
|
|
2317
|
+
const id = o.id ?? "time-series";
|
|
2318
|
+
const gap = domainGap(o.x.domain) ?? domainGap(o.y.domain);
|
|
2319
|
+
if (gap) return {
|
|
2320
|
+
id,
|
|
2321
|
+
refused: gap
|
|
2322
|
+
};
|
|
2323
|
+
if (!rows.length || rows.every((row) => !row.points.length)) return {
|
|
2324
|
+
id,
|
|
2325
|
+
refused: "No trajectory observations recorded."
|
|
2326
|
+
};
|
|
2327
|
+
if (new Set(rows.map((row) => row.id)).size !== rows.length) return {
|
|
2328
|
+
id,
|
|
2329
|
+
refused: "Duplicate trajectory ids."
|
|
2330
|
+
};
|
|
2331
|
+
for (const row of rows) {
|
|
2332
|
+
let previous = -Infinity;
|
|
2333
|
+
for (const point of row.points) {
|
|
2334
|
+
if (!Number.isFinite(point.x) || point.x <= previous || point.x < o.x.domain[0] || point.x > o.x.domain[1]) return {
|
|
2335
|
+
id,
|
|
2336
|
+
refused: `${row.label}: x values must be finite, strictly increasing and inside the axis domain.`
|
|
2337
|
+
};
|
|
2338
|
+
previous = point.x;
|
|
2339
|
+
const problem = scalarGap(point.y, point.interval, point.observations, o.y.domain);
|
|
2340
|
+
if (problem) return {
|
|
2341
|
+
id,
|
|
2342
|
+
refused: `${row.label} at ${point.x}: ${problem}`
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
const xLabel = (x) => o.formatX ? o.formatX(x) : String(x);
|
|
2347
|
+
const yLabel = (y) => scalarLabel(y, o.y.unit, o.formatY);
|
|
2348
|
+
const flat = rows.flatMap((row) => row.points.map((point) => ({
|
|
2349
|
+
row,
|
|
2350
|
+
point
|
|
2351
|
+
})));
|
|
2352
|
+
const measured = flat.filter(({ point }) => point.y !== null);
|
|
2353
|
+
const facts = (row, point) => [
|
|
2354
|
+
row.label,
|
|
2355
|
+
`${o.x.label}: ${xLabel(point.x)}`,
|
|
2356
|
+
`${o.y.label}: ${point.y === null ? "not measured" : yLabel(point.y)}`,
|
|
2357
|
+
observationLabel(point.observations),
|
|
2358
|
+
...point.interval ? [`Interval: ${scalarIntervalLabel(point.interval, o.y.unit, o.formatY)}`] : [],
|
|
2359
|
+
...point.annotation ? [point.annotation] : []
|
|
2360
|
+
];
|
|
2361
|
+
return {
|
|
2362
|
+
id,
|
|
2363
|
+
finding: `${capitalize(o.y.label)} by ${o.x.label}.`,
|
|
2364
|
+
lede: `${measured.length} of ${flat.length} positions have a recorded value.`,
|
|
2365
|
+
read: "Points show recorded values. Lines connect adjacent measured positions; missing positions break the line. Whiskers show recorded intervals.",
|
|
2366
|
+
svg: {
|
|
2367
|
+
wide: plot(rows, o, 720, xLabel, yLabel, facts),
|
|
2368
|
+
narrow: plot(rows, o, 358, xLabel, yLabel, facts),
|
|
2369
|
+
interactive: true
|
|
2370
|
+
},
|
|
2371
|
+
table: table(`${capitalize(o.y.label)} by ${o.x.label}`, [
|
|
2372
|
+
{ label: "Setup" },
|
|
2373
|
+
{
|
|
2374
|
+
label: o.x.label,
|
|
2375
|
+
numeric: true
|
|
2376
|
+
},
|
|
2377
|
+
{
|
|
2378
|
+
label: o.y.label,
|
|
2379
|
+
numeric: true
|
|
2380
|
+
},
|
|
2381
|
+
{ label: "Observations" },
|
|
2382
|
+
{ label: "Recorded interval" },
|
|
2383
|
+
{ label: "Recorded annotation" }
|
|
2384
|
+
], flat.map(({ row, point }) => [
|
|
2385
|
+
row.label,
|
|
2386
|
+
xLabel(point.x),
|
|
2387
|
+
point.y === null ? "not measured" : yLabel(point.y),
|
|
2388
|
+
observationLabel(point.observations),
|
|
2389
|
+
scalarIntervalLabel(point.interval, o.y.unit, o.formatY),
|
|
2390
|
+
point.annotation ?? ""
|
|
2391
|
+
])).replace("class=\"tgc-table\"", "class=\"tgc-table tgc-scalar-table\""),
|
|
2392
|
+
note: {
|
|
2393
|
+
method: "Recorded scalar observations. Missing positions are not interpolated; no uncertainty or statistical comparison is inferred.",
|
|
2394
|
+
n: "Observation counts are recorded separately at each position.",
|
|
2395
|
+
exclusions: flat.filter(({ point }) => point.y === null).map(({ row, point }) => `${row.label}, ${o.x.label} ${xLabel(point.x)}: not measured.`)
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
function plot(rows, o, width, xLabel, yLabel, facts) {
|
|
2400
|
+
const left = 64, right = width - 24, top = 36, bottom = 260;
|
|
2401
|
+
const px = (x) => left + (x - o.x.domain[0]) / (o.x.domain[1] - o.x.domain[0]) * (right - left);
|
|
2402
|
+
const py = (y) => bottom - (y - o.y.domain[0]) / (o.y.domain[1] - o.y.domain[0]) * 224;
|
|
2403
|
+
let marks = text(left, 18, o.y.label, { cls: "tgc-ink-muted" });
|
|
2404
|
+
for (let i = 0; i <= 4; i++) {
|
|
2405
|
+
const y = o.y.domain[0] + i / 4 * (o.y.domain[1] - o.y.domain[0]);
|
|
2406
|
+
marks += line(left, py(y), right, py(y), i === 0 ? "tgc-axis" : "tgc-gridline") + text(56, py(y) + 4, yLabel(y), {
|
|
2407
|
+
anchor: "end",
|
|
2408
|
+
cls: "tgc-ink-muted tgc-num",
|
|
2409
|
+
size: 11
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
const xs = [...new Set(rows.flatMap((row) => row.points.map((point) => point.x)))].sort((a, b) => a - b);
|
|
2413
|
+
const tickStep = Math.max(1, Math.ceil(xs.length / (width < 720 ? 5 : 10)));
|
|
2414
|
+
for (const [i, x] of xs.entries()) if (i % tickStep === 0 || i === xs.length - 1) marks += text(px(x), 280, xLabel(x), {
|
|
2415
|
+
anchor: "middle",
|
|
2416
|
+
cls: "tgc-ink-muted tgc-num"
|
|
2417
|
+
});
|
|
2418
|
+
marks += text((left + right) / 2, 303, o.x.label, {
|
|
2419
|
+
anchor: "middle",
|
|
2420
|
+
cls: "tgc-ink-muted"
|
|
2421
|
+
});
|
|
2422
|
+
let legendY = 332;
|
|
2423
|
+
let points = "", tips = "", rules = "";
|
|
2424
|
+
let pointId = 0;
|
|
2425
|
+
for (const [seriesIndex, row] of rows.entries()) {
|
|
2426
|
+
const dash = seriesIndex === 0 ? "" : `${2 + seriesIndex * 2} 3`;
|
|
2427
|
+
let segment = [];
|
|
2428
|
+
const flush = () => {
|
|
2429
|
+
if (segment.length > 1) marks += `<g data-series="${esc(row.id)}" style="stroke-dasharray:${dash}">${polyline(segment, "tgc-series-line")}</g>`;
|
|
2430
|
+
segment = [];
|
|
2431
|
+
};
|
|
2432
|
+
for (const point of row.points) {
|
|
2433
|
+
if (point.y === null) {
|
|
2434
|
+
flush();
|
|
2435
|
+
continue;
|
|
2436
|
+
}
|
|
2437
|
+
const x = px(point.x), y = py(point.y);
|
|
2438
|
+
segment.push([x, y]);
|
|
2439
|
+
const pointFacts = facts(row, point);
|
|
2440
|
+
const key = pointId++;
|
|
2441
|
+
let dot = circle(x, y, 10, "tgc-series-hit") + circle(x, y, 3.5, "tgc-series-point");
|
|
2442
|
+
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");
|
|
2443
|
+
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>`;
|
|
2444
|
+
const tipWidth = Math.min(280, width - 24), tipX = Math.max(12, Math.min(width - tipWidth - 12, x - tipWidth / 2));
|
|
2445
|
+
const lines = pointFacts.flatMap((fact) => wrap(fact, tipWidth - 24, 12, "sans", 8).lines);
|
|
2446
|
+
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>`;
|
|
2447
|
+
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}`;
|
|
2448
|
+
}
|
|
2449
|
+
flush();
|
|
2450
|
+
const label = wrap(row.label, right - left - 32, 12, "sans", 8);
|
|
2451
|
+
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\"");
|
|
2452
|
+
legendY += label.lines.length * 16 + 8;
|
|
2453
|
+
}
|
|
2454
|
+
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>`;
|
|
2455
|
+
}
|
|
2456
|
+
//#endregion
|
|
2457
|
+
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.12",
|
|
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",
|