@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +187 -1
  2. package/dist/AreaChart.js +1 -0
  3. package/dist/BandChart.js +1 -0
  4. package/dist/BarChart.d.ts +14 -1
  5. package/dist/BarChart.js +5 -2
  6. package/dist/BoxPlot.js +1 -0
  7. package/dist/Candlestick.js +1 -0
  8. package/dist/ChartContainer.d.ts +61 -11
  9. package/dist/ChartContainer.js +70 -23
  10. package/dist/Layers.js +53 -14
  11. package/dist/Legend.js +5 -2
  12. package/dist/LineChart.js +1 -0
  13. package/dist/ScatterChart.d.ts +12 -1
  14. package/dist/ScatterChart.js +4 -2
  15. package/dist/XAxis.js +3 -2
  16. package/dist/affine.d.ts +41 -0
  17. package/dist/affine.js +77 -0
  18. package/dist/area.d.ts +20 -2
  19. package/dist/area.js +151 -45
  20. package/dist/band.d.ts +2 -1
  21. package/dist/band.js +5 -0
  22. package/dist/bars.d.ts +14 -1
  23. package/dist/bars.js +42 -1
  24. package/dist/box.d.ts +2 -1
  25. package/dist/box.js +8 -3
  26. package/dist/context.d.ts +121 -22
  27. package/dist/context.js +8 -0
  28. package/dist/data.d.ts +8 -0
  29. package/dist/decimate.d.ts +117 -1
  30. package/dist/decimate.js +241 -1
  31. package/dist/encoding.d.ts +9 -0
  32. package/dist/encoding.js +5 -1
  33. package/dist/index.d.ts +6 -3
  34. package/dist/index.js +5 -3
  35. package/dist/line.d.ts +15 -1
  36. package/dist/line.js +84 -30
  37. package/dist/ohlc.d.ts +2 -1
  38. package/dist/ohlc.js +8 -3
  39. package/dist/scatter.d.ts +3 -2
  40. package/dist/scatter.js +61 -1
  41. package/dist/tracker.d.ts +17 -4
  42. package/dist/tracker.js +19 -6
  43. package/dist/useChartLegend.d.ts +2 -2
  44. package/dist/useChartLegend.js +8 -7
  45. package/dist/viewport.d.ts +19 -0
  46. package/dist/viewport.js +32 -0
  47. 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 { decimateM4 } from './decimate.js';
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
- // Viewport culling (Phase 2): clip to the visible slice (+1 entry/exit point)
72
- // before any path work, so a pan repaint strokes O(visible), not O(N). A no-op
73
- // the same `cs` object back when the whole series is in view or `xScale`
74
- // exposes no domain (a bare test stub), keeping the fully-visible hot path
75
- // byte-identical. Everything below indexes `cs` relatively, so the zero-copy
76
- // subarray view drops in transparently; `boundaries` are absolute instants that
77
- // `sessionRuns` bisects by value, so they still cut the slice correctly.
78
- cs = cullChartSeries(cs, xScale);
79
- // M4 decimation (Phase 3): once the culled slice is still denser than ~2
80
- // samples per device pixel, replace it with the pixel-dense M4 polyline
81
- // ({@link decimateM4}) O(devicePlotWidth) points that rasterize identically.
82
- // The edge union makes the decimated series break at exactly the real gaps
83
- // (gap-mode connectors compose unchanged) **and** aligns a bucket edge to each
84
- // session break in `boundaries`, so `sessionRuns` below still splits the
85
- // decimated series into clean per-session subpaths. Only a non-linear **curve**
86
- // stays gated (a smoothing curve would distort the 4-points-per-column
87
- // polyline) that draws full-resolution. Off (`decimate === false`) or a curve
88
- // set the full culled slice draws. `decimateM4` itself no-ops on a sparse
89
- // slice or a domainless test scale, so this stays byte-identical there.
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 before = cs;
94
- cs = decimateM4(cs, xScale, ctx, k, boundaries);
95
- decimated = cs !== before;
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
- const gen = d3line()
117
- .defined((v) => Number.isFinite(v))
118
- .x((_, j) => xScale(cs.x[s + j]))
119
- .y((v) => yScale(v))
120
- .curve(curve)
121
- .context(ctx);
122
- gen(ys);
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): void;
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 decimated = decimate !== false
107
+ const decimatedOhlc = decimate !== false
107
108
  ? decimateOhlc(ohlc, xScale, ctx, 2, vEnd - vStart)
108
109
  : ohlc;
109
- if (decimated !== ohlc) {
110
- ohlc = decimated; // aggregate candles are already the visible set
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): void;
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 controlled
62
- * `trackerPosition` (epoch ms) maps through `xScale`, so a pinned time rides with
63
- * the data; `null` hides it; `undefined` (uncontrolled) uses the stored hover
64
- * pixel, so a still cursor stays put while a live window slides under it.
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 controlled
109
- * `trackerPosition` (epoch ms) maps through `xScale`, so a pinned time rides with
110
- * the data; `null` hides it; `undefined` (uncontrolled) uses the stored hover
111
- * pixel, so a still cursor stays put while a live window slides under it.
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 (trackerPosition === undefined)
127
+ if (hoverX !== null)
115
128
  return hoverX;
116
- if (trackerPosition === null)
129
+ if (trackerPosition == null)
117
130
  return null;
118
131
  return xScale(trackerPosition);
119
132
  }
@@ -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
@@ -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 = container.cursorX !== null &&
65
- container.cursorX >= 0 &&
66
- container.cursorX <= container.plotWidth
67
- ? +container.xScale.invert(container.cursorX)
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
@@ -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.50.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.50.0",
42
- "pond-ts": "^0.50.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": {