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
@@ -214,8 +214,8 @@ export function useCrosshair(
214
214
  // lifecycle helpers are also used by LiveChartSeries and unit-tested directly.
215
215
  const fingerDown = useSharedValue(false);
216
216
  const panActivated = useSharedValue(false);
217
- // Where and when the current touch went down, for the stationary-hold and
218
- // stale-timer guards (see delayedPanGuard).
217
+ // Where and when the current touch went down, for overlay arbitration and
218
+ // the stationary-hold / stale-timer guards (see delayedPanGuard).
219
219
  const downX = useSharedValue(0);
220
220
  const downY = useSharedValue(0);
221
221
  const downAtMs = useSharedValue(0);
@@ -661,6 +661,14 @@ export function useCrosshair(
661
661
  .onTouchesDown(
662
662
  /* istanbul ignore next */ (e) => {
663
663
  "worklet";
664
+ // Overlay drags decide ownership at touch-down. Preserve that point
665
+ // even without a hold: the finger can enter a grabRange before the
666
+ // scrub reaches its horizontal activation threshold.
667
+ const touch = e.changedTouches[0];
668
+ if (touch) {
669
+ downX.set(touch.x);
670
+ downY.set(touch.y);
671
+ }
664
672
  delayedPanTouchDown(
665
673
  longPressMs,
666
674
  e,
@@ -751,7 +759,11 @@ export function useCrosshair(
751
759
  // overlay tap. `scrubActive` is only set here, so bailing in `onStart`
752
760
  // also keeps a follow-on drag from showing a crosshair — no `onUpdate`
753
761
  // guard needed. (Plain-scrub counterpart of the scrub-action tap defer.)
754
- if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
762
+ if (
763
+ deferTapHit !== undefined &&
764
+ deferTapHit(downX.get(), downY.get())
765
+ )
766
+ return;
755
767
  startPlainScrub(
756
768
  snapCandleX(e.x),
757
769
  padding,
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import { Gesture } from "react-native-gesture-handler";
3
3
  import {
4
+ useAnimatedReaction,
4
5
  useFrameCallback,
5
6
  useSharedValue,
6
7
  type SharedValue,
@@ -57,6 +58,8 @@ export function useMarkers(
57
58
  cluster: ResolvedMarkerCluster = ANCHORED_CLUSTER,
58
59
  /** Runtime gate shared with the chart engine. */
59
60
  isFrameLoopActive?: SharedValue<boolean>,
61
+ /** Experimental input-driven projection instead of continuous frames; defaults to false. */
62
+ autoSleep = false,
60
63
  ): {
61
64
  projected: SharedValue<ProjectedMarker[]>;
62
65
  tapGesture: ReturnType<typeof Gesture.Tap>;
@@ -79,7 +82,7 @@ export function useMarkers(
79
82
  onMarkerPress?.(event);
80
83
  };
81
84
 
82
- const markerFrameCallback = useFrameCallback(
85
+ const project =
83
86
  /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
84
87
  "worklet";
85
88
  if (isFrameLoopActive?.get() === false) return;
@@ -111,13 +114,24 @@ export function useMarkers(
111
114
  maxY: engine.canvasHeight.get(),
112
115
  });
113
116
  projected.set(buf);
117
+ };
118
+ const legacyActive = autostart && !autoSleep;
119
+ const markerFrameCallback = useFrameCallback(project, legacyActive);
120
+ useAnimatedReaction(
121
+ () => {
122
+ if (!autoSleep || !autostart) return null;
123
+ return [active, markers.get(), engine.canvasWidth.get(), engine.canvasHeight.get(),
124
+ engine.timestamp.get(), engine.displayWindow.get(), engine.displayMin.get(),
125
+ engine.displayMax.get(), seriesSV?.get(), lineData?.get(), isFrameLoopActive?.get()];
126
+ },
127
+ (inputs) => {
128
+ if (inputs !== null) project();
114
129
  },
115
- autostart,
116
130
  );
117
131
  useEffect(() => {
118
132
  // Reanimated does not reactively apply later `autostart` values.
119
- markerFrameCallback.setActive(autostart);
120
- }, [autostart, markerFrameCallback]);
133
+ markerFrameCallback.setActive(legacyActive);
134
+ }, [legacyActive, markerFrameCallback]);
121
135
 
122
136
  const tapGesture = Gesture.Tap().onEnd(
123
137
  /* istanbul ignore next -- gesture worklet runs on UI thread, not in Jest */ (
@@ -30,6 +30,7 @@ export function useMultiSeriesDegen(
30
30
  ): {
31
31
  pack: SharedValue<Float64Array<ArrayBuffer>>;
32
32
  packRevision: SharedValue<number>;
33
+ particleTimestamp: SharedValue<number>;
33
34
  shakeTransform: DerivedValue<
34
35
  [{ translateX: number }, { translateY: number }]
35
36
  >;
@@ -37,6 +38,7 @@ export function useMultiSeriesDegen(
37
38
  const MAX_SLOTS = 80;
38
39
  const pack = useSharedValue(new Float64Array(MAX_SLOTS * DEGEN_STRIDE));
39
40
  const packRevision = useSharedValue(0);
41
+ const particleTimestamp = useSharedValue(0);
40
42
  // Per-series previous momentum (0 = flat, 1 = up, 2 = down), index-aligned to series.
41
43
  const prevMoms = useSharedValue<number[]>([]);
42
44
  const writeRot = useSharedValue(0);
@@ -131,9 +133,10 @@ export function useMultiSeriesDegen(
131
133
  ]);
132
134
 
133
135
  useFrameCallback(
134
- /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
136
+ /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ (frame) => {
135
137
  "worklet";
136
- const now = engine.timestamp.get();
138
+ // Effect timing is independent of the quantized/pinned viewport clock.
139
+ const now = frame.timestamp / 1000;
137
140
  const buf = pack.get();
138
141
  const slots = slotCountSV.get();
139
142
 
@@ -242,6 +245,7 @@ export function useMultiSeriesDegen(
242
245
  );
243
246
 
244
247
  if (activeCount > 0 || prevActiveCount.get() > 0) {
248
+ particleTimestamp.set(now);
245
249
  packRevision.set(packRevision.get() + 1);
246
250
  }
247
251
  prevActiveCount.set(activeCount);
@@ -256,5 +260,5 @@ export function useMultiSeriesDegen(
256
260
  ];
257
261
  });
258
262
 
259
- return { pack, packRevision, shakeTransform };
263
+ return { pack, packRevision, particleTimestamp, shakeTransform };
260
264
  }
@@ -22,6 +22,8 @@ export type PanScrollGestureMode = "holdToScrub" | "axisDrag";
22
22
 
23
23
  /** Engine SharedValues the pan-scroll gesture reads/writes (subset of the engine state). */
24
24
  export interface PanScrollEngineRefs {
25
+ /** Wake a demand-driven engine before reading the gesture bounds. */
26
+ wake?: () => void;
25
27
  /** Absolute right-edge time to freeze at, or `null` to follow the live edge. */
26
28
  viewEnd: SharedValue<number | null>;
27
29
  /** Right-edge time the engine would use if following live (advances each frame). */
@@ -263,10 +265,13 @@ export function usePanScroll({
263
265
  },
264
266
  );
265
267
 
268
+ const wakeEngine = engine.wake;
269
+
266
270
  const onStart =
267
271
  /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
268
272
  () => {
269
273
  "worklet";
274
+ wakeEngine?.();
270
275
  // Stop an in-flight fling FIRST. `withDecay` keeps writing `viewEnd` after
271
276
  // the finger lifts, and `Gesture.Race` declares no relation between the two
272
277
  // pans, so this can run while a scrub is already engaged. Bailing out below
@@ -10,6 +10,8 @@ import { FOLLOW_SNAP, panLowerBound, panUpperBound } from "./usePanScroll";
10
10
 
11
11
  /** Engine SharedValues the pinch-zoom gesture reads/writes. */
12
12
  export interface PinchZoomEngineRefs {
13
+ /** Wake a demand-driven engine before reading the gesture bounds. */
14
+ wake?: () => void;
13
15
  /** Visible-window width override in seconds, or `null` to follow `timeWindow`. */
14
16
  viewWindow: SharedValue<number | null>;
15
17
  /** Absolute right-edge time, or `null` to follow the live edge (shared with pan). */
@@ -170,10 +172,13 @@ export function usePinchZoom({
170
172
  const startWindow = useSharedValue(timeWindow);
171
173
  const startViewEnd = useSharedValue(0);
172
174
 
175
+ const wakeEngine = engine.wake;
176
+
173
177
  const onStart =
174
178
  /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
175
179
  () => {
176
180
  "worklet";
181
+ wakeEngine?.();
177
182
  cancelAnimation(viewEnd);
178
183
  cancelAnimation(viewWindow);
179
184
  const edge = liveEdge.get();
@@ -18,11 +18,7 @@ import {
18
18
  } from "../math/referenceDrag";
19
19
  import { referenceLineForm } from "../math/referenceLines";
20
20
  import type { ReferenceLine } from "../types";
21
- import {
22
- computeScrubDotY,
23
- computeValueAtY,
24
- snapPrice,
25
- } from "./crosshairShared";
21
+ import { computeValueAtY, pinnedPlotY, snapPrice } from "./crosshairShared";
26
22
 
27
23
  /** Vertical reach (px) around a line within which a touch grabs it. */
28
24
  const GRAB_SLOP = 14;
@@ -37,12 +33,13 @@ const EMPTY: never[] = [];
37
33
  * Builds the per-line **drag** gesture for draggable Form-A reference lines: grab a
38
34
  * line near its value-Y and drag vertically to set a new value, with optional
39
35
  * `snap` + `bounds` clamp. Mirrors the order-ticket reticle in {@link useCrosshair}
40
- * (value↔Y via `computeValueAtY` / `computeScrubDotY`, frozen value re-projected
36
+ * (value↔Y via `computeValueAtY` / `pinnedPlotY`, frozen value re-projected
41
37
  * each frame) but per line, writing into the shared `dragValues` array the layout
42
38
  * and overlays read.
43
39
  *
44
40
  * The pan uses `manualActivation`: it grabs only when a touch starts within
45
- * {@link GRAB_SLOP} of a draggable line, then **owns** that touch — any drag past
41
+ * {@link GRAB_SLOP} of a draggable line (and inside its `grabRange`, when it has
42
+ * one), then **owns** that touch — any drag past
46
43
  * {@link DRAG_ACTIVATE_PX} (in either axis) drags the line. A touch off every line
47
44
  * fails fast so the chart's other gestures (scrub / scroll) run everywhere else
48
45
  * (compose this ahead of them via `Gesture.Exclusive`). Crucially it no longer
@@ -86,6 +83,7 @@ export function useReferenceDrag(
86
83
  const dMax = engine.displayMax.get();
87
84
  const top = padding.top;
88
85
  const bottom = ch - padding.bottom;
86
+ if (bottom <= top) return EMPTY;
89
87
  const out: number[] = [];
90
88
  for (let i = 0; i < lines.length; i++) {
91
89
  const l = lines[i];
@@ -98,12 +96,16 @@ export function useReferenceDrag(
98
96
  continue;
99
97
  }
100
98
  const v = dragValues.get()[i] ?? l.value;
101
- const y = computeScrubDotY(v, dMin, dMax, ch, top, padding.bottom);
102
- out.push(y < 0 ? -1 : Math.min(bottom, Math.max(top, y)));
99
+ out.push(pinnedPlotY(v, dMin, dMax, ch, top, padding.bottom));
103
100
  }
104
101
  return out;
105
102
  });
106
103
 
104
+ // Per-line grab x-ranges (`ReferenceLine.grabRange`), index-aligned with
105
+ // `lines`; null = anywhere along the line. Read by the gesture worklets below,
106
+ // which close over this render's `lines` as the callbacks do.
107
+ const grabRanges = lines.map((l) => l.grabRange ?? null);
108
+
107
109
  const dragIndex = useSharedValue(-1);
108
110
  const startX = useSharedValue(0);
109
111
  const startY = useSharedValue(0);
@@ -175,7 +177,10 @@ export function useReferenceDrag(
175
177
  "worklet";
176
178
  const t = e.changedTouches[0];
177
179
  if (!t) return;
178
- const i = nearestDraggableIndex(handleYs.get(), t.y, GRAB_SLOP);
180
+ const i = nearestDraggableIndex(handleYs.get(), t.y, GRAB_SLOP, {
181
+ x: t.x,
182
+ ranges: grabRanges,
183
+ });
179
184
  if (i < 0) {
180
185
  manager.fail();
181
186
  return;
@@ -266,13 +271,22 @@ export function useReferenceDrag(
266
271
  // is grabbed the answer is yes regardless of position: the scrub asks with its
267
272
  // touch-DOWN point, which a longer drag has carried the line away from, so the
268
273
  // geometric test alone said "no line here" and a crosshair opened mid-drag.
269
- // Otherwise it is the y-reach around the handles (x is unused — a Form-A line
270
- // spans the full width).
274
+ // Otherwise it is the y-reach around the handles, along the whole line — or,
275
+ // for a line with a `grabRange`, only inside it.
271
276
  /* istanbul ignore next -- worklet, runs on the UI thread */
272
- const hitTest = (_x: number, y: number): boolean => {
277
+ const hitTest = (x: number, y: number): boolean => {
273
278
  "worklet";
274
279
  if (!anyDraggable) return false;
275
- return referenceDragOwnsTouch(dragIndex.get(), handleYs.get(), y, GRAB_SLOP);
280
+ return referenceDragOwnsTouch(
281
+ dragIndex.get(),
282
+ handleYs.get(),
283
+ y,
284
+ GRAB_SLOP,
285
+ {
286
+ x,
287
+ ranges: grabRanges,
288
+ },
289
+ );
276
290
  };
277
291
 
278
292
  const gesture = Gesture.Pan()
@@ -0,0 +1,72 @@
1
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
2
+
3
+ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
4
+ import type { ChartPadding } from "../draw/line";
5
+ import { groupReferenceLines, type ReferenceGrouping } from "../math/referenceGroup";
6
+ import { referenceLineForm } from "../math/referenceLines";
7
+ import type { ReferenceLine } from "../types";
8
+ import { pinnedPlotY } from "./crosshairShared";
9
+
10
+ /** Stable result while grouping is disabled. */
11
+ const EMPTY_GROUPING: ReferenceGrouping = { hidden: [], groups: [] };
12
+
13
+ /** Cluster the built-in tags that can actually render in the current plot. */
14
+ export function useReferenceLineGrouping({
15
+ radius,
16
+ engine,
17
+ padding,
18
+ lines,
19
+ custom,
20
+ offAxisCustom,
21
+ dragValues,
22
+ }: {
23
+ radius: number | null;
24
+ engine: ChartEngineLayout;
25
+ padding: ChartPadding;
26
+ lines: ReferenceLine[];
27
+ custom: boolean[];
28
+ offAxisCustom: boolean[];
29
+ dragValues: SharedValue<number[]>;
30
+ }) {
31
+ const hasOffAxisBadge = lines.map((line) => Boolean(line.badge || line.offAxisBadge));
32
+ const result = useDerivedValue<ReferenceGrouping>(() => {
33
+ if (radius == null) return EMPTY_GROUPING;
34
+ const canvasHeight = engine.canvasHeight.get();
35
+ const displayMin = engine.displayMin.get();
36
+ const displayMax = engine.displayMax.get();
37
+ const top = padding.top;
38
+ const yPositions: number[] = [];
39
+ for (let index = 0; index < lines.length; index++) {
40
+ const line = lines[index];
41
+ if (
42
+ referenceLineForm(line) !== "line" ||
43
+ line.value === undefined ||
44
+ custom[index] ||
45
+ offAxisCustom[index]
46
+ ) {
47
+ yPositions.push(-1);
48
+ continue;
49
+ }
50
+ const value = dragValues.get()[index] ?? line.value;
51
+ // Plain off-axis lines are culled by useReferenceLine. Only a configured
52
+ // badge can contribute a visible tag to the edge's count pill.
53
+ if ((value < displayMin || value > displayMax) && !hasOffAxisBadge[index]) {
54
+ yPositions.push(-1);
55
+ continue;
56
+ }
57
+ yPositions.push(
58
+ pinnedPlotY(
59
+ value,
60
+ displayMin,
61
+ displayMax,
62
+ canvasHeight,
63
+ top,
64
+ padding.bottom,
65
+ ),
66
+ );
67
+ }
68
+ return groupReferenceLines(yPositions, radius);
69
+ });
70
+ const hidden = useDerivedValue<boolean[]>(() => result.get().hidden);
71
+ return { refGroupResult: result, groupHidden: hidden };
72
+ }
@@ -0,0 +1,40 @@
1
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
2
+ import type { CandlePoint } from "../types";
3
+ import {
4
+ visibleMaxVolume,
5
+ volumeEndIndex,
6
+ volumeStartIndex,
7
+ } from "../draw/volumeRange";
8
+
9
+ /**
10
+ * Primitive index outputs suppress downstream scans while the window glides
11
+ * within the same buckets. The maximum also depends directly on candles, so
12
+ * every notified data revision (including same-length replacement or modify)
13
+ * invalidates it. Array length and object identity are never cache keys.
14
+ */
15
+ export function useVolumeRange(
16
+ candles: SharedValue<CandlePoint[]> | undefined,
17
+ timestamp: SharedValue<number>,
18
+ window: SharedValue<number>,
19
+ width: SharedValue<number>,
20
+ active: boolean,
21
+ ) {
22
+ const start = useDerivedValue(() =>
23
+ !active || !candles
24
+ ? 0
25
+ : volumeStartIndex(
26
+ candles.get(),
27
+ timestamp.get() - window.get(),
28
+ width.get(),
29
+ ),
30
+ );
31
+ const end = useDerivedValue(() =>
32
+ !active || !candles ? 0 : volumeEndIndex(candles.get(), timestamp.get()),
33
+ );
34
+ const max = useDerivedValue(() =>
35
+ !active || !candles
36
+ ? 0
37
+ : visibleMaxVolume(candles.get(), start.get(), end.get()),
38
+ );
39
+ return { start, end, max };
40
+ }
@@ -167,11 +167,16 @@ export function useXAxis(
167
167
  const edgeAlpha =
168
168
  fromEdge >= fadeZone ? 1 : fromEdge <= 0 ? 0 : fromEdge / fadeZone;
169
169
 
170
- const target = xAxisKeyIsTarget(key, targetKeys) ? edgeAlpha : 0;
170
+ const isTarget = xAxisKeyIsTarget(key, targetKeys);
171
+ const target = isTarget ? edgeAlpha : 0;
171
172
  let next = lerp(label.alpha, target, FADE, MS_PER_FRAME_60FPS);
172
173
  if (Math.abs(next - target) < 0.02) next = target;
173
174
 
174
- if (next < 0.01 && target === 0) {
175
+ // Only a key that left the target set is deleted once it has faded out. A
176
+ // target key can sit at alpha 0 (the set spans one interval past each edge,
177
+ // outside the plot); deleting it would re-create it on the next run and
178
+ // write `labelAlphas`, this mapper's own input, every frame.
179
+ if (next < 0.01 && !isTarget) {
175
180
  delete alphas[key];
176
181
  cacheChanged = true;
177
182
  } else {
@@ -182,7 +187,12 @@ export function useXAxis(
182
187
  }
183
188
  }
184
189
 
185
- if (cacheChanged) labelAlphas.set(alphas);
190
+ // Publish only a changed final cache so this derived value cannot keep
191
+ // waking itself after the engine sleeps. Off-screen target keys are retained
192
+ // above; this equality check also guards against other no-op cache updates.
193
+ if (cacheChanged && !sameXAxisLabels(previousAlphas, alphas)) {
194
+ labelAlphas.set(alphas);
195
+ }
186
196
 
187
197
  // Collect visible labels
188
198
  const raw: XAxisEntry[] = [];
@@ -226,3 +236,17 @@ export function useXAxis(
226
236
 
227
237
  return { xAxisEntries, font };
228
238
  }
239
+
240
+ export function sameXAxisLabels(
241
+ previous: Record<number, { alpha: number; text: string }>,
242
+ next: Record<number, { alpha: number; text: string }>,
243
+ ): boolean {
244
+ "worklet";
245
+ const keys = Object.keys(next);
246
+ if (keys.length !== Object.keys(previous).length) return false;
247
+ for (let i = 0; i < keys.length; i++) {
248
+ const key = Number(keys[i]);
249
+ if (!previous[key] || previous[key].alpha !== next[key].alpha || previous[key].text !== next[key].text) return false;
250
+ }
251
+ return true;
252
+ }
@@ -28,7 +28,15 @@ export function useYAxis(
28
28
  const yAxisEntries = useDerivedValue(() => {
29
29
  const dt = MS_PER_FRAME_60FPS;
30
30
 
31
- const alphas = labelAlphas.get();
31
+ // SharedValue payloads may be frozen after crossing the JS/UI boundary.
32
+ // Copy before computeGridEntries adds, updates, or removes label keys.
33
+ const previousAlphas = labelAlphas.get();
34
+ const alphas: Record<number, number> = {};
35
+ const previousKeys = Object.keys(previousAlphas);
36
+ for (let i = 0; i < previousKeys.length; i++) {
37
+ const key = Number(previousKeys[i]);
38
+ alphas[key] = previousAlphas[key];
39
+ }
32
40
  const result = computeGridEntries(
33
41
  engine.displayMin.get(),
34
42
  engine.displayMax.get(),
@@ -46,7 +54,15 @@ export function useYAxis(
46
54
  );
47
55
 
48
56
  prevInterval.set(result.interval);
49
- labelAlphas.set(alphas);
57
+ // This derived value also reads the cache. Only publish actual changes so
58
+ // a settled axis doesn't keep scheduling itself with fresh object identities.
59
+ const nextKeys = Object.keys(alphas);
60
+ let cacheChanged = nextKeys.length !== previousKeys.length;
61
+ for (let i = 0; i < nextKeys.length && !cacheChanged; i++) {
62
+ const key = Number(nextKeys[i]);
63
+ if (alphas[key] !== previousAlphas[key]) cacheChanged = true;
64
+ }
65
+ if (cacheChanged) labelAlphas.set(alphas);
50
66
 
51
67
  return result.entries;
52
68
  });
package/src/index.ts CHANGED
@@ -97,6 +97,7 @@ export type {
97
97
  MultiSeriesDotConfig,
98
98
  PerSeriesTooltipConfig,
99
99
  PulseConfig,
100
+ RangeAnimationConfig,
100
101
  ReferenceLine,
101
102
  ReferenceLineBadgeConfig,
102
103
  ReferenceLineGroupingConfig,