@pond-ts/charts 0.69.0 → 0.71.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 (46) hide show
  1. package/AGENTS.md +3 -1
  2. package/API.md +55 -56
  3. package/CHANGELOG.md +177 -1
  4. package/dist/AreaChart.d.ts +30 -22
  5. package/dist/AreaChart.js +37 -22
  6. package/dist/BandChart.d.ts +20 -1
  7. package/dist/BandChart.js +18 -2
  8. package/dist/BoxPlot.js +5 -3
  9. package/dist/Candlestick.d.ts +3 -3
  10. package/dist/Candlestick.js +5 -5
  11. package/dist/ChartContainer.d.ts +8 -149
  12. package/dist/ChartContainer.js +68 -111
  13. package/dist/ChartRow.d.ts +1 -12
  14. package/dist/ChartRow.js +15 -30
  15. package/dist/HeatMap.d.ts +3 -3
  16. package/dist/HeatMap.js +3 -3
  17. package/dist/Layers.js +84 -34
  18. package/dist/XAxis.js +1 -0
  19. package/dist/area.d.ts +6 -5
  20. package/dist/area.js +13 -6
  21. package/dist/axis-events.d.ts +4 -4
  22. package/dist/band.d.ts +10 -2
  23. package/dist/band.js +38 -10
  24. package/dist/brush.d.ts +13 -21
  25. package/dist/brush.js +22 -36
  26. package/dist/context.d.ts +81 -96
  27. package/dist/cursors.d.ts +81 -51
  28. package/dist/cursors.js +164 -127
  29. package/dist/decimate.d.ts +10 -1
  30. package/dist/decimate.js +51 -10
  31. package/dist/elapsed.d.ts +1 -1
  32. package/dist/elapsed.js +1 -1
  33. package/dist/format.d.ts +2 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/selection-fixtures.d.ts +4 -7
  36. package/dist/selection-fixtures.js +0 -9
  37. package/dist/selection-stories.d.ts +4 -6
  38. package/dist/selection-stories.js +4 -4
  39. package/dist/selectors.d.ts +2 -3
  40. package/dist/selectors.js +2 -3
  41. package/dist/tracker.d.ts +10 -28
  42. package/dist/tracker.js +17 -40
  43. package/dist/tradingAxis.fixture.d.ts +19 -0
  44. package/dist/tradingAxis.fixture.js +24 -0
  45. package/dist/tradingTimeScale.d.ts +2 -2
  46. package/package.json +3 -3
package/dist/cursors.js CHANGED
@@ -1,21 +1,24 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useContext, useEffect, useMemo } from 'react';
2
+ import { useContext, useEffect, useLayoutEffect, useMemo, useRef, } from 'react';
3
3
  import { ContainerContext, RowContext, } from './context.js';
4
4
  import { renderBrushBand } from './brush.js';
5
5
  import { flagChipStyle, flagChipX, axisPillStyle, axisPillX, axisPillConnector, } from './chip.js';
6
6
  import { useSlotKey } from './use-slot-key.js';
7
7
  import { isDev } from './dev.js';
