react-native-livechart 4.3.0 → 4.5.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 (42) hide show
  1. package/dist/components/LiveChart.d.ts.map +1 -1
  2. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  3. package/dist/components/LoadingOverlay.d.ts +9 -1
  4. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  5. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  6. package/dist/constants.d.ts +14 -0
  7. package/dist/constants.d.ts.map +1 -1
  8. package/dist/core/resolveConfig.d.ts +35 -1
  9. package/dist/core/resolveConfig.d.ts.map +1 -1
  10. package/dist/draw/markerAtlas.d.ts +9 -2
  11. package/dist/draw/markerAtlas.d.ts.map +1 -1
  12. package/dist/hooks/useChartPaths.d.ts +5 -1
  13. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  14. package/dist/hooks/useChartReveal.d.ts +3 -1
  15. package/dist/hooks/useChartReveal.d.ts.map +1 -1
  16. package/dist/hooks/useMarkers.d.ts.map +1 -1
  17. package/dist/hooks/useModeBlend.d.ts +4 -1
  18. package/dist/hooks/useModeBlend.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/math/markerCluster.d.ts +8 -1
  22. package/dist/math/markerCluster.d.ts.map +1 -1
  23. package/dist/math/squiggly.d.ts +8 -5
  24. package/dist/math/squiggly.d.ts.map +1 -1
  25. package/dist/types.d.ts +128 -2
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/components/LiveChart.tsx +126 -42
  29. package/src/components/LiveChartSeries.tsx +95 -26
  30. package/src/components/LoadingOverlay.tsx +25 -6
  31. package/src/components/MarkerOverlay.tsx +135 -37
  32. package/src/constants.ts +17 -0
  33. package/src/core/resolveConfig.ts +67 -0
  34. package/src/draw/markerAtlas.ts +30 -1
  35. package/src/hooks/useChartPaths.ts +11 -1
  36. package/src/hooks/useChartReveal.ts +7 -2
  37. package/src/hooks/useMarkers.ts +2 -0
  38. package/src/hooks/useModeBlend.ts +5 -3
  39. package/src/index.ts +3 -0
  40. package/src/math/markerCluster.ts +8 -1
  41. package/src/math/squiggly.ts +21 -7
  42. package/src/types.ts +131 -2
@@ -6,12 +6,14 @@
6
6
  */
7
7
  import { Canvas, Group } from "@shopify/react-native-skia";
8
8
  import { useLayoutEffect, useState } from "react";
