react-native-livechart 4.11.0 → 4.13.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 (82) hide show
  1. package/dist/components/CrosshairLine.d.ts +12 -2
  2. package/dist/components/CrosshairLine.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts +12 -2
  4. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  5. package/dist/components/CustomReferenceLineOverlay.d.ts +15 -10
  6. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  8. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  9. package/dist/components/DotOverlay.d.ts +3 -5
  10. package/dist/components/DotOverlay.d.ts.map +1 -1
  11. package/dist/components/LiveChart.d.ts.map +1 -1
  12. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  13. package/dist/components/PerSeriesTooltipOverlay.d.ts +21 -0
  14. package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
  15. package/dist/components/ReferenceLineOverlay.d.ts +27 -6
  16. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  17. package/dist/components/YAxisOverlay.d.ts +8 -2
  18. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  19. package/dist/core/liveIndicatorVisibility.d.ts +9 -0
  20. package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
  21. package/dist/core/resolveConfig.d.ts +45 -0
  22. package/dist/core/resolveConfig.d.ts.map +1 -1
  23. package/dist/draw/grid.d.ts +13 -0
  24. package/dist/draw/grid.d.ts.map +1 -1
  25. package/dist/hooks/crosshairSeries.d.ts +10 -2
  26. package/dist/hooks/crosshairSeries.d.ts.map +1 -1
  27. package/dist/hooks/crosshairShared.d.ts +55 -5
  28. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  29. package/dist/hooks/delayedPanGuard.d.ts +66 -8
  30. package/dist/hooks/delayedPanGuard.d.ts.map +1 -1
  31. package/dist/hooks/useChartPaths.d.ts +5 -9
  32. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  33. package/dist/hooks/useCrosshair.d.ts +13 -1
  34. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  35. package/dist/hooks/useCrosshairSeries.d.ts +27 -3
  36. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  37. package/dist/hooks/useCrosshairVisibleOpacity.d.ts +8 -0
  38. package/dist/hooks/useCrosshairVisibleOpacity.d.ts.map +1 -0
  39. package/dist/hooks/useMomentum.d.ts +1 -1
  40. package/dist/hooks/useMomentum.d.ts.map +1 -1
  41. package/dist/hooks/usePanScroll.d.ts +15 -1
  42. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  43. package/dist/hooks/useReferenceLine.d.ts +10 -1
  44. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/math/lerp.d.ts.map +1 -1
  48. package/dist/math/momentum.d.ts +1 -1
  49. package/dist/math/momentum.d.ts.map +1 -1
  50. package/dist/math/referenceLines.d.ts +7 -0
  51. package/dist/math/referenceLines.d.ts.map +1 -1
  52. package/dist/types.d.ts +207 -18
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/components/CrosshairLine.tsx +39 -8
  56. package/src/components/CrosshairOverlay.tsx +49 -15
  57. package/src/components/CustomReferenceLineOverlay.tsx +42 -13
  58. package/src/components/CustomTooltipOverlay.tsx +15 -3
  59. package/src/components/DotOverlay.tsx +24 -8
  60. package/src/components/LiveChart.tsx +182 -39
  61. package/src/components/LiveChartSeries.tsx +195 -16
  62. package/src/components/PerSeriesTooltipOverlay.tsx +290 -0
  63. package/src/components/ReferenceLineOverlay.tsx +288 -141
  64. package/src/components/YAxisOverlay.tsx +39 -10
  65. package/src/core/liveIndicatorVisibility.ts +25 -0
  66. package/src/core/resolveConfig.ts +115 -0
  67. package/src/draw/grid.ts +33 -0
  68. package/src/hooks/crosshairSeries.ts +262 -33
  69. package/src/hooks/crosshairShared.ts +117 -6
  70. package/src/hooks/delayedPanGuard.ts +111 -6
  71. package/src/hooks/useChartPaths.ts +25 -15
  72. package/src/hooks/useCrosshair.ts +103 -32
  73. package/src/hooks/useCrosshairSeries.ts +126 -11
  74. package/src/hooks/useCrosshairVisibleOpacity.ts +57 -0
  75. package/src/hooks/useMomentum.ts +8 -1
  76. package/src/hooks/usePanScroll.ts +46 -5
  77. package/src/hooks/useReferenceLine.ts +26 -1
  78. package/src/index.ts +1 -0
  79. package/src/math/lerp.ts +7 -0
  80. package/src/math/momentum.ts +22 -5
  81. package/src/math/referenceLines.ts +38 -0
  82. package/src/types.ts +215 -18
