react-native-livechart 4.24.0 → 4.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/components/DegenParticlesOverlay.d.ts +12 -11
  2. package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
  3. package/dist/components/LiveChart.d.ts.map +1 -1
  4. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  5. package/dist/components/LoadingOverlay.d.ts +5 -1
  6. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  7. package/dist/components/YAxisOverlay.d.ts +3 -1
  8. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  9. package/dist/constants.d.ts +3 -3
  10. package/dist/core/historyRangeCache.d.ts +25 -0
  11. package/dist/core/historyRangeCache.d.ts.map +1 -0
  12. package/dist/core/liveChartEngineTick.d.ts +4 -1
  13. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  14. package/dist/core/resolveConfig.d.ts +1 -1
  15. package/dist/core/useHistoryRevision.d.ts +5 -0
  16. package/dist/core/useHistoryRevision.d.ts.map +1 -0
  17. package/dist/core/useLiveChartEngine.d.ts +17 -1
  18. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  19. package/dist/core/useLiveChartSeriesEngine.d.ts +2 -0
  20. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  21. package/dist/draw/candleBodyPath.d.ts +10 -0
  22. package/dist/draw/candleBodyPath.d.ts.map +1 -0
  23. package/dist/draw/lineFillPaths.d.ts +10 -0
  24. package/dist/draw/lineFillPaths.d.ts.map +1 -0
  25. package/dist/draw/volume.d.ts +4 -1
  26. package/dist/draw/volume.d.ts.map +1 -1
  27. package/dist/draw/volumeRange.d.ts +12 -0
  28. package/dist/draw/volumeRange.d.ts.map +1 -0
  29. package/dist/hooks/useCandlePaths.d.ts +1 -1
  30. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  31. package/dist/hooks/useChartPaths.d.ts +1 -1
  32. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  33. package/dist/hooks/useDegen.d.ts +1 -0
  34. package/dist/hooks/useDegen.d.ts.map +1 -1
  35. package/dist/hooks/useDemandFrameLoop.d.ts +11 -0
  36. package/dist/hooks/useDemandFrameLoop.d.ts.map +1 -0
  37. package/dist/hooks/useLoadingLook.d.ts +12 -0
  38. package/dist/hooks/useLoadingLook.d.ts.map +1 -0
  39. package/dist/hooks/useMarkers.d.ts +3 -1
  40. package/dist/hooks/useMarkers.d.ts.map +1 -1
  41. package/dist/hooks/useMultiSeriesDegen.d.ts +1 -0
  42. package/dist/hooks/useMultiSeriesDegen.d.ts.map +1 -1
  43. package/dist/hooks/usePanScroll.d.ts +2 -0
  44. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  45. package/dist/hooks/usePinchZoom.d.ts +2 -0
  46. package/dist/hooks/usePinchZoom.d.ts.map +1 -1
  47. package/dist/hooks/useVolumeRange.d.ts +14 -0
  48. package/dist/hooks/useVolumeRange.d.ts.map +1 -0
  49. package/dist/hooks/useXAxis.d.ts +7 -0
  50. package/dist/hooks/useXAxis.d.ts.map +1 -1
  51. package/dist/hooks/useYAxis.d.ts.map +1 -1
  52. package/dist/math/spline.d.ts +3 -1
  53. package/dist/math/spline.d.ts.map +1 -1
  54. package/dist/math/squiggly.d.ts +8 -0
  55. package/dist/math/squiggly.d.ts.map +1 -1
  56. package/dist/types.d.ts +11 -2
  57. package/dist/types.d.ts.map +1 -1
  58. package/package.json +1 -1
  59. package/src/components/DegenParticlesOverlay.tsx +56 -35
  60. package/src/components/LiveChart.tsx +34 -4
  61. package/src/components/LiveChartSeries.tsx +14 -6
  62. package/src/components/LoadingOverlay.tsx +53 -12
  63. package/src/components/YAxisOverlay.tsx +38 -2
  64. package/src/constants.ts +3 -3
  65. package/src/core/historyRangeCache.ts +109 -0
  66. package/src/core/liveChartEngineTick.ts +24 -57
  67. package/src/core/resolveConfig.ts +1 -1
  68. package/src/core/useHistoryRevision.ts +36 -0
  69. package/src/core/useLiveChartEngine.ts +151 -64
  70. package/src/core/useLiveChartSeriesEngine.ts +16 -1
  71. package/src/draw/candleBodyPath.ts +38 -0
  72. package/src/draw/lineFillPaths.ts +63 -0
  73. package/src/draw/volume.ts +21 -23
  74. package/src/draw/volumeRange.ts +51 -0
  75. package/src/hooks/useCandlePaths.ts +68 -94
  76. package/src/hooks/useChartPaths.ts +87 -111
  77. package/src/hooks/useDegen.ts +10 -6
  78. package/src/hooks/useDemandFrameLoop.ts +82 -0
  79. package/src/hooks/useLoadingLook.ts +30 -0
  80. package/src/hooks/useMarkers.ts +18 -4
  81. package/src/hooks/useMultiSeriesDegen.ts +7 -3
  82. package/src/hooks/usePanScroll.ts +5 -0
  83. package/src/hooks/usePinchZoom.ts +5 -0
  84. package/src/hooks/useVolumeRange.ts +40 -0
  85. package/src/hooks/useXAxis.ts +27 -3
  86. package/src/hooks/useYAxis.ts +18 -2
  87. package/src/math/spline.ts +16 -8
  88. package/src/math/squiggly.ts +12 -0
  89. package/src/types.ts +11 -2
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @see https://github.com/benjitaylor/liveline
7
7
  */
