react-native-livechart 4.24.1 → 4.26.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 (114) hide show
  1. package/dist/components/DegenParticlesOverlay.d.ts +2 -2
  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 +6 -2
  13. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  14. package/dist/core/liveChartSeriesEngineTick.d.ts +2 -1
  15. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  16. package/dist/core/resolveConfig.d.ts +1 -1
  17. package/dist/core/useHistoryRevision.d.ts +5 -0
  18. package/dist/core/useHistoryRevision.d.ts.map +1 -0
  19. package/dist/core/useLiveChartEngine.d.ts +20 -2
  20. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  21. package/dist/core/useLiveChartSeriesEngine.d.ts +5 -1
  22. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  23. package/dist/draw/candleBodyPath.d.ts +10 -0
  24. package/dist/draw/candleBodyPath.d.ts.map +1 -0
  25. package/dist/draw/lineFillPaths.d.ts +10 -0
  26. package/dist/draw/lineFillPaths.d.ts.map +1 -0
  27. package/dist/draw/volume.d.ts +4 -1
  28. package/dist/draw/volume.d.ts.map +1 -1
  29. package/dist/draw/volumeRange.d.ts +12 -0
  30. package/dist/draw/volumeRange.d.ts.map +1 -0
  31. package/dist/hooks/crosshairShared.d.ts +8 -0
  32. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  33. package/dist/hooks/useCandlePaths.d.ts +1 -1
  34. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  35. package/dist/hooks/useChartPaths.d.ts +1 -1
  36. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  37. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  38. package/dist/hooks/useDegen.d.ts +1 -0
  39. package/dist/hooks/useDegen.d.ts.map +1 -1
  40. package/dist/hooks/useDemandFrameLoop.d.ts +11 -0
  41. package/dist/hooks/useDemandFrameLoop.d.ts.map +1 -0
  42. package/dist/hooks/useLoadingLook.d.ts +12 -0
  43. package/dist/hooks/useLoadingLook.d.ts.map +1 -0
  44. package/dist/hooks/useMarkers.d.ts +3 -1
  45. package/dist/hooks/useMarkers.d.ts.map +1 -1
  46. package/dist/hooks/useMultiSeriesDegen.d.ts +1 -0
  47. package/dist/hooks/useMultiSeriesDegen.d.ts.map +1 -1
  48. package/dist/hooks/usePanScroll.d.ts +2 -0
  49. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  50. package/dist/hooks/usePinchZoom.d.ts +2 -0
  51. package/dist/hooks/usePinchZoom.d.ts.map +1 -1
  52. package/dist/hooks/useReferenceDrag.d.ts +3 -2
  53. package/dist/hooks/useReferenceDrag.d.ts.map +1 -1
  54. package/dist/hooks/useReferenceLineGrouping.d.ts +19 -0
  55. package/dist/hooks/useReferenceLineGrouping.d.ts.map +1 -0
  56. package/dist/hooks/useVolumeRange.d.ts +14 -0
  57. package/dist/hooks/useVolumeRange.d.ts.map +1 -0
  58. package/dist/hooks/useXAxis.d.ts +7 -0
  59. package/dist/hooks/useXAxis.d.ts.map +1 -1
  60. package/dist/hooks/useYAxis.d.ts.map +1 -1
  61. package/dist/index.d.ts +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/math/rangeAnimation.d.ts +4 -0
  64. package/dist/math/rangeAnimation.d.ts.map +1 -0
  65. package/dist/math/referenceDrag.d.ts +17 -5
  66. package/dist/math/referenceDrag.d.ts.map +1 -1
  67. package/dist/math/referenceGroup.d.ts +2 -2
  68. package/dist/math/spline.d.ts +3 -1
  69. package/dist/math/spline.d.ts.map +1 -1
  70. package/dist/math/squiggly.d.ts +8 -0
  71. package/dist/math/squiggly.d.ts.map +1 -1
  72. package/dist/types.d.ts +52 -3
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +1 -1
  75. package/src/components/DegenParticlesOverlay.tsx +5 -5
  76. package/src/components/LiveChart.tsx +37 -66
  77. package/src/components/LiveChartSeries.tsx +18 -6
  78. package/src/components/LoadingOverlay.tsx +53 -12
  79. package/src/components/YAxisOverlay.tsx +38 -2
  80. package/src/constants.ts +3 -3
  81. package/src/core/historyRangeCache.ts +109 -0
  82. package/src/core/liveChartEngineTick.ts +41 -63
  83. package/src/core/liveChartSeriesEngineTick.ts +18 -5
  84. package/src/core/resolveConfig.ts +1 -1
  85. package/src/core/useHistoryRevision.ts +36 -0
  86. package/src/core/useLiveChartEngine.ts +159 -64
  87. package/src/core/useLiveChartSeriesEngine.ts +22 -2
  88. package/src/draw/candleBodyPath.ts +38 -0
  89. package/src/draw/lineFillPaths.ts +63 -0
  90. package/src/draw/volume.ts +21 -23
  91. package/src/draw/volumeRange.ts +51 -0
  92. package/src/hooks/crosshairShared.ts +29 -0
  93. package/src/hooks/useCandlePaths.ts +68 -94
  94. package/src/hooks/useChartPaths.ts +87 -111
  95. package/src/hooks/useCrosshair.ts +15 -3
  96. package/src/hooks/useDegen.ts +10 -6
  97. package/src/hooks/useDemandFrameLoop.ts +82 -0
  98. package/src/hooks/useLoadingLook.ts +30 -0
  99. package/src/hooks/useMarkers.ts +18 -4
  100. package/src/hooks/useMultiSeriesDegen.ts +7 -3
  101. package/src/hooks/usePanScroll.ts +5 -0
  102. package/src/hooks/usePinchZoom.ts +5 -0
  103. package/src/hooks/useReferenceDrag.ts +28 -14
  104. package/src/hooks/useReferenceLineGrouping.ts +72 -0
  105. package/src/hooks/useVolumeRange.ts +40 -0
  106. package/src/hooks/useXAxis.ts +27 -3
  107. package/src/hooks/useYAxis.ts +18 -2
  108. package/src/index.ts +1 -0
  109. package/src/math/rangeAnimation.ts +28 -0
  110. package/src/math/referenceDrag.ts +21 -4
  111. package/src/math/referenceGroup.ts +2 -2
  112. package/src/math/spline.ts +16 -8
  113. package/src/math/squiggly.ts +12 -0
  114. package/src/types.ts +53 -3
