@jarenjs/charts 0.46.5 → 0.49.2

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 CHANGED
@@ -72,12 +72,15 @@ separate `data` argument). The type-specific fields, briefly:
72
72
 
73
73
  **Time axes.** An `x: 'time'` axis (and every `candlestick`) accepts epoch milliseconds, a `Date`, or an **RFC 3339 string** — a date in a JSON document plots without being pre-converted, and a bare `2026-07-27` reads as UTC midnight. A *numeric* string still does not coerce: accepting `"5"` where the config asked for a number is a type confusion, not a date.
74
74
 
75
- Ticks land on calendar boundaries rather than on the 1/2/5 ladder, because a quantity axis and a clock have different nice numbers — the ladder puts ticks 50 seconds or 8.64 days apart, which no reader converts back into a time. Steps come from a clock/calendar ladder (1/5/15/30 seconds and minutes, 1/3/6/12 hours, 1/2 days, 1/2 weeks, 1/3/6 months, years), and the label granularity follows the step, so an axis never repeats one string on every tick:
75
+ Ticks land on calendar boundaries rather than on the 1/2/5 ladder, because a quantity axis and a clock have different nice numbers — the ladder puts ticks 50 seconds or 8.64 days apart, which no reader converts back into a time. Steps come from a clock/calendar ladder (1/5/15/30 seconds and minutes, 1/3/6/12 hours, 1/2 days, 1/2 weeks, 1/3/6 months, years, then whole years on the 1/2/5 × 10^k ladder above that), each tick lands on a multiple of its own step, and the label granularity follows the step, so an axis never repeats one string on every tick:
76
76
 
77
77
  ```javascript
78
78
  axisTicksTime(Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1)); // → 2024, 2025, 2026, 2027
79
79
  axisTicksTime(Date.UTC(2026, 6, 27, 0), Date.UTC(2026, 6, 27, 6)); // → 00:00 … 06:00
80
+ axisTicksTime(Date.UTC(1970, 0, 1), Date.UTC(5000, 0, 1)); // → 2000, 3000, 4000, 5000
80
81
  ```
82
+
83
+ `axisTicksTime`, `niceTimeStep`, `axisTicksLinear` and `niceStep` are the kernel's (`@jarenjs/core/dates`, `@jarenjs/core/math`) and are re-exported here as part of this module's tick vocabulary: a timeline that is not a chart wants the same boundaries, and a second copy of either ladder would be a second set of answers.
81
84
  | `radar` | `axes: string[]`, `series: [{name, values}]`; `max` pins the domain, `labelEvery` the spoke-label stride |
82
85
  | `gauge` | `value`; `min`/`max` (0..100 default), `unit`, `tone` |
83
86
  | `boxplot` | `boxes: [{label, values}]` raw, or `{label, min, q1, med, q3, max, outliers?}` |
@@ -248,18 +251,30 @@ property-tested over thousands of random frames rather than assumed.
248
251
 
249
252
  Measured (`npm run benchmark:charts`, one appended point):
250
253
 
