@jarenjs/charts 0.46.5 → 0.56.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 +79 -9
- package/dist/types/core/axis.d.ts +12 -37
- package/dist/types/core/sampling.d.ts +143 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/types/line.d.ts +21 -1
- package/package.json +3 -3
- package/schemas/chart-definition.schema.json +25 -0
- package/src/core/axis.js +16 -128
- package/src/core/sampling.js +206 -0
- package/src/core/session.js +0 -0
- package/src/index.js +4 -0
- package/src/types/line.js +33 -5
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
|
255
|
-
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
254
|
+
<!--fact: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
|
+
<!--/fact-->
|
|
262
|
+
|
|
263
|
+
The session tick is *flat* in n — it
|
|
264
|
+
moves <!--fact:charts.sessionFlatness-->0.7× while the wholesale tick grows 28.6×<!--/fact-->. 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
|
+
<!--fact: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
|
+
<!--/fact-->
|
|
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 — <!--fact:charts.samplingWin-->100,000 points draw as 560 and render 5.1× faster<!--/fact-->. 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.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.56.0",
|
|
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.
|
|
74
|
-
"@jarenjs/view": "^0.
|
|
73
|
+
"@jarenjs/core": "^0.56.0",
|
|
74
|
+
"@jarenjs/view": "^0.56.0"
|
|
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.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
}
|
package/src/core/session.js
CHANGED
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|