@pond-ts/charts 0.38.0 → 0.40.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.38.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.40.0...HEAD
12
+ [0.40.0]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...v0.40.0
13
+ [0.39.0]: https://github.com/pjm17971/pond-ts/compare/v0.38.0...v0.39.0
12
14
  [0.38.0]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...v0.38.0
13
15
  [0.37.0]: https://github.com/pjm17971/pond-ts/compare/v0.36.0...v0.37.0
14
16
  [0.36.0]: https://github.com/pjm17971/pond-ts/compare/v0.35.0...v0.36.0
@@ -34,6 +36,87 @@ patch bumps are strictly additive.
34
36
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
35
37
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
36
38
 
39
+ ## [Unreleased]
40
+
41
+ ## [0.40.0] — 2026-07-05
42
+
43
+ A **core + charts** release from the estela `DataChart`-port friction wave.
44
+ `@pond-ts/react` and `@pond-ts/fit` carry no code changes — republished in
45
+ lock-step (peer ranges widen to `^0.40.0`).
46
+
47
+ ### Added
48
+
49
+ - `pond-ts`: **`TimeSeries.fromColumns({ sort })`** — an opt-in `sort?: boolean`
50
+ (default `false`) that stable-sorts a columnar payload by key before
51
+ construction, the columnar counterpart of `fromJSON`'s `sort`. The default path
52
+ is unchanged: a decreasing key still throws (a backwards key on the trusted fast
53
+ door is a corruption signal, not silently accepted), and the `Float64Array`
54
+ zero-copy adoption is preserved when `sort` is unset. (#344)
55
+ - `@pond-ts/charts`: **controlled bar hover** — `<ChartContainer hovered
56
+ onHover>`, the transient-hover analog of the existing `selected` / `onSelect`
57
+ pair, keyed by the same `SelectInfo`. Pin a lit `<BarChart>` bar from a legend
58
+ or list row (`hovered`), or mirror a bar-originated hover out-of-band
59
+ (`onHover`); omit both for today's uncontrolled behavior. (#343)
60
+
61
+ ### Fixed
62
+
63
+ - `@pond-ts/charts`: **axis and layer registration are value-equality-guarded** —
64
+ a fresh-but-value-equal `ticks` / `format` / `byValue()`-projected `series`
65
+ reference no longer re-registers the axis/layer, fixing a "Maximum update depth
66
+ exceeded" loop on frequently re-rendering (scrub-driven) charts. The layer and
67
+ axis docs gain a memoize note for `format` / `series` (an inline `format`
68
+ closure still must be hoisted — a closure can't be value-compared). (#342)
69
+
70
+ ## [0.39.0] — 2026-07-03
71
+
72
+ A `@pond-ts/charts` release: the **crosshair reticle + annotation-layout** wave,
73
+ driven by the Tidal terminal. `pond-ts`, `@pond-ts/react`, and `@pond-ts/fit`
74
+ carry no code changes — republished in lock-step (peer ranges widen to `^0.39.0`).
75
+
76
+ ### Added
77
+
78
+ - `@pond-ts/charts`: **`<ChartContainer crosshairSnap>`** (default `true`) — the
79
+ `cursor="crosshair"` reticle centres on the nearest data point; `false` gives a
80
+ **free** reticle whose horizontal line + value follow the pointer y
81
+ (`yScale.invert`), while the vertical line still snaps its x to the data grid
82
+ for a clean time readout.
83
+ - `@pond-ts/charts`: **coincident marker labels merge** — labelled `<Marker>`s at
84
+ the same x fold into one chip (`"a, b, c"`) instead of stacking; their x-axis
85
+ indicator pills dedup to one.
86
+ - `@pond-ts/charts`: **x-axis indicator pills lane-stack** when they'd overlap
87
+ (each connector lengthens to its lane).
88
+
89
+ ### Changed
90
+
91
+ - `@pond-ts/charts`: **`cursor="crosshair"` is now a single reticle** — a
92
+ full-height dashed vertical + full-width dashed horizontal line + a centre dot +
93
+ one value pill, with the time pill connected to the vertical line. (Was
94
+ per-series dots + on-axis pills; the per-series readout stays on `flag` /
95
+ `inline`.)
96
+ - `@pond-ts/charts`: top-flag **labels pack per row** — a label only contends with
97
+ labels in its own row's top space (a bottom-row label no longer dodges a
98
+ top-row one at the same x). A dragged mark is excluded from the pack, so static
99
+ marks hold their lanes as it crosses them (no phantom lane swaps). A marker's
100
+ staff now hangs from the top of its (stacked) flag.
101
+
102
+ ### Fixed
103
+
104
+ - `@pond-ts/charts`: the crosshair x-axis pill and marker pills read the axis's
105
+ own formatter (a value-axis / off-boundary time no longer shows a raw number).
106
+ - **Charts — click-to-select an annotation now works while `panZoom` is on.** A
107
+ _selectable but non-editable_ `<Region>` / `<Marker>` (one with no `onChange`)
108
+ lets its press bubble to the plot so a drag can pan _through_ it. The plot
109
+ captured the pointer on press to start the pan, and the browser then retargeted
110
+ the resulting `click` onto the plot (Pointer Events spec: a captured pointer's
111
+ compatibility mouse events fire on the capture target) — silently dropping the
112
+ mark's `onSelectAnnotation`. The plot now **defers** its pan pointer-capture
113
+ until the pointer actually moves past the drag slop, so a click (no drag) leaves
114
+ the pointer on the mark and its select fires, while a press-drag still pans
115
+ through and the tracker still hides once the pan commits. Resolves the
116
+ browser-dependent finding deferred from #308; adds
117
+ `e2e/annotations-panzoom.spec.ts`, the first real-pointer-event behavior e2e for
118
+ the annotation layer. (#309)
119
+
37
120
  ## [0.38.0] — 2026-07-03
38
121
 
39
122
  A `@pond-ts/charts` release: **axis-edge value indicators + the crosshair
@@ -8,6 +8,10 @@ export interface AreaChartProps<S extends SeriesSchema = SeriesSchema, VS extend
8
8
  * `ValueSeries` (`series.byValue('dist')`) against its value axis — the
9
9
  * container infers which from the data, no axis-type prop (mirrors
10
10
  * `<LineChart>`). Either way `column` names the numeric value to fill from.
11
+ *
12
+ * **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
13
+ * an inline `series={s.byValue('dist')}` re-registers this layer every render —
14
+ * on a frequently re-rendering chart, memoize the projection (`useMemo`).
11
15
  */
12
16
  series: TimeSeries<S> | ValueSeries<VS>;
13
17
  /** Name of the numeric value column to fill from. */
@@ -8,6 +8,10 @@ export interface BandChartProps<S extends SeriesSchema = SeriesSchema, VS extend
8
8
  * container infers which from the data, no axis-type prop (mirrors
9
9
  * `<LineChart>` / `<AreaChart>`). Either way `lower`/`upper` name the numeric
10
10
  * edge columns.
11
+ *
12
+ * **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
13
+ * an inline `series={s.byValue('dist')}` re-registers this layer every render —
14
+ * on a frequently re-rendering chart, memoize the projection (`useMemo`).
11
15
  */
12
16
  series: TimeSeries<S> | ValueSeries<VS>;
13
17
  /** Name of the numeric column for the band's lower edge (e.g. `p25`). */
@@ -10,6 +10,10 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
10
10
  * the same neighbour-spacing span applies (see {@link barsFromValueSeries}); the
11
11
  * container infers the x-kind from the data, no axis-type prop (mirrors the
12
12
  * other layers).
13
+ *
14
+ * **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
15
+ * an inline `series={s.byValue('dist')}` re-registers this layer every render —
16
+ * on a frequently re-rendering chart, memoize the projection (`useMemo`).
13
17
  */
14
18
  series: TimeSeries<S> | ValueSeries<VS>;
15
19
  /** Name of the numeric value column for the bar height. */
@@ -63,6 +67,12 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
63
67
  * readout reads the same bar you click, even across a wide bucket (they differ
64
68
  * only by the `gap` inset, where the pixel rect is narrower than the span).
65
69
  *
70
+ * Both channels are also **controllable from outside** the chart via the
71
+ * container: `selected`/`onSelect` (committed) and `hovered`/`onHover` (transient)
72
+ * — pass either to pin the lit/selected bar from a legend or list row, and read
73
+ * the callback to mirror a bar-originated hover/click out-of-band. Symmetric pair,
74
+ * keyed by the same {@link SelectInfo} identity.
75
+ *
66
76
  * **Value axis** — bars also scale on a value axis when fed a `ValueSeries`
67
77
  * (`series.byValue('dist')`): estela's distance-domain splits/laps, one bar per
68
78
  * segment over a monotonic axis. A `ValueSeries` is point-keyed, so the span is
package/dist/BarChart.js CHANGED
@@ -26,6 +26,12 @@ import { useSlotKey } from './use-slot-key.js';
26
26
  * readout reads the same bar you click, even across a wide bucket (they differ
27
27
  * only by the `gap` inset, where the pixel rect is narrower than the span).
28
28
  *
29
+ * Both channels are also **controllable from outside** the chart via the
30
+ * container: `selected`/`onSelect` (committed) and `hovered`/`onHover` (transient)
31
+ * — pass either to pin the lit/selected bar from a legend or list row, and read
32
+ * the callback to mirror a bar-originated hover/click out-of-band. Symmetric pair,
33
+ * keyed by the same {@link SelectInfo} identity.
34
+ *
29
35
  * **Value axis** — bars also scale on a value axis when fed a `ValueSeries`
30
36
  * (`series.byValue('dist')`): estela's distance-domain splits/laps, one bar per
31
37
  * segment over a monotonic axis. A `ValueSeries` is point-keyed, so the span is
@@ -57,6 +57,31 @@ export interface ChartContainerProps {
57
57
  * works in both controlled and uncontrolled mode.
58
58
  */
59
59
  onSelect?: (hit: SelectInfo | null) => void;
60
+ /**
61
+ * Controlled hover-highlight — the transiently lit mark (echo the `onHover` arg
62
+ * back), or `null`. **Omitted ⇒ uncontrolled** (the pointer over a selectable
63
+ * layer manages it internally). The hover analog of {@link selected}: pass it to
64
+ * **pin** a lit mark from outside the chart (e.g. hovering a legend / list row
65
+ * lights the matching {@link BarChart} bar). Only layers with a hover-highlight
66
+ * (currently `BarChart`) render it; keyed by the same {@link SelectInfo} identity
67
+ * as selection.
68
+ */
69
+ hovered?: SelectInfo | null;
70
+ /**
71
+ * Fires when the pointer enters a selectable layer's mark (the hit mark) or
72
+ * leaves every mark (`null`) — the hover analog of {@link onSelect}. Notification
73
+ * only (works controlled or uncontrolled), and **deduped**: it fires on a mark
74
+ * transition, not on every pointer move. Wire it to mirror hover out-of-band
75
+ * (e.g. a list row ↔ the bar), pairing with {@link hovered} to sync both ways.
76
+ * (The annotation counterpart is {@link onHoverAnnotation}.)
77
+ *
78
+ * **Dedup key:** by the mark's `key` + `label` only (not `value`/`color`). So on
79
+ * a live chart where a bar's value changes while the cursor stays on it, this
80
+ * won't re-fire — read the current value from your series, not the last
81
+ * `onHover` payload. (Matches the internal hover-highlight, which repaints on
82
+ * key transitions.)
83
+ */
84
+ onHover?: (hit: SelectInfo | null) => void;
60
85
  /**
61
86
  * Enable pan/zoom: drag the plot to pan the time range, wheel to zoom around
62
87
  * the cursor. **Default off** — so it doesn't capture drag/scroll unless asked.
@@ -82,6 +107,16 @@ export interface ChartContainerProps {
82
107
  * axis.
83
108
  */
84
109
  cursorTime?: boolean;
110
+ /**
111
+ * `cursor="crosshair"` reticle **y** snapping. **Default `true`** — the
112
+ * crosshair centres on the nearest **data point** (the horizontal line snaps to
113
+ * that sample's value). `false` — the horizontal line + centre follow the
114
+ * pointer **y** freely, the value read as `yScale.invert(pointerY)`. Either way
115
+ * the vertical line snaps its **x** to the data grid (so the time readout is
116
+ * clean), and both draw a full-height dashed vertical + full-width dashed
117
+ * horizontal line.
118
+ */
119
+ crosshairSnap?: boolean;
85
120
  /**
86
121
  * Enter **annotation-edit mode**: suppresses the data cursor and makes editable
87
122
  * annotations (those given an `onChange`) interactive — hovering one reveals its
@@ -145,5 +180,5 @@ export interface ChartContainerProps {
145
180
  * {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
146
181
  * (`<YAxis>`).
147
182
  */
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;
183
+ export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
149
184
  //# 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, 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, }) {
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
@@ -168,18 +175,36 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
168
175
  setInternalSelected(hit);
169
176
  }, []);
170
177
  // Hover-highlight: the transient mark under the pointer (distinct from the
171
- // committed selection). Deduped by key+label so the data canvas repaints only
172
- // when the hovered mark changes not on every pointer move (the move itself
173
- // just slides the SVG cursor, which never touches the data canvas).
174
- const [hovered, setHoveredState] = useState(null);
178
+ // committed selection). Controlled (`hovered` prop) or uncontrolled (internal),
179
+ // mirroring selection; `onHover` notifies in both modes. Deduped by key+label
180
+ // so it fires — and the data canvas repaints only when the hovered mark
181
+ // changes, not on every pointer move (the move itself just slides the SVG
182
+ // cursor, which never touches the data canvas).
183
+ const [internalHovered, setInternalHovered] = useState(null);
184
+ const controlledHover = hovered !== undefined;
185
+ const hoveredValue = controlledHover ? (hovered ?? null) : internalHovered;
186
+ const onHoverRef = useRef(onHover);
187
+ const controlledHoverRef = useRef(controlledHover);
188
+ // The last mark we reported — so the callback dedups across pointer moves even
189
+ // in controlled mode, where there's no internal state to compare against.
190
+ const lastHoverRef = useRef(null);
191
+ useLayoutEffect(() => {
192
+ onHoverRef.current = onHover;
193
+ controlledHoverRef.current = controlledHover;
194
+ });
175
195
  const setHovered = useCallback((hit) => {
176
- setHoveredState((prev) => prev === hit ||
196
+ const prev = lastHoverRef.current;
197
+ const same = prev === hit ||
177
198
  (prev !== null &&
178
199
  hit !== null &&
179
200
  prev.key === hit.key &&
180
- prev.label === hit.label)
181
- ? prev
182
- : hit);
201
+ prev.label === hit.label);
202
+ if (same)
203
+ return;
204
+ lastHoverRef.current = hit;
205
+ onHoverRef.current?.(hit);
206
+ if (!controlledHoverRef.current)
207
+ setInternalHovered(hit);
183
208
  }, []);
