react-native-livechart 1.0.0 → 2.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 (84) hide show
  1. package/README.md +15 -3
  2. package/dist/components/CrosshairLine.d.ts +6 -1
  3. package/dist/components/CrosshairLine.d.ts.map +1 -1
  4. package/dist/components/CrosshairOverlay.d.ts +7 -1
  5. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  6. package/dist/components/DotOverlay.d.ts +12 -5
  7. package/dist/components/DotOverlay.d.ts.map +1 -1
  8. package/dist/components/LiveChart.d.ts.map +1 -1
  9. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  10. package/dist/components/LoadingOverlay.d.ts +6 -2
  11. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  12. package/dist/components/MarkerOverlay.d.ts +8 -4
  13. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  14. package/dist/components/MultiSeriesDots.d.ts +8 -2
  15. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  16. package/dist/components/YAxisOverlay.d.ts +4 -2
  17. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  18. package/dist/constants.d.ts +16 -13
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/core/liveChartEngineTick.d.ts +2 -0
  21. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  22. package/dist/core/liveChartSeriesEngineTick.d.ts +2 -0
  23. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  24. package/dist/core/resolveConfig.d.ts +34 -4
  25. package/dist/core/resolveConfig.d.ts.map +1 -1
  26. package/dist/core/useLiveChartEngine.d.ts +3 -0
  27. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  28. package/dist/core/useLiveChartSeriesEngine.d.ts +3 -0
  29. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  30. package/dist/draw/candle.d.ts +2 -2
  31. package/dist/draw/candle.d.ts.map +1 -1
  32. package/dist/draw/grid.d.ts +2 -1
  33. package/dist/draw/grid.d.ts.map +1 -1
  34. package/dist/draw/line.d.ts +8 -8
  35. package/dist/draw/line.d.ts.map +1 -1
  36. package/dist/draw/markerAtlas.d.ts +44 -0
  37. package/dist/draw/markerAtlas.d.ts.map +1 -0
  38. package/dist/hooks/crosshairShared.d.ts +12 -4
  39. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  40. package/dist/hooks/resolveChartLayout.d.ts +3 -1
  41. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  42. package/dist/hooks/useBadge.d.ts +2 -2
  43. package/dist/hooks/useBadge.d.ts.map +1 -1
  44. package/dist/hooks/useCandlePaths.d.ts +2 -2
  45. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  46. package/dist/hooks/useCrosshair.d.ts +3 -1
  47. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  48. package/dist/hooks/useCrosshairSeries.d.ts +3 -1
  49. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  50. package/dist/hooks/useYAxis.d.ts +2 -1
  51. package/dist/hooks/useYAxis.d.ts.map +1 -1
  52. package/dist/index.d.ts +1 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/types.d.ts +136 -5
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/components/CrosshairLine.tsx +30 -10
  58. package/src/components/CrosshairOverlay.tsx +31 -10
  59. package/src/components/DotOverlay.tsx +29 -14
  60. package/src/components/LiveChart.tsx +67 -15
  61. package/src/components/LiveChartSeries.tsx +76 -17
  62. package/src/components/LoadingOverlay.tsx +20 -11
  63. package/src/components/MarkerOverlay.tsx +119 -164
  64. package/src/components/MultiSeriesDots.tsx +30 -2
  65. package/src/components/YAxisOverlay.tsx +8 -4
  66. package/src/constants.ts +54 -14
  67. package/src/core/liveChartEngineTick.ts +7 -2
  68. package/src/core/liveChartSeriesEngineTick.ts +8 -2
  69. package/src/core/resolveConfig.ts +137 -38
  70. package/src/core/useLiveChartEngine.ts +6 -0
  71. package/src/core/useLiveChartSeriesEngine.ts +6 -0
  72. package/src/draw/candle.ts +11 -6
  73. package/src/draw/grid.ts +5 -5
  74. package/src/draw/line.ts +106 -25
  75. package/src/draw/markerAtlas.ts +304 -0
  76. package/src/hooks/crosshairShared.ts +24 -3
  77. package/src/hooks/resolveChartLayout.ts +11 -2
  78. package/src/hooks/useBadge.ts +38 -24
  79. package/src/hooks/useCandlePaths.ts +4 -2
  80. package/src/hooks/useCrosshair.ts +15 -2
  81. package/src/hooks/useCrosshairSeries.ts +8 -2
  82. package/src/hooks/useYAxis.ts +4 -1
  83. package/src/index.ts +7 -0
  84. package/src/types.ts +147 -5
