react-native-livechart 3.11.0 → 4.0.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.
Files changed (138) hide show
  1. package/dist/components/BadgeOverlay.d.ts +9 -1
  2. package/dist/components/BadgeOverlay.d.ts.map +1 -1
  3. package/dist/components/ChartOverlayLayer.d.ts +19 -0
  4. package/dist/components/ChartOverlayLayer.d.ts.map +1 -0
  5. package/dist/components/CustomMarkerOverlay.d.ts +18 -6
  6. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomReferenceLineOverlay.d.ts +36 -0
  8. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -0
  9. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  10. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  11. package/dist/components/DotOverlay.d.ts +7 -1
  12. package/dist/components/DotOverlay.d.ts.map +1 -1
  13. package/dist/components/LiveChart.d.ts.map +1 -1
  14. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  15. package/dist/components/MarkerOverlay.d.ts +8 -3
  16. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  17. package/dist/components/MultiSeriesDots.d.ts +4 -1
  18. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  19. package/dist/components/ReferenceLineGroupOverlay.d.ts +27 -0
  20. package/dist/components/ReferenceLineGroupOverlay.d.ts.map +1 -0
  21. package/dist/components/ReferenceLineOverlay.d.ts +25 -2
  22. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  23. package/dist/components/XAxisOverlay.d.ts +7 -1
  24. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  25. package/dist/components/YAxisOverlay.d.ts +12 -1
  26. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  27. package/dist/constants.d.ts +7 -0
  28. package/dist/constants.d.ts.map +1 -1
  29. package/dist/core/liveChartEngineTick.d.ts +29 -0
  30. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  31. package/dist/core/liveChartSeriesEngineTick.d.ts +18 -0
  32. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  33. package/dist/core/resolveConfig.d.ts +56 -2
  34. package/dist/core/resolveConfig.d.ts.map +1 -1
  35. package/dist/core/useLiveChartEngine.d.ts +58 -7
  36. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  37. package/dist/core/useLiveChartSeriesEngine.d.ts +8 -2
  38. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  39. package/dist/draw/grid.d.ts.map +1 -1
  40. package/dist/draw/markerAtlas.d.ts +20 -2
  41. package/dist/draw/markerAtlas.d.ts.map +1 -1
  42. package/dist/draw/volume.d.ts +25 -0
  43. package/dist/draw/volume.d.ts.map +1 -0
  44. package/dist/hooks/crosshairShared.d.ts +12 -6
  45. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  46. package/dist/hooks/overlayScale.d.ts +22 -0
  47. package/dist/hooks/overlayScale.d.ts.map +1 -0
  48. package/dist/hooks/resolveChartLayout.d.ts +9 -0
  49. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  50. package/dist/hooks/useBadge.d.ts +22 -1
  51. package/dist/hooks/useBadge.d.ts.map +1 -1
  52. package/dist/hooks/useCandlePaths.d.ts +6 -0
  53. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  54. package/dist/hooks/useChartOverlayContext.d.ts +45 -0
  55. package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
  56. package/dist/hooks/useChartPaths.d.ts +8 -1
  57. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  58. package/dist/hooks/useCrosshair.d.ts +9 -2
  59. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  60. package/dist/hooks/useLiveDot.d.ts +6 -1
  61. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  62. package/dist/hooks/useMarkers.d.ts +12 -4
  63. package/dist/hooks/useMarkers.d.ts.map +1 -1
  64. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  65. package/dist/hooks/usePanScroll.d.ts +82 -0
  66. package/dist/hooks/usePanScroll.d.ts.map +1 -0
  67. package/dist/hooks/usePinchZoom.d.ts +74 -0
  68. package/dist/hooks/usePinchZoom.d.ts.map +1 -0
  69. package/dist/hooks/useReferenceDrag.d.ts +24 -0
  70. package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
  71. package/dist/hooks/useReferenceLine.d.ts +23 -4
  72. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  73. package/dist/hooks/useReferenceLinePress.d.ts +5 -1
  74. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
  75. package/dist/hooks/useVisibleRange.d.ts +39 -0
  76. package/dist/hooks/useVisibleRange.d.ts.map +1 -0
  77. package/dist/index.d.ts +2 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/math/markerCluster.d.ts +47 -0
  80. package/dist/math/markerCluster.d.ts.map +1 -0
  81. package/dist/math/markers.d.ts +15 -0
  82. package/dist/math/markers.d.ts.map +1 -1
  83. package/dist/math/referenceDrag.d.ts +24 -0
  84. package/dist/math/referenceDrag.d.ts.map +1 -0
  85. package/dist/math/referenceGroup.d.ts +31 -0
  86. package/dist/math/referenceGroup.d.ts.map +1 -0
  87. package/dist/math/referenceLines.d.ts +33 -7
  88. package/dist/math/referenceLines.d.ts.map +1 -1
  89. package/dist/types.d.ts +512 -21
  90. package/dist/types.d.ts.map +1 -1
  91. package/package.json +1 -1
  92. package/src/components/BadgeOverlay.tsx +26 -1
  93. package/src/components/ChartOverlayLayer.tsx +32 -0
  94. package/src/components/CustomMarkerOverlay.tsx +153 -53
  95. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  96. package/src/components/CustomTooltipOverlay.tsx +27 -7
  97. package/src/components/DotOverlay.tsx +9 -0
  98. package/src/components/LiveChart.tsx +561 -34
  99. package/src/components/LiveChartSeries.tsx +110 -5
  100. package/src/components/MarkerOverlay.tsx +100 -6
  101. package/src/components/MultiSeriesDots.tsx +12 -3
  102. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  103. package/src/components/ReferenceLineOverlay.tsx +105 -32
  104. package/src/components/XAxisOverlay.tsx +9 -2
  105. package/src/components/YAxisOverlay.tsx +48 -26
  106. package/src/constants.ts +10 -0
  107. package/src/core/liveChartEngineTick.ts +81 -8
  108. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  109. package/src/core/resolveConfig.ts +122 -1
  110. package/src/core/useLiveChartEngine.ts +103 -10
  111. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  112. package/src/draw/grid.ts +13 -1
  113. package/src/draw/markerAtlas.ts +117 -3
  114. package/src/draw/volume.ts +190 -0
  115. package/src/hooks/crosshairShared.ts +37 -9
  116. package/src/hooks/overlayScale.ts +59 -0
  117. package/src/hooks/resolveChartLayout.ts +33 -0
  118. package/src/hooks/useBadge.ts +89 -21
  119. package/src/hooks/useCandlePaths.ts +104 -7
  120. package/src/hooks/useChartOverlayContext.ts +93 -0
  121. package/src/hooks/useChartPaths.ts +14 -1
  122. package/src/hooks/useCrosshair.ts +29 -2
  123. package/src/hooks/useLiveDot.ts +16 -5
  124. package/src/hooks/useMarkers.ts +43 -11
  125. package/src/hooks/useMultiSeriesLinePaths.ts +2 -1
  126. package/src/hooks/usePanScroll.ts +253 -0
  127. package/src/hooks/usePinchZoom.ts +189 -0
  128. package/src/hooks/useReferenceDrag.ts +254 -0
  129. package/src/hooks/useReferenceLine.ts +56 -9
  130. package/src/hooks/useReferenceLinePress.ts +16 -2
  131. package/src/hooks/useVisibleRange.ts +119 -0
  132. package/src/index.ts +17 -1
  133. package/src/math/markerCluster.ts +214 -0
  134. package/src/math/markers.ts +54 -9
  135. package/src/math/referenceDrag.ts +66 -0
  136. package/src/math/referenceGroup.ts +65 -0
  137. package/src/math/referenceLines.ts +76 -19
  138. package/src/types.ts +532 -21
