@pond-ts/charts 0.41.0 → 0.43.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.
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { scaleLinear, scaleTime } from 'd3-scale';
4
+ import { scaleTradingTime, } from './tradingTimeScale.js';
5
+ import { scaleBand } from './bandScale.js';
6
+ import { Sequence } from 'pond-ts';
4
7
  import { ContainerContext, } from './context.js';
5
8
  import { maxSlotWidths, sum } from './slots.js';
6
9
  import { computeLabelLanes } from './annotations.js';
@@ -31,7 +34,7 @@ function normalizeRange(range) {
31
34
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
32
35
  * (`<YAxis>`).
33
36
  */
34
- export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, children, }) {
37
+ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, discontinuities, calendar, spacing, children, }) {
35
38
  // The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
36
39
  // ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
37
40
  // is the placeholder while auto-fitting.
@@ -77,6 +80,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
77
80
  // still cursor stays put while a live window slides under it; a controlled
78
81
  // `trackerPosition` resolves to a pixel below.
79
82
  const [hoverX, setHoverX] = useState(null);
83
+ // The region-cursor drag anchor (epoch ms) — set on press, cleared on release.
84
+ const [regionAnchor, setRegionAnchor] = useState(null);
80
85
  // The free-form crosshair also needs the pointer's y + which row (row-specific,
81
86
  // unlike the shared x). One state object so a move updates both atomically.
82
87
  const [hoverPoint, setHoverPoint] = useState(null);
@@ -99,6 +104,29 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
99
104
  return next;
100
105
  });
101
106
  }, []);
107
+ // Selectable-layer registry: an id-bearing Bar/Scatter registers here (keyed
108
+ // by its slot) so the container knows whether *any* series is selectable. Only
109
+ // used to power the dev-warn below — selection resolution itself walks the
110
+ // rows' layers, not this set. Backed by a **ref** (the synchronous source of
111
+ // truth) mirrored to state: a child layer's register effect runs before this
112
+ // parent's dev-warn effect in the same commit, so the ref is already settled
113
+ // there (reading state would lag a render). State only triggers the re-check.
114
+ const selectableRef = useRef(new Set());
115
+ const [selectableKeys, setSelectableKeys] = useState(selectableRef.current);
116
+ const registerSelectable = useCallback((key) => {
117
+ if (selectableRef.current.has(key))
118
+ return;
119
+ selectableRef.current = new Set(selectableRef.current).add(key);
120
+ setSelectableKeys(selectableRef.current);
121
+ }, []);
122
+ const unregisterSelectable = useCallback((key) => {
123
+ if (!selectableRef.current.has(key))
124
+ return;
125
+ const next = new Set(selectableRef.current);
126
+ next.delete(key);
127
+ selectableRef.current = next;
128
+ setSelectableKeys(next);
129
+ }, []);
102
130
  // Annotations register here so the container can do what a mark can't in
103
131
  // isolation: draw its guide line across other rows, order regions, serve snap
104
132
  // targets. Keyed by per-instance slot key (same discipline as the sources).
@@ -127,11 +155,31 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
127
155
  else if (kind !== s.xKind) {
128
156
  throw new Error(`ChartContainer: rows mix x-axis kinds ('${kind}' and '${s.xKind}'). ` +
129
157
  `A container has one shared x axis — every row must plot the same ` +
130
- `kind (all time-keyed, or all value-keyed).`);
158
+ `kind (all time-keyed, all value-keyed, or all category).`);
131
159
  }
132
160
  }
133
161
  return kind ?? 'time';
134
162
  }, [sources]);
