@jarenjs/charts 0.34.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.
Files changed (56) hide show
  1. package/README.md +293 -0
  2. package/dist/types/component/index.d.ts +95 -0
  3. package/dist/types/core/axis.d.ts +77 -0
  4. package/dist/types/core/cartesian.d.ts +127 -0
  5. package/dist/types/core/chart.d.ts +58 -0
  6. package/dist/types/core/domain.d.ts +96 -0
  7. package/dist/types/core/marks.d.ts +86 -0
  8. package/dist/types/core/palette.d.ts +72 -0
  9. package/dist/types/core/scale.d.ts +59 -0
  10. package/dist/types/core/session.d.ts +85 -0
  11. package/dist/types/core/stream-adapter.d.ts +187 -0
  12. package/dist/types/index.d.ts +35 -0
  13. package/dist/types/transforms/benchmark-adapter.d.ts +169 -0
  14. package/dist/types/transforms/mermaid-adapter.d.ts +30 -0
  15. package/dist/types/types/bar.d.ts +213 -0
  16. package/dist/types/types/boxplot.d.ts +116 -0
  17. package/dist/types/types/candlestick.d.ts +218 -0
  18. package/dist/types/types/gauge.d.ts +68 -0
  19. package/dist/types/types/heatmap.d.ts +104 -0
  20. package/dist/types/types/line.d.ts +272 -0
  21. package/dist/types/types/map.d.ts +137 -0
  22. package/dist/types/types/pie.d.ts +146 -0
  23. package/dist/types/types/radar.d.ts +89 -0
  24. package/dist/types/types/sankey.d.ts +100 -0
  25. package/dist/types/types/scatter.d.ts +80 -0
  26. package/dist/types/types/streamgraph.d.ts +75 -0
  27. package/dist/types/types/treemap.d.ts +118 -0
  28. package/package.json +76 -0
  29. package/schemas/chart-definition.schema.json +448 -0
  30. package/src/component/index.js +125 -0
  31. package/src/core/axis.js +221 -0
  32. package/src/core/cartesian.js +192 -0
  33. package/src/core/chart.js +101 -0
  34. package/src/core/domain.js +123 -0
  35. package/src/core/marks.js +110 -0
  36. package/src/core/palette.js +126 -0
  37. package/src/core/scale.js +106 -0
  38. package/src/core/session.js +0 -0
  39. package/src/core/stream-adapter.js +613 -0
  40. package/src/index.js +40 -0
  41. package/src/transforms/benchmark-adapter.js +298 -0
  42. package/src/transforms/mermaid-adapter.js +19 -0
  43. package/src/types/bar.js +276 -0
  44. package/src/types/boxplot.js +216 -0
  45. package/src/types/candlestick.js +274 -0
  46. package/src/types/gauge.js +140 -0
  47. package/src/types/heatmap.js +176 -0
  48. package/src/types/line.js +349 -0
  49. package/src/types/map.js +378 -0
  50. package/src/types/pie.js +163 -0
  51. package/src/types/radar.js +224 -0
  52. package/src/types/sankey.js +391 -0
  53. package/src/types/scatter.js +148 -0
  54. package/src/types/streamgraph.js +158 -0
  55. package/src/types/treemap.js +322 -0
  56. package/styles/charts.css +83 -0
