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.
- package/README.md +3 -2
- package/dist/components/CrosshairLine.d.ts +6 -1
- package/dist/components/CrosshairLine.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +7 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +12 -5
- package/dist/components/DotOverlay.d.ts.map +1 -1
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/MultiSeriesDots.d.ts +8 -2
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +18 -2
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +12 -4
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +3 -1
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useCrosshairSeries.d.ts +3 -1
- package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
- package/dist/types.d.ts +39 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairLine.tsx +7 -1
- package/src/components/CrosshairOverlay.tsx +8 -1
- package/src/components/DotOverlay.tsx +29 -14
- package/src/components/LiveChart.tsx +53 -15
- package/src/components/LiveChartSeries.tsx +66 -16
- package/src/components/MultiSeriesDots.tsx +30 -2
- package/src/core/resolveConfig.ts +57 -14
- package/src/hooks/crosshairShared.ts +24 -3
- package/src/hooks/useCrosshair.ts +15 -2
- package/src/hooks/useCrosshairSeries.ts +8 -2
- 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
|
-
{/*
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
?
|
|
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:
|
|
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
|
-
|
|
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
|
-
<
|
|
473
|
+
<MultiSeriesDots
|
|
478
474
|
engine={engine}
|
|
479
475
|
padding={effectivePadding}
|
|
480
476
|
colors={lineColors}
|
|
481
|
-
|
|
482
|
-
|
|
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 {
|
|
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
|
-
// ───
|
|
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
|
-
|
|
507
|
+
/** Shared, fully-resolved dot styling (single- and multi-series). */
|
|
508
|
+
export interface ResolvedDotConfig {
|
|
483
509
|
radius: number;
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
510
|
+
ring: ResolvedDotRingConfig | null;
|
|
511
|
+
show: boolean;
|
|
512
|
+
color: string | undefined;
|
|
487
513
|
}
|
|
488
514
|
|
|
489
|
-
const
|
|
515
|
+
const DOT_DEFAULTS: ResolvedDotConfig = {
|
|
490
516
|
radius: 3.5,
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
502
|
-
pulse: resolvePulse(prop
|
|
503
|
-
valueLine: resolveValueLine(prop
|
|
504
|
-
valueLabel: prop
|
|
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 =
|
|
126
|
-
|
|
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 =
|
|
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(
|
|
219
|
+
.activateAfterLongPress(panGestureDelay)
|
|
211
220
|
.maxPointers(1)
|
|
212
221
|
.shouldCancelWhenOutside(false)
|
|
213
|
-
|
|
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(
|
|
126
|
+
.activateAfterLongPress(panGestureDelay)
|
|
125
127
|
.maxPointers(1)
|
|
126
128
|
.shouldCancelWhenOutside(false)
|
|
127
|
-
|
|
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;
|