163
+ // A `'category'` container's ordered category names — the ordinal axis domain.
164
+ // Every category layer must agree on the same list (a mix is an error, like the
165
+ // kind), so the shared band scale has one authoritative slot order. `null` when
166
+ // no category layer has registered (or the kind isn't category).
167
+ const categories = useMemo(() => {
168
+ let cats = null;
169
+ for (const s of sources.values()) {
170
+ const c = s.xCategories?.() ?? null;
171
+ if (c === null)
172
+ continue;
173
+ if (cats === null)
174
+ cats = c;
175
+ else if (cats.length !== c.length || cats.some((v, i) => v !== c[i])) {
176
+ throw new Error(`ChartContainer: category rows disagree on the axis categories. ` +
177
+ `Every category layer in one container must share the same ordered ` +
178
+ `column set (got [${cats.join(', ')}] and [${c.join(', ')}]).`);
179
+ }
180
+ }
181
+ return cats;
182
+ }, [sources]);
135
183
  // Auto-fit extent — the union of the layers' x extents — used as the domain
136
184
  // when no explicit `range` is given. (Same source registry as the kind; the
137
185
  // two-pass register→resolve applies.)
@@ -174,6 +222,26 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
174
222
  if (!controlledSelectionRef.current)
175
223
  setInternalSelected(hit);
176
224
  }, []);
225
+ // Dev-warn: selection is wired (`selected` and/or `onSelect`) but no layer
226
+ // carries an `id`, so nothing is selectable — `id` gates interactivity, so a
227
+ // consumer who forgot it gets a silent no-op click without this nudge. Fires
228
+ // once per wired-but-empty transition (guarded by a ref); child layers
229
+ // register before this parent effect runs, so the set is settled here.
230
+ const selectionWired = controlledSelection || onSelect !== undefined;
231
+ const warnedNoSelectableRef = useRef(false);
232
+ useEffect(() => {
233
+ if (selectionWired && selectableRef.current.size === 0) {
234
+ if (!warnedNoSelectableRef.current) {
235
+ warnedNoSelectableRef.current = true;
236
+ console.warn('[pond-charts] `selected`/`onSelect` is set but no layer has an `id` — ' +
237
+ 'nothing is selectable. Give a <BarChart>/<ScatterChart> an `id` to ' +
238
+ 'make it interactive (an `id` gates selection + hover).');
239
+ }
240
+ }
241
+ else {
242
+ warnedNoSelectableRef.current = false;
243
+ }
244
+ }, [selectionWired, selectableKeys]);
177
245
  // Hover-highlight: the transient mark under the pointer (distinct from the
178
246
  // committed selection). Controlled (`hovered` prop) or uncontrolled (internal),
179
247
  // mirroring selection; `onHover` notifies in both modes. Deduped by key+label
@@ -197,8 +265,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
197
265
  const same = prev === hit ||
198
266
  (prev !== null &&
199
267
  hit !== null &&
200
- prev.key === hit.key &&
201
- prev.label === hit.label);
268
+ prev.id === hit.id &&
269
+ prev.key === hit.key);
202
270
  if (same)
203
271
  return;
204
272
  lastHoverRef.current = hit;
@@ -239,7 +307,34 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
239
307
  // is the one formatter <TimeAxis> + the cursor readout share, so a tick and
240
308
  // the cursor read identically. (The `formatTime` name predates the value axis
241
309
  // — on a value axis it formats the value, not a time.)
