react-native-livechart 3.2.0 → 3.4.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 (59) hide show
  1. package/README.md +6 -1
  2. package/dist/components/LiveChart.d.ts.map +1 -1
  3. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  4. package/dist/components/ReferenceLineOverlay.d.ts +10 -3
  5. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  6. package/dist/components/ScrubActionOverlay.d.ts +32 -0
  7. package/dist/components/ScrubActionOverlay.d.ts.map +1 -0
  8. package/dist/components/SegmentDividerOverlay.d.ts +19 -0
  9. package/dist/components/SegmentDividerOverlay.d.ts.map +1 -0
  10. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  11. package/dist/constants.d.ts +4 -0
  12. package/dist/constants.d.ts.map +1 -1
  13. package/dist/core/resolveConfig.d.ts +23 -1
  14. package/dist/core/resolveConfig.d.ts.map +1 -1
  15. package/dist/core/resolveSegment.d.ts +42 -0
  16. package/dist/core/resolveSegment.d.ts.map +1 -0
  17. package/dist/hooks/crosshairShared.d.ts +110 -0
  18. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  19. package/dist/hooks/useCrosshair.d.ts +14 -2
  20. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  21. package/dist/hooks/useMarkers.d.ts +2 -0
  22. package/dist/hooks/useMarkers.d.ts.map +1 -1
  23. package/dist/hooks/useReferenceLine.d.ts +36 -3
  24. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  25. package/dist/hooks/useReferenceLinePress.d.ts +23 -0
  26. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -0
  27. package/dist/hooks/useSegmentDivider.d.ts +28 -0
  28. package/dist/hooks/useSegmentDivider.d.ts.map +1 -0
  29. package/dist/hooks/useSegmentLineGradient.d.ts +20 -0
  30. package/dist/hooks/useSegmentLineGradient.d.ts.map +1 -0
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/math/referenceLines.d.ts +23 -0
  34. package/dist/math/referenceLines.d.ts.map +1 -1
  35. package/dist/math/segments.d.ts +46 -0
  36. package/dist/math/segments.d.ts.map +1 -0
  37. package/dist/types.d.ts +184 -6
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/components/LiveChart.tsx +242 -26
  41. package/src/components/LiveChartSeries.tsx +39 -2
  42. package/src/components/ReferenceLineOverlay.tsx +121 -94
  43. package/src/components/ScrubActionOverlay.tsx +187 -0
  44. package/src/components/SegmentDividerOverlay.tsx +84 -0
  45. package/src/components/XAxisOverlay.tsx +2 -1
  46. package/src/constants.ts +5 -0
  47. package/src/core/resolveConfig.ts +40 -0
  48. package/src/core/resolveSegment.ts +62 -0
  49. package/src/hooks/crosshairShared.ts +293 -0
  50. package/src/hooks/useCrosshair.ts +340 -6
  51. package/src/hooks/useMarkers.ts +18 -2
  52. package/src/hooks/useReferenceLine.ts +259 -14
  53. package/src/hooks/useReferenceLinePress.ts +92 -0
  54. package/src/hooks/useSegmentDivider.ts +84 -0
  55. package/src/hooks/useSegmentLineGradient.ts +57 -0
  56. package/src/index.ts +4 -0
  57. package/src/math/referenceLines.ts +55 -0
  58. package/src/math/segments.ts +173 -0
  59. package/src/types.ts +192 -6
@@ -8,20 +8,77 @@ import {
8
8
  type SharedValue,
9
9
  } from "react-native-reanimated";
10
10
  import { scheduleOnRN } from "react-native-worklets";
11
+ import { BADGE_METRICS_DEFAULTS, X_AXIS_LABEL_OFFSET_Y } from "../constants";
12
+ import type { ResolvedScrubActionConfig } from "../core/resolveConfig";
11
13
  import type { SingleEngineState } from "../core/useLiveChartEngine";
12
14
  import { type ChartPadding } from "../draw/line";
13
15
  import { interpolateAtTime } from "../math/interpolate";
14
16
  import { pickCandleAtTime } from "../math/pickCandle";
