react-native-livechart 3.11.0 → 4.0.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 (138) hide show
  1. package/dist/components/BadgeOverlay.d.ts +9 -1
  2. package/dist/components/BadgeOverlay.d.ts.map +1 -1
  3. package/dist/components/ChartOverlayLayer.d.ts +19 -0
  4. package/dist/components/ChartOverlayLayer.d.ts.map +1 -0
  5. package/dist/components/CustomMarkerOverlay.d.ts +18 -6
  6. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomReferenceLineOverlay.d.ts +36 -0
  8. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -0
  9. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  10. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  11. package/dist/components/DotOverlay.d.ts +7 -1
  12. package/dist/components/DotOverlay.d.ts.map +1 -1
  13. package/dist/components/LiveChart.d.ts.map +1 -1
  14. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  15. package/dist/components/MarkerOverlay.d.ts +8 -3
  16. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  17. package/dist/components/MultiSeriesDots.d.ts +4 -1
  18. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  19. package/dist/components/ReferenceLineGroupOverlay.d.ts +27 -0
  20. package/dist/components/ReferenceLineGroupOverlay.d.ts.map +1 -0
  21. package/dist/components/ReferenceLineOverlay.d.ts +25 -2
  22. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  23. package/dist/components/XAxisOverlay.d.ts +7 -1
  24. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  25. package/dist/components/YAxisOverlay.d.ts +12 -1
  26. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  27. package/dist/constants.d.ts +7 -0
  28. package/dist/constants.d.ts.map +1 -1
  29. package/dist/core/liveChartEngineTick.d.ts +29 -0
  30. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  31. package/dist/core/liveChartSeriesEngineTick.d.ts +18 -0
  32. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  33. package/dist/core/resolveConfig.d.ts +56 -2
  34. package/dist/core/resolveConfig.d.ts.map +1 -1
  35. package/dist/core/useLiveChartEngine.d.ts +58 -7
  36. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  37. package/dist/core/useLiveChartSeriesEngine.d.ts +8 -2
  38. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  39. package/dist/draw/grid.d.ts.map +1 -1
  40. package/dist/draw/markerAtlas.d.ts +20 -2
  41. package/dist/draw/markerAtlas.d.ts.map +1 -1
  42. package/dist/draw/volume.d.ts +25 -0
  43. package/dist/draw/volume.d.ts.map +1 -0
  44. package/dist/hooks/crosshairShared.d.ts +12 -6
  45. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  46. package/dist/hooks/overlayScale.d.ts +22 -0
  47. package/dist/hooks/overlayScale.d.ts.map +1 -0
  48. package/dist/hooks/resolveChartLayout.d.ts +9 -0
  49. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  50. package/dist/hooks/useBadge.d.ts +22 -1
  51. package/dist/hooks/useBadge.d.ts.map +1 -1
  52. package/dist/hooks/useCandlePaths.d.ts +6 -0
  53. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  54. package/dist/hooks/useChartOverlayContext.d.ts +45 -0
  55. package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
  56. package/dist/hooks/useChartPaths.d.ts +8 -1
  57. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  58. package/dist/hooks/useCrosshair.d.ts +9 -2
  59. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  60. package/dist/hooks/useLiveDot.d.ts +6 -1
  61. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  62. package/dist/hooks/useMarkers.d.ts +12 -4
  63. package/dist/hooks/useMarkers.d.ts.map +1 -1
  64. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  65. package/dist/hooks/usePanScroll.d.ts +82 -0
  66. package/dist/hooks/usePanScroll.d.ts.map +1 -0
  67. package/dist/hooks/usePinchZoom.d.ts +74 -0
  68. package/dist/hooks/usePinchZoom.d.ts.map +1 -0
  69. package/dist/hooks/useReferenceDrag.d.ts +24 -0
  70. package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
  71. package/dist/hooks/useReferenceLine.d.ts +23 -4
  72. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  73. package/dist/hooks/useReferenceLinePress.d.ts +5 -1
  74. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
  75. package/dist/hooks/useVisibleRange.d.ts +39 -0
  76. package/dist/hooks/useVisibleRange.d.ts.map +1 -0
  77. package/dist/index.d.ts +2 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/math/markerCluster.d.ts +47 -0
  80. package/dist/math/markerCluster.d.ts.map +1 -0
  81. package/dist/math/markers.d.ts +15 -0
  82. package/dist/math/markers.d.ts.map +1 -1
  83. package/dist/math/referenceDrag.d.ts +24 -0
  84. package/dist/math/referenceDrag.d.ts.map +1 -0
  85. package/dist/math/referenceGroup.d.ts +31 -0
  86. package/dist/math/referenceGroup.d.ts.map +1 -0
  87. package/dist/math/referenceLines.d.ts +33 -7
  88. package/dist/math/referenceLines.d.ts.map +1 -1
  89. package/dist/types.d.ts +512 -21
  90. package/dist/types.d.ts.map +1 -1
  91. package/package.json +1 -1
  92. package/src/components/BadgeOverlay.tsx +26 -1
  93. package/src/components/ChartOverlayLayer.tsx +32 -0
  94. package/src/components/CustomMarkerOverlay.tsx +153 -53
  95. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  96. package/src/components/CustomTooltipOverlay.tsx +27 -7
  97. package/src/components/DotOverlay.tsx +9 -0
  98. package/src/components/LiveChart.tsx +561 -34
  99. package/src/components/LiveChartSeries.tsx +110 -5
  100. package/src/components/MarkerOverlay.tsx +100 -6
  101. package/src/components/MultiSeriesDots.tsx +12 -3
  102. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  103. package/src/components/ReferenceLineOverlay.tsx +105 -32
  104. package/src/components/XAxisOverlay.tsx +9 -2
  105. package/src/components/YAxisOverlay.tsx +48 -26
  106. package/src/constants.ts +10 -0
  107. package/src/core/liveChartEngineTick.ts +81 -8
  108. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  109. package/src/core/resolveConfig.ts +122 -1
  110. package/src/core/useLiveChartEngine.ts +103 -10
  111. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  112. package/src/draw/grid.ts +13 -1
  113. package/src/draw/markerAtlas.ts +117 -3
  114. package/src/draw/volume.ts +190 -0
  115. package/src/hooks/crosshairShared.ts +37 -9
  116. package/src/hooks/overlayScale.ts +59 -0
  117. package/src/hooks/resolveChartLayout.ts +33 -0
  118. package/src/hooks/useBadge.ts +89 -21
  119. package/src/hooks/useCandlePaths.ts +104 -7
  120. package/src/hooks/useChartOverlayContext.ts +93 -0
  121. package/src/hooks/useChartPaths.ts +14 -1
  122. package/src/hooks/useCrosshair.ts +29 -2
  123. package/src/hooks/useLiveDot.ts +16 -5
  124. package/src/hooks/useMarkers.ts +43 -11
  125. package/src/hooks/useMultiSeriesLinePaths.ts +2 -1
  126. package/src/hooks/usePanScroll.ts +253 -0
  127. package/src/hooks/usePinchZoom.ts +189 -0
  128. package/src/hooks/useReferenceDrag.ts +254 -0
  129. package/src/hooks/useReferenceLine.ts +56 -9
  130. package/src/hooks/useReferenceLinePress.ts +16 -2
  131. package/src/hooks/useVisibleRange.ts +119 -0
  132. package/src/index.ts +17 -1
  133. package/src/math/markerCluster.ts +214 -0
  134. package/src/math/markers.ts +54 -9
  135. package/src/math/referenceDrag.ts +66 -0
  136. package/src/math/referenceGroup.ts +65 -0
  137. package/src/math/referenceLines.ts +76 -19
  138. package/src/types.ts +532 -21