310
+ // The trading-time provider only applies to a **time** axis — a value axis is
311
+ // always a plain `scaleLinear`. Gate it once here so the scale branch AND the
312
+ // frame (which pan/zoom read) agree: on a value axis the provider is dropped,
313
+ // so interactions use continuous value math, not trading-time math.
314
+ // Resolve the trading-time provider: the low-level `discontinuities` prop wins;
315
+ // otherwise derive it from the high-level `calendar` sugar at the chosen
316
+ // `spacing`. Memoized on `(calendar, spacing)` so a stable calendar yields a
317
+ // stable provider (the scale + frame only rebuild when it actually changes) —
318
+ // pan/zoom read the same provider identity as the low-level path would. Gated
319
+ // on a time axis so a value-axis chart never calls `calendar.discontinuities`.
320
+ const calendarProvider = useMemo(() => resolvedKind === 'time' &&
321
+ discontinuities === undefined &&
322
+ calendar !== undefined
323
+ ? calendar.discontinuities(spacing ? { spacing } : undefined)
324
+ : undefined, [resolvedKind, discontinuities, calendar, spacing]);
325
+ const xDiscontinuities = resolvedKind === 'time' ? (discontinuities ?? calendarProvider) : undefined;
242
326
  const { xScale, formatTime } = useMemo(() => {
327
+ if (resolvedKind === 'category') {
328
+ // Ordinal column-domain axis: a band scale over the category slots. The
329
+ // domain is **always** `[0, n]` (one unit slot per category) — NOT the
330
+ // resolved `[d0, d1]`: a category axis ignores an explicit `range` (its
331
+ // slots are absolute `0..n`, matching `categoryStack`), so an out-of-`[0,n]`
332
+ // range can't silently offset the labels from the bars. The pixel mapping
333
+ // stays linear; the formatter is the category-name lookup.
334
+ const cats = categories ?? [];
335
+ const s = scaleBand(cats).domain([0, cats.length]).range([0, plotWidth]);
336
+ return { xScale: s, formatTime: (v) => s.label(v) };
337
+ }
243
338
  if (resolvedKind === 'value') {
244
339
  const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
245
340
  return {
@@ -247,17 +342,58 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
247
342
  formatTime: resolveAxisFormat(s, TIME_TICK_COUNT, timeFormat),
248
343
  };
249
344
  }
345
+ if (xDiscontinuities !== undefined) {
346
+ // Trading-time axis: closed-market gaps collapse, time proportional within
347
+ // sessions. Same tickFormat surface as scaleTime, so the readout is shared.
348
+ const s = scaleTradingTime(xDiscontinuities)
349
+ .domain([d0, d1])
350
+ .range([0, plotWidth]);
351
+ return {
352
+ xScale: s,
353
+ formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
354
+ };
355
+ }
250
356
  const s = scaleTime().domain([d0, d1]).range([0, plotWidth]);
251
357
  return {
252
358
  xScale: s,
253
359
  formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
254
360
  };
255
- }, [resolvedKind, d0, d1, plotWidth, timeFormat]);
361
+ }, [
362
+ resolvedKind,
363
+ categories,
364
+ d0,
365
+ d1,
366
+ plotWidth,
367
+ timeFormat,
368
+ xDiscontinuities,
369
+ ]);
256
370
  // The crosshair pixel (see resolveCursorX). A stored hoverX is a *plot* pixel;
257
371
  // if plotWidth changes mid-hover (a gutter reserving, or a width change) it's
258
372
  // briefly stale until the next pointer move — rare, and the bounds check below
259
373
  // hides an out-of-plot crosshair meanwhile.
260
374
  const cursorX = resolveCursorX(trackerPosition, hoverX, xScale);
375
+ // `cursor="region"` buckets: realize the `cursorSequence` over the current view
376
+ // (a `Sequence` → `.bounded`; a `BoundedSequence` used as-is), so the band can
377
+ // find the interval under the pointer. Memoized on the sequence + view range;
378
+ // a coarse sequence (days / sessions) is a handful of intervals.
379
+ // A `Sequence` bucket is a **time** interval, so gate it to a time axis — on a
380
+ // value axis (a horizontal histogram, a value-keyed chart) the value domain is
381
+ // not epoch-ms, so realizing time buckets over it is meaningless (it would
382
+ // shade the whole plot). Region cursor is time-axis only.
383
+ const cursorBuckets = useMemo(() => {
384
+ if (cursorSequence === undefined || resolvedKind !== 'time')
385
+ return undefined;
386
+ if (!(cursorSequence instanceof Sequence))
387
+ return cursorSequence.intervals();
388
+ // `bounded` (sample 'begin') drops a partial *leading* bucket — the one that
389
+ // contains the view start begins before it. Widen the realized range back by
390
+ // one bucket width so that covering bucket is included (a coarse calendar
391
+ // unit is bounded at ~a year; a fixed step uses its own width).
392
+ const back = cursorSequence.kind() === 'fixed'
393
+ ? cursorSequence.stepMs()
394
+ : 366 * 86_400_000;
395
+ return cursorSequence.bounded({ start: d0 - back, end: d1 }).intervals();
396
+ }, [cursorSequence, d0, d1, resolvedKind]);
261
397
  // Emit { time, values } for an outside readout — recomputed as the cursor moves
