react-native-livechart 3.12.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 (136) 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 +16 -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 +6 -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 +9 -4
  63. package/dist/hooks/useMarkers.d.ts.map +1 -1
  64. package/dist/hooks/usePanScroll.d.ts +82 -0
  65. package/dist/hooks/usePanScroll.d.ts.map +1 -0
  66. package/dist/hooks/usePinchZoom.d.ts +74 -0
  67. package/dist/hooks/usePinchZoom.d.ts.map +1 -0
  68. package/dist/hooks/useReferenceDrag.d.ts +24 -0
  69. package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
  70. package/dist/hooks/useReferenceLine.d.ts +23 -4
  71. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  72. package/dist/hooks/useReferenceLinePress.d.ts +5 -1
  73. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
  74. package/dist/hooks/useVisibleRange.d.ts +39 -0
  75. package/dist/hooks/useVisibleRange.d.ts.map +1 -0
  76. package/dist/index.d.ts +2 -1
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/math/markerCluster.d.ts +47 -0
  79. package/dist/math/markerCluster.d.ts.map +1 -0
  80. package/dist/math/markers.d.ts +10 -0
  81. package/dist/math/markers.d.ts.map +1 -1
  82. package/dist/math/referenceDrag.d.ts +24 -0
  83. package/dist/math/referenceDrag.d.ts.map +1 -0
  84. package/dist/math/referenceGroup.d.ts +31 -0
  85. package/dist/math/referenceGroup.d.ts.map +1 -0
  86. package/dist/math/referenceLines.d.ts +33 -7
  87. package/dist/math/referenceLines.d.ts.map +1 -1
  88. package/dist/types.d.ts +505 -21
  89. package/dist/types.d.ts.map +1 -1
  90. package/package.json +1 -1
  91. package/src/components/BadgeOverlay.tsx +26 -1
  92. package/src/components/ChartOverlayLayer.tsx +32 -0
  93. package/src/components/CustomMarkerOverlay.tsx +150 -57
  94. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  95. package/src/components/CustomTooltipOverlay.tsx +27 -7
  96. package/src/components/DotOverlay.tsx +9 -0
  97. package/src/components/LiveChart.tsx +549 -32
  98. package/src/components/LiveChartSeries.tsx +110 -5
  99. package/src/components/MarkerOverlay.tsx +92 -6
  100. package/src/components/MultiSeriesDots.tsx +12 -3
  101. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  102. package/src/components/ReferenceLineOverlay.tsx +105 -32
  103. package/src/components/XAxisOverlay.tsx +9 -2
  104. package/src/components/YAxisOverlay.tsx +48 -26
  105. package/src/constants.ts +10 -0
  106. package/src/core/liveChartEngineTick.ts +81 -8
  107. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  108. package/src/core/resolveConfig.ts +122 -1
  109. package/src/core/useLiveChartEngine.ts +103 -10
  110. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  111. package/src/draw/grid.ts +13 -1
  112. package/src/draw/markerAtlas.ts +117 -3
  113. package/src/draw/volume.ts +190 -0
  114. package/src/hooks/crosshairShared.ts +37 -9
  115. package/src/hooks/overlayScale.ts +59 -0
  116. package/src/hooks/resolveChartLayout.ts +33 -0
  117. package/src/hooks/useBadge.ts +89 -21
  118. package/src/hooks/useCandlePaths.ts +104 -7
  119. package/src/hooks/useChartOverlayContext.ts +93 -0
  120. package/src/hooks/useChartPaths.ts +14 -1
  121. package/src/hooks/useCrosshair.ts +29 -2
  122. package/src/hooks/useLiveDot.ts +16 -5
  123. package/src/hooks/useMarkers.ts +39 -11
  124. package/src/hooks/usePanScroll.ts +253 -0
  125. package/src/hooks/usePinchZoom.ts +189 -0
  126. package/src/hooks/useReferenceDrag.ts +254 -0
  127. package/src/hooks/useReferenceLine.ts +56 -9
  128. package/src/hooks/useReferenceLinePress.ts +16 -2
  129. package/src/hooks/useVisibleRange.ts +119 -0
  130. package/src/index.ts +17 -1
  131. package/src/math/markerCluster.ts +214 -0
  132. package/src/math/markers.ts +35 -3
  133. package/src/math/referenceDrag.ts +66 -0
  134. package/src/math/referenceGroup.ts +65 -0
  135. package/src/math/referenceLines.ts +76 -19
  136. package/src/types.ts +525 -21
