react-native-livechart 2.0.1 → 3.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 (93) hide show
  1. package/README.md +9 -3
  2. package/dist/components/AxisLabelOverlay.d.ts +29 -0
  3. package/dist/components/AxisLabelOverlay.d.ts.map +1 -0
  4. package/dist/components/CrosshairLine.d.ts +12 -1
  5. package/dist/components/CrosshairLine.d.ts.map +1 -1
  6. package/dist/components/CrosshairOverlay.d.ts +15 -1
  7. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  8. package/dist/components/DegenParticlesOverlay.d.ts +17 -0
  9. package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
  10. package/dist/components/LiveChart.d.ts.map +1 -1
  11. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  12. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  13. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  14. package/dist/components/MultiSeriesValueLines.d.ts.map +1 -1
  15. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  16. package/dist/components/SelectionDot.d.ts +32 -0
  17. package/dist/components/SelectionDot.d.ts.map +1 -0
  18. package/dist/components/ValueLineOverlay.d.ts.map +1 -1
  19. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  20. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  21. package/dist/core/resolveConfig.d.ts +46 -1
  22. package/dist/core/resolveConfig.d.ts.map +1 -1
  23. package/dist/core/useLiveChartEngine.d.ts +6 -0
  24. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  25. package/dist/draw/particleAtlas.d.ts +39 -0
  26. package/dist/draw/particleAtlas.d.ts.map +1 -0
  27. package/dist/hooks/crosshairShared.d.ts +10 -0
  28. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  29. package/dist/hooks/useBadge.d.ts +2 -2
  30. package/dist/hooks/useBadge.d.ts.map +1 -1
  31. package/dist/hooks/useCandlePaths.d.ts +11 -11
  32. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  33. package/dist/hooks/useChartColors.d.ts +5 -0
  34. package/dist/hooks/useChartColors.d.ts.map +1 -1
  35. package/dist/hooks/useChartPaths.d.ts +8 -7
  36. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  37. package/dist/hooks/useChartReveal.d.ts +3 -1
  38. package/dist/hooks/useChartReveal.d.ts.map +1 -1
  39. package/dist/hooks/useCrosshair.d.ts +1 -1
  40. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  41. package/dist/hooks/useCrosshairSeries.d.ts +1 -1
  42. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  43. package/dist/hooks/useDegen.d.ts +3 -1
  44. package/dist/hooks/useDegen.d.ts.map +1 -1
  45. package/dist/hooks/useMarkers.d.ts +3 -1
  46. package/dist/hooks/useMarkers.d.ts.map +1 -1
  47. package/dist/hooks/useMultiSeriesLinePaths.d.ts +9 -8
  48. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  49. package/dist/hooks/usePathBuilder.d.ts +27 -0
  50. package/dist/hooks/usePathBuilder.d.ts.map +1 -0
  51. package/dist/hooks/useTradeStream.d.ts +3 -1
  52. package/dist/hooks/useTradeStream.d.ts.map +1 -1
  53. package/dist/index.d.ts +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/math/spline.d.ts +10 -2
  56. package/dist/math/spline.d.ts.map +1 -1
  57. package/dist/types.d.ts +97 -0
  58. package/dist/types.d.ts.map +1 -1
  59. package/package.json +2 -2
  60. package/src/components/AxisLabelOverlay.tsx +133 -0
  61. package/src/components/CrosshairLine.tsx +27 -0
  62. package/src/components/CrosshairOverlay.tsx +50 -18
  63. package/src/components/DegenParticlesOverlay.tsx +93 -109
  64. package/src/components/LiveChart.tsx +66 -10
  65. package/src/components/LiveChartSeries.tsx +40 -0
  66. package/src/components/LoadingOverlay.tsx +15 -29
  67. package/src/components/MarkerOverlay.tsx +23 -28
  68. package/src/components/MultiSeriesValueLines.tsx +20 -40
  69. package/src/components/ReferenceLineOverlay.tsx +65 -107
  70. package/src/components/SelectionDot.tsx +92 -0
  71. package/src/components/ValueLineOverlay.tsx +9 -28
  72. package/src/components/XAxisOverlay.tsx +9 -29
  73. package/src/components/YAxisOverlay.tsx +6 -22
  74. package/src/core/resolveConfig.ts +98 -0
  75. package/src/core/useLiveChartEngine.ts +78 -27
  76. package/src/draw/markerAtlas.ts +9 -9
  77. package/src/draw/particleAtlas.ts +95 -0
  78. package/src/hooks/crosshairShared.ts +26 -0
  79. package/src/hooks/useBadge.ts +17 -32
  80. package/src/hooks/useCandlePaths.ts +27 -64
  81. package/src/hooks/useChartColors.ts +19 -0
  82. package/src/hooks/useChartPaths.ts +27 -39
  83. package/src/hooks/useChartReveal.ts +7 -4
  84. package/src/hooks/useCrosshair.ts +46 -7
  85. package/src/hooks/useCrosshairSeries.ts +47 -8
  86. package/src/hooks/useDegen.ts +6 -0
  87. package/src/hooks/useMarkers.ts +3 -0
  88. package/src/hooks/useMultiSeriesLinePaths.ts +26 -28
  89. package/src/hooks/usePathBuilder.ts +42 -0
  90. package/src/hooks/useTradeStream.ts +3 -0
  91. package/src/index.ts +4 -0
  92. package/src/math/spline.ts +22 -2
  93. package/src/types.ts +101 -0