262
398
  // *or* the window slides under it (xScale change → new time at the same pixel).
263
399
  // Out of the plot (null, or a controlled trackerPosition d3 extrapolated past
@@ -298,6 +434,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
298
434
  cursorRowKey: hoverPoint?.rowKey ?? null,
299
435
  setHoverY,
300
436
  crosshairSnap,
437
+ cursorBuckets,
438
+ regionAnchor,
439
+ setRegionAnchor,
440
+ onRegionSelect,
441
+ regionSelectModifier,
301
442
  draggingKey,
302
443
  setDragging,
303
444
  selected: selectedValue,
@@ -316,12 +457,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
316
457
  formatTime,
317
458
  registerTrackerSource,
318
459
  unregisterTrackerSource,
460
+ registerSelectable,
461
+ unregisterSelectable,
319
462
  registerAnnotation,
320
463
  unregisterAnnotation,
321
464
  annotations,
322
465
  labelLanes,
323
466
  xScale,
324
467
  xKind: resolvedKind,
468
+ discontinuities: xDiscontinuities,
325
469
  panZoom,
326
470
  minDuration,
327
471
  applyRange,
@@ -343,6 +487,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
343
487
  hoverPoint,
344
488
  setHoverY,
345
489
  crosshairSnap,
490
+ cursorBuckets,
491
+ regionAnchor,
492
+ setRegionAnchor,
493
+ onRegionSelect,
494
+ regionSelectModifier,
346
495
  draggingKey,
347
496
  setDragging,
348
497
  selectedValue,
@@ -361,12 +510,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
361
510
  formatTime,
362
511
  registerTrackerSource,
363
512
  unregisterTrackerSource,
513
+ registerSelectable,
514
+ unregisterSelectable,
364
515
  registerAnnotation,
365
516
  unregisterAnnotation,
366
517
  annotations,
367
518
  labelLanes,
368
519
  xScale,
369
520
  resolvedKind,
521
+ xDiscontinuities,
370
522
  panZoom,
371
523
  minDuration,
372
524
  applyRange,
package/dist/Layers.js CHANGED
@@ -1,14 +1,21 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { Canvas } from './Canvas.js';
4
- import { drawGrid } from './grid.js';
5
- import { cursorParts } from './tracker.js';
4
+ import { drawGrid, drawDividers, thinPixels } from './grid.js';
5
+ import { TimeRange } from 'pond-ts';
6
+ import { cursorParts, bandRect, regionSpan } from './tracker.js';
6
7
  import { resolveSelection } from './select.js';
7
- import { panRange, zoomRange } from './viewport.js';
8
+ import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
8
9
  import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
9
10
  import { ContainerContext, LayersContext, RowContext, } from './context.js';
10
- /** Gridline tick count matches the axes (`YAxis`/`TimeAxis`) so they align. */
11
+ /** Gridline tick count. **Must match the axis label counts** (`XAxis`
12
+ * `TICK_COUNT`, `ChartContainer` `TIME_TICK_COUNT`, `YAxis`) — the grid, the
13
+ * session dividers, and the axis labels are all derived from `ticks(count)`, so
14
+ * they only line up while the counts agree. Kept at 5 across all four. */
11
15
  const GRID_TICKS = 5;
16
+ /** Minimum px between session dividers — thins dense collapse points (e.g. a
17
+ * daily chart where every candle is a new session) so the axis never crowds. */
18
+ const MIN_DIVIDER_PX = 40;
12
19
  /** Wheel-zoom sensitivity: `factor = exp(deltaY * k)` (one ~100px notch ≈ ±15%). */
