react-native-livechart 4.11.0 → 4.12.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 (67) hide show
  1. package/dist/components/CrosshairLine.d.ts +3 -1
  2. package/dist/components/CrosshairLine.d.ts.map +1 -1
  3. package/dist/components/CustomReferenceLineOverlay.d.ts +15 -10
  4. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
  5. package/dist/components/DotOverlay.d.ts +3 -5
  6. package/dist/components/DotOverlay.d.ts.map +1 -1
  7. package/dist/components/LiveChart.d.ts.map +1 -1
  8. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  9. package/dist/components/PerSeriesTooltipOverlay.d.ts +19 -0
  10. package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
  11. package/dist/components/ReferenceLineOverlay.d.ts +20 -6
  12. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  13. package/dist/components/YAxisOverlay.d.ts +3 -1
  14. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  15. package/dist/core/liveIndicatorVisibility.d.ts +9 -0
  16. package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
  17. package/dist/core/resolveConfig.d.ts +29 -0
  18. package/dist/core/resolveConfig.d.ts.map +1 -1
  19. package/dist/hooks/crosshairSeries.d.ts +10 -2
  20. package/dist/hooks/crosshairSeries.d.ts.map +1 -1
  21. package/dist/hooks/crosshairShared.d.ts +33 -2
  22. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  23. package/dist/hooks/delayedPanGuard.d.ts +66 -8
  24. package/dist/hooks/delayedPanGuard.d.ts.map +1 -1
  25. package/dist/hooks/useChartPaths.d.ts +5 -9
  26. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  27. package/dist/hooks/useCrosshair.d.ts +7 -1
  28. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  29. package/dist/hooks/useCrosshairSeries.d.ts +22 -3
  30. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  31. package/dist/hooks/useMomentum.d.ts +1 -1
  32. package/dist/hooks/useMomentum.d.ts.map +1 -1
  33. package/dist/hooks/usePanScroll.d.ts +15 -1
  34. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/math/lerp.d.ts.map +1 -1
  38. package/dist/math/momentum.d.ts +1 -1
  39. package/dist/math/momentum.d.ts.map +1 -1
  40. package/dist/math/referenceLines.d.ts +7 -0
  41. package/dist/math/referenceLines.d.ts.map +1 -1
  42. package/dist/types.d.ts +160 -18
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/components/CrosshairLine.tsx +4 -1
  46. package/src/components/CustomReferenceLineOverlay.tsx +42 -13
  47. package/src/components/DotOverlay.tsx +24 -8
  48. package/src/components/LiveChart.tsx +157 -37
  49. package/src/components/LiveChartSeries.tsx +128 -16
  50. package/src/components/PerSeriesTooltipOverlay.tsx +287 -0
  51. package/src/components/ReferenceLineOverlay.tsx +274 -141
  52. package/src/components/YAxisOverlay.tsx +5 -1
  53. package/src/core/liveIndicatorVisibility.ts +25 -0
  54. package/src/core/resolveConfig.ts +86 -0
  55. package/src/hooks/crosshairSeries.ts +262 -33
  56. package/src/hooks/crosshairShared.ts +36 -2
  57. package/src/hooks/delayedPanGuard.ts +111 -6
  58. package/src/hooks/useChartPaths.ts +25 -15
  59. package/src/hooks/useCrosshair.ts +57 -4
  60. package/src/hooks/useCrosshairSeries.ts +95 -7
  61. package/src/hooks/useMomentum.ts +8 -1
  62. package/src/hooks/usePanScroll.ts +46 -5
  63. package/src/index.ts +1 -0
  64. package/src/math/lerp.ts +7 -0
  65. package/src/math/momentum.ts +22 -5
  66. package/src/math/referenceLines.ts +38 -0
  67. package/src/types.ts +168 -18
@@ -1,13 +1,28 @@
1
1
  import { Skia, type SkPath } from "@shopify/react-native-skia";
2
2
  import { useRef } from "react";
