import { type SkFont } from "@shopify/react-native-skia"; import { Gesture } from "react-native-gesture-handler"; import { useAnimatedReaction, useSharedValue, type SharedValue, } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { BADGE_METRICS_DEFAULTS, X_AXIS_LABEL_OFFSET_Y } from "../constants"; import type { ResolvedScrubActionConfig } from "../core/resolveConfig"; import type { SingleEngineState } from "../core/useLiveChartEngine"; import { type ChartPadding } from "../draw/line"; import { lineGapContainsData, previousLineValueAtTime } from "../draw/lineGap"; import { interpolateAtTime } from "../math/interpolate"; import { pickCandleGapAtTime, previousCandleCloseAtTime, } from "../math/candleGaps"; import { pickCandleAtTime } from "../math/pickCandle"; import type { BadgeMetrics, CandleGap, CandlePoint, LiveChartPalette, ScrubActionPoint, ScrubPoint, } from "../types"; import { clampPlotX, computeActionBadgeLayout, computeCandleTooltipLayout, computeCrosshairOpacity, computeLineGapTooltipLayout, computeScrubDotY, computeScrubTime, computeTimeBadgeLayout, computeValueAtY, deriveCrosshairTooltipSingle, HIDDEN_ACTION_BADGE, HIDDEN_TIME_BADGE, HIDDEN_TOOLTIP, pointInRect, resolveScrubHitSlop, SCRUB_ACTIVATE_X_PX, SCRUB_FAIL_Y_PX, snapPrice, snapScrubXToCandleCenter, startPlainScrub, type CrosshairState, updatePlainScrub, } from "./crosshairShared"; import { delayedPanTouchCancelled, delayedPanTouchDown, delayedPanTouchMove, delayedPanTouchUp, resetDelayedPanGuard, shouldStartDelayedPan, } from "./delayedPanGuard"; const ACTION_HIT_SLOP = 6; const RETICLE_HIT = 14; /** * Movement (px) a scrub-action *tap* (place / press / dismiss) may travel before * it's treated as a drag — the Tap's `maxDistance`. ~Touch slop, so a finger that * jitters a few px still registers as a tap rather than failing. */ const SCRUB_ACTION_TAP_SLOP = 10; /** * Default press-hold (ms) before the live scrub / lock-adjust pan activates in * scrub-action ("order ticket") mode. A quick tap places or acts on the reticle; * the scrub crosshair only appears on a deliberate hold, so a tap never flashes * it. Overridden by an explicit `scrub.panGestureDelay`. */ const SCRUB_ACTION_PRESS_HOLD_MS = 200; /** Clamp a Y pixel to the plot's vertical bounds. */ /* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */ function clampPlotY( y: number, padTop: number, canvasHeight: number, padBottom: number, ): number { "worklet"; return Math.min(canvasHeight - padBottom, Math.max(padTop, y)); } export type { CrosshairState, TooltipLayout } from "./crosshairShared"; export type { ScrubPoint }; export { computeCandleTooltipLayout, computeCrosshairOpacity, computeLineGapTooltipLayout, computeScrubTime, computeTooltipLayout, computeTooltipLayoutMulti, deriveCrosshairTooltipSingle, deriveScrubValueSingle, HIDDEN_TOOLTIP, } from "./crosshairShared"; export interface CrosshairChartOpts { mode: "line" | "candle"; candles?: SharedValue; liveCandle?: SharedValue; candleWidthSecs?: number; gaps?: CandleGap[]; bridgeNoTrades?: boolean; bridgeUnavailable?: boolean; bridgeUnknown?: boolean; } function resolveCrosshairSettings( chartOpts: CrosshairChartOpts | undefined, scrubAction: ResolvedScrubActionConfig | null | undefined, onScrubAction: ((point: ScrubActionPoint) => void) | undefined, clampToPlot: boolean, ) { const hasScrubAction = scrubAction != null; return { hasScrubAction, clampPlainScrubToPlot: clampToPlot && !hasScrubAction, hasOnScrubAction: onScrubAction != null, dismissOnTapOutside: scrubAction?.dismissOnTapOutside ?? false, dismissOnAction: scrubAction?.dismissOnAction ?? false, snapIncrement: scrubAction?.snap, actionIcon: scrubAction?.icon ?? "+", actionShowText: scrubAction?.text ?? true, hasTimeBadge: scrubAction?.timeBadge ?? false, isCandleMode: chartOpts?.mode === "candle", candlesSV: chartOpts?.candles, liveCandleSV: chartOpts?.liveCandle, candleWidthSecs: chartOpts?.candleWidthSecs ?? 60, chartGaps: chartOpts?.gaps ?? [], bridgeNoTrades: chartOpts?.bridgeNoTrades ?? false, bridgeUnavailable: chartOpts?.bridgeUnavailable ?? false, bridgeUnknown: chartOpts?.bridgeUnknown ?? false, }; } /** * Single-series crosshair + scrub. Use `useCrosshairSeries` for `LiveChartSeries`. */ export function useCrosshair( engine: SingleEngineState, padding: ChartPadding, _palette: LiveChartPalette, formatValue: (v: number) => string, formatTime: (t: number) => string, font: SkFont, enabled: boolean, onScrub?: (point: ScrubPoint | null) => void, chartOpts?: CrosshairChartOpts, /** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */ panGestureDelay = 0, onGestureStart?: () => void, onGestureEnd?: () => void, /** Scrub-action ("order ticket") config; `null`/omitted disables lock mode. */ scrubAction?: ResolvedScrubActionConfig | null, onScrubAction?: (point: ScrubActionPoint) => void, /** Badge geometry (gutter margins / pad) for the action pill. */ badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS, /** * Worklet predicate: true when a tap at (x, y) lands on another interactive * overlay (a marker, or a pressable reference-line badge). The scrub-action tap * defers to it (no reticle placed) so that overlay's tap handler can act. * Omitted when nothing coexists. */ deferTapHit?: (x: number, y: number) => boolean, /** Where the tooltip pill sits relative to the scrub line. Default `"side"`. */ tooltipPlacement: "side" | "top" | "bottom" | "point" = "side", /** Render the value row in the default tooltip. Default `true`. */ tooltipShowValue = true, /** Render the time row in the default tooltip. Default `true`. */ tooltipShowTime = true, /** Gap (px) between the tooltip and the plot edge it's pinned to. Default `8`. */ tooltipMargin = 8, /** * Height (px) of a bottom band to exclude from scrub recognition — the * axis-drag "grab the time ruler" strip, so a drag there scrolls instead of * scrubbing (and a vertical drag falls through to the parent). `0` = no * exclusion (the default; scrub covers the whole plot). */ scrubBottomExclude = 0, /** * True while the time-scroll pan owns the touch. The hold-to-scrub pan refuses * to activate while set, so a drag that already started scrolling can never * mature into a scrub (see `delayedPanGuard.shouldStartDelayedPan`). */ scrollActive?: SharedValue, /** * Reject plain-scrub starts beyond either horizontal plot edge and clamp * active drags to those bounds. Scrub-action behavior is unchanged. * Default `false`. */ clampToPlot = false, /** * Candle mode: quantize the plain-scrub X to the hovered candle's center * before it enters `scrubX` (`scrub.snapToCandles`). No-op in line mode. * Default `false`. */ snapToCandles = false, ): CrosshairState { const scrubX = useSharedValue(-1); const scrubActive = useSharedValue(false); // Tracks whether the active scrub phase actually began, so a tap that never // activates doesn't emit a spurious onGestureEnd. const gestureStarted = useSharedValue(false); // Guard state for RNGH's delayed-pan post-lift activation on iOS. The shared // lifecycle helpers are also used by LiveChartSeries and unit-tested directly. const fingerDown = useSharedValue(false); const panActivated = useSharedValue(false); // Where and when the current touch went down, for the stationary-hold and // stale-timer guards (see delayedPanGuard). const downX = useSharedValue(0); const downY = useSharedValue(0); const downAtMs = useSharedValue(0); // Latched once the finger leaves the hold slop: this touch is a drag, so its // long-press timer must not be honored even if it still fires. const holdBroken = useSharedValue(false); // Inert stand-in so the guard always has a latch to read when the controller // wires no time-scroll (hooks must be created unconditionally). const noScrollActive = useSharedValue(false); const scrollActiveSV = scrollActive ?? noScrollActive; // Where the crosshair line should start (canvas Y) so it stops at a top-pinned // custom tooltip instead of running through it. -1 = no top tooltip → the line // starts at padding.top. Written by CustomTooltipOverlay, read by CrosshairOverlay. const tooltipLineTop = useSharedValue(-1); // Scrub-action lock state. Created unconditionally (hooks must be), but the // lock gestures are only wired by the controller when `scrubAction` is set. const lockActive = useSharedValue(false); const lockX = useSharedValue(-1); // The chosen PRICE is the source of truth (frozen on place/drag); the reticle's // screen Y is re-derived from it each frame (see `lockY` below) so the level // tracks the price as the axis rescales instead of drifting under a fixed pixel. const lockPriceValue = useSharedValue(null); const { hasScrubAction, clampPlainScrubToPlot, hasOnScrubAction, dismissOnTapOutside, dismissOnAction, snapIncrement, actionIcon, actionShowText, hasTimeBadge, isCandleMode, candlesSV, liveCandleSV, candleWidthSecs, chartGaps, bridgeNoTrades, bridgeUnavailable, bridgeUnknown, } = resolveCrosshairSettings( chartOpts, scrubAction, onScrubAction, clampToPlot, ); // Monospace advance width, measured once per render (not per scrub frame) so // the tooltip layout worklet can size text by character count instead of a // per-frame Skia measureText. const monoCharWidth = font.measureText("0").width; const scrubTime = useSharedValue(-1); const scrubCandle = useSharedValue(null); const scrubGap = useSharedValue(null); const scrubValue = useSharedValue(null); const crosshairOpacity = useSharedValue(0); const scrubDotY = useSharedValue(-1); const tooltipLayout = useSharedValue(HIDDEN_TOOLTIP); // These six outputs traverse the same scrub + engine inputs. One reaction // computes and publishes the snapshot atomically instead of compiling six // independent derived-value mappers with chained dependencies. useAnimatedReaction( () => { "worklet"; const active = scrubActive.get(); const x = scrubX.get(); const canvasWidth = engine.canvasWidth.get(); const canvasHeight = engine.canvasHeight.get(); const time = computeScrubTime( active, x, padding, canvasWidth, engine.timestamp.get(), engine.displayWindow.get(), ); const candle = isCandleMode && candlesSV && active && time >= 0 ? pickCandleAtTime( candlesSV.get(), liveCandleSV?.get() ?? null, time, candleWidthSecs, ) : null; const lineData = engine.data.get(); const candidateGap = active && time >= 0 && (!isCandleMode || !candle) ? pickCandleGapAtTime(chartGaps, time) : null; const gap = candidateGap && !isCandleMode && lineGapContainsData(lineData, candidateGap) ? null : candidateGap; const gapBridged = gap ? gap.kind === "no-trades" ? bridgeNoTrades : gap.kind === "unavailable" ? bridgeUnavailable : bridgeUnknown : false; const gapValue = gap && gapBridged ? isCandleMode && candlesSV ? previousCandleCloseAtTime( candlesSV.get(), liveCandleSV?.get() ?? null, time, ) : previousLineValueAtTime(lineData, gap.from) : null; const value = !active || time < 0 ? null : isCandleMode ? (candle?.close ?? gapValue) : gap ? gapValue : interpolateAtTime(lineData, time); const dotY = computeScrubDotY( value, engine.displayMin.get(), engine.displayMax.get(), canvasHeight, padding.top, padding.bottom, ); const layout = isCandleMode ? computeCandleTooltipLayout( active, x, candle, time, padding, canvasWidth, formatValue, formatTime, font, monoCharWidth, gap, gapValue, ) : gap ? computeLineGapTooltipLayout( active, x, gap, time, padding, canvasWidth, formatTime, font, monoCharWidth, tooltipPlacement, tooltipShowValue, tooltipShowTime, canvasHeight, tooltipMargin, dotY, ) : deriveCrosshairTooltipSingle( active, x, time, value, padding, canvasWidth, formatValue, formatTime, font, monoCharWidth, tooltipPlacement, tooltipShowValue, tooltipShowTime, canvasHeight, tooltipMargin, dotY, ); return { time, candle, gap, value, opacity: computeCrosshairOpacity(active, x, canvasWidth, padding.right), dotY, layout, }; }, (snapshot) => { "worklet"; scrubTime.set(snapshot.time); scrubCandle.set(snapshot.candle); scrubGap.set(snapshot.gap); scrubValue.set(snapshot.value); crosshairOpacity.set(snapshot.opacity); scrubDotY.set(snapshot.dotY); tooltipLayout.set(snapshot.layout); }, ); // ── Scrub-action lock derivations ────────────────────────────────────────── const lockPrice = useSharedValue(null); const lockY = useSharedValue(-1); const lockTime = useSharedValue(-1); const lockCandle = useSharedValue(null); const actionBadge = useSharedValue(HIDDEN_ACTION_BADGE); const timeBadge = useSharedValue(HIDDEN_TIME_BADGE); // The lock price, projected coordinates, candle and both badges are one // coherent reticle snapshot. Publishing them from one reaction replaces six // chained derived-value mappers and prevents intermediate mixed-frame state. /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */ useAnimatedReaction( () => { "worklet"; const active = lockActive.get(); const x = lockX.get(); const rawPrice = lockPriceValue.get(); const canvasWidth = engine.canvasWidth.get(); const canvasHeight = engine.canvasHeight.get(); const price = active && rawPrice !== null ? snapPrice(rawPrice, snapIncrement) : null; let y = -1; if ( rawPrice !== null && canvasHeight - padding.top - padding.bottom > 0 ) { y = clampPlotY( computeScrubDotY( rawPrice, engine.displayMin.get(), engine.displayMax.get(), canvasHeight, padding.top, padding.bottom, ), padding.top, canvasHeight, padding.bottom, ); } const time = computeScrubTime( active, x, padding, canvasWidth, engine.timestamp.get(), engine.displayWindow.get(), ); const candle = isCandleMode && candlesSV && active && time >= 0 ? pickCandleAtTime( candlesSV.get(), liveCandleSV?.get() ?? null, time, candleWidthSecs, ) : null; const badge = price === null ? HIDDEN_ACTION_BADGE : computeActionBadgeLayout( active, y, actionShowText ? formatValue(price) : "", actionIcon, canvasWidth, canvasWidth - padding.right, font, badgeMetrics.marginEdge, badgeMetrics.padX, badgeMetrics.padY, ); const timeLayout = !hasTimeBadge || !active || time < 0 ? HIDDEN_TIME_BADGE : computeTimeBadgeLayout( active, x, formatTime(time), canvasWidth, canvasHeight - padding.bottom + X_AXIS_LABEL_OFFSET_Y, font, badgeMetrics.padX, badgeMetrics.padY, badgeMetrics.marginEdge, ); return { price, y, time, candle, badge, timeLayout }; }, (snapshot) => { "worklet"; lockPrice.set(snapshot.price); lockY.set(snapshot.y); lockTime.set(snapshot.time); lockCandle.set(snapshot.candle); actionBadge.set(snapshot.badge); timeBadge.set(snapshot.timeLayout); }, ); /* istanbul ignore next -- invoked only via scheduleOnRN from UI-thread gesture */ function handleScrubAction( price: number, time: number, x: number, y: number, candleJson: string | null, ) { const candle: CandlePoint | undefined = candleJson ? (JSON.parse(candleJson) as CandlePoint) : undefined; onScrubAction?.({ price, time, x, y, candle }); } /* istanbul ignore next -- invoked only via scheduleOnRN from UI-thread gesture */ function handleScrub( x: number, y: number, time: number, value: number, candleJson: string | null, gapJson: string | null, ) { const candle: CandlePoint | undefined = candleJson ? (JSON.parse(candleJson) as CandlePoint) : undefined; const gap: CandleGap | undefined = gapJson ? (JSON.parse(gapJson) as CandleGap) : undefined; onScrub?.({ time, value, x, y, candle, gap }); } /* istanbul ignore next */ function handleScrubEnd() { onScrub?.(null); } /* istanbul ignore next */ function handleGestureStart() { onGestureStart?.(); } /* istanbul ignore next */ function handleGestureEnd() { onGestureEnd?.(); } const hasOnScrub = onScrub != null; const hasOnGestureStart = onGestureStart != null; const hasOnGestureEnd = onGestureEnd != null; useAnimatedReaction( () => { "worklet"; if (!hasOnScrub) return "__idle__"; if (!scrubActive.get()) return "__inactive__"; const time = scrubTime.get(); const val = scrubValue.get(); const x = scrubX.get(); if (val === null || time < 0) return "__pending__"; const chartW = engine.canvasWidth.get() - padding.left - padding.right; if (chartW <= 0) return "__pending__"; const dotY = computeScrubDotY( val, engine.displayMin.get(), engine.displayMax.get(), engine.canvasHeight.get(), padding.top, padding.bottom, ); let candleJson: string | null = null; if (isCandleMode) { const c = scrubCandle.get(); if (c) candleJson = JSON.stringify(c); } const gap = scrubGap.get(); const gapJson = gap ? JSON.stringify(gap) : null; return JSON.stringify([time, val, x, dotY, candleJson, gapJson]); }, (curr, prev) => { "worklet"; if (!hasOnScrub) return; if ( curr === "__idle__" || curr === "__inactive__" || curr === "__pending__" ) { return; } if (curr === prev) return; const row = JSON.parse(curr) as [ number, number, number, number, string | null, string | null, ]; scheduleOnRN(handleScrub, row[2], row[3], row[0], row[1], row[4], row[5]); }, ); // In scrub-action mode the live scrub / lock-adjust requires a deliberate // press-hold: a quick tap (even a sloppy one that travels a few px) places or // acts on the reticle, and must never flash the crosshair. The press-hold is // what gates activation — and because movement during the hold *fails* the pan // (RNGH), a tap can't trip the scrub by moving. Honor an explicit // `scrub.panGestureDelay`, else default to the hold. Outside scrub-action the // pan keeps its normal (no-hold) activation unless a delay was set. const longPressMs = hasScrubAction && panGestureDelay <= 0 ? SCRUB_ACTION_PRESS_HOLD_MS : panGestureDelay; // `scrub.snapToCandles`: quantize the scrub X to the hovered candle's center // before it enters `scrubX`, so the crosshair — and everything derived from // it (time, tooltip, dim edge) — jumps candle-to-candle instead of gliding. // Line mode and gaps between candles pass the raw X through. /* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */ const snapCandleX = (x: number): number => { "worklet"; if (!snapToCandles || !isCandleMode || !candlesSV) return x; return snapScrubXToCandleCenter( x, candlesSV.get(), liveCandleSV?.get() ?? null, candleWidthSecs, padding, engine.canvasWidth.get(), engine.timestamp.get(), engine.displayWindow.get(), chartGaps, ); }; let gesture = Gesture.Pan() .maxPointers(1) .shouldCancelWhenOutside(false) .onTouchesDown( /* istanbul ignore next */ (e) => { "worklet"; delayedPanTouchDown( longPressMs, e, fingerDown, downX, downY, downAtMs, holdBroken, ); }, ) // Stationary hold: drift beyond the slop while the long-press is still // pending fails the pan, so a drag can never mature into a scrub. .onTouchesMove( /* istanbul ignore next */ (e, manager) => { "worklet"; delayedPanTouchMove( longPressMs, e, manager, panActivated, downX, downY, holdBroken, ); }, ) .onTouchesUp( /* istanbul ignore next */ (e, manager) => { "worklet"; delayedPanTouchUp(longPressMs, e, manager, fingerDown, panActivated); }, ) .onTouchesCancelled( /* istanbul ignore next */ () => { "worklet"; delayedPanTouchCancelled(longPressMs, fingerDown); }, ) // Start scrubbing on ACTIVE (onStart), not on touch-down (onBegin): // `activateAfterLongPress` only delays activation, so onBegin still fires // immediately — using it would scrub instantly and ignore panGestureDelay, // and leave scrubActive stuck for taps that never reach the long-press. .onStart( /* istanbul ignore next */ (e) => { "worklet"; if ( !shouldStartDelayedPan( longPressMs, fingerDown, panActivated, downAtMs, holdBroken, scrollActiveSV, ) ) return; if (!enabled) return; // Scrub-action: once a reticle is placed, drag adjusts it (2D — the Y is // the price). The ephemeral live-scrub never engages, so scrubActive // stays false and the live crosshair hides itself. if (hasScrubAction && lockActive.get()) { lockX.set( clampPlotX( e.x, padding.left, engine.canvasWidth.get(), padding.right, ), ); // Freeze the chosen price (value at the pointer Y); the line's Y is // re-derived from it so the level stays put in PRICE as the axis rescales. { const p = computeValueAtY( e.y, engine.displayMin.get(), engine.displayMax.get(), engine.canvasHeight.get(), padding.top, padding.bottom, ); if (p !== null) lockPriceValue.set(p); } return; } // Defer to a marker / pressable badge under the finger: don't start a // live scrub (or drop a crosshair) where the press is routed to an // overlay tap. `scrubActive` is only set here, so bailing in `onStart` // also keeps a follow-on drag from showing a crosshair — no `onUpdate` // guard needed. (Plain-scrub counterpart of the scrub-action tap defer.) if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return; startPlainScrub( snapCandleX(e.x), padding, engine.canvasWidth.get(), clampPlainScrubToPlot, scrubX, scrubActive, gestureStarted, ); if (hasOnGestureStart) scheduleOnRN(handleGestureStart); }, ) .onUpdate( /* istanbul ignore next */ (e) => { "worklet"; if (!enabled) return; if (hasScrubAction && lockActive.get()) { lockX.set( clampPlotX( e.x, padding.left, engine.canvasWidth.get(), padding.right, ), ); // Freeze the chosen price (value at the pointer Y); the line's Y is // re-derived from it so the level stays put in PRICE as the axis rescales. { const p = computeValueAtY( e.y, engine.displayMin.get(), engine.displayMax.get(), engine.canvasHeight.get(), padding.top, padding.bottom, ); if (p !== null) lockPriceValue.set(p); } return; } updatePlainScrub( snapCandleX(e.x), padding, engine.canvasWidth.get(), clampPlainScrubToPlot, scrubX, scrubActive, ); }, ) .onFinalize( /* istanbul ignore next */ () => { "worklet"; resetDelayedPanGuard(fingerDown, panActivated, holdBroken); // A lock-adjust drag leaves the reticle in place (scrubActive was never // set); a live-scrub clears its crosshair. Always clear scrubActive so a // stray scrub can never linger behind a placed reticle. if (scrubActive.get()) { scrubActive.set(false); if (hasOnScrub) scheduleOnRN(handleScrubEnd); } if (gestureStarted.get()) { gestureStarted.set(false); if (hasOnGestureEnd) scheduleOnRN(handleGestureEnd); } }, ); // Passing zero-valued activation modifiers lets RNGH's iOS pan recognizer // activate before the axis constraints classify the drag. Only configure a // hold when one is requested, and reserve minDistance(0) for scrub-action's // deliberate press-hold interaction. if (longPressMs > 0) { gesture = gesture.activateAfterLongPress(longPressMs); } if (hasScrubAction) { gesture = gesture.minDistance(0); } else { // Lock mode needs free vertical drag (Y = price), so the failOffsetY clamp — // which would kill a vertical adjust — is applied only outside scrub-action. gesture = gesture .activeOffsetX([-SCRUB_ACTIVATE_X_PX, SCRUB_ACTIVATE_X_PX]) .failOffsetY([-SCRUB_FAIL_Y_PX, SCRUB_FAIL_Y_PX]); } // Restrict recognition by touch-down position. This rejects before ACTIVE, // leaving outside starts to competing/parent gestures. Once accepted, // `shouldCancelWhenOutside(false)` keeps tracking beyond these bounds. const scrubHitSlop = resolveScrubHitSlop( padding, clampPlainScrubToPlot, scrubBottomExclude, ); if (scrubHitSlop) gesture = gesture.hitSlop(scrubHitSlop); // Tap: place/move the reticle, press the action badge, or dismiss the lock. // Composed ahead of the pan by the controller, so a tap is never swallowed. // Built only in scrub-action mode (the plain-scrub path never constructs a Tap). /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */ const handleActionTap = (e: { x: number; y: number }, success: boolean) => { "worklet"; if (!enabled || !hasScrubAction || !success) return; if (lockActive.get()) { // 1. Action-badge press → fire onScrubAction with the chosen price. const rect = actionBadge.get(); if (rect.visible && pointInRect(e.x, e.y, rect, ACTION_HIT_SLOP)) { const price = lockPrice.get(); if (price !== null) { let candleJson: string | null = null; if (isCandleMode) { const c = lockCandle.get(); if (c) candleJson = JSON.stringify(c); } if (hasOnScrubAction) scheduleOnRN( handleScrubAction, price, lockTime.get(), lockX.get(), lockY.get(), candleJson, ); // Clear the reticle once the action fires, so no crosshair lingers after // the order is placed via the badge. if (dismissOnAction) lockActive.set(false); } return; } // 2. Dismiss: tap the reticle itself, or any empty tap when configured. const onReticle = Math.abs(e.x - lockX.get()) <= RETICLE_HIT && Math.abs(e.y - lockY.get()) <= RETICLE_HIT; if (onReticle || dismissOnTapOutside) { lockActive.set(false); return; } } // 2.5. Defer to a marker / reference-line badge under the tap: when one // coexists, the tap fires both this handler and that overlay's tap // (Simultaneous) — yield so it acts instead of dropping a reticle on top. if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return; // 3. Place / move the reticle. Clear any in-progress live-scrub so its // crosshair never shows behind the placed reticle. scrubActive.set(false); lockX.set( clampPlotX(e.x, padding.left, engine.canvasWidth.get(), padding.right), ); // Freeze the chosen price at the tap Y; the line's Y derives from it (see // `lockY`). No-op until the canvas is laid out (price === null). const placedPrice = computeValueAtY( e.y, engine.displayMin.get(), engine.displayMax.get(), engine.canvasHeight.get(), padding.top, padding.bottom, ); if (placedPrice === null) return; lockPriceValue.set(placedPrice); lockActive.set(true); }; let tapGesture = hasScrubAction ? Gesture.Tap() .maxDuration(250) .maxDistance(SCRUB_ACTION_TAP_SLOP) .onEnd(handleActionTap) : undefined; // Keep the bottom axis scroll-only. Do not apply horizontal plot bounds here: // the action badge is intentionally tappable in the right gutter. const tapHitSlop = resolveScrubHitSlop(padding, false, scrubBottomExclude); if (tapGesture && tapHitSlop) tapGesture = tapGesture.hitSlop(tapHitSlop); return { scrubX, scrubActive, scrubTime, scrubValue, scrubCandle, scrubGap, crosshairOpacity, tooltipLayout, scrubDotY, tooltipLineTop, gesture, lockActive, lockX, lockY, lockPrice, actionBadge, timeBadge, tapGesture, }; }