@pond-ts/charts 0.62.0 → 0.64.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/API.md +1 -1
- package/CHANGELOG.md +223 -1
- package/dist/BarChart.js +6 -6
- package/dist/ChartContainer.d.ts +72 -0
- package/dist/ChartContainer.js +151 -24
- package/dist/ChartRow.d.ts +39 -3
- package/dist/ChartRow.js +194 -7
- package/dist/Layers.js +25 -8
- package/dist/XAxis.d.ts +6 -0
- package/dist/XAxis.js +81 -3
- package/dist/YAxis.d.ts +92 -2
- package/dist/YAxis.js +268 -6
- package/dist/annotations.js +14 -7
- package/dist/bars.d.ts +21 -8
- package/dist/bars.js +38 -12
- package/dist/chip.d.ts +50 -7
- package/dist/chip.js +66 -9
- package/dist/context.d.ts +134 -7
- package/dist/cursors.js +33 -8
- package/dist/domain.d.ts +18 -0
- package/dist/domain.js +33 -0
- package/dist/select.d.ts +1 -1
- package/dist/select.js +7 -2
- package/dist/tradingTimeScale.d.ts +11 -1
- package/dist/tradingTimeScale.js +53 -7
- package/dist/use-axis-gestures.d.ts +79 -0
- package/dist/use-axis-gestures.js +257 -0
- package/package.json +3 -3
|
@@ -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
|
+
* Both the x strip and the y gutter pan on drag — the canvas gesture, one
|
|
18
|
+
* mental model for every surface. `'zoom'` remains available for a strip
|
|
19
|
+
* that wants drag-to-zoom instead, but nothing in this repo currently wires
|
|
20
|
+
* it up: wheel is the zoom gesture everywhere now.
|
|
21
|
+
*/
|
|
22
|
+
drag: 'none' | 'pan' | 'zoom';
|
|
23
|
+
/** Whether the **wheel** zooms this axis — again as it does over the plot. */
|
|
24
|
+
wheel: boolean;
|
|
25
|
+
/** Called on press, before any movement: the caller snapshots its view here so
|
|
26
|
+
* a `'pan'` drag can anchor on it. */
|
|
27
|
+
onDragStart?: () => void;
|
|
28
|
+
/** Total drag delta from the press, in px (`'pan'` mode only). */
|
|
29
|
+
onPan?: (totalDeltaPx: number) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Zoom by `factor` about `pivotPx` (strip-local pixels). `factor` is a
|
|
32
|
+
* **domain-span multiplier** — `< 1` zooms in, `> 1` out — matching
|
|
33
|
+
* {@link zoomRange}'s convention. From the wheel it is one notch; from a
|
|
34
|
+
* `'zoom'` drag it arrives **incrementally**, each move reporting the step
|
|
35
|
+
* since the last, so the caller composes onto the current view.
|
|
36
|
+
*/
|
|
37
|
+
onZoom?: (factor: number, pivotPx: number) => void;
|
|
38
|
+
/** Double-click — return this axis to its declared view. */
|
|
39
|
+
onReset?: () => void;
|
|
40
|
+
}
|
|
41
|
+
/** What a strip spreads onto its root element to become gesture-capable. */
|
|
42
|
+
export interface AxisGestures {
|
|
43
|
+
/** Attach to the strip element — the wheel listener needs it non-passive. */
|
|
44
|
+
ref: (el: HTMLDivElement | null) => void;
|
|
45
|
+
props: {
|
|
46
|
+
onPointerDown?: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
47
|
+
onPointerMove?: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
48
|
+
onPointerUp?: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
49
|
+
onPointerCancel?: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
50
|
+
onDoubleClick?: () => void;
|
|
51
|
+
};
|
|
52
|
+
/** Cursor affordance — merged into the strip's own style. */
|
|
53
|
+
style: CSSProperties;
|
|
54
|
+
/**
|
|
55
|
+
* Did the sequence that just ended pass the slop and zoom? The axis reports
|
|
56
|
+
* mouse events too ({@link AxisMouseHandler}), and a drag on the same element
|
|
57
|
+
* still emits a trailing `click` — which would read as "the user clicked the
|
|
58
|
+
* axis at the value they happened to release on". Consulted (and consumed) by
|
|
59
|
+
* the axis to swallow exactly that one report.
|
|
60
|
+
*/
|
|
61
|
+
consumeDrag: () => boolean;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Drag, wheel and double-click gestures for an axis strip.
|
|
65
|
+
*
|
|
66
|
+
* **Every strip behaves exactly as the canvas does** — drag pans, wheel zooms
|
|
67
|
+
* about the pointer — so a chart has one gesture vocabulary rather than one per
|
|
68
|
+
* surface, and a strip is simply a second place to reach the same view. A y
|
|
69
|
+
* gutter's pan moves only *that* axis (the plot's own vertical drag, where it
|
|
70
|
+
* exists, scales every axis in the row by one factor — the aspect lock; a
|
|
71
|
+
* gutter names a single axis instead).
|
|
72
|
+
*
|
|
73
|
+
* The two `drag` shapes report differently: a pan is **anchored** (total delta
|
|
74
|
+
* from the press, re-derived from a snapshot — how the plot's own pan avoids
|
|
75
|
+
* accumulating rounding), a zoom is **incremental** (a span multiplier per
|
|
76
|
+
* 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,257 @@
|
|
|
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
|
+
* **Every 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 a strip is simply a second place to reach the same view. A y
|
|
32
|
+
* gutter's pan moves only *that* axis (the plot's own vertical drag, where it
|
|
33
|
+
* exists, scales every axis in the row by one factor — the aspect lock; a
|
|
34
|
+
* gutter names a single axis instead).
|
|
35
|
+
*
|
|
36
|
+
* The two `drag` shapes report differently: a pan is **anchored** (total delta
|
|
37
|
+
* from the press, re-derived from a snapshot — how the plot's own pan avoids
|
|
38
|
+
* accumulating rounding), a zoom is **incremental** (a span multiplier per
|
|
39
|
+
* step, composed onto the current view).
|
|
40
|
+
*/
|
|
41
|
+
export function useAxisGestures(spec) {
|
|
42
|
+
// The live spec, read by the handlers — so a listener attached once still sees
|
|
43
|
+
// current props (the pattern `Layers` uses for the plot's gestures).
|
|
44
|
+
//
|
|
45
|
+
// Published from a layout effect, **not** during render: a render React
|
|
46
|
+
// abandons under concurrent rendering would otherwise leave the ref pointing at
|
|
47
|
+
// callbacks that close over a frame that was never committed. `ChartContainer`
|
|
48
|
+
// writes `onRangeRef` the same way, for the same reason. It still lands before
|
|
49
|
+
// paint, so the first event after mount reads a current spec.
|
|
50
|
+
const specRef = useRef(spec);
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
specRef.current = spec;
|
|
53
|
+
});
|
|
54
|
+
const elRef = useRef(null);
|
|
55
|
+
const drag = useRef(null);
|
|
56
|
+
/** Set on release when the sequence had committed; consumed by the axis. */
|
|
57
|
+
const draggedRef = useRef(false);
|
|
58
|
+
/**
|
|
59
|
+
* Whether a gesture is happening *right now* — the cursor's only input.
|
|
60
|
+
*
|
|
61
|
+
* At rest a strip shows the ordinary arrow: it is chrome you also click, hover
|
|
62
|
+
* and read, and a permanent resize cursor over it would claim the whole strip
|
|
63
|
+
* is a handle. The directional cursor appears while a drag is live (and for a
|
|
64
|
+
* moment after a wheel notch, which has no press to hang it on) — the same way
|
|
65
|
+
* a scrollbar tells you what it is doing rather than what it could do.
|
|
66
|
+
*/
|
|
67
|
+
const [gesturing, setGesturing] = useState(false);
|
|
68
|
+
/** Clears the post-wheel cursor; also cancelled on unmount. */
|
|
69
|
+
const wheelIdle = useRef(null);
|
|
70
|
+
const local = (e) => {
|
|
71
|
+
const el = elRef.current;
|
|
72
|
+
if (el === null)
|
|
73
|
+
return 0;
|
|
74
|
+
const r = el.getBoundingClientRect();
|
|
75
|
+
const px = specRef.current.axis === 'x' ? e.clientX - r.left : e.clientY - r.top;
|
|
76
|
+
// Clamped to the strip, as `Layers` clamps the plot's own wheel pivot: a
|
|
77
|
+
// pivot off the end would zoom about a value the axis does not draw.
|
|
78
|
+
const extent = specRef.current.axis === 'x' ? r.width : r.height;
|
|
79
|
+
return extent > 0 ? Math.max(0, Math.min(extent, px)) : px;
|
|
80
|
+
};
|
|
81
|
+
/** Pointer delta → span multiplier. Up / right = zoom in (multiplier < 1). */
|
|
82
|
+
const factorFor = (delta, sensitivity) => Math.exp(sensitivity * (specRef.current.axis === 'x' ? -delta : delta));
|
|
83
|
+
/**
|
|
84
|
+
* Zoom, unless the factor isn't a usable multiplier. A device (or a DOM shim)
|
|
85
|
+
* that reports no `deltaY` yields `exp(NaN)`, and a `NaN` factor walks
|
|
86
|
+
* straight into the view range — `[NaN, NaN]` is an unrecoverable chart, not a
|
|
87
|
+
* dropped frame, so it is worth the guard at the one choke point.
|
|
88
|
+
*/
|
|
89
|
+
const zoom = (factor, pivotPx) => {
|
|
90
|
+
if (!Number.isFinite(factor) || factor <= 0)
|
|
91
|
+
return;
|
|
92
|
+
if (!Number.isFinite(pivotPx))
|
|
93
|
+
return;
|
|
94
|
+
const clamped = Math.max(1 / MAX_STEP_FACTOR, Math.min(MAX_STEP_FACTOR, factor));
|
|
95
|
+
specRef.current.onZoom?.(clamped, pivotPx);
|
|
96
|
+
};
|
|
97
|
+
const onPointerDown = useCallback((e) => {
|
|
98
|
+
const s = specRef.current;
|
|
99
|
+
if (s.drag === 'none' || e.button !== 0)
|
|
100
|
+
return;
|
|
101
|
+
const at = s.axis === 'x' ? e.clientX : e.clientY;
|
|
102
|
+
drag.current = { start: at, last: at, pivot: local(e), committed: false };
|
|
103
|
+
s.onDragStart?.();
|
|
104
|
+
// Capture so a drag that leaves the strip (very likely — the strip is
|
|
105
|
+
// ~20px tall) keeps steering the gesture until release. Feature-detected: a
|
|
106
|
+
// test DOM may not implement it, and the gesture works without it as long
|
|
107
|
+
// as the pointer stays over the strip.
|
|
108
|
+
if (typeof e.currentTarget.setPointerCapture === 'function') {
|
|
109
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
110
|
+
}
|
|
111
|
+
}, []);
|
|
112
|
+
const onPointerMove = useCallback((e) => {
|
|
113
|
+
const d = drag.current;
|
|
114
|
+
const s = specRef.current;
|
|
115
|
+
if (d === null || s.drag === 'none')
|
|
116
|
+
return;
|
|
117
|
+
const at = s.axis === 'x' ? e.clientX : e.clientY;
|
|
118
|
+
const total = at - d.start;
|
|
119
|
+
// Below the slop the sequence is still a click: report nothing, so a click's
|
|
120
|
+
// jitter neither moves the view nor shifts the scale the click hit-tests
|
|
121
|
+
// against. The plot's own drag holds the same line, at the same 3px.
|
|
122
|
+
if (!d.committed && Math.abs(total) <= DRAG_SLOP)
|
|
123
|
+
return;
|
|
124
|
+
if (!d.committed)
|
|
125
|
+
setGesturing(true);
|
|
126
|
+
d.committed = true;
|
|
127
|
+
if (s.drag === 'pan') {
|
|
128
|
+
// Anchored on the press: the caller re-derives from the range it
|
|
129
|
+
// snapshotted in `onDragStart`, which is how the plot's pan avoids
|
|
130
|
+
// accumulating rounding over a long drag.
|
|
131
|
+
if (Number.isFinite(total))
|
|
132
|
+
s.onPan?.(total);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const step = at - d.last;
|
|
136
|
+
if (step !== 0)
|
|
137
|
+
zoom(factorFor(step, DRAG_SENSITIVITY), d.pivot);
|
|
138
|
+
}
|
|
139
|
+
d.last = at;
|
|
140
|
+
}, []);
|
|
141
|
+
const endDrag = useCallback((e) => {
|
|
142
|
+
const d = drag.current;
|
|
143
|
+
drag.current = null;
|
|
144
|
+
setGesturing(false);
|
|
145
|
+
if (d === null)
|
|
146
|
+
return;
|
|
147
|
+
if (d.committed)
|
|
148
|
+
draggedRef.current = true;
|
|
149
|
+
if (typeof e.currentTarget.hasPointerCapture === 'function' &&
|
|
150
|
+
e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
151
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
152
|
+
}
|
|
153
|
+
}, []);
|
|
154
|
+
// Disabling gestures mid-drag (a prop flip, or a category axis appearing) has
|
|
155
|
+
// the same problem as the element vanishing: no release will arrive.
|
|
156
|
+
useLayoutEffect(() => {
|
|
157
|
+
if (spec.drag === 'none' && drag.current !== null) {
|
|
158
|
+
drag.current = null;
|
|
159
|
+
draggedRef.current = false;
|
|
160
|
+
setGesturing(false);
|
|
161
|
+
}
|
|
162
|
+
}, [spec.drag]);
|
|
163
|
+
const onDoubleClick = useCallback(() => {
|
|
164
|
+
const s = specRef.current;
|
|
165
|
+
if (s.drag !== 'none' || s.wheel)
|
|
166
|
+
s.onReset?.();
|
|
167
|
+
}, []);
|
|
168
|
+
// Wheel must be a native non-passive listener to `preventDefault()` the page
|
|
169
|
+
// scroll — React's `onWheel` is passive. Bound in the **ref callback** rather
|
|
170
|
+
// than an effect: the strip element comes and goes (a `<YAxis hide>` toggle
|
|
171
|
+
// unmounts the gutter), and an effect with `[]` deps would stay attached to the
|
|
172
|
+
// first element and go silent on the replacement.
|
|
173
|
+
const onWheelRef = useRef(null);
|
|
174
|
+
const setRef = useCallback((el) => {
|
|
175
|
+
const prev = elRef.current;
|
|
176
|
+
if (prev !== null && onWheelRef.current !== null) {
|
|
177
|
+
prev.removeEventListener('wheel', onWheelRef.current);
|
|
178
|
+
onWheelRef.current = null;
|
|
179
|
+
}
|
|
180
|
+
elRef.current = el;
|
|
181
|
+
if (el === null) {
|
|
182
|
+
// The strip went away mid-gesture — a `<YAxis hide>` toggle, or the axis
|
|
183
|
+
// unmounting under the pointer. Nothing will deliver its `pointerup`, so
|
|
184
|
+
// drop the drag here: otherwise re-showing the strip resumed a gesture
|
|
185
|
+
// nobody was making, and the directional cursor stayed on.
|
|
186
|
+
drag.current = null;
|
|
187
|
+
draggedRef.current = false;
|
|
188
|
+
setGesturing(false);
|
|
189
|
+
if (wheelIdle.current !== null) {
|
|
190
|
+
clearTimeout(wheelIdle.current);
|
|
191
|
+
wheelIdle.current = null;
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const onWheel = (e) => {
|
|
196
|
+
const s = specRef.current;
|
|
197
|
+
if (!s.wheel)
|
|
198
|
+
return;
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
// The wheel's own axis is irrelevant here: the strip *is* the axis, so a
|
|
201
|
+
// notch means "zoom me" whichever way the device reports it. `deltaY` is
|
|
202
|
+
// what a mouse wheel and a two-finger scroll both produce.
|
|
203
|
+
zoom(Math.exp(e.deltaY * WHEEL_SENSITIVITY), local(e));
|
|
204
|
+
// A wheel notch has no press to bracket, so the cursor is shown for a beat
|
|
205
|
+
// and re-armed by each further notch — a continuous scroll reads as one
|
|
206
|
+
// gesture rather than flickering per notch.
|
|
207
|
+
setGesturing(true);
|
|
208
|
+
if (wheelIdle.current !== null)
|
|
209
|
+
clearTimeout(wheelIdle.current);
|
|
210
|
+
wheelIdle.current = setTimeout(() => setGesturing(false), WHEEL_CURSOR_MS);
|
|
211
|
+
};
|
|
212
|
+
onWheelRef.current = onWheel;
|
|
213
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
214
|
+
}, []);
|
|
215
|
+
// Nothing to detach on unmount beyond the pending cursor timer: React calls the
|
|
216
|
+
// ref callback with `null` first, which releases the listener above.
|
|
217
|
+
useLayoutEffect(() => () => {
|
|
218
|
+
if (wheelIdle.current !== null)
|
|
219
|
+
clearTimeout(wheelIdle.current);
|
|
220
|
+
}, []);
|
|
221
|
+
const consumeDrag = useCallback(() => {
|
|
222
|
+
const was = draggedRef.current;
|
|
223
|
+
draggedRef.current = false;
|
|
224
|
+
return was;
|
|
225
|
+
}, []);
|
|
226
|
+
if (spec.drag === 'none' && !spec.wheel) {
|
|
227
|
+
return { ref: setRef, props: {}, style: {}, consumeDrag };
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
ref: setRef,
|
|
231
|
+
props: {
|
|
232
|
+
...(spec.drag === 'none'
|
|
233
|
+
? {}
|
|
234
|
+
: {
|
|
235
|
+
onPointerDown,
|
|
236
|
+
onPointerMove,
|
|
237
|
+
onPointerUp: endDrag,
|
|
238
|
+
onPointerCancel: endDrag,
|
|
239
|
+
}),
|
|
240
|
+
onDoubleClick,
|
|
241
|
+
},
|
|
242
|
+
style: {
|
|
243
|
+
// Arrow at rest (see `gesturing`); while moving, name the direction the
|
|
244
|
+
// gesture works in — up/down over a y gutter, left/right over the x strip.
|
|
245
|
+
...(gesturing
|
|
246
|
+
? { cursor: spec.axis === 'x' ? 'ew-resize' : 'ns-resize' }
|
|
247
|
+
: {}),
|
|
248
|
+
// A drag must not start a text selection of the tick labels.
|
|
249
|
+
userSelect: 'none',
|
|
250
|
+
// The strip owns the wheel where it takes it, so a two-finger scroll
|
|
251
|
+
// zooms rather than scrolling the page.
|
|
252
|
+
...(spec.wheel ? { touchAction: 'none' } : {}),
|
|
253
|
+
},
|
|
254
|
+
consumeDrag,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
//# 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.
|
|
3
|
+
"version": "0.64.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.
|
|
43
|
-
"pond-ts": "^0.
|
|
42
|
+
"@pond-ts/react": "^0.64.0",
|
|
43
|
+
"pond-ts": "^0.64.0",
|
|
44
44
|
"react": "^18.0.0 || ^19.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|