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
@@ -2,7 +2,9 @@ import { Group, Line, Rect } from "@shopify/react-native-skia";
2
2
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
3
3
  import { type ChartPadding } from "../draw/line";
4
4
  import type { LiveChartPalette } from "../types";
5
+ import type { ResolvedSelectionDotConfig } from "../core/resolveConfig";
5
6
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
7
+ import { SelectionDotSlot } from "./SelectionDot";
6
8
 
7
9
  /**
8
10
  * Crosshair vertical line + dim region to the right. No tooltip pill —
@@ -14,6 +16,10 @@ export function CrosshairLine({
14
16
  engine,
15
17
  padding,
16
18
  palette,
19
+ selectionDot,
20
+ selectionY,
21
+ scrubActive,
22
+ selectionColor,
17
23
  dimOpacity = 0.3,
18
24
  liveDotExtent = 0,
19
25
  crosshairLineColor,
@@ -24,6 +30,16 @@ export function CrosshairLine({
24
30
  engine: ChartEngineLayout;
25
31
  padding: ChartPadding;
26
32
  palette: LiveChartPalette;
33
+ /** Resolved selection-dot config; `null` hides it, a `component` renders the
34
+ * consumer's dot, otherwise the built-in dot. */
35
+ selectionDot?: ResolvedSelectionDotConfig | null;
36
+ /** Scrub intersection Y in canvas px (the value the dot marks); -1 hides it. */
37
+ selectionY?: SharedValue<number>;
38
+ /** Whether scrubbing is active (passed through to a custom dot). */
39
+ scrubActive?: SharedValue<number> | SharedValue<boolean>;
40
+ /** Fallback selection-dot color (leading-series color), used when the config's
41
+ * own `color` is unset. */
42
+ selectionColor?: string;
27
43
  /** Opacity of content right of the crosshair (dstOut fade). Default 0.3. */
28
44
  dimOpacity?: number;
29
45
  /** How far the live series dots extend past the plot's right edge. The dim
@@ -104,6 +120,17 @@ export function CrosshairLine({
104
120
  color={crosshairLineColor ?? palette.crosshairLine}
105
121
  strokeWidth={1}
106
122
  />
123
+
124
+ {/* Selection dot at the scrub intersection (leading series). `null`
125
+ hides it; a custom component renders the consumer's dot. */}
126
+ <SelectionDotSlot
127
+ config={selectionDot}
128
+ x={scrubX}
129
+ y={selectionY}
130
+ active={scrubActive}
131
+ opacity={crosshairOpacity}
132
+ color={selectionColor ?? palette.line}
133
+ />
107
134
  </Group>
108
135
  </>
109
136
  );
@@ -11,7 +11,9 @@ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
11
11
  import { type ChartPadding } from "../draw/line";
12
12
  import { type TooltipLayout } from "../hooks/crosshairShared";
13
13
  import type { LiveChartPalette } from "../types";
14
+ import type { ResolvedSelectionDotConfig } from "../core/resolveConfig";
14
15
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
16
+ import { SelectionDotSlot } from "./SelectionDot";
15
17
 
16
18
  const TOOLTIP_RADIUS = 5;
17
19
 
@@ -25,6 +27,11 @@ export function CrosshairOverlay({
25
27
  font,
26
28
  showTooltip = true,
27
29
  children,
30
+ renderTooltip,
31
+ selectionDot,
32
+ selectionY,
33
+ scrubActive,
34
+ selectionColor,
28
35
  dimOpacity = 0.3,
29
36
  liveDotExtent = 0,
30
37
  crosshairLineColor,
@@ -45,6 +52,19 @@ export function CrosshairOverlay({
45
52
  * text (e.g. the multi-line candle stack). Passed as children so it composes
46
53
  * instead of being threaded through as a JSX-valued prop. */
47
54
  children?: ReactNode;
55
+ /** Public custom-tooltip hook for line mode: when provided (and no `children`
56
+ * are passed), its result replaces the default value/time tooltip body. */
57
+ renderTooltip?: () => ReactNode;
58
+ /** Resolved selection-dot config; `null` hides it, a `component` renders the
59
+ * consumer's dot, otherwise the built-in dot. */
60
+ selectionDot?: ResolvedSelectionDotConfig | null;
61
+ /** Scrub intersection Y in canvas px (the value the dot marks); -1 hides it. */
62
+ selectionY?: SharedValue<number>;
63
+ /** Whether scrubbing is active (passed through to a custom dot). */
64
+ scrubActive?: SharedValue<number> | SharedValue<boolean>;
65
+ /** Fallback selection-dot color (accent / leading-series color), used when the
66
+ * config's own `color` is unset. */
67
+ selectionColor?: string;
48
68
  /** Opacity of content right of the crosshair (dstOut fade). Default 0.3. */
49
69
  dimOpacity?: number;
50
70
  /** How far the live dot (and its pulse ring) extends past the plot's right
@@ -143,6 +163,17 @@ export function CrosshairOverlay({
143
163
  strokeWidth={1}
144
164
  />
145
165
 
166
+ {/* Selection dot at the scrub intersection. `null` hides it; a custom
167
+ component renders the consumer's dot; otherwise the built-in dot. */}
168
+ <SelectionDotSlot
169
+ config={selectionDot}
170
+ x={scrubX}
171
+ y={selectionY}
172
+ active={scrubActive}
173
+ opacity={crosshairOpacity}
174
+ color={selectionColor ?? palette.line}
175
+ />
176
+
146
177
  {showTooltip && (
147
178
  <>
148
179
  <RoundedRect
@@ -165,24 +196,25 @@ export function CrosshairOverlay({
165
196
  strokeWidth={1}
166
197
  />
167
198
 
168
- {children ?? (
169
- <Group>
170
- <SkiaText
171
- x={valueTextX}
172
- y={line1Y}
173
- text={valueStr}
174
- font={font}
175
- color={tooltipColor ?? palette.tooltipText}
176
- />
177
- <SkiaText
178
- x={timeTextX}
179
- y={line2Y}
180
- text={timeStr}
181
- font={font}
182
- color={palette.gridLabel}
183
- />
184
- </Group>
185
- )}
199
+ {children ??
200
+ renderTooltip?.() ?? (
201
+ <Group>
202
+ <SkiaText
203
+ x={valueTextX}
204
+ y={line1Y}
205
+ text={valueStr}
206
+ font={font}
207
+ color={tooltipColor ?? palette.tooltipText}
208
+ />
209
+ <SkiaText
210
+ x={timeTextX}
211
+ y={line2Y}
212
+ text={timeStr}
213
+ font={font}
214
+ color={palette.gridLabel}
215
+ />
216
+ </Group>
217
+ )}
186
218
  </>
187
219
  )}
188
220
  </Group>
@@ -1,99 +1,38 @@
1
- import { Circle, Group } from "@shopify/react-native-skia";
1
+ import {
2
+ Atlas,
3
+ Skia,
4
+ type SkColor,
5
+ type SkRSXform,
6
+ type SkRect,
7
+ } from "@shopify/react-native-skia";
2
8
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
3
- import { DEGEN_STRIDE } from "../constants";
9
+ import {
10
+ buildParticleInstances,
11
+ buildParticleSprite,
12
+ } from "../draw/particleAtlas";
13
+ import { parseColorRgb } from "../theme";
4
14
  import type { LiveChartPalette } from "../types";
5
15
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
6
16
 
7
17
  type DegenPack = SharedValue<Float64Array<ArrayBuffer>>;
8
18
 
9
19
  /**
10
- * Renders one particle slot from the packed ring buffer.
11
- * Buffer layout per slot (stride = DEGEN_STRIDE = 8):
12
- * [0] x, [1] y, [2] vx, [3] vy, [4] t0, [5] active, [6] size, [7] colorIndex
13
- * See `math/degenTick.ts` for the authoritative layout documentation.
20
+ * Renders the degen particle burst with a single `drawAtlas` call.
14
21
  *
15
- * Each particle stores a `colorIndex` (set at spawn) that selects its color
16
- * from `colorList`, so multi-series bursts render in their own series' color.
22
+ * Every active particle shares one pre-rasterized white-circle sprite and
23
+ * differs only in position, scale, color, and opacity the ideal `drawAtlas`
24
+ * case. A single per-frame worklet projects the packed ring buffer (layout:
25
+ * `[x, y, vx, vy, t0, active, size, colorIndex]`, stride = DEGEN_STRIDE = 8;
26
+ * see `math/degenTick.ts`) into transforms + per-sprite colors, replacing the
27
+ * old O(slots × 4 derived values + 1 `<Circle>` each) fan-out with O(1) mappers
28
+ * and one draw.
29
+ *
30
+ * Per-particle color/opacity is baked into the sprite color (`rgba(r,g,b,a)`)
31
+ * and applied with the `"modulate"` blend mode, which multiplies the white
32
+ * sprite by each color — so a white AA circle becomes a soft, correctly-tinted,
33
+ * correctly-faded particle, visually equivalent to the old `<Circle color
34
+ * opacity>`.
17
35
  */
18
- function DegenSlot({
19
- index,
20
- pack,
21
- packRevision,
22
- engine,
23
- particleBurstDurationSec,
24
- particleOpacity,
25
- colorList,
26
- }: {
27
- index: number;
28
- pack: DegenPack;
29
- packRevision: SharedValue<number>;
30
- engine: ChartEngineLayout;
31
- particleBurstDurationSec: number;
32
- particleOpacity: number;
33
- colorList: string[];
34
- }) {
35
- const base = index * DEGEN_STRIDE;
36
-
37
- /* istanbul ignore next -- useDerivedValue worklet runs on UI thread, not in Jest */
38
- const cx = useDerivedValue(() => {
39
- const rev = packRevision.value;
40
- const buf = pack.value;
41
- const active = buf[base + 5];
42
- if (!(rev >= 0) || !(active > 0.5)) return -9999;
43
- return buf[base + 0];
44
- });
45
-
46
- /* istanbul ignore next -- worklet */
47
- const cy = useDerivedValue(() => {
48
- const rev = packRevision.value;
49
- const buf = pack.value;
50
- const active = buf[base + 5];
51
- if (!(rev >= 0) || !(active > 0.5)) return -9999;
52
- return buf[base + 1];
53
- });
54
-
55
- /* istanbul ignore next -- worklet */
56
- const r = useDerivedValue(() => {
57
- const rev = packRevision.value;
58
- const buf = pack.value;
59
- const active = buf[base + 5];
60
- if (!(rev >= 0) || !(active > 0.5)) return 0;
61
- const now = engine.timestamp.value;
62
- const dt = now - buf[base + 4];
63
- if (dt < 0) return 0;
64
- const life = Math.max(0, 1 - dt / particleBurstDurationSec);
65
- const size = buf[base + 6] || 1;
66
- return size * (0.5 + life * 0.5);
67
- });
68
-
69
- /* istanbul ignore next -- worklet */
70
- const opacity = useDerivedValue(() => {
71
- const rev = packRevision.value;
72
- const buf = pack.value;
73
- const active = buf[base + 5];
74
- if (!(rev >= 0) || !(active > 0.5)) return 0;
75
- const now = engine.timestamp.value;
76
- const dt = now - buf[base + 4];
77
- if (dt < 0) return 0;
78
- const life = Math.max(0, 1 - dt / particleBurstDurationSec);
79
- return life * particleOpacity;
80
- });
81
-
82
- /* istanbul ignore next -- worklet */
83
- const color = useDerivedValue(() => {
84
- // Read packRevision so this re-runs each frame — the pack buffer is mutated
85
- // in place (stable reference), so without this the color would freeze at the
86
- // mount-time colorIndex (0) for every particle.
87
- const rev = packRevision.value;
88
- const buf = pack.value;
89
- if (!(rev >= 0)) return colorList[0];
90
- const idx = Math.max(0, Math.floor(buf[base + 7]));
91
- return colorList[idx % colorList.length];
92
- });
93
-
94
- return <Circle cx={cx} cy={cy} r={r} color={color} opacity={opacity} />;
95
- }
96
-
97
36
  export function DegenParticlesOverlay({
98
37
  pack,
99
38
  packRevision,
@@ -115,27 +54,72 @@ export function DegenParticlesOverlay({
115
54
  }) {
116
55
  /* istanbul ignore next -- branch depends on render-time props */
117
56
  const colorList = colors && colors.length > 0 ? colors : [palette.line];
118
- // Fixed-size persistent slot pool. Each <DegenSlot> renders whatever particle
119
- // currently occupies slot `i` (read from `pack` by index), and particles cycle
120
- // through the slots over time. The pool is positional — slot `i` always renders
121
- // ring-buffer index `i`, and the list only grows/shrinks at the tail, never
122
- // reorders so each slot has a permanent identity. Key by that stable per-slot
123
- // id (`particle-slot-<i>`) rather than the bare array index.
124
- const slotKeys = Array.from(
125
- { length: particleSlotCount },
126
- (_, i) => `particle-slot-${i}`,
127
- );
128
- const slots = slotKeys.map((slotKey, i) => (
129
- <DegenSlot
130
- key={slotKey}
131
- index={i}
132
- pack={pack}
133
- packRevision={packRevision}
134
- engine={engine}
135
- particleBurstDurationSec={particleBurstDurationSec}
136
- particleOpacity={particleOpacity}
137
- colorList={colorList}
57
+
58
+ // White-circle sprite has no per-render inputs; React Compiler memoizes it.
59
+ const sprite = buildParticleSprite();
60
+
61
+ // Pre-parse the color list to [r,g,b] tuples so the per-frame worklet never
62
+ // parses arbitrary color strings (only formats `rgba(...)`). React Compiler
63
+ // memoizes this on `colorList`.
64
+ const colorRgb = colorList.map((c) => parseColorRgb(c));
65
+
66
+ // Single per-frame worklet. Reads `packRevision` so it re-runs each frame
67
+ // while a burst is alive (the pack buffer is mutated in place, so its
68
+ // reference is stable and wouldn't otherwise re-notify).
69
+ const atlasData = useDerivedValue(() => {
70
+ const rev = packRevision.get();
71
+ const transforms: SkRSXform[] = [];
72
+ const sprites: SkRect[] = [];
73
+ const colorsOut: SkColor[] = [];
74
+ if (rev >= 0) {
75
+ const instances = buildParticleInstances(
76
+ pack.get(),
77
+ particleSlotCount,
78
+ engine.timestamp.get(),
79
+ particleBurstDurationSec,
80
+ particleOpacity,
81
+ sprite.radius,
82
+ );
83
+ const half = sprite.size / 2;
84
+ for (let i = 0; i < instances.length; i++) {
85
+ const inst = instances[i];
86
+ // Center the scaled sprite on (x, y); RSXform has no rotation.
87
+ transforms.push(
88
+ Skia.RSXform(
89
+ inst.scale,
90
+ 0,
91
+ inst.x - inst.scale * half,
92
+ inst.y - inst.scale * half,
93
+ ),
94
+ );
95
+ sprites.push(Skia.XYWHRect(0, 0, sprite.size, sprite.size));
96
+ const [r, g, b] = colorRgb[inst.colorIndex % colorRgb.length];
97
+ colorsOut.push(Skia.Color(`rgba(${r}, ${g}, ${b}, ${inst.alpha})`));
98
+ }
99
+ }
100
+ return { transforms, sprites, colors: colorsOut };
101
+ }, [
102
+ pack,
103
+ packRevision,
104
+ engine,
105
+ particleSlotCount,
106
+ particleBurstDurationSec,
107
+ particleOpacity,
108
+ sprite,
109
+ colorRgb,
110
+ ]);
111
+
112
+ const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]);
113
+ const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]);
114
+ const atlasColors = useDerivedValue(() => atlasData.get().colors, [atlasData]);
115
+
116
+ return (
117
+ <Atlas
118
+ image={sprite.image}
119
+ sprites={sprites}
120
+ transforms={transforms}
121
+ colors={atlasColors}
122
+ colorBlendMode="modulate"
138
123
  />
139
- ));
140
- return <Group>{slots}</Group>;
124
+ );
141
125
  }
@@ -19,6 +19,7 @@ import {
19
19
 
20
20
  import { DEFAULT_ACCENT_COLOR } from "../constants";
21
21
  import {
22
+ resolveAxisLabel,
22
23
  resolveBadge,
23
24
  resolveDegen,
24
25
  resolveDot,
@@ -28,6 +29,7 @@ import {
28
29
  resolveMetrics,
29
30
  resolvePulse,
30
31
  resolveScrub,
32
+ resolveSelectionDot,
31
33
  resolveTradeStream,
32
34
  resolveValueLine,
33
35
  resolveXAxis,
@@ -66,6 +68,7 @@ import {
66
68
  resolveTheme,
67
69
  } from "../theme";
68
70
  import type { LiveChartProps, Marker, TradeEvent } from "../types";
71
+ import { AxisLabelOverlay } from "./AxisLabelOverlay";
69
72
  import { BadgeOverlay } from "./BadgeOverlay";
70
73
  import { CrosshairOverlay } from "./CrosshairOverlay";
71
74
  import { DegenParticlesOverlay } from "./DegenParticlesOverlay";
@@ -111,6 +114,8 @@ function useLiveChartController({
111
114
  timeWindow = 30,
112
115
  paused = false,
113
116
  loading = false,
117
+ // `static` is a reserved word — alias it so the destructure parses.
118
+ static: isStatic = false,
114
119
  smoothing = 0.08,
115
120
  exaggerate = false,
116
121
  nonNegative = false,
@@ -126,6 +131,8 @@ function useLiveChartController({
126
131
  // ── Overlays ────────────────────────────────────────────────────────────
127
132
  yAxis = true,
128
133
  xAxis = true,
134
+ topLabel,
135
+ bottomLabel,
129
136
  badge = true,
130
137
  momentum = true,
131
138
  pulse = true,
@@ -138,6 +145,7 @@ function useLiveChartController({
138
145
  palette: paletteOverride,
139
146
  metrics,
140
147
  scrub = true,
148
+ selectionDot,
141
149
  tradeStream,
142
150
  degen,
143
151
  markers,
@@ -147,6 +155,8 @@ function useLiveChartController({
147
155
 
148
156
  // ── Callbacks ───────────────────────────────────────────────────────────
149
157
  onScrub,
158
+ onGestureStart,
159
+ onGestureEnd,
150
160
  onDegenShake,
151
161
  }: LiveChartProps) {
152
162
  const emptyTradeStream = useSharedValue<TradeEvent[]>([]);
@@ -158,16 +168,23 @@ function useLiveChartController({
158
168
  // ── Resolve feature configs ────────────────────────────────────────────
159
169
  const yAxisCfg = resolveYAxis(yAxis);
160
170
  const xAxisCfg = resolveXAxis(xAxis);
171
+ const topLabelCfg = resolveAxisLabel(topLabel);
172
+ const bottomLabelCfg = resolveAxisLabel(bottomLabel);
161
173
  const badgeCfg = resolveBadge(badge);
162
174
  const scrubCfg = resolveScrub(scrub);
163
175
  const gradientCfg = isCandle ? null : resolveGradient(gradient);
164
176
  const valueLineCfg = resolveValueLine(valueLine);
165
- const pulseCfg = resolvePulse(pulse);
177
+ // Static charts run zero loops: force the pulse off so its `withRepeat`-driven
178
+ // ring never starts (the DotOverlay reads `pulseCfg`, so null = no pulse).
179
+ const pulseCfg = isStatic ? null : resolvePulse(pulse);
166
180
  const dotCfg = resolveDot(dot);
181
+ const selectionDotCfg = resolveSelectionDot(selectionDot);
167
182
  // Outer footprint of the dot (color-filled radius plus the halo ring).
168
183
  const dotOuterRadius = dotCfg.radius + (dotCfg.ring?.width ?? 0);
169
184
  const gridStyleCfg = resolveGridStyle(gridStyle);
170
- const degenCfg = resolveDegen(degen);
185
+ // Static charts run zero loops: force degen off so `useDegen`'s frame callback
186
+ // never starts (also passed `isStatic` below as a belt-and-braces autostart gate).
187
+ const degenCfg = isStatic ? null : resolveDegen(degen);
171
188
  const tradeStreamResolved = resolveTradeStream(tradeStream);
172
189
  const metricsCfg = resolveMetrics(metrics);
173
190
 
@@ -248,7 +265,7 @@ function useLiveChartController({
248
265
  candles,
249
266
  });
250
267
 
251
- const reveal = useChartReveal(loading, hasData);
268
+ const reveal = useChartReveal(loading, hasData, isStatic);
252
269
 
253
270
  // After data clears, keep last snapshot until morphT finishes dropping (web parity).
254
271
  const { lineEngineData, candlesEngine, liveEngine } =
@@ -269,6 +286,7 @@ function useLiveChartController({
269
286
  value,
270
287
  timeWindow,
271
288
  paused,
289
+ static: isStatic,
272
290
  smoothing,
273
291
  adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
274
292
  exaggerate,
@@ -306,6 +324,7 @@ function useLiveChartController({
306
324
  candleWidth,
307
325
  isCandle,
308
326
  metricsCfg.candle,
327
+ !isStatic, // static: no candle-width lerp loop
309
328
  );
310
329
  const { dotX, dotY } = useLiveDot(engine, effectivePadding);
311
330
 
@@ -315,14 +334,15 @@ function useLiveChartController({
315
334
  engine,
316
335
  tradeStreamSV,
317
336
  effectivePadding,
318
- tradeStreamResolved !== null,
337
+ !isStatic && tradeStreamResolved !== null,
338
+ !isStatic, // static: no trade-tape loop
319
339
  );
320
340
 
321
341
  const {
322
342
  pack: degenPack,
323
343
  packRevision: degenPackRevision,
324
344
  shakeTransform: degenShakeTransform,
325
- } = useDegen(engine, dotX, dotY, momentumSV, degenCfg, onDegenShake);
345
+ } = useDegen(engine, dotX, dotY, momentumSV, degenCfg, onDegenShake, isStatic);
326
346
 
327
347
  // ── Overlay hooks ─────────────────────────────────────────────────────
328
348
  const { yAxisEntries } = useYAxis(
@@ -373,10 +393,13 @@ function useLiveChartController({
373
393
  formatValue,
374
394
  formatTime,
375
395
  skiaFont,
376
- scrubCfg !== null,
396
+ // Static charts have an inert pan gesture — no scrub work on the UI thread.
397
+ !isStatic && scrubCfg !== null,
377
398
  onScrub,
378
399
  candleOpts,
379
400
  scrubCfg?.panGestureDelay ?? 0,
401
+ onGestureStart,
402
+ onGestureEnd,
380
403
  );
381
404
 
382
405
  const markersActive = markers != null;
@@ -386,11 +409,12 @@ function useLiveChartController({
386
409
  engine,
387
410
  effectivePadding,
388
411
  markersSV,
389
- markersActive,
412
+ !isStatic && markersActive,
390
413
  markerHitRadius,
391
414
  onMarkerHover,
392
415
  undefined, // seriesSV — single-series has none
393
416
  engine.data, // anchor value-less markers to the line
417
+ !isStatic, // static: no marker-projection loop
394
418
  );
395
419
 
396
420
  const rootGesture = markersActive
@@ -403,6 +427,8 @@ function useLiveChartController({
403
427
  gradientEnd,
404
428
  gradientTopColor,
405
429
  gradientBottomColor,
430
+ gradientColors,
431
+ gradientPositions,
406
432
  } = useChartColors(
407
433
  palette,
408
434
  gradientCfg,
@@ -453,6 +479,8 @@ function useLiveChartController({
453
479
  gradientEnd,
454
480
  gradientTopColor,
455
481
  gradientBottomColor,
482
+ gradientColors,
483
+ gradientPositions,
456
484
  lineGroupOpacity,
457
485
  candleGroupOpacity,
458
486
  onLayout,
@@ -476,6 +504,12 @@ function useLiveChartController({
476
504
  rootGesture,
477
505
  markersActive,
478
506
  markersSV,
507
+ // selection dot: resolved config + fallback color (the chart line/accent color)
508
+ selectionDot: selectionDotCfg,
509
+ selectionColor: lineProp?.color ?? palette.line,
510
+ // RN axis edge labels (floated over the canvas as a sibling layer)
511
+ topLabelCfg,
512
+ bottomLabelCfg,
479
513
  };
480
514
  }
481
515
 
@@ -500,8 +534,8 @@ function ChartStack({ model }: { model: LiveChartModel }) {
500
534
  gradientCfg,
501
535
  fillPath,
502
536
  gradientEnd,
503
- gradientTopColor,
504
- gradientBottomColor,
537
+ gradientColors,
538
+ gradientPositions,
505
539
  valueLineCfg,
506
540
  dotY,
507
541
  allRefLines,
@@ -555,7 +589,8 @@ function ChartStack({ model }: { model: LiveChartModel }) {
555
589
  <LinearGradient
556
590
  start={vec(0, effectivePadding.top)}
557
591
  end={vec(0, gradientEnd)}
558
- colors={[gradientTopColor, gradientBottomColor]}
592
+ colors={gradientColors}
593
+ positions={gradientPositions}
559
594
  />
560
595
  </Path>
561
596
  </Group>
@@ -713,6 +748,8 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
713
748
  isCandle,
714
749
  pulseCfg,
715
750
  dotOuterRadius,
751
+ selectionDot,
752
+ selectionColor,
716
753
  } = model;
717
754
 
718
755
  if (!tradeStreamResolved && !scrubCfg) return null;
@@ -748,6 +785,10 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
748
785
  palette={palette}
749
786
  font={skiaFont}
750
787
  showTooltip={scrubCfg.tooltip}
788
+ selectionDot={selectionDot}
789
+ selectionY={crosshair.scrubDotY}
790
+ scrubActive={crosshair.scrubActive}
791
+ selectionColor={selectionColor}
751
792
  dimOpacity={scrubCfg.dimOpacity}
752
793
  liveDotExtent={liveDotExtent}
753
794
  crosshairLineColor={scrubCfg.crosshairLineColor}
@@ -834,6 +875,10 @@ export function LiveChart(props: LiveChartProps) {
834
875
  leftEdgeFadeCfg,
835
876
  effectivePadding,
836
877
  engine,
878
+ palette,
879
+ formatValue,
880
+ topLabelCfg,
881
+ bottomLabelCfg,
837
882
  } = model;
838
883
 
839
884
  return (
@@ -867,6 +912,17 @@ export function LiveChart(props: LiveChartProps) {
867
912
  its left edge (the badge tracks the live value, not the scrub). */}
868
913
  <ChartBadgeLayer model={model} />
869
914
  </Canvas>
915
+
916
+ {/* RN labels floated over the canvas (sibling of <Canvas>, an RN view).
917
+ Pinned to the plot's top/bottom edges via the resolved padding. */}
918
+ <AxisLabelOverlay
919
+ topLabel={topLabelCfg}
920
+ bottomLabel={bottomLabelCfg}
921
+ engine={engine}
922
+ formatValue={formatValue}
923
+ defaultColor={palette.gridLabel}
924
+ padding={effectivePadding}
925
+ />
870
926
  </View>
871
927
  </GestureDetector>
872
928
  );