react-native-livechart 3.12.0 → 4.1.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 (140) 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 +13 -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 +62 -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 +13 -1
  40. package/dist/draw/grid.d.ts.map +1 -1
  41. package/dist/draw/markerAtlas.d.ts +20 -2
  42. package/dist/draw/markerAtlas.d.ts.map +1 -1
  43. package/dist/draw/volume.d.ts +25 -0
  44. package/dist/draw/volume.d.ts.map +1 -0
  45. package/dist/hooks/crosshairShared.d.ts +12 -6
  46. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  47. package/dist/hooks/overlayScale.d.ts +22 -0
  48. package/dist/hooks/overlayScale.d.ts.map +1 -0
  49. package/dist/hooks/resolveChartLayout.d.ts +9 -0
  50. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  51. package/dist/hooks/useBadge.d.ts +22 -1
  52. package/dist/hooks/useBadge.d.ts.map +1 -1
  53. package/dist/hooks/useCandlePaths.d.ts +6 -0
  54. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  55. package/dist/hooks/useChartOverlayContext.d.ts +45 -0
  56. package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
  57. package/dist/hooks/useChartPaths.d.ts +8 -1
  58. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  59. package/dist/hooks/useCrosshair.d.ts +9 -2
  60. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  61. package/dist/hooks/useLiveDot.d.ts +6 -1
  62. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  63. package/dist/hooks/useMarkers.d.ts +9 -4
  64. package/dist/hooks/useMarkers.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/hooks/useYAxis.d.ts +1 -1
  78. package/dist/hooks/useYAxis.d.ts.map +1 -1
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/math/markerCluster.d.ts +47 -0
  82. package/dist/math/markerCluster.d.ts.map +1 -0
  83. package/dist/math/markers.d.ts +10 -0
  84. package/dist/math/markers.d.ts.map +1 -1
  85. package/dist/math/referenceDrag.d.ts +24 -0
  86. package/dist/math/referenceDrag.d.ts.map +1 -0
  87. package/dist/math/referenceGroup.d.ts +31 -0
  88. package/dist/math/referenceGroup.d.ts.map +1 -0
  89. package/dist/math/referenceLines.d.ts +33 -7
  90. package/dist/math/referenceLines.d.ts.map +1 -1
  91. package/dist/types.d.ts +524 -21
  92. package/dist/types.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/components/BadgeOverlay.tsx +26 -1
  95. package/src/components/ChartOverlayLayer.tsx +32 -0
  96. package/src/components/CustomMarkerOverlay.tsx +150 -57
  97. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  98. package/src/components/CustomTooltipOverlay.tsx +27 -7
  99. package/src/components/DotOverlay.tsx +9 -0
  100. package/src/components/LiveChart.tsx +550 -32
  101. package/src/components/LiveChartSeries.tsx +111 -5
  102. package/src/components/MarkerOverlay.tsx +92 -6
  103. package/src/components/MultiSeriesDots.tsx +12 -3
  104. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  105. package/src/components/ReferenceLineOverlay.tsx +105 -32
  106. package/src/components/XAxisOverlay.tsx +9 -2
  107. package/src/components/YAxisOverlay.tsx +49 -28
  108. package/src/constants.ts +17 -0
  109. package/src/core/liveChartEngineTick.ts +81 -8
  110. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  111. package/src/core/resolveConfig.ts +133 -2
  112. package/src/core/useLiveChartEngine.ts +111 -11
  113. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  114. package/src/draw/grid.ts +71 -2
  115. package/src/draw/markerAtlas.ts +117 -3
  116. package/src/draw/volume.ts +190 -0
  117. package/src/hooks/crosshairShared.ts +37 -9
  118. package/src/hooks/overlayScale.ts +59 -0
  119. package/src/hooks/resolveChartLayout.ts +33 -0
  120. package/src/hooks/useBadge.ts +89 -21
  121. package/src/hooks/useCandlePaths.ts +104 -7
  122. package/src/hooks/useChartOverlayContext.ts +93 -0
  123. package/src/hooks/useChartPaths.ts +14 -1
  124. package/src/hooks/useCrosshair.ts +33 -2
  125. package/src/hooks/useLiveDot.ts +16 -5
  126. package/src/hooks/useMarkers.ts +39 -11
  127. package/src/hooks/usePanScroll.ts +253 -0
  128. package/src/hooks/usePinchZoom.ts +189 -0
  129. package/src/hooks/useReferenceDrag.ts +254 -0
  130. package/src/hooks/useReferenceLine.ts +56 -9
  131. package/src/hooks/useReferenceLinePress.ts +16 -2
  132. package/src/hooks/useVisibleRange.ts +119 -0
  133. package/src/hooks/useYAxis.ts +2 -0
  134. package/src/index.ts +17 -1
  135. package/src/math/markerCluster.ts +214 -0
  136. package/src/math/markers.ts +35 -3
  137. package/src/math/referenceDrag.ts +66 -0
  138. package/src/math/referenceGroup.ts +65 -0
  139. package/src/math/referenceLines.ts +76 -19
  140. package/src/types.ts +544 -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);