13
20
  const ZOOM_SENSITIVITY = 0.0015;
14
21
  /** Pointer slop (px): a drag must exceed this before it pans, and a click within
@@ -63,11 +70,31 @@ export function Layers({ children }) {
63
70
  // Explicit `<YAxis ticks>` drive the gridlines too, so they align with the
64
71
  // axis labels; otherwise d3 auto-picks (the default).
65
72
  const explicitY = tickValues.get(defaultAxisId);
66
- const xTicks = xScale.ticks(GRID_TICKS).map((d) => xScale(d));
73
+ // A category axis draws no vertical gridlines — a line through each bar
74
+ // centre reads as noise; the bars are the structure.
75
+ const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(GRID_TICKS);
76
+ const xTicks = xTickVals.map((d) => xScale(+d));
67
77
  const yTicks = gridY
68
78
  ? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
69
79
  : [];
70
80
  drawGrid(ctx, xTicks, yTicks, w, h, gridColor, gridDash);
81
+ // Session dividers: solid verticals at the trading calendar's collapse
82
+ // points (session/day opens), where closed time was removed from the axis.
83
+ // Draw them at the axis ticks that are collapse points — the same
84
+ // calendar-coarsened instants the axis labels — so a divider sits under
85
+ // each date/month/year label, not at every session (which crowds).
86
+ const disc = container.discontinuities;
87
+ if (disc?.boundaries) {
88
+ const [d0, d1] = container.timeRange;
89
+ // Call as a method (not a detached reference) so a class-based provider
90
+ // whose `boundaries` reads `this` keeps its receiver.
91
+ const collapse = new Set(disc.boundaries(d0, d1));
92
+ const bx = xTickVals
93
+ .filter((t) => collapse.has(+t))
94
+ .map((t) => xScale(+t));
95
+ const dividerColor = container.theme.axis.sessionDivider ?? gridColor;
96
+ drawDividers(ctx, thinPixels(bx, MIN_DIVIDER_PX), h, dividerColor);
97
+ }
71
98
  for (const entry of layers) {
72
99
  const yScale = yScales.get(entry.axisId ?? defaultAxisId);
73
100
  if (yScale === undefined)
@@ -83,6 +110,8 @@ export function Layers({ children }) {
83
110
  background,
84
111
  gridColor,
85
112
  gridDash,
113
+ container.discontinuities,
114
+ container.timeRange,
86
115
  ]);
87
116
  // Interaction overlay: the cursor marks live on a DOM/SVG overlay above the
88
117
  // data, so hovering never repaints the data canvas (whose `draw` doesn't depend
@@ -231,7 +260,29 @@ export function Layers({ children }) {
231
260
  }
232
261
  return;
233
262
  }
234
- if (!c.panZoom)
263
+ // Region-cursor drag-select (opt-in via `onRegionSelect`, time axis only):
264
+ // anchor the selection at the press; the band then extends as the pointer
265
+ // moves (bucket by bucket with a sequence, freeform without), and release
266
+ // commits the range. A `regionSelectModifier` (only while `panZoom` is on)
267
+ // gates it behind the key so plain drag can still pan; otherwise it preempts
268
+ // pan (returns before the pan is armed below).
269
+ if (c.cursor === 'region' && c.onRegionSelect && c.xKind === 'time') {
270
+ const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
271
+ if (!needsShift || e.shiftKey) {
272
+ const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
273
+ c.setRegionAnchor(+c.xScale.invert(px));
274
+ c.setHoverX(px);
275
+ try {
276
+ e.currentTarget.setPointerCapture(e.pointerId);
277
+ }
278
+ catch {
279
+ /* ignore */
280
+ }
281
+ return;
282
+ }
283
+ // Modifier required but not held → fall through to pan.
284
+ }
285
+ if (!c.panZoom || c.xKind === 'category')
235
286
  return;
236
287
  const r = c.timeRange;
237
288
  // Arm a potential pan: record the anchor, but DON'T capture the pointer or
