@pond-ts/charts 0.50.0 → 0.52.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 +187 -1
- package/dist/AreaChart.js +1 -0
- package/dist/BandChart.js +1 -0
- package/dist/BarChart.d.ts +14 -1
- package/dist/BarChart.js +5 -2
- package/dist/BoxPlot.js +1 -0
- package/dist/Candlestick.js +1 -0
- package/dist/ChartContainer.d.ts +61 -11
- package/dist/ChartContainer.js +70 -23
- package/dist/Layers.js +53 -14
- package/dist/Legend.js +5 -2
- package/dist/LineChart.js +1 -0
- package/dist/ScatterChart.d.ts +12 -1
- package/dist/ScatterChart.js +4 -2
- package/dist/XAxis.js +3 -2
- package/dist/affine.d.ts +41 -0
- package/dist/affine.js +77 -0
- package/dist/area.d.ts +20 -2
- package/dist/area.js +151 -45
- package/dist/band.d.ts +2 -1
- package/dist/band.js +5 -0
- package/dist/bars.d.ts +14 -1
- package/dist/bars.js +42 -1
- package/dist/box.d.ts +2 -1
- package/dist/box.js +8 -3
- package/dist/context.d.ts +121 -22
- package/dist/context.js +8 -0
- package/dist/data.d.ts +8 -0
- package/dist/decimate.d.ts +117 -1
- package/dist/decimate.js +241 -1
- package/dist/encoding.d.ts +9 -0
- package/dist/encoding.js +5 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -3
- package/dist/line.d.ts +15 -1
- package/dist/line.js +84 -30
- package/dist/ohlc.d.ts +2 -1
- package/dist/ohlc.js +8 -3
- package/dist/scatter.d.ts +3 -2
- package/dist/scatter.js +61 -1
- package/dist/tracker.d.ts +17 -4
- package/dist/tracker.js +19 -6
- package/dist/useChartLegend.d.ts +2 -2
- package/dist/useChartLegend.js +8 -7
- package/dist/viewport.d.ts +19 -0
- package/dist/viewport.js +32 -0
- package/package.json +3 -3
package/dist/line.js
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
import { line as d3line, curveLinear } from 'd3-shape';
|
|
2
2
|
import { cullChartSeries } from './culling.js';
|
|
3
|
-
import {
|
|
3
|
+
import { decimateM4Cached } from './decimate.js';
|
|
4
|
+
import { affineOf } from './affine.js';
|
|
4
5
|
import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
|
|
5
6
|
/** Shared empty boundary list — passed to `sessionRuns` when a decimated series
|
|
6
7
|
* already carries its session breaks as baked-in `NaN` points. */
|
|
7
8
|
const EMPTY_BOUNDARIES = [];
|
|
9
|
+
/**
|
|
10
|
+
* Stroke one run of `ys` (aligned index-for-index with `xs`) through the affine
|
|
11
|
+
* pixel maps `ax` / `ay` — the [PND-AFFINE] fast path. Replicates d3-shape's
|
|
12
|
+
* `curveLinear` + `.defined(Number.isFinite)` behaviour exactly: a non-finite
|
|
13
|
+
* value lifts the pen (the next finite point `moveTo`s a fresh subpath), a
|
|
14
|
+
* finite value `lineTo`s (or `moveTo`s when the pen is up), so a gap breaks and
|
|
15
|
+
* a lone point draws nothing — the same op sequence the generator emits, minus
|
|
16
|
+
* the per-point `scale()` + d3-shape closures. The caller brackets
|
|
17
|
+
* `beginPath`/`stroke`; `xs`/`ys` are the run (a `subarray` view, so index 0 is
|
|
18
|
+
* the run start). Used for lines and for the area outline.
|
|
19
|
+
*/
|
|
20
|
+
export function strokeAffinePolyline(ctx, xs, ys, ax, ay) {
|
|
21
|
+
const n = ys.length;
|
|
22
|
+
let penDown = false;
|
|
23
|
+
for (let j = 0; j < n; j += 1) {
|
|
24
|
+
const v = ys[j];
|
|
25
|
+
if (!Number.isFinite(v)) {
|
|
26
|
+
penDown = false;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const px = ax.k * xs[j] + ax.b;
|
|
30
|
+
const py = ay.k * v + ay.b;
|
|
31
|
+
if (penDown)
|
|
32
|
+
ctx.lineTo(px, py);
|
|
33
|
+
else {
|
|
34
|
+
ctx.moveTo(px, py);
|
|
35
|
+
penDown = true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
8
39
|
/**
|
|
9
40
|
* The y-scale's domain lower bound (the axis floor) in pixels — where the
|
|
10
41
|
* `step` / `fade` gap bridges drop to. The runtime `yScale` is a d3
|
|
@@ -68,31 +99,37 @@ export function yExtent(cs) {
|
|
|
68
99
|
* output is identical to a single-pass draw.
|
|
69
100
|
*/
|
|
70
101
|
export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, gaps = DEFAULT_GAP_MODE, gapConnectorOpacity = DEFAULT_GAP_CONNECTOR_OPACITY, boundaries = [], decimate = true) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
102
|
+
const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
|
|
103
|
+
const source = cs; // pre-cull source — the decimation cache key ([PND-DECKEY])
|
|
104
|
+
// Two paths clip `cs` to what actually draws:
|
|
105
|
+
// - **Decimated** (linear curve, `decimate !== false`): cull to the visible
|
|
106
|
+
// slice, then replace it with the pixel-dense M4 polyline ({@link decimateM4})
|
|
107
|
+
// — O(devicePlotWidth) points that rasterize identically. The edge union
|
|
108
|
+
// breaks the decimated series at exactly the real gaps (gap-mode connectors
|
|
109
|
+
// compose unchanged) **and** aligns a bucket edge to each session break in
|
|
110
|
+
// `boundaries`, so `sessionRuns` below still splits it into clean per-session
|
|
111
|
+
// subpaths. Cull + decimate are **memoized per source** ({@link
|
|
112
|
+
// decimateM4Cached}) so a y-zoom / y-autorange frame reuses the prior
|
|
113
|
+
// polyline instead of re-binning O(N) — the decimation output never depends
|
|
114
|
+
// on the y-scale (finding 3). `decimateM4` no-ops on a sparse slice or a
|
|
115
|
+
// domainless test scale, so this stays byte-identical there.
|
|
116
|
+
// - **Full-resolution** (a smoothing curve would distort the 4-points-per-
|
|
117
|
+
// column polyline, or `decimate === false`): just cull to the visible slice
|
|
118
|
+
// (+1 entry/exit point) so a pan repaint strokes O(visible), not O(N). A
|
|
119
|
+
// no-op — the same `cs` back — when the whole series is in view or `xScale`
|
|
120
|
+
// exposes no domain (a bare test stub), keeping that hot path byte-identical.
|
|
121
|
+
// Everything below indexes `cs` relatively, so the zero-copy subarray view drops
|
|
122
|
+
// in transparently; `boundaries` are absolute instants that `sessionRuns`
|
|
123
|
+
// bisects by value, so they still cut the slice correctly.
|
|
90
124
|
let decimated = false;
|
|
91
125
|
if (decimate !== false && curve === curveLinear) {
|
|
92
126
|
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
93
|
-
const
|
|
94
|
-
cs =
|
|
95
|
-
decimated =
|
|
127
|
+
const r = decimateM4Cached(source, xScale, ctx, k, boundaries);
|
|
128
|
+
cs = r.series;
|
|
129
|
+
decimated = r.decimated;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
cs = cullChartSeries(source, xScale);
|
|
96
133
|
}
|
|
97
134
|
// Split into independent index runs at each boundary; no boundary inside the
|
|
98
135
|
// data ⇒ one run over the whole series (the hot path — no slicing, so the draw
|
|
@@ -103,6 +140,14 @@ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, ga
|
|
|
103
140
|
// the sessions.
|
|
104
141
|
const runs = sessionRuns(cs.x, cs.length, decimated ? EMPTY_BOUNDARIES : boundaries);
|
|
105
142
|
const singleRun = runs.length === 1;
|
|
143
|
+
// [PND-AFFINE] fast path: when the curve is linear and **both** scales are
|
|
144
|
+
// affine (every y axis is `scaleLinear`; x is `scaleLinear` / `scaleTime` /
|
|
145
|
+
// the gap-free default trading axis — a real-gap trading scale probes
|
|
146
|
+
// non-affine and is rejected), stroke each run with an inline multiply-add over
|
|
147
|
+
// the typed arrays, skipping the per-point d3-scale + d3-shape closures. Any
|
|
148
|
+
// other case (smoothing curve, non-affine x) keeps the exact d3-shape path.
|
|
149
|
+
const ax = curve === curveLinear ? affineOf(xScale) : null;
|
|
150
|
+
const ay = ax !== null ? affineOf(yScale) : null;
|
|
106
151
|
// Solid pass: one path across every run. Each run's generator opens with its
|
|
107
152
|
// own moveTo, so a run boundary is a clean pen-up — the session break.
|
|
108
153
|
ctx.beginPath();
|
|
@@ -113,13 +158,19 @@ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, ga
|
|
|
113
158
|
// bridge, if any, is a separate overlay pass below).
|
|
114
159
|
const seg = singleRun ? cs.y : cs.y.subarray(s, e);
|
|
115
160
|
const ys = gaps === 'none' ? bridgeGaps(seg, e - s) : seg;
|
|
116
|
-
|
|
117
|
-
.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
161
|
+
if (ax !== null && ay !== null) {
|
|
162
|
+
const xs = singleRun ? cs.x : cs.x.subarray(s, e);
|
|
163
|
+
strokeAffinePolyline(ctx, xs, ys, ax, ay);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const gen = d3line()
|
|
167
|
+
.defined((v) => Number.isFinite(v))
|
|
168
|
+
.x((_, j) => xScale(cs.x[s + j]))
|
|
169
|
+
.y((v) => yScale(v))
|
|
170
|
+
.curve(curve)
|
|
171
|
+
.context(ctx);
|
|
172
|
+
gen(ys);
|
|
173
|
+
}
|
|
123
174
|
}
|
|
124
175
|
ctx.strokeStyle = style.color;
|
|
125
176
|
ctx.lineWidth = style.width;
|
|
@@ -157,6 +208,9 @@ export function drawLine(ctx, cs, xScale, yScale, style, curve = curveLinear, ga
|
|
|
157
208
|
drawGapFades(ctx, edges, baselinePxFromScale(yScale), style.color, style.width);
|
|
158
209
|
}
|
|
159
210
|
}
|
|
211
|
+
// `drawnCount` = points actually stroked (culled slice, or the M4 polyline when
|
|
212
|
+
// decimation engaged); `sourceCount` = the full series it started from.
|
|
213
|
+
return { sourceCount, drawnCount: cs.length, decimated };
|
|
160
214
|
}
|
|
161
215
|
/**
|
|
162
216
|
* Split a sorted columnar x-axis into contiguous index runs `[start, endEx)`,
|
package/dist/ohlc.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OhlcSeries } from './data.js';
|
|
2
2
|
import type { Scale } from './line.js';
|
|
3
3
|
import type { CandleStyle } from './theme.js';
|
|
4
|
+
import type { LayerDrawStats } from './context.js';
|
|
4
5
|
import { type DecimateOption } from './decimate.js';
|
|
5
6
|
/**
|
|
6
7
|
* How an OHLC mark renders (pjm17971's fork 2 — bundled as one component, like
|
|
@@ -78,5 +79,5 @@ export declare function resolveCandleStyle(style: CandleStyle, open: number, clo
|
|
|
78
79
|
* O(N) over the keys, a fixed number of path ops each — no per-key allocation
|
|
79
80
|
* beyond the `barSpanPx` tuple.
|
|
80
81
|
*/
|
|
81
|
-
export declare function drawCandles(ctx: CanvasRenderingContext2D, ohlc: OhlcSeries, xScale: Scale, yScale: Scale, style: CandleStyle, variant?: CandleVariant, colorBy?: ColorBy, gapPx?: number, minWidthPx?: number, decimate?: DecimateOption):
|
|
82
|
+
export declare function drawCandles(ctx: CanvasRenderingContext2D, ohlc: OhlcSeries, xScale: Scale, yScale: Scale, style: CandleStyle, variant?: CandleVariant, colorBy?: ColorBy, gapPx?: number, minWidthPx?: number, decimate?: DecimateOption): LayerDrawStats;
|
|
82
83
|
//# sourceMappingURL=ohlc.d.ts.map
|
package/dist/ohlc.js
CHANGED
|
@@ -92,6 +92,7 @@ export function resolveCandleStyle(style, open, close, colorBy) {
|
|
|
92
92
|
*/
|
|
93
93
|
export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle', colorBy = 'direction', gapPx = 0, minWidthPx = 1, decimate = true) {
|
|
94
94
|
const bodyFraction = style.bodyWidth ?? DEFAULT_BODY_WIDTH;
|
|
95
|
+
const sourceCount = ohlc.length; // pre-cull, pre-decimation (for draw stats)
|
|
95
96
|
// Viewport cull first (Phase 2): the [vStart, vEnd) candles whose span overlaps
|
|
96
97
|
// the window (+1 each side). Full range when `xScale` has no domain (a stub).
|
|
97
98
|
let [vStart, vEnd] = visibleSpanRange(ohlc.x, ohlc.xEnd, ohlc.length, xScale);
|
|
@@ -103,11 +104,12 @@ export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle'
|
|
|
103
104
|
// into a large series) would re-slot each to a 1px sliver. `decimateOhlc` no-ops
|
|
104
105
|
// (returns the same object) below the visible-density threshold or on a
|
|
105
106
|
// domainless scale, leaving the loop-bound cull above.
|
|
106
|
-
const
|
|
107
|
+
const decimatedOhlc = decimate !== false
|
|
107
108
|
? decimateOhlc(ohlc, xScale, ctx, 2, vEnd - vStart)
|
|
108
109
|
: ohlc;
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
const decimated = decimatedOhlc !== ohlc;
|
|
111
|
+
if (decimated) {
|
|
112
|
+
ohlc = decimatedOhlc; // aggregate candles are already the visible set
|
|
111
113
|
vStart = 0;
|
|
112
114
|
vEnd = ohlc.length;
|
|
113
115
|
}
|
|
@@ -170,5 +172,8 @@ export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle'
|
|
|
170
172
|
ctx.fillRect(bx0, top, bodyW, h);
|
|
171
173
|
}
|
|
172
174
|
}
|
|
175
|
+
// `drawnCount` = candle slots iterated (visible span, or the aggregate set when
|
|
176
|
+
// decimation engaged); `sourceCount` = the raw candle count it started from.
|
|
177
|
+
return { sourceCount, drawnCount: vEnd - vStart, decimated };
|
|
173
178
|
}
|
|
174
179
|
//# sourceMappingURL=ohlc.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/dist/tracker.d.ts
CHANGED
|
@@ -58,10 +58,23 @@ export declare function cursorParts(mode: CursorMode): {
|
|
|
58
58
|
readonly band: boolean;
|
|
59
59
|
};
|
|
60
60
|
/**
|
|
61
|
-
* The crosshair's plot-pixel x from the tracker inputs. A
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
61
|
+
* The crosshair's plot-pixel x from the tracker inputs. **A live local pointer
|
|
62
|
+
* always wins:** a chart the user is actively hovering (`hoverX` non-null) shows
|
|
63
|
+
* its own cursor, even when a controlled `trackerPosition` is also supplied.
|
|
64
|
+
* With no local pointer, a controlled `trackerPosition` (epoch ms) maps through
|
|
65
|
+
* this chart's `xScale` — so a pinned/synced time rides with the data and lands
|
|
66
|
+
* at the right pixel even under a different zoom — else there's no cursor.
|
|
67
|
+
*
|
|
68
|
+
* This ordering is what makes **cross-chart cursor sync** compose from the plain
|
|
69
|
+
* props: give every chart the same `trackerPosition={sharedTime}` and wire
|
|
70
|
+
* `onTrackerChanged` back to `sharedTime`. The chart under the pointer favors its
|
|
71
|
+
* own hover (it's the source, and reports out); every other chart has no local
|
|
72
|
+
* pointer, so it follows the shared time. No "which chart is active" bookkeeping.
|
|
73
|
+
*
|
|
74
|
+
* `null` and `undefined` are **equivalent** — both mean "no controlled position"
|
|
75
|
+
* (a hovered chart still tracks its pointer; a non-hovered one shows nothing). To
|
|
76
|
+
* force a chart to never show a cursor at all, use `cursor="none"`, not
|
|
77
|
+
* `trackerPosition={null}`.
|
|
65
78
|
*/
|
|
66
79
|
export declare function resolveCursorX(trackerPosition: number | null | undefined, hoverX: number | null, xScale: (time: number) => number): number | null;
|
|
67
80
|
//# sourceMappingURL=tracker.d.ts.map
|
package/dist/tracker.js
CHANGED
|
@@ -105,15 +105,28 @@ export function cursorParts(mode) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
/**
|
|
108
|
-
* The crosshair's plot-pixel x from the tracker inputs. A
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
108
|
+
* The crosshair's plot-pixel x from the tracker inputs. **A live local pointer
|
|
109
|
+
* always wins:** a chart the user is actively hovering (`hoverX` non-null) shows
|
|
110
|
+
* its own cursor, even when a controlled `trackerPosition` is also supplied.
|
|
111
|
+
* With no local pointer, a controlled `trackerPosition` (epoch ms) maps through
|
|
112
|
+
* this chart's `xScale` — so a pinned/synced time rides with the data and lands
|
|
113
|
+
* at the right pixel even under a different zoom — else there's no cursor.
|
|
114
|
+
*
|
|
115
|
+
* This ordering is what makes **cross-chart cursor sync** compose from the plain
|
|
116
|
+
* props: give every chart the same `trackerPosition={sharedTime}` and wire
|
|
117
|
+
* `onTrackerChanged` back to `sharedTime`. The chart under the pointer favors its
|
|
118
|
+
* own hover (it's the source, and reports out); every other chart has no local
|
|
119
|
+
* pointer, so it follows the shared time. No "which chart is active" bookkeeping.
|
|
120
|
+
*
|
|
121
|
+
* `null` and `undefined` are **equivalent** — both mean "no controlled position"
|
|
122
|
+
* (a hovered chart still tracks its pointer; a non-hovered one shows nothing). To
|
|
123
|
+
* force a chart to never show a cursor at all, use `cursor="none"`, not
|
|
124
|
+
* `trackerPosition={null}`.
|
|
112
125
|
*/
|
|
113
126
|
export function resolveCursorX(trackerPosition, hoverX, xScale) {
|
|
114
|
-
if (
|
|
127
|
+
if (hoverX !== null)
|
|
115
128
|
return hoverX;
|
|
116
|
-
if (trackerPosition
|
|
129
|
+
if (trackerPosition == null)
|
|
117
130
|
return null;
|
|
118
131
|
return xScale(trackerPosition);
|
|
119
132
|
}
|
package/dist/useChartLegend.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ContainerFrame } from './context.js';
|
|
1
|
+
import { type ContainerFrame, type CursorFrame } from './context.js';
|
|
2
2
|
import type { SelectInfo } from './context.js';
|
|
3
3
|
import { type LegendItemInput, type SwatchSpec } from './swatch.js';
|
|
4
4
|
/** One legend **item** as {@link useChartLegend} serves it — a series entry:
|
|
@@ -74,7 +74,7 @@ export declare function swatchColor(s: SwatchSpec): string;
|
|
|
74
74
|
* card can never disagree about rows or sync semantics. Pass a `rowKey` to
|
|
75
75
|
* **scope** the rows to a single chart row (the layers registered under it);
|
|
76
76
|
* omit it for the whole container. */
|
|
77
|
-
export declare function buildChartLegend(container: ContainerFrame, rowKey?: symbol): ChartLegend;
|
|
77
|
+
export declare function buildChartLegend(container: ContainerFrame, cursor: CursorFrame, rowKey?: symbol): ChartLegend;
|
|
78
78
|
/**
|
|
79
79
|
* **Headless legend** — the registry `<Legend>` renders, as data, plus the
|
|
80
80
|
* hover/select verbs already wired to the chart. For consumers whose legend
|
package/dist/useChartLegend.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useContext, useMemo } from 'react';
|
|
2
|
-
import { ContainerContext, RowContext, } from './context.js';
|
|
2
|
+
import { ContainerContext, CursorContext, RowContext, } from './context.js';
|
|
3
3
|
import { orderLegendItems, } from './swatch.js';
|
|
4
4
|
/** The series-scoped {@link SelectInfo} a legend interaction reports: no
|
|
5
5
|
* sample under it, so `key`/`value` are deliberately `NaN` (see the
|
|
@@ -38,7 +38,7 @@ export function swatchColor(s) {
|
|
|
38
38
|
* card can never disagree about rows or sync semantics. Pass a `rowKey` to
|
|
39
39
|
* **scope** the rows to a single chart row (the layers registered under it);
|
|
40
40
|
* omit it for the whole container. */
|
|
41
|
-
export function buildChartLegend(container, rowKey) {
|
|
41
|
+
export function buildChartLegend(container, cursor, rowKey) {
|
|
42
42
|
const scoped = Array.from(container.legendItems.values()).filter((it) => rowKey === undefined || it.rowKey === rowKey);
|
|
43
43
|
// Ordered + deduped specs (chart-row first), each carrying its `rowKey`;
|
|
44
44
|
// group consecutive same-row specs into a LegendRow, mapping spec → item.
|
|
@@ -61,10 +61,10 @@ export function buildChartLegend(container, rowKey) {
|
|
|
61
61
|
}
|
|
62
62
|
// The cursor pixel → axis units, exactly as the tracker fan-in resolves it
|
|
63
63
|
// (in-bounds guard included, so an off-plot cursor reads as "no cursor").
|
|
64
|
-
const cursorTime =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
? +container.xScale.invert(
|
|
64
|
+
const cursorTime = cursor.cursorX !== null &&
|
|
65
|
+
cursor.cursorX >= 0 &&
|
|
66
|
+
cursor.cursorX <= container.plotWidth
|
|
67
|
+
? +container.xScale.invert(cursor.cursorX)
|
|
68
68
|
: null;
|
|
69
69
|
return {
|
|
70
70
|
rows,
|
|
@@ -115,8 +115,9 @@ export function useChartLegend() {
|
|
|
115
115
|
if (container === null) {
|
|
116
116
|
throw new Error('useChartLegend() must be used inside a <ChartContainer>');
|
|
117
117
|
}
|
|
118
|
+
const cursor = useContext(CursorContext);
|
|
118
119
|
// A RowContext in scope (rendered inside a <Layers>) narrows to that row.
|
|
119
120
|
const rowKey = useContext(RowContext)?.rowKey;
|
|
120
|
-
return useMemo(() => buildChartLegend(container, rowKey), [container, rowKey]);
|
|
121
|
+
return useMemo(() => buildChartLegend(container, cursor, rowKey), [container, cursor, rowKey]);
|
|
121
122
|
}
|
|
122
123
|
//# sourceMappingURL=useChartLegend.js.map
|
package/dist/viewport.d.ts
CHANGED
|
@@ -5,6 +5,25 @@
|
|
|
5
5
|
* the geometry is unit-tested directly, like {@link maxSlotWidths} / the tracker.
|
|
6
6
|
*/
|
|
7
7
|
export type TimeRange = readonly [number, number];
|
|
8
|
+
/**
|
|
9
|
+
* Clamp a view range to an **outer extent** `bounds` — the pan/zoom limit, so
|
|
10
|
+
* the view can never show time outside `[bounds[0], bounds[1]]`. Applied at the
|
|
11
|
+
* range choke point (`applyRange`) so it constrains **every** gesture (pan,
|
|
12
|
+
* zoom-out) and any programmatic range in one place:
|
|
13
|
+
*
|
|
14
|
+
* - **Wider than the extent** (zoomed out past the whole span) → clamp to the
|
|
15
|
+
* full `bounds` (you can't zoom out beyond the total). This makes `bounds`'s
|
|
16
|
+
* width the zoom-**out** ceiling, the outer companion to the `minDuration`
|
|
17
|
+
* zoom-**in** floor.
|
|
18
|
+
* - **Panned past an edge** → slide back so the nearer edge sits on the bound,
|
|
19
|
+
* **preserving the span** (a pan into the boundary stops rather than shrinking
|
|
20
|
+
* the window).
|
|
21
|
+
* - **Already inside** → unchanged.
|
|
22
|
+
*
|
|
23
|
+
* A degenerate `bounds` (`hi <= lo`) is treated as "no constraint" (returns the
|
|
24
|
+
* range untouched) so a mis-specified extent can't collapse the view.
|
|
25
|
+
*/
|
|
26
|
+
export declare function clampToBounds(range: TimeRange, bounds: TimeRange): [number, number];
|
|
8
27
|
/**
|
|
9
28
|
* Shift a range by `dt` ms (drag-pan). The caller signs `dt` from the gesture —
|
|
10
29
|
* dragging the plot right reveals earlier data, i.e. a negative `dt`.
|
package/dist/viewport.js
CHANGED
|
@@ -4,6 +4,38 @@
|
|
|
4
4
|
* `Layers` supplies the pixel→time deltas). Kept pure + free of React/canvas so
|
|
5
5
|
* the geometry is unit-tested directly, like {@link maxSlotWidths} / the tracker.
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* Clamp a view range to an **outer extent** `bounds` — the pan/zoom limit, so
|
|
9
|
+
* the view can never show time outside `[bounds[0], bounds[1]]`. Applied at the
|
|
10
|
+
* range choke point (`applyRange`) so it constrains **every** gesture (pan,
|
|
11
|
+
* zoom-out) and any programmatic range in one place:
|
|
12
|
+
*
|
|
13
|
+
* - **Wider than the extent** (zoomed out past the whole span) → clamp to the
|
|
14
|
+
* full `bounds` (you can't zoom out beyond the total). This makes `bounds`'s
|
|
15
|
+
* width the zoom-**out** ceiling, the outer companion to the `minDuration`
|
|
16
|
+
* zoom-**in** floor.
|
|
17
|
+
* - **Panned past an edge** → slide back so the nearer edge sits on the bound,
|
|
18
|
+
* **preserving the span** (a pan into the boundary stops rather than shrinking
|
|
19
|
+
* the window).
|
|
20
|
+
* - **Already inside** → unchanged.
|
|
21
|
+
*
|
|
22
|
+
* A degenerate `bounds` (`hi <= lo`) is treated as "no constraint" (returns the
|
|
23
|
+
* range untouched) so a mis-specified extent can't collapse the view.
|
|
24
|
+
*/
|
|
25
|
+
export function clampToBounds(range, bounds) {
|
|
26
|
+
const [lo, hi] = bounds;
|
|
27
|
+
const maxSpan = hi - lo;
|
|
28
|
+
if (!(maxSpan > 0))
|
|
29
|
+
return [range[0], range[1]];
|
|
30
|
+
const span = range[1] - range[0];
|
|
31
|
+
if (span >= maxSpan)
|
|
32
|
+
return [lo, hi];
|
|
33
|
+
if (range[0] < lo)
|
|
34
|
+
return [lo, lo + span];
|
|
35
|
+
if (range[1] > hi)
|
|
36
|
+
return [hi - span, hi];
|
|
37
|
+
return [range[0], range[1]];
|
|
38
|
+
}
|
|
7
39
|
/**
|
|
8
40
|
* Shift a range by `dt` ms (drag-pan). The caller signs `dt` from the gesture —
|
|
9
41
|
* dragging the plot right reveals earlier data, i.e. a negative `dt`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/charts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.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.52.0",
|
|
42
|
+
"pond-ts": "^0.52.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|