react-native-livechart 4.10.0 → 4.12.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 +24 -0
- package/dist/components/CrosshairLine.d.ts +5 -1
- package/dist/components/CrosshairLine.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +3 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/CustomReferenceLineOverlay.d.ts +19 -12
- package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +3 -5
- package/dist/components/DotOverlay.d.ts.map +1 -1
- package/dist/components/LeftEdgeFade.d.ts +3 -1
- package/dist/components/LeftEdgeFade.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/LoadingOverlay.d.ts +3 -1
- package/dist/components/LoadingOverlay.d.ts.map +1 -1
- package/dist/components/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/MultiSeriesDots.d.ts +3 -1
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/components/MultiSeriesTooltipStack.d.ts.map +1 -1
- package/dist/components/MultiSeriesValueLabels.d.ts +3 -1
- package/dist/components/MultiSeriesValueLabels.d.ts.map +1 -1
- package/dist/components/MultiSeriesValueLines.d.ts +3 -1
- package/dist/components/MultiSeriesValueLines.d.ts.map +1 -1
- package/dist/components/PerSeriesTooltipOverlay.d.ts +19 -0
- package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
- package/dist/components/ReferenceLineOverlay.d.ts +20 -6
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/ThresholdSplitShader.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts +3 -1
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- package/dist/components/thresholdSplitShaderSource.d.ts +13 -0
- package/dist/components/thresholdSplitShaderSource.d.ts.map +1 -0
- package/dist/core/liveIndicatorVisibility.d.ts +9 -0
- package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
- package/dist/core/resolveConfig.d.ts +29 -0
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +9 -1
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +10 -5
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/draw/particleAtlas.d.ts +3 -2
- package/dist/draw/particleAtlas.d.ts.map +1 -1
- package/dist/hooks/crosshairSeries.d.ts +10 -2
- package/dist/hooks/crosshairSeries.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +37 -2
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/delayedPanGuard.d.ts +66 -8
- package/dist/hooks/delayedPanGuard.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts +5 -9
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +7 -1
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useCrosshairSeries.d.ts +22 -3
- package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
- package/dist/hooks/useMomentum.d.ts +1 -1
- package/dist/hooks/useMomentum.d.ts.map +1 -1
- package/dist/hooks/useMultiSeriesLinePaths.d.ts +4 -4
- package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
- package/dist/hooks/usePanScroll.d.ts +15 -1
- package/dist/hooks/usePanScroll.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/lerp.d.ts.map +1 -1
- package/dist/math/momentum.d.ts +1 -1
- package/dist/math/momentum.d.ts.map +1 -1
- package/dist/math/referenceLines.d.ts +7 -0
- package/dist/math/referenceLines.d.ts.map +1 -1
- package/dist/math/squiggly.d.ts +6 -3
- package/dist/math/squiggly.d.ts.map +1 -1
- package/dist/math/threshold.d.ts +2 -2
- package/dist/types.d.ts +184 -16
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairLine.tsx +21 -1
- package/src/components/CrosshairOverlay.tsx +48 -35
- package/src/components/CustomReferenceLineOverlay.tsx +58 -20
- package/src/components/DegenParticlesOverlay.tsx +40 -5
- package/src/components/DotOverlay.tsx +24 -8
- package/src/components/LeftEdgeFade.tsx +12 -6
- package/src/components/LiveChart.tsx +186 -53
- package/src/components/LiveChartSeries.tsx +214 -29
- package/src/components/LoadingOverlay.tsx +26 -12
- package/src/components/MarkerOverlay.tsx +20 -3
- package/src/components/MultiSeriesDots.tsx +4 -2
- package/src/components/MultiSeriesTooltipStack.tsx +4 -2
- package/src/components/MultiSeriesValueLabels.tsx +4 -2
- package/src/components/MultiSeriesValueLines.tsx +4 -2
- package/src/components/PerSeriesTooltipOverlay.tsx +287 -0
- package/src/components/ReferenceLineOverlay.tsx +274 -141
- package/src/components/ThresholdSplitShader.tsx +5 -37
- package/src/components/YAxisOverlay.tsx +5 -1
- package/src/components/thresholdSplitShaderSource.ts +70 -0
- package/src/core/liveIndicatorVisibility.ts +25 -0
- package/src/core/resolveConfig.ts +86 -0
- package/src/core/useLiveChartEngine.ts +110 -42
- package/src/core/useLiveChartSeriesEngine.ts +137 -70
- package/src/draw/particleAtlas.ts +21 -10
- package/src/hooks/crosshairSeries.ts +262 -33
- package/src/hooks/crosshairShared.ts +41 -2
- package/src/hooks/delayedPanGuard.ts +111 -6
- package/src/hooks/useChartPaths.ts +36 -15
- package/src/hooks/useCrosshair.ts +73 -14
- package/src/hooks/useCrosshairSeries.ts +106 -13
- package/src/hooks/useMomentum.ts +8 -1
- package/src/hooks/useMultiSeriesLinePaths.ts +15 -9
- package/src/hooks/usePanScroll.ts +46 -5
- package/src/index.ts +2 -0
- package/src/math/lerp.ts +7 -0
- package/src/math/momentum.ts +22 -5
- package/src/math/referenceLines.ts +38 -0
- package/src/math/squiggly.ts +28 -11
- package/src/math/threshold.ts +2 -2
- package/src/types.ts +197 -17
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import { Skia, type SkPath } from "@shopify/react-native-skia";
|
|
2
2
|
import { useRef } from "react";
|
|
3
3
|
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ChartEngineEdge,
|
|
6
|
+
ChartEngineScroll,
|
|
7
|
+
SingleEngineState,
|
|
8
|
+
} from "../core/useLiveChartEngine";
|
|
5
9
|
import { buildLinePoints, type ChartPadding } from "../draw/line";
|
|
6
10
|
import { drawSpline, makeSplineScratch } from "../math/spline";
|
|
7
11
|
import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
|
|
8
12
|
import { blendPtsY, squigglifyPts } from "../math/squiggly";
|
|
9
13
|
import { usePathBuilder } from "./usePathBuilder";
|
|
10
14
|
|
|
15
|
+
/** Selects the synthetic right-edge line tip without coupling chart geometry
|
|
16
|
+
* to badge or live-indicator presentation options. */
|
|
17
|
+
export function resolveLineTipValue(
|
|
18
|
+
displayValue: number,
|
|
19
|
+
edgeValue: number,
|
|
20
|
+
viewEnd: number | null,
|
|
21
|
+
): number {
|
|
22
|
+
"worklet";
|
|
23
|
+
return viewEnd == null ? displayValue : edgeValue;
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
/**
|
|
12
27
|
* Builds the `linePath` / `fillPath` with `Skia.PathBuilder`s reused across
|
|
13
28
|
* frames (one per curve, held in a SharedValue) and finalized with `detach()` —
|
|
@@ -20,7 +35,7 @@ import { usePathBuilder } from "./usePathBuilder";
|
|
|
20
35
|
* fillPath.
|
|
21
36
|
*/
|
|
22
37
|
export function useChartPaths(
|
|
23
|
-
engine: SingleEngineState,
|
|
38
|
+
engine: SingleEngineState & ChartEngineScroll & ChartEngineEdge,
|
|
24
39
|
padding: ChartPadding,
|
|
25
40
|
morphT?: SharedValue<number>,
|
|
26
41
|
/** When set, also build `thresholdFillPath` — the band between the line and this
|
|
@@ -28,14 +43,6 @@ export function useChartPaths(
|
|
|
28
43
|
thresholdY?: SharedValue<number>,
|
|
29
44
|
/** Draw the line/fill as a straight polyline instead of the monotone cubic. */
|
|
30
45
|
linear = false,
|
|
31
|
-
/**
|
|
32
|
-
* Value at the visible window's right edge (engine `edgeValue`). With
|
|
33
|
-
* `followViewEdge`, the line's right-edge tip uses this instead of the live
|
|
34
|
-
* `displayValue` — so while time-scrolled the line ends at the last visible
|
|
35
|
-
* price rather than dropping to the (off-screen) live value.
|
|
36
|
-
*/
|
|
37
|
-
edgeValue?: SharedValue<number>,
|
|
38
|
-
followViewEdge = false,
|
|
39
46
|
/** Loading squiggle wave amplitude (px) for the reveal morph. Default 14. */
|
|
40
47
|
squiggleAmplitude = 14,
|
|
41
48
|
/** Loading squiggle wave speed multiplier for the reveal morph. Default 1. */
|
|
@@ -55,6 +62,10 @@ export function useChartPaths(
|
|
|
55
62
|
ptsA: number[];
|
|
56
63
|
ptsB: number[];
|
|
57
64
|
ptsTick: boolean;
|
|
65
|
+
squigglePts: number[];
|
|
66
|
+
morphA: number[];
|
|
67
|
+
morphB: number[];
|
|
68
|
+
morphTick: boolean;
|
|
58
69
|
scratch: ReturnType<typeof makeSplineScratch>;
|
|
59
70
|
} | null>(null);
|
|
60
71
|
if (cacheRef.current === null) {
|
|
@@ -63,6 +74,10 @@ export function useChartPaths(
|
|
|
63
74
|
ptsA: [] as number[],
|
|
64
75
|
ptsB: [] as number[],
|
|
65
76
|
ptsTick: false,
|
|
77
|
+
squigglePts: [] as number[],
|
|
78
|
+
morphA: [] as number[],
|
|
79
|
+
morphB: [] as number[],
|
|
80
|
+
morphTick: false,
|
|
66
81
|
scratch: makeSplineScratch(),
|
|
67
82
|
};
|
|
68
83
|
}
|
|
@@ -71,11 +86,14 @@ export function useChartPaths(
|
|
|
71
86
|
const cache = cacheRef.current!;
|
|
72
87
|
cache.ptsTick = !cache.ptsTick;
|
|
73
88
|
const buf = cache.ptsTick ? cache.ptsA : cache.ptsB;
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
const tipValue =
|
|
78
|
-
|
|
89
|
+
// A historical window must end at its own right-edge price. Badge and
|
|
90
|
+
// live-indicator options affect overlays only; they must not distort the
|
|
91
|
+
// plotted series by connecting history to the off-screen live value.
|
|
92
|
+
const tipValue = resolveLineTipValue(
|
|
93
|
+
engine.displayValue.get(),
|
|
94
|
+
engine.edgeValue.get(),
|
|
95
|
+
engine.viewEnd.get(),
|
|
96
|
+
);
|
|
79
97
|
const realPts = buildLinePoints(
|
|
80
98
|
engine.data.get(),
|
|
81
99
|
tipValue,
|
|
@@ -102,15 +120,18 @@ export function useChartPaths(
|
|
|
102
120
|
centerY,
|
|
103
121
|
squiggleAmplitude,
|
|
104
122
|
squiggleSpeed,
|
|
123
|
+
cache.squigglePts,
|
|
105
124
|
);
|
|
106
125
|
|
|
107
126
|
// Blend center-out: centre of chart reveals first, edges last
|
|
127
|
+
cache.morphTick = !cache.morphTick;
|
|
108
128
|
return blendPtsY(
|
|
109
129
|
squigglyPts,
|
|
110
130
|
realPts,
|
|
111
131
|
t,
|
|
112
132
|
padding,
|
|
113
133
|
engine.canvasWidth.get(),
|
|
134
|
+
cache.morphTick ? cache.morphA : cache.morphB,
|
|
114
135
|
);
|
|
115
136
|
});
|
|
116
137
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type SkFont } from "@shopify/react-native-skia";
|
|
2
|
-
import { Platform } from "react-native";
|
|
3
2
|
import { Gesture } from "react-native-gesture-handler";
|
|
4
3
|
import {
|
|
5
4
|
useAnimatedReaction,
|
|
@@ -33,12 +32,15 @@ import {
|
|
|
33
32
|
HIDDEN_TIME_BADGE,
|
|
34
33
|
HIDDEN_TOOLTIP,
|
|
35
34
|
pointInRect,
|
|
35
|
+
SCRUB_ACTIVATE_X_PX,
|
|
36
|
+
SCRUB_FAIL_Y_PX,
|
|
36
37
|
snapPrice,
|
|
37
38
|
type CrosshairState,
|
|
38
39
|
} from "./crosshairShared";
|
|
39
40
|
import {
|
|
40
41
|
delayedPanTouchCancelled,
|
|
41
42
|
delayedPanTouchDown,
|
|
43
|
+
delayedPanTouchMove,
|
|
42
44
|
delayedPanTouchUp,
|
|
43
45
|
resetDelayedPanGuard,
|
|
44
46
|
shouldStartDelayedPan,
|
|
@@ -151,6 +153,12 @@ export function useCrosshair(
|
|
|
151
153
|
* exclusion (the default; scrub covers the whole plot).
|
|
152
154
|
*/
|
|
153
155
|
scrubBottomExclude = 0,
|
|
156
|
+
/**
|
|
157
|
+
* True while the time-scroll pan owns the touch. The hold-to-scrub pan refuses
|
|
158
|
+
* to activate while set, so a drag that already started scrolling can never
|
|
159
|
+
* mature into a scrub (see `delayedPanGuard.shouldStartDelayedPan`).
|
|
160
|
+
*/
|
|
161
|
+
scrollActive?: SharedValue<boolean>,
|
|
154
162
|
): CrosshairState {
|
|
155
163
|
const scrubX = useSharedValue(-1);
|
|
156
164
|
const scrubActive = useSharedValue(false);
|
|
@@ -161,6 +169,18 @@ export function useCrosshair(
|
|
|
161
169
|
// lifecycle helpers are also used by LiveChartSeries and unit-tested directly.
|
|
162
170
|
const fingerDown = useSharedValue(false);
|
|
163
171
|
const panActivated = useSharedValue(false);
|
|
172
|
+
// Where and when the current touch went down, for the stationary-hold and
|
|
173
|
+
// stale-timer guards (see delayedPanGuard).
|
|
174
|
+
const downX = useSharedValue(0);
|
|
175
|
+
const downY = useSharedValue(0);
|
|
176
|
+
const downAtMs = useSharedValue(0);
|
|
177
|
+
// Latched once the finger leaves the hold slop: this touch is a drag, so its
|
|
178
|
+
// long-press timer must not be honored even if it still fires.
|
|
179
|
+
const holdBroken = useSharedValue(false);
|
|
180
|
+
// Inert stand-in so the guard always has a latch to read when the controller
|
|
181
|
+
// wires no time-scroll (hooks must be created unconditionally).
|
|
182
|
+
const noScrollActive = useSharedValue(false);
|
|
183
|
+
const scrollActiveSV = scrollActive ?? noScrollActive;
|
|
164
184
|
// Where the crosshair line should start (canvas Y) so it stops at a top-pinned
|
|
165
185
|
// custom tooltip instead of running through it. -1 = no top tooltip → the line
|
|
166
186
|
// starts at padding.top. Written by CustomTooltipOverlay, read by CrosshairOverlay.
|
|
@@ -501,18 +521,36 @@ export function useCrosshair(
|
|
|
501
521
|
: panGestureDelay;
|
|
502
522
|
|
|
503
523
|
let gesture = Gesture.Pan()
|
|
504
|
-
// Scrub-action: minDistance 0 — the press-hold above is the sole activator
|
|
505
|
-
// (movement during the hold fails the pan), so the crosshair never appears
|
|
506
|
-
// from a tap's travel. The Tap (maxDistance SCRUB_ACTION_TAP_SLOP) owns quick
|
|
507
|
-
// gestures; only a held press becomes a scrub/adjust drag.
|
|
508
|
-
.minDistance(!hasScrubAction && Platform.OS === "android" ? 10 : 0)
|
|
509
|
-
.activateAfterLongPress(longPressMs)
|
|
510
524
|
.maxPointers(1)
|
|
511
525
|
.shouldCancelWhenOutside(false)
|
|
512
526
|
.onTouchesDown(
|
|
513
|
-
/* istanbul ignore next */ () => {
|
|
527
|
+
/* istanbul ignore next */ (e) => {
|
|
514
528
|
"worklet";
|
|
515
|
-
delayedPanTouchDown(
|
|
529
|
+
delayedPanTouchDown(
|
|
530
|
+
longPressMs,
|
|
531
|
+
e,
|
|
532
|
+
fingerDown,
|
|
533
|
+
downX,
|
|
534
|
+
downY,
|
|
535
|
+
downAtMs,
|
|
536
|
+
holdBroken,
|
|
537
|
+
);
|
|
538
|
+
},
|
|
539
|
+
)
|
|
540
|
+
// Stationary hold: drift beyond the slop while the long-press is still
|
|
541
|
+
// pending fails the pan, so a drag can never mature into a scrub.
|
|
542
|
+
.onTouchesMove(
|
|
543
|
+
/* istanbul ignore next */ (e, manager) => {
|
|
544
|
+
"worklet";
|
|
545
|
+
delayedPanTouchMove(
|
|
546
|
+
longPressMs,
|
|
547
|
+
e,
|
|
548
|
+
manager,
|
|
549
|
+
panActivated,
|
|
550
|
+
downX,
|
|
551
|
+
downY,
|
|
552
|
+
holdBroken,
|
|
553
|
+
);
|
|
516
554
|
},
|
|
517
555
|
)
|
|
518
556
|
.onTouchesUp(
|
|
@@ -540,7 +578,17 @@ export function useCrosshair(
|
|
|
540
578
|
.onStart(
|
|
541
579
|
/* istanbul ignore next */ (e) => {
|
|
542
580
|
"worklet";
|
|
543
|
-
if (
|
|
581
|
+
if (
|
|
582
|
+
!shouldStartDelayedPan(
|
|
583
|
+
longPressMs,
|
|
584
|
+
fingerDown,
|
|
585
|
+
panActivated,
|
|
586
|
+
downAtMs,
|
|
587
|
+
holdBroken,
|
|
588
|
+
scrollActiveSV,
|
|
589
|
+
)
|
|
590
|
+
)
|
|
591
|
+
return;
|
|
544
592
|
if (!enabled) return;
|
|
545
593
|
// Scrub-action: once a reticle is placed, drag adjusts it (2D — the Y is
|
|
546
594
|
// the price). The ephemeral live-scrub never engages, so scrubActive
|
|
@@ -605,7 +653,7 @@ export function useCrosshair(
|
|
|
605
653
|
.onFinalize(
|
|
606
654
|
/* istanbul ignore next */ () => {
|
|
607
655
|
"worklet";
|
|
608
|
-
resetDelayedPanGuard(fingerDown, panActivated);
|
|
656
|
+
resetDelayedPanGuard(fingerDown, panActivated, holdBroken);
|
|
609
657
|
// A lock-adjust drag leaves the reticle in place (scrubActive was never
|
|
610
658
|
// set); a live-scrub clears its crosshair. Always clear scrubActive so a
|
|
611
659
|
// stray scrub can never linger behind a placed reticle.
|
|
@@ -620,11 +668,22 @@ export function useCrosshair(
|
|
|
620
668
|
},
|
|
621
669
|
);
|
|
622
670
|
|
|
623
|
-
|
|
624
|
-
|
|
671
|
+
// Passing zero-valued activation modifiers lets RNGH's iOS pan recognizer
|
|
672
|
+
// activate before the axis constraints classify the drag. Only configure a
|
|
673
|
+
// hold when one is requested, and reserve minDistance(0) for scrub-action's
|
|
674
|
+
// deliberate press-hold interaction.
|
|
675
|
+
if (longPressMs > 0) {
|
|
676
|
+
gesture = gesture.activateAfterLongPress(longPressMs);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (hasScrubAction) {
|
|
680
|
+
gesture = gesture.minDistance(0);
|
|
681
|
+
} else {
|
|
625
682
|
// Lock mode needs free vertical drag (Y = price), so the failOffsetY clamp —
|
|
626
683
|
// which would kill a vertical adjust — is applied only outside scrub-action.
|
|
627
|
-
gesture = gesture
|
|
684
|
+
gesture = gesture
|
|
685
|
+
.activeOffsetX([-SCRUB_ACTIVATE_X_PX, SCRUB_ACTIVATE_X_PX])
|
|
686
|
+
.failOffsetY([-SCRUB_FAIL_Y_PX, SCRUB_FAIL_Y_PX]);
|
|
628
687
|
}
|
|
629
688
|
|
|
630
689
|
// Axis-drag time-scroll: carve the bottom "time ruler" band out of the scrub's
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SkFont } from "@shopify/react-native-skia";
|
|
2
2
|
import { Gesture } from "react-native-gesture-handler";
|
|
3
3
|
import {
|
|
4
4
|
useAnimatedReaction,
|
|
5
5
|
useDerivedValue,
|
|
6
6
|
useSharedValue,
|
|
7
|
+
type DerivedValue,
|
|
8
|
+
type SharedValue,
|
|
7
9
|
} from "react-native-reanimated";
|
|
8
10
|
import { scheduleOnRN } from "react-native-worklets";
|
|
11
|
+
import type { ResolvedPerSeriesTooltipConfig } from "../core/resolveConfig";
|
|
9
12
|
import type { MultiEngineState } from "../core/useLiveChartEngine";
|
|
10
13
|
import { type ChartPadding } from "../draw/line";
|
|
11
14
|
import type { ScrubPointMulti } from "../types";
|
|
12
15
|
import {
|
|
16
|
+
computePerSeriesTooltipLayout,
|
|
13
17
|
deriveScrubValueSeries,
|
|
14
18
|
interpolateSeriesAtTime,
|
|
15
19
|
} from "./crosshairSeries";
|
|
@@ -18,20 +22,33 @@ import {
|
|
|
18
22
|
computeScrubDotY,
|
|
19
23
|
computeScrubTime,
|
|
20
24
|
HIDDEN_TOOLTIP,
|
|
25
|
+
SCRUB_ACTIVATE_X_PX,
|
|
26
|
+
SCRUB_FAIL_Y_PX,
|
|
21
27
|
type CrosshairState,
|
|
22
28
|
} from "./crosshairShared";
|
|
23
29
|
import {
|
|
24
30
|
delayedPanTouchCancelled,
|
|
25
31
|
delayedPanTouchDown,
|
|
32
|
+
delayedPanTouchMove,
|
|
26
33
|
delayedPanTouchUp,
|
|
27
34
|
resetDelayedPanGuard,
|
|
28
35
|
shouldStartDelayedPan,
|
|
29
36
|
} from "./delayedPanGuard";
|
|
30
37
|
|
|
31
38
|
/**
|
|
32
|
-
* LiveChartSeries crosshair + scrub.
|
|
33
|
-
*
|
|
39
|
+
* LiveChartSeries crosshair + scrub. The optional per-series tooltip is
|
|
40
|
+
* calculated on the UI thread alongside the callback payload.
|
|
34
41
|
*/
|
|
42
|
+
interface CrosshairSeriesTooltipOptions {
|
|
43
|
+
config: ResolvedPerSeriesTooltipConfig;
|
|
44
|
+
formatValue: (value: number) => string;
|
|
45
|
+
formatTime: (time: number) => string;
|
|
46
|
+
font: SkFont;
|
|
47
|
+
colors: string[];
|
|
48
|
+
/** Current-time anchor used to clamp the live bucket's range end. */
|
|
49
|
+
maxTime: SharedValue<number> | DerivedValue<number>;
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
export function useCrosshairSeries(
|
|
36
53
|
engine: MultiEngineState,
|
|
37
54
|
padding: ChartPadding,
|
|
@@ -41,6 +58,13 @@ export function useCrosshairSeries(
|
|
|
41
58
|
panGestureDelay = 0,
|
|
42
59
|
onGestureStart?: () => void,
|
|
43
60
|
onGestureEnd?: () => void,
|
|
61
|
+
/**
|
|
62
|
+
* True while the time-scroll pan owns the touch. The hold-to-scrub pan refuses
|
|
63
|
+
* to activate while set, so a drag that already started scrolling can never
|
|
64
|
+
* mature into a scrub (see `delayedPanGuard.shouldStartDelayedPan`).
|
|
65
|
+
*/
|
|
66
|
+
scrollActive?: SharedValue<boolean>,
|
|
67
|
+
tooltip?: CrosshairSeriesTooltipOptions,
|
|
44
68
|
): CrosshairState {
|
|
45
69
|
const scrubX = useSharedValue(-1);
|
|
46
70
|
const scrubActive = useSharedValue(false);
|
|
@@ -51,6 +75,18 @@ export function useCrosshairSeries(
|
|
|
51
75
|
// the final pointer has already lifted on iOS.
|
|
52
76
|
const fingerDown = useSharedValue(false);
|
|
53
77
|
const panActivated = useSharedValue(false);
|
|
78
|
+
// Where and when the current touch went down, for the stationary-hold and
|
|
79
|
+
// stale-timer guards (see delayedPanGuard).
|
|
80
|
+
const downX = useSharedValue(0);
|
|
81
|
+
const downY = useSharedValue(0);
|
|
82
|
+
const downAtMs = useSharedValue(0);
|
|
83
|
+
// Latched once the finger leaves the hold slop: this touch is a drag, so its
|
|
84
|
+
// long-press timer must not be honored even if it still fires.
|
|
85
|
+
const holdBroken = useSharedValue(false);
|
|
86
|
+
// Inert stand-in so the guard always has a latch to read when the controller
|
|
87
|
+
// wires no time-scroll (hooks must be created unconditionally).
|
|
88
|
+
const noScrollActive = useSharedValue(false);
|
|
89
|
+
const scrollActiveSV = scrollActive ?? noScrollActive;
|
|
54
90
|
|
|
55
91
|
const scrubTime = useDerivedValue(() =>
|
|
56
92
|
computeScrubTime(
|
|
@@ -93,7 +129,27 @@ export function useCrosshairSeries(
|
|
|
93
129
|
),
|
|
94
130
|
);
|
|
95
131
|
|
|
96
|
-
const tooltipLayout =
|
|
132
|
+
const tooltipLayout = useDerivedValue(() => {
|
|
133
|
+
if (!tooltip) return HIDDEN_TOOLTIP;
|
|
134
|
+
return computePerSeriesTooltipLayout(
|
|
135
|
+
scrubActive.get(),
|
|
136
|
+
scrubX.get(),
|
|
137
|
+
scrubTime.get(),
|
|
138
|
+
engine.series.get(),
|
|
139
|
+
engine.displaySeriesValues.get(),
|
|
140
|
+
tooltip.colors,
|
|
141
|
+
engine.displayMin.get(),
|
|
142
|
+
engine.displayMax.get(),
|
|
143
|
+
padding,
|
|
144
|
+
engine.canvasWidth.get(),
|
|
145
|
+
engine.canvasHeight.get(),
|
|
146
|
+
tooltip.maxTime.get(),
|
|
147
|
+
tooltip.formatValue,
|
|
148
|
+
tooltip.formatTime,
|
|
149
|
+
tooltip.font,
|
|
150
|
+
tooltip.config,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
97
153
|
|
|
98
154
|
/* istanbul ignore next */
|
|
99
155
|
function handleGestureStart() {
|
|
@@ -165,14 +221,36 @@ export function useCrosshairSeries(
|
|
|
165
221
|
);
|
|
166
222
|
|
|
167
223
|
let gesture = Gesture.Pan()
|
|
168
|
-
.minDistance(Platform.OS === "android" ? 10 : 0)
|
|
169
|
-
.activateAfterLongPress(panGestureDelay)
|
|
170
224
|
.maxPointers(1)
|
|
171
225
|
.shouldCancelWhenOutside(false)
|
|
172
226
|
.onTouchesDown(
|
|
173
|
-
/* istanbul ignore next */ () => {
|
|
227
|
+
/* istanbul ignore next */ (e) => {
|
|
174
228
|
"worklet";
|
|
175
|
-
delayedPanTouchDown(
|
|
229
|
+
delayedPanTouchDown(
|
|
230
|
+
panGestureDelay,
|
|
231
|
+
e,
|
|
232
|
+
fingerDown,
|
|
233
|
+
downX,
|
|
234
|
+
downY,
|
|
235
|
+
downAtMs,
|
|
236
|
+
holdBroken,
|
|
237
|
+
);
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
// Stationary hold: drift beyond the slop while the long-press is still
|
|
241
|
+
// pending fails the pan, so a drag can never mature into a scrub.
|
|
242
|
+
.onTouchesMove(
|
|
243
|
+
/* istanbul ignore next */ (e, manager) => {
|
|
244
|
+
"worklet";
|
|
245
|
+
delayedPanTouchMove(
|
|
246
|
+
panGestureDelay,
|
|
247
|
+
e,
|
|
248
|
+
manager,
|
|
249
|
+
panActivated,
|
|
250
|
+
downX,
|
|
251
|
+
downY,
|
|
252
|
+
holdBroken,
|
|
253
|
+
);
|
|
176
254
|
},
|
|
177
255
|
)
|
|
178
256
|
.onTouchesUp(
|
|
@@ -200,7 +278,16 @@ export function useCrosshairSeries(
|
|
|
200
278
|
.onStart(
|
|
201
279
|
/* istanbul ignore next */ (e) => {
|
|
202
280
|
"worklet";
|
|
203
|
-
if (
|
|
281
|
+
if (
|
|
282
|
+
!shouldStartDelayedPan(
|
|
283
|
+
panGestureDelay,
|
|
284
|
+
fingerDown,
|
|
285
|
+
panActivated,
|
|
286
|
+
downAtMs,
|
|
287
|
+
holdBroken,
|
|
288
|
+
scrollActiveSV,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
204
291
|
return;
|
|
205
292
|
if (!enabled) return;
|
|
206
293
|
scrubX.set(e.x);
|
|
@@ -219,7 +306,7 @@ export function useCrosshairSeries(
|
|
|
219
306
|
.onFinalize(
|
|
220
307
|
/* istanbul ignore next */ () => {
|
|
221
308
|
"worklet";
|
|
222
|
-
resetDelayedPanGuard(fingerDown, panActivated);
|
|
309
|
+
resetDelayedPanGuard(fingerDown, panActivated, holdBroken);
|
|
223
310
|
scrubActive.set(false);
|
|
224
311
|
if (gestureStarted.get()) {
|
|
225
312
|
gestureStarted.set(false);
|
|
@@ -228,11 +315,17 @@ export function useCrosshairSeries(
|
|
|
228
315
|
},
|
|
229
316
|
);
|
|
230
317
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
318
|
+
// Omit zero-valued activation modifiers so RNGH can classify direction before
|
|
319
|
+
// the pan activates on iOS. A vertical drag then remains available to a parent
|
|
320
|
+
// ScrollView/list, while a clear horizontal drag starts scrubbing.
|
|
321
|
+
if (panGestureDelay > 0) {
|
|
322
|
+
gesture = gesture.activateAfterLongPress(panGestureDelay);
|
|
234
323
|
}
|
|
235
324
|
|
|
325
|
+
gesture = gesture
|
|
326
|
+
.activeOffsetX([-SCRUB_ACTIVATE_X_PX, SCRUB_ACTIVATE_X_PX])
|
|
327
|
+
.failOffsetY([-SCRUB_FAIL_Y_PX, SCRUB_FAIL_Y_PX]);
|
|
328
|
+
|
|
236
329
|
return {
|
|
237
330
|
scrubX,
|
|
238
331
|
scrubActive,
|
package/src/hooks/useMomentum.ts
CHANGED
|
@@ -13,16 +13,23 @@ import type { Momentum, MomentumConfig } from "../types";
|
|
|
13
13
|
export function resolveMomentumProp(
|
|
14
14
|
prop: boolean | Momentum | MomentumConfig,
|
|
15
15
|
data: readonly { time: number; value: number }[],
|
|
16
|
+
endTime?: number,
|
|
16
17
|
): Momentum {
|
|
17
18
|
"worklet";
|
|
18
19
|
if (prop === false) return "flat";
|
|
19
20
|
if (prop === true)
|
|
20
|
-
return detectMomentum(
|
|
21
|
+
return detectMomentum(
|
|
22
|
+
data as { time: number; value: number }[],
|
|
23
|
+
undefined,
|
|
24
|
+
undefined,
|
|
25
|
+
endTime,
|
|
26
|
+
);
|
|
21
27
|
if (typeof prop === "object")
|
|
22
28
|
return detectMomentum(
|
|
23
29
|
data as { time: number; value: number }[],
|
|
24
30
|
prop.lookback,
|
|
25
31
|
prop.threshold,
|
|
32
|
+
endTime,
|
|
26
33
|
);
|
|
27
34
|
return prop;
|
|
28
35
|
}
|
|
@@ -8,30 +8,37 @@ import { drawSpline, makeSplineScratch } from "../math/spline";
|
|
|
8
8
|
import { usePathBuilders } from "./usePathBuilder";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* One derived shared value holding
|
|
12
|
-
*
|
|
11
|
+
* One derived shared value holding only the active series' Skia paths, capped at
|
|
12
|
+
* `MAX_MULTI_SERIES`.
|
|
13
13
|
*
|
|
14
14
|
* Each visible series' path is built into a per-slot `Skia.PathBuilder` (reused
|
|
15
15
|
* across frames via a SharedValue) and finalized with `detach()` — a fresh
|
|
16
16
|
* immutable `SkPath` per frame, so `MultiSeriesStroke`'s per-slot
|
|
17
17
|
* `useDerivedValue(() => paths.value[index])` repaints without the two-SkPath
|
|
18
|
-
* ping-pong. Only
|
|
18
|
+
* ping-pong. Only active series allocate and publish a path.
|
|
19
19
|
*/
|
|
20
20
|
export function useMultiSeriesLinePaths(
|
|
21
21
|
engine: MultiEngineState,
|
|
22
22
|
padding: ChartPadding,
|
|
23
|
+
activeSeriesCount = MAX_MULTI_SERIES,
|
|
23
24
|
): SharedValue<SkPath[]> {
|
|
24
25
|
const builders = usePathBuilders(MAX_MULTI_SERIES);
|
|
25
26
|
|
|
26
27
|
const poolRef = useRef<{
|
|
27
28
|
empty: SkPath;
|
|
28
29
|
ptsBuf: number[];
|
|
30
|
+
pathsA: SkPath[];
|
|
31
|
+
pathsB: SkPath[];
|
|
32
|
+
pathsTick: boolean;
|
|
29
33
|
scratch: ReturnType<typeof makeSplineScratch>;
|
|
30
34
|
} | null>(null);
|
|
31
35
|
if (poolRef.current === null) {
|
|
32
36
|
poolRef.current = {
|
|
33
37
|
empty: Skia.Path.Make(),
|
|
34
38
|
ptsBuf: [] as number[],
|
|
39
|
+
pathsA: [] as SkPath[],
|
|
40
|
+
pathsB: [] as SkPath[],
|
|
41
|
+
pathsTick: false,
|
|
35
42
|
scratch: makeSplineScratch(),
|
|
36
43
|
};
|
|
37
44
|
}
|
|
@@ -41,12 +48,11 @@ export function useMultiSeriesLinePaths(
|
|
|
41
48
|
const slots = builders.value;
|
|
42
49
|
const s = engine.series.get();
|
|
43
50
|
const displays = engine.displaySeriesValues.get();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
51
|
+
pool.pathsTick = !pool.pathsTick;
|
|
52
|
+
const out = pool.pathsTick ? pool.pathsA : pool.pathsB;
|
|
53
|
+
out.length = 0;
|
|
54
|
+
const count = Math.min(activeSeriesCount, s.length, MAX_MULTI_SERIES);
|
|
55
|
+
for (let i = 0; i < count; i++) {
|
|
50
56
|
const pts = buildLinePoints(
|
|
51
57
|
s[i].data,
|
|
52
58
|
displays[i] ?? s[i].value,
|
|
@@ -57,6 +57,20 @@ export interface UsePanScrollOptions {
|
|
|
57
57
|
* a stray scrub doesn't linger behind the pan.
|
|
58
58
|
*/
|
|
59
59
|
onScrollStart?: () => void;
|
|
60
|
+
/**
|
|
61
|
+
* Set true for as long as this gesture owns the touch, cleared on finalize.
|
|
62
|
+
* The hold-to-scrub pan reads it and refuses to activate mid-scroll — RNGH's
|
|
63
|
+
* `Gesture.Race` declares no relation between the two pans, so this latch is
|
|
64
|
+
* the only thing stopping the scrub's long-press timer from firing while the
|
|
65
|
+
* chart is already being dragged. See `delayedPanGuard.shouldStartDelayedPan`.
|
|
66
|
+
*/
|
|
67
|
+
scrollActive?: SharedValue<boolean>;
|
|
68
|
+
/**
|
|
69
|
+
* True while the scrub crosshair owns the touch. Scrolling is inert while set,
|
|
70
|
+
* so an engaged scrub locks the chart in place and the finger only moves the
|
|
71
|
+
* price indicator. Cleared when the finger lifts (the scrub pan's finalize).
|
|
72
|
+
*/
|
|
73
|
+
scrubActive?: SharedValue<boolean>;
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
/**
|
|
@@ -134,6 +148,8 @@ export function usePanScroll({
|
|
|
134
148
|
enabled,
|
|
135
149
|
mode = "holdToScrub",
|
|
136
150
|
onScrollStart,
|
|
151
|
+
scrollActive,
|
|
152
|
+
scrubActive,
|
|
137
153
|
}: UsePanScrollOptions): ReturnType<typeof Gesture.Pan> {
|
|
138
154
|
const { viewEnd, liveEdge, displayWindow, canvasWidth, canvasHeight } = engine;
|
|
139
155
|
const padLeft = padding.left;
|
|
@@ -150,10 +166,21 @@ export function usePanScroll({
|
|
|
150
166
|
/* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
|
|
151
167
|
() => {
|
|
152
168
|
"worklet";
|
|
169
|
+
// Stop an in-flight fling FIRST. `withDecay` keeps writing `viewEnd` after
|
|
170
|
+
// the finger lifts, and `Gesture.Race` declares no relation between the two
|
|
171
|
+
// pans, so this can run while a scrub is already engaged. Bailing out below
|
|
172
|
+
// without cancelling left the decay sliding the window underneath the
|
|
173
|
+
// crosshair — the opposite of the lock the scrub is meant to hold.
|
|
153
174
|
cancelAnimation(viewEnd);
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
175
|
+
// An engaged scrub locks the chart: the finger moves the price indicator
|
|
176
|
+
// across a fixed window, so a scroll that activates underneath it must do
|
|
177
|
+
// nothing further (and must not claim the touch via `scrollActive`).
|
|
178
|
+
if (scrubActive?.get()) return;
|
|
179
|
+
scrollActive?.set(true);
|
|
180
|
+
// Keep `null` while following live. `onChange` resolves it to the current
|
|
181
|
+
// live edge before applying the first delta. Materializing that same edge
|
|
182
|
+
// here would falsely signal "scrolled back" between start and change,
|
|
183
|
+
// briefly collapsing the floating-axis gutter on a forward-only overdrag.
|
|
157
184
|
onScrollStart?.();
|
|
158
185
|
};
|
|
159
186
|
|
|
@@ -161,6 +188,7 @@ export function usePanScroll({
|
|
|
161
188
|
/* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
|
|
162
189
|
(e: { changeX: number }) => {
|
|
163
190
|
"worklet";
|
|
191
|
+
if (scrubActive?.get()) return;
|
|
164
192
|
const win = displayWindow.get();
|
|
165
193
|
const chartW = canvasWidth.get() - padLeft - padRight;
|
|
166
194
|
if (chartW <= 0) return;
|
|
@@ -174,6 +202,7 @@ export function usePanScroll({
|
|
|
174
202
|
/* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
|
|
175
203
|
(e: { velocityX: number }) => {
|
|
176
204
|
"worklet";
|
|
205
|
+
if (scrubActive?.get()) return;
|
|
177
206
|
if (viewEnd.get() == null) return;
|
|
178
207
|
const win = displayWindow.get();
|
|
179
208
|
const chartW = canvasWidth.get() - padLeft - padRight;
|
|
@@ -194,6 +223,16 @@ export function usePanScroll({
|
|
|
194
223
|
);
|
|
195
224
|
};
|
|
196
225
|
|
|
226
|
+
// Release the touch claim on every terminal state (END, FAIL, CANCEL), not just
|
|
227
|
+
// onEnd — a scroll that fails its offset clamps never reaches onEnd, and a
|
|
228
|
+
// stuck `scrollActive` would block scrub for the rest of the session.
|
|
229
|
+
const onFinalize =
|
|
230
|
+
/* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
|
|
231
|
+
() => {
|
|
232
|
+
"worklet";
|
|
233
|
+
scrollActive?.set(false);
|
|
234
|
+
};
|
|
235
|
+
|
|
197
236
|
if (mode === "axisDrag") {
|
|
198
237
|
return Gesture.Pan()
|
|
199
238
|
.enabled(enabled)
|
|
@@ -235,7 +274,8 @@ export function usePanScroll({
|
|
|
235
274
|
)
|
|
236
275
|
.onStart(onStart)
|
|
237
276
|
.onChange(onChange)
|
|
238
|
-
.onEnd(onEnd)
|
|
277
|
+
.onEnd(onEnd)
|
|
278
|
+
.onFinalize(onFinalize);
|
|
239
279
|
}
|
|
240
280
|
|
|
241
281
|
// holdToScrub (default): a one-finger drag anywhere scrolls. Activate on
|
|
@@ -249,5 +289,6 @@ export function usePanScroll({
|
|
|
249
289
|
.failOffsetY([-HOLD_SCRUB_FAIL_Y_PX, HOLD_SCRUB_FAIL_Y_PX])
|
|
250
290
|
.onStart(onStart)
|
|
251
291
|
.onChange(onChange)
|
|
252
|
-
.onEnd(onEnd)
|
|
292
|
+
.onEnd(onEnd)
|
|
293
|
+
.onFinalize(onFinalize);
|
|
253
294
|
}
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export type {
|
|
|
40
40
|
BadgeVariant,
|
|
41
41
|
CandleMetrics,
|
|
42
42
|
CandlePoint,
|
|
43
|
+
CanvasMode,
|
|
43
44
|
ChartInsets,
|
|
44
45
|
ChartOverlayContext,
|
|
45
46
|
ChartPlotRect,
|
|
@@ -76,6 +77,7 @@ export type {
|
|
|
76
77
|
MomentumConfig,
|
|
77
78
|
MotionMetrics,
|
|
78
79
|
MultiSeriesDotConfig,
|
|
80
|
+
PerSeriesTooltipConfig,
|
|
79
81
|
PulseConfig,
|
|
80
82
|
ReferenceLine,
|
|
81
83
|
ReferenceLineBadgeConfig,
|