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
@@ -59,6 +59,7 @@ import { useChartOverlayContext } from "../hooks/useChartOverlayContext";
59
59
  import { useChartSkiaFont } from "../hooks/useChartSkiaFont";
60
60
  import { useCrosshairSeries } from "../hooks/useCrosshairSeries";
61
61
  import { useCrosshairVisibleOpacity } from "../hooks/useCrosshairVisibleOpacity";
62
+ import { useLoadingLook } from "../hooks/useLoadingLook";
62
63
  import { useMarkers } from "../hooks/useMarkers";
63
64
  import { useMultiSeriesDegen } from "../hooks/useMultiSeriesDegen";
64
65
  import { useMultiSeriesLinePaths } from "../hooks/useMultiSeriesLinePaths";
@@ -517,6 +518,9 @@ function useLiveChartSeriesController(props: LiveChartSeriesProps) {
517
518
  // Resolve the loading shell: null = not loading, else the styled config.
518
519
  const loadingCfg = resolveLoading(loading);
519
520
  const loadingActive = loadingCfg !== null;
521
+ // The shell fades after `loading` turns off: it keeps the last loading
522
+ // config's look instead of the defaults.
523
+ const loadingLook = useLoadingLook(loadingCfg);
520
524
  // Multi-series is always lines, so only the reveal transition applies (no
521
525
  // candle↔line crossfade); `transitions.mode` is accepted but inert here.
522
526
  const transitionsCfg = resolveTransitions(transitions);
@@ -583,6 +587,7 @@ function useLiveChartSeriesController(props: LiveChartSeriesProps) {
583
587
  const {
584
588
  pack: degenPack,
585
589
  packRevision: degenPackRevision,
590
+ particleTimestamp: degenParticleTimestamp,
586
591
  shakeTransform: degenShakeTransform,
587
592
  } = useMultiSeriesDegen(engine, effectivePadding, degenCfg, onDegenShake);
588
593
 
@@ -786,11 +791,11 @@ function useLiveChartSeriesController(props: LiveChartSeriesProps) {
786
791
  reveal,
787
792
  loadingActive,
788
793
  // loading shell styling (null → not loading)
789
- loadingLineColor: loadingCfg?.color,
790
- loadingStrokeWidth: loadingCfg?.strokeWidth,
791
- loadingAmplitude: loadingCfg?.amplitude,
792
- loadingSpeed: loadingCfg?.speed,
793
- loadingAxisLabels: loadingCfg?.axisLabels ?? true,
794
+ loadingLineColor: loadingLook?.color,
795
+ loadingStrokeWidth: loadingLook?.strokeWidth,
796
+ loadingAmplitude: loadingLook?.amplitude,
797
+ loadingSpeed: loadingLook?.speed,
798
+ loadingAxisLabels: loadingLook?.axisLabels ?? true,
794
799
  effectiveSeries,
795
800
  layoutHeight,
796
801
  onLayout,
@@ -800,6 +805,7 @@ function useLiveChartSeriesController(props: LiveChartSeriesProps) {
800
805
  lineStyles,
801
806
  degenPack,
802
807
  degenPackRevision,
808
+ degenParticleTimestamp,
803
809
  degenShakeTransform,
804
810
  yAxisEntries,
805
811
  xAxisEntries,
@@ -863,6 +869,7 @@ function SeriesYAxisLayer({
863
869
  gridStyle={gridStyleCfg}
864
870
  labelRightMargin={yAxisCfg.labelRightMargin}
865
871
  gridEndGap={yAxisCfg.gridEndGap}
872
+ minGap={yAxisCfg.minGap}
866
873
  />
867
874
  </Group>
868
875
  );
@@ -893,6 +900,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
893
900
  degenCfg,
894
901
  degenPack,
895
902
  degenPackRevision,
903
+ degenParticleTimestamp,
896
904
  markersActive,
897
905
  markersSV,
898
906
  markerClusterCfg,
@@ -1007,7 +1015,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
1007
1015
  <DegenParticlesOverlay
1008
1016
  pack={degenPack}
1009
1017
  packRevision={degenPackRevision}
1010
- engine={engine}
1018
+ particleTimestamp={degenParticleTimestamp}
1011
1019
  palette={palette}
1012
1020
  particleSlotCount={degenCfg.particleSlotCount}
1013
1021
  particleBurstDurationSec={degenCfg.particleBurstDurationSec}
@@ -8,8 +8,15 @@ import {
8
8
  vec,
9
9
  type SkFont,
10
10
  } from "@shopify/react-native-skia";
11
- import { useRef } from "react";
12
- import { useDerivedValue, type SharedValue } from "react-native-reanimated";
11
+ import { useCallback, useRef } from "react";
12
+ import {
13
+ useAnimatedReaction,
14
+ useDerivedValue,
15
+ useFrameCallback,
16
+ useSharedValue,
17
+ type SharedValue,
18
+ } from "react-native-reanimated";
19
+ import { scheduleOnRN } from "react-native-worklets";
13
20
  import {
14
21
  BADGE_METRICS_DEFAULTS,
15
22
  EMPTY_STATE_METRICS_DEFAULTS,
@@ -25,7 +32,7 @@ import {
25
32
  type ReanimatedPathBuilder,
26
33
  } from "../hooks/usePathBuilder";
27
34
  import { drawSpline } from "../math/spline";
28
- import { buildSquigglyPts } from "../math/squiggly";
35
+ import { buildSquigglyPts, squiggleClockSeconds } from "../math/squiggly";
29
36
  import type {
30
37
  BadgeMetrics,
31
38
  EmptyStateMetrics,
@@ -64,6 +71,8 @@ export function LoadingOverlay({
64
71
  badgeMetrics = BADGE_METRICS_DEFAULTS,
65
72
  emptyMetrics = EMPTY_STATE_METRICS_DEFAULTS,
66
73
  showAxisLabels = true,
74
+ isStatic = false,
75
+ isFrameLoopActive,
67
76
  lineColor,
68
77
  lineStrokeWidth,
69
78
  waveAmplitude = 14,
@@ -100,6 +109,10 @@ export function LoadingOverlay({
100
109
  emptyMetrics?: EmptyStateMetrics;
101
110
  /** Draw the skeleton Y-axis label placeholders. Default `true`. */
102
111
  showAxisLabels?: boolean;
112
+ /** `static` chart: no per-frame loops, so the squiggle holds still. */
113
+ isStatic?: boolean;
114
+ /** The chart's frame-loop gate: the squiggle holds still while it is off. */
115
+ isFrameLoopActive?: SharedValue<boolean>;
103
116
  }) {
104
117
  // Same left-inset formula as GridOverlay (only used when badge=true)
105
118
  const leftInset =
@@ -124,7 +137,37 @@ export function LoadingOverlay({
124
137
  const squigglyBuilder = usePathBuilder();
125
138
  const squigglyPtsRef = useRef<number[]>([]);
126
139
 
127
- // Squiggly path — animated each frame via timestamp
140
+ // Squiggle clock: wall time, not `engine.timestamp` — a `nowOverride` freezes
141
+ // the engine clock between data updates, which froze the loading line with it
142
+ // (the pulse clock in DotOverlay has the same fix). Ticks only while the shell
143
+ // shows (loading, empty, or mid-reveal) on a chart whose frame loops run: never
144
+ // on a `static` chart, while `isFrameLoopActive` is off, or at wave speed 0.
145
+ const squiggleClock = useSharedValue(squiggleClockSeconds());
146
+ /* istanbul ignore next -- frame-callback worklet runs on the UI thread, not in Jest */
147
+ const squiggleFrames = useFrameCallback(() => {
148
+ if (isFrameLoopActive?.get() === false) return;
149
+ squiggleClock.value = squiggleClockSeconds();
150
+ }, false);
151
+ const setSquiggleTicking = useCallback(
152
+ (on: boolean) => squiggleFrames.setActive(on),
153
+ [squiggleFrames],
154
+ );
155
+ useAnimatedReaction(
156
+ () =>
157
+ !isStatic &&
158
+ waveSpeed !== 0 &&
159
+ isFrameLoopActive?.get() !== false &&
160
+ (isLoading.get() || isEmpty.value || morphT.get() < 1),
161
+ (ticking, previous) => {
162
+ if (ticking === previous) return;
163
+ // Start from now, not from wherever the clock stopped when the shell last hid:
164
+ // the callback only starts after a UI → JS → UI round trip.
165
+ if (ticking) squiggleClock.set(squiggleClockSeconds());
166
+ scheduleOnRN(setSquiggleTicking, ticking);
167
+ },
168
+ );
169
+
170
+ // Squiggly path — animated each frame by the squiggle clock
128
171
  const squigglyPath = useDerivedValue(() => {
129
172
  const b = squigglyBuilder.value;
130
173
  if (!isLoading.get() && !isEmpty.value && morphT.get() >= 1) {
@@ -134,7 +177,7 @@ export function LoadingOverlay({
134
177
  engine.canvasWidth.get(),
135
178
  engine.canvasHeight.get(),
136
179
  padding,
137
- engine.timestamp.get(),
180
+ squiggleClock.get(),
138
181
  waveAmplitude,
139
182
  waveSpeed,
140
183
  squigglyPtsRef.current,
@@ -243,12 +286,10 @@ export function LoadingOverlay({
243
286
  return [0, t1, t2, 1];
244
287
  });
245
288
 
246
- const gapGradEnd = useDerivedValue(() =>
247
- vec(
248
- Math.max(1, emptyGapLayout.get().gapRight - emptyGapLayout.get().gapLeft),
249
- 0,
250
- ),
251
- );
289
+ // Gradient coordinates are canvas coordinates, not relative to the Rect.
290
+ // Starting at (0, 0) shifts the mask left and exposes a rectangular band.
291
+ const gapGradStart = useDerivedValue(() => vec(emptyGapLayout.get().gapLeft, 0));
292
+ const gapGradEnd = useDerivedValue(() => vec(emptyGapLayout.get().gapRight, 0));
252
293
 
253
294
  const emptyLabelText = useDerivedValue(() =>
254
295
  isEmpty.value ? emptyText : "",
@@ -276,7 +317,7 @@ export function LoadingOverlay({
276
317
  >
277
318
  <Rect x={gapLeft} y={gapTop} width={gapWidth} height={gapHeight}>
278
319
  <LinearGradient
279
- start={vec(0, 0)}
320
+ start={gapGradStart}
280
321
  end={gapGradEnd}
281
322
  colors={gapMaskColors}
282
323
  positions={gapGradientPositions}
@@ -4,7 +4,13 @@ import {
4
4
  Path,
5
5
  type SkFont,
6
6
  } from "@shopify/react-native-skia";
7
- import { useDerivedValue, type SharedValue } from "react-native-reanimated";
7
+ import { useState } from "react";
8
+ import {
9
+ useAnimatedReaction,
10
+ useDerivedValue,
11
+ type SharedValue,
12
+ } from "react-native-reanimated";
13
+ import { scheduleOnRN } from "react-native-worklets";
8
14
  import { BADGE_METRICS_DEFAULTS, MAX_Y_LABELS } from "../constants";
9
15
  import type { ResolvedGridStyleConfig } from "../core/resolveConfig";
10
16
  import {
@@ -63,6 +69,7 @@ export function YAxisOverlay({
63
69
  side = "right",
64
70
  labelRightMargin,
65
71
  gridEndGap = 0,
72
+ minGap = 36,
66
73
  }: {
67
74
  entries: SharedValue<YAxisEntry[]>;
68
75
  engine: ChartEngineLayout;
@@ -104,6 +111,8 @@ export function YAxisOverlay({
104
111
  labelRightMargin?: number;
105
112
  /** Gap between the grid-line end and the shared label column. */
106
113
  gridEndGap?: number;
114
+ /** The grid's `minGap`: bounds how many labelled lines the plot can hold. */
115
+ minGap?: number;
107
116
  }) {
108
117
  const gridColor = gridStyle?.color ?? palette.gridLine;
109
118
  const gridWidth = gridStyle?.strokeWidth ?? 1;
@@ -190,6 +199,33 @@ export function YAxisOverlay({
190
199
  return result;
191
200
  });
192
201
 
202
+ // The label pool starts at MAX_Y_LABELS and grows to the number of labels the
203
+ // grid produces. A tall plot (or a small `minGap`) carries more labelled grid
204
+ // lines than a fixed pool held, and the extra lines were drawn without labels —
205
+ // which ones depended on key order. It grows no further than a settled grid
206
+ // can need: labelled lines stay at least half a `minGap` apart (the grid's
207
+ // hysteresis floor). Lines still fading out after a zoom can briefly outnumber
208
+ // that; for those frames some lines go unlabelled, as before. The pool never
209
+ // shrinks: a chart that fits keeps its cost, and zooming back and forth
210
+ // doesn't remount labels.
211
+ const [labelSlots, setLabelSlots] = useState(MAX_Y_LABELS);
212
+ const growLabelSlots = (slots: number) =>
213
+ setLabelSlots((current) => Math.max(current, slots));
214
+ useAnimatedReaction(
215
+ () => {
216
+ const plotHeight =
217
+ engine.canvasHeight.get() - padding.top - padding.bottom;
218
+ const settledMax = Math.floor(plotHeight / (minGap / 2)) + 1;
219
+ return Math.min(labelEntries.get().length, settledMax);
220
+ },
221
+ // Runs on every pass of the mapper: ask once per new size.
222
+ (slots, previous) => {
223
+ if (slots > labelSlots && slots !== previous) {
224
+ scheduleOnRN(growLabelSlots, slots);
225
+ }
226
+ },
227
+ );
228
+
193
229
  return (
194
230
  <Group>
195
231
  {variant !== "labels" && (
@@ -207,7 +243,7 @@ export function YAxisOverlay({
207
243
  </Group>
208
244
  )}
209
245
  {variant !== "grid" &&
210
- Array.from({ length: MAX_Y_LABELS }, (_, i) => (
246
+ Array.from({ length: labelSlots }, (_, i) => (
211
247
  <AnimatedLabel
212
248
  key={i}
213
249
  entries={labelEntries}
package/src/constants.ts CHANGED
@@ -10,9 +10,9 @@ import type {
10
10
  export const MS_PER_FRAME_60FPS = 16.67;
11
11
 
12
12
  /**
13
- * Size of the pre-allocated Y-axis label pool — the most price labels the axis
14
- * ever renders at once. Caps both the dynamic (nice-interval) grid and the
15
- * fixed-`count` mode (see {@link YAxisConfig.count}).
13
+ * Initial size of the Y-axis label pool. The pool grows when the dynamic
14
+ * (nice-interval) grid produces more labels (a tall plot or a small `minGap`);
15
+ * the fixed-`count` mode stays capped at this (see {@link YAxisConfig.count}).
16
16
  */
17
17
  export const MAX_Y_LABELS = 15;
18
18
 
@@ -0,0 +1,109 @@
1
+ import type { CandlePoint, LiveChartPoint } from "../types";
2
+
3
+ /** A fresh wrapper per SharedValue notification, including same-array modify(). */
4
+ export interface HistoryRevision<T> {
5
+ data: T[];
6
+ }
7
+
8
+ /** Per-engine, one-entry cache. Only committed history belongs here. */
9
+ export interface HistoryRangeCache {
10
+ revision: object | undefined;
11
+ data: LiveChartPoint[] | CandlePoint[] | undefined;
12
+ candle: boolean;
13
+ start: number;
14
+ end: number;
15
+ min: number;
16
+ max: number;
17
+ minTime: number;
18
+ maxTime: number;
19
+ }
20
+
21
+ export function makeHistoryRangeCache(): HistoryRangeCache {
22
+ "worklet";
23
+ return {
24
+ revision: undefined,
25
+ data: undefined,
26
+ candle: false,
27
+ start: -1,
28
+ end: -1,
29
+ min: Infinity,
30
+ max: -Infinity,
31
+ minTime: 0,
32
+ maxTime: 0,
33
+ };
34
+ }
35
+
36
+ /** Inclusive time bounds; the returned end index is exclusive. */
37
+ function timeBound(
38
+ data: { time: number }[],
39
+ time: number,
40
+ upper: boolean,
41
+ ): number {
42
+ "worklet";
43
+ let lo = 0;
44
+ let hi = data.length;
45
+ while (lo < hi) {
46
+ const mid = (lo + hi) >>> 1;
47
+ if (upper ? data[mid].time <= time : data[mid].time < time) lo = mid + 1;
48
+ else hi = mid;
49
+ }
50
+ return lo;
51
+ }
52
+
53
+ /**
54
+ * Reuse only when a trusted revision AND both visible index bounds match.
55
+ * Without a revision, always scan: identity/length cannot detect in-place edits.
56
+ * The caller owns the cache; live candles, references and animation stay outside.
57
+ */
58
+ export function historyRange(
59
+ data: LiveChartPoint[] | CandlePoint[],
60
+ candle: boolean,
61
+ from: number,
62
+ to: number,
63
+ revision: object | undefined,
64
+ cache: HistoryRangeCache,
65
+ ): HistoryRangeCache {
66
+ "worklet";
67
+ const start = timeBound(data, from, false);
68
+ const end = to === Infinity ? data.length : timeBound(data, to, true);
69
+ if (
70
+ revision !== undefined &&
71
+ cache.revision === revision &&
72
+ cache.data === data &&
73
+ cache.candle === candle &&
74
+ cache.start === start &&
75
+ cache.end === end
76
+ )
77
+ return cache;
78
+
79
+ cache.revision = revision;
80
+ cache.data = data;
81
+ cache.candle = candle;
82
+ cache.start = start;
83
+ cache.end = end;
84
+ let min = Infinity;
85
+ let max = -Infinity;
86
+ let minTime = 0;
87
+ let maxTime = 0;
88
+ for (let i = start; i < end; i++) {
89
+ const point = data[i];
90
+ const low = candle
91
+ ? (point as CandlePoint).low
92
+ : (point as LiveChartPoint).value;
93
+ const high = candle ? (point as CandlePoint).high : low;
94
+ // Strict comparisons preserve the first occurrence when extrema tie.
95
+ if (low < min) {
96
+ min = low;
97
+ minTime = point.time;
98
+ }
99
+ if (high > max) {
100
+ max = high;
101
+ maxTime = point.time;
102
+ }
103
+ }
104
+ cache.min = min;
105
+ cache.max = max;
106
+ cache.minTime = minTime;
107
+ cache.maxTime = maxTime;
108
+ return cache;
109
+ }
@@ -1,5 +1,11 @@
1
1
  import type { CandleGap, CandlePoint, LiveChartPoint } from "../types";
2
2
 
3
+ import {
4
+ historyRange,
5
+ makeHistoryRangeCache,
6
+ type HistoryRangeCache,
7
+ } from "./historyRangeCache";
8
+
3
9
  import { MOTION_METRICS_DEFAULTS } from "../constants";
4
10
  import { lerp } from "../math/lerp";
5
11
  import { thresholdRangeMinMax } from "../math/threshold";
@@ -79,6 +85,8 @@ export interface EngineTickInput {
79
85
  yRangeScale?: number;
80
86
  targetValue: number;
81
87
  points: LiveChartPoint[];
88
+ /** Internal notification revision for the selected history; omit to disable reuse. */
89
+ historyRevision?: object;
82
90
  /** Seconds since Unix epoch; defaults to `Date.now() / 1000` */
83
91
  nowSeconds?: number;
84
92
  /** Override the engine's "now" (unix seconds) — e.g. fill historical data edge-to-edge. */
@@ -187,6 +195,7 @@ export function advanceTimestampByPixel(
187
195
  export function tickLiveChartEngineFrame(
188
196
  state: EngineTickMutable,
189
197
  input: EngineTickInput,
198
+ historyCache?: HistoryRangeCache,
190
199
  ): void {
191
200
  "worklet";
192
201
  const baseNow = input.nowOverride ?? input.nowSeconds ?? Date.now() / 1000;
@@ -301,39 +310,22 @@ export function tickLiveChartEngineFrame(
301
310
 
302
311
  const winStart = state.timestamp - state.displayWindow;
303
312
 
304
- let tMin = Infinity;
305
- let tMax = -Infinity;
306
- // Time of the running min / max — captured alongside the value so an extrema
307
- // label can be pinned at the point's x. Holds the data extrema only (folded
308
- // below with the live value / references for the Y range, but snapshotted
309
- // before that into `extrema*` so the label tracks the real high/low).
310
- let minTime = 0;
311
- let maxTime = 0;
313
+ const candleMode = input.mode === "candle";
314
+ const history = historyRange(
315
+ candleMode ? (input.candles ?? []) : input.points,
316
+ candleMode,
317
+ winStart,
318
+ // Line feeds may lead the local clock. Preserve their inclusive live tail.
319
+ candleMode || scrolledBack ? state.timestamp : Infinity,
320
+ input.historyRevision,
321
+ historyCache ?? makeHistoryRangeCache(),
322
+ );
323
+ let tMin = history.min;
324
+ let tMax = history.max;
325
+ let minTime = history.minTime;
326
+ let maxTime = history.maxTime;
312
327
 
313
- if (input.mode === "candle") {
314
- const candles = input.candles;
315
- if (candles && candles.length > 0) {
316
- let lo = 0;
317
- let hi = candles.length;
318
- while (lo < hi) {
319
- const mid = (lo + hi) >> 1;
320
- if (candles[mid].time < winStart) lo = mid + 1;
321
- else hi = mid;
322
- }
323
- for (let i = lo; i < candles.length; i++) {
324
- if (candles[i].time > state.timestamp) break;
325
- /* istanbul ignore next -- trivial min/max */
326
- if (candles[i].low < tMin) {
327
- tMin = candles[i].low;
328
- minTime = candles[i].time;
329
- }
330
- /* istanbul ignore next -- trivial min/max */
331
- if (candles[i].high > tMax) {
332
- tMax = candles[i].high;
333
- maxTime = candles[i].time;
334
- }
335
- }
336
- }
328
+ if (candleMode) {
337
329
  const lc = input.liveCandle;
338
330
  // Fold the in-progress candle in only while its bucket is visible — when
339
331
  // scrolled back past it, the live candle must not stretch the Y range.
@@ -349,31 +341,6 @@ export function tickLiveChartEngineFrame(
349
341
  maxTime = lc.time;
350
342
  }
351
343
  }
352
- } else {
353
- const points = input.points;
354
- let lo = 0;
355
- let hi = points.length;
356
- while (lo < hi) {
357
- const mid = (lo + hi) >> 1;
358
- if (points[mid].time < winStart) lo = mid + 1;
359
- else hi = mid;
360
- }
361
- for (let i = lo; i < points.length; i++) {
362
- // While scrolled back, stop at the frozen right edge (mirrors the candle
363
- // scan) so newer points don't inflate the visible Y range. Following
364
- // live, keep the tail inclusive — feed timestamps can run slightly ahead
365
- // of the local clock and must not flicker out of the range.
366
- if (scrolledBack && points[i].time > state.timestamp) break;
367
- const v = points[i].value;
368
- if (v < tMin) {
369
- tMin = v;
370
- minTime = points[i].time;
371
- }
372
- if (v > tMax) {
373
- tMax = v;
374
- maxTime = points[i].time;
375
- }
376
- }
377
344
  }
378
345
 
379
346
  // Snapshot the raw data extrema (value + time) before the live value /
@@ -545,7 +545,7 @@ const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
545
545
  * Resolves `yAxis` prop to a fully-typed config or null (disabled).
546
546
  * `true` → defaults, object → merged with defaults, falsy → null.
547
547
  * `count` is normalized to a non-negative integer (the grid math clamps the
548
- * upper bound to the label pool size).
548
+ * upper bound to `MAX_Y_LABELS`).
549
549
  */
550
550
  export function resolveYAxis(
551
551
  prop: boolean | YAxisConfig | undefined,
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useSharedValue, type SharedValue } from "react-native-reanimated";
3
+ import { scheduleOnUI } from "react-native-worklets";
4
+ import type { HistoryRevision } from "./historyRangeCache";
5
+
6
+ // Reanimated's mapper IDs are positive. Separate IDs also let multiple charts
7
+ // subscribe to the same feed without replacing each other's listeners.
8
+ let nextHistoryListenerId = -1;
9
+
10
+ /** Invalidate synchronously: a frame can run before derived mappers flush. */
11
+ export function useHistoryRevision<T>(
12
+ data: SharedValue<T[]> | undefined,
13
+ source: SharedValue<T[]> | undefined = data,
14
+ ): SharedValue<HistoryRevision<T> | undefined> {
15
+ const revision = useSharedValue<HistoryRevision<T> | undefined>(undefined);
16
+ const [listenerId] = useState(() => nextHistoryListenerId--);
17
+ useEffect(() => {
18
+ scheduleOnUI(() => {
19
+ "worklet";
20
+ const refresh = () => {
21
+ "worklet";
22
+ revision.set({ data: data?.get() ?? [] });
23
+ };
24
+ data?.addListener(listenerId, refresh);
25
+ if (source !== data) source?.addListener(listenerId, refresh);
26
+ refresh();
27
+ });
28
+ return () =>
29
+ scheduleOnUI(() => {
30
+ "worklet";
31
+ data?.removeListener(listenerId);
32
+ if (source !== data) source?.removeListener(listenerId);
33
+ });
34
+ }, [data, source, revision, listenerId]);
35
+ return revision;
36
+ }