15
- import type { CandlePoint, LiveChartPalette, ScrubPoint } from "../types";
17
+ import type {
18
+ BadgeMetrics,
19
+ CandlePoint,
20
+ LiveChartPalette,
21
+ ScrubActionPoint,
22
+ ScrubPoint,
23
+ } from "../types";
16
24
  import {
25
+ computeActionBadgeLayout,
17
26
  computeCandleTooltipLayout,
18
27
  computeCrosshairOpacity,
19
28
  computeScrubDotY,
20
29
  computeScrubTime,
30
+ computeTimeBadgeLayout,
31
+ computeValueAtY,
21
32
  deriveCrosshairTooltipSingle,
33
+ HIDDEN_ACTION_BADGE,
34
+ HIDDEN_TIME_BADGE,
35
+ pointInRect,
36
+ snapPrice,
22
37
  type CrosshairState,
23
38
  } from "./crosshairShared";
24
39
 
40
+ const ACTION_HIT_SLOP = 6;
41
+ const RETICLE_HIT = 14;
42
+
43
+ /**
44
+ * Movement (px) a scrub-action *tap* (place / press / dismiss) may travel before
45
+ * it's treated as a drag — the Tap's `maxDistance`. ~Touch slop, so a finger that
46
+ * jitters a few px still registers as a tap rather than failing.
47
+ */
48
+ const SCRUB_ACTION_TAP_SLOP = 10;
49
+
50
+ /**
51
+ * Default press-hold (ms) before the live scrub / lock-adjust pan activates in
52
+ * scrub-action ("order ticket") mode. A quick tap places or acts on the reticle;
53
+ * the scrub crosshair only appears on a deliberate hold, so a tap never flashes
54
+ * it. Overridden by an explicit `scrub.panGestureDelay`.
55
+ */
56
+ const SCRUB_ACTION_PRESS_HOLD_MS = 200;
57
+
58
+ /** Clamp an X pixel to the plot's horizontal bounds. */
59
+ /* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
60
+ function clampPlotX(
61
+ x: number,
62
+ padLeft: number,
63
+ canvasWidth: number,
64
+ padRight: number,
65
+ ): number {
66
+ "worklet";
67
+ return Math.min(canvasWidth - padRight, Math.max(padLeft, x));
68
+ }
69
+
70
+ /** Clamp a Y pixel to the plot's vertical bounds. */
71
+ /* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
72
+ function clampPlotY(
73
+ y: number,
74
+ padTop: number,
75
+ canvasHeight: number,
76
+ padBottom: number,
77
+ ): number {
78
+ "worklet";
79
+ return Math.min(canvasHeight - padBottom, Math.max(padTop, y));
80
+ }
81
+
25
82
  export type { CrosshairState, TooltipLayout } from "./crosshairShared";
26
83
  export type { ScrubPoint };
27
84
 
@@ -60,6 +117,18 @@ export function useCrosshair(
60
117
  panGestureDelay = 0,
61
118
  onGestureStart?: () => void,
62
119
  onGestureEnd?: () => void,
120
+ /** Scrub-action ("order ticket") config; `null`/omitted disables lock mode. */
121
+ scrubAction?: ResolvedScrubActionConfig | null,
122
+ onScrubAction?: (point: ScrubActionPoint) => void,
123
+ /** Badge geometry (gutter margins / pad) for the action pill. */
124
+ badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
125
+ /**
126
+ * Worklet predicate: true when a tap at (x, y) lands on another interactive
127
+ * overlay (a marker, or a pressable reference-line badge). The scrub-action tap
128
+ * defers to it (no reticle placed) so that overlay's tap handler can act.
129
+ * Omitted when nothing coexists.
130
+ */
131
+ deferTapHit?: (x: number, y: number) => boolean,
63
132
  ): CrosshairState {
64
133
  const scrubX = useSharedValue(-1);
65
134
  const scrubActive = useSharedValue(false);
@@ -67,6 +136,22 @@ export function useCrosshair(
67
136
  // activates doesn't emit a spurious onGestureEnd.
68
137
  const gestureStarted = useSharedValue(false);
69
138
 
139
+ // Scrub-action lock state. Created unconditionally (hooks must be), but the
140
+ // lock gestures are only wired by the controller when `scrubAction` is set.
141
+ const lockActive = useSharedValue(false);
142
+ const lockX = useSharedValue(-1);
143
+ // The chosen PRICE is the source of truth (frozen on place/drag); the reticle's
144
+ // screen Y is re-derived from it each frame (see `lockY` below) so the level
145
+ // tracks the price as the axis rescales instead of drifting under a fixed pixel.
146
+ const lockPriceValue = useSharedValue<number | null>(null);
147
+ const hasScrubAction = scrubAction != null;
148
+ const hasOnScrubAction = onScrubAction != null;
149
+ const dismissOnTapOutside = scrubAction?.dismissOnTapOutside ?? false;
150
+ const snapIncrement = scrubAction?.snap;
151
+ const actionIcon = scrubAction?.icon ?? "+";
152
+ const actionShowText = scrubAction?.text ?? true;
153
+ const hasTimeBadge = scrubAction?.timeBadge ?? false;
154
+
70
155
  const scrubTime = useDerivedValue(() =>
71
156
  computeScrubTime(
72
157
  scrubActive.get(),
@@ -165,6 +250,113 @@ export function useCrosshair(
165
250
  );
166
251
  });
167
252
 
253
+ // ── Scrub-action lock derivations ──────────────────────────────────────────
254
+ // Reported price = the frozen chosen price, optionally snapped. Independent of
255
+ // the display range (the point of freezing it), so the badge readout and the
256
+ // onScrubAction payload stay stable while the chart auto-rescales. Null until a
257
+ // reticle is placed.
258
+ /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
259
+ const lockPrice = useDerivedValue(() => {
260
+ if (!lockActive.get()) return null;
261
+ const p = lockPriceValue.get();
262
+ return p === null ? null : snapPrice(p, snapIncrement);
263
+ });
264
+
265
+ // Screen Y of the locked level, DERIVED from the frozen price each frame so the
266
+ // line + badge track the chosen price as displayMin/Max move (rather than the
267
+ // price drifting under a pixel-fixed reticle). Pins to the plot edge when the
268
+ // price scrolls out of the visible range; -1 until placed / laid out.
269
+ /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
270
+ const lockY = useDerivedValue(() => {
271
+ const p = lockPriceValue.get();
272
+ const ch = engine.canvasHeight.get();
273
+ if (p === null || ch - padding.top - padding.bottom <= 0) return -1;
274
+ const y = computeScrubDotY(
275
+ p,
276
+ engine.displayMin.get(),
277
+ engine.displayMax.get(),
278
+ ch,
279
+ padding.top,
280
+ padding.bottom,
281
+ );
282
+ return clampPlotY(y, padding.top, ch, padding.bottom);
283
+ });
284
+
285
+ const lockTime = useDerivedValue(() =>
286
+ computeScrubTime(
287
+ lockActive.get(),
288
+ lockX.get(),
289
+ padding,
290
+ engine.canvasWidth.get(),
291
+ engine.timestamp.get(),
292
+ engine.displayWindow.get(),
293
+ ),
294
+ );
295
+
296
+ /* istanbul ignore next -- worklet */
297
+ const lockCandle = useDerivedValue(() => {
298
+ if (!isCandleMode || !candlesSV || !lockActive.get() || lockTime.get() < 0)
299
+ return null;
300
+ return pickCandleAtTime(
301
+ candlesSV.get(),
302
+ liveCandleSV?.get() ?? null,
303
+ lockTime.get(),
304
+ candleWidthSecs,
305
+ );
306
+ });
307
+
308
+ // Right-gutter action-badge layout — also the tap hit rect. Hidden when not locked.
309
+ /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
310
+ const actionBadge = useDerivedValue(() => {
311
+ const price = lockPrice.get();
312
+ if (price === null) return HIDDEN_ACTION_BADGE;
313
+ return computeActionBadgeLayout(
314
+ lockActive.get(),
315
+ lockY.get(),
316
+ actionShowText ? formatValue(price) : "",
317
+ actionIcon,
318
+ engine.canvasWidth.get(),
319
+ engine.canvasWidth.get() - padding.right,
320
+ font,
321
+ badgeMetrics.marginEdge,
322
+ badgeMetrics.padX,
323
+ badgeMetrics.padY,
324
+ );
325
+ });
326
+
327
+ // X-axis time badge at the reticle (opt-in). Hidden unless enabled + locked.
328
+ /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
329
+ const timeBadge = useDerivedValue(() => {
330
+ if (!hasTimeBadge || !lockActive.get()) return HIDDEN_TIME_BADGE;
331
+ const t = lockTime.get();
332
+ if (t < 0) return HIDDEN_TIME_BADGE;
333
+ return computeTimeBadgeLayout(
334
+ lockActive.get(),
335
+ lockX.get(),
336
+ formatTime(t),
337
+ engine.canvasWidth.get(),
338
+ engine.canvasHeight.get() - padding.bottom + X_AXIS_LABEL_OFFSET_Y,
339
+ font,
340
+ badgeMetrics.padX,
341
+ badgeMetrics.padY,
342
+ badgeMetrics.marginEdge,
343
+ );
344
+ });
345
+
346
+ /* istanbul ignore next -- invoked only via scheduleOnRN from UI-thread gesture */
347
+ function handleScrubAction(
348
+ price: number,
349
+ time: number,
350
+ x: number,
351
+ y: number,
352
+ candleJson: string | null,
353
+ ) {
354
+ const candle: CandlePoint | undefined = candleJson
355
+ ? (JSON.parse(candleJson) as CandlePoint)
356
+ : undefined;
357
+ onScrubAction?.({ price, time, x, y, candle });
358
+ }
359
+
168
360
  /* istanbul ignore next -- invoked only via scheduleOnRN from UI-thread gesture */
169
361
  function handleScrub(
170
362
  x: number,
@@ -246,9 +438,25 @@ export function useCrosshair(
246
438
  },
247
439
  );
248
440
 
441
+ // In scrub-action mode the live scrub / lock-adjust requires a deliberate
442
+ // press-hold: a quick tap (even a sloppy one that travels a few px) places or
443
+ // acts on the reticle, and must never flash the crosshair. The press-hold is
444
+ // what gates activation — and because movement during the hold *fails* the pan
445
+ // (RNGH), a tap can't trip the scrub by moving. Honor an explicit
446
+ // `scrub.panGestureDelay`, else default to the hold. Outside scrub-action the
447
+ // pan keeps its normal (no-hold) activation unless a delay was set.
448
+ const longPressMs =
449
+ hasScrubAction && panGestureDelay <= 0
450
+ ? SCRUB_ACTION_PRESS_HOLD_MS
451
+ : panGestureDelay;
452
+
249
453
  let gesture = Gesture.Pan()
250
- .minDistance(Platform.OS === "android" ? 10 : 0)
251
- .activateAfterLongPress(panGestureDelay)
454
+ // Scrub-action: minDistance 0 the press-hold above is the sole activator
455
+ // (movement during the hold fails the pan), so the crosshair never appears
456
+ // from a tap's travel. The Tap (maxDistance SCRUB_ACTION_TAP_SLOP) owns quick
457
+ // gestures; only a held press becomes a scrub/adjust drag.
458
+ .minDistance(!hasScrubAction && Platform.OS === "android" ? 10 : 0)
459
+ .activateAfterLongPress(longPressMs)
252
460
  .maxPointers(1)
253
461
  .shouldCancelWhenOutside(false)
254
462
  // Start scrubbing on ACTIVE (onStart), not on touch-down (onBegin):
@@ -259,6 +467,28 @@ export function useCrosshair(
259
467
  /* istanbul ignore next */ (e) => {
260
468
  "worklet";
261
469
  if (!enabled) return;
470
+ // Scrub-action: once a reticle is placed, drag adjusts it (2D — the Y is
471
+ // the price). The ephemeral live-scrub never engages, so scrubActive
472
+ // stays false and the live crosshair hides itself.
473
+ if (hasScrubAction && lockActive.get()) {
474
+ lockX.set(
475
+ clampPlotX(e.x, padding.left, engine.canvasWidth.get(), padding.right),
476
+ );
477
+ // Freeze the chosen price (value at the pointer Y); the line's Y is
478
+ // re-derived from it so the level stays put in PRICE as the axis rescales.
479
+ {
480
+ const p = computeValueAtY(
481
+ e.y,
482
+ engine.displayMin.get(),
483
+ engine.displayMax.get(),
484
+ engine.canvasHeight.get(),
485
+ padding.top,
486
+ padding.bottom,
487
+ );
488
+ if (p !== null) lockPriceValue.set(p);
489
+ }
490
+ return;
491
+ }
262
492
  scrubX.set(e.x);
263
493
  scrubActive.set(true);
264
494
  gestureStarted.set(true);
@@ -269,14 +499,38 @@ export function useCrosshair(
269
499
  /* istanbul ignore next */ (e) => {
270
500
  "worklet";
271
501
  if (!enabled) return;
502
+ if (hasScrubAction && lockActive.get()) {
503
+ lockX.set(
504
+ clampPlotX(e.x, padding.left, engine.canvasWidth.get(), padding.right),
505
+ );
506
+ // Freeze the chosen price (value at the pointer Y); the line's Y is
507
+ // re-derived from it so the level stays put in PRICE as the axis rescales.
508
+ {
509
+ const p = computeValueAtY(
510
+ e.y,
511
+ engine.displayMin.get(),
512
+ engine.displayMax.get(),
513
+ engine.canvasHeight.get(),
514
+ padding.top,
515
+ padding.bottom,
516
+ );
517
+ if (p !== null) lockPriceValue.set(p);
518
+ }
519
+ return;
520
+ }
272
521
  scrubX.set(e.x);
273
522
  },
274
523
  )
275
524
  .onFinalize(
276
525
  /* istanbul ignore next */ () => {
277
526
  "worklet";
278
- scrubActive.set(false);
279
- if (hasOnScrub) scheduleOnRN(handleScrubEnd);
527
+ // A lock-adjust drag leaves the reticle in place (scrubActive was never
528
+ // set); a live-scrub clears its crosshair. Always clear scrubActive so a
529
+ // stray scrub can never linger behind a placed reticle.
530
+ if (scrubActive.get()) {
531
+ scrubActive.set(false);
532
+ if (hasOnScrub) scheduleOnRN(handleScrubEnd);
533
+ }
280
534
  if (gestureStarted.get()) {
281
535
  gestureStarted.set(false);
282
536
  if (hasOnGestureEnd) scheduleOnRN(handleGestureEnd);
@@ -285,10 +539,83 @@ export function useCrosshair(
285
539
  );
286
540
 
287
541
  /* istanbul ignore next -- Android-only gesture axis config */
288
- if (Platform.OS === "android") {
542
+ if (Platform.OS === "android" && !hasScrubAction) {
543
+ // Lock mode needs free vertical drag (Y = price), so the failOffsetY clamp —
544
+ // which would kill a vertical adjust — is applied only outside scrub-action.
289
545
  gesture = gesture.activeOffsetX([-25, 25]).failOffsetY([-25, 25]);
290
546
  }
291
547
 
548
+ // Tap: place/move the reticle, press the action badge, or dismiss the lock.
549
+ // Composed ahead of the pan by the controller, so a tap is never swallowed.
550
+ // Built only in scrub-action mode (the plain-scrub path never constructs a Tap).
551
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
552
+ const handleActionTap = (e: { x: number; y: number }, success: boolean) => {
553
+ "worklet";
554
+ if (!enabled || !hasScrubAction || !success) return;
555
+ if (lockActive.get()) {
556
+ // 1. Action-badge press → fire onScrubAction with the chosen price.
557
+ const rect = actionBadge.get();
558
+ if (rect.visible && pointInRect(e.x, e.y, rect, ACTION_HIT_SLOP)) {
559
+ const price = lockPrice.get();
560
+ if (price !== null) {
561
+ let candleJson: string | null = null;
562
+ if (isCandleMode) {
563
+ const c = lockCandle.get();
564
+ if (c) candleJson = JSON.stringify(c);
565
+ }
566
+ if (hasOnScrubAction)
567
+ scheduleOnRN(
568
+ handleScrubAction,
569
+ price,
570
+ lockTime.get(),
571
+ lockX.get(),
572
+ lockY.get(),
573
+ candleJson,
574
+ );
575
+ }
576
+ return;
577
+ }
578
+ // 2. Dismiss: tap the reticle itself, or any empty tap when configured.
579
+ const onReticle =
580
+ Math.abs(e.x - lockX.get()) <= RETICLE_HIT &&
581
+ Math.abs(e.y - lockY.get()) <= RETICLE_HIT;
582
+ if (onReticle || dismissOnTapOutside) {
583
+ lockActive.set(false);
584
+ return;
585
+ }
586
+ }
587
+ // 2.5. Defer to a marker / reference-line badge under the tap: when one
588
+ // coexists, the tap fires both this handler and that overlay's tap
589
+ // (Simultaneous) — yield so it acts instead of dropping a reticle on top.
590
+ if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
591
+ // 3. Place / move the reticle. Clear any in-progress live-scrub so its
592
+ // crosshair never shows behind the placed reticle.
593
+ scrubActive.set(false);
594
+ lockX.set(
595
+ clampPlotX(e.x, padding.left, engine.canvasWidth.get(), padding.right),
596
+ );
597
+ // Freeze the chosen price at the tap Y; the line's Y derives from it (see
598
+ // `lockY`). No-op until the canvas is laid out (price === null).
599
+ const placedPrice = computeValueAtY(
600
+ e.y,
601
+ engine.displayMin.get(),
602
+ engine.displayMax.get(),
603
+ engine.canvasHeight.get(),
604
+ padding.top,
605
+ padding.bottom,
606
+ );
607
+ if (placedPrice === null) return;
608
+ lockPriceValue.set(placedPrice);
609
+ lockActive.set(true);
610
+ };
611
+
612
+ const tapGesture = hasScrubAction
613
+ ? Gesture.Tap()
614
+ .maxDuration(250)
615
+ .maxDistance(SCRUB_ACTION_TAP_SLOP)
616
+ .onEnd(handleActionTap)
617
+ : undefined;
618
+
292
619
  return {
293
620
  scrubX,
294
621
  scrubActive,
@@ -298,5 +625,12 @@ export function useCrosshair(
298
625
  tooltipLayout,
299
626
  scrubDotY,
300
627
  gesture,
628
+ lockActive,
629
+ lockX,
630
+ lockY,
631
+ lockPrice,
632
+ actionBadge,
633
+ timeBadge,
634
+ tapGesture,
301
635
  };
302
636
  }
@@ -32,7 +32,12 @@ export function useMarkers(
32
32
  lineData?: SharedValue<LiveChartPoint[]>,
33
33
  /** Static charts run no loops: register without starting. Default `true`. */
34
34
  autostart = true,
35
- ): { projected: SharedValue<ProjectedMarker[]>; tapGesture: ReturnType<typeof Gesture.Tap> } {
35
+ ): {
36
+ projected: SharedValue<ProjectedMarker[]>;
37
+ tapGesture: ReturnType<typeof Gesture.Tap>;
38
+ /** Worklet hit-test: true when (x, y) lands on a projected marker. */
39
+ hitTest: (x: number, y: number) => boolean;
40
+ } {
36
41
  const projected = useSharedValue<ProjectedMarker[]>([]);
37
42
  const cacheRef = useRef<{
38
43
  a: ProjectedMarker[];
@@ -94,5 +99,16 @@ export function useMarkers(
94
99
  },
95
100
  );
96
101
 
97
- return { projected, tapGesture };
102
+ // Lets a coexisting gesture (e.g. the scrub-action tap) defer to a marker
103
+ // under the finger instead of acting on it.
104
+ const hitTest =
105
+ /* istanbul ignore next -- worklet, runs on the UI thread, not in Jest */ (
106
+ x: number,
107
+ y: number,
108
+ ) => {
109
+ "worklet";
110
+ return nearestMarkerIndex(projected.get(), x, y, hitRadius) >= 0;
111
+ };
112
+
113
+ return { projected, tapGesture, hitTest };
98
114
  }