@@ -0,0 +1,176 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The heatmap chart type: a category × category matrix of
4
+ * magnitudes on the sequential blue ramp — the alternative reading of
5
+ * the benchmark scenario matrices that ship as grouped bars. Data
6
+ * shape:
7
+ *
8
+ * data = { xLabels: string[], yLabels: string[],
9
+ * values: number[][] } // values[yi][xi], row-major
10
+ * config = { type:'heatmap', title?, xLabel?, yLabel?, log? }
11
+ *
12
+ * The AST is unit-space cell bands plus a normalized magnitude `t` per
13
+ * cell; the color (the sequential ramp), the cell inset and the ramp
14
+ * legend are render decisions. Rows read top-down: `yLabels[0]` is the
15
+ * top row. Non-finite cells (and non-positive ones under `log`) are
16
+ * simply absent — the surface shows through, which is the honest
17
+ * rendering of "no measurement".
18
+ */
19
+
20
+ import { svgRoot, coord } from '@jarenjs/view/helpers';
21
+ import { clamp01 } from '@jarenjs/core/math';
22
+ import { formatTickValue } from '../core/axis.js';
23
+ import { cartesianFrame, annotateChart } from '../core/cartesian.js';
24
+ import { SEQUENTIAL, sequentialColor } from '../core/palette.js';
25
+ import { normalizeTooltip, valueMark } from '../core/marks.js';
26
+
27
+ /**
28
+ * @typedef {object} HeatCellAST
29
+ * @property {number} xi @property {number} yi
30
+ * @property {number} u0 @property {number} u1
31
+ * @property {number} v0 @property {number} v1
32
+ * @property {number} t normalized magnitude (0..1)
33
+ * @property {number} value
34
+ */
35
+ /**
36
+ * @typedef {object} HeatmapAST
37
+ * @property {'heatmap'} type
38
+ * @property {string|null} title
39
+ * @property {string[]} xLabels @property {string[]} yLabels
40
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} x
41
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} y
42
+ * @property {HeatCellAST[]} cells
43
+ * @property {{min: number, max: number}|null} domain finite-value extent (null = no data)
44
+ */
45
+
46
+ /**
47
+ * Build the geometry-free heatmap AST.
48
+ * @param {any} data
49
+ * @param {any} [config]
50
+ * @returns {HeatmapAST}
51
+ */
52
+ export function buildHeatmapAST(data, config = {}) {
53
+ const xLabels = (data?.xLabels ?? []).map((l) => String(l));
54
+ const yLabels = (data?.yLabels ?? []).map((l) => String(l));
55
+ const values = Array.isArray(data?.values) ? data.values : [];
56
+ const log = config.log === true;
57
+
58
+ let min = Infinity;
59
+ let max = -Infinity;
60
+ for (let yi = 0; yi < yLabels.length; yi++) {
61
+ const row = values[yi];
62
+ if (!Array.isArray(row)) continue;
63
+ for (let xi = 0; xi < xLabels.length; xi++) {
64
+ const v = row[xi];
65
+ if (typeof v !== 'number' || !Number.isFinite(v)) continue;
66
+ if (log && v <= 0) continue;
67
+ if (v < min) min = v;
68
+ if (v > max) max = v;
69
+ }
70
+ }
71
+ const domain = Number.isFinite(min) ? { min, max } : null;
72
+ const span = domain === null ? 0
73
+ : log ? Math.log10(max) - Math.log10(min)
74
+ : max - min;
75
+
76
+ const xStep = xLabels.length === 0 ? 1 : 1 / xLabels.length;
77
+ const yStep = yLabels.length === 0 ? 1 : 1 / yLabels.length;
78
+ const cells = [];
79
+ if (domain !== null) {
80
+ for (let yi = 0; yi < yLabels.length; yi++) {
81
+ const row = values[yi];
82
+ if (!Array.isArray(row)) continue;
83
+ for (let xi = 0; xi < xLabels.length; xi++) {
84
+ const v = row[xi];
85
+ if (typeof v !== 'number' || !Number.isFinite(v)) continue;
86
+ if (log && v <= 0) continue;
87
+ const t = span === 0 ? 0.5
88
+ : log ? (Math.log10(v) - Math.log10(min)) / span
89
+ : (v - min) / span;
90
+ cells.push({
91
+ xi, yi,
92
+ u0: xi * xStep,
93
+ u1: (xi + 1) * xStep,
94
+ // yLabels[0] is the top row; v = 0 sits at the plot bottom.
95
+ v0: 1 - (yi + 1) * yStep,
96
+ v1: 1 - yi * yStep,
97
+ t: clamp01(t),
98
+ value: v,
99
+ });
100
+ }
101
+ }
102
+ }
103
+
104
+ return {
105
+ type: 'heatmap',
106
+ title: config.title ?? null,
107
+ xLabels,
108
+ yLabels,
109
+ x: {
110
+ ticks: xLabels.map((label, xi) => ({ pos: (xi + 0.5) * xStep, label })),
111
+ label: config.xLabel ?? null,
112
+ },
113
+ y: {
114
+ ticks: yLabels.map((label, yi) => ({ pos: 1 - (yi + 0.5) * yStep, label })),
115
+ label: config.yLabel ?? null,
116
+ },
117
+ cells,
118
+ domain,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Render a heatmap AST to a pure-vnode SVG: inset cell rects colored by
124
+ * the sequential ramp, a labeled min→max ramp key in the legend slot,
125
+ * and a `<title>` per cell with the exact value.
126
+ * @param {HeatmapAST} ast
127
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
128
+ * @param {string} hash
129
+ * @param {{rootClass?: string, keyPrefix?: string, ramp?: readonly string[], width?: number,
130
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
131
+ * @returns {any}
132
+ */
133
+ export function renderHeatmapAST(ast, theme, hash, options = {}) {
134
+ const ramp = options.ramp ?? SEQUENTIAL;
135
+ const tooltip = normalizeTooltip(options.tooltip);
136
+ // The ramp key rides the frame's legend mechanism: five sequential
137
+ // stops as swatches, the extent values as the end labels.
138
+ const rampStops = [0, 0.25, 0.5, 0.75, 1].map((t) => sequentialColor(t, ramp));
139
+ const legend = ast.domain === null ? null : rampStops.map((_, i) => ({
140
+ name: i === 0 ? formatTickValue(ast.domain.min)
141
+ : i === rampStops.length - 1 ? formatTickValue(ast.domain.max) : '',
142
+ swatch: i,
143
+ }));
144
+ const frame = cartesianFrame({
145
+ title: ast.title,
146
+ legend,
147
+ xAxis: ast.x,
148
+ yAxis: ast.y,
149
+ grid: 'none',
150
+ width: options.width,
151
+ plotHeight: Math.max(80, ast.yLabels.length * 26),
152
+ palette: rampStops,
153
+ theme,
154
+ });
155
+ const { plot } = frame;
156
+ const children = frame.children;
157
+ const inset = 1;
158
+ for (const cell of ast.cells) {
159
+ const x = plot.x + cell.u0 * plot.w + inset;
160
+ const y = plot.y + (1 - cell.v1) * plot.h + inset;
161
+ const w = (cell.u1 - cell.u0) * plot.w - 2 * inset;
162
+ const h = (cell.v1 - cell.v0) * plot.h - 2 * inset;
163
+ children.push(valueMark('rect', {
164
+ x: coord(x), y: coord(y),
165
+ width: coord(Math.max(0.5, w)), height: coord(Math.max(0.5, h)),
166
+ fill: sequentialColor(cell.t, ramp), class: 'chart-heat-cell',
167
+ }, tooltip,
168
+ `${ast.xLabels[cell.xi]} × ${ast.yLabels[cell.yi]}: ${formatTickValue(cell.value)}`,
169
+ {
170
+ type: 'heatmap', x: ast.xLabels[cell.xi], y: ast.yLabels[cell.yi], value: cell.value,
171
+ }));
172
+ }
173
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-heatmap-chart',
174
+ frame.width, frame.height, theme, children, (options.keyPrefix ?? 'heat-') + hash);
175
+ return annotateChart(svg, ast.title);
176
+ }
@@ -0,0 +1,349 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The line chart type: multi-series polylines over a linear or
4
+ * time x axis, linear or log y axis, optional point markers. This is
5
+ * the streaming-critical type — a live feed re-renders it per snapshot
6
+ * — so build and render stay allocation-light (one pass per series,
7
+ * `polylinePath` breaks the line on unplottable samples instead of
8
+ * filtering arrays). Data shape:
9
+ *
10
+ * data = { series: [{ name, points: [{x, y}] }] }
11
+ * config = { type:'line', title?, x?: 'linear'|'time', log?,
12
+ * markers?, xLabel?, yLabel?, domain? }
13
+ *
14
+ * `config.domain` declares a domain-stability policy (`core/domain.js`)
15
+ * so most streaming ticks keep the scales still: a quantized sliding
16
+ * `x` window (samples older than it become null vertices), pinned or
17
+ * step-quantized `y` bounds. The AST records the resolved domain so a
18
+ * later build — or the incremental session — can detect "unchanged".
19
+ *
20
+ * The extremes scan, domain resolution and scale construction are
21
+ * exported (`scanLineExtremes` / `resolveLineDomains` / `lineScales`)
22
+ * because the incremental session must make the SAME decisions from
23
+ * the same numbers — one implementation, no drift. Each series
24
+ * renders as one `<g class="chart-series">` (path, then marker dots),
25
+ * so a session — and the view patcher — can treat a series as one
26
+ * replaceable unit; `buildLineRender` is the render variant that also
27
+ * returns that per-series geometry.
28
+ */
29
+
30
+ import { svgRoot, path as svgPath, circle, polylinePath, coord } from '@jarenjs/view/helpers';
31
+ import { clamp01 } from '@jarenjs/core/math';
32
+ import { scaleLinear, scaleLog, scaleTime } from '../core/scale.js';
33
+ import {
34
+ axisTicksLinear, axisTicksLog, axisTicksTime, niceTimeStep,
35
+ formatTickValue, formatTimeTick,
36
+ } from '../core/axis.js';
37
+ import { cartesianFrame, annotateChart } from '../core/cartesian.js';
38
+ import { numOf } from '../core/stream-adapter.js';
39
+ import {
40
+ normalizeDomainPolicy, resolveWindowX, resolveStepY, resolveStepYLog, resolvePinnedY,
41
+ } from '../core/domain.js';
42
+ import { CATEGORICAL, seriesColor } from '../core/palette.js';
43
+ import { normalizeTooltip, markProps } from '../core/marks.js';
44
+
45
+ /**
46
+ * @typedef {object} LineAST
47
+ * @property {'line'} type
48
+ * @property {string|null} title
49
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} x
50
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} y
51
+ * @property {{x: [number, number], y: [number, number]}} domain resolved scale bounds
52
+ * @property {{name: string, swatch: number}[]|null} legend
53
+ * @property {{name: string, points: ({u:number,v:number}|null)[]}[]} series
54
+ * @property {boolean} markers
55
+ */
56
+
57
+ /**
58
+ * Scan the data extremes the domain resolution needs: raw x bounds
59
+ * over every finite sample, y bounds over the samples a window keeps
60
+ * (windowed-out samples must not pin the value axis). Under `log`,
61
+ * non-positive y values never join the y extremes (they still extend
62
+ * x, as unplottable vertices on a real time axis do).
63
+ * @param {{points: any[]}[]} input series with array points
64
+ * @param {import('../core/domain.js').DomainPolicy} policy
65
+ * @param {boolean} log
66
+ * @returns {{x0:number, x1:number, y0:number, y1:number}}
67
+ */
68
+ export function scanLineExtremes(input, policy, log) {
69
+ let x0 = Infinity;
70
+ let x1 = -Infinity;
71
+ let y0 = Infinity;
72
+ let y1 = -Infinity;
73
+ for (const s of input) {
74
+ for (const p of s.points) {
75
+ const px = numOf(p?.x);
76
+ const py = numOf(p?.y);
77
+ if (!Number.isFinite(px) || !Number.isFinite(py)) continue;
78
+ if (px < x0) x0 = px;
79
+ if (px > x1) x1 = px;
80
+ if (log && py <= 0) continue;
81
+ if (py < y0) y0 = py;
82
+ if (py > y1) y1 = py;
83
+ }
84
+ }
85
+ if (policy.window !== null) {
86
+ const [wLo] = resolveWindowX(x1, policy.window, policy.slide);
87
+ y0 = Infinity;
88
+ y1 = -Infinity;
89
+ for (const s of input) {
90
+ for (const p of s.points) {
91
+ const px = numOf(p?.x);
92
+ const py = numOf(p?.y);
93
+ if (!Number.isFinite(px) || !Number.isFinite(py) || px < wLo) continue;
94
+ if (log && py <= 0) continue;
95
+ if (py < y0) y0 = py;
96
+ if (py > y1) y1 = py;
97
+ }
98
+ }
99
+ }
100
+ return { x0, x1, y0, y1 };
101
+ }
102
+
103
+ /**
104
+ * Resolve the scale domains (and their tick values) from the scanned
105
+ * extremes under the domain policy — the single place the line type
106
+ * decides its bounds; the incremental session compares the result
107
+ * against the AST's recorded domain to detect a still frame.
108
+ * @param {{x0:number, x1:number, y0:number, y1:number}} ext
109
+ * @param {import('../core/domain.js').DomainPolicy} policy
110
+ * @param {boolean} time
111
+ * @param {boolean} log
112
+ * @returns {{x: [number,number], y: [number,number], xDrop: number|null,
113
+ * xTickValues: number[], yTickValues: number[]}}
114
+ */
115
+ export function resolveLineDomains(ext, policy, time, log) {
116
+ let { x0, x1, y0, y1 } = ext;
117
+ let xDrop = null;
118
+ if (policy.window !== null) {
119
+ const [wLo, wHi] = resolveWindowX(x1, policy.window, policy.slide);
120
+ xDrop = wLo;
121
+ x0 = wLo;
122
+ x1 = wHi;
123
+ }
124
+ if (!Number.isFinite(x0)) { x0 = 0; x1 = 1; }
125
+ if (!Number.isFinite(y0)) { y0 = log ? 0.1 : 0; y1 = log ? 1 : 1; }
126
+
127
+ let yTickValues;
128
+ let yLo;
129
+ let yHi;
130
+ if (log) {
131
+ if (policy.step) [yLo, yHi] = resolveStepYLog(y0, y1);
132
+ else if (policy.pin !== null) [yLo, yHi] = resolvePinnedY(y0, y1, policy.pin, true);
133
+ else { yLo = y0; yHi = y1; }
134
+ if (yHi === yLo) yHi = yLo * 10;
135
+ yTickValues = axisTicksLog(yLo, yHi);
136
+ }
137
+ else if (policy.step || policy.pin !== null) {
138
+ [yLo, yHi] = policy.step ? resolveStepY(y0, y1) : resolvePinnedY(y0, y1, policy.pin, false);
139
+ if (yHi === yLo) yHi = yLo + 1;
140
+ yTickValues = axisTicksLinear(yLo, yHi, 5);
141
+ }
142
+ else {
143
+ yTickValues = axisTicksLinear(y0, y1, 5);
144
+ const lo = Math.min(y0, yTickValues[0] ?? y0);
145
+ const hi = Math.max(y1, yTickValues[yTickValues.length - 1] ?? y1);
146
+ yLo = lo;
147
+ yHi = hi === lo ? lo + 1 : hi;
148
+ }
149
+ return {
150
+ x: [x0, x1],
151
+ y: [yLo, yHi],
152
+ xDrop,
153
+ xTickValues: time ? axisTicksTime(x0, x1, 4) : axisTicksLinear(x0, x1, 5),
154
+ // the step the ticks were chosen on, so the labels can match it
155
+ xTickStep: time ? niceTimeStep(x1 - x0, 4) : null,
156
+ yTickValues,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Unit scales over a resolved domain — the same closures the build
162
+ * uses, reconstructable by the session from the AST's domain alone.
163
+ * @param {{x: [number,number], y: [number,number]}} domains
164
+ * @param {boolean} time
165
+ * @param {boolean} log
166
+ * @returns {{xScale: (v:number)=>number, yScale: (v:number)=>number}}
167
+ */
168
+ export function lineScales(domains, time, log) {
169
+ return {
170
+ xScale: time ? scaleTime(domains.x[0], domains.x[1]) : scaleLinear(domains.x[0], domains.x[1]),
171
+ yScale: log ? scaleLog(domains.y[0], domains.y[1]) : scaleLinear(domains.y[0], domains.y[1]),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Map one sample onto a unit vertex (or null for an unplottable one) —
177
+ * the per-point half of the build, shared with the session.
178
+ * @param {any} p the `{x, y}` sample
179
+ * @param {number|null} xDrop window low bound (drop older samples)
180
+ * @param {(v:number)=>number} xScale @param {(v:number)=>number} yScale
181
+ * @returns {{u:number, v:number}|null}
182
+ */
183
+ export function lineVertex(p, xDrop, xScale, yScale) {
184
+ const px = numOf(p?.x);
185
+ const py = numOf(p?.y);
186
+ if (!Number.isFinite(px) || !Number.isFinite(py)) return null;
187
+ if (xDrop !== null && px < xDrop) return null;
188
+ const v = yScale(py);
189
+ return Number.isFinite(v) ? { u: xScale(px), v: clamp01(v) } : null;
190
+ }
191
+
192
+ /**
193
+ * Build the geometry-free line AST.
194
+ * @param {any} data
195
+ * @param {any} [config]
196
+ * @returns {LineAST}
197
+ */
198
+ export function buildLineAST(data, config = {}) {
199
+ const input = (data?.series ?? []).filter((s) => Array.isArray(s.points));
200
+ const time = config.x === 'time';
201
+ const log = config.log === true;
202
+ const policy = normalizeDomainPolicy(config.domain);
203
+ const domains = resolveLineDomains(scanLineExtremes(input, policy, log), policy, time, log);
204
+ const { xScale, yScale } = lineScales(domains, time, log);
205
+
206
+ const series = input.map((s) => ({
207
+ name: String(s.name ?? ''),
208
+ points: s.points.map((p) => lineVertex(p, domains.xDrop, xScale, yScale)),
209
+ }));
210
+
211
+ return {
212
+ type: 'line',
213
+ title: config.title ?? null,
214
+ x: {
215
+ ticks: domains.xTickValues.map((v) => ({
216
+ pos: clamp01(xScale(v)),
217
+ label: time ? formatTimeTick(v, domains.xTickStep) : formatTickValue(v),
218
+ })),
219
+ label: config.xLabel ?? null,
220
+ },
221
+ y: {
222
+ ticks: domains.yTickValues.map((v) => ({ pos: clamp01(yScale(v)), label: formatTickValue(v) })),
223
+ label: config.yLabel ?? null,
224
+ },
225
+ domain: { x: domains.x, y: domains.y },
226
+ legend: series.length > 1 ? series.map((s, i) => ({ name: s.name, swatch: i })) : null,
227
+ series,
228
+ markers: config.markers === true,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * @typedef {object} LineSeriesRender
234
+ * @property {any} group the series' `<g>` vnode
235
+ * @property {string} d the polyline path data
236
+ * @property {boolean} pen true when the last vertex was drawable (the
237
+ * next appended token is an `L`, not an `M`)
238
+ * @property {any[]} dots marker circle vnodes (empty when markers off)
239
+ * @property {string} color the series color
240
+ * @property {string} name the series name (its hover text)
241
+ */
242
+
243
+ /**
244
+ * The children of one series `<g>`: the hover `<title>`, the polyline
245
+ * path (when it has one), then the marker dots. The series — not the
246
+ * individual sample — is the value mark here: a live line carries tens
247
+ * of thousands of vertices, and a `<title>` per vertex would put a
248
+ * label allocation on the streaming path for text no reader can aim at.
249
+ * Shared with the incremental session, which rebuilds these children in
250
+ * place, so the two cannot drift.
251
+ * @param {string} name @param {string} d @param {any[]} dots @param {string} color
252
+ * @returns {any[]}
253
+ */
254
+ export function lineSeriesChildren(name, d, dots, color) {
255
+ const children = [];
256
+ if (name !== '') children.push(['title', {}, name]);
257
+ if (d !== '')
258
+ children.push(svgPath(d, { stroke: color, 'stroke-width': 2, fill: 'none', class: 'chart-line' }));
259
+ children.push(...dots);
260
+ return children;
261
+ }
262
+
263
+ /**
264
+ * Render one series as its `<g>` group.
265
+ * @param {{name?: string, points: ({u:number,v:number}|null)[]}} s
266
+ * @param {number} si series index
267
+ * @param {{x:number,y:number,w:number,h:number}} plot
268
+ * @param {readonly string[]} palette
269
+ * @param {boolean} markers
270
+ * @param {import('../core/marks.js').ChartTooltip|null} [tooltip]
271
+ * @returns {LineSeriesRender}
272
+ */
273
+ export function lineSeriesRender(s, si, plot, palette, markers, tooltip = null) {
274
+ const color = seriesColor(si, palette);
275
+ const name = String(s.name ?? '');
276
+ const pixels = s.points.map((p) => p === null ? null : {
277
+ x: coord(plot.x + p.u * plot.w),
278
+ y: coord(plot.y + (1 - p.v) * plot.h),
279
+ });
280
+ const d = polylinePath(pixels);
281
+ const dots = [];
282
+ if (markers) {
283
+ for (const p of pixels) {
284
+ if (p !== null) dots.push(circle(p.x, p.y, 2.5, { fill: color, class: 'chart-dot' }));
285
+ }
286
+ }
287
+ const props = markProps({ key: `ls${si}`, class: 'chart-series' },
288
+ tooltip, name, { type: 'line', series: name });
289
+ return {
290
+ group: ['g', props, ...lineSeriesChildren(name, d, dots, color)],
291
+ d,
292
+ pen: s.points.length !== 0 && s.points[s.points.length - 1] !== null,
293
+ dots,
294
+ color,
295
+ name,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Render a line AST and return the svg WITH the geometry a session
301
+ * needs to patch it incrementally: the plot rect, how many chrome
302
+ * children precede the series groups, and each series' render parts.
303
+ * @param {LineAST} ast
304
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
305
+ * @param {string} hash
306
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[], width?: number,
307
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
308
+ * @returns {{svg: any, plot: {x:number,y:number,w:number,h:number},
309
+ * chromeLen: number, series: LineSeriesRender[]}}
310
+ */
311
+ export function buildLineRender(ast, theme, hash, options = {}) {
312
+ const palette = options.palette ?? CATEGORICAL;
313
+ const tooltip = normalizeTooltip(options.tooltip);
314
+ const frame = cartesianFrame({
315
+ title: ast.title,
316
+ legend: ast.legend,
317
+ xAxis: ast.x,
318
+ yAxis: ast.y,
319
+ grid: 'y',
320
+ width: options.width,
321
+ palette,
322
+ theme,
323
+ });
324
+ const { plot } = frame;
325
+ const chromeLen = frame.children.length;
326
+ const children = frame.children;
327
+ const series = [];
328
+ for (let si = 0; si < ast.series.length; si++) {
329
+ const parts = lineSeriesRender(ast.series[si], si, plot, palette, ast.markers, tooltip);
330
+ series.push(parts);
331
+ children.push(parts.group);
332
+ }
333
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-line-chart',
334
+ frame.width, frame.height, theme, children, (options.keyPrefix ?? 'line-') + hash);
335
+ annotateChart(svg, ast.title);
336
+ return { svg, plot, chromeLen, series };
337
+ }
338
+
339
+ /**
340
+ * Render a line AST to a pure-vnode SVG.
341
+ * @param {LineAST} ast
342
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
343
+ * @param {string} hash
344
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[], width?: number}} [options]
345
+ * @returns {any}
346
+ */
347
+ export function renderLineAST(ast, theme, hash, options = {}) {
348
+ return buildLineRender(ast, theme, hash, options).svg;
349
+ }