@@ -1,6 +1,6 @@
1
1
  import { type SkFont } from "@shopify/react-native-skia";
2
2
  import { type Gesture } from "react-native-gesture-handler";
3
- import type { SharedValue } from "react-native-reanimated";
3
+ import type { DerivedValue, SharedValue } from "react-native-reanimated";
4
4
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
5
5
  import { type ChartPadding } from "../draw/line";
6
6
  import { interpolateAtTime } from "../math/interpolate";
@@ -12,13 +12,87 @@ const TOOLTIP_LINE_GAP = 4;
12
12
  const TOOLTIP_OFFSET_X = 12;
13
13
  const TOOLTIP_EDGE_GAP = 4;
14
14
  const TOOLTIP_TOP_MARGIN = 8;
15
- const FADE_ZONE = 4;
16
15
 
17
16
  /** Horizontal travel required before a plain scrub claims the touch. */
18
17
  export const SCRUB_ACTIVATE_X_PX = 20;
19
18
  /** Vertical travel that fails a plain scrub so a parent scroll gesture can win. */
20
19
  export const SCRUB_FAIL_Y_PX = 10;
21
20
 
21
+ /** Clamp an X pixel to the plot's horizontal bounds. */
22
+ export function clampPlotX(
23
+ x: number,
24
+ padLeft: number,
25
+ canvasWidth: number,
26
+ padRight: number,
27
+ ): number {
28
+ "worklet";
29
+ return Math.min(canvasWidth - padRight, Math.max(padLeft, x));
30
+ }
31
+
32
+ export interface ScrubHitSlop {
33
+ left?: number;
34
+ right?: number;
35
+ bottom?: number;
36
+ }
37
+
38
+ /** Build touch-down bounds for a scrub recognizer. */
39
+ export function resolveScrubHitSlop(
40
+ padding: ChartPadding,
41
+ clampToPlot: boolean,
42
+ bottomExclude = 0,
43
+ ): ScrubHitSlop | undefined {
44
+ if (!clampToPlot && bottomExclude <= 0) return undefined;
45
+ return {
46
+ ...(clampToPlot
47
+ ? { left: -padding.left, right: -padding.right }
48
+ : {}),
49
+ ...(bottomExclude > 0 ? { bottom: -bottomExclude } : {}),
50
+ };
51
+ }
52
+
53
+ interface ScrubGestureValue<T> {
54
+ get(): T;
55
+ set(value: T): void;
56
+ }
57
+
58
+ /** Start plain scrubbing; the recognizer gates out-of-plot touch origins. */
59
+ export function startPlainScrub(
60
+ x: number,
61
+ padding: ChartPadding,
62
+ canvasWidth: number,
63
+ clampToPlot: boolean,
64
+ scrubX: ScrubGestureValue<number>,
65
+ scrubActive: ScrubGestureValue<boolean>,
66
+ gestureStarted: ScrubGestureValue<boolean>,
67
+ ): void {
68
+ "worklet";
69
+ if (clampToPlot) {
70
+ scrubX.set(clampPlotX(x, padding.left, canvasWidth, padding.right));
71
+ } else {
72
+ scrubX.set(x);
73
+ }
74
+ scrubActive.set(true);
75
+ gestureStarted.set(true);
76
+ }
77
+
78
+ /** Track plain scrubbing, clamping gestures accepted by the recognizer. */
79
+ export function updatePlainScrub(
80
+ x: number,
81
+ padding: ChartPadding,
82
+ canvasWidth: number,
83
+ clampToPlot: boolean,
84
+ scrubX: ScrubGestureValue<number>,
85
+ scrubActive: ScrubGestureValue<boolean>,
86
+ ): void {
87
+ "worklet";
88
+ if (clampToPlot) {
89
+ if (!scrubActive.get()) return;
90
+ scrubX.set(clampPlotX(x, padding.left, canvasWidth, padding.right));
91
+ } else {
92
+ scrubX.set(x);
93
+ }
94
+ }
95
+
22
96
  /** Measure rendered text so the pill and its content share the same centre. */