3
3
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
4
- import type { SingleEngineState } from "../core/useLiveChartEngine";
4
+ import type {
5
+ ChartEngineEdge,
6
+ ChartEngineScroll,
7
+ SingleEngineState,
8
+ } from "../core/useLiveChartEngine";
5
9
  import { buildLinePoints, type ChartPadding } from "../draw/line";
6
10
  import { drawSpline, makeSplineScratch } from "../math/spline";
7
11
  import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
8
12
  import { blendPtsY, squigglifyPts } from "../math/squiggly";
9
13
  import { usePathBuilder } from "./usePathBuilder";
10
14
 
15
+ /** Selects the synthetic right-edge line tip without coupling chart geometry
16
+ * to badge or live-indicator presentation options. */
17
+ export function resolveLineTipValue(
18
+ displayValue: number,
19
+ edgeValue: number,
20
+ viewEnd: number | null,
21
+ ): number {
22
+ "worklet";
23
+ return viewEnd == null ? displayValue : edgeValue;
24
+ }
25
+
11
26
  /**
12
27
  * Builds the `linePath` / `fillPath` with `Skia.PathBuilder`s reused across
13
28
  * frames (one per curve, held in a SharedValue) and finalized with `detach()` —
@@ -20,7 +35,7 @@ import { usePathBuilder } from "./usePathBuilder";
20
35
  * fillPath.
21
36
  */
