import { useCallback, useEffect } from "react"; import { useDerivedValue, useFrameCallback, useSharedValue, type SharedValue, } from "react-native-reanimated"; import { CANDLE_METRICS_DEFAULTS, MS_PER_FRAME_60FPS } from "../constants"; import type { SingleEngineState } from "../core/useLiveChartEngine"; import { buildCandleBodyPath } from "../draw/candleBodyPath"; import { buildCandleGeometry } from "../draw/candle"; import { buildVolumeGeometry } from "../draw/volume"; import type { ChartPadding } from "../draw/line"; import { lerp } from "../math/lerp"; import type { CandleMetrics, CandlePoint } from "../types"; import { useVolumeRange } from "./useVolumeRange"; import { usePathBuilder } from "./usePathBuilder"; import { useDemandFrameLoop } from "./useDemandFrameLoop"; const CANDLE_WIDTH_LERP_SPEED = 0.08; /** * Bridges the `candleWidth` prop to the UI thread and eases the displayed * width toward it each frame. Must be called from the chart's OUTER component * (the controller), not from inside the Skia canvas: canvas children render * through Skia's own reconciler one commit behind the outer tree, so a width * bridged in there lands one frame after the engine's framing targets — a * timeframe switch then draws one frame of new data at the old width. See #176. */ export function useCandleWidthLerp( candleWidthSecs: number, /** * Per-frame lerp speed (0–1) for the candle-body width as it eases toward * `candleWidthSecs`. `1` snaps in one frame (no "fat → thin" slide on a * timeframe / bucket change); `undefined` uses {@link CANDLE_WIDTH_LERP_SPEED}. * Resolved from `transitions.candleLerpSpeed`. See #176. */ candleLerpSpeed: number | undefined, /** Static charts run no loops: register without starting. */ autostart: boolean, /** Only advance the displayed width while candle mode is active. */ active: boolean, /** Runtime gate shared with the chart engine. */ isFrameLoopActive?: SharedValue, autoSleep = false, ): SharedValue { const targetCandleWidth = useDerivedValue(() => candleWidthSecs); const displayCandleWidth = useSharedValue(candleWidthSecs); // `transitions.candleLerpSpeed` overrides the built-in speed (0–1, already // clamped by `resolveTransitions`); fall back to the default when unset. // Bridged through a derived value so the frame-callback worklet always reads // the current speed on the UI thread (mirrors `targetCandleWidth`). const widthLerpSpeed = useDerivedValue( () => candleLerpSpeed ?? CANDLE_WIDTH_LERP_SPEED, ); const tickWidth = useCallback((dt: number) => { "worklet"; if (!active || isFrameLoopActive?.get() === false) return false; const previous = displayCandleWidth.get(); const target = targetCandleWidth.get(); const next = lerp( displayCandleWidth.get(), target, widthLerpSpeed.get(), dt, ); const settled = Math.abs(target - next) < 0.01 ? target : next; displayCandleWidth.set(settled); return previous !== settled; }, [active, isFrameLoopActive, displayCandleWidth, targetCandleWidth, widthLerpSpeed]); const prepareWidth = useCallback(() => { "worklet"; return [targetCandleWidth.get(), widthLerpSpeed.get(), isFrameLoopActive?.get()]; }, [targetCandleWidth, widthLerpSpeed, isFrameLoopActive]); useDemandFrameLoop(autoSleep && autostart && active, prepareWidth, tickWidth); const legacyActive = autostart && !autoSleep; const widthFrameCallback = useFrameCallback((frameInfo) => { "worklet"; tickWidth(frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS); }, legacyActive); useEffect(() => { // `autostart` is an initial seed in Reanimated, not a reactive switch. widthFrameCallback.setActive(legacyActive); }, [legacyActive, widthFrameCallback]); return displayCandleWidth; } /** * Candle paths (up/down bodies + up/down wicks). Each is built into a reused * `Skia.PathBuilder` and finalized with `detach()` each frame — a fresh * immutable `SkPath`, so Skia repaints without a per-curve ping-pong and no * mutable `SkPath` is retained across frames. */ export function useCandlePaths( engine: SingleEngineState, padding: ChartPadding, candles: SharedValue | undefined, liveCandle: SharedValue | undefined, /** Displayed bucket width from {@link useCandleWidthLerp} (outer component). */ displayCandleWidth: SharedValue, active: boolean, candleMetrics: CandleMetrics = CANDLE_METRICS_DEFAULTS, /** Reserved volume-band height (px). `0` = no volume bars. */ volumeBandHeight = 0, /** Corner radius (px) of the volume bars. */ volumeRadius = 0, ) { const upBodiesBuilder = usePathBuilder(); const downBodiesBuilder = usePathBuilder(); const upWicksBuilder = usePathBuilder(); const downWicksBuilder = usePathBuilder(); const upBarsBuilder = usePathBuilder(); const downBarsBuilder = usePathBuilder(); /* istanbul ignore next -- worklet */ const geometry = useDerivedValue(() => { if (!active || !candles) return { bodies: [], wicks: [] }; return buildCandleGeometry( candles.value, liveCandle?.value ?? null, padding, engine.canvasWidth.value, engine.canvasHeight.value, engine.timestamp.value - engine.displayWindow.value, engine.displayWindow.value, engine.displayMin.value, engine.displayMax.value, displayCandleWidth.get(), candleMetrics, ); }); /* istanbul ignore next -- worklet */ const upBodiesPath = useDerivedValue(() => buildCandleBodyPath( upBodiesBuilder.get(), geometry.get().bodies, true, candleMetrics.bodyRadius, ), ); /* istanbul ignore next -- worklet */ const downBodiesPath = useDerivedValue(() => buildCandleBodyPath( downBodiesBuilder.get(), geometry.get().bodies, false, candleMetrics.bodyRadius, ), ); /* istanbul ignore next -- worklet */ const upWicksPath = useDerivedValue(() => { const b = upWicksBuilder.value; const { wicks } = geometry.value; for (let i = 0; i < wicks.length; i++) { if (wicks[i].up) { b.moveTo(wicks[i].x, wicks[i].y1); b.lineTo(wicks[i].x, wicks[i].y2); } } return b.detach(); }); /* istanbul ignore next -- worklet */ const downWicksPath = useDerivedValue(() => { const b = downWicksBuilder.value; const { wicks } = geometry.value; for (let i = 0; i < wicks.length; i++) { if (!wicks[i].up) { b.moveTo(wicks[i].x, wicks[i].y1); b.lineTo(wicks[i].x, wicks[i].y2); } } return b.detach(); }); // Volume bars share the candle window + (lerped) candle width so each bar sits // directly under its candle body. Empty unless a volume band is reserved. /* istanbul ignore next -- worklet */ const volumeRange = useVolumeRange( candles, engine.timestamp, engine.displayWindow, displayCandleWidth, active && volumeBandHeight > 0, ); const volumeGeometry = useDerivedValue(() => { if (!active || !candles || volumeBandHeight <= 0) return { bars: [] }; return buildVolumeGeometry( candles.value, liveCandle?.value ?? null, padding, engine.canvasWidth.value, engine.canvasHeight.value, engine.timestamp.value - engine.displayWindow.value, engine.displayWindow.value, volumeBandHeight, displayCandleWidth.get(), candleMetrics, { start: volumeRange.start.get(), end: volumeRange.end.get(), max: volumeRange.max.get(), }, ); }); /* istanbul ignore next -- worklet */ const upBarsPath = useDerivedValue(() => buildCandleBodyPath( upBarsBuilder.get(), volumeGeometry.get().bars, true, volumeRadius, ), ); /* istanbul ignore next -- worklet */ const downBarsPath = useDerivedValue(() => buildCandleBodyPath( downBarsBuilder.get(), volumeGeometry.get().bars, false, volumeRadius, ), ); return { upBodiesPath, downBodiesPath, upWicksPath, downWicksPath, upBarsPath, downBarsPath, } as const; }