@pond-ts/charts 0.70.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.
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
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
@@ -48,8 +48,6 @@ export const categoricalBars = {
48
48
  picks: SERVICES.map((c) => ({ label: c.label, info: svc(c.label) })),
49
49
  describe: (hit) => hit.mark ?? hit.label,
50
50
  // No `sequence`: a time bucketing over ordinal slots is meaningless.
51
- // No `rangeCursor`: it gates on a continuous x.
52
- rangeCursor: false,
53
51
  sweep: true,
54
52
  };
55
53
  // ── Time axis ──────────────────────────────────────────────────────────────
@@ -108,7 +106,6 @@ export const timeBars = {
108
106
  * bar so the buckets line up with the data rather than with the epoch.
109
107
  */
110
108
  sequence: () => Sequence.every('7d', { anchor: D0 }),
111
- rangeCursor: true,
112
109
  sweep: true,
113
110
  };
114
111
  // ── Stacked bars (many marks per bin) ──────────────────────────────────────
@@ -177,7 +174,6 @@ export const stackedBars = {
177
174
  ? isoDay(hit.key)
178
175
  : `${isoDay(hit.key)}·${hit.label}`,
179
176
  sequence: () => Sequence.every('7d', { anchor: D0 }),
180
- rangeCursor: true,
181
177
  sweep: true,
182
178
  };
183
179
  // ── Box plots (a mark with internal structure) ─────────────────────────────
@@ -258,7 +254,6 @@ const boxFixture = (name, shape, note) => ({
258
254
  /** Three 10-minute boxes per bucket — coarser than the mark, so the snap has
259
255
  * something to do (the lesson `timeBars` learned with its `'1d'`). */
260
256
  sequence: () => Sequence.every('30m', { anchor: BOX_BASE }),
261
- rangeCursor: true,
262
257
  });
263
258
  /** The default shape: a q1→q3 body with thin stems and end caps. The stems are
264
259
  * the interesting part for selection — they are drawn ink far from the body. */
@@ -332,7 +327,6 @@ export const candles = {
332
327
  describe: (hit) => isoDay(hit.key),
333
328
  sweep: true,
334
329
  sequence: () => Sequence.every('5d', { anchor: D0 }),
335
- rangeCursor: true,
336
330
  };
337
331
  // ── The 2-D family: scatter and heat map ──────────────────────────────────
338
332
  /** Forty points on a time axis — a scatter's marks live at `(x, y)`, not in a
@@ -402,7 +396,6 @@ export const scatterPoints = {
402
396
  // A **2-D** sweep: the drag cuts a free rect, and the committed span carries
403
397
  // a `y` window alongside `x` ([PND-INTERACT2D]).
404
398
  sweep: true,
405
- rangeCursor: true,
406
399
  };
407
400
  /**
408
401
  * **A heat-map column** — a *grid* of cells, `(bin × row)`.
@@ -443,7 +436,6 @@ export const heatGrid = {
443
436
  // A **2-D** sweep, snapped in both dimensions — bins on x, rows on y — so a
444
437
  // capture is always a contiguous rectangle of cells ([PND-INTERACT2D]).
445
438
  sweep: true,
446
- rangeCursor: true,
447
439
  };
448
440
  // ── Trading sessions (a discontinuous time axis) ───────────────────────────
449
441
  /** Six weekday sessions (09:30–16:00 UTC) from a Monday — five overnight seams
@@ -531,7 +523,6 @@ export const tradingSessions = {
531
523
  // the same bucketing `sessions.conforming` names, so the generated story and
532
524
  // the session pair below agree about what "conforming" means.
533
525
  sequence: () => sessionSeq(SESSIONS),
534
- rangeCursor: true,
535
526
  sweep: true,
536
527
  sessions: {
537
528
  conforming: () => sessionSeq(SESSIONS),
@@ -9,15 +9,13 @@ import { type ChartFixture } from './selection-fixtures.js';
9
9
  * to write the story. That is what makes walking the matrix a review technique
10
10
  * rather than a gallery — see CLAUDE.md → "Storybook stories".
11
11
  *
12
- * Stories whose subject is *not* chart-type dependent (the deprecation shim,
13
- * for instance) deliberately live in one column only; running them against
14
- * every fixture would test nothing new.
12
+ * Stories whose subject is *not* chart-type dependent deliberately live in one
13
+ * column only; running them against every fixture would test nothing new.
15
14
  */
16
15
  type Story = StoryObj;
17
16
  /**
18
- * The `<Selector>` feature set for one chart type. `<RangeCursor>` is mounted
19
- * only where the fixture declares it draws — on an ordinal axis it does not,
20
- * and mounting one there costs the row its cursor entirely.
17
+ * The `<Selector>` feature set for one chart type, each with a `<RangeCursor>`
18
+ * mounted (on an ordinal axis it shades the slot under the pointer).
21
19
  */