@@ -24,6 +24,7 @@ import {
24
24
  type SeriesLineStyle,
25
25
  } from "../core/multiSeriesLayout";
26
26
  import {
27
+ resolveAxisLabel,
27
28
  resolveDegen,
28
29
  resolveGridStyle,
29
30
  resolveLeftEdgeFade,
@@ -31,6 +32,7 @@ import {
31
32
  resolveMetrics,
32
33
  resolveMultiSeriesDot,
33
34
  resolveScrub,
35
+ resolveSelectionDot,
34
36
  resolveXAxis,
35
37
  resolveYAxis,
36
38
  } from "../core/resolveConfig";
@@ -60,6 +62,7 @@ import {
60
62
  resolveTheme,
61
63
  } from "../theme";
62
64
  import type { LiveChartSeriesProps, Marker, SeriesConfig } from "../types";
65
+ import { AxisLabelOverlay } from "./AxisLabelOverlay";
63
66
  import { CrosshairLine } from "./CrosshairLine";
64
67
  import { DegenParticlesOverlay } from "./DegenParticlesOverlay";
65
68
  import { LeftEdgeFade } from "./LeftEdgeFade";
@@ -121,12 +124,17 @@ function useLiveChartSeriesController({
121
124
  formatTime = defaultFormatTime,
122
125
  yAxis = true,
123
126
  xAxis = true,
127
+ topLabel,
128
+ bottomLabel,
124
129
  referenceLines,
125
130
  gridStyle,
126
131
  palette: paletteOverride,
127
132
  metrics,
128
133
  scrub = true,
134
+ selectionDot,
129
135
  onScrub,
136
+ onGestureStart,
137
+ onGestureEnd,
130
138
  onSeriesToggle,
131
139
  dot: dotProp,
132
140
  legend: legendProp,
@@ -142,8 +150,11 @@ function useLiveChartSeriesController({
142
150
  const markersActive = markers != null;
143
151
  const yAxisCfg = resolveYAxis(yAxis);
144
152
  const xAxisCfg = resolveXAxis(xAxis);
153
+ const topLabelCfg = resolveAxisLabel(topLabel);
154
+ const bottomLabelCfg = resolveAxisLabel(bottomLabel);
145
155
  const scrubCfg = resolveScrub(scrub);
146
156
  const scrubEnabled = scrubCfg !== null;
157
+ const selectionDotCfg = resolveSelectionDot(selectionDot);
147
158
  const gridStyleCfg = resolveGridStyle(gridStyle);
148
159
  const dotCfg = resolveMultiSeriesDot(dotProp);
149
160
  // Outer footprint of a dot (the color-filled radius plus the halo ring).
@@ -307,6 +318,8 @@ function useLiveChartSeriesController({
307
318
  scrubEnabled,
308
319
  onScrub,
309
320
  scrubCfg?.panGestureDelay ?? 0,
321
+ onGestureStart,
322
+ onGestureEnd,
310
323
  );
311
324
 
312
325
  // `projected` is used internally by the hit-test gesture; the overlay
@@ -373,6 +386,12 @@ function useLiveChartSeriesController({
373
386
  rootGesture,
374
387
  markersActive,
375
388
  markersSV,
389
+ // selection dot: resolved config + fallback color (the leading series' color)
390
+ selectionDot: selectionDotCfg,
391
+ selectionColor: lineColors[0],
392
+ // RN axis edge labels (floated over the canvas as a sibling layer)
393
+ topLabelCfg,
394
+ bottomLabelCfg,
376
395
  };
377
396
  }
378
397
 
@@ -591,6 +610,11 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
591
610
  palette,
592
611
  dotCfg,
593
612
  dotOuterRadius,
613
+ selectionDot,
614
+ selectionColor,
615
+ formatValue,
616
+ topLabelCfg,
617
+ bottomLabelCfg,
594
618
  } = model;
595
619
 
596
620
  // Extend the scrub dim past the plot's right edge to fully cover the series
@@ -647,6 +671,10 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
647
671
  engine={engine}
648
672
  padding={effectivePadding}
649
673
  palette={palette}
674
+ selectionDot={selectionDot}
675
+ selectionY={crosshair.scrubDotY}
676
+ scrubActive={crosshair.scrubActive}
677
+ selectionColor={selectionColor}
650
678
  dimOpacity={scrubCfg.dimOpacity}
651
679
  liveDotExtent={liveDotExtent}
652
680
  crosshairLineColor={scrubCfg.crosshairLineColor}
@@ -658,6 +686,18 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
658
686
  clips them (they track each series' live value, not the scrub). */}
659
687
  <SeriesValueLabelLayer model={model} />
660
688
  </Canvas>
689
+
690
+ {/* RN labels floated over the canvas (sibling of <Canvas>, an RN
691
+ view). Inside the canvas wrapper so its top/bottom edges align
692
+ with the plot area, not the legend row. */}
693
+ <AxisLabelOverlay
694
+ topLabel={topLabelCfg}
695
+ bottomLabel={bottomLabelCfg}
696
+ engine={engine}
697
+ formatValue={formatValue}
698
+ defaultColor={palette.gridLabel}
699
+ padding={effectivePadding}
700
+ />
661
701
  </View>
662
702
  </GestureDetector>
663
703
  {legendCfg.position === "bottom" ? legend : null}
@@ -4,13 +4,10 @@ import {
4
4
  Path,
5
5
  Rect,
6
6
  RoundedRect,
7
- Skia,
8
7
  Text as SkiaText,
9
8
  vec,
10
9
  type SkFont,
11
- type SkPath,
12
10
  } from "@shopify/react-native-skia";
13
- import { useRef } from "react";
14
11
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
15
12
  import {
16
13
  BADGE_METRICS_DEFAULTS,
@@ -22,6 +19,10 @@ import {
22
19
  pillTextLeftX,
23
20
  type ChartPadding,
24
21
  } from "../draw/line";
22
+ import {
23
+ usePathBuilder,
24
+ type ReanimatedPathBuilder,
25
+ } from "../hooks/usePathBuilder";
25
26
  import { drawSpline } from "../math/spline";
26
27
  import { buildSquigglyPts } from "../math/squiggly";
27
28
  import type {
@@ -37,14 +38,14 @@ const RECT_H = 4;
37
38
  const RECT_R = 4;
38
39
  const RECT_SPACING = 32;
39
40
 
40
- function buildSplineInto(path: ReturnType<typeof Skia.Path.Make>, pts: number[]) {
41
+ function buildSplineDetached(b: ReanimatedPathBuilder, pts: number[]) {
41
42
  "worklet";
42
- path.reset();
43
43
  const n = pts.length >> 1;
44
- if (n < 2) return path;
45
- path.moveTo(pts[0], pts[1]);
46
- drawSpline(path, pts);
47
- return path;
44
+ if (n >= 2) {
45
+ b.moveTo(pts[0], pts[1]);
46
+ drawSpline(b, pts);
47
+ }
48
+ return b.detach();
48
49
  }
49
50
 
50
51
  export function LoadingOverlay({
@@ -85,29 +86,14 @@ export function LoadingOverlay({
85
86
  const leftInset =
86
87
  badgeMetrics.dotGap + badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
87
88
 
88
- // Ping-pong persistent paths avoid allocating a JSI-backed SkPath per frame
89
- // while the squiggly loading/empty-state animation is running.
90
- const squigglyCacheRef = useRef<{
91
- a: SkPath;
92
- b: SkPath;
93
- tick: boolean;
94
- } | null>(null);
95
- if (squigglyCacheRef.current === null) {
96
- squigglyCacheRef.current = {
97
- a: Skia.Path.Make(),
98
- b: Skia.Path.Make(),
99
- tick: false,
100
- };
101
- }
89
+ // Squiggly pathbuilt into a reused PathBuilder and detach()-ed each frame.
90
+ const squigglyBuilder = usePathBuilder();
102
91
 
103
92
  // Squiggly path — animated each frame via timestamp
104
93
  const squigglyPath = useDerivedValue(() => {
105
- const squigglyCache = squigglyCacheRef.current!;
106
- squigglyCache.tick = !squigglyCache.tick;
107
- const path = squigglyCache.tick ? squigglyCache.a : squigglyCache.b;
94
+ const b = squigglyBuilder.value;
108
95
  if (!isLoading.get() && !isEmpty.value && morphT.get() >= 1) {
109
- path.reset();
110
- return path;
96
+ return b.detach();
111
97
  }
112
98
  const pts = buildSquigglyPts(
113
99
  engine.canvasWidth.get(),
@@ -115,7 +101,7 @@ export function LoadingOverlay({
115
101
  padding,
116
102
  engine.timestamp.get(),
117
103
  );
118
- return buildSplineInto(path, pts);
104
+ return buildSplineDetached(b, pts);
119
105
  });
120
106
 
121
107
  // Fades out quickly as the reveal animation begins (first 33% of morphT)
@@ -4,7 +4,6 @@ import {
4
4
  Path,
5
5
  Skia,
6
6
  type SkFont,
7
- type SkPath,
8
7
  } from "@shopify/react-native-skia";
9
8
  import { useMemo, useRef, useState } from "react";
10
9
  import {
@@ -15,6 +14,7 @@ import {
15
14
  import { scheduleOnRN } from "react-native-worklets";
16
15
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
17
16
  import type { ChartPadding } from "../draw/line";
17
+ import { usePathBuilder } from "../hooks/usePathBuilder";
18
18
  import {
19
19
  buildMarkerAtlas,
20
20
  defaultMarkerColor,
@@ -84,37 +84,32 @@ function ConnectorGlyph({
84
84
 
85
85
  const opacity = useDerivedValue(() => (layout.get().visible ? 1 : 0));
86
86
 
87
- const cacheRef = useRef<{ a: SkPath; b: SkPath; tick: boolean } | null>(null);
88
- if (cacheRef.current === null) {
89
- cacheRef.current = { a: Skia.Path.Make(), b: Skia.Path.Make(), tick: false };
90
- }
87
+ const glyphBuilder = usePathBuilder();
91
88
 
92
89
  const glyphPath = useDerivedValue(() => {
93
- const cache = cacheRef.current!;
94
- cache.tick = !cache.tick;
95
- const p: SkPath = cache.tick ? cache.a : cache.b;
96
- p.reset();
90
+ const b = glyphBuilder.value;
97
91
  const l = layout.get();
98
- if (!l.visible) return p;
99
- const x = l.visible ? l.x : OFF;
100
- const y = l.y;
101
- if (kind === "graduation") {
102
- p.moveTo(x, axisY.get());
103
- p.lineTo(x, y);
104
- p.moveTo(x, y - 7);
105
- p.lineTo(x + 8, y - 4);
106
- p.lineTo(x, y - 1);
107
- p.close();
108
- } else if (kind === "clawback") {
109
- const s = 5;
110
- const ay = axisY.get() - s;
111
- p.moveTo(x - s, ay);
112
- p.lineTo(x + s, ay);
113
- p.lineTo(x + s, ay + s * 2);
114
- p.lineTo(x - s, ay + s * 2);
115
- p.close();
92
+ if (l.visible) {
93
+ const x = l.x;
94
+ const y = l.y;
95
+ if (kind === "graduation") {
96
+ b.moveTo(x, axisY.get());
97
+ b.lineTo(x, y);
98
+ b.moveTo(x, y - 7);
99
+ b.lineTo(x + 8, y - 4);
100
+ b.lineTo(x, y - 1);
101
+ b.close();
102
+ } else if (kind === "clawback") {
103
+ const s = 5;
104
+ const ay = axisY.get() - s;
105
+ b.moveTo(x - s, ay);
106
+ b.lineTo(x + s, ay);
107
+ b.lineTo(x + s, ay + s * 2);
108
+ b.lineTo(x - s, ay + s * 2);
109
+ b.close();
110
+ }
116
111
  }
117
- return p;
112
+ return b.detach();
118
113
  });
119
114
 
120
115
  const fill = kind === "clawback";
@@ -1,17 +1,11 @@
1
- import {
2
- DashPathEffect,
3
- Group,
4
- Path,
5
- Skia,
6
- type SkPath,
7
- } from "@shopify/react-native-skia";
1
+ import { DashPathEffect, Group, Path } from "@shopify/react-native-skia";
8
2
 
9
- import { useRef } from "react";
10
3
  import { useDerivedValue } from "react-native-reanimated";
11
4
  import { MAX_MULTI_SERIES } from "../constants";
12
5
  import type { ChartPadding } from "../draw/line";
13
6
  import type { ResolvedValueLineConfig } from "../core/resolveConfig";
14
7
  import type { MultiEngineState } from "../core/useLiveChartEngine";
8
+ import { usePathBuilder } from "../hooks/usePathBuilder";
15
9
 
16
10
  function SeriesValueLineAtIndex({
17
11
  index,
@@ -26,44 +20,30 @@ function SeriesValueLineAtIndex({
26
20
  color: string;
27
21
  config: ResolvedValueLineConfig;
28
22
  }) {
29
- const cacheRef = useRef<{
30
- a: SkPath;
31
- b: SkPath;
32
- tick: boolean;
33
- } | null>(null);
34
- if (cacheRef.current === null) {
35
- cacheRef.current = {
36
- a: Skia.Path.Make(),
37
- b: Skia.Path.Make(),
38
- tick: false,
39
- };
40
- }
23
+ const builder = usePathBuilder();
41
24
 
42
25
  const path = useDerivedValue(() => {
43
- const cache = cacheRef.current!;
44
- cache.tick = !cache.tick;
45
- const p = cache.tick ? cache.a : cache.b;
46
- p.reset();
26
+ const b = builder.value;
47
27
  const h = engine.canvasHeight.get();
48
- if (h === 0) return p;
49
28
  const s = engine.series.get();
50
29
  const displays = engine.displaySeriesValues.get();
51
- if (index >= s.length) return p;
30
+ if (h !== 0 && index < s.length) {
31
+ const chartH = h - padding.top - padding.bottom;
32
+ const dMin = engine.displayMin.get();
33
+ const dMax = engine.displayMax.get();
34
+ const valRange = dMax - dMin;
35
+ const v = displays[index] ?? s[index].value;
36
+ const y =
37
+ valRange === 0
38
+ ? padding.top + chartH / 2
39
+ : padding.top + ((dMax - v) / valRange) * chartH;
52
40
 
53
- const chartH = h - padding.top - padding.bottom;
54
- const dMin = engine.displayMin.get();
55
- const dMax = engine.displayMax.get();
56
- const valRange = dMax - dMin;
57
- const v = displays[index] ?? s[index].value;
58
- const y =
59
- valRange === 0
60
- ? padding.top + chartH / 2
61
- : padding.top + ((dMax - v) / valRange) * chartH;
62
-
63
- if (y < 0) return p;
64
- p.moveTo(padding.left, y);
65
- p.lineTo(engine.canvasWidth.get() - padding.right, y);
66
- return p;
41
+ if (y >= 0) {
42
+ b.moveTo(padding.left, y);
43
+ b.lineTo(engine.canvasWidth.get() - padding.right, y);
44
+ }
45
+ }
46
+ return b.detach();
67
47
  });
68
48
 
69
49
  const opacity = useDerivedValue(() => {
@@ -3,16 +3,14 @@ import {
3
3
  Group,
4
4
  Path,
5
5
  RoundedRect,
6
- Skia,
7
6
  Text as SkiaText,
8
7
  type SkFont,
9
- type SkPath,
10
8
  } from "@shopify/react-native-skia";
11
- import { useRef } from "react";
12
9
  import { useDerivedValue } from "react-native-reanimated";
13
10
 
14
11
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
15
12
  import type { ChartPadding } from "../draw/line";
13
+ import { usePathBuilder } from "../hooks/usePathBuilder";
16
14
  import { useReferenceLine } from "../hooks/useReferenceLine";
17
15
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
18
16
  import { referenceLineForm } from "../math/referenceLines";
@@ -65,131 +63,91 @@ export function ReferenceLineOverlay({
65
63
  const badgeBorderColor = line.badgeBorderColor ?? color;
66
64
  const badgeRadius = line.badgeRadius ?? OFF_AXIS_PILL_RADIUS;
67
65
 
68
- const cacheRef = useRef<{
69
- lineA: SkPath;
70
- lineB: SkPath;
71
- lineT: boolean;
72
- bandA: SkPath;
73
- bandB: SkPath;
74
- bandT: boolean;
75
- borderA: SkPath;
76
- borderB: SkPath;
77
- borderT: boolean;
78
- offA: SkPath;
79
- offB: SkPath;
80
- offT: boolean;
81
- chevA: SkPath;
82
- chevB: SkPath;
83
- chevT: boolean;
84
- } | null>(null);
85
- if (cacheRef.current === null) {
86
- cacheRef.current = {
87
- lineA: Skia.Path.Make(),
88
- lineB: Skia.Path.Make(),
89
- lineT: false,
90
- bandA: Skia.Path.Make(),
91
- bandB: Skia.Path.Make(),
92
- bandT: false,
93
- borderA: Skia.Path.Make(),
94
- borderB: Skia.Path.Make(),
95
- borderT: false,
96
- offA: Skia.Path.Make(),
97
- offB: Skia.Path.Make(),
98
- offT: false,
99
- chevA: Skia.Path.Make(),
100
- chevB: Skia.Path.Make(),
101
- chevT: false,
102
- };
103
- }
66
+ const lineBuilder = usePathBuilder();
67
+ const bandBuilder = usePathBuilder();
68
+ const borderBuilder = usePathBuilder();
69
+ const offBuilder = usePathBuilder();
70
+ const chevBuilder = usePathBuilder();
104
71
 
105
72
  const linePath = useDerivedValue(() => {
106
- const cache = cacheRef.current!;
107
- cache.lineT = !cache.lineT;
108
- const p = cache.lineT ? cache.lineA : cache.lineB;
109
- p.reset();
73
+ const b = lineBuilder.value;
110
74
  const l = layout.get();
111
- if (!l.visible || l.offAxis || isBand) return p;
112
- p.moveTo(l.x1, l.y);
113
- p.lineTo(l.x2, l.y);
114
- return p;
75
+ if (l.visible && !l.offAxis && !isBand) {
76
+ b.moveTo(l.x1, l.y);
77
+ b.lineTo(l.x2, l.y);
78
+ }
79
+ return b.detach();
115
80
  });
116
81
 
117
82
  const bandPath = useDerivedValue(() => {
118
- const cache = cacheRef.current!;
119
- cache.bandT = !cache.bandT;
120
- const p = cache.bandT ? cache.bandA : cache.bandB;
121
- p.reset();
83
+ const b = bandBuilder.value;
122
84
  const l = layout.get();
123
- if (!l.visible || !isBand) return p;
124
- p.moveTo(l.x1, l.y);
125
- p.lineTo(l.x2, l.y);
126
- p.lineTo(l.x2, l.yBottom);
127
- p.lineTo(l.x1, l.yBottom);
128
- p.close();
129
- return p;
85
+ if (l.visible && isBand) {
86
+ b.moveTo(l.x1, l.y);
87
+ b.lineTo(l.x2, l.y);
88
+ b.lineTo(l.x2, l.yBottom);
89
+ b.lineTo(l.x1, l.yBottom);
90
+ b.close();
91
+ }
92
+ return b.detach();
130
93
  });
131
94
 
132
95
  const bandBorderPath = useDerivedValue(() => {
133
- const cache = cacheRef.current!;
134
- cache.borderT = !cache.borderT;
135
- const p = cache.borderT ? cache.borderA : cache.borderB;
136
- p.reset();
96
+ const b = borderBuilder.value;
137
97
  const l = layout.get();
138
- if (!l.visible || !hasBandBorder) return p;
139
- if (form === "time-band") {
140
- // Vertical edges at the band's left / right.
141
- p.moveTo(l.x1, l.y);
142
- p.lineTo(l.x1, l.yBottom);
143
- p.moveTo(l.x2, l.y);
144
- p.lineTo(l.x2, l.yBottom);
145
- } else {
146
- // Horizontal edges at the band's top / bottom.
147
- p.moveTo(l.x1, l.y);
148
- p.lineTo(l.x2, l.y);
149
- p.moveTo(l.x1, l.yBottom);
150
- p.lineTo(l.x2, l.yBottom);
98
+ if (l.visible && hasBandBorder) {
99
+ if (form === "time-band") {
100
+ // Vertical edges at the band's left / right.
101
+ b.moveTo(l.x1, l.y);
102
+ b.lineTo(l.x1, l.yBottom);
103
+ b.moveTo(l.x2, l.y);
104
+ b.lineTo(l.x2, l.yBottom);
105
+ } else {
106
+ // Horizontal edges at the band's top / bottom.
107
+ b.moveTo(l.x1, l.y);
108
+ b.lineTo(l.x2, l.y);
109
+ b.moveTo(l.x1, l.yBottom);
110
+ b.lineTo(l.x2, l.yBottom);
111
+ }
151
112
  }
152
- return p;
113
+ return b.detach();
153
114
  });
154
115
 
155
116
  const offLinePath = useDerivedValue(() => {
156
- const cache = cacheRef.current!;
157
- cache.offT = !cache.offT;
158
- const p = cache.offT ? cache.offA : cache.offB;
159
- p.reset();
117
+ const b = offBuilder.value;
160
118
  const l = layout.get();
161
- if (!l.visible || !l.offAxis) return p;
162
- // Start the connector just past the badge pill's right edge so the dashed
163
- // line runs out to the chart edge rather than behind the badge.
164
- const pillRight =
165
- l.labelX + measureFontTextWidth(font, l.label) + OFF_AXIS_PILL_PAD_X;
166
- const start = pillRight + 4;
167
- if (start >= l.x2) return p;
168
- p.moveTo(start, l.y);
169
- p.lineTo(l.x2, l.y);
170
- return p;
119
+ if (l.visible && l.offAxis) {
120
+ // Start the connector just past the badge pill's right edge so the dashed
121
+ // line runs out to the chart edge rather than behind the badge.
122
+ const pillRight =
123
+ l.labelX + measureFontTextWidth(font, l.label) + OFF_AXIS_PILL_PAD_X;
124
+ const start = pillRight + 4;
125
+ if (start < l.x2) {
126
+ b.moveTo(start, l.y);
127
+ b.lineTo(l.x2, l.y);
128
+ }
129
+ }
130
+ return b.detach();
171
131
  });
172
132
 
173
133
  const chevronPath = useDerivedValue(() => {
174
- const cache = cacheRef.current!;
175
- cache.chevT = !cache.chevT;
176
- const p = cache.chevT ? cache.chevA : cache.chevB;
177
- p.reset();
134
+ const b = chevBuilder.value;
178
135
  const l = layout.get();
179
- if (!l.visible || !l.offAxis) return p;
180
- const cx = l.x1 + 6;
181
- const cy = l.y;
182
- const s = 4;
183
- if (l.chevronUp) {
184
- p.moveTo(cx - s, cy + s);
185
- p.lineTo(cx, cy - s);
186
- p.lineTo(cx + s, cy + s);
187
- } else {
188
- p.moveTo(cx - s, cy - s);
189
- p.lineTo(cx, cy + s);
190
- p.lineTo(cx + s, cy - s);
136
+ if (l.visible && l.offAxis) {
137
+ const cx = l.x1 + 6;
138
+ const cy = l.y;
139
+ const s = 4;
140
+ if (l.chevronUp) {
141
+ b.moveTo(cx - s, cy + s);
142
+ b.lineTo(cx, cy - s);
143
+ b.lineTo(cx + s, cy + s);
144
+ } else {
145
+ b.moveTo(cx - s, cy - s);
146
+ b.lineTo(cx, cy + s);
147
+ b.lineTo(cx + s, cy - s);
148
+ }
191
149
  }
192
- return p;
150
+ return b.detach();
193
151
  });
194
152
 
195
153
  const lineOpacity = useDerivedValue(() =>
@@ -0,0 +1,92 @@
1
+ import { Circle, Group } from "@shopify/react-native-skia";
2
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
3
+ import type { ResolvedSelectionDotConfig } from "../core/resolveConfig";
4
+
5
+ /**
6
+ * Built-in selection dot: a filled circle at the scrub intersection plus an
7
+ * optional subtle outer ring (the `ring` config). Drawn only when `y >= 0` (the
8
+ * sentinel for "no dot") — an off-screen Y hides it naturally.
9
+ */
10
+ export function DefaultSelectionDot({
11
+ x,
12
+ y,
13
+ opacity,
14
+ color,
15
+ size,
16
+ ring,
17
+ }: {
18
+ x: SharedValue<number>;
19
+ y: SharedValue<number>;
20
+ opacity: SharedValue<number>;
21
+ color: string;
22
+ size: number;
23
+ ring: ResolvedSelectionDotConfig["ring"];
24
+ }) {
25
+ // Push the dot off-screen when there's no value to mark (y < 0), so the
26
+ // sentinel never paints a stray dot at the top of the plot.
27
+ const cy = useDerivedValue(() => (y.get() < 0 ? -size * 4 : y.get()));
28
+ return (
29
+ <Group opacity={opacity}>
30
+ {ring && (
31
+ <Circle
32
+ cx={x}
33
+ cy={cy}
34
+ r={size + ring.width}
35
+ color={ring.color ?? color}
36
+ opacity={0.25}
37
+ />
38
+ )}
39
+ <Circle cx={x} cy={cy} r={size} color={color} />
40
+ </Group>
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Resolves the selection-dot slot inside a crosshair overlay: renders nothing
46
+ * when `config` is null or the position is unavailable, the consumer's custom
47
+ * `component` when one is given, and the built-in dot otherwise.
48
+ *
49
+ * `color` is the resolved fallback (line / leading-series color) used when the
50
+ * config's own `color` is unset.
51
+ */
52
+ export function SelectionDotSlot({
53
+ config,
54
+ x,
55
+ y,
56
+ active,
57
+ opacity,
58
+ color,
59
+ }: {
60
+ config?: ResolvedSelectionDotConfig | null;
61
+ x: SharedValue<number>;
62
+ y?: SharedValue<number>;
63
+ active?: SharedValue<number> | SharedValue<boolean>;
64
+ opacity: SharedValue<number>;
65
+ color: string;
66
+ }) {
67
+ if (!config || y === undefined || !active) return null;
68
+ const dotColor = config.color ?? color;
69
+ if (config.component) {
70
+ const Custom = config.component;
71
+ return (
72
+ <Custom
73
+ x={x}
74
+ y={y}
75
+ active={active as SharedValue<boolean>}
76
+ opacity={opacity}
77
+ color={dotColor}
78
+ size={config.size}
79
+ />
80
+ );
81
+ }
82
+ return (
83
+ <DefaultSelectionDot
84
+ x={x}
85
+ y={y}
86
+ opacity={opacity}
87
+ color={dotColor}
88
+ size={config.size}
89
+ ring={config.ring}
90
+ />
91
+ );
92
+ }