@@ -9,6 +9,20 @@ export interface EngineTickMutable {
9
9
  displayMax: number;
10
10
  displayWindow: number;
11
11
  timestamp: number;
12
+ /**
13
+ * The right-edge time the engine would use if it were following live —
14
+ * `now (+ windowBuffer)`. Equals {@link timestamp} while following; when
15
+ * scrolled back in time (see {@link EngineTickInput.viewEnd}) it keeps
16
+ * advancing while `timestamp` stays frozen. Exposed so the pan-scroll gesture
17
+ * can clamp against the live edge and detect catch-up.
18
+ */
19
+ liveEdge: number;
20
+ /**
21
+ * Smoothed value at the visible window's right edge: the live value while
22
+ * following, or the price at `viewEnd` while scrolled back. Lets a badge track
23
+ * the last visible price as you pan (see `badge.followViewEdge`).
24
+ */
25
+ edgeValue: number;
12
26
  /**
13
27
  * Value + time of the lowest / highest data point in the visible window —
14
28
  * the actual extrema, NOT the smoothed display bounds (which carry margin and
@@ -48,6 +62,21 @@ export interface EngineTickInput {
48
62
  windowBuffer?: number;
49
63
  /** When true, freeze the viewport timestamp and skip displayWindow lerp */
50
64
  paused?: boolean;
65
+ /**
66
+ * Absolute right-edge time (unix seconds) to freeze the window at, or
67
+ * `null`/`undefined` to follow the live edge. Set by the pan-scroll gesture
68
+ * to scroll back in time; once it reaches (or passes) the live edge the engine
69
+ * resumes following. Takes precedence over {@link paused}.
70
+ */
71
+ viewEnd?: number | null;
72
+ /**
73
+ * Absolute visible-window width (seconds) to freeze at, or `null`/`undefined`
74
+ * to follow the configured {@link timeWindow}. Set by the pinch-zoom gesture
75
+ * (see `usePinchZoom` / the `zoom` prop). The symmetric counterpart of
76
+ * {@link viewEnd}: `viewEnd` overrides the window's right edge, `viewWindow`
77
+ * overrides its width. `displayWindow` eases toward this when set.
78
+ */
79
+ viewWindow?: number | null;
51
80
  /** Chart mode — `"candle"` uses OHLC bars for Y range instead of line points. */
52
81
  mode?: "line" | "candle";
53
82
  /** Committed OHLC bars (sorted by time). Used when mode is `"candle"`. */
@@ -66,9 +95,18 @@ export function tickLiveChartEngineFrame(
66
95
  ): void {
67
96
  "worklet";
68
97
  const baseNow = input.nowOverride ?? input.nowSeconds ?? Date.now() / 1000;
69
- if (!input.paused) {
70
- state.timestamp = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
98
+ const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
99
+ state.liveEdge = liveEdge;
100
+ 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).
104
+ state.timestamp = viewEnd;
105
+ } else if (!input.paused) {
106
+ // Following the live edge — viewEnd is null/undefined or has caught back up.
107
+ state.timestamp = liveEdge;
71
108
  }
109
+ // else: paused with no active pan → leave the frozen timestamp untouched.
72
110
 
73
111
  if (input.canvasWidth === 0 || input.canvasHeight === 0) return;
74
112
 
@@ -93,12 +131,10 @@ export function tickLiveChartEngineFrame(
93
131
  input.dt,
94
132
  );
95
133
 
96
- state.displayWindow = lerp(
97
- state.displayWindow,
98
- input.timeWindow,
99
- speed,
100
- input.dt,
101
- );
134
+ // Pinch-zoom: ease toward the zoom override when set, else the configured
135
+ // window. Mirrors the viewEnd freeze above (width vs. right edge).
136
+ const targetWindow = input.viewWindow ?? input.timeWindow;
137
+ state.displayWindow = lerp(state.displayWindow, targetWindow, speed, input.dt);
102
138
 
103
139
  const winStart = state.timestamp - state.displayWindow;
104
140
 
@@ -232,4 +268,41 @@ export function tickLiveChartEngineFrame(
232
268
  state.displayMax = lerp(state.displayMax, tMax, speed, input.dt);
233
269
  }
234
270
  }