@@ -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
+ }
@@ -244,6 +244,35 @@ export function computeScrubDotY(
244
244
  return padTop + ((displayMax - value) / valRange) * chartH;
245
245
  }
246
246
 
247
+ /**
248
+ * A reference value's canvas Y pinned to the plot: a value off the visible range
249
+ * sits on the nearest plot edge. `-1` when there is no plot height (not laid out,
250
+ * or shorter than its padding) — decided from the layout, not from the projected
251
+ * Y: {@link computeScrubDotY} returns a negative Y for a value far above the range
252
+ * too, and that one must pin to the top edge as a value below pins to the bottom.
253
+ */
254
+ export function pinnedPlotY(
255
+ value: number,
256
+ displayMin: number,
257
+ displayMax: number,
258
+ canvasHeight: number,
259
+ padTop: number,
260
+ padBottom: number,
261
+ ): number {
262
+ "worklet";
263
+ const bottom = canvasHeight - padBottom;
264
+ if (bottom - padTop <= 0) return -1;
265
+ const y = computeScrubDotY(
266
+ value,
267
+ displayMin,
268
+ displayMax,
269
+ canvasHeight,
270
+ padTop,
271
+ padBottom,
272
+ );
273
+ return Math.min(bottom, Math.max(padTop, y));
274
+ }
275
+
247
276
  /**
248
277
  * Inverse of {@link computeScrubDotY}: maps a canvas Y pixel back to a value (a
249
278
  * free price *level*, used by scrub-action mode where the reticle Y is the chosen
@@ -1,5 +1,4 @@
1
- import { Skia } from "@shopify/react-native-skia";
2
- import { useEffect } from "react";
1
+ import { useCallback, useEffect } from "react";
3
2
  import {
4
3
  useDerivedValue,
5
4
  useFrameCallback,
@@ -8,12 +7,15 @@ import {
8
7
  } from "react-native-reanimated";
9
8
  import { CANDLE_METRICS_DEFAULTS, MS_PER_FRAME_60FPS } from "../constants";
10
9
  import type { SingleEngineState } from "../core/useLiveChartEngine";
10
+ import { buildCandleBodyPath } from "../draw/candleBodyPath";
11
11
  import { buildCandleGeometry } from "../draw/candle";
12
12
  import { buildVolumeGeometry } from "../draw/volume";
13
13
  import type { ChartPadding } from "../draw/line";
14
14
  import { lerp } from "../math/lerp";
15
15
  import type { CandleMetrics, CandlePoint } from "../types";
16
+ import { useVolumeRange } from "./useVolumeRange";
16
17
  import { usePathBuilder } from "./usePathBuilder";
18
+ import { useDemandFrameLoop } from "./useDemandFrameLoop";
17
19
 
18
20
  const CANDLE_WIDTH_LERP_SPEED = 0.08;
19
21
 
@@ -40,6 +42,7 @@ export function useCandleWidthLerp(
40
42
  active: boolean,
41
43
  /** Runtime gate shared with the chart engine. */
