@pond-ts/charts 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`, and
8
8
  them all. Pre-1.0: minor bumps may include new features and type-level changes;
9
9
  patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...HEAD
12
+ [0.39.0]: https://github.com/pjm17971/pond-ts/compare/v0.38.0...v0.39.0
13
+ [0.38.0]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...v0.38.0
12
14
  [0.37.0]: https://github.com/pjm17971/pond-ts/compare/v0.36.0...v0.37.0
13
15
  [0.36.0]: https://github.com/pjm17971/pond-ts/compare/v0.35.0...v0.36.0
14
16
  [0.35.0]: https://github.com/pjm17971/pond-ts/compare/v0.34.1...v0.35.0
@@ -33,6 +35,104 @@ patch bumps are strictly additive.
33
35
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
34
36
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
35
37
 
38
+ ## [Unreleased]
39
+
40
+ ### Fixed
41
+
42
+ - **Charts — click-to-select an annotation now works while `panZoom` is on.** A
43
+ _selectable but non-editable_ `<Region>` / `<Marker>` (one with no `onChange`)
44
+ lets its press bubble to the plot so a drag can pan _through_ it. The plot
45
+ captured the pointer on press to start the pan, and the browser then retargeted
46
+ the resulting `click` onto the plot (Pointer Events spec: a captured pointer's
47
+ compatibility mouse events fire on the capture target) — silently dropping the
48
+ mark's `onSelectAnnotation`. The plot now **defers** its pan pointer-capture
49
+ until the pointer actually moves past the drag slop, so a click (no drag) leaves
50
+ the pointer on the mark and its select fires, while a press-drag still pans
51
+ through and the tracker still hides once the pan commits. Resolves the
52
+ browser-dependent finding deferred from #308; adds
53
+ `e2e/annotations-panzoom.spec.ts`, the first real-pointer-event behavior e2e for
54
+ the annotation layer. (#309)
55
+
56
+ ## [0.39.0] — 2026-07-03
57
+
58
+ A `@pond-ts/charts` release: the **crosshair reticle + annotation-layout** wave,
59
+ driven by the Tidal terminal. `pond-ts`, `@pond-ts/react`, and `@pond-ts/fit`
60
+ carry no code changes — republished in lock-step (peer ranges widen to `^0.39.0`).
61
+
62
+ ### Added
63
+
64
+ - `@pond-ts/charts`: **`<ChartContainer crosshairSnap>`** (default `true`) — the
65
+ `cursor="crosshair"` reticle centres on the nearest data point; `false` gives a
66
+ **free** reticle whose horizontal line + value follow the pointer y
67
+ (`yScale.invert`), while the vertical line still snaps its x to the data grid
68
+ for a clean time readout.
69
+ - `@pond-ts/charts`: **coincident marker labels merge** — labelled `<Marker>`s at
70
+ the same x fold into one chip (`"a, b, c"`) instead of stacking; their x-axis
71
+ indicator pills dedup to one.
72
+ - `@pond-ts/charts`: **x-axis indicator pills lane-stack** when they'd overlap
73
+ (each connector lengthens to its lane).
74
+
75
+ ### Changed
76
+
77
+ - `@pond-ts/charts`: **`cursor="crosshair"` is now a single reticle** — a
78
+ full-height dashed vertical + full-width dashed horizontal line + a centre dot +
79
+ one value pill, with the time pill connected to the vertical line. (Was
80
+ per-series dots + on-axis pills; the per-series readout stays on `flag` /
81
+ `inline`.)
82
+ - `@pond-ts/charts`: top-flag **labels pack per row** — a label only contends with
83
+ labels in its own row's top space (a bottom-row label no longer dodges a
84
+ top-row one at the same x). A dragged mark is excluded from the pack, so static
85
+ marks hold their lanes as it crosses them (no phantom lane swaps). A marker's
86
+ staff now hangs from the top of its (stacked) flag.
87
+
88
+ ### Fixed
89
+
90
+ - `@pond-ts/charts`: the crosshair x-axis pill and marker pills read the axis's
91
+ own formatter (a value-axis / off-boundary time no longer shows a raw number).
92
+
93
+ ## [0.38.0] — 2026-07-03
94
+
95
+ A `@pond-ts/charts` release: **axis-edge value indicators + the crosshair
96
+ cursor** — the ChartIQ / Yahoo-Finance price-tag family, driven by the Tidal
97
+ terminal. `pond-ts`, `@pond-ts/react`, and `@pond-ts/fit` carry no code changes —
98
+ republished in lock-step (peer ranges widen to `^0.38.0`).
99
+
100
+ ### Added
101
+
102
+ - `@pond-ts/charts`: **`<YAxisIndicator>` + `createLiveValue`** — a value pill
103
+ pinned to a y-axis edge, decoupled from the series' last point. A `LiveValue`
104
+ `source` updates it at high frequency **without re-rendering the chart** (only
105
+ the subscribed pill repaints). Props: `value` / `source`, `axis`, `side`,
106
+ `color`, `format`, `line` (dashed guide), `pointer` (callout triangle).
107
+ - `@pond-ts/charts`: **`cursor="crosshair"`** `CursorMode` — a synced vertical
108
+ line + per-series dots, each series' value pinned to its y-axis and the hovered
109
+ time pinned to the x-axis.
110
+ - `@pond-ts/charts`: **`indicator`** opt-in on `<Baseline>` (a y-axis value pill)
111
+ and `<Marker>` (an x-axis time pill, with a connector down to the mark).
112
+ - `@pond-ts/charts`: `<Baseline labelSide>` (`left` / `right`) + `labelPosition`
113
+ (`center` on the line / `above` it) for the near-line label chip.
114
+ - `@pond-ts/charts`: `<Region edges>` (default `true`; `false` = shaded fill with
115
+ no side outlines).
116
+ - `@pond-ts/charts`: `axisPillStyle`, `contrastText`, `pointerStyle` chip helpers
117
+ are exported.
118
+
119
+ ### Changed
120
+
121
+ - `@pond-ts/charts`: axis indicator pills are **solid** (colour fill +
122
+ auto-contrast text), aligned to the tick-label row, and **always show the axis
123
+ coordinate** — never a custom label (a label stays the in-plot chip).
124
+ - `@pond-ts/charts`: cursor flag / inline chips now have **square corners**; the
125
+ cursor **time** atop a flag stack renders as plain text (no chip background).
126
+ - `@pond-ts/charts`: Storybook reorganized into a feature-axis reference tree with
127
+ systematic per-prop coverage (dev-only; stories are excluded from the package).
128
+
129
+ ### Fixed
130
+
131
+ - `@pond-ts/charts`: the crosshair x-axis pill used the container's time formatter
132
+ (showing a raw number on a value axis) — it now uses the axis's own resolved
133
+ formatter, matching the ticks. The crosshair also no longer double-renders the
134
+ time (a stray per-row chip alongside the x-axis pill).
135
+
36
136
  ## [0.37.0] — 2026-07-02
37
137
 
38
138
  A `@pond-ts/charts` release: the axis wave — label, tick, and domain controls
@@ -110,7 +210,7 @@ lock-step (their `pond-ts` / `@pond-ts/react` peer ranges widen to `^0.35.0`).
110
210
  (`[6, 4]` dashed, `[2, 3]` ≈ dotted; omit or `[]` = solid) applied to the
111
211
  series stroke. Lets a theme set a **modeled / forecast** line (e.g. GARCH
112
212
  vol) apart from an observed one at a glance. Distinct from a `GapMode`'s
113
- inferred gap-bridge dashing (which marks *missing data*, not the whole
213
+ inferred gap-bridge dashing (which marks _missing data_, not the whole
114
214
  line). Additive: existing themes are unaffected; a solid line never touches
115
215
  `setLineDash`. New `Charts/LineChart → LineStyles` story. (#313)
116
216
 
@@ -149,7 +249,7 @@ Tidal wire-format spike. `@pond-ts/react`, `@pond-ts/charts`, and
149
249
  - **`TimeSeries.fromColumns`** — the columnar (struct-of-arrays) ingress,
150
250
  the counterpart to `fromJSON`'s row-tuple shape. Accepts either a plain
151
251
  `number[]` or a `Float64Array` per column — one polymorphic door, so a
152
- wire format only changes the *decoder*, not the ingest. `Float64Array`
252
+ wire format only changes the _decoder_, not the ingest. `Float64Array`
153
253
  columns are adopted directly (zero-copy); `number[]` columns are copied.
154
254
  A `null`/`undefined` cell or a non-finite value (`NaN`/`Infinity`) is a
155
255
  gap, identically across both input shapes. Enforces the same
@@ -82,6 +82,16 @@ export interface ChartContainerProps {
82
82
  * axis.
83
83
  */
84
84
  cursorTime?: boolean;
85
+ /**
86
+ * `cursor="crosshair"` reticle **y** snapping. **Default `true`** — the
87
+ * crosshair centres on the nearest **data point** (the horizontal line snaps to
88
+ * that sample's value). `false` — the horizontal line + centre follow the
89
+ * pointer **y** freely, the value read as `yScale.invert(pointerY)`. Either way
90
+ * the vertical line snaps its **x** to the data grid (so the time readout is
91
+ * clean), and both draw a full-height dashed vertical + full-width dashed
92
+ * horizontal line.
93
+ */
94
+ crosshairSnap?: boolean;
85
95
  /**
86
96
  * Enter **annotation-edit mode**: suppresses the data cursor and makes editable
87
97
  * annotations (those given an `onChange`) interactive — hovering one reveals its
@@ -145,5 +155,5 @@ export interface ChartContainerProps {
145
155
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
146
156
  * (`<YAxis>`).
147
157
  */
148
- export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
158
+ export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
149
159
  //# sourceMappingURL=ChartContainer.d.ts.map
@@ -31,7 +31,7 @@ function normalizeRange(range) {
31
31
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
32
32
  * (`<YAxis>`).
33
33
  */
34
- export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorTime = false, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, children, }) {
34
+ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, 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, }) {
35
35
  // The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
36
36
  // ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
37
37
  // is the placeholder while auto-fitting.
@@ -77,6 +77,13 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
77
77
  // still cursor stays put while a live window slides under it; a controlled
78
78
  // `trackerPosition` resolves to a pixel below.
79
79
  const [hoverX, setHoverX] = useState(null);
80
+ // The free-form crosshair also needs the pointer's y + which row (row-specific,
81
+ // unlike the shared x). One state object so a move updates both atomically.
82
+ const [hoverPoint, setHoverPoint] = useState(null);
83
+ const setHoverY = useCallback((y, rowKey) => setHoverPoint(y === null || rowKey === null ? null : { y, rowKey }), []);
84
+ // The actively-dragged annotation — excluded from the lane packers so static
85
+ // marks hold their lanes while it crosses them (see ContainerFrame.draggingKey).
86
+ const [draggingKey, setDragging] = useState(null);
80
87
  // Draw layers register as tracker sources; on hover we fan in their values at
81
88
  // the cursor and hand them out via onTrackerChanged (held in a ref so an
82
89
  // inline callback doesn't churn the frame). This powers a readout rendered
@@ -256,7 +263,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
256
263
  }, [cursorX, xScale, sources, plotWidth]);
257
264
  // Pack overlapping top-flag labels (markers + regions) into stacked lanes so
258
265
  // close-in-x labels don't collide; chips read their lane back off the frame.
259
- const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v)), [annotations, xScale]);
266
+ const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey), [annotations, xScale, draggingKey]);
260
267
  const frame = useMemo(() => ({
261
268
  timeRange: [d0, d1],
262
269
  width,
@@ -269,6 +276,12 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
269
276
  rowGap,
270
277
  cursorX,
271
278
  setHoverX,
279
+ cursorY: hoverPoint?.y ?? null,
280
+ cursorRowKey: hoverPoint?.rowKey ?? null,
281
+ setHoverY,
282
+ crosshairSnap,
283
+ draggingKey,
284
+ setDragging,
272
285
  selected: selectedValue,
273
286
  select,
274
287
  hovered,
@@ -309,6 +322,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
309
322
  rightGutter,
310
323
  rowGap,
311
324
  cursorX,
325
+ hoverPoint,
326
+ setHoverY,
327
+ crosshairSnap,
328
+ draggingKey,
329
+ setDragging,
312
330
  selectedValue,
313
331
  select,
314
332
  hovered,
package/dist/ChartRow.js CHANGED
@@ -179,6 +179,14 @@ export function ChartRow({ height, cursor, children }) {
179
179
  }
180
180
  return map;
181
181
  }, [effectiveAxes]);
182
+ // Which side each axis sits on — so an axis-edge overlay (the crosshair's
183
+ // value pills) hugs the correct gutter without re-deriving from the specs.
184
+ const axisSides = useMemo(() => {
185
+ const map = new Map();
186
+ for (const ax of effectiveAxes)
187
+ map.set(ax.id, ax.side);
188
+ return map;
189
+ }, [effectiveAxes]);
182
190
  const frame = useMemo(() => ({
183
191
  height,
184
192
  cursor,
@@ -187,6 +195,7 @@ export function ChartRow({ height, cursor, children }) {
187
195
  yScales,
188
196
  formats,
189
197
  tickValues,
198
+ axisSides,
190
199
  defaultAxisId,
191
200
  axisSlots,
192
201
  registerAxis,
@@ -202,6 +211,7 @@ export function ChartRow({ height, cursor, children }) {
202
211
  yScales,
203
212
  formats,
204
213
  tickValues,
214
+ axisSides,
205
215
  defaultAxisId,
206
216
  axisSlots,
207
217
  registerAxis,
package/dist/Layers.js CHANGED
@@ -5,7 +5,7 @@ import { drawGrid } from './grid.js';
5
5
  import { cursorParts } from './tracker.js';
6
6
  import { resolveSelection } from './select.js';
7
7
  import { panRange, zoomRange } from './viewport.js';
8
- import { flagChipStyle, flagChipX } from './chip.js';
8
+ import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
9
9
  import { ContainerContext, LayersContext, RowContext, } from './context.js';
10
10
  /** Gridline tick count — matches the axes (`YAxis`/`TimeAxis`) so they align. */
11
11
  const GRID_TICKS = 5;
@@ -49,7 +49,7 @@ export function Layers({ children }) {
49
49
  }), [row.registerLayer, row.unregisterLayer]);
50
50
  const background = container.theme.background;
51
51
  const { grid: gridColor, gridDash } = container.theme.axis;
52
- const { layers, yScales, formats, defaultAxisId, tickValues } = row;
52
+ const { layers, yScales, formats, defaultAxisId, tickValues, axisSides } = row;
53
53
  // x geometry is shared and lives on the container (uniform across rows).
54
54
  const { xScale, plotWidth } = container;
55
55
  const draw = useCallback((ctx, w, h) => {
@@ -132,6 +132,8 @@ export function Layers({ children }) {
132
132
  // The chip uses this layer's axis formatter, so a readout value reads
133
133
  // exactly as the axis labels it.
134
134
  const fmt = formats.get(axisId) ?? String;
135
+ // Which gutter the crosshair value pill hugs (the axis's own side).
136
+ const side = axisSides.get(axisId) ?? 'left';
135
137
  for (const s of entry.layer.sampleAt(cursorTime)) {
136
138
  out.push({
137
139
  px: xScale(s.x),
@@ -139,6 +141,7 @@ export function Layers({ children }) {
139
141
  value: s.value,
140
142
  color: s.color,
141
143
  format: fmt,
144
+ side,
142
145
  });
143
146
  }
144
147
  }
@@ -148,6 +151,7 @@ export function Layers({ children }) {
148
151
  layers,
149
152
  yScales,
150
153
  formats,
154
+ axisSides,
151
155
  xScale,
152
156
  defaultAxisId,
153
157
  parts.dots,
@@ -230,17 +234,23 @@ export function Layers({ children }) {
230
234
  if (!c.panZoom)
231
235
  return;
232
236
  const r = c.timeRange;
233
- dragRef.current = { startX: e.clientX, startRange: [r[0], r[1]] };
234
- c.setHoverX(null); // hide the tracker while panning
235
- c.setHovered(null); // and drop any hover-highlight
236
- // Capture so the pan continues outside the plot; an enhancement, not
237
- // critical guard the throw for synthetic / already-released pointers.
238
- try {
239
- e.currentTarget.setPointerCapture(e.pointerId);
240
- }
241
- catch {
242
- /* ignore */
243
- }
237
+ // Arm a potential pan: record the anchor, but DON'T capture the pointer or
238
+ // hide the tracker yet. Capturing on press retargets the eventual `click`
239
+ // to the plot (Pointer Events spec: a captured pointer's compatibility
240
+ // mouse events fire on the capture target) which silently swallows a
241
+ // click-select on a *selectable but non-editable* mark, whose press bubbles
242
+ // up to here (its DragArea deliberately lets a non-edit press through so a
243
+ // pan can read past it). The pan commits — capture + hide tracker — only
244
+ // once the pointer moves past the slop (handlePointerMove); a press that
245
+ // stays put is a click, and leaving the pointer on the mark lets its
246
+ // onClick fire. Pan-through (drag starting on a non-editable mark) is
247
+ // unaffected: the first move past the slop still reaches this handler and
248
+ // captures then.
249
+ dragRef.current = {
250
+ startX: e.clientX,
251
+ startRange: [r[0], r[1]],
252
+ captured: false,
253
+ };
244
254
  }, []);
245
255
  const handlePointerMove = useCallback((e) => {
246
256
  const c = containerRef.current;
@@ -251,6 +261,16 @@ export function Layers({ children }) {
251
261
  c.setHoverX(px); // share the preview x so other rows draw a guide there
252
262
  return;
253
263
  }
264
+ // A pan is only live while a button is held. A move with no buttons means
265
+ // the press already ended without us seeing the pointerup — which the
266
+ // deferred-capture path allows: an uncommitted (sub-slop) potential-pan
267
+ // released *outside* the plot never captured, so its pointerup fires
268
+ // off-plot and never reaches handlePointerUp. Drop the stale dragRef here
269
+ // (and fall through to hover) so it can't fire a phantom pan on re-entry.
270
+ // A genuine pan-in-progress always has buttons !== 0, so this never cuts
271
+ // one short — including the press-leave-then-return-still-holding case.
272
+ if (dragRef.current && e.buttons === 0)
273
+ dragRef.current = null;
254
274
  const drag = dragRef.current;
255
275
  if (drag) {
256
276
  // Pan from the start range by the total drag — right → earlier (−dt).
@@ -259,21 +279,61 @@ export function Layers({ children }) {
259
279
  // the view nor shifts the scale the click then hit-tests against.
260
280
  if (Math.abs(dx) <= DRAG_SLOP)
261
281
  return;
282
+ // First move past the slop ⇒ this is a real pan, not a click. Commit it
283
+ // now (deferred from press, see handlePointerDown): hide the tracker and
284
+ // claim the pointer so the pan keeps tracking outside the plot. Capturing
285
+ // here — after the click/select decision is already moot — is what keeps a
286
+ // tap-select on a non-editable mark working while a drag still pans.
287
+ if (!drag.captured) {
288
+ drag.captured = true;
289
+ c.setHoverX(null); // hide the tracker while panning
290
+ c.setHoverY(null, null);
291
+ c.setHovered(null); // and drop any hover-highlight
292
+ try {
293
+ e.currentTarget.setPointerCapture(e.pointerId);
294
+ }
295
+ catch {
296
+ /* ignore (synthetic / already-released pointer) */
297
+ }
298
+ }
262
299
  const span = drag.startRange[1] - drag.startRange[0];
263
300
  const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
264
301
  c.applyRange(panRange(drag.startRange, dt));
265
302
  return; // tracker suppressed during a pan
266
303
  }
267
304
  const rect = e.currentTarget.getBoundingClientRect();
268
- const px = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
305
+ const r = rowRef.current;
306
+ const rawX = Math.max(0, Math.min(c.plotWidth, e.clientX - rect.left));
307
+ const py = Math.max(0, Math.min(r.height, e.clientY - rect.top));
308
+ // Crosshair with `crosshairSnap` (default): snap the shared vertical line to
309
+ // the nearest sample's x, so the reticle centres on a real data point (and
310
+ // stays aligned across rows on a shared grid). Free mode keeps the raw x.
311
+ // Crosshair always snaps the shared vertical line (and so the x-time pill)
312
+ // to the nearest sample's x — the reticle rides the data grid on x, giving a
313
+ // clean time readout (a raw pointer time formats to unreadable sub-second
314
+ // precision). `crosshairSnap` only governs the *y* (snap to the value vs a
315
+ // free horizontal line at the pointer). This is ChartIQ's model.
316
+ let px = rawX;
317
+ if ((r.cursor ?? c.cursor) === 'crosshair') {
318
+ const t = +c.xScale.invert(rawX);
319
+ for (const entry of r.layers) {
320
+ if (entry.layer.cursorFlag)
321
+ continue;
322
+ const s = entry.layer.sampleAt(t)[0];
323
+ if (s !== undefined) {
324
+ px = c.xScale(s.x);
325
+ break;
326
+ }
327
+ }
328
+ }
269
329
  c.setHoverX(px);
330
+ c.setHoverY(py, r.rowKey);
270
331
  // Hover-highlight: hit-test the row's selectable layers (Bar) under the
271
332
  // pointer and set the hovered mark. Deduped in the container, so the data
272
333
  // canvas repaints only on a mark transition — not every move (the move just
273
334
  // slides the SVG cursor). A row with no selectable layer (line/area/band)
274
- // resolves to null → a no-op.
275
- const r = rowRef.current;
276
- const hit = resolveSelection(r.layers, px, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
335
+ // resolves to null → a no-op. Uses the raw pointer, not the snapped x.
336
+ const hit = resolveSelection(r.layers, rawX, py, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
277
337
  c.setHovered(hit);
278
338
  }, []);
279
339
  const handlePointerUp = useCallback((e) => {
@@ -319,13 +379,18 @@ export function Layers({ children }) {
319
379
  }
320
380
  return;
321
381
  }
322
- if (dragRef.current) {
382
+ const drag = dragRef.current;
383
+ if (drag) {
323
384
  dragRef.current = null;
324
- try {
325
- e.currentTarget.releasePointerCapture(e.pointerId);
326
- }
327
- catch {
328
- /* ignore */
385
+ // Only release if the pan actually committed + captured (a click never
386
+ // captured, so there's nothing to release).
387
+ if (drag.captured) {
388
+ try {
389
+ e.currentTarget.releasePointerCapture(e.pointerId);
390
+ }
391
+ catch {
392
+ /* ignore */
393
+ }
329
394
  }
330
395
  }
331
396
  }, []);
@@ -337,9 +402,11 @@ export function Layers({ children }) {
337
402
  drawFromRef.current = null;
338
403
  setDrawFrom(null);
339
404
  c.setHoverX(null);
405
+ c.setHoverY(null, null);
340
406
  return;
341
407
  }
342
408
  c.setHoverX(null);
409
+ c.setHoverY(null, null);
343
410
  c.setHovered(null);
344
411
  }, []);
345
412
  // Click selection: ignore the click that ends a drag/pan (moved past a few px),
@@ -403,9 +470,13 @@ export function Layers({ children }) {
403
470
  // The time is shared across rows (one cursor, one time), so it shows **once**,
404
471
  // atop the first row — not repeated per row. (Gating it here also drops the
405
472
  // top-of-stack space reservation on the other rows, see `flagBase`.)
473
+ // Crosshair (`chip: 'axis'`) is excluded: it pins the time to the shared x-axis
474
+ // pill (`<XAxis>`), so a per-row chip here would double it (and land wrong on a
475
+ // stacked row).
406
476
  const showTime = showCursorTime &&
407
477
  cursorTime !== null &&
408
478
  (parts.line || parts.dots) &&
479
+ parts.chip !== 'axis' &&
409
480
  row.isFirstRow;
410
481
  // Flag geometry: each value flies as a flag from the top of its own staff — the
411
482
  // chip's top sits at `flagBase` (just below the time chip when shown) and the
@@ -419,6 +490,48 @@ export function Layers({ children }) {
419
490
  const timeX = parts.chip === 'flag' && trackerSamples.length > 0
420
491
  ? trackerSamples[0].px
421
492
  : cursorX;
493
+ // Crosshair reticle (`chip: 'axis'`): a single centre for THIS row — the
494
+ // horizontal line + centre dot + value pill anchor to `center.py` (the vertical
495
+ // line is the shared `cursorX`, drawn in every row). Snap: the sample nearest
496
+ // the pointer y in the hovered row (or the first sample when nothing's hovered,
497
+ // e.g. a pinned demo — so every row shows a reticle then). Free: the raw pointer
498
+ // y in the hovered row, its value via `yScale.invert`. `null` ⇒ vertical only.
499
+ const cursorInBounds = cursorX !== null && cursorX >= 0 && cursorX <= plotWidth;
500
+ const reticle = (() => {
501
+ if (parts.chip !== 'axis' || !cursorInBounds)
502
+ return null;
503
+ const hoveredRow = container.cursorRowKey === row.rowKey;
504
+ const cy = container.cursorY;
505
+ if (container.crosshairSnap) {
506
+ if (trackerSamples.length === 0)
507
+ return null;
508
+ const pick = hoveredRow && cy !== null
509
+ ? trackerSamples.reduce((a, b) => Math.abs(b.py - cy) < Math.abs(a.py - cy) ? b : a)
510
+ : container.cursorRowKey === null
511
+ ? trackerSamples[0]
512
+ : null;
513
+ return pick
514
+ ? {
515
+ py: pick.py,
516
+ value: pick.value,
517
+ format: pick.format,
518
+ side: pick.side,
519
+ }
520
+ : null;
521
+ }
522
+ // Free reticle — the raw pointer y in the hovered row.
523
+ if (!hoveredRow || cy === null)
524
+ return null;
525
+ const ys = yScales.get(defaultAxisId);
526
+ if (ys === undefined)
527
+ return null;
528
+ return {
529
+ py: cy,
530
+ value: ys.invert(cy),
531
+ format: formats.get(defaultAxisId) ?? String,
532
+ side: axisSides.get(defaultAxisId) ?? 'left',
533
+ };
534
+ })();
422
535
  // Cross-row guide lines: the x-positions of annotations on the OTHER rows
423
536
  // (markers + region edges), so a mark on one row reads against this row's data +
424
537
  // the shared x axis. A mark's own row skips itself; baselines cast no vertical
@@ -491,9 +604,14 @@ export function Layers({ children }) {
491
604
  cursorX >= 0 &&
492
605
  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' &&
493
606
  trackerSamples.map((s, i) => s.py > flagBase ? (_jsx("line", { x1: s.px, y1: flagBase, x2: s.px, y2: s.py, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `staff-${i}`)) : null), parts.chip === 'flag' &&
494
- trackerFlags.map((f, i) => f.topPy > flagBase ? (_jsx("line", { x1: f.px, y1: flagBase, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null), parts.dots &&
607
+ trackerFlags.map((f, i) => f.topPy > flagBase ? (_jsx("line", { x1: f.px, y1: flagBase, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null), parts.chip === 'axis' &&
608
+ cursorX !== null &&
609
+ cursorX >= 0 &&
610
+ cursorX <= plotWidth && (_jsxs(_Fragment, { children: [_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), reticle && (_jsxs(_Fragment, { children: [_jsx("line", { x1: 0, y1: Math.round(reticle.py), x2: plotWidth, y2: Math.round(reticle.py), stroke: cursorColor, strokeWidth: 1, strokeDasharray: "3 3", shapeRendering: "crispEdges" }), _jsx("circle", { cx: cursorX, cy: reticle.py, r: 3, fill: cursorColor, stroke: background, strokeWidth: background ? 1 : 0 })] }))] })), parts.dots &&
495
611
  trackerSamples.map((s, i) => (_jsx("circle", { cx: s.px, cy: s.py, r: 3, fill: s.color, stroke: background, strokeWidth: background ? 1 : 0 }, `dot-${i}`)))] }), showTime && timeX !== null && cursorTime !== null && (_jsx("div", { style: {
496
612
  ...chipStyle,
613
+ background: 'transparent',
614
+ padding: 0,
497
615
  top: `${flagTop}px`,
498
616
  left: timeX > plotWidth * LABEL_FLIP_FRACTION
499
617
  ? undefined
@@ -518,7 +636,12 @@ export function Layers({ children }) {
518
636
  right: flip ? `${plotWidth - s.px + 8}px` : undefined,
519
637
  color: s.color,
520
638
  }, children: s.format(s.value) }, i));
521
- }), parts.chip === 'flag' &&
639
+ }), reticle && (_jsx("div", { style: {
640
+ ...axisPillStyle(container.theme, cursorColor),
641
+ top: `${Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, reticle.py))}px`,
642
+ transform: 'translateY(-50%)',
643
+ ...axisPillX(reticle.side, plotWidth),
644
+ }, children: reticle.format(reticle.value) })), parts.chip === 'flag' &&
522
645
  cursorX !== null &&
523
646
  trackerSamples.map((s, i) => (
524
647
  // The flag flies from the top of its staff — chip top at the staff top