184
209
  // Rows report their per-slot gutter widths; we reserve each slot's max.
185
210
  const [gutters, setGutters] = useState([]);
@@ -256,7 +281,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
256
281
  }, [cursorX, xScale, sources, plotWidth]);
257
282
  // Pack overlapping top-flag labels (markers + regions) into stacked lanes so
258
283
  // 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]);
284
+ const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey), [annotations, xScale, draggingKey]);
260
285
  const frame = useMemo(() => ({
261
286
  timeRange: [d0, d1],
262
287
  width,
@@ -269,9 +294,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
269
294
  rowGap,
270
295
  cursorX,
271
296
  setHoverX,
297
+ cursorY: hoverPoint?.y ?? null,
298
+ cursorRowKey: hoverPoint?.rowKey ?? null,
299
+ setHoverY,
300
+ crosshairSnap,
301
+ draggingKey,
302
+ setDragging,
272
303
  selected: selectedValue,
273
304
  select,
274
- hovered,
305
+ hovered: hoveredValue,
275
306
  setHovered,
276
307
  cursor,
277
308
  cursorTime,
@@ -309,9 +340,14 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
309
340
  rightGutter,
310
341
  rowGap,
311
342
  cursorX,
343
+ hoverPoint,
344
+ setHoverY,
345
+ crosshairSnap,
346
+ draggingKey,
347
+ setDragging,
312
348
  selectedValue,
313
349
  select,
314
- hovered,
350
+ hoveredValue,
315
351
  setHovered,
316
352
  cursor,
317
353
  cursorTime,
package/dist/ChartRow.js CHANGED
@@ -9,6 +9,62 @@ import { YAxis } from './YAxis.js';
9
9
  import { ContainerContext, RowContext, } from './context.js';
10
10
  /** Sentinel id for the implicit axis a row gets when no `<YAxis>` is declared. */
11
11
  const IMPLICIT_AXIS_ID = '__default__';
12
+ /** Element-wise compare of two optional number arrays (an axis's tick values) —
13
+ * so a *fresh* `ticks={[…]}` array whose contents are unchanged doesn't count as
14
+ * a new spec (see {@link axisSpecEqual}). */
15
+ function numberArraysEqual(a, b) {
16
+ if (a === b)
17
+ return true;
18
+ if (a === undefined || b === undefined)
19
+ return false;
20
+ if (a.length !== b.length)
21
+ return false;
22
+ for (let i = 0; i < a.length; i += 1)
23
+ if (!Object.is(a[i], b[i]))
24
+ return false;
25
+ return true;
26
+ }
27
+ /**
28
+ * Value-equality for two {@link AxisSpec}s — the registration guard's compare
29
+ * (see `registerAxis`). Every field is a plain value except `format`, which may
30
+ * be a `(value) => string` closure: those are compared by **reference**
31
+ * (`Object.is`), so a stable/hoisted formatter (or a string specifier) is equal
32
+ * across renders but a *fresh inline function* is not — the one case a structural
33
+ * guard provably can't collapse, hence the `<YAxis format>` memoize note. A
34
+ * fresh-but-value-equal `ticks` array (the common live-chart footgun) compares
35
+ * equal element-wise and no-ops.
36
+ */
37
+ function axisSpecEqual(a, b) {
38
+ return (a.id === b.id &&
39
+ a.side === b.side &&
40
+ a.width === b.width &&
41
+ // Object.is (not ===) so a degenerate NaN bound compares equal to itself and
42
+ // doesn't re-register every render.
43
+ Object.is(a.min, b.min) &&
44
+ Object.is(a.max, b.max) &&
45
+ a.pad === b.pad &&
46
+ a.labelPlacement === b.labelPlacement &&
47
+ a.index === b.index &&
48
+ Object.is(a.format, b.format) &&
49
+ numberArraysEqual(a.tickValues, b.tickValues));
50
+ }
51
+ /**
52
+ * Value-equality for two {@link LayerEntry}s — **defensive, not load-bearing.**
53
+ * The axis guard (`axisSpecEqual`) is what breaks the update-depth loop; this is
54
+ * belt-and-suspenders for the layer setter. Note that under the current draw-layer
55
+ * structure it *won't* actually fire: every layer builds `layer` inside the same
56
+ * `useMemo` as `entry`, so the register effect only runs when `entry` is fresh —
57
+ * and a fresh `entry` always carries a fresh `layer`, so `a.layer === b.layer` is
58
+ * never true when the guard runs (it falls through to a normal register). It would
59
+ * only bite if a future layer memoized `layer` separately from `entry` (or the
60
+ * `Layers` registry ref changed under a stable entry). A fresh `series` projection
61
+ * (`byValue()` mints one each call) rebuilds the memo → a new `layer` and
62
+ * legitimately re-registers; that's a consumer-side memoize (see the `series` note
63
+ * on the draw-layer components), not something the setter can value-compare.
64
+ */
65
+ function layerEntryEqual(a, b) {
66
+ return a.layer === b.layer && a.axisId === b.axisId && a.index === b.index;
67
+ }
12
68
  /** Axis tick count for the per-axis formatter — matches `<YAxis>`'s tick count
13
69
  * so the readout formatter is calibrated exactly as the labels are. */
14
70
  const AXIS_TICK_COUNT = 5;
@@ -52,8 +108,19 @@ export function ChartRow({ height, cursor, children }) {
52
108
  // min/max or series change silently rebind axes / reorder the z-stack.)
53
109
  const [axes, setAxes] = useState(() => new Map());
54
110
  const [layers, setLayers] = useState(() => new Map());
111
+ // Registration is idempotent under value-equality: a `<YAxis>` re-fires its
112
+ // register effect whenever its `spec` memo yields a fresh object — which an
113
+ // inline `ticks={[]}` / `format` or a re-rendered parent does every render. If
114
+ // the spec is *value*-equal to the stored one, skip the `setState` entirely so
115
+ // it can't spin `register → setState → re-render → register` into React's
116
+ // "Maximum update depth exceeded" on a scrub-heavy chart (F-charts-axis-reregister).
55
117
  const registerAxis = useCallback((key, spec) => {
56
- setAxes((m) => new Map(m).set(key, spec));
118
+ setAxes((m) => {
119
+ const prev = m.get(key);
120
+ if (prev !== undefined && axisSpecEqual(prev, spec))
121
+ return m;
122
+ return new Map(m).set(key, spec);
123
+ });
57
124
  }, []);
58
125
  const unregisterAxis = useCallback((key) => {
59
126
  setAxes((m) => {
@@ -64,8 +131,17 @@ export function ChartRow({ height, cursor, children }) {
64
131
  return next;
65
132
  });
66
133
  }, []);
134
+ // Same value-equality guard as `registerAxis`: a re-register carrying the same
135
+ // `layer` object (stable while the draw layer's inputs are) + `axisId`/`index`
136
+ // no-ops rather than churning state. (A fresh `series` projection rebuilds the
137
+ // layer and legitimately re-registers — see `layerEntryEqual`.)
67
138
  const registerLayer = useCallback((key, entry) => {
68
- setLayers((m) => new Map(m).set(key, entry));
139
+ setLayers((m) => {
140
+ const prev = m.get(key);
141
+ if (prev !== undefined && layerEntryEqual(prev, entry))
142
+ return m;
143
+ return new Map(m).set(key, entry);
144
+ });
69
145
  }, []);
70
146
  const unregisterLayer = useCallback((key) => {
71
147
  setLayers((m) => {
package/dist/Layers.js CHANGED
@@ -234,17 +234,23 @@ export function Layers({ children }) {
234
234
  if (!c.panZoom)
235
235
  return;
236
236
  const r = c.timeRange;
237
- dragRef.current = { startX: e.clientX, startRange: [r[0], r[1]] };
238
- c.setHoverX(null); // hide the tracker while panning
239
- c.setHovered(null); // and drop any hover-highlight
240
- // Capture so the pan continues outside the plot; an enhancement, not
241
- // critical guard the throw for synthetic / already-released pointers.
242
- try {
243
- e.currentTarget.setPointerCapture(e.pointerId);
244
- }
245
- catch {
246
- /* ignore */
247
- }
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
+ };
248
254
  }, []);
249
255
  const handlePointerMove = useCallback((e) => {
250
256
  const c = containerRef.current;
@@ -255,6 +261,16 @@ export function Layers({ children }) {
255
261
  c.setHoverX(px); // share the preview x so other rows draw a guide there
256
262
  return;
257
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;
258
274
  const drag = dragRef.current;
259
275
  if (drag) {
260
276
  // Pan from the start range by the total drag — right → earlier (−dt).
@@ -263,21 +279,61 @@ export function Layers({ children }) {
263
279
  // the view nor shifts the scale the click then hit-tests against.
264
280
  if (Math.abs(dx) <= DRAG_SLOP)
265
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
+ }
266
299
  const span = drag.startRange[1] - drag.startRange[0];
267
300
  const dt = c.plotWidth > 0 ? -dx * (span / c.plotWidth) : 0;
268
301
  c.applyRange(panRange(drag.startRange, dt));
269
302
  return; // tracker suppressed during a pan
270
303
  }
271
304
  const rect = e.currentTarget.getBoundingClientRect();
272
- 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
+ }
273
329
  c.setHoverX(px);
330
+ c.setHoverY(py, r.rowKey);
274
331
  // Hover-highlight: hit-test the row's selectable layers (Bar) under the
275
332
  // pointer and set the hovered mark. Deduped in the container, so the data
276
333
  // canvas repaints only on a mark transition — not every move (the move just
277
334
  // slides the SVG cursor). A row with no selectable layer (line/area/band)
278
- // resolves to null → a no-op.
279
- const r = rowRef.current;
280
- 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));
281
337
  c.setHovered(hit);
282
338
  }, []);
283
339
  const handlePointerUp = useCallback((e) => {
@@ -323,13 +379,18 @@ export function Layers({ children }) {
323
379
  }
324
380
  return;
325
381
  }
326
- if (dragRef.current) {
382
+ const drag = dragRef.current;
383
+ if (drag) {
327
384
  dragRef.current = null;
328
- try {
329
- e.currentTarget.releasePointerCapture(e.pointerId);
330
- }
331
- catch {
332
- /* 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
+ }
333
394
  }
334
395
  }
335
396
  }, []);
@@ -341,9 +402,11 @@ export function Layers({ children }) {
341
402
  drawFromRef.current = null;
342
403
  setDrawFrom(null);
343
404
  c.setHoverX(null);
405
+ c.setHoverY(null, null);
344
406
  return;
345
407
  }
346
408
  c.setHoverX(null);
409
+ c.setHoverY(null, null);
347
410
  c.setHovered(null);
348
411
  }, []);
349
412
  // Click selection: ignore the click that ends a drag/pan (moved past a few px),
@@ -427,6 +490,48 @@ export function Layers({ children }) {
427
490
  const timeX = parts.chip === 'flag' && trackerSamples.length > 0
428
491
  ? trackerSamples[0].px
429
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
+ })();
430
535
  // Cross-row guide lines: the x-positions of annotations on the OTHER rows
431
536
  // (markers + region edges), so a mark on one row reads against this row's data +
432
537
  // the shared x axis. A mark's own row skips itself; baselines cast no vertical
@@ -500,7 +605,9 @@ export function Layers({ children }) {
500
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' &&
501
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' &&
502
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' &&
503
- trackerSamples.map((s, i) => (_jsx("line", { x1: s.side === 'right' ? Math.round(s.px) : 0, y1: Math.round(s.py), x2: s.side === 'right' ? plotWidth : Math.round(s.px), y2: Math.round(s.py), stroke: s.color, strokeWidth: 1, opacity: 0.4, strokeDasharray: "3 3", shapeRendering: "crispEdges" }, `hconn-${i}`))), parts.dots &&
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 &&
504
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: {
505
612
  ...chipStyle,
506
613
  background: 'transparent',
@@ -529,16 +636,12 @@ export function Layers({ children }) {
529
636
  right: flip ? `${plotWidth - s.px + 8}px` : undefined,
530
637
  color: s.color,
531
638
  }, children: s.format(s.value) }, i));
532
- }), parts.chip === 'axis' &&
533
- trackerSamples.map((s, i) => {
534
- const top = Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, s.py));
535
- return (_jsx("div", { style: {
536
- ...axisPillStyle(container.theme, s.color),
537
- top: `${top}px`,
538
- transform: 'translateY(-50%)',
539
- ...axisPillX(s.side, plotWidth),
540
- }, children: s.format(s.value) }, `ytag-${i}`));
541
- }), 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' &&
542
645
  cursorX !== null &&
543
646
  trackerSamples.map((s, i) => (
544
647
  // The flag flies from the top of its staff — chip top at the staff top
@@ -8,6 +8,11 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
8
8
  * `ValueSeries` (`series.byValue('cumDist')`) against its value axis — the
9
9
  * container infers which from the data, no axis-type prop. Either way the key
10
10
  * / axis column supplies x and `column` supplies y.
11
+ *
12
+ * **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
13
+ * passing `series={s.byValue('dist')}` inline re-registers this layer every
14
+ * render — on a frequently re-rendering (e.g. scrub-driven) chart, memoize the
15
+ * projection (`useMemo`) so the layer isn't rebuilt each frame.
11
16
  */
12
17
  series: TimeSeries<S> | ValueSeries<VS>;
13
18
  /** Name of the numeric value column to plot. */
package/dist/XAxis.js CHANGED
@@ -49,24 +49,61 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
49
49
  // colour, reading like a tick. An indicator always shows the axis coordinate
50
50
  // (the formatted `at`), never the marker's custom label (that stays the in-plot
51
51
  // chip). Skipped when off-plot.
52
+ // Coincident indicator markers (same `at`) show the same time, so one pill
53
+ // stands for the group — dedup by `at` (the first wins), then place.
54
+ const seenAt = new Set();
52
55
  const markerTags = container.annotations
53
56
  .filter((a) => a.indicator && a.kind === 'marker' && a.xs[0] !== undefined)
57
+ .filter((a) => {
58
+ const at = a.xs[0];
59
+ if (seenAt.has(at))
60
+ return false;
61
+ seenAt.add(at);
62
+ return true;
63
+ })
54
64
  .map((a) => {
55
65
  const at = a.xs[0];
56
- return { id: a.id ?? `marker-at-${at}`, x: xScale(at), text: fmt(at) };
66
+ return {
67
+ key: a.key,
68
+ id: a.id ?? `marker-at-${at}`,
69
+ x: xScale(at),
70
+ text: fmt(at),
71
+ };
57
72
  })
58
73
  .filter((t) => t.x >= 0 && t.x <= plotWidth);
74
+ // Stack overlapping marker pills into lanes — they all share this one strip.
75
+ // Greedy left→right; the dragged mark (`draggingKey`) is pinned to lane 0 so the
76
+ // static pills hold their lanes as it crosses them (no reshuffle mid-drag).
77
+ const pillWidth = (text) => text.length * theme.font.size * 0.62 + 10;
78
+ const pillLaneEnds = [];
79
+ const markerLanes = new Map();
80
+ for (const t of [...markerTags].sort((p, q) => p.x - q.x)) {
81
+ if (t.key === container.draggingKey) {
82
+ markerLanes.set(t.id, 0);
83
+ continue;
84
+ }
85
+ const left = t.x - pillWidth(t.text) / 2;
86
+ let lane = 0;
87
+ while (lane < pillLaneEnds.length && pillLaneEnds[lane] + 4 > left)
88
+ lane += 1;
89
+ pillLaneEnds[lane] = left + pillWidth(t.text);
90
+ markerLanes.set(t.id, lane);
91
+ }
92
+ const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
59
93
  const placed = customTicks
60
94
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
61
95
  : xScale.ticks(TICK_COUNT).map((d) => ({
62
96
  x: xScale(d),
63
97
  label: fmt(+d),
64
98
  }));
65
- const stripHeight = height ?? TICK_STRIP + (label ? LABEL_STRIP : 0);
66
99
  const onTop = side === 'top';
67
100
  // Axis pills (marker / crosshair) sit at the same offset as the tick labels so
68
101
  // they line up with their tick-label neighbours (matches `labelOffset` below).
69
102
  const pillOffset = align === 'right' ? 2 : 6;
103
+ // Per-lane vertical step for stacked pills; grow the strip to fit the stack.
104
+ const PILL_LANE_H = theme.font.size + 6;
105
+ const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
106
+ maxPillLane * PILL_LANE_H;
70
107
  return (_jsxs("div", { style: {
71
108
  position: 'relative',
72
109
  marginLeft: `${leftGutter}px`,
@@ -119,26 +156,39 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
119
156
  color: theme.axis.title?.color ?? theme.axis.label,
120
157
  opacity: theme.axis.title?.opacity ?? 0.85,
121
158
  whiteSpace: 'nowrap',
122
- }, children: label })), markerTags.map((t) => (_jsxs(Fragment, { children: [_jsx("div", { style: {
159
+ }, children: label })), markerTags.map((t) => {
160
+ // The pill's lane: stacked below the base row when it would overlap a
161
+ // neighbour; the connector lengthens to reach it.
162
+ const laneY = pillOffset + (markerLanes.get(t.id) ?? 0) * PILL_LANE_H;
163
+ return (_jsxs(Fragment, { children: [_jsx("div", { style: {
164
+ position: 'absolute',
165
+ left: `${t.x}px`,
166
+ [onTop ? 'bottom' : 'top']: 0,
167
+ width: '1px',
168
+ height: `${laneY}px`,
169
+ background: annotationColor,
170
+ zIndex: 2,
171
+ } }), _jsx("div", { style: {
172
+ ...axisPillStyle(theme, annotationColor),
173
+ left: `${t.x}px`,
174
+ transform: 'translateX(-50%)',
175
+ [onTop ? 'bottom' : 'top']: `${laneY}px`,
176
+ zIndex: 2,
177
+ }, children: t.text })] }, t.id));
178
+ }), showCursorTag && (_jsxs(Fragment, { children: [_jsx("div", { style: {
123
179
  position: 'absolute',
124
- left: `${t.x}px`,
180
+ left: `${cursorX}px`,
125
181
  [onTop ? 'bottom' : 'top']: 0,
126
182
  width: '1px',
127
183
  height: `${pillOffset}px`,
128
- background: annotationColor,
129
- zIndex: 2,
184
+ background: cursorColor,
185
+ zIndex: 3,
130
186
  } }), _jsx("div", { style: {
131
- ...axisPillStyle(theme, annotationColor),
132
- left: `${t.x}px`,
187
+ ...axisPillStyle(theme, cursorColor),
188
+ left: `${cursorX}px`,
133
189
  transform: 'translateX(-50%)',
134
190
  [onTop ? 'bottom' : 'top']: `${pillOffset}px`,
135
- zIndex: 2,
136
- }, children: t.text })] }, t.id))), showCursorTag && (_jsx("div", { style: {
137
- ...axisPillStyle(theme, cursorColor),
138
- left: `${cursorX}px`,
139
- transform: 'translateX(-50%)',
140
- [onTop ? 'bottom' : 'top']: `${pillOffset}px`,
141
- zIndex: 3,
142
- }, children: fmt(+xScale.invert(cursorX)) }))] }));
191
+ zIndex: 3,
192
+ }, children: fmt(+xScale.invert(cursorX)) })] }))] }));
143
193
  }
