react-native-livechart 1.0.0 → 1.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 (33) hide show
  1. package/README.md +3 -2
  2. package/dist/components/CrosshairLine.d.ts +6 -1
  3. package/dist/components/CrosshairLine.d.ts.map +1 -1
  4. package/dist/components/CrosshairOverlay.d.ts +7 -1
  5. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  6. package/dist/components/DotOverlay.d.ts +12 -5
  7. package/dist/components/DotOverlay.d.ts.map +1 -1
  8. package/dist/components/LiveChart.d.ts.map +1 -1
  9. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  10. package/dist/components/MultiSeriesDots.d.ts +8 -2
  11. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  12. package/dist/core/resolveConfig.d.ts +18 -2
  13. package/dist/core/resolveConfig.d.ts.map +1 -1
  14. package/dist/hooks/crosshairShared.d.ts +12 -4
  15. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  16. package/dist/hooks/useCrosshair.d.ts +3 -1
  17. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  18. package/dist/hooks/useCrosshairSeries.d.ts +3 -1
  19. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  20. package/dist/types.d.ts +39 -3
  21. package/dist/types.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/components/CrosshairLine.tsx +7 -1
  24. package/src/components/CrosshairOverlay.tsx +8 -1
  25. package/src/components/DotOverlay.tsx +29 -14
  26. package/src/components/LiveChart.tsx +53 -15
  27. package/src/components/LiveChartSeries.tsx +66 -16
  28. package/src/components/MultiSeriesDots.tsx +30 -2
  29. package/src/core/resolveConfig.ts +57 -14
  30. package/src/hooks/crosshairShared.ts +24 -3
  31. package/src/hooks/useCrosshair.ts +15 -2
  32. package/src/hooks/useCrosshairSeries.ts +8 -2
  33. package/src/types.ts +41 -3