@@ -309,6 +345,7 @@ function useLiveChartSeriesController({
309
345
  skiaFont,
310
346
  yAxisCfg?.minGap ?? 36,
311
347
  metricsCfg.grid,
348
+ yAxisCfg?.count ?? 0,
312
349
  );
313
350
 
314
351
  const { xAxisEntries } = useXAxis(
@@ -323,7 +360,7 @@ function useLiveChartSeriesController({
323
360
  effectivePadding,
324
361
  scrubEnabled,
325
362
  onScrub,
326
- scrubCfg?.panGestureDelay ?? 0,
363
+ scrubHoldMs,
327
364
  onGestureStart,
328
365
  onGestureEnd,
329
366
  );
@@ -336,14 +373,77 @@ function useLiveChartSeriesController({
336
373
  markersSV,
337
374
  markersActive,
338
375
  markerHitRadius,
339
- onMarkerHover,
376
+ onMarkerPress,
340
377
  series,
378
+ undefined, // lineData — multi-series anchors by seriesId
379
+ true, // autostart
380
+ false, // lineLinear — per-series curve handled in projection
381
+ markerClusterCfg,
341
382
  );
342
383
 
343
- const rootGesture = markersActive
384
+ // Earliest retained time across all series — clamps how far the window pans
385
+ // back, and the `onReachStart` reference. Falls back to the live edge (no
386
+ // scrollable history) so panning is a no-op.
387
+ const scrollMinTime = useDerivedValue(() => {
388
+ const s = engine.series.get();
389
+ let min = Infinity;
390
+ for (let i = 0; i < s.length; i++) {
391
+ const d = s[i].data;
392
+ if (d.length > 0 && d[0].time < min) min = d[0].time;
393
+ }
394
+ return min === Infinity ? engine.liveEdge.get() : min;
395
+ });
396
+
397
+ const panScrollGesture = usePanScroll({
398
+ engine,
399
+ padding: effectivePadding,
400
+ minTime: scrollMinTime,
401
+ enabled: timeScrollEnabled,
402
+ mode: scrollGestureMode,
403
+ onScrollStart: () => {
404
+ "worklet";
405
+ crosshair.scrubActive.set(false);
406
+ },
407
+ });
408
+
409
+ const pinchZoomGesture = usePinchZoom({
410
+ engine,
411
+ padding: effectivePadding,
412
+ minTime: scrollMinTime,
413
+ timeWindow,
414
+ enabled: zoomEnabled,
415
+ minTimeWindow: zoomCfg?.minTimeWindow,
416
+ maxTimeWindow: zoomCfg?.maxTimeWindow,
417
+ onZoomStart: () => {
418
+ "worklet";
419
+ crosshair.scrubActive.set(false);
420
+ },
421
+ });
422
+
423
+ useVisibleRange({
424
+ engine,
425
+ minTime: scrollMinTime,
426
+ onVisibleRangeChange,
427
+ onReachStart,
428
+ });
429
+
430
+ let rootGesture = markersActive
344
431
  ? Gesture.Race(crosshair.gesture, markerTapGesture)
345
432
  : crosshair.gesture;
346
433
 
434
+ // holdToScrub races the scrub (quick drag scrolls; press-hold scrubs);
435
+ // axisDrag goes first via Exclusive (fails fast outside the bottom band).
436
+ if (timeScrollEnabled) {
437
+ rootGesture =
438
+ scrollGestureMode === "axisDrag"
439
+ ? Gesture.Exclusive(panScrollGesture, rootGesture)
440
+ : Gesture.Race(panScrollGesture, rootGesture);
441
+ }
442
+ // Pinch runs alongside (two-finger, disjoint from the one-finger gestures).
443
+ if (zoomEnabled) {
444
+ rootGesture = Gesture.Simultaneous(rootGesture, pinchZoomGesture);
445
+ }
446
+
347
447
  const backgroundColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
348
448
 
349
449
  return {
@@ -392,6 +492,7 @@ function useLiveChartSeriesController({
392
492
  rootGesture,
393
493
  markersActive,
394
494
  markersSV,
495
+ markerClusterCfg,
395
496
  renderMarker,
396
497
  // selection dot: resolved config + fallback color (the leading series' color)
397
498
  selectionDot: selectionDotCfg,
@@ -436,6 +537,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
436
537
  degenPackRevision,
437
538
  markersActive,
438
539
  markersSV,
540
+ markerClusterCfg,
439
541
  renderMarker,
440
542
  series,
441
543
  emptyText,
@@ -520,6 +622,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
520
622
  ringColor={palette.badgeOuterBg}
521
623
  color={dotCfg.color}
522
624
  pulse={dotCfg.pulse}
625
+ viewEnd={engine.viewEnd}
523
626
  />
524
627
  </Group>
525
628
  )}
@@ -553,6 +656,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
553
656
  font={skiaFont}
554
657
  series={series}
555
658
  renderMarker={renderMarker}
659
+ cluster={markerClusterCfg}
556
660
  />
557
661
  </Group>
558
662
  )}
@@ -665,6 +769,7 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
665
769
  bottomConnector,
666
770
  markersActive,
667
771
  markersSV,
772
+ markerClusterCfg,
668
773
  renderMarker,
669
774
  } = model;
670
775
 
@@ -772,6 +877,7 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
772
877
  engine={engine}
773
878
  padding={effectivePadding}
774
879
  series={series}
880
+ cluster={markerClusterCfg}
775
881
  />
776
882
  )}