42
44
  isFrameLoopActive?: SharedValue<boolean>,
45
+ autoSleep = false,
43
46
  ): SharedValue<number> {
44
47
  const targetCandleWidth = useDerivedValue(() => candleWidthSecs);
45
48
  const displayCandleWidth = useSharedValue(candleWidthSecs);
@@ -51,10 +54,10 @@ export function useCandleWidthLerp(
51
54
  () => candleLerpSpeed ?? CANDLE_WIDTH_LERP_SPEED,
52
55
  );
53
56
 
54
- const widthFrameCallback = useFrameCallback((frameInfo) => {
57
+ const tickWidth = useCallback((dt: number) => {
55
58
  "worklet";
56
- if (!active || isFrameLoopActive?.get() === false) return;
57
- const dt = frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS;
59
+ if (!active || isFrameLoopActive?.get() === false) return false;
60
+ const previous = displayCandleWidth.get();
58
61
  const target = targetCandleWidth.get();
59
62
  const next = lerp(
60
63
  displayCandleWidth.get(),
@@ -62,12 +65,24 @@ export function useCandleWidthLerp(
62
65
  widthLerpSpeed.get(),
63
66
  dt,
64
67
  );
65
- displayCandleWidth.set(Math.abs(target - next) < 0.01 ? target : next);
66
- }, autostart);
68
+ const settled = Math.abs(target - next) < 0.01 ? target : next;
69
+ displayCandleWidth.set(settled);
70
+ return previous !== settled;
71
+ }, [active, isFrameLoopActive, displayCandleWidth, targetCandleWidth, widthLerpSpeed]);
72
+ const prepareWidth = useCallback(() => {
73
+ "worklet";
74
+ return [targetCandleWidth.get(), widthLerpSpeed.get(), isFrameLoopActive?.get()];
75
+ }, [targetCandleWidth, widthLerpSpeed, isFrameLoopActive]);
76
+ useDemandFrameLoop(autoSleep && autostart && active, prepareWidth, tickWidth);
77
+ const legacyActive = autostart && !autoSleep;
78
+ const widthFrameCallback = useFrameCallback((frameInfo) => {
79
+ "worklet";
80
+ tickWidth(frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS);
81
+ }, legacyActive);
67
82
  useEffect(() => {
68
83
  // `autostart` is an initial seed in Reanimated, not a reactive switch.
69
- widthFrameCallback.setActive(autostart);
70
- }, [autostart, widthFrameCallback]);
84
+ widthFrameCallback.setActive(legacyActive);
85
+ }, [legacyActive, widthFrameCallback]);
71
86
 
72
87
  return displayCandleWidth;
73
88
  }
@@ -118,50 +133,24 @@ export function useCandlePaths(
118
133
  });
119
134
 
120
135
  /* istanbul ignore next -- worklet */