23
97
  function measureTooltipTextWidth(
24
98
  font: SkFont,
@@ -48,6 +122,37 @@ export interface TooltipLayout {
48
122
  baselineY: number;
49
123
  dim: boolean;
50
124
  }[];
125
+ /** LiveChartSeries' opt-in Morfi-style time pill + per-series value pills. */
126
+ perSeries?: {
127
+ /** Idle endpoint mode: value pills only, with no guide or time pill. */
128
+ pinned: boolean;
129
+ timePill?: {
130
+ x: number;
131
+ y: number;
132
+ w: number;
133
+ h: number;
134
+ text: string;
135
+ textX: number;
136
+ baselineY: number;
137
+ };
138
+ pills: {
139
+ id: string;
140
+ color: string;
141
+ anchorX: number;
142
+ anchorY: number;
143
+ x: number;
144
+ y: number;
145
+ w: number;
146
+ h: number;
147
+ dotX: number;
148
+ dotY: number;
149
+ label: string;
150
+ labelX: number;
151
+ value: string;
152
+ valueX: number;
153
+ baselineY: number;
154
+ }[];
155
+ };
51
156
  }
52
157
 
53
158
  export const HIDDEN_TOOLTIP: TooltipLayout = {
@@ -62,6 +167,7 @@ export const HIDDEN_TOOLTIP: TooltipLayout = {
62
167
  line1Y: 0,
63
168
  line2Y: 0,
64
169
  stackedLines: undefined,
170
+ perSeries: undefined,
65
171
  };
66
172
 
67
173
  export interface CrosshairState {
@@ -70,7 +176,9 @@ export interface CrosshairState {
70
176
  scrubTime: SharedValue<number>;
71
177
  scrubValue: SharedValue<number | null>;
72
178
  crosshairOpacity: SharedValue<number>;
73
- tooltipLayout: SharedValue<TooltipLayout>;
179
+ tooltipLayout:
180
+ | SharedValue<TooltipLayout>
181
+ | DerivedValue<TooltipLayout>;
74
182
  /** Scrub intersection Y in canvas px; -1 when there's no dot to draw
75
183
  * (inactive / no value / degenerate range). See {@link computeScrubDotY}. */
76
184
  scrubDotY: SharedValue<number>;
@@ -183,20 +291,23 @@ export function computeScrubTime(
183
291
  }
184
292
 
185
293
  /**
186
- * Crosshair opacity: fades 1→0 over FADE_ZONE px as the crosshair
187
- * approaches the live dot at the right chart edge.
294
+ * Crosshair opacity: fades 1→0 over `fadeDistance` px as the crosshair
295
+ * approaches the live dot at the right chart edge. A zero distance removes
296
+ * the ramp while keeping the crosshair hidden at and beyond the live edge.
188
297
  */
189
298
  export function computeCrosshairOpacity(
190
299
  scrubActive: boolean,
191
300
  scrubX: number,
192
301
  canvasWidth: number,
193
302
  paddingRight: number,
303
+ fadeDistance = 4,
194
304
  ): number {
195
305
  "worklet";
196
306
  if (!scrubActive) return 0;
197
307
  const dotX = canvasWidth - paddingRight;
198
308
  const dist = dotX - scrubX;
199
- return Math.min(1, Math.max(0, dist / FADE_ZONE));
309
+ if (fadeDistance <= 0) return dist > 0 ? 1 : 0;
310
+ return Math.min(1, Math.max(0, dist / fadeDistance));
200
311
  }
201
312
 
202
313
  /**
@@ -1,22 +1,95 @@
1
1
  import type { SharedValue } from "react-native-reanimated";
2
2
 
3
3
  type BooleanSharedValue = Pick<SharedValue<boolean>, "get" | "set">;
4
+ type NumberSharedValue = Pick<SharedValue<number>, "get" | "set">;
5
+
6
+ type TouchPoint = { x: number; y: number };
4
7
 
5
8
  type TouchCountEvent = {
6
9
  numberOfTouches: number;
7
10
  };
8
11
 
12
+ type TouchesEvent = TouchCountEvent & {
13
+ changedTouches: TouchPoint[];
14
+ allTouches: TouchPoint[];
15
+ };
16
+
9
17
  type GestureStateManager = {
10
18
  fail: () => void;
11
19
  };
12
20
 
13
- /** Mark the first pointer of a delayed pan as down. Zero-delay pans are inert. */
21
+ /**
22
+ * A pending hold-to-scrub pan must stay within this radius of its touch-down
23
+ * point to activate. Matches the platform long-press convention (RNGH
24
+ * LongPress `maxDist` / UIKit `allowableMovement` default 10) with a little
25
+ * slack for thumbs.
26
+ */
27
+ export const HOLD_MAX_DRIFT_PX = 12;
28
+
29
+ /**
30
+ * An activation earlier than `delayMs - slack` after the current touch went
31
+ * down cannot be a real hold — it is a stale `activateAfterLongPress` timer
32
+ * carried over from a previous touch (see `shouldStartDelayedPan`).
33
+ */
34
+ export const HOLD_TIMER_SLACK_MS = 50;
35
+
36
+ /** Record the first pointer of a delayed pan going down. Zero-delay pans are inert. */
14
37
  export function delayedPanTouchDown(
15
38
  delayMs: number,
39
+ event: TouchesEvent,
16
40
  fingerDown: BooleanSharedValue,
41
+ downX: NumberSharedValue,
42
+ downY: NumberSharedValue,
43
+ downAtMs: NumberSharedValue,
44
+ holdBroken: BooleanSharedValue,
45
+ ): void {
46
+ "worklet";
47
+ if (delayMs <= 0) return;
48
+ fingerDown.set(true);
49
+ const t = event.changedTouches[0];
50
+ if (t) {
51
+ downX.set(t.x);
52
+ downY.set(t.y);
53
+ }
54
+ downAtMs.set(Date.now());
55
+ holdBroken.set(false);
56
+ }
57
+
58
+ /**
59
+ * Enforce the stationary hold: while the delayed pan is still pending, any
60
+ * drift beyond HOLD_MAX_DRIFT_PX fails it outright AND latches `holdBroken`.
61
+ *
62
+ * `manager.fail()` alone is not enough. It only lands if the recognizer is
63
+ * still in a state that can fail, and — on iOS — the native
64
+ * `activateAfterLongPress` timer fires `handleGesture:inState:Active`
65
+ * unconditionally (RNPanHandler.m), so a timer that survived the fail still
66
+ * emits ACTIVE. The latch makes "the finger moved before the hold elapsed" a
67
+ * decision `shouldStartDelayedPan` can re-check at activation time, instead of
68
+ * trusting the native recognizer to have stayed failed.
69
+ *
70
+ * The latch also covers movement that returns to its origin: a finger that
71
+ * wanders 40px away and comes back is within the slop when the timer fires, but
72
+ * it was never a stationary hold.
73
+ */
74
+ export function delayedPanTouchMove(
75
+ delayMs: number,
76
+ event: TouchesEvent,
77
+ manager: GestureStateManager,
78
+ panActivated: BooleanSharedValue,
79
+ downX: NumberSharedValue,
80
+ downY: NumberSharedValue,
81
+ holdBroken: BooleanSharedValue,
17
82
  ): void {
18
83
  "worklet";
19
- if (delayMs > 0) fingerDown.set(true);
84
+ if (delayMs <= 0 || panActivated.get()) return;
85
+ const t = event.allTouches[0];
86
+ if (!t) return;
87
+ const dx = t.x - downX.get();
88
+ const dy = t.y - downY.get();
89
+ if (dx * dx + dy * dy > HOLD_MAX_DRIFT_PX * HOLD_MAX_DRIFT_PX) {
90
+ holdBroken.set(true);
91
+ manager.fail();
92
+ }
20
93
  }
21
94
 
22
95
  /**
@@ -49,15 +122,37 @@ export function delayedPanTouchCancelled(
49
122
  }
50
123
 
51
124
  /**
52
- * Record a real delayed-pan activation, or reject an activation whose timer
53
- * raced the final pointer-up. The rejected path deliberately keeps
54
- * `panActivated` false so a missing native FINALIZE cannot poison the next
55
- * interaction.
125
+ * Record a real delayed-pan activation, or reject a spurious one. Two spurious
126
+ * shapes exist, both from RNGH's long-press timer surviving where it shouldn't:
127
+ *
128
+ * 1. The timer fires after the final pointer already lifted (`fingerDown`
129
+ * false) — the original touch-up race.
130
+ * 2. The timer fires DURING a later touch: fast successive taps can land a new
131
+ * pointer before the failed recognizer's native reset completes, so the
132
+ * previous tap's timer fires only `now - downAtMs < delayMs` into the new
133
+ * touch. A real hold can never activate early, so reject anything faster
134
+ * than `delayMs - HOLD_TIMER_SLACK_MS`.
135
+ *
136
+ * 3. The timer fires after the finger already moved off the touch-down point
137
+ * (`holdBroken`) — it was a drag, not a hold. See `delayedPanTouchMove`.
138
+ *
139
+ * 4. The timer fires after the time-scroll pan already took the touch
140
+ * (`scrollActive`) — the chart is mid-drag, so the hold lost the race. The
141
+ * two pans are composed with `Gesture.Race`, which in RNGH declares NO
142
+ * relation between them (`RaceGestureType = ComposedGestureType`), so
143
+ * nothing native stops a losing recognizer from activating later. This latch
144
+ * is the arbitration.
145
+ *
146
+ * All rejected paths deliberately keep `panActivated` false so a missing
147
+ * native FINALIZE cannot poison the next interaction.
56
148
  */
57
149
  export function shouldStartDelayedPan(
58
150
  delayMs: number,
59
151
  fingerDown: BooleanSharedValue,
60
152
  panActivated: BooleanSharedValue,
153
+ downAtMs: NumberSharedValue,
154
+ holdBroken: BooleanSharedValue,
155
+ scrollActive: BooleanSharedValue,
61
156
  ): boolean {
62
157
  "worklet";
63
158
  if (delayMs <= 0) return true;
@@ -65,6 +160,14 @@ export function shouldStartDelayedPan(
65
160
  panActivated.set(false);
66
161
  return false;
67
162
  }
163
+ if (Date.now() - downAtMs.get() < delayMs - HOLD_TIMER_SLACK_MS) {
164
+ panActivated.set(false);
165
+ return false;
166
+ }
167
+ if (holdBroken.get() || scrollActive.get()) {
168
+ panActivated.set(false);
169
+ return false;
170
+ }
68
171
  panActivated.set(true);
69
172
  return true;
70
173
  }
@@ -73,8 +176,10 @@ export function shouldStartDelayedPan(
73
176
  export function resetDelayedPanGuard(
74
177
  fingerDown: BooleanSharedValue,
75
178
  panActivated: BooleanSharedValue,
179
+ holdBroken: BooleanSharedValue,
76
180
  ): void {
77
181
  "worklet";
78
182
  fingerDown.set(false);
79
183
  panActivated.set(false);
184
+ holdBroken.set(false);
80
185
  }
@@ -1,13 +1,28 @@
1
1
  import { Skia, type SkPath } from "@shopify/react-native-skia";
2
2
  import { useRef } from "react";
3
3
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
4
- import type { SingleEngineState } from "../core/useLiveChartEngine";
4
+ import type {
5
+ ChartEngineEdge,
6
+ ChartEngineScroll,
7
+ SingleEngineState,
8
+ } from "../core/useLiveChartEngine";
5
9
  import { buildLinePoints, type ChartPadding } from "../draw/line";
6
10
  import { drawSpline, makeSplineScratch } from "../math/spline";
7
11
  import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
8
12
  import { blendPtsY, squigglifyPts } from "../math/squiggly";
9
13
  import { usePathBuilder } from "./usePathBuilder";
10
14
 
15
+ /** Selects the synthetic right-edge line tip without coupling chart geometry
16
+ * to badge or live-indicator presentation options. */
17
+ export function resolveLineTipValue(
18
+ displayValue: number,
19
+ edgeValue: number,
20
+ viewEnd: number | null,
21
+ ): number {
22
+ "worklet";
23
+ return viewEnd == null ? displayValue : edgeValue;
24
+ }
25
+
11
26
  /**
12
27
  * Builds the `linePath` / `fillPath` with `Skia.PathBuilder`s reused across
13
28
  * frames (one per curve, held in a SharedValue) and finalized with `detach()` —
@@ -20,7 +35,7 @@ import { usePathBuilder } from "./usePathBuilder";
20
35
  * fillPath.
21
36
  */
22
37
  export function useChartPaths(
23
- engine: SingleEngineState,
38
+ engine: SingleEngineState & ChartEngineScroll & ChartEngineEdge,
24
39
  padding: ChartPadding,
25
40
  morphT?: SharedValue<number>,
26
41
  /** When set, also build `thresholdFillPath` — the band between the line and this
@@ -28,14 +43,6 @@ export function useChartPaths(
28
43
  thresholdY?: SharedValue<number>,
29
44
  /** Draw the line/fill as a straight polyline instead of the monotone cubic. */
30
45
  linear = false,
31
- /**
32
- * Value at the visible window's right edge (engine `edgeValue`). With
33
- * `followViewEdge`, the line's right-edge tip uses this instead of the live
34
- * `displayValue` — so while time-scrolled the line ends at the last visible
35
- * price rather than dropping to the (off-screen) live value.
36
- */
37
- edgeValue?: SharedValue<number>,
38
- followViewEdge = false,
39
46
  /** Loading squiggle wave amplitude (px) for the reveal morph. Default 14. */
40
47
  squiggleAmplitude = 14,
41
48
  /** Loading squiggle wave speed multiplier for the reveal morph. Default 1. */
@@ -79,11 +86,14 @@ export function useChartPaths(
79
86
  const cache = cacheRef.current!;
80
87
  cache.ptsTick = !cache.ptsTick;
81
88
  const buf = cache.ptsTick ? cache.ptsA : cache.ptsB;
82
- // While scrolled back with `followViewEdge`, tip the line at the view-edge
83
- // price (engine `edgeValue`) instead of the live value otherwise the line
84
- // drops from the last visible point to the off-screen live value at the edge.
85
- const tipValue =
86
- followViewEdge && edgeValue ? edgeValue.get() : engine.displayValue.get();
89
+ // A historical window must end at its own right-edge price. Badge and
90
+ // live-indicator options affect overlays only; they must not distort the
91
+ // plotted series by connecting history to the off-screen live value.
92
+ const tipValue = resolveLineTipValue(
93
+ engine.displayValue.get(),
94
+ engine.edgeValue.get(),
95
+ engine.viewEnd.get(),
96
+ );
87
97
  const realPts = buildLinePoints(
88
98
  engine.data.get(),
89
99
  tipValue,
@@ -20,6 +20,7 @@ import type {
20
20
  ScrubPoint,
21
21
  } from "../types";
22
22
  import {
23
+ clampPlotX,
23
24
  computeActionBadgeLayout,
24
25
  computeCandleTooltipLayout,
25
26
  computeCrosshairOpacity,
@@ -32,14 +33,18 @@ import {
32
33
  HIDDEN_TIME_BADGE,
33
34
  HIDDEN_TOOLTIP,
34
35
  pointInRect,
36
+ resolveScrubHitSlop,
35
37
  SCRUB_ACTIVATE_X_PX,
36
38
  SCRUB_FAIL_Y_PX,
37
39
  snapPrice,
40
+ startPlainScrub,
38
41
  type CrosshairState,
42
+ updatePlainScrub,
39
43
  } from "./crosshairShared";
40
44
  import {
41
45
  delayedPanTouchCancelled,
42
46
  delayedPanTouchDown,
47
+ delayedPanTouchMove,
43
48
  delayedPanTouchUp,
44
49
  resetDelayedPanGuard,
45
50
  shouldStartDelayedPan,
@@ -63,18 +68,6 @@ const SCRUB_ACTION_TAP_SLOP = 10;
63
68
  */
64
69
  const SCRUB_ACTION_PRESS_HOLD_MS = 200;
65
70
 
66
- /** Clamp an X pixel to the plot's horizontal bounds. */
67
- /* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
68
- function clampPlotX(
69
- x: number,
70
- padLeft: number,
71
- canvasWidth: number,
72
- padRight: number,
73
- ): number {
74
- "worklet";
75
- return Math.min(canvasWidth - padRight, Math.max(padLeft, x));
76
- }
77
-
78
71
  /** Clamp a Y pixel to the plot's vertical bounds. */
79
72
  /* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
80
73
  function clampPlotY(
@@ -152,6 +145,18 @@ export function useCrosshair(
152
145
  * exclusion (the default; scrub covers the whole plot).
153
146
  */
154
147
  scrubBottomExclude = 0,
148
+ /**
149
+ * True while the time-scroll pan owns the touch. The hold-to-scrub pan refuses
150
+ * to activate while set, so a drag that already started scrolling can never
151
+ * mature into a scrub (see `delayedPanGuard.shouldStartDelayedPan`).
152
+ */
153
+ scrollActive?: SharedValue<boolean>,
154
+ /**
155
+ * Reject plain-scrub starts beyond either horizontal plot edge and clamp
156
+ * active drags to those bounds. Scrub-action behavior is unchanged.
157
+ * Default `false`.
158
+ */
159
+ clampToPlot = false,
155
160
  ): CrosshairState {
156
161
  const scrubX = useSharedValue(-1);
157
162
  const scrubActive = useSharedValue(false);
@@ -162,6 +167,18 @@ export function useCrosshair(
162
167
  // lifecycle helpers are also used by LiveChartSeries and unit-tested directly.
163
168
  const fingerDown = useSharedValue(false);
164
169
  const panActivated = useSharedValue(false);
170
+ // Where and when the current touch went down, for the stationary-hold and
171
+ // stale-timer guards (see delayedPanGuard).
172
+ const downX = useSharedValue(0);
173
+ const downY = useSharedValue(0);
174
+ const downAtMs = useSharedValue(0);
175
+ // Latched once the finger leaves the hold slop: this touch is a drag, so its
176
+ // long-press timer must not be honored even if it still fires.
177
+ const holdBroken = useSharedValue(false);
178
+ // Inert stand-in so the guard always has a latch to read when the controller
179
+ // wires no time-scroll (hooks must be created unconditionally).
180
+ const noScrollActive = useSharedValue(false);
181
+ const scrollActiveSV = scrollActive ?? noScrollActive;
165
182
  // Where the crosshair line should start (canvas Y) so it stops at a top-pinned
166
183
  // custom tooltip instead of running through it. -1 = no top tooltip → the line
167
184
  // starts at padding.top. Written by CustomTooltipOverlay, read by CrosshairOverlay.
@@ -176,6 +193,7 @@ export function useCrosshair(
176
193
  // tracks the price as the axis rescales instead of drifting under a fixed pixel.
177
194
  const lockPriceValue = useSharedValue<number | null>(null);
178
195
  const hasScrubAction = scrubAction != null;
196
+ const clampPlainScrubToPlot = clampToPlot && !hasScrubAction;
179
197
  const hasOnScrubAction = onScrubAction != null;
180
198
  const dismissOnTapOutside = scrubAction?.dismissOnTapOutside ?? false;
181
199
  const dismissOnAction = scrubAction?.dismissOnAction ?? false;
@@ -505,9 +523,33 @@ export function useCrosshair(
505
523
  .maxPointers(1)
506
524
  .shouldCancelWhenOutside(false)
507
525
  .onTouchesDown(
508
- /* istanbul ignore next */ () => {
526
+ /* istanbul ignore next */ (e) => {
527
+ "worklet";
528
+ delayedPanTouchDown(
529
+ longPressMs,
530
+ e,
531
+ fingerDown,
532
+ downX,
533
+ downY,
534
+ downAtMs,
535
+ holdBroken,
536
+ );
537
+ },
538
+ )
539
+ // Stationary hold: drift beyond the slop while the long-press is still
540
+ // pending fails the pan, so a drag can never mature into a scrub.
541
+ .onTouchesMove(
542
+ /* istanbul ignore next */ (e, manager) => {
509
543
  "worklet";
510
- delayedPanTouchDown(longPressMs, fingerDown);
544
+ delayedPanTouchMove(
545
+ longPressMs,
546
+ e,
547
+ manager,
548
+ panActivated,
549
+ downX,
550
+ downY,
551
+ holdBroken,
552
+ );
511
553
  },
512
554
  )
513
555
  .onTouchesUp(
@@ -535,7 +577,17 @@ export function useCrosshair(
535
577
  .onStart(
536
578
  /* istanbul ignore next */ (e) => {
537
579
  "worklet";
538
- if (!shouldStartDelayedPan(longPressMs, fingerDown, panActivated)) return;
580
+ if (
581
+ !shouldStartDelayedPan(
582
+ longPressMs,
583
+ fingerDown,
584
+ panActivated,
585
+ downAtMs,
586
+ holdBroken,
587
+ scrollActiveSV,
588
+ )
589
+ )
590
+ return;
539
591
  if (!enabled) return;
540
592
  // Scrub-action: once a reticle is placed, drag adjusts it (2D — the Y is
541
593
  // the price). The ephemeral live-scrub never engages, so scrubActive
@@ -565,9 +617,15 @@ export function useCrosshair(
565
617
  // also keeps a follow-on drag from showing a crosshair — no `onUpdate`
566
618
  // guard needed. (Plain-scrub counterpart of the scrub-action tap defer.)
567
619
  if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
568
- scrubX.set(e.x);
569
- scrubActive.set(true);
570
- gestureStarted.set(true);
620
+ startPlainScrub(
621
+ e.x,
622
+ padding,
623
+ engine.canvasWidth.get(),
624
+ clampPlainScrubToPlot,
625
+ scrubX,
626
+ scrubActive,
627
+ gestureStarted,
628
+ );
571
629
  if (hasOnGestureStart) scheduleOnRN(handleGestureStart);
572
630
  },
573
631
  )
@@ -594,13 +652,20 @@ export function useCrosshair(
594
652
  }
595
653
  return;
596
654
  }
597
- scrubX.set(e.x);
655
+ updatePlainScrub(
656
+ e.x,
657
+ padding,
658
+ engine.canvasWidth.get(),
659
+ clampPlainScrubToPlot,
660
+ scrubX,
661
+ scrubActive,
662
+ );
598
663
  },
599
664
  )
600
665
  .onFinalize(
601
666
  /* istanbul ignore next */ () => {
602
667
  "worklet";
603
- resetDelayedPanGuard(fingerDown, panActivated);
668
+ resetDelayedPanGuard(fingerDown, panActivated, holdBroken);
604
669
  // A lock-adjust drag leaves the reticle in place (scrubActive was never
605
670
  // set); a live-scrub clears its crosshair. Always clear scrubActive so a
606
671
  // stray scrub can never linger behind a placed reticle.
@@ -633,13 +698,15 @@ export function useCrosshair(
633
698
  .failOffsetY([-SCRUB_FAIL_Y_PX, SCRUB_FAIL_Y_PX]);
634
699
  }
635
700
 
636
- // Axis-drag time-scroll: carve the bottom "time ruler" band out of the scrub's
637
- // hit area so a drag starting there scrolls (the pan-scroll gesture owns it)
638
- // and never trips the crosshair. `shouldCancelWhenOutside(false)` above keeps a
639
- // scrub that *started* in the plot tracking on into the band.
640
- if (scrubBottomExclude > 0) {
641
- gesture = gesture.hitSlop({ bottom: -scrubBottomExclude });
642
- }
701
+ // Restrict recognition by touch-down position. This rejects before ACTIVE,
702
+ // leaving outside starts to competing/parent gestures. Once accepted,
703
+ // `shouldCancelWhenOutside(false)` keeps tracking beyond these bounds.
704
+ const scrubHitSlop = resolveScrubHitSlop(
705
+ padding,
706
+ clampPlainScrubToPlot,
707
+ scrubBottomExclude,
708
+ );
709
+ if (scrubHitSlop) gesture = gesture.hitSlop(scrubHitSlop);
643
710
 
644
711
  // Tap: place/move the reticle, press the action badge, or dismiss the lock.
645
712
  // Composed ahead of the pan by the controller, so a tap is never swallowed.
@@ -714,11 +781,15 @@ export function useCrosshair(
714
781
  .maxDistance(SCRUB_ACTION_TAP_SLOP)
715
782
  .onEnd(handleActionTap)
716
783
  : undefined;
717
- // Keep the axis-drag band scroll-only for taps too a tap there shouldn't
718
- // drop an order-ticket reticle.
719
- if (tapGesture && scrubBottomExclude > 0) {
720
- tapGesture = tapGesture.hitSlop({ bottom: -scrubBottomExclude });
721
- }
784
+ // Keep the bottom axis scroll-only. Do not apply horizontal plot bounds here:
785
+ // the action badge is intentionally tappable in the right gutter.
786
+ const tapHitSlop = resolveScrubHitSlop(
787
+ padding,
788
+ false,
789
+ scrubBottomExclude,
790
+ );
791
+ if (tapGesture && tapHitSlop)
792
+ tapGesture = tapGesture.hitSlop(tapHitSlop);
722
793
 
723
794
  return {
724
795
  scrubX,