react-native-livechart 4.11.0 → 4.13.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/dist/components/CrosshairLine.d.ts +12 -2
- package/dist/components/CrosshairLine.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +12 -2
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/CustomReferenceLineOverlay.d.ts +15 -10
- package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/CustomTooltipOverlay.d.ts +5 -2
- package/dist/components/CustomTooltipOverlay.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/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/PerSeriesTooltipOverlay.d.ts +21 -0
- package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
- package/dist/components/ReferenceLineOverlay.d.ts +27 -6
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts +8 -2
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- package/dist/core/liveIndicatorVisibility.d.ts +9 -0
- package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
- package/dist/core/resolveConfig.d.ts +45 -0
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/draw/grid.d.ts +13 -0
- package/dist/draw/grid.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 +55 -5
- 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 +13 -1
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useCrosshairSeries.d.ts +27 -3
- package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
- package/dist/hooks/useCrosshairVisibleOpacity.d.ts +8 -0
- package/dist/hooks/useCrosshairVisibleOpacity.d.ts.map +1 -0
- package/dist/hooks/useMomentum.d.ts +1 -1
- package/dist/hooks/useMomentum.d.ts.map +1 -1
- package/dist/hooks/usePanScroll.d.ts +15 -1
- package/dist/hooks/usePanScroll.d.ts.map +1 -1
- package/dist/hooks/useReferenceLine.d.ts +10 -1
- package/dist/hooks/useReferenceLine.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/types.d.ts +207 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairLine.tsx +39 -8
- package/src/components/CrosshairOverlay.tsx +49 -15
- package/src/components/CustomReferenceLineOverlay.tsx +42 -13
- package/src/components/CustomTooltipOverlay.tsx +15 -3
- package/src/components/DotOverlay.tsx +24 -8
- package/src/components/LiveChart.tsx +182 -39
- package/src/components/LiveChartSeries.tsx +195 -16
- package/src/components/PerSeriesTooltipOverlay.tsx +290 -0
- package/src/components/ReferenceLineOverlay.tsx +288 -141
- package/src/components/YAxisOverlay.tsx +39 -10
- package/src/core/liveIndicatorVisibility.ts +25 -0
- package/src/core/resolveConfig.ts +115 -0
- package/src/draw/grid.ts +33 -0
- package/src/hooks/crosshairSeries.ts +262 -33
- package/src/hooks/crosshairShared.ts +117 -6
- package/src/hooks/delayedPanGuard.ts +111 -6
- package/src/hooks/useChartPaths.ts +25 -15
- package/src/hooks/useCrosshair.ts +103 -32
- package/src/hooks/useCrosshairSeries.ts +126 -11
- package/src/hooks/useCrosshairVisibleOpacity.ts +57 -0
- package/src/hooks/useMomentum.ts +8 -1
- package/src/hooks/usePanScroll.ts +46 -5
- package/src/hooks/useReferenceLine.ts +26 -1
- package/src/index.ts +1 -0
- package/src/math/lerp.ts +7 -0
- package/src/math/momentum.ts +22 -5
- package/src/math/referenceLines.ts +38 -0
- package/src/types.ts +215 -18
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type SkFont } from "@shopify/react-native-skia";
|
|
2
2
|
import { type Gesture } from "react-native-gesture-handler";
|
|
3
|
-
import type { SharedValue } from "react-native-reanimated";
|
|
3
|
+
import type { DerivedValue, SharedValue } from "react-native-reanimated";
|
|
4
4
|
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
5
5
|
import { type ChartPadding } from "../draw/line";
|
|
6
6
|
import { interpolateAtTime } from "../math/interpolate";
|
|
@@ -12,13 +12,87 @@ const TOOLTIP_LINE_GAP = 4;
|
|
|
12
12
|
const TOOLTIP_OFFSET_X = 12;
|
|
13
13
|
const TOOLTIP_EDGE_GAP = 4;
|
|
14
14
|
const TOOLTIP_TOP_MARGIN = 8;
|
|
15
|
-
const FADE_ZONE = 4;
|
|
16
15
|
|
|
17
16
|
/** Horizontal travel required before a plain scrub claims the touch. */
|
|
18
17
|
export const SCRUB_ACTIVATE_X_PX = 20;
|
|
19
18
|
/** Vertical travel that fails a plain scrub so a parent scroll gesture can win. */
|
|
20
19
|
export const SCRUB_FAIL_Y_PX = 10;
|
|
21
20
|
|
|
21
|
+
/** Clamp an X pixel to the plot's horizontal bounds. */
|
|
22
|
+
export function clampPlotX(
|
|
23
|
+
x: number,
|
|
24
|
+
padLeft: number,
|
|
25
|
+
canvasWidth: number,
|
|
26
|
+
padRight: number,
|
|
27
|
+
): number {
|
|
28
|
+
"worklet";
|
|
29
|
+
return Math.min(canvasWidth - padRight, Math.max(padLeft, x));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ScrubHitSlop {
|
|
33
|
+
left?: number;
|
|
34
|
+
right?: number;
|
|
35
|
+
bottom?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build touch-down bounds for a scrub recognizer. */
|
|
39
|
+
export function resolveScrubHitSlop(
|
|
40
|
+
padding: ChartPadding,
|
|
41
|
+
clampToPlot: boolean,
|
|
42
|
+
bottomExclude = 0,
|
|
43
|
+
): ScrubHitSlop | undefined {
|
|
44
|
+
if (!clampToPlot && bottomExclude <= 0) return undefined;
|
|
45
|
+
return {
|
|
46
|
+
...(clampToPlot
|
|
47
|
+
? { left: -padding.left, right: -padding.right }
|
|
48
|
+
: {}),
|
|
49
|
+
...(bottomExclude > 0 ? { bottom: -bottomExclude } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ScrubGestureValue<T> {
|
|
54
|
+
get(): T;
|
|
55
|
+
set(value: T): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Start plain scrubbing; the recognizer gates out-of-plot touch origins. */
|
|
59
|
+
export function startPlainScrub(
|
|
60
|
+
x: number,
|
|
61
|
+
padding: ChartPadding,
|
|
62
|
+
canvasWidth: number,
|
|
63
|
+
clampToPlot: boolean,
|
|
64
|
+
scrubX: ScrubGestureValue<number>,
|
|
65
|
+
scrubActive: ScrubGestureValue<boolean>,
|
|
66
|
+
gestureStarted: ScrubGestureValue<boolean>,
|
|
67
|
+
): void {
|
|
68
|
+
"worklet";
|
|
69
|
+
if (clampToPlot) {
|
|
70
|
+
scrubX.set(clampPlotX(x, padding.left, canvasWidth, padding.right));
|
|
71
|
+
} else {
|
|
72
|
+
scrubX.set(x);
|
|
73
|
+
}
|
|
74
|
+
scrubActive.set(true);
|
|
75
|
+
gestureStarted.set(true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Track plain scrubbing, clamping gestures accepted by the recognizer. */
|
|
79
|
+
export function updatePlainScrub(
|
|
80
|
+
x: number,
|
|
81
|
+
padding: ChartPadding,
|
|
82
|
+
canvasWidth: number,
|
|
83
|
+
clampToPlot: boolean,
|
|
84
|
+
scrubX: ScrubGestureValue<number>,
|
|
85
|
+
scrubActive: ScrubGestureValue<boolean>,
|
|
86
|
+
): void {
|
|
87
|
+
"worklet";
|
|
88
|
+
if (clampToPlot) {
|
|
89
|
+
if (!scrubActive.get()) return;
|
|
90
|
+
scrubX.set(clampPlotX(x, padding.left, canvasWidth, padding.right));
|
|
91
|
+
} else {
|
|
92
|
+
scrubX.set(x);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
22
96
|
/** Measure rendered text so the pill and its content share the same centre. */
|
|
23
97
|
function measureTooltipTextWidth(
|
|
24
98
|
font: SkFont,
|
|
@@ -48,6 +122,37 @@ export interface TooltipLayout {
|
|
|
48
122
|
baselineY: number;
|
|
49
123
|
dim: boolean;
|
|
50
124
|
}[];
|
|
125
|
+
/** LiveChartSeries' opt-in Morfi-style time pill + per-series value pills. */
|
|
126
|
+
perSeries?: {
|
|
127
|
+
/** Idle endpoint mode: value pills only, with no guide or time pill. */
|
|
128
|
+
pinned: boolean;
|
|
129
|
+
timePill?: {
|
|
130
|
+
x: number;
|
|
131
|
+
y: number;
|
|
132
|
+
w: number;
|
|
133
|
+
h: number;
|
|
134
|
+
text: string;
|
|
135
|
+
textX: number;
|
|
136
|
+
baselineY: number;
|
|
137
|
+
};
|
|
138
|
+
pills: {
|
|
139
|
+
id: string;
|
|
140
|
+
color: string;
|
|
141
|
+
anchorX: number;
|
|
142
|
+
anchorY: number;
|
|
143
|
+
x: number;
|
|
144
|
+
y: number;
|
|
145
|
+
w: number;
|
|
146
|
+
h: number;
|
|
147
|
+
dotX: number;
|
|
148
|
+
dotY: number;
|
|
149
|
+
label: string;
|
|
150
|
+
labelX: number;
|
|
151
|
+
value: string;
|
|
152
|
+
valueX: number;
|
|
153
|
+
baselineY: number;
|
|
154
|
+
}[];
|
|
155
|
+
};
|
|
51
156
|
}
|
|
52
157
|
|
|
53
158
|
export const HIDDEN_TOOLTIP: TooltipLayout = {
|
|
@@ -62,6 +167,7 @@ export const HIDDEN_TOOLTIP: TooltipLayout = {
|
|
|
62
167
|
line1Y: 0,
|
|
63
168
|
line2Y: 0,
|
|
64
169
|
stackedLines: undefined,
|
|
170
|
+
perSeries: undefined,
|
|
65
171
|
};
|
|
66
172
|
|
|
67
173
|
export interface CrosshairState {
|
|
@@ -70,7 +176,9 @@ export interface CrosshairState {
|
|
|
70
176
|
scrubTime: SharedValue<number>;
|
|
71
177
|
scrubValue: SharedValue<number | null>;
|
|
72
178
|
crosshairOpacity: SharedValue<number>;
|
|
73
|
-
tooltipLayout:
|
|
179
|
+
tooltipLayout:
|
|
180
|
+
| SharedValue<TooltipLayout>
|
|
181
|
+
| DerivedValue<TooltipLayout>;
|
|
74
182
|
/** Scrub intersection Y in canvas px; -1 when there's no dot to draw
|
|
75
183
|
* (inactive / no value / degenerate range). See {@link computeScrubDotY}. */
|
|
76
184
|
scrubDotY: SharedValue<number>;
|
|
@@ -183,20 +291,23 @@ export function computeScrubTime(
|
|
|
183
291
|
}
|
|
184
292
|
|
|
185
293
|
/**
|
|
186
|
-
* Crosshair opacity: fades 1→0 over
|
|
187
|
-
* approaches the live dot at the right chart edge.
|
|
294
|
+
* Crosshair opacity: fades 1→0 over `fadeDistance` px as the crosshair
|
|
295
|
+
* approaches the live dot at the right chart edge. A zero distance removes
|
|
296
|
+
* the ramp while keeping the crosshair hidden at and beyond the live edge.
|
|
188
297
|
*/
|
|
189
298
|
export function computeCrosshairOpacity(
|
|
190
299
|
scrubActive: boolean,
|
|
191
300
|
scrubX: number,
|
|
192
301
|
canvasWidth: number,
|
|
193
302
|
paddingRight: number,
|
|
303
|
+
fadeDistance = 4,
|
|
194
304
|
): number {
|
|
195
305
|
"worklet";
|
|
196
306
|
if (!scrubActive) return 0;
|
|
197
307
|
const dotX = canvasWidth - paddingRight;
|
|
198
308
|
const dist = dotX - scrubX;
|
|
199
|
-
|
|
309
|
+
if (fadeDistance <= 0) return dist > 0 ? 1 : 0;
|
|
310
|
+
return Math.min(1, Math.max(0, dist / fadeDistance));
|
|
200
311
|
}
|
|
201
312
|
|
|
202
313
|
/**
|
|
@@ -1,22 +1,95 @@
|
|
|
1
1
|
import type { SharedValue } from "react-native-reanimated";
|
|
2
2
|
|
|
3
3
|
type BooleanSharedValue = Pick<SharedValue<boolean>, "get" | "set">;
|
|
4
|
+
type NumberSharedValue = Pick<SharedValue<number>, "get" | "set">;
|
|
5
|
+
|
|
6
|
+
type TouchPoint = { x: number; y: number };
|
|
4
7
|
|
|
5
8
|
type TouchCountEvent = {
|
|
6
9
|
numberOfTouches: number;
|
|
7
10
|
};
|
|
8
11
|
|
|
12
|
+
type TouchesEvent = TouchCountEvent & {
|
|
13
|
+
changedTouches: TouchPoint[];
|
|
14
|
+
allTouches: TouchPoint[];
|
|
15
|
+
};
|
|
16
|
+
|
|
9
17
|
type GestureStateManager = {
|
|
10
18
|
fail: () => void;
|
|
11
19
|
};
|
|
12
20
|
|
|
13
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* A pending hold-to-scrub pan must stay within this radius of its touch-down
|
|
23
|
+
* point to activate. Matches the platform long-press convention (RNGH
|
|
24
|
+
* LongPress `maxDist` / UIKit `allowableMovement` default 10) with a little
|
|
25
|
+
* slack for thumbs.
|
|
26
|
+
*/
|
|
27
|
+
export const HOLD_MAX_DRIFT_PX = 12;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An activation earlier than `delayMs - slack` after the current touch went
|
|
31
|
+
* down cannot be a real hold — it is a stale `activateAfterLongPress` timer
|
|
32
|
+
* carried over from a previous touch (see `shouldStartDelayedPan`).
|
|
33
|
+
*/
|
|
34
|
+
export const HOLD_TIMER_SLACK_MS = 50;
|
|
35
|
+
|
|
36
|
+
/** Record the first pointer of a delayed pan going down. Zero-delay pans are inert. */
|
|
14
37
|
export function delayedPanTouchDown(
|
|
15
38
|
delayMs: number,
|
|
39
|
+
event: TouchesEvent,
|
|
16
40
|
fingerDown: BooleanSharedValue,
|
|
41
|
+
downX: NumberSharedValue,
|
|
42
|
+
downY: NumberSharedValue,
|
|
43
|
+
downAtMs: NumberSharedValue,
|
|
44
|
+
holdBroken: BooleanSharedValue,
|
|
45
|
+
): void {
|
|
46
|
+
"worklet";
|
|
47
|
+
if (delayMs <= 0) return;
|
|
48
|
+
fingerDown.set(true);
|
|
49
|
+
const t = event.changedTouches[0];
|
|
50
|
+
if (t) {
|
|
51
|
+
downX.set(t.x);
|
|
52
|
+
downY.set(t.y);
|
|
53
|
+
}
|
|
54
|
+
downAtMs.set(Date.now());
|
|
55
|
+
holdBroken.set(false);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Enforce the stationary hold: while the delayed pan is still pending, any
|
|
60
|
+
* drift beyond HOLD_MAX_DRIFT_PX fails it outright AND latches `holdBroken`.
|
|
61
|
+
*
|
|
62
|
+
* `manager.fail()` alone is not enough. It only lands if the recognizer is
|
|
63
|
+
* still in a state that can fail, and — on iOS — the native
|
|
64
|
+
* `activateAfterLongPress` timer fires `handleGesture:inState:Active`
|
|
65
|
+
* unconditionally (RNPanHandler.m), so a timer that survived the fail still
|
|
66
|
+
* emits ACTIVE. The latch makes "the finger moved before the hold elapsed" a
|
|
67
|
+
* decision `shouldStartDelayedPan` can re-check at activation time, instead of
|
|
68
|
+
* trusting the native recognizer to have stayed failed.
|
|
69
|
+
*
|
|
70
|
+
* The latch also covers movement that returns to its origin: a finger that
|
|
71
|
+
* wanders 40px away and comes back is within the slop when the timer fires, but
|
|
72
|
+
* it was never a stationary hold.
|
|
73
|
+
*/
|
|
74
|
+
export function delayedPanTouchMove(
|
|
75
|
+
delayMs: number,
|
|
76
|
+
event: TouchesEvent,
|
|
77
|
+
manager: GestureStateManager,
|
|
78
|
+
panActivated: BooleanSharedValue,
|
|
79
|
+
downX: NumberSharedValue,
|
|
80
|
+
downY: NumberSharedValue,
|
|
81
|
+
holdBroken: BooleanSharedValue,
|
|
17
82
|
): void {
|
|
18
83
|
"worklet";
|
|
19
|
-
if (delayMs
|
|
84
|
+
if (delayMs <= 0 || panActivated.get()) return;
|
|
85
|
+
const t = event.allTouches[0];
|
|
86
|
+
if (!t) return;
|
|
87
|
+
const dx = t.x - downX.get();
|
|
88
|
+
const dy = t.y - downY.get();
|
|
89
|
+
if (dx * dx + dy * dy > HOLD_MAX_DRIFT_PX * HOLD_MAX_DRIFT_PX) {
|
|
90
|
+
holdBroken.set(true);
|
|
91
|
+
manager.fail();
|
|
92
|
+
}
|
|
20
93
|
}
|
|
21
94
|
|
|
22
95
|
/**
|
|
@@ -49,15 +122,37 @@ export function delayedPanTouchCancelled(
|
|
|
49
122
|
}
|
|
50
123
|
|
|
51
124
|
/**
|
|
52
|
-
* Record a real delayed-pan activation, or reject
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
125
|
+
* Record a real delayed-pan activation, or reject a spurious one. Two spurious
|
|
126
|
+
* shapes exist, both from RNGH's long-press timer surviving where it shouldn't:
|
|
127
|
+
*
|
|
128
|
+
* 1. The timer fires after the final pointer already lifted (`fingerDown`
|
|
129
|
+
* false) — the original touch-up race.
|
|
130
|
+
* 2. The timer fires DURING a later touch: fast successive taps can land a new
|
|
131
|
+
* pointer before the failed recognizer's native reset completes, so the
|
|
132
|
+
* previous tap's timer fires only `now - downAtMs < delayMs` into the new
|
|
133
|
+
* touch. A real hold can never activate early, so reject anything faster
|
|
134
|
+
* than `delayMs - HOLD_TIMER_SLACK_MS`.
|
|
135
|
+
*
|
|
136
|
+
* 3. The timer fires after the finger already moved off the touch-down point
|
|
137
|
+
* (`holdBroken`) — it was a drag, not a hold. See `delayedPanTouchMove`.
|
|
138
|
+
*
|
|
139
|
+
* 4. The timer fires after the time-scroll pan already took the touch
|
|
140
|
+
* (`scrollActive`) — the chart is mid-drag, so the hold lost the race. The
|
|
141
|
+
* two pans are composed with `Gesture.Race`, which in RNGH declares NO
|
|
142
|
+
* relation between them (`RaceGestureType = ComposedGestureType`), so
|
|
143
|
+
* nothing native stops a losing recognizer from activating later. This latch
|
|
144
|
+
* is the arbitration.
|
|
145
|
+
*
|
|
146
|
+
* All rejected paths deliberately keep `panActivated` false so a missing
|
|
147
|
+
* native FINALIZE cannot poison the next interaction.
|
|
56
148
|
*/
|
|
57
149
|
export function shouldStartDelayedPan(
|
|
58
150
|
delayMs: number,
|
|
59
151
|
fingerDown: BooleanSharedValue,
|
|
60
152
|
panActivated: BooleanSharedValue,
|
|
153
|
+
downAtMs: NumberSharedValue,
|
|
154
|
+
holdBroken: BooleanSharedValue,
|
|
155
|
+
scrollActive: BooleanSharedValue,
|
|
61
156
|
): boolean {
|
|
62
157
|
"worklet";
|
|
63
158
|
if (delayMs <= 0) return true;
|
|
@@ -65,6 +160,14 @@ export function shouldStartDelayedPan(
|
|
|
65
160
|
panActivated.set(false);
|
|
66
161
|
return false;
|
|
67
162
|
}
|
|
163
|
+
if (Date.now() - downAtMs.get() < delayMs - HOLD_TIMER_SLACK_MS) {
|
|
164
|
+
panActivated.set(false);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (holdBroken.get() || scrollActive.get()) {
|
|
168
|
+
panActivated.set(false);
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
68
171
|
panActivated.set(true);
|
|
69
172
|
return true;
|
|
70
173
|
}
|
|
@@ -73,8 +176,10 @@ export function shouldStartDelayedPan(
|
|
|
73
176
|
export function resetDelayedPanGuard(
|
|
74
177
|
fingerDown: BooleanSharedValue,
|
|
75
178
|
panActivated: BooleanSharedValue,
|
|
179
|
+
holdBroken: BooleanSharedValue,
|
|
76
180
|
): void {
|
|
77
181
|
"worklet";
|
|
78
182
|
fingerDown.set(false);
|
|
79
183
|
panActivated.set(false);
|
|
184
|
+
holdBroken.set(false);
|
|
80
185
|
}
|
|
@@ -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. */
|
|
@@ -79,11 +86,14 @@ export function useChartPaths(
|
|
|
79
86
|
const cache = cacheRef.current!;
|
|
80
87
|
cache.ptsTick = !cache.ptsTick;
|
|
81
88
|
const buf = cache.ptsTick ? cache.ptsA : cache.ptsB;
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
const tipValue =
|
|
86
|
-
|
|
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
|
+
);
|
|
87
97
|
const realPts = buildLinePoints(
|
|
88
98
|
engine.data.get(),
|
|
89
99
|
tipValue,
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
ScrubPoint,
|
|
21
21
|
} from "../types";
|
|
22
22
|
import {
|
|
23
|
+
clampPlotX,
|
|
23
24
|
computeActionBadgeLayout,
|
|
24
25
|
computeCandleTooltipLayout,
|
|
25
26
|
computeCrosshairOpacity,
|
|
@@ -32,14 +33,18 @@ import {
|
|
|
32
33
|
HIDDEN_TIME_BADGE,
|
|
33
34
|
HIDDEN_TOOLTIP,
|
|
34
35
|
pointInRect,
|
|
36
|
+
resolveScrubHitSlop,
|
|
35
37
|
SCRUB_ACTIVATE_X_PX,
|
|
36
38
|
SCRUB_FAIL_Y_PX,
|
|
37
39
|
snapPrice,
|
|
40
|
+
startPlainScrub,
|
|
38
41
|
type CrosshairState,
|
|
42
|
+
updatePlainScrub,
|
|
39
43
|
} from "./crosshairShared";
|
|
40
44
|
import {
|
|
41
45
|
delayedPanTouchCancelled,
|
|
42
46
|
delayedPanTouchDown,
|
|
47
|
+
delayedPanTouchMove,
|
|
43
48
|
delayedPanTouchUp,
|
|
44
49
|
resetDelayedPanGuard,
|
|
45
50
|
shouldStartDelayedPan,
|
|
@@ -63,18 +68,6 @@ const SCRUB_ACTION_TAP_SLOP = 10;
|
|
|
63
68
|
*/
|
|
64
69
|
const SCRUB_ACTION_PRESS_HOLD_MS = 200;
|
|
65
70
|
|
|
66
|
-
/** Clamp an X pixel to the plot's horizontal bounds. */
|
|
67
|
-
/* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
|
|
68
|
-
function clampPlotX(
|
|
69
|
-
x: number,
|
|
70
|
-
padLeft: number,
|
|
71
|
-
canvasWidth: number,
|
|
72
|
-
padRight: number,
|
|
73
|
-
): number {
|
|
74
|
-
"worklet";
|
|
75
|
-
return Math.min(canvasWidth - padRight, Math.max(padLeft, x));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
71
|
/** Clamp a Y pixel to the plot's vertical bounds. */
|
|
79
72
|
/* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
|
|
80
73
|
function clampPlotY(
|
|
@@ -152,6 +145,18 @@ export function useCrosshair(
|
|
|
152
145
|
* exclusion (the default; scrub covers the whole plot).
|
|
153
146
|
*/
|
|
154
147
|
scrubBottomExclude = 0,
|
|
148
|
+
/**
|
|
149
|
+
* True while the time-scroll pan owns the touch. The hold-to-scrub pan refuses
|
|
150
|
+
* to activate while set, so a drag that already started scrolling can never
|
|
151
|
+
* mature into a scrub (see `delayedPanGuard.shouldStartDelayedPan`).
|
|
152
|
+
*/
|
|
153
|
+
scrollActive?: SharedValue<boolean>,
|
|
154
|
+
/**
|
|
155
|
+
* Reject plain-scrub starts beyond either horizontal plot edge and clamp
|
|
156
|
+
* active drags to those bounds. Scrub-action behavior is unchanged.
|
|
157
|
+
* Default `false`.
|
|
158
|
+
*/
|
|
159
|
+
clampToPlot = false,
|
|
155
160
|
): CrosshairState {
|
|
156
161
|
const scrubX = useSharedValue(-1);
|
|
157
162
|
const scrubActive = useSharedValue(false);
|
|
@@ -162,6 +167,18 @@ export function useCrosshair(
|
|
|
162
167
|
// lifecycle helpers are also used by LiveChartSeries and unit-tested directly.
|
|
163
168
|
const fingerDown = useSharedValue(false);
|
|
164
169
|
const panActivated = useSharedValue(false);
|
|
170
|
+
// Where and when the current touch went down, for the stationary-hold and
|
|
171
|
+
// stale-timer guards (see delayedPanGuard).
|
|
172
|
+
const downX = useSharedValue(0);
|
|
173
|
+
const downY = useSharedValue(0);
|
|
174
|
+
const downAtMs = useSharedValue(0);
|
|
175
|
+
// Latched once the finger leaves the hold slop: this touch is a drag, so its
|
|
176
|
+
// long-press timer must not be honored even if it still fires.
|
|
177
|
+
const holdBroken = useSharedValue(false);
|
|
178
|
+
// Inert stand-in so the guard always has a latch to read when the controller
|
|
179
|
+
// wires no time-scroll (hooks must be created unconditionally).
|
|
180
|
+
const noScrollActive = useSharedValue(false);
|
|
181
|
+
const scrollActiveSV = scrollActive ?? noScrollActive;
|
|
165
182
|
// Where the crosshair line should start (canvas Y) so it stops at a top-pinned
|
|
166
183
|
// custom tooltip instead of running through it. -1 = no top tooltip → the line
|
|
167
184
|
// starts at padding.top. Written by CustomTooltipOverlay, read by CrosshairOverlay.
|
|
@@ -176,6 +193,7 @@ export function useCrosshair(
|
|
|
176
193
|
// tracks the price as the axis rescales instead of drifting under a fixed pixel.
|
|
177
194
|
const lockPriceValue = useSharedValue<number | null>(null);
|
|
178
195
|
const hasScrubAction = scrubAction != null;
|
|
196
|
+
const clampPlainScrubToPlot = clampToPlot && !hasScrubAction;
|
|
179
197
|
const hasOnScrubAction = onScrubAction != null;
|
|
180
198
|
const dismissOnTapOutside = scrubAction?.dismissOnTapOutside ?? false;
|
|
181
199
|
const dismissOnAction = scrubAction?.dismissOnAction ?? false;
|
|
@@ -505,9 +523,33 @@ export function useCrosshair(
|
|
|
505
523
|
.maxPointers(1)
|
|
506
524
|
.shouldCancelWhenOutside(false)
|
|
507
525
|
.onTouchesDown(
|
|
508
|
-
/* istanbul ignore next */ () => {
|
|
526
|
+
/* istanbul ignore next */ (e) => {
|
|
527
|
+
"worklet";
|
|
528
|
+
delayedPanTouchDown(
|
|
529
|
+
longPressMs,
|
|
530
|
+
e,
|
|
531
|
+
fingerDown,
|
|
532
|
+
downX,
|
|
533
|
+
downY,
|
|
534
|
+
downAtMs,
|
|
535
|
+
holdBroken,
|
|
536
|
+
);
|
|
537
|
+
},
|
|
538
|
+
)
|
|
539
|
+
// Stationary hold: drift beyond the slop while the long-press is still
|
|
540
|
+
// pending fails the pan, so a drag can never mature into a scrub.
|
|
541
|
+
.onTouchesMove(
|
|
542
|
+
/* istanbul ignore next */ (e, manager) => {
|
|
509
543
|
"worklet";
|
|
510
|
-
|
|
544
|
+
delayedPanTouchMove(
|
|
545
|
+
longPressMs,
|
|
546
|
+
e,
|
|
547
|
+
manager,
|
|
548
|
+
panActivated,
|
|
549
|
+
downX,
|
|
550
|
+
downY,
|
|
551
|
+
holdBroken,
|
|
552
|
+
);
|
|
511
553
|
},
|
|
512
554
|
)
|
|
513
555
|
.onTouchesUp(
|
|
@@ -535,7 +577,17 @@ export function useCrosshair(
|
|
|
535
577
|
.onStart(
|
|
536
578
|
/* istanbul ignore next */ (e) => {
|
|
537
579
|
"worklet";
|
|
538
|
-
if (
|
|
580
|
+
if (
|
|
581
|
+
!shouldStartDelayedPan(
|
|
582
|
+
longPressMs,
|
|
583
|
+
fingerDown,
|
|
584
|
+
panActivated,
|
|
585
|
+
downAtMs,
|
|
586
|
+
holdBroken,
|
|
587
|
+
scrollActiveSV,
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
return;
|
|
539
591
|
if (!enabled) return;
|
|
540
592
|
// Scrub-action: once a reticle is placed, drag adjusts it (2D — the Y is
|
|
541
593
|
// the price). The ephemeral live-scrub never engages, so scrubActive
|
|
@@ -565,9 +617,15 @@ export function useCrosshair(
|
|
|
565
617
|
// also keeps a follow-on drag from showing a crosshair — no `onUpdate`
|
|
566
618
|
// guard needed. (Plain-scrub counterpart of the scrub-action tap defer.)
|
|
567
619
|
if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
620
|
+
startPlainScrub(
|
|
621
|
+
e.x,
|
|
622
|
+
padding,
|
|
623
|
+
engine.canvasWidth.get(),
|
|
624
|
+
clampPlainScrubToPlot,
|
|
625
|
+
scrubX,
|
|
626
|
+
scrubActive,
|
|
627
|
+
gestureStarted,
|
|
628
|
+
);
|
|
571
629
|
if (hasOnGestureStart) scheduleOnRN(handleGestureStart);
|
|
572
630
|
},
|
|
573
631
|
)
|
|
@@ -594,13 +652,20 @@ export function useCrosshair(
|
|
|
594
652
|
}
|
|
595
653
|
return;
|
|
596
654
|
}
|
|
597
|
-
|
|
655
|
+
updatePlainScrub(
|
|
656
|
+
e.x,
|
|
657
|
+
padding,
|
|
658
|
+
engine.canvasWidth.get(),
|
|
659
|
+
clampPlainScrubToPlot,
|
|
660
|
+
scrubX,
|
|
661
|
+
scrubActive,
|
|
662
|
+
);
|
|
598
663
|
},
|
|
599
664
|
)
|
|
600
665
|
.onFinalize(
|
|
601
666
|
/* istanbul ignore next */ () => {
|
|
602
667
|
"worklet";
|
|
603
|
-
resetDelayedPanGuard(fingerDown, panActivated);
|
|
668
|
+
resetDelayedPanGuard(fingerDown, panActivated, holdBroken);
|
|
604
669
|
// A lock-adjust drag leaves the reticle in place (scrubActive was never
|
|
605
670
|
// set); a live-scrub clears its crosshair. Always clear scrubActive so a
|
|
606
671
|
// stray scrub can never linger behind a placed reticle.
|
|
@@ -633,13 +698,15 @@ export function useCrosshair(
|
|
|
633
698
|
.failOffsetY([-SCRUB_FAIL_Y_PX, SCRUB_FAIL_Y_PX]);
|
|
634
699
|
}
|
|
635
700
|
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
701
|
+
// Restrict recognition by touch-down position. This rejects before ACTIVE,
|
|
702
|
+
// leaving outside starts to competing/parent gestures. Once accepted,
|
|
703
|
+
// `shouldCancelWhenOutside(false)` keeps tracking beyond these bounds.
|
|
704
|
+
const scrubHitSlop = resolveScrubHitSlop(
|
|
705
|
+
padding,
|
|
706
|
+
clampPlainScrubToPlot,
|
|
707
|
+
scrubBottomExclude,
|
|
708
|
+
);
|
|
709
|
+
if (scrubHitSlop) gesture = gesture.hitSlop(scrubHitSlop);
|
|
643
710
|
|
|
644
711
|
// Tap: place/move the reticle, press the action badge, or dismiss the lock.
|
|
645
712
|
// Composed ahead of the pan by the controller, so a tap is never swallowed.
|
|
@@ -714,11 +781,15 @@ export function useCrosshair(
|
|
|
714
781
|
.maxDistance(SCRUB_ACTION_TAP_SLOP)
|
|
715
782
|
.onEnd(handleActionTap)
|
|
716
783
|
: undefined;
|
|
717
|
-
// Keep the axis
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
784
|
+
// Keep the bottom axis scroll-only. Do not apply horizontal plot bounds here:
|
|
785
|
+
// the action badge is intentionally tappable in the right gutter.
|
|
786
|
+
const tapHitSlop = resolveScrubHitSlop(
|
|
787
|
+
padding,
|
|
788
|
+
false,
|
|
789
|
+
scrubBottomExclude,
|
|
790
|
+
);
|
|
791
|
+
if (tapGesture && tapHitSlop)
|
|
792
|
+
tapGesture = tapGesture.hitSlop(tapHitSlop);
|
|
722
793
|
|
|
723
794
|
return {
|
|
724
795
|
scrubX,
|