8
8
  /**
9
- * Cursor **presets** — the mounted-component successors of the `cursor` string
10
- * modes (interaction RFC §4 / A4.1): `<LineCursor>`, `<PointCursor>`,
11
- * `<InlineCursor>`, `<FlagCursor>`, `<CrosshairCursor>`, `<RangeCursor>`.
9
+ * Cursor **presets** (interaction RFC §4 / A4.1): `<LineCursor>`,
10
+ * `<PointCursor>`, `<InlineCursor>`, `<FlagCursor>`, `<CrosshairCursor>`,
11
+ * `<RangeCursor>`. **A chart shows a cursor only when one is mounted** —
12
+ * mounting none means no in-chart cursor (hover still reports through
13
+ * `onTrackerChanged`).
12
14
  *
13
15
  * Each preset registers a `CursorSpec` with the container (the
14
16
  * `registerAxis` / `registerLayer` idiom): **declared** snap plus render slots
15
17
  * taking resolved geometry. The container resolves — the x-snap, the
16
18
  * per-sample measurements, the band — and the slots draw (RFC A2.3). Mount a
17
19
  * preset as a child of `<ChartContainer>` (the default for every row) or
18
- * inside a `<ChartRow>` (the per-row override, replacing `<ChartRow cursor>`).
20
+ * inside a `<ChartRow>` (the per-row override: a row with its own mounts
21
+ * ignores the container's).
19
22
  *
20
23
  * Render-only presets may stack; **one cursor owns snap and gesture per
21
24
  * scope**, resolved to the hovered row's innermost mount (RFC A2.5) — the
@@ -78,12 +81,13 @@ function cursorLine(f) {
78
81
  }
79
82
  const NO_WANTS = {
80
83
  samples: false,
84
+ reticle: false,
81
85
  flags: false,
82
86
  band: false,
83
87
  pointer: false,
84
88
  time: false,
85
89
  };
86
- /** `cursor="line"` as a spec: the synced vertical line only (+ optional time). */
90
+ /** `<LineCursor>` as a spec: the synced vertical line only (+ optional time). */
87
91
  function buildLineCursor(o) {
88
92
  return {
89
93
  spec: {
@@ -95,9 +99,10 @@ function buildLineCursor(o) {
95
99
  },
96
100
  wants: { ...NO_WANTS, time: o.showTime },
97
101
  ownsGesture: false,
102
+ format: o.format,
98
103
  };
99
104
  }
100
- /** `cursor="point"` as a spec: a dot on each series, no line. */
105
+ /** `<PointCursor>` as a spec: a dot on each series, no line. */
101
106
  function buildPointCursor(o) {
102
107
  return {
103
108
  spec: {
@@ -109,9 +114,10 @@ function buildPointCursor(o) {
109
114
  },
110
115
  wants: { ...NO_WANTS, samples: true, time: o.showTime },
111
116
  ownsGesture: false,
117
+ format: o.format,
112
118
  };
113
119
  }
114
- /** `cursor="inline"` as a spec: dots + a value chip beside each, clamped
120
+ /** `<InlineCursor>` as a spec: dots + a value chip beside each, clamped
115
121
  * within the row and flipped left near the right edge. */
116
122
  function buildInlineCursor(o) {
117
123
  return {
@@ -137,9 +143,10 @@ function buildInlineCursor(o) {
137
143
  },
138
144
  wants: { ...NO_WANTS, samples: true, time: o.showTime },
139
145
  ownsGesture: false,
146
+ format: o.format,
140
147
  };
141
148
  }
142
- /** `cursor="flag"` as a spec: dots + staffed value flags stacked near the top
149
+ /** `<FlagCursor>` as a spec: dots + staffed value flags stacked near the top
143
150
  * of the row, plus the consolidated one-chip flag for `cursorFlag` layers
144
151
  * (BoxPlot). The time readout (when shown, first row) tops the stack and the
145
152
  * staffs start just below it. */
@@ -181,13 +188,29 @@ function buildFlagCursor(o) {
181
188
  },
182
189
  wants: { ...NO_WANTS, samples: true, flags: true, time: o.showTime },
183
190
  ownsGesture: false,
191
+ format: o.format,
184
192
  };
185
193
  }
