@pond-ts/charts 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/domain.d.ts CHANGED
@@ -29,6 +29,24 @@ import type { YScaleKind } from './context.js';
29
29
  * the *decades* spanned rather than of the difference.
30
30
  */
31
31
  export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number, scale?: YScaleKind): [number, number];
32
+ /**
33
+ * The inverse of {@link resolveYDomain}'s `pad` — recover the bounds that, fed
34
+ * back in as `min`/`max`, re-pad to exactly the domain passed in.
35
+ *
36
+ * **Why this has to exist.** `pad` is applied *last*, and to explicit bounds too,
37
+ * so a scale's live domain is the **padded** one. Anything that reads a domain
38
+ * off the scale and hands it back as bounds (a y-gutter gesture reporting through
39
+ * `<YAxis onBoundsChange>`) would otherwise re-pad an already-padded domain and
40
+ * inflate it by `1 + 2·pad` on every report — compounding, so a padded axis
41
+ * walks outward a notch at a time under a gesture that should be zooming *in*.
42
+ *
43
+ * Both directions preserve the domain's centre, so this is the same arithmetic
44
+ * run backwards: in value space for linear / symlog, in log space for `'log'`
45
+ * (where `pad` is a fraction of the decades). A `pad` of `0` — the default — is
46
+ * the identity, and a log domain that is not strictly positive is returned
47
+ * untouched rather than taken through `log10`.
48
+ */
49
+ export declare function unpadDomain(domain: readonly [number, number], pad?: number, scale?: YScaleKind): [number, number];
32
50
  /**
33
51
  * Does resolving this axis's domain need its layers' extents walked?
34
52
  * `yExtent()` is O(points) per layer, so the caller only pays it when a side
package/dist/domain.js CHANGED
@@ -39,6 +39,39 @@ export function resolveYDomain(min, max, extents, pad = 0, scale = 'linear') {
39
39
  }
40
40
  return result;
41
41
  }
42
+ /**
43
+ * The inverse of {@link resolveYDomain}'s `pad` — recover the bounds that, fed
44
+ * back in as `min`/`max`, re-pad to exactly the domain passed in.
45
+ *
46
+ * **Why this has to exist.** `pad` is applied *last*, and to explicit bounds too,
47
+ * so a scale's live domain is the **padded** one. Anything that reads a domain
48
+ * off the scale and hands it back as bounds (a y-gutter gesture reporting through
49
+ * `<YAxis onBoundsChange>`) would otherwise re-pad an already-padded domain and
50
+ * inflate it by `1 + 2·pad` on every report — compounding, so a padded axis
51
+ * walks outward a notch at a time under a gesture that should be zooming *in*.
52
+ *
53
+ * Both directions preserve the domain's centre, so this is the same arithmetic
54
+ * run backwards: in value space for linear / symlog, in log space for `'log'`
55
+ * (where `pad` is a fraction of the decades). A `pad` of `0` — the default — is
56
+ * the identity, and a log domain that is not strictly positive is returned
57
+ * untouched rather than taken through `log10`.
58
+ */
59
+ export function unpadDomain(domain, pad = 0, scale = 'linear') {
60
+ const [lo, hi] = domain;
61
+ if (!pad)
62
+ return [lo, hi];
63
+ const shrink = 1 + 2 * pad;
64
+ if (scale === 'log') {
65
+ if (!(lo > 0) || !(hi > lo))
66
+ return [lo, hi];
67
+ const midLog = (Math.log10(lo) + Math.log10(hi)) / 2;
68
+ const halfLog = (Math.log10(hi) - Math.log10(lo)) / (2 * shrink);
69
+ return [10 ** (midLog - halfLog), 10 ** (midLog + halfLog)];
70
+ }
71
+ const mid = (lo + hi) / 2;
72
+ const half = (hi - lo) / (2 * shrink);
73
+ return [mid - half, mid + half];
74
+ }
42
75
  /** Smallest positive value a log domain will fall back to when the data offers
43
76
  * nothing positive at all. Arbitrary but finite — a log scale has no natural
44
77
  * zero to anchor on, and `[0, 1]` (the linear empty-data domain) has no
@@ -0,0 +1,79 @@
1
+ import { type CSSProperties } from 'react';
2
+ import type { PointerEvent as ReactPointerEvent } from 'react';
3
+ /** What a strip needs to know to turn pointer input into view changes. */
4
+ export interface AxisGestureSpec {
5
+ /** Which pointer delta drives the gesture, and which cursor to show. */
6
+ axis: 'x' | 'y';
7
+ /**
8
+ * What a **drag** on the strip does, or `'none'` when it captures no drag:
9
+ *
10
+ * - `'pan'` — slides the view, **exactly as a drag on the plot does**. Reports
11
+ * the total delta from the press (anchored, not incremental) because that is
12
+ * what the plot's own pan needs: it re-derives the view from the range it
13
+ * snapshotted at press, so a pan can't accumulate rounding across a drag.
14
+ * - `'zoom'` — scales the axis about the grabbed pixel, reporting incremental
15
+ * span multipliers.
16
+ *
17
+ * The x strip pans (the canvas gesture, one mental model for both surfaces);
18
+ * a y gutter zooms, which is the gesture the plot cannot offer per axis.
19
+ */
20
+ drag: 'none' | 'pan' | 'zoom';
21
+ /** Whether the **wheel** zooms this axis — again as it does over the plot. */
22
+ wheel: boolean;
23
+ /** Called on press, before any movement: the caller snapshots its view here so
24
+ * a `'pan'` drag can anchor on it. */
25
+ onDragStart?: () => void;
26
+ /** Total drag delta from the press, in px (`'pan'` mode only). */
27
+ onPan?: (totalDeltaPx: number) => void;
28
+ /**
29
+ * Zoom by `factor` about `pivotPx` (strip-local pixels). `factor` is a
30
+ * **domain-span multiplier** — `< 1` zooms in, `> 1` out — matching
31
+ * {@link zoomRange}'s convention. From the wheel it is one notch; from a
32
+ * `'zoom'` drag it arrives **incrementally**, each move reporting the step
33
+ * since the last, so the caller composes onto the current view.
34
+ */
35
+ onZoom?: (factor: number, pivotPx: number) => void;
36
+ /** Double-click — return this axis to its declared view. */
37
+ onReset?: () => void;
38
+ }
39
+ /** What a strip spreads onto its root element to become gesture-capable. */
40
+ export interface AxisGestures {
41
+ /** Attach to the strip element — the wheel listener needs it non-passive. */
42
+ ref: (el: HTMLDivElement | null) => void;
43
+ props: {
44
+ onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void;
45
+ onPointerMove?: (e: ReactPointerEvent<HTMLDivElement>) => void;
46
+ onPointerUp?: (e: ReactPointerEvent<HTMLDivElement>) => void;
47
+ onPointerCancel?: (e: ReactPointerEvent<HTMLDivElement>) => void;
48
+ onDoubleClick?: () => void;
49
+ };
50
+ /** Cursor affordance — merged into the strip's own style. */
51
+ style: CSSProperties;
52
+ /**
53
+ * Did the sequence that just ended pass the slop and zoom? The axis reports
54
+ * mouse events too ({@link AxisMouseHandler}), and a drag on the same element
55
+ * still emits a trailing `click` — which would read as "the user clicked the
56
+ * axis at the value they happened to release on". Consulted (and consumed) by
57
+ * the axis to swallow exactly that one report.
58
+ */
59
+ consumeDrag: () => boolean;
60
+ }
61
+ /**
62
+ * Drag, wheel and double-click gestures for an axis strip.
63
+ *
64
+ * **The x strip behaves exactly as the canvas does** — drag pans, wheel zooms
65
+ * about the pointer — so a chart has one gesture vocabulary rather than one per
66
+ * surface, and the strip is simply a second place to reach the same view.
67
+ *
68
+ * **A y gutter zooms on drag**, because that is the gesture the plot cannot
69
+ * offer: the plot's vertical drag scales every axis in the row by one factor
70
+ * (the aspect lock), while grabbing a gutter names a single axis. Up expands it,
71
+ * down compresses it, about the grabbed pixel.
72
+ *
73
+ * The two shapes are why the drag reports differently per mode: a pan is
74
+ * **anchored** (total delta from the press, re-derived from a snapshot — how the
75
+ * plot's own pan avoids accumulating rounding), a zoom is **incremental** (a
76
+ * span multiplier per step, composed onto the current view).
77
+ */
78
+ export declare function useAxisGestures(spec: AxisGestureSpec): AxisGestures;
79
+ //# sourceMappingURL=use-axis-gestures.d.ts.map
@@ -0,0 +1,259 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState, } from 'react';
2
+ /**
3
+ * Pixels of drag per e-fold of zoom, and the wheel's per-notch equivalent. The
4
+ * drag figure is deliberately slower than a plot pan feels: an axis drag scales
5
+ * the view rather than sliding it, so the same pixel budget covers a much bigger
6
+ * change in what's on screen.
7
+ */
8
+ const DRAG_SENSITIVITY = 0.006;
9
+ const WHEEL_SENSITIVITY = 0.0015; // matches the plot's `ZOOM_SENSITIVITY`
10
+ /**
11
+ * Slop before a press becomes a zoom. Below it the sequence is still a click —
12
+ * which is what keeps `onMouseEvent` consumers (and a double-click reset) working
13
+ * on a strip that also zooms.
14
+ */
15
+ const DRAG_SLOP = 3;
16
+ /**
17
+ * Most a **single** event may scale the view. A captured drag can deliver one
18
+ * enormous move (a fast flick, or a coalesced move after the pointer left the
19
+ * strip), and `exp` of it is a factor in the hundreds — enough to overflow a log
20
+ * domain to `[0, Infinity]` in one step. Clamping per event keeps every gesture
21
+ * reachable by repetition while bounding what one event can do.
22
+ */
23
+ const MAX_STEP_FACTOR = 4;
24
+ /** How long the directional cursor lingers after the last wheel notch. */
25
+ const WHEEL_CURSOR_MS = 400;
26
+ /**
27
+ * Drag, wheel and double-click gestures for an axis strip.
28
+ *
29
+ * **The x strip behaves exactly as the canvas does** — drag pans, wheel zooms
30
+ * about the pointer — so a chart has one gesture vocabulary rather than one per
31
+ * surface, and the strip is simply a second place to reach the same view.
32
+ *
33
+ * **A y gutter zooms on drag**, because that is the gesture the plot cannot
34
+ * offer: the plot's vertical drag scales every axis in the row by one factor
35
+ * (the aspect lock), while grabbing a gutter names a single axis. Up expands it,
36
+ * down compresses it, about the grabbed pixel.
37
+ *
38
+ * The two shapes are why the drag reports differently per mode: a pan is
39
+ * **anchored** (total delta from the press, re-derived from a snapshot — how the
40
+ * plot's own pan avoids accumulating rounding), a zoom is **incremental** (a
41
+ * span multiplier per step, composed onto the current view).
42
+ */
43
+ export function useAxisGestures(spec) {
44
+ // The live spec, read by the handlers — so a listener attached once still sees
45
+ // current props (the pattern `Layers` uses for the plot's gestures).
46
+ //
47
+ // Published from a layout effect, **not** during render: a render React
48
+ // abandons under concurrent rendering would otherwise leave the ref pointing at
49
+ // callbacks that close over a frame that was never committed. `ChartContainer`
50
+ // writes `onRangeRef` the same way, for the same reason. It still lands before
51
+ // paint, so the first event after mount reads a current spec.
52
+ const specRef = useRef(spec);
53
+ useLayoutEffect(() => {
54
+ specRef.current = spec;
55
+ });
56
+ const elRef = useRef(null);
57
+ const drag = useRef(null);
58
+ /** Set on release when the sequence had committed; consumed by the axis. */
59
+ const draggedRef = useRef(false);
60
+ /**
61
+ * Whether a gesture is happening *right now* — the cursor's only input.
62
+ *
63
+ * At rest a strip shows the ordinary arrow: it is chrome you also click, hover
64
+ * and read, and a permanent resize cursor over it would claim the whole strip
65
+ * is a handle. The directional cursor appears while a drag is live (and for a
66
+ * moment after a wheel notch, which has no press to hang it on) — the same way
67
+ * a scrollbar tells you what it is doing rather than what it could do.
68
+ */
69
+ const [gesturing, setGesturing] = useState(false);
70
+ /** Clears the post-wheel cursor; also cancelled on unmount. */
71
+ const wheelIdle = useRef(null);
72
+ const local = (e) => {
73
+ const el = elRef.current;
74
+ if (el === null)
75
+ return 0;
76
+ const r = el.getBoundingClientRect();
77
+ const px = specRef.current.axis === 'x' ? e.clientX - r.left : e.clientY - r.top;
78
+ // Clamped to the strip, as `Layers` clamps the plot's own wheel pivot: a
79
+ // pivot off the end would zoom about a value the axis does not draw.
80
+ const extent = specRef.current.axis === 'x' ? r.width : r.height;
81
+ return extent > 0 ? Math.max(0, Math.min(extent, px)) : px;
82
+ };
83
+ /** Pointer delta → span multiplier. Up / right = zoom in (multiplier < 1). */
84
+ const factorFor = (delta, sensitivity) => Math.exp(sensitivity * (specRef.current.axis === 'x' ? -delta : delta));
85
+ /**
86
+ * Zoom, unless the factor isn't a usable multiplier. A device (or a DOM shim)
87
+ * that reports no `deltaY` yields `exp(NaN)`, and a `NaN` factor walks
88
+ * straight into the view range — `[NaN, NaN]` is an unrecoverable chart, not a
89
+ * dropped frame, so it is worth the guard at the one choke point.
90
+ */
91
+ const zoom = (factor, pivotPx) => {
92
+ if (!Number.isFinite(factor) || factor <= 0)
93
+ return;
94
+ if (!Number.isFinite(pivotPx))
95
+ return;
96
+ const clamped = Math.max(1 / MAX_STEP_FACTOR, Math.min(MAX_STEP_FACTOR, factor));
97
+ specRef.current.onZoom?.(clamped, pivotPx);
98
+ };
99
+ const onPointerDown = useCallback((e) => {
100
+ const s = specRef.current;
101
+ if (s.drag === 'none' || e.button !== 0)
102
+ return;
103
+ const at = s.axis === 'x' ? e.clientX : e.clientY;
104
+ drag.current = { start: at, last: at, pivot: local(e), committed: false };
105
+ s.onDragStart?.();
106
+ // Capture so a drag that leaves the strip (very likely — the strip is
107
+ // ~20px tall) keeps steering the gesture until release. Feature-detected: a
108
+ // test DOM may not implement it, and the gesture works without it as long
109
+ // as the pointer stays over the strip.
110
+ if (typeof e.currentTarget.setPointerCapture === 'function') {
111
+ e.currentTarget.setPointerCapture(e.pointerId);
112
+ }
113
+ }, []);
114
+ const onPointerMove = useCallback((e) => {
115
+ const d = drag.current;
116
+ const s = specRef.current;
117
+ if (d === null || s.drag === 'none')
118
+ return;
119
+ const at = s.axis === 'x' ? e.clientX : e.clientY;
120
+ const total = at - d.start;
121
+ // Below the slop the sequence is still a click: report nothing, so a click's
122
+ // jitter neither moves the view nor shifts the scale the click hit-tests
123
+ // against. The plot's own drag holds the same line, at the same 3px.
124
+ if (!d.committed && Math.abs(total) <= DRAG_SLOP)
125
+ return;
126
+ if (!d.committed)
127
+ setGesturing(true);
128
+ d.committed = true;
129
+ if (s.drag === 'pan') {
130
+ // Anchored on the press: the caller re-derives from the range it
131
+ // snapshotted in `onDragStart`, which is how the plot's pan avoids
132
+ // accumulating rounding over a long drag.
133
+ if (Number.isFinite(total))
134
+ s.onPan?.(total);
135
+ }
136
+ else {
137
+ const step = at - d.last;
138
+ if (step !== 0)
139
+ zoom(factorFor(step, DRAG_SENSITIVITY), d.pivot);
140
+ }
141
+ d.last = at;
142
+ }, []);
143
+ const endDrag = useCallback((e) => {
144
+ const d = drag.current;
145
+ drag.current = null;
146
+ setGesturing(false);
147
+ if (d === null)
148
+ return;
149
+ if (d.committed)
150
+ draggedRef.current = true;
151
+ if (typeof e.currentTarget.hasPointerCapture === 'function' &&
152
+ e.currentTarget.hasPointerCapture(e.pointerId)) {
153
+ e.currentTarget.releasePointerCapture(e.pointerId);
154
+ }
155
+ }, []);
156
+ // Disabling gestures mid-drag (a prop flip, or a category axis appearing) has
157
+ // the same problem as the element vanishing: no release will arrive.
158
+ useLayoutEffect(() => {
159
+ if (spec.drag === 'none' && drag.current !== null) {
160
+ drag.current = null;
161
+ draggedRef.current = false;
162
+ setGesturing(false);
163
+ }
164
+ }, [spec.drag]);
165
+ const onDoubleClick = useCallback(() => {
166
+ const s = specRef.current;
167
+ if (s.drag !== 'none' || s.wheel)
168
+ s.onReset?.();
169
+ }, []);
170
+ // Wheel must be a native non-passive listener to `preventDefault()` the page
171
+ // scroll — React's `onWheel` is passive. Bound in the **ref callback** rather
172
+ // than an effect: the strip element comes and goes (a `<YAxis hide>` toggle
173
+ // unmounts the gutter), and an effect with `[]` deps would stay attached to the
174
+ // first element and go silent on the replacement.
175
+ const onWheelRef = useRef(null);
176
+ const setRef = useCallback((el) => {
177
+ const prev = elRef.current;
178
+ if (prev !== null && onWheelRef.current !== null) {
179
+ prev.removeEventListener('wheel', onWheelRef.current);
180
+ onWheelRef.current = null;
181
+ }
182
+ elRef.current = el;
183
+ if (el === null) {
184
+ // The strip went away mid-gesture — a `<YAxis hide>` toggle, or the axis
185
+ // unmounting under the pointer. Nothing will deliver its `pointerup`, so
186
+ // drop the drag here: otherwise re-showing the strip resumed a gesture
187
+ // nobody was making, and the directional cursor stayed on.
188
+ drag.current = null;
189
+ draggedRef.current = false;
190
+ setGesturing(false);
191
+ if (wheelIdle.current !== null) {
192
+ clearTimeout(wheelIdle.current);
193
+ wheelIdle.current = null;
194
+ }
195
+ return;
196
+ }
197
+ const onWheel = (e) => {
198
+ const s = specRef.current;
199
+ if (!s.wheel)
200
+ return;
201
+ e.preventDefault();
202
+ // The wheel's own axis is irrelevant here: the strip *is* the axis, so a
203
+ // notch means "zoom me" whichever way the device reports it. `deltaY` is
204
+ // what a mouse wheel and a two-finger scroll both produce.
205
+ zoom(Math.exp(e.deltaY * WHEEL_SENSITIVITY), local(e));
206
+ // A wheel notch has no press to bracket, so the cursor is shown for a beat
207
+ // and re-armed by each further notch — a continuous scroll reads as one
208
+ // gesture rather than flickering per notch.
209
+ setGesturing(true);
210
+ if (wheelIdle.current !== null)
211
+ clearTimeout(wheelIdle.current);
212
+ wheelIdle.current = setTimeout(() => setGesturing(false), WHEEL_CURSOR_MS);
213
+ };
214
+ onWheelRef.current = onWheel;
215
+ el.addEventListener('wheel', onWheel, { passive: false });
216
+ }, []);
217
+ // Nothing to detach on unmount beyond the pending cursor timer: React calls the
218
+ // ref callback with `null` first, which releases the listener above.
219
+ useLayoutEffect(() => () => {
220
+ if (wheelIdle.current !== null)
221
+ clearTimeout(wheelIdle.current);
222
+ }, []);
223
+ const consumeDrag = useCallback(() => {
224
+ const was = draggedRef.current;
225
+ draggedRef.current = false;
226
+ return was;
227
+ }, []);
228
+ if (spec.drag === 'none' && !spec.wheel) {
229
+ return { ref: setRef, props: {}, style: {}, consumeDrag };
230
+ }
231
+ return {
232
+ ref: setRef,
233
+ props: {
234
+ ...(spec.drag === 'none'
235
+ ? {}
236
+ : {
237
+ onPointerDown,
238
+ onPointerMove,
239
+ onPointerUp: endDrag,
240
+ onPointerCancel: endDrag,
241
+ }),
242
+ onDoubleClick,
243
+ },
244
+ style: {
245
+ // Arrow at rest (see `gesturing`); while moving, name the direction the
246
+ // gesture works in — up/down over a y gutter, left/right over the x strip.
247
+ ...(gesturing
248
+ ? { cursor: spec.axis === 'x' ? 'ew-resize' : 'ns-resize' }
249
+ : {}),
250
+ // A drag must not start a text selection of the tick labels.
251
+ userSelect: 'none',
252
+ // The strip owns the wheel where it takes it, so a two-finger scroll
253
+ // zooms rather than scrolling the page.
254
+ ...(spec.wheel ? { touchAction: 'none' } : {}),
255
+ },
256
+ consumeDrag,
257
+ };
258
+ }
259
+ //# sourceMappingURL=use-axis-gestures.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -39,8 +39,8 @@
39
39
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
40
40
  },
41
41
  "peerDependencies": {
42
- "@pond-ts/react": "^0.61.0",
43
- "pond-ts": "^0.61.0",
42
+ "@pond-ts/react": "^0.63.0",
43
+ "pond-ts": "^0.63.0",
44
44
  "react": "^18.0.0 || ^19.0.0"
45
45
  },
46
46
  "devDependencies": {