144
194
  //# sourceMappingURL=XAxis.js.map
package/dist/YAxis.d.ts CHANGED
@@ -36,6 +36,12 @@ export interface YAxisProps {
36
36
  * function. Omit for the scale's d3 default — which is calibrated to the tick
37
37
  * step, so a between-ticks readout rounds to tick precision; pass a specifier
38
38
  * (e.g. `',.2f'`) when you want finer readout precision. See {@link AxisFormat}.
39
+ *
40
+ * **Live charts:** a string specifier is value-compared, so an inline
41
+ * `format='.0%'` is safe every render. An inline `format={(v) => …}` **function**
42
+ * is a fresh reference each render — the one axis prop a structural guard can't
43
+ * value-compare — so on a frequently re-rendering (e.g. scrub-driven) chart,
44
+ * hoist it or wrap it in `useCallback`, or it re-registers the axis each frame.
39
45
  */
40
46
  format?: AxisFormat;
41
47
  /**
@@ -45,9 +51,10 @@ export interface YAxisProps {
45
51
  * lever for a non-uniform axis like pace, where the caller chooses round-pace
46
52
  * positions and their own `m:ss` labels (`{ at: -300, label: '5:00' }`). `at`
47
53
  * values outside `[min, max]` extrapolate off-plot (the scale does not clamp).
48
- * Pass `[]` to draw none. For a live / animating chart, **memoize the array** —
49
- * an inline `ticks={[…]}` is a fresh reference each render and re-registers the
50
- * axis (like `format`; harmless for a static chart).
54
+ * Pass `[]` to draw none. The array is **value-compared on registration**, so an
55
+ * inline `ticks={[…]}` (or `ticks={[]}`) with unchanged contents no longer
56
+ * re-registers the axis only genuinely changed tick positions do. (An inline
57
+ * `format` *function* still needs hoisting; see `format`.)
51
58
  */
52
59
  ticks?: ReadonlyArray<{
53
60
  readonly at: number;
@@ -1,12 +1,21 @@
1
- import { type AnnotationSpec } from './context.js';
1
+ import { type AnnotationSpec, type LabelPlacement } from './context.js';
2
2
  /**
3
- * Greedy left→right lane packing for the **top-flag** labels (markers + regions): a
4
- * label that would overlap the one to its left drops to the next free lane below,
5
- * so close-in-x labels stack instead of colliding (and a dragged label slides under
6
- * its neighbour). Returns slot-key → lane (0 = top). Baselines, whose labels anchor
7
- * at the left at their own y, don't participate.
3
+ * Lane placement for the **top-flag** labels (markers + regions). Returns, per
4
+ * slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
5
+ * don't participate.
6
+ *
7
+ * Three behaviours:
8
+ * - **Per row** — labels only contend within their own row's top space (a
9
+ * bottom-row label at the same x as a top-row one isn't "in the way").
10
+ * - **Coincident markers merge** — labelled markers at the *same x* (e.g. dragged
11
+ * together, snapped onto one line) fold into a **single** chip (`"z1, z2, max"`)
12
+ * on one lane, rather than stacking three deep. The first is the representative
13
+ * (its `label` is the joined text); the rest map to `label: null`.
14
+ * - **Greedy stacking** — non-coincident labels that would still overlap drop to
15
+ * the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
16
+ * label) so the static labels hold their lanes as it crosses them.
8
17
  */
9
- export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number): Map<symbol, number>;
18
+ export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
10
19
  /**
11
20
  * Order two region bounds so `from ≤ to`. A region **edge resize** pivots around
12
21
  * the *opposite* (fixed) edge: the dragged value `v` and the pivot are ordered
@@ -151,38 +151,93 @@ const LANE_H = 22;
151
151
  const LABEL_CHAR_W = 7;
152
152
  const LABEL_PAD = 16;
153
153
  const LANE_GAP = 6;
154
+ /** Rough chip width (px) for the overlap model. */
155
+ const labelWidth = (text) => text.length * LABEL_CHAR_W + LABEL_PAD;
154
156
  /**
155
- * Greedy left→right lane packing for the **top-flag** labels (markers + regions): a
156
- * label that would overlap the one to its left drops to the next free lane below,
157
- * so close-in-x labels stack instead of colliding (and a dragged label slides under
158
- * its neighbour). Returns slot-key → lane (0 = top). Baselines, whose labels anchor
159
- * at the left at their own y, don't participate.
157
+ * Lane placement for the **top-flag** labels (markers + regions). Returns, per
158
+ * slot key, its {@link LabelPlacement}. Baselines (label anchored at their own y)
159
+ * don't participate.
160
+ *
161
+ * Three behaviours:
162
+ * - **Per row** — labels only contend within their own row's top space (a
163
+ * bottom-row label at the same x as a top-row one isn't "in the way").
164
+ * - **Coincident markers merge** — labelled markers at the *same x* (e.g. dragged
165
+ * together, snapped onto one line) fold into a **single** chip (`"z1, z2, max"`)
166
+ * on one lane, rather than stacking three deep. The first is the representative
167
+ * (its `label` is the joined text); the rest map to `label: null`.
168
+ * - **Greedy stacking** — non-coincident labels that would still overlap drop to
169
+ * the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
170
+ * label) so the static labels hold their lanes as it crosses them.
160
171
  */
161
- export function computeLabelLanes(annotations, toPixel) {
162
- const flags = annotations
163
- .filter((a) => (a.kind === 'marker' || a.kind === 'region') &&
164
- a.label.length > 0 &&
165
- a.xs.length > 0)
166
- .map((a) => {
167
- const ax = a.kind === 'region' ? Math.min(a.xs[0], a.xs[1]) : a.xs[0];
168
- return {
169
- key: a.key,
170
- left: toPixel(ax),
171
- width: a.label.length * LABEL_CHAR_W + LABEL_PAD,
172
- };
173
- })
174
- .sort((p, q) => p.left - q.left);
175
- const laneEnds = []; // right-px of the last label placed in each lane
176
- const lanes = new Map();
177
- for (const f of flags) {
178
- let lane = 0;
179
- while (lane < laneEnds.length && laneEnds[lane] + LANE_GAP > f.left) {
180
- lane += 1;
172
+ export function computeLabelLanes(annotations, toPixel, draggingKey) {
173
+ const out = new Map();
174
+ const byRow = new Map();
175
+ for (const a of annotations) {
176
+ if ((a.kind !== 'marker' && a.kind !== 'region') ||
177
+ a.label.length === 0 ||
178
+ a.xs.length === 0)
179
+ continue;
180
+ const arr = byRow.get(a.rowKey);
181
+ if (arr)
182
+ arr.push(a);
183
+ else
184
+ byRow.set(a.rowKey, [a]);
185
+ }
186
+ for (const specs of byRow.values()) {
187
+ const flags = [];
188
+ const markerGroups = new Map();
189
+ for (const a of specs) {
190
+ if (a.kind === 'marker' && a.key !== draggingKey) {
191
+ const g = markerGroups.get(a.xs[0]);
192
+ if (g)
193
+ g.push(a);
194
+ else
195
+ markerGroups.set(a.xs[0], [a]);
196
+ }
197
+ else {
198
+ const ax = a.kind === 'region' ? Math.min(a.xs[0], a.xs[1]) : a.xs[0];
199
+ flags.push({
200
+ rep: a.key,
201
+ members: [a.key],
202
+ left: toPixel(ax),
203
+ width: labelWidth(a.label),
204
+ label: a.label,
205
+ });
206
+ }
207
+ }
208
+ for (const [x, group] of markerGroups) {
209
+ const label = group.map((g) => g.label).join(', ');
210
+ flags.push({
211
+ rep: group[0].key,
212
+ members: group.map((g) => g.key),
213
+ left: toPixel(x),
214
+ width: labelWidth(label),
215
+ label,
216
+ });
181
217
  }
182
- laneEnds[lane] = f.left + f.width;
183
- lanes.set(f.key, lane);
218
+ const assign = (f, lane) => {
219
+ out.set(f.rep, { lane, label: f.label });
220
+ for (const m of f.members)
221
+ if (m !== f.rep)
222
+ out.set(m, { lane, label: null });
223
+ };
224
+ // Greedy-pack the static flags; the dragged one is pinned to lane 0.
225
+ const dragged = flags.filter((f) => f.members.length === 1 && f.members[0] === draggingKey);
226
+ flags
227
+ .filter((f) => !dragged.includes(f))
228
+ .sort((p, q) => p.left - q.left)
229
+ .reduce((laneEnds, f) => {
230
+ let lane = 0;
231
+ while (lane < laneEnds.length && laneEnds[lane] + LANE_GAP > f.left)
232
+ lane += 1;
233
+ laneEnds[lane] = f.left + f.width;
234
+ assign(f, lane);
235
+ return laneEnds;
236
+ }, []);
237
+ for (const f of dragged)
238
+ assign(f, 0);
184
239
  }
185
- return lanes;
240
+ return out;
186
241
  }
187
242
  /** A mark's hover state, synced both ways with the consumer. The effective hover
188
243
  * is the local pointer hover **OR** the controlled `hovered` prop (so a legend row
@@ -259,7 +314,7 @@ function Pill({ cx, cy, w, h, color, }) {
259
314
  * **plot-pixel** position on press (`onDragStart`) / move (`onDrag`). With editing
260
315
  * off, press / move bubble so a pan reads straight through.
261
316
  */
262
- function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onDragStart, onDrag, }) {
317
+ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onDragStart, onDrag, onDragActive, }) {
263
318
  const dragging = useRef(false);
264
319
  // Tracks whether this press became a drag (moved past a few px) — a click that
265
320
  // didn't drag selects instead of edits. Tracked in *both* modes so a pan-drag
@@ -284,6 +339,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
284
339
  return; // edit off: let it bubble (pan reads through)
285
340
  e.stopPropagation(); // claim the gesture — don't let the plot start a pan
286
341
  dragging.current = true;
342
+ onDragActive?.(true);
287
343
  onDragStart?.(p[0], p[1]);
288
344
  try {
289
345
  e.currentTarget.setPointerCapture(e.pointerId);
@@ -312,6 +368,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
312
368
  /* ignore */
313
369
  }
314
370
  dragging.current = false;
371
+ onDragActive?.(false);
315
372
  onHover(false);
316
373
  },
317
374
  // A system gesture takeover fires pointercancel, not pointerup — clear the
@@ -319,6 +376,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
319
376
  onPointerCancel: (e) => {
320
377
  if (!dragging.current)
321
378
  return;
379
+ onDragActive?.(false);
322
380
  try {
323
381
  e.currentTarget.releasePointerCapture(e.pointerId);
324
382
  }
@@ -364,12 +422,20 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
364
422
  const h = row.height;
365
423
  const opacity = rampAt(ann.depth, lineLevel(selectable, editable, hovering, selected));
366
424
  const showHandle = editable && (editing || hovering);
367
- const lane = container.labelLanes.get(selfKey) ?? 0;
368
- return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: 0, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
369
- +container.xScale.invert(px)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
425
+ // Placement decides the lane + the chip text: `chipLabel` is this marker's own
426
+ // label, the merged `"a, b"` when coincident markers fold together, or `null`
427
+ // for a folded-in member (its chip is subsumed by the representative's).
428
+ const placement = container.labelLanes.get(selfKey);
429
+ const lane = placement?.lane ?? 0;
430
+ const chipLabel = placement?.label ?? null;
431
+ // The staff (vertical line) hangs from the top of its flag — so a flag stacked
432
+ // into a lower lane doesn't leave line poking above it. No label ⇒ full height.
433
+ const staffTop = text ? FLAG_TOP + lane * LANE_H : 0;
434
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: staffTop, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
435
+ +container.xScale.invert(px)) }))] }), chipLabel && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
370
436
  top: `${FLAG_TOP + lane * LANE_H}px`,