251
- | points × series | session tick | wholesale tick |
252
- |---|---|---|
253
- | 100 × 5 | ~8 µs | ~319 µs |
254
- | 1 000 × 5 | ~4.6 µs | ~1.03 ms |
255
- | 10 000 × 5 | ~4.2 µs | ~11.3 ms |
256
-
257
- The session tick is *flat* in n (10 000 points cost no more than 100)
258
- while the wholesale tick grows 35×. Supported types: `line` (appends
254
+ <!--bm:charts.sessionTable-->
255
+ | points × series | session tick | wholesale tick | frames incremental |
256
+ |---|---:|---:|---:|
257
+ | 100 × 5 | 6.26 µs | 301 µs | 1100 of 1100 |
258
+ | 1,000 × 5 | 4.84 µs | 1.1 ms | 1100 of 1100 |
259
+ | 10,000 × 5 | 4.44 µs | 8.61 ms | 1100 of 1100 |
260
+ | 10,000 × 5 *(sampled)* | 3.25 ms | 2.53 ms | 0 of 1100 |
261
+ <!--/bm-->
262
+
263
+ The session tick is *flat* in n — it
264
+ moves <!--bm:charts.sessionFlatness-->0.7× while the wholesale tick grows 28.6×<!--/bm-->. Supported types: `line` (appends
259
265
  and ring-buffer evictions), `bar` (live counts and sums) and
260
266
  `candlestick` (keyed kline upserts — one candle group re-renders). The
261
267
  website's Binance demo runs on it.
262
268
 
269
+ The first three rows are lines the **sampler is not choosing the points
270
+ of** (see below). A session over a time line above two thousand points
271
+ rebuilds every frame by design — one appended reading can change which
272
+ vertices the downsampler picks, anywhere on the line — and that rebuild
273
+ costs *more* than the wholesale render it replaces, because it scans the
274
+ extremes a second time. That is the last row, and it is the price of the
275
+ default: a long live line declares `sampling: false`, which is exactly
276
+ what the three rows before it are.
277
+
263
278
  A bar chart's stillness test is the nice-number top rather than a
264
279
  declared policy: a count below it repaints one rect, a count that
265
280
  pushes the axis higher rebuilds. A new category rebuilds too — every
@@ -279,6 +294,61 @@ does grow with the category count. It grows about 2× where the
279
294
  wholesale render grows about 4×; the line session's flatness is the
280
295
  stronger claim, and this is deliberately the weaker one.
281
296
 
297
+ ## Sampling a big line
298
+
299
+ A hundred thousand readings on a line five hundred pixels wide is two
300
+ hundred readings per column. Something has to choose, and the only
301
+ question is whether the choosing is visible.
302
+
303
+ Above **two thousand source points** a **time** line is reduced through
304
+ `downsampleSeries` from `@jarenjs/core/series` — the same kernel a query
305
+ and a database call, so a chart cannot disagree with the rest of the
306
+ suite about what a gap is or where a series ends. Everything smaller,
307
+ and every non-time line, is the AST the previous version built, point
308
+ for point.
309
+
310
+ ```js
311
+ compileChart({ type: 'line', x: 'time' }, data); // LTTB above 2 000
312
+ compileChart({ type: 'line', x: 'time', sampling: false }, data); // draw every point
313
+ compileChart({ type: 'line', x: 'time', sampling: 'minmax' }, data);
314
+ compileChart({ type: 'line', x: 'time',
315
+ sampling: { method: 'lttb', target: 800 } }, data); // an explicit budget
316
+ ```
317
+
318
+ The default budget is a function of the **declared** width
319
+ (`sampling: { width, pixelRatio }`, default 560 × 1, clamped to
320
+ 64…8 192) and never of a measured element — nothing in this package
321
+ reads a layout, so an SSR render and a browser render of one definition
322
+ are the same bytes. An explicit `target` fixes it outright.
323
+
324
+ `ast.sampling` reports what happened: `{ method, target, sourceCount,
325
+ renderedCount }`, or `null` when every point is drawn. What the sampled
326
+ line still promises is the kernel's: every segment's **ends** survive,
327
+ every run of gaps keeps a marker and is never bridged, `minmax` keeps
328
+ the envelope exactly, and no vertex carries an instant that no reading
329
+ had. The domain is scanned from every **source** point, so the axis
330
+ reports the data rather than the drawing.
331
+
332
+ Measured (`npm run benchmark:charts`, one series):
333
+
334
+ <!--bm:charts.samplingTable-->
335
+ | source points | drawn | method | source → AST | source → svg |
336
+ |---:|---:|---|---:|---:|
337
+ | 2,000 | 2,000 | none | 73.4 µs → 72.3 µs | 587 µs → 515 µs |
338
+ | 20,000 | 560 | lttb | 838 µs → 1.28 ms | 5.84 ms → 1.05 ms |
339
+ | 100,000 | 560 | lttb | 4.35 ms → 5.36 ms | 27.8 ms → 5.42 ms |
340
+ <!--/bm-->
341
+
342
+ Choosing the points costs about what mapping them costs — the sampler
343
+ reads every reading either way — so the AST column is a small **loss**.
344
+ What it buys is the render — <!--bm:charts.samplingWin-->100,000 points draw as 560 and render 5.1× faster<!--/bm-->. The invariants above are asserted in the benchmark
345
+ before a single timing is printed.
346
+
347
+ **Sampling is not retention.** `createStreamAdapter`'s `maxPoints`
348
+ decides what *exists*; sampling decides what is *drawn*, over whatever
349
+ exists. Changing one leaves the other untouched, and a chart can switch
350
+ method or turn sampling off without the adapter noticing.
351
+
282
352
  ## Mermaid interop
283
353
 
284
354
  `@jarenjs/mermaid` delegates its `pie` diagrams here (the arrow is
@@ -1,22 +1,16 @@
1
1
  /**
2
- * @file Tick generators. Linear ticks snap to a nice-number step
3
- * (1/2/5 × 10^k) so an axis never shows values like `12.75`; log ticks
4
- * are decade powers (the shape benchmark-ratio charts need, where one
5
- * axis spans two orders of magnitude); ordinal ticks center on their
6
- * band. Tick *positions* are the caller's job via the matching scale —
7
- * these functions return domain values only.
8
- */
9
- import { niceStep } from '@jarenjs/core/math';
10
- export { niceStep };
11
- /**
12
- * Linear ticks: multiples of a nice step inside `[min, max]`.
13
- * Degenerate domains yield a single tick.
14
- * @param {number} min - Domain minimum
15
- * @param {number} max - Domain maximum
16
- * @param {number} [count] - Desired tick count (approximate)
17
- * @returns {number[]}
18
- */
19
- export declare function axisTicksLinear(min: number, max: number, count?: number): number[];
2
+ * @file Tick generators. The numeric 1/2/5 × 10^k ladder and the
3
+ * calendar step ladder are the kernel's, so an axis never shows values
4
+ * like `12.75` and a timeline that is not a chart reads the same
5
+ * boundaries; log ticks are decade powers (the shape benchmark-ratio
6
+ * charts need, where one axis spans two orders of magnitude); ordinal
7
+ * ticks center on their band; and the tick *labels* are this module's.
8
+ * Tick *positions* are the caller's job via the matching scale — these
9
+ * functions return domain values only.
10
+ */
11
+ import { niceStep, axisTicksLinear } from '@jarenjs/core/math';
12
+ import { niceTimeStep, axisTicksTime } from '@jarenjs/core/dates';
13
+ export { niceStep, axisTicksLinear, niceTimeStep, axisTicksTime };
20
14
  /**
21
15
  * Log ticks: the decade powers (… 0.1, 1, 10, 100 …) inside
22
16
  * `[min, max]`. When the domain sits within a single decade the
@@ -42,25 +36,6 @@ export declare function axisTicksOrdinal(categories: readonly string[]): {
42
36
  * @returns {string}
43
37
  */
44
38
  export declare function formatTickValue(v: number): string;
45
- /**
46
- * Choose the calendar step closest to covering `span` in `count` ticks.
47
- * @param {number} span - Domain width in milliseconds
48
- * @param {number} count - Desired tick count
49
- * @returns {[string, number]} a [unit, amount] pair
50
- */
51
- export declare function niceTimeStep(span: number, count: number): [string, number];
52
- /**
53
- * Time ticks on calendar boundaries: the first tick is the start of a
54
- * `[unit, amount]` step at or after `min`, and each following one is a
55
- * whole step later. Month and year steps land on the first of the
56
- * month, so a multi-year axis reads `2024 2025 2026` rather than
57
- * arbitrary instants.
58
- * @param {number} min - Domain minimum, epoch milliseconds
59
- * @param {number} max - Domain maximum, epoch milliseconds
60
- * @param {number} [count] - Desired tick count (approximate)
61
- * @returns {number[]} tick values in epoch milliseconds
62
- */
63
- export declare function axisTicksTime(min: number, max: number, count?: number): number[];
64
39
  /**
65
40
  * A time-axis tick label in UTC (deterministic across machines).
66
41
  *
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @file The line sampling policy: how many points a time line draws,
3
+ * and which ones.
4
+ *
5
+ * A hundred thousand readings on a line five hundred pixels wide is two
6
+ * hundred readings per column. Something has to choose, and the choice
7
+ * belongs in one place — `downsampleSeries` from `@jarenjs/core/series`,
8
+ * the same kernel a query and a database call — so a chart cannot
9
+ * disagree with the rest of the suite about what a gap is or where a
10
+ * series ends.
11
+ *
12
+ * What lives HERE is only the policy: when the sampler runs, how big
13
+ * its budget is, and which series it may touch at all.
14
+ *
15
+ * - **Above two thousand points, a time line samples by default.**
16
+ * Below it nothing changes: the AST is the one the previous version
17
+ * built, point for point.
18
+ * - **`sampling: false` is the opt-out**, and `'lttb'` / `'minmax'` /
19
+ * `{ method, target }` are the explicit spellings. An explicit
20
+ * spelling asks for the sampler whatever the count is, and on a
21
+ * linear x axis as well as a time one.
22
+ * - **The default budget is a function of the declared width**, never
23
+ * of a measured element: `width × pixelRatio`, clamped. Nothing in
24
+ * this package reads a layout, so an SSR render and a browser render
25
+ * of the same definition are the same bytes. A host that wants the
26
+ * viewport's budget passes it (`sampling: { width, pixelRatio }`);
27
+ * an explicit `target` fixes it outright.
28
+ *
29
+ * Two guards keep the sampler off a series it would misread. Its input
30
+ * must be ASCENDING in x — the kernel sorts, and a line whose points
31
+ * arrive out of order is drawn in the order it was given, so sampling
32
+ * an unsorted series would redraw it — and every x must be a finite
33
+ * instant, because a point with no place on the axis is a break in the
34
+ * line rather than a sample. A series failing either is mapped whole,
35
+ * exactly as before.
36
+ *
37
+ * This is not retention. `createStreamAdapter`'s `maxPoints` decides
38
+ * what EXISTS; sampling decides what is DRAWN, over whatever exists,
39
+ * and changing one leaves the other alone.
40
+ */
41
+ /** Source points above which an omitted `sampling` starts sampling a
42
+ * time line. Below it the previous AST is reproduced exactly. */
43
+ export declare const SAMPLING_THRESHOLD = 2000;
44
+ /** The width a derived budget assumes when the caller declares none —
45
+ * `cartesianFrame`'s own default, so the default budget is about one
46
+ * point per rendered column. */
47
+ export declare const SAMPLING_WIDTH = 560;
48
+ /** The budget clamp. Under `MIN` a line has no shape left to read;
49
+ * over `MAX` there are more points than a display can separate, so the
50
+ * sampler would be cost without a picture. */
51
+ export declare const SAMPLING_TARGET_MIN = 64;
52
+ export declare const SAMPLING_TARGET_MAX = 8192;
53
+ export type SamplingPolicy = {
54
+ method: 'lttb' | 'minmax';
55
+ /**
56
+ * the most points one series may draw
57
+ */
58
+ target: number;
59
+ /**
60
+ * true when `sampling` was omitted, so the
61
+ * policy only applies to a time line above {@link SAMPLING_THRESHOLD}
62
+ */
63
+ auto: boolean;
64
+ };
65
+ /**
66
+ * @typedef {object} SamplingPolicy
67
+ * @property {'lttb'|'minmax'} method
68
+ * @property {number} target the most points one series may draw
69
+ * @property {boolean} auto true when `sampling` was omitted, so the
70
+ * policy only applies to a time line above {@link SAMPLING_THRESHOLD}
71
+ */
72
+ /**
73
+ * Resolve `config.sampling` into a policy, or `null` for "never
74
+ * sample". Invalid spellings resolve to the default rather than
75
+ * throwing: a chart definition is validated against the schema when it
76
+ * is untrusted, and a build that threw would take a whole dashboard
77
+ * down for a misspelled member.
78
+ * @param {any} sampling the config member
79
+ * @returns {SamplingPolicy|null}
80
+ */
81
+ export declare function normalizeSampling(sampling: any): SamplingPolicy | null;
82
+ /**
83
+ * Could a series of this many source points be reduced under this
84
+ * policy? An OVER-approximation by design: it counts source points
85
+ * where {@link samplingInput} counts the ones a window still draws, so
86
+ * it may say yes where the build then samples nothing.
87
+ *
88
+ * The incremental session asks it, and there the safe direction is
89
+ * this one: a wholesale rebuild is always the right picture, while an
90
+ * appended vertex on a line whose points the sampler chose would be a
91
+ * vertex the wholesale build never selected.
92
+ * @param {SamplingPolicy|null} policy
93
+ * @param {boolean} time
94
+ * @param {number} count
95
+ * @returns {boolean}
96
+ */
97
+ export declare function mightSample(policy: SamplingPolicy | null, time: boolean, count: number): boolean;
98
+ /**
99
+ * The canonical samples one line series offers the sampler, or `null`
100
+ * when it offers none: a point with no finite x, an x that goes
101
+ * backwards, or nothing the policy applies to.
102
+ *
103
+ * The records are `{ at, value }` and nothing else, which is what lets
104
+ * `downsampleSeries` hand this very array back without copying it. A
105
+ * reading with no plottable y — absent, not a number, or non-positive
106
+ * under a log scale — is a `null` value, which is the same measured
107
+ * gap the kernel already refuses to draw through.
108
+ * @param {any[]} points
109
+ * @param {SamplingPolicy} policy
110
+ * @param {boolean} time
111
+ * @param {boolean} log
112
+ * @param {number|null} xDrop window low bound (samples below it are not drawn)
113
+ * @returns {{ at: number, value: number|null }[] | null}
114
+ */
115
+ export declare function samplingInput(points: any[], policy: SamplingPolicy, time: boolean, log: boolean, xDrop: number | null): {
116
+ at: number;
117
+ value: number | null;
118
+ }[] | null;
119
+ /**
120
+ * Sample one series' points into unit vertices, or `null` when the
121
+ * policy leaves this series alone.
122
+ *
123
+ * The kernel keeps every segment's ends and one marker per run of gaps,
124
+ * so the drawn line still starts and ends where the data does and every
125
+ * hole stays a hole. Every vertex here carries a source point's own
126
+ * instant and reading — the sampler chooses points, it never averages
127
+ * them into new ones.
128
+ * @param {any[]} points
129
+ * @param {SamplingPolicy} policy
130
+ * @param {boolean} time
131
+ * @param {boolean} log
132
+ * @param {number|null} xDrop
133
+ * @param {(v:number)=>number} xScale
134
+ * @param {(v:number)=>number} yScale
135
+ * @returns {{ vertices: ({u:number,v:number}|null)[], sourceCount: number } | null}
136
+ */
137
+ export declare function sampleLineSeries(points: any[], policy: SamplingPolicy, time: boolean, log: boolean, xDrop: number | null, xScale: (v: number) => number, yScale: (v: number) => number): {
138
+ vertices: ({
139
+ u: number;
140
+ v: number;
141
+ } | null)[];
142
+ sourceCount: number;
143
+ } | null;
@@ -31,5 +31,6 @@ export { buildTreemapAST, renderTreemapAST } from './types/treemap.js';
31
31
  export { buildStreamgraphAST, renderStreamgraphAST } from './types/streamgraph.js';
32
32
  export { buildSankeyAST, renderSankeyAST } from './types/sankey.js';
33
33
  export { buildMapAST, renderMapAST } from './types/map.js';
34
+ export { normalizeSampling, SAMPLING_THRESHOLD, SAMPLING_WIDTH, SAMPLING_TARGET_MIN, SAMPLING_TARGET_MAX, } from './core/sampling.js';
34
35
  export { createStreamAdapter } from './core/stream-adapter.js';
35
36
  export { createChartSession } from './core/session.js';
@@ -8,7 +8,14 @@
8
8
  *
9
9
  * data = { series: [{ name, points: [{x, y}] }] }
10
10
  * config = { type:'line', title?, x?: 'linear'|'time', log?,
11
- * markers?, xLabel?, yLabel?, domain? }
11
+ * markers?, xLabel?, yLabel?, domain?, sampling? }
12
+ *
13
+ * `config.sampling` (`core/sampling.js`) decides how many of those
14
+ * points are drawn: above two thousand a time line is reduced through
15
+ * `@jarenjs/core/series`'s downsampler by default, and the AST reports
16
+ * what that cost under `sampling`. The domain is always scanned from
17
+ * every source point, so what a reader is told about the range does not
18
+ * depend on what fitted on the line.
12
19
  *
13
20
  * `config.domain` declares a domain-stability policy (`core/domain.js`)
14
21
  * so most streaming ticks keep the scales still: a quantized sliding
@@ -61,6 +68,16 @@ export type LineAST = {
61
68
  } | null)[];
