@pond-ts/charts 0.51.0 → 0.53.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/CHANGELOG.md +161 -1
- package/dist/ChartContainer.d.ts +30 -1
- package/dist/ChartContainer.js +72 -1
- package/dist/ScatterChart.d.ts +12 -1
- package/dist/ScatterChart.js +3 -2
- package/dist/context.d.ts +9 -2
- package/dist/decimate.d.ts +39 -0
- package/dist/decimate.js +109 -0
- package/dist/elapsed.d.ts +140 -0
- package/dist/elapsed.js +247 -0
- package/dist/encoding.d.ts +9 -0
- package/dist/encoding.js +5 -1
- package/dist/scatter.d.ts +3 -2
- package/dist/scatter.js +61 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
|
|
|
8
8
|
tag, so this file covers them all. Pre-1.0: minor bumps may include new features
|
|
9
9
|
and type-level changes; patch bumps are strictly additive.
|
|
10
10
|
|
|
11
|
-
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.
|
|
11
|
+
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.53.0...HEAD
|
|
12
|
+
[0.53.0]: https://github.com/pond-ts/pond/compare/v0.52.0...v0.53.0
|
|
13
|
+
[0.52.0]: https://github.com/pond-ts/pond/compare/v0.51.0...v0.52.0
|
|
12
14
|
[0.51.0]: https://github.com/pond-ts/pond/compare/v0.50.0...v0.51.0
|
|
13
15
|
[0.50.0]: https://github.com/pond-ts/pond/compare/v0.49.0...v0.50.0
|
|
14
16
|
[0.49.0]: https://github.com/pond-ts/pond/compare/v0.48.1...v0.49.0
|
|
@@ -51,6 +53,164 @@ and type-level changes; patch bumps are strictly additive.
|
|
|
51
53
|
|
|
52
54
|
## [Unreleased]
|
|
53
55
|
|
|
56
|
+
## [0.53.0] — 2026-07-25
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **fit (breaking):** **Power bins and zones now use pond's canonical bin
|
|
61
|
+
edges**, so they feed `@pond-ts/charts` with no mapping step:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
<BarChart bins={power.distribution} column="seconds" />
|
|
65
|
+
<BarChart bins={power.zones} column="seconds" orientation="horizontal" ordinal />
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Each type previously spoke its own dialect for the same concept —
|
|
69
|
+
`PowerBin.wattsFrom` (with **no upper edge at all**), `ZoneTime.lo`/`hi`, and
|
|
70
|
+
`PowerZone.minWatts`/`maxWatts` — while core's `byColumn` and charts' `BinRecord`
|
|
71
|
+
both use `{ start, end, …aggregates }`. Every caller had to hand-map before
|
|
72
|
+
drawing, even though the internals already computed the canonical shape and
|
|
73
|
+
discarded it.
|
|
74
|
+
|
|
75
|
+
**Migration** (pre-1.0, so the old names are gone rather than deprecated):
|
|
76
|
+
|
|
77
|
+
| Was | Now |
|
|
78
|
+
| --------------------- | -------------------------------------- |
|
|
79
|
+
| `PowerBin.wattsFrom` | `PowerBin.start` (+ new `end`) |
|
|
80
|
+
| `ZoneTime.lo` / `.hi` | `ZoneTime.start` / `.end`, `openEnded` |
|
|
81
|
+
| `PowerZone.minWatts` | `PowerZone.start` |
|
|
82
|
+
| `PowerZone.maxWatts` | `PowerZone.end`, `openEnded` |
|
|
83
|
+
|
|
84
|
+
Only **`PowerZone.maxWatts`** — a zone's upper edge — is affected. The
|
|
85
|
+
identically-named `PowerSummary.maxWatts` and the per-lap / per-section peak
|
|
86
|
+
power are a different concept and are unchanged.
|
|
87
|
+
|
|
88
|
+
`end` is now **always finite and always `> start`** — the guarantee core
|
|
89
|
+
enforces (`byColumn` throws on a zero-width bin) and charts need (an infinite
|
|
90
|
+
edge blows up an axis domain). The open-ended top band, which previously
|
|
91
|
+
carried only `Infinity`, gets a **drawable stand-in** edge: wide enough to
|
|
92
|
+
cover the highest value observed, and at least as wide as the band below it.
|
|
93
|
+
Treat it as a drawing bound rather than data, and test for the band with the
|
|
94
|
+
new **`openEnded`** flag rather than comparing an edge against `Infinity`
|
|
95
|
+
(`openEnded` is now also strictly positional — only the final band can carry
|
|
96
|
+
it). Rounding zone edges to whole watts no longer collapses bands at very low
|
|
97
|
+
FTPs.
|
|
98
|
+
|
|
99
|
+
### Added
|
|
100
|
+
|
|
101
|
+
- **charts:** **Duration (elapsed) x axis** — `<ChartContainer origin>` labels
|
|
102
|
+
the shared x axis as offsets from a zero point instead of absolute values, so
|
|
103
|
+
a workout / lab run / load test reads `00:00 00:05 00:10` rather than
|
|
104
|
+
`10:35 10:40 10:45`:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
<ChartContainer width={620} origin="data">
|
|
108
|
+
…
|
|
109
|
+
<XAxis label="Elapsed" />
|
|
110
|
+
</ChartContainer>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`'data'` zeroes at the start of the data (and stays there as you pan); a
|
|
114
|
+
**number** sets an explicit zero point — a gun, a trigger, a lap — with ticks
|
|
115
|
+
before it reading negative (`-00:05`). Ticks are placed at round durations
|
|
116
|
+
**measured from the origin** (a ride starting at 10:33:17 ticks 10:33:17,
|
|
117
|
+
10:38:17, …), off a clock ladder (…15s, 30s, 1m, 2m, 5m, …, 12h, then whole
|
|
118
|
+
days) rather than the 1-2-5 ladder — the part a formatter alone can't do.
|
|
119
|
+
Labels pick their shape from the step and the axis's magnitude
|
|
120
|
+
(`00:00.500` · `00:15` · `01:01:30` · `1d 12:00` · `5d`), gridlines follow the
|
|
121
|
+
same ticks, and the cursor / marker pills read one grain finer (`00:05:12`).
|
|
122
|
+
|
|
123
|
+
It's a **labelling** mode, not a data transform: `range`, `<Marker at>`,
|
|
124
|
+
`onRegionSelect`, `trackerPosition` all stay in absolute axis units. The same
|
|
125
|
+
prop works on a **value** x axis (distance travelled, not distance recorded).
|
|
126
|
+
An explicit format still wins — on a time axis a d3 _time_ specifier can only
|
|
127
|
+
describe an instant, so it labels the wall clock, which is the lever for
|
|
128
|
+
stacking a wall-clock strip under a duration strip on one shared tick set; on
|
|
129
|
+
a value axis a number specifier formats the offset. Ignored on a category
|
|
130
|
+
axis; on a trading calendar the durations are wall-clock, so ticks spanning a
|
|
131
|
+
collapsed session gap sit unevenly.
|
|
132
|
+
|
|
133
|
+
- **fit:** `computePower` takes an options object — **`{ binWatts }`** sets the
|
|
134
|
+
width of the `distribution` buckets (default `1`, unchanged). 1 W bins draw as
|
|
135
|
+
hairlines, so pass the width you intend to render rather than re-bucketing the
|
|
136
|
+
output yourself. It throws `RangeError` on a non-positive or non-finite
|
|
137
|
+
`binWatts`. New exported type `ComputePowerOptions`, also accepted by the
|
|
138
|
+
activity façade: `Activity.power(ftp, options)` and
|
|
139
|
+
`ProfiledActivity.power(options)`.
|
|
140
|
+
|
|
141
|
+
## [0.52.0] — 2026-07-23
|
|
142
|
+
|
|
143
|
+
### Changed
|
|
144
|
+
|
|
145
|
+
- **core / financial:** **Market-scale studies are now typed-array fast**
|
|
146
|
+
(the "SMA/EMA at 1M bars costs hundreds of ms" report). Three cuts along
|
|
147
|
+
the same path, all behaviour-preserving (identical values, warm-ups,
|
|
148
|
+
missing-cell semantics, and rejection errors; every fast path falls back
|
|
149
|
+
to the original sweep when it doesn't apply):
|
|
150
|
+
- **`smooth('ema')` columnar fast path** — on a packed numeric source
|
|
151
|
+
column the EMA recurrence runs straight off the typed buffer into a
|
|
152
|
+
typed result column via trusted construction (key + untouched columns
|
|
153
|
+
pass through zero-copy), replacing the per-row Event/tuple rebuild +
|
|
154
|
+
full-series intake re-pack. 1M rows: **530 ms → 4.4 ms (~120×)**.
|
|
155
|
+
- **`rolling({ count })` numeric fast path** — an all-built-in numeric
|
|
156
|
+
mapping over packed sources feeds the shared incremental reducer states
|
|
157
|
+
directly from the typed buffers and writes snapshots into typed columns
|
|
158
|
+
(no per-row snapshot arrays, no boxed accumulators, no post-pass
|
|
159
|
+
assert/re-pack). 1M rows, `avg`: **135 ms → 32 ms (~4×)**.
|
|
160
|
+
- **financial kernel reads columns, not events** — `rollingColumns` /
|
|
161
|
+
`columnValues` now read study inputs/outputs off the public column API
|
|
162
|
+
instead of materializing `series.events` (an Event + data object per
|
|
163
|
+
row, ~400 ms of pure overhead at 1M rows).
|
|
164
|
+
- End-to-end at 1M bars: `ema()` **603 ms → 2.5 ms (~240×)**, `sma()`
|
|
165
|
+
**569 ms → 56 ms (~10×)**, `bollinger()` **748 ms → 162 ms (~4.6×)**.
|
|
166
|
+
Durable benchmarks: `packages/core/scripts/perf-smooth-ema.mjs`,
|
|
167
|
+
`packages/financial/scripts/perf-studies.mjs`.
|
|
168
|
+
|
|
169
|
+
### Added
|
|
170
|
+
|
|
171
|
+
- **core:** **`TimeSeries.fromArrow(table, options?)` — ingest a decoded Apache
|
|
172
|
+
Arrow `Table`.** pond stays zero-dependency: bring your own Arrow
|
|
173
|
+
(`tableFromIPC(...)`) and hand the `Table` in; the input is duck-typed against
|
|
174
|
+
a small structural surface (`ArrowTableLike` / `ArrowVectorLike` / …, all
|
|
175
|
+
exported). Ingest is the zero-copy path — every `Float64` column's backing
|
|
176
|
+
`Float64Array` is adopted as-is (`Float32`/int columns convert; int64 value
|
|
177
|
+
columns recombine BigInt-free), and the schema is derived from the Arrow
|
|
178
|
+
fields. The time key is converted **BigInt-free**: Arrow's idiomatic int64
|
|
179
|
+
timestamps are recombined from their two int32 halves rather than
|
|
180
|
+
`Number(bigint)` per row — measured **~11× faster** on the time column (0.6ms
|
|
181
|
+
vs 6.8ms at 500k rows; `scripts/perf-from-arrow.mjs`). Options: `time` (key
|
|
182
|
+
column, default the `'time'` field), `timeUnit` (default read from the Arrow
|
|
183
|
+
Arrow type family — a `Timestamp`'s raw-unit int64 is scaled by its
|
|
184
|
+
`TimeUnit`; `Date32`/`Date64` arrive already normalized to epoch-ms and pass
|
|
185
|
+
through; overridable), `columns` (subset, in order), `name`, `sort`. Numeric
|
|
186
|
+
**and string** columns are supported — string columns (Arrow
|
|
187
|
+
`Utf8`) become dict-encoded `StringColumn`s; any other Arrow type
|
|
188
|
+
(list/struct) throws, naming it. A null time key throws; numeric nulls map to
|
|
189
|
+
`NaN` and string nulls to missing.
|
|
190
|
+
- **core:** **`TimeSeries.fromColumns` / `ValueSeries.fromColumns` now accept
|
|
191
|
+
`string` value columns** (previously numeric-only), packed to dict-encoded
|
|
192
|
+
`StringColumn`s (`null`/`undefined` → missing) — the shared columnar-ingress
|
|
193
|
+
engine now dispatches on the schema kind. Other value kinds (`boolean`,
|
|
194
|
+
arrays) still throw.
|
|
195
|
+
- **charts:** **`<ScatterChart decimate>` — dense scatter plots now decimate**
|
|
196
|
+
(PND-MARKDEC scatter half — the last un-decimated mark type). **Default
|
|
197
|
+
`true`.** When the marks are **uniform** (fixed size + colour, no data-driven
|
|
198
|
+
`radius`/`color`), **opaque**, and denser than the pixel grid, overlapping
|
|
199
|
+
marks collapse to one representative per **mark-radius cell** via a 2D
|
|
200
|
+
pixel-**occupancy** sweep. Scatter has no fill, so a line/bar's per-column
|
|
201
|
+
`[min, max]` envelope would erase interior points — the occupancy grid keeps
|
|
202
|
+
one mark per occupied cell instead, which is **visually lossless** for uniform
|
|
203
|
+
opaque marks at that density (same-cell marks overlap). Interaction (hover /
|
|
204
|
+
click / tracker) still reads **every source point**; the per-point selection
|
|
205
|
+
ring + labels are suppressed only on the decimated (dense) path. A
|
|
206
|
+
**translucent** fill (density-encoded — overlap _should_ build up) or a
|
|
207
|
+
data-driven size/colour keeps the full draw. `decimate={false}` draws every
|
|
208
|
+
mark; `{ threshold }` tunes the trigger. The occupancy sweep uses the affine
|
|
209
|
+
fast path for the per-point pixel mapping. Measured (SciChart-suite
|
|
210
|
+
point-update, real browser): **100k 18 → 73 fps (4×)**, and the ladder now
|
|
211
|
+
runs to **10M** points (previously dead by 1M). `drawScatter` now returns
|
|
212
|
+
`LayerDrawStats` (visible via `onDrawStats`).
|
|
213
|
+
|
|
54
214
|
## [0.51.0] — 2026-07-22
|
|
55
215
|
|
|
56
216
|
### Changed
|
package/dist/ChartContainer.d.ts
CHANGED
|
@@ -377,6 +377,35 @@ export interface ChartContainerProps {
|
|
|
377
377
|
* `cursorFormat`.)
|
|
378
378
|
*/
|
|
379
379
|
cursorFormat?: CursorFormat;
|
|
380
|
+
/**
|
|
381
|
+
* Label the x axis as **offsets from a zero point** instead of absolute
|
|
382
|
+
* values — the *duration* (elapsed-time) axis. A time axis reads
|
|
383
|
+
* `00:00 00:05 00:10` where it read `10:35 10:40 10:45`; a value axis reads
|
|
384
|
+
* distance-from-the-origin (`0 500 1000`) where it read absolute distance.
|
|
385
|
+
*
|
|
386
|
+
* - **`'data'`** — the start of the data (the union of the layers' x extents),
|
|
387
|
+
* so the labels are "since the beginning of the series" and stay put as you
|
|
388
|
+
* pan.
|
|
389
|
+
* - **a number** — an explicit zero point in axis units: a race gun, a trigger
|
|
390
|
+
* instant, a lap marker. Ticks before it read negative (`-00:05` — the
|
|
391
|
+
* T-minus case).
|
|
392
|
+
*
|
|
393
|
+
* Ticks are placed at round durations **measured from the origin**, not at the
|
|
394
|
+
* wall-clock boundaries the calendar ladder would pick — that's the difference
|
|
395
|
+
* between `00:00 00:05 00:10` and `00:01:43 00:06:43`. Gridlines follow them,
|
|
396
|
+
* and so does the cursor pill (one grain finer, as ever: `00:05:12`).
|
|
397
|
+
*
|
|
398
|
+
* This is a **labelling** mode, not a data transform: `range`, an annotation's
|
|
399
|
+
* `at`, an `onRegionSelect` span, `trackerPosition` are all still absolute
|
|
400
|
+
* axis units. Ignored on a category axis. An explicit `timeFormat` /
|
|
401
|
+
* `<XAxis format>` still wins — on a time axis a d3 *time* specifier can only
|
|
402
|
+
* describe an instant, so it labels the underlying wall clock (the lever for
|
|
403
|
+
* stacking a wall-clock strip under a duration strip, on shared ticks); on a
|
|
404
|
+
* value axis a number specifier formats the offset. On a trading-calendar
|
|
405
|
+
* axis the durations are **wall-clock**, so ticks spanning a collapsed session
|
|
406
|
+
* gap sit unevenly — elapsed *trading* time is not implemented.
|
|
407
|
+
*/
|
|
408
|
+
origin?: number | 'data';
|
|
380
409
|
/** Visual theme for all rows; defaults to {@link defaultTheme}. */
|
|
381
410
|
theme?: ChartTheme;
|
|
382
411
|
children?: ReactNode;
|
|
@@ -390,5 +419,5 @@ export interface ChartContainerProps {
|
|
|
390
419
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
391
420
|
* (`<YAxis>`).
|
|
392
421
|
*/
|
|
393
|
-
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom, bounds, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
422
|
+
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom, bounds, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
394
423
|
//# sourceMappingURL=ChartContainer.d.ts.map
|
package/dist/ChartContainer.js
CHANGED
|
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } f
|
|
|
3
3
|
import { scaleLinear } from 'd3-scale';
|
|
4
4
|
import { identityProvider, scaleTradingTime, } from './tradingTimeScale.js';
|
|
5
5
|
import { scaleBand } from './bandScale.js';
|
|
6
|
+
import { scaleElapsed } from './elapsed.js';
|
|
6
7
|
import { Sequence } from 'pond-ts';
|
|
7
8
|
import { ContainerContext, CursorContext, } from './context.js';
|
|
8
9
|
import { maxSlotWidths, sum } from './slots.js';
|
|
@@ -44,7 +45,7 @@ function normalizeRange(range) {
|
|
|
44
45
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
45
46
|
* (`<YAxis>`).
|
|
46
47
|
*/
|
|
47
|
-
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
|
|
48
|
+
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
|
|
48
49
|
// Normalize the `panZoom` mode (boolean shorthand or the three-way string)
|
|
49
50
|
// into the two gesture flags the event surface reads. `true` ⇒ both; `'pan'`
|
|
50
51
|
// ⇒ drag only; `false`/`'none'` ⇒ neither. Zoom implies pan (there is no
|
|
@@ -391,6 +392,17 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
391
392
|
const xTickCount = resolvedKind === 'time'
|
|
392
393
|
? Math.max(2, Math.floor(plotWidth / TRADING_TICK_PX))
|
|
393
394
|
: TIME_TICK_COUNT;
|
|
395
|
+
// The elapsed-axis zero point (`origin`), resolved to a number: `'data'` is
|
|
396
|
+
// the start of the data, which before any layer registers falls back to the
|
|
397
|
+
// domain start (the same two-pass settle `resolvedKind` makes). A category
|
|
398
|
+
// axis has no numeric origin to offset from, and a non-finite one is ignored
|
|
399
|
+
// rather than poisoning every tick.
|
|
400
|
+
const elapsedOrigin = useMemo(() => {
|
|
401
|
+
if (origin === undefined || resolvedKind === 'category')
|
|
402
|
+
return undefined;
|
|
403
|
+
const at = origin === 'data' ? (autoExtent?.[0] ?? d0) : origin;
|
|
404
|
+
return Number.isFinite(at) ? at : undefined;
|
|
405
|
+
}, [origin, resolvedKind, autoExtent, d0]);
|
|
394
406
|
const { xScale, formatTime, formatReadout } = useMemo(() => {
|
|
395
407
|
if (resolvedKind === 'category') {
|
|
396
408
|
// Ordinal column-domain axis: a band scale over the category slots. The
|
|
@@ -411,6 +423,24 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
411
423
|
}
|
|
412
424
|
if (resolvedKind === 'value') {
|
|
413
425
|
const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
|
|
426
|
+
if (elapsedOrigin !== undefined) {
|
|
427
|
+
// Offset (elapsed) value axis: same pixels, ticks anchored at the
|
|
428
|
+
// origin, labels reading `v - origin`. A `timeFormat` / `cursorFormat`
|
|
429
|
+
// number specifier resolves through the *offset* domain (that's what
|
|
430
|
+
// the wrapper's `tickFormat` does), so a specifier describes the number
|
|
431
|
+
// actually on show.
|
|
432
|
+
const e = scaleElapsed(s, { origin: elapsedOrigin, kind: 'value' });
|
|
433
|
+
const labels = resolveAxisFormat(e, xTickCount, timeFormat);
|
|
434
|
+
return {
|
|
435
|
+
xScale: e,
|
|
436
|
+
formatTime: labels,
|
|
437
|
+
formatReadout: typeof cursorFormat === 'function'
|
|
438
|
+
? (v) => cursorFormat(v, { grain: undefined, defaultText: labels(v) })
|
|
439
|
+
: cursorFormat !== undefined
|
|
440
|
+
? resolveAxisFormat(e, xTickCount, cursorFormat)
|
|
441
|
+
: undefined,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
414
444
|
const labels = resolveAxisFormat(s, xTickCount, timeFormat);
|
|
415
445
|
// The value-axis readout channel: a `cursorFormat` **string** is a d3
|
|
416
446
|
// *number* specifier here (resolved through the linear scale, exactly as
|
|
@@ -447,6 +477,42 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
447
477
|
}
|
|
448
478
|
return undefined;
|
|
449
479
|
};
|
|
480
|
+
// The **elapsed** (duration) flavour of both channels — the same scale
|
|
481
|
+
// wrapped so its ticks are anchored at `at` and its labels are durations.
|
|
482
|
+
// `at` is passed rather than closed over so the caller's `!== undefined`
|
|
483
|
+
// narrowing carries in.
|
|
484
|
+
const elapsedTime = (s, at) => {
|
|
485
|
+
const e = scaleElapsed(s, {
|
|
486
|
+
origin: at,
|
|
487
|
+
kind: 'time',
|
|
488
|
+
// An explicit d3 time specifier can only describe an instant, so it
|
|
489
|
+
// labels the wall clock underneath — the wall-clock-strip-under-a-
|
|
490
|
+
// duration-strip lever (see the `origin` prop docs).
|
|
491
|
+
absolute: (count, specifier) => {
|
|
492
|
+
const f = s.tickFormat(count, specifier);
|
|
493
|
+
return (v) => f(new Date(v));
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
// Labels: durations, unless a container `timeFormat` owns them.
|
|
497
|
+
const labels = timeFormat !== undefined
|
|
498
|
+
? resolveTimeFormat(e, xTickCount, timeFormat)
|
|
499
|
+
: e.tickFormat(xTickCount);
|
|
500
|
+
// Readout: one grain finer than the ticks (`00:05:12` under a `00:05`
|
|
501
|
+
// axis) — the elapsed twin of the calendar axis's `readoutFormat`. Set
|
|
502
|
+
// explicitly (not left `undefined` to fall back to the labels) because
|
|
503
|
+
// here the labels ARE the terse tick text: an elapsed axis runs no
|
|
504
|
+
// date-style ladder, so nothing else would restore the precision.
|
|
505
|
+
const fine = e.readoutFormat(xTickCount);
|
|
506
|
+
const readout = typeof cursorFormat === 'function'
|
|
507
|
+
? (v) => cursorFormat(v, {
|
|
508
|
+
grain: s.grain(xTickCount),
|
|
509
|
+
defaultText: fine(v),
|
|
510
|
+
})
|
|
511
|
+
: cursorFormat !== undefined
|
|
512
|
+
? resolveTimeFormat(e, xTickCount, cursorFormat)
|
|
513
|
+
: fine;
|
|
514
|
+
return { xScale: e, formatTime: labels, formatReadout: readout };
|
|
515
|
+
};
|
|
450
516
|
if (xDiscontinuities !== undefined) {
|
|
451
517
|
// Trading-time axis: closed-market gaps collapse, time proportional within
|
|
452
518
|
// sessions. Same tickFormat surface as scaleTime, so the readout is shared.
|
|
@@ -455,6 +521,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
455
521
|
const s = scaleTradingTime(xDiscontinuities)
|
|
456
522
|
.domain([d0, d1])
|
|
457
523
|
.range([0, plotWidth]);
|
|
524
|
+
if (elapsedOrigin !== undefined)
|
|
525
|
+
return elapsedTime(s, elapsedOrigin);
|
|
458
526
|
return {
|
|
459
527
|
xScale: s,
|
|
460
528
|
formatTime: timeLabels(s),
|
|
@@ -470,6 +538,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
470
538
|
const s = scaleTradingTime(identityProvider())
|
|
471
539
|
.domain([d0, d1])
|
|
472
540
|
.range([0, plotWidth]);
|
|
541
|
+
if (elapsedOrigin !== undefined)
|
|
542
|
+
return elapsedTime(s, elapsedOrigin);
|
|
473
543
|
return {
|
|
474
544
|
xScale: s,
|
|
475
545
|
formatTime: timeLabels(s),
|
|
@@ -483,6 +553,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
483
553
|
plotWidth,
|
|
484
554
|
timeFormat,
|
|
485
555
|
cursorFormat,
|
|
556
|
+
elapsedOrigin,
|
|
486
557
|
xDiscontinuities,
|
|
487
558
|
xTickCount,
|
|
488
559
|
]);
|
package/dist/ScatterChart.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ValueSeries } from 'pond-ts';
|
|
2
2
|
import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
|
|
3
3
|
import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
|
|
4
|
+
import type { DecimateOption } from './decimate.js';
|
|
4
5
|
export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
|
|
5
6
|
/**
|
|
6
7
|
* The source series. A `TimeSeries` scatters against the time axis; a
|
|
@@ -85,6 +86,16 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
|
|
|
85
86
|
* together, so a nudged point still selects.
|
|
86
87
|
*/
|
|
87
88
|
offset?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Collapse dense, **uniform** marks to one representative per pixel cell —
|
|
91
|
+
* lossless at that density, so a scatter of 100k+ points stays interactive.
|
|
92
|
+
* **Default `true`.** It engages only when the marks are a fixed size + colour
|
|
93
|
+
* (no data-driven `radius`/`color`), the fill is opaque, and the visible points
|
|
94
|
+
* are denser than the pixel grid; otherwise every point draws. Interaction
|
|
95
|
+
* (hover / click / tracker) always reads the source points. `decimate={false}`
|
|
96
|
+
* draws every mark; `{ threshold }` tunes the samples-per-pixel trigger.
|
|
97
|
+
*/
|
|
98
|
+
decimate?: DecimateOption;
|
|
88
99
|
/**
|
|
89
100
|
* This layer's `<Legend>` row: `false` ⇒ no row (opt out), a string ⇒ the
|
|
90
101
|
* row's display name. **Omitted ⇒ a row named by the layer's readout
|
|
@@ -126,5 +137,5 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
|
|
|
126
137
|
* </Layers>
|
|
127
138
|
* ```
|
|
128
139
|
*/
|
|
129
|
-
export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, legend, index, }: ScatterChartProps<S, VS>): null;
|
|
140
|
+
export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, decimate, legend, index, }: ScatterChartProps<S, VS>): null;
|
|
130
141
|
//# sourceMappingURL=ScatterChart.d.ts.map
|
package/dist/ScatterChart.js
CHANGED
|
@@ -34,7 +34,7 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
34
34
|
* </Layers>
|
|
35
35
|
* ```
|
|
36
36
|
*/
|
|
37
|
-
export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, legend, index = 0, }) {
|
|
37
|
+
export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, decimate = true, legend, index = 0, }) {
|
|
38
38
|
const container = useContext(ContainerContext);
|
|
39
39
|
if (container === null) {
|
|
40
40
|
throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
|
|
@@ -131,7 +131,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
131
131
|
: {
|
|
132
132
|
hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel, offset),
|
|
133
133
|
}),
|
|
134
|
-
draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset),
|
|
134
|
+
draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset, decimate),
|
|
135
135
|
},
|
|
136
136
|
axisId: axis,
|
|
137
137
|
index,
|
|
@@ -148,6 +148,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
|
|
|
148
148
|
font,
|
|
149
149
|
container.selected,
|
|
150
150
|
offset,
|
|
151
|
+
decimate,
|
|
151
152
|
axis,
|
|
152
153
|
index,
|
|
153
154
|
]);
|
package/dist/context.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { LegendItemSpec } from './swatch.js';
|
|
|
5
5
|
import type { Interval } from 'pond-ts';
|
|
6
6
|
import type { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
|
|
7
7
|
import type { ScaleBand } from './bandScale.js';
|
|
8
|
+
import type { ElapsedScale } from './elapsed.js';
|
|
8
9
|
/**
|
|
9
10
|
* The frame a {@link ChartContainer} provides to its rows and the time axis.
|
|
10
11
|
* The container owns the **shared x geometry**: each side is split into *slots*
|
|
@@ -219,8 +220,14 @@ export interface ContainerFrame {
|
|
|
219
220
|
* container is given `discontinuities`) is the third kind — same callable /
|
|
220
221
|
* `invert` / `ticks` / `tickFormat` surface, but the mapping runs through
|
|
221
222
|
* trading time so closed-market gaps collapse (see {@link discontinuities}).
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
*
|
|
224
|
+
* A container given an `origin` wraps whichever of these it built in an
|
|
225
|
+
* {@link ElapsedScale} — the same pixel mapping, but ticks anchored at the
|
|
226
|
+
* origin and labelled as offsets (`00:05`), which is how the whole frame
|
|
227
|
+
* (axis labels, gridlines, cursor pill) reads durations without any consumer
|
|
228
|
+
* knowing about the mode.
|
|
229
|
+
*/
|
|
230
|
+
readonly xScale: ScaleTime<number, number> | ScaleLinear<number, number> | TradingTimeScale | ScaleBand | ElapsedScale;
|
|
224
231
|
/**
|
|
225
232
|
* The discontinuity provider backing a **trading-time** x axis, if one was
|
|
226
233
|
* supplied to the container — closed-market time (weekends, holidays,
|
package/dist/decimate.d.ts
CHANGED
|
@@ -305,4 +305,43 @@ export interface BarColumnEnvelope {
|
|
|
305
305
|
* `resolveBarBaseline`), so the union is honest about the zero line.
|
|
306
306
|
*/
|
|
307
307
|
export declare function decimateBars(cs: BarSeries, xScale: Scale, ctx: CanvasRenderingContext2D, baseline: number, k?: number, visibleCount?: number): BarColumnEnvelope | null;
|
|
308
|
+
/**
|
|
309
|
+
* Decimate a **uniform** scatter to one representative mark per occupied
|
|
310
|
+
* **pixel cell** ([PND-MARKDEC] scatter half) — the marks analog of
|
|
311
|
+
* {@link decimateBars}, but **2D**: scatter has no fill, so a line/bar's
|
|
312
|
+
* `[min, max]`-per-column envelope would erase interior points. Instead this
|
|
313
|
+
* bins the plane into `cellPx × cellPx` cells and keeps the **first** point in
|
|
314
|
+
* each occupied cell.
|
|
315
|
+
*
|
|
316
|
+
* **Why it's visually lossless (for uniform, opaque marks):** with `cellPx` set
|
|
317
|
+
* to the mark **radius**, any two points sharing a cell are at most `cellPx·√2 ≈
|
|
318
|
+
* 1.4·r` apart — less than `2·r` — so their discs overlap; drawing one paints
|
|
319
|
+
* (essentially) the pixels of both. Points in different cells are kept, so a
|
|
320
|
+
* sparse-in-y scatter reduces little (and stays exact); a dense blob collapses to
|
|
321
|
+
* ~`plotArea / cellPx²` marks regardless of N. The reduction is therefore an
|
|
322
|
+
* **unconditional** identity for overlapping uniform marks — the caller's job is
|
|
323
|
+
* only to decide when the O(visible) sweep is worth it (density gate) and that
|
|
324
|
+
* the marks are in fact uniform + opaque (data-driven size/colour or translucent
|
|
325
|
+
* fill must **not** decimate — see {@link isOpaqueColor}).
|
|
326
|
+
*
|
|
327
|
+
* `cs.x` is sorted, so the sweep visits columns left-to-right and only the
|
|
328
|
+
* **current** column's occupied rows need tracking (a `Set` cleared on each
|
|
329
|
+
* column change — bounded memory, O(visible) total). Positions are in CSS px
|
|
330
|
+
* (`xScale`/`yScale` output), matching the mark radius' units. `[vStart, vEnd)`
|
|
331
|
+
* is the pre-culled visible window (defaults to the whole series). Gaps
|
|
332
|
+
* (non-finite y) are skipped, like {@link drawScatter}.
|
|
333
|
+
*/
|
|
334
|
+
export declare function decimateScatter(cs: ChartSeries, xScale: Scale, yScale: Scale, cellPx: number, vStart?: number, vEnd?: number): ChartSeries;
|
|
335
|
+
/**
|
|
336
|
+
* Whether `color` is fully opaque — the second precondition (beside
|
|
337
|
+
* {@link ResolvedEncoding.uniform}) for scatter decimation. A **translucent**
|
|
338
|
+
* fill accumulates opacity where marks overlap (that build-up _is_ the density
|
|
339
|
+
* signal), so collapsing overlaps would lighten the plot — decimation must not
|
|
340
|
+
* engage. Returns `false` only when it can **prove** alpha < 1
|
|
341
|
+
* (`rgba(…, a<1)`, `#rrggbbaa` / `#rgba` with alpha < full, or `transparent`);
|
|
342
|
+
* every other form (`#rgb` / `#rrggbb`, `rgb()`, a named colour, `hsl()`) is
|
|
343
|
+
* treated as opaque. Conservative in the safe direction: a colour it can't prove
|
|
344
|
+
* translucent still decimates, but the realistic translucent cases are caught.
|
|
345
|
+
*/
|
|
346
|
+
export declare function isOpaqueColor(color: string): boolean;
|
|
308
347
|
//# sourceMappingURL=decimate.d.ts.map
|
package/dist/decimate.js
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
* shrinks the point count, not a second renderer.
|
|
47
47
|
*/
|
|
48
48
|
import { Float64Column } from 'pond-ts';
|
|
49
|
+
import { affineOf } from './affine.js';
|
|
49
50
|
import { scaleDomain, cullChartSeries } from './culling.js';
|
|
50
51
|
/** The device-pixel bucket count for `ctx` — the backing buffer width, i.e.
|
|
51
52
|
* `plotWidthCss × DPR` (so buckets land at device-pixel resolution). Falls back
|
|
@@ -606,4 +607,112 @@ export function decimateBars(cs, xScale, ctx, baseline, k = 2, visibleCount = cs
|
|
|
606
607
|
}
|
|
607
608
|
return { begin, end, lo, hi, length: W };
|
|
608
609
|
}
|
|
610
|
+
/**
|
|
611
|
+
* Decimate a **uniform** scatter to one representative mark per occupied
|
|
612
|
+
* **pixel cell** ([PND-MARKDEC] scatter half) — the marks analog of
|
|
613
|
+
* {@link decimateBars}, but **2D**: scatter has no fill, so a line/bar's
|
|
614
|
+
* `[min, max]`-per-column envelope would erase interior points. Instead this
|
|
615
|
+
* bins the plane into `cellPx × cellPx` cells and keeps the **first** point in
|
|
616
|
+
* each occupied cell.
|
|
617
|
+
*
|
|
618
|
+
* **Why it's visually lossless (for uniform, opaque marks):** with `cellPx` set
|
|
619
|
+
* to the mark **radius**, any two points sharing a cell are at most `cellPx·√2 ≈
|
|
620
|
+
* 1.4·r` apart — less than `2·r` — so their discs overlap; drawing one paints
|
|
621
|
+
* (essentially) the pixels of both. Points in different cells are kept, so a
|
|
622
|
+
* sparse-in-y scatter reduces little (and stays exact); a dense blob collapses to
|
|
623
|
+
* ~`plotArea / cellPx²` marks regardless of N. The reduction is therefore an
|
|
624
|
+
* **unconditional** identity for overlapping uniform marks — the caller's job is
|
|
625
|
+
* only to decide when the O(visible) sweep is worth it (density gate) and that
|
|
626
|
+
* the marks are in fact uniform + opaque (data-driven size/colour or translucent
|
|
627
|
+
* fill must **not** decimate — see {@link isOpaqueColor}).
|
|
628
|
+
*
|
|
629
|
+
* `cs.x` is sorted, so the sweep visits columns left-to-right and only the
|
|
630
|
+
* **current** column's occupied rows need tracking (a `Set` cleared on each
|
|
631
|
+
* column change — bounded memory, O(visible) total). Positions are in CSS px
|
|
632
|
+
* (`xScale`/`yScale` output), matching the mark radius' units. `[vStart, vEnd)`
|
|
633
|
+
* is the pre-culled visible window (defaults to the whole series). Gaps
|
|
634
|
+
* (non-finite y) are skipped, like {@link drawScatter}.
|
|
635
|
+
*/
|
|
636
|
+
export function decimateScatter(cs, xScale, yScale, cellPx, vStart = 0, vEnd = cs.length) {
|
|
637
|
+
const cell = cellPx > 0 ? cellPx : 1;
|
|
638
|
+
// Affine fast path ([PND-AFFINE]) for the per-point pixel mapping the sweep
|
|
639
|
+
// needs — an inline `k·v + b` over the typed arrays instead of a d3-scale
|
|
640
|
+
// closure per point (each axis independently; a non-affine axis, e.g. a
|
|
641
|
+
// real-gap trading x, falls back to the exact scale call). Without this the
|
|
642
|
+
// sweep would re-introduce the per-point d3-scale cost the line/area paths
|
|
643
|
+
// shed, making the decimation's own cost dominate.
|
|
644
|
+
const ax = affineOf(xScale);
|
|
645
|
+
const ay = affineOf(yScale);
|
|
646
|
+
const outX = [];
|
|
647
|
+
const outY = [];
|
|
648
|
+
let curCol = Number.NaN;
|
|
649
|
+
const rows = new Set();
|
|
650
|
+
for (let i = vStart; i < vEnd; i += 1) {
|
|
651
|
+
const y = cs.y[i];
|
|
652
|
+
if (!Number.isFinite(y))
|
|
653
|
+
continue; // gap — no mark
|
|
654
|
+
const xv = cs.x[i];
|
|
655
|
+
const px = ax !== null ? ax.k * xv + ax.b : xScale(xv);
|
|
656
|
+
const col = Math.floor(px / cell);
|
|
657
|
+
if (col !== curCol) {
|
|
658
|
+
rows.clear();
|
|
659
|
+
curCol = col;
|
|
660
|
+
}
|
|
661
|
+
const py = ay !== null ? ay.k * y + ay.b : yScale(y);
|
|
662
|
+
const row = Math.floor(py / cell);
|
|
663
|
+
if (!rows.has(row)) {
|
|
664
|
+
rows.add(row);
|
|
665
|
+
outX.push(xv);
|
|
666
|
+
outY.push(y);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return {
|
|
670
|
+
x: Float64Array.from(outX),
|
|
671
|
+
y: Float64Array.from(outY),
|
|
672
|
+
length: outX.length,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Whether `color` is fully opaque — the second precondition (beside
|
|
677
|
+
* {@link ResolvedEncoding.uniform}) for scatter decimation. A **translucent**
|
|
678
|
+
* fill accumulates opacity where marks overlap (that build-up _is_ the density
|
|
679
|
+
* signal), so collapsing overlaps would lighten the plot — decimation must not
|
|
680
|
+
* engage. Returns `false` only when it can **prove** alpha < 1
|
|
681
|
+
* (`rgba(…, a<1)`, `#rrggbbaa` / `#rgba` with alpha < full, or `transparent`);
|
|
682
|
+
* every other form (`#rgb` / `#rrggbb`, `rgb()`, a named colour, `hsl()`) is
|
|
683
|
+
* treated as opaque. Conservative in the safe direction: a colour it can't prove
|
|
684
|
+
* translucent still decimates, but the realistic translucent cases are caught.
|
|
685
|
+
*/
|
|
686
|
+
export function isOpaqueColor(color) {
|
|
687
|
+
const c = color.trim().toLowerCase();
|
|
688
|
+
if (c === 'transparent')
|
|
689
|
+
return false;
|
|
690
|
+
// rgb/rgba/hsl/hsla, both syntaxes: legacy comma (`rgba(r,g,b,a)`) and modern
|
|
691
|
+
// slash (`rgb(r g b / a)` / `hsl(h s l / a)`). Extract the alpha either way.
|
|
692
|
+
const fn = /^(?:rgba?|hsla?)\(([^)]+)\)$/.exec(c);
|
|
693
|
+
if (fn) {
|
|
694
|
+
const body = fn[1];
|
|
695
|
+
const slash = body.split('/');
|
|
696
|
+
if (slash.length === 2) {
|
|
697
|
+
const a = parseFloat(slash[1].trim()); // modern: `… / <alpha>`
|
|
698
|
+
return !(Number.isFinite(a) && a < 1);
|
|
699
|
+
}
|
|
700
|
+
const parts = body.split(',').map((p) => p.trim());
|
|
701
|
+
if (parts.length >= 4) {
|
|
702
|
+
const a = parseFloat(parts[3]); // legacy: 4th component is alpha
|
|
703
|
+
return !(Number.isFinite(a) && a < 1);
|
|
704
|
+
}
|
|
705
|
+
return true; // 3-component rgb/hsl → opaque
|
|
706
|
+
}
|
|
707
|
+
const hex = /^#([0-9a-f]{3,8})$/.exec(c);
|
|
708
|
+
if (hex) {
|
|
709
|
+
const h = hex[1];
|
|
710
|
+
if (h.length === 8)
|
|
711
|
+
return parseInt(h.slice(6, 8), 16) === 255;
|
|
712
|
+
if (h.length === 4)
|
|
713
|
+
return parseInt(h[3] + h[3], 16) === 255;
|
|
714
|
+
return true; // #rgb / #rrggbb
|
|
715
|
+
}
|
|
716
|
+
return true; // named / unknown → opaque
|
|
717
|
+
}
|
|
609
718
|
//# sourceMappingURL=decimate.js.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **elapsed (duration) x axis** — the shared x scale relabelled as *offsets
|
|
3
|
+
* from an origin*, so an axis reads `00:00 00:05 00:10` (time since the start of
|
|
4
|
+
* the series) instead of `10:35 10:40 10:45` (wall clock), and a value axis
|
|
5
|
+
* reads distance-from-the-start instead of absolute distance.
|
|
6
|
+
*
|
|
7
|
+
* Two things change, and only these two: **where the ticks sit** and **what they
|
|
8
|
+
* say**. The pixel mapping is untouched, and so are the data coordinates — a
|
|
9
|
+
* mark's `at`, the container's `range`, an `onRegionSelect` span are all still
|
|
10
|
+
* absolute axis units. Relabeling only.
|
|
11
|
+
*
|
|
12
|
+
* Where the ticks sit is the part that can't be done with `<XAxis transform>`:
|
|
13
|
+
* an elapsed axis wants ticks at **round durations measured from the origin**
|
|
14
|
+
* (a run starting at 10:33:17 ticks at 10:33:17, 10:38:17, … so its labels read
|
|
15
|
+
* `00:00 00:05`), not at the wall-clock boundaries a calendar ladder picks. So
|
|
16
|
+
* the walk here is `origin + k·step` with `step` off a **duration ladder**
|
|
17
|
+
* (…15s, 30s, 1m, 2m, 5m… — not the 1-2-5 ladder, which would offer a
|
|
18
|
+
* 200-second tick). A value axis runs the identical walk on the plain 1-2-5
|
|
19
|
+
* ladder.
|
|
20
|
+
*
|
|
21
|
+
* Pure — no DOM, no React; {@link scaleElapsed} wraps a base scale with these
|
|
22
|
+
* ticks + labels and the container hands the result out as its `xScale`, so
|
|
23
|
+
* every consumer (axis labels, gridlines, cursor pill, marker indicators) reads
|
|
24
|
+
* the elapsed axis without knowing it exists.
|
|
25
|
+
*/
|
|
26
|
+
/** The smallest 1-2-5 nice step ≥ `target` (the value-axis ladder). */
|
|
27
|
+
export declare function niceStep(target: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* The tick step for a **duration** axis: the smallest ladder step that keeps the
|
|
30
|
+
* tick total at or under `count` across `span` ms. Past a day the ladder runs
|
|
31
|
+
* out and 1-2-5 whole days take over (2d, 5d, 10d, 20d, …) — calendar months
|
|
32
|
+
* are deliberately not a rung, since an elapsed axis measures duration, and
|
|
33
|
+
* "1 month later" is not a duration.
|
|
34
|
+
*/
|
|
35
|
+
export declare function durationStep(span: number, count: number): number;
|
|
36
|
+
/**
|
|
37
|
+
* Tick values in **absolute axis units** at `origin + k·step`, covering
|
|
38
|
+
* `domain` — the anchored walk that makes `00:05` land exactly five minutes
|
|
39
|
+
* after the origin rather than on the nearest clock boundary. `k` runs negative
|
|
40
|
+
* where the domain reaches back before the origin (a T-minus axis), so the walk
|
|
41
|
+
* is origin-anchored, not domain-anchored. Ascending; `[]` for a degenerate
|
|
42
|
+
* domain or step.
|
|
43
|
+
*/
|
|
44
|
+
export declare function originTicks(domain: readonly [number, number], origin: number, step: number): number[];
|
|
45
|
+
/**
|
|
46
|
+
* Which components a duration label shows. Resolved once from the tick step and
|
|
47
|
+
* the axis's magnitude ({@link durationShape}) so every label on one axis has
|
|
48
|
+
* the same shape — and so the cursor readout can add seconds to the *same*
|
|
49
|
+
* shape rather than picking its own (a `00:05` axis must not read `05:12` under
|
|
50
|
+
* the pointer).
|
|
51
|
+
*/
|
|
52
|
+
export interface DurationShape {
|
|
53
|
+
/** Prefix a `Nd ` day part (only rendered when the day count is non-zero). */
|
|
54
|
+
readonly days: boolean;
|
|
55
|
+
/** Head the clock with hours (`HH:MM`) rather than minutes (`MM:SS`). */
|
|
56
|
+
readonly hours: boolean;
|
|
57
|
+
readonly seconds: boolean;
|
|
58
|
+
readonly millis: boolean;
|
|
59
|
+
/** Whole days only (`0d 1d 2d`) — a day-or-coarser step has no clock to show. */
|
|
60
|
+
readonly dayGrain: boolean;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Pick the label shape for a duration axis from its tick `step` (which sets the
|
|
64
|
+
* *finest* component shown — a 5-minute step has no business printing seconds)
|
|
65
|
+
* and `maxAbs`, the largest offset the axis reaches (which sets the *coarsest*).
|
|
66
|
+
*
|
|
67
|
+
* The one non-obvious rung: an axis whose step is a minute or coarser heads its
|
|
68
|
+
* clock with **hours even when they're zero** (`00:05` = five minutes in),
|
|
69
|
+
* because that is what the wall-clock axis it replaces looked like. Only an axis
|
|
70
|
+
* fine enough to show seconds drops to `MM:SS`.
|
|
71
|
+
*/
|
|
72
|
+
export declare function durationShape(step: number, maxAbs: number): DurationShape;
|
|
73
|
+
/**
|
|
74
|
+
* Render an elapsed `ms` in the given {@link DurationShape}: `00:05`, `12:30`,
|
|
75
|
+
* `01:15:30`, `2d 06:00`, `0d`, `-00:05`. Negative offsets (a domain reaching
|
|
76
|
+
* back before the origin — the T-minus case) carry a leading `-`.
|
|
77
|
+
*
|
|
78
|
+
* Truncates rather than rounds, so a label reads like a clock: 59.7s at second
|
|
79
|
+
* grain is `00:59`, not `01:00`. Hours accumulate past 24 when the shape has no
|
|
80
|
+
* day part, so an off-axis readout can't silently wrap.
|
|
81
|
+
*/
|
|
82
|
+
export declare function formatDuration(ms: number, shape: DurationShape): string;
|
|
83
|
+
/**
|
|
84
|
+
* The x scale a container in elapsed mode hands out — the base scale's pixel
|
|
85
|
+
* mapping (`invert`, `domain`, `range` all pass straight through) with
|
|
86
|
+
* origin-anchored {@link originTicks} and offset labels layered on. Deliberately
|
|
87
|
+
* *not* a {@link TradingTimeScale}: it exposes no `tickBoundaries` / `bands` /
|
|
88
|
+
* `gridLevels`, which is exactly how `<XAxis>` knows to skip the calendar date
|
|
89
|
+
* styles and how `Layers` knows to draw its gridlines at the labelled (elapsed)
|
|
90
|
+
* ticks instead of the calendar grain populations.
|
|
91
|
+
*/
|
|
92
|
+
export interface ElapsedScale {
|
|
93
|
+
(value: number): number;
|
|
94
|
+
invert(pixel: number): number;
|
|
95
|
+
ticks(count?: number): number[];
|
|
96
|
+
/**
|
|
97
|
+
* The label formatter. With no `specifier` this is the **offset** formatter —
|
|
98
|
+
* a duration on a time axis, the d3 default over the offset domain on a value
|
|
99
|
+
* axis. With one, see {@link ElapsedOptions.absolute}.
|
|
100
|
+
*/
|
|
101
|
+
tickFormat(count?: number, specifier?: string): (value: number | Date) => string;
|
|
102
|
+
domain(): [number, number];
|
|
103
|
+
range(): [number, number];
|
|
104
|
+
/** The zero point, in absolute axis units. */
|
|
105
|
+
readonly origin: number;
|
|
106
|
+
/** A formatter one grain finer than the tick labels (seconds always shown on a
|
|
107
|
+
* time axis), for the cursor pill / marker indicators — the same
|
|
108
|
+
* precise-readout-over-terse-ticks split the calendar axis makes. */
|
|
109
|
+
readoutFormat(count?: number): (value: number) => string;
|
|
110
|
+
}
|
|
111
|
+
/** The slice of the base scale {@link scaleElapsed} wraps — d3's `ScaleLinear`
|
|
112
|
+
* and a `TradingTimeScale` both satisfy it. */
|
|
113
|
+
interface ElapsedBase {
|
|
114
|
+
(value: number): number;
|
|
115
|
+
invert(pixel: number): number;
|
|
116
|
+
domain(): number[];
|
|
117
|
+
range(): number[];
|
|
118
|
+
}
|
|
119
|
+
export interface ElapsedOptions {
|
|
120
|
+
/** The zero point in absolute axis units — what `00:00` (or `0`) means. */
|
|
121
|
+
readonly origin: number;
|
|
122
|
+
readonly kind: 'time' | 'value';
|
|
123
|
+
/**
|
|
124
|
+
* Formatter for an explicit d3 **specifier** on a *time* axis, in absolute
|
|
125
|
+
* units (the container passes its wall-clock scale's `tickFormat`). A d3 time
|
|
126
|
+
* specifier can only describe an instant — `%H:%M` of a duration is not a
|
|
127
|
+
* thing — so an explicit format on an elapsed time axis labels the underlying
|
|
128
|
+
* wall clock. That's the lever for pairing a wall-clock strip with a duration
|
|
129
|
+
* strip on the same ticks. A **value** axis needs none: a number specifier
|
|
130
|
+
* describes the offset perfectly well, so it formats the offset.
|
|
131
|
+
*/
|
|
132
|
+
absolute?(count: number, specifier: string): (value: number) => string;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Wrap `base` as an {@link ElapsedScale}: same pixels, ticks anchored at
|
|
136
|
+
* `origin`, labels in offsets.
|
|
137
|
+
*/
|
|
138
|
+
export declare function scaleElapsed(base: ElapsedBase, options: ElapsedOptions): ElapsedScale;
|
|
139
|
+
export {};
|
|
140
|
+
//# sourceMappingURL=elapsed.d.ts.map
|
package/dist/elapsed.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **elapsed (duration) x axis** — the shared x scale relabelled as *offsets
|
|
3
|
+
* from an origin*, so an axis reads `00:00 00:05 00:10` (time since the start of
|
|
4
|
+
* the series) instead of `10:35 10:40 10:45` (wall clock), and a value axis
|
|
5
|
+
* reads distance-from-the-start instead of absolute distance.
|
|
6
|
+
*
|
|
7
|
+
* Two things change, and only these two: **where the ticks sit** and **what they
|
|
8
|
+
* say**. The pixel mapping is untouched, and so are the data coordinates — a
|
|
9
|
+
* mark's `at`, the container's `range`, an `onRegionSelect` span are all still
|
|
10
|
+
* absolute axis units. Relabeling only.
|
|
11
|
+
*
|
|
12
|
+
* Where the ticks sit is the part that can't be done with `<XAxis transform>`:
|
|
13
|
+
* an elapsed axis wants ticks at **round durations measured from the origin**
|
|
14
|
+
* (a run starting at 10:33:17 ticks at 10:33:17, 10:38:17, … so its labels read
|
|
15
|
+
* `00:00 00:05`), not at the wall-clock boundaries a calendar ladder picks. So
|
|
16
|
+
* the walk here is `origin + k·step` with `step` off a **duration ladder**
|
|
17
|
+
* (…15s, 30s, 1m, 2m, 5m… — not the 1-2-5 ladder, which would offer a
|
|
18
|
+
* 200-second tick). A value axis runs the identical walk on the plain 1-2-5
|
|
19
|
+
* ladder.
|
|
20
|
+
*
|
|
21
|
+
* Pure — no DOM, no React; {@link scaleElapsed} wraps a base scale with these
|
|
22
|
+
* ticks + labels and the container hands the result out as its `xScale`, so
|
|
23
|
+
* every consumer (axis labels, gridlines, cursor pill, marker indicators) reads
|
|
24
|
+
* the elapsed axis without knowing it exists.
|
|
25
|
+
*/
|
|
26
|
+
import { scaleLinear } from 'd3-scale';
|
|
27
|
+
const SECOND = 1000;
|
|
28
|
+
const MINUTE = 60 * SECOND;
|
|
29
|
+
const HOUR = 60 * MINUTE;
|
|
30
|
+
const DAY = 24 * HOUR;
|
|
31
|
+
/**
|
|
32
|
+
* The duration tick ladder in ms — the steps a *clock* subdivides by, which is
|
|
33
|
+
* not the 1-2-5 ladder: 15s and 30s are round durations where 20s and 50s are
|
|
34
|
+
* not, and an hour divides by 2/3/6/12 rather than by 2/5. Steps coarser than a
|
|
35
|
+
* day fall back to 1-2-5 whole days (see {@link durationStep}).
|
|
36
|
+
*/
|
|
37
|
+
const DURATION_STEPS = [
|
|
38
|
+
1,
|
|
39
|
+
2,
|
|
40
|
+
5,
|
|
41
|
+
10,
|
|
42
|
+
20,
|
|
43
|
+
50,
|
|
44
|
+
100,
|
|
45
|
+
200,
|
|
46
|
+
500,
|
|
47
|
+
SECOND,
|
|
48
|
+
2 * SECOND,
|
|
49
|
+
5 * SECOND,
|
|
50
|
+
10 * SECOND,
|
|
51
|
+
15 * SECOND,
|
|
52
|
+
30 * SECOND,
|
|
53
|
+
MINUTE,
|
|
54
|
+
2 * MINUTE,
|
|
55
|
+
5 * MINUTE,
|
|
56
|
+
10 * MINUTE,
|
|
57
|
+
15 * MINUTE,
|
|
58
|
+
30 * MINUTE,
|
|
59
|
+
HOUR,
|
|
60
|
+
2 * HOUR,
|
|
61
|
+
3 * HOUR,
|
|
62
|
+
6 * HOUR,
|
|
63
|
+
12 * HOUR,
|
|
64
|
+
DAY,
|
|
65
|
+
];
|
|
66
|
+
/** The smallest 1-2-5 nice step ≥ `target` (the value-axis ladder). */
|
|
67
|
+
export function niceStep(target) {
|
|
68
|
+
if (!(target > 0) || !Number.isFinite(target))
|
|
69
|
+
return 1;
|
|
70
|
+
const pow = 10 ** Math.floor(Math.log10(target));
|
|
71
|
+
for (const m of [1, 2, 5]) {
|
|
72
|
+
if (m * pow >= target)
|
|
73
|
+
return m * pow;
|
|
74
|
+
}
|
|
75
|
+
return 10 * pow;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The tick step for a **duration** axis: the smallest ladder step that keeps the
|
|
79
|
+
* tick total at or under `count` across `span` ms. Past a day the ladder runs
|
|
80
|
+
* out and 1-2-5 whole days take over (2d, 5d, 10d, 20d, …) — calendar months
|
|
81
|
+
* are deliberately not a rung, since an elapsed axis measures duration, and
|
|
82
|
+
* "1 month later" is not a duration.
|
|
83
|
+
*/
|
|
84
|
+
export function durationStep(span, count) {
|
|
85
|
+
const target = span / Math.max(1, count);
|
|
86
|
+
if (!(target > 0) || !Number.isFinite(target))
|
|
87
|
+
return 1;
|
|
88
|
+
for (const step of DURATION_STEPS) {
|
|
89
|
+
if (step >= target)
|
|
90
|
+
return step;
|
|
91
|
+
}
|
|
92
|
+
return niceStep(target / DAY) * DAY;
|
|
93
|
+
}
|
|
94
|
+
/** Backstop against a pathological (step, domain) pair flooding the axis; the
|
|
95
|
+
* step is derived from the domain span, so a real axis never comes close. */
|
|
96
|
+
const MAX_TICKS = 10_000;
|
|
97
|
+
/**
|
|
98
|
+
* Tick values in **absolute axis units** at `origin + k·step`, covering
|
|
99
|
+
* `domain` — the anchored walk that makes `00:05` land exactly five minutes
|
|
100
|
+
* after the origin rather than on the nearest clock boundary. `k` runs negative
|
|
101
|
+
* where the domain reaches back before the origin (a T-minus axis), so the walk
|
|
102
|
+
* is origin-anchored, not domain-anchored. Ascending; `[]` for a degenerate
|
|
103
|
+
* domain or step.
|
|
104
|
+
*/
|
|
105
|
+
export function originTicks(domain, origin, step) {
|
|
106
|
+
const lo = Math.min(domain[0], domain[1]);
|
|
107
|
+
const hi = Math.max(domain[0], domain[1]);
|
|
108
|
+
if (!Number.isFinite(lo) ||
|
|
109
|
+
!Number.isFinite(hi) ||
|
|
110
|
+
!Number.isFinite(origin) ||
|
|
111
|
+
!(step > 0) ||
|
|
112
|
+
hi < lo) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
// ±1e-9 relative slack so a tick sitting exactly on a domain edge (the very
|
|
116
|
+
// common `origin === lo` case — the `00:00` tick) is not lost to float drift.
|
|
117
|
+
const eps = 1e-9 * Math.max(1, Math.abs(hi - lo) / step);
|
|
118
|
+
const k0 = Math.ceil((lo - origin) / step - eps);
|
|
119
|
+
const k1 = Math.floor((hi - origin) / step + eps);
|
|
120
|
+
if (k1 < k0 || k1 - k0 > MAX_TICKS)
|
|
121
|
+
return [];
|
|
122
|
+
const out = [];
|
|
123
|
+
for (let k = k0; k <= k1; k++)
|
|
124
|
+
out.push(origin + k * step);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Pick the label shape for a duration axis from its tick `step` (which sets the
|
|
129
|
+
* *finest* component shown — a 5-minute step has no business printing seconds)
|
|
130
|
+
* and `maxAbs`, the largest offset the axis reaches (which sets the *coarsest*).
|
|
131
|
+
*
|
|
132
|
+
* The one non-obvious rung: an axis whose step is a minute or coarser heads its
|
|
133
|
+
* clock with **hours even when they're zero** (`00:05` = five minutes in),
|
|
134
|
+
* because that is what the wall-clock axis it replaces looked like. Only an axis
|
|
135
|
+
* fine enough to show seconds drops to `MM:SS`.
|
|
136
|
+
*/
|
|
137
|
+
export function durationShape(step, maxAbs) {
|
|
138
|
+
const seconds = step < MINUTE;
|
|
139
|
+
return {
|
|
140
|
+
dayGrain: step >= DAY,
|
|
141
|
+
days: maxAbs >= DAY,
|
|
142
|
+
hours: maxAbs >= HOUR || !seconds,
|
|
143
|
+
seconds,
|
|
144
|
+
millis: step < SECOND,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
|
148
|
+
/**
|
|
149
|
+
* Render an elapsed `ms` in the given {@link DurationShape}: `00:05`, `12:30`,
|
|
150
|
+
* `01:15:30`, `2d 06:00`, `0d`, `-00:05`. Negative offsets (a domain reaching
|
|
151
|
+
* back before the origin — the T-minus case) carry a leading `-`.
|
|
152
|
+
*
|
|
153
|
+
* Truncates rather than rounds, so a label reads like a clock: 59.7s at second
|
|
154
|
+
* grain is `00:59`, not `01:00`. Hours accumulate past 24 when the shape has no
|
|
155
|
+
* day part, so an off-axis readout can't silently wrap.
|
|
156
|
+
*/
|
|
157
|
+
export function formatDuration(ms, shape) {
|
|
158
|
+
if (!Number.isFinite(ms))
|
|
159
|
+
return '';
|
|
160
|
+
const sign = ms < 0 ? '-' : '';
|
|
161
|
+
let rest = Math.floor(Math.abs(ms));
|
|
162
|
+
const dayPart = Math.floor(rest / DAY);
|
|
163
|
+
if (shape.dayGrain)
|
|
164
|
+
return `${sign}${dayPart}d`;
|
|
165
|
+
if (shape.days)
|
|
166
|
+
rest -= dayPart * DAY;
|
|
167
|
+
const hours = Math.floor(rest / HOUR);
|
|
168
|
+
rest -= hours * HOUR;
|
|
169
|
+
const mins = Math.floor(rest / MINUTE);
|
|
170
|
+
rest -= mins * MINUTE;
|
|
171
|
+
const secs = Math.floor(rest / SECOND);
|
|
172
|
+
rest -= secs * SECOND;
|
|
173
|
+
// The day part shows only when there is one — an axis's first day reads
|
|
174
|
+
// `06:00`, its second `1d 06:00`, exactly as the flat date style promotes a
|
|
175
|
+
// tick that opens a coarser period.
|
|
176
|
+
const prefix = shape.days && dayPart > 0 ? `${dayPart}d ` : '';
|
|
177
|
+
const clock = shape.hours
|
|
178
|
+
? `${pad(hours)}:${pad(mins)}${shape.seconds ? `:${pad(secs)}` : ''}`
|
|
179
|
+
: `${pad(mins)}:${pad(secs)}`;
|
|
180
|
+
const frac = shape.millis ? `.${pad(rest, 3)}` : '';
|
|
181
|
+
return `${sign}${prefix}${clock}${frac}`;
|
|
182
|
+
}
|
|
183
|
+
/** Default tick target when a caller passes none (d3's convention). */
|
|
184
|
+
const DEFAULT_COUNT = 10;
|
|
185
|
+
/**
|
|
186
|
+
* Wrap `base` as an {@link ElapsedScale}: same pixels, ticks anchored at
|
|
187
|
+
* `origin`, labels in offsets.
|
|
188
|
+
*/
|
|
189
|
+
export function scaleElapsed(base, options) {
|
|
190
|
+
const { origin, kind, absolute } = options;
|
|
191
|
+
const bounds = () => {
|
|
192
|
+
const d = base.domain();
|
|
193
|
+
return [Number(d[0] ?? 0), Number(d[1] ?? 0)];
|
|
194
|
+
};
|
|
195
|
+
const stepFor = (count) => {
|
|
196
|
+
const [lo, hi] = bounds();
|
|
197
|
+
const span = Math.abs(hi - lo);
|
|
198
|
+
return kind === 'time'
|
|
199
|
+
? durationStep(span, count)
|
|
200
|
+
: niceStep(span / Math.max(1, count));
|
|
201
|
+
};
|
|
202
|
+
const shapeFor = (count) => {
|
|
203
|
+
const [lo, hi] = bounds();
|
|
204
|
+
const maxAbs = Math.max(Math.abs(lo - origin), Math.abs(hi - origin));
|
|
205
|
+
return durationShape(stepFor(count), maxAbs);
|
|
206
|
+
};
|
|
207
|
+
/** The value-axis offset formatter — resolved against a scale over the
|
|
208
|
+
* *offset* domain, so d3 picks its precision from the numbers on show. */
|
|
209
|
+
const offsetFormat = (count, specifier) => {
|
|
210
|
+
const [lo, hi] = bounds();
|
|
211
|
+
const s = scaleLinear().domain([lo - origin, hi - origin]);
|
|
212
|
+
const f = specifier !== undefined
|
|
213
|
+
? s.tickFormat(count, specifier)
|
|
214
|
+
: s.tickFormat(count);
|
|
215
|
+
return (v) => f(v - origin);
|
|
216
|
+
};
|
|
217
|
+
const scale = ((value) => base(value));
|
|
218
|
+
Object.assign(scale, {
|
|
219
|
+
origin,
|
|
220
|
+
invert: (pixel) => Number(base.invert(pixel)),
|
|
221
|
+
domain: () => bounds(),
|
|
222
|
+
range: () => {
|
|
223
|
+
const r = base.range();
|
|
224
|
+
return [Number(r[0] ?? 0), Number(r[1] ?? 0)];
|
|
225
|
+
},
|
|
226
|
+
ticks: (count = DEFAULT_COUNT) => originTicks(bounds(), origin, stepFor(count)),
|
|
227
|
+
tickFormat: (count = DEFAULT_COUNT, specifier) => {
|
|
228
|
+
if (kind === 'value')
|
|
229
|
+
return offsetFormat(count, specifier);
|
|
230
|
+
if (specifier !== undefined && absolute !== undefined) {
|
|
231
|
+
return absolute(count, specifier);
|
|
232
|
+
}
|
|
233
|
+
const shape = shapeFor(count);
|
|
234
|
+
return (value) => formatDuration(+value - origin, shape);
|
|
235
|
+
},
|
|
236
|
+
readoutFormat: (count = DEFAULT_COUNT) => {
|
|
237
|
+
if (kind === 'value')
|
|
238
|
+
return offsetFormat(count);
|
|
239
|
+
// Seconds on top of the ticks' own shape — never a *different* shape, so
|
|
240
|
+
// a `00:05` axis reads `00:05:12` under the pointer, not `05:12`.
|
|
241
|
+
const shape = { ...shapeFor(count), seconds: true, dayGrain: false };
|
|
242
|
+
return (value) => formatDuration(value - origin, shape);
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
return scale;
|
|
246
|
+
}
|
|
247
|
+
//# sourceMappingURL=elapsed.js.map
|
package/dist/encoding.d.ts
CHANGED
|
@@ -62,6 +62,15 @@ export interface ResolvedEncoding {
|
|
|
62
62
|
radiusAt(i: number): number;
|
|
63
63
|
/** This point's fill colour (base colour if unencoded / non-finite). */
|
|
64
64
|
colorAt(i: number): string;
|
|
65
|
+
/**
|
|
66
|
+
* `true` when **neither** radius nor colour is data-driven — every mark is the
|
|
67
|
+
* same fixed size and colour (`radiusAt`/`colorAt` ignore their index). This is
|
|
68
|
+
* the precondition for lossless occupancy **decimation** (PND-MARKDEC scatter
|
|
69
|
+
* half): only same-size, same-colour marks can be collapsed where they overlap
|
|
70
|
+
* without changing the picture. A `{column, range}` on either channel makes it
|
|
71
|
+
* `false`, and the scatter draws every point.
|
|
72
|
+
*/
|
|
73
|
+
readonly uniform: boolean;
|
|
65
74
|
}
|
|
66
75
|
/** A numeric column read into a `Float64Array` (gaps as NaN), by name. */
|
|
67
76
|
export type ColumnReader = (column: string) => Float64Array;
|
package/dist/encoding.js
CHANGED
|
@@ -139,6 +139,10 @@ export function resolveEncoding(cs, baseRadius, baseColor, radius, color, readCo
|
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
// Uniform iff both channels are fixed: radius a number/omitted and no colour
|
|
143
|
+
// encoding. Data-driven either side (`{column, range}`) makes every mark
|
|
144
|
+
// potentially distinct, so decimation must not collapse them.
|
|
145
|
+
const uniform = (radius === undefined || typeof radius === 'number') && color === undefined;
|
|
146
|
+
return { radiusAt, colorAt, uniform };
|
|
143
147
|
}
|
|
144
148
|
//# sourceMappingURL=encoding.js.map
|
package/dist/scatter.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { ChartSeries } from './data.js';
|
|
|
2
2
|
import type { Scale } from './line.js';
|
|
3
3
|
import type { ScatterStyle } from './theme.js';
|
|
4
4
|
import type { ResolvedEncoding } from './encoding.js';
|
|
5
|
-
import type { SelectInfo } from './context.js';
|
|
5
|
+
import type { SelectInfo, LayerDrawStats } from './context.js';
|
|
6
|
+
import { type DecimateOption } from './decimate.js';
|
|
6
7
|
/**
|
|
7
8
|
* Index of the point in `cs` **nearest** `time` by `|x − time|`, restricted to
|
|
8
9
|
* finite points, or `-1` if none. `cs.x` is the sorted time axis, so a binary
|
|
@@ -52,7 +53,7 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
|
|
|
52
53
|
export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: ScatterStyle, encoding: ResolvedEncoding, keyAt: (i: number) => number, labelAt: ((i: number) => string | undefined) | undefined, font: {
|
|
53
54
|
readonly family: string;
|
|
54
55
|
readonly size: number;
|
|
55
|
-
}, selected: SelectInfo | null, seriesId: string | undefined, offsetPx?: number):
|
|
56
|
+
}, selected: SelectInfo | null, seriesId: string | undefined, offsetPx?: number, decimate?: DecimateOption): LayerDrawStats;
|
|
56
57
|
/**
|
|
57
58
|
* Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
|
|
58
59
|
* point whose circle contains the click, or `null`. "Topmost" = the
|
package/dist/scatter.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { visiblePointRange } from './culling.js';
|
|
2
|
+
import { shouldDecimateCount, decimateScatter, isOpaqueColor, } from './decimate.js';
|
|
2
3
|
/**
|
|
3
4
|
* Scatter geometry + the canvas draw — pure, like {@link drawLine} /
|
|
4
5
|
* {@link drawBand}, so the recording-mock tests assert the op sequence and the
|
|
@@ -117,7 +118,7 @@ export function scatterExtent(cs) {
|
|
|
117
118
|
* of the selection match. A point lights only when the selection's
|
|
118
119
|
* `id` matches, keyed to the sample by its `key`.
|
|
119
120
|
*/
|
|
120
|
-
export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId, offsetPx = 0) {
|
|
121
|
+
export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, selected, seriesId, offsetPx = 0, decimate = true) {
|
|
121
122
|
ctx.save();
|
|
122
123
|
// The selection only lights up a point of *this* series; resolve the key once.
|
|
123
124
|
// A no-id (non-selectable) layer passes `undefined` and never matches.
|
|
@@ -158,9 +159,64 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
158
159
|
const [vStart, vEnd] = pad > 0
|
|
159
160
|
? visiblePointRange(cs.x, cs.length, xScale, pad)
|
|
160
161
|
: [w0Start, w0End];
|
|
162
|
+
// [PND-MARKDEC] scatter decimation. When the marks are **uniform** (fixed
|
|
163
|
+
// size + colour), **opaque**, and denser than the pixel grid, collapse each
|
|
164
|
+
// overlapping cluster to one representative per mark-radius cell (2D
|
|
165
|
+
// occupancy — {@link decimateScatter}). Visually lossless at that density, and
|
|
166
|
+
// O(visible). Interaction is unaffected ({@link hitTestScatter} still walks
|
|
167
|
+
// every source point); the selection ring and per-point labels are dropped on
|
|
168
|
+
// this path — both are illegible under a dense blob — matching the decimated
|
|
169
|
+
// bar path. Data-driven size/colour (`!encoding.uniform`) or a translucent
|
|
170
|
+
// fill (density-encoded, where overlap *should* build up) keep the full draw.
|
|
171
|
+
const visibleCount = vEnd - vStart;
|
|
172
|
+
const k = typeof decimate === 'object' && decimate.threshold !== undefined
|
|
173
|
+
? decimate.threshold
|
|
174
|
+
: 2;
|
|
175
|
+
if (decimate !== false &&
|
|
176
|
+
encoding.uniform &&
|
|
177
|
+
shouldDecimateCount(visibleCount, ctx, k) &&
|
|
178
|
+
isOpaqueColor(encoding.colorAt(vStart))) {
|
|
179
|
+
const r = encoding.radiusAt(vStart);
|
|
180
|
+
const dec = decimateScatter(cs, xScale, yScale, Math.max(1, r), vStart, vEnd);
|
|
181
|
+
// Only take the decimated pass if it actually shrank the work (a sparse-in-y
|
|
182
|
+
// scatter over the threshold may not overlap — then the full draw is fine,
|
|
183
|
+
// and keeps its selection ring + labels). Compare against the **finite**
|
|
184
|
+
// visible count, not `vEnd - vStart`: `decimateScatter` skips gaps, so the
|
|
185
|
+
// raw span would falsely read as a reduction on any gappy series (and wrongly
|
|
186
|
+
// suppress the ring / labels with zero cell collisions).
|
|
187
|
+
let finite = 0;
|
|
188
|
+
for (let i = vStart; i < vEnd; i += 1)
|
|
189
|
+
if (isPoint(cs, i))
|
|
190
|
+
finite += 1;
|
|
191
|
+
if (dec.length < finite) {
|
|
192
|
+
ctx.fillStyle = encoding.colorAt(vStart);
|
|
193
|
+
const outlined = style.outlineWidth > 0;
|
|
194
|
+
if (outlined) {
|
|
195
|
+
ctx.lineWidth = style.outlineWidth;
|
|
196
|
+
ctx.strokeStyle = style.outline;
|
|
197
|
+
}
|
|
198
|
+
for (let j = 0; j < dec.length; j += 1) {
|
|
199
|
+
const px = xScale(dec.x[j]) + offsetPx;
|
|
200
|
+
const py = yScale(dec.y[j]);
|
|
201
|
+
ctx.beginPath();
|
|
202
|
+
ctx.arc(px, py, r, 0, Math.PI * 2);
|
|
203
|
+
ctx.fill();
|
|
204
|
+
if (outlined)
|
|
205
|
+
ctx.stroke();
|
|
206
|
+
}
|
|
207
|
+
ctx.restore();
|
|
208
|
+
return {
|
|
209
|
+
sourceCount: cs.length,
|
|
210
|
+
drawnCount: dec.length,
|
|
211
|
+
decimated: true,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
let drawn = 0;
|
|
161
216
|
for (let i = vStart; i < vEnd; i += 1) {
|
|
162
217
|
if (!isPoint(cs, i))
|
|
163
218
|
continue;
|
|
219
|
+
drawn += 1;
|
|
164
220
|
// `offsetPx` nudges the whole scatter in pixel space (zoom-stable) — for
|
|
165
221
|
// pairing same-key marks (call/put at one strike) beside each other.
|
|
166
222
|
const px = xScale(cs.x[i]) + offsetPx;
|
|
@@ -212,6 +268,10 @@ export function drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, lab
|
|
|
212
268
|
}
|
|
213
269
|
}
|
|
214
270
|
ctx.restore();
|
|
271
|
+
// Full draw (no decimation): every finite mark in the visible window drew.
|
|
272
|
+
// `drawnCount < sourceCount` here reflects viewport **culling**, not
|
|
273
|
+
// decimation (`decimated: false`).
|
|
274
|
+
return { sourceCount: cs.length, drawnCount: drawn, decimated: false };
|
|
215
275
|
}
|
|
216
276
|
/** Gap (px) between a point's edge and its label text. */
|
|
217
277
|
const LABEL_GAP = 4;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/charts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@pond-ts/react": "^0.
|
|
42
|
-
"pond-ts": "^0.
|
|
41
|
+
"@pond-ts/react": "^0.53.0",
|
|
42
|
+
"pond-ts": "^0.53.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|