/** * UI-thread chart engine: time window, Y-range smoothing, and tick cadence pursue * the same “live chart” feel as liveline’s `requestAnimationFrame` loop; mechanism * here is Reanimated frame callbacks and SharedValues, not DOM canvas. * * @see https://github.com/benjitaylor/liveline */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { cancelAnimation, Easing, useAnimatedReaction, useDerivedValue, useFrameCallback, useSharedValue, withTiming, type SharedValue, } from "react-native-reanimated"; import { useDemandFrameLoop } from "../hooks/useDemandFrameLoop"; import { MS_PER_FRAME_60FPS, RETURN_TO_LIVE_MS } from "../constants"; import type { CandleGap, CandlePoint, LiveChartFrameStats, LiveChartPoint, RangeAnimationConfig, SeriesConfig, } from "../types"; import { tickLiveChartEngineFrame, type EngineTickInput, type EngineTickMutable, } from "./liveChartEngineTick"; import { makeHistoryRangeCache, type HistoryRangeCache, type HistoryRevision, } from "./historyRangeCache"; import { useHistoryRevision } from "./useHistoryRevision"; import type { ResolvedViewportConfig } from "./resolveConfig"; export interface EngineConfig { /** External scroll/zoom values adopted in place of private gesture state. */ viewport?: ResolvedViewportConfig | null; /** Original inputs before a reveal/stash bridge that may suppress same-array notifications. */ dataChangeSource?: SharedValue; candlesChangeSource?: SharedValue; data: SharedValue; value: SharedValue; timeWindow: number; smoothing: number; rangeAnimation?: RangeAnimationConfig; /** Extra catch-up speed added to `smoothing` when the live value lags. */ adaptiveSpeedBoost?: number; exaggerate?: boolean; referenceValue?: number; referenceValues?: number[]; /** * Live, per-frame Y values to fold into the axis-range fit on top of the static * {@link referenceValues} — used so a *dragging* reference line expands the range * and the axis follows the finger smoothly (the committed values already sit in * `referenceValues`). Read on the UI thread each frame; omit for non-draggable charts. */ liveReferenceValues?: SharedValue; /** * Time-varying threshold series folded into the axis-range fit * (`threshold.includeInRange`): each tick contributes the series' min/max over * the visible window — like {@link referenceValues}, but windowed. A * `SharedValue` for the live `threshold.series` form, a plain array for the * `LiveChartPoint[]` form. */ thresholdRangePoints?: SharedValue | LiveChartPoint[]; /** With {@link thresholdRangePoints}: whether the series extends flat before * its first point to the visible window start. Default `true`. */ thresholdRangeExtendToStart?: boolean; /** With {@link thresholdRangePoints}: whether the series extends flat past its * last point to "now" (`threshold.extendToNow`). Default `true`. */ thresholdRangeExtendToNow?: boolean; nonNegative?: boolean; maxValue?: number; minRange?: number; /** Positive, finite live Y-range multiplier (1 = auto-fit); read each frame. */ yRangeScale?: SharedValue; nowOverride?: number; windowBuffer?: number; paused?: boolean; /** * Whether the `timeScroll` gesture is active. Flipping this to `false` while * scrolled back clears {@link ChartEngineScroll.viewEnd} and **glides** the * window back to the live edge (a brief eased animation, not an instant jump), * so disabling time-scroll — or a mode switch that turns it off — never strands * the chart at a past position. See #164. */ scrollEnabled?: boolean; /** * Honor a `viewEnd` parked at or past the live edge — set when * `timeScroll.overscroll` is active so the pan/pinch gestures can drag into * blank future space. See {@link EngineTickInput.allowFutureViewEnd}. */ allowFutureViewEnd?: boolean; /** * Duration (ms) of the return-to-live glide when {@link scrollEnabled} flips to * `false` while scrolled back. `0` snaps instantly (no animation). Defaults to * {@link RETURN_TO_LIVE_MS}. Resolved from `timeScroll.returnToLive`. See #164. */ returnToLiveMs?: number; /** * Loop-free render: settle the display state once (smoothing forced to 1, no * per-frame frame callback) for many small charts in a list. See * {@link LiveChartProps.static}. */ static?: boolean; /** Runtime gate for the frame loop. Gestures and configuration stay mounted. */ isFrameLoopActive?: SharedValue; /** Experimental automatic idle scheduling, single-series only. */ autoSleep?: boolean; /** Effects that require continuous engine work. */ keepAwake?: SharedValue; /** Appearance/reveal changes that should wake a settled engine. */ wakeSignal?: SharedValue; /** Optional development-only frame publication counters. */ debugFrameStats?: SharedValue; /** * Opaque key that snaps the framing to its target in one frame whenever it * changes (the next tick bypasses `smoothing` for the window, Y-range, and * value, then normal easing resumes). Lets a timeframe / dataset switch land * instantly while live ticks stay smooth. See {@link LiveChartProps.snapKey}. */ snapKey?: string | number; mode?: "line" | "candle"; candles?: SharedValue; liveCandle?: SharedValue; candleGaps?: CandleGap[]; candleGapBridgeNoTrades?: boolean; candleGapBridgeUnavailable?: boolean; candleGapBridgeUnknown?: boolean; } /** Canvas, time window, and Y-range — shared by single- and multi-series engines. */ export interface ChartEngineLayout { displayMin: SharedValue; displayMax: SharedValue; /** Animating window (lerps toward {@link timeWindow}); use for positioning. */ displayWindow: SharedValue; /** * The *effective* target window — the value `displayWindow` eases toward: the * pinch-zoom override ({@link ChartEngineScroll.viewWindow}) when active, else * the `timeWindow` prop. Tick *selection* (which X-axis labels exist) must read * this, not `displayWindow`: the lerp is asymptotic and never reaches the * target, so it settles just above or just below it depending on the prior * window. Bucketing that off-by-epsilon value (e.g. via `niceTimeInterval`) * would otherwise make the tick cadence depend on where you came from. See * issue #126. */ timeWindow: SharedValue; canvasWidth: SharedValue; canvasHeight: SharedValue; timestamp: SharedValue; } /** * Live extrema (value + time of the lowest / highest data point in the visible * window) — the raw data high/low, not the smoothed display bounds. Each field * is `NaN` when the window holds no data. Provided by both engines so an * `"extrema"`-positioned `topLabel` / `bottomLabel` can be pinned at the point. */ export interface ChartEngineExtrema { extremaMinValue: SharedValue; extremaMaxValue: SharedValue; extremaMinTime: SharedValue; extremaMaxTime: SharedValue; } /** * Time-scroll state: lets a pan gesture freeze the window at an absolute time * and resume following the live edge. Returned by both engines (intersected onto * the state) but kept off {@link ChartEngineLayout} so overlay components — which * only read the layout — don't have to carry it. */ export interface ChartEngineScroll { /** * Absolute right-edge time (unix seconds) to freeze at, or `null` to follow * the live edge. Written by the pan-scroll gesture; read by the engine tick. */ viewEnd: SharedValue; /** * The right-edge time the engine would use if following live — advances each * frame even while {@link ChartEngineLayout.timestamp} is frozen by a pan. */ liveEdge: SharedValue; /** * Absolute visible-window width (seconds) to freeze at, or `null` to follow * the configured `timeWindow`. Written by the pinch-zoom gesture; the * symmetric counterpart of {@link viewEnd} (width vs. right edge). Folded into * {@link ChartEngineLayout.timeWindow}, so downstream selection/positioning * picks up the zoom for free. */ viewWindow: SharedValue; } /** Single-series only: smoothed price at the visible window's right edge. */ export interface ChartEngineEdge { /** * Smoothed value at the window's right edge — the live value while following, * the price at `viewEnd` while scrolled back. For a `followViewEdge` badge. */ edgeValue: SharedValue; } export interface SingleEngineState extends ChartEngineLayout, ChartEngineExtrema { data: SharedValue; value: SharedValue; displayValue: SharedValue; } export interface MultiEngineState extends ChartEngineLayout, ChartEngineExtrema { data: SharedValue; value: SharedValue; displayValue: SharedValue; /** Entries captured by the last measured engine frame; history arrays remain shared. */ series: SharedValue; displaySeriesValues: SharedValue; seriesOpacities: SharedValue; } export type EngineState = SingleEngineState | MultiEngineState; /** Canvas, range, and animated live value (badge / value line). */ export type ChartEngineWithLiveValue = ChartEngineLayout & { displayValue: SharedValue; }; export interface EngineFrameRefs { pointHistory?: SharedValue | undefined>; candleHistory?: SharedValue | undefined>; data: SharedValue; value: SharedValue; displayValue: SharedValue; displayMin: SharedValue; displayMax: SharedValue; displayWindow: SharedValue; canvasWidth: SharedValue; canvasHeight: SharedValue; timestamp: SharedValue; timeWindow: SharedValue; smoothing: SharedValue; rangeAnimationSV?: SharedValue; adaptiveSpeedBoostSV?: SharedValue; exaggerateSV: SharedValue; referenceValue: SharedValue; referenceValues?: SharedValue; /** Series threshold folded into the range fit (`threshold.includeInRange`). */ thresholdRangePoints?: SharedValue; thresholdRangeExtendToStart?: SharedValue; thresholdRangeExtendToNow?: SharedValue; nonNegativeSV?: SharedValue; maxValueSV?: SharedValue; minRangeSV?: SharedValue; yRangeScaleSV?: SharedValue; nowOverrideSV?: SharedValue; windowBufferSV?: SharedValue; pausedSV: SharedValue; /** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */ viewEndSV?: SharedValue; /** Honor a `viewEnd` past the live edge (timeScroll.overscroll). Optional. */ allowFutureViewEndSV?: SharedValue; /** "Return to live" glide progress (0→1); the right edge eases to live. Optional. */ returnTSV?: SharedValue; /** Frozen right-edge time the return glide starts from. Optional. */ returnFromSV?: SharedValue; /** Pinch-zoom window-width override (null = follow timeWindow). Optional for callers/tests. */ viewWindowSV?: SharedValue; /** Whether a non-null window override eases (false copies a drawn width exactly). */ viewWindowSmoothingSV?: SharedValue; /** Receives the computed live edge each frame. Optional for callers/tests. */ liveEdgeSV?: SharedValue; /** Receives the smoothed right-edge value each frame. Optional for callers/tests. */ edgeValueSV?: SharedValue; /** * One-shot "snap the framing this frame" flag (set by the `snapKey` effect). * Consumed and cleared by {@link applyLiveChartEngineFrame} after the tick. * Optional for callers/tests. */ snapSV?: SharedValue; modeSV: SharedValue<"line" | "candle">; candles?: SharedValue; liveCandle?: SharedValue; candleGapsSV?: SharedValue; candleGapBridgeNoTradesSV?: SharedValue; candleGapBridgeUnavailableSV?: SharedValue; candleGapBridgeUnknownSV?: SharedValue; extremaMinValue: SharedValue; extremaMaxValue: SharedValue; extremaMinTime: SharedValue; extremaMaxTime: SharedValue; } /** Stable state/input containers reused by the UI-thread frame callback. */ export interface EngineFrameScratch { historyCache: HistoryRangeCache; state: EngineTickMutable; input: EngineTickInput; } /** Allocate one single-series frame scratch. Call once per engine instance. */ export function makeEngineFrameScratch(): EngineFrameScratch { return { historyCache: makeHistoryRangeCache(), state: { displayValue: 0, displayMin: 0, displayMax: 1, displayWindow: 0, timestamp: 0, liveEdge: 0, edgeValue: 0, extremaMinValue: NaN, extremaMaxValue: NaN, extremaMinTime: NaN, extremaMaxTime: NaN, lastCanvasWidth: undefined, lastCanvasHeight: undefined, }, input: { dt: MS_PER_FRAME_60FPS, canvasWidth: 0, canvasHeight: 0, timeWindow: 0, smoothing: 0, exaggerate: false, referenceValue: undefined, targetValue: 0, points: [], }, }; } /** * Shared between the `useFrameCallback` worklet and unit tests. * Mutates shared values from a snapshot tick (`tickLiveChartEngineFrame`). */ export function applyLiveChartEngineFrame( frameInfo: { timeSincePreviousFrame?: number | null }, sv: EngineFrameRefs, scratch?: EngineFrameScratch, ): void { "worklet"; const dt = frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS; // One-shot settle: a `snapKey` change set this flag; consume it for this tick // and clear it below so only this frame snaps (live ticks stay smoothed). const snap = sv.snapSV?.value ?? false; const state: EngineTickMutable = scratch?.state ?? { displayValue: 0, displayMin: 0, displayMax: 1, displayWindow: 0, timestamp: 0, liveEdge: 0, edgeValue: 0, extremaMinValue: NaN, extremaMaxValue: NaN, extremaMinTime: NaN, extremaMaxTime: NaN, lastCanvasWidth: undefined, lastCanvasHeight: undefined, }; state.displayValue = sv.displayValue.value; state.displayMin = sv.displayMin.value; state.displayMax = sv.displayMax.value; state.displayWindow = sv.displayWindow.value; state.timestamp = sv.timestamp.value; state.liveEdge = sv.liveEdgeSV?.value ?? 0; state.edgeValue = sv.edgeValueSV?.value ?? 0; state.extremaMinValue = sv.extremaMinValue.value; state.extremaMaxValue = sv.extremaMaxValue.value; state.extremaMinTime = sv.extremaMinTime.value; state.extremaMaxTime = sv.extremaMaxTime.value; const input: EngineTickInput = scratch?.input ?? { dt, canvasWidth: 0, canvasHeight: 0, timeWindow: 0, smoothing: 0, exaggerate: false, referenceValue: undefined, targetValue: 0, points: [], }; input.dt = dt; input.canvasWidth = sv.canvasWidth.value; input.canvasHeight = sv.canvasHeight.value; input.timeWindow = sv.timeWindow.value; input.smoothing = sv.smoothing.value; input.rangeAnimation = sv.rangeAnimationSV?.value; input.adaptiveSpeedBoost = sv.adaptiveSpeedBoostSV?.value; input.exaggerate = sv.exaggerateSV.value; input.referenceValue = sv.referenceValue.value; input.referenceValues = sv.referenceValues?.value; input.thresholdRangePoints = sv.thresholdRangePoints?.value; input.thresholdRangeExtendToStart = sv.thresholdRangeExtendToStart?.value ?? true; input.thresholdRangeExtendToNow = sv.thresholdRangeExtendToNow?.value ?? true; input.nonNegative = sv.nonNegativeSV?.value ?? false; input.maxValue = sv.maxValueSV?.value; input.minRange = sv.minRangeSV?.value; input.yRangeScale = sv.yRangeScaleSV?.value ?? 1; input.nowOverride = sv.nowOverrideSV?.value; input.windowBuffer = sv.windowBufferSV?.value ?? 0; input.targetValue = sv.value.value; const pointHistory = sv.pointHistory?.value; const candleHistory = sv.candleHistory?.value; input.points = pointHistory?.data ?? sv.data.value; input.nowSeconds = Date.now() / 1000; input.paused = sv.pausedSV.value; input.snap = snap; input.viewEnd = sv.viewEndSV?.value; input.allowFutureViewEnd = sv.allowFutureViewEndSV?.value ?? false; input.returnT = sv.returnTSV?.value; input.returnFrom = sv.returnFromSV?.value; input.viewWindow = sv.viewWindowSV?.value; input.viewWindowSmoothing = sv.viewWindowSmoothingSV?.value ?? true; input.mode = sv.modeSV.value; input.candles = candleHistory?.data ?? sv.candles?.value; input.historyRevision = input.mode === "candle" ? candleHistory : pointHistory; input.liveCandle = sv.liveCandle?.value; input.candleGaps = sv.candleGapsSV?.value; input.candleGapBridgeNoTrades = sv.candleGapBridgeNoTradesSV?.value ?? false; input.candleGapBridgeUnavailable = sv.candleGapBridgeUnavailableSV?.value ?? false; input.candleGapBridgeUnknown = sv.candleGapBridgeUnknownSV?.value ?? false; tickLiveChartEngineFrame(state, input, scratch?.historyCache); sv.displayValue.value = state.displayValue; sv.displayMin.value = state.displayMin; sv.displayMax.value = state.displayMax; sv.displayWindow.value = state.displayWindow; sv.timestamp.value = state.timestamp; if (sv.liveEdgeSV) sv.liveEdgeSV.value = state.liveEdge; if (sv.edgeValueSV) sv.edgeValueSV.value = state.edgeValue; sv.extremaMinValue.value = state.extremaMinValue; sv.extremaMaxValue.value = state.extremaMaxValue; sv.extremaMinTime.value = state.extremaMinTime; sv.extremaMaxTime.value = state.extremaMaxTime; // Clear the one-shot snap so the next frame eases normally again — but only // once a real (measured) frame consumed it. The tick early-returns on a // zero-size canvas (before applying the snap), so keep the flag pending until // the canvas has laid out, or a snap arriving pre-layout would be dropped. if ( snap && sv.snapSV && sv.canvasWidth.value !== 0 && sv.canvasHeight.value !== 0 ) { sv.snapSV.value = false; } } export function useLiveChartEngine( config: EngineConfig, ): SingleEngineState & ChartEngineScroll & ChartEngineEdge & { wake: () => void } { const { viewport, data, dataChangeSource = data, value, timeWindow: configuredTimeWindow, smoothing: configuredSmoothing, rangeAnimation, adaptiveSpeedBoost, exaggerate, referenceValue: configuredReferenceValue, referenceValues: configuredReferenceValues, liveReferenceValues, thresholdRangePoints: configuredThresholdRangePoints, thresholdRangeExtendToNow: configuredThresholdRangeExtendToNow, thresholdRangeExtendToStart: configuredThresholdRangeExtendToStart, nonNegative, maxValue, minRange, yRangeScale, nowOverride, windowBuffer, paused, scrollEnabled, allowFutureViewEnd, returnToLiveMs, static: isStatic, isFrameLoopActive, debugFrameStats, autoSleep = false, keepAwake, wakeSignal, snapKey, mode, candles, candlesChangeSource = candles, liveCandle, candleGaps, candleGapBridgeNoTrades, candleGapBridgeUnavailable, candleGapBridgeUnknown, } = config; // Pinch-zoom window-width override (null = follow the configured window). // Declared first so `timeWindow` below can fold it in. Defaults to null so // charts without `zoom` behave exactly as before. const internalViewWindow = useSharedValue(null); const viewWindow = viewport?.window ?? internalViewWindow; const hasViewport = viewport != null; const windowSmoothing = viewport?.windowSmoothing ?? true; const viewWindowSmoothingSV = useDerivedValue(() => windowSmoothing); // A change to the `timeWindow` prop (a range / timeframe selector) is an explicit // request to set the window, so it clears any active pinch-zoom override — // otherwise the override below (`viewWindow ?? config.timeWindow`) would shadow // the new prop forever, and prop-driven window changes would silently no-op. // An app-owned viewport keeps its override until the app/handle clears it. useEffect(() => { if (hasViewport) return; viewWindow.set(null); }, [configuredTimeWindow, viewWindow, hasViewport]); // Low-frequency config → UI thread via useDerivedValue. `timeWindow` is the // *effective* target window: the zoom override when set, else the prop. Both // the tick's window lerp and the X-axis tick selection read it, so zoom flows // downstream for free (mirrors how viewEnd drives `timestamp`). const timeWindow = useDerivedValue( () => viewWindow.value ?? configuredTimeWindow, ); // Static charts snap to their target in one tick (smoothing=1), so the single // settle reaction below produces the final state with no per-frame easing. const smoothing = useDerivedValue(() => (isStatic ? 1 : configuredSmoothing)); const rangeAnimationSV = useDerivedValue(() => rangeAnimation); const adaptiveSpeedBoostSV = useDerivedValue(() => adaptiveSpeedBoost); const exaggerateSV = useDerivedValue(() => exaggerate ?? false); const referenceValue = useDerivedValue(() => configuredReferenceValue); // Captured directly (not via `config.`) so Reanimated tracks the SharedValue and // the merge re-runs each frame while a line is dragged; stable when idle. const liveReferenceValuesSV = liveReferenceValues; const referenceValues = useDerivedValue(() => { const base = configuredReferenceValues; const live = liveReferenceValuesSV?.value; if (!live || live.length === 0) return base; return base && base.length > 0 ? base.concat(live) : live; }); // Threshold range fold (`threshold.includeInRange`, series forms). Captured // directly (not via `config.`) like `liveReferenceValues`, so the live // `threshold.series` SharedValue is tracked and the tick sees fresh points. const thresholdRangeSourceSV = configuredThresholdRangePoints !== undefined && !Array.isArray(configuredThresholdRangePoints) ? configuredThresholdRangePoints : null; const thresholdRangePoints = useDerivedValue( () => thresholdRangeSourceSV ? thresholdRangeSourceSV.value : (configuredThresholdRangePoints as LiveChartPoint[] | undefined), ); const thresholdRangeExtendToNow = useDerivedValue( () => configuredThresholdRangeExtendToNow ?? true, ); const thresholdRangeExtendToStart = useDerivedValue( () => configuredThresholdRangeExtendToStart ?? true, ); const nonNegativeSV = useDerivedValue(() => nonNegative ?? false); const maxValueSV = useDerivedValue(() => maxValue); const minRangeSV = useDerivedValue(() => minRange); const nowOverrideSV = useDerivedValue(() => nowOverride); const windowBufferSV = useDerivedValue(() => windowBuffer ?? 0); const pausedSV = useDerivedValue(() => paused ?? false); // Whether time-scroll is active. Drives the return-to-live reaction below // (the tick no longer reads it — clearing `viewEnd` is what makes it follow). // Defaults to enabled so a caller that omits it behaves as before. const scrollEnabledSV = useDerivedValue(() => scrollEnabled ?? true); // External viewports can follow a pane whose forming bar ends past our live edge. const allowFutureViewEndSV = useDerivedValue( () => hasViewport || (allowFutureViewEnd ?? false), ); // Return-to-live glide duration (ms); 0 = instant snap. Read by the reaction. const returnToLiveMsSV = useDerivedValue( () => returnToLiveMs ?? RETURN_TO_LIVE_MS, ); const modeSV = useDerivedValue(() => mode ?? "line"); const candleGapsSV = useDerivedValue(() => candleGaps); const candleGapBridgeNoTradesSV = useDerivedValue( () => candleGapBridgeNoTrades ?? false, ); const candleGapBridgeUnavailableSV = useDerivedValue( () => candleGapBridgeUnavailable ?? false, ); const candleGapBridgeUnknownSV = useDerivedValue( () => candleGapBridgeUnknown ?? false, ); // Animation state (mutated on UI thread each frame) const displayValue = useSharedValue(0); const displayMin = useSharedValue(0); const displayMax = useSharedValue(1); const displayWindow = useSharedValue(configuredTimeWindow); const canvasWidth = useSharedValue(0); const canvasHeight = useSharedValue(0); // Seed once; overwritten by the frame callback on the first tick. const [initialTimestamp] = useState(() => Date.now() / 1000); const timestamp = useSharedValue(initialTimestamp); // Pan-scroll state. `viewEnd` null = follow live; a number freezes the right // edge at that absolute time. `liveEdge` mirrors the would-be-live timestamp // each frame so the gesture can clamp / detect catch-up. Both default to // "following" so charts without `timeScroll` behave exactly as before. const internalViewEnd = useSharedValue(null); const viewEnd = viewport?.end ?? internalViewEnd; const liveEdge = useSharedValue(initialTimestamp); const edgeValue = useSharedValue(0); // "Return to live" glide (see #164). When time-scroll is disabled while scrolled // back, the reaction below clears `viewEnd`, snapshots the frozen edge into // `returnFrom`, and animates `returnT` 0→1; the tick eases the right edge onto // the live edge over that progress. `returnT` rests at 1 (no glide in flight). const returnT = useSharedValue(1); const returnFrom = useSharedValue(0); // Live data extrema (value + time of the visible high / low). NaN until the // first tick finds data — the extrema label stays hidden until then. const extremaMinValue = useSharedValue(NaN); const extremaMaxValue = useSharedValue(NaN); const extremaMinTime = useSharedValue(NaN); const extremaMaxTime = useSharedValue(NaN); // High-frequency data reads directly from the caller's shared values — // no useDerivedValue bridging, no closure serialization per tick. // One-shot "snap the framing" flag. The effect below flips it to `true` when // `snapKey` changes; the next frame consumes it (bypassing `smoothing` for the // window / range / value) and clears it, so a timeframe / dataset switch lands // in one frame while live ticks stay smoothed. See `snapKey`. const snapSV = useSharedValue(false); // Compare against the previous key (not a "first render" flag) so React 18 // StrictMode's double-invoked mount effect can't fire a spurious snap. const lastSnapKey = useRef(snapKey); // Layout effect, not passive: a passive effect runs after the commit has // painted, so the frame between them draws the new data/candleWidth against // the OLD framing (a one-frame squeeze on a timeframe switch). Flipping the // flag before paint lets the first frame with the new props consume the snap. useLayoutEffect(() => { if (snapKey === lastSnapKey.current) return; lastSnapKey.current = snapKey; snapSV.set(true); }, [snapKey, snapSV]); // Static charts run zero per-frame loops. `isStaticSV` gates both the // frame-callback autostart and the one-shot settle reaction below. const isStaticSV = useDerivedValue(() => isStatic ?? false); // Single refs object shared by the frame callback and the settle reaction so // they can never drift out of sync. Keep its identity stable across unrelated // React renders: Reanimated uses captured closure values as implicit reaction // dependencies, and rebuilding this object would unregister/re-register the // static settle mapper behind its path consumers. See #329. // Observe original feeds as well as reveal bridges: same-array assignments // may suppress bridge notifications. Live candle changes stay uncached. const pointHistory = useHistoryRevision(data, dataChangeSource); const candleHistory = useHistoryRevision(candles, candlesChangeSource); const frameRefs = useMemo( () => ({ pointHistory, candleHistory, data, value, displayValue, displayMin, displayMax, displayWindow, timestamp, canvasWidth, canvasHeight, timeWindow, smoothing, rangeAnimationSV, adaptiveSpeedBoostSV, exaggerateSV, referenceValue, referenceValues, thresholdRangePoints, thresholdRangeExtendToStart, thresholdRangeExtendToNow, nonNegativeSV, maxValueSV, minRangeSV, yRangeScaleSV: yRangeScale, nowOverrideSV, windowBufferSV, pausedSV, viewEndSV: viewEnd, allowFutureViewEndSV, returnTSV: returnT, returnFromSV: returnFrom, viewWindowSV: viewWindow, viewWindowSmoothingSV, liveEdgeSV: liveEdge, edgeValueSV: edgeValue, snapSV, modeSV, candles, liveCandle, candleGapsSV, candleGapBridgeNoTradesSV, candleGapBridgeUnavailableSV, candleGapBridgeUnknownSV, extremaMinValue, extremaMaxValue, extremaMinTime, extremaMaxTime, }), [ pointHistory, candleHistory, adaptiveSpeedBoostSV, allowFutureViewEndSV, candleGapBridgeNoTradesSV, candleGapBridgeUnavailableSV, candleGapBridgeUnknownSV, candleGapsSV, candles, canvasHeight, canvasWidth, data, displayMax, displayMin, displayValue, displayWindow, edgeValue, exaggerateSV, extremaMaxTime, extremaMaxValue, extremaMinTime, extremaMinValue, liveCandle, liveEdge, maxValueSV, minRangeSV, modeSV, nonNegativeSV, nowOverrideSV, pausedSV, referenceValue, referenceValues, returnFrom, returnT, smoothing, rangeAnimationSV, snapSV, thresholdRangeExtendToNow, thresholdRangeExtendToStart, thresholdRangePoints, timeWindow, timestamp, value, viewEnd, viewWindow, viewWindowSmoothingSV, windowBufferSV, yRangeScale, ], ); const frameScratchRef = useRef(null); if (frameScratchRef.current === null) { // React permits this predictable lazy-ref initialization: https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents // react-doctor-disable-next-line react-doctor/no-ref-current-in-render -- false positive for React's documented lazy-ref exception frameScratchRef.current = makeEngineFrameScratch(); } const tickFrame = useCallback((dt: number): boolean => { "worklet"; if (isFrameLoopActive?.get() === false) return false; if (!autoSleep && !debugFrameStats) { applyLiveChartEngineFrame({ timeSincePreviousFrame: dt }, frameRefs, frameScratchRef.current!); return true; } const beforeValue = displayValue.get(); const beforeMin = displayMin.get(); const beforeMax = displayMax.get(); const beforeWindow = displayWindow.get(); const beforeTimestamp = timestamp.get(); const beforeLiveEdge = liveEdge.get(); const beforeEdgeValue = edgeValue.get(); applyLiveChartEngineFrame({ timeSincePreviousFrame: dt }, frameRefs, frameScratchRef.current!); const changed = beforeValue !== displayValue.get() || beforeMin !== displayMin.get() || beforeMax !== displayMax.get() || beforeWindow !== displayWindow.get() || beforeTimestamp !== timestamp.get() || beforeEdgeValue !== edgeValue.get(); if (debugFrameStats) { const didPublish = changed || beforeLiveEdge !== liveEdge.get(); const previous = debugFrameStats.get(); debugFrameStats.set({ frames: previous.frames + 1, published: previous.published + (didPublish ? 1 : 0), skipped: previous.skipped + (didPublish ? 0 : 1) }); } // The liveEdge clock alone is not a visible animation while parked/paused. // Do not sleep if a stranded viewEnd actually fell back to following live. const parked = viewEnd.get() !== null && timestamp.get() === viewEnd.get(); const clockMoves = nowOverrideSV.get() === undefined && !pausedSV.get() && !parked; return changed || clockMoves || keepAwake?.get() === true || returnT.get() < 1; }, [autoSleep, isFrameLoopActive, displayValue, displayMin, displayMax, displayWindow, timestamp, liveEdge, edgeValue, frameRefs, frameScratchRef, debugFrameStats, viewEnd, nowOverrideSV, pausedSV, keepAwake, returnT]); const prepareWake = useCallback(() => { "worklet"; // Read the arrays themselves, not a length/endpoints fingerprint: modify() // can change an interior value without changing any of those quantities. // History revisions also notify when a reveal bridge suppresses a same-array // source update. A sleeping engine must wake to consume that invalidation. return [pointHistory.get(), candleHistory.get(), data.get(), value.get(), candles?.get(), liveCandle?.get(), canvasWidth.get(), canvasHeight.get(), timeWindow.get(), smoothing.get(), rangeAnimationSV.get(), adaptiveSpeedBoostSV.get(), exaggerateSV.get(), referenceValue.get(), referenceValues.get(), thresholdRangePoints.get(), thresholdRangeExtendToStart.get(), thresholdRangeExtendToNow.get(), nonNegativeSV.get(), maxValueSV.get(), minRangeSV.get(), yRangeScale?.get(), nowOverrideSV.get(), windowBufferSV.get(), pausedSV.get(), viewEnd.get(), viewWindow.get(), viewWindowSmoothingSV.get(), allowFutureViewEndSV.get(), returnT.get(), returnFrom.get(), snapSV.get(), modeSV.get(), candleGapsSV.get(), candleGapBridgeNoTradesSV.get(), candleGapBridgeUnavailableSV.get(), candleGapBridgeUnknownSV.get(), isFrameLoopActive?.get(), keepAwake?.get(), wakeSignal?.get()]; }, [pointHistory, candleHistory, data, value, candles, liveCandle, canvasWidth, canvasHeight, timeWindow, smoothing, rangeAnimationSV, adaptiveSpeedBoostSV, exaggerateSV, referenceValue, referenceValues, thresholdRangePoints, thresholdRangeExtendToStart, thresholdRangeExtendToNow, nonNegativeSV, maxValueSV, minRangeSV, yRangeScale, nowOverrideSV, windowBufferSV, pausedSV, viewEnd, viewWindow, viewWindowSmoothingSV, allowFutureViewEndSV, returnT, returnFrom, snapSV, modeSV, candleGapsSV, candleGapBridgeNoTradesSV, candleGapBridgeUnavailableSV, candleGapBridgeUnknownSV, isFrameLoopActive, keepAwake, wakeSignal]); const wakeLoop = useDemandFrameLoop(autoSleep && !isStatic, prepareWake, tickFrame); const wake = useCallback(() => { "worklet"; if (!autoSleep || isStatic) return; // Gestures need a fresh live boundary BEFORE clamping their first delta. if (isFrameLoopActive?.get() !== false) { liveEdge.set((nowOverrideSV.get() ?? Date.now() / 1000) + windowBufferSV.get() * timeWindow.get()); } wakeLoop(); }, [autoSleep, isStatic, isFrameLoopActive, liveEdge, nowOverrideSV, windowBufferSV, timeWindow, wakeLoop]); const legacyActive = !isStatic && !autoSleep; const engineFrameCallback = useFrameCallback((frameInfo) => { "worklet"; tickFrame(frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS); }, legacyActive); useEffect(() => { engineFrameCallback.setActive(legacyActive); }, [legacyActive, engineFrameCallback]); // When time-scroll is disabled while scrolled back, return the window to the // live edge. With a positive duration this glides: snapshot the frozen edge into // `returnFrom`, clear `viewEnd` (so the tick stops freezing and follows), and // animate `returnT` 0→1 — the tick interpolates `returnFrom`→live by that // progress, landing exactly on live. Duration 0 (`returnToLive: false`) just // clears `viewEnd` for an instant snap. Clearing `viewEnd` also means a later // re-enable resumes from live, not the stale frozen position. All on the UI // thread, no JS round-trip. See #164. useAnimatedReaction( () => scrollEnabledSV.value, /* istanbul ignore next -- Reanimated reaction driven by a prop→derived change; not exercised under the SharedValue mock (see the viewWindow-reset test note), verified in-app */ (enabled, prev) => { if (hasViewport) return; if (prev === true && !enabled && viewEnd.value != null) { const ms = returnToLiveMsSV.value; if (ms > 0) { returnFrom.value = viewEnd.value; cancelAnimation(viewEnd); viewEnd.value = null; returnT.value = 0; returnT.value = withTiming(1, { duration: ms, easing: Easing.out(Easing.cubic), }); } else { cancelAnimation(viewEnd); viewEnd.value = null; // returnT stays 1 → tick pins to live (instant) } } }, ); // One-shot settle for static charts: the fingerprint changes when the canvas // lays out (width 0→real) or the framing inputs change, and the handler runs a // single snap tick (smoothing=1). Inert when not static — `prepare` returns a // constant so it never fires. useAnimatedReaction( () => { if (!isStaticSV.get()) return null; // inert when not static return { revision: modeSV.get() === "candle" ? candleHistory.get() : pointHistory.get(), fingerprint: [ value.get(), canvasWidth.get(), canvasHeight.get(), timeWindow.get(), viewEnd.get(), viewWindowSmoothingSV.get(), // `nowOverride` sets the right edge and `smoothing` changes when // `static` flips. Reading them here makes them inputs of this mapper, // so a same-render prop change can't settle on their previous values. nowOverrideSV.get(), smoothing.get(), // Range props can arrive after the data shared value. Re-settle when // those bounds change too, rather than retaining the previous scale // until another data/layout update (static charts have no frame loop). maxValueSV.get(), minRangeSV.get(), nonNegativeSV.get(), yRangeScale?.get(), referenceValue.get(), ...(referenceValues.get() ?? []), ].join(","), }; }, (curr, prev) => { if ( !isStaticSV.get() || curr === null || (curr.revision === prev?.revision && curr.fingerprint === prev?.fingerprint) ) return; applyLiveChartEngineFrame( { timeSincePreviousFrame: MS_PER_FRAME_60FPS }, frameRefs, frameScratchRef.current!, ); }, // Reanimated otherwise derives dependencies from closure values. Keeping // this mapper registered in its original hook-order position ensures range // settling runs before the path mappers on a static data swap. See #329. [frameRefs, frameScratchRef], ); return { wake, data, value, displayValue, displayMin, displayMax, displayWindow, timeWindow, canvasWidth, canvasHeight, timestamp, viewEnd, viewWindow, liveEdge, edgeValue, extremaMinValue, extremaMaxValue, extremaMinTime, extremaMaxTime, }; }