62
69
  }[];
63
70
  markers: boolean;
71
+ /**
72
+ * what the downsampler did, or
73
+ * `null` when every source point is drawn
74
+ */
75
+ sampling: {
76
+ method: 'lttb' | 'minmax';
77
+ target: number;
78
+ sourceCount: number;
79
+ renderedCount: number;
80
+ } | null;
64
81
  };
65
82
  /**
66
83
  * @typedef {object} LineAST
@@ -72,6 +89,9 @@ export type LineAST = {
72
89
  * @property {{name: string, swatch: number}[]|null} legend
73
90
  * @property {{name: string, points: ({u:number,v:number}|null)[]}[]} series
74
91
  * @property {boolean} markers
92
+ * @property {{method: 'lttb'|'minmax', target: number, sourceCount: number,
93
+ * renderedCount: number}|null} sampling what the downsampler did, or
94
+ * `null` when every source point is drawn
75
95
  */
76
96
  /**
77
97
  * Scan the data extremes the domain resolution needs: raw x bounds
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/charts",
3
3
  "private": false,
4
- "version": "0.46.5",
4
+ "version": "0.49.2",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -70,7 +70,7 @@
70
70
  "prepack": "npm run build:types"
71
71
  },
72
72
  "dependencies": {
73
- "@jarenjs/core": "^0.46.5",
74
- "@jarenjs/view": "^0.46.5"
73
+ "@jarenjs/core": "^0.49.2",
74
+ "@jarenjs/view": "^0.49.2"
75
75
  }
76
76
  }
@@ -62,6 +62,7 @@
62
62
  "xLabel": { "type": ["string", "null"] },
63
63
  "yLabel": { "type": ["string", "null"] },
64
64
  "domain": { "$ref": "#/$defs/domainPolicy" },
65
+ "sampling": { "$ref": "#/$defs/samplingPolicy" },
65
66
  "series": {
66
67
  "type": "array",
67
68
  "items": { "$ref": "#/$defs/lineSeries" }
@@ -418,6 +419,30 @@
418
419
  "value": { "type": "number" }
419
420
  }
420
421
  },
422
+ "samplingPolicy": {
423
+ "description": "how many of a line's points are drawn: false never samples, a method name or object samples whatever the count, and an omitted member samples a TIME line above 2000 points through @jarenjs/core/series. Distinct from stream.maxPoints, which decides what exists rather than what is drawn.",
424
+ "anyOf": [
425
+ { "const": false },
426
+ { "enum": ["lttb", "minmax"] },
427
+ {
428
+ "type": "object",
429
+ "properties": {
430
+ "method": { "enum": ["lttb", "minmax"] },
431
+ "target": {
432
+ "description": "the most points one series may draw; an explicit target makes the rendered line independent of any layout",
433
+ "type": "integer",
434
+ "minimum": 2
435
+ },
436
+ "width": {
437
+ "description": "the width the derived target assumes, in CSS pixels (default 560)",
438
+ "type": "number",
439
+ "exclusiveMinimum": 0
440
+ },
441
+ "pixelRatio": { "type": "number", "exclusiveMinimum": 0 }
442
+ }
443
+ }
444
+ ]
445
+ },
421
446
  "domainPolicy": {
422
447
  "description": "domain-stability policy: a quantized sliding x window and/or pinned or step-quantized y bounds, so most streaming ticks keep the scales still",
423
448
  "type": "object",
package/src/core/axis.js CHANGED
@@ -1,50 +1,24 @@
1
1
  //@ts-check
2
2
  /**
3
- * @file Tick generators. Linear ticks snap to a nice-number step
4
- * (1/2/5 × 10^k) so an axis never shows values like `12.75`; log ticks
5
- * are decade powers (the shape benchmark-ratio charts need, where one
6
- * axis spans two orders of magnitude); ordinal ticks center on their
7
- * band. Tick *positions* are the caller's job via the matching scale —
8
- * these functions return domain values only.
3
+ * @file Tick generators. The numeric 1/2/5 × 10^k ladder and the
4
+ * calendar step ladder are the kernel's, so an axis never shows values
5
+ * like `12.75` and a timeline that is not a chart reads the same
6
+ * boundaries; log ticks are decade powers (the shape benchmark-ratio
7
+ * charts need, where one axis spans two orders of magnitude); ordinal
8
+ * ticks center on their band; and the tick *labels* are this module's.
9
+ * Tick *positions* are the caller's job via the matching scale — these
10
+ * functions return domain values only.
9
11
  */