9
- import { View } from "react-native";
9
+ import { StyleSheet, View } from "react-native";
10
10
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
11
- import {
11
+ import Animated, {
12
12
  useAnimatedReaction,
13
+ useAnimatedStyle,
13
14
  useDerivedValue,
14
15
  useSharedValue,
16
+ withTiming,
15
17
  type SharedValue,
16
18
  } from "react-native-reanimated";
17
19
  import { scheduleOnRN } from "react-native-worklets";
@@ -19,6 +21,7 @@ import {
19
21
  DEFAULT_ACCENT_COLOR,
20
22
  HOLD_TO_SCRUB_MS,
21
23
  MAX_MULTI_SERIES,
24
+ SCRUB_OVERLAY_FADE_MS,
22
25
  } from "../constants";
23
26
  import {
24
27
  lineColorsSignatureFromArray,
@@ -33,12 +36,14 @@ import {
33
36
  resolveGridStyle,
34
37
  resolveLeftEdgeFade,
35
38
  resolveLegend,
39
+ resolveLoading,
36
40
  resolveMarkerCluster,
37
41
  resolveMetrics,
38
42
  resolveMultiSeriesDot,
39
43
  resolveReturnToLiveMs,
40
44
  resolveScrub,
41
45
  resolveSelectionDot,
46
+ resolveTransitions,
42
47
  resolveXAxis,
43
48
  resolveYAxis,
44
49
  resolveZoom,
@@ -126,6 +131,7 @@ function useLiveChartSeriesController({
126
131
  timeWindow = 30,
127
132
  paused = false,
128
133
  loading = false,
134
+ transitions,
129
135
  smoothing = 0.08,
130
136
  exaggerate = false,
131
137
  nonNegative = false,
@@ -289,7 +295,18 @@ function useLiveChartSeriesController({
289
295
  return false;
290
296
  });
291
297
 
292
- const reveal = useChartReveal(loading, hasData);
298
+ // Resolve the loading shell: null = not loading, else the styled config.
299
+ const loadingCfg = resolveLoading(loading);
300
+ const loadingActive = loadingCfg !== null;
301
+ // Multi-series is always lines, so only the reveal transition applies (no
302
+ // candle↔line crossfade); `transitions.mode` is accepted but inert here.
303
+ const transitionsCfg = resolveTransitions(transitions);
304
+ const reveal = useChartReveal(
305
+ loadingActive,
306
+ hasData,
307
+ false,
308
+ transitionsCfg.reveal,
309
+ );
293
310
 
294
311
  const effectiveSeries = useMultiSeriesReverseMorphInputs({
295
312
  series,
@@ -453,6 +470,23 @@ function useLiveChartSeriesController({
453
470
 
454
471
  const backgroundColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
455
472
 
473
+ // Fade markers + reference lines out while scrubbing when
474
+ // `scrub.hideOverlaysOnScrub` is set. Eased off the scrub-ACTIVE flag (not the
475
+ // crosshair edge fade, which would resurface them near the live dot); only a
476
+ // group opacity animates — the overlay draws stay intact. See `LiveChart`.
477
+ const fadeOverlaysOnScrub =
478
+ scrubCfg !== null && scrubCfg.hideOverlaysOnScrub === true;
479
+ const overlayScrubFade = useDerivedValue(() =>
480
+ fadeOverlaysOnScrub
481
+ ? withTiming(crosshair.scrubActive.get() ? 0 : 1, {
482
+ duration: SCRUB_OVERLAY_FADE_MS,
483
+ })
484
+ : 1,
485
+ );
486
+ const markerGroupOpacity = useDerivedValue(
487
+ () => reveal.dotOpacity.get() * overlayScrubFade.get(),
488
+ );
489
+
456
490
  return {
457
491
  // passthrough props the render needs
458
492
  series,
@@ -484,6 +518,11 @@ function useLiveChartSeriesController({
484
518
  // engine + reveal
485
519
  engine,
486
520
  reveal,
521
+ // loading shell styling (null → not loading)
522
+ loadingLineColor: loadingCfg?.color,
523
+ loadingStrokeWidth: loadingCfg?.strokeWidth,
524
+ loadingAmplitude: loadingCfg?.amplitude,
525
+ loadingSpeed: loadingCfg?.speed,
487
526
  effectiveSeries,
488
527
  layoutHeight,
489
528
  onLayout,
@@ -500,6 +539,8 @@ function useLiveChartSeriesController({
500
539
  markersActive,
501
540
  markersSV,
502
541
  markerClusterCfg,
542
+ markerGroupOpacity,
543
+ overlayScrubFade,
503
544
  renderMarker,
504
545
  // selection dot: resolved config + fallback color (the leading series' color)
505
546
  selectionDot: selectionDotCfg,
@@ -545,10 +586,16 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
545
586
  markersActive,
546
587
  markersSV,
547
588
  markerClusterCfg,
589
+ markerGroupOpacity,
590
+ overlayScrubFade,
548
591
  renderMarker,
549
592
  series,
550
593
  emptyText,
551
594
  metricsCfg,
595
+ loadingLineColor,
596
+ loadingStrokeWidth,
597
+ loadingAmplitude,
598
+ loadingSpeed,
552
599
  } = model;
553
600
 
554
601
  return (
@@ -570,18 +617,21 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
570
617
 
571
618
  {/* Index keys: reference lines are a positional array and two may share
572
619
  value + label (e.g. duplicate working orders at the same price), which a
573
- content-derived key would collapse to one. */}
574
- {allRefLines.map((rl, i) => (
575
- <ReferenceLineOverlay
576
- key={i}
577
- engine={engine}
578
- padding={effectivePadding}
579
- line={rl}
580
- palette={palette}
581
- formatValue={formatValue}
582
- font={skiaFont}
583
- />
584
- ))}
620
+ content-derived key would collapse to one. Fade group lets
621
+ `scrub.hideOverlaysOnScrub` ease the lines out while scrubbing. */}
622
+ <Group opacity={overlayScrubFade}>
623
+ {allRefLines.map((rl, i) => (
624
+ <ReferenceLineOverlay
625
+ key={i}
626
+ engine={engine}
627
+ padding={effectivePadding}
628
+ line={rl}
629
+ palette={palette}
630
+ formatValue={formatValue}
631
+ font={skiaFont}
632
+ />
633
+ ))}
634
+ </Group>
585
635
 
586
636
  {dotCfg.valueLine && (
587
637
  <Group opacity={reveal.lineOpacity}>
@@ -654,7 +704,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
654
704
  )}
655
705
 
656
706
  {markersActive && (
657
- <Group opacity={reveal.dotOpacity}>
707
+ <Group opacity={markerGroupOpacity}>
658
708
  <MarkerOverlay
659
709
  markers={markersSV}
660
710
  engine={engine}
@@ -680,6 +730,10 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
680
730
  strokeWidth={strokeWidth}
681
731
  badge={false}
682
732
  emptyMetrics={metricsCfg.emptyState}
733
+ lineColor={loadingLineColor}
734
+ lineStrokeWidth={loadingStrokeWidth}
735
+ waveAmplitude={loadingAmplitude}
736
+ waveSpeed={loadingSpeed}
683
737
  />
684
738
  </Group>
685
739
  );
@@ -726,10 +780,11 @@ function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
726
780
  formatValue,
727
781
  skiaFont,
728
782
  degenShakeTransform,
783
+ overlayScrubFade,
729
784
  } = model;
730
785
  if (allRefLines.length === 0) return null;
731
786
  return (
732
- <Group transform={degenShakeTransform}>
787
+ <Group transform={degenShakeTransform} opacity={overlayScrubFade}>
733
788
  {allRefLines.map((rl, i) => (
734
789
  <ReferenceLineOverlay
735
790
  key={i}
@@ -778,8 +833,15 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
778
833
  markersSV,
779
834
  markerClusterCfg,
780
835
  renderMarker,
836
+ overlayScrubFade,
781
837
  } = model;
782
838
 
839
+ // Mirror the Skia overlay fade onto the RN custom-marker sibling so
840
+ // `scrub.hideOverlaysOnScrub` hides it with the Skia markers.
841
+ const overlayFadeStyle = useAnimatedStyle(() => ({
842
+ opacity: overlayScrubFade.get(),
843
+ }));
844
+
783
845
  // Extend the scrub dim past the plot's right edge to fully cover the series
784
846
  // dots (with their halo) and pulse rings, all centered on that edge. The
785
847
  // gutter reserves room beyond this for the value/Y-axis labels, drawn on top.
@@ -876,16 +938,23 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
876
938
  />
877
939
 
878
940
  {/* Custom-rendered markers — RN views floated over the canvas
879
- (non-Skia), pinned to each marker's live position. */}
941
+ (non-Skia), pinned to each marker's live position. Box-none fade
942
+ wrapper so `scrub.hideOverlaysOnScrub` hides them with the Skia
943
+ markers (full-bleed; children keep their own absolute positions). */}
880
944
  {markersActive && renderMarker && (
881
- <CustomMarkerOverlay
882
- markers={markersSV}
883
- renderMarker={renderMarker}
884
- engine={engine}
885
- padding={effectivePadding}
886
- series={series}
887
- cluster={markerClusterCfg}
888
- />
945
+ <Animated.View
946
+ pointerEvents="box-none"
947
+ style={[StyleSheet.absoluteFill, overlayFadeStyle]}
948
+ >
949
+ <CustomMarkerOverlay
950
+ markers={markersSV}
951
+ renderMarker={renderMarker}
952
+ engine={engine}
953
+ padding={effectivePadding}
954
+ series={series}
955
+ cluster={markerClusterCfg}
956
+ />
957
+ </Animated.View>
889
958
  )}
890
959
  </View>
891
960
  </GestureDetector>
@@ -62,6 +62,10 @@ export function LoadingOverlay({
62
62
  badgeTail = true,
63
63
  badgeMetrics = BADGE_METRICS_DEFAULTS,
64
64
  emptyMetrics = EMPTY_STATE_METRICS_DEFAULTS,
65
+ lineColor,
66
+ lineStrokeWidth,
67
+ waveAmplitude = 14,
68
+ waveSpeed = 1,
65
69
  }: {
66
70
  engine: ChartEngineLayout;
67
71
  padding: ChartPadding;
@@ -73,6 +77,14 @@ export function LoadingOverlay({
73
77
  isEmpty: SharedValue<boolean> | { value: boolean };
74
78
  emptyText: string;
75
79
  strokeWidth: number;
80
+ /** Loading squiggle + skeleton color. Omit → theme `gridLine`. */
81
+ lineColor?: string;
82
+ /** Loading squiggle stroke width. Omit → `strokeWidth`. */
83
+ lineStrokeWidth?: number;
84
+ /** Breathing-wave base amplitude (px). */
85
+ waveAmplitude?: number;
86
+ /** Breathing-wave speed multiplier. */
87
+ waveSpeed?: number;
76
88
  /** Mirror the badge prop so labels align with GridOverlay's label positions. */
77
89
  badge?: boolean;
78
90
  /** Whether the badge tail spike is shown; affects the left inset used for skeleton alignment. */
@@ -86,6 +98,11 @@ export function LoadingOverlay({
86
98
  const leftInset =
87
99
  badgeMetrics.dotGap + badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
88
100
 
101
+ // Loading-shell color (squiggle + skeleton placeholders) and squiggle stroke,
102
+ // both overridable via `loading={{ color, strokeWidth }}`.
103
+ const loadingColor = lineColor ?? palette.gridLine;
104
+ const loadingStroke = lineStrokeWidth ?? strokeWidth;
105
+
89
106
  // Squiggly path — built into a reused PathBuilder and detach()-ed each frame.
90
107
  const squigglyBuilder = usePathBuilder();
91
108
 
@@ -100,6 +117,8 @@ export function LoadingOverlay({
100
117
  engine.canvasHeight.get(),
101
118
  padding,
102
119
  engine.timestamp.get(),
120
+ waveAmplitude,
121
+ waveSpeed,
103
122
  );
104
123
  return buildSplineDetached(b, pts);
105
124
  });
@@ -225,8 +244,8 @@ export function LoadingOverlay({
225
244
  <Path
226
245
  path={squigglyPath}
227
246
  style="stroke"
228
- strokeWidth={strokeWidth}
229
- color={palette.gridLine}
247
+ strokeWidth={loadingStroke}
248
+ color={loadingColor}
230
249
  strokeCap="round"
231
250
  strokeJoin="round"
232
251
  />
@@ -255,7 +274,7 @@ export function LoadingOverlay({
255
274
  width={RECT_W}
256
275
  height={RECT_H}
257
276
  r={RECT_R}
258
- color={palette.gridLine}
277
+ color={loadingColor}
259
278
  />
260
279
  <RoundedRect
261
280
  x={lx}
@@ -263,7 +282,7 @@ export function LoadingOverlay({
263
282
  width={RECT_W}
264
283
  height={RECT_H}
265
284
  r={RECT_R}
266
- color={palette.gridLine}
285
+ color={loadingColor}
267
286
  />
268
287
  <RoundedRect
269
288
  x={lx}
@@ -271,7 +290,7 @@ export function LoadingOverlay({
271
290
  width={RECT_W}
272
291
  height={RECT_H}
273
292
  r={RECT_R}
274
- color={palette.gridLine}
293
+ color={loadingColor}
275
294
  />
276
295
  <RoundedRect
277
296
  x={lx}
@@ -279,7 +298,7 @@ export function LoadingOverlay({
279
298
  width={RECT_W}
280
299
  height={RECT_H}
281
300
  r={RECT_R}
282
- color={palette.gridLine}
301
+ color={loadingColor}
283
302
  />
284
303
 
285
304
  {/* Empty state label */}
@@ -4,6 +4,8 @@ import {
4
4
  Path,
5
5
  Skia,
6
6
  type SkFont,
7
+ type SkRect,
8
+ type SkRSXform,
7
9
  } from "@shopify/react-native-skia";
8
10
  import { useMemo, useRef, useState } from "react";
9
11
  import { PixelRatio } from "react-native";
@@ -20,6 +22,7 @@ import {
20
22
  BADGE_TEXT_SCALE,
21
23
  buildMarkerAtlas,
22
24
  defaultMarkerColor,
25
+ GROUP_BADGE_SIG,
23
26
  groupBgSig,
24
27
  groupCountText,
25
28
  groupGlyphSig,
@@ -47,6 +50,77 @@ import type {
47
50
 
48
51
  const OFF = -9999;
49
52
 
53
+ /** Size of the `showGroupCount` corner badge relative to a full count badge. */
54
+ const GROUP_CORNER_SCALE = 0.62;
55
+ /** How far the corner count badge sits toward the glyph's top-right corner
56
+ * (1 = centered exactly on the corner; < 1 pulls it inward). */
57
+ const GROUP_CORNER_INSET = 0.82;
58
+
59
+ /**
60
+ * Append a collapsed-cluster count badge (round bg + centered, proportionally
61
+ * kerned digits) to the atlas blit lists at `(cx, cy)`, scaled by `mul` — full
62
+ * size as the default group badge, shrunk in a glyph's corner for `showGroupCount`.
63
+ *
64
+ * A **module-level** worklet (not a closure inside the per-frame derived value):
65
+ * the `transforms` / `sprites` arrays are passed in by reference so the pushes land
66
+ * on the real per-frame lists. A nested `"worklet"` closure would capture them by
67
+ * serialized copy, so its pushes would be silently dropped and the badge never drawn.
68
+ */
69
+ function pushGroupCountBadge(
70
+ transforms: SkRSXform[],
71
+ sprites: SkRect[],
72
+ cells: Record<string, AtlasCell>,
73
+ digitWidths: Record<string, number>,
74
+ invScale: number,
75
+ cx: number,
76
+ cy: number,
77
+ repColor: string,
78
+ count: number,
79
+ mul: number,
80
+ ): void {
81
+ "worklet";
82
+ const text = groupCountText(count);
83
+ const bg = cells[groupBgSig(repColor)];
84
+ if (bg) {
85
+ transforms.push(
86
+ Skia.RSXform(invScale * mul, 0, cx - (bg.w * mul) / 2, cy - (bg.h * mul) / 2),
87
+ );
88
+ sprites.push(bg.rect);
89
+ }
90
+ // Lay digits out proportionally (each by its own ink width), scaled to fit the
91
+ // small round badge, centered. A negative kern (fraction of a digit) closes the
92
+ // font's side-bearing gap so "12" reads tight, not "1 2".
93
+ const S = BADGE_TEXT_SCALE;
94
+ let maxDW = 1;
95
+ for (let d = 0; d < 10; d++) {
96
+ const dw = digitWidths["" + d] ?? 0;
97
+ if (dw > maxDW) maxDW = dw;
98
+ }
99
+ const kern = -maxDW * S * 0.42;
100
+ let totalW = 0;
101
+ for (let c = 0; c < text.length; c++) {
102
+ totalW += (digitWidths[text[c]] ?? 0) * S + (c > 0 ? kern : 0);
103
+ }
104
+ let dx = cx - (totalW * mul) / 2;
105
+ for (let c = 0; c < text.length; c++) {
106
+ const w = (digitWidths[text[c]] ?? 0) * S * mul;
107
+ const gc = cells[groupGlyphSig(text[c])];
108
+ if (gc) {
109
+ const gcx = dx + w / 2;
110
+ transforms.push(
111
+ Skia.RSXform(
112
+ invScale * S * mul,
113
+ 0,
114
+ gcx - (gc.w * S * mul) / 2,
115
+ cy - (gc.h * S * mul) / 2,
116
+ ),
117
+ );
118
+ sprites.push(gc.rect);
119
+ }
120
+ dx += w + kern * mul;
121
+ }
122
+ }
123
+
50
124
  /**
51
125
  * Self-projecting fallback for the axis-anchored kinds (graduation stem + flag,
52
126
  * clawback box) that can't be fixed-size atlas sprites. Rare, so the per-glyph
@@ -246,6 +320,14 @@ export function MarkerOverlay({
246
320
  // retina canvases instead of being upscaled from a logical-sized texture.
247
321
  const dpr = PixelRatio.get();
248
322
  const clusterStacked = cluster.mode === "stacked";
323
+ // A dedicated group badge (object form) bakes one extra atlas cell; its image
324
+ // identity + styling drive the rebuild key alongside the marker appearances.
325
+ const groupBadgeCfg =
326
+ typeof cluster.groupBadge === "object" ? cluster.groupBadge : undefined;
327
+ const groupBadgeImage = groupBadgeCfg?.image;
328
+ const groupBadgeKey = groupBadgeCfg
329
+ ? `${groupBadgeCfg.icon ?? ""}|${groupBadgeCfg.color ?? ""}|${groupBadgeCfg.pill ? 1 : 0}|${groupBadgeCfg.size ?? ""}`
330
+ : "";
249
331
  const atlas = useMemo(
250
332
  () =>
251
333
  buildMarkerAtlas(
@@ -254,9 +336,10 @@ export function MarkerOverlay({
254
336
  font,
255
337
  dpr,
256
338
  clusterStacked,
339
+ groupBadgeCfg,
257
340
  ),
258
- // eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey capture the inputs that change cell pixels
259
- [appearanceKey, paletteKey, font, dpr, clusterStacked],
341
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey/groupBadgeKey capture the inputs that change cell pixels
342
+ [appearanceKey, paletteKey, font, dpr, clusterStacked, groupBadgeKey, groupBadgeImage],
260
343
  );
261
344
  const cells: Record<string, AtlasCell> = atlas.cells;
262
345
  const atlasImage = atlas.image;
@@ -301,8 +384,8 @@ export function MarkerOverlay({
301
384
  lineLinear,
302
385
  });
303
386
  clusterMarkers(ms, buf, { config: cluster });
304
- const transforms = [];
305
- const sprites = [];
387
+ const transforms: SkRSXform[] = [];
388
+ const sprites: SkRect[] = [];
306
389
  for (let i = 0; i < ms.length; i++) {
307
390
  const pt = buf[i];
308
391
  // Collapsed-cluster members fold into their representative's badge.
@@ -311,46 +394,61 @@ export function MarkerOverlay({
311
394
  if (isConnectorMarker(m)) continue;
312
395
  // Custom-rendered markers are floated as RN views, not drawn here.
313
396
  if (customIds[m.id]) continue;
314
- // Collapsed cluster: draw a count badge (bg stadium + composed digits)
315
- // instead of the marker's own glyph, all within this one `drawAtlas`.
397
+ // Collapsed cluster. By default a round count badge; with
398
+ // `groupBadge: "marker"` the representative marker's own glyph, or with a
399
+ // `MarkerGroupBadge` object a dedicated baked badge — either optionally
400
+ // carrying a corner count, all within this one `drawAtlas`.
316
401
  if (pt.isGrouped) {
317
402
  const repColor = m.color ?? defaultMarkerColor(m.kind, palette);
318
- const text = groupCountText(pt.groupCount);
319
- const bg = cells[groupBgSig(repColor)];
320
- if (bg) {
403
+ const gb = cluster.groupBadge;
404
+ // Pick the non-count glyph cell: a dedicated group badge (object form)
405
+ // or the representative marker's own appearance.
406
+ const glyphCell =
407
+ typeof gb === "object" && gb !== null
408
+ ? cells[GROUP_BADGE_SIG]
409
+ : gb === "marker"
410
+ ? cells[markerAppearanceSig(m)]
411
+ : undefined;
412
+ if (glyphCell) {
321
413
  transforms.push(
322
- Skia.RSXform(invScale, 0, pt.x - bg.w / 2, pt.y - bg.h / 2),
414
+ Skia.RSXform(
415
+ invScale,
416
+ 0,
417
+ pt.x - glyphCell.w / 2,
418
+ pt.y - glyphCell.h / 2,
419
+ ),
323
420
  );
324
- sprites.push(bg.rect);
325
- }
326
- // Lay the digits out proportionally (each by its own ink width), scaled
327
- // down to fit the small round badge, and center the whole number. A
328
- // negative kern (fraction of a digit) closes the font's side-bearing gap
329
- // so "12" reads tight, not "1 2".
330
- const S = BADGE_TEXT_SCALE;
331
- let maxDW = 1;
332
- for (let d = 0; d < 10; d++) {
333
- const dw = digitWidths["" + d] ?? 0;
334
- if (dw > maxDW) maxDW = dw;
335
- }
336
- const kern = -maxDW * S * 0.42;
337
- let totalW = 0;
338
- for (let c = 0; c < text.length; c++) {
339
- totalW += (digitWidths[text[c]] ?? 0) * S + (c > 0 ? kern : 0);
340
- }
341
- let dx = pt.x - totalW / 2;
342
- for (let c = 0; c < text.length; c++) {
343
- const w = (digitWidths[text[c]] ?? 0) * S;
344
- const gc = cells[groupGlyphSig(text[c])];
345
- if (gc) {
346
- const cx = dx + w / 2;
347
- transforms.push(
348
- Skia.RSXform(invScale * S, 0, cx - (gc.w * S) / 2, pt.y - (gc.h * S) / 2),
421
+ sprites.push(glyphCell.rect);
422
+ if (cluster.showGroupCount) {
423
+ pushGroupCountBadge(
424
+ transforms,
425
+ sprites,
426
+ cells,
427
+ digitWidths,
428
+ invScale,
429
+ pt.x + (glyphCell.w / 2) * GROUP_CORNER_INSET,
430
+ pt.y - (glyphCell.h / 2) * GROUP_CORNER_INSET,
431
+ repColor,
432
+ pt.groupCount,
433
+ GROUP_CORNER_SCALE,
349
434
  );
350
- sprites.push(gc.rect);
351
435
  }
352
- dx += w + kern;
436
+ continue;
353
437
  }
438
+ // Default — or fall back here when a non-count badge has no baked cell
439
+ // (e.g. an empty group-badge config): the round count badge.
440
+ pushGroupCountBadge(
441
+ transforms,
442
+ sprites,
443
+ cells,
444
+ digitWidths,
445
+ invScale,
446
+ pt.x,
447
+ pt.y,
448
+ repColor,
449
+ pt.groupCount,
450
+ 1,
451
+ );
354
452
  continue;
355
453
  }
356
454
  const cell = cells[markerAppearanceSig(m)];
package/src/constants.ts CHANGED
@@ -31,6 +31,23 @@ export const HOLD_TO_SCRUB_MS = 500;
31
31
  */
32
32
  export const RETURN_TO_LIVE_MS = 450;
33
33
 
34
+ /**
35
+ * Duration (ms) of the fade applied to annotation overlays (markers + reference
36
+ * lines) when `scrub.hideOverlaysOnScrub` is on — how long they take to ease out as a
37
+ * scrub starts and back in on release. Driven by the scrub-active flag, eased on
38
+ * the UI thread. Shared by `LiveChart` and `LiveChartSeries`.
39
+ */
40
+ export const SCRUB_OVERLAY_FADE_MS = 150;
41
+
42
+ /**
43
+ * Base wave amplitude (px) of the breathing loading squiggle — it breathes
44
+ * between `0.4×` and `1.0×` this. Overridable via `loading={{ amplitude }}`.
45
+ */
46
+ export const LOADING_WAVE_AMPLITUDE = 14;
47
+
48
+ /** Default breathing-wave speed multiplier (`1` = built-in cadence). */
49
+ export const LOADING_WAVE_SPEED = 1;
50
+
34
51
  // ─── Metric default tokens (single source of truth for LiveChartMetrics) ─────
35
52
  // The resolved `metrics` config (see resolveMetrics) is assembled from these
36
53
  // objects. Draw/worklet helpers default their metric params to the matching