271
+
272
+ // Edge value: the price at the visible window's right edge. Following live →
273
+ // track the live value (badge unchanged). Scrolled back → track the last
274
+ // 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;
277
+ if (!scrolledBack) {
278
+ state.edgeValue = state.displayValue;
279
+ } else {
280
+ let edgeTarget = state.displayValue;
281
+ if (input.mode === "candle") {
282
+ const cs = input.candles;
283
+ if (cs && cs.length > 0) {
284
+ let elo = 0;
285
+ let ehi = cs.length;
286
+ while (elo < ehi) {
287
+ const m = (elo + ehi) >> 1;
288
+ if (cs[m].time <= state.timestamp) elo = m + 1;
289
+ else ehi = m;
290
+ }
291
+ if (elo > 0) edgeTarget = cs[elo - 1].close;
292
+ }
293
+ const lc = input.liveCandle;
294
+ if (lc && lc.time <= state.timestamp) edgeTarget = lc.close;
295
+ } else {
296
+ const pts = input.points;
297
+ let elo = 0;
298
+ let ehi = pts.length;
299
+ while (elo < ehi) {
300
+ const m = (elo + ehi) >> 1;
301
+ if (pts[m].time <= state.timestamp) elo = m + 1;
302
+ else ehi = m;
303
+ }
304
+ if (elo > 0) edgeTarget = pts[elo - 1].value;
305
+ }
306
+ state.edgeValue = lerp(state.edgeValue, edgeTarget, speed, input.dt);
307
+ }
235
308
  }
@@ -7,6 +7,12 @@ export interface MultiEngineTickMutable {
7
7
  displayMax: number;
8
8
  displayWindow: number;
9
9
  timestamp: number;
10
+ /**
11
+ * The right-edge time the engine would use if following live (`now (+ buffer)`).
12
+ * Equals {@link timestamp} while following; keeps advancing while `timestamp`
13
+ * stays frozen when scrolled back (see {@link MultiEngineTickInput.viewEnd}).
14
+ */
15
+ liveEdge: number;
10
16
  displayValues: number[];
11
17
  opacities: number[];
12
18
  /**
@@ -44,6 +50,18 @@ export interface MultiEngineTickInput {
44
50
  /** Right-edge buffer as a fraction of the time window. */
45
51
  windowBuffer?: number;
46
52
  paused?: boolean;
53
+ /**
54
+ * Absolute right-edge time (unix seconds) to freeze the window at, or
55
+ * `null`/`undefined` to follow the live edge. Drives pan-scroll; takes
56
+ * precedence over {@link paused}.
57
+ */
58
+ viewEnd?: number | null;
59
+ /**
60
+ * Absolute visible-window width (seconds) to freeze at, or `null`/`undefined`
61
+ * to follow {@link timeWindow}. Drives pinch-zoom (see `usePinchZoom`); the
62
+ * symmetric counterpart of {@link viewEnd}.
63
+ */
64
+ viewWindow?: number | null;
47
65
  }
48
66
 