121
- const upBodiesPath = useDerivedValue(() => {
122
- const b = upBodiesBuilder.value;
123
- const { bodies } = geometry.value;
124
- const radius = candleMetrics.bodyRadius;
125
- for (let i = 0; i < bodies.length; i++) {
126
- if (bodies[i].up) {
127
- const bd = bodies[i];
128
- const rr = radius > 0 ? Math.min(radius, bd.w / 2, bd.h / 2) : 0;
129
- if (rr > 0) {
130
- b.addRRect({
131
- rect: { x: bd.x, y: bd.y, width: bd.w, height: bd.h },
132
- rx: rr,
133
- ry: rr,
134
- });
135
- } else {
136
- b.addRect(Skia.XYWHRect(bd.x, bd.y, bd.w, bd.h));
137
- }
138
- }
139
- }
140
- return b.detach();
141
- });
136
+ const upBodiesPath = useDerivedValue(() =>
137
+ buildCandleBodyPath(
138
+ upBodiesBuilder.get(),
139
+ geometry.get().bodies,
140
+ true,
141
+ candleMetrics.bodyRadius,
142
+ ),
143
+ );
142
144
 
143
145
  /* istanbul ignore next -- worklet */
144
- const downBodiesPath = useDerivedValue(() => {
145
- const b = downBodiesBuilder.value;
146
- const { bodies } = geometry.value;
147
- const radius = candleMetrics.bodyRadius;
148
- for (let i = 0; i < bodies.length; i++) {
149
- if (!bodies[i].up) {
150
- const bd = bodies[i];
151
- const rr = radius > 0 ? Math.min(radius, bd.w / 2, bd.h / 2) : 0;
152
- if (rr > 0) {
153
- b.addRRect({
154
- rect: { x: bd.x, y: bd.y, width: bd.w, height: bd.h },
155
- rx: rr,
156
- ry: rr,
157
- });
158
- } else {
159
- b.addRect(Skia.XYWHRect(bd.x, bd.y, bd.w, bd.h));
160
- }
161
- }
162
- }
163
- return b.detach();
164
- });
146
+ const downBodiesPath = useDerivedValue(() =>
147
+ buildCandleBodyPath(
148
+ downBodiesBuilder.get(),
149
+ geometry.get().bodies,
150
+ false,
151
+ candleMetrics.bodyRadius,
152
+ ),
153
+ );
165
154
 
166
155
  /* istanbul ignore next -- worklet */