194
+ /**
195
+ * The sample the snapping crosshair centres on in a row: the one nearest the
196
+ * pointer y in the hovered row — or the first sample when nothing is hovered
197
+ * (a pinned tracker shows a reticle in every row). `null` in a row that isn't
198
+ * hovered while another is, or when there is nothing to snap to.
199
+ */
200
+ function snappedSample(f) {
201
+ const samples = f.reticleSamples;
202
+ if (inBoundsX(f) === null || samples.length === 0)
203
+ return null;
204
+ const cy = f.cursorY;
205
+ if (f.hoveredRowKey === f.rowKey && cy !== null) {
206
+ return samples.reduce((a, b) => Math.abs(b.py - cy) < Math.abs(a.py - cy) ? b : a);
207
+ }
208
+ return f.hoveredRowKey === null ? samples[0] : null;
209
+ }
186
210
  /**
187
211
  * The crosshair's single reticle centre for a row: with `snap` (default) the
188
- * sample nearest the pointer y in the hovered row — or the first sample when
189
- * nothing is hovered (a pinned tracker shows a reticle in every row); free
190
- * mode reads the container-resolved raw-pointer measurement.
212
+ * {@link snappedSample}; free mode reads the container-resolved raw-pointer
213
+ * measurement.
191
214
  *
192
215
  * It carries the picked sample's **axis placement** (side + gutter offset +
193
216
  * axis ink) as well as its value, because the reticle reads one series and its
@@ -199,16 +222,8 @@ function crosshairPick(f, snap) {
199
222
  if (inBoundsX(f) === null)
200
223
  return null;
201
224
  if (!snap)
202
- return f.pointer;
203
- if (f.samples.length === 0)
204
- return null;
205
- const hoveredRow = f.hoveredRowKey === f.rowKey;
206
- const cy = f.cursorY;
207
- const pick = hoveredRow && cy !== null
208
- ? f.samples.reduce((a, b) => Math.abs(b.py - cy) < Math.abs(a.py - cy) ? b : a)
209
- : f.hoveredRowKey === null
210
- ? f.samples[0]
211
- : null;
225
+ return f.pointer && { ...f.pointer, color: undefined };
226
+ const pick = snappedSample(f);
212
227
  return pick
213
228
  ? {
214
229
  py: pick.py,
@@ -216,10 +231,60 @@ function crosshairPick(f, snap) {
216
231
  side: pick.side,
217
232
  axisOffset: pick.axisOffset,
218
233
  axisColor: pick.axisColor,
234
+ color: pick.color,
219
235
  }
220
236
  : null;
221
237
  }
222
- /** `cursor="crosshair"` as a spec: the dashed reticle (renderPlot), the axis
238
+ /** The consumer-facing {@link CursorSnap} for a resolved sample. */
239
+ function toCursorSnap(s) {
240
+ return {
241
+ x: s.x,
242
+ value: s.value,
243
+ color: s.color,
244
+ label: s.label,
245
+ ...(s.readout !== undefined ? { readout: s.readout } : {}),
246
+ axisId: s.axisId,
247
+ formatted: s.formatted,
248
+ };
249
+ }
250
+ /** A {@link CursorSnap}'s identity for change detection — every field, so a
251
+ * new axis format under a still pointer (same point, new `formatted`) is a
252
+ * change the consumer hears about too. */
253
+ function snapKey(s) {
254
+ return s === null
255
+ ? null
256
+ : [s.axisId, s.label, s.x, s.value, s.readout, s.color, s.formatted].join('\u0000');
257
+ }
258
+ /**
259
+ * The crosshair's snap reporter: picks the snapped sample from a hovered row's
260
+ * frame (`null` = nothing snapped) and calls the consumer only when the pick
261
+ * **changes** — rows re-render on every pointer move, and a callback that fired
262
+ * per move while the reticle sat still would make every consumer dedupe.
263
+ *
264
+ * Its state belongs to the mounted `<CrosshairCursor>`, not to this reporter:
265
+ * the reporter is rebuilt whenever `snap` / `showTime` / `format` change, and
266
+ * a fresh "last reported" would swallow the `null` that toggling to free mode
267
+ * owes a consumer still holding a point. `live` goes false on unmount, so a
268
+ * row still holding the old registration for one more commit can't report a
269
+ * point after the unmount's final `null`. The callback is read through a ref
270
+ * so an inline `onSnap` doesn't rebuild the cursor spec on every parent render.
271
+ */
272
+ function snapReporter(onSnapRef, state, snap) {
273
+ return (f) => {
274
+ const st = state.current;
275
+ if (!st.live)
276
+ return;
277
+ // Free mode follows the pointer, not a series: there is nothing to report.
278
+ const pick = f !== null && snap ? snappedSample(f) : null;
279
+ const info = pick === null ? null : toCursorSnap(pick);
280
+ const key = snapKey(info);
281
+ if (key === st.last)
282
+ return;
283
+ st.last = key;
284
+ onSnapRef.current?.(info);
285
+ };
286
+ }
287
+ /** `<CrosshairCursor>` as a spec: the dashed reticle (renderPlot), the axis
223
288
  * value pill (renderYGutter), and the x-axis time pill (renderXAxis). Declares
224
289
  * `snapX: 'sample'` — the container snaps the shared cursorX to the data grid. */
