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
@@ -15,7 +15,11 @@ import {
15
15
  type SharedValue,
16
16
  } from "react-native-reanimated";
17
17
  import { scheduleOnRN } from "react-native-worklets";
18
- import { DEFAULT_ACCENT_COLOR, MAX_MULTI_SERIES } from "../constants";
18
+ import {
19
+ DEFAULT_ACCENT_COLOR,
20
+ HOLD_TO_SCRUB_MS,
21
+ MAX_MULTI_SERIES,
22
+ } from "../constants";
19
23
  import {
20
24
  lineColorsSignatureFromArray,
21
25
  lineStyleSignatureFromArray,
@@ -29,12 +33,14 @@ import {
29
33
  resolveGridStyle,
30
34
  resolveLeftEdgeFade,
31
35
  resolveLegend,
36
+ resolveMarkerCluster,
32
37
  resolveMetrics,
33
38
  resolveMultiSeriesDot,
34
39
  resolveScrub,
35
40
  resolveSelectionDot,
36
41
  resolveXAxis,
37
42
  resolveYAxis,
43
+ resolveZoom,
38
44
  } from "../core/resolveConfig";
39
45
  import { useLiveChartSeriesEngine } from "../core/useLiveChartSeriesEngine";
40
46
  import { pulseRadialOutset } from "../draw/line";
@@ -46,7 +52,10 @@ import { useCrosshairSeries } from "../hooks/useCrosshairSeries";
46
52
  import { useMarkers } from "../hooks/useMarkers";
47
53
  import { useMultiSeriesDegen } from "../hooks/useMultiSeriesDegen";
48
54
  import { useMultiSeriesLinePaths } from "../hooks/useMultiSeriesLinePaths";
55
+ import { usePanScroll } from "../hooks/usePanScroll";
56
+ import { usePinchZoom } from "../hooks/usePinchZoom";
49
57
  import { useMultiSeriesReverseMorphInputs } from "../hooks/useReverseMorphEngineInputs";
58
+ import { useVisibleRange } from "../hooks/useVisibleRange";
50
59
  import { useXAxis } from "../hooks/useXAxis";
51
60
  import { useYAxis } from "../hooks/useYAxis";
52
61
  import {
@@ -137,22 +146,28 @@ function useLiveChartSeriesController({
137
146
  metrics,
138
147
  scrub = true,
139
148
  selectionDot,
149
+ timeScroll = false,
150
+ zoom = false,
140
151
  onScrub,
141
152
  onGestureStart,
142
153
  onGestureEnd,
154
+ onVisibleRangeChange,
155
+ onReachStart,
143
156
  onSeriesToggle,
144
157
  dot: dotProp,
145
158
  legend: legendProp,
146
159
  degen,
147
160
  onDegenShake,
148
161
  markers,
149
- onMarkerHover,
162
+ onMarkerPress,
150
163
  markerHitRadius = 16,
164
+ markerCluster,
151
165
  renderMarker,
152
166
  leftEdgeFade = true,
153
167
  }: LiveChartSeriesProps) {
154
168
  const emptyMarkers = useSharedValue<Marker[]>([]);
155
169
  const markersSV = markers ?? emptyMarkers;
170
+ const markerClusterCfg = resolveMarkerCluster(markerCluster);
156
171
  const markersActive = markers != null;
157
172
  const yAxisCfg = resolveYAxis(yAxis);
158
173
  const xAxisCfg = resolveXAxis(xAxis);
@@ -160,6 +175,27 @@ function useLiveChartSeriesController({
160
175
  const bottomLabelCfg = resolveAxisLabel(bottomLabel);
161
176
  const scrubCfg = resolveScrub(scrub);
162
177
  const scrubEnabled = scrubCfg !== null;
178
+
179
+ // Time-scroll + pinch-zoom (mirrors LiveChart). LiveChartSeries has no static
180
+ // mode, so these gate on the props alone. `holdToScrub` requires the scrub
181
+ // gesture to wait for a press-and-hold so a quick drag scrolls instead — the
182
+ // hold delay is threaded into `useCrosshairSeries` below.
183
+ const timeScrollEnabled = Boolean(timeScroll);
184
+ const zoomCfg = resolveZoom(zoom);
185
+ const zoomEnabled = zoomCfg !== null;
186
+ const scrollGestureMode =
187
+ typeof timeScroll === "object"
188
+ ? (timeScroll.gesture ?? "holdToScrub")
189
+ : "holdToScrub";
190
+ const timeScrollHoldMs =
191
+ typeof timeScroll === "object" ? timeScroll.scrubHoldMs : undefined;
192
+ // Precedence: explicit scrubHoldMs, then scrub.panGestureDelay, then default.
193
+ // `||` (not `??`) skips the resolved panGestureDelay's 0 default.
194
+ const scrubHoldMs =
195
+ timeScrollEnabled && scrollGestureMode === "holdToScrub"
196
+ ? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
197
+ : (scrubCfg?.panGestureDelay ?? 0);
198
+
163
199
  const selectionDotCfg = resolveSelectionDot(selectionDot);
164
200
  const gridStyleCfg = resolveGridStyle(gridStyle);
165
201
  const dotCfg = resolveMultiSeriesDot(dotProp);
@@ -323,7 +359,7 @@ function useLiveChartSeriesController({
323
359
  effectivePadding,
324
360
  scrubEnabled,
325
361
  onScrub,
326
- scrubCfg?.panGestureDelay ?? 0,
362
+ scrubHoldMs,
327
363
  onGestureStart,
328
364
  onGestureEnd,
329
365
  );
@@ -336,14 +372,77 @@ function useLiveChartSeriesController({
336
372
  markersSV,
337
373
  markersActive,
338
374
  markerHitRadius,
339
- onMarkerHover,
375
+ onMarkerPress,
340
376
  series,
377
+ undefined, // lineData — multi-series anchors by seriesId
378
+ true, // autostart
379
+ false, // lineLinear — per-series curve handled in projection
380
+ markerClusterCfg,
341
381
  );
342
382
 
343
- const rootGesture = markersActive
383
+ // Earliest retained time across all series — clamps how far the window pans
384
+ // back, and the `onReachStart` reference. Falls back to the live edge (no
385
+ // scrollable history) so panning is a no-op.
386
+ const scrollMinTime = useDerivedValue(() => {
387
+ const s = engine.series.get();
388
+ let min = Infinity;
389
+ for (let i = 0; i < s.length; i++) {
390
+ const d = s[i].data;
391
+ if (d.length > 0 && d[0].time < min) min = d[0].time;
392
+ }
393
+ return min === Infinity ? engine.liveEdge.get() : min;
394
+ });
395
+
396
+ const panScrollGesture = usePanScroll({
397
+ engine,
398
+ padding: effectivePadding,
399
+ minTime: scrollMinTime,
400
+ enabled: timeScrollEnabled,
401
+ mode: scrollGestureMode,
402
+ onScrollStart: () => {
403
+ "worklet";
404
+ crosshair.scrubActive.set(false);
405
+ },
406
+ });
407
+
408
+ const pinchZoomGesture = usePinchZoom({
409
+ engine,
410
+ padding: effectivePadding,
411
+ minTime: scrollMinTime,
412
+ timeWindow,
413
+ enabled: zoomEnabled,
414
+ minTimeWindow: zoomCfg?.minTimeWindow,
415
+ maxTimeWindow: zoomCfg?.maxTimeWindow,
416
+ onZoomStart: () => {
417
+ "worklet";
418
+ crosshair.scrubActive.set(false);
419
+ },
420
+ });
421
+
422
+ useVisibleRange({
423
+ engine,
424
+ minTime: scrollMinTime,
425
+ onVisibleRangeChange,
426
+ onReachStart,
427
+ });
428
+
429
+ let rootGesture = markersActive
344
430
  ? Gesture.Race(crosshair.gesture, markerTapGesture)
345
431
  : crosshair.gesture;
346
432
 
433
+ // holdToScrub races the scrub (quick drag scrolls; press-hold scrubs);
434
+ // axisDrag goes first via Exclusive (fails fast outside the bottom band).
435
+ if (timeScrollEnabled) {
436
+ rootGesture =
437
+ scrollGestureMode === "axisDrag"
438
+ ? Gesture.Exclusive(panScrollGesture, rootGesture)
439
+ : Gesture.Race(panScrollGesture, rootGesture);
440
+ }
441
+ // Pinch runs alongside (two-finger, disjoint from the one-finger gestures).
442
+ if (zoomEnabled) {
443
+ rootGesture = Gesture.Simultaneous(rootGesture, pinchZoomGesture);
444
+ }
445
+
347
446
  const backgroundColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
348
447
 
349
448
  return {
@@ -392,6 +491,7 @@ function useLiveChartSeriesController({
392
491
  rootGesture,
393
492
  markersActive,
394
493
  markersSV,
494
+ markerClusterCfg,
395
495
  renderMarker,
396
496
  // selection dot: resolved config + fallback color (the leading series' color)
397
497
  selectionDot: selectionDotCfg,
@@ -436,6 +536,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
436
536
  degenPackRevision,
437
537
  markersActive,
438
538
  markersSV,
539
+ markerClusterCfg,
439
540
  renderMarker,
440
541
  series,
441
542
  emptyText,
@@ -520,6 +621,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
520
621
  ringColor={palette.badgeOuterBg}
521
622
  color={dotCfg.color}
522
623
  pulse={dotCfg.pulse}
624
+ viewEnd={engine.viewEnd}
523
625
  />
524
626
  </Group>
525
627
  )}
@@ -553,6 +655,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
553
655
  font={skiaFont}
554
656
  series={series}
555
657
  renderMarker={renderMarker}
658
+ cluster={markerClusterCfg}
556
659
  />
557
660
  </Group>
558
661
  )}
@@ -665,6 +768,7 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
665
768
  bottomConnector,
666
769
  markersActive,
667
770
  markersSV,
771
+ markerClusterCfg,
668
772
  renderMarker,
669
773
  } = model;
670
774
 
@@ -772,6 +876,7 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
772
876
  engine={engine}
773
877
  padding={effectivePadding}
774
878
  series={series}
879
+ cluster={markerClusterCfg}
775
880
  />
776
881
  )}
777
882
  </View>
@@ -17,8 +17,12 @@ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
17
17
  import type { ChartPadding } from "../draw/line";
18
18
  import { usePathBuilder } from "../hooks/usePathBuilder";
19
19
  import {
20
+ BADGE_TEXT_SCALE,
20
21
  buildMarkerAtlas,
21
22
  defaultMarkerColor,
23
+ groupBgSig,
24
+ groupCountText,
25
+ groupGlyphSig,
22
26
  isConnectorMarker,
23
27
  markerAppearanceSig,
24
28
  type AtlasCell,
@@ -29,10 +33,15 @@ import {
29
33
  projectPoint,
30
34
  type ProjectedMarker,
31
35
  } from "../math/markers";
36
+ import {
37
+ clusterMarkers,
38
+ type ResolvedMarkerCluster,
39
+ } from "../math/markerCluster";
32
40
  import type {
33
41
  LiveChartPalette,
34
42
  LiveChartPoint,
35
43
  Marker,
44
+ MarkerRenderContext,
36
45
  SeriesConfig,
37
46
  } from "../types";
38
47
 
@@ -51,6 +60,7 @@ function ConnectorGlyph({
51
60
  padding,
52
61
  seriesSV,
53
62
  lineDataSV,
63
+ lineLinear,
54
64
  axisY,
55
65
  }: {
56
66
  marker: Marker;
@@ -59,6 +69,7 @@ function ConnectorGlyph({
59
69
  padding: ChartPadding;
60
70
  seriesSV?: SharedValue<SeriesConfig[]>;
61
71
  lineDataSV?: SharedValue<LiveChartPoint[]>;
72
+ lineLinear?: boolean;
62
73
  axisY: SharedValue<number>;
63
74
  }) {
64
75
  const { kind } = marker;
@@ -80,6 +91,7 @@ function ConnectorGlyph({
80
91
  displayMax: engine.displayMax.get(),
81
92
  series: seriesSV?.get(),
82
93
  lineData: lineDataSV?.get(),
94
+ lineLinear,
83
95
  }),
84
96
  );
85
97
 
@@ -148,7 +160,9 @@ export function MarkerOverlay({
148
160
  font,
149
161
  series,
150
162
  lineData,
163
+ lineLinear,
151
164
  renderMarker,
165
+ cluster,
152
166
  }: {
153
167
  markers: SharedValue<Marker[]>;
154
168
  engine: ChartEngineLayout;
@@ -159,12 +173,16 @@ export function MarkerOverlay({
159
173
  series?: SharedValue<SeriesConfig[]>;
160
174
  /** Single-series line data; anchors markers that omit `value`. */
161
175
  lineData?: SharedValue<LiveChartPoint[]>;
176
+ /** Single-series line is drawn linear — anchor `lineData` markers on the chord. */
177
+ lineLinear?: boolean;
162
178
  /**
163
179
  * When provided, markers it returns an element for are rendered as RN views by
164
180
  * `CustomMarkerOverlay` instead — so they're excluded from the atlas here to
165
181
  * avoid a (blurry) glyph drawn behind the custom element.
166
182
  */
167
- renderMarker?: (marker: Marker) => unknown;
183
+ renderMarker?: (marker: Marker, ctx: MarkerRenderContext) => unknown;
184
+ /** Collision config; `"stacked"` fans/collapses co-located markers. */
185
+ cluster: ResolvedMarkerCluster;
168
186
  }) {
169
187
  // Seed from the current markers at mount; the reaction below keeps it in sync.
170
188
  const [snapshot, setSnapshot] = useState<Marker[]>(() => markers.get().slice());
@@ -192,7 +210,15 @@ export function MarkerOverlay({
192
210
  let k = "";
193
211
  for (let i = 0; i < snapshot.length; i++) {
194
212
  const m = snapshot[i];
195
- if (renderMarker(m) != null) k += `${m.id}\x1f`;
213
+ // Detection ctx (an element-vs-null result must not depend on cluster
214
+ // state — see CustomMarkerOverlay); the live ctx is applied there.
215
+ const ctx: MarkerRenderContext = {
216
+ index: i,
217
+ isGrouped: false,
218
+ groupCount: 0,
219
+ side: m.side ?? "center",
220
+ };
221
+ if (renderMarker(m, ctx) != null) k += `${m.id}\x1f`;
196
222
  }
197
223
  return k;
198
224
  }, [snapshot, renderMarker]);
@@ -219,13 +245,22 @@ export function MarkerOverlay({
219
245
  // Rasterize at the screen's device-pixel ratio so sprites stay crisp on
220
246
  // retina canvases instead of being upscaled from a logical-sized texture.
221
247
  const dpr = PixelRatio.get();
248
+ const clusterStacked = cluster.mode === "stacked";
222
249
  const atlas = useMemo(
223
- () => buildMarkerAtlas(snapshot.filter((m) => !customIds[m.id]), palette, font, dpr),
250
+ () =>
251
+ buildMarkerAtlas(
252
+ snapshot.filter((m) => !customIds[m.id]),
253
+ palette,
254
+ font,
255
+ dpr,
256
+ clusterStacked,
257
+ ),
224
258
  // eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey capture the inputs that change cell pixels
225
- [appearanceKey, paletteKey, font, dpr],
259
+ [appearanceKey, paletteKey, font, dpr, clusterStacked],
226
260
  );
227
261
  const cells: Record<string, AtlasCell> = atlas.cells;
228
262
  const atlasImage = atlas.image;
263
+ const digitWidths = atlas.digitWidths;
229
264
  // Inverse of the texture's device-pixel scale; shrinks each hi-res cell back
230
265
  // to its logical on-canvas size in the per-frame blit.
231
266
  const invScale = 1 / atlas.scale;
@@ -263,16 +298,61 @@ export function MarkerOverlay({
263
298
  displayMax: engine.displayMax.get(),
264
299
  series: series?.get(),
265
300
  lineData: lineData?.get(),
301
+ lineLinear,
266
302
  });
303
+ clusterMarkers(ms, buf, { config: cluster });
267
304
  const transforms = [];
268
305
  const sprites = [];
269
306
  for (let i = 0; i < ms.length; i++) {
270
307
  const pt = buf[i];
271
- if (!pt.visible) continue;
308
+ // Collapsed-cluster members fold into their representative's badge.
309
+ if (!pt.visible || pt.hidden) continue;
272
310
  const m = ms[i];
273
311
  if (isConnectorMarker(m)) continue;
274
312
  // Custom-rendered markers are floated as RN views, not drawn here.
275
313
  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`.
316
+ if (pt.isGrouped) {
317
+ const repColor = m.color ?? defaultMarkerColor(m.kind, palette);
318
+ const text = groupCountText(pt.groupCount);
319
+ const bg = cells[groupBgSig(repColor)];
320
+ if (bg) {
321
+ transforms.push(
322
+ Skia.RSXform(invScale, 0, pt.x - bg.w / 2, pt.y - bg.h / 2),
323
+ );
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),
349
+ );
350
+ sprites.push(gc.rect);
351
+ }
352
+ dx += w + kern;
353
+ }
354
+ continue;
355
+ }
276
356
  const cell = cells[markerAppearanceSig(m)];
277
357
  if (!cell) continue;
278
358
  // Center the cell on the projected point. The cell's source rect is in
@@ -284,7 +364,20 @@ export function MarkerOverlay({
284
364
  }
285
365
  return { transforms, sprites };
286
366
  },
287
- [cells, customIds, invScale, markers, engine, padding, series, lineData],
367
+ [
368
+ cells,
369
+ customIds,
370
+ invScale,
371
+ digitWidths,
372
+ markers,
373
+ engine,
374
+ padding,
375
+ palette,
376
+ series,
377
+ lineData,
378
+ lineLinear,
379
+ cluster,
380
+ ],
288
381
  );
289
382
  const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]);
290
383
  const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]);
@@ -309,6 +402,7 @@ export function MarkerOverlay({
309
402
  padding={padding}
310
403
  seriesSV={series}
311
404
  lineDataSV={lineData}
405
+ lineLinear={lineLinear}
312
406
  axisY={axisY}
313
407
  />
314
408
  ))}
@@ -1,6 +1,6 @@
1
1
  import { Circle, Group } from "@shopify/react-native-skia";
2
2
 
3
- import { useDerivedValue } from "react-native-reanimated";
3
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
4
4
  import { MAX_MULTI_SERIES } from "../constants";
5
5
  import type { ChartPadding } from "../draw/line";
6
6
  import type {
@@ -21,6 +21,7 @@ function SeriesDotAtIndex({
21
21
  ring,
22
22
  ringColor,
23
23
  pulse,
24
+ viewEnd,
24
25
  }: {
25
26
  index: number;
26
27
  engine: MultiEngineState;
@@ -32,6 +33,8 @@ function SeriesDotAtIndex({
32
33
  /** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */
33
34
  ringColor: string;
34
35
  pulse: ResolvedPulseConfig | null;
36
+ /** Pan/zoom right-edge override — pulse is frozen-out while scrolled back. */
37
+ viewEnd?: SharedValue<number | null>;
35
38
  }) {
36
39
  const dotX = useDerivedValue(() => {
37
40
  const w = engine.canvasWidth.value;
@@ -62,7 +65,9 @@ function SeriesDotAtIndex({
62
65
  });
63
66
 
64
67
  const pulseRadius = useDerivedValue(() => {
65
- if (!pulse) return 0;
68
+ // A live heartbeat on a frozen historical point is wrong (and `timestamp` is
69
+ // frozen, so it wouldn't animate) — suppress the pulse while scrolled back.
70
+ if (!pulse || viewEnd?.value != null) return 0;
66
71
  const nowMs = engine.timestamp.value * 1000 + index * PULSE_STAGGER_MS;
67
72
  const t = (nowMs % pulse.interval) / pulse.duration;
68
73
  if (t >= 1) return 0;
@@ -70,7 +75,7 @@ function SeriesDotAtIndex({
70
75
  });
71
76
 
72
77
  const pulseOpacity = useDerivedValue(() => {
73
- if (!pulse) return 0;
78
+ if (!pulse || viewEnd?.value != null) return 0;
74
79
  const nowMs = engine.timestamp.value * 1000 + index * PULSE_STAGGER_MS;
75
80
  const t = (nowMs % pulse.interval) / pulse.duration;
76
81
  if (t >= 1) return 0;
@@ -112,6 +117,7 @@ export function MultiSeriesDots({
112
117
  ringColor,
113
118
  color,
114
119
  pulse,
120
+ viewEnd,
115
121
  }: {
116
122
  engine: MultiEngineState;
117
123
  padding: ChartPadding;
@@ -124,6 +130,8 @@ export function MultiSeriesDots({
124
130
  /** Fill color override; falls back to each series' line color. */
125
131
  color: string | undefined;
126
132
  pulse: ResolvedPulseConfig | null;
133
+ /** Pan/zoom right-edge override — pulse is frozen-out while scrolled back. */
134
+ viewEnd?: SharedValue<number | null>;
127
135
  }) {
128
136
  return (
129
137
  <Group>
@@ -138,6 +146,7 @@ export function MultiSeriesDots({
138
146
  ring={ring}
139
147
  ringColor={ringColor}
140
148
  pulse={pulse}
149
+ viewEnd={viewEnd}
141
150
  />
142
151
  ))}
143
152
  </Group>