react-native-livechart 4.2.0 → 4.3.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 (35) hide show
  1. package/dist/components/LiveChart.d.ts.map +1 -1
  2. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  3. package/dist/constants.d.ts +6 -0
  4. package/dist/constants.d.ts.map +1 -1
  5. package/dist/core/liveChartEngineTick.d.ts +11 -0
  6. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  7. package/dist/core/liveChartSeriesEngineTick.d.ts +10 -0
  8. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  9. package/dist/core/resolveConfig.d.ts +11 -1
  10. package/dist/core/resolveConfig.d.ts.map +1 -1
  11. package/dist/core/useLiveChartEngine.d.ts +18 -0
  12. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  13. package/dist/core/useLiveChartSeriesEngine.d.ts +17 -0
  14. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  15. package/dist/hooks/useReferenceDrag.d.ts +12 -4
  16. package/dist/hooks/useReferenceDrag.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/math/referenceDrag.d.ts +9 -0
  20. package/dist/math/referenceDrag.d.ts.map +1 -1
  21. package/dist/types.d.ts +36 -0
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/components/LiveChart.tsx +35 -22
  25. package/src/components/LiveChartSeries.tsx +7 -0
  26. package/src/constants.ts +7 -0
  27. package/src/core/liveChartEngineTick.ts +42 -7
  28. package/src/core/liveChartSeriesEngineTick.ts +37 -5
  29. package/src/core/resolveConfig.ts +21 -0
  30. package/src/core/useLiveChartEngine.ts +71 -1
  31. package/src/core/useLiveChartSeriesEngine.ts +65 -1
  32. package/src/hooks/useReferenceDrag.ts +41 -11
  33. package/src/index.ts +1 -0
  34. package/src/math/referenceDrag.ts +19 -0
  35. package/src/types.ts +37 -0