8
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
8
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
9
9
  import {
10
10
  cancelAnimation,
11
11
  Easing,
@@ -16,6 +16,7 @@ import {
16
16
  withTiming,
17
17
  type SharedValue,
18
18
  } from "react-native-reanimated";
19
+ import { useDemandFrameLoop } from "../hooks/useDemandFrameLoop";
19
20
  import { MS_PER_FRAME_60FPS, RETURN_TO_LIVE_MS } from "../constants";
20
21
  import type {
21
22
  CandleGap,
@@ -30,7 +31,18 @@ import {
30
31
  type EngineTickMutable,
31
32
  } from "./liveChartEngineTick";
32
33
 
34
+ import {
35
+ makeHistoryRangeCache,
36
+ type HistoryRangeCache,
37
+ type HistoryRevision,
38
+ } from "./historyRangeCache";
39
+
40
+ import { useHistoryRevision } from "./useHistoryRevision";
41
+
33
42
  export interface EngineConfig {
43
+ /** Original inputs before a reveal/stash bridge that may suppress same-array notifications. */
44
+ dataChangeSource?: SharedValue<LiveChartPoint[]>;
45
+ candlesChangeSource?: SharedValue<CandlePoint[]>;
34
46
  data: SharedValue<LiveChartPoint[]>;
35
47
  value: SharedValue<number>;
36
48
  timeWindow: number;
@@ -96,6 +108,12 @@ export interface EngineConfig {
96
108
  static?: boolean;
97
109
  /** Runtime gate for the frame loop. Gestures and configuration stay mounted. */
98
110
  isFrameLoopActive?: SharedValue<boolean>;
111
+ /** Experimental automatic idle scheduling, single-series only. */
112
+ autoSleep?: boolean;
113
+ /** Effects that require continuous engine work. */
114
+ keepAwake?: SharedValue<boolean>;
115
+ /** Appearance/reveal changes that should wake a settled engine. */
116
+ wakeSignal?: SharedValue<unknown>;
99
117
  /** Optional development-only frame publication counters. */
100
118
  debugFrameStats?: SharedValue<LiveChartFrameStats>;
101
119
  /**
@@ -185,16 +203,21 @@ export interface ChartEngineEdge {
185
203
  edgeValue: SharedValue<number>;
186
204
  }
187
205
 
188
- export interface SingleEngineState extends ChartEngineLayout, ChartEngineExtrema {
206
+ export interface SingleEngineState
207
+ extends ChartEngineLayout,
208
+ ChartEngineExtrema {
189
209
  data: SharedValue<LiveChartPoint[]>;
190
210
  value: SharedValue<number>;
191
211
  displayValue: SharedValue<number>;
192
212
  }
193
213
 
194
- export interface MultiEngineState extends ChartEngineLayout, ChartEngineExtrema {
214
+ export interface MultiEngineState
215
+ extends ChartEngineLayout,
216
+ ChartEngineExtrema {
195
217
  data: SharedValue<LiveChartPoint[]>;
196
218
  value: SharedValue<number>;
197
219
  displayValue: SharedValue<number>;
220
+ /** Entries captured by the last measured engine frame; history arrays remain shared. */
198
221
  series: SharedValue<SeriesConfig[]>;
199
222
  displaySeriesValues: SharedValue<number[]>;
200
223
  seriesOpacities: SharedValue<number[]>;
@@ -208,6 +231,8 @@ export type ChartEngineWithLiveValue = ChartEngineLayout & {
208
231
  };
209
232
 
210
233
  export interface EngineFrameRefs {
234
+ pointHistory?: SharedValue<HistoryRevision<LiveChartPoint> | undefined>;
235
+ candleHistory?: SharedValue<HistoryRevision<CandlePoint> | undefined>;
211
236
  data: SharedValue<LiveChartPoint[]>;
212
237
  value: SharedValue<number>;
213
238
  displayValue: SharedValue<number>;
@@ -268,6 +293,7 @@ export interface EngineFrameRefs {
268
293
 
269
294
  /** Stable state/input containers reused by the UI-thread frame callback. */
270
295
  export interface EngineFrameScratch {
296
+ historyCache: HistoryRangeCache;
271
297
  state: EngineTickMutable;
272
298
  input: EngineTickInput;
273
299
  }
@@ -275,6 +301,7 @@ export interface EngineFrameScratch {
275
301
  /** Allocate one single-series frame scratch. Call once per engine instance. */
276
302
  export function makeEngineFrameScratch(): EngineFrameScratch {
277
303
  return {
304
+ historyCache: makeHistoryRangeCache(),
278
305
  state: {
279
306
  displayValue: 0,
280
307
  displayMin: 0,
@@ -375,7 +402,9 @@ export function applyLiveChartEngineFrame(
375
402
  input.nowOverride = sv.nowOverrideSV?.value;
376
403
  input.windowBuffer = sv.windowBufferSV?.value ?? 0;
377
404
  input.targetValue = sv.value.value;
378
- input.points = sv.data.value;
405
+ const pointHistory = sv.pointHistory?.value;
406
+ const candleHistory = sv.candleHistory?.value;
407
+ input.points = pointHistory?.data ?? sv.data.value;
379
408
  input.nowSeconds = Date.now() / 1000;
380
409
  input.paused = sv.pausedSV.value;
381
410
  input.snap = snap;
@@ -385,15 +414,16 @@ export function applyLiveChartEngineFrame(
385
414
  input.returnFrom = sv.returnFromSV?.value;
386
415
  input.viewWindow = sv.viewWindowSV?.value;
387
416
  input.mode = sv.modeSV.value;
388
- input.candles = sv.candles?.value;
417
+ input.candles = candleHistory?.data ?? sv.candles?.value;
418
+ input.historyRevision =
419
+ input.mode === "candle" ? candleHistory : pointHistory;
389
420
  input.liveCandle = sv.liveCandle?.value;
390
421
  input.candleGaps = sv.candleGapsSV?.value;
391
- input.candleGapBridgeNoTrades =
392
- sv.candleGapBridgeNoTradesSV?.value ?? false;
422
+ input.candleGapBridgeNoTrades = sv.candleGapBridgeNoTradesSV?.value ?? false;
393
423
  input.candleGapBridgeUnavailable =
394
424
  sv.candleGapBridgeUnavailableSV?.value ?? false;
395
425
  input.candleGapBridgeUnknown = sv.candleGapBridgeUnknownSV?.value ?? false;
396
- tickLiveChartEngineFrame(state, input);
426
+ tickLiveChartEngineFrame(state, input, scratch?.historyCache);
397
427
  sv.displayValue.value = state.displayValue;
398
428
  sv.displayMin.value = state.displayMin;
399
429
  sv.displayMax.value = state.displayMax;
@@ -409,16 +439,22 @@ export function applyLiveChartEngineFrame(
409
439
  // once a real (measured) frame consumed it. The tick early-returns on a
410
440
  // zero-size canvas (before applying the snap), so keep the flag pending until
411
441
  // the canvas has laid out, or a snap arriving pre-layout would be dropped.
412
- if (snap && sv.snapSV && sv.canvasWidth.value !== 0 && sv.canvasHeight.value !== 0) {
442
+ if (
443
+ snap &&
444
+ sv.snapSV &&
445
+ sv.canvasWidth.value !== 0 &&
446
+ sv.canvasHeight.value !== 0
447
+ ) {
413
448
  sv.snapSV.value = false;
414
449
  }
415
450
  }
416
451
 
417
452
  export function useLiveChartEngine(
418
453
  config: EngineConfig,
419
- ): SingleEngineState & ChartEngineScroll & ChartEngineEdge {
454
+ ): SingleEngineState & ChartEngineScroll & ChartEngineEdge & { wake: () => void } {
420
455
  const {
421
456
  data,
457
+ dataChangeSource = data,
422
458
  value,
423
459
  timeWindow: configuredTimeWindow,
424
460
  smoothing: configuredSmoothing,
@@ -442,9 +478,13 @@ export function useLiveChartEngine(
442
478
  static: isStatic,
443
479
  isFrameLoopActive,
444
480
  debugFrameStats,
481
+ autoSleep = false,
482
+ keepAwake,
483
+ wakeSignal,
445
484
  snapKey,
446
485
  mode,
447
486
  candles,
487
+ candlesChangeSource = candles,
448
488
  liveCandle,
449
489
  candleGaps,
450
490
  candleGapBridgeNoTrades,
@@ -472,9 +512,7 @@ export function useLiveChartEngine(
472
512
  );
473
513
  // Static charts snap to their target in one tick (smoothing=1), so the single
474
514
  // settle reaction below produces the final state with no per-frame easing.
475
- const smoothing = useDerivedValue(() =>
476
- isStatic ? 1 : configuredSmoothing,
477
- );
515
+ const smoothing = useDerivedValue(() => (isStatic ? 1 : configuredSmoothing));
478
516
  const adaptiveSpeedBoostSV = useDerivedValue(() => adaptiveSpeedBoost);
479
517
  const exaggerateSV = useDerivedValue(() => exaggerate ?? false);
480
518
  const referenceValue = useDerivedValue(() => configuredReferenceValue);
@@ -597,8 +635,14 @@ export function useLiveChartEngine(
597
635
  // React renders: Reanimated uses captured closure values as implicit reaction
598
636
  // dependencies, and rebuilding this object would unregister/re-register the
599
637
  // static settle mapper behind its path consumers. See #329.
638
+ // Observe original feeds as well as reveal bridges: same-array assignments
639
+ // may suppress bridge notifications. Live candle changes stay uncached.
640
+ const pointHistory = useHistoryRevision(data, dataChangeSource);
641
+ const candleHistory = useHistoryRevision(candles, candlesChangeSource);
600
642
  const frameRefs = useMemo<EngineFrameRefs>(
601
643
  () => ({
644
+ pointHistory,
645
+ candleHistory,
602
646
  data,
603
647
  value,
604
648
  displayValue,
@@ -644,6 +688,8 @@ export function useLiveChartEngine(
644
688
  extremaMaxTime,
645
689
  }),
646
690
  [
691
+ pointHistory,
692
+ candleHistory,
647
693
  adaptiveSpeedBoostSV,
648
694
  allowFutureViewEndSV,
649
695
  candleGapBridgeNoTradesSV,
@@ -696,41 +742,78 @@ export function useLiveChartEngine(
696
742
  frameScratchRef.current = makeEngineFrameScratch();
697
743
  }
698
744
 
699
- // `autostart=false` registers the frame callback without running it — the live
700
- // loop is fully inert in static mode (the invariant that makes this worth it).
701
- const engineFrameCallback = useFrameCallback((frameInfo) => {
745
+ const tickFrame = useCallback((dt: number): boolean => {
702
746
  "worklet";
703
- if (isFrameLoopActive?.get() === false) return;
704
- const beforeValue = debugFrameStats ? displayValue.get() : 0;
705
- const beforeMin = debugFrameStats ? displayMin.get() : 0;
706
- const beforeMax = debugFrameStats ? displayMax.get() : 0;
707
- const beforeWindow = debugFrameStats ? displayWindow.get() : 0;
708
- const beforeTimestamp = debugFrameStats ? timestamp.get() : 0;
709
- const beforeLiveEdge = debugFrameStats ? liveEdge.get() : 0;
710
- const beforeEdgeValue = debugFrameStats ? edgeValue.get() : 0;
711
- applyLiveChartEngineFrame(frameInfo, frameRefs, frameScratchRef.current!);
747
+ if (isFrameLoopActive?.get() === false) return false;
748
+ if (!autoSleep && !debugFrameStats) {
749
+ applyLiveChartEngineFrame({ timeSincePreviousFrame: dt }, frameRefs, frameScratchRef.current!);
750
+ return true;
751
+ }
752
+ const beforeValue = displayValue.get();
753
+ const beforeMin = displayMin.get();
754
+ const beforeMax = displayMax.get();
755
+ const beforeWindow = displayWindow.get();
756
+ const beforeTimestamp = timestamp.get();
757
+ const beforeLiveEdge = liveEdge.get();
758
+ const beforeEdgeValue = edgeValue.get();
759
+ applyLiveChartEngineFrame({ timeSincePreviousFrame: dt }, frameRefs, frameScratchRef.current!);
760
+ const changed = beforeValue !== displayValue.get() || beforeMin !== displayMin.get() ||
761
+ beforeMax !== displayMax.get() || beforeWindow !== displayWindow.get() ||
762
+ beforeTimestamp !== timestamp.get() || beforeEdgeValue !== edgeValue.get();
712
763
  if (debugFrameStats) {
713
- const didPublish =
714
- beforeValue !== displayValue.get() ||
715
- beforeMin !== displayMin.get() ||
716
- beforeMax !== displayMax.get() ||
717
- beforeWindow !== displayWindow.get() ||
718
- beforeTimestamp !== timestamp.get() ||
719
- beforeLiveEdge !== liveEdge.get() ||
720
- beforeEdgeValue !== edgeValue.get();
764
+ const didPublish = changed || beforeLiveEdge !== liveEdge.get();
721
765
  const previous = debugFrameStats.get();
722
- debugFrameStats.set({
723
- frames: previous.frames + 1,
724
- published: previous.published + (didPublish ? 1 : 0),
725
- skipped: previous.skipped + (didPublish ? 0 : 1),
726
- });
766
+ debugFrameStats.set({ frames: previous.frames + 1, published: previous.published + (didPublish ? 1 : 0), skipped: previous.skipped + (didPublish ? 0 : 1) });
767
+ }
768
+ // The liveEdge clock alone is not a visible animation while parked/paused.
769
+ // Do not sleep if a stranded viewEnd actually fell back to following live.
770
+ const parked = viewEnd.get() !== null && timestamp.get() === viewEnd.get();
771
+ const clockMoves = nowOverrideSV.get() === undefined && !pausedSV.get() && !parked;
772
+ return changed || clockMoves || keepAwake?.get() === true || returnT.get() < 1;
773
+ }, [autoSleep, isFrameLoopActive, displayValue, displayMin, displayMax, displayWindow, timestamp,
774
+ liveEdge, edgeValue, frameRefs, frameScratchRef, debugFrameStats, viewEnd, nowOverrideSV, pausedSV, keepAwake, returnT]);
775
+
776
+ const prepareWake = useCallback(() => {
777
+ "worklet";
778
+ // Read the arrays themselves, not a length/endpoints fingerprint: modify()
779
+ // can change an interior value without changing any of those quantities.
780
+ // History revisions also notify when a reveal bridge suppresses a same-array
781
+ // source update. A sleeping engine must wake to consume that invalidation.
782
+ return [pointHistory.get(), candleHistory.get(), data.get(), value.get(), candles?.get(), liveCandle?.get(), canvasWidth.get(),
783
+ canvasHeight.get(), timeWindow.get(), smoothing.get(), adaptiveSpeedBoostSV.get(),
784
+ exaggerateSV.get(), referenceValue.get(), referenceValues.get(), thresholdRangePoints.get(),
785
+ thresholdRangeExtendToStart.get(), thresholdRangeExtendToNow.get(), nonNegativeSV.get(),
786
+ maxValueSV.get(), yRangeScale?.get(), nowOverrideSV.get(), windowBufferSV.get(), pausedSV.get(),
787
+ viewEnd.get(), viewWindow.get(), allowFutureViewEndSV.get(), returnT.get(), returnFrom.get(),
788
+ snapSV.get(), modeSV.get(), candleGapsSV.get(), candleGapBridgeNoTradesSV.get(),
789
+ candleGapBridgeUnavailableSV.get(), candleGapBridgeUnknownSV.get(), isFrameLoopActive?.get(),
790
+ keepAwake?.get(), wakeSignal?.get()];
791
+ }, [pointHistory, candleHistory, data, value, candles, liveCandle, canvasWidth, canvasHeight, timeWindow, smoothing,
792
+ adaptiveSpeedBoostSV, exaggerateSV, referenceValue, referenceValues, thresholdRangePoints,
793
+ thresholdRangeExtendToStart, thresholdRangeExtendToNow, nonNegativeSV, maxValueSV, yRangeScale,
794
+ nowOverrideSV, windowBufferSV, pausedSV, viewEnd, viewWindow, allowFutureViewEndSV, returnT,
795
+ returnFrom, snapSV, modeSV, candleGapsSV, candleGapBridgeNoTradesSV, candleGapBridgeUnavailableSV,
796
+ candleGapBridgeUnknownSV, isFrameLoopActive, keepAwake, wakeSignal]);
797
+
798
+ const wakeLoop = useDemandFrameLoop(autoSleep && !isStatic, prepareWake, tickFrame);
799
+ const wake = useCallback(() => {
800
+ "worklet";
801
+ if (!autoSleep || isStatic) return;
802
+ // Gestures need a fresh live boundary BEFORE clamping their first delta.
803
+ if (isFrameLoopActive?.get() !== false) {
804
+ liveEdge.set((nowOverrideSV.get() ?? Date.now() / 1000) + windowBufferSV.get() * timeWindow.get());
727
805
  }
728
- }, !isStatic);
806
+ wakeLoop();
807
+ }, [autoSleep, isStatic, isFrameLoopActive, liveEdge, nowOverrideSV, windowBufferSV, timeWindow, wakeLoop]);
808
+
809
+ const legacyActive = !isStatic && !autoSleep;
810
+ const engineFrameCallback = useFrameCallback((frameInfo) => {
811
+ "worklet";
812
+ tickFrame(frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS);
813
+ }, legacyActive);
729
814
  useEffect(() => {
730
- // Reanimated only seeds `isActive` from `autostart` on mount. Keep the
731
- // imperative state aligned when a chart switches between static and live.
732
- engineFrameCallback.setActive(!isStatic);
733
- }, [isStatic, engineFrameCallback]);
815
+ engineFrameCallback.setActive(legacyActive);
816
+ }, [legacyActive, engineFrameCallback]);
734
817
 
735
818
  // When time-scroll is disabled while scrolled back, return the window to the
736
819
  // live edge. With a positive duration this glides: snapshot the frozen edge into
@@ -769,29 +852,32 @@ export function useLiveChartEngine(
769
852
  // constant so it never fires.
770
853
  useAnimatedReaction(
771
854
  () => {
772
- if (!isStaticSV.get()) return 0; // inert when not static — never changes
773
- const d = data.get();
774
- const n = d.length;
775
- return [
776
- n,
777
- n ? d[0].time : 0,
778
- n ? d[0].value : 0,
779
- n ? d[n - 1].time : 0,
780
- n ? d[n - 1].value : 0,
781
- value.get(),
782
- canvasWidth.get(),
783
- canvasHeight.get(),
784
- timeWindow.get(),
785
- // Range props can arrive after the data shared value. Re-settle when
786
- // those bounds change too, rather than retaining the previous scale
787
- // until another data/layout update (static charts have no frame loop).
788
- maxValueSV.get(),
789
- referenceValue.get(),
790
- ...(referenceValues.get() ?? []),
791
- ].join(",");
855
+ if (!isStaticSV.get()) return null; // inert when not static
856
+ return {
857
+ revision:
858
+ modeSV.get() === "candle" ? candleHistory.get() : pointHistory.get(),
859
+ fingerprint: [
860
+ value.get(),
861
+ canvasWidth.get(),
862
+ canvasHeight.get(),
863
+ timeWindow.get(),
864
+ // Range props can arrive after the data shared value. Re-settle when
865
+ // those bounds change too, rather than retaining the previous scale
866
+ // until another data/layout update (static charts have no frame loop).
867
+ maxValueSV.get(),
868
+ referenceValue.get(),
869
+ ...(referenceValues.get() ?? []),
870
+ ].join(","),
871
+ };
792
872
  },
793
873
  (curr, prev) => {
794
- if (!isStaticSV.get() || curr === prev) return;
874
+ if (
875
+ !isStaticSV.get() ||
876
+ curr === null ||
877
+ (curr.revision === prev?.revision &&
878
+ curr.fingerprint === prev?.fingerprint)
879
+ )
880
+ return;
795
881
  applyLiveChartEngineFrame(
796
882
  { timeSincePreviousFrame: MS_PER_FRAME_60FPS },
797
883
  frameRefs,
@@ -805,6 +891,7 @@ export function useLiveChartEngine(
805
891
  );
806
892
 
807
893
  return {
894
+ wake,
808
895
  data,
809
896
  value,
810
897
  displayValue,
@@ -64,6 +64,8 @@ export interface MultiSeriesEngineConfig {
64
64
 
65
65
  export interface MultiEngineFrameRefs {
66
66
  series: SharedValue<SeriesConfig[]>;
67
+ /** Drawing input published with this frame's range/tips. Optional for standalone callers. */
68
+ renderedSeries?: SharedValue<SeriesConfig[]>;
67
69
  displaySeriesValues: SharedValue<number[]>;
68
70
  seriesOpacities: SharedValue<number[]>;
69
71
  displayMin: SharedValue<number>;
@@ -268,6 +270,17 @@ export function applyLiveChartSeriesEngineFrame(
268
270
  sv.extremaMaxValue.value = state.extremaMaxValue;
269
271
  sv.extremaMinTime.value = state.extremaMinTime;
270
272
  sv.extremaMaxTime.value = state.extremaMaxTime;
273
+ // Drawing must not react to replacement histories before the engine has
274
+ // calculated their range and tips. Publish from the same measured frame and
275
+ // the same input read above. Copy entries because producers can mutate live
276
+ // values in place; retain history arrays to avoid copying every point per frame.
277
+ if (sv.renderedSeries && input.canvasWidth !== 0 && input.canvasHeight !== 0) {
278
+ const rendered: SeriesConfig[] = [];
279
+ for (let i = 0; i < seriesSnap.length; i++) {
280
+ rendered.push({ ...seriesSnap[i] });
281
+ }
282
+ sv.renderedSeries.value = rendered;
283
+ }
271
284
  // Clear the one-shot snap so the next frame eases normally again — but only
272
285
  // once a real (measured) frame consumed it (the tick early-returns on a
273
286
  // zero-size canvas before applying the snap). See applyLiveChartEngineFrame.
@@ -328,6 +341,7 @@ export function useLiveChartSeriesEngine(
328
341
 
329
342
  const displaySeriesValues = useSharedValue<number[]>([]);
330
343
  const seriesOpacities = useSharedValue<number[]>([]);
344
+ const renderedSeries = useSharedValue<SeriesConfig[]>([]);
331
345
 
332
346
  // Live data extrema (value + time of the visible high / low across series).
333
347
  const extremaMinValue = useSharedValue(NaN);
@@ -363,6 +377,7 @@ export function useLiveChartSeriesEngine(
363
377
 
364
378
  const frameRefs: MultiEngineFrameRefs = {
365
379
  series,
380
+ renderedSeries,
366
381
  displaySeriesValues,
367
382
  seriesOpacities,
368
383
  displayMin,
@@ -448,7 +463,7 @@ export function useLiveChartSeriesEngine(
448
463
  viewEnd,
449
464
  viewWindow,
450
465
  liveEdge,
451
- series,
466
+ series: renderedSeries,
452
467
  displaySeriesValues,
453
468
  seriesOpacities,
454
469
  extremaMinValue,
@@ -0,0 +1,38 @@
1
+ import type { SkPathBuilder } from "@shopify/react-native-skia";
2
+ import type { CandleRect } from "./candle";
3
+
4
+ /**
5
+ * Build one color's candle-body or volume-bar batch. Skia copies addRect's coordinates synchronously,
6
+ * so one plain rectangle can serve every sharp body in this rebuild. Keep it
7
+ * local to the worklet invocation: derived initializers also run on React's
8
+ * runtime, where mutating a previously serialized scratch object is unsafe.
9
+ */
10
+ export function buildCandleBodyPath(
11
+ builder: SkPathBuilder,
12
+ bodies: CandleRect[],
13
+ up: boolean,
14
+ radius: number,
15
+ ) {
16
+ "worklet";
17
+ const rect = { x: 0, y: 0, width: 0, height: 0 };
18
+ for (let i = 0; i < bodies.length; i++) {
19
+ const body = bodies[i];
20
+ if (body.up !== up) continue;
21
+ const rr = radius > 0 ? Math.min(radius, body.w / 2, body.h / 2) : 0;
22
+ if (rr > 0) {
23
+ builder.addRRect({
24
+ rect: { x: body.x, y: body.y, width: body.w, height: body.h },
25
+ rx: rr,
26
+ ry: rr,
27
+ });
28
+ } else {
29
+ rect.x = body.x;
30
+ rect.y = body.y;
31
+ rect.width = body.w;
32
+ rect.height = body.h;
33
+ builder.addRect(rect);
34
+ }
35
+ }
36
+ // A fresh immutable path both resets the builder and notifies subscribers.
37
+ return builder.detach();
38
+ }
@@ -0,0 +1,63 @@
1
+ import type { SkPathBuilder } from "@shopify/react-native-skia";
2
+ import { drawSpline, type SplineScratch } from "../math/spline";
3
+ import { sampleThresholdYAt } from "../math/threshold";
4
+
5
+ /** Emit each segment's curve math once into the line, fill and optional band.
6
+ * Builders remain separate so gaps and independent fill closures are preserved.
7
+ * Finalization is left to the caller; no extra intermediate native paths.
8
+ */
9
+ export function buildLineFillPaths(
10
+ line: SkPathBuilder,
11
+ fill: SkPathBuilder,
12
+ band: SkPathBuilder | undefined,
13
+ pts: number[],
14
+ ranges: number[],
15
+ scratch: SplineScratch,
16
+ linear: boolean,
17
+ bottom: number,
18
+ thresholdY: number,
19
+ samples: number[] | undefined,
20
+ x0: number,
21
+ x1: number,
22
+ /** A threshold-only update can retain the existing line and fill paths. */
23
+ bandOnly = false,
24
+ ) {
25
+ "worklet";
26
+ for (let range = 0; range < ranges.length; range += 2) {
27
+ const start = ranges[range],
28
+ end = ranges[range + 1];
29
+ const first = start * 2,
30
+ last = (end - 1) * 2;
31
+ const left = pts[first],
32
+ right = pts[last];
33
+ if (!bandOnly) {
34
+ line.moveTo(left, pts[first + 1]);
35
+ fill.moveTo(left, pts[first + 1]);
36
+ }
37
+ band?.moveTo(left, pts[first + 1]);
38
+ if (bandOnly) {
39
+ if (band) drawSpline(band, pts, scratch, linear, start, end);
40
+ } else {
41
+ drawSpline(line, pts, scratch, linear, start, end, fill, band);
42
+ fill.lineTo(right, bottom);
43
+ fill.lineTo(left, bottom);
44
+ fill.close();
45
+ }
46
+ if (band) {
47
+ if (samples && samples.length >= 2) {
48
+ const count = samples.length,
49
+ step = (x1 - x0) / (count - 1);
50
+ band.lineTo(right, sampleThresholdYAt(samples, x0, x1, right));
51
+ for (let i = count - 1; i >= 0; i--) {
52
+ const x = x0 + step * i;
53
+ if (x > left && x < right) band.lineTo(x, samples[i]);
54
+ }
55
+ band.lineTo(left, sampleThresholdYAt(samples, x0, x1, left));
56
+ } else {
57
+ band.lineTo(right, thresholdY);
58
+ band.lineTo(left, thresholdY);
59
+ }
60
+ band.close();
61
+ }
62
+ }
63
+ }
@@ -1,3 +1,9 @@
1
+ import {
2
+ volumeStartIndex,
3
+ volumeEndIndex,
4
+ visibleMaxVolume,
5
+ type VolumeRange,
6
+ } from "./volumeRange";
1
7
  import { CANDLE_METRICS_DEFAULTS } from "../constants";
2
8
  import type { CandleMetrics, CandlePoint } from "../types";
3
9
  import type { ChartPadding } from "./line";
@@ -105,6 +111,8 @@ export function buildVolumeGeometry(
105
111
  bandHeight: number,
106
112
  candleWidthSecs: number,
107
113
  metrics: CandleMetrics = CANDLE_METRICS_DEFAULTS,
114
+ /** Internal cached history range. Live volume is always folded in below. */
115
+ history?: VolumeRange,
108
116
  ): VolumeGeometry {
109
117
  "worklet";
110
118
  const chartW = canvasW - padding.left - padding.right;
@@ -126,25 +134,14 @@ export function buildVolumeGeometry(
126
134
  const padLeft = padding.left;
127
135
  const baseline = canvasH - padding.bottom + bandHeight;
128
136
 
129
- // Binary search for the first candle overlapping the window (same as candles).
130
- let lo = 0;
131
- let hi = candles.length;
132
- while (lo < hi) {
133
- const mid = (lo + hi) >> 1;
134
- if (candles[mid].time + candleWidthSecs < winStart) lo = mid + 1;
135
- else hi = mid;
136
- }
137
-
138
- // Pass 1 — largest visible volume (the bar that fills the band). The live
139
- // candle counts too so a tall in-progress bar doesn't overflow the band.
140
- let maxVol = 0;
141
- for (let i = lo; i < candles.length; i++) {
142
- /* istanbul ignore next -- past visible window */
143
- if (candles[i].time > winEnd) break;
144
- const v = candles[i].volume ?? 0;
145
- if (v > maxVol) maxVol = v;
146
- }
147
- if (liveCandle && candleVisible(liveCandle, winStart, winEnd, candleWidthSecs)) {
137
+ const lo =
138
+ history?.start ?? volumeStartIndex(candles, winStart, candleWidthSecs);
139
+ const end = history?.end ?? volumeEndIndex(candles, winEnd);
140
+ let maxVol = history?.max ?? visibleMaxVolume(candles, lo, end);
141
+ if (
142
+ liveCandle &&
143
+ candleVisible(liveCandle, winStart, winEnd, candleWidthSecs)
144
+ ) {
148
145
  const v = liveCandle.volume ?? 0;
149
146
  if (v > maxVol) maxVol = v;
150
147
  }
@@ -153,9 +150,7 @@ export function buildVolumeGeometry(
153
150
 
154
151
  // Pass 2 — build the bars normalized to maxVol.
155
152
  const bars: VolumeBar[] = [];
156
- for (let i = lo; i < candles.length; i++) {
157
- /* istanbul ignore next -- past visible window */
158
- if (candles[i].time > winEnd) break;
153
+ for (let i = lo; i < end; i++) {
159
154
  appendVolumeBar(
160
155
  candles[i],
161
156
  winStart,
@@ -172,7 +167,10 @@ export function buildVolumeGeometry(
172
167
  bars,
173
168
  );
174
169
  }
175
- if (liveCandle && candleVisible(liveCandle, winStart, winEnd, candleWidthSecs)) {
170
+ if (
171
+ liveCandle &&
172
+ candleVisible(liveCandle, winStart, winEnd, candleWidthSecs)
173
+ ) {
176
174
  appendVolumeBar(
177
175
  liveCandle,
178
176
  winStart,
@@ -0,0 +1,51 @@
1
+ import type { CandlePoint } from "../types";
2
+
3
+ /** First bucket touching the left edge (inclusive). */
4
+ export function volumeStartIndex(
5
+ candles: CandlePoint[],
6
+ start: number,
7
+ width: number,
8
+ ): number {
9
+ "worklet";
10
+ let lo = 0,
11
+ hi = candles.length;
12
+ while (lo < hi) {
13
+ const mid = (lo + hi) >> 1;
14
+ if (candles[mid].time + width < start) lo = mid + 1;
15
+ else hi = mid;
16
+ }
17
+ return lo;
18
+ }
19
+
20
+ /** First bucket beyond the right edge (exclusive). */
21
+ export function volumeEndIndex(candles: CandlePoint[], end: number): number {
22
+ "worklet";
23
+ let lo = 0,
24
+ hi = candles.length;
25
+ while (lo < hi) {
26
+ const mid = (lo + hi) >> 1;
27
+ if (candles[mid].time <= end) lo = mid + 1;
28
+ else hi = mid;
29
+ }
30
+ return lo;
31
+ }
32
+
33
+ export function visibleMaxVolume(
34
+ candles: CandlePoint[],
35
+ start: number,
36
+ end: number,
37
+ ): number {
38
+ "worklet";
39
+ let max = 0;
40
+ for (let i = start; i < end; i++) {
41
+ const volume = candles[i].volume ?? 0;
42
+ if (volume > max) max = volume;
43
+ }
44
+ return max;
45
+ }
46
+
47
+ export interface VolumeRange {
48
+ start: number;
49
+ end: number;
50
+ max: number;
51
+ }