10
12
 
11
- import { Float64, niceStep } from '@jarenjs/core/math';
12
- import {
13
- partsFromEpoch,
14
- epochOfRFC3339Parts,
15
- startOfParts,
16
- addToParts,
17
- compileDateFormat,
18
- } from '@jarenjs/core/dates';
13
+ import { Float64, niceStep, axisTicksLinear } from '@jarenjs/core/math';
14
+ import { partsFromEpoch, compileDateFormat, niceTimeStep, axisTicksTime } from '@jarenjs/core/dates';
19
15
 
20
- // The 1/2/5 ladder is generic numeric math, so it lives in the kernel; it is
21
- // re-exported here because it is part of this module's tick vocabulary.
22
- export { niceStep };
23
-
24
- /**
25
- * Linear ticks: multiples of a nice step inside `[min, max]`.
26
- * Degenerate domains yield a single tick.
27
- * @param {number} min - Domain minimum
28
- * @param {number} max - Domain maximum
29
- * @param {number} [count] - Desired tick count (approximate)
30
- * @returns {number[]}
31
- */
32
- export function axisTicksLinear(min, max, count = 5) {
33
- if (!Number.isFinite(min) || !Number.isFinite(max))
34
- return [];
35
- if (min === max)
36
- return [min];
37
- if (min > max)
38
- return axisTicksLinear(max, min, count);
39
- const step = niceStep(max - min, count);
40
- const decimals = Math.max(0, -Math.floor(Math.log10(step)));
41
- const ticks = [];
42
- const first = Math.ceil(min / step);
43
- const last = Math.floor(max / step);
44
- for (let i = first; i <= last; ++i)
45
- ticks.push(Number((i * step).toFixed(decimals)));
46
- return ticks;
47
- }
16
+ // The 1/2/5 ladder and the ticks it lays down are generic numeric math, and
17
+ // the calendar step ladder is the kernel's too — a timeline that is not a
18
+ // chart wants the same tick positions, and a second copy of either would be
19
+ // a second set of answers. They are re-exported here because they are part
20
+ // of this module's tick vocabulary.
21
+ export { niceStep, axisTicksLinear, niceTimeStep, axisTicksTime };
48
22
 