@@ -15,6 +15,7 @@ export function CrosshairLine({
15
15
  padding,
16
16
  palette,
17
17
  dimOpacity = 0.3,
18
+ liveDotExtent = 0,
18
19
  crosshairLineColor,
19
20
  crosshairDimColor,
20
21
  }: {
@@ -25,30 +26,49 @@ export function CrosshairLine({
25
26
  palette: LiveChartPalette;
26
27
  /** Opacity of content right of the crosshair (dstOut fade). Default 0.3. */
27
28
  dimOpacity?: number;
29
+ /** How far the live series dots extend past the plot's right edge. The dim
30
+ * region extends by this much so it fully covers the dots — centered on that
31
+ * edge, otherwise only half-dimmed — while leaving the value/Y-axis labels
32
+ * the gutter reserves beyond them bright. Default 0. */
33
+ liveDotExtent?: number;
28
34
  crosshairLineColor?: string;
29
35
  crosshairDimColor?: string;
30
36
  }) {
31
- const p1 = useDerivedValue(() => ({
32
- x: scrubX.value,
33
- y: padding.top,
34
- }));
35
- const p2 = useDerivedValue(() => ({
36
- x: scrubX.value,
37
- y: engine.canvasHeight.value - padding.bottom,
38
- }));
37
+ // Explicit dependency arrays: with React Compiler enabled, Reanimated's
38
+ // auto-detected worklet dependencies can change array size between renders
39
+ // (e.g. when `liveDotExtent` flips 0 → the live-dot extent), which trips
40
+ // React's "final argument changed size between renders" error. Listing the
41
+ // captured plain values keeps the dependency array a constant size. SharedValue
42
+ // reads stay reactive regardless of this list.
43
+ const p1 = useDerivedValue(
44
+ () => ({
45
+ x: scrubX.value,
46
+ y: padding.top,
47
+ }),
48
+ [scrubX, padding.top],
49
+ );
50
+ const p2 = useDerivedValue(
51
+ () => ({
52
+ x: scrubX.value,
53
+ y: engine.canvasHeight.value - padding.bottom,
54
+ }),
55
+ [scrubX, engine.canvasHeight, padding.bottom],
56
+ );
39
57
 
40
58
  const dimWidth = useDerivedValue(() => {
41
- const rightEdge = engine.canvasWidth.value - padding.right;
59
+ const rightEdge = engine.canvasWidth.value - padding.right + liveDotExtent;
42
60
  return Math.max(0, rightEdge - scrubX.value);
43
- });
61
+ }, [engine.canvasWidth, padding.right, liveDotExtent, scrubX]);
44
62
  const dimHeight = useDerivedValue(
45
63
  () => engine.canvasHeight.value - padding.top - padding.bottom,
64
+ [engine.canvasHeight, padding.top, padding.bottom],
46
65
  );
47
66
 
48
67
  // dstOut erase color: alpha = fraction of trailing content to remove, ramped
49
68
  // by the crosshair fade-in. RGB is irrelevant for dstOut.
50
69
  const dimErase = useDerivedValue(
51
70
  () => `rgba(0,0,0,${(1 - dimOpacity) * crosshairOpacity.value})`,
71
+ [dimOpacity, crosshairOpacity],
52
72
  );
53
73
 
54
74
  return (
@@ -26,6 +26,7 @@ export function CrosshairOverlay({
26
26
  showTooltip = true,
27
27
  children,
28
28
  dimOpacity = 0.3,
29
+ liveDotExtent = 0,
29
30
  crosshairLineColor,
30
31
  crosshairDimColor,
31
32
  tooltipBackground,
@@ -46,27 +47,46 @@ export function CrosshairOverlay({
46
47
  children?: ReactNode;
47
48
  /** Opacity of content right of the crosshair (dstOut fade). Default 0.3. */
48
49
  dimOpacity?: number;
50
+ /** How far the live dot (and its pulse ring) extends past the plot's right
51
+ * edge. The dim region extends by this much so it fully covers the live
52
+ * indicator — which is centered on that edge and would otherwise be only
53
+ * half-dimmed — while stopping short of the Y-axis labels the gutter
54
+ * reserves beyond it. Default 0. */
55
+ liveDotExtent?: number;
49
56
  crosshairLineColor?: string;
50
57
  crosshairDimColor?: string;
51
58
  tooltipBackground?: string;
52
59
  tooltipColor?: string;
53
60
  tooltipBorderColor?: string;
54
61
  }) {
55
- const p1 = useDerivedValue(() => ({
56
- x: scrubX.value,
57
- y: padding.top,
58
- }));
59
- const p2 = useDerivedValue(() => ({
60
- x: scrubX.value,
61
- y: engine.canvasHeight.value - padding.bottom,
62
- }));
62
+ // Explicit dependency arrays: with React Compiler enabled, Reanimated's
63
+ // auto-detected worklet dependencies can change array size between renders
64
+ // (e.g. when `liveDotExtent` flips 0 → the live-dot extent), which trips
65
+ // React's "final argument changed size between renders" error. Listing the
66
+ // captured plain values keeps the dependency array a constant size. SharedValue
67
+ // reads stay reactive regardless of this list.
68
+ const p1 = useDerivedValue(
69
+ () => ({
70
+ x: scrubX.value,
71
+ y: padding.top,
72
+ }),
73
+ [scrubX, padding.top],
74
+ );
75
+ const p2 = useDerivedValue(
76
+ () => ({
77
+ x: scrubX.value,
78
+ y: engine.canvasHeight.value - padding.bottom,
79
+ }),
80
+ [scrubX, engine.canvasHeight, padding.bottom],
81
+ );
63
82
 
64
83
  const dimWidth = useDerivedValue(() => {
65
- const rightEdge = engine.canvasWidth.value - padding.right;
84
+ const rightEdge = engine.canvasWidth.value - padding.right + liveDotExtent;
66
85
  return Math.max(0, rightEdge - scrubX.value);
67
- });
86
+ }, [engine.canvasWidth, padding.right, liveDotExtent, scrubX]);
68
87
  const dimHeight = useDerivedValue(
69
88
  () => engine.canvasHeight.value - padding.top - padding.bottom,
89
+ [engine.canvasHeight, padding.top, padding.bottom],
70
90
  );
71
91
 
72
92
  const tipX = useDerivedValue(() => tooltipLayout.value.x);
@@ -85,6 +105,7 @@ export function CrosshairOverlay({
85
105
  // ramped by the crosshair fade-in. Color RGB is irrelevant for dstOut.
86
106
  const dimErase = useDerivedValue(
87
107
  () => `rgba(0,0,0,${(1 - dimOpacity) * crosshairOpacity.value})`,
108
+ [dimOpacity, crosshairOpacity],
88
109
  );
89
110
 
90
111
  return (
@@ -1,17 +1,19 @@
1
1
  import { Circle, Group } from "@shopify/react-native-skia";
2
2
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
3
- import type { ResolvedPulseConfig } from "../core/resolveConfig";
3
+ import type {
4
+ ResolvedDotRingConfig,
5
+ ResolvedPulseConfig,
6
+ } from "../core/resolveConfig";
4
7
  import type { LiveChartPalette } from "../types";
5
8
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
6
9
 
7
10
  const MIN_PULSE_RADIUS = 9;
8
- const DOT_OUTER_RADIUS = 6.5;
9
- const DOT_INNER_RADIUS = 3.5;
10
11
 
11
12
  /**
12
- * Live dot + expanding pulse ring. Peak ring size uses `pulse.maxRadius` and
13
- * `pulse.strokeWidth`; chart padding reserves the same outer extent via
14
- * `pulseRadialOutset` in `draw/line.ts` (see `resolveChartLayout`).
13
+ * Live dot + expanding pulse ring. The dot is a color-filled circle of `radius`
14
+ * with an optional contrasting outer `ring` (halo). Peak pulse size uses
15
+ * `pulse.maxRadius` / `pulse.strokeWidth`; chart padding reserves the same outer
16
+ * extent via `pulseRadialOutset` in `draw/line.ts` (see `resolveChartLayout`).
15
17
  */
16
18
  export function DotOverlay({
17
19
  dotX,
@@ -19,13 +21,24 @@ export function DotOverlay({
19
21
  palette,
20
22
  engine,
21
23
  pulse,
24
+ radius,
25
+ ring,
26
+ color,
22
27
  }: {
23
28
  dotX: SharedValue<number>;
24
29
  dotY: SharedValue<number>;
25
30
  palette: LiveChartPalette;
26
31
  engine: ChartEngineLayout;
27
32
  pulse: ResolvedPulseConfig | null;
33
+ /** Radius of the color-filled dot in pixels. */
34
+ radius: number;
35
+ /** Outer halo ring, or `null` for a flat dot. */
36
+ ring: ResolvedDotRingConfig | null;
37
+ /** Dot (and pulse) fill color; falls back to the chart line color. */
38
+ color: string | undefined;
28
39
  }) {
40
+ const dotColor = color ?? palette.line;
41
+
29
42
  const pulseRadius = useDerivedValue(() => {
30
43
  if (!pulse) return 0;
31
44
  const nowMs = engine.timestamp.value * 1000;
@@ -49,21 +62,23 @@ export function DotOverlay({
49
62
  cx={dotX}
50
63
  cy={dotY}
51
64
  r={pulseRadius}
52
- color={palette.line}
65
+ color={dotColor}
53
66
  style="stroke"
54
67
  strokeWidth={pulse.strokeWidth}
55
68
  opacity={pulseOpacity}
56
69
  />
57
70
  )}
58
71
 
59
- <Circle
60
- cx={dotX}
61
- cy={dotY}
62
- r={DOT_OUTER_RADIUS}
63
- color={palette.badgeOuterBg}
64
- />
72
+ {ring && (
73
+ <Circle
74
+ cx={dotX}
75
+ cy={dotY}
76
+ r={radius + ring.width}
77
+ color={ring.color ?? palette.badgeOuterBg}
78
+ />
79
+ )}
65
80
 
66
- <Circle cx={dotX} cy={dotY} r={DOT_INNER_RADIUS} color={palette.line} />
81
+ <Circle cx={dotX} cy={dotY} r={radius} color={dotColor} />
67
82
  </Group>
68
83
  );
69
84
  }
@@ -21,9 +21,11 @@ import { DEFAULT_ACCENT_COLOR } from "../constants";
21
21
  import {
22
22
  resolveBadge,
23
23
  resolveDegen,
24
+ resolveDot,
24
25
  resolveGradient,
25
26
  resolveGridStyle,
26
27
  resolveLeftEdgeFade,
28
+ resolveMetrics,
27
29
  resolvePulse,
28
30
  resolveScrub,
29
31
  resolveTradeStream,
@@ -32,6 +34,7 @@ import {
32
34
  resolveYAxis,
33
35
  } from "../core/resolveConfig";
34
36
  import { useLiveChartEngine } from "../core/useLiveChartEngine";
37
+ import { pulseRadialOutset } from "../draw/line";
35
38
  import { resolveChartLayout } from "../hooks/resolveChartLayout";
36
39
  import { useBadge } from "../hooks/useBadge";
37
40
  import { useCandlePaths } from "../hooks/useCandlePaths";
@@ -126,12 +129,14 @@ function useLiveChartController({
126
129
  badge = true,
127
130
  momentum = true,
128
131
  pulse = true,
132
+ dot,
129
133
  valueLine = true,
130
134
  showValue = false,
131
135
  valueMomentumColor = false,
132
136
  referenceLines,
133
137
  gridStyle,
134
138
  palette: paletteOverride,
139
+ metrics,
135
140
  scrub = true,
136
141
  tradeStream,
137
142
  degen,
@@ -158,9 +163,13 @@ function useLiveChartController({
158
163
  const gradientCfg = isCandle ? null : resolveGradient(gradient);
159
164
  const valueLineCfg = resolveValueLine(valueLine);
160
165
  const pulseCfg = resolvePulse(pulse);
166
+ const dotCfg = resolveDot(dot);
167
+ // Outer footprint of the dot (color-filled radius plus the halo ring).
168
+ const dotOuterRadius = dotCfg.radius + (dotCfg.ring?.width ?? 0);
161
169
  const gridStyleCfg = resolveGridStyle(gridStyle);
162
170
  const degenCfg = resolveDegen(degen);
163
171
  const tradeStreamResolved = resolveTradeStream(tradeStream);
172
+ const metricsCfg = resolveMetrics(metrics);
164
173
 
165
174
  const allRefLines = referenceLines ?? [];
166
175
  const refValues = collectReferenceValues(allRefLines);
@@ -221,6 +230,7 @@ function useLiveChartController({
221
230
  insetsOverride: insets,
222
231
  yAxis: yAxisCfg !== null,
223
232
  badge: badgeCfg !== null,
233
+ badgeMetrics: metricsCfg.badge,
224
234
  badgeUsesRightGutter,
225
235
  badgeShowTail: badgeCfg?.tail ?? true,
226
236
  xAxis: xAxisCfg !== null,
@@ -260,6 +270,7 @@ function useLiveChartController({
260
270
  timeWindow,
261
271
  paused,
262
272
  smoothing,
273
+ adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
263
274
  exaggerate,
264
275
  referenceValues: refValues,
265
276
  nonNegative,
@@ -294,6 +305,7 @@ function useLiveChartController({
294
305
  isCandle ? liveEngine : liveCandle,
295
306
  candleWidth,
296
307
  isCandle,
308
+ metricsCfg.candle,
297
309
  );
298
310
  const { dotX, dotY } = useLiveDot(engine, effectivePadding);
299
311
 
@@ -319,6 +331,7 @@ function useLiveChartController({
319
331
  formatValue,
320
332
  skiaFont,
321
333
  yAxisCfg?.minGap ?? 36,
334
+ metricsCfg.grid,
322
335
  );
323
336
 
324
337
  const { xAxisEntries } = useXAxis(
@@ -339,6 +352,8 @@ function useLiveChartController({
339
352
  momentumSV,
340
353
  badgeCfg?.position ?? "right",
341
354
  badgeCfg?.background,
355
+ metricsCfg.badge,
356
+ metricsCfg.motion.badgeColorSpeed,
342
357
  );
343
358
 
344
359
  // Scrub/crosshair must see the same stash-backed candles as the engine.
@@ -361,6 +376,7 @@ function useLiveChartController({
361
376
  scrubCfg !== null,
362
377
  onScrub,
363
378
  candleOpts,
379
+ scrubCfg?.panGestureDelay ?? 0,
364
380
  );
365
381
 
366
382
  const markersActive = markers != null;
@@ -414,10 +430,13 @@ function useLiveChartController({
414
430
  gradientCfg,
415
431
  valueLineCfg,
416
432
  pulseCfg,
433
+ dotCfg,
434
+ dotOuterRadius,
417
435
  gridStyleCfg,
418
436
  degenCfg,
419
437
  tradeStreamResolved,
420
438
  leftEdgeFadeCfg,
439
+ metricsCfg,
421
440
  allRefLines,
422
441
  badgeUsesRightGutter,
423
442
  // theme / layout / fonts
@@ -498,15 +517,16 @@ function ChartStack({ model }: { model: LiveChartModel }) {
498
517
  downBodiesPath,
499
518
  xAxisCfg,
500
519
  xAxisEntries,
501
- badgeData,
502
520
  dotX,
503
521
  pulseCfg,
522
+ dotCfg,
504
523
  degenCfg,
505
524
  degenPack,
506
525
  degenPackRevision,
507
526
  markersActive,
508
527
  markersSV,
509
528
  emptyText,
529
+ metricsCfg,
510
530
  } = model;
511
531
 
512
532
  return (
@@ -522,6 +542,7 @@ function ChartStack({ model }: { model: LiveChartModel }) {
522
542
  font={skiaFont}
523
543
  badge={badgeUsesRightGutter}
524
544
  badgeTail={badgeCfg?.tail ?? true}
545
+ badgeMetrics={metricsCfg.badge}
525
546
  gridStyle={gridStyleCfg}
526
547
  />
527
548
  </Group>
@@ -611,23 +632,23 @@ function ChartStack({ model }: { model: LiveChartModel }) {
611
632
  />
612
633
  )}
613
634
 
614
- {/* Badge and live dot */}
615
- {badgeCfg && (
616
- <Group opacity={reveal.badgeOpacity}>
617
- <BadgeOverlay badge={badgeData} font={skiaFont} />
635
+ {/* Live dot the badge is drawn later (after the scrub layer) so the
636
+ scrub dim never clips the live-price badge's left edge. */}
637
+ {dotCfg.show && (
638
+ <Group opacity={reveal.dotOpacity}>
639
+ <DotOverlay
640
+ dotX={dotX}
641
+ dotY={dotY}
642
+ palette={palette}
643
+ engine={engine}
644
+ pulse={pulseCfg}
645
+ radius={dotCfg.radius}
646
+ ring={dotCfg.ring}
647
+ color={dotCfg.color}
648
+ />
618
649
  </Group>
619
650
  )}
620
651
 
621
- <Group opacity={reveal.dotOpacity}>
622
- <DotOverlay
623
- dotX={dotX}
624
- dotY={dotY}
625
- palette={palette}
626
- engine={engine}
627
- pulse={pulseCfg}
628
- />
629
- </Group>
630
-
631
652
  {degenCfg && (
632
653
  <Group opacity={reveal.dotOpacity}>
633
654
  <DegenParticlesOverlay
@@ -669,6 +690,8 @@ function ChartStack({ model }: { model: LiveChartModel }) {
669
690
  strokeWidth={strokeWidth}
670
691
  badge={badgeCfg !== null}
671
692
  badgeTail={badgeCfg?.tail ?? true}
693
+ badgeMetrics={metricsCfg.badge}
694
+ emptyMetrics={metricsCfg.emptyState}
672
695
  />
673
696
  </Group>
674
697
  );
@@ -688,10 +711,20 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
688
711
  reveal,
689
712
  crosshair,
690
713
  isCandle,
714
+ pulseCfg,
715
+ dotOuterRadius,
691
716
  } = model;
692
717
 
693
718
  if (!tradeStreamResolved && !scrubCfg) return null;
694
719
 
720
+ // Extend the scrub dim past the plot's right edge to fully cover the live dot
721
+ // (with its halo) and pulse ring, all centered on that edge. The gutter
722
+ // reserves an 8px gap beyond this for the Y-axis labels, so they stay readable.
723
+ const liveDotExtent = Math.max(
724
+ dotOuterRadius,
725
+ pulseCfg ? pulseRadialOutset(pulseCfg.maxRadius, pulseCfg.strokeWidth) : 0,
726
+ );
727
+
695
728
  return (
696
729
  <Group transform={degenShakeTransform}>
697
730
  {tradeStreamResolved && (
@@ -716,6 +749,7 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
716
749
  font={skiaFont}
717
750
  showTooltip={scrubCfg.tooltip}
718
751
  dimOpacity={scrubCfg.dimOpacity}
752
+ liveDotExtent={liveDotExtent}
719
753
  crosshairLineColor={scrubCfg.crosshairLineColor}
720
754
  crosshairDimColor={scrubCfg.crosshairDimColor}
721
755
  tooltipBackground={scrubCfg.tooltipBackground}
@@ -774,6 +808,20 @@ function ChartValueOverlay({ model }: { model: LiveChartModel }) {
774
808
  );
775
809
  }
776
810
 
811
+ /** Live-price badge, drawn above the scrub dim so the dim never clips its left
812
+ * edge. Shares the degen shake transform so it tracks the shaken stack. */
813
+ function ChartBadgeLayer({ model }: { model: LiveChartModel }) {
814
+ const { badgeCfg, badgeData, skiaFont, reveal, degenShakeTransform } = model;
815
+ if (!badgeCfg) return null;
816
+ return (
817
+ <Group transform={degenShakeTransform}>
818
+ <Group opacity={reveal.badgeOpacity}>
819
+ <BadgeOverlay badge={badgeData} font={skiaFont} />
820
+ </Group>
821
+ </Group>
822
+ );
823
+ }
824
+
777
825
  export function LiveChart(props: LiveChartProps) {
778
826
  const model = useLiveChartController(props);
779
827
  const {
@@ -814,6 +862,10 @@ export function LiveChart(props: LiveChartProps) {
814
862
  <ChartValueOverlay model={model} />
815
863
 
816
864
  <ChartScrubLayer model={model} />
865
+
866
+ {/* Live-price badge on top of the scrub dim so the dim never clips
867
+ its left edge (the badge tracks the live value, not the scrub). */}
868
+ <ChartBadgeLayer model={model} />
817
869
  </Canvas>
818
870
  </View>
819
871
  </GestureDetector>
@@ -28,12 +28,14 @@ import {
28
28
  resolveGridStyle,
29
29
  resolveLeftEdgeFade,
30
30
  resolveLegend,
31
+ resolveMetrics,
31
32
  resolveMultiSeriesDot,
32
33
  resolveScrub,
33
34
  resolveXAxis,
34
35
  resolveYAxis,
35
36
  } from "../core/resolveConfig";
36
37
  import { useLiveChartSeriesEngine } from "../core/useLiveChartSeriesEngine";
38
+ import { pulseRadialOutset } from "../draw/line";
37
39
  import { resolveChartLayout } from "../hooks/resolveChartLayout";
38
40
  import { useCanvasLayout } from "../hooks/useCanvasLayout";
39
41
  import { useChartReveal } from "../hooks/useChartReveal";
@@ -122,7 +124,8 @@ function useLiveChartSeriesController({
122
124
  referenceLines,
123
125
  gridStyle,
124
126
  palette: paletteOverride,
125
- scrub = false,
127
+ metrics,
128
+ scrub = true,
126
129
  onScrub,
127
130
  onSeriesToggle,
128
131
  dot: dotProp,
@@ -143,8 +146,12 @@ function useLiveChartSeriesController({
143
146
  const scrubEnabled = scrubCfg !== null;
144
147
  const gridStyleCfg = resolveGridStyle(gridStyle);
145
148
  const dotCfg = resolveMultiSeriesDot(dotProp);
149
+ // Outer footprint of a dot (the color-filled radius plus the halo ring).
150
+ // Used to keep the gutter labels clear of the haloed dot.
151
+ const dotOuterRadius = dotCfg.radius + (dotCfg.ring?.width ?? 0);
146
152
  const legendCfg = resolveLegend(legendProp);
147
153
  const degenCfg = resolveDegen(degen);
154
+ const metricsCfg = resolveMetrics(metrics);
148
155
 
149
156
  const allRefLines = referenceLines ?? [];
150
157
  const refValues = collectReferenceValues(allRefLines);
@@ -190,7 +197,7 @@ function useLiveChartSeriesController({
190
197
  : 0;
191
198
 
192
199
  const seriesLabelInset = dotCfg.valueLabel
193
- ? dotCfg.radius + 8 + maxSeriesLabelWidth + 8
200
+ ? dotOuterRadius + 8 + maxSeriesLabelWidth + 8
194
201
  : 0;
195
202
 
196
203
  const representativeValue =
@@ -204,12 +211,13 @@ function useLiveChartSeriesController({
204
211
  insetsOverride: insets,
205
212
  yAxis: yAxisCfg !== null,
206
213
  badge: false,
214
+ badgeMetrics: metricsCfg.badge,
207
215
  xAxis: xAxisCfg !== null,
208
216
  font: skiaFont,
209
217
  formatValue,
210
218
  currentValue: representativeValue,
211
219
  pulse: dotCfg.pulse,
212
- multiSeriesDotRadius: dotCfg.radius,
220
+ multiSeriesDotRadius: dotOuterRadius,
213
221
  multiSeriesValueLabel: dotCfg.valueLabel,
214
222
  multiSeriesMaxLabelWidth: maxSeriesLabelWidth,
215
223
  });
@@ -236,6 +244,7 @@ function useLiveChartSeriesController({
236
244
  timeWindow,
237
245
  paused,
238
246
  smoothing,
247
+ adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
239
248
  exaggerate,
240
249
  referenceValues: refValues,
241
250
  nonNegative,
@@ -282,6 +291,7 @@ function useLiveChartSeriesController({
282
291
  formatValue,
283
292
  skiaFont,
284
293
  yAxisCfg?.minGap ?? 36,
294
+ metricsCfg.grid,
285
295
  );
286
296
 
287
297
  const { xAxisEntries } = useXAxis(
@@ -296,6 +306,7 @@ function useLiveChartSeriesController({
296
306
  effectivePadding,
297
307
  scrubEnabled,
298
308
  onScrub,
309
+ scrubCfg?.panGestureDelay ?? 0,
299
310
  );
300
311
 
301
312
  // `projected` is used internally by the hit-test gesture; the overlay
@@ -331,8 +342,10 @@ function useLiveChartSeriesController({
331
342
  scrubCfg,
332
343
  gridStyleCfg,
333
344
  dotCfg,
345
+ dotOuterRadius,
334
346
  legendCfg,
335
347
  degenCfg,
348
+ metricsCfg,
336
349
  allRefLines,
337
350
  leftEdgeFadeCfg,
338
351
  // theme / layout / fonts
@@ -396,6 +409,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
396
409
  markersSV,
397
410
  series,
398
411
  emptyText,
412
+ metricsCfg,
399
413
  } = model;
400
414
 
401
415
  return (
@@ -462,28 +476,25 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
462
476
  />
463
477
  )}
464
478
 
465
- <Group opacity={reveal.dotOpacity}>
466
- <MultiSeriesDots
467
- engine={engine}
468
- padding={effectivePadding}
469
- colors={lineColors}
470
- radius={dotCfg.radius}
471
- pulse={dotCfg.pulse}
472
- />
473
- </Group>
474
-
475
- {dotCfg.valueLabel && (
479
+ {dotCfg.show && (
476
480
  <Group opacity={reveal.dotOpacity}>
477
- <MultiSeriesValueLabels
481
+ <MultiSeriesDots
478
482
  engine={engine}
479
483
  padding={effectivePadding}
480
484
  colors={lineColors}
481
- font={skiaFont}
482
- dotRadius={dotCfg.radius}
485
+ radius={dotCfg.radius}
486
+ ring={dotCfg.ring}
487
+ ringColor={palette.badgeOuterBg}
488
+ color={dotCfg.color}
489
+ pulse={dotCfg.pulse}
483
490
  />
484
491
  </Group>
485
492
  )}
486
493
 
494
+ {/* Value labels are drawn later (after the crosshair layer) so the scrub
495
+ dim — which now covers the dots + pulse rings — never clips them.
496
+ They track each series' live value, not the scrub point. */}
497
+
487
498
  {degenCfg && (
488
499
  <Group opacity={reveal.dotOpacity}>
489
500
  <DegenParticlesOverlay
@@ -523,11 +534,42 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
523
534
  emptyText={emptyText}
524
535
  strokeWidth={strokeWidth}
525
536
  badge={false}
537
+ emptyMetrics={metricsCfg.emptyState}
526
538
  />
527
539
  </Group>
528
540
  );
529
541
  }
530
542
 
543
+ /** Per-series live-value labels, drawn above the scrub dim so the dim (which
544
+ * covers the dots + pulse rings) never clips them. Keeps the degen shake
545
+ * transform so they track the shaken stack. */
546
+ function SeriesValueLabelLayer({ model }: { model: LiveChartSeriesModel }) {
547
+ const {
548
+ dotCfg,
549
+ dotOuterRadius,
550
+ engine,
551
+ effectivePadding,
552
+ lineColors,
553
+ skiaFont,
554
+ reveal,
555
+ degenShakeTransform,
556
+ } = model;
557
+ if (!dotCfg.valueLabel) return null;
558
+ return (
559
+ <Group transform={degenShakeTransform}>
560
+ <Group opacity={reveal.dotOpacity}>
561
+ <MultiSeriesValueLabels
562
+ engine={engine}
563
+ padding={effectivePadding}
564
+ colors={lineColors}
565
+ font={skiaFont}
566
+ dotRadius={dotOuterRadius}
567
+ />
568
+ </Group>
569
+ </Group>
570
+ );
571
+ }
572
+
531
573
  export function LiveChartSeries(props: LiveChartSeriesProps) {
532
574
  const model = useLiveChartSeriesController(props);
533
575
  const {
@@ -547,8 +589,20 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
547
589
  scrubCfg,
548
590
  crosshair,
549
591
  palette,
592
+ dotCfg,
593
+ dotOuterRadius,
550
594
  } = model;
551
595
 
596
+ // Extend the scrub dim past the plot's right edge to fully cover the series
597
+ // dots (with their halo) and pulse rings, all centered on that edge. The
598
+ // gutter reserves room beyond this for the value/Y-axis labels, drawn on top.
599
+ const liveDotExtent = Math.max(
600
+ dotOuterRadius,
601
+ dotCfg.pulse
602
+ ? pulseRadialOutset(dotCfg.pulse.maxRadius, dotCfg.pulse.strokeWidth)
603
+ : 0,
604
+ );
605
+
552
606
  const legend =
553
607
  legendCfg.position === "top" || legendCfg.position === "bottom" ? (
554
608
  <SeriesToggleChips
@@ -594,10 +648,15 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
594
648
  padding={effectivePadding}
595
649
  palette={palette}
596
650
  dimOpacity={scrubCfg.dimOpacity}
651
+ liveDotExtent={liveDotExtent}
597
652
  crosshairLineColor={scrubCfg.crosshairLineColor}
598
653
  crosshairDimColor={scrubCfg.crosshairDimColor}
599
654
  />
600
655
  )}
656
+
657
+ {/* Per-series value labels on top of the scrub dim so the dim never
658
+ clips them (they track each series' live value, not the scrub). */}
659
+ <SeriesValueLabelLayer model={model} />
601
660
  </Canvas>
602
661
  </View>
603
662
  </GestureDetector>