22
20
  export interface SelectorStories {
23
21
  MountedAtContainer: Story;
@@ -6,7 +6,7 @@ import { Layers } from './Layers.js';
6
6
  import { YAxis } from './YAxis.js';
7
7
  import { Selector, MultiSelector } from './selectors.js';
8
8
  import { isSpanSelection, sameMark, selectionContains } from './span.js';
9
- import { RangeCursor } from './cursors.js';
9
+ import { LineCursor, RangeCursor } from './cursors.js';
10
10
  import { caption } from './selection-fixtures.js';
11
11
  const H = 240;
12
12
  /**
@@ -23,7 +23,7 @@ function Chart({ fx, children, height = H, selector, ...container }) {
23
23
  }
24
24
  const list = (fx, sel) => sel.map((m) => fx.describe(m)).join(', ') || '—';
25
25
  export function makeSelectorStories(fx) {
26
- const Cursor = () => (fx.rangeCursor ? _jsx(RangeCursor, {}) : null);
26
+ const Cursor = () => _jsx(RangeCursor, {});
27
27
  return {
28
28
  /** **Mounted at the container** — the ordinary case: one `<Selector>` as a
29
29
  * child of `<ChartContainer>`, enabling the click for every row. It
@@ -40,7 +40,7 @@ export function makeSelectorStories(fx) {
40
40
  MountedInRow: {
41
41
  render: function Render() {
42
42
  const [sel, setSel] = useState([]);
43
- return (_jsxs("div", { children: [_jsxs(ChartContainer, { width: 640, ...fx.container, children: [_jsxs(ChartRow, { height: 150, children: [_jsx(YAxis, { id: fx.axis.id, label: fx.axis.label || 'primary', min: fx.axis.min, max: fx.axis.max }), _jsx(Cursor, {}), _jsx(Selector, { selected: sel, onSelect: (h) => setSel(h === null ? [] : [h]), children: _jsx(Layers, { children: fx.renderLayer('svc') }) })] }), _jsxs(ChartRow, { height: 110, children: [_jsx(YAxis, { id: fx.secondary.axis.id, label: fx.secondary.axis.label, min: fx.secondary.axis.min, max: fx.secondary.axis.max }), _jsx(Layers, { children: fx.secondary.renderLayer('err') })] })] }), _jsxs("p", { style: caption, children: ["The top row selects; the bottom row has no", ' ', _jsx("code", { children: "<Selector>" }), " in scope. ", _jsx("strong", { children: "selected:" }), ' ', list(fx, sel)] })] }));
43
+ return (_jsxs("div", { children: [_jsxs(ChartContainer, { width: 640, ...fx.container, children: [_jsx(LineCursor, {}), _jsxs(ChartRow, { height: 150, children: [_jsx(YAxis, { id: fx.axis.id, label: fx.axis.label || 'primary', min: fx.axis.min, max: fx.axis.max }), _jsx(Cursor, {}), _jsx(Selector, { selected: sel, onSelect: (h) => setSel(h === null ? [] : [h]), children: _jsx(Layers, { children: fx.renderLayer('svc') }) })] }), _jsxs(ChartRow, { height: 110, children: [_jsx(YAxis, { id: fx.secondary.axis.id, label: fx.secondary.axis.label, min: fx.secondary.axis.min, max: fx.secondary.axis.max }), _jsx(Layers, { children: fx.secondary.renderLayer('err') })] })] }), _jsxs("p", { style: caption, children: ["The top row selects; the bottom row has no", ' ', _jsx("code", { children: "<Selector>" }), " in scope. ", _jsx("strong", { children: "selected:" }), ' ', list(fx, sel)] })] }));
44
44
  },
45
45
  },
46
46
  /** **No selector — the plot is inert.** An id-bearing, hit-testable layer
@@ -68,7 +68,7 @@ export function makeSelectorStories(fx) {
68
68
  };
69
69
  return (_jsxs("div", { children: [_jsx("div", { style: { marginBottom: 8 }, children: fx.picks.map((p) => (_jsx("button", { style: btn, onClick: () => setSel((cur) => cur.some((m) => sameMark(m, p.info))
70
70
  ? cur.filter((m) => !sameMark(m, p.info))
71
- : [...cur, p.info]), children: p.label }, p.label))) }), _jsx(Chart, { fx: fx, selector: _jsx(Selector, { enabled: false, selected: sel }) }), _jsxs("p", { style: caption, children: ["The buttons drive the highlight; clicking the plot does nothing.", ' ', _jsx("strong", { children: "selected:" }), " ", list(fx, sel)] })] }));
71
+ : [...cur, p.info]), children: p.label }, p.label))) }), _jsx(Chart, { fx: fx, selector: _jsx(Selector, { enabled: false, selected: sel }), children: _jsx(LineCursor, {}) }), _jsxs("p", { style: caption, children: ["The buttons drive the highlight; clicking the plot does nothing.", ' ', _jsx("strong", { children: "selected:" }), " ", list(fx, sel)] })] }));
72
72
  },
73
73
  },
74
74
  /** **The modifiers are reported; the policy is yours.** `onSelect`'s second