49
23
  /**
50
24
  * Log ticks: the decade powers (… 0.1, 1, 10, 100 …) inside
@@ -92,92 +66,6 @@ export function formatTickValue(v) {
92
66
  return trim(v);
93
67
  }
94
68
 
95
- // The steps a clock and a calendar actually have. The 1/2/5 ladder is
96
- // right for quantities and wrong for time: it puts ticks 50 seconds or
97
- // 8.64 days apart, which no reader converts back into a date. Each entry
98
- // is [unit, amount]; the unit names are the kernel's.
99
- const TIME_STEPS = Object.freeze([
100
- ['second', 1], ['second', 5], ['second', 15], ['second', 30],
101
- ['minute', 1], ['minute', 5], ['minute', 15], ['minute', 30],
102
- ['hour', 1], ['hour', 3], ['hour', 6], ['hour', 12],
103
- ['day', 1], ['day', 2], ['week', 1], ['week', 2],
104
- ['month', 1], ['month', 3], ['month', 6],
105
- ['year', 1],
106
- ]);
107
-
108
- // approximate widths, used only to pick a step near the target count
109
- const STEP_MS = Object.freeze({
110
- second: 1000, minute: 60000, hour: 3600000, day: 86400000,
111
- week: 604800000, month: 2629800000, year: 31557600000,
112
- });
113
-
114
- /**
115
- * Choose the calendar step closest to covering `span` in `count` ticks.
116
- * @param {number} span - Domain width in milliseconds
117
- * @param {number} count - Desired tick count
118
- * @returns {[string, number]} a [unit, amount] pair
119
- */
120
- export function niceTimeStep(span, count) {
121
- const target = span / Math.max(1, count);
122
- let best = TIME_STEPS[0];
123
- let bestErr = Infinity;
124
- for (let i = 0; i < TIME_STEPS.length; i++) {
125
- const [unit, amount] = TIME_STEPS[i];
126
- const err = Math.abs(Math.log(STEP_MS[unit] * amount / target));
127
- if (err < bestErr) {
128
- bestErr = err;
129
- best = TIME_STEPS[i];
130
- }
131
- }
132
- return best;
133
- }
134
-
135
- /**
136
- * Time ticks on calendar boundaries: the first tick is the start of a
137
- * `[unit, amount]` step at or after `min`, and each following one is a
138
- * whole step later. Month and year steps land on the first of the
139
- * month, so a multi-year axis reads `2024 2025 2026` rather than
140
- * arbitrary instants.
141
- * @param {number} min - Domain minimum, epoch milliseconds
142
- * @param {number} max - Domain maximum, epoch milliseconds
143
- * @param {number} [count] - Desired tick count (approximate)
144
- * @returns {number[]} tick values in epoch milliseconds
145
- */
146
- export function axisTicksTime(min, max, count = 4) {
147
- if (!Number.isFinite(min) || !Number.isFinite(max))
148
- return [];
149
- if (min === max)
150
- return [min];
151
- if (min > max)
152
- return axisTicksTime(max, min, count);
153
- const span = max - min;
154
- // below a second the calendar has nothing to say; the numeric ladder does
155
- if (span < 1000 * count)
156
- return axisTicksLinear(min, max, count);
157
- const [unit, amount] = niceTimeStep(span, count);
158
- // snap to the step's own boundary, then advance whole steps
159
- let parts = startOfParts(partsFromEpoch(min), unit === 'week' ? 'week' : unit);
160
- if (unit === 'month' && amount > 1) {
161
- // quarters and half-years start on month 1, 4, 7, 10 (or 1, 7)
162
- const aligned = Math.floor((parts.month - 1) / amount) * amount + 1;
163
- parts = { ...parts, month: aligned };
164
- }
165
- const ticks = [];
166
- let ms = epochOfRFC3339Parts(parts);
167
- if (ms < min) {
168
- parts = addToParts(parts, amount, unit);
169
- ms = epochOfRFC3339Parts(parts);
170
- }
171
- // the step is never zero, so this terminates; the cap is a guard
172
- // against a pathological domain rather than an expected path
173
- for (let i = 0; ms <= max && i < 1000; i++) {
174
- ticks.push(ms);
175
- parts = addToParts(parts, amount, unit);
176
- ms = epochOfRFC3339Parts(parts);
177
- }
178
- return ticks;
179
- }
180
-
181
69
  // label patterns by how coarse the step is, compiled once