371
437
  ...flagChipX(x, container.plotWidth),
372
- }, children: text }))] }));
438
+ }, children: chipLabel }))] }));
373
439
  }
374
440
  /** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
375
441
  * Its label anchors at the left, at the line's height. */
@@ -413,7 +479,7 @@ export function Baseline({ value, axis, label, labelSide = 'left', labelPosition
413
479
  const text = label === false ? '' : (label ?? (fmt ? fmt(value) : String(value)));
414
480
  // Handle pill near the right end (clears the left-anchored label).
415
481
  const handleX = w - 14;
416
- return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
482
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
417
483
  top: `${y}px`,
418
484
  [labelSide === 'right' ? 'right' : 'left']: '2px',
419
485
  // `center` rides on the line; `above` sits its bottom edge on the line.
@@ -466,7 +532,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
466
532
  const fillOpacity = ann.fillOpacity *
467
533
  rampAt(FILL_MULT, bodyLevel(selectable, editable, hovering, selected));
468
534
  const showHandles = editable && (editing || hovering);
469
- const lane = container.labelLanes.get(selfKey) ?? 0;
535
+ const lane = container.labelLanes.get(selfKey)?.lane ?? 0;
470
536
  // Body move-drag: capture the start position + pointer on press, then move by
471
537
  // the TOTAL delta from there, so the *raw* position accumulates from a fixed
472
538
  // origin. Snap is applied only to the output — never fed back into this
@@ -478,7 +544,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
478
544
  // region the other way instead of dead-ending at zero width.
479
545
  const edgeRef = useRef(null);
480
546
  const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
481
- return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edges && edge(xa), edges && edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: (px) => {
547
+ return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edges && edge(xa), edges && edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: (px) => {
482
548
  dragRef.current = { from, to, startPx: px };
483
549
  }, onDrag: (px) => {
484
550
  const s = dragRef.current;
@@ -503,10 +569,10 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
503
569
  nt = st;
504
570
  }
505
571
  onChange?.({ from: nf, to: nt });
506
- } }), editable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: xa - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: () => {
572
+ } }), editable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: xa - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: () => {
507
573
  edgeRef.current = to; // the fixed pivot = the far edge
508
574
  }, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