@@ -261,6 +312,13 @@ export function Layers({ children }) {
261
312
  c.setHoverX(px); // share the preview x so other rows draw a guide there
262
313
  return;
263
314
  }
315
+ // Region drag in progress: just track the pointer x (the band spans from the
316
+ // anchor bucket to here); no pan, no hover hit-test.
317
+ if (c.regionAnchor !== null) {
318
+ const rect = e.currentTarget.getBoundingClientRect();
319
+ c.setHoverX(Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left)));
320
+ return;
321
+ }
264
322
  // A pan is only live while a button is held. A move with no buttons means
265
323
  // the press already ended without us seeing the pointerup — which the
266
324
  // deferred-capture path allows: an uncommitted (sub-slop) potential-pan
@@ -296,9 +354,17 @@ export function Layers({ children }) {
296
354
  /* ignore (synthetic / already-released pointer) */
297
355
  }
298
356
  }
299
- const span = drag.startRange[1] - drag.startRange[0];
300
- const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
301
- c.applyRange(panRange(drag.startRange, dt));
357
+ if (c.discontinuities) {
358
+ // Trading-time axis: pan by an equal amount of *trading* time so the
359
+ // drag feels uniform across collapsed gaps (a raw-ms shift jumps).
360
+ const fraction = c.plotWidth > 0 ? -dx / c.plotWidth : 0;
361
+ c.applyRange(panRangeTrading(drag.startRange, fraction, c.discontinuities));
362
+ }
363
+ else {
364
+ const span = drag.startRange[1] - drag.startRange[0];
365
+ const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
366
+ c.applyRange(panRange(drag.startRange, dt));
367
+ }
302
368
  return; // tracker suppressed during a pan
303
369
  }
304
370
  const rect = e.currentTarget.getBoundingClientRect();
@@ -338,6 +404,22 @@ export function Layers({ children }) {
338
404
  }, []);
