@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,613 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The streaming adapter: unified reader events in, chart data
|
|
4
|
+
* out. Source-agnostic by design — it consumes the `pair` event shape
|
|
5
|
+
* that the JOSL reader (`createStreamReader`) and the JSONX/strict-JSON
|
|
6
|
+
* reader (`createJsonxStreamReader`) both emit, so one adapter serves
|
|
7
|
+
* every syntax and this package never depends on a parser (it sees
|
|
8
|
+
* events, never a reader; pair it with `@jarenjs/josl` at the call
|
|
9
|
+
* site).
|
|
10
|
+
*
|
|
11
|
+
* Two record-boundary modes cover the two streaming shapes:
|
|
12
|
+
*
|
|
13
|
+
* - `recordBoundary: 'path'` — one large document arriving in chunks;
|
|
14
|
+
* a record is the subtree at `recordPath + [index]` (a JOSL `[[run]]`
|
|
15
|
+
* array-of-tables and a JSON `{"run": […]}` array produce the same
|
|
16
|
+
* pair paths — that is the point of the unified events), and closes
|
|
17
|
+
* when the event path leaves it.
|
|
18
|
+
* - `recordBoundary: 'document'` — many small complete documents over
|
|
19
|
+
* time (e.g. one WebSocket message each); every document IS one
|
|
20
|
+
* record — the fields directly under `recordPath` (default: the
|
|
21
|
+
* document root) — closed by an `endDocument()` call after the
|
|
22
|
+
* reader's `end()`. A message that fails mid-parse is discarded with
|
|
23
|
+
* `abortDocument()`, leaving the accumulated snapshot untouched.
|
|
24
|
+
*
|
|
25
|
+
* Accumulators: `line` (records → per-series points, ring-buffer
|
|
26
|
+
* eviction), `bar` (live category counts or sums), `heatmap` (the same
|
|
27
|
+
* counts or sums under TWO grouping keys — the column is `xField`, the
|
|
28
|
+
* row `seriesField`), `gauge` (the latest reading, nothing kept),
|
|
29
|
+
* `candlestick` (records keyed by open time; a re-delivered key
|
|
30
|
+
* REPLACES its candle, which is exactly how exchange kline updates
|
|
31
|
+
* behave), and `map` (whole GeoJSON Features, projected and simplified
|
|
32
|
+
* on arrival — see below).
|
|
33
|
+
*
|
|
34
|
+
* The `map` accumulator is the odd one out in two ways. Its record is
|
|
35
|
+
* not flat pair fields but a complete Feature, so it consumes the
|
|
36
|
+
* reader's `object-end` events at `recordPath + [index]` (default
|
|
37
|
+
* `['features']`) — pair the reader with `detach: ['features', '*']` so
|
|
38
|
+
* the document root keeps nothing and the accumulator is the only
|
|
39
|
+
* retention. And what it keeps is *reduced*: each feature's geometry is
|
|
40
|
+
* simplified on arrival to the vertices a drawing of the current extent
|
|
41
|
+
* could distinguish, so memory is bounded by the drawn detail, not the
|
|
42
|
+
* source detail. The projection cannot be fitted before the last
|
|
43
|
+
* feature has been seen, so the design is refit-on-growth: the running
|
|
44
|
+
* bbox sets the simplification tolerance, and when it grows enough to
|
|
45
|
+
* double the tolerance, the kept features are re-simplified against the
|
|
46
|
+
* new extent (a coarsening of already-kept vertices — never a re-read
|
|
47
|
+
* of dropped ones, which is why early features can only ever be finer
|
|
48
|
+
* than needed, not wrong).
|
|
49
|
+
*
|
|
50
|
+
* A record is emitted into the snapshot only when its required fields
|
|
51
|
+
* are present; `getData()` returns a FRESH object shaped for
|
|
52
|
+
* `compileChart(config, adapter.getData())`, so identity-keyed memos
|
|
53
|
+
* re-render per snapshot.
|
|
54
|
+
*
|
|
55
|
+
* Change reporting (`{ changes: true }`): every snapshot mutation is
|
|
56
|
+
* also buffered as an RFC 6902 operation against the `getData()`
|
|
57
|
+
* shape, collected with `takeChanges()` — the feed the incremental
|
|
58
|
+
* chart session consumes. The contract is replay equivalence: applying
|
|
59
|
+
* a `takeChanges()` batch to the previous snapshot yields exactly the
|
|
60
|
+
* next one (`reset()` buffers a whole-document replace). The ops are
|
|
61
|
+
* plain data; this module never imports a patch applier.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { parseRFC3339Parts, epochOfRFC3339Parts } from '@jarenjs/core/dates';
|
|
65
|
+
import {
|
|
66
|
+
bboxOf, bboxUnion, projectMercator, simplifyLine, simplifyRing,
|
|
67
|
+
} from '@jarenjs/core/geo';
|
|
68
|
+
import { MAP_SIMPLIFY } from '../types/map.js';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} StreamAdapterConfig
|
|
72
|
+
* @property {(string|number)[]} [recordPath] path prefix owning the
|
|
73
|
+
* records: in path mode, e.g. `['run']` for `{"run": […]}` / `[[run]]`;
|
|
74
|
+
* in document mode, the object whose direct fields form the record
|
|
75
|
+
* (e.g. `['data', 'k']` for a combined-stream kline payload)
|
|
76
|
+
* @property {'path'|'document'} [recordBoundary] default 'path'
|
|
77
|
+
* @property {string} [xField] record field for x (line/candlestick) or
|
|
78
|
+
* the category (bar) / column (heatmap)
|
|
79
|
+
* @property {string} [yField] record field for y (line), the summed
|
|
80
|
+
* value (bar/heatmap; omitted = count records), or the reading (gauge)
|
|
81
|
+
* @property {string} [seriesField] record field naming the series
|
|
82
|
+
* (line) or the row (heatmap)
|
|
83
|
+
* @property {string} [openField] candlestick fields (defaults
|
|
84
|
+
* 'open'/'high'/'low'/'close')
|
|
85
|
+
* @property {string} [highField]
|
|
86
|
+
* @property {string} [lowField]
|
|
87
|
+
* @property {string} [closeField]
|
|
88
|
+
* @property {number} [maxPoints] ring-buffer size (line: per series;
|
|
89
|
+
* candlestick: total candles)
|
|
90
|
+
* @property {boolean} [changes] buffer RFC 6902 ops per snapshot
|
|
91
|
+
* mutation for {@link StreamAdapter#takeChanges}
|
|
92
|
+
* @property {string} [labelField] map: the feature property naming a
|
|
93
|
+
* feature (default 'name')
|
|
94
|
+
* @property {string} [valueField] map: the feature property to shade by
|
|
95
|
+
* @property {number|false} [simplify] map: simplification tolerance as a
|
|
96
|
+
* fraction of the frame's width (default the map chart's own; `false`
|
|
97
|
+
* keeps every vertex, which unbounds memory)
|
|
98
|
+
* @property {number} [aspect] map: frame width:height ratio the drawing
|
|
99
|
+
* will use (default 1.6, the map chart's own)
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {object} StreamAdapter
|
|
104
|
+
* @property {(event: any) => void} onEvent the reader event sink
|
|
105
|
+
* @property {() => void} endDocument close the current record
|
|
106
|
+
* (document mode: call once per completed document; path mode: call
|
|
107
|
+
* once at end of stream)
|
|
108
|
+
* @property {() => void} abortDocument discard the record in progress
|
|
109
|
+
* (a message that failed to parse)
|
|
110
|
+
* @property {() => any} getData a fresh chart-data snapshot
|
|
111
|
+
* @property {() => {op: string, path: string, value?: any}[]} takeChanges
|
|
112
|
+
* drain the buffered ops since the last call (requires
|
|
113
|
+
* `{ changes: true }`; throws a TypeError otherwise)
|
|
114
|
+
* @property {() => void} reset drop all accumulated state
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/** The chart types with a streaming accumulator. */
|
|
118
|
+
const STREAM_TYPES = new Set(['line', 'bar', 'heatmap', 'gauge', 'candlestick', 'map']);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a streaming accumulator for a chart type.
|
|
122
|
+
* @param {'line'|'bar'|'heatmap'|'gauge'|'candlestick'|'map'} chartType
|
|
123
|
+
* @param {StreamAdapterConfig} [config]
|
|
124
|
+
* @returns {StreamAdapter}
|
|
125
|
+
*/
|
|
126
|
+
export function createStreamAdapter(chartType, config = {}) {
|
|
127
|
+
if (!STREAM_TYPES.has(chartType))
|
|
128
|
+
throw new TypeError(`unknown stream chart type '${chartType}'`);
|
|
129
|
+
if (chartType === 'map' && config.recordBoundary === 'document')
|
|
130
|
+
throw new TypeError("createStreamAdapter: the map accumulator reads whole features from one stream ('path' mode only)");
|
|
131
|
+
const recordPath = config.recordPath ?? (chartType === 'map' ? ['features'] : []);
|
|
132
|
+
const documentMode = config.recordBoundary === 'document';
|
|
133
|
+
const xField = config.xField ?? 'x';
|
|
134
|
+
const yField = config.yField;
|
|
135
|
+
const seriesField = config.seriesField;
|
|
136
|
+
const openField = config.openField ?? 'open';
|
|
137
|
+
const highField = config.highField ?? 'high';
|
|
138
|
+
const lowField = config.lowField ?? 'low';
|
|
139
|
+
const closeField = config.closeField ?? 'close';
|
|
140
|
+
const maxPoints = config.maxPoints ?? 500;
|
|
141
|
+
const track = config.changes === true;
|
|
142
|
+
const labelField = typeof config.labelField === 'string' && config.labelField !== ''
|
|
143
|
+
? config.labelField : 'name';
|
|
144
|
+
const valueField = typeof config.valueField === 'string' ? config.valueField : null;
|
|
145
|
+
const simplifyFrac = config.simplify === false ? 0
|
|
146
|
+
: (typeof config.simplify === 'number' && config.simplify >= 0 ? config.simplify : MAP_SIMPLIFY);
|
|
147
|
+
const aspect = typeof config.aspect === 'number' && Number.isFinite(config.aspect) && config.aspect > 0
|
|
148
|
+
? config.aspect : 1.6;
|
|
149
|
+
|
|
150
|
+
/** @type {Map<string, {buf: any[], head: number, index: number}>} line series */
|
|
151
|
+
let series = new Map();
|
|
152
|
+
/** @type {Map<string, number>} bar counts */
|
|
153
|
+
let counts = new Map();
|
|
154
|
+
/** @type {{xLabels: string[], yLabels: string[], rows: (number|null)[][],
|
|
155
|
+
* xAt: Map<string, number>, yAt: Map<string, number>}} heatmap matrix */
|
|
156
|
+
let matrix = emptyMatrix();
|
|
157
|
+
/** @type {number|null} gauge reading */
|
|
158
|
+
let reading = null;
|
|
159
|
+
/** @type {Map<number|string, any>} candles keyed by open time */
|
|
160
|
+
let candles = new Map();
|
|
161
|
+
/** @type {Record<string, any>|null} the record being assembled */
|
|
162
|
+
let record = null;
|
|
163
|
+
/** @type {string|number|null} current record index (path mode) */
|
|
164
|
+
let index = null;
|
|
165
|
+
/** @type {{op: string, path: string, value?: any}[]} buffered snapshot ops */
|
|
166
|
+
let ops = [];
|
|
167
|
+
/** @type {any[]} map: kept (reduced) Feature objects */
|
|
168
|
+
let mapFeatures = [];
|
|
169
|
+
/** @type {number[]|null} map: running geographic extent */
|
|
170
|
+
let mapBbox = null;
|
|
171
|
+
/** @type {number} map: plane tolerance the kept set was simplified at */
|
|
172
|
+
let mapTolerance = 0;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The Douglas-Peucker tolerance in Mercator-plane units that equals
|
|
176
|
+
* `simplifyFrac` of the frame's width once the running bbox is fitted:
|
|
177
|
+
* frac x max(planeWidth, planeHeight x aspect). Growing the bbox can
|
|
178
|
+
* only raise it, which is what makes simplify-on-arrival safe — an
|
|
179
|
+
* early feature was simplified at least as finely as the final fit
|
|
180
|
+
* would have.
|
|
181
|
+
*/
|
|
182
|
+
function mapPlaneTolerance() {
|
|
183
|
+
if (simplifyFrac <= 0 || mapBbox === null)
|
|
184
|
+
return 0;
|
|
185
|
+
const [x0, y1] = projectMercator(mapBbox[0], mapBbox[1]);
|
|
186
|
+
const [x1, y0] = projectMercator(mapBbox[2], mapBbox[3]);
|
|
187
|
+
return simplifyFrac * Math.max(x1 - x0, (y1 - y0) * aspect);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Project a run of positions, carrying lon/lat along as p[2]/p[3]. */
|
|
191
|
+
function projectCarrying(positions) {
|
|
192
|
+
if (!Array.isArray(positions))
|
|
193
|
+
return [];
|
|
194
|
+
const out = [];
|
|
195
|
+
for (const position of positions) {
|
|
196
|
+
if (!Array.isArray(position))
|
|
197
|
+
continue;
|
|
198
|
+
const lon = position[0];
|
|
199
|
+
const lat = position[1];
|
|
200
|
+
if (typeof lon !== 'number' || typeof lat !== 'number'
|
|
201
|
+
|| !Number.isFinite(lon) || !Number.isFinite(lat))
|
|
202
|
+
continue;
|
|
203
|
+
const [px, py] = projectMercator(lon, lat);
|
|
204
|
+
out.push([px, py, lon, lat]);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const unproject = (p) => [p[2], p[3]];
|
|
210
|
+
|
|
211
|
+
function reduceLine(positions, tolerance) {
|
|
212
|
+
const run = projectCarrying(positions);
|
|
213
|
+
if (run.length < 2)
|
|
214
|
+
return null;
|
|
215
|
+
return (tolerance > 0 ? simplifyLine(run, tolerance) : run).map(unproject);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function reduceRing(positions, tolerance) {
|
|
219
|
+
const run = projectCarrying(positions);
|
|
220
|
+
if (run.length < 4)
|
|
221
|
+
return null;
|
|
222
|
+
return (tolerance > 0 ? simplifyRing(run, tolerance) : run).map(unproject);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function reduceParts(list, reduceOne, tolerance) {
|
|
226
|
+
if (!Array.isArray(list))
|
|
227
|
+
return null;
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const part of list) {
|
|
230
|
+
const reduced = reduceOne(part, tolerance);
|
|
231
|
+
if (reduced !== null)
|
|
232
|
+
out.push(reduced);
|
|
233
|
+
}
|
|
234
|
+
return out.length === 0 ? null : out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A geometry with the vertices a drawing at the current extent could
|
|
239
|
+
* not distinguish removed, as plain lon/lat GeoJSON — a subset of the
|
|
240
|
+
* source vertices, so re-reducing at a coarser tolerance later is
|
|
241
|
+
* exact. Null when nothing drawable remains.
|
|
242
|
+
*/
|
|
243
|
+
function reduceGeometry(geometry, tolerance) {
|
|
244
|
+
if (geometry === null || typeof geometry !== 'object')
|
|
245
|
+
return null;
|
|
246
|
+
const { type, coordinates } = geometry;
|
|
247
|
+
if (type === 'Point') {
|
|
248
|
+
const run = projectCarrying([coordinates]);
|
|
249
|
+
return run.length === 0 ? null : { type, coordinates: unproject(run[0]) };
|
|
250
|
+
}
|
|
251
|
+
if (type === 'MultiPoint') {
|
|
252
|
+
const run = projectCarrying(coordinates);
|
|
253
|
+
return run.length === 0 ? null : { type, coordinates: run.map(unproject) };
|
|
254
|
+
}
|
|
255
|
+
if (type === 'LineString') {
|
|
256
|
+
const line = reduceLine(coordinates, tolerance);
|
|
257
|
+
return line === null ? null : { type, coordinates: line };
|
|
258
|
+
}
|
|
259
|
+
if (type === 'MultiLineString') {
|
|
260
|
+
const lines = reduceParts(coordinates, reduceLine, tolerance);
|
|
261
|
+
return lines === null ? null : { type, coordinates: lines };
|
|
262
|
+
}
|
|
263
|
+
if (type === 'Polygon') {
|
|
264
|
+
const rings = reduceParts(coordinates, reduceRing, tolerance);
|
|
265
|
+
return rings === null ? null : { type, coordinates: rings };
|
|
266
|
+
}
|
|
267
|
+
if (type === 'MultiPolygon') {
|
|
268
|
+
const polys = reduceParts(coordinates,
|
|
269
|
+
(rings, t) => reduceParts(rings, reduceRing, t), tolerance);
|
|
270
|
+
return polys === null ? null : { type, coordinates: polys };
|
|
271
|
+
}
|
|
272
|
+
if (type === 'GeometryCollection') {
|
|
273
|
+
const inner = reduceParts(geometry.geometries,
|
|
274
|
+
(g, t) => reduceGeometry(g, t), tolerance);
|
|
275
|
+
return inner === null ? null : { type, geometries: inner };
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One complete Feature (or bare geometry) off the stream. */
|
|
281
|
+
function acceptMapFeature(item) {
|
|
282
|
+
if (item === null || typeof item !== 'object')
|
|
283
|
+
return;
|
|
284
|
+
const properties = item.type === 'Feature' ? (item.properties ?? {}) : item;
|
|
285
|
+
const geometry = item.type === 'Feature' ? item.geometry : item;
|
|
286
|
+
if (geometry === null || typeof geometry !== 'object')
|
|
287
|
+
return;
|
|
288
|
+
const box = bboxOf(geometry);
|
|
289
|
+
if (box !== null)
|
|
290
|
+
mapBbox = mapBbox === null ? box : bboxUnion(mapBbox, box);
|
|
291
|
+
const tolerance = mapPlaneTolerance();
|
|
292
|
+
const reduced = reduceGeometry(geometry, tolerance);
|
|
293
|
+
if (reduced === null)
|
|
294
|
+
return;
|
|
295
|
+
// only what the drawing reads survives: the label, the shading
|
|
296
|
+
// value, and the reduced geometry — the rest of the feature goes
|
|
297
|
+
// with the feature
|
|
298
|
+
const props = {};
|
|
299
|
+
const named = properties?.[labelField] ?? properties?.label;
|
|
300
|
+
if (named !== null && named !== undefined)
|
|
301
|
+
props[labelField] = String(named);
|
|
302
|
+
if (valueField !== null && typeof properties?.[valueField] === 'number'
|
|
303
|
+
&& Number.isFinite(properties[valueField]))
|
|
304
|
+
props[valueField] = properties[valueField];
|
|
305
|
+
mapFeatures.push({ type: 'Feature', properties: props, geometry: reduced });
|
|
306
|
+
if (track)
|
|
307
|
+
ops.push({ op: 'add', path: '/features/-', value: mapFeatures[mapFeatures.length - 1] });
|
|
308
|
+
// refit-on-growth: a bbox that has doubled the tolerance since the
|
|
309
|
+
// kept set was last simplified means earlier features now carry
|
|
310
|
+
// detail the drawing cannot show — coarsen them once, amortized
|
|
311
|
+
if (mapTolerance === 0) {
|
|
312
|
+
mapTolerance = tolerance;
|
|
313
|
+
}
|
|
314
|
+
else if (tolerance > mapTolerance * 2) {
|
|
315
|
+
for (let i = 0; i < mapFeatures.length - 1; i++) {
|
|
316
|
+
const again = reduceGeometry(mapFeatures[i].geometry, tolerance);
|
|
317
|
+
if (again !== null) {
|
|
318
|
+
mapFeatures[i] = { ...mapFeatures[i], geometry: again };
|
|
319
|
+
if (track)
|
|
320
|
+
ops.push({ op: 'replace', path: `/features/${i}`, value: mapFeatures[i] });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
mapTolerance = tolerance;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Position of a candle key in snapshot order (bounded by maxPoints). */
|
|
328
|
+
function candlePosition(key) {
|
|
329
|
+
let at = 0;
|
|
330
|
+
for (const k of candles.keys()) {
|
|
331
|
+
if (k === key) return at;
|
|
332
|
+
at++;
|
|
333
|
+
}
|
|
334
|
+
return -1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function flush() {
|
|
338
|
+
if (record === null) return;
|
|
339
|
+
const done = record;
|
|
340
|
+
record = null;
|
|
341
|
+
index = null;
|
|
342
|
+
if (chartType === 'gauge') {
|
|
343
|
+
// A gauge keeps no history: the newest reading IS the state, so
|
|
344
|
+
// there is nothing to key a record by and no x to require.
|
|
345
|
+
if (yField === undefined) return;
|
|
346
|
+
const v = numish(done[yField]);
|
|
347
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return;
|
|
348
|
+
reading = v;
|
|
349
|
+
if (track) ops.push({ op: 'replace', path: '/value', value: v });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const x = done[xField];
|
|
353
|
+
if (x === undefined) return;
|
|
354
|
+
if (chartType === 'heatmap') {
|
|
355
|
+
const column = String(x);
|
|
356
|
+
const row = String(done[seriesField] ?? '');
|
|
357
|
+
const add = yField === undefined ? 1 : Number(done[yField]);
|
|
358
|
+
if (yField !== undefined && !Number.isFinite(add)) return;
|
|
359
|
+
// The matrix stays rectangular: a new column widens every row,
|
|
360
|
+
// a new row arrives at the current width. Unmeasured cells are
|
|
361
|
+
// null, which the heatmap build reads as "no measurement" and
|
|
362
|
+
// leaves the surface showing through.
|
|
363
|
+
if (!matrix.xAt.has(column)) {
|
|
364
|
+
matrix.xAt.set(column, matrix.xLabels.length);
|
|
365
|
+
matrix.xLabels.push(column);
|
|
366
|
+
if (track) {
|
|
367
|
+
ops.push({ op: 'add', path: '/xLabels/-', value: column });
|
|
368
|
+
for (let r = 0; r < matrix.rows.length; r++)
|
|
369
|
+
ops.push({ op: 'add', path: `/values/${r}/-`, value: null });
|
|
370
|
+
}
|
|
371
|
+
for (const cells of matrix.rows) cells.push(null);
|
|
372
|
+
}
|
|
373
|
+
if (!matrix.yAt.has(row)) {
|
|
374
|
+
matrix.yAt.set(row, matrix.rows.length);
|
|
375
|
+
matrix.yLabels.push(row);
|
|
376
|
+
matrix.rows.push(new Array(matrix.xLabels.length).fill(null));
|
|
377
|
+
if (track) {
|
|
378
|
+
ops.push({ op: 'add', path: '/yLabels/-', value: row });
|
|
379
|
+
ops.push({ op: 'add', path: '/values/-', value: matrix.rows[matrix.rows.length - 1].slice() });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const ri = matrix.yAt.get(row);
|
|
383
|
+
const ci = matrix.xAt.get(column);
|
|
384
|
+
const cells = matrix.rows[ri];
|
|
385
|
+
cells[ci] = (cells[ci] ?? 0) + add;
|
|
386
|
+
if (track) ops.push({ op: 'replace', path: `/values/${ri}/${ci}`, value: cells[ci] });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (chartType === 'bar') {
|
|
390
|
+
const key = String(x);
|
|
391
|
+
const add = yField === undefined ? 1 : Number(done[yField]);
|
|
392
|
+
if (yField !== undefined && !Number.isFinite(add)) return;
|
|
393
|
+
const known = counts.has(key);
|
|
394
|
+
const at = known ? [...counts.keys()].indexOf(key) : counts.size;
|
|
395
|
+
counts.set(key, (counts.get(key) ?? 0) + add);
|
|
396
|
+
if (track) {
|
|
397
|
+
if (known) {
|
|
398
|
+
ops.push({ op: 'replace', path: `/series/0/values/${at}`, value: counts.get(key) });
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
ops.push({ op: 'add', path: '/categories/-', value: key });
|
|
402
|
+
ops.push({ op: 'add', path: '/series/0/values/-', value: counts.get(key) });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (chartType === 'candlestick') {
|
|
408
|
+
const open = numish(done[openField]);
|
|
409
|
+
const high = numish(done[highField]);
|
|
410
|
+
const low = numish(done[lowField]);
|
|
411
|
+
const close = numish(done[closeField]);
|
|
412
|
+
if (![open, high, low, close].every(Number.isFinite)) return;
|
|
413
|
+
const key = numish(x);
|
|
414
|
+
const known = candles.has(key);
|
|
415
|
+
const at = track && known ? candlePosition(key) : -1;
|
|
416
|
+
const candle = { t: key, open, high, low, close };
|
|
417
|
+
candles.set(key, candle);
|
|
418
|
+
if (track) {
|
|
419
|
+
if (known) ops.push({ op: 'replace', path: `/candles/${at}`, value: candle });
|
|
420
|
+
else ops.push({ op: 'add', path: '/candles/-', value: candle });
|
|
421
|
+
}
|
|
422
|
+
if (candles.size > maxPoints) {
|
|
423
|
+
candles.delete(candles.keys().next().value);
|
|
424
|
+
if (track) ops.push({ op: 'remove', path: '/candles/0' });
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (yField === undefined || done[yField] === undefined) return;
|
|
429
|
+
const name = seriesField !== undefined ? String(done[seriesField] ?? '') : 'value';
|
|
430
|
+
let s = series.get(name);
|
|
431
|
+
if (s === undefined) {
|
|
432
|
+
s = { buf: [], head: 0, index: series.size };
|
|
433
|
+
series.set(name, s);
|
|
434
|
+
if (track) ops.push({ op: 'add', path: '/series/-', value: { name, points: [] } });
|
|
435
|
+
}
|
|
436
|
+
const point = { x: numish(x), y: numish(done[yField]) };
|
|
437
|
+
s.buf.push(point);
|
|
438
|
+
if (track) ops.push({ op: 'add', path: `/series/${s.index}/points/-`, value: point });
|
|
439
|
+
if (s.buf.length - s.head > maxPoints) {
|
|
440
|
+
s.head++;
|
|
441
|
+
if (track) ops.push({ op: 'remove', path: `/series/${s.index}/points/0` });
|
|
442
|
+
}
|
|
443
|
+
// amortized compaction keeps the buffer bounded without O(n) shifts
|
|
444
|
+
if (s.head > maxPoints) {
|
|
445
|
+
s.buf = s.buf.slice(s.head);
|
|
446
|
+
s.head = 0;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function onEvent(event) {
|
|
451
|
+
const path = event.path;
|
|
452
|
+
if (path === undefined) return;
|
|
453
|
+
if (chartType === 'map') {
|
|
454
|
+
// the record is a complete Feature, delivered whole by the
|
|
455
|
+
// reader's object-end (pair events carry only its scalar leaves)
|
|
456
|
+
if (event.type === 'object-end' && path.length === recordPath.length + 1
|
|
457
|
+
&& startsWith(path, recordPath))
|
|
458
|
+
acceptMapFeature(event.value);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (documentMode) {
|
|
462
|
+
if (event.type === 'pair' && path.length === recordPath.length + 1
|
|
463
|
+
&& startsWith(path, recordPath)) {
|
|
464
|
+
if (record === null) record = {};
|
|
465
|
+
record[event.key] = event.value;
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// path mode: does this event live inside recordPath + [index]?
|
|
470
|
+
if (path.length > recordPath.length && startsWith(path, recordPath)) {
|
|
471
|
+
const at = path[recordPath.length];
|
|
472
|
+
if (at !== index) {
|
|
473
|
+
flush();
|
|
474
|
+
index = at;
|
|
475
|
+
record = {};
|
|
476
|
+
}
|
|
477
|
+
if (event.type === 'pair' && path.length === recordPath.length + 2) {
|
|
478
|
+
record[event.key] = event.value;
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
flush(); // the path left the record prefix
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function getData() {
|
|
486
|
+
if (chartType === 'map') {
|
|
487
|
+
return { features: mapFeatures.slice() };
|
|
488
|
+
}
|
|
489
|
+
if (chartType === 'gauge') {
|
|
490
|
+
return { value: reading };
|
|
491
|
+
}
|
|
492
|
+
if (chartType === 'heatmap') {
|
|
493
|
+
return {
|
|
494
|
+
xLabels: matrix.xLabels.slice(),
|
|
495
|
+
yLabels: matrix.yLabels.slice(),
|
|
496
|
+
values: matrix.rows.map((cells) => cells.slice()),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (chartType === 'bar') {
|
|
500
|
+
const categories = [...counts.keys()];
|
|
501
|
+
return {
|
|
502
|
+
categories,
|
|
503
|
+
series: [{ name: yField ?? 'count', values: categories.map((c) => counts.get(c)) }],
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
if (chartType === 'candlestick') {
|
|
507
|
+
return { candles: [...candles.values()] };
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
series: [...series.entries()].map(([name, s]) => ({
|
|
511
|
+
name,
|
|
512
|
+
points: s.buf.slice(s.head),
|
|
513
|
+
})),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The empty snapshot shape for this chart type (the replay base). */
|
|
518
|
+
function emptyData() {
|
|
519
|
+
if (chartType === 'bar')
|
|
520
|
+
return { categories: [], series: [{ name: yField ?? 'count', values: [] }] };
|
|
521
|
+
if (chartType === 'heatmap') return { xLabels: [], yLabels: [], values: [] };
|
|
522
|
+
// null, not 0: no reading yet is not a reading of zero (both draw
|
|
523
|
+
// an empty dial, but only one of them is a claim)
|
|
524
|
+
if (chartType === 'gauge') return { value: null };
|
|
525
|
+
if (chartType === 'candlestick') return { candles: [] };
|
|
526
|
+
if (chartType === 'map') return { features: [] };
|
|
527
|
+
return { series: [] };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
onEvent,
|
|
532
|
+
endDocument: flush,
|
|
533
|
+
abortDocument() {
|
|
534
|
+
record = null;
|
|
535
|
+
index = null;
|
|
536
|
+
},
|
|
537
|
+
getData,
|
|
538
|
+
takeChanges() {
|
|
539
|
+
if (!track)
|
|
540
|
+
throw new TypeError("createStreamAdapter: takeChanges() requires '{ changes: true }'");
|
|
541
|
+
const out = ops;
|
|
542
|
+
ops = [];
|
|
543
|
+
return out;
|
|
544
|
+
},
|
|
545
|
+
reset() {
|
|
546
|
+
series = new Map();
|
|
547
|
+
counts = new Map();
|
|
548
|
+
matrix = emptyMatrix();
|
|
549
|
+
reading = null;
|
|
550
|
+
candles = new Map();
|
|
551
|
+
record = null;
|
|
552
|
+
index = null;
|
|
553
|
+
mapFeatures = [];
|
|
554
|
+
mapBbox = null;
|
|
555
|
+
mapTolerance = 0;
|
|
556
|
+
// one whole-document replace supersedes any uncollected ops
|
|
557
|
+
if (track) ops = [{ op: 'replace', path: '', value: emptyData() }];
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** A heatmap matrix with no rows, columns or cells yet. */
|
|
563
|
+
function emptyMatrix() {
|
|
564
|
+
return { xLabels: [], yLabels: [], rows: [], xAt: new Map(), yAt: new Map() };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function startsWith(path, prefix) {
|
|
568
|
+
for (let i = 0; i < prefix.length; ++i) {
|
|
569
|
+
if (path[i] !== prefix[i]) return false;
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Lift a temporal coordinate to its epoch-millisecond number, passing every
|
|
576
|
+
* other value through untouched so a downstream `Number.isFinite` still
|
|
577
|
+
* decides what is plottable.
|
|
578
|
+
*
|
|
579
|
+
* A `Date` and an **RFC 3339 string** both lift, because both say
|
|
580
|
+
* unambiguously that they are an instant — which is what a time axis asked
|
|
581
|
+
* for, and JSON has no other way to spell one. A *numeric* string still does
|
|
582
|
+
* not: unlike `numish`, this refuses to accept `"5"` where the config asked
|
|
583
|
+
* for a number, because that is a type confusion rather than a date.
|
|
584
|
+
*
|
|
585
|
+
* A date with no time (`2026-07-27`) reads as UTC midnight, and a value
|
|
586
|
+
* carrying an offset is shifted to its instant, so points spelled in
|
|
587
|
+
* different zones land in the right order on one axis.
|
|
588
|
+
*
|
|
589
|
+
* @param {any} v
|
|
590
|
+
* @returns {any}
|
|
591
|
+
*/
|
|
592
|
+
export function numOf(v) {
|
|
593
|
+
if (v instanceof Date)
|
|
594
|
+
return v.getTime();
|
|
595
|
+
if (typeof v === 'string') {
|
|
596
|
+
const parts = parseRFC3339Parts(v);
|
|
597
|
+
if (parts === null)
|
|
598
|
+
return v;
|
|
599
|
+
const ms = epochOfRFC3339Parts(parts);
|
|
600
|
+
return ms === ms ? ms : v; // a full-time has no instant to plot
|
|
601
|
+
}
|
|
602
|
+
return v;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function numish(v) {
|
|
606
|
+
if (v instanceof Date) return v.getTime();
|
|
607
|
+
if (typeof v === 'bigint') return Number(v);
|
|
608
|
+
if (typeof v === 'string') {
|
|
609
|
+
const n = Number(v);
|
|
610
|
+
return Number.isFinite(n) ? n : v;
|
|
611
|
+
}
|
|
612
|
+
return v;
|
|
613
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `@jarenjs/charts` — headless charts. This is the **engine**:
|
|
4
|
+
* pure functions over data — definition + data ⇄ geometry-free AST ⇄
|
|
5
|
+
* pure-vnode SVG — that know only the `@jarenjs/view` vnode shape. It
|
|
6
|
+
* imports nothing from the component layer, `@jarenjs/app`, or the DOM
|
|
7
|
+
* (the two-layer component rule).
|
|
8
|
+
*
|
|
9
|
+
* The pipeline mirrors `@jarenjs/mermaid`:
|
|
10
|
+
*
|
|
11
|
+
* {config, data} ──build{Type}AST──▶ geometry-free AST (abstract
|
|
12
|
+
* fractions/angles, no pixels)
|
|
13
|
+
* │
|
|
14
|
+
* ▼
|
|
15
|
+
* render{Type}AST ──▶ pure-vnode SVG (toVnode /
|
|
16
|
+
* toSvgString via compileChart)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { compileChart, chartTypes } from './core/chart.js';
|
|
20
|
+
export { scaleLinear, scaleLog, scaleOrdinal, scaleBand, scaleTime } from './core/scale.js';
|
|
21
|
+
export {
|
|
22
|
+
niceStep, axisTicksLinear, axisTicksLog, axisTicksOrdinal,
|
|
23
|
+
formatTickValue, formatTimeTick, axisTicksTime, niceTimeStep,
|
|
24
|
+
} from './core/axis.js';
|
|
25
|
+
export { CATEGORICAL, SEQUENTIAL, seriesColor, sequentialColor, inkFor, createTheme, THEMES, HOST_VARS } from './core/palette.js';
|
|
26
|
+
export { buildPieAST, renderPieAST } from './types/pie.js';
|
|
27
|
+
export { buildBarAST, renderBarAST } from './types/bar.js';
|
|
28
|
+
export { buildLineAST, renderLineAST } from './types/line.js';
|
|
29
|
+
export { buildScatterAST, renderScatterAST } from './types/scatter.js';
|
|
30
|
+
export { buildCandlestickAST, renderCandlestickAST } from './types/candlestick.js';
|
|
31
|
+
export { buildRadarAST, renderRadarAST } from './types/radar.js';
|
|
32
|
+
export { buildGaugeAST, renderGaugeAST } from './types/gauge.js';
|
|
33
|
+
export { buildBoxplotAST, renderBoxplotAST, quantileSorted } from './types/boxplot.js';
|
|
34
|
+
export { buildHeatmapAST, renderHeatmapAST } from './types/heatmap.js';
|
|
35
|
+
export { buildTreemapAST, renderTreemapAST } from './types/treemap.js';
|
|
36
|
+
export { buildStreamgraphAST, renderStreamgraphAST } from './types/streamgraph.js';
|
|
37
|
+
export { buildSankeyAST, renderSankeyAST } from './types/sankey.js';
|
|
38
|
+
export { buildMapAST, renderMapAST } from './types/map.js';
|
|
39
|
+
export { createStreamAdapter } from './core/stream-adapter.js';
|
|
40
|
+
export { createChartSession } from './core/session.js';
|