777
883
  </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
 
@@ -153,6 +162,7 @@ export function MarkerOverlay({
153
162
  lineData,
154
163
  lineLinear,
155
164
  renderMarker,
165
+ cluster,
156
166
  }: {
157
167
  markers: SharedValue<Marker[]>;
158
168
  engine: ChartEngineLayout;
@@ -170,7 +180,9 @@ export function MarkerOverlay({
170
180
  * `CustomMarkerOverlay` instead — so they're excluded from the atlas here to
171
181
  * avoid a (blurry) glyph drawn behind the custom element.
172
182
  */
173
- renderMarker?: (marker: Marker) => unknown;
183
+ renderMarker?: (marker: Marker, ctx: MarkerRenderContext) => unknown;
184
+ /** Collision config; `"stacked"` fans/collapses co-located markers. */
185
+ cluster: ResolvedMarkerCluster;
174
186
  }) {
175
187
  // Seed from the current markers at mount; the reaction below keeps it in sync.
176
188
  const [snapshot, setSnapshot] = useState<Marker[]>(() => markers.get().slice());
@@ -198,7 +210,15 @@ export function MarkerOverlay({
198
210
  let k = "";
199
211
  for (let i = 0; i < snapshot.length; i++) {
200
212
  const m = snapshot[i];
201
- 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`;
202
222
  }
203
223
  return k;
204
224
  }, [snapshot, renderMarker]);
@@ -225,13 +245,22 @@ export function MarkerOverlay({
225
245
  // Rasterize at the screen's device-pixel ratio so sprites stay crisp on
226
246
  // retina canvases instead of being upscaled from a logical-sized texture.
227
247
  const dpr = PixelRatio.get();
248
+ const clusterStacked = cluster.mode === "stacked";
228
249
  const atlas = useMemo(
229
- () => 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
+ ),
230
258
  // eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey capture the inputs that change cell pixels
231
- [appearanceKey, paletteKey, font, dpr],
259
+ [appearanceKey, paletteKey, font, dpr, clusterStacked],
232
260
  );
233
261
  const cells: Record<string, AtlasCell> = atlas.cells;
234
262
  const atlasImage = atlas.image;
263
+ const digitWidths = atlas.digitWidths;
235
264
  // Inverse of the texture's device-pixel scale; shrinks each hi-res cell back
236
265
  // to its logical on-canvas size in the per-frame blit.
237
266
  const invScale = 1 / atlas.scale;
@@ -271,15 +300,59 @@ export function MarkerOverlay({
271
300
  lineData: lineData?.get(),
272
301
  lineLinear,
273
302
  });
303
+ clusterMarkers(ms, buf, { config: cluster });
274
304
  const transforms = [];
275
305
  const sprites = [];
276
306
  for (let i = 0; i < ms.length; i++) {
277
307
  const pt = buf[i];
278
- if (!pt.visible) continue;
308
+ // Collapsed-cluster members fold into their representative's badge.
309
+ if (!pt.visible || pt.hidden) continue;
279
310
  const m = ms[i];
280
311
  if (isConnectorMarker(m)) continue;
281
312
  // Custom-rendered markers are floated as RN views, not drawn here.
282
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
+ }
283
356
  const cell = cells[markerAppearanceSig(m)];
284
357
  if (!cell) continue;
285
358
  // Center the cell on the projected point. The cell's source rect is in
@@ -291,7 +364,20 @@ export function MarkerOverlay({
291
364
  }
292
365
  return { transforms, sprites };
293
366
  },
294
- [cells, customIds, invScale, markers, engine, padding, series, lineData, lineLinear],
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
+ ],
295
381
  );
296
382
  const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]);
297
383
  const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]);
@@ -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>
@@ -0,0 +1,207 @@
1
+ import {
2
+ Group,
3
+ RoundedRect,
4
+ Text as SkiaText,
5
+ type SkFont,
6
+ } from "@shopify/react-native-skia";
7
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
8
+
9
+ import type { ChartPadding } from "../draw/line";
10
+ import { measureFontTextWidth } from "../lib/measureFontTextWidth";
11
+ import type { ReferenceGrouping } from "../math/referenceGroup";
12
+ import type { ResolvedReferenceGroupBadge } from "../math/referenceLines";
13
+ import type { LiveChartPalette } from "../types";
14
+
15
+ /** Max simultaneous group handles (a fixed Skia slot pool — the per-frame group
16
+ * count varies, so we draw a fixed set of slots and hide the unused ones). */
17
+ const MAX_GROUP_PILLS = 12;
18
+ /** Horizontal padding inside the count pill, in px. */
19
+ const PILL_PAD_X = 6;
20
+ /** Vertical padding inside the count pill, in px. */
21
+ const PILL_PAD_Y = 3;
22
+ /** Pill inset from the anchored plot edge, in px. */
23
+ const EDGE_INSET = 2;
24
+
25
+ interface PillLayout {
26
+ visible: boolean;
27
+ x: number;
28
+ top: number;
29
+ w: number;
30
+ text: string;
31
+ textX: number;
32
+ textY: number;
33
+ }
34
+
35
+ const HIDDEN_PILL: PillLayout = {
36
+ visible: false,
37
+ x: 0,
38
+ top: -1,
39
+ w: 0,
40
+ text: "",
41
+ textX: 0,
42
+ textY: -1,
43
+ };
44
+
45
+ /** One collapsed-group count pill (slot `index` of the fixed pool). Reads the
46
+ * cluster at `grouping.groups[index]`; hidden when fewer groups exist this frame. */
47
+ function GroupCountPill({
48
+ grouping,
49
+ index,
50
+ padding,
51
+ canvasWidth,
52
+ font,
53
+ position,
54
+ icon,
55
+ showText,
56
+ format,
57
+ baselineOffset,
58
+ pillH,
59
+ radius,
60
+ borderWidth,
61
+ color,
62
+ background,
63
+ textColor,
64
+ }: {
65
+ grouping: SharedValue<ReferenceGrouping>;
66
+ index: number;
67
+ padding: ChartPadding;
68
+ canvasWidth: SharedValue<number>;
69
+ font: SkFont;
70
+ position: "left" | "center" | "right";
71
+ icon: string;
72
+ showText: boolean;
73
+ format?: (count: number) => string;
74
+ baselineOffset: number;
75
+ pillH: number;
76
+ radius: number;
77
+ borderWidth: number;
78
+ color: string;
79
+ background: string;
80
+ textColor: string;
81
+ }) {
82
+ const layout = useDerivedValue<PillLayout>(() => {
83
+ const gs = grouping.get().groups;
84
+ if (index >= gs.length) return HIDDEN_PILL;
85
+ const g = gs[index];
86
+ const count = showText ? (format ? format(g.count) : String(g.count)) : "";
87
+ const text = icon ? (count ? `${icon} ${count}` : icon) : count;
88
+ const w = measureFontTextWidth(font, text) + PILL_PAD_X * 2;
89
+ // Anchor the pill horizontally to the chosen plot edge (or center).
90
+ const x1 = padding.left;
91
+ const x2 = canvasWidth.get() - padding.right;
92
+ let x: number;
93
+ if (position === "right") x = x2 - EDGE_INSET - w;
94
+ else if (position === "center") x = (x1 + x2) / 2 - w / 2;
95
+ else x = x1 + EDGE_INSET;
96
+ return {
97
+ visible: true,
98
+ x,
99
+ top: g.cy - pillH / 2,
100
+ w,
101
+ text,
102
+ textX: x + PILL_PAD_X,
103
+ textY: g.cy - baselineOffset,
104
+ };
105
+ });
106
+
107
+ const opacity = useDerivedValue(() => (layout.get().visible ? 1 : 0));
108
+ const x = useDerivedValue(() => layout.get().x);
109
+ const top = useDerivedValue(() => layout.get().top);
110
+ const w = useDerivedValue(() => layout.get().w);
111
+ const text = useDerivedValue(() => layout.get().text);
112
+ const textX = useDerivedValue(() => layout.get().textX);
113
+ const textY = useDerivedValue(() => layout.get().textY);
114
+
115
+ return (
116
+ <Group opacity={opacity}>
117
+ <RoundedRect
118
+ x={x}
119
+ y={top}
120
+ width={w}
121
+ height={pillH}
122
+ r={radius}
123
+ color={background}
124
+ />
125
+ <RoundedRect
126
+ x={x}
127
+ y={top}
128
+ width={w}
129
+ height={pillH}
130
+ r={radius}
131
+ color={color}
132
+ style="stroke"
133
+ strokeWidth={borderWidth}
134
+ />
135
+ <SkiaText x={textX} y={textY} text={text} font={font} color={textColor} />
136
+ </Group>
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Draws the collapsed-group count handles (e.g. a "×3" pill) for reference-line
142
+ * grouping — one pill per multi-line cluster, pinned to the plot at the cluster
143
+ * centroid. The individual tags of clustered lines are suppressed upstream (via
144
+ * `groupHidden`), so a stack of nearby orders reads as one handle. The pill uses the
145
+ * same style/shape config as a per-line badge (`badge`): `position`, `icon`,
146
+ * `text` (showText), `background` / `borderColor` / `borderWidth`, `radius` (corner),
147
+ * `textColor`, per-badge `font`, and `offsetX` / `offsetY`; the `format` fn maps the
148
+ * count to the label. A fixed slot pool keeps the Skia tree stable while the
149
+ * per-frame group count varies.
150
+ */
151
+ export function ReferenceLineGroupOverlay({
152
+ grouping,
153
+ padding,
154
+ canvasWidth,
155
+ palette,
156
+ font,
157
+ badge,
158
+ format,
159
+ }: {
160
+ grouping: SharedValue<ReferenceGrouping>;
161
+ padding: ChartPadding;
162
+ canvasWidth: SharedValue<number>;
163
+ palette: LiveChartPalette;
164
+ font: SkFont;
165
+ badge: ResolvedReferenceGroupBadge;
166
+ format?: (count: number) => string;
167
+ }) {
168
+ const fm = font.getMetrics();
169
+ const baselineOffset = (fm.ascent + fm.descent) / 2;
170
+ const pillH = fm.descent - fm.ascent + PILL_PAD_Y * 2;
171
+
172
+ // Theme defaults for the unset colors (mirrors the per-line badge resolution).
173
+ const background = badge.background ?? palette.tooltipBg;
174
+ const borderColor = badge.borderColor ?? palette.refLine;
175
+ const textColor = badge.textColor ?? palette.refLabel;
176
+
177
+ const transform =
178
+ badge.offsetX !== 0 || badge.offsetY !== 0
179
+ ? [{ translateX: badge.offsetX }, { translateY: badge.offsetY }]
180
+ : undefined;
181
+
182
+ const slots: React.ReactElement[] = [];
183
+ for (let i = 0; i < MAX_GROUP_PILLS; i++) {
184
+ slots.push(
185
+ <GroupCountPill
186
+ key={i}
187
+ grouping={grouping}
188
+ index={i}
189
+ padding={padding}
190
+ canvasWidth={canvasWidth}
191
+ font={font}
192
+ position={badge.position}
193
+ icon={badge.icon}
194
+ showText={badge.showText}
195
+ format={format}
196
+ baselineOffset={baselineOffset}
197
+ pillH={pillH}
198
+ radius={badge.radius}
199
+ borderWidth={badge.borderWidth}
200
+ color={borderColor}
201
+ background={background}
202
+ textColor={textColor}
203
+ />,
204
+ );
205
+ }
206
+ return <Group transform={transform}>{slots}</Group>;
207
+ }