@@ -21,6 +21,7 @@ import { DEFAULT_ACCENT_COLOR } from "../constants";
21
21
  import {
22
22
  resolveBadge,
23
23
  resolveDegen,
24
+ resolveDot,
24
25
  resolveGradient,
25
26
  resolveGridStyle,
26
27
  resolveLeftEdgeFade,
@@ -32,6 +33,7 @@ import {
32
33
  resolveYAxis,
33
34
  } from "../core/resolveConfig";
34
35
  import { useLiveChartEngine } from "../core/useLiveChartEngine";
36
+ import { pulseRadialOutset } from "../draw/line";
35
37
  import { resolveChartLayout } from "../hooks/resolveChartLayout";
36
38
  import { useBadge } from "../hooks/useBadge";
37
39
  import { useCandlePaths } from "../hooks/useCandlePaths";
@@ -126,6 +128,7 @@ function useLiveChartController({
126
128
  badge = true,
127
129
  momentum = true,
128
130
  pulse = true,
131
+ dot,
129
132
  valueLine = true,
130
133
  showValue = false,
131
134
  valueMomentumColor = false,
@@ -158,6 +161,9 @@ function useLiveChartController({
158
161
  const gradientCfg = isCandle ? null : resolveGradient(gradient);
159
162
  const valueLineCfg = resolveValueLine(valueLine);
160
163
  const pulseCfg = resolvePulse(pulse);
164
+ const dotCfg = resolveDot(dot);
165
+ // Outer footprint of the dot (color-filled radius plus the halo ring).
166
+ const dotOuterRadius = dotCfg.radius + (dotCfg.ring?.width ?? 0);
161
167
  const gridStyleCfg = resolveGridStyle(gridStyle);
162
168
  const degenCfg = resolveDegen(degen);
163
169
  const tradeStreamResolved = resolveTradeStream(tradeStream);
@@ -361,6 +367,7 @@ function useLiveChartController({
361
367
  scrubCfg !== null,
362
368
  onScrub,
363
369
  candleOpts,
370
+ scrubCfg?.panGestureDelay ?? 0,
364
371
  );
365
372
 
366
373
  const markersActive = markers != null;
@@ -414,6 +421,8 @@ function useLiveChartController({
414
421
  gradientCfg,
415
422
  valueLineCfg,
416
423
  pulseCfg,
424
+ dotCfg,
425
+ dotOuterRadius,
417
426
  gridStyleCfg,
418
427
  degenCfg,
419
428
  tradeStreamResolved,
@@ -498,9 +507,9 @@ function ChartStack({ model }: { model: LiveChartModel }) {
498
507
  downBodiesPath,
499
508
  xAxisCfg,
500
509
  xAxisEntries,
501
- badgeData,
502
510
  dotX,
503
511
  pulseCfg,
512
+ dotCfg,
504
513
  degenCfg,
505
514
  degenPack,
506
515
  degenPackRevision,
@@ -611,23 +620,23 @@ function ChartStack({ model }: { model: LiveChartModel }) {
611
620
  />
612
621
  )}
613
622
 
614
- {/* Badge and live dot */}
615
- {badgeCfg && (
616
- <Group opacity={reveal.badgeOpacity}>
617
- <BadgeOverlay badge={badgeData} font={skiaFont} />
623
+ {/* Live dot the badge is drawn later (after the scrub layer) so the
624
+ scrub dim never clips the live-price badge's left edge. */}
625
+ {dotCfg.show && (
626
+ <Group opacity={reveal.dotOpacity}>
627
+ <DotOverlay
628
+ dotX={dotX}
629
+ dotY={dotY}
630
+ palette={palette}
631
+ engine={engine}
632
+ pulse={pulseCfg}
633
+ radius={dotCfg.radius}
634
+ ring={dotCfg.ring}
635
+ color={dotCfg.color}
636
+ />
618
637
  </Group>
619
638
  )}
620
639
 
621
- <Group opacity={reveal.dotOpacity}>
622
- <DotOverlay
623
- dotX={dotX}
624
- dotY={dotY}
625
- palette={palette}
626
- engine={engine}
627
- pulse={pulseCfg}
628
- />
629
- </Group>
630
-
631
640
  {degenCfg && (
632
641
  <Group opacity={reveal.dotOpacity}>
633
642
  <DegenParticlesOverlay
@@ -688,10 +697,20 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
688
697
  reveal,
689
698
  crosshair,
690
699
  isCandle,
700
+ pulseCfg,
701
+ dotOuterRadius,
691
702
  } = model;
692
703
 
693
704
  if (!tradeStreamResolved && !scrubCfg) return null;
694
705
 
706
+ // Extend the scrub dim past the plot's right edge to fully cover the live dot
707
+ // (with its halo) and pulse ring, all centered on that edge. The gutter
708
+ // reserves an 8px gap beyond this for the Y-axis labels, so they stay readable.
709
+ const liveDotExtent = Math.max(
710
+ dotOuterRadius,
711
+ pulseCfg ? pulseRadialOutset(pulseCfg.maxRadius, pulseCfg.strokeWidth) : 0,
712
+ );
713
+
695
714
  return (
696
715
  <Group transform={degenShakeTransform}>
697
716
  {tradeStreamResolved && (
@@ -716,6 +735,7 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
716
735
  font={skiaFont}
717
736
  showTooltip={scrubCfg.tooltip}
718
737
  dimOpacity={scrubCfg.dimOpacity}
738
+ liveDotExtent={liveDotExtent}
719
739
  crosshairLineColor={scrubCfg.crosshairLineColor}
720
740
  crosshairDimColor={scrubCfg.crosshairDimColor}
721
741
  tooltipBackground={scrubCfg.tooltipBackground}
@@ -774,6 +794,20 @@ function ChartValueOverlay({ model }: { model: LiveChartModel }) {
774
794
  );
775
795
  }
776
796
 
797
+ /** Live-price badge, drawn above the scrub dim so the dim never clips its left
798
+ * edge. Shares the degen shake transform so it tracks the shaken stack. */
799
+ function ChartBadgeLayer({ model }: { model: LiveChartModel }) {
800
+ const { badgeCfg, badgeData, skiaFont, reveal, degenShakeTransform } = model;
801
+ if (!badgeCfg) return null;
802
+ return (
803
+ <Group transform={degenShakeTransform}>
804
+ <Group opacity={reveal.badgeOpacity}>
805
+ <BadgeOverlay badge={badgeData} font={skiaFont} />
806
+ </Group>
807
+ </Group>
808
+ );
809
+ }
810
+
777
811
  export function LiveChart(props: LiveChartProps) {
778
812
  const model = useLiveChartController(props);
779
813
  const {
@@ -814,6 +848,10 @@ export function LiveChart(props: LiveChartProps) {
814
848
  <ChartValueOverlay model={model} />
815
849
 
816
850
  <ChartScrubLayer model={model} />
851
+
852
+ {/* Live-price badge on top of the scrub dim so the dim never clips
853
+ its left edge (the badge tracks the live value, not the scrub). */}
854
+ <ChartBadgeLayer model={model} />
817
855
  </Canvas>
818
856
  </View>
819
857
  </GestureDetector>
@@ -34,6 +34,7 @@ import {
34
34
  resolveYAxis,
35
35
  } from "../core/resolveConfig";
36
36
  import { useLiveChartSeriesEngine } from "../core/useLiveChartSeriesEngine";
37
+ import { pulseRadialOutset } from "../draw/line";
37
38
  import { resolveChartLayout } from "../hooks/resolveChartLayout";
38
39
  import { useCanvasLayout } from "../hooks/useCanvasLayout";
39
40
  import { useChartReveal } from "../hooks/useChartReveal";
@@ -143,6 +144,9 @@ function useLiveChartSeriesController({
143
144
  const scrubEnabled = scrubCfg !== null;
144
145
  const gridStyleCfg = resolveGridStyle(gridStyle);
145
146
  const dotCfg = resolveMultiSeriesDot(dotProp);
147
+ // Outer footprint of a dot (the color-filled radius plus the halo ring).
148
+ // Used to keep the gutter labels clear of the haloed dot.
149
+ const dotOuterRadius = dotCfg.radius + (dotCfg.ring?.width ?? 0);
146
150
  const legendCfg = resolveLegend(legendProp);
147
151
  const degenCfg = resolveDegen(degen);
148
152
 
@@ -190,7 +194,7 @@ function useLiveChartSeriesController({
190
194
  : 0;
191
195
 
192
196
  const seriesLabelInset = dotCfg.valueLabel
193
- ? dotCfg.radius + 8 + maxSeriesLabelWidth + 8
197
+ ? dotOuterRadius + 8 + maxSeriesLabelWidth + 8
194
198
  : 0;
195
199
 
196
200
  const representativeValue =
@@ -209,7 +213,7 @@ function useLiveChartSeriesController({
209
213
  formatValue,
210
214
  currentValue: representativeValue,
211
215
  pulse: dotCfg.pulse,
212
- multiSeriesDotRadius: dotCfg.radius,
216
+ multiSeriesDotRadius: dotOuterRadius,
213
217
  multiSeriesValueLabel: dotCfg.valueLabel,
214
218
  multiSeriesMaxLabelWidth: maxSeriesLabelWidth,
215
219
  });
@@ -296,6 +300,7 @@ function useLiveChartSeriesController({
296
300
  effectivePadding,
297
301
  scrubEnabled,
298
302
  onScrub,
303
+ scrubCfg?.panGestureDelay ?? 0,
299
304
  );
300
305
 
301
306
  // `projected` is used internally by the hit-test gesture; the overlay
@@ -331,6 +336,7 @@ function useLiveChartSeriesController({
331
336
  scrubCfg,
332
337
  gridStyleCfg,
333
338
  dotCfg,
339
+ dotOuterRadius,
334
340
  legendCfg,
335
341
  degenCfg,
336
342
  allRefLines,
@@ -462,28 +468,25 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
462
468
  />
463
469
  )}
464
470
 
465
- <Group opacity={reveal.dotOpacity}>
466
- <MultiSeriesDots
467
- engine={engine}
468
- padding={effectivePadding}
469
- colors={lineColors}
470
- radius={dotCfg.radius}
471
- pulse={dotCfg.pulse}
472
- />
473
- </Group>
474
-
475
- {dotCfg.valueLabel && (
471
+ {dotCfg.show && (
476
472
  <Group opacity={reveal.dotOpacity}>
477
- <MultiSeriesValueLabels
473
+ <MultiSeriesDots
478
474
  engine={engine}
479
475
  padding={effectivePadding}
480
476
  colors={lineColors}
481
- font={skiaFont}
482
- dotRadius={dotCfg.radius}
477
+ radius={dotCfg.radius}
478
+ ring={dotCfg.ring}
479
+ ringColor={palette.badgeOuterBg}
480
+ color={dotCfg.color}
481
+ pulse={dotCfg.pulse}
483
482
  />
484
483
  </Group>
485
484
  )}
486
485
 
486
+ {/* Value labels are drawn later (after the crosshair layer) so the scrub
487
+ dim — which now covers the dots + pulse rings — never clips them.
488
+ They track each series' live value, not the scrub point. */}
489
+
487
490
  {degenCfg && (
488
491
  <Group opacity={reveal.dotOpacity}>
489
492
  <DegenParticlesOverlay
@@ -528,6 +531,36 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
528
531
  );
529
532
  }
530
533
 
534
+ /** Per-series live-value labels, drawn above the scrub dim so the dim (which
535
+ * covers the dots + pulse rings) never clips them. Keeps the degen shake
536
+ * transform so they track the shaken stack. */
537
+ function SeriesValueLabelLayer({ model }: { model: LiveChartSeriesModel }) {
538
+ const {
539
+ dotCfg,
540
+ dotOuterRadius,
541
+ engine,
542
+ effectivePadding,
543
+ lineColors,
544
+ skiaFont,
545
+ reveal,
546
+ degenShakeTransform,
547
+ } = model;
548
+ if (!dotCfg.valueLabel) return null;
549
+ return (
550
+ <Group transform={degenShakeTransform}>
551
+ <Group opacity={reveal.dotOpacity}>
552
+ <MultiSeriesValueLabels
553
+ engine={engine}
554
+ padding={effectivePadding}
555
+ colors={lineColors}
556
+ font={skiaFont}
557
+ dotRadius={dotOuterRadius}
558
+ />
559
+ </Group>
560
+ </Group>
561
+ );
562
+ }
563
+
531
564
  export function LiveChartSeries(props: LiveChartSeriesProps) {
532
565
  const model = useLiveChartSeriesController(props);
533
566
  const {
@@ -547,8 +580,20 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
547
580
  scrubCfg,
548
581
  crosshair,
549
582
  palette,
583
+ dotCfg,
584
+ dotOuterRadius,
550
585
  } = model;
551
586
 
587
+ // Extend the scrub dim past the plot's right edge to fully cover the series
588
+ // dots (with their halo) and pulse rings, all centered on that edge. The
589
+ // gutter reserves room beyond this for the value/Y-axis labels, drawn on top.
590
+ const liveDotExtent = Math.max(
591
+ dotOuterRadius,
592
+ dotCfg.pulse
593
+ ? pulseRadialOutset(dotCfg.pulse.maxRadius, dotCfg.pulse.strokeWidth)
594
+ : 0,
595
+ );
596
+
552
597
  const legend =
553
598
  legendCfg.position === "top" || legendCfg.position === "bottom" ? (
554
599
  <SeriesToggleChips
@@ -594,10 +639,15 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
594
639
  padding={effectivePadding}
595
640
  palette={palette}
596
641
  dimOpacity={scrubCfg.dimOpacity}
642
+ liveDotExtent={liveDotExtent}
597
643
  crosshairLineColor={scrubCfg.crosshairLineColor}
598
644
  crosshairDimColor={scrubCfg.crosshairDimColor}
599
645
  />
600
646
  )}
647
+
648
+ {/* Per-series value labels on top of the scrub dim so the dim never
649
+ clips them (they track each series' live value, not the scrub). */}
650
+ <SeriesValueLabelLayer model={model} />
601
651
  </Canvas>
602
652
  </View>
603
653
  </GestureDetector>
@@ -3,7 +3,10 @@ import { Circle, Group } from "@shopify/react-native-skia";
3
3
  import { useDerivedValue } from "react-native-reanimated";
4
4
  import { MAX_MULTI_SERIES } from "../constants";
5
5
  import type { ChartPadding } from "../draw/line";
6
- import type { ResolvedPulseConfig } from "../core/resolveConfig";
6
+ import type {
7
+ ResolvedDotRingConfig,
8
+ ResolvedPulseConfig,
9
+ } from "../core/resolveConfig";
7
10
  import type { MultiEngineState } from "../core/useLiveChartEngine";
8
11
 
9
12
  const MIN_PULSE_RADIUS = 6;
@@ -15,6 +18,8 @@ function SeriesDotAtIndex({
15
18
  padding,
16
19
  color,
17
20
  radius,
21
+ ring,
22
+ ringColor,
18
23
  pulse,
19
24
  }: {
20
25
  index: number;
@@ -22,6 +27,10 @@ function SeriesDotAtIndex({
22
27
  padding: ChartPadding;
23
28
  color: string;
24
29
  radius: number;
30
+ /** Outer halo ring, or `null` for a flat circle. */
31
+ ring: ResolvedDotRingConfig | null;
32
+ /** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */
33
+ ringColor: string;
25
34
  pulse: ResolvedPulseConfig | null;
26
35
  }) {
27
36
  const dotX = useDerivedValue(() => {
@@ -81,6 +90,14 @@ function SeriesDotAtIndex({
81
90
  opacity={pulseOpacity}
82
91
  />
83
92
  )}
93
+ {ring && (
94
+ <Circle
95
+ cx={dotX}
96
+ cy={dotY}
97
+ r={radius + ring.width}
98
+ color={ring.color ?? ringColor}
99
+ />
100
+ )}
84
101
  <Circle cx={dotX} cy={dotY} r={radius} color={color} />
85
102
  </Group>
86
103
  );
@@ -91,12 +108,21 @@ export function MultiSeriesDots({
91
108
  padding,
92
109
  colors,
93
110
  radius,
111
+ ring,
112
+ ringColor,
113
+ color,
94
114
  pulse,
95
115
  }: {
96
116
  engine: MultiEngineState;
97
117
  padding: ChartPadding;
98
118
  colors: string[];
99
119
  radius: number;
120
+ /** Outer halo ring, or `null` for flat circles. */
121
+ ring: ResolvedDotRingConfig | null;
122
+ /** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */
123
+ ringColor: string;
124
+ /** Fill color override; falls back to each series' line color. */
125
+ color: string | undefined;
100
126
  pulse: ResolvedPulseConfig | null;
101
127
  }) {
102
128
  return (
@@ -107,8 +133,10 @@ export function MultiSeriesDots({
107
133
  index={i}
108
134
  engine={engine}
109
135
  padding={padding}
110
- color={colors[i] ?? "#ffffff"}
136
+ color={color ?? colors[i] ?? "#ffffff"}
111
137
  radius={radius}
138
+ ring={ring}
139
+ ringColor={ringColor}
112
140
  pulse={pulse}
113
141
  />
114
142
  ))}
@@ -9,6 +9,8 @@ import type {
9
9
  LeftEdgeFadeConfig,
10
10
  LegendConfig,
11
11
  LegendStyle,
12
+ DotConfig,
13
+ DotRingConfig,
12
14
  MultiSeriesDotConfig,
13
15
  PulseConfig,
14
16
  ReferenceLine,
@@ -60,6 +62,8 @@ export interface ResolvedScrubConfig {
60
62
  tooltipColor: string | undefined;
61
63
  /** undefined → palette.tooltipBorder */
62
64
  tooltipBorderColor: string | undefined;
65
+ /** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */
66
+ panGestureDelay: number;
63
67
  }
64
68
 
65
69
  export interface ResolvedGradientConfig {
@@ -212,6 +216,7 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
212
216
  tooltipBackground: undefined,
213
217
  tooltipColor: undefined,
214
218
  tooltipBorderColor: undefined,
219
+ panGestureDelay: 0,
215
220
  };
216
221
 
217
222
  /**
@@ -477,31 +482,69 @@ export function resolveTradeStream(
477
482
  };
478
483
  }
479
484
 
480
- // ─── Multi-series dot ─────────────────────────────────────────────────────────
485
+ // ─── Dot (shared) ─────────────────────────────────────────────────────────────
486
+
487
+ /** Resolved halo ring. `color: undefined` means "use the theme `badgeOuterBg`". */
488
+ export interface ResolvedDotRingConfig {
489
+ color: string | undefined;
490
+ width: number;
491
+ }
492
+
493
+ const RING_DEFAULTS: ResolvedDotRingConfig = {
494
+ color: undefined,
495
+ width: 2.5,
496
+ };
497
+
498
+ /** `undefined`/`true` → haloed defaults; `false` → null (flat circle). */
499
+ export function resolveDotRing(
500
+ prop: boolean | DotRingConfig | undefined,
501
+ ): ResolvedDotRingConfig | null {
502
+ if (prop === false) return null;
503
+ if (prop === undefined || prop === true) return RING_DEFAULTS;
504
+ return { ...RING_DEFAULTS, ...prop };
505
+ }
481
506
 
482
- export interface ResolvedMultiSeriesDotConfig {
507
+ /** Shared, fully-resolved dot styling (single- and multi-series). */
508
+ export interface ResolvedDotConfig {
483
509
  radius: number;
484
- pulse: ResolvedPulseConfig | null;
485
- valueLine: ResolvedValueLineConfig | null;
486
- valueLabel: boolean;
510
+ ring: ResolvedDotRingConfig | null;
511
+ show: boolean;
512
+ color: string | undefined;
487
513
  }
488
514
 
489
- const MULTI_DOT_DEFAULTS: ResolvedMultiSeriesDotConfig = {
515
+ const DOT_DEFAULTS: ResolvedDotConfig = {
490
516
  radius: 3.5,
491
- pulse: PULSE_DEFAULTS,
492
- valueLine: null,
493
- valueLabel: true,
517
+ ring: RING_DEFAULTS,
518
+ show: true,
519
+ color: undefined,
494
520
  };
495
521
 
522
+ export function resolveDot(prop: DotConfig | undefined): ResolvedDotConfig {
523
+ if (!prop) return DOT_DEFAULTS;
524
+ return {
525
+ radius: prop.radius ?? DOT_DEFAULTS.radius,
526
+ ring: resolveDotRing(prop.ring),
527
+ show: prop.show ?? DOT_DEFAULTS.show,
528
+ color: prop.color,
529
+ };
530
+ }
531
+
532
+ // ─── Multi-series dot ─────────────────────────────────────────────────────────
533
+
534
+ export interface ResolvedMultiSeriesDotConfig extends ResolvedDotConfig {
535
+ pulse: ResolvedPulseConfig | null;
536
+ valueLine: ResolvedValueLineConfig | null;
537
+ valueLabel: boolean;
538
+ }
539
+
496
540
  export function resolveMultiSeriesDot(
497
541
  prop: MultiSeriesDotConfig | undefined,
498
542
  ): ResolvedMultiSeriesDotConfig {
499
- if (!prop) return MULTI_DOT_DEFAULTS;
500
543
  return {
501
- radius: prop.radius ?? MULTI_DOT_DEFAULTS.radius,
502
- pulse: resolvePulse(prop.pulse ?? true),
503
- valueLine: resolveValueLine(prop.valueLine),
504
- valueLabel: prop.valueLabel ?? MULTI_DOT_DEFAULTS.valueLabel,
544
+ ...resolveDot(prop),
545
+ pulse: resolvePulse(prop?.pulse ?? true),
546
+ valueLine: resolveValueLine(prop?.valueLine),
547
+ valueLabel: prop?.valueLabel ?? true,
505
548
  };
506
549
  }
507
550
 
@@ -109,6 +109,11 @@ export function computeTooltipLayout(
109
109
  formatValue: (v: number) => string,
110
110
  formatTime: (t: number) => string,
111
111
  font: SkFont,
112
+ /** Monospace advance width. When > 0, text width is `len * monoCharWidth`
113
+ * instead of a per-frame Skia `measureText` — scrubbing re-runs this worklet
114
+ * every frame, and `measureText` shapes text each call (a real cost,
115
+ * especially in the simulator). Falls back to `measureText` when 0. */
116
+ monoCharWidth = 0,
112
117
  ): TooltipLayout {
113
118
  "worklet";
114
119
  if (!scrubActive || scrubValue === null) return HIDDEN_TOOLTIP;
@@ -122,8 +127,14 @@ export function computeTooltipLayout(
122
127
  const lineH = -fm.ascent + fm.descent;
123
128
  const totalH = TOOLTIP_PAD_Y * 2 + lineH * 2 + TOOLTIP_LINE_GAP;
124
129
 
125
- const valueW = measureFontTextWidth(font, valueStr);
126
- const timeW = measureFontTextWidth(font, timeStr);
130
+ const valueW =
131
+ monoCharWidth > 0
132
+ ? valueStr.length * monoCharWidth
133
+ : measureFontTextWidth(font, valueStr);
134
+ const timeW =
135
+ monoCharWidth > 0
136
+ ? timeStr.length * monoCharWidth
137
+ : measureFontTextWidth(font, timeStr);
127
138
  const contentW = Math.max(valueW, timeW);
128
139
  const pillW = contentW + TOOLTIP_PAD_X * 2;
129
140
 
@@ -166,6 +177,9 @@ export function computeTooltipLayoutMulti(
166
177
  padding: ChartPadding,
167
178
  canvasWidth: number,
168
179
  font: SkFont,
180
+ /** Monospace advance width; when > 0, sizes text by length instead of a
181
+ * per-frame Skia `measureText`. See {@link computeTooltipLayout}. */
182
+ monoCharWidth = 0,
169
183
  ): TooltipLayout {
170
184
  "worklet";
171
185
  if (!scrubActive || lines.length === 0) return HIDDEN_TOOLTIP;
@@ -179,7 +193,10 @@ export function computeTooltipLayoutMulti(
179
193
  let contentW = 0;
180
194
  const lineWidths: number[] = [];
181
195
  for (let i = 0; i < n; i++) {
182
- const w = measureFontTextWidth(font, lines[i].text);
196
+ const w =
197
+ monoCharWidth > 0
198
+ ? lines[i].text.length * monoCharWidth
199
+ : measureFontTextWidth(font, lines[i].text);
183
200
  lineWidths.push(w);
184
201
  if (w > contentW) contentW = w;
185
202
  }
@@ -237,6 +254,7 @@ export function computeCandleTooltipLayout(
237
254
  formatValue: (v: number) => string,
238
255
  formatTime: (t: number) => string,
239
256
  font: SkFont,
257
+ monoCharWidth = 0,
240
258
  ): TooltipLayout {
241
259
  "worklet";
242
260
  if (!scrubActive || !candle) return HIDDEN_TOOLTIP;
@@ -254,6 +272,7 @@ export function computeCandleTooltipLayout(
254
272
  padding,
255
273
  canvasWidth,
256
274
  font,
275
+ monoCharWidth,
257
276
  );
258
277
  }
259
278
 
@@ -279,6 +298,7 @@ export function deriveCrosshairTooltipSingle(
279
298
  formatValue: (v: number) => string,
280
299
  formatTime: (t: number) => string,
281
300
  font: SkFont,
301
+ monoCharWidth = 0,
282
302
  ): TooltipLayout {
283
303
  "worklet";
284
304
  if (!scrubActive || scrubTime < 0) return HIDDEN_TOOLTIP;
@@ -292,5 +312,6 @@ export function deriveCrosshairTooltipSingle(
292
312
  formatValue,
293
313
  formatTime,
294
314
  font,
315
+ monoCharWidth,
295
316
  );
296
317
  }
@@ -55,6 +55,8 @@ export function useCrosshair(
55
55
  enabled: boolean,
56
56
  onScrub?: (point: ScrubPoint | null) => void,
57
57
  candleOpts?: CrosshairCandleOpts,
58
+ /** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */
59
+ panGestureDelay = 0,
58
60
  ): CrosshairState {
59
61
  const scrubX = useSharedValue(-1);
60
62
  const scrubActive = useSharedValue(false);
@@ -110,6 +112,11 @@ export function useCrosshair(
110
112
  ),
111
113
  );
112
114
 
115
+ // Monospace advance width, measured once per render (not per scrub frame) so
116
+ // the tooltip layout worklet can size text by character count instead of a
117
+ // per-frame Skia measureText.
118
+ const monoCharWidth = font.measureText("0").width;
119
+
113
120
  const tooltipLayout = useDerivedValue(() => {
114
121
  if (isCandleMode) {
115
122
  return computeCandleTooltipLayout(
@@ -122,6 +129,7 @@ export function useCrosshair(
122
129
  formatValue,
123
130
  formatTime,
124
131
  font,
132
+ monoCharWidth,
125
133
  );
126
134
  }
127
135
  return deriveCrosshairTooltipSingle(
@@ -134,6 +142,7 @@ export function useCrosshair(
134
142
  formatValue,
135
143
  formatTime,
136
144
  font,
145
+ monoCharWidth,
137
146
  );
138
147
  });
139
148
 
@@ -207,10 +216,14 @@ export function useCrosshair(
207
216
 
208
217
  let gesture = Gesture.Pan()
209
218
  .minDistance(Platform.OS === "android" ? 10 : 0)
210
- .activateAfterLongPress(0)
219
+ .activateAfterLongPress(panGestureDelay)
211
220
  .maxPointers(1)
212
221
  .shouldCancelWhenOutside(false)
213
- .onBegin(
222
+ // Start scrubbing on ACTIVE (onStart), not on touch-down (onBegin):
223
+ // `activateAfterLongPress` only delays activation, so onBegin still fires
224
+ // immediately — using it would scrub instantly and ignore panGestureDelay,
225
+ // and leave scrubActive stuck for taps that never reach the long-press.
226
+ .onStart(
214
227
  /* istanbul ignore next */ (e) => {
215
228
  "worklet";
216
229
  if (!enabled) return;
@@ -28,6 +28,8 @@ export function useCrosshairSeries(
28
28
  padding: ChartPadding,
29
29
  enabled: boolean,
30
30
  onScrub?: (point: ScrubPointMulti | null) => void,
31
+ /** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */
32
+ panGestureDelay = 0,
31
33
  ): CrosshairState {
32
34
  const scrubX = useSharedValue(-1);
33
35
  const scrubActive = useSharedValue(false);
@@ -121,10 +123,14 @@ export function useCrosshairSeries(
121
123
 
122
124
  let gesture = Gesture.Pan()
123
125
  .minDistance(Platform.OS === "android" ? 10 : 0)
124
- .activateAfterLongPress(0)
126
+ .activateAfterLongPress(panGestureDelay)
125
127
  .maxPointers(1)
126
128
  .shouldCancelWhenOutside(false)
127
- .onBegin(
129
+ // Start scrubbing on ACTIVE (onStart), not on touch-down (onBegin):
130
+ // `activateAfterLongPress` only delays activation, so onBegin still fires
131
+ // immediately — using it would scrub instantly and ignore panGestureDelay,
132
+ // and leave scrubActive stuck for taps that never reach the long-press.
133
+ .onStart(
128
134
  /* istanbul ignore next */ (e) => {
129
135
  "worklet";
130
136
  if (!enabled) return;