182
70
  const LABEL_SECOND = compileDateFormat('HH:mm:ss');
183
71
  const LABEL_MINUTE = compileDateFormat('HH:mm');
@@ -0,0 +1,206 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The line sampling policy: how many points a time line draws,
4
+ * and which ones.
5
+ *
6
+ * A hundred thousand readings on a line five hundred pixels wide is two
7
+ * hundred readings per column. Something has to choose, and the choice
8
+ * belongs in one place — `downsampleSeries` from `@jarenjs/core/series`,
9
+ * the same kernel a query and a database call — so a chart cannot
10
+ * disagree with the rest of the suite about what a gap is or where a
11
+ * series ends.
12
+ *
13
+ * What lives HERE is only the policy: when the sampler runs, how big
14
+ * its budget is, and which series it may touch at all.
15
+ *
16
+ * - **Above two thousand points, a time line samples by default.**
17
+ * Below it nothing changes: the AST is the one the previous version
18
+ * built, point for point.
19
+ * - **`sampling: false` is the opt-out**, and `'lttb'` / `'minmax'` /
20
+ * `{ method, target }` are the explicit spellings. An explicit
21
+ * spelling asks for the sampler whatever the count is, and on a
22
+ * linear x axis as well as a time one.
23
+ * - **The default budget is a function of the declared width**, never
24
+ * of a measured element: `width × pixelRatio`, clamped. Nothing in
25
+ * this package reads a layout, so an SSR render and a browser render
26
+ * of the same definition are the same bytes. A host that wants the
27
+ * viewport's budget passes it (`sampling: { width, pixelRatio }`);
28
+ * an explicit `target` fixes it outright.
29
+ *
30
+ * Two guards keep the sampler off a series it would misread. Its input
31
+ * must be ASCENDING in x — the kernel sorts, and a line whose points
32
+ * arrive out of order is drawn in the order it was given, so sampling
33
+ * an unsorted series would redraw it — and every x must be a finite
34
+ * instant, because a point with no place on the axis is a break in the
35
+ * line rather than a sample. A series failing either is mapped whole,
36
+ * exactly as before.
37
+ *
38
+ * This is not retention. `createStreamAdapter`'s `maxPoints` decides
39
+ * what EXISTS; sampling decides what is DRAWN, over whatever exists,
40
+ * and changing one leaves the other alone.
41
+ */
42
+
43
+ import { downsampleSeries } from '@jarenjs/core/series';
44
+ import { clamp01 } from '@jarenjs/core/math';
45
+ import { numOf } from './stream-adapter.js';
46
+
47
+ /** Source points above which an omitted `sampling` starts sampling a
48
+ * time line. Below it the previous AST is reproduced exactly. */
49
+ export const SAMPLING_THRESHOLD = 2000;
50
+
51
+ /** The width a derived budget assumes when the caller declares none —
52
+ * `cartesianFrame`'s own default, so the default budget is about one
53
+ * point per rendered column. */
54
+ export const SAMPLING_WIDTH = 560;
55
+
56
+ /** The budget clamp. Under `MIN` a line has no shape left to read;
57
+ * over `MAX` there are more points than a display can separate, so the
58
+ * sampler would be cost without a picture. */
59
+ export const SAMPLING_TARGET_MIN = 64;
60
+ export const SAMPLING_TARGET_MAX = 8192;
61
+
62
+ const METHODS = Object.freeze(['lttb', 'minmax']);
63
+
64
+ /**
65
+ * @typedef {object} SamplingPolicy
66
+ * @property {'lttb'|'minmax'} method
67
+ * @property {number} target the most points one series may draw
68
+ * @property {boolean} auto true when `sampling` was omitted, so the
69
+ * policy only applies to a time line above {@link SAMPLING_THRESHOLD}
70
+ */
71
+
72
+ /**
73
+ * Resolve `config.sampling` into a policy, or `null` for "never
74
+ * sample". Invalid spellings resolve to the default rather than
75
+ * throwing: a chart definition is validated against the schema when it
76
+ * is untrusted, and a build that threw would take a whole dashboard
77
+ * down for a misspelled member.
78
+ * @param {any} sampling the config member
79
+ * @returns {SamplingPolicy|null}
80
+ */
81
+ export function normalizeSampling(sampling) {
82
+ if (sampling === false) return null;
83
+ const auto = sampling === undefined || sampling === null;
84
+ const spec = typeof sampling === 'string' ? { method: sampling }
85
+ : (sampling !== null && typeof sampling === 'object' ? sampling : {});
86
+ const method = METHODS.includes(spec.method) ? spec.method : 'lttb';
87
+ return { method, target: targetOf(spec), auto };
88
+ }
89
+
90
+ /**
91
+ * The budget: the declared `target`, else one point per rendered
92
+ * column at the declared width and pixel ratio, clamped.
93
+ * @param {any} spec
94
+ * @returns {number}
95
+ */
96
+ function targetOf(spec) {
97
+ const declared = spec.target;
98
+ if (Number.isInteger(declared) && declared >= 2)
99
+ return Math.min(SAMPLING_TARGET_MAX, declared);
100
+ const width = Number.isFinite(spec.width) && spec.width > 0 ? spec.width : SAMPLING_WIDTH;
101
+ const ratio = Number.isFinite(spec.pixelRatio) && spec.pixelRatio > 0 ? spec.pixelRatio : 1;
102
+ return Math.min(SAMPLING_TARGET_MAX,
103
+ Math.max(SAMPLING_TARGET_MIN, Math.round(width * ratio)));
104
+ }
105
+
106
+ /**
107
+ * Could a series of this many source points be reduced under this
108
+ * policy? An OVER-approximation by design: it counts source points
109
+ * where {@link samplingInput} counts the ones a window still draws, so
110
+ * it may say yes where the build then samples nothing.
111
+ *
112
+ * The incremental session asks it, and there the safe direction is
113
+ * this one: a wholesale rebuild is always the right picture, while an
114
+ * appended vertex on a line whose points the sampler chose would be a
115
+ * vertex the wholesale build never selected.
116
+ * @param {SamplingPolicy|null} policy
117
+ * @param {boolean} time
118
+ * @param {number} count
119
+ * @returns {boolean}
120
+ */
121
+ export function mightSample(policy, time, count) {
122
+ if (policy === null) return false;
123
+ if (policy.auto && (!time || count <= SAMPLING_THRESHOLD)) return false;
124
+ return count > policy.target;
125
+ }
126
+
127
+ /**
128
+ * The canonical samples one line series offers the sampler, or `null`
129
+ * when it offers none: a point with no finite x, an x that goes
130
+ * backwards, or nothing the policy applies to.
131
+ *
132
+ * The records are `{ at, value }` and nothing else, which is what lets
133
+ * `downsampleSeries` hand this very array back without copying it. A
134
+ * reading with no plottable y — absent, not a number, or non-positive
135
+ * under a log scale — is a `null` value, which is the same measured
136
+ * gap the kernel already refuses to draw through.
137
+ * @param {any[]} points
138
+ * @param {SamplingPolicy} policy
139
+ * @param {boolean} time
140
+ * @param {boolean} log
141
+ * @param {number|null} xDrop window low bound (samples below it are not drawn)
142
+ * @returns {{ at: number, value: number|null }[] | null}
143
+ */
144
+ export function samplingInput(points, policy, time, log, xDrop) {
145
+ if (policy.auto && !time) return null;
146
+ // decide before allocating: a window can only REMOVE points, so the
147
+ // source length bounds the sampler's input, and a series too short to
148
+ // reduce must cost a line under two thousand points nothing at all
149
+ if (policy.auto && points.length <= SAMPLING_THRESHOLD) return null;
150
+ if (points.length <= policy.target) return null;
151
+ /** @type {any[]} */
152
+ const out = [];
153
+ let previous = -Infinity;
154
+ for (const p of points) {
155
+ const px = numOf(p?.x);
156
+ if (!Number.isFinite(px) || px < previous) return null;
157
+ previous = px;
158
+ if (xDrop !== null && px < xDrop) continue;
159
+ const py = numOf(p?.y);
160
+ const plottable = Number.isFinite(py) && (!log || py > 0);
161
+ out.push({ at: px, value: plottable ? py : null });
162
+ }
163
+ if (policy.auto && out.length <= SAMPLING_THRESHOLD) return null;
164
+ if (out.length <= policy.target) return null;
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * Sample one series' points into unit vertices, or `null` when the
170
+ * policy leaves this series alone.
171
+ *
172
+ * The kernel keeps every segment's ends and one marker per run of gaps,
173
+ * so the drawn line still starts and ends where the data does and every
174
+ * hole stays a hole. Every vertex here carries a source point's own
175
+ * instant and reading — the sampler chooses points, it never averages
176
+ * them into new ones.
177
+ * @param {any[]} points
178
+ * @param {SamplingPolicy} policy
179
+ * @param {boolean} time
180
+ * @param {boolean} log
181
+ * @param {number|null} xDrop
182
+ * @param {(v:number)=>number} xScale
183
+ * @param {(v:number)=>number} yScale
184
+ * @returns {{ vertices: ({u:number,v:number}|null)[], sourceCount: number } | null}
185
+ */
186
+ export function sampleLineSeries(points, policy, time, log, xDrop, xScale, yScale) {
187
+ const input = samplingInput(points, policy, time, log, xDrop);
188
+ if (input === null) return null;
189
+ let kept;
190
+ try {
191
+ kept = downsampleSeries(input, { target: policy.target, method: policy.method });
192
+ }
193
+ catch {
194
+ // the one refusal a policy can provoke: a target too small to hold
195
+ // this series' segment ends and gap markers. Drawing every point is
196
+ // the honest answer — the kernel would rather refuse than lie, and
197
+ // the chart would rather be slow than wrong.
198
+ return null;
199
+ }
200
+ const vertices = kept.points.map((sample) => {
201
+ if (sample.value === null) return null;
202
+ const v = yScale(sample.value);
203
+ return Number.isFinite(v) ? { u: xScale(sample.at), v: clamp01(v) } : null;
204
+ });
205
+ return { vertices, sourceCount: points.length };
206
+ }
Binary file
package/src/index.js CHANGED
@@ -36,5 +36,9 @@ export { buildTreemapAST, renderTreemapAST } from './types/treemap.js';
36
36
  export { buildStreamgraphAST, renderStreamgraphAST } from './types/streamgraph.js';
37
37
  export { buildSankeyAST, renderSankeyAST } from './types/sankey.js';
38
38
  export { buildMapAST, renderMapAST } from './types/map.js';
39
+ export {
40
+ normalizeSampling, SAMPLING_THRESHOLD, SAMPLING_WIDTH,
41
+ SAMPLING_TARGET_MIN, SAMPLING_TARGET_MAX,
42
+ } from './core/sampling.js';
39
43
  export { createStreamAdapter } from './core/stream-adapter.js';
40
44
  export { createChartSession } from './core/session.js';
package/src/types/line.js CHANGED
@@ -9,7 +9,14 @@
9
9
  *
10
10
  * data = { series: [{ name, points: [{x, y}] }] }
11
11
  * config = { type:'line', title?, x?: 'linear'|'time', log?,
12
- * markers?, xLabel?, yLabel?, domain? }
12
+ * markers?, xLabel?, yLabel?, domain?, sampling? }
13
+ *
14
+ * `config.sampling` (`core/sampling.js`) decides how many of those
15
+ * points are drawn: above two thousand a time line is reduced through
16
+ * `@jarenjs/core/series`'s downsampler by default, and the AST reports
17
+ * what that cost under `sampling`. The domain is always scanned from
18
+ * every source point, so what a reader is told about the range does not
19
+ * depend on what fitted on the line.
13
20
  *