49
67
  /**
@@ -57,9 +75,17 @@ export function tickLiveChartSeriesEngineFrame(
57
75
  ): void {
58
76
  "worklet";
59
77
  const baseNow = input.nowOverride ?? input.nowSeconds ?? Date.now() / 1000;
60
- if (!input.paused) {
61
- state.timestamp = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
78
+ const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
79
+ state.liveEdge = liveEdge;
80
+ const viewEnd = input.viewEnd;
81
+ if (viewEnd != null && viewEnd < liveEdge) {
82
+ // Scrolled back in time: freeze the right edge (see usePanScroll).
83
+ state.timestamp = viewEnd;
84
+ } else if (!input.paused) {
85
+ // Following the live edge — viewEnd is null/undefined or has caught back up.
86
+ state.timestamp = liveEdge;
62
87
  }
88
+ // else: paused with no active pan → leave the frozen timestamp untouched.
63
89
 
64
90
  if (input.canvasWidth === 0 || input.canvasHeight === 0) return;
65
91
 
@@ -77,18 +103,33 @@ export function tickLiveChartSeriesEngineFrame(
77
103
  state.opacities.length = n;
78
104
  }
79
105
 
80
- state.displayWindow = lerp(
81
- state.displayWindow,
82
- input.timeWindow,
83
- speed,
84
- input.dt,
85
- );
106
+ // Pinch-zoom: ease toward the zoom override when set, else the configured
107
+ // window (mirrors the single-series tick).
108
+ const targetWindow = input.viewWindow ?? input.timeWindow;
109
+ state.displayWindow = lerp(state.displayWindow, targetWindow, speed, input.dt);
86
110
 
87
111
  const winStart = state.timestamp - state.displayWindow;
88
112
  const range = state.displayMax - state.displayMin;
89
113
 
114
+ // Scrolled back in time (pan/zoom): the per-series tips/dots sit at the
115
+ // window's right edge, so they must track each series' value AT that edge
116
+ // (`timestamp`), not the live value — otherwise the dot floats at the current
117
+ // price while the line ends in the past. Mirrors single-series `edgeValue`.
118
+ const scrolledBack = viewEnd != null && viewEnd < liveEdge;
119
+
90
120
  for (let i = 0; i < n; i++) {
91
- const target = series[i].value;
121
+ let target = series[i].value;
122
+ if (scrolledBack) {
123
+ const pts = series[i].data;
124
+ let elo = 0;
125
+ let ehi = pts.length;
126
+ while (elo < ehi) {
127
+ const m = (elo + ehi) >> 1;
128
+ if (pts[m].time <= state.timestamp) elo = m + 1;
129
+ else ehi = m;
130
+ }
131
+ if (elo > 0) target = pts[elo - 1].value;
132
+ }
92
133
  const cur = state.displayValues[i];
93
134
  const gapRatio =
94
135
  range > 0 ? Math.min(Math.abs(target - cur) / range, 1) : 0;
@@ -14,6 +14,7 @@ import type {
14
14
  LineStyleConfig,
15
15
  LiveChartMetrics,
16
16
  LiveChartMetricsOverride,
17
+ MarkerClusterConfig,
17
18
  DotConfig,
18
19
  DotRingConfig,
19
20
  MultiSeriesDotConfig,
@@ -28,12 +29,15 @@ import type {
28
29
  ThresholdLineConfig,
29
30
  TradeEvent,
30
31
  ValueLineConfig,
32
+ VolumeConfig,
31
33
  XAxisConfig,
32
34
  YAxisConfig,
35
+ ZoomConfig,
33
36
  } from "../types";
34
37
 
35
38
  import type { ComponentType, ReactElement } from "react";
36
39
  import type { SharedValue } from "react-native-reanimated";
40
+ import type { ResolvedMarkerCluster } from "../math/markerCluster";
37
41
  import {
38
42
  BADGE_METRICS_DEFAULTS,
39
43
  CANDLE_METRICS_DEFAULTS,
@@ -57,10 +61,39 @@ export interface ResolvedBadgeConfig {
57
61
  tail: boolean;
58
62
  position: "right" | "left";
59
63
  background: string | undefined;
64
+ /** undefined → capsule (pillHeight / 2) at render time */
65
+ radius: number | undefined;
66
+ /** undefined → no border */
67
+ borderColor: string | undefined;
68
+ borderWidth: number;
69
+ /** undefined → variant/theme text color at render time */
70
+ textColor: string | undefined;
71
+ fontSize: number | undefined;
72
+ fontFamily: string | undefined;
73
+ fontWeight: FontWeight | undefined;
74
+ offsetX: number;
75
+ offsetY: number;
76
+ /** Track the visible window's right-edge price while scrolled back. */
77
+ followViewEdge: boolean;
60
78
  }
61
79
 
62
80
  export interface ResolvedYAxisConfig {
63
81
  minGap: number;
82
+ /** Float the axis over a full-width plot (no reserved right gutter). */
83
+ float: boolean;
84
+ }
85
+
86
+ export interface ResolvedVolumeConfig {
87
+ /** undefined → use palette.candleUp at render time. */
88
+ upColor: string | undefined;
89
+ /** undefined → use palette.candleDown at render time. */
90
+ downColor: string | undefined;
91
+ /** Reserved band height (px) — the tallest a bar can be. */
92
+ maxHeight: number;
93
+ /** Corner radius (px) of bar tops. */
94
+ radius: number;
95
+ /** Opacity (0..1) applied to the whole band. */
96
+ opacity: number;
64
97
  }
65
98
 
66
99
  /** Resolved straight-line styling (connector, etc.). `color: undefined` → caller default. */
