import { DashPathEffect, Group, Line, Rect } from "@shopify/react-native-skia"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import { type ChartPadding } from "../draw/line"; import type { LiveChartPalette } from "../types"; import type { ResolvedSelectionDotConfig } from "../core/resolveConfig"; import type { ChartEngineLayout } from "../core/useLiveChartEngine"; import { useCrosshairVisibleOpacity } from "../hooks/useCrosshairVisibleOpacity"; import { SelectionDotSlot } from "./SelectionDot"; /** * Crosshair vertical line + dim region to the right. No tooltip pill — * multi-series delivers scrub data via `onScrub` / `onScrubWorklet` callbacks. */ export function CrosshairLine({ scrubX, crosshairOpacity, engine, padding, palette, selectionDot, selectionY, scrubActive, selectionColor, dimOpacity = 0.3, liveDotExtent = 0, crosshairLineColor, crosshairStrokeWidth = 1, crosshairOvershoot = 0, crosshairFade = true, crosshairFadeDistance = 4, crosshairLineCap, crosshairDash, crosshairDimColor, opaqueCanvas = false, }: { scrubX: SharedValue; crosshairOpacity: SharedValue; engine: ChartEngineLayout; padding: ChartPadding; palette: LiveChartPalette; /** Resolved selection-dot config; `null` hides it, a `component` renders the * consumer's dot, otherwise the built-in dot. */ selectionDot?: ResolvedSelectionDotConfig | null; /** Scrub intersection Y in canvas px (the value the dot marks); -1 hides it. */ selectionY?: SharedValue; /** Whether scrubbing is active (passed through to a custom dot). */ scrubActive: SharedValue | SharedValue; /** Fallback selection-dot color (leading-series color), used when the config's * own `color` is unset. */ selectionColor?: string; /** Opacity of content right of the crosshair (dstOut fade). Default 0.3. */ dimOpacity?: number; /** How far the live series dots extend past the plot's right edge. The dim * region extends by this much so it fully covers the dots — centered on that * edge, otherwise only half-dimmed — while leaving the value/Y-axis labels * the gutter reserves beyond them bright. Default 0. */ liveDotExtent?: number; crosshairLineColor?: string; /** Vertical crosshair line width in px. Default 1. */ crosshairStrokeWidth?: number; /** Resolved extension past the top and bottom plot edges in px. Default 0. */ crosshairOvershoot?: number; /** Fade the crosshair near the live edge. Default true. */ crosshairFade?: boolean; /** Visible-crosshair fade distance near the live edge in px. Default 4. */ crosshairFadeDistance?: number; /** Cap style for the vertical crosshair line. */ crosshairLineCap?: "butt" | "round" | "square"; /** Dash intervals `[on, off, …]` for the crosshair line; omit → solid. */ crosshairDash?: number[]; crosshairDimColor?: string; /** Paint the owned background instead of erasing destination alpha. */ opaqueCanvas?: boolean; }) { // Explicit dependency arrays: with React Compiler enabled, Reanimated's // auto-detected worklet dependencies can change array size between renders // (e.g. when `liveDotExtent` flips 0 → the live-dot extent), which trips // React's "final argument changed size between renders" error. Listing the // captured plain values keeps the dependency array a constant size. SharedValue // reads stay reactive regardless of this list. const p1 = useDerivedValue( () => ({ x: scrubX.value, y: padding.top - crosshairOvershoot, }), [scrubX, padding.top, crosshairOvershoot], ); const p2 = useDerivedValue( () => ({ x: scrubX.value, y: engine.canvasHeight.value - padding.bottom + crosshairOvershoot, }), [scrubX, engine.canvasHeight, padding.bottom, crosshairOvershoot], ); const dimWidth = useDerivedValue(() => { const rightEdge = engine.canvasWidth.value - padding.right + liveDotExtent; return Math.max(0, rightEdge - scrubX.value); }, [engine.canvasWidth, padding.right, liveDotExtent, scrubX]); const dimHeight = useDerivedValue( () => engine.canvasHeight.value - padding.top - padding.bottom, [engine.canvasHeight, padding.top, padding.bottom], ); // dstOut erase color: alpha = fraction of trailing content to remove, ramped // by the crosshair fade-in. RGB is irrelevant for dstOut. const dimErase = useDerivedValue( () => `rgba(0,0,0,${(1 - dimOpacity) * crosshairOpacity.value})`, [dimOpacity, crosshairOpacity], ); const opaqueDimOpacity = useDerivedValue( () => (1 - dimOpacity) * crosshairOpacity.value, [dimOpacity, crosshairOpacity], ); const backgroundColor = `rgb(${palette.bgRgb[0]},${palette.bgRgb[1]},${palette.bgRgb[2]})`; // The trailing dim deliberately keeps the original edge fade while this // visible group uses the configurable distance. const visibleOpacity = useCrosshairVisibleOpacity( scrubX, engine.canvasWidth, padding.right, scrubActive, crosshairFade, crosshairFadeDistance, ); return ( <> {crosshairDimColor !== undefined ? ( // Legacy: solid colored mask painted over the chart (opt-in). ) : dimOpacity < 1 && opaqueCanvas ? ( ) : dimOpacity < 1 ? ( // Erase the trailing content's alpha so it fades to the real background. ) : null} {crosshairDash ? : null} {/* Selection dot at the scrub intersection (leading series). `null` hides it; a custom component renders the consumer's dot. */} ); }