react-native-livechart 4.2.1 → 4.4.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 (39) 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/components/MarkerOverlay.d.ts.map +1 -1
  4. package/dist/constants.d.ts +6 -0
  5. package/dist/constants.d.ts.map +1 -1
  6. package/dist/core/liveChartEngineTick.d.ts +11 -0
  7. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  8. package/dist/core/liveChartSeriesEngineTick.d.ts +10 -0
  9. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  10. package/dist/core/resolveConfig.d.ts +11 -1
  11. package/dist/core/resolveConfig.d.ts.map +1 -1
  12. package/dist/core/useLiveChartEngine.d.ts +18 -0
  13. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  14. package/dist/core/useLiveChartSeriesEngine.d.ts +17 -0
  15. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  16. package/dist/draw/markerAtlas.d.ts +9 -2
  17. package/dist/draw/markerAtlas.d.ts.map +1 -1
  18. package/dist/hooks/useMarkers.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/math/markerCluster.d.ts +8 -1
  22. package/dist/math/markerCluster.d.ts.map +1 -1
  23. package/dist/types.d.ts +82 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/components/LiveChart.tsx +13 -2
  27. package/src/components/LiveChartSeries.tsx +7 -0
  28. package/src/components/MarkerOverlay.tsx +135 -37
  29. package/src/constants.ts +7 -0
  30. package/src/core/liveChartEngineTick.ts +42 -7
  31. package/src/core/liveChartSeriesEngineTick.ts +37 -5
  32. package/src/core/resolveConfig.ts +25 -0
  33. package/src/core/useLiveChartEngine.ts +71 -1
  34. package/src/core/useLiveChartSeriesEngine.ts +65 -1
  35. package/src/draw/markerAtlas.ts +30 -1
  36. package/src/hooks/useMarkers.ts +2 -0
  37. package/src/index.ts +2 -0
  38. package/src/math/markerCluster.ts +8 -1
  39. package/src/types.ts +84 -0
package/package.json CHANGED
@@ -67,5 +67,5 @@
67
67
  },
68
68
  "sideEffects": false,
69
69
  "types": "./dist/index.d.ts",
70
- "version": "4.2.1"
70
+ "version": "4.4.0"
71
71
  }
@@ -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
  },
@@ -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,
@@ -4,6 +4,8 @@ import {
4
4
  Path,
5
5
  Skia,
6
6
  type SkFont,
7
+ type SkRect,
8
+ type SkRSXform,
7
9
  } from "@shopify/react-native-skia";
8
10
  import { useMemo, useRef, useState } from "react";
9
11
  import { PixelRatio } from "react-native";