509
- +container.xScale.invert(px), edgeRef.current ?? to)) }), _jsx(DragArea, { x: xb - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: () => {
575
+ +container.xScale.invert(px), edgeRef.current ?? to)) }), _jsx(DragArea, { x: xb - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: () => {
510
576
  edgeRef.current = from; // the fixed pivot = the near edge
511
577
  }, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
512
578
  +container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
package/dist/context.d.ts CHANGED
@@ -11,6 +11,14 @@ import type { AxisFormat } from './format.js';
11
11
  * time→pixel `xScale` follow. Y scales stay per-row (row-local data), on the
12
12
  * {@link RowFrame}.
13
13
  */
14
+ /** Where a top-flag label sits: its lane (0 = top; overlapping labels stack
15
+ * down) and the chip text to render — the merged label for the representative of
16
+ * a coincident-marker group, `null` for the members folded into it, else the
17
+ * mark's own label. Computed by `computeLabelLanes`. */
18
+ export interface LabelPlacement {
19
+ readonly lane: number;
20
+ readonly label: string | null;
21
+ }
14
22
  export interface ContainerFrame {
15
23
  readonly timeRange: readonly [number, number];
16
24
  readonly width: number;
@@ -39,6 +47,22 @@ export interface ContainerFrame {
39
47
  readonly cursorX: number | null;
40
48
  /** Set the hovered plot-pixel x; a row's event surface calls this on pointer move. */
41
49
  setHoverX(x: number | null): void;
50
+ /**
51
+ * The hovered plot-pixel **y** and the row it's in — for the free-form
52
+ * crosshair's horizontal line + value readout (which are row-specific, unlike
53
+ * the shared vertical `cursorX`). `null` when not hovering a plot. Hover-driven
54
+ * only (no controlled equivalent).
55
+ */
56
+ readonly cursorY: number | null;
57
+ readonly cursorRowKey: symbol | null;
58
+ /** Set the hovered plot-pixel y + its row; the event surface calls this on move. */
59
+ setHoverY(y: number | null, rowKey: symbol | null): void;
60
+ /**
61
+ * `cursor="crosshair"` **y** snapping. **Default `true`** — the reticle centres
62
+ * on the nearest data point's value. `false` — the y follows the pointer freely
63
+ * (`yScale.invert`). The x always snaps to the data grid either way.
64
+ */
65
+ readonly crosshairSnap: boolean;
42
66
  /**
43
67
  * The selected mark, or `null`. Shared across rows (single selection). A layer
44
68
  * highlights the mark matching **both** the key (epoch ms) and the series
@@ -146,10 +170,20 @@ export interface ContainerFrame {
146
170
  /** Every registered annotation — read by each row to draw the *other* rows'
147
171
  * guides, and by a drag to find snap targets. */
148
172
  readonly annotations: readonly AnnotationSpec[];
149
- /** Vertical lane (0 = top) for each top-flag label, by slot key overlapping
150
- * marker/region labels stack into lower lanes instead of colliding. A key absent
151
- * from the map (or mapping to 0) sits in the top lane. */
152
- readonly labelLanes: ReadonlyMap<symbol, number>;
173
+ /** Per-key top-flag {@link LabelPlacement} the lane (0 = top; overlapping
174
+ * labels stack down) + the chip text (merged for the representative of a
175
+ * coincident-marker group, `null` for the folded-in members). A key absent
176
+ * from the map sits at lane 0 with its own label. */
177
+ readonly labelLanes: ReadonlyMap<symbol, LabelPlacement>;
178
+ /**
179
+ * The annotation currently being **dragged** (its slot key), or `null`. Set on
180
+ * drag-start, cleared on release. The lane packers (label lanes + x-axis pill
181
+ * lanes) exclude it so the *static* marks don't reshuffle as the dragged one
182
+ * crosses them — only the mark under the pointer moves; it settles on release.
183
+ */
184
+ readonly draggingKey: symbol | null;
185
+ /** Mark/clear the actively-dragged annotation; a mark's drag calls this. */
186
+ setDragging(key: symbol | null): void;
153
187
  /**
154
188
  * The armed creation tool, or `null` (idle). Set by the consumer's toolbar;
155
189
  * when non-null the plot captures a **create gesture** (draw a new mark) instead
package/dist/tracker.js CHANGED
@@ -25,9 +25,10 @@ export function cursorParts(mode) {
25
25
  case 'flag':
26
26
  return { line: false, dots: true, chip: 'flag' };
27
27
  case 'crosshair':
28
- // Vertical line + per-series dots, values pinned to the axes (y pills in
29
- // `Layers`, the x-time pill on `<XAxis>`).
30
- return { line: true, dots: true, chip: 'axis' };
28
+ // A single reticle (not per-series): `Layers` draws the dashed vertical +
29
+ // full-width horizontal lines, the centre dot, and one value pill itself
30
+ // (so no generic line/dots here); the x-time pill is on `<XAxis>`.
31
+ return { line: false, dots: false, chip: 'axis' };
31
32
  case 'none':
32
33
  return { line: false, dots: false, chip: 'none' };
33
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.38.0",
42
- "pond-ts": "^0.38.0",
41
+ "@pond-ts/react": "^0.40.0",
42
+ "pond-ts": "^0.40.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {