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,14 +1,9 @@
1
- import {
2
- DashPathEffect,
3
- Path,
4
- Skia,
5
- type SkPath,
6
- } from "@shopify/react-native-skia";
7
- import { useRef } from "react";
1
+ import { DashPathEffect, Path } from "@shopify/react-native-skia";
8
2
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
9
3
 
10
4
  import type { ChartPadding } from "../draw/line";
11
5
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
6
+ import { usePathBuilder } from "../hooks/usePathBuilder";
12
7
 
13
8
  /**
14
9
  * A dashed horizontal line that tracks the live display value — sitting at
@@ -29,30 +24,16 @@ export function ValueLineOverlay({
29
24
  intervals: [number, number];
30
25
  color: string;
31
26
  }) {
32
- // Ping-pong persistent paths — avoid allocating a JSI-backed SkPath per frame.
33
- const cacheRef = useRef<{
34
- a: SkPath;
35
- b: SkPath;
36
- tick: boolean;
37
- } | null>(null);
38
- if (cacheRef.current === null) {
39
- cacheRef.current = {
40
- a: Skia.Path.Make(),
41
- b: Skia.Path.Make(),
42
- tick: false,
43
- };
44
- }
27
+ const builder = usePathBuilder();
45
28
 
46
29
  const path = useDerivedValue(() => {
47
- const cache = cacheRef.current!;
48
- cache.tick = !cache.tick;
49
- const p = cache.tick ? cache.a : cache.b;
50
- p.reset();
30
+ const b = builder.value;
51
31
  const y = dotY.get();
52
- if (y < 0) return p;
53
- p.moveTo(padding.left, y);
54
- p.lineTo(engine.canvasWidth.get() - padding.right, y);
55
- return p;
32
+ if (y >= 0) {
33
+ b.moveTo(padding.left, y);
34
+ b.lineTo(engine.canvasWidth.get() - padding.right, y);
35
+ }
36
+ return b.detach();
56
37
  });
57
38
 
58
39
  return (
@@ -1,15 +1,9 @@
1
- import {
2
- Group,
3
- Path,
4
- Skia,
5
- type SkFont,
6
- type SkPath,
7
- } from "@shopify/react-native-skia";
8
- import { useRef } from "react";
1
+ import { Group, Path, type SkFont } from "@shopify/react-native-skia";
9
2
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
10
3
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
11
4
  import type { ChartPadding } from "../draw/line";
12
5
  import type { XAxisEntry } from "../hooks/useXAxis";
6
+ import { usePathBuilder } from "../hooks/usePathBuilder";
13
7
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
14
8
  import type { LiveChartPalette } from "../types";
15
9
  import { AnimatedLabel } from "./AnimatedLabel";
@@ -31,40 +25,26 @@ export function XAxisOverlay({
31
25
  palette: LiveChartPalette;
32
26
  font: SkFont;
33
27
  }) {
34
- const axisCacheRef = useRef<{
35
- a: SkPath;
36
- b: SkPath;
37
- tick: boolean;
38
- } | null>(null);
39
- if (axisCacheRef.current === null) {
40
- axisCacheRef.current = {
41
- a: Skia.Path.Make(),
42
- b: Skia.Path.Make(),
43
- tick: false,
44
- };
45
- }
28
+ const axisBuilder = usePathBuilder();
46
29
 
47
30
  const axisPath = useDerivedValue(() => {
48
31
  "worklet";
49
- const axisCache = axisCacheRef.current!;
50
- axisCache.tick = !axisCache.tick;
51
- const path = axisCache.tick ? axisCache.a : axisCache.b;
52
- path.reset();
32
+ const b = axisBuilder.value;
53
33
  const w = engine.canvasWidth.get();
54
34
  const h = engine.canvasHeight.get();
55
35
  const lineY = h - padding.bottom;
56
36
 
57
37
  // Bottom axis line
58
- path.moveTo(padding.left, lineY);
59
- path.lineTo(w - padding.right, lineY);
38
+ b.moveTo(padding.left, lineY);
39
+ b.lineTo(w - padding.right, lineY);
60
40
 
61
41
  // Tick marks
62
42
  const items = entries.get();
63
43
  for (let i = 0; i < items.length; i++) {
64
- path.moveTo(items[i].x, lineY);
65
- path.lineTo(items[i].x, lineY + TICK_HEIGHT);
44
+ b.moveTo(items[i].x, lineY);
45
+ b.lineTo(items[i].x, lineY + TICK_HEIGHT);
66
46
  }
67
- return path;
47
+ return b.detach();
68
48
  });
69
49
 
70
50
  // Transform XAxisEntry[] into { x, y, label, alpha } for AnimatedLabel
@@ -2,11 +2,8 @@ import {
2
2
  DashPathEffect,
3
3
  Group,
4
4
  Path,
5
- Skia,
6
5
  type SkFont,
7
- type SkPath,
8
6
  } from "@shopify/react-native-skia";
9
- import { useRef } from "react";
10
7
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
11
8
  import { BADGE_METRICS_DEFAULTS } from "../constants";
12
9
  import type { ResolvedGridStyleConfig } from "../core/resolveConfig";
@@ -18,6 +15,7 @@ import {
18
15
  pillTextLeftX,
19
16
  type ChartPadding,
20
17
  } from "../draw/line";
18
+ import { usePathBuilder } from "../hooks/usePathBuilder";
21
19
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
22
20
  import type { BadgeMetrics, LiveChartPalette } from "../types";
23
21
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
@@ -57,31 +55,17 @@ export function YAxisOverlay({
57
55
  const gridWidth = gridStyle?.strokeWidth ?? 1;
58
56
  const gridIntervals = gridStyle?.intervals ?? [];
59
57
  const gridOpacity = gridStyle?.opacity ?? 1;
60
- const gridCacheRef = useRef<{
61
- a: SkPath;
62
- b: SkPath;
63
- tick: boolean;
64
- } | null>(null);
65
- if (gridCacheRef.current === null) {
66
- gridCacheRef.current = {
67
- a: Skia.Path.Make(),
68
- b: Skia.Path.Make(),
69
- tick: false,
70
- };
71
- }
58
+ const gridBuilder = usePathBuilder();
72
59
 
73
60
  const gridLinesPath = useDerivedValue(() => {
74
- const gridCache = gridCacheRef.current!;
75
- gridCache.tick = !gridCache.tick;
76
- const path = gridCache.tick ? gridCache.a : gridCache.b;
77
- path.reset();
61
+ const b = gridBuilder.value;
78
62
  const items = entries.get();
79
63
  const w = engine.canvasWidth.get();
80
64
  for (let i = 0; i < items.length; i++) {
81
- path.moveTo(padding.left, items[i].y);
82
- path.lineTo(w - padding.right, items[i].y);
65
+ b.moveTo(padding.left, items[i].y);
66
+ b.lineTo(w - padding.right, items[i].y);
83
67
  }
84
- return path;
68
+ return b.detach();
85
69
  });
86
70
 
87
71
  const leftInset =
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AxisLabelConfig,
2
3
  BadgeConfig,
3
4
  BadgeVariant,
4
5
  DegenOptions,
@@ -17,12 +18,16 @@ import type {
17
18
  PulseConfig,
18
19
  ReferenceLine,
19
20
  ScrubConfig,
21
+ SelectionDotConfig,
22
+ SelectionDotProps,
23
+ SelectionDotRingConfig,
20
24
  TradeEvent,
21
25
  ValueLineConfig,
22
26
  XAxisConfig,
23
27
  YAxisConfig,
24
28
  } from "../types";
25
29
 
30
+ import type { ComponentType, ReactElement } from "react";
26
31
  import type { SharedValue } from "react-native-reanimated";
27
32
  import {
28
33
  BADGE_METRICS_DEFAULTS,
@@ -53,6 +58,16 @@ export interface ResolvedYAxisConfig {
53
58
  minGap: number;
54
59
  }
55
60
 
61
+ export interface ResolvedAxisLabelConfig {
62
+ /** undefined → use the chart's `formatValue` at render time. */
63
+ format?: (v: number) => string;
64
+ /** undefined → use the muted default label color at render time. */
65
+ color?: string;
66
+ position: "left" | "right";
67
+ /** When set, the built-in value label is replaced by this custom element. */
68
+ render?: () => ReactElement | null;
69
+ }
70
+
56
71
  export interface ResolvedXAxisConfig {
57
72
  minGap: number;
58
73
  }