@@ -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,
package/src/draw/grid.ts CHANGED
@@ -114,7 +114,19 @@ export function computeGridEntries(
114
114
  // Phase 1: compute target alpha for every grid line
115
115
  const targets: Record<number, number> = {};
116
116
  const first = Math.ceil(displayMin / fine) * fine;
117
- for (let val = first; val <= displayMax; val += fine) {
117
+ // Guard against a non-advancing loop. On a near-flat (but not bit-identical)
118
+ // range, `fine` can drop below ulp(val), so `val += fine === val` and the loop
119
+ // never terminates — freezing the UI thread, since this runs in a worklet every
120
+ // frame. `stepResolves` skips the degenerate range (also catches fine === 0);
121
+ // MAX_GRID_LINES is a hard backstop in case the step stalls mid-loop (real
122
+ // grids only have a handful of lines, so it never bites normal data).
123
+ const stepResolves = first + fine !== first;
124
+ const MAX_GRID_LINES = 1000;
125
+ for (
126
+ let val = first, lineCount = 0;
127
+ stepResolves && val <= displayMax && lineCount < MAX_GRID_LINES;
128
+ val += fine, lineCount++
129
+ ) {
118
130
  const y = gridValueToY(val, displayMax, valRange, padTop, chartH);
119
131
  /* istanbul ignore next -- y stays in chart band for v in [first, displayMax] with consistent toY */
120
132
  if (y < padTop - 2 || y > canvasHeight - padBottom + 2) continue;
@@ -21,11 +21,42 @@ const PILL_TEXT_COLOR = "#ffffff";
21
21
  /** Anti-alias breathing room baked around every cell so sprites don't clip. */
22
22
  const CELL_MARGIN = 2;
23
23
 
24
- /** Default glyph color per kind when `marker.color` is unset. */
24
+ /** Glyphs composed into a collapsed-cluster count badge (digits only; capped "99"). */
25
+ const GROUP_CHARS = "0123456789";
26
+ /** Padding (px) between the digits and the inner circle edge. */
27
+ const BADGE_PAD = 2;
28
+ /** Background-colored halo ring around the badge — matches the icon-pill border. */
29
+ const BADGE_BORDER = 2;
30
+ /** Count-badge digits render at this fraction of the chart font, so two digits fit
31
+ * a small round badge. Shared with the overlay's per-frame digit layout. */
32
+ export const BADGE_TEXT_SCALE = 0.75;
33
+
34
+ /** Atlas cell sig for one count-badge glyph (`0`-`9`), drawn white. */
35
+ export function groupGlyphSig(ch: string): string {
36
+ "worklet";
37
+ return `gd\x1f${ch}`;
38
+ }
39
+
40
+ /** Atlas cell sig for the round count-badge background (one fixed circle per color). */
41
+ export function groupBgSig(color: string): string {
42
+ "worklet";
43
+ return `gbg\x1f${color}`;
44
+ }
45
+
46
+ /** Text shown in a collapsed-cluster count badge — exact up to `"99"`, capped there
47
+ * so it always fits the fixed round badge (≤ 2 digits). */
48
+ export function groupCountText(count: number): string {
49
+ "worklet";
50
+ return count > 99 ? "99" : `${count}`;
51
+ }
52
+
53
+ /** Default glyph color per kind when `marker.color` is unset. Worklet-safe so the
54
+ * per-frame overlay worklet can resolve a collapsed cluster's badge color. */
25
55
  export function defaultMarkerColor(
26
56
  kind: MarkerKind,
27
57
  palette: LiveChartPalette,
28
58
  ): string {
59
+ "worklet";
29
60
  switch (kind) {
30
61
  case "trade":
31
62
  return palette.line;
@@ -93,9 +124,13 @@ export interface MarkerAtlas {
93
124
  * hi-res cell back to its logical on-canvas size. See `buildMarkerAtlas`.
94
125
  */
95
126
  scale: number;
127
+ /** Logical ink width (px) of each count-badge digit `"0"`–`"9"`, for laying them
128
+ * out proportionally (so narrow digits like `1` don't leave gaps). Empty when
129
+ * the atlas carries no group cells (clustering off). */
130
+ digitWidths: Record<string, number>;
96
131
  }
97
132
 
98
- const EMPTY_ATLAS: MarkerAtlas = { image: null, cells: {}, scale: 1 };
133
+ const EMPTY_ATLAS: MarkerAtlas = { image: null, cells: {}, scale: 1, digitWidths: {} };
99
134
 
100
135
  function fillPaint(color: string): SkPaint {
101
136
  const p = Skia.Paint();
@@ -260,6 +295,51 @@ function cellSpec(m: Marker, palette: LiveChartPalette, font: SkFont): CellSpec
260
295
  };
261
296
  }
262
297
 
298
+ /** One count-badge glyph (`0`-`9` / `+`) in white, on a uniform-width box so the
299
+ * digits lay out tabular. `uw`/`gh` are the shared logical glyph metrics. */
300
+ function groupGlyphCellSpec(
301
+ ch: string,
302
+ uw: number,
303
+ gh: number,
304
+ font: SkFont,
305
+ ): CellSpec {
306
+ const b = font.measureText(ch);
307
+ const dx = b.x + b.width / 2;
308
+ const dy = b.y + b.height / 2;
309
+ const m2 = CELL_MARGIN * 2;
310
+ return {
311
+ w: uw + m2,
312
+ h: gh + m2,
313
+ draw: (canvas, cx, cy) =>
314
+ canvas.drawText(ch, cx - dx, cy - dy, fillPaint(PILL_TEXT_COLOR), font),
315
+ };
316
+ }
317
+
318
+ /** Round count-badge background — a single fixed-size circle (independent of digit
319
+ * count) with a background-colored halo ring like the icon pills. Sized to fit the
320
+ * scaled two-digit text, so the badge stays small, round, and never changes size.
321
+ * `uw`/`gh` are the shared (full-size) glyph metrics; the text is drawn scaled. */
322
+ function groupBgCellSpec(
323
+ color: string,
324
+ bgColor: string,
325
+ uw: number,
326
+ gh: number,
327
+ ): CellSpec {
328
+ const m2 = CELL_MARGIN * 2;
329
+ // Inner circle fits a centered two-digit string at BADGE_TEXT_SCALE plus padding.
330
+ const innerR = Math.ceil(Math.max(2 * uw, gh) * BADGE_TEXT_SCALE) / 2 + BADGE_PAD;
331
+ const outerR = innerR + BADGE_BORDER;
332
+ const diameter = 2 * outerR;
333
+ return {
334
+ w: diameter + m2,
335
+ h: diameter + m2,
336
+ draw: (canvas, cx, cy) => {
337
+ canvas.drawCircle(cx, cy, outerR, fillPaint(bgColor)); // halo ring
338
+ canvas.drawCircle(cx, cy, innerR, fillPaint(color)); // colored badge
339
+ },
340
+ };
341
+ }
342
+
263
343
  /**
264
344
  * Rasterize every distinct marker appearance into a single packed atlas image,
265
345
  * once per appearance-set change (NOT per frame). The per-frame worklet then
@@ -279,6 +359,9 @@ export function buildMarkerAtlas(
279
359
  palette: LiveChartPalette,
280
360
  font: SkFont,
281
361
  scale = 1,
362
+ /** Also bake count-badge cells (digits + per-color backgrounds) for
363
+ * `markerCluster: "stacked"` collapse. Off by default — no atlas bloat. */
364
+ withGroups = false,
282
365
  ): MarkerAtlas {
283
366
  const seen = new Set<string>();
284
367
  const specs: { sig: string; spec: CellSpec }[] = [];
@@ -290,6 +373,37 @@ export function buildMarkerAtlas(
290
373
  seen.add(sig);
291
374
  specs.push({ sig, spec: cellSpec(m, palette, font) });
292
375
  }
376
+
377
+ // Count-badge cells: 10 uniform white digit cells shared across colors, plus one
378
+ // fixed round background per cluster color. A collapsed group composes its number
379
+ // (centered, ≤ 2 digits) over the circle in the same `drawAtlas`.
380
+ const digitWidths: Record<string, number> = {};
381
+ if (withGroups && specs.length > 0) {
382
+ let uw = 1;
383
+ let gh = 1;
384
+ for (const ch of GROUP_CHARS) {
385
+ const b = font.measureText(ch);
386
+ digitWidths[ch] = Math.max(1, Math.ceil(b.width));
387
+ if (b.width > uw) uw = b.width;
388
+ if (b.height > gh) gh = b.height;
389
+ }
390
+ uw = Math.ceil(uw);
391
+ gh = Math.ceil(gh);
392
+ for (const ch of GROUP_CHARS) {
393
+ specs.push({ sig: groupGlyphSig(ch), spec: groupGlyphCellSpec(ch, uw, gh, font) });
394
+ }
395
+ const bgColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
396
+ const colors = new Set<string>();
397
+ for (let i = 0; i < markers.length; i++) {
398
+ const m = markers[i];
399
+ if (isConnectorMarker(m)) continue;
400
+ colors.add(m.color ?? defaultMarkerColor(m.kind, palette));
401
+ }
402
+ for (const color of colors) {
403
+ specs.push({ sig: groupBgSig(color), spec: groupBgCellSpec(color, bgColor, uw, gh) });
404
+ }
405
+ }
406
+
293
407
  if (specs.length === 0) return EMPTY_ATLAS;
294
408
 
295
409
  let totalW = 0;
@@ -326,5 +440,5 @@ export function buildMarkerAtlas(
326
440
  }
327
441
  const picture = recorder.finishRecordingAsPicture();
328
442
  const image = drawAsImageFromPicture(picture, { width: texW, height: texH });
329
- return { image, cells, scale };
443
+ return { image, cells, scale, digitWidths };
330
444
  }