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
@@ -1,5 +1,4 @@
1
- import { Skia, type SkFont, type SkPath } from "@shopify/react-native-skia";
2
- import { useRef } from "react";
1
+ import { type SkFont } from "@shopify/react-native-skia";
3
2
  import {
4
3
  useDerivedValue,
5
4
  useSharedValue,
@@ -25,6 +24,7 @@ import type {
25
24
  LiveChartPalette,
26
25
  Momentum,
27
26
  } from "../types";
27
+ import { usePathBuilder } from "./usePathBuilder";
28
28
 
29
29
  export function useBadge(
30
30
  engine: ChartEngineWithLiveValue,
@@ -48,32 +48,17 @@ export function useBadge(
48
48
  const downRgb = hexToRgb(palette.dotDown);
49
49
  const accentRgb = hexToRgb(palette.badgeBg);
50
50
 
51
- // Ping-pong between two persistent badge paths so the derived value always
52
- // returns a freshly-mutated SkPath without allocating a new one every frame.
53
- // See useChartPaths for the full rationale on per-frame Skia.Path.Make().
54
- const cacheRef = useRef<{
55
- a: SkPath;
56
- b: SkPath;
57
- tick: boolean;
58
- } | null>(null);
59
- if (cacheRef.current === null) {
60
- cacheRef.current = {
61
- a: Skia.Path.Make(),
62
- b: Skia.Path.Make(),
63
- tick: false,
64
- };
65
- }
51
+ // Pill path is built into a reused PathBuilder and finalized with detach()
52
+ // each frame a fresh immutable SkPath, no per-frame Skia.Path.Make().
53
+ const badgeBuilder = usePathBuilder();
66
54
 
67
55
  const badge = useDerivedValue(() => {
68
- const cache = cacheRef.current!;
56
+ const b = badgeBuilder.value;
69
57
  const w = engine.canvasWidth.get();
70
58
  const h = engine.canvasHeight.get();
71
- cache.tick = !cache.tick;
72
- const path = cache.tick ? cache.a : cache.b;
73
- path.reset();
74
59
  if (w === 0 || h === 0) {
75
60
  return {
76
- path,
61
+ path: b.detach(),
77
62
  textX: 0,
78
63
  textY: 0,
79
64
  text: "",
@@ -108,7 +93,7 @@ export function useBadge(
108
93
  const bodyLeft = Math.max(badgeMetrics.marginEdge, bodyRight - pillW);
109
94
  const pillBodyW = bodyRight - bodyLeft;
110
95
  textX = (bodyLeft + bodyRight - textW) / 2;
111
- path.addRRect({
96
+ b.addRRect({
112
97
  rect: { x: bodyLeft, y: badgeY, width: pillBodyW, height: pillH },
113
98
  rx: r,
114
99
  ry: r,
@@ -132,16 +117,16 @@ export function useBadge(
132
117
  const badgeX = w - padding.right + badgeMetrics.dotGap;
133
118
  const cx = tl + pillW - r;
134
119
 
135
- path.moveTo(badgeX + tl, badgeY);
136
- path.lineTo(badgeX + cx, badgeY);
137
- path.arcToOval(
120
+ b.moveTo(badgeX + tl, badgeY);
121
+ b.lineTo(badgeX + cx, badgeY);
122
+ b.arcToOval(
138
123
  { x: badgeX + cx - r, y: badgeY, width: r * 2, height: pillH },
139
124
  -90,
140
125
  180,
141
126
  false,
142
127
  );
143
- path.lineTo(badgeX + tl, badgeY + pillH);
144
- path.cubicTo(
128
+ b.lineTo(badgeX + tl, badgeY + pillH);
129
+ b.cubicTo(
145
130
  badgeX + badgeMetrics.tailLength + 2,
146
131
  badgeY + pillH,
147
132
  badgeX + 3,
@@ -149,7 +134,7 @@ export function useBadge(
149
134
  badgeX,
150
135
  badgeY + r,
151
136
  );
152
- path.cubicTo(
137
+ b.cubicTo(
153
138
  badgeX + 3,
154
139
  badgeY + r - badgeMetrics.tailSpread,
155
140
  badgeX + badgeMetrics.tailLength + 2,
@@ -157,9 +142,9 @@ export function useBadge(
157
142
  badgeX + tl,
158
143
  badgeY,
159
144
  );
160
- path.close();
145
+ b.close();
161
146
  } else {
162
- path.addRRect({
147
+ b.addRRect({
163
148
  rect: { x: bodyLeft, y: badgeY, width: pillW, height: pillH },
164
149
  rx: r,
165
150
  ry: r,
@@ -199,7 +184,7 @@ export function useBadge(
199
184
  const textColor =
200
185
  variant === "minimal" ? "rgba(100,100,100,1)" : palette.badgeText;
201
186
 
202
- return { path, textX, textY, text, bgColor, textColor };
187
+ return { path: b.detach(), textX, textY, text, bgColor, textColor };
203
188
  });
204
189
 
205
190
  return badge;
@@ -1,5 +1,4 @@
1
- import { Skia, type SkPath } from "@shopify/react-native-skia";
2
- import { useRef } from "react";
1
+ import { Skia } from "@shopify/react-native-skia";
3
2
  import {
4
3
  useDerivedValue,
5
4
  useFrameCallback,
@@ -12,15 +11,15 @@ import { buildCandleGeometry } from "../draw/candle";
12
11
  import type { ChartPadding } from "../draw/line";
13
12
  import { lerp } from "../math/lerp";
14
13
  import type { CandleMetrics, CandlePoint } from "../types";
14
+ import { usePathBuilder } from "./usePathBuilder";
15
15
 
16
16
  const CANDLE_WIDTH_LERP_SPEED = 0.08;
17
17
 
18
18
  /**
19
- * Persistent candle `SkPath`s — two per geometry slot (up/down bodies + up/down wicks).
20
- * Each derived value ping-pongs between its pair and mutates the picked path in place
21
- * so we don't allocate a new JSI-backed `SkPath` per frame. See {@link useChartPaths}
22
- * for the rationale (this was the primary driver of the iOS memory climb under live
23
- * ticking).
19
+ * Candle paths (up/down bodies + up/down wicks). Each is built into a reused
20
+ * `Skia.PathBuilder` and finalized with `detach()` each frame a fresh
21
+ * immutable `SkPath`, so Skia repaints without a per-curve ping-pong and no
22
+ * mutable `SkPath` is retained across frames.
24
23
  */
25
24
  export function useCandlePaths(
26
25
  engine: SingleEngineState,
@@ -30,40 +29,16 @@ export function useCandlePaths(
30
29
  candleWidthSecs: number,
31
30
  active: boolean,
32
31
  candleMetrics: CandleMetrics = CANDLE_METRICS_DEFAULTS,
32
+ /** Static charts run no loops: register without starting. Default `true`. */
33
+ autostart = true,
33
34
  ) {
34
35
  const targetCandleWidth = useDerivedValue(() => candleWidthSecs);
35
36
  const displayCandleWidth = useSharedValue(candleWidthSecs);
36
37
 
37
- const cacheRef = useRef<{
38
- upBodiesA: SkPath;
39
- upBodiesB: SkPath;
40
- downBodiesA: SkPath;
41
- downBodiesB: SkPath;
42
- upWicksA: SkPath;
43
- upWicksB: SkPath;
44
- downWicksA: SkPath;
45
- downWicksB: SkPath;
46
- ubTick: boolean;
47
- dbTick: boolean;
48
- uwTick: boolean;
49
- dwTick: boolean;
50
- } | null>(null);
51
- if (cacheRef.current === null) {
52
- cacheRef.current = {
53
- upBodiesA: Skia.Path.Make(),
54
- upBodiesB: Skia.Path.Make(),
55
- downBodiesA: Skia.Path.Make(),
56
- downBodiesB: Skia.Path.Make(),
57
- upWicksA: Skia.Path.Make(),
58
- upWicksB: Skia.Path.Make(),
59
- downWicksA: Skia.Path.Make(),
60
- downWicksB: Skia.Path.Make(),
61
- ubTick: false,
62
- dbTick: false,
63
- uwTick: false,
64
- dwTick: false,
65
- };
66
- }
38
+ const upBodiesBuilder = usePathBuilder();
39
+ const downBodiesBuilder = usePathBuilder();
40
+ const upWicksBuilder = usePathBuilder();
41
+ const downWicksBuilder = usePathBuilder();
67
42
 
68
43
  useFrameCallback((frameInfo) => {
69
44
  "worklet";
@@ -77,7 +52,7 @@ export function useCandlePaths(
77
52
  dt,
78
53
  ),
79
54
  );
80
- });
55
+ }, autostart);
81
56
 
82
57
  /* istanbul ignore next -- worklet */
83
58
  const geometry = useDerivedValue(() => {
@@ -99,68 +74,56 @@ export function useCandlePaths(
99
74
 
100
75
  /* istanbul ignore next -- worklet */
101
76
  const upBodiesPath = useDerivedValue(() => {
102
- const cache = cacheRef.current!;
103
- cache.ubTick = !cache.ubTick;
104
- const path = cache.ubTick ? cache.upBodiesA : cache.upBodiesB;
105
- path.reset();
77
+ const b = upBodiesBuilder.value;
106
78
  const { bodies } = geometry.value;
107
79
  for (let i = 0; i < bodies.length; i++) {
108
80
  if (bodies[i].up) {
109
- path.addRect(
81
+ b.addRect(
110
82
  Skia.XYWHRect(bodies[i].x, bodies[i].y, bodies[i].w, bodies[i].h),
111
83
  );
112
84
  }
113
85
  }
114
- return path;
86
+ return b.detach();
115
87
  });
116
88
 
117
89
  /* istanbul ignore next -- worklet */
118
90
  const downBodiesPath = useDerivedValue(() => {
119
- const cache = cacheRef.current!;
120
- cache.dbTick = !cache.dbTick;
121
- const path = cache.dbTick ? cache.downBodiesA : cache.downBodiesB;
122
- path.reset();
91
+ const b = downBodiesBuilder.value;
123
92
  const { bodies } = geometry.value;
124
93
  for (let i = 0; i < bodies.length; i++) {
125
94
  if (!bodies[i].up) {
126
- path.addRect(
95
+ b.addRect(
127
96
  Skia.XYWHRect(bodies[i].x, bodies[i].y, bodies[i].w, bodies[i].h),
128
97
  );
129
98
  }
130
99
  }
131
- return path;
100
+ return b.detach();
132
101
  });
133
102
 
134
103
  /* istanbul ignore next -- worklet */
135
104
  const upWicksPath = useDerivedValue(() => {
136
- const cache = cacheRef.current!;
137
- cache.uwTick = !cache.uwTick;
138
- const path = cache.uwTick ? cache.upWicksA : cache.upWicksB;
139
- path.reset();
105
+ const b = upWicksBuilder.value;
140
106
  const { wicks } = geometry.value;
141
107
  for (let i = 0; i < wicks.length; i++) {
142
108
  if (wicks[i].up) {
143
- path.moveTo(wicks[i].x, wicks[i].y1);
144
- path.lineTo(wicks[i].x, wicks[i].y2);
109
+ b.moveTo(wicks[i].x, wicks[i].y1);
110
+ b.lineTo(wicks[i].x, wicks[i].y2);
145
111
  }
146
112
  }
147
- return path;
113
+ return b.detach();
148
114
  });
149
115
 
150
116
  /* istanbul ignore next -- worklet */
151
117
  const downWicksPath = useDerivedValue(() => {
152
- const cache = cacheRef.current!;
153
- cache.dwTick = !cache.dwTick;
154
- const path = cache.dwTick ? cache.downWicksA : cache.downWicksB;
155
- path.reset();
118
+ const b = downWicksBuilder.value;
156
119
  const { wicks } = geometry.value;
157
120
  for (let i = 0; i < wicks.length; i++) {
158
121
  if (!wicks[i].up) {
159
- path.moveTo(wicks[i].x, wicks[i].y1);
160
- path.lineTo(wicks[i].x, wicks[i].y2);
122
+ b.moveTo(wicks[i].x, wicks[i].y1);
123
+ b.lineTo(wicks[i].x, wicks[i].y2);
161
124
  }
162
125
  }
163
- return path;
126
+ return b.detach();
164
127
  });
165
128
 
166
129
  return { upBodiesPath, downBodiesPath, upWicksPath, downWicksPath } as const;
@@ -12,6 +12,11 @@ export interface ChartColors {
12
12
  gradientTopColor: string;
13
13
  /** Bottom gradient stop — custom opacity or palette default. */
14
14
  gradientBottomColor: string;
15
+ /** Gradient color stops (top → bottom). Custom `colors` when provided, else the
16
+ * 2-stop `[gradientTopColor, gradientBottomColor]` fallback. */
17
+ gradientColors: string[];
18
+ /** Stop positions matching `gradientColors`, or undefined for even spacing. */
19
+ gradientPositions: number[] | undefined;
15
20
  }
16
21
 
17
22
  /**
@@ -39,10 +44,24 @@ export function useChartColors(
39
44
  ? `rgba(${r}, ${g}, ${b}, ${gradientCfg.bottomOpacity})`
40
45
  : palette.fillBottom;
41
46
 
47
+ const customColors = gradientCfg?.colors;
48
+ const hasCustomColors = Array.isArray(customColors) && customColors.length >= 2;
49
+ const gradientColors = hasCustomColors
50
+ ? customColors
51
+ : [gradientTopColor, gradientBottomColor];
52
+ const gradientPositions =
53
+ hasCustomColors &&
54
+ gradientCfg?.positions !== undefined &&
55
+ gradientCfg.positions.length === customColors.length
56
+ ? gradientCfg.positions
57
+ : undefined;
58
+
42
59
  return {
43
60
  backgroundColor,
44
61
  gradientEnd,
45
62
  gradientTopColor,
46
63
  gradientBottomColor,
64
+ gradientColors,
65
+ gradientPositions,
47
66
  };
48
67
  }
@@ -5,30 +5,29 @@ import type { SingleEngineState } from "../core/useLiveChartEngine";
5
5
  import { buildLinePoints, type ChartPadding } from "../draw/line";
6
6
  import { drawSpline, makeSplineScratch } from "../math/spline";
7
7
  import { blendPtsY, squigglifyPts } from "../math/squiggly";
8
+ import { usePathBuilder } from "./usePathBuilder";
8
9
 
9
10
  /**
10
- * Persistent `linePath` / `fillPath` `SkPath`s that are **mutated in place** each frame.
11
+ * Builds the `linePath` / `fillPath` with `Skia.PathBuilder`s reused across
12
+ * frames (one per curve, held in a SharedValue) and finalized with `detach()` —
13
+ * which returns a fresh immutable `SkPath` each frame and resets the builder.
14
+ * The fresh reference makes Reanimated notify subscribers (re-record + repaint)
15
+ * without the two-SkPath ping-pong the mutable-path pool needed.
11
16
  *
12
- * Why not `useDerivedValue(() => Skia.Path.Make())`? Each call would allocate a brand-new
13
- * JSI-backed `SkPath`, and on iOS the native GC lags the JS GC under a steady firehose —
14
- * resident memory climbs steadily for as long as the chart is mounted. Here we allocate
15
- * two paths per curve once and ping-pong the reference each frame so Reanimated's
16
- * value-equality early-return still fires its subscribers (i.e. the Skia reanimated
17
- * container re-records and repaints).
17
+ * The flat point buffer still ping-pongs (ptsA/ptsB) so the intermediate
18
+ * `flatPts` derived value changes reference each frame and re-runs linePath /
19
+ * fillPath.
18
20
  */
19
21
  export function useChartPaths(
20
22
  engine: SingleEngineState,
21
23
  padding: ChartPadding,
22
24
  morphT?: SharedValue<number>,
23
25
  ) {
24
- // Two persistent paths per curve; ping-pong so the returned reference changes
25
- // every frame, which keeps Reanimated's setter from short-circuiting the notify.
26
+ const lineBuilder = usePathBuilder();
27
+ const fillBuilder = usePathBuilder();
28
+
26
29
  const cacheRef = useRef<{
27
- lineA: SkPath;
28
- lineB: SkPath;
29
- fillA: SkPath;
30
- fillB: SkPath;
31
- tick: boolean;
30
+ emptyPath: SkPath;
32
31
  ptsA: number[];
33
32
  ptsB: number[];
34
33
  ptsTick: boolean;
@@ -36,13 +35,7 @@ export function useChartPaths(
36
35
  } | null>(null);
37
36
  if (cacheRef.current === null) {
38
37
  cacheRef.current = {
39
- lineA: Skia.Path.Make(),
40
- lineB: Skia.Path.Make(),
41
- fillA: Skia.Path.Make(),
42
- fillB: Skia.Path.Make(),
43
- tick: false,
44
- // Ping-pong point buffers: reused across frames but the returned reference
45
- // still alternates, so Reanimated keeps notifying linePath/fillPath.
38
+ emptyPath: Skia.Path.Make(),
46
39
  ptsA: [] as number[],
47
40
  ptsB: [] as number[],
48
41
  ptsTick: false,
@@ -89,32 +82,27 @@ export function useChartPaths(
89
82
  const linePath = useDerivedValue(() => {
90
83
  const cache = cacheRef.current!;
91
84
  const pts = flatPts.get();
92
- cache.tick = !cache.tick;
93
- const path = cache.tick ? cache.lineA : cache.lineB;
94
- path.reset();
95
85
  const n = pts.length >> 1;
96
- if (n < 2) return path;
97
- path.moveTo(pts[0], pts[1]);
98
- drawSpline(path, pts, cache.scratch);
99
- return path;
86
+ if (n < 2) return cache.emptyPath;
87
+ const b = lineBuilder.value;
88
+ b.moveTo(pts[0], pts[1]);
89
+ drawSpline(b, pts, cache.scratch);
90
+ return b.detach();
100
91
  });
101
92
 
102
93
  const fillPath = useDerivedValue(() => {
103
94
  const cache = cacheRef.current!;
104
95
  const pts = flatPts.get();
105
- // Use the same tick flag flipped by linePath — flipping again here would
106
- // keep both paths in sync, so just read the current parity.
107
- const path = cache.tick ? cache.fillA : cache.fillB;
108
- path.reset();
109
96
  const n = pts.length >> 1;
110
- if (n < 2) return path;
111
- path.moveTo(pts[0], pts[1]);
112
- drawSpline(path, pts, cache.scratch);
97
+ if (n < 2) return cache.emptyPath;
98
+ const b = fillBuilder.value;
99
+ b.moveTo(pts[0], pts[1]);
100
+ drawSpline(b, pts, cache.scratch);
113
101
  const bottom = engine.canvasHeight.get() - padding.bottom;
114
- path.lineTo(pts[(n - 1) * 2], bottom);
115
- path.lineTo(pts[0], bottom);
116
- path.close();
117
- return path;
102
+ b.lineTo(pts[(n - 1) * 2], bottom);
103
+ b.lineTo(pts[0], bottom);
104
+ b.close();
105
+ return b.detach();
118
106
  });
119
107
 
120
108
  return { linePath, fillPath } as const;
@@ -58,6 +58,8 @@ export interface ChartRevealState {
58
58
  export function useChartReveal(
59
59
  loading: boolean,
60
60
  hasData: SharedValue<boolean>,
61
+ /** Static charts skip the entry ramp: morphT snaps to its target, no `withTiming`. */
62
+ isStatic = false,
61
63
  ): ChartRevealState {
62
64
  // Best-guess initial so the common case — a live chart that already has data
63
65
  // and isn't loading — paints fully revealed with no flash. The reaction below
@@ -71,13 +73,14 @@ export function useChartReveal(
71
73
 
72
74
  // The reaction reads presence/loading on the UI thread (a worklet, never during
73
75
  // render — so no Reanimated "reading `value` during render" warning), seeds the
74
- // exact reveal state on its first run, then animates on later changes.
76
+ // exact reveal state on its first run, then animates on later changes. Static
77
+ // charts always snap (no `withTiming` ramp) so no entry animation ever runs.
75
78
  useAnimatedReaction(
76
79
  () => !isLoading.get() && hasData.get(),
77
80
  (chartVisible, prev) => {
78
81
  "worklet";
79
- if (prev === null || prev === undefined) {
80
- // First run: snap to the correct initial reveal state (no animation).
82
+ if (isStatic || prev === null || prev === undefined) {
83
+ // Static, or first run: snap to the correct reveal state (no animation).
81
84
  morphT.set(chartVisible ? 1 : 0);
82
85
  return;
83
86
  }
@@ -90,7 +93,7 @@ export function useChartReveal(
90
93
  );
91
94
  }
92
95
  },
93
- [hasData],
96
+ [hasData, isStatic],
94
97
  );
95
98
 
96
99
  const isEmpty = useDerivedValue(() => !isLoading.get() && !hasData.get());
@@ -16,6 +16,7 @@ import type { CandlePoint, LiveChartPalette, ScrubPoint } from "../types";
16
16
  import {
17
17
  computeCandleTooltipLayout,
18
18
  computeCrosshairOpacity,
19
+ computeScrubDotY,
19
20
  computeScrubTime,
20
21
  deriveCrosshairTooltipSingle,
21
22
  type CrosshairState,
@@ -57,9 +58,14 @@ export function useCrosshair(
57
58
  candleOpts?: CrosshairCandleOpts,
58
59
  /** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */
59
60
  panGestureDelay = 0,
61
+ onGestureStart?: () => void,
62
+ onGestureEnd?: () => void,
60
63
  ): CrosshairState {
61
64
  const scrubX = useSharedValue(-1);
62
65
  const scrubActive = useSharedValue(false);
66
+ // Tracks whether the active scrub phase actually began, so a tap that never
67
+ // activates doesn't emit a spurious onGestureEnd.
68
+ const gestureStarted = useSharedValue(false);
63
69
 
64
70
  const scrubTime = useDerivedValue(() =>
65
71
  computeScrubTime(
@@ -112,6 +118,19 @@ export function useCrosshair(
112
118
  ),
113
119
  );
114
120
 
121
+ // Y pixel of the scrub intersection — used by the selection dot. -1 when
122
+ // there's no value to mark.
123
+ const scrubDotY = useDerivedValue(() =>
124
+ computeScrubDotY(
125
+ scrubValue.get(),
126
+ engine.displayMin.get(),
127
+ engine.displayMax.get(),
128
+ engine.canvasHeight.get(),
129
+ padding.top,
130
+ padding.bottom,
131
+ ),
132
+ );
133
+
115
134
  // Monospace advance width, measured once per render (not per scrub frame) so
116
135
  // the tooltip layout worklet can size text by character count instead of a
117
136
  // per-frame Skia measureText.
@@ -165,7 +184,19 @@ export function useCrosshair(
165
184
  onScrub?.(null);
166
185
  }
167
186
 
187
+ /* istanbul ignore next */
188
+ function handleGestureStart() {
189
+ onGestureStart?.();
190
+ }
191
+
192
+ /* istanbul ignore next */
193
+ function handleGestureEnd() {
194
+ onGestureEnd?.();
195
+ }
196
+
168
197
  const hasOnScrub = onScrub != null;
198
+ const hasOnGestureStart = onGestureStart != null;
199
+ const hasOnGestureEnd = onGestureEnd != null;
169
200
 
170
201
  useAnimatedReaction(
171
202
  () => {
@@ -178,13 +209,14 @@ export function useCrosshair(
178
209
  if (val === null || time < 0) return "__pending__";
179
210
  const chartW = engine.canvasWidth.get() - padding.left - padding.right;
180
211
  if (chartW <= 0) return "__pending__";
181
- const h = engine.canvasHeight.get();
182
- const chartH = h - padding.top - padding.bottom;
183
- const valRange = engine.displayMax.get() - engine.displayMin.get();
184
- const dotY =
185
- valRange === 0
186
- ? padding.top + chartH / 2
187
- : padding.top + ((engine.displayMax.get() - val) / valRange) * chartH;
212
+ const dotY = computeScrubDotY(
213
+ val,
214
+ engine.displayMin.get(),
215
+ engine.displayMax.get(),
216
+ engine.canvasHeight.get(),
217
+ padding.top,
218
+ padding.bottom,
219
+ );
188
220
  let candleJson: string | null = null;
189
221
  if (isCandleMode) {
190
222
  const c = scrubCandle.get();
@@ -229,6 +261,8 @@ export function useCrosshair(
229
261
  if (!enabled) return;
230
262
  scrubX.set(e.x);
231
263
  scrubActive.set(true);
264
+ gestureStarted.set(true);
265
+ if (hasOnGestureStart) scheduleOnRN(handleGestureStart);
232
266
  },
233
267
  )
234
268
  .onUpdate(
@@ -243,6 +277,10 @@ export function useCrosshair(
243
277
  "worklet";
244
278
  scrubActive.set(false);
245
279
  if (hasOnScrub) scheduleOnRN(handleScrubEnd);
280
+ if (gestureStarted.get()) {
281
+ gestureStarted.set(false);
282
+ if (hasOnGestureEnd) scheduleOnRN(handleGestureEnd);
283
+ }
246
284
  },
247
285
  );
248
286
 
@@ -258,6 +296,7 @@ export function useCrosshair(
258
296
  scrubValue,
259
297
  crosshairOpacity,
260
298
  tooltipLayout,
299
+ scrubDotY,
261
300
  gesture,
262
301
  };
263
302
  }
@@ -5,6 +5,7 @@ import {
5
5
  useDerivedValue,
6
6
  useSharedValue,
7
7
  } from "react-native-reanimated";
8
+ import { scheduleOnRN } from "react-native-worklets";
8
9
  import type { MultiEngineState } from "../core/useLiveChartEngine";
9
10
  import { type ChartPadding } from "../draw/line";
10
11
  import type { ScrubPointMulti } from "../types";
@@ -14,6 +15,7 @@ import {
14
15
  } from "./crosshairSeries";
15
16
  import {
16
17
  computeCrosshairOpacity,
18
+ computeScrubDotY,
17
19
  computeScrubTime,
18
20
  HIDDEN_TOOLTIP,
19
21
  type CrosshairState,
@@ -30,9 +32,14 @@ export function useCrosshairSeries(
30
32
  onScrub?: (point: ScrubPointMulti | null) => void,
31
33
  /** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */
32
34
  panGestureDelay = 0,
35
+ onGestureStart?: () => void,
36
+ onGestureEnd?: () => void,
33
37
  ): CrosshairState {
34
38
  const scrubX = useSharedValue(-1);
35
39
  const scrubActive = useSharedValue(false);
40
+ // Tracks whether the active scrub phase actually began, so a tap that never
41
+ // activates doesn't emit a spurious onGestureEnd.
42
+ const gestureStarted = useSharedValue(false);
36
43
 
37
44
  const scrubTime = useDerivedValue(() =>
38
45
  computeScrubTime(
@@ -62,9 +69,34 @@ export function useCrosshairSeries(
62
69
  ),
63
70
  );
64
71
 
72
+ // Y pixel of the scrub intersection (leading-series value) — used by the
73
+ // selection dot. -1 when there's no value to mark.
74
+ const scrubDotY = useDerivedValue(() =>
75
+ computeScrubDotY(
76
+ scrubValue.get(),
77
+ engine.displayMin.get(),
78
+ engine.displayMax.get(),
79
+ engine.canvasHeight.get(),
80
+ padding.top,
81
+ padding.bottom,
82
+ ),
83
+ );
84
+
65
85
  const tooltipLayout = useSharedValue(HIDDEN_TOOLTIP);
66
86
 
87
+ /* istanbul ignore next */
88
+ function handleGestureStart() {
89
+ onGestureStart?.();
90
+ }
91
+
92
+ /* istanbul ignore next */
93
+ function handleGestureEnd() {
94
+ onGestureEnd?.();
95
+ }
96
+
67
97
  const hasOnScrub = onScrub != null;
98
+ const hasOnGestureStart = onGestureStart != null;
99
+ const hasOnGestureEnd = onGestureEnd != null;
68
100
 
69
101
  useAnimatedReaction(
70
102
  () => {
@@ -77,14 +109,14 @@ export function useCrosshairSeries(
77
109
  if (chartW <= 0) return "__pending__";
78
110
  const r = interpolateSeriesAtTime(engine.series.get(), time);
79
111
  if (r.primary === null) return "__pending__";
80
- const h = engine.canvasHeight.get();
81
- const chartH = h - padding.top - padding.bottom;
82
- const valRange = engine.displayMax.get() - engine.displayMin.get();
83
- const dotY =
84
- valRange === 0
85
- ? padding.top + chartH / 2
86
- : padding.top +
87
- ((engine.displayMax.get() - r.primary) / valRange) * chartH;
112
+ const dotY = computeScrubDotY(
113
+ r.primary,
114
+ engine.displayMin.get(),
115
+ engine.displayMax.get(),
116
+ engine.canvasHeight.get(),
117
+ padding.top,
118
+ padding.bottom,
119
+ );
88
120
  return JSON.stringify({
89
121
  time,
90
122
  x,
@@ -136,6 +168,8 @@ export function useCrosshairSeries(
136
168
  if (!enabled) return;
137
169
  scrubX.set(e.x);
138
170
  scrubActive.set(true);
171
+ gestureStarted.set(true);
172
+ if (hasOnGestureStart) scheduleOnRN(handleGestureStart);
139
173
  },
140
174
  )
141
175
  .onUpdate(
@@ -149,6 +183,10 @@ export function useCrosshairSeries(
149
183
  /* istanbul ignore next */ () => {
150
184
  "worklet";
151
185
  scrubActive.set(false);
186
+ if (gestureStarted.get()) {
187
+ gestureStarted.set(false);
188
+ if (hasOnGestureEnd) scheduleOnRN(handleGestureEnd);
189
+ }
152
190
  },
153
191
  );
154
192
 
@@ -164,6 +202,7 @@ export function useCrosshairSeries(
164
202
  scrubValue,
165
203
  crosshairOpacity,
166
204
  tooltipLayout,
205
+ scrubDotY,
167
206
  gesture,
168
207
  };
169
208
  }