@oh-my-pi/pi-tui 18.6.3 → 18.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -58
- package/dist/types/app-keybindings.d.ts +1 -1
- package/dist/types/autocomplete.d.ts +5 -2
- package/dist/types/charts/chart-plan.d.ts +92 -0
- package/dist/types/charts/chart-svg.d.ts +30 -0
- package/dist/types/charts/table-data.d.ts +107 -0
- package/dist/types/chat/assistant-message.d.ts +4 -0
- package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
- package/dist/types/chat/figure-markdown.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +33 -0
- package/dist/types/chat/svg-source.d.ts +49 -0
- package/dist/types/chat/table-chart.d.ts +88 -0
- package/dist/types/components/editor.d.ts +1 -1
- package/dist/types/components/image.d.ts +9 -0
- package/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/fuzzy.d.ts +14 -0
- package/dist/types/keybindings.d.ts +1 -1
- package/dist/types/overlays/agents-hub.d.ts +9 -0
- package/dist/types/overlays/annotation-overlay.d.ts +5 -2
- package/dist/types/overlays/model-browser.d.ts +25 -3
- package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
- package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
- package/dist/types/render/composite.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -0
- package/dist/types/utils.d.ts +6 -0
- package/package.json +9 -9
- package/src/autocomplete.ts +8 -10
- package/src/charts/chart-plan.ts +367 -0
- package/src/charts/chart-svg.ts +693 -0
- package/src/charts/table-data.ts +436 -0
- package/src/chat/assistant-message.ts +139 -21
- package/src/chat/chat-transcript-builder.ts +4 -0
- package/src/chat/figure-markdown.ts +174 -0
- package/src/chat/svg-figure.ts +364 -0
- package/src/chat/svg-source.ts +188 -0
- package/src/chat/table-chart.ts +301 -0
- package/src/chrome/transcript-container.ts +7 -1
- package/src/components/editor.ts +52 -30
- package/src/components/image.ts +27 -1
- package/src/components/markdown.ts +6 -3
- package/src/fuzzy.ts +59 -19
- package/src/keybindings.ts +1 -1
- package/src/overlays/agent-transcript-viewer.ts +2 -0
- package/src/overlays/agents-hub.ts +14 -1
- package/src/overlays/annotation-overlay.ts +179 -80
- package/src/overlays/ask-dialog.ts +1 -1
- package/src/overlays/model-browser.ts +169 -76
- package/src/overlays/plan-review-overlay.ts +41 -4
- package/src/prompt/composer.ts +2 -14
- package/src/prompt/interactive-context-helpers.ts +3 -0
- package/src/prompt/model-mention-autocomplete.ts +14 -14
- package/src/render/composite.ts +62 -0
- package/src/status-line/segments.ts +3 -1
- package/src/tools/glob.ts +23 -6
- package/src/tui.ts +118 -105
- package/src/utils.ts +19 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draws a {@link ChartSpec} as a standalone SVG document colored only with
|
|
3
|
+
* figure tokens — `var(--fg)`, `var(--muted)`, `var(--border)`,
|
|
4
|
+
* `var(--accent)`, `var(--surface)`, `var(--c1)`…`var(--c6)` — on a
|
|
5
|
+
* transparent background — the token vocabulary of assistant ```svg figures —
|
|
6
|
+
* so the host resolves them against the live theme (`prepareSvg`) for both
|
|
7
|
+
* the rasterized terminal image and the native TSP blob.
|
|
8
|
+
*
|
|
9
|
+
* Geometry follows the terminal: 16 user units per row and a monospace face,
|
|
10
|
+
* so 13-unit labels land near the terminal's own glyph size. Marks are flat
|
|
11
|
+
* and square (no rounding, gradients or shadows); bolded table cells draw at
|
|
12
|
+
* full strength while their neighbours recede.
|
|
13
|
+
*/
|
|
14
|
+
import type { ChartPoint, ChartSeries, ChartSpec } from "./chart-plan";
|
|
15
|
+
import type { Dimension } from "./table-data";
|
|
16
|
+
|
|
17
|
+
const WIDTH = 720;
|
|
18
|
+
const PAD = 12;
|
|
19
|
+
const LABEL_SIZE = 13;
|
|
20
|
+
const TICK_SIZE = 11;
|
|
21
|
+
/** Monospace advance per em. */
|
|
22
|
+
const ADVANCE = 0.6;
|
|
23
|
+
const FONT = "ui-monospace, 'SF Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace";
|
|
24
|
+
/** Series colors in draw order. */
|
|
25
|
+
const SERIES = ["accent", "c2", "c3", "c4", "c5", "c6", "c1"] as const;
|
|
26
|
+
/** Spread (max/min of positive values) from which an axis turns logarithmic. */
|
|
27
|
+
const LOG_SPREAD = 100;
|
|
28
|
+
const MAX_LABEL_CHARS = 28;
|
|
29
|
+
/** Opacity of marks beside an emphasized one. */
|
|
30
|
+
const RECEDE = 0.45;
|
|
31
|
+
|
|
32
|
+
/** A drawn layer: its SVG elements and the height they span from `top`. */
|
|
33
|
+
interface Layer {
|
|
34
|
+
readonly height: number;
|
|
35
|
+
readonly body: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Map a value to a coordinate, with tick values for the axis. */
|
|
39
|
+
interface Scale {
|
|
40
|
+
at(value: number): number;
|
|
41
|
+
readonly ticks: readonly number[];
|
|
42
|
+
readonly log: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A drawn chart: the SVG document and its size in user units. */
|
|
46
|
+
export interface ChartSvg {
|
|
47
|
+
readonly svg: string;
|
|
48
|
+
readonly width: number;
|
|
49
|
+
readonly height: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The SVG document for `spec`. */
|
|
53
|
+
export function renderChartSvg(spec: ChartSpec): ChartSvg {
|
|
54
|
+
const layer = drawChart(spec);
|
|
55
|
+
let height = PAD + layer.height;
|
|
56
|
+
let caption = "";
|
|
57
|
+
if (spec.caption) {
|
|
58
|
+
caption = text(PAD, height + 14, clip(spec.caption, Math.floor((WIDTH - 2 * PAD) / (TICK_SIZE * ADVANCE))), {
|
|
59
|
+
size: TICK_SIZE,
|
|
60
|
+
fill: "muted",
|
|
61
|
+
});
|
|
62
|
+
height += 20;
|
|
63
|
+
}
|
|
64
|
+
height = Math.ceil(height + PAD);
|
|
65
|
+
const svg = [
|
|
66
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${height}" viewBox="0 0 ${WIDTH} ${height}" font-family="${FONT}" font-size="${LABEL_SIZE}">`,
|
|
67
|
+
`<g transform="translate(0 ${PAD})">${layer.body}</g>`,
|
|
68
|
+
caption,
|
|
69
|
+
"</svg>",
|
|
70
|
+
].join("");
|
|
71
|
+
return { svg, width: WIDTH, height };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One-line description of the chart, for screen readers and terminals that show the alt text. */
|
|
75
|
+
export function chartAlt(spec: ChartSpec): string {
|
|
76
|
+
const names = spec.series.map(entry => entry.name).join(", ");
|
|
77
|
+
const kind = spec.kind === "multiples" ? "small multiples" : spec.kind;
|
|
78
|
+
return `${kind} chart of ${names}${spec.axis ? ` by ${spec.axis}` : ""}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function drawChart(spec: ChartSpec): Layer {
|
|
82
|
+
switch (spec.kind) {
|
|
83
|
+
case "line":
|
|
84
|
+
return drawLine(spec);
|
|
85
|
+
case "heatmap":
|
|
86
|
+
return drawGrid(spec, "heat");
|
|
87
|
+
case "multiples":
|
|
88
|
+
return drawGrid(spec, "bars");
|
|
89
|
+
case "share":
|
|
90
|
+
return drawShare(spec);
|
|
91
|
+
case "scatter":
|
|
92
|
+
return drawScatter(spec);
|
|
93
|
+
default:
|
|
94
|
+
return drawBars(spec);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Horizontal bars: bar, grouped, paired, diverging ─────────────────────────
|
|
99
|
+
|
|
100
|
+
function drawBars(spec: ChartSpec): Layer {
|
|
101
|
+
const { kind, categories, series } = spec;
|
|
102
|
+
const values = series.flatMap(entry =>
|
|
103
|
+
entry.points.flatMap(point => (point ? [point.value, point.upper ?? point.value] : [])),
|
|
104
|
+
);
|
|
105
|
+
const labelWidth = labelColumnWidth(categories);
|
|
106
|
+
const valueText = (at: number) =>
|
|
107
|
+
kind === "paired"
|
|
108
|
+
? [pointText(series[0]!, at), pointText(series[1]!, at)].filter(Boolean).join(" → ")
|
|
109
|
+
: series.length === 1
|
|
110
|
+
? pointText(series[0]!, at) || (series[0]!.notes[at] ?? "")
|
|
111
|
+
: "";
|
|
112
|
+
const valueWidth = Math.min(
|
|
113
|
+
180,
|
|
114
|
+
Math.max(0, ...categories.map((_, at) => measure(valueText(at), TICK_SIZE))) +
|
|
115
|
+
(series.length === 1 || kind === "paired" ? 8 : 0),
|
|
116
|
+
);
|
|
117
|
+
const left = PAD + labelWidth + 10;
|
|
118
|
+
const right = WIDTH - PAD - Math.max(valueWidth, series.length > 1 && kind !== "paired" ? 64 : 0);
|
|
119
|
+
const scale = valueScale(values, left, right, { zero: true });
|
|
120
|
+
|
|
121
|
+
const parts: string[] = [];
|
|
122
|
+
let top = 0;
|
|
123
|
+
if (series.length > 1) {
|
|
124
|
+
const legend = drawLegend(
|
|
125
|
+
series.map((entry, at) => [entry.name, seriesColor(kind, at)]),
|
|
126
|
+
PAD,
|
|
127
|
+
WIDTH - PAD,
|
|
128
|
+
);
|
|
129
|
+
parts.push(legend.body);
|
|
130
|
+
top += legend.height + 6;
|
|
131
|
+
}
|
|
132
|
+
const groupRow = kind === "grouped" ? series.length * 11 + 8 : 22;
|
|
133
|
+
const gridTop = top;
|
|
134
|
+
const gridBottom = top + categories.length * groupRow;
|
|
135
|
+
parts.push(drawValueAxis(scale, series[0]!, gridTop, gridBottom));
|
|
136
|
+
|
|
137
|
+
const zero = scale.log ? left : scale.at(0);
|
|
138
|
+
const emphasized = series.some(entry => entry.points.some(point => point?.emphasis));
|
|
139
|
+
categories.forEach((name, at) => {
|
|
140
|
+
const rowTop = top + at * groupRow;
|
|
141
|
+
const middle = rowTop + groupRow / 2;
|
|
142
|
+
parts.push(text(left - 10, middle, clip(name, MAX_LABEL_CHARS), { anchor: "end", fill: "fg" }));
|
|
143
|
+
if (kind === "paired") {
|
|
144
|
+
const [a, b] = [series[0]!.points[at], series[1]!.points[at]];
|
|
145
|
+
if (a && b) parts.push(line(scale.at(a.value), middle, scale.at(b.value), middle, "border", 2));
|
|
146
|
+
if (a) parts.push(dot(scale.at(a.value), middle, 4.5, seriesColor(kind, 0)));
|
|
147
|
+
if (b)
|
|
148
|
+
parts.push(dot(scale.at(b.value), middle, 4.5, seriesColor(kind, 1), a?.emphasis || b.emphasis ? 1 : 0.9));
|
|
149
|
+
parts.push(
|
|
150
|
+
text(right + 8, middle, valueText(at), {
|
|
151
|
+
size: TICK_SIZE,
|
|
152
|
+
fill: a?.emphasis || b?.emphasis ? "fg" : "muted",
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const barHeight = kind === "grouped" ? 9 : 14;
|
|
158
|
+
series.forEach((entry, index) => {
|
|
159
|
+
const point = entry.points[at];
|
|
160
|
+
const y = kind === "grouped" ? rowTop + 4 + index * 11 : middle - barHeight / 2;
|
|
161
|
+
const fill = kind === "diverging" && point && point.value < 0 ? "c2" : seriesColor(kind, index);
|
|
162
|
+
const opacity = emphasized && !point?.emphasis ? RECEDE : 1;
|
|
163
|
+
if (!point) {
|
|
164
|
+
const note = entry.notes[at];
|
|
165
|
+
if (note)
|
|
166
|
+
parts.push(text(left + 4, y + barHeight / 2, clip(note, 16), { size: TICK_SIZE, fill: "warning" }));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const end = scale.at(point.value);
|
|
170
|
+
if (scale.log) {
|
|
171
|
+
parts.push(line(left, y + barHeight / 2, end, y + barHeight / 2, "border", 1));
|
|
172
|
+
parts.push(dot(end, y + barHeight / 2, barHeight / 2.8, fill, opacity));
|
|
173
|
+
} else {
|
|
174
|
+
parts.push(rect(Math.min(zero, end), y, Math.abs(end - zero), barHeight, fill, opacity));
|
|
175
|
+
}
|
|
176
|
+
if (point.upper !== undefined) parts.push(whisker(end, scale.at(point.upper), y + barHeight / 2, fill));
|
|
177
|
+
if (kind === "grouped") {
|
|
178
|
+
const labelX = Math.max(end, point.upper === undefined ? end : scale.at(point.upper)) + 4;
|
|
179
|
+
parts.push(text(labelX, y + barHeight / 2, clip(point.text, 12), { size: 10, fill: "muted" }));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
if (series.length === 1) {
|
|
183
|
+
const point = series[0]!.points[at];
|
|
184
|
+
const end = point
|
|
185
|
+
? Math.max(scale.at(point.value), point.upper === undefined ? 0 : scale.at(point.upper))
|
|
186
|
+
: left;
|
|
187
|
+
// Clear the dot a log axis draws instead of a bar.
|
|
188
|
+
const x = kind === "diverging" ? right + 8 : Math.max(end, zero) + (scale.log ? 11 : 6);
|
|
189
|
+
parts.push(
|
|
190
|
+
text(x, middle, valueText(at), {
|
|
191
|
+
size: TICK_SIZE,
|
|
192
|
+
fill: point?.emphasis ? "fg" : "muted",
|
|
193
|
+
weight: point?.emphasis ? 600 : undefined,
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (kind === "diverging" && !scale.log) parts.push(line(zero, gridTop - 2, zero, gridBottom + 2, "muted", 1));
|
|
199
|
+
return { height: gridBottom + 20, body: parts.join("") };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Series color: dumbbells pair a muted "before" with the accent "after". */
|
|
203
|
+
function seriesColor(kind: ChartSpec["kind"], index: number): string {
|
|
204
|
+
if (kind === "paired") return index === 0 ? "muted" : "accent";
|
|
205
|
+
return SERIES[index % SERIES.length]!;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Grids: heatmap and small multiples ──────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
function drawGrid(spec: ChartSpec, style: "heat" | "bars"): Layer {
|
|
211
|
+
const { categories, series } = spec;
|
|
212
|
+
const labelWidth = labelColumnWidth(categories);
|
|
213
|
+
const left = PAD + labelWidth + 10;
|
|
214
|
+
const cellWidth = (WIDTH - PAD - left) / series.length;
|
|
215
|
+
const rowHeight = 22;
|
|
216
|
+
const headerChars = Math.max(4, Math.floor((cellWidth - 6) / (TICK_SIZE * ADVANCE)));
|
|
217
|
+
const parts: string[] = [];
|
|
218
|
+
series.forEach((entry, index) => {
|
|
219
|
+
const x = left + index * cellWidth;
|
|
220
|
+
const anchor = style === "heat" ? "middle" : "start";
|
|
221
|
+
const tx = style === "heat" ? x + cellWidth / 2 : x + 2;
|
|
222
|
+
parts.push(text(tx, 8, clip(entry.name, headerChars), { size: TICK_SIZE, fill: "muted", anchor }));
|
|
223
|
+
});
|
|
224
|
+
const top = 22;
|
|
225
|
+
// Heatmaps share one scale; small multiples scale each series on its own.
|
|
226
|
+
const all = series.flatMap(entry => entry.points.flatMap(point => (point ? [point.value] : [])));
|
|
227
|
+
const shared = intensity(all);
|
|
228
|
+
categories.forEach((name, row) => {
|
|
229
|
+
const y = top + row * rowHeight;
|
|
230
|
+
parts.push(text(left - 10, y + rowHeight / 2, clip(name, MAX_LABEL_CHARS), { anchor: "end", fill: "fg" }));
|
|
231
|
+
});
|
|
232
|
+
series.forEach((entry, index) => {
|
|
233
|
+
const x = left + index * cellWidth;
|
|
234
|
+
const values = entry.points.flatMap(point => (point ? [point.value] : []));
|
|
235
|
+
const own = intensity(values);
|
|
236
|
+
const textWidth = Math.max(0, ...entry.points.map(point => measure(point?.text ?? "", TICK_SIZE)));
|
|
237
|
+
const barSpace = Math.max(0, cellWidth - textWidth - 14);
|
|
238
|
+
const emphasized = entry.points.some(point => point?.emphasis);
|
|
239
|
+
const color = SERIES[index % SERIES.length]!;
|
|
240
|
+
entry.points.forEach((point, row) => {
|
|
241
|
+
const y = top + row * rowHeight;
|
|
242
|
+
const middle = y + rowHeight / 2;
|
|
243
|
+
if (!point) {
|
|
244
|
+
const note = entry.notes[row];
|
|
245
|
+
if (note) parts.push(text(x + 4, middle, clip(note, headerChars), { size: TICK_SIZE, fill: "warning" }));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (style === "heat") {
|
|
249
|
+
// Capped so the foreground-colored value stays legible on the strongest cell.
|
|
250
|
+
const level = shared(point.value);
|
|
251
|
+
parts.push(rect(x + 1, y + 1, cellWidth - 2, rowHeight - 2, "accent", 0.08 + 0.57 * level));
|
|
252
|
+
parts.push(
|
|
253
|
+
text(x + cellWidth / 2, middle, clip(point.text, headerChars), {
|
|
254
|
+
size: TICK_SIZE,
|
|
255
|
+
fill: "fg",
|
|
256
|
+
anchor: "middle",
|
|
257
|
+
weight: point.emphasis ? 600 : undefined,
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const width = Math.max(1, own(point.value) * barSpace);
|
|
263
|
+
parts.push(rect(x + 2, middle - 6, width, 12, color, emphasized && !point.emphasis ? RECEDE : 1));
|
|
264
|
+
parts.push(
|
|
265
|
+
text(x + 2 + width + 4, middle, point.text, {
|
|
266
|
+
size: TICK_SIZE,
|
|
267
|
+
fill: point.emphasis ? "fg" : "muted",
|
|
268
|
+
weight: point.emphasis ? 600 : undefined,
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
if (style === "bars") {
|
|
274
|
+
for (let index = 1; index < series.length; index++) {
|
|
275
|
+
const x = left + index * cellWidth - 3;
|
|
276
|
+
parts.push(line(x, top, x, top + categories.length * rowHeight, "border", 1));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { height: top + categories.length * rowHeight + 4, body: parts.join("") };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Map values onto 0–1 for bar length or fill strength: from zero for
|
|
284
|
+
* non-negative values (log beyond {@link LOG_SPREAD}), min–max otherwise.
|
|
285
|
+
*/
|
|
286
|
+
function intensity(values: readonly number[]): (value: number) => number {
|
|
287
|
+
const max = Math.max(...values);
|
|
288
|
+
const min = Math.min(...values);
|
|
289
|
+
if (!Number.isFinite(max) || max === min) return () => 1;
|
|
290
|
+
if (min > 0 && max / min >= LOG_SPREAD) {
|
|
291
|
+
const low = Math.log10(min);
|
|
292
|
+
const span = Math.log10(max) - low;
|
|
293
|
+
return value => (value > 0 ? 0.04 + (0.96 * (Math.log10(value) - low)) / span : 0);
|
|
294
|
+
}
|
|
295
|
+
if (min >= 0) return value => value / max;
|
|
296
|
+
return value => (value - min) / (max - min);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Share of a whole ─────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
function drawShare(spec: ChartSpec): Layer {
|
|
302
|
+
const entry = spec.series[0]!;
|
|
303
|
+
const total = entry.points.reduce((sum, point) => sum + Math.max(0, point?.value ?? 0), 0) || 1;
|
|
304
|
+
const parts: string[] = [];
|
|
305
|
+
const barWidth = WIDTH - 2 * PAD;
|
|
306
|
+
let x = PAD;
|
|
307
|
+
entry.points.forEach((point, at) => {
|
|
308
|
+
if (!point || point.value <= 0) return;
|
|
309
|
+
const width = (point.value / total) * barWidth;
|
|
310
|
+
parts.push(rect(x, 0, width, 20, SERIES[at % SERIES.length]!, 1));
|
|
311
|
+
if (at > 0) parts.push(line(x, 0, x, 20, "surface", 1.5));
|
|
312
|
+
x += width;
|
|
313
|
+
});
|
|
314
|
+
const columns = spec.categories.length > 5 ? 2 : 1;
|
|
315
|
+
const columnWidth = barWidth / columns;
|
|
316
|
+
const perColumn = Math.ceil(spec.categories.length / columns);
|
|
317
|
+
const chars = Math.floor((columnWidth - 90) / (LABEL_SIZE * ADVANCE));
|
|
318
|
+
spec.categories.forEach((name, at) => {
|
|
319
|
+
const column = Math.floor(at / perColumn);
|
|
320
|
+
const lx = PAD + column * columnWidth;
|
|
321
|
+
const ly = 34 + (at % perColumn) * 20;
|
|
322
|
+
const point = entry.points[at];
|
|
323
|
+
parts.push(rect(lx, ly - 5, 10, 10, SERIES[at % SERIES.length]!, 1));
|
|
324
|
+
parts.push(text(lx + 16, ly, clip(name, chars), { fill: "fg" }));
|
|
325
|
+
parts.push(
|
|
326
|
+
text(lx + columnWidth - 12, ly, point?.text ?? "", {
|
|
327
|
+
size: TICK_SIZE,
|
|
328
|
+
fill: point?.emphasis ? "fg" : "muted",
|
|
329
|
+
anchor: "end",
|
|
330
|
+
weight: point?.emphasis ? 600 : undefined,
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
return { height: 34 + perColumn * 20, body: parts.join("") };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Line over an ordered axis ────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
function drawLine(spec: ChartSpec): Layer {
|
|
340
|
+
const { categories, series, x } = spec;
|
|
341
|
+
const parts: string[] = [];
|
|
342
|
+
let top = 0;
|
|
343
|
+
if (series.length > 1) {
|
|
344
|
+
const legend = drawLegend(
|
|
345
|
+
series.map((entry, at) => [entry.name, SERIES[at % SERIES.length]!]),
|
|
346
|
+
PAD,
|
|
347
|
+
WIDTH - PAD,
|
|
348
|
+
);
|
|
349
|
+
parts.push(legend.body);
|
|
350
|
+
top += legend.height + 6;
|
|
351
|
+
}
|
|
352
|
+
const values = series.flatMap(entry => entry.points.flatMap(point => (point ? [point.value] : [])));
|
|
353
|
+
const lastLabels = series.flatMap(entry => entry.points.map(point => point?.text ?? ""));
|
|
354
|
+
const tickWidth = Math.max(
|
|
355
|
+
...valueScale(values, 0, 1, { zero: false }).ticks.map(tick =>
|
|
356
|
+
measure(formatValue(tick, series[0]!.dim, series[0]!.unit), TICK_SIZE),
|
|
357
|
+
),
|
|
358
|
+
);
|
|
359
|
+
const left = PAD + tickWidth + 8;
|
|
360
|
+
const right = WIDTH - PAD - Math.min(120, Math.max(...lastLabels.map(label => measure(label, TICK_SIZE))) + 10);
|
|
361
|
+
const plotTop = top + 6;
|
|
362
|
+
const plotBottom = plotTop + 180;
|
|
363
|
+
const yScale = valueScale(values, plotBottom, plotTop, { zero: false });
|
|
364
|
+
const xScale = x
|
|
365
|
+
? valueScale(x.values, left, right, { zero: false })
|
|
366
|
+
: {
|
|
367
|
+
at: (index: number) =>
|
|
368
|
+
left + (categories.length === 1 ? 0 : (index / (categories.length - 1)) * (right - left)),
|
|
369
|
+
ticks: [],
|
|
370
|
+
log: false,
|
|
371
|
+
};
|
|
372
|
+
const xAt = (index: number) => xScale.at(x ? x.values[index]! : index);
|
|
373
|
+
|
|
374
|
+
for (const tick of yScale.ticks) {
|
|
375
|
+
const y = yScale.at(tick);
|
|
376
|
+
parts.push(line(left, y, right, y, "border", 1));
|
|
377
|
+
parts.push(
|
|
378
|
+
text(left - 6, y, formatValue(tick, series[0]!.dim, series[0]!.unit), {
|
|
379
|
+
size: TICK_SIZE,
|
|
380
|
+
fill: "muted",
|
|
381
|
+
anchor: "end",
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
// Category labels left to right, skipping any that would touch the last one drawn.
|
|
386
|
+
const slot = (right - left) / Math.max(1, categories.length - 1);
|
|
387
|
+
const labelChars = Math.max(3, Math.floor(Math.max(slot, 60) / (TICK_SIZE * ADVANCE)) - 1);
|
|
388
|
+
const order = categories.map((_, at) => at).sort((a, b) => xAt(a) - xAt(b));
|
|
389
|
+
let drawnEnd = Number.NEGATIVE_INFINITY;
|
|
390
|
+
for (const at of order) {
|
|
391
|
+
const label = clip(categories[at]!, labelChars);
|
|
392
|
+
const width = measure(label, TICK_SIZE);
|
|
393
|
+
const center = Math.min(Math.max(xAt(at), left + width / 2), WIDTH - PAD - width / 2);
|
|
394
|
+
if (center - width / 2 < drawnEnd + 8) continue;
|
|
395
|
+
parts.push(text(center, plotBottom + 14, label, { size: TICK_SIZE, fill: "muted", anchor: "middle" }));
|
|
396
|
+
drawnEnd = center + width / 2;
|
|
397
|
+
}
|
|
398
|
+
series.forEach((entry, index) => {
|
|
399
|
+
const color = SERIES[index % SERIES.length]!;
|
|
400
|
+
let path = "";
|
|
401
|
+
let pen = false;
|
|
402
|
+
entry.points.forEach((point, at) => {
|
|
403
|
+
if (!point) {
|
|
404
|
+
pen = false;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
path += `${pen ? "L" : "M"}${num(xAt(at))} ${num(yScale.at(point.value))}`;
|
|
408
|
+
pen = true;
|
|
409
|
+
});
|
|
410
|
+
parts.push(`<path d="${path}" fill="none" stroke="${token(color)}" stroke-width="2" stroke-linejoin="round"/>`);
|
|
411
|
+
entry.points.forEach((point, at) => {
|
|
412
|
+
if (point) parts.push(dot(xAt(at), yScale.at(point.value), point.emphasis ? 4.5 : 3, color));
|
|
413
|
+
});
|
|
414
|
+
// The rightmost point carries the series' value label.
|
|
415
|
+
let lastIndex = -1;
|
|
416
|
+
entry.points.forEach((point, at) => {
|
|
417
|
+
if (point && (lastIndex < 0 || xAt(at) > xAt(lastIndex))) lastIndex = at;
|
|
418
|
+
});
|
|
419
|
+
const last = entry.points[lastIndex];
|
|
420
|
+
if (last) parts.push(text(xAt(lastIndex) + 8, yScale.at(last.value), last.text, { size: TICK_SIZE, fill: "fg" }));
|
|
421
|
+
});
|
|
422
|
+
return { height: plotBottom + 24, body: parts.join("") };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Scatter ──────────────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
function drawScatter(spec: ChartSpec): Layer {
|
|
428
|
+
const entry = spec.series[0]!;
|
|
429
|
+
const xs = spec.x!;
|
|
430
|
+
const ys = entry.points.map(point => point?.value ?? Number.NaN);
|
|
431
|
+
const finite = (values: readonly number[]) => values.filter(Number.isFinite);
|
|
432
|
+
const yTicks = valueScale(finite(ys), 0, 1, { zero: false }).ticks;
|
|
433
|
+
const left = PAD + Math.max(...yTicks.map(tick => measure(formatValue(tick, entry.dim, entry.unit), TICK_SIZE))) + 8;
|
|
434
|
+
const right = WIDTH - PAD - 80;
|
|
435
|
+
const top = 18;
|
|
436
|
+
const bottom = top + 200;
|
|
437
|
+
const xScale = valueScale(finite(xs.values), left, right, { zero: false });
|
|
438
|
+
const yScale = valueScale(finite(ys), bottom, top, { zero: false });
|
|
439
|
+
const parts: string[] = [text(left, 6, entry.name, { size: TICK_SIZE, fill: "muted" })];
|
|
440
|
+
for (const tick of yScale.ticks) {
|
|
441
|
+
parts.push(line(left, yScale.at(tick), right, yScale.at(tick), "border", 1));
|
|
442
|
+
parts.push(
|
|
443
|
+
text(left - 6, yScale.at(tick), formatValue(tick, entry.dim, entry.unit), {
|
|
444
|
+
size: TICK_SIZE,
|
|
445
|
+
fill: "muted",
|
|
446
|
+
anchor: "end",
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
for (const tick of xScale.ticks) {
|
|
451
|
+
parts.push(
|
|
452
|
+
text(xScale.at(tick), bottom + 14, formatValue(tick, xs.dim, xs.unit), {
|
|
453
|
+
size: TICK_SIZE,
|
|
454
|
+
fill: "muted",
|
|
455
|
+
anchor: "middle",
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
parts.push(text(right, bottom + 30, xs.name, { size: TICK_SIZE, fill: "muted", anchor: "end" }));
|
|
460
|
+
const labelled = spec.categories.length <= 15;
|
|
461
|
+
entry.points.forEach((point, at) => {
|
|
462
|
+
const xv = xs.values[at]!;
|
|
463
|
+
if (!point || !Number.isFinite(xv)) return;
|
|
464
|
+
const px = xScale.at(xv);
|
|
465
|
+
const py = yScale.at(point.value);
|
|
466
|
+
parts.push(dot(px, py, point.emphasis ? 5 : 3.5, "accent"));
|
|
467
|
+
if (labelled) parts.push(text(px + 7, py, clip(spec.categories[at]!, 14), { size: 10, fill: "muted" }));
|
|
468
|
+
});
|
|
469
|
+
return { height: bottom + 36, body: parts.join("") };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── Shared pieces ────────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* A value axis over `values` from `from` to `to`: logarithmic when every
|
|
476
|
+
* value is positive and they span {@link LOG_SPREAD}× or more, else linear on
|
|
477
|
+
* nice ticks, including zero when `zero` asks for a baseline.
|
|
478
|
+
*/
|
|
479
|
+
function valueScale(values: readonly number[], from: number, to: number, options: { zero: boolean }): Scale {
|
|
480
|
+
let min = Math.min(...values);
|
|
481
|
+
let max = Math.max(...values);
|
|
482
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
483
|
+
min = 0;
|
|
484
|
+
max = 1;
|
|
485
|
+
}
|
|
486
|
+
if (min > 0 && max / min >= LOG_SPREAD) {
|
|
487
|
+
const low = Math.floor(Math.log10(min));
|
|
488
|
+
const high = Math.ceil(Math.log10(max));
|
|
489
|
+
const ticks: number[] = [];
|
|
490
|
+
const stride = Math.max(1, Math.ceil((high - low) / 6));
|
|
491
|
+
for (let power = low; power <= high; power += stride) ticks.push(10 ** power);
|
|
492
|
+
const span = high - low || 1;
|
|
493
|
+
return {
|
|
494
|
+
at: value => from + ((Math.log10(Math.max(value, 10 ** low)) - low) / span) * (to - from),
|
|
495
|
+
ticks,
|
|
496
|
+
log: true,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (options.zero) {
|
|
500
|
+
min = Math.min(0, min);
|
|
501
|
+
max = Math.max(0, max);
|
|
502
|
+
} else if (min > 0 && min < max * 0.35) {
|
|
503
|
+
min = 0;
|
|
504
|
+
}
|
|
505
|
+
if (min === max) {
|
|
506
|
+
max = min === 0 ? 1 : min + Math.abs(min) * 0.5;
|
|
507
|
+
if (!options.zero) min -= Math.abs(min) * 0.5;
|
|
508
|
+
}
|
|
509
|
+
const step = niceStep((max - min) / 4);
|
|
510
|
+
const low = Math.floor(min / step) * step;
|
|
511
|
+
const high = Math.ceil(max / step) * step;
|
|
512
|
+
const ticks: number[] = [];
|
|
513
|
+
for (let tick = low; tick <= high + step / 2; tick += step) ticks.push(Number(tick.toPrecision(12)));
|
|
514
|
+
return { at: value => from + ((value - low) / (high - low || 1)) * (to - from), ticks, log: false };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** 1, 2, 2.5 or 5 times a power of ten, at least `raw`. */
|
|
518
|
+
function niceStep(raw: number): number {
|
|
519
|
+
const power = 10 ** Math.floor(Math.log10(raw || 1));
|
|
520
|
+
const fraction = raw / power;
|
|
521
|
+
return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10) * power;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Gridlines and tick labels under a horizontal value axis. */
|
|
525
|
+
function drawValueAxis(scale: Scale, series: ChartSeries, top: number, bottom: number): string {
|
|
526
|
+
const parts: string[] = [];
|
|
527
|
+
for (const tick of scale.ticks) {
|
|
528
|
+
const x = scale.at(tick);
|
|
529
|
+
parts.push(line(x, top, x, bottom, "border", 1));
|
|
530
|
+
parts.push(
|
|
531
|
+
text(x, bottom + 12, formatValue(tick, series.dim, series.unit), {
|
|
532
|
+
size: TICK_SIZE,
|
|
533
|
+
fill: "muted",
|
|
534
|
+
anchor: "middle",
|
|
535
|
+
}),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return parts.join("");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Swatch-and-name legend wrapping across `left`…`right`. */
|
|
542
|
+
function drawLegend(items: readonly (readonly [string, string])[], left: number, right: number): Layer {
|
|
543
|
+
const parts: string[] = [];
|
|
544
|
+
let x = left;
|
|
545
|
+
let y = 6;
|
|
546
|
+
for (const [name, color] of items) {
|
|
547
|
+
const label = clip(name, 24);
|
|
548
|
+
const width = 16 + measure(label, TICK_SIZE) + 16;
|
|
549
|
+
if (x + width > right && x > left) {
|
|
550
|
+
x = left;
|
|
551
|
+
y += 18;
|
|
552
|
+
}
|
|
553
|
+
parts.push(rect(x, y - 5, 10, 10, color, 1));
|
|
554
|
+
parts.push(text(x + 15, y, label, { size: TICK_SIZE, fill: "fg" }));
|
|
555
|
+
x += width;
|
|
556
|
+
}
|
|
557
|
+
return { height: y + 8, body: parts.join("") };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Width of the category label column. */
|
|
561
|
+
function labelColumnWidth(categories: readonly string[]): number {
|
|
562
|
+
const chars = Math.min(MAX_LABEL_CHARS, Math.max(4, ...categories.map(name => name.length)));
|
|
563
|
+
return chars * LABEL_SIZE * ADVANCE;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** A point's written figure, or its value formatted when the table wrote none. */
|
|
567
|
+
function pointText(series: ChartSeries, at: number): string {
|
|
568
|
+
const point: ChartPoint | null | undefined = series.points[at];
|
|
569
|
+
if (!point) return "";
|
|
570
|
+
return point.text || formatValue(point.value, series.dim, series.unit);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* A value in base units as an axis label: durations and sizes in the unit
|
|
575
|
+
* their magnitude reads best in, counts compacted (`12k`, `3.4M`).
|
|
576
|
+
*/
|
|
577
|
+
export function formatValue(value: number, dim: Dimension, unit: string): string {
|
|
578
|
+
switch (dim) {
|
|
579
|
+
case "percent":
|
|
580
|
+
return `${compact(value)}%`;
|
|
581
|
+
case "ratio":
|
|
582
|
+
return `${compact(value)}×`;
|
|
583
|
+
case "currency":
|
|
584
|
+
return `${value < 0 ? "-" : ""}${unit}${compact(Math.abs(value))}`;
|
|
585
|
+
case "duration": {
|
|
586
|
+
const magnitude = Math.abs(value);
|
|
587
|
+
const [divisor, suffix] =
|
|
588
|
+
magnitude === 0
|
|
589
|
+
? [1, "s"]
|
|
590
|
+
: magnitude < 1e-6
|
|
591
|
+
? [1e-9, "ns"]
|
|
592
|
+
: magnitude < 1e-3
|
|
593
|
+
? [1e-6, "µs"]
|
|
594
|
+
: magnitude < 1
|
|
595
|
+
? [1e-3, "ms"]
|
|
596
|
+
: magnitude < 120
|
|
597
|
+
? [1, "s"]
|
|
598
|
+
: magnitude < 7200
|
|
599
|
+
? [60, "m"]
|
|
600
|
+
: magnitude < 172_800
|
|
601
|
+
? [3600, "h"]
|
|
602
|
+
: [86_400, "d"];
|
|
603
|
+
return `${compact(value / divisor)}${suffix}`;
|
|
604
|
+
}
|
|
605
|
+
case "bytes": {
|
|
606
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
607
|
+
let scaled = value;
|
|
608
|
+
let index = 0;
|
|
609
|
+
while (Math.abs(scaled) >= 1000 && index < units.length - 1) {
|
|
610
|
+
scaled /= 1000;
|
|
611
|
+
index++;
|
|
612
|
+
}
|
|
613
|
+
return `${compact(scaled)}${units[index]}`;
|
|
614
|
+
}
|
|
615
|
+
default:
|
|
616
|
+
return compact(value);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** `1234567` → `1.2M`, `0.0123` → `0.012`. */
|
|
621
|
+
function compact(value: number): string {
|
|
622
|
+
const magnitude = Math.abs(value);
|
|
623
|
+
if (magnitude >= 1e9) return `${trim(value / 1e9)}B`;
|
|
624
|
+
if (magnitude >= 1e6) return `${trim(value / 1e6)}M`;
|
|
625
|
+
if (magnitude >= 1e4) return `${trim(value / 1e3)}k`;
|
|
626
|
+
return trim(value);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function trim(value: number): string {
|
|
630
|
+
const magnitude = Math.abs(value);
|
|
631
|
+
const digits = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : magnitude >= 1 ? 2 : 3;
|
|
632
|
+
return Number(value.toFixed(digits)).toString();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function measure(content: string, size: number): number {
|
|
636
|
+
return content.length * size * ADVANCE;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function clip(content: string, chars: number): string {
|
|
640
|
+
return content.length <= chars ? content : `${content.slice(0, Math.max(1, chars - 1))}…`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function token(name: string): string {
|
|
644
|
+
return `var(--${name})`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function num(value: number): string {
|
|
648
|
+
return Number(value.toFixed(1)).toString();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
interface TextStyle {
|
|
652
|
+
size?: number;
|
|
653
|
+
fill: string;
|
|
654
|
+
anchor?: "start" | "middle" | "end";
|
|
655
|
+
weight?: number;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Text vertically centred on `y`. */
|
|
659
|
+
function text(x: number, y: number, content: string, style: TextStyle): string {
|
|
660
|
+
if (!content) return "";
|
|
661
|
+
const size = style.size ?? LABEL_SIZE;
|
|
662
|
+
const attrs = [
|
|
663
|
+
`x="${num(x)}"`,
|
|
664
|
+
`y="${num(y + size * 0.35)}"`,
|
|
665
|
+
`fill="${token(style.fill)}"`,
|
|
666
|
+
size === LABEL_SIZE ? "" : `font-size="${size}"`,
|
|
667
|
+
style.anchor && style.anchor !== "start" ? `text-anchor="${style.anchor}"` : "",
|
|
668
|
+
style.weight ? `font-weight="${style.weight}"` : "",
|
|
669
|
+
].filter(Boolean);
|
|
670
|
+
const escaped = content.replace(/[&<>"]/g, char =>
|
|
671
|
+
char === "&" ? "&" : char === "<" ? "<" : char === ">" ? ">" : """,
|
|
672
|
+
);
|
|
673
|
+
return `<text ${attrs.join(" ")}>${escaped}</text>`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function rect(x: number, y: number, width: number, height: number, fill: string, opacity: number): string {
|
|
677
|
+
const alpha = opacity < 1 ? ` fill-opacity="${Number(opacity.toFixed(2))}"` : "";
|
|
678
|
+
return `<rect x="${num(x)}" y="${num(y)}" width="${num(Math.max(0, width))}" height="${num(height)}" fill="${token(fill)}"${alpha}/>`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function line(x1: number, y1: number, x2: number, y2: number, stroke: string, width: number): string {
|
|
682
|
+
return `<line x1="${num(x1)}" y1="${num(y1)}" x2="${num(x2)}" y2="${num(y2)}" stroke="${token(stroke)}" stroke-width="${width}"/>`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function dot(x: number, y: number, radius: number, fill: string, opacity = 1): string {
|
|
686
|
+
const alpha = opacity < 1 ? ` fill-opacity="${Number(opacity.toFixed(2))}"` : "";
|
|
687
|
+
return `<circle cx="${num(x)}" cy="${num(y)}" r="${radius}" fill="${token(fill)}"${alpha}/>`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Range whisker from a value to its upper bound, capped at both ends. */
|
|
691
|
+
function whisker(from: number, to: number, y: number, stroke: string): string {
|
|
692
|
+
return `${line(from, y, to, y, stroke, 1.5)}${line(to, y - 4, to, y + 4, stroke, 1.5)}`;
|
|
693
|
+
}
|