167
156
  const upWicksPath = useDerivedValue(() => {
@@ -192,6 +181,13 @@ export function useCandlePaths(
192
181
  // Volume bars share the candle window + (lerped) candle width so each bar sits
193
182
  // directly under its candle body. Empty unless a volume band is reserved.
194
183
  /* istanbul ignore next -- worklet */
184
+ const volumeRange = useVolumeRange(
185
+ candles,
186
+ engine.timestamp,
187
+ engine.displayWindow,
188
+ displayCandleWidth,
189
+ active && volumeBandHeight > 0,
190
+ );
195
191
  const volumeGeometry = useDerivedValue(() => {
196
192
  if (!active || !candles || volumeBandHeight <= 0) return { bars: [] };
197
193
  return buildVolumeGeometry(
@@ -205,54 +201,32 @@ export function useCandlePaths(
205
201
  volumeBandHeight,
206
202
  displayCandleWidth.get(),
207
203
  candleMetrics,
204
+ {
205
+ start: volumeRange.start.get(),
206
+ end: volumeRange.end.get(),
207
+ max: volumeRange.max.get(),
208
+ },
208
209
  );
209
210
  });
210
211
 
211
212
  /* istanbul ignore next -- worklet */
212
- const upBarsPath = useDerivedValue(() => {
213
- const b = upBarsBuilder.value;
214
- const { bars } = volumeGeometry.value;
215
- for (let i = 0; i < bars.length; i++) {
216
- if (bars[i].up) {
217
- const bar = bars[i];
218
- const rr =
219
- volumeRadius > 0 ? Math.min(volumeRadius, bar.w / 2, bar.h / 2) : 0;
220
- if (rr > 0) {
221
- b.addRRect({
222
- rect: { x: bar.x, y: bar.y, width: bar.w, height: bar.h },
223
- rx: rr,
224
- ry: rr,
225
- });
226
- } else {
227
- b.addRect(Skia.XYWHRect(bar.x, bar.y, bar.w, bar.h));
228
- }
229
- }
230
- }
231
- return b.detach();
232
- });
233
-
213
+ const upBarsPath = useDerivedValue(() =>
214
+ buildCandleBodyPath(
215
+ upBarsBuilder.get(),
216
+ volumeGeometry.get().bars,
217
+ true,
218
+ volumeRadius,
219
+ ),
220
+ );
234
221
  /* istanbul ignore next -- worklet */
235
- const downBarsPath = useDerivedValue(() => {
236
- const b = downBarsBuilder.value;
237
- const { bars } = volumeGeometry.value;
238
- for (let i = 0; i < bars.length; i++) {
239
- if (!bars[i].up) {
240
- const bar = bars[i];
241
- const rr =
242
- volumeRadius > 0 ? Math.min(volumeRadius, bar.w / 2, bar.h / 2) : 0;
243
- if (rr > 0) {
244
- b.addRRect({
245
- rect: { x: bar.x, y: bar.y, width: bar.w, height: bar.h },
246
- rx: rr,
247
- ry: rr,
248
- });
249
- } else {
250
- b.addRect(Skia.XYWHRect(bar.x, bar.y, bar.w, bar.h));
251
- }
252
- }
253
- }
254
- return b.detach();
255
- });
222
+ const downBarsPath = useDerivedValue(() =>
223
+ buildCandleBodyPath(
224
+ downBarsBuilder.get(),
225
+ volumeGeometry.get().bars,
226
+ false,
227
+ volumeRadius,
228
+ ),
229
+ );
256
230
 
257
231
  return {
258
232
  upBodiesPath,
@@ -1,3 +1,4 @@
1
+ import { buildLineFillPaths } from "../draw/lineFillPaths";
1
2
  import { Skia, type SkPath } from "@shopify/react-native-skia";
2
3
  import { useRef } from "react";
3
4
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
@@ -8,13 +9,14 @@ import type {
8
9
  } from "../core/useLiveChartEngine";
9
10
  import { buildLinePoints, type ChartPadding } from "../draw/line";
10
11
  import { buildLineGapSegmentRanges } from "../draw/lineGap";
12
+ import { makeLineSimplifyScratch, simplifyLinePoints } from "../math/simplify";
13
+ import { makeSplineScratch } from "../math/spline";
14
+ import { thresholdSampleSpanX } from "../math/threshold";
11
15
  import {
12
- makeLineSimplifyScratch,
13
- simplifyLinePoints,
14
- } from "../math/simplify";
15
- import { drawSpline, makeSplineScratch } from "../math/spline";
16
- import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
17
- import { blendPtsY, squigglifyPts } from "../math/squiggly";
16
+ blendPtsY,
17
+ squiggleClockSeconds,
18
+ squigglifyPts,
19
+ } from "../math/squiggly";
18
20
  import { usePathBuilder } from "./usePathBuilder";
19
21
  import type { CandleGap } from "../types";
20
22
 
@@ -31,7 +33,7 @@ export function resolveLineTipValue(
31
33
 
32
34
  /**
33
35
  * Builds the `linePath` / `fillPath` with `Skia.PathBuilder`s reused across
34
- * frames (one per curve, held in a SharedValue) and finalized with `detach()` —
36
+ * geometry updates (one per curve, held in a SharedValue) and finalized with `detach()` —
35
37
  * which returns a fresh immutable `SkPath` each frame and resets the builder.
36
38
  * The fresh reference makes Reanimated notify subscribers (re-record + repaint)
37
39
  * without the two-SkPath ping-pong the mutable-path pool needed.
@@ -69,6 +71,12 @@ export function useChartPaths(
69
71
 
70
72
  const cacheRef = useRef<{
71
73
  emptyPath: SkPath;
74
+ lastPts: number[] | null;
75
+ lastRanges: number[] | null;
76
+ lastLinear: boolean;
77
+ lastBottom: number;
78
+ lineOutput: SkPath | null;
79
+ fillOutput: SkPath | null;
72
80
  ptsA: number[];
73
81
  ptsB: number[];
74
82
  rawPts: number[];
@@ -87,6 +95,12 @@ export function useChartPaths(
87
95
  if (cacheRef.current === null) {
88
96
  cacheRef.current = {
89
97
  emptyPath: Skia.Path.Make(),
98
+ lastPts: null,
99
+ lastRanges: null,
100
+ lastLinear: false,
101
+ lastBottom: NaN,
102
+ lineOutput: null,
103
+ fillOutput: null,
90
104
  ptsA: [] as number[],
91
105
  ptsB: [] as number[],
92
106
  rawPts: [] as number[],
@@ -160,9 +174,11 @@ export function useChartPaths(
160
174
  // Compute squiggly Y values at the same X positions as the real line
161
175
  const centerY =
162
176
  (engine.canvasHeight.get() - padding.bottom + padding.top) / 2;
177
+ // Same wall clock as the loading shell's squiggle, so the wave stays
178
+ // continuous where the shell hands over to the morph.
163
179
  const squigglyPts = squigglifyPts(
164
180
  renderPts,
165
- engine.timestamp.get(),
181
+ squiggleClockSeconds(),
166
182
  centerY,
167
183
  squiggleAmplitude,
168
184
  squiggleSpeed,
@@ -200,117 +216,77 @@ export function useChartPaths(
200
216
  );
201
217
  });
202
218
 
203
- const linePath = useDerivedValue(() => {
219
+ // One derived rebuild keeps curve inputs and all outputs in the same revision.
220
+ // The spline solver emits to separate builders without extra command buffers.
221
+ const paths = useDerivedValue(() => {
204
222
  const cache = cacheRef.current!;
205
223
  const pts = flatPts.get();
206
- const n = pts.length >> 1;
207
- if (n < 2) return cache.emptyPath;
208
- const b = lineBuilder.value;
209
224
  const ranges = segmentRanges.get();
210
- if (ranges.length === 0) return cache.emptyPath;
211
- for (let i = 0; i < ranges.length; i += 2) {
212
- const start = ranges[i];
213
- const end = ranges[i + 1];
214
- b.moveTo(pts[start * 2], pts[start * 2 + 1]);
215
- drawSpline(b, pts, cache.scratch, linear, start, end);
225
+ if (pts.length < 4 || ranges.length === 0) {
226
+ cache.lastPts = null;
227
+ return {
228
+ line: cache.emptyPath,
229
+ fill: cache.emptyPath,
230
+ band: cache.emptyPath,
231
+ };
216
232
  }
217
- return b.detach();
218
- });
219
-
220
- const fillPath = useDerivedValue(() => {
221
- const cache = cacheRef.current!;
222
- const pts = flatPts.get();
223
- const n = pts.length >> 1;
224
- if (n < 2) return cache.emptyPath;
225
- const b = fillBuilder.value;
226
- const ranges = segmentRanges.get();
227
- if (ranges.length === 0) return cache.emptyPath;
228
233
  const bottom = engine.canvasHeight.get() - padding.bottom;
229
- for (let i = 0; i < ranges.length; i += 2) {
230
- const start = ranges[i];
231
- const end = ranges[i + 1];
232
- const first = start * 2;
233
- const last = (end - 1) * 2;
234
- b.moveTo(pts[first], pts[first + 1]);
235
- drawSpline(b, pts, cache.scratch, linear, start, end);
236
- b.lineTo(pts[last], bottom);
237
- b.lineTo(pts[first], bottom);
238
- b.close();
239
- }
240
- return b.detach();
241
- });
242
-
243
- // Threshold-anchored fill: the same spline, closed along the threshold instead
244
- // of the baseline, so the band lies between the line and the threshold (the
245
- // profit/loss area). Painted with the split gradient/shader, the part above the
246
- // split shows the above-color and the part below shows the below-color.
247
- //
248
- // `thresholdPts` (a time-varying series) closes the band along that polyline,
249
- // right-to-left; otherwise `thresholdY` closes it at a single horizontal Y.
250
- const thresholdFillPath = useDerivedValue(() => {
251
- const cache = cacheRef.current!;
252
- const pts = flatPts.get();
253
- const n = pts.length >> 1;
254
- if (n < 2) return cache.emptyPath;
255
- const ranges = segmentRanges.get();
256
- if (ranges.length === 0) return cache.emptyPath;
257
-
258
- const tsamples = thresholdSamples?.get();
259
- if (tsamples && tsamples.length >= 2) {
260
- const b = thresholdFillBuilder.value;
261
- // Band bottom = the SAMPLED threshold (identical to what the split shader
262
- // reads), pinned to the LINE's x-range. Because the geometry and the shader
263
- // use the same evenly-spaced, linearly-interpolated samples, a step riser
264
- // ramps the same way in both — no green/red sliver bleeds through — and the
265
- // x-range pin keeps the band closing with clean vertical sides (no wedge).
266
- const count = tsamples.length;
267
- // The samples live on the time-anchored, gliding grid — interpolate them
268
- // across that span (same as the shader), not the static plot edges.
269
- const [x0, x1] = thresholdSampleSpanX(
270
- engine.timestamp.get(),
271
- engine.displayWindow.get(),
272
- padding.left,
273
- engine.canvasWidth.get() - padding.right,
274
- count,
234
+ // Buffers alternate on each geometry update. Identical references here
235
+ // therefore mean only the threshold changed, not the line geometry.
236
+ const bandOnly =
237
+ cache.lastPts === pts &&
238
+ cache.lastRanges === ranges &&
239
+ cache.lastLinear === linear &&
240
+ cache.lastBottom === bottom;
241
+ const samples = thresholdSamples?.get();
242
+ const sampled = samples != null && samples.length >= 2;
243
+ const y = thresholdY?.get() ?? NaN;
244
+ const hasBand = sampled || Number.isFinite(y);
245
+ const span = sampled
246
+ ? thresholdSampleSpanX(
247
+ engine.timestamp.get(),
248
+ engine.displayWindow.get(),
249
+ padding.left,
250
+ engine.canvasWidth.get() - padding.right,
251
+ samples.length,
252
+ )
253
+ : [0, 0];
254
+ const line = lineBuilder.get(),
255
+ fill = fillBuilder.get();
256
+ const band = hasBand ? thresholdFillBuilder.get() : undefined;
257
+ if (!bandOnly || band)
258
+ buildLineFillPaths(
259
+ line,
260
+ fill,
261
+ band,
262
+ pts,
263
+ ranges,
264
+ cache.scratch,
265
+ linear,
266
+ bottom,
267
+ y,
268
+ samples,
269
+ span[0],
270
+ span[1],
271
+ bandOnly,
275
272
  );
276
- const step = (x1 - x0) / (count - 1);
277
- for (let range = 0; range < ranges.length; range += 2) {
278
- const start = ranges[range];
279
- const end = ranges[range + 1];
280
- const first = start * 2;
281
- const last = (end - 1) * 2;
282
- const leftX = pts[first];
283
- const rightX = pts[last];
284
- b.moveTo(leftX, pts[first + 1]);
285
- drawSpline(b, pts, cache.scratch, linear, start, end);
286
- b.lineTo(rightX, sampleThresholdYAt(tsamples, x0, x1, rightX));
287
- for (let i = count - 1; i >= 0; i--) {
288
- const sx = x0 + step * i;
289
- if (sx > leftX && sx < rightX) b.lineTo(sx, tsamples[i]);
290
- }
291
- b.lineTo(leftX, sampleThresholdYAt(tsamples, x0, x1, leftX));
292
- b.close();
293
- }
294
- return b.detach();
273
+ if (!bandOnly) {
274
+ cache.lineOutput = line.detach();
275
+ cache.fillOutput = fill.detach();
295
276
  }
296
-
297
- if (!thresholdY) return cache.emptyPath;
298
- const yT = thresholdY.get();
299
- if (!Number.isFinite(yT)) return cache.emptyPath;
300
- const b = thresholdFillBuilder.value;
301
- for (let i = 0; i < ranges.length; i += 2) {
302
- const start = ranges[i];
303
- const end = ranges[i + 1];
304
- const first = start * 2;
305
- const last = (end - 1) * 2;
306
- b.moveTo(pts[first], pts[first + 1]);
307
- drawSpline(b, pts, cache.scratch, linear, start, end);
308
- b.lineTo(pts[last], yT);
309
- b.lineTo(pts[first], yT);
310
- b.close();
311
- }
312
- return b.detach();
277
+ cache.lastPts = pts;
278
+ cache.lastRanges = ranges;
279
+ cache.lastLinear = linear;
280
+ cache.lastBottom = bottom;
281
+ return {
282
+ line: cache.lineOutput!,
283
+ fill: cache.fillOutput!,
284
+ band: band?.detach() ?? cache.emptyPath,
285
+ };
313
286
  });
287
+ const linePath = useDerivedValue(() => paths.get().line);
288
+ const fillPath = useDerivedValue(() => paths.get().fill);
289
+ const thresholdFillPath = useDerivedValue(() => paths.get().band);
314
290
 
315
291
  return { linePath, fillPath, thresholdFillPath } as const;
316
292
  }