22
37
  export function useChartPaths(
23
- engine: SingleEngineState,
38
+ engine: SingleEngineState & ChartEngineScroll & ChartEngineEdge,
24
39
  padding: ChartPadding,
25
40
  morphT?: SharedValue<number>,
26
41
  /** When set, also build `thresholdFillPath` — the band between the line and this
@@ -28,14 +43,6 @@ export function useChartPaths(
28
43
  thresholdY?: SharedValue<number>,
29
44
  /** Draw the line/fill as a straight polyline instead of the monotone cubic. */
30
45
  linear = false,
31
- /**
32
- * Value at the visible window's right edge (engine `edgeValue`). With
33
- * `followViewEdge`, the line's right-edge tip uses this instead of the live
34
- * `displayValue` — so while time-scrolled the line ends at the last visible
35
- * price rather than dropping to the (off-screen) live value.
36
- */
37
- edgeValue?: SharedValue<number>,
38
- followViewEdge = false,
39
46
  /** Loading squiggle wave amplitude (px) for the reveal morph. Default 14. */
40
47
  squiggleAmplitude = 14,
41
48
  /** Loading squiggle wave speed multiplier for the reveal morph. Default 1. */
@@ -79,11 +86,14 @@ export function useChartPaths(
79
86
  const cache = cacheRef.current!;
80
87
  cache.ptsTick = !cache.ptsTick;
81
88
  const buf = cache.ptsTick ? cache.ptsA : cache.ptsB;
82
- // While scrolled back with `followViewEdge`, tip the line at the view-edge
83
- // price (engine `edgeValue`) instead of the live value otherwise the line
84
- // drops from the last visible point to the off-screen live value at the edge.
85
- const tipValue =
86
- followViewEdge && edgeValue ? edgeValue.get() : engine.displayValue.get();
89
+ // A historical window must end at its own right-edge price. Badge and
90
+ // live-indicator options affect overlays only; they must not distort the
91
+ // plotted series by connecting history to the off-screen live value.
92
+ const tipValue = resolveLineTipValue(
93
+ engine.displayValue.get(),
94
+ engine.edgeValue.get(),
95
+ engine.viewEnd.get(),
96
+ );
87
97
  const realPts = buildLinePoints(
88
98
  engine.data.get(),
89
99
  tipValue,
@@ -40,6 +40,7 @@ import {
40
40
  import {
41
41
  delayedPanTouchCancelled,
42
42
  delayedPanTouchDown,
43
+ delayedPanTouchMove,
43
44
  delayedPanTouchUp,
44
45
  resetDelayedPanGuard,
45
46
  shouldStartDelayedPan,
@@ -152,6 +153,12 @@ export function useCrosshair(
152
153
  * exclusion (the default; scrub covers the whole plot).
153
154
  */
154
155
  scrubBottomExclude = 0,
156
+ /**
157
+ * True while the time-scroll pan owns the touch. The hold-to-scrub pan refuses
158
+ * to activate while set, so a drag that already started scrolling can never
159
+ * mature into a scrub (see `delayedPanGuard.shouldStartDelayedPan`).
160
+ */
161
+ scrollActive?: SharedValue<boolean>,
155
162
  ): CrosshairState {
156
163
  const scrubX = useSharedValue(-1);
157
164
  const scrubActive = useSharedValue(false);
@@ -162,6 +169,18 @@ export function useCrosshair(
162
169
  // lifecycle helpers are also used by LiveChartSeries and unit-tested directly.
163
170
  const fingerDown = useSharedValue(false);
164
171
  const panActivated = useSharedValue(false);
172
+ // Where and when the current touch went down, for the stationary-hold and
173
+ // stale-timer guards (see delayedPanGuard).
174
+ const downX = useSharedValue(0);
175
+ const downY = useSharedValue(0);
176
+ const downAtMs = useSharedValue(0);
177
+ // Latched once the finger leaves the hold slop: this touch is a drag, so its
178
+ // long-press timer must not be honored even if it still fires.
179
+ const holdBroken = useSharedValue(false);
180
+ // Inert stand-in so the guard always has a latch to read when the controller
181
+ // wires no time-scroll (hooks must be created unconditionally).
182
+ const noScrollActive = useSharedValue(false);
183
+ const scrollActiveSV = scrollActive ?? noScrollActive;
165
184
  // Where the crosshair line should start (canvas Y) so it stops at a top-pinned
166
185
  // custom tooltip instead of running through it. -1 = no top tooltip → the line
167
186
  // starts at padding.top. Written by CustomTooltipOverlay, read by CrosshairOverlay.
@@ -505,9 +524,33 @@ export function useCrosshair(
505
524
  .maxPointers(1)
506
525
  .shouldCancelWhenOutside(false)
507
526
  .onTouchesDown(
508
- /* istanbul ignore next */ () => {
527
+ /* istanbul ignore next */ (e) => {
509
528
  "worklet";
510
- delayedPanTouchDown(longPressMs, fingerDown);
529
+ delayedPanTouchDown(
530
+ longPressMs,
531
+ e,
532
+ fingerDown,
533
+ downX,
534
+ downY,
535
+ downAtMs,
536
+ holdBroken,
537
+ );
538
+ },
539
+ )
540
+ // Stationary hold: drift beyond the slop while the long-press is still
541
+ // pending fails the pan, so a drag can never mature into a scrub.
542
+ .onTouchesMove(
543
+ /* istanbul ignore next */ (e, manager) => {
544
+ "worklet";
545
+ delayedPanTouchMove(
546
+ longPressMs,
547
+ e,
548
+ manager,
549
+ panActivated,
550
+ downX,
551
+ downY,
552
+ holdBroken,
553
+ );
511
554
  },
512
555
  )
513
556
  .onTouchesUp(
@@ -535,7 +578,17 @@ export function useCrosshair(
535
578
  .onStart(
536
579
  /* istanbul ignore next */ (e) => {
537
580
  "worklet";
538
- if (!shouldStartDelayedPan(longPressMs, fingerDown, panActivated)) return;
581
+ if (
582
+ !shouldStartDelayedPan(
583
+ longPressMs,
584
+ fingerDown,
585
+ panActivated,
586
+ downAtMs,
587
+ holdBroken,
588
+ scrollActiveSV,
589
+ )
590
+ )
591
+ return;
539
592
  if (!enabled) return;
540
593
  // Scrub-action: once a reticle is placed, drag adjusts it (2D — the Y is
541
594
  // the price). The ephemeral live-scrub never engages, so scrubActive
@@ -600,7 +653,7 @@ export function useCrosshair(
600
653
  .onFinalize(
601
654
  /* istanbul ignore next */ () => {
602
655
  "worklet";
603
- resetDelayedPanGuard(fingerDown, panActivated);
656
+ resetDelayedPanGuard(fingerDown, panActivated, holdBroken);
604
657
  // A lock-adjust drag leaves the reticle in place (scrubActive was never
605
658
  // set); a live-scrub clears its crosshair. Always clear scrubActive so a
606
659
  // stray scrub can never linger behind a placed reticle.
@@ -1,14 +1,19 @@
1
+ import type { SkFont } from "@shopify/react-native-skia";
1
2
  import { Gesture } from "react-native-gesture-handler";
2
3
  import {
3
4
  useAnimatedReaction,
4
5
  useDerivedValue,
5
6
  useSharedValue,
7
+ type DerivedValue,
8
+ type SharedValue,
6
9
  } from "react-native-reanimated";
7
10
  import { scheduleOnRN } from "react-native-worklets";
11
+ import type { ResolvedPerSeriesTooltipConfig } from "../core/resolveConfig";
8
12
  import type { MultiEngineState } from "../core/useLiveChartEngine";
9
13
  import { type ChartPadding } from "../draw/line";
10
14
  import type { ScrubPointMulti } from "../types";
11
15
  import {
16
+ computePerSeriesTooltipLayout,
12
17
  deriveScrubValueSeries,
13
18
  interpolateSeriesAtTime,
14
19
  } from "./crosshairSeries";
@@ -24,15 +29,26 @@ import {
24
29
  import {
25
30
  delayedPanTouchCancelled,
26
31
  delayedPanTouchDown,
32
+ delayedPanTouchMove,
27
33
  delayedPanTouchUp,
28
34
  resetDelayedPanGuard,
29
35
  shouldStartDelayedPan,
30
36
  } from "./delayedPanGuard";
31
37
 
32
38
  /**
33
- * LiveChartSeries crosshair + scrub. No tooltip data is delivered via
34
- * `onScrub` worklet callback on the UI thread.
39
+ * LiveChartSeries crosshair + scrub. The optional per-series tooltip is
40
+ * calculated on the UI thread alongside the callback payload.
35
41
  */
42
+ interface CrosshairSeriesTooltipOptions {
43
+ config: ResolvedPerSeriesTooltipConfig;
44
+ formatValue: (value: number) => string;
45
+ formatTime: (time: number) => string;
46
+ font: SkFont;
47
+ colors: string[];
48
+ /** Current-time anchor used to clamp the live bucket's range end. */
49
+ maxTime: SharedValue<number> | DerivedValue<number>;
50
+ }
51
+
36
52
  export function useCrosshairSeries(
37
53
  engine: MultiEngineState,
38
54
  padding: ChartPadding,
@@ -42,6 +58,13 @@ export function useCrosshairSeries(
42
58
  panGestureDelay = 0,
43
59
  onGestureStart?: () => void,
44
60
  onGestureEnd?: () => void,
61
+ /**
62
+ * True while the time-scroll pan owns the touch. The hold-to-scrub pan refuses
63
+ * to activate while set, so a drag that already started scrolling can never
64
+ * mature into a scrub (see `delayedPanGuard.shouldStartDelayedPan`).
65
+ */
66
+ scrollActive?: SharedValue<boolean>,
67
+ tooltip?: CrosshairSeriesTooltipOptions,
45
68
  ): CrosshairState {
46
69
  const scrubX = useSharedValue(-1);
47
70
  const scrubActive = useSharedValue(false);
@@ -52,6 +75,18 @@ export function useCrosshairSeries(
52
75
  // the final pointer has already lifted on iOS.
53
76
  const fingerDown = useSharedValue(false);
54
77
  const panActivated = useSharedValue(false);
78
+ // Where and when the current touch went down, for the stationary-hold and
79
+ // stale-timer guards (see delayedPanGuard).
80
+ const downX = useSharedValue(0);
81
+ const downY = useSharedValue(0);
82
+ const downAtMs = useSharedValue(0);
83
+ // Latched once the finger leaves the hold slop: this touch is a drag, so its
84
+ // long-press timer must not be honored even if it still fires.
85
+ const holdBroken = useSharedValue(false);
86
+ // Inert stand-in so the guard always has a latch to read when the controller
87
+ // wires no time-scroll (hooks must be created unconditionally).
88
+ const noScrollActive = useSharedValue(false);
89
+ const scrollActiveSV = scrollActive ?? noScrollActive;
55
90
 
56
91
  const scrubTime = useDerivedValue(() =>
57
92
  computeScrubTime(
@@ -94,7 +129,27 @@ export function useCrosshairSeries(
94
129
  ),
95
130
  );
96
131
 
97
- const tooltipLayout = useSharedValue(HIDDEN_TOOLTIP);
132
+ const tooltipLayout = useDerivedValue(() => {
133
+ if (!tooltip) return HIDDEN_TOOLTIP;
134
+ return computePerSeriesTooltipLayout(
135
+ scrubActive.get(),
136
+ scrubX.get(),
137
+ scrubTime.get(),
138
+ engine.series.get(),
139
+ engine.displaySeriesValues.get(),
140
+ tooltip.colors,
141
+ engine.displayMin.get(),
142
+ engine.displayMax.get(),
143
+ padding,
144
+ engine.canvasWidth.get(),
145
+ engine.canvasHeight.get(),
146
+ tooltip.maxTime.get(),
147
+ tooltip.formatValue,
148
+ tooltip.formatTime,
149
+ tooltip.font,
150
+ tooltip.config,
151
+ );
152
+ });
98
153
 
99
154
  /* istanbul ignore next */
100
155
  function handleGestureStart() {
@@ -169,9 +224,33 @@ export function useCrosshairSeries(
169
224
  .maxPointers(1)
170
225
  .shouldCancelWhenOutside(false)
171
226
  .onTouchesDown(
172
- /* istanbul ignore next */ () => {
227
+ /* istanbul ignore next */ (e) => {
173
228
  "worklet";
174
- delayedPanTouchDown(panGestureDelay, fingerDown);
229
+ delayedPanTouchDown(
230
+ panGestureDelay,
231
+ e,
232
+ fingerDown,
233
+ downX,
234
+ downY,
235
+ downAtMs,
236
+ holdBroken,
237
+ );
238
+ },
239
+ )
240
+ // Stationary hold: drift beyond the slop while the long-press is still
241
+ // pending fails the pan, so a drag can never mature into a scrub.
242
+ .onTouchesMove(
243
+ /* istanbul ignore next */ (e, manager) => {
244
+ "worklet";
245
+ delayedPanTouchMove(
246
+ panGestureDelay,
247
+ e,
248
+ manager,
249
+ panActivated,
250
+ downX,
251
+ downY,
252
+ holdBroken,
253
+ );
175
254
  },
176
255
  )
177
256
  .onTouchesUp(
@@ -199,7 +278,16 @@ export function useCrosshairSeries(
199
278
  .onStart(
200
279
  /* istanbul ignore next */ (e) => {
201
280
  "worklet";
202
- if (!shouldStartDelayedPan(panGestureDelay, fingerDown, panActivated))
281
+ if (
282
+ !shouldStartDelayedPan(
283
+ panGestureDelay,
284
+ fingerDown,
285
+ panActivated,
286
+ downAtMs,
287
+ holdBroken,
288
+ scrollActiveSV,
289
+ )
290
+ )
203
291
  return;
204
292
  if (!enabled) return;
205
293
  scrubX.set(e.x);
@@ -218,7 +306,7 @@ export function useCrosshairSeries(
218
306
  .onFinalize(
219
307
  /* istanbul ignore next */ () => {
220
308
  "worklet";
221
- resetDelayedPanGuard(fingerDown, panActivated);
309
+ resetDelayedPanGuard(fingerDown, panActivated, holdBroken);
222
310
  scrubActive.set(false);
223
311
  if (gestureStarted.get()) {
224
312
  gestureStarted.set(false);
@@ -13,16 +13,23 @@ import type { Momentum, MomentumConfig } from "../types";
13
13
  export function resolveMomentumProp(
14
14
  prop: boolean | Momentum | MomentumConfig,
15
15
  data: readonly { time: number; value: number }[],
16
+ endTime?: number,
16
17
  ): Momentum {
17
18
  "worklet";
18
19
  if (prop === false) return "flat";
19
20
  if (prop === true)
20
- return detectMomentum(data as { time: number; value: number }[]);
21
+ return detectMomentum(
22
+ data as { time: number; value: number }[],
23
+ undefined,
24
+ undefined,
25
+ endTime,
26
+ );
21
27
  if (typeof prop === "object")
22
28
  return detectMomentum(
23
29
  data as { time: number; value: number }[],
24
30
  prop.lookback,
25
31
  prop.threshold,
32
+ endTime,
26
33
  );
27
34
  return prop;
28
35
  }
@@ -57,6 +57,20 @@ export interface UsePanScrollOptions {
57
57
  * a stray scrub doesn't linger behind the pan.
58
58
  */
59
59
  onScrollStart?: () => void;
60
+ /**
61
+ * Set true for as long as this gesture owns the touch, cleared on finalize.
62
+ * The hold-to-scrub pan reads it and refuses to activate mid-scroll — RNGH's
63
+ * `Gesture.Race` declares no relation between the two pans, so this latch is
64
+ * the only thing stopping the scrub's long-press timer from firing while the
65
+ * chart is already being dragged. See `delayedPanGuard.shouldStartDelayedPan`.
66
+ */
67
+ scrollActive?: SharedValue<boolean>;
68
+ /**
69
+ * True while the scrub crosshair owns the touch. Scrolling is inert while set,
70
+ * so an engaged scrub locks the chart in place and the finger only moves the
71
+ * price indicator. Cleared when the finger lifts (the scrub pan's finalize).
72
+ */
73
+ scrubActive?: SharedValue<boolean>;
60
74
  }
61
75
 
62
76
  /**
@@ -134,6 +148,8 @@ export function usePanScroll({
134
148
  enabled,
135
149
  mode = "holdToScrub",
136
150
  onScrollStart,
151
+ scrollActive,
152
+ scrubActive,
137
153
  }: UsePanScrollOptions): ReturnType<typeof Gesture.Pan> {
138
154
  const { viewEnd, liveEdge, displayWindow, canvasWidth, canvasHeight } = engine;
139
155
  const padLeft = padding.left;
@@ -150,10 +166,21 @@ export function usePanScroll({
150
166
  /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
151
167
  () => {
152
168
  "worklet";
169
+ // Stop an in-flight fling FIRST. `withDecay` keeps writing `viewEnd` after
170
+ // the finger lifts, and `Gesture.Race` declares no relation between the two
171
+ // pans, so this can run while a scrub is already engaged. Bailing out below
172
+ // without cancelling left the decay sliding the window underneath the
173
+ // crosshair — the opposite of the lock the scrub is meant to hold.
153
174
  cancelAnimation(viewEnd);
154
- // Anchor at the current right edge so the first delta is relative to where
155
- // the window sits (the live edge when we were following).
156
- if (viewEnd.get() == null) viewEnd.set(liveEdge.get());
175
+ // An engaged scrub locks the chart: the finger moves the price indicator
176
+ // across a fixed window, so a scroll that activates underneath it must do
177
+ // nothing further (and must not claim the touch via `scrollActive`).
178
+ if (scrubActive?.get()) return;
179
+ scrollActive?.set(true);
180
+ // Keep `null` while following live. `onChange` resolves it to the current
181
+ // live edge before applying the first delta. Materializing that same edge
182
+ // here would falsely signal "scrolled back" between start and change,
183
+ // briefly collapsing the floating-axis gutter on a forward-only overdrag.
157
184
  onScrollStart?.();
158
185
  };
159
186
 
@@ -161,6 +188,7 @@ export function usePanScroll({
161
188
  /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
162
189
  (e: { changeX: number }) => {
163
190
  "worklet";
191
+ if (scrubActive?.get()) return;
164
192
  const win = displayWindow.get();
165
193
  const chartW = canvasWidth.get() - padLeft - padRight;
166
194
  if (chartW <= 0) return;
@@ -174,6 +202,7 @@ export function usePanScroll({
174
202
  /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
175
203
  (e: { velocityX: number }) => {
176
204
  "worklet";
205
+ if (scrubActive?.get()) return;
177
206
  if (viewEnd.get() == null) return;
178
207
  const win = displayWindow.get();
179
208
  const chartW = canvasWidth.get() - padLeft - padRight;
@@ -194,6 +223,16 @@ export function usePanScroll({
194
223
  );
195
224
  };
196
225
 
226
+ // Release the touch claim on every terminal state (END, FAIL, CANCEL), not just
227
+ // onEnd — a scroll that fails its offset clamps never reaches onEnd, and a
228
+ // stuck `scrollActive` would block scrub for the rest of the session.
229
+ const onFinalize =
230
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
231
+ () => {
232
+ "worklet";
233
+ scrollActive?.set(false);
234
+ };
235
+
197
236
  if (mode === "axisDrag") {
198
237
  return Gesture.Pan()
199
238
  .enabled(enabled)
@@ -235,7 +274,8 @@ export function usePanScroll({
235
274
  )
236
275
  .onStart(onStart)
237
276
  .onChange(onChange)
238
- .onEnd(onEnd);
277
+ .onEnd(onEnd)
278
+ .onFinalize(onFinalize);
239
279
  }
240
280
 
241
281
  // holdToScrub (default): a one-finger drag anywhere scrolls. Activate on
@@ -249,5 +289,6 @@ export function usePanScroll({
249
289
  .failOffsetY([-HOLD_SCRUB_FAIL_Y_PX, HOLD_SCRUB_FAIL_Y_PX])
250
290
  .onStart(onStart)
251
291
  .onChange(onChange)
252
- .onEnd(onEnd);
292
+ .onEnd(onEnd)
293
+ .onFinalize(onFinalize);
253
294
  }
package/src/index.ts CHANGED
@@ -77,6 +77,7 @@ export type {
77
77
  MomentumConfig,
78
78
  MotionMetrics,
79
79
  MultiSeriesDotConfig,
80
+ PerSeriesTooltipConfig,
80
81
  PulseConfig,
81
82
  ReferenceLine,
82
83
  ReferenceLineBadgeConfig,
package/src/math/lerp.ts CHANGED
@@ -12,6 +12,13 @@ export function lerp(
12
12
  dt = MS_PER_FRAME_60FPS,
13
13
  ): number {
14
14
  "worklet";
15
+ // speed >= 1 means "snap in one frame". Computing it through the exponential
16
+ // would evaluate 0 ** (dt / frame), which is Infinity for a negative frame
17
+ // delta. When current === target, the resulting 0 * -Infinity becomes NaN
18
+ // and is permanent once written back into a SharedValue. No broader guard:
19
+ // NaN inputs must keep propagating (the engine tick relies on a NaN
20
+ // displayValue staying NaN so the y-range block stays skipped).
21
+ if (speed >= 1) return target;
15
22
  const factor = 1 - Math.pow(1 - speed, dt / MS_PER_FRAME_60FPS);
16
23
  return current + (target - current) * factor;
17
24
  }
@@ -9,15 +9,32 @@ export function detectMomentum(
9
9
  points: LiveChartPoint[],
10
10
  lookback = 20,
11
11
  threshold = 0.12,
12
+ endTime?: number,
12
13
  ): Momentum {
13
14
  "worklet";
14
- if (points.length < 5) return "flat";
15
+ // `badge.followViewEdge` needs momentum at the historical window edge rather
16
+ // than at the live dataset tail. Points are chronological, so find the first
17
+ // sample after the inclusive cutoff without allocating a sliced array on the
18
+ // UI thread. With no cutoff, preserve the normal live-tail behavior.
19
+ let end = points.length;
20
+ if (endTime !== undefined) {
21
+ let lo = 0;
22
+ let hi = points.length;
23
+ while (lo < hi) {
24
+ const mid = (lo + hi) >> 1;
25
+ if (points[mid].time <= endTime) lo = mid + 1;
26
+ else hi = mid;
27
+ }
28
+ end = lo;
29
+ }
30
+
31
+ if (end < 5) return "flat";
15
32
 
16
- const start = Math.max(0, points.length - lookback);
33
+ const start = Math.max(0, end - lookback);
17
34
 
18
35
  let min = Infinity;
19
36
  let max = -Infinity;
20
- for (let i = start; i < points.length; i++) {
37
+ for (let i = start; i < end; i++) {
21
38
  const v = points[i].value;
22
39
  if (v < min) min = v;
23
40
  if (v > max) max = v;
@@ -25,9 +42,9 @@ export function detectMomentum(
25
42
  const range = max - min;
26
43
  if (range === 0) return "flat";
27
44
 
28
- const tailStart = Math.max(start, points.length - 5);
45
+ const tailStart = Math.max(start, end - 5);
29
46
  const first = points[tailStart].value;
30
- const last = points[points.length - 1].value;
47
+ const last = points[end - 1].value;
31
48
  const delta = last - first;
32
49
 
33
50
  const absThreshold = range * threshold;
@@ -44,6 +44,44 @@ export function collectReferenceValues(lines: ReferenceLine[]): number[] {
44
44
  return out;
45
45
  }
46
46
 
47
+ /**
48
+ * Stable React keys for an ordered reference-line list. A caller-supplied `id`
49
+ * keeps the key stable across reordering; the visual config is a deterministic
50
+ * fallback for legacy lines. Equal fallback signatures receive a suffix so they
51
+ * remain distinct siblings.
52
+ */
53
+ export function referenceLineReactKeys(
54
+ lines: readonly ReferenceLine[],
55
+ ): string[] {
56
+ const occurrences = new Map<string, number>();
57
+ const keys: string[] = [];
58
+ for (const line of lines) {
59
+ const base = line.id ?? stableReferenceLineSignature(line);
60
+ const occurrence = occurrences.get(base) ?? 0;
61
+ occurrences.set(base, occurrence + 1);
62
+ keys.push(`${base}:${occurrence}`);
63
+ }
64
+ return keys;
65
+ }
66
+
67
+ /** Serializes render-relevant config without callback identity churn. */
68
+ function stableReferenceLineSignature(value: unknown): string {
69
+ if (Array.isArray(value)) {
70
+ return `[${value.map(stableReferenceLineSignature).join(",")}]`;
71
+ }
72
+ if (value !== null && typeof value === "object") {
73
+ const record = value as Record<string, unknown>;
74
+ const entries: string[] = [];
75
+ for (const key of Object.keys(record).sort()) {
76
+ const entry = record[key];
77
+ if (typeof entry === "function" || entry === undefined) continue;
78
+ entries.push(`${key}:${stableReferenceLineSignature(entry)}`);
79
+ }
80
+ return `{${entries.join(",")}}`;
81
+ }
82
+ return JSON.stringify(value) ?? "undefined";
83
+ }
84
+
47
85
  /**
48
86
  * Fully-resolved badge **style/shape** + anchor — the resolved counterpart of
49
87
  * {@link BadgeStyleConfig} (`position` / `icon` / `showText` plus the style knobs).