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
@@ -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
  }
@@ -50,6 +50,7 @@ export function useDegen(
50
50
  ): {
51
51
  pack: SharedValue<Float64Array<ArrayBuffer>>;
52
52
  packRevision: SharedValue<number>;
53
+ particleTimestamp: SharedValue<number>;
53
54
  shakeTransform: DerivedValue<
54
55
  [{ translateX: number }, { translateY: number }]
55
56
  >;
@@ -57,6 +58,7 @@ export function useDegen(
57
58
  const MAX_SLOTS = 80;
58
59
  const pack = useSharedValue(new Float64Array(MAX_SLOTS * DEGEN_STRIDE));
59
60
  const packRevision = useSharedValue(0);
61
+ const particleTimestamp = useSharedValue(0);
60
62
  const prevM = useSharedValue<Momentum>("flat");
61
63
  const writeRot = useSharedValue(0);
62
64
  const enabledSV = useSharedValue(0);
@@ -185,10 +187,12 @@ export function useDegen(
185
187
  // ChartWithDegen only mounts while the resolved effect is enabled; static
186
188
  // charts force that config to null and unmount this callback entirely.
187
189
  useFrameCallback(
188
- /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
190
+ /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ (frame) => {
189
191
  "worklet";
190
192
  if (isFrameLoopActive?.get() === false) return;
191
- const now = engine.timestamp.get();
193
+ // The viewport clock advances in half-pixel steps and may be pinned by
194
+ // nowOverride or pan. Effects need the continuous display-frame clock.
195
+ const now = frame.timestamp / 1000;
192
196
  const buf = pack.get();
193
197
  const slots = slotCountSV.get();
194
198
 
@@ -273,10 +277,10 @@ export function useDegen(
273
277
  );
274
278
 
275
279
  // Repaint only while particles are alive (or on the frame they all expire,
276
- // so the overlay clears). When the field is empty the 4 derived values per
277
- // slot stay subscribed to `packRevision` alone and freeze — no per-frame
278
- // worklet churn for an idle particle system.
280
+ // so the overlay clears). Publish the same clock for Atlas size/opacity,
281
+ // only while particles are alive, to keep an idle field from repainting.
279
282
  if (activeCount > 0 || prevActiveCount.get() > 0) {
283
+ particleTimestamp.set(now);
280
284
  packRevision.set(packRevision.get() + 1);
281
285
  }
282
286
  prevActiveCount.set(activeCount);
@@ -291,5 +295,5 @@ export function useDegen(
291
295
  ];
292
296
  });
293
297
 
294
- return { pack, packRevision, shakeTransform };
298
+ return { pack, packRevision, particleTimestamp, shakeTransform };
295
299
  }
@@ -0,0 +1,82 @@
1
+ import { useCallback, useEffect } from "react";
2
+ import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
3
+ import { scheduleOnUI } from "react-native-worklets";
4
+
5
+ /** Wait through several unchanged frames, rather than mistaking one quiet frame for rest. */
6
+ export const IDLE_SETTLE_MS = 160;
7
+
8
+ function noInputs() { "worklet"; return null; }
9
+ function noReaction() { "worklet"; }
10
+
11
+ export function nextQuietTime(previous: number, changed: boolean, dt: number): number {
12
+ "worklet";
13
+ return changed ? 0 : previous + Math.min(Math.max(dt, 0), 34);
14
+ }
15
+
16
+ /**
17
+ * Opt-in UI-runtime RAF loop. Inputs wake it without a JS-thread round trip.
18
+ * `tick` returns true while work changed or a continuous effect needs frames.
19
+ * `prepare` must read INPUTS only: subscribing to tick outputs causes feedback.
20
+ * A generation token makes queued frames harmless after replacement/unmount.
21
+ */
22
+ export function useDemandFrameLoop(
23
+ enabled: boolean,
24
+ prepare: () => unknown,
25
+ tick: (dt: number) => boolean,
26
+ ): () => void {
27
+ const mounted = useSharedValue(false);
28
+ const running = useSharedValue(false);
29
+ const generation = useSharedValue(0);
30
+ const quietTime = useSharedValue(0);
31
+
32
+ const wake = useCallback(() => {
33
+ "worklet";
34
+ if (!enabled || !mounted.get()) return;
35
+ quietTime.set(0);
36
+ if (running.get()) return;
37
+ running.set(true);
38
+ const token = generation.get();
39
+ let previous: number | null = null;
40
+ function step(timestamp: number) {
41
+ "worklet";
42
+ if (!mounted.get() || generation.get() !== token) return;
43
+ // Resuming never integrates the entire sleeping interval as a single frame.
44
+ const dt = previous === null ? 1000 / 60 : Math.min(timestamp - previous, 34);
45
+ previous = timestamp;
46
+ const quiet = nextQuietTime(quietTime.get(), tick(dt), dt);
47
+ quietTime.set(quiet);
48
+ if (quiet < IDLE_SETTLE_MS) requestAnimationFrame(step);
49
+ else running.set(false);
50
+ }
51
+ requestAnimationFrame(step);
52
+ }, [enabled, mounted, running, generation, quietTime, tick]);
53
+
54
+ useAnimatedReaction(
55
+ // Pass the worklet itself: Reanimated extracts SharedValue subscriptions
56
+ // from its closure. Wrapping it in () => prepare() hides those inputs.
57
+ enabled ? prepare : noInputs,
58
+ enabled ? wake : noReaction,
59
+ [enabled, enabled ? prepare : noInputs, enabled ? wake : noReaction],
60
+ );
61
+
62
+ useEffect(() => {
63
+ if (!enabled) return;
64
+ scheduleOnUI(() => {
65
+ "worklet";
66
+ generation.set(generation.get() + 1);
67
+ running.set(false);
68
+ mounted.set(true);
69
+ wake();
70
+ });
71
+ return () => {
72
+ scheduleOnUI(() => {
73
+ "worklet";
74
+ mounted.set(false);
75
+ generation.set(generation.get() + 1);
76
+ running.set(false);
77
+ });
78
+ };
79
+ }, [enabled, generation, mounted, running, wake]);
80
+
81
+ return wake;
82
+ }
@@ -0,0 +1,30 @@
1
+ import { useState } from "react";
2
+
3
+ import type { ResolvedLoadingConfig } from "../core/resolveConfig";
4
+
5
+ /** Field by field, with `Object.is` (a `NaN` must equal itself, or the render
6
+ * below would never settle), over every field the config has. */
7
+ function sameLook(a: ResolvedLoadingConfig, b: ResolvedLoadingConfig | null) {
8
+ if (b === null) return false;
9
+ const keys = Object.keys(a) as (keyof ResolvedLoadingConfig)[];
10
+ return keys.every((key) => Object.is(a[key], b[key]));
11
+ }
12
+
13
+ /**
14
+ * The loading shell's look: the live `loading` config while loading, and the
15
+ * last one after. The shell fades out and the reveal morph melts its squiggle
16
+ * into the line only once `loading` has turned off, when `resolveLoading`
17
+ * already returns `null` — so they would otherwise snap to the default color,
18
+ * stroke, wave and Y-axis placeholders just as the data arrives. The empty
19
+ * shell ("No data") shown later keeps it too; before any loading, it has none
20
+ * and the defaults apply.
21
+ */
22
+ export function useLoadingLook(
23
+ loadingCfg: ResolvedLoadingConfig | null,
24
+ ): ResolvedLoadingConfig | null {
25
+ const [last, setLast] = useState(loadingCfg);
26
+ // Stored from render (React's "previous props" pattern), compared by value:
27
+ // `resolveLoading` returns a fresh object for an inline config every render.
28
+ if (loadingCfg !== null && !sameLook(loadingCfg, last)) setLast(loadingCfg);
29
+ return loadingCfg ?? last;
30
+ }