@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,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';