@@ -0,0 +1,253 @@
1
+ import { Gesture } from "react-native-gesture-handler";
2
+ import {
3
+ cancelAnimation,
4
+ useSharedValue,
5
+ withDecay,
6
+ type SharedValue,
7
+ } from "react-native-reanimated";
8
+
9
+ import type { ChartPadding } from "../draw/line";
10
+
11
+ /** Minimum height (px) of the bottom "grab the time ruler" band in axis-drag mode. */
12
+ export const AXIS_GRAB_MIN_PX = 44;
13
+ /** Horizontal travel (px) before a one-finger drag commits to scrolling vs. falling through. */
14
+ const AXIS_ACTIVATE_PX = 6;
15
+ /** Vertical travel (px) that fails the one-finger scroll so a parent vertical scroll wins. */
16
+ const HOLD_SCRUB_FAIL_Y_PX = 12;
17
+
18
+ /** Which gesture activates a time-scroll. */
19
+ export type PanScrollGestureMode = "holdToScrub" | "axisDrag";
20
+
21
+ /** Engine SharedValues the pan-scroll gesture reads/writes (subset of the engine state). */
22
+ export interface PanScrollEngineRefs {
23
+ /** Absolute right-edge time to freeze at, or `null` to follow the live edge. */
24
+ viewEnd: SharedValue<number | null>;
25
+ /** Right-edge time the engine would use if following live (advances each frame). */
26
+ liveEdge: SharedValue<number>;
27
+ /** Animating visible window width in seconds. */
28
+ displayWindow: SharedValue<number>;
29
+ /** Canvas width in px. */
30
+ canvasWidth: SharedValue<number>;
31
+ /** Canvas height in px (for the axis-drag band hit-test). */
32
+ canvasHeight: SharedValue<number>;
33
+ }
34
+
35
+ export interface UsePanScrollOptions {
36
+ engine: PanScrollEngineRefs;
37
+ padding: ChartPadding;
38
+ /**
39
+ * Earliest selectable time (unix seconds) — typically the first data point /
40
+ * candle. Clamps how far back the window can pan. When there's no scrollable
41
+ * history this should equal (or exceed) the live edge so panning is a no-op.
42
+ */
43
+ minTime: SharedValue<number>;
44
+ /** Master switch. When false the gesture is disabled and the chart follows live. */
45
+ enabled: boolean;
46
+ /**
47
+ * Activation model:
48
+ * - `"holdToScrub"` (default): a one-finger drag anywhere scrolls; scrub moves
49
+ * to press-and-hold (Rainbow-style). The caller must give the scrub gesture
50
+ * a long-press delay so a quick drag falls through to this one.
51
+ * - `"axisDrag"`: a one-finger drag starting in the bottom X-axis band ("grab
52
+ * the time ruler"); one-finger plot scrub untouched.
53
+ */
54
+ mode?: PanScrollGestureMode;
55
+ /**
56
+ * Worklet fired when a scroll drag activates — e.g. to clear the crosshair so
57
+ * a stray scrub doesn't linger behind the pan.
58
+ */
59
+ onScrollStart?: () => void;
60
+ }
61
+
62
+ /**
63
+ * Smallest valid right-edge time: keeps the window's left edge
64
+ * (`rightEdge - window`) from passing `minTime`, and never exceeds the live edge
65
+ * so the `[lo, liveEdge]` clamp range stays valid when history is short.
66
+ */
67
+ export function panLowerBound(
68
+ minTime: number,
69
+ windowSecs: number,
70
+ liveEdge: number,
71
+ ): number {
72
+ "worklet";
73
+ return Math.min(minTime + windowSecs, liveEdge);
74
+ }
75
+
76
+ /**
77
+ * Next right-edge time after dragging `changeX` px (drag right ⇒ reveal earlier
78
+ * time ⇒ smaller right edge), clamped to `[lo, liveEdge]`. Returns `null` once
79
+ * the drag reaches the live edge — the signal to resume following.
80
+ */
81
+ export function nextViewEnd(
82
+ cur: number,
83
+ changeX: number,
84
+ chartW: number,
85
+ windowSecs: number,
86
+ liveEdge: number,
87
+ lo: number,
88
+ ): number | null {
89
+ "worklet";
90
+ let next = cur - (changeX / chartW) * windowSecs;
91
+ if (next < lo) next = lo;
92
+ if (next > liveEdge) next = liveEdge;
93
+ return next >= liveEdge ? null : next;
94
+ }
95
+
96
+ /** Pan velocity (px/s) → time-seconds/s for `withDecay` (drag right ⇒ earlier). */
97
+ export function flingVelocity(
98
+ velocityX: number,
99
+ chartW: number,
100
+ windowSecs: number,
101
+ ): number {
102
+ "worklet";
103
+ return -(velocityX / chartW) * windowSecs;
104
+ }
105
+
106
+ /**
107
+ * Top y (px) of the axis-drag grab band — a touch at or below this row starts a
108
+ * scroll. The band is the bottom padding (where the time labels sit), widened to
109
+ * a comfortable touch target.
110
+ */
111
+ export function axisBandTop(canvasHeight: number, padBottom: number): number {
112
+ "worklet";
113
+ return canvasHeight - Math.max(padBottom, AXIS_GRAB_MIN_PX);
114
+ }
115
+
116
+ /**
117
+ * Horizontal pan that scrolls the chart back through time. Pick the activation
118
+ * with `mode`:
119
+ * - `"holdToScrub"` — a one-finger drag anywhere (activates on horizontal
120
+ * travel; vertical falls through). Scrub must be press-and-hold so a quick
121
+ * drag races past it (compose with `Gesture.Race`).
122
+ * - `"axisDrag"` — a one-finger drag starting in the bottom X-axis band, gated
123
+ * via `manualActivation` (compose with `Gesture.Exclusive`, this gesture
124
+ * first: outside the band it fails instantly so scrub runs).
125
+ *
126
+ * Writes `engine.viewEnd`: a number freezes the window at that absolute right
127
+ * edge; `null` means "follow live". Dragging (or flinging) back to the live edge
128
+ * resumes following.
129
+ */
130
+ export function usePanScroll({
131
+ engine,
132
+ padding,
133
+ minTime,
134
+ enabled,
135
+ mode = "holdToScrub",
136
+ onScrollStart,
137
+ }: UsePanScrollOptions): ReturnType<typeof Gesture.Pan> {
138
+ const { viewEnd, liveEdge, displayWindow, canvasWidth, canvasHeight } = engine;
139
+ const padLeft = padding.left;
140
+ const padRight = padding.right;
141
+ const padBottom = padding.bottom;
142
+
143
+ // Axis-drag activation tracking (manualActivation). Created unconditionally so
144
+ // the hook order is stable; unused in hold-to-scrub mode.
145
+ const startX = useSharedValue(0);
146
+ const startY = useSharedValue(0);
147
+ const armed = useSharedValue(false);
148
+
149
+ const onStart =
150
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
151
+ () => {
152
+ "worklet";
153
+ cancelAnimation(viewEnd);
154
+ // Anchor at the current right edge so the first delta is relative to where
155
+ // the window sits (the live edge when we were following).
156
+ if (viewEnd.get() == null) viewEnd.set(liveEdge.get());
157
+ onScrollStart?.();
158
+ };
159
+
160
+ const onChange =
161
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
162
+ (e: { changeX: number }) => {
163
+ "worklet";
164
+ const win = displayWindow.get();
165
+ const chartW = canvasWidth.get() - padLeft - padRight;
166
+ if (chartW <= 0) return;
167
+ const edge = liveEdge.get();
168
+ const cur = viewEnd.get() ?? edge;
169
+ const lo = panLowerBound(minTime.get(), win, edge);
170
+ viewEnd.set(nextViewEnd(cur, e.changeX, chartW, win, edge, lo));
171
+ };
172
+
173
+ const onEnd =
174
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
175
+ (e: { velocityX: number }) => {
176
+ "worklet";
177
+ if (viewEnd.get() == null) return;
178
+ const win = displayWindow.get();
179
+ const chartW = canvasWidth.get() - padLeft - padRight;
180
+ if (chartW <= 0) return;
181
+ const edge = liveEdge.get();
182
+ const lo = panLowerBound(minTime.get(), win, edge);
183
+ const velocity = flingVelocity(e.velocityX, chartW, win);
184
+ cancelAnimation(viewEnd);
185
+ viewEnd.set(
186
+ withDecay({ velocity, clamp: [lo, edge] }, (finished) => {
187
+ "worklet";
188
+ // Landed on the live-edge clamp → resume following; stopped short →
189
+ // stay frozen where inertia died.
190
+ if (finished && (viewEnd.get() ?? edge) >= edge - 1e-3) {
191
+ viewEnd.set(null);
192
+ }
193
+ }),
194
+ );
195
+ };
196
+
197
+ if (mode === "axisDrag") {
198
+ return Gesture.Pan()
199
+ .enabled(enabled)
200
+ .maxPointers(1)
201
+ // Position-gate a one-finger pan: only a drag that starts in the bottom
202
+ // axis band scrolls; everything else fails fast so scrub/parent gestures run.
203
+ .manualActivation(true)
204
+ .onTouchesDown(
205
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
206
+ (e, manager) => {
207
+ "worklet";
208
+ const t = e.changedTouches[0];
209
+ if (!t) return;
210
+ if (t.y < axisBandTop(canvasHeight.get(), padBottom)) {
211
+ armed.set(false);
212
+ manager.fail();
213
+ return;
214
+ }
215
+ armed.set(true);
216
+ startX.set(t.x);
217
+ startY.set(t.y);
218
+ },
219
+ )
220
+ .onTouchesMove(
221
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
222
+ (e, manager) => {
223
+ "worklet";
224
+ if (!armed.get()) return;
225
+ const t = e.allTouches[0];
226
+ if (!t) return;
227
+ const dx = Math.abs(t.x - startX.get());
228
+ const dy = Math.abs(t.y - startY.get());
229
+ if (dx > AXIS_ACTIVATE_PX && dx >= dy) {
230
+ manager.activate(); // horizontal intent → take the gesture
231
+ } else if (dy > AXIS_ACTIVATE_PX) {
232
+ manager.fail(); // vertical intent → release to parent/scroll
233
+ }
234
+ },
235
+ )
236
+ .onStart(onStart)
237
+ .onChange(onChange)
238
+ .onEnd(onEnd);
239
+ }
240
+
241
+ // holdToScrub (default): a one-finger drag anywhere scrolls. Activate on
242
+ // horizontal travel so a quick one-finger drag scrolls; a still press-hold
243
+ // crosses no offset and falls through to the scrub gesture (which owns the
244
+ // long-press). Vertical travel fails it so a parent vertical scroll wins.
245
+ return Gesture.Pan()
246
+ .enabled(enabled)
247
+ .maxPointers(1)
248
+ .activeOffsetX([-AXIS_ACTIVATE_PX, AXIS_ACTIVATE_PX])
249
+ .failOffsetY([-HOLD_SCRUB_FAIL_Y_PX, HOLD_SCRUB_FAIL_Y_PX])
250
+ .onStart(onStart)
251
+ .onChange(onChange)
252
+ .onEnd(onEnd);
253
+ }
@@ -0,0 +1,189 @@
1
+ import { Gesture } from "react-native-gesture-handler";
2
+ import {
3
+ cancelAnimation,
4
+ useSharedValue,
5
+ type SharedValue,
6
+ } from "react-native-reanimated";
7
+
8
+ import type { ChartPadding } from "../draw/line";
9
+ import { panLowerBound } from "./usePanScroll";
10
+
11
+ /** Engine SharedValues the pinch-zoom gesture reads/writes. */
12
+ export interface PinchZoomEngineRefs {
13
+ /** Visible-window width override in seconds, or `null` to follow `timeWindow`. */
14
+ viewWindow: SharedValue<number | null>;
15
+ /** Absolute right-edge time, or `null` to follow the live edge (shared with pan). */
16
+ viewEnd: SharedValue<number | null>;
17
+ /** Right-edge time the engine would use if following live (advances each frame). */
18
+ liveEdge: SharedValue<number>;
19
+ /**
20
+ * Animating visible window width in seconds. Written directly during a pinch so
21
+ * the displayed window tracks the fingers 1:1 (the frame-loop lerp toward
22
+ * `timeWindow` would otherwise lag a fast pinch and the focal anchor would drift).
23
+ */
24
+ displayWindow: SharedValue<number>;
25
+ /** Canvas width in px. */
26
+ canvasWidth: SharedValue<number>;
27
+ }
28
+
29
+ export interface UsePinchZoomOptions {
30
+ engine: PinchZoomEngineRefs;
31
+ padding: ChartPadding;
32
+ /**
33
+ * Earliest selectable time (unix seconds) — typically the first data point /
34
+ * candle. Caps how far the (now wider) window can pan back when zooming out.
35
+ */
36
+ minTime: SharedValue<number>;
37
+ /** The configured `timeWindow` prop (seconds) — the anchor for the zoom bounds. */
38
+ timeWindow: number;
39
+ /** Master switch. When false the gesture is disabled. */
40
+ enabled: boolean;
41
+ /** Tightest window (max zoom-in), seconds. Default `timeWindow / 8`. */
42
+ minTimeWindow?: number;
43
+ /** Widest window (max zoom-out), seconds. Default: the full data span, ≥ `timeWindow`. */
44
+ maxTimeWindow?: number;
45
+ /** Worklet fired when a pinch activates — e.g. to clear a lingering crosshair. */
46
+ onZoomStart?: () => void;
47
+ }
48
+
49
+ /** Clamp a window width to `[minWin, maxWin]`. */
50
+ export function clampWindow(
51
+ win: number,
52
+ minWin: number,
53
+ maxWin: number,
54
+ ): number {
55
+ "worklet";
56
+ if (win < minWin) return minWin;
57
+ if (win > maxWin) return maxWin;
58
+ return win;
59
+ }
60
+
61
+ /**
62
+ * Resolve the zoom bounds for a gesture. `maxWin` defaults to the data span but
63
+ * never goes below the configured window (so you can always zoom back out to the
64
+ * starting view); `minWin` defaults to an 8× zoom-in of the configured window.
65
+ * Guards against an inverted range (min ≤ max).
66
+ */
67
+ export function zoomWindowBounds(
68
+ configWindow: number,
69
+ span: number,
70
+ minCfg: number | undefined,
71
+ maxCfg: number | undefined,
72
+ ): [number, number] {
73
+ "worklet";
74
+ const maxWin = Math.max(maxCfg ?? span, configWindow);
75
+ let minWin = minCfg ?? configWindow / 8;
76
+ if (minWin > maxWin) minWin = maxWin;
77
+ return [minWin, maxWin];
78
+ }
79
+
80
+ /**
81
+ * Absolute time under a pixel x, given the visible window. Inverse of the chart's
82
+ * time→x projection: `x = chartLeft + (t - winStart)/window * chartW`.
83
+ */
84
+ export function focalTime(
85
+ focalX: number,
86
+ chartLeft: number,
87
+ chartW: number,
88
+ winStart: number,
89
+ windowSecs: number,
90
+ ): number {
91
+ "worklet";
92
+ return winStart + ((focalX - chartLeft) / chartW) * windowSecs;
93
+ }
94
+
95
+ /**
96
+ * The new right-edge time that keeps `focalT` pinned under `focalX` after the
97
+ * window width changes to `newWindow` — i.e. zoom around the focal point. Derived
98
+ * from the projection above by solving for the right edge (`winStart + window`):
99
+ * the right edge sits `(fraction of the window to the right of the focal)` past
100
+ * `focalT`.
101
+ */
102
+ export function zoomViewEnd(
103
+ focalT: number,
104
+ focalX: number,
105
+ chartLeft: number,
106
+ chartW: number,
107
+ newWindow: number,
108
+ ): number {
109
+ "worklet";
110
+ const rightFraction = (chartLeft + chartW - focalX) / chartW;
111
+ return focalT + rightFraction * newWindow;
112
+ }
113
+
114
+ /**
115
+ * Pinch-to-zoom the visible time window, anchored at the focal point between the
116
+ * two fingers (the time under your fingers stays put). Writes `engine.viewWindow`
117
+ * (the window width) and `engine.viewEnd` (the right edge, so the focal anchor
118
+ * holds) — the symmetric counterpart of {@link usePanScroll}, which only moves the
119
+ * right edge. Pinch is two-finger, so it composes with the one-finger pan/scrub
120
+ * via `Gesture.Simultaneous`.
121
+ *
122
+ * Reaching the live edge resumes following (`viewEnd → null`); the zoom level
123
+ * (`viewWindow`) is independent of scroll position and persists.
124
+ */
125
+ export function usePinchZoom({
126
+ engine,
127
+ padding,
128
+ minTime,
129
+ timeWindow,
130
+ enabled,
131
+ minTimeWindow,
132
+ maxTimeWindow,
133
+ onZoomStart,
134
+ }: UsePinchZoomOptions): ReturnType<typeof Gesture.Pinch> {
135
+ const { viewWindow, viewEnd, liveEdge, displayWindow, canvasWidth } = engine;
136
+ const padLeft = padding.left;
137
+ const padRight = padding.right;
138
+
139
+ // Snapshot at gesture start so the cumulative `scale`/`focalX` map from a fixed
140
+ // origin (no compounding drift while pinching).
141
+ const startWindow = useSharedValue(timeWindow);
142
+ const startViewEnd = useSharedValue(0);
143
+
144
+ const onStart =
145
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
146
+ () => {
147
+ "worklet";
148
+ cancelAnimation(viewEnd);
149
+ cancelAnimation(viewWindow);
150
+ const edge = liveEdge.get();
151
+ startWindow.set(viewWindow.get() ?? timeWindow);
152
+ startViewEnd.set(viewEnd.get() ?? edge);
153
+ onZoomStart?.();
154
+ };
155
+
156
+ const onChange =
157
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
158
+ (e: { scale: number; focalX: number }) => {
159
+ "worklet";
160
+ const chartW = canvasWidth.get() - padLeft - padRight;
161
+ if (chartW <= 0 || e.scale <= 0) return;
162
+ const edge = liveEdge.get();
163
+ const sWin = startWindow.get();
164
+ const sEnd = startViewEnd.get();
165
+ const span = edge - minTime.get();
166
+ const [minWin, maxWin] = zoomWindowBounds(
167
+ timeWindow,
168
+ span,
169
+ minTimeWindow,
170
+ maxTimeWindow,
171
+ );
172
+ // Pinch out (scale > 1) ⇒ narrower window ⇒ zoom in.
173
+ const newWin = clampWindow(sWin / e.scale, minWin, maxWin);
174
+ // Time under the fingers, mapped through the START window so the anchor
175
+ // doesn't compound; the right edge then keeps it under the (live) focal.
176
+ const focalT = focalTime(e.focalX, padLeft, chartW, sEnd - sWin, sWin);
177
+ let newEnd = zoomViewEnd(focalT, e.focalX, padLeft, chartW, newWin);
178
+ const lo = panLowerBound(minTime.get(), newWin, edge);
179
+ if (newEnd < lo) newEnd = lo;
180
+ if (newEnd > edge) newEnd = edge;
181
+ viewWindow.set(newWin);
182
+ // Track the displayed window 1:1 (bypass the frame-loop lerp lag) so the
183
+ // focal anchor is pixel-accurate during the gesture.
184
+ displayWindow.set(newWin);
185
+ viewEnd.set(newEnd >= edge ? null : newEnd);
186
+ };
187
+
188
+ return Gesture.Pinch().enabled(enabled).onStart(onStart).onChange(onChange);
189
+ }
@@ -0,0 +1,254 @@
1
+ import { Gesture } from "react-native-gesture-handler";
2
+ import {
3
+ useAnimatedReaction,
4
+ useDerivedValue,
5
+ useSharedValue,
6
+ type SharedValue,
7
+ } from "react-native-reanimated";
8
+ import { scheduleOnRN } from "react-native-worklets";
9
+
10
+ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
11
+ import type { ChartPadding } from "../draw/line";
12
+ import {
13
+ clampToBounds,
14
+ nearestDraggableIndex,
15
+ referenceValueOut,
16
+ } from "../math/referenceDrag";
17
+ import { referenceLineForm } from "../math/referenceLines";
18
+ import type { ReferenceLine } from "../types";
19
+ import {
20
+ computeScrubDotY,
21
+ computeValueAtY,
22
+ snapPrice,
23
+ } from "./crosshairShared";
24
+
25
+ /** Vertical reach (px) around a line within which a touch grabs it. */
26
+ const GRAB_SLOP = 14;
27
+ /** Travel (px) that resolves drag (vertical) vs. fall-through (horizontal) intent. */
28
+ const DRAG_ACTIVATE_PX = 4;
29
+
30
+ /** Stable empty array so the handle / out-state worklets stay referentially stable
31
+ * (and the onDragIn/Out reaction never fires) when the feature is unused. */
32
+ const EMPTY: never[] = [];
33
+
34
+ /**
35
+ * Builds the per-line **drag** gesture for draggable Form-A reference lines: grab a
36
+ * line near its value-Y and drag vertically to set a new value, with optional
37
+ * `snap` + `bounds` clamp. Mirrors the order-ticket reticle in {@link useCrosshair}
38
+ * (value↔Y via `computeValueAtY` / `computeScrubDotY`, frozen value re-projected
39
+ * each frame) but per line, writing into the shared `dragValues` array the layout
40
+ * and overlays read.
41
+ *
42
+ * The pan uses `manualActivation`: it grabs only when a touch starts within
43
+ * {@link GRAB_SLOP} of a draggable line **and** the motion is vertical — a
44
+ * horizontal drag (scrub) or a touch off every line fails fast so the chart's other
45
+ * gestures run (compose this ahead of them via `Gesture.Exclusive`).
46
+ *
47
+ * Also fires the per-line drag callbacks: `onChange` (de-duped during drag),
48
+ * `onCommit` (on release), and `onDragIn` / `onDragOut` (value crossing the visible
49
+ * range or a `bounds`, from a drag or the axis rescaling — edge-detected each frame).
50
+ */
51
+ export function useReferenceDrag(
52
+ engine: ChartEngineLayout,
53
+ padding: ChartPadding,
54
+ lines: ReferenceLine[],
55
+ dragValues: SharedValue<number[]>,
56
+ dragActive: SharedValue<boolean[]>,
57
+ enabled: boolean,
58
+ ): ReturnType<typeof Gesture.Pan> {
59
+ const anyDraggable =
60
+ enabled &&
61
+ lines.some((l) => l.draggable && referenceLineForm(l) === "line");
62
+ const anyDragInOut = lines.some(
63
+ (l) =>
64
+ referenceLineForm(l) === "line" &&
65
+ (l.onDragIn != null || l.onDragOut != null),
66
+ );
67
+
68
+ // Per-line handle Y (canvas px), index-aligned with `lines`; -1 when the line
69
+ // isn't draggable or the canvas isn't laid out. Off-screen lines pin to the
70
+ // nearest plot edge so they stay grabbable. Recomputed each frame (UI thread).
71
+ /* istanbul ignore next -- worklet runs on the UI thread, not in Jest */
72
+ const handleYs = useDerivedValue<number[]>(() => {
73
+ if (!anyDraggable) return EMPTY;
74
+ const ch = engine.canvasHeight.get();
75
+ const dMin = engine.displayMin.get();
76
+ const dMax = engine.displayMax.get();
77
+ const top = padding.top;
78
+ const bottom = ch - padding.bottom;
79
+ const out: number[] = [];
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const l = lines[i];
82
+ if (
83
+ !l.draggable ||
84
+ referenceLineForm(l) !== "line" ||
85
+ l.value === undefined
86
+ ) {
87
+ out.push(-1);
88
+ continue;
89
+ }
90
+ const v = dragValues.get()[i] ?? l.value;
91
+ const y = computeScrubDotY(v, dMin, dMax, ch, top, padding.bottom);
92
+ out.push(y < 0 ? -1 : Math.min(bottom, Math.max(top, y)));
93
+ }
94
+ return out;
95
+ });
96
+
97
+ const dragIndex = useSharedValue(-1);
98
+ const startX = useSharedValue(0);
99
+ const startY = useSharedValue(0);
100
+ const activated = useSharedValue(false);
101
+ const lastChange = useSharedValue(0);
102
+
103
+ // JS-thread callback dispatch — closes over the latest `lines` each render.
104
+ /* istanbul ignore next -- runs via scheduleOnRN from the UI-thread gesture */
105
+ function emitChange(i: number, v: number) {
106
+ lines[i]?.onChange?.(v);
107
+ }
108
+ /* istanbul ignore next -- runs via scheduleOnRN from the UI-thread gesture */
109
+ function emitCommit(i: number, v: number) {
110
+ lines[i]?.onCommit?.(v);
111
+ }
112
+ /* istanbul ignore next -- runs via scheduleOnRN from the UI-thread reaction */
113
+ function emitDragOut(i: number, v: number) {
114
+ lines[i]?.onDragOut?.(v);
115
+ }
116
+ /* istanbul ignore next -- runs via scheduleOnRN from the UI-thread reaction */
117
+ function emitDragIn(i: number, v: number) {
118
+ lines[i]?.onDragIn?.(v);
119
+ }
120
+
121
+ // onDragIn / onDragOut — edge-detect each line's "out of the watched interval"
122
+ // state (from a drag or the axis rescaling under a fixed value).
123
+ useAnimatedReaction(
124
+ /* istanbul ignore next -- worklet runs on the UI thread, not in Jest */
125
+ () => {
126
+ if (!anyDragInOut) return EMPTY as boolean[];
127
+ const dMin = engine.displayMin.get();
128
+ const dMax = engine.displayMax.get();
129
+ const out: boolean[] = [];
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const l = lines[i];
132
+ if (
133
+ referenceLineForm(l) !== "line" ||
134
+ l.value === undefined ||
135
+ (l.onDragIn == null && l.onDragOut == null)
136
+ ) {
137
+ out.push(false);
138
+ continue;
139
+ }
140
+ const v = dragValues.get()[i] ?? l.value;
141
+ out.push(referenceValueOut(v, dMin, dMax, l.bounds));
142
+ }
143
+ return out;
144
+ },
145
+ /* istanbul ignore next -- worklet runs on the UI thread, not in Jest */
146
+ (curr, prev) => {
147
+ if (!prev || curr === prev) return;
148
+ for (let i = 0; i < curr.length; i++) {
149
+ if (prev[i] === undefined || curr[i] === prev[i]) continue;
150
+ const l = lines[i];
151
+ const v = dragValues.get()[i] ?? l.value ?? 0;
152
+ if (curr[i]) scheduleOnRN(emitDragOut, i, v);
153
+ else scheduleOnRN(emitDragIn, i, v);
154
+ }
155
+ },
156
+ [lines, anyDragInOut],
157
+ );
158
+
159
+ // ── Gesture callbacks (UI-thread worklets — excluded from Jest coverage) ──────
160
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
161
+ const onTouchesDown = (
162
+ e: { changedTouches: { x: number; y: number }[] },
163
+ manager: { fail: () => void },
164
+ ) => {
165
+ "worklet";
166
+ const t = e.changedTouches[0];
167
+ if (!t) return;
168
+ const i = nearestDraggableIndex(handleYs.get(), t.y, GRAB_SLOP);
169
+ if (i < 0) {
170
+ manager.fail();
171
+ return;
172
+ }
173
+ dragIndex.set(i);
174
+ startX.set(t.x);
175
+ startY.set(t.y);
176
+ };
177
+
178
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
179
+ const onTouchesMove = (
180
+ e: { allTouches: { x: number; y: number }[] },
181
+ manager: { activate: () => void; fail: () => void },
182
+ ) => {
183
+ "worklet";
184
+ if (dragIndex.get() < 0) return;
185
+ const t = e.allTouches[0];
186
+ if (!t) return;
187
+ const dx = Math.abs(t.x - startX.get());
188
+ const dy = Math.abs(t.y - startY.get());
189
+ if (dy > DRAG_ACTIVATE_PX && dy >= dx) manager.activate();
190
+ else if (dx > DRAG_ACTIVATE_PX) manager.fail();
191
+ };
192
+
193
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
194
+ const onStart = () => {
195
+ "worklet";
196
+ const i = dragIndex.get();
197
+ if (i < 0) return;
198
+ activated.set(true);
199
+ const arr = dragActive.get().slice();
200
+ arr[i] = true;
201
+ dragActive.set(arr);
202
+ lastChange.set(dragValues.get()[i] ?? lines[i].value ?? 0);
203
+ };
204
+
205
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
206
+ const onUpdate = (e: { y: number }) => {
207
+ "worklet";
208
+ const i = dragIndex.get();
209
+ if (i < 0) return;
210
+ const l = lines[i];
211
+ const raw = computeValueAtY(
212
+ e.y,
213
+ engine.displayMin.get(),
214
+ engine.displayMax.get(),
215
+ engine.canvasHeight.get(),
216
+ padding.top,
217
+ padding.bottom,
218
+ );
219
+ if (raw === null) return;
220
+ const v = clampToBounds(snapPrice(raw, l.snap), l.bounds);
221
+ const arr = dragValues.get().slice();
222
+ arr[i] = v;
223
+ dragValues.set(arr);
224
+ if (v !== lastChange.get()) {
225
+ lastChange.set(v);
226
+ if (l.onChange) scheduleOnRN(emitChange, i, v);
227
+ }
228
+ };
229
+
230
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
231
+ const onFinalize = () => {
232
+ "worklet";
233
+ const i = dragIndex.get();
234
+ if (i >= 0 && activated.get()) {
235
+ const v = dragValues.get()[i] ?? lines[i].value ?? 0;
236
+ const arr = dragActive.get().slice();
237
+ arr[i] = false;
238
+ dragActive.set(arr);
239
+ if (lines[i].onCommit) scheduleOnRN(emitCommit, i, v);
240
+ }
241
+ dragIndex.set(-1);
242
+ activated.set(false);
243
+ };
244
+
245
+ return Gesture.Pan()
246
+ .enabled(anyDraggable)
247
+ .maxPointers(1)
248
+ .manualActivation(true)
249
+ .onTouchesDown(onTouchesDown)
250
+ .onTouchesMove(onTouchesMove)
251
+ .onStart(onStart)
252
+ .onUpdate(onUpdate)
253
+ .onFinalize(onFinalize);
254
+ }