@@ -20,6 +22,7 @@ import {
20
22
  BADGE_TEXT_SCALE,
21
23
  buildMarkerAtlas,
22
24
  defaultMarkerColor,
25
+ GROUP_BADGE_SIG,
23
26
  groupBgSig,
24
27
  groupCountText,
25
28
  groupGlyphSig,
@@ -47,6 +50,77 @@ import type {
47
50
 
48
51
  const OFF = -9999;
49
52
 
53
+ /** Size of the `showGroupCount` corner badge relative to a full count badge. */
54
+ const GROUP_CORNER_SCALE = 0.62;
55
+ /** How far the corner count badge sits toward the glyph's top-right corner
56
+ * (1 = centered exactly on the corner; < 1 pulls it inward). */
57
+ const GROUP_CORNER_INSET = 0.82;
58
+
59
+ /**
60
+ * Append a collapsed-cluster count badge (round bg + centered, proportionally
61
+ * kerned digits) to the atlas blit lists at `(cx, cy)`, scaled by `mul` — full
62
+ * size as the default group badge, shrunk in a glyph's corner for `showGroupCount`.
63
+ *
64
+ * A **module-level** worklet (not a closure inside the per-frame derived value):
65
+ * the `transforms` / `sprites` arrays are passed in by reference so the pushes land
66
+ * on the real per-frame lists. A nested `"worklet"` closure would capture them by
67
+ * serialized copy, so its pushes would be silently dropped and the badge never drawn.
68
+ */
69
+ function pushGroupCountBadge(
70
+ transforms: SkRSXform[],
71
+ sprites: SkRect[],
72
+ cells: Record<string, AtlasCell>,
73
+ digitWidths: Record<string, number>,
74
+ invScale: number,
75
+ cx: number,
76
+ cy: number,
77
+ repColor: string,
78
+ count: number,
79
+ mul: number,
80
+ ): void {
81
+ "worklet";
82
+ const text = groupCountText(count);
83
+ const bg = cells[groupBgSig(repColor)];
84
+ if (bg) {
85
+ transforms.push(
86
+ Skia.RSXform(invScale * mul, 0, cx - (bg.w * mul) / 2, cy - (bg.h * mul) / 2),
87
+ );
88
+ sprites.push(bg.rect);
89
+ }
90
+ // Lay digits out proportionally (each by its own ink width), scaled to fit the
91
+ // small round badge, centered. A negative kern (fraction of a digit) closes the
92
+ // font's side-bearing gap so "12" reads tight, not "1 2".
93
+ const S = BADGE_TEXT_SCALE;
94
+ let maxDW = 1;
95
+ for (let d = 0; d < 10; d++) {
96
+ const dw = digitWidths["" + d] ?? 0;
97
+ if (dw > maxDW) maxDW = dw;
98
+ }
99
+ const kern = -maxDW * S * 0.42;
100
+ let totalW = 0;
101
+ for (let c = 0; c < text.length; c++) {
102
+ totalW += (digitWidths[text[c]] ?? 0) * S + (c > 0 ? kern : 0);
103
+ }
104
+ let dx = cx - (totalW * mul) / 2;
105
+ for (let c = 0; c < text.length; c++) {
106
+ const w = (digitWidths[text[c]] ?? 0) * S * mul;
107
+ const gc = cells[groupGlyphSig(text[c])];
108
+ if (gc) {
109
+ const gcx = dx + w / 2;
110
+ transforms.push(
111
+ Skia.RSXform(
112
+ invScale * S * mul,
113
+ 0,
114
+ gcx - (gc.w * S * mul) / 2,
115
+ cy - (gc.h * S * mul) / 2,
116
+ ),
117
+ );
118
+ sprites.push(gc.rect);
119
+ }
120
+ dx += w + kern * mul;
121
+ }
122
+ }
123
+
50
124
  /**
51
125
  * Self-projecting fallback for the axis-anchored kinds (graduation stem + flag,
52
126
  * clawback box) that can't be fixed-size atlas sprites. Rare, so the per-glyph
@@ -246,6 +320,14 @@ export function MarkerOverlay({
246
320
  // retina canvases instead of being upscaled from a logical-sized texture.
247
321
  const dpr = PixelRatio.get();
248
322
  const clusterStacked = cluster.mode === "stacked";
323
+ // A dedicated group badge (object form) bakes one extra atlas cell; its image
324
+ // identity + styling drive the rebuild key alongside the marker appearances.
325
+ const groupBadgeCfg =
326
+ typeof cluster.groupBadge === "object" ? cluster.groupBadge : undefined;
327
+ const groupBadgeImage = groupBadgeCfg?.image;
328
+ const groupBadgeKey = groupBadgeCfg
329
+ ? `${groupBadgeCfg.icon ?? ""}|${groupBadgeCfg.color ?? ""}|${groupBadgeCfg.pill ? 1 : 0}|${groupBadgeCfg.size ?? ""}`
330
+ : "";
249
331
  const atlas = useMemo(
250
332
  () =>
251
333
  buildMarkerAtlas(
@@ -254,9 +336,10 @@ export function MarkerOverlay({
254
336
  font,
255
337
  dpr,
256
338
  clusterStacked,
339
+ groupBadgeCfg,
257
340
  ),
258
- // eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey capture the inputs that change cell pixels
259
- [appearanceKey, paletteKey, font, dpr, clusterStacked],
341
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey/groupBadgeKey capture the inputs that change cell pixels
342
+ [appearanceKey, paletteKey, font, dpr, clusterStacked, groupBadgeKey, groupBadgeImage],
260
343
  );
261
344
  const cells: Record<string, AtlasCell> = atlas.cells;
262
345
  const atlasImage = atlas.image;
@@ -301,8 +384,8 @@ export function MarkerOverlay({
301
384
  lineLinear,
302
385
  });
303
386
  clusterMarkers(ms, buf, { config: cluster });
304
- const transforms = [];
305
- const sprites = [];
387
+ const transforms: SkRSXform[] = [];
388
+ const sprites: SkRect[] = [];
306
389
  for (let i = 0; i < ms.length; i++) {
307
390
  const pt = buf[i];
308
391
  // Collapsed-cluster members fold into their representative's badge.
@@ -311,46 +394,61 @@ export function MarkerOverlay({
311
394
  if (isConnectorMarker(m)) continue;
312
395
  // Custom-rendered markers are floated as RN views, not drawn here.
313
396
  if (customIds[m.id]) continue;
314
- // Collapsed cluster: draw a count badge (bg stadium + composed digits)
315
- // instead of the marker's own glyph, all within this one `drawAtlas`.
397
+ // Collapsed cluster. By default a round count badge; with
398
+ // `groupBadge: "marker"` the representative marker's own glyph, or with a
399
+ // `MarkerGroupBadge` object a dedicated baked badge — either optionally
400
+ // carrying a corner count, all within this one `drawAtlas`.
316
401
  if (pt.isGrouped) {
317
402
  const repColor = m.color ?? defaultMarkerColor(m.kind, palette);
318
- const text = groupCountText(pt.groupCount);
319
- const bg = cells[groupBgSig(repColor)];
320
- if (bg) {
403
+ const gb = cluster.groupBadge;
404
+ // Pick the non-count glyph cell: a dedicated group badge (object form)
405
+ // or the representative marker's own appearance.
406
+ const glyphCell =
407
+ typeof gb === "object" && gb !== null
408
+ ? cells[GROUP_BADGE_SIG]
409
+ : gb === "marker"
410
+ ? cells[markerAppearanceSig(m)]
411
+ : undefined;
412
+ if (glyphCell) {
321
413
  transforms.push(
322
- Skia.RSXform(invScale, 0, pt.x - bg.w / 2, pt.y - bg.h / 2),
414
+ Skia.RSXform(
415
+ invScale,
416
+ 0,
417
+ pt.x - glyphCell.w / 2,
418
+ pt.y - glyphCell.h / 2,
419
+ ),
323
420
  );
324
- sprites.push(bg.rect);
325
- }
326
- // Lay the digits out proportionally (each by its own ink width), scaled
327
- // down to fit the small round badge, and center the whole number. A
328
- // negative kern (fraction of a digit) closes the font's side-bearing gap
329
- // so "12" reads tight, not "1 2".
330
- const S = BADGE_TEXT_SCALE;
331
- let maxDW = 1;
332
- for (let d = 0; d < 10; d++) {
333
- const dw = digitWidths["" + d] ?? 0;
334
- if (dw > maxDW) maxDW = dw;
335
- }
336
- const kern = -maxDW * S * 0.42;
337
- let totalW = 0;
338
- for (let c = 0; c < text.length; c++) {
339
- totalW += (digitWidths[text[c]] ?? 0) * S + (c > 0 ? kern : 0);
340
- }
341
- let dx = pt.x - totalW / 2;
342
- for (let c = 0; c < text.length; c++) {
343
- const w = (digitWidths[text[c]] ?? 0) * S;
344
- const gc = cells[groupGlyphSig(text[c])];
345
- if (gc) {
346
- const cx = dx + w / 2;
347
- transforms.push(
348
- Skia.RSXform(invScale * S, 0, cx - (gc.w * S) / 2, pt.y - (gc.h * S) / 2),
421
+ sprites.push(glyphCell.rect);
422
+ if (cluster.showGroupCount) {
423
+ pushGroupCountBadge(
424
+ transforms,
425
+ sprites,
426
+ cells,
427
+ digitWidths,
428
+ invScale,
429
+ pt.x + (glyphCell.w / 2) * GROUP_CORNER_INSET,
430
+ pt.y - (glyphCell.h / 2) * GROUP_CORNER_INSET,
431
+ repColor,
432
+ pt.groupCount,
433
+ GROUP_CORNER_SCALE,
349
434
  );
350
- sprites.push(gc.rect);
351
435
  }
352
- dx += w + kern;
436
+ continue;
353
437
  }
438
+ // Default — or fall back here when a non-count badge has no baked cell
439
+ // (e.g. an empty group-badge config): the round count badge.
440
+ pushGroupCountBadge(
441
+ transforms,
442
+ sprites,
443
+ cells,
444
+ digitWidths,
445
+ invScale,
446
+ pt.x,
447
+ pt.y,
448
+ repColor,
449
+ pt.groupCount,
450
+ 1,
451
+ );
354
452
  continue;
355
453
  }
356
454
  const cell = cells[markerAppearanceSig(m)];
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,
@@ -613,6 +634,8 @@ export function resolveMarkerCluster(
613
634
  overlap: clamp01(prop.overlap ?? MARKER_CLUSTER_OVERLAP),
614
635
  gap: MARKER_CLUSTER_GAP,
615
636
  maxBeforeGroup: prop.maxBeforeGroup ?? MARKER_CLUSTER_MAX_BEFORE_GROUP,
637
+ groupBadge: prop.groupBadge ?? "count",
638
+ showGroupCount: prop.showGroupCount ?? false,
616
639
  };
617
640
  }
618
641
  return {
@@ -621,6 +644,8 @@ export function resolveMarkerCluster(
621
644
  overlap: MARKER_CLUSTER_OVERLAP,
622
645
  gap: MARKER_CLUSTER_GAP,
623
646
  maxBeforeGroup: MARKER_CLUSTER_MAX_BEFORE_GROUP,
647
+ groupBadge: "count",
648
+ showGroupCount: false,
624
649
  };
625
650
  }
626
651
 
@@ -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