14
21
  * `config.domain` declares a domain-stability policy (`core/domain.js`)
15
22
  * so most streaming ticks keep the scales still: a quantized sliding
@@ -41,6 +48,7 @@ import {
41
48
  } from '../core/domain.js';
42
49
  import { CATEGORICAL, seriesColor } from '../core/palette.js';
43
50
  import { normalizeTooltip, markProps } from '../core/marks.js';
51
+ import { normalizeSampling, sampleLineSeries } from '../core/sampling.js';
44
52
 
45
53
  /**
46
54
  * @typedef {object} LineAST
@@ -52,6 +60,9 @@ import { normalizeTooltip, markProps } from '../core/marks.js';
52
60
  * @property {{name: string, swatch: number}[]|null} legend
53
61
  * @property {{name: string, points: ({u:number,v:number}|null)[]}[]} series
54
62
  * @property {boolean} markers
63
+ * @property {{method: 'lttb'|'minmax', target: number, sourceCount: number,
64
+ * renderedCount: number}|null} sampling what the downsampler did, or
65
+ * `null` when every source point is drawn
55
66
  */
56
67
 
57
68
  /**
@@ -203,10 +214,24 @@ export function buildLineAST(data, config = {}) {
203
214
  const domains = resolveLineDomains(scanLineExtremes(input, policy, log), policy, time, log);
204
215
  const { xScale, yScale } = lineScales(domains, time, log);
205
216
 
206
- const series = input.map((s) => ({
207
- name: String(s.name ?? ''),
208
- points: s.points.map((p) => lineVertex(p, domains.xDrop, xScale, yScale)),
209
- }));
217
+ // Sampling chooses which points are DRAWN; it never moves a domain,
218
+ // which is scanned from every source point above. A series the policy
219
+ // leaves alone is mapped exactly as it always was.
220
+ const sampler = normalizeSampling(config.sampling);
221
+ let sourceCount = 0;
222
+ let renderedCount = 0;
223
+ let reduced = false;
224
+ const series = input.map((s) => {
225
+ sourceCount += s.points.length;
226
+ const sampled = sampler === null ? null
227
+ : sampleLineSeries(s.points, sampler, time, log, domains.xDrop, xScale, yScale);
228
+ const points = sampled === null
229
+ ? s.points.map((p) => lineVertex(p, domains.xDrop, xScale, yScale))
230
+ : sampled.vertices;
231
+ if (sampled !== null) reduced = true;
232
+ renderedCount += points.length;
233
+ return { name: String(s.name ?? ''), points };
234
+ });
210
235
 
211
236
  return {
212
237
  type: 'line',
@@ -226,6 +251,9 @@ export function buildLineAST(data, config = {}) {
226
251
  legend: series.length > 1 ? series.map((s, i) => ({ name: s.name, swatch: i })) : null,
227
252
  series,
228
253
  markers: config.markers === true,
254
+ sampling: !reduced || sampler === null ? null : {
255
+ method: sampler.method, target: sampler.target, sourceCount, renderedCount,
256
+ },
229
257
  };
230
258
  }
231
259