react-native-livechart 3.0.0 → 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 (63) 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/SelectionDot.d.ts +32 -0
  13. package/dist/components/SelectionDot.d.ts.map +1 -0
  14. package/dist/core/resolveConfig.d.ts +46 -1
  15. package/dist/core/resolveConfig.d.ts.map +1 -1
  16. package/dist/core/useLiveChartEngine.d.ts +6 -0
  17. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  18. package/dist/draw/particleAtlas.d.ts +39 -0
  19. package/dist/draw/particleAtlas.d.ts.map +1 -0
  20. package/dist/hooks/crosshairShared.d.ts +10 -0
  21. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  22. package/dist/hooks/useCandlePaths.d.ts +3 -1
  23. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  24. package/dist/hooks/useChartColors.d.ts +5 -0
  25. package/dist/hooks/useChartColors.d.ts.map +1 -1
  26. package/dist/hooks/useChartReveal.d.ts +3 -1
  27. package/dist/hooks/useChartReveal.d.ts.map +1 -1
  28. package/dist/hooks/useCrosshair.d.ts +1 -1
  29. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  30. package/dist/hooks/useCrosshairSeries.d.ts +1 -1
  31. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  32. package/dist/hooks/useDegen.d.ts +3 -1
  33. package/dist/hooks/useDegen.d.ts.map +1 -1
  34. package/dist/hooks/useMarkers.d.ts +3 -1
  35. package/dist/hooks/useMarkers.d.ts.map +1 -1
  36. package/dist/hooks/useTradeStream.d.ts +3 -1
  37. package/dist/hooks/useTradeStream.d.ts.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/types.d.ts +97 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/AxisLabelOverlay.tsx +133 -0
  44. package/src/components/CrosshairLine.tsx +27 -0
  45. package/src/components/CrosshairOverlay.tsx +50 -18
  46. package/src/components/DegenParticlesOverlay.tsx +93 -109
  47. package/src/components/LiveChart.tsx +66 -10
  48. package/src/components/LiveChartSeries.tsx +40 -0
  49. package/src/components/SelectionDot.tsx +92 -0
  50. package/src/core/resolveConfig.ts +98 -0
  51. package/src/core/useLiveChartEngine.ts +78 -27
  52. package/src/draw/particleAtlas.ts +95 -0
  53. package/src/hooks/crosshairShared.ts +26 -0
  54. package/src/hooks/useCandlePaths.ts +3 -1
  55. package/src/hooks/useChartColors.ts +19 -0
  56. package/src/hooks/useChartReveal.ts +7 -4
  57. package/src/hooks/useCrosshair.ts +46 -7
  58. package/src/hooks/useCrosshairSeries.ts +47 -8
  59. package/src/hooks/useDegen.ts +6 -0
  60. package/src/hooks/useMarkers.ts +3 -0
  61. package/src/hooks/useTradeStream.ts +3 -0
  62. package/src/index.ts +4 -0
  63. package/src/types.ts +101 -0
@@ -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,
@@ -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.
@@ -29,6 +29,8 @@ export function useCandlePaths(
29
29
  candleWidthSecs: number,
30
30
  active: boolean,
31
31
  candleMetrics: CandleMetrics = CANDLE_METRICS_DEFAULTS,
32
+ /** Static charts run no loops: register without starting. Default `true`. */
33
+ autostart = true,
32
34
  ) {
33
35
  const targetCandleWidth = useDerivedValue(() => candleWidthSecs);
34
36
  const displayCandleWidth = useSharedValue(candleWidthSecs);
@@ -50,7 +52,7 @@ export function useCandlePaths(
50
52
  dt,
51
53
  ),
52
54
  );
53
- });
55
+ }, autostart);
54
56
 
55
57
  /* istanbul ignore next -- worklet */
56
58
  const geometry = useDerivedValue(() => {
@@ -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
  }
@@ -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
  }