@@ -37,6 +37,7 @@ import {
37
37
  resolvePulse,
38
38
  resolveScrub,
39
39
  resolveScrubAction,
40
+ resolveReturnToLiveMs,
40
41
  resolveSelectionDot,
41
42
  resolveThreshold,
42
43
  resolveTradeStream,
@@ -216,6 +217,7 @@ function useLiveChartController({
216
217
  windowBuffer = 0,
217
218
  nowOverride,
218
219
  timeScroll = false,
220
+ returnToLive,
219
221
  zoom = false,
220
222
  accessibilityLabel,
221
223
  accessibilityRole = "image",
@@ -494,6 +496,9 @@ function useLiveChartController({
494
496
  // onto the JS thread (set by the reaction below, after the engine exists).
495
497
  const [scrolledBack, setScrolledBack] = useState(false);
496
498
  const timeScrollEnabled = Boolean(timeScroll) && !isStatic;
499
+ // Glide duration for the return-to-live animation (0 = instant). A sibling of
500
+ // `timeScroll` so it survives `timeScroll={false}` (the disable that triggers it).
501
+ const returnToLiveMs = resolveReturnToLiveMs(returnToLive);
497
502
  const zoomCfg = resolveZoom(zoom);
498
503
  const zoomEnabled = zoomCfg !== null && !isStatic;
499
504
 
@@ -552,6 +557,8 @@ function useLiveChartController({
552
557
  timeWindow,
553
558
  paused,
554
559
  static: isStatic,
560
+ scrollEnabled: timeScrollEnabled,
561
+ returnToLiveMs,
555
562
  smoothing,
556
563
  adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
557
564
  exaggerate,
@@ -568,12 +575,16 @@ function useLiveChartController({
568
575
 
569
576
  // Mirror the UI-thread scroll state to React so the floating y-axis can keep
570
577
  // a right gutter at the live edge and collapse it only while scrolled back.
571
- // Fires once per null↔frozen transition, and only matters for float+timeScroll.
578
+ // Fires once per null↔frozen transition. `viewEnd` is only non-null while
579
+ // time-scroll is enabled and actively scrolled (the engine resets it to null
580
+ // when time-scroll is disabled — see #164), so we don't gate on
581
+ // `timeScrollEnabled` here: that would strand `scrolledBack` at `true` after a
582
+ // disable-while-scrolled, wrongly floating the y-axis on a later re-enable.
572
583
  useAnimatedReaction(
573
584
  () => engine.viewEnd.value != null,
574
585
  /* istanbul ignore next -- Reanimated reaction; state mirrored on the JS thread, not exercised under Jest */
575
586
  (isScrolled, prev) => {
576
- if (isScrolled !== prev && yAxisFloat && timeScrollEnabled) {
587
+ if (isScrolled !== prev && yAxisFloat) {
577
588
  scheduleOnRN(setScrolledBack, isScrolled);
578
589
  }
579
590
  },
@@ -798,13 +809,30 @@ function useLiveChartController({
798
809
  dragValues,
799
810
  );
800
811
 
801
- // Combined "defer" hit-test for the scrub-action place-tap: yield to a marker or
802
- // a pressable badge under the finger so the tap is routed there instead of
803
- // dropping a reticle. (Both hit-tests return false when their feature is off.)
812
+ // Draggable reference lines: a per-line vertical pan that grabs a line near its
813
+ // value and drags it along the Y-axis (with snap / bounds / callbacks). Built
814
+ // unconditionally for stable hook order (and before `useCrosshair` so the scrub
815
+ // can defer to a line under the finger); the gesture self-disables when no line
816
+ // opts in, and it's only composed into the root when `refDragEnabled`.
817
+ const refDragEnabled =
818
+ !isStatic && allRefLines.some((l) => l.draggable === true);
819
+ const { gesture: refDragGesture, hitTest: refDragHitTest } = useReferenceDrag(
820
+ engine,
821
+ effectivePadding,
822
+ allRefLines,
823
+ dragValues,
824
+ dragActive,
825
+ !isStatic,
826
+ );
827
+
828
+ // Combined "defer" hit-test: the scrub-action place-tap and the live scrub both
829
+ // yield to a marker, a pressable badge, or a draggable line under the finger — so
830
+ // a press there is routed to that overlay / drag instead of dropping a reticle or
831
+ // crosshair. (Each hit-test returns false when its feature is off.)
804
832
  /* istanbul ignore next -- worklet runs on the UI thread, not in Jest */
805
833
  const deferTapHit = (x: number, y: number): boolean => {
806
834
  "worklet";
807
- return markerHitTest(x, y) || refLineHitTest(x, y);
835
+ return markerHitTest(x, y) || refLineHitTest(x, y) || refDragHitTest(x, y);
808
836
  };
809
837
 
810
838
  // Time-scroll activation. `holdToScrub`: a quick one-finger drag scrolls while
@@ -843,7 +871,7 @@ function useLiveChartController({
843
871
  scrubActionCfg,
844
872
  onScrubAction,
845
873
  metricsCfg.badge,
846
- markersActive || refPressActive ? deferTapHit : undefined,
874
+ markersActive || refPressActive || refDragEnabled ? deferTapHit : undefined,
847
875
  scrubCfg?.tooltipPlacement ?? "side",
848
876
  scrubCfg?.tooltipShowValue ?? true,
849
877
  scrubCfg?.tooltipShowTime ?? true,
@@ -876,21 +904,6 @@ function useLiveChartController({
876
904
  },
877
905
  });
878
906
 
879
- // Draggable reference lines: a per-line vertical pan that grabs a line near its
880
- // value and drags it along the Y-axis (with snap / bounds / callbacks). Built
881
- // unconditionally for stable hook order; the gesture self-disables when no line
882
- // opts in, and it's only composed into the root when `refDragEnabled`.
883
- const refDragEnabled =
884
- !isStatic && allRefLines.some((l) => l.draggable === true);
885
- const refDragGesture = useReferenceDrag(
886
- engine,
887
- effectivePadding,
888
- allRefLines,
889
- dragValues,
890
- dragActive,
891
- !isStatic,
892
- );
893
-
894
907
  // Pinch-to-zoom the visible window (two-finger). Anchors at the focal point and
895
908
  // writes viewWindow + viewEnd; composes via Simultaneous (it's two-finger, so
896
909
  // disjoint from the one-finger pan/scrub). See `zoom`.
@@ -36,6 +36,7 @@ import {
36
36
  resolveMarkerCluster,
37
37
  resolveMetrics,
38
38
  resolveMultiSeriesDot,
39
+ resolveReturnToLiveMs,
39
40
  resolveScrub,
40
41
  resolveSelectionDot,
41
42
  resolveXAxis,
@@ -147,6 +148,7 @@ function useLiveChartSeriesController({
147
148
  scrub = true,
148
149
  selectionDot,
149
150
  timeScroll = false,
151
+ returnToLive,
150
152
  zoom = false,
151
153
  onScrub,
152
154
  onGestureStart,
@@ -181,6 +183,9 @@ function useLiveChartSeriesController({
181
183
  // gesture to wait for a press-and-hold so a quick drag scrolls instead — the
182
184
  // hold delay is threaded into `useCrosshairSeries` below.
183
185
  const timeScrollEnabled = Boolean(timeScroll);
186
+ // Return-to-live glide duration (0 = instant); sibling of `timeScroll` so it
187
+ // survives `timeScroll={false}` (the disable that triggers it). See #164.
188
+ const returnToLiveMs = resolveReturnToLiveMs(returnToLive);
184
189
  const zoomCfg = resolveZoom(zoom);
185
190
  const zoomEnabled = zoomCfg !== null;
186
191
  const scrollGestureMode =
@@ -296,6 +301,8 @@ function useLiveChartSeriesController({
296
301
  series: effectiveSeries,
297
302
  timeWindow,
298
303
  paused,
304
+ scrollEnabled: timeScrollEnabled,
305
+ returnToLiveMs,
299
306
  smoothing,
300
307
  adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
301
308
  exaggerate,
package/src/constants.ts CHANGED
@@ -24,6 +24,13 @@ export const MAX_Y_LABELS = 15;
24
24
  */
25
25
  export const HOLD_TO_SCRUB_MS = 500;
26
26
 
27
+ /**
28
+ * Duration (ms) of the "return to live" glide — how long the window takes to ease
29
+ * from a scrolled-back position to the live edge when `timeScroll` is disabled
30
+ * (or a mode switch turns it off). Eased out so it decelerates onto live. See #164.
31
+ */
32
+ export const RETURN_TO_LIVE_MS = 450;
33
+
27
34
  // ─── Metric default tokens (single source of truth for LiveChartMetrics) ─────
28
35
  // The resolved `metrics` config (see resolveMetrics) is assembled from these
29
36
  // objects. Draw/worklet helpers default their metric params to the matching
@@ -69,6 +69,17 @@ export interface EngineTickInput {
69
69
  * resumes following. Takes precedence over {@link paused}.
70
70
  */
71
71
  viewEnd?: number | null;
72
+ /**
73
+ * "Return to live" glide (see #164). When time-scroll is disabled while scrolled
74
+ * back, the engine hook clears {@link viewEnd} and animates {@link returnT} from
75
+ * `0`→`1`; each frame the right edge interpolates from {@link returnFrom} (the
76
+ * frozen edge captured at that moment) to the *current* live edge by `returnT`,
77
+ * so the window glides forward and lands exactly on live (no end-snap). At
78
+ * `1`/`undefined` it pins to the live edge — the normal follow behavior.
79
+ */
80
+ returnT?: number;
81
+ /** Frozen right-edge time the {@link returnT} glide starts from. See {@link returnT}. */
82
+ returnFrom?: number;
72
83
  /**
73
84
  * Absolute visible-window width (seconds) to freeze at, or `null`/`undefined`
74
85
  * to follow the configured {@link timeWindow}. Set by the pinch-zoom gesture
@@ -98,13 +109,38 @@ export function tickLiveChartEngineFrame(
98
109
  const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
99
110
  state.liveEdge = liveEdge;
100
111
  const viewEnd = input.viewEnd;
101
- if (viewEnd != null && viewEnd < liveEdge) {
102
- // Scrolled back in time: freeze the right edge at an absolute timestamp so
103
- // the window stops tracking "now" (see usePanScroll / the `timeScroll` prop).
112
+ // First time of the visible series' data — the floor a frozen edge must stay
113
+ // at or above. `-Infinity` when there's no data to bound against, so the freeze
114
+ // still works (we can't strand a plot we have no time bounds for).
115
+ let firstDataTime = -Infinity;
116
+ if (input.mode === "candle") {
117
+ const cs = input.candles;
118
+ if (cs && cs.length > 0) firstDataTime = cs[0].time;
119
+ } else if (input.points.length > 0) {
120
+ firstDataTime = input.points[0].time;
121
+ }
122
+ // Freeze the right edge while the pan gesture has parked `viewEnd` behind the
123
+ // live edge AND that edge still sits within the data. A frozen edge stranded
124
+ // before the active series' first point falls through to following live (so a
125
+ // line/candle span mismatch never strands the window on an empty plot). When
126
+ // time-scroll is disabled the hook clears `viewEnd` (and kicks off the glide
127
+ // below), so a stale edge can't keep the window frozen. See #164.
128
+ const scrolledBack =
129
+ viewEnd != null && viewEnd < liveEdge && viewEnd >= firstDataTime;
130
+ if (scrolledBack) {
104
131
  state.timestamp = viewEnd;
105
132
  } else if (!input.paused) {
106
- // Following the live edge viewEnd is null/undefined or has caught back up.
107
- state.timestamp = liveEdge;
133
+ // Following live. While a "return to live" glide is in flight (returnT < 1)
134
+ // ease the right edge from the frozen `returnFrom` to the *current* live edge
135
+ // by returnT — converges exactly on live with no end-snap. Otherwise pin to
136
+ // the live edge (the steady-state follow).
137
+ const returnT = input.returnT;
138
+ if (returnT != null && returnT < 1 && input.returnFrom != null) {
139
+ state.timestamp =
140
+ input.returnFrom + (liveEdge - input.returnFrom) * returnT;
141
+ } else {
142
+ state.timestamp = liveEdge;
143
+ }
108
144
  }
109
145
  // else: paused with no active pan → leave the frozen timestamp untouched.
110
146
 
@@ -272,8 +308,7 @@ export function tickLiveChartEngineFrame(
272
308
  // Edge value: the price at the visible window's right edge. Following live →
273
309
  // track the live value (badge unchanged). Scrolled back → track the last
274
310
  // visible point/candle close, lerped so a `followViewEdge` badge glides to the
275
- // last price as you pan.
276
- const scrolledBack = viewEnd != null && viewEnd < liveEdge;
311
+ // last price as you pan. Reuses the gated `scrolledBack` computed above.
277
312
  if (!scrolledBack) {
278
313
  state.edgeValue = state.displayValue;
279
314
  } else {
@@ -56,6 +56,16 @@ export interface MultiEngineTickInput {
56
56
  * precedence over {@link paused}.
57
57
  */
58
58
  viewEnd?: number | null;
59
+ /**
60
+ * "Return to live" glide (see #164). When time-scroll is disabled while scrolled
61
+ * back, the hook clears {@link viewEnd} and animates {@link returnT} `0`→`1`;
62
+ * each frame the right edge interpolates from {@link returnFrom} to the *current*
63
+ * live edge by `returnT`, gliding onto live with no end-snap. `1`/`undefined`
64
+ * pins to the live edge.
65
+ */
66
+ returnT?: number;
67
+ /** Frozen right-edge time the {@link returnT} glide starts from. */
68
+ returnFrom?: number;
59
69
  /**
60
70
  * Absolute visible-window width (seconds) to freeze at, or `null`/`undefined`
61
71
  * to follow {@link timeWindow}. Drives pinch-zoom (see `usePinchZoom`); the
@@ -78,12 +88,34 @@ export function tickLiveChartSeriesEngineFrame(
78
88
  const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
79
89
  state.liveEdge = liveEdge;
80
90
  const viewEnd = input.viewEnd;
81
- if (viewEnd != null && viewEnd < liveEdge) {
82
- // Scrolled back in time: freeze the right edge (see usePanScroll).
91
+ // Earliest first-point time across visible series the floor a frozen edge
92
+ // must stay at or above. `-Infinity` when no visible series has data, so the
93
+ // freeze still works (nothing to bound the window against).
94
+ let firstDataTime = Infinity;
95
+ for (let i = 0; i < input.series.length; i++) {
96
+ if (input.series[i].visible === false) continue;
97
+ const d = input.series[i].data;
98
+ if (d.length > 0 && d[0].time < firstDataTime) firstDataTime = d[0].time;
99
+ }
100
+ if (firstDataTime === Infinity) firstDataTime = -Infinity;
101
+ // Freeze the right edge while the gesture has parked `viewEnd` behind the live
102
+ // edge AND that edge still sits within the data; a stranded edge falls through
103
+ // to following live. When time-scroll is disabled the hook clears `viewEnd` and
104
+ // kicks off the glide below, so a stale edge can't keep the window frozen. #164.
105
+ const scrolledBack =
106
+ viewEnd != null && viewEnd < liveEdge && viewEnd >= firstDataTime;
107
+ if (scrolledBack) {
83
108
  state.timestamp = viewEnd;
84
109
  } else if (!input.paused) {
85
- // Following the live edge viewEnd is null/undefined or has caught back up.
86
- state.timestamp = liveEdge;
110
+ // Following live; ease from the frozen `returnFrom` to the current live edge
111
+ // while a "return to live" glide is in flight (returnT < 1), else pin to live.
112
+ const returnT = input.returnT;
113
+ if (returnT != null && returnT < 1 && input.returnFrom != null) {
114
+ state.timestamp =
115
+ input.returnFrom + (liveEdge - input.returnFrom) * returnT;
116
+ } else {
117
+ state.timestamp = liveEdge;
118
+ }
87
119
  }
88
120
  // else: paused with no active pan → leave the frozen timestamp untouched.
89
121
 
@@ -115,7 +147,7 @@ export function tickLiveChartSeriesEngineFrame(
115
147
  // window's right edge, so they must track each series' value AT that edge
116
148
  // (`timestamp`), not the live value — otherwise the dot floats at the current
117
149
  // price while the line ends in the past. Mirrors single-series `edgeValue`.
118
- const scrolledBack = viewEnd != null && viewEnd < liveEdge;
150
+ // Reuses the gated `scrolledBack` computed above.
119
151
 
120
152
  for (let i = 0; i < n; i++) {
121
153
  let target = series[i].value;
@@ -25,6 +25,7 @@ import type {
25
25
  SelectionDotConfig,
26
26
  SelectionDotProps,
27
27
  SelectionDotRingConfig,
28
+ ReturnToLiveConfig,
28
29
  ThresholdConfig,
29
30
  ThresholdLineConfig,
30
31
  TradeEvent,
@@ -45,6 +46,7 @@ import {
45
46
  FADE_EDGE_WIDTH,
46
47
  GRID_METRICS_DEFAULTS,
47
48
  MOTION_METRICS_DEFAULTS,
49
+ RETURN_TO_LIVE_MS,
48
50
  } from "../constants";
49
51
 
50
52
  // ─── Resolved types (all fields required, no optionals) ──────────────────────
@@ -456,6 +458,25 @@ export function resolveZoom(
456
458
  return resolveToggle(prop, ZOOM_DEFAULTS, false);
457
459
  }
458
460
 
461
+ /**
462
+ * Resolves `timeScroll.returnToLive` to the "return to live" glide duration in ms,
463
+ * where `0` means an instant snap (no animation):
464
+ * - `undefined` / `true` → default {@link RETURN_TO_LIVE_MS}
465
+ * - `false` → `0` (instant)
466
+ * - `{ duration }` → that duration (a non-positive value collapses to `0`)
467
+ *
468
+ * See {@link ReturnToLiveConfig} / #164.
469
+ */
470
+ export function resolveReturnToLiveMs(
471
+ prop: boolean | ReturnToLiveConfig | undefined,
472
+ ): number {
473
+ if (prop === false) return 0;
474
+ if (prop == null || prop === true) return RETURN_TO_LIVE_MS;
475
+ const d = prop.duration;
476
+ if (d == null) return RETURN_TO_LIVE_MS;
477
+ return d > 0 ? d : 0;
478
+ }
479
+
459
480
  const AXIS_LABEL_DEFAULTS: ResolvedAxisLabelConfig = {
460
481
  format: undefined,
461
482
  color: undefined,
@@ -7,13 +7,16 @@
7
7
  */
8
8
  import { useEffect, useState } from "react";
9
9
  import {
10
+ cancelAnimation,
11
+ Easing,
10
12
  useAnimatedReaction,
11
13
  useDerivedValue,
12
14
  useFrameCallback,
13
15
  useSharedValue,
16
+ withTiming,
14
17
  type SharedValue,
15
18
  } from "react-native-reanimated";
16
- import { MS_PER_FRAME_60FPS } from "../constants";
19
+ import { MS_PER_FRAME_60FPS, RETURN_TO_LIVE_MS } from "../constants";
17
20
  import type { CandlePoint, LiveChartPoint, SeriesConfig } from "../types";
18
21
  import { tickLiveChartEngineFrame } from "./liveChartEngineTick";
19
22
 
@@ -39,6 +42,20 @@ export interface EngineConfig {
39
42
  nowOverride?: number;
40
43
  windowBuffer?: number;
41
44
  paused?: boolean;
45
+ /**
46
+ * Whether the `timeScroll` gesture is active. Flipping this to `false` while
47
+ * scrolled back clears {@link ChartEngineScroll.viewEnd} and **glides** the
48
+ * window back to the live edge (a brief eased animation, not an instant jump),
49
+ * so disabling time-scroll — or a mode switch that turns it off — never strands
50
+ * the chart at a past position. See #164.
51
+ */
52
+ scrollEnabled?: boolean;
53
+ /**
54
+ * Duration (ms) of the return-to-live glide when {@link scrollEnabled} flips to
55
+ * `false` while scrolled back. `0` snaps instantly (no animation). Defaults to
56
+ * {@link RETURN_TO_LIVE_MS}. Resolved from `timeScroll.returnToLive`. See #164.
57
+ */
58
+ returnToLiveMs?: number;
42
59
  /**
43
60
  * Loop-free render: settle the display state once (smoothing forced to 1, no
44
61
  * per-frame frame callback) for many small charts in a list. See
@@ -166,6 +183,10 @@ export interface EngineFrameRefs {
166
183
  pausedSV: SharedValue<boolean>;
167
184
  /** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
168
185
  viewEndSV?: SharedValue<number | null>;
186
+ /** "Return to live" glide progress (0→1); the right edge eases to live. Optional. */
187
+ returnTSV?: SharedValue<number>;
188
+ /** Frozen right-edge time the return glide starts from. Optional. */
189
+ returnFromSV?: SharedValue<number>;
169
190
  /** Pinch-zoom window-width override (null = follow timeWindow). Optional for callers/tests. */
170
191
  viewWindowSV?: SharedValue<number | null>;
171
192
  /** Receives the computed live edge each frame. Optional for callers/tests. */
@@ -223,6 +244,8 @@ export function applyLiveChartEngineFrame(
223
244
  nowSeconds: Date.now() / 1000,
224
245
  paused: sv.pausedSV.value,
225
246
  viewEnd: sv.viewEndSV?.value,
247
+ returnT: sv.returnTSV?.value,
248
+ returnFrom: sv.returnFromSV?.value,
226
249
  viewWindow: sv.viewWindowSV?.value,
227
250
  mode: sv.modeSV.value,
228
251
  candles: sv.candles?.value,
@@ -283,6 +306,14 @@ export function useLiveChartEngine(
283
306
  const nowOverrideSV = useDerivedValue(() => config.nowOverride);
284
307
  const windowBufferSV = useDerivedValue(() => config.windowBuffer ?? 0);
285
308
  const pausedSV = useDerivedValue(() => config.paused ?? false);
309
+ // Whether time-scroll is active. Drives the return-to-live reaction below
310
+ // (the tick no longer reads it — clearing `viewEnd` is what makes it follow).
311
+ // Defaults to enabled so a caller that omits it behaves as before.
312
+ const scrollEnabledSV = useDerivedValue(() => config.scrollEnabled ?? true);
313
+ // Return-to-live glide duration (ms); 0 = instant snap. Read by the reaction.
314
+ const returnToLiveMsSV = useDerivedValue(
315
+ () => config.returnToLiveMs ?? RETURN_TO_LIVE_MS,
316
+ );
286
317
  const modeSV = useDerivedValue(() => config.mode ?? "line");
287
318
 
288
319
  // Animation state (mutated on UI thread each frame)
@@ -303,6 +334,12 @@ export function useLiveChartEngine(
303
334
  const viewEnd = useSharedValue<number | null>(null);
304
335
  const liveEdge = useSharedValue(initialTimestamp);
305
336
  const edgeValue = useSharedValue(0);
337
+ // "Return to live" glide (see #164). When time-scroll is disabled while scrolled
338
+ // back, the reaction below clears `viewEnd`, snapshots the frozen edge into
339
+ // `returnFrom`, and animates `returnT` 0→1; the tick eases the right edge onto
340
+ // the live edge over that progress. `returnT` rests at 1 (no glide in flight).
341
+ const returnT = useSharedValue(1);
342
+ const returnFrom = useSharedValue(0);
306
343
 
307
344
  // Live data extrema (value + time of the visible high / low). NaN until the
308
345
  // first tick finds data — the extrema label stays hidden until then.
@@ -343,6 +380,8 @@ export function useLiveChartEngine(
343
380
  windowBufferSV,
344
381
  pausedSV,
345
382
  viewEndSV: viewEnd,
383
+ returnTSV: returnT,
384
+ returnFromSV: returnFrom,
346
385
  viewWindowSV: viewWindow,
347
386
  liveEdgeSV: liveEdge,
348
387
  edgeValueSV: edgeValue,
@@ -362,6 +401,37 @@ export function useLiveChartEngine(
362
401
  applyLiveChartEngineFrame(frameInfo, frameRefs);
363
402
  }, !config.static);
364
403
 
404
+ // When time-scroll is disabled while scrolled back, return the window to the
405
+ // live edge. With a positive duration this glides: snapshot the frozen edge into
406
+ // `returnFrom`, clear `viewEnd` (so the tick stops freezing and follows), and
407
+ // animate `returnT` 0→1 — the tick interpolates `returnFrom`→live by that
408
+ // progress, landing exactly on live. Duration 0 (`returnToLive: false`) just
409
+ // clears `viewEnd` for an instant snap. Clearing `viewEnd` also means a later
410
+ // re-enable resumes from live, not the stale frozen position. All on the UI
411
+ // thread, no JS round-trip. See #164.
412
+ useAnimatedReaction(
413
+ () => scrollEnabledSV.value,
414
+ /* istanbul ignore next -- Reanimated reaction driven by a prop→derived change; not exercised under the SharedValue mock (see the viewWindow-reset test note), verified in-app */
415
+ (enabled, prev) => {
416
+ if (prev === true && !enabled && viewEnd.value != null) {
417
+ const ms = returnToLiveMsSV.value;
418
+ if (ms > 0) {
419
+ returnFrom.value = viewEnd.value;
420
+ cancelAnimation(viewEnd);
421
+ viewEnd.value = null;
422
+ returnT.value = 0;
423
+ returnT.value = withTiming(1, {
424
+ duration: ms,
425
+ easing: Easing.out(Easing.cubic),
426
+ });
427
+ } else {
428
+ cancelAnimation(viewEnd);
429
+ viewEnd.value = null; // returnT stays 1 → tick pins to live (instant)
430
+ }
431
+ }
432
+ },
433
+ );
434
+
365
435
  // One-shot settle for static charts: the fingerprint changes when the canvas
366
436
  // lays out (width 0→real) or the framing inputs change, and the handler runs a
367
437
  // single snap tick (smoothing=1). Inert when not static — `prepare` returns a
@@ -1,11 +1,15 @@
1
1
  import { useRef, useState } from "react";
2
2
  import {
3
+ cancelAnimation,
4
+ Easing,
5
+ useAnimatedReaction,
3
6
  useDerivedValue,
4
7
  useFrameCallback,
5
8
  useSharedValue,
9
+ withTiming,
6
10
  type SharedValue,
7
11
  } from "react-native-reanimated";
8
- import { MS_PER_FRAME_60FPS } from "../constants";
12
+ import { MS_PER_FRAME_60FPS, RETURN_TO_LIVE_MS } from "../constants";
9
13
  import type { LiveChartPoint, SeriesConfig } from "../types";
10
14
  import { tickLiveChartSeriesEngineFrame } from "./liveChartSeriesEngineTick";
11
15
  import type { ChartEngineScroll, MultiEngineState } from "./useLiveChartEngine";
@@ -24,6 +28,19 @@ export interface MultiSeriesEngineConfig {
24
28
  nowOverride?: number;
25
29
  windowBuffer?: number;
26
30
  paused?: boolean;
31
+ /**
32
+ * Whether the `timeScroll` gesture is active. Flipping to `false` while scrolled
33
+ * back clears {@link ChartEngineScroll.viewEnd} and **glides** the window back to
34
+ * the live edge (eased, not an instant jump), so disabling time-scroll never
35
+ * strands the chart at a past position. See #164.
36
+ */
37
+ scrollEnabled?: boolean;
38
+ /**
39
+ * Duration (ms) of the return-to-live glide when {@link scrollEnabled} flips to
40
+ * `false` while scrolled back. `0` snaps instantly. Defaults to
41
+ * {@link RETURN_TO_LIVE_MS}. Resolved from `timeScroll.returnToLive`. See #164.
42
+ */
43
+ returnToLiveMs?: number;
27
44
  }
28
45
 
29
46
  export interface MultiEngineFrameRefs {
@@ -49,6 +66,10 @@ export interface MultiEngineFrameRefs {
49
66
  pausedSV: SharedValue<boolean>;
50
67
  /** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
51
68
  viewEndSV?: SharedValue<number | null>;
69
+ /** "Return to live" glide progress (0→1); the right edge eases to live. Optional. */
70
+ returnTSV?: SharedValue<number>;
71
+ /** Frozen right-edge time the return glide starts from. Optional. */
72
+ returnFromSV?: SharedValue<number>;
52
73
  /** Pinch-zoom window-width override (null = follow timeWindow). Optional for callers/tests. */
53
74
  viewWindowSV?: SharedValue<number | null>;
54
75
  /** Receives the computed live edge each frame. Optional for callers/tests. */
@@ -137,6 +158,8 @@ export function applyLiveChartSeriesEngineFrame(
137
158
  nowSeconds: Date.now() / 1000,
138
159
  paused: sv.pausedSV.value,
139
160
  viewEnd: sv.viewEndSV?.value,
161
+ returnT: sv.returnTSV?.value,
162
+ returnFrom: sv.returnFromSV?.value,
140
163
  viewWindow: sv.viewWindowSV?.value,
141
164
  });
142
165
  sv.displayMin.value = state.displayMin;
@@ -173,6 +196,13 @@ export function useLiveChartSeriesEngine(
173
196
  const nowOverrideSV = useDerivedValue(() => config.nowOverride);
174
197
  const windowBufferSV = useDerivedValue(() => config.windowBuffer ?? 0);
175
198
  const pausedSV = useDerivedValue(() => config.paused ?? false);
199
+ // Whether time-scroll is active — drives the return-to-live reaction below.
200
+ // Defaults to enabled (legacy behavior).
201
+ const scrollEnabledSV = useDerivedValue(() => config.scrollEnabled ?? true);
202
+ // Return-to-live glide duration (ms); 0 = instant snap. Read by the reaction.
203
+ const returnToLiveMsSV = useDerivedValue(
204
+ () => config.returnToLiveMs ?? RETURN_TO_LIVE_MS,
205
+ );
176
206
 
177
207
  const displayMin = useSharedValue(0);
178
208
  const displayMax = useSharedValue(1);
@@ -186,6 +216,10 @@ export function useLiveChartSeriesEngine(
186
216
  // Pan-scroll state (see useLiveChartEngine). Defaults to following live.
187
217
  const viewEnd = useSharedValue<number | null>(null);
188
218
  const liveEdge = useSharedValue(initialTimestamp);
219
+ // "Return to live" glide (see #164) — `returnT` rests at 1; the reaction below
220
+ // animates it 0→1 from `returnFrom` when time-scroll is disabled while scrolled.
221
+ const returnT = useSharedValue(1);
222
+ const returnFrom = useSharedValue(0);
189
223
 
190
224
  const displaySeriesValues = useSharedValue<number[]>([]);
191
225
  const seriesOpacities = useSharedValue<number[]>([]);
@@ -235,6 +269,8 @@ export function useLiveChartSeriesEngine(
235
269
  windowBufferSV,
236
270
  pausedSV,
237
271
  viewEndSV: viewEnd,
272
+ returnTSV: returnT,
273
+ returnFromSV: returnFrom,
238
274
  viewWindowSV: viewWindow,
239
275
  liveEdgeSV: liveEdge,
240
276
  extremaMinValue,
@@ -246,6 +282,34 @@ export function useLiveChartSeriesEngine(
246
282
  );
247
283
  });
248
284
 
285
+ // Return the window to live when time-scroll is disabled while scrolled back
286
+ // (mirrors useLiveChartEngine): with a positive duration, glide — snapshot the
287
+ // frozen edge, clear `viewEnd`, and animate `returnT` 0→1 so the tick eases
288
+ // `returnFrom`→live; duration 0 just clears `viewEnd` for an instant snap.
289
+ // Clearing `viewEnd` also means a later re-enable resumes from live. See #164.
290
+ useAnimatedReaction(
291
+ () => scrollEnabledSV.value,
292
+ /* istanbul ignore next -- Reanimated reaction driven by a prop→derived change; not exercised under the SharedValue mock, verified in-app */
293
+ (enabled, prev) => {
294
+ if (prev === true && !enabled && viewEnd.value != null) {
295
+ const ms = returnToLiveMsSV.value;
296
+ if (ms > 0) {
297
+ returnFrom.value = viewEnd.value;
298
+ cancelAnimation(viewEnd);
299
+ viewEnd.value = null;
300
+ returnT.value = 0;
301
+ returnT.value = withTiming(1, {
302
+ duration: ms,
303
+ easing: Easing.out(Easing.cubic),
304
+ });
305
+ } else {
306
+ cancelAnimation(viewEnd);
307
+ viewEnd.value = null;
308
+ }
309
+ }
310
+ },
311
+ );
312
+
249
313
  return {
250
314
  data,
251
315
  value,