@@ -119,7 +152,7 @@ export interface ResolvedScrubConfig {
119
152
  /** Tooltip pill corner radius in px. */
120
153
  tooltipBorderRadius: number;
121
154
  /** Where the tooltip pill sits relative to the scrub line. */
122
- tooltipPlacement: "side" | "top" | "bottom";
155
+ tooltipPlacement: "side" | "top" | "bottom" | "point";
123
156
  /** Gap (px) between the tooltip and the plot edge it's pinned to. */
124
157
  tooltipMargin: number;
125
158
  /** Show the value row in the default tooltip body. */
@@ -336,6 +369,16 @@ const BADGE_DEFAULTS: ResolvedBadgeConfig = {
336
369
  tail: true,
337
370
  position: "right",
338
371
  background: undefined,
372
+ radius: undefined,
373
+ borderColor: undefined,
374
+ borderWidth: 1,
375
+ textColor: undefined,
376
+ fontSize: undefined,
377
+ fontFamily: undefined,
378
+ fontWeight: undefined,
379
+ offsetX: 0,
380
+ offsetY: 0,
381
+ followViewEdge: false,
339
382
  };
340
383
 
341
384
  /**
@@ -350,6 +393,7 @@ export function resolveBadge(
350
393
 
351
394
  const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
352
395
  minGap: 36,
396
+ float: false,
353
397
  };
354
398
 
355
399
  /**
@@ -362,6 +406,47 @@ export function resolveYAxis(
362
406
  return resolveToggle(prop, Y_AXIS_DEFAULTS, false);
363
407
  }
364
408
 
409
+ const VOLUME_DEFAULTS: ResolvedVolumeConfig = {
410
+ upColor: undefined,
411
+ downColor: undefined,
412
+ maxHeight: 48,
413
+ radius: 2,
414
+ opacity: 0.6,
415
+ };
416
+
417
+ /**
418
+ * Resolves the `volume` prop to a fully-typed config or null (disabled).
419
+ * `true` → defaults, object → merged with defaults, falsy → null. Colors left
420
+ * `undefined` fall back to the candle palette at render time.
421
+ */
422
+ export function resolveVolume(
423
+ prop: boolean | VolumeConfig | undefined,
424
+ ): ResolvedVolumeConfig | null {
425
+ return resolveToggle(prop, VOLUME_DEFAULTS, false);
426
+ }
427
+
428
+ export interface ResolvedZoomConfig {
429
+ /** Tightest window (max zoom-in), seconds. `undefined` → `timeWindow / 8`. */
430
+ minTimeWindow: number | undefined;
431
+ /** Widest window (max zoom-out), seconds. `undefined` → full data span. */
432
+ maxTimeWindow: number | undefined;
433
+ }
434
+
435
+ const ZOOM_DEFAULTS: ResolvedZoomConfig = {
436
+ minTimeWindow: undefined,
437
+ maxTimeWindow: undefined,
438
+ };
439
+
440
+ /**
441
+ * Resolves the `zoom` prop to a config or null (disabled). `true` → defaults
442
+ * (bounds derived at gesture time), object → merged, falsy → null.
443
+ */
444
+ export function resolveZoom(
445
+ prop: boolean | ZoomConfig | undefined,
446
+ ): ResolvedZoomConfig | null {
447
+ return resolveToggle(prop, ZOOM_DEFAULTS, false);
448
+ }
449
+
365
450
  const AXIS_LABEL_DEFAULTS: ResolvedAxisLabelConfig = {
366
451
  format: undefined,
367
452
  color: undefined,
@@ -496,6 +581,42 @@ export function resolveScrubAction(
496
581
  return resolveToggle(prop, SCRUB_ACTION_DEFAULTS, false);
497
582
  }
498
583
 
584
+ /** Spacing + collapse-threshold defaults for `markerCluster: "stacked"`. */
585
+ const MARKER_CLUSTER_GAP = 2;
586
+ const MARKER_CLUSTER_MAX_BEFORE_GROUP = 5;
587
+ const MARKER_CLUSTER_OVERLAP = 0.75;
588
+
589
+ /**
590
+ * Resolves the `markerCluster` prop (a `"anchored"`/`"stacked"` shorthand or a
591
+ * {@link MarkerClusterConfig} object) to a full config. Always returns a config
592
+ * (never null) — `"anchored"` (the default) is a valid mode the cluster pass
593
+ * treats as "side offsets only, no bucketing". The object form implies
594
+ * `"stacked"` unless `mode` is set.
595
+ */
596
+ export function resolveMarkerCluster(
597
+ prop: "anchored" | "stacked" | MarkerClusterConfig | undefined,
598
+ ): ResolvedMarkerCluster {
599
+ if (typeof prop === "object") {
600
+ return {
601
+ mode: prop.mode ?? "stacked",
602
+ overlap: clamp01(prop.overlap ?? MARKER_CLUSTER_OVERLAP),
603
+ gap: MARKER_CLUSTER_GAP,
604
+ maxBeforeGroup: prop.maxBeforeGroup ?? MARKER_CLUSTER_MAX_BEFORE_GROUP,
605
+ };
606
+ }
607
+ return {
608
+ mode: prop === "stacked" ? "stacked" : "anchored",
609
+ overlap: MARKER_CLUSTER_OVERLAP,
610
+ gap: MARKER_CLUSTER_GAP,
611
+ maxBeforeGroup: MARKER_CLUSTER_MAX_BEFORE_GROUP,
612
+ };
613
+ }
614
+
615
+ /** Clamp to [0, 0.95] so a glyph never fully covers its neighbor (overlap 1). */
616
+ function clamp01(v: number): number {
617
+ return v < 0 ? 0 : v > 0.95 ? 0.95 : v;
618
+ }
619
+
499
620
  const GRADIENT_DEFAULTS: ResolvedGradientConfig = {
500
621
  topOpacity: undefined,
501
622
  bottomOpacity: undefined,
@@ -27,6 +27,13 @@ export interface EngineConfig {
27
27
  exaggerate?: boolean;
28
28
  referenceValue?: number;
29
29
  referenceValues?: number[];
30
+ /**
31
+ * Live, per-frame Y values to fold into the axis-range fit on top of the static
32
+ * {@link referenceValues} — used so a *dragging* reference line expands the range
33
+ * and the axis follows the finger smoothly (the committed values already sit in
34
+ * `referenceValues`). Read on the UI thread each frame; omit for non-draggable charts.
35
+ */
36
+ liveReferenceValues?: SharedValue<number[]>;
30
37
  nonNegative?: boolean;
31
38
  maxValue?: number;
32
39
  nowOverride?: number;
@@ -50,12 +57,14 @@ export interface ChartEngineLayout {
50
57
  /** Animating window (lerps toward {@link timeWindow}); use for positioning. */
51
58
  displayWindow: SharedValue<number>;
52
59
  /**
53
- * The target window from props — the exact value `displayWindow` eases toward.
54
- * Tick *selection* (which X-axis labels exist) must read this, not
55
- * `displayWindow`: the lerp is asymptotic and never reaches the target, so it
56
- * settles just above or just below it depending on the prior window. Bucketing
57
- * that off-by-epsilon value (e.g. via `niceTimeInterval`) would otherwise make
58
- * the tick cadence depend on where you came from. See issue #126.
60
+ * The *effective* target window — the value `displayWindow` eases toward: the
61
+ * pinch-zoom override ({@link ChartEngineScroll.viewWindow}) when active, else
62
+ * the `timeWindow` prop. Tick *selection* (which X-axis labels exist) must read
63
+ * this, not `displayWindow`: the lerp is asymptotic and never reaches the
64
+ * target, so it settles just above or just below it depending on the prior
65
+ * window. Bucketing that off-by-epsilon value (e.g. via `niceTimeInterval`)
66
+ * would otherwise make the tick cadence depend on where you came from. See
67
+ * issue #126.
59
68
  */
60
69
  timeWindow: SharedValue<number>;
61
70
  canvasWidth: SharedValue<number>;
@@ -76,6 +85,42 @@ export interface ChartEngineExtrema {
76
85
  extremaMaxTime: SharedValue<number>;
77
86
  }
78
87
 
88
+ /**
89
+ * Time-scroll state: lets a pan gesture freeze the window at an absolute time
90
+ * and resume following the live edge. Returned by both engines (intersected onto
91
+ * the state) but kept off {@link ChartEngineLayout} so overlay components — which
92
+ * only read the layout — don't have to carry it.
93
+ */
94
+ export interface ChartEngineScroll {
95
+ /**
96
+ * Absolute right-edge time (unix seconds) to freeze at, or `null` to follow
97
+ * the live edge. Written by the pan-scroll gesture; read by the engine tick.
98
+ */
99
+ viewEnd: SharedValue<number | null>;
100
+ /**
101
+ * The right-edge time the engine would use if following live — advances each
102
+ * frame even while {@link ChartEngineLayout.timestamp} is frozen by a pan.
103
+ */
104
+ liveEdge: SharedValue<number>;
105
+ /**
106
+ * Absolute visible-window width (seconds) to freeze at, or `null` to follow
107
+ * the configured `timeWindow`. Written by the pinch-zoom gesture; the
108
+ * symmetric counterpart of {@link viewEnd} (width vs. right edge). Folded into
109
+ * {@link ChartEngineLayout.timeWindow}, so downstream selection/positioning
110
+ * picks up the zoom for free.
111
+ */
112
+ viewWindow: SharedValue<number | null>;
113
+ }
114
+
115
+ /** Single-series only: smoothed price at the visible window's right edge. */
116
+ export interface ChartEngineEdge {
117
+ /**
118
+ * Smoothed value at the window's right edge — the live value while following,
119
+ * the price at `viewEnd` while scrolled back. For a `followViewEdge` badge.
120
+ */
121
+ edgeValue: SharedValue<number>;
122
+ }
123
+
79
124
  export interface SingleEngineState extends ChartEngineLayout, ChartEngineExtrema {
80
125
  data: SharedValue<LiveChartPoint[]>;
81
126
  value: SharedValue<number>;
@@ -119,6 +164,14 @@ export interface EngineFrameRefs {
119
164
  nowOverrideSV?: SharedValue<number | undefined>;
120
165
  windowBufferSV?: SharedValue<number>;
121
166
  pausedSV: SharedValue<boolean>;
167
+ /** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
168
+ viewEndSV?: SharedValue<number | null>;
169
+ /** Pinch-zoom window-width override (null = follow timeWindow). Optional for callers/tests. */
170
+ viewWindowSV?: SharedValue<number | null>;
171
+ /** Receives the computed live edge each frame. Optional for callers/tests. */
172
+ liveEdgeSV?: SharedValue<number>;
173
+ /** Receives the smoothed right-edge value each frame. Optional for callers/tests. */
174
+ edgeValueSV?: SharedValue<number>;
122
175
  modeSV: SharedValue<"line" | "candle">;
123
176
  candles?: SharedValue<CandlePoint[]>;
124
177
  liveCandle?: SharedValue<CandlePoint | null>;
@@ -144,6 +197,8 @@ export function applyLiveChartEngineFrame(
144
197
  displayMax: sv.displayMax.value,
145
198
  displayWindow: sv.displayWindow.value,
146
199
  timestamp: sv.timestamp.value,
200
+ liveEdge: sv.liveEdgeSV?.value ?? 0,
201
+ edgeValue: sv.edgeValueSV?.value ?? 0,
147
202
  extremaMinValue: sv.extremaMinValue.value,
148
203
  extremaMaxValue: sv.extremaMaxValue.value,
149
204
  extremaMinTime: sv.extremaMinTime.value,
@@ -167,6 +222,8 @@ export function applyLiveChartEngineFrame(
167
222
  points: sv.data.value,
168
223
  nowSeconds: Date.now() / 1000,
169
224
  paused: sv.pausedSV.value,
225
+ viewEnd: sv.viewEndSV?.value,
226
+ viewWindow: sv.viewWindowSV?.value,
170
227
  mode: sv.modeSV.value,
171
228
  candles: sv.candles?.value,
172
229
  liveCandle: sv.liveCandle?.value,
@@ -176,15 +233,27 @@ export function applyLiveChartEngineFrame(
176
233
  sv.displayMax.value = state.displayMax;
177
234
  sv.displayWindow.value = state.displayWindow;
178
235
  sv.timestamp.value = state.timestamp;
236
+ if (sv.liveEdgeSV) sv.liveEdgeSV.value = state.liveEdge;
237
+ if (sv.edgeValueSV) sv.edgeValueSV.value = state.edgeValue;
179
238
  sv.extremaMinValue.value = state.extremaMinValue;
180
239
  sv.extremaMaxValue.value = state.extremaMaxValue;
181
240
  sv.extremaMinTime.value = state.extremaMinTime;
182
241
  sv.extremaMaxTime.value = state.extremaMaxTime;
183
242
  }
184
243
 
185
- export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
186
- // Low-frequency config → UI thread via useDerivedValue
187
- const timeWindow = useDerivedValue(() => config.timeWindow);
244
+ export function useLiveChartEngine(
245
+ config: EngineConfig,
246
+ ): SingleEngineState & ChartEngineScroll & ChartEngineEdge {
247
+ // Pinch-zoom window-width override (null = follow the configured window).
248
+ // Declared first so `timeWindow` below can fold it in. Defaults to null so
249
+ // charts without `zoom` behave exactly as before.
250
+ const viewWindow = useSharedValue<number | null>(null);
251
+
252
+ // Low-frequency config → UI thread via useDerivedValue. `timeWindow` is the
253
+ // *effective* target window: the zoom override when set, else the prop. Both
254
+ // the tick's window lerp and the X-axis tick selection read it, so zoom flows
255
+ // downstream for free (mirrors how viewEnd drives `timestamp`).
256
+ const timeWindow = useDerivedValue(() => viewWindow.value ?? config.timeWindow);
188
257
  // Static charts snap to their target in one tick (smoothing=1), so the single
189
258
  // settle reaction below produces the final state with no per-frame easing.
190
259
  const smoothing = useDerivedValue(() =>
@@ -193,7 +262,15 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
193
262
  const adaptiveSpeedBoostSV = useDerivedValue(() => config.adaptiveSpeedBoost);
194
263
  const exaggerateSV = useDerivedValue(() => config.exaggerate ?? false);
195
264
  const referenceValue = useDerivedValue(() => config.referenceValue);
196
- const referenceValues = useDerivedValue(() => config.referenceValues);
265
+ // Captured directly (not via `config.`) so Reanimated tracks the SharedValue and
266
+ // the merge re-runs each frame while a line is dragged; stable when idle.
267
+ const liveReferenceValuesSV = config.liveReferenceValues;
268
+ const referenceValues = useDerivedValue(() => {
269
+ const base = config.referenceValues;
270
+ const live = liveReferenceValuesSV?.value;
271
+ if (!live || live.length === 0) return base;
272
+ return base && base.length > 0 ? base.concat(live) : live;
273
+ });
197
274
  const nonNegativeSV = useDerivedValue(() => config.nonNegative ?? false);
198
275
  const maxValueSV = useDerivedValue(() => config.maxValue);
199
276
  const nowOverrideSV = useDerivedValue(() => config.nowOverride);
@@ -212,6 +289,14 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
212
289
  const [initialTimestamp] = useState(() => Date.now() / 1000);
213
290
  const timestamp = useSharedValue(initialTimestamp);
214
291
 
292
+ // Pan-scroll state. `viewEnd` null = follow live; a number freezes the right
293
+ // edge at that absolute time. `liveEdge` mirrors the would-be-live timestamp
294
+ // each frame so the gesture can clamp / detect catch-up. Both default to
295
+ // "following" so charts without `timeScroll` behave exactly as before.
296
+ const viewEnd = useSharedValue<number | null>(null);
297
+ const liveEdge = useSharedValue(initialTimestamp);
298
+ const edgeValue = useSharedValue(0);
299
+
215
300
  // Live data extrema (value + time of the visible high / low). NaN until the
216
301
  // first tick finds data — the extrema label stays hidden until then.
217
302
  const extremaMinValue = useSharedValue(NaN);
@@ -250,6 +335,10 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
250
335
  nowOverrideSV,
251
336
  windowBufferSV,
252
337
  pausedSV,
338
+ viewEndSV: viewEnd,
339
+ viewWindowSV: viewWindow,
340
+ liveEdgeSV: liveEdge,
341
+ edgeValueSV: edgeValue,
253
342
  modeSV,
254
343
  candles,
255
344
  liveCandle,
@@ -307,6 +396,10 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
307
396
  canvasWidth,
308
397
  canvasHeight,
309
398
  timestamp,
399
+ viewEnd,
400
+ viewWindow,
401
+ liveEdge,
402
+ edgeValue,
310
403
  extremaMinValue,
311
404
  extremaMaxValue,
312
405
  extremaMinTime,
@@ -8,7 +8,7 @@ import {
8
8
  import { MS_PER_FRAME_60FPS } from "../constants";
9
9
  import type { LiveChartPoint, SeriesConfig } from "../types";
10
10
  import { tickLiveChartSeriesEngineFrame } from "./liveChartSeriesEngineTick";
11
- import type { MultiEngineState } from "./useLiveChartEngine";
11
+ import type { ChartEngineScroll, MultiEngineState } from "./useLiveChartEngine";
12
12
 
13
13
  export interface MultiSeriesEngineConfig {
14
14
  series: SharedValue<SeriesConfig[]>;
@@ -47,6 +47,12 @@ export interface MultiEngineFrameRefs {
47
47
  nowOverrideSV?: SharedValue<number | undefined>;
48
48
  windowBufferSV?: SharedValue<number>;
49
49
  pausedSV: SharedValue<boolean>;
50
+ /** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
51
+ viewEndSV?: SharedValue<number | null>;
52
+ /** Pinch-zoom window-width override (null = follow timeWindow). Optional for callers/tests. */
53
+ viewWindowSV?: SharedValue<number | null>;
54
+ /** Receives the computed live edge each frame. Optional for callers/tests. */
55
+ liveEdgeSV?: SharedValue<number>;
50
56
  extremaMinValue: SharedValue<number>;
51
57
  extremaMaxValue: SharedValue<number>;
52
58
  extremaMinTime: SharedValue<number>;
@@ -105,6 +111,7 @@ export function applyLiveChartSeriesEngineFrame(
105
111
  displayMax: sv.displayMax.value,
106
112
  displayWindow: sv.displayWindow.value,
107
113
  timestamp: sv.timestamp.value,
114
+ liveEdge: sv.liveEdgeSV?.value ?? 0,
108
115
  displayValues,
109
116
  opacities,
110
117
  extremaMinValue: sv.extremaMinValue.value,
@@ -129,11 +136,14 @@ export function applyLiveChartSeriesEngineFrame(
129
136
  series: seriesSnap,
130
137
  nowSeconds: Date.now() / 1000,
131
138
  paused: sv.pausedSV.value,
139
+ viewEnd: sv.viewEndSV?.value,
140
+ viewWindow: sv.viewWindowSV?.value,
132
141
  });
133
142
  sv.displayMin.value = state.displayMin;
134
143
  sv.displayMax.value = state.displayMax;
135
144
  sv.displayWindow.value = state.displayWindow;
136
145
  sv.timestamp.value = state.timestamp;
146
+ if (sv.liveEdgeSV) sv.liveEdgeSV.value = state.liveEdge;
137
147
  sv.displaySeriesValues.value = state.displayValues;
138
148
  sv.seriesOpacities.value = state.opacities;
139
149
  sv.extremaMinValue.value = state.extremaMinValue;
@@ -148,8 +158,11 @@ export function applyLiveChartSeriesEngineFrame(
148
158
  */
149
159
  export function useLiveChartSeriesEngine(
150
160
  config: MultiSeriesEngineConfig,
151
- ): MultiEngineState {
152
- const timeWindow = useDerivedValue(() => config.timeWindow);
161
+ ): MultiEngineState & ChartEngineScroll {
162
+ // Pinch-zoom window-width override (null = follow the configured window).
163
+ // Declared first so `timeWindow` folds it in — see useLiveChartEngine.
164
+ const viewWindow = useSharedValue<number | null>(null);
165
+ const timeWindow = useDerivedValue(() => viewWindow.value ?? config.timeWindow);
153
166
  const smoothing = useDerivedValue(() => config.smoothing);
154
167
  const adaptiveSpeedBoostSV = useDerivedValue(() => config.adaptiveSpeedBoost);
155
168
  const exaggerateSV = useDerivedValue(() => config.exaggerate ?? false);
@@ -170,6 +183,10 @@ export function useLiveChartSeriesEngine(
170
183
  const [initialTimestamp] = useState(() => Date.now() / 1000);
171
184
  const timestamp = useSharedValue(initialTimestamp);
172
185
 
186
+ // Pan-scroll state (see useLiveChartEngine). Defaults to following live.
187
+ const viewEnd = useSharedValue<number | null>(null);
188
+ const liveEdge = useSharedValue(initialTimestamp);
189
+
173
190
  const displaySeriesValues = useSharedValue<number[]>([]);
174
191
  const seriesOpacities = useSharedValue<number[]>([]);
175
192
 
@@ -217,6 +234,9 @@ export function useLiveChartSeriesEngine(
217
234
  nowOverrideSV,
218
235
  windowBufferSV,
219
236
  pausedSV,
237
+ viewEndSV: viewEnd,
238
+ viewWindowSV: viewWindow,
239
+ liveEdgeSV: liveEdge,
220
240
  extremaMinValue,
221
241
  extremaMaxValue,
222
242
  extremaMinTime,
@@ -237,6 +257,9 @@ export function useLiveChartSeriesEngine(
237
257
  canvasWidth,
238
258
  canvasHeight,
239
259
  timestamp,
260
+ viewEnd,
261
+ viewWindow,
262
+ liveEdge,
240
263
  series,
241
264
  displaySeriesValues,
242
265
  seriesOpacities,