@@ -80,6 +95,10 @@ export interface ResolvedGradientConfig {
80
95
  topOpacity: number | undefined;
81
96
  /** undefined → use palette.fillBottom (transparent) at render time */
82
97
  bottomOpacity: number | undefined;
98
+ /** Explicit color stops (top → bottom); overrides the opacity stops. */
99
+ colors: string[] | undefined;
100
+ /** Stop positions (0..1) matching `colors` length. */
101
+ positions: number[] | undefined;
83
102
  }
84
103
 
85
104
  export interface ResolvedPulseConfig {
@@ -217,6 +236,24 @@ export function resolveYAxis(
217
236
  return resolveToggle(prop, Y_AXIS_DEFAULTS, false);
218
237
  }
219
238
 
239
+ const AXIS_LABEL_DEFAULTS: ResolvedAxisLabelConfig = {
240
+ format: undefined,
241
+ color: undefined,
242
+ position: "right",
243
+ render: undefined,
244
+ };
245
+
246
+ /**
247
+ * Resolves a `topLabel` / `bottomLabel` prop to a fully-typed config or null
248
+ * (no label). Opt-in, so `undefined`/`false` → null; `true` → the built-in
249
+ * value label with defaults; object → configured built-in (or a custom `render`).
250
+ */
251
+ export function resolveAxisLabel(
252
+ prop: boolean | AxisLabelConfig | undefined,
253
+ ): ResolvedAxisLabelConfig | null {
254
+ return resolveToggle(prop, AXIS_LABEL_DEFAULTS, false);
255
+ }
256
+
220
257
  const X_AXIS_DEFAULTS: ResolvedXAxisConfig = {
221
258
  minGap: 60,
222
259
  };
@@ -255,6 +292,8 @@ export function resolveScrub(
255
292
  const GRADIENT_DEFAULTS: ResolvedGradientConfig = {
256
293
  topOpacity: undefined,
257
294
  bottomOpacity: undefined,
295
+ colors: undefined,
296
+ positions: undefined,
258
297
  };
259
298
 
260
299
  /**
@@ -574,6 +613,65 @@ export function resolveMultiSeriesDot(
574
613
  };
575
614
  }
576
615
 
616
+ // ─── Selection dot ──────────────────────────────────────────────────────────
617
+
618
+ /** Resolved selection-dot ring. `color: undefined` → use the dot color. */
619
+ export interface ResolvedSelectionDotRingConfig {
620
+ color: string | undefined;
621
+ width: number;
622
+ }
623
+
624
+ const SELECTION_DOT_RING_DEFAULTS: ResolvedSelectionDotRingConfig = {
625
+ color: undefined,
626
+ width: 2,
627
+ };
628
+
629
+ /** `undefined`/`true` → ring defaults; `false` → null (no ring). */
630
+ export function resolveSelectionDotRing(
631
+ prop: boolean | SelectionDotRingConfig | undefined,
632
+ ): ResolvedSelectionDotRingConfig | null {
633
+ return resolveToggle(prop, SELECTION_DOT_RING_DEFAULTS, true);
634
+ }
635
+
636
+ /** Fully-resolved selection-dot styling. */
637
+ export interface ResolvedSelectionDotConfig {
638
+ size: number;
639
+ /** undefined → use the line / leading-series color at render time. */
640
+ color: string | undefined;
641
+ ring: ResolvedSelectionDotRingConfig | null;
642
+ /** When set, the built-in size/color/ring knobs are ignored. */
643
+ component?: ComponentType<SelectionDotProps>;
644
+ }
645
+
646
+ const SELECTION_DOT_SIZE_DEFAULT = 4;
647
+
648
+ const SELECTION_DOT_DEFAULTS: ResolvedSelectionDotConfig = {
649
+ size: SELECTION_DOT_SIZE_DEFAULT,
650
+ color: undefined,
651
+ ring: SELECTION_DOT_RING_DEFAULTS,
652
+ };
653
+
654
+ /**
655
+ * Resolves the `selectionDot` prop to a fully-typed config or null (hidden).
656
+ * Defaults to ON, so `undefined`/`true` yield the built-in dot.
657
+ * - `false` → `null` (no dot)
658
+ * - `undefined`/`true` → built-in dot with defaults
659
+ * - object → configured built-in dot, or the custom `component` when set
660
+ * (the size/color/ring knobs are still resolved but ignored by the slot)
661
+ */
662
+ export function resolveSelectionDot(
663
+ prop: boolean | SelectionDotConfig | undefined,
664
+ ): ResolvedSelectionDotConfig | null {
665
+ if (prop === false) return null;
666
+ if (prop == null || prop === true) return SELECTION_DOT_DEFAULTS;
667
+ return {
668
+ size: prop.size ?? SELECTION_DOT_DEFAULTS.size,
669
+ color: prop.color,
670
+ ring: resolveSelectionDotRing(prop.ring),
671
+ component: prop.component,
672
+ };
673
+ }
674
+
577
675
  // ─── Legend ───────────────────────────────────────────────────────────────────
578
676
 
579
677
  export interface ResolvedLegendConfig {
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { useState } from "react";
9
9
  import {
10
+ useAnimatedReaction,
10
11
  useDerivedValue,
11
12
  useFrameCallback,
12
13
  useSharedValue,
@@ -31,6 +32,12 @@ export interface EngineConfig {
31
32
  nowOverride?: number;
32
33
  windowBuffer?: number;
33
34
  paused?: boolean;
35
+ /**
36
+ * Loop-free render: settle the display state once (smoothing forced to 1, no
37
+ * per-frame frame callback) for many small charts in a list. See
38
+ * {@link LiveChartProps.static}.
39
+ */
40
+ static?: boolean;
34
41
  mode?: "line" | "candle";
35
42
  candles?: SharedValue<CandlePoint[]>;
36
43
  liveCandle?: SharedValue<CandlePoint | null>;
@@ -143,7 +150,11 @@ export function applyLiveChartEngineFrame(
143
150
  export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
144
151
  // Low-frequency config → UI thread via useDerivedValue
145
152
  const timeWindow = useDerivedValue(() => config.timeWindow);
146
- const smoothing = useDerivedValue(() => config.smoothing);
153
+ // Static charts snap to their target in one tick (smoothing=1), so the single
154
+ // settle reaction below produces the final state with no per-frame easing.
155
+ const smoothing = useDerivedValue(() =>
156
+ config.static ? 1 : config.smoothing,
157
+ );
147
158
  const adaptiveSpeedBoostSV = useDerivedValue(() => config.adaptiveSpeedBoost);
148
159
  const exaggerateSV = useDerivedValue(() => config.exaggerate ?? false);
149
160
  const referenceValue = useDerivedValue(() => config.referenceValue);
@@ -170,34 +181,74 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
170
181
  // no useDerivedValue bridging, no closure serialization per tick.
171
182
  const { data, value, candles, liveCandle } = config;
172
183
 
184
+ // Static charts run zero per-frame loops. `isStaticSV` gates both the
185
+ // frame-callback autostart and the one-shot settle reaction below.
186
+ const isStaticSV = useDerivedValue(() => config.static ?? false);
187
+
188
+ // Single refs object shared by the frame callback and the settle reaction so
189
+ // they can never drift out of sync.
190
+ const frameRefs: EngineFrameRefs = {
191
+ data,
192
+ value,
193
+ displayValue,
194
+ displayMin,
195
+ displayMax,
196
+ displayWindow,
197
+ timestamp,
198
+ canvasWidth,
199
+ canvasHeight,
200
+ timeWindow,
201
+ smoothing,
202
+ adaptiveSpeedBoostSV,
203
+ exaggerateSV,
204
+ referenceValue,
205
+ referenceValues,
206
+ nonNegativeSV,
207
+ maxValueSV,
208
+ nowOverrideSV,
209
+ windowBufferSV,
210
+ pausedSV,
211
+ modeSV,
212
+ candles,
213
+ liveCandle,
214
+ };
215
+
216
+ // `autostart=false` registers the frame callback without running it — the live
217
+ // loop is fully inert in static mode (the invariant that makes this worth it).
173
218
  useFrameCallback((frameInfo) => {
174
219
  "worklet";
175
- applyLiveChartEngineFrame(frameInfo, {
176
- data,
177
- value,
178
- displayValue,
179
- displayMin,
180
- displayMax,
181
- displayWindow,
182
- timestamp,
183
- canvasWidth,
184
- canvasHeight,
185
- timeWindow,
186
- smoothing,
187
- adaptiveSpeedBoostSV,
188
- exaggerateSV,
189
- referenceValue,
190
- referenceValues,
191
- nonNegativeSV,
192
- maxValueSV,
193
- nowOverrideSV,
194
- windowBufferSV,
195
- pausedSV,
196
- modeSV,
197
- candles,
198
- liveCandle,
199
- });
200
- });
220
+ applyLiveChartEngineFrame(frameInfo, frameRefs);
221
+ }, !config.static);
222
+
223
+ // One-shot settle for static charts: the fingerprint changes when the canvas
224
+ // lays out (width 0→real) or the framing inputs change, and the handler runs a
225
+ // single snap tick (smoothing=1). Inert when not static — `prepare` returns a
226
+ // constant so it never fires.
227
+ useAnimatedReaction(
228
+ () => {
229
+ if (!isStaticSV.get()) return 0; // inert when not static — never changes
230
+ const d = data.get();
231
+ const n = d.length;
232
+ return [
233
+ n,
234
+ n ? d[0].time : 0,
235
+ n ? d[0].value : 0,
236
+ n ? d[n - 1].time : 0,
237
+ n ? d[n - 1].value : 0,
238
+ value.get(),
239
+ canvasWidth.get(),
240
+ canvasHeight.get(),
241
+ timeWindow.get(),
242
+ ].join(",");
243
+ },
244
+ (curr, prev) => {
245
+ if (!isStaticSV.get() || curr === prev) return;
246
+ applyLiveChartEngineFrame(
247
+ { timeSincePreviousFrame: MS_PER_FRAME_60FPS },
248
+ frameRefs,
249
+ );
250
+ },
251
+ );
201
252
 
202
253
  return {
203
254
  data,
@@ -207,17 +207,17 @@ function cellSpec(m: Marker, palette: LiveChartPalette, font: SkFont): CellSpec
207
207
  w: box,
208
208
  h: box,
209
209
  draw: (canvas, cx, cy) => {
210
- const p = Skia.Path.Make();
210
+ const pb = Skia.PathBuilder.Make();
211
211
  for (let k = 0; k < 10; k++) {
212
212
  const ang = -Math.PI / 2 + (k * Math.PI) / 5;
213
213
  const rad = k % 2 === 0 ? outer : inner;
214
214
  const px = cx + rad * Math.cos(ang);
215
215
  const py = cy + rad * Math.sin(ang);
216
- if (k === 0) p.moveTo(px, py);
217
- else p.lineTo(px, py);
216
+ if (k === 0) pb.moveTo(px, py);
217
+ else pb.lineTo(px, py);
218
218
  }
219
- p.close();
220
- canvas.drawPath(p, fillPaint(color));
219
+ pb.close();
220
+ canvas.drawPath(pb.detach(), fillPaint(color));
221
221
  },
222
222
  };
223
223
  }
@@ -229,15 +229,15 @@ function cellSpec(m: Marker, palette: LiveChartPalette, font: SkFont): CellSpec
229
229
  w: box,
230
230
  h: box,
231
231
  draw: (canvas, cx, cy) => {
232
- const p = Skia.Path.Make();
232
+ const pb = Skia.PathBuilder.Make();
233
233
  for (let k = 0; k < 4; k++) {
234
234
  const ang = (k * Math.PI) / 4;
235
235
  const dx = L * Math.cos(ang);
236
236
  const dy = L * Math.sin(ang);
237
- p.moveTo(cx - dx, cy - dy);
238
- p.lineTo(cx + dx, cy + dy);
237
+ pb.moveTo(cx - dx, cy - dy);
238
+ pb.lineTo(cx + dx, cy + dy);
239
239
  }
240
- canvas.drawPath(p, strokePaint(color, 1.5, true));
240
+ canvas.drawPath(pb.detach(), strokePaint(color, 1.5, true));
241
241
  },
242
242
  };
243
243
  }
@@ -0,0 +1,95 @@
1
+ import {
2
+ Skia,
3
+ PaintStyle,
4
+ drawAsImageFromPicture,
5
+ type SkImage,
6
+ } from "@shopify/react-native-skia";
7
+ import { DEGEN_STRIDE } from "../constants";
8
+
9
+ /** Reference radius of the rasterized white circle sprite (px). */
10
+ const SPRITE_RADIUS = 16;
11
+ /** Anti-alias breathing room baked around the circle so it never clips. */
12
+ const SPRITE_MARGIN = 2;
13
+
14
+ /** A rasterized white-circle sprite + its geometry. */
15
+ export interface ParticleSprite {
16
+ image: SkImage | null;
17
+ /** Side length of the square sprite box (px). */
18
+ size: number;
19
+ /** Radius of the white circle inside the box (px). */
20
+ radius: number;
21
+ }
22
+
23
+ /**
24
+ * Rasterize ONE anti-aliased **white** filled circle into a square sprite, once
25
+ * (NOT per frame). Drawing it white lets each particle's per-sprite color
26
+ * modulate (multiply) the texture to its own tint + alpha via `drawAtlas`.
27
+ *
28
+ * Mirrors the PictureRecorder pattern in `markerAtlas.ts`.
29
+ */
30
+ export function buildParticleSprite(): ParticleSprite {
31
+ const R = SPRITE_RADIUS;
32
+ const S = 2 * (R + SPRITE_MARGIN);
33
+ const paint = Skia.Paint();
34
+ paint.setAntiAlias(true);
35
+ paint.setColor(Skia.Color("#ffffff"));
36
+ paint.setStyle(PaintStyle.Fill);
37
+
38
+ const recorder = Skia.PictureRecorder();
39
+ const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, S, S));
40
+ canvas.drawCircle(S / 2, S / 2, R, paint);
41
+ const picture = recorder.finishRecordingAsPicture();
42
+ const image = drawAsImageFromPicture(picture, { width: S, height: S });
43
+ return { image, size: S, radius: R };
44
+ }
45
+
46
+ /** One particle ready to blit: world position + per-sprite scale/alpha/color. */
47
+ export interface ParticleInstance {
48
+ x: number;
49
+ y: number;
50
+ scale: number;
51
+ alpha: number;
52
+ colorIndex: number;
53
+ }
54
+
55
+ /**
56
+ * Pure, worklet-safe projection of the packed particle ring buffer into render
57
+ * instances. No Skia — plain numbers only — so it's directly unit-testable.
58
+ *
59
+ * Preserves the exact size/opacity formulas the old per-slot `<Circle>` used:
60
+ * life = max(0, 1 - (now - t0) / burstDur) (skip if dt = now - t0 < 0)
61
+ * r = size * (0.5 + life * 0.5), size = buf[base+6] || 1
62
+ * scale = r / spriteRadius
63
+ * alpha = life * particleOpacity
64
+ * Inactive (active <= 0.5), pre-spawn (dt < 0) and fully-faded (life <= 0)
65
+ * slots are skipped.
66
+ */
67
+ export function buildParticleInstances(
68
+ buf: Float64Array,
69
+ slots: number,
70
+ now: number,
71
+ burstDur: number,
72
+ particleOpacity: number,
73
+ spriteRadius: number,
74
+ ): ParticleInstance[] {
75
+ "worklet";
76
+ const out: ParticleInstance[] = [];
77
+ for (let i = 0; i < slots; i++) {
78
+ const base = i * DEGEN_STRIDE;
79
+ if (!(buf[base + 5] > 0.5)) continue;
80
+ const dt = now - buf[base + 4];
81
+ if (dt < 0) continue;
82
+ const life = Math.max(0, 1 - dt / burstDur);
83
+ if (life <= 0) continue;
84
+ const size = buf[base + 6] || 1;
85
+ const r = size * (0.5 + life * 0.5);
86
+ out.push({
87
+ x: buf[base + 0],
88
+ y: buf[base + 1],
89
+ scale: r / spriteRadius,
90
+ alpha: life * particleOpacity,
91
+ colorIndex: Math.max(0, Math.floor(buf[base + 7])),
92
+ });
93
+ }
94
+ return out;
95
+ }
@@ -54,9 +54,35 @@ export interface CrosshairState {
54
54
  scrubValue: SharedValue<number | null>;
55
55
  crosshairOpacity: SharedValue<number>;
56
56
  tooltipLayout: SharedValue<TooltipLayout>;
57
+ /** Scrub intersection Y in canvas px; -1 when there's no dot to draw
58
+ * (inactive / no value / degenerate range). See {@link computeScrubDotY}. */
59
+ scrubDotY: SharedValue<number>;
57
60
  gesture: ReturnType<typeof Gesture.Pan>;
58
61
  }
59
62
 
63
+ /**
64
+ * Maps a scrub value to its Y pixel at the crosshair intersection — the same
65
+ * mapping the live dot and tooltip use. Returns -1 when there is no dot to draw
66
+ * (value is null or the canvas isn't laid out); a degenerate (zero) range pins
67
+ * the dot to the vertical center of the plot.
68
+ */
69
+ export function computeScrubDotY(
70
+ value: number | null,
71
+ displayMin: number,
72
+ displayMax: number,
73
+ canvasHeight: number,
74
+ padTop: number,
75
+ padBottom: number,
76
+ ): number {
77
+ "worklet";
78
+ if (value === null) return -1;
79
+ const chartH = canvasHeight - padTop - padBottom;
80
+ if (chartH <= 0) return -1;
81
+ const valRange = displayMax - displayMin;
82
+ if (valRange === 0) return padTop + chartH / 2;
83
+ return padTop + ((displayMax - value) / valRange) * chartH;
84
+ }
85
+
60
86
  /**
61
87
  * Maps a scrub X position to a window timestamp.
62
88
  * Returns -1 when inactive or when the canvas is not yet laid out.