import type { CandleGap, CandlePoint, LiveChartPoint, RangeAnimationConfig, } from "../types"; import { historyRange, makeHistoryRangeCache, type HistoryRangeCache, } from "./historyRangeCache"; import { MOTION_METRICS_DEFAULTS } from "../constants"; import { lerp } from "../math/lerp"; import { rangeAnimationSpeed } from "../math/rangeAnimation"; import { thresholdRangeMinMax } from "../math/threshold"; import { previousCandleCloseAtTime } from "../math/candleGaps"; /** Scratch for the threshold range fold — only alive within one tick call. */ const THRESHOLD_RANGE_SCRATCH: [number, number] = [0, 0]; export interface EngineTickMutable { displayValue: number; displayMin: number; displayMax: number; displayWindow: number; timestamp: number; /** * The right-edge time the engine would use if it were following live — * `now (+ windowBuffer)`. Equals {@link timestamp} while following; when * scrolled back in time (see {@link EngineTickInput.viewEnd}) it keeps * advancing while `timestamp` stays frozen. Exposed so the pan-scroll gesture * can clamp against the live edge and detect catch-up. */ liveEdge: number; /** * Smoothed value at the visible window's right edge: the live value while * following, or the price at `viewEnd` while scrolled back. Lets a badge track * the last visible price as you pan (see `badge.followViewEdge`). */ edgeValue: number; /** * Value + time of the lowest / highest data point in the visible window — * the actual extrema, NOT the smoothed display bounds (which carry margin and * fold in the live value / reference lines). `NaN` when the window holds no * data. Used to float `topLabel` / `bottomLabel` at their occurrence point * (see {@link AxisLabelConfig.position} `"extrema"`). */ extremaMinValue: number; extremaMaxValue: number; extremaMinTime: number; extremaMaxTime: number; /** Previous frame's yRangeScale — detects an in-flight scale drag. */ lastYRangeScale?: number; /** Canvas width observed by the previous measured tick. */ lastCanvasWidth?: number; /** Canvas height observed by the previous measured tick. */ lastCanvasHeight?: number; } export interface EngineTickInput { dt: number; canvasWidth: number; canvasHeight: number; timeWindow: number; smoothing: number; rangeAnimation?: RangeAnimationConfig; exaggerate: boolean; /** Extra catch-up speed added to `smoothing` when the live value lags. Default `0.12`. */ adaptiveSpeedBoost?: number; referenceValue: number | undefined; /** Additional reference values (lines + bands) folded into the Y range. */ referenceValues?: number[]; /** * Time-varying threshold series (`threshold.includeInRange`): its min/max over * the visible window is folded into the Y range, like {@link referenceValues} * but windowed each tick. */ thresholdRangePoints?: LiveChartPoint[]; /** Whether {@link thresholdRangePoints} extends flat before its first point to * the visible window start (`threshold.extendToStart`). Default `true`. */ thresholdRangeExtendToStart?: boolean; /** Whether {@link thresholdRangePoints} extends flat past its last point to * "now" (`threshold.extendToNow`). Default `true`. */ thresholdRangeExtendToNow?: boolean; /** Clamp the computed lower bound at 0. */ nonNegative?: boolean; /** Hard cap for the computed upper bound. */ maxValue?: number; /** Smallest fitted span, in data units; widened around the data. */ minRange?: number; /** Positive, finite Y-range multiplier around the fitted midpoint (1 = auto-fit). */ yRangeScale?: number; targetValue: number; points: LiveChartPoint[]; /** Internal notification revision for the selected history; omit to disable reuse. */ historyRevision?: object; /** Seconds since Unix epoch; defaults to `Date.now() / 1000` */ nowSeconds?: number; /** Override the engine's "now" (unix seconds) — e.g. fill historical data edge-to-edge. */ nowOverride?: number; /** Right-edge buffer as a fraction of the time window (pushes the live edge past "now"). */ windowBuffer?: number; /** When true, freeze the viewport timestamp and skip displayWindow lerp */ paused?: boolean; /** * Settle the framing in this one frame instead of easing into it: the time * window ({@link EngineTickMutable.displayWindow}), Y-range * ({@link EngineTickMutable.displayMin}/{@link EngineTickMutable.displayMax}), * and value ({@link EngineTickMutable.displayValue}/{@link EngineTickMutable.edgeValue}) * jump straight to their targets — `smoothing` is bypassed for this tick only. * Set for the single frame after a `snapKey` change so a timeframe / dataset * switch lands instantly while live ticks keep their normal smoothing. Leaves * the timestamp / pan-scroll state untouched. See {@link LiveChartProps.snapKey}. */ snap?: boolean; /** * Absolute right-edge time (unix seconds) to freeze the window at, or * `null`/`undefined` to follow the live edge. Set by the pan-scroll gesture * to scroll back in time; once it reaches (or passes) the live edge the engine * resumes following. Takes precedence over {@link paused}. */ viewEnd?: number | null; /** * Honor a {@link viewEnd} parked at or past the live edge (blank future space) * instead of falling through to following live. Set when `timeScroll.overscroll` * is active — the pan/pinch gestures may then park the right edge beyond the * live edge, and the engine must freeze there or the gestures render nothing. * The `viewEnd >= firstDataTime` strand-guard applies in both modes. Default * `false` (classic behavior: only a past `viewEnd` freezes). */ allowFutureViewEnd?: boolean; /** * "Return to live" glide (see #164). When time-scroll is disabled while scrolled * back, the engine hook clears {@link viewEnd} and animates {@link returnT} from * `0`→`1`; each frame the right edge interpolates from {@link returnFrom} (the * frozen edge captured at that moment) to the *current* live edge by `returnT`, * so the window glides forward and lands exactly on live (no end-snap). At * `1`/`undefined` it pins to the live edge — the normal follow behavior. */ returnT?: number; /** Frozen right-edge time the {@link returnT} glide starts from. See {@link returnT}. */ returnFrom?: number; /** * Absolute visible-window width (seconds) to freeze at, or `null`/`undefined` * to follow the configured {@link timeWindow}. Set by the pinch-zoom gesture * (see `usePinchZoom` / the `zoom` prop). The symmetric counterpart of * {@link viewEnd}: `viewEnd` overrides the window's right edge, `viewWindow` * overrides its width. `displayWindow` eases toward this when set. */ viewWindow?: number | null; /** Ease a non-null viewWindow override. False adopts a leader's drawn width exactly. Default true. */ viewWindowSmoothing?: boolean; /** Chart mode — `"candle"` uses OHLC bars for Y range instead of line points. */ mode?: "line" | "candle"; /** Committed OHLC bars (sorted by time). Used when mode is `"candle"`. */ candles?: CandlePoint[]; /** In-progress candle. Included in Y range when mode is `"candle"`. */ liveCandle?: CandlePoint | null; /** Explicit semantic gaps used only for previous-close range anchoring. */ candleGaps?: CandleGap[]; candleGapBridgeNoTrades?: boolean; candleGapBridgeUnavailable?: boolean; candleGapBridgeUnknown?: boolean; } const SUBPIXEL_SETTLE_THRESHOLD = 0.5; /** * Lerps toward a target, then lands exactly once the remaining distance is * smaller than half a rendered pixel. */ export function lerpAndSettle( current: number, target: number, speed: number, dt: number, unitsPerPixel: number, ): number { "worklet"; const next = lerp(current, target, speed, dt); const epsilon = Math.abs(unitsPerPixel) * SUBPIXEL_SETTLE_THRESHOLD; return epsilon > 0 && Math.abs(target - next) <= epsilon ? target : next; } /** * Advances a live timestamp only after the chart has moved by half a pixel. */ export function advanceTimestampByPixel( current: number, target: number, secondsPerPixel: number, ): number { "worklet"; const step = Math.abs(secondsPerPixel) * SUBPIXEL_SETTLE_THRESHOLD; if (!(step > 0) || !Number.isFinite(step) || target <= current) return target; const elapsedSteps = Math.floor((target - current) / step); return elapsedSteps > 0 ? current + elapsedSteps * step : current; } /** * One frame of the live chart engine (mirrors `useLiveChartEngine` worklet body). * Mutates `state` in place for testability and reuse from the hook. */ export function tickLiveChartEngineFrame( state: EngineTickMutable, input: EngineTickInput, historyCache?: HistoryRangeCache, ): void { "worklet"; const baseNow = input.nowOverride ?? input.nowSeconds ?? Date.now() / 1000; const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow; const targetWindow = input.viewWindow ?? input.timeWindow; // Keep clock rounding below half a pixel of the displayed window as it // changes. Using only the target window during expansion can leave "now" // several visible pixels past the edge and hide a live marker. const timestampSecondsPerPixel = Math.min(state.displayWindow, targetWindow) / input.canvasWidth; const viewEnd = input.viewEnd; // First time of the visible series' data — the floor a frozen edge must stay // at or above. `-Infinity` when there's no data to bound against, so the freeze // still works (we can't strand a plot we have no time bounds for). let firstDataTime = -Infinity; if (input.mode === "candle") { const cs = input.candles; if (cs && cs.length > 0) firstDataTime = cs[0].time; } else if (input.points.length > 0) { firstDataTime = input.points[0].time; } // Freeze the right edge while the pan gesture has parked `viewEnd` behind the // live edge AND that edge still sits within the data. A frozen edge stranded // before the active series' first point falls through to following live (so a // line/candle span mismatch never strands the window on an empty plot). When // time-scroll is disabled the hook clears `viewEnd` (and kicks off the glide // below), so a stale edge can't keep the window frozen. See #164. With // `allowFutureViewEnd` (timeScroll.overscroll) an edge parked past live is // honored too — the data-overlap strand-guard stays in both modes. const scrolledBack = viewEnd != null && viewEnd >= firstDataTime && (viewEnd < liveEdge || input.allowFutureViewEnd === true); const canvasChanged = state.lastCanvasWidth !== input.canvasWidth || state.lastCanvasHeight !== input.canvasHeight; state.liveEdge = !canvasChanged && input.canvasWidth > 0 && input.timeWindow > 0 ? advanceTimestampByPixel( state.liveEdge, liveEdge, timestampSecondsPerPixel, ) : liveEdge; if (scrolledBack) { state.timestamp = viewEnd; } else if (!input.paused) { // Following live. While a "return to live" glide is in flight (returnT < 1) // ease the right edge from the frozen `returnFrom` to the *current* live edge // by returnT — converges exactly on live with no end-snap. Otherwise pin to // the live edge (the steady-state follow). const returnT = input.returnT; if (returnT != null && returnT < 1 && input.returnFrom != null) { state.timestamp = input.returnFrom + (liveEdge - input.returnFrom) * returnT; } else if ( !canvasChanged && input.canvasWidth > 0 && input.timeWindow > 0 ) { state.timestamp = advanceTimestampByPixel( state.timestamp, liveEdge, timestampSecondsPerPixel, ); } else { state.timestamp = liveEdge; } } // else: paused with no active pan → leave the frozen timestamp untouched. if (input.canvasWidth === 0 || input.canvasHeight === 0) return; state.lastCanvasWidth = input.canvasWidth; state.lastCanvasHeight = input.canvasHeight; const speed = input.smoothing; // One-shot settle (snapKey change): collapse this frame's easing so the // window / range / value land on target instantly, then normal smoothing // resumes next frame. See `EngineTickInput.snap`. const snap = input.snap === true; let target = input.targetValue; if (input.mode === "candle" && input.liveCandle) { target = input.liveCandle.close; } const range = state.displayMax - state.displayMin; const gapRatio = range > 0 ? Math.min(Math.abs(target - state.displayValue) / range, 1) : 0; const adaptiveSpeed = speed + (1 - gapRatio) * (input.adaptiveSpeedBoost ?? MOTION_METRICS_DEFAULTS.adaptiveSpeedBoost); const valueUnitsPerPixel = (state.displayMax - state.displayMin) / input.canvasHeight; state.displayValue = snap ? target : lerpAndSettle( state.displayValue, target, adaptiveSpeed, input.dt, valueUnitsPerPixel, ); // Pinch-zoom: ease toward the zoom override when set, else the configured // window. Mirrors the viewEnd freeze above (width vs. right edge). state.displayWindow = snap || (input.viewWindow != null && input.viewWindowSmoothing === false) ? targetWindow : lerpAndSettle( state.displayWindow, targetWindow, speed, input.dt, targetWindow / input.canvasWidth, ); const winStart = state.timestamp - state.displayWindow; const candleMode = input.mode === "candle"; const history = historyRange( candleMode ? (input.candles ?? []) : input.points, candleMode, winStart, // Line feeds may lead the local clock. Preserve their inclusive live tail. candleMode || scrolledBack ? state.timestamp : Infinity, input.historyRevision, historyCache ?? makeHistoryRangeCache(), ); let tMin = history.min; let tMax = history.max; let minTime = history.minTime; let maxTime = history.maxTime; if (candleMode) { const lc = input.liveCandle; // Fold the in-progress candle in only while its bucket is visible — when // scrolled back past it, the live candle must not stretch the Y range. if (lc && (!scrolledBack || lc.time <= state.timestamp)) { /* istanbul ignore next -- trivial min/max */ if (lc.low < tMin) { tMin = lc.low; minTime = lc.time; } /* istanbul ignore next -- trivial min/max */ if (lc.high > tMax) { tMax = lc.high; maxTime = lc.time; } } } // Snapshot the raw data extrema (value + time) before the live value / // reference values fold into tMin/tMax for the Y range. NaN when the window // is empty so the extrema label hides rather than pinning to a fake point. const hasMin = tMin !== Infinity; const hasMax = tMax !== -Infinity; state.extremaMinValue = hasMin ? tMin : NaN; state.extremaMaxValue = hasMax ? tMax : NaN; state.extremaMinTime = hasMin ? minTime : NaN; state.extremaMaxTime = hasMax ? maxTime : NaN; // A previous-close bridge is presentation, not a traded observation: include // its anchor in the fitted range (so a viewport parked wholly inside an outage // stays framed correctly) only after snapshotting the real-data extrema above. if (input.mode === "candle" && input.candleGaps) { const cs = input.candles ?? []; const lc = input.liveCandle ?? null; for (let i = 0; i < input.candleGaps.length; i++) { const gap = input.candleGaps[i]; if (gap.from >= state.timestamp || gap.to <= winStart) continue; const bridge = gap.kind === "no-trades" ? input.candleGapBridgeNoTrades : gap.kind === "unavailable" ? input.candleGapBridgeUnavailable : input.candleGapBridgeUnknown; if (!bridge) continue; const anchor = previousCandleCloseAtTime( cs, lc, Math.min(gap.to, state.timestamp), ); if (anchor === null) continue; if (anchor < tMin) tMin = anchor; if (anchor > tMax) tMax = anchor; } } // Keep the animated live tip inside the range — but only while it's actually // in the window. Scrolled back, the live value sits beyond the frozen right // edge; folding it in would pin the Y range to the live price and stop the // axis from adapting to the visible history. if (!scrolledBack) { const cv = state.displayValue; if (cv < tMin) tMin = cv; if (cv > tMax) tMax = cv; } const ref = input.referenceValue; if (ref !== undefined) { if (ref < tMin) tMin = ref; if (ref > tMax) tMax = ref; } const refs = input.referenceValues; if (refs !== undefined) { for (let i = 0; i < refs.length; i++) { const rv = refs[i]; if (rv < tMin) tMin = rv; if (rv > tMax) tMax = rv; } } const thrPts = input.thresholdRangePoints; if (thrPts !== undefined && thrPts.length > 0) { const mm = thresholdRangeMinMax( thrPts, state.timestamp, state.displayWindow, input.thresholdRangeExtendToStart ?? true, input.thresholdRangeExtendToNow ?? true, THRESHOLD_RANGE_SCRATCH, ); if (mm !== null) { if (mm[0] < tMin) tMin = mm[0]; if (mm[1] > tMax) tMax = mm[1]; } } const isExaggerate = input.exaggerate; if (tMin !== Infinity && tMax !== -Infinity) { const rawRange = tMax - tMin; const marginFactor = isExaggerate ? 0.01 : 0.12; const minRange = rawRange * (isExaggerate ? 0.02 : 0.1) || (isExaggerate ? 0.04 : 0.4); if (rawRange < minRange) { const mid = (tMin + tMax) / 2; tMin = mid - minRange / 2; tMax = mid + minRange / 2; } else { const margin = rawRange * marginFactor; tMin -= margin; tMax += margin; } const floorRange = input.minRange; // Compare the bounded fit: a clamp can shrink an otherwise wider range. // Slide a widened fit off either bound; both hard bounds win if the floor // cannot fit between them. Resolve this before the optional manual zoom. if (floorRange !== undefined && floorRange > 0 && Number.isFinite(floorRange)) { const boundedMin = input.nonNegative && tMin < 0 ? 0 : tMin; const boundedMax = input.maxValue !== undefined && tMax > input.maxValue ? input.maxValue : tMax; if (floorRange > boundedMax - boundedMin) { // Halve before adding so large finite prices cannot overflow the midpoint. const mid = tMin / 2 + tMax / 2; let nextMin = mid - floorRange / 2; let nextMax = mid + floorRange / 2; if (input.nonNegative && nextMin < 0) { nextMax -= nextMin; nextMin = 0; } if (input.maxValue !== undefined && nextMax > input.maxValue) { nextMin -= nextMax - input.maxValue; nextMax = input.maxValue; } if (input.nonNegative && nextMin < 0) nextMin = 0; // Ignore a floor that would overflow or round the bounds onto each other. if ( Number.isFinite(nextMin) && Number.isFinite(nextMax) && nextMin < nextMax && Number.isFinite(nextMax - nextMin) ) { tMin = nextMin; tMax = nextMax; } } } // Treat malformed gesture output as auto-fit. A zero/negative multiplier // collapses or inverts the range; a non-finite/overflowing one poisons every // downstream price-to-Y projection. Keep the guard here on the UI thread so // callers can write the SharedValue directly without a JS round-trip. const requestedYScale = input.yRangeScale ?? 1; let yScale = requestedYScale > 0 && Number.isFinite(requestedYScale) ? requestedYScale : 1; if (yScale !== 1) { const scaledMid = (tMin + tMax) / 2; let scaledHalf = ((tMax - tMin) / 2) * yScale; // On non-negative charts, cap the zoom-out at the point where the floor // reaches 0. Letting it grow and clamping min afterwards keeps only the // top expanding, which pins the data to the bottom of the plot. if (input.nonNegative && scaledHalf > scaledMid) scaledHalf = scaledMid; const scaledMin = scaledMid - scaledHalf; const scaledMax = scaledMid + scaledHalf; // Reject multiplication overflow and subnormal scales that round the two // final bounds onto the same number. if ( Number.isFinite(scaledMin) && Number.isFinite(scaledMax) && scaledMin < scaledMax ) { tMin = scaledMin; tMax = scaledMax; } else { yScale = 1; } } // Snap only while the scale is actively moving, so the drag tracks the // finger but a parked scale (and the reset back to 1) keeps the eased fit. const yScaleDragging = yScale !== 1 && yScale !== state.lastYRangeScale; state.lastYRangeScale = yScale; if (input.nonNegative && tMin < 0) tMin = 0; const maxV = input.maxValue; if (maxV !== undefined && tMax > maxV) tMax = maxV; const disjoint = tMin >= state.displayMax || tMax <= state.displayMin; // A subpixel settle must not jump across the other bound of a disjoint fit. const rangeUnitsPerPixel = disjoint && input.rangeAnimation?.animateExpansion ? 0 : (tMax - tMin) / input.canvasHeight; if (snap || yScaleDragging) { state.displayMin = tMin; } else { state.displayMin = lerpAndSettle( state.displayMin, tMin, rangeAnimationSpeed(speed, input.rangeAnimation, tMin < state.displayMin, disjoint), input.dt, rangeUnitsPerPixel, ); } if (snap || yScaleDragging) { state.displayMax = tMax; } else { state.displayMax = lerpAndSettle( state.displayMax, tMax, rangeAnimationSpeed(speed, input.rangeAnimation, tMax > state.displayMax, disjoint), input.dt, rangeUnitsPerPixel, ); } } // Edge value: the price at the visible window's right edge. Following live → // track the live value (badge unchanged). Scrolled back → track the last // visible point/candle close, lerped so a `followViewEdge` badge glides to the // last price as you pan. Reuses the gated `scrolledBack` computed above. if (!scrolledBack) { state.edgeValue = state.displayValue; } else { let edgeTarget = state.displayValue; if (input.mode === "candle") { const cs = input.candles; if (cs && cs.length > 0) { let elo = 0; let ehi = cs.length; while (elo < ehi) { const m = (elo + ehi) >> 1; if (cs[m].time <= state.timestamp) elo = m + 1; else ehi = m; } if (elo > 0) edgeTarget = cs[elo - 1].close; } const lc = input.liveCandle; if (lc && lc.time <= state.timestamp) edgeTarget = lc.close; } else { const pts = input.points; let elo = 0; let ehi = pts.length; while (elo < ehi) { const m = (elo + ehi) >> 1; if (pts[m].time <= state.timestamp) elo = m + 1; else ehi = m; } if (elo > 0) edgeTarget = pts[elo - 1].value; } state.edgeValue = snap ? edgeTarget : lerpAndSettle( state.edgeValue, edgeTarget, speed, input.dt, valueUnitsPerPixel, ); } }