339
405
  const handlePointerUp = useCallback((e) => {
340
406
  const c = containerRef.current;
407
+ // End a region drag: commit the anchor→pointer span as a one-shot range,
408
+ // then clear the anchor (the cursor reverts to the single-bucket highlight).
409
+ if (c.regionAnchor !== null) {
410
+ const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
411
+ const span = regionSpan(c.cursorBuckets ?? [], c.regionAnchor, +c.xScale.invert(px));
412
+ c.setRegionAnchor(null);
413
+ try {
414
+ e.currentTarget.releasePointerCapture(e.pointerId);
415
+ }
416
+ catch {
417
+ /* ignore */
418
+ }
419
+ if (span)
420
+ c.onRegionSelect?.(new TimeRange({ start: span.start, end: span.end }));
421
+ return;
422
+ }
341
423
  if (c.creating !== null) {
342
424
  const rect = e.currentTarget.getBoundingClientRect();
343
425
  const px = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
@@ -405,6 +487,10 @@ export function Layers({ children }) {
405
487
  c.setHoverY(null, null);
406
488
  return;
407
489
  }
490
+ // Cancel a region-drag on leave (no commit) — a safety net for the rare case
491
+ // where the pointer capture didn't take, so the anchor can't get stuck.
492
+ if (c.regionAnchor !== null)
493
+ c.setRegionAnchor(null);
408
494
  c.setHoverX(null);
409
495
  c.setHoverY(null, null);
410
496
  c.setHovered(null);
@@ -444,14 +530,19 @@ export function Layers({ children }) {
444
530
  return;
445
531
  const onWheel = (e) => {
446
532
  const c = containerRef.current;
447
- if (!c.panZoom)
533
+ if (!c.panZoom || c.xKind === 'category')
448
534
  return;
449
535
  e.preventDefault();
450
536
  const rect = el.getBoundingClientRect();
451
537
  const localX = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
452
538
  const pivot = +c.xScale.invert(localX);
453
539
  const factor = Math.exp(e.deltaY * ZOOM_SENSITIVITY);
454
- c.applyRange(zoomRange(c.timeRange, pivot, factor, c.minDuration));
540
+ c.applyRange(c.discontinuities
541
+ ? // minDuration is the zoom-in floor; on a trading-time axis it caps
542
+ // the minimum visible *trading* time (ms of open-market time) rather
543
+ // than wall-clock ms — the sensible meaning for this axis.
544
+ zoomRangeTrading(c.timeRange, pivot, factor, c.discontinuities, c.minDuration)
545
+ : zoomRange(c.timeRange, pivot, factor, c.minDuration));
455
546
  };
456
547
  el.addEventListener('wheel', onWheel, { passive: false });
457
548
  return () => el.removeEventListener('wheel', onWheel);
@@ -532,6 +623,22 @@ export function Layers({ children }) {
532
623
  side: axisSides.get(defaultAxisId) ?? 'left',
533
624
  };
534
625
  })();
626
+ // `region` cursor (time axis only): shade the span under the pointer. With a
627
+ // `cursorSequence` the band snaps to the bucket (and extends bucket by bucket
628
+ // under a drag); with none it's the **freeform** case — a bare hover draws a
629
+ // plain line (`regionLine`), a drag shades the raw `[anchor, pointer]`. Edges
630
+ // map through `xScale`, so on a trading-time axis the band crops to live time.
631
+ const regionActive = parts.band && container.xKind === 'time';
632
+ const band = regionActive && cursorTime !== null
633
+ ? bandRect(container.cursorBuckets ?? [], cursorTime, (v) => xScale(v), plotWidth, container.regionAnchor ?? undefined)
634
+ : null;
635
+ // Degenerate region cursor (no sequence, not mid-drag): a plain vertical line.
636
+ const regionLine = regionActive &&
637
+ container.cursorBuckets === undefined &&
638
+ container.regionAnchor === null &&
639
+ cursorX !== null &&
640
+ cursorX >= 0 &&
641
+ cursorX <= plotWidth;
535
642
  // Cross-row guide lines: the x-positions of annotations on the OTHER rows
536
643
  // (markers + region edges), so a mark on one row reads against this row's data +
537
644
  // the shared x axis. A mark's own row skips itself; baselines cast no vertical
@@ -599,7 +706,7 @@ export function Layers({ children }) {
599
706
  top: 0,
600
707
  left: 0,
601
708
  pointerEvents: 'none',
602
- }, children: [parts.line &&
709
+ }, children: [band !== null && (_jsx("rect", { x: band.x0, y: 0, width: band.x1 - band.x0, height: row.height, fill: cursorColor, opacity: 0.12 })), regionLine && cursorX !== null && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.line &&
603
710
  cursorX !== null &&
604
711
  cursorX >= 0 &&
605
712
  cursorX <= plotWidth && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.chip === 'flag' &&
@@ -15,6 +15,17 @@ export interface ScatterChartProps<S extends SeriesSchema> {
15
15
  * exception), not a per-component style override.
16
16
  */
17
17
  as?: string;
18
+ /**
19
+ * The **stable series identity** for selection + hover. **Optional, and it
20
+ * gates interactivity:** the scatter is selectable/hoverable only when given an
21
+ * `id` — omit it and the points render + read out but can't be clicked (a click
22
+ * on them reads as empty space ⇒ deselect). Distinct from `as` (a theme role
23
+ * that can repeat): `id` must be unique among the selectable layers, and it is
24
+ * the key the controlled `selected` echo, dedup, and (later) multi-select all
25
+ * match on — so a selection survives a data update where a sample `key` goes
26
+ * stale.
27
+ */
28
+ id?: string;
18
29
  /**
19
30
  * Which `<YAxis>` (by its `id`) this scatter scales against — picks the
20
31
  * *scale*, where `as` picks the *style*. **Omitted ⇒ the row's default axis.**
@@ -66,8 +77,9 @@ export interface ScatterChartProps<S extends SeriesSchema> {
66
77
  * (`sampleAt`), and that sample flows to the container's `onTrackerChanged` —
67
78
  * the nearest-point readout. Scatter reuses the shared tracker rather than
68
79
  * adding a separate `onNearest` channel, so a scatter reads out exactly like a
69
- * line. Click selection hit-tests each point's disc (`hitTest`); the selected
70
- * point (matching both its key and this series' label) gets a highlight ring.
80
+ * line. Click selection hit-tests each point's disc (`hitTest`) **opt-in via
81
+ * `id`**; the selected point (matching the selection's series `id` and the sample
82
+ * `key`) gets a highlight ring. Without an `id` the scatter is display-only.
71
83
  *
72
84
  * ```tsx
73
85
  * <Layers>
@@ -80,5 +92,5 @@ export interface ScatterChartProps<S extends SeriesSchema> {
80
92
  * </Layers>
81
93
  * ```
82
94
  */
83
- export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
95
+ export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
84
96
  //# sourceMappingURL=ScatterChart.d.ts.map
@@ -16,8 +16,9 @@ import { useSlotKey } from './use-slot-key.js';
16
16
  * (`sampleAt`), and that sample flows to the container's `onTrackerChanged` —
17
17
  * the nearest-point readout. Scatter reuses the shared tracker rather than
18
18
  * adding a separate `onNearest` channel, so a scatter reads out exactly like a
19
- * line. Click selection hit-tests each point's disc (`hitTest`); the selected
20
- * point (matching both its key and this series' label) gets a highlight ring.
19
+ * line. Click selection hit-tests each point's disc (`hitTest`) **opt-in via
20
+ * `id`**; the selected point (matching the selection's series `id` and the sample
21
+ * `key`) gets a highlight ring. Without an `id` the scatter is display-only.
21
22
  *
22
23
  * ```tsx
23
24
  * <Layers>
@@ -30,7 +31,7 @@ import { useSlotKey } from './use-slot-key.js';
30
31
  * </Layers>
31
32
  * ```
32
33
  */
33
- export function ScatterChart({ series, column, as: semantic, axis, radius, color, label, index = 0, }) {
34
+ export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, index = 0, }) {
34
35
  const container = useContext(ContainerContext);
35
36
  if (container === null) {
36
37
  throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
@@ -101,8 +102,15 @@ export function ScatterChart({ series, column, as: semantic, axis, radius, color
101
102
  },
102
103
  ];
103
104
  },
104
- hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, seriesLabel),
105
- draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, seriesLabel),
105
+ // `id` gates interactivity: only an id-bearing layer wires a hitTest, so
106
+ // a no-id scatter is display-only (a click on it resolves to empty space).
107
+ // Omit the key entirely when there's no id (exactOptionalPropertyTypes).
108
+ ...(id === undefined
109
+ ? {}
110
+ : {
111
+ hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel),
112
+ }),
113
+ draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id),
106
114
  },
107
115
  axisId: axis,
108
116
  index,
@@ -112,6 +120,7 @@ export function ScatterChart({ series, column, as: semantic, axis, radius, color
112
120
  column,
113
121
  style,
114
122
  seriesLabel,
123
+ id,
115
124
  encoding,
116
125
  keyAt,
117
126
  labelAt,
@@ -134,6 +143,15 @@ export function ScatterChart({ series, column, as: semantic, axis, radius, color
134
143
  useEffect(() => {
135
144
  registerTrackerSource(slot, entry.layer);
136
145
  }, [registerTrackerSource, slot, entry.layer]);
146
+ // Advertise selectability (only when an `id` was given) so the container can
147
+ // warn if selection is wired but nothing is selectable.
148
+ const { registerSelectable, unregisterSelectable } = container;
149
+ useEffect(() => {
150
+ if (id === undefined)
151
+ return;
152
+ registerSelectable(slot);
153
+ return () => unregisterSelectable(slot);
154
+ }, [registerSelectable, unregisterSelectable, slot, id]);
137
155
  return null;
138
156
  }
139
157
  //# sourceMappingURL=ScatterChart.js.map