@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.
- package/README.md +293 -0
- package/dist/types/component/index.d.ts +95 -0
- package/dist/types/core/axis.d.ts +77 -0
- package/dist/types/core/cartesian.d.ts +127 -0
- package/dist/types/core/chart.d.ts +58 -0
- package/dist/types/core/domain.d.ts +96 -0
- package/dist/types/core/marks.d.ts +86 -0
- package/dist/types/core/palette.d.ts +72 -0
- package/dist/types/core/scale.d.ts +59 -0
- package/dist/types/core/session.d.ts +85 -0
- package/dist/types/core/stream-adapter.d.ts +187 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/transforms/benchmark-adapter.d.ts +169 -0
- package/dist/types/transforms/mermaid-adapter.d.ts +30 -0
- package/dist/types/types/bar.d.ts +213 -0
- package/dist/types/types/boxplot.d.ts +116 -0
- package/dist/types/types/candlestick.d.ts +218 -0
- package/dist/types/types/gauge.d.ts +68 -0
- package/dist/types/types/heatmap.d.ts +104 -0
- package/dist/types/types/line.d.ts +272 -0
- package/dist/types/types/map.d.ts +137 -0
- package/dist/types/types/pie.d.ts +146 -0
- package/dist/types/types/radar.d.ts +89 -0
- package/dist/types/types/sankey.d.ts +100 -0
- package/dist/types/types/scatter.d.ts +80 -0
- package/dist/types/types/streamgraph.d.ts +75 -0
- package/dist/types/types/treemap.d.ts +118 -0
- package/package.json +76 -0
- package/schemas/chart-definition.schema.json +448 -0
- package/src/component/index.js +125 -0
- package/src/core/axis.js +221 -0
- package/src/core/cartesian.js +192 -0
- package/src/core/chart.js +101 -0
- package/src/core/domain.js +123 -0
- package/src/core/marks.js +110 -0
- package/src/core/palette.js +126 -0
- package/src/core/scale.js +106 -0
- package/src/core/session.js +0 -0
- package/src/core/stream-adapter.js +613 -0
- package/src/index.js +40 -0
- package/src/transforms/benchmark-adapter.js +298 -0
- package/src/transforms/mermaid-adapter.js +19 -0
- package/src/types/bar.js +276 -0
- package/src/types/boxplot.js +216 -0
- package/src/types/candlestick.js +274 -0
- package/src/types/gauge.js +140 -0
- package/src/types/heatmap.js +176 -0
- package/src/types/line.js +349 -0
- package/src/types/map.js +378 -0
- package/src/types/pie.js +163 -0
- package/src/types/radar.js +224 -0
- package/src/types/sankey.js +391 -0
- package/src/types/scatter.js +148 -0
- package/src/types/streamgraph.js +158 -0
- package/src/types/treemap.js +322 -0
- 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
|
+
}
|