225
290
  function buildCrosshairCursor(o) {
@@ -233,7 +298,7 @@ function buildCrosshairCursor(o) {
233
298
  const ink = cursorInk(f.theme);
234
299
  const background = f.theme.background;
235
300
  const reticle = crosshairPick(f, o.snap);
236
- return (_jsxs(_Fragment, { children: [_jsx("line", { x1: Math.round(x), y1: 0, x2: Math.round(x), y2: f.rowHeight, stroke: ink, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), reticle && (_jsxs(_Fragment, { children: [_jsx("line", { x1: 0, y1: Math.round(reticle.py), x2: f.plotWidth, y2: Math.round(reticle.py), stroke: ink, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), _jsx("circle", { cx: x, cy: reticle.py, r: 3, fill: ink, stroke: background, strokeWidth: background ? 1 : 0 })] }))] }));
301
+ return (_jsxs(_Fragment, { children: [_jsx("line", { x1: Math.round(x), y1: 0, x2: Math.round(x), y2: f.rowHeight, stroke: ink, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), reticle && (_jsxs(_Fragment, { children: [_jsx("line", { x1: 0, y1: Math.round(reticle.py), x2: f.plotWidth, y2: Math.round(reticle.py), stroke: ink, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), _jsx("circle", { cx: x, cy: reticle.py, r: 3, fill: reticle.color ?? ink, stroke: background, strokeWidth: background ? 1 : 0 })] }))] }));
237
302
  },
238
303
  // The value pill goes **on the reticle's own axis**: its side, its offset
239
304
  // out into that gutter (so a second axis on a side gets its own pill
@@ -287,12 +352,13 @@ function buildCrosshairCursor(o) {
287
352
  }
288
353
  : {}),
289
354
  },
290
- wants: { ...NO_WANTS, samples: true, pointer: !o.snap },
355
+ wants: { ...NO_WANTS, reticle: true, pointer: !o.snap },
291
356
  ownsGesture: true,
292
357
  format: o.format,
358
+ reportSnap: o.reportSnap,
293
359
  };
294
360
  }
295
- /** `cursor="region"` as a spec: the hover-time **band** — the bucket under the
361
+ /** `<RangeCursor>` as a spec: the hover-time **band** — the bucket under the
296
362
  * pointer (sequence-snapped; freeform = a plain line until a drag shades the
297
363
  * raw span) — plus the drag registration the brush recognizer reads
298
364
  * (`resolveRangeDrag`). The container resolves the band; the shared
@@ -320,10 +386,7 @@ function buildRangeCursor(o) {
320
386
  * Update-in-place on a prop change (the entry memo), unregister on unmount —
321
387
  * the `registerAxis` discipline.
322
388
  */
323
- function useCursorMount(built, legacy,
324
- /** The container shim's un-asked-for `'line'` default (see
325
- * {@link CursorEntry.implicit}) — never set by component mounts. */
326
- implicit = false) {
389
+ function useCursorMount(built) {
327
390
  const container = useContext(ContainerContext);
328
391
  if (container === null) {
329
392
  throw new Error('cursor components must be mounted inside a <ChartContainer> (as a ' +
@@ -343,10 +406,9 @@ implicit = false) {
343
406
  onDragRelease: built.onDragRelease,
344
407
  enableDrag: built.enableDrag,
345
408
  dragModifier: built.dragModifier,
409
+ reportSnap: built.reportSnap,
346
410
  rowKey,
347
- legacy,
348
- ...(implicit ? { implicit } : {}),
349
- }, [built, rowKey, legacy, implicit]);
411
+ }, [built, rowKey]);
350
412
  const { registerCursor, unregisterCursor } = container;
351
413
  useEffect(() => {
352
414
  if (entry === null) {
@@ -357,98 +419,100 @@ implicit = false) {
357
419
  }, [registerCursor, unregisterCursor, key, entry]);
358
420
  useEffect(() => () => unregisterCursor(key), [unregisterCursor, key]);
359
421
  }
360
- /** The synced vertical cursor **line** — `cursor="line"` as a component (the
361
- * container default during the deprecation window). Pair with an off-chart
362
- * readout via `onTrackerChanged`. */
363
- export function LineCursor({ showTime = false } = {}) {
364
- useCursorMount(useMemo(() => buildLineCursor({ showTime }), [showTime]), false);
422
+ /** The synced vertical cursor **line**. Pair with an off-chart readout via
423
+ * `onTrackerChanged`. */
424
+ export function LineCursor({ showTime = false, format } = {}) {
425
+ useCursorMount(useMemo(() => buildLineCursor({ showTime, format }), [showTime, format]));
365
426
  return null;
366
427
  }
367
- /** A **dot on each series** at the cursor, no line — `cursor="point"`. */
368
- export function PointCursor({ showTime = false } = {}) {
369
- useCursorMount(useMemo(() => buildPointCursor({ showTime }), [showTime]), false);
428
+ /** A **dot on each series** at the cursor, no line. */
429
+ export function PointCursor({ showTime = false, format, } = {}) {
430
+ useCursorMount(useMemo(() => buildPointCursor({ showTime, format }), [showTime, format]));
370
431
  return null;
371
432
  }
372
- /** Dots **plus a value chip beside each** — `cursor="inline"`. */
373
- export function InlineCursor({ showTime = false } = {}) {
374
- useCursorMount(useMemo(() => buildInlineCursor({ showTime }), [showTime]), false);
433
+ /** Dots **plus a value chip beside each**. */
434
+ export function InlineCursor({ showTime = false, format, } = {}) {
435
+ useCursorMount(useMemo(() => buildInlineCursor({ showTime, format }), [showTime, format]));
375
436
  return null;
376
437
  }
377
- /** Dots + **staffed value flags** stacked near the top of the row —
378
- * `cursor="flag"`. A `cursorFlag` layer (BoxPlot) consolidates onto one flag. */
379
- export function FlagCursor({ showTime = false } = {}) {
380
- useCursorMount(useMemo(() => buildFlagCursor({ showTime }), [showTime]), false);
438
+ /** Dots + **staffed value flags** stacked near the top of the row — a `cursorFlag` layer (BoxPlot)
439
+ * consolidates onto one flag. */
440
+ export function FlagCursor({ showTime = false, format } = {}) {
441
+ useCursorMount(useMemo(() => buildFlagCursor({ showTime, format }), [showTime, format]));
381
442
  return null;
382
443
  }
383
- /** The inspection **reticle** — `cursor="crosshair"`: dashed cross lines, a
384
- * centre dot, the value pinned to its y axis, the time pinned to the x axis. */
385
- export function CrosshairCursor({ snap = true, showTime = true, format, } = {}) {
386
- useCursorMount(useMemo(() => buildCrosshairCursor({ snap, showTime, format }), [snap, showTime, format]), false);
444
+ /** The inspection **reticle**: dashed cross lines, a
445
+ * centre dot in the snapped series' colour, the value pinned to its y axis,
446
+ * the time pinned to the x axis. */
447
+ export function CrosshairCursor({ snap = true, showTime = true, format, onSnap, } = {}) {
448
+ // Latest callback in a ref, so an inline `onSnap` doesn't re-register the
449
+ // cursor on every parent render; the spec is rebuilt only when a callback
450
+ // appears or goes away. Written in a *layout* effect: the rows report from
451
+ // passive effects, and every layout effect in a commit runs before any
452
+ // passive one — so a row always calls this commit's callback, wherever the
453
+ // cursor sits in the tree relative to the rows.
454
+ const onSnapRef = useRef(onSnap);
455
+ useLayoutEffect(() => {
456
+ onSnapRef.current = onSnap;
457
+ });
458
+ // What the consumer was last told, shared across reporter rebuilds (see
459
+ // `snapReporter`).
460
+ const reportState = useRef({ last: null, live: true });
461
+ const listening = onSnap !== undefined;
462
+ // A consumer who stops listening is owed nothing more; a fresh listener
463
+ // starts from "nothing reported yet".
464
+ if (!listening)
465
+ reportState.current.last = null;
466
+ // Unmounting while snapped lets go of the point, the same as the pointer
467
+ // leaving would. (`live` is re-set on mount for StrictMode's
468
+ // unmount-remount.)
469
+ useEffect(() => {
470
+ const st = reportState.current;
471
+ st.live = true;
472
+ return () => {
473
+ st.live = false;
474
+ if (st.last === null)
475
+ return;
476
+ st.last = null;
477
+ onSnapRef.current?.(null);
478
+ };
479
+ }, []);
480
+ useCursorMount(useMemo(() => buildCrosshairCursor({
481
+ snap,
482
+ showTime,
483
+ format,
484
+ reportSnap: listening
485
+ ? snapReporter(onSnapRef, reportState, snap)
486
+ : undefined,
487
+ }), [snap, showTime, format, listening]));
387
488
  return null;
388
489
  }
389
490
  /**
390
- * The **range** cursor — `cursor="region"` as a component (RFC A4.1 renames
491
+ * The **range** cursor (RFC A4.1 names
391
492
  * it for what it emits: a live extent — and, dragged, exactly what
392
493
  * `ChartContainer.range` accepts — against the annotation `<Region>`'s fixed
393
- * mark). Hover shades the bucket under the pointer; wiring
494
+ * mark). Hover shades the bucket under the pointer (on a category axis, the
495
+ * slot); wiring
394
496
  * {@link RangeCursorProps.onDragRelease} adds the drag, which fires once on
395
497
  * release and reverts (RFC §6: a region is deliberately a cursor **and** a
396
498
  * drag that fires and resets). The gesture rides the shared brush recognizer
397
499
  * (`brush.tsx`) — one engine arbitrating every drag claim on the plot.
398
500
  */
399
501
  export function RangeCursor({ sequence, onDragRelease, enableDrag, dragModifier, } = {}) {
400
- useCursorMount(useMemo(() => buildRangeCursor({ sequence, onDragRelease, enableDrag, dragModifier }), [sequence, onDragRelease, enableDrag, dragModifier]), false);
502
+ useCursorMount(useMemo(() => buildRangeCursor({ sequence, onDragRelease, enableDrag, dragModifier }), [sequence, onDragRelease, enableDrag, dragModifier]));
401
503
  return null;
402
504
  }
403
- /**
404
- * The deprecation shim (internal): synthesizes the preset equivalent of a
405
- * legacy `cursor` string — the container's `cursor` prop (or its `'line'`
406
- * default), and `<ChartRow cursor>` inside a row. Registers as `legacy`, so a
407
- * component-mounted cursor in the same scope overrides it.
408
- */
409
- export function LegacyCursor({ mode, showTime, snap, sequence, implicit = false, }) {
410
- const built = useMemo(() => {
411
- switch (mode) {
412
- case 'line':
413
- return buildLineCursor({ showTime });
414
- case 'point':
415
- return buildPointCursor({ showTime });
416
- case 'inline':
417
- return buildInlineCursor({ showTime });
418
- case 'flag':
419
- return buildFlagCursor({ showTime });
420
- case 'crosshair':
421
- // The legacy crosshair always pins the time to the x axis; its y-snap
422
- // is the container's `crosshairSnap`. (`cursorTime` is deliberately
423
- // NOT forwarded — crosshair has no per-row time chip.)
424
- return buildCrosshairCursor({ snap, showTime: true });
425
- case 'region':
426
- return buildRangeCursor({ sequence });
427
- case 'none':
428
- return null;
429
- }
430
- }, [mode, showTime, snap, sequence]);
431
- useCursorMount(built, true, implicit);
432
- return null;
433
- }
434
- /** Drop a scope's legacy (shim-synthesized) entries when the scope also has a
435
- * component-mounted cursor — mounting a component overrides the string prop. */
436
- function dropShadowedLegacy(entries) {
437
- return entries.some((e) => !e.legacy)
438
- ? entries.filter((e) => !e.legacy)
439
- : entries;
440
- }
441
505
  /**
442
506
  * The cursors in effect for a row: the row's own mounts when it has any (the
443
- * per-row override — nearest mount wins, exactly `row.cursor ?? container
444
- * .cursor`'s semantics), else the container-scoped mounts. Within a scope,
445
- * component mounts shadow the legacy shim.
507
+ * per-row override — nearest mount wins), else the container-scoped mounts.
508
+ * A row that wants no cursor while its siblings have one mounts the cursors
509
+ * per row rather than at the container.
446
510
  */
447
511
  export function effectiveCursorEntries(all, rowKey) {
448
512
  const rowEntries = all.filter((e) => e.rowKey === rowKey);
449
513
  if (rowEntries.length > 0)
450
- return dropShadowedLegacy(rowEntries);
451
- return dropShadowedLegacy(all.filter((e) => e.rowKey === null));
514
+ return rowEntries;
515
+ return all.filter((e) => e.rowKey === null);
452
516
  }
453
517
  /** The scope's single snap/gesture owner (RFC A2.5) — first mount wins; the
454
518
  * container dev-warns when a scope has two. */
@@ -467,7 +531,7 @@ export function xAxisCursorEntries(all, hoveredRowKey) {
467
531
  return effectiveCursorEntries(all, hoveredRowKey);
468
532
  const out = [];
469
533
  const seenRows = new Set();
470
- out.push(...dropShadowedLegacy(all.filter((e) => e.rowKey === null)));
534
+ out.push(...all.filter((e) => e.rowKey === null));
471
535
  for (const e of all) {
472
536
  if (e.rowKey === null || seenRows.has(e.rowKey))
473
537
  continue;
@@ -486,7 +550,7 @@ export function warnOnDuplicateGestureOwners(all, warned) {
486
550
  return;
487
551
  const scopes = new Set(all.map((e) => e.rowKey));
488
552
  for (const scope of scopes) {
489
- const entries = dropShadowedLegacy(all.filter((e) => e.rowKey === scope));
553
+ const entries = all.filter((e) => e.rowKey === scope);
490
554
  if (entries.filter((e) => e.ownsGesture).length > 1) {
491
555
  warned.current = true;
492
556
  console.warn('[pond-charts] two gesture-owning cursors (<CrosshairCursor> / ' +
@@ -498,31 +562,4 @@ export function warnOnDuplicateGestureOwners(all, warned) {
498
562
  }
499
563
  }
500
564
  }
501
- /** @internal The dev deprecation notice for a legacy cursor prop — one line
502
- * naming the replacement, shared by the container and row shims. */
503
- export function legacyCursorWarning(lines) {
504
- return ('[pond-charts] deprecated cursor props (they keep working this minor, ' +
505
- 'removed next): ' +
506
- lines.join('; ') +
507
- '. Mount a cursor component instead (docs/rfcs/interaction.md §9).');
508
- }
509
- /** @internal The preset name a legacy `cursor` mode maps to (for warnings). */
510
- export function presetNameFor(mode) {
511
- switch (mode) {
512
- case 'line':
513
- return '<LineCursor>';
514
- case 'point':
515
- return '<PointCursor>';
516
- case 'inline':
517
- return '<InlineCursor>';
518
- case 'flag':
519
- return '<FlagCursor>';
520
- case 'crosshair':
521
- return '<CrosshairCursor>';
522
- case 'region':
523
- return '<RangeCursor>';
524
- case 'none':
525
- return 'nothing (mount no cursor)';
526
- }
527
- }
528
565
  //# sourceMappingURL=cursors.js.map
@@ -224,8 +224,17 @@ export declare function m4Polyline(edges: Float64Array, mn: Float64Array, mx: Fl
224
224
  * `upper` are finite **together** per sample (the paired-percentile shape bands
225
225
  * are built from); a column where only one edge has finite samples would bin a
226
226
  * band segment that no single sample carried.
227
+ *
228
+ * `boundaries` are trading-axis session-break instants (`<BandChart
229
+ * sessionBreaks>`), handled exactly as {@link decimateM4} does for a line: each
230
+ * in-domain instant is unioned into the bucket edges so no column merges two
231
+ * sessions' envelopes across the discontinuity, **and** a `NaN` sample is
232
+ * emitted at the instant so the fill ends at the close and re-starts at the
233
+ * open — otherwise the closing and opening columns would sit as adjacent finite
234
+ * samples and the envelope would flow straight across the collapsed gap. The
235
+ * caller's `sessionRuns` then sees the break baked in and passes no boundaries.
227
236
  */
228
- export declare function decimateBand(band: BandSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number): BandSeries;
237
+ export declare function decimateBand(band: BandSeries, xScale: Scale, ctx: CanvasRenderingContext2D, k?: number, boundaries?: readonly number[]): BandSeries;
229
238
  /**
230
239
  * Decimate an {@link OhlcSeries} to one **aggregate candle per device-pixel
231
240
  * column** — `open = first`, `high = max`, `low = min`, `close = last` over the
package/dist/decimate.js CHANGED
@@ -400,8 +400,17 @@ export function m4Polyline(edges, mn, mx, first, last, W, breakAt = NO_BREAKS) {
400
400
  * `upper` are finite **together** per sample (the paired-percentile shape bands
401
401
  * are built from); a column where only one edge has finite samples would bin a
402
402
  * band segment that no single sample carried.
403
+ *
404
+ * `boundaries` are trading-axis session-break instants (`<BandChart
405
+ * sessionBreaks>`), handled exactly as {@link decimateM4} does for a line: each
406
+ * in-domain instant is unioned into the bucket edges so no column merges two
407
+ * sessions' envelopes across the discontinuity, **and** a `NaN` sample is
408
+ * emitted at the instant so the fill ends at the close and re-starts at the
409
+ * open — otherwise the closing and opening columns would sit as adjacent finite
410
+ * samples and the envelope would flow straight across the collapsed gap. The
411
+ * caller's `sessionRuns` then sees the break baked in and passes no boundaries.
403
412
  */
404
- export function decimateBand(band, xScale, ctx, k = 2) {
413
+ export function decimateBand(band, xScale, ctx, k = 2, boundaries = []) {
405
414
  if (!shouldDecimateCount(band.length, ctx, k))
406
415
  return band;
407
416
  const dom = scaleDomain(xScale);
@@ -412,18 +421,50 @@ export function decimateBand(band, xScale, ctx, k = 2) {
412
421
  if (invert === null || plotWidthCss === null)
413
422
  return band;
414
423
  const W = deviceBucketCount(ctx);
415
- const edges = pixelEdges(invert, plotWidthCss, W);
424
+ const pixels = pixelEdges(invert, plotWidthCss, W);
425
+ // Session-break instants inside the visible domain — unioned into the edges
426
+ // (so a column never straddles a break) AND marked as explicit break samples.
427
+ // `mergeGapEdges` keeps their exact values, so the set matches the edges.
428
+ const breaks = boundaries.length > 0
429
+ ? boundaries.filter((b) => b > dom[0] && b < dom[1])
430
+ : [];
431
+ const edges = breaks.length > 0 ? mergeGapEdges(pixels, breaks, dom[0], dom[1]) : pixels;
432
+ const buckets = edges.length - 1;
416
433
  const lowerMin = new Float64Column(band.lower, band.length).binBy(band.x, edges, 'min');
417
434
  const upperMax = new Float64Column(band.upper, band.length).binBy(band.x, edges, 'max');
418
- const x = new Float64Array(W);
419
- const lower = new Float64Array(W);
420
- const upper = new Float64Array(W);
421
- for (let b = 0; b < W; b += 1) {
422
- x[b] = (edges[b] + edges[b + 1]) / 2; // column centre
423
- lower[b] = lowerMin[b]; // NaN on an empty column → the fill break
424
- upper[b] = upperMax[b];
435
+ const breakAt = breaks.length > 0 ? new Set(breaks) : null;
436
+ // One sample per column + one NaN break slot per session break. `breaks` are
437
+ // strictly inside the domain and `mergeGapEdges` keeps every one, so each
438
+ // lands on some `edges[b]` with `b > 0` and the arrays fill exactly; the
439
+ // subarray trim below is a guard against a provider instant that sorts ahead
440
+ // of the first pixel edge under float rounding, not an expected path.
441
+ const cap = buckets + (breakAt === null ? 0 : breakAt.size);
442
+ const x = new Float64Array(cap);
443
+ const lower = new Float64Array(cap);
444
+ const upper = new Float64Array(cap);
445
+ let n = 0;
446
+ for (let b = 0; b < buckets; b += 1) {
447
+ // Explicit session break: this column opens a new session → end the fill
448
+ // first. A NaN on both edges is the band's own gap signal (`.defined`).
449
+ if (breakAt !== null && b > 0 && breakAt.has(edges[b])) {
450
+ x[n] = edges[b];
451
+ lower[n] = NaN;
452
+ upper[n] = NaN;
453
+ n += 1;
454
+ }
455
+ x[n] = (edges[b] + edges[b + 1]) / 2; // column centre
456
+ lower[n] = lowerMin[b]; // NaN on an empty column → the fill break
457
+ upper[n] = upperMax[b];
458
+ n += 1;
425
459
  }
426
- return { x, lower, upper, length: W };
460
+ return n === cap
461
+ ? { x, lower, upper, length: n }
462
+ : {
463
+ x: x.subarray(0, n),
464
+ lower: lower.subarray(0, n),
465
+ upper: upper.subarray(0, n),
466
+ length: n,
467
+ };
427
468
  }
428
469
  /**
429
470
  * Decimate an {@link OhlcSeries} to one **aggregate candle per device-pixel
package/dist/elapsed.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Two things change, and only these two: **where the ticks sit** and **what they
8
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
9
+ * mark's `at`, the container's `range`, a `<RangeCursor onDragRelease>` span are all still
10
10
  * absolute axis units. Relabeling only.
11
11
  *
12
12
  * Where the ticks sit is the part that can't be done with `<XAxis transform>`:
package/dist/elapsed.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Two things change, and only these two: **where the ticks sit** and **what they
8
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
9
+ * mark's `at`, the container's `range`, a `<RangeCursor onDragRelease>` span are all still
10
10
  * absolute axis units. Relabeling only.
11
11
  *
12
12
  * Where the ticks sit is the part that can't be done with `<XAxis transform>`:
package/dist/format.d.ts CHANGED
@@ -15,7 +15,8 @@ import type { TimeGrain } from './tickLadder.js';
15
15
  export type AxisFormat = string | ((value: number) => string);
16
16
  /**
17
17
  * How to format the **cursor / marker readout** on the x axis
18
- * ({@link ChartContainerProps.cursorFormat}) — time or value kind. Either:
18
+ * (the `format` prop on a cursor such as `<CrosshairCursor>`) — time or
19
+ * value kind. Either:
19
20
  *
20
21
  * - a d3 specifier **string** applied uniformly: a [time specifier]
21
22
  * (https://github.com/d3/d3-time-format#locale_format) on a time axis
package/dist/index.d.ts CHANGED
@@ -94,7 +94,7 @@ export { LineCursor, PointCursor, InlineCursor, FlagCursor, CrosshairCursor, Ran
94
94
  export type { LineCursorProps, PointCursorProps, InlineCursorProps, FlagCursorProps, CrosshairCursorProps, RangeCursorProps, } from './cursors.js';
95
95
  export { Selector, MultiSelector } from './selectors.js';
96
96
  export type { SelectorProps, MultiSelectorProps } from './selectors.js';
97
- export type { CursorMode, TrackerInfo, TrackerSample, SelectInfo, SelectModifiers, RangeSpan, SpanSelection, SelectionEntry, } from './context.js';
97
+ export type { CursorSnap, TrackerInfo, TrackerSample, SelectInfo, SelectModifiers, RangeSpan, SpanSelection, SelectionEntry, } from './context.js';
98
98
  export { selectionContains, isSpanSelection, sameMark } from './span.js';
99
99
  export type { DrawStatsFrame, LayerDrawInfo } from './context.js';
100
100
  //# sourceMappingURL=index.d.ts.map
@@ -19,10 +19,11 @@ import type { SelectInfo } from './context.js';
19
19
  *
20
20
  * That is not hypothetical: half a built matrix already surfaced two defects —
21
21
  * categorical hover being ink-only where the time axis claims the whole slot,
22
- * and `<RangeCursor>` on an ordinal axis suppressing the row's cursor entirely.
22
+ * and `<RangeCursor>` on an ordinal axis suppressing the row's cursor entirely
23
+ * (since fixed: it shades the slot there, [PND-ORDCURSOR]).
23
24
  *
24
- * **Capabilities are declared, not assumed.** `sequence` and `rangeCursor` are
25
- * absent on fixtures that cannot support them, so the factory omits those
25
+ * **Capabilities are declared, not assumed.** `sequence` is absent on
26
+ * fixtures that cannot support it, so the factory omits those
26
27
  * stories rather than generating one that silently does nothing. A gap in the
27
28
  * matrix is information; a story that renders nothing is a trap.
28
29
  */
@@ -72,10 +73,6 @@ export interface ChartFixture {
72
73
  readonly sweep: boolean;
73
74
  /** A snapping sequence, where the axis has one. Ordinal axes do not. */
74
75
  readonly sequence?: () => Sequence | BoundedSequence;
75
- /** Whether `<RangeCursor>` draws here — it gates on a continuous x
76
- * (`brush.tsx`), so mounting one on a category axis is not just inert, it
77
- * takes the row's cursor with it. */
78
- readonly rangeCursor: boolean;
79
76
  /**
80
77
  * Present only where the x axis **collapses closed-market time**, which is
81
78
  * what makes a snap block's relationship to the session grid a question at