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,14 +1,19 @@
|
|
|
1
|
+
import type { SkFont } from "@shopify/react-native-skia";
|
|
1
2
|
import { Gesture } from "react-native-gesture-handler";
|
|
2
3
|
import {
|
|
3
4
|
useAnimatedReaction,
|
|
4
5
|
useDerivedValue,
|
|
5
6
|
useSharedValue,
|
|
7
|
+
type DerivedValue,
|
|
8
|
+
type SharedValue,
|
|
6
9
|
} from "react-native-reanimated";
|
|
7
10
|
import { scheduleOnRN } from "react-native-worklets";
|
|
11
|
+
import type { ResolvedPerSeriesTooltipConfig } from "../core/resolveConfig";
|
|
8
12
|
import type { MultiEngineState } from "../core/useLiveChartEngine";
|
|
9
13
|
import { type ChartPadding } from "../draw/line";
|
|
10
14
|
import type { ScrubPointMulti } from "../types";
|
|
11
15
|
import {
|
|
16
|
+
computePerSeriesTooltipLayout,
|
|
12
17
|
deriveScrubValueSeries,
|
|
13
18
|
interpolateSeriesAtTime,
|
|
14
19
|
} from "./crosshairSeries";
|
|
@@ -17,22 +22,36 @@ import {
|
|
|
17
22
|
computeScrubDotY,
|
|
18
23
|
computeScrubTime,
|
|
19
24
|
HIDDEN_TOOLTIP,
|
|
25
|
+
resolveScrubHitSlop,
|
|
20
26
|
SCRUB_ACTIVATE_X_PX,
|
|
21
27
|
SCRUB_FAIL_Y_PX,
|
|
28
|
+
startPlainScrub,
|
|
22
29
|
type CrosshairState,
|
|
30
|
+
updatePlainScrub,
|
|
23
31
|
} from "./crosshairShared";
|
|
24
32
|
import {
|
|
25
33
|
delayedPanTouchCancelled,
|
|
26
34
|
delayedPanTouchDown,
|
|
35
|
+
delayedPanTouchMove,
|
|
27
36
|
delayedPanTouchUp,
|
|
28
37
|
resetDelayedPanGuard,
|
|
29
38
|
shouldStartDelayedPan,
|
|
30
39
|
} from "./delayedPanGuard";
|
|
31
40
|
|
|
32
41
|
/**
|
|
33
|
-
* LiveChartSeries crosshair + scrub.
|
|
34
|
-
*
|
|
42
|
+
* LiveChartSeries crosshair + scrub. The optional per-series tooltip is
|
|
43
|
+
* calculated on the UI thread alongside the callback payload.
|
|
35
44
|
*/
|
|
45
|
+
interface CrosshairSeriesTooltipOptions {
|
|
46
|
+
config: ResolvedPerSeriesTooltipConfig;
|
|
47
|
+
formatValue: (value: number) => string;
|
|
48
|
+
formatTime: (time: number) => string;
|
|
49
|
+
font: SkFont;
|
|
50
|
+
colors: string[];
|
|
51
|
+
/** Current-time anchor used to clamp the live bucket's range end. */
|
|
52
|
+
maxTime: SharedValue<number> | DerivedValue<number>;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
export function useCrosshairSeries(
|
|
37
56
|
engine: MultiEngineState,
|
|
38
57
|
padding: ChartPadding,
|
|
@@ -42,6 +61,18 @@ export function useCrosshairSeries(
|
|
|
42
61
|
panGestureDelay = 0,
|
|
43
62
|
onGestureStart?: () => void,
|
|
44
63
|
onGestureEnd?: () => void,
|
|
64
|
+
/**
|
|
65
|
+
* True while the time-scroll pan owns the touch. The hold-to-scrub pan refuses
|
|
66
|
+
* to activate while set, so a drag that already started scrolling can never
|
|
67
|
+
* mature into a scrub (see `delayedPanGuard.shouldStartDelayedPan`).
|
|
68
|
+
*/
|
|
69
|
+
scrollActive?: SharedValue<boolean>,
|
|
70
|
+
tooltip?: CrosshairSeriesTooltipOptions,
|
|
71
|
+
/**
|
|
72
|
+
* Reject scrub starts beyond either horizontal plot edge and clamp active
|
|
73
|
+
* drags to those bounds. Default `false`.
|
|
74
|
+
*/
|
|
75
|
+
clampToPlot = false,
|
|
45
76
|
): CrosshairState {
|
|
46
77
|
const scrubX = useSharedValue(-1);
|
|
47
78
|
const scrubActive = useSharedValue(false);
|
|
@@ -52,6 +83,18 @@ export function useCrosshairSeries(
|
|
|
52
83
|
// the final pointer has already lifted on iOS.
|
|
53
84
|
const fingerDown = useSharedValue(false);
|
|
54
85
|
const panActivated = useSharedValue(false);
|
|
86
|
+
// Where and when the current touch went down, for the stationary-hold and
|
|
87
|
+
// stale-timer guards (see delayedPanGuard).
|
|
88
|
+
const downX = useSharedValue(0);
|
|
89
|
+
const downY = useSharedValue(0);
|
|
90
|
+
const downAtMs = useSharedValue(0);
|
|
91
|
+
// Latched once the finger leaves the hold slop: this touch is a drag, so its
|
|
92
|
+
// long-press timer must not be honored even if it still fires.
|
|
93
|
+
const holdBroken = useSharedValue(false);
|
|
94
|
+
// Inert stand-in so the guard always has a latch to read when the controller
|
|
95
|
+
// wires no time-scroll (hooks must be created unconditionally).
|
|
96
|
+
const noScrollActive = useSharedValue(false);
|
|
97
|
+
const scrollActiveSV = scrollActive ?? noScrollActive;
|
|
55
98
|
|
|
56
99
|
const scrubTime = useDerivedValue(() =>
|
|
57
100
|
computeScrubTime(
|
|
@@ -94,7 +137,27 @@ export function useCrosshairSeries(
|
|
|
94
137
|
),
|
|
95
138
|
);
|
|
96
139
|
|
|
97
|
-
const tooltipLayout =
|
|
140
|
+
const tooltipLayout = useDerivedValue(() => {
|
|
141
|
+
if (!tooltip) return HIDDEN_TOOLTIP;
|
|
142
|
+
return computePerSeriesTooltipLayout(
|
|
143
|
+
scrubActive.get(),
|
|
144
|
+
scrubX.get(),
|
|
145
|
+
scrubTime.get(),
|
|
146
|
+
engine.series.get(),
|
|
147
|
+
engine.displaySeriesValues.get(),
|
|
148
|
+
tooltip.colors,
|
|
149
|
+
engine.displayMin.get(),
|
|
150
|
+
engine.displayMax.get(),
|
|
151
|
+
padding,
|
|
152
|
+
engine.canvasWidth.get(),
|
|
153
|
+
engine.canvasHeight.get(),
|
|
154
|
+
tooltip.maxTime.get(),
|
|
155
|
+
tooltip.formatValue,
|
|
156
|
+
tooltip.formatTime,
|
|
157
|
+
tooltip.font,
|
|
158
|
+
tooltip.config,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
98
161
|
|
|
99
162
|
/* istanbul ignore next */
|
|
100
163
|
function handleGestureStart() {
|
|
@@ -169,9 +232,33 @@ export function useCrosshairSeries(
|
|
|
169
232
|
.maxPointers(1)
|
|
170
233
|
.shouldCancelWhenOutside(false)
|
|
171
234
|
.onTouchesDown(
|
|
172
|
-
/* istanbul ignore next */ () => {
|
|
235
|
+
/* istanbul ignore next */ (e) => {
|
|
236
|
+
"worklet";
|
|
237
|
+
delayedPanTouchDown(
|
|
238
|
+
panGestureDelay,
|
|
239
|
+
e,
|
|
240
|
+
fingerDown,
|
|
241
|
+
downX,
|
|
242
|
+
downY,
|
|
243
|
+
downAtMs,
|
|
244
|
+
holdBroken,
|
|
245
|
+
);
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
// Stationary hold: drift beyond the slop while the long-press is still
|
|
249
|
+
// pending fails the pan, so a drag can never mature into a scrub.
|
|
250
|
+
.onTouchesMove(
|
|
251
|
+
/* istanbul ignore next */ (e, manager) => {
|
|
173
252
|
"worklet";
|
|
174
|
-
|
|
253
|
+
delayedPanTouchMove(
|
|
254
|
+
panGestureDelay,
|
|
255
|
+
e,
|
|
256
|
+
manager,
|
|
257
|
+
panActivated,
|
|
258
|
+
downX,
|
|
259
|
+
downY,
|
|
260
|
+
holdBroken,
|
|
261
|
+
);
|
|
175
262
|
},
|
|
176
263
|
)
|
|
177
264
|
.onTouchesUp(
|
|
@@ -199,12 +286,27 @@ export function useCrosshairSeries(
|
|
|
199
286
|
.onStart(
|
|
200
287
|
/* istanbul ignore next */ (e) => {
|
|
201
288
|
"worklet";
|
|
202
|
-
if (
|
|
289
|
+
if (
|
|
290
|
+
!shouldStartDelayedPan(
|
|
291
|
+
panGestureDelay,
|
|
292
|
+
fingerDown,
|
|
293
|
+
panActivated,
|
|
294
|
+
downAtMs,
|
|
295
|
+
holdBroken,
|
|
296
|
+
scrollActiveSV,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
203
299
|
return;
|
|
204
300
|
if (!enabled) return;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
301
|
+
startPlainScrub(
|
|
302
|
+
e.x,
|
|
303
|
+
padding,
|
|
304
|
+
engine.canvasWidth.get(),
|
|
305
|
+
clampToPlot,
|
|
306
|
+
scrubX,
|
|
307
|
+
scrubActive,
|
|
308
|
+
gestureStarted,
|
|
309
|
+
);
|
|
208
310
|
if (hasOnGestureStart) scheduleOnRN(handleGestureStart);
|
|
209
311
|
},
|
|
210
312
|
)
|
|
@@ -212,13 +314,20 @@ export function useCrosshairSeries(
|
|
|
212
314
|
/* istanbul ignore next */ (e) => {
|
|
213
315
|
"worklet";
|
|
214
316
|
if (!enabled) return;
|
|
215
|
-
|
|
317
|
+
updatePlainScrub(
|
|
318
|
+
e.x,
|
|
319
|
+
padding,
|
|
320
|
+
engine.canvasWidth.get(),
|
|
321
|
+
clampToPlot,
|
|
322
|
+
scrubX,
|
|
323
|
+
scrubActive,
|
|
324
|
+
);
|
|
216
325
|
},
|
|
217
326
|
)
|
|
218
327
|
.onFinalize(
|
|
219
328
|
/* istanbul ignore next */ () => {
|
|
220
329
|
"worklet";
|
|
221
|
-
resetDelayedPanGuard(fingerDown, panActivated);
|
|
330
|
+
resetDelayedPanGuard(fingerDown, panActivated, holdBroken);
|
|
222
331
|
scrubActive.set(false);
|
|
223
332
|
if (gestureStarted.get()) {
|
|
224
333
|
gestureStarted.set(false);
|
|
@@ -238,6 +347,12 @@ export function useCrosshairSeries(
|
|
|
238
347
|
.activeOffsetX([-SCRUB_ACTIVATE_X_PX, SCRUB_ACTIVATE_X_PX])
|
|
239
348
|
.failOffsetY([-SCRUB_FAIL_Y_PX, SCRUB_FAIL_Y_PX]);
|
|
240
349
|
|
|
350
|
+
// Gate by touch-down position so outside starts fail before activation and
|
|
351
|
+
// remain available to competing/parent gestures. Accepted pans keep tracking
|
|
352
|
+
// outside because `shouldCancelWhenOutside(false)` is set above.
|
|
353
|
+
const scrubHitSlop = resolveScrubHitSlop(padding, clampToPlot);
|
|
354
|
+
if (scrubHitSlop) gesture = gesture.hitSlop(scrubHitSlop);
|
|
355
|
+
|
|
241
356
|
return {
|
|
242
357
|
scrubX,
|
|
243
358
|
scrubActive,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useDerivedValue,
|
|
3
|
+
type SharedValue,
|
|
4
|
+
} from "react-native-reanimated";
|
|
5
|
+
import { computeCrosshairOpacity } from "./crosshairShared";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Opacity for the visible crosshair UI. The trailing-content dim keeps using
|
|
9
|
+
* the edge-faded opacity directly, independent of this setting.
|
|
10
|
+
*/
|
|
11
|
+
export function useCrosshairVisibleOpacity(
|
|
12
|
+
scrubX: SharedValue<number>,
|
|
13
|
+
canvasWidth: SharedValue<number>,
|
|
14
|
+
paddingRight: number,
|
|
15
|
+
active: SharedValue<number> | SharedValue<boolean>,
|
|
16
|
+
fade: boolean,
|
|
17
|
+
fadeDistance = 4,
|
|
18
|
+
) {
|
|
19
|
+
return useDerivedValue(
|
|
20
|
+
() =>
|
|
21
|
+
resolveCrosshairVisibleOpacity(
|
|
22
|
+
Boolean(active.get()),
|
|
23
|
+
scrubX.get(),
|
|
24
|
+
canvasWidth.get(),
|
|
25
|
+
paddingRight,
|
|
26
|
+
fade,
|
|
27
|
+
fadeDistance,
|
|
28
|
+
),
|
|
29
|
+
[
|
|
30
|
+
scrubX,
|
|
31
|
+
canvasWidth,
|
|
32
|
+
paddingRight,
|
|
33
|
+
active,
|
|
34
|
+
fade,
|
|
35
|
+
fadeDistance,
|
|
36
|
+
],
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveCrosshairVisibleOpacity(
|
|
41
|
+
active: boolean,
|
|
42
|
+
scrubX: number,
|
|
43
|
+
canvasWidth: number,
|
|
44
|
+
paddingRight: number,
|
|
45
|
+
fade: boolean,
|
|
46
|
+
fadeDistance = 4,
|
|
47
|
+
): number {
|
|
48
|
+
"worklet";
|
|
49
|
+
if (!fade) return active ? 1 : 0;
|
|
50
|
+
return computeCrosshairOpacity(
|
|
51
|
+
active,
|
|
52
|
+
scrubX,
|
|
53
|
+
canvasWidth,
|
|
54
|
+
paddingRight,
|
|
55
|
+
fadeDistance,
|
|
56
|
+
);
|
|
57
|
+
}
|
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
|
}
|
|
@@ -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
|
}
|
|
@@ -2,6 +2,10 @@ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
|
2
2
|
|
|
3
3
|
import type { SkFont } from "@shopify/react-native-skia";
|
|
4
4
|
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
5
|
+
import {
|
|
6
|
+
rightAnchoredYAxisColumnLayout,
|
|
7
|
+
type YAxisEntry,
|
|
8
|
+
} from "../draw/grid";
|
|
5
9
|
import type { ChartPadding } from "../draw/line";
|
|
6
10
|
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
7
11
|
import {
|
|
@@ -293,6 +297,14 @@ export function useReferenceLine(
|
|
|
293
297
|
*/
|
|
294
298
|
dragValues?: SharedValue<number[]>,
|
|
295
299
|
index = 0,
|
|
300
|
+
/** Y-axis labels used to match the right-anchored grid endpoint. */
|
|
301
|
+
yAxisEntries?: SharedValue<YAxisEntry[]>,
|
|
302
|
+
/** Enables clipping when set; matches {@link YAxisConfig.labelRightMargin}. */
|
|
303
|
+
labelRightMargin?: number,
|
|
304
|
+
/** Gap before the label column; matches {@link YAxisConfig.gridEndGap}. */
|
|
305
|
+
gridEndGap = 0,
|
|
306
|
+
/** Font used by the Y-axis labels; badge font overrides must not move the line. */
|
|
307
|
+
yAxisFont: SkFont = font,
|
|
296
308
|
): SharedValue<ReferenceLineLayout> {
|
|
297
309
|
const form = line ? referenceLineForm(line) : "none";
|
|
298
310
|
// Badge presentation depends only on the (stable) line props — resolve once.
|
|
@@ -314,7 +326,20 @@ export function useReferenceLine(
|
|
|
314
326
|
// the badge/label anchor (x1/x2) stays at the plot edges either way.
|
|
315
327
|
const fullWidth = line.fullWidth ?? false;
|
|
316
328
|
const lineX1 = fullWidth ? 0 : x1;
|
|
317
|
-
const
|
|
329
|
+
const yAxisColumn =
|
|
330
|
+
form === "line" &&
|
|
331
|
+
!fullWidth &&
|
|
332
|
+
yAxisEntries !== undefined &&
|
|
333
|
+
labelRightMargin !== undefined
|
|
334
|
+
? rightAnchoredYAxisColumnLayout(
|
|
335
|
+
w,
|
|
336
|
+
yAxisEntries.value,
|
|
337
|
+
yAxisFont,
|
|
338
|
+
labelRightMargin,
|
|
339
|
+
gridEndGap,
|
|
340
|
+
)
|
|
341
|
+
: null;
|
|
342
|
+
const lineX2 = fullWidth ? w : (yAxisColumn?.gridEndX ?? x2);
|
|
318
343
|
|
|
319
344
|
const fm = font.getMetrics();
|
|
320
345
|
const baselineOffset = (fm.ascent + fm.descent) / 2;
|
package/src/index.ts
CHANGED
package/src/math/lerp.ts
CHANGED
|
@@ -12,6 +12,13 @@ export function lerp(
|
|
|
12
12
|
dt = MS_PER_FRAME_60FPS,
|
|
13
13
|
): number {
|
|
14
14
|
"worklet";
|
|
15
|
+
// speed >= 1 means "snap in one frame". Computing it through the exponential
|
|
16
|
+
// would evaluate 0 ** (dt / frame), which is Infinity for a negative frame
|
|
17
|
+
// delta. When current === target, the resulting 0 * -Infinity becomes NaN
|
|
18
|
+
// and is permanent once written back into a SharedValue. No broader guard:
|
|
19
|
+
// NaN inputs must keep propagating (the engine tick relies on a NaN
|
|
20
|
+
// displayValue staying NaN so the y-range block stays skipped).
|
|
21
|
+
if (speed >= 1) return target;
|
|
15
22
|
const factor = 1 - Math.pow(1 - speed, dt / MS_PER_FRAME_60FPS);
|
|
16
23
|
return current + (target - current) * factor;
|
|
17
24
|
}
|
package/src/math/momentum.ts
CHANGED
|
@@ -9,15 +9,32 @@ export function detectMomentum(
|
|
|
9
9
|
points: LiveChartPoint[],
|
|
10
10
|
lookback = 20,
|
|
11
11
|
threshold = 0.12,
|
|
12
|
+
endTime?: number,
|
|
12
13
|
): Momentum {
|
|
13
14
|
"worklet";
|
|
14
|
-
|
|
15
|
+
// `badge.followViewEdge` needs momentum at the historical window edge rather
|
|
16
|
+
// than at the live dataset tail. Points are chronological, so find the first
|
|
17
|
+
// sample after the inclusive cutoff without allocating a sliced array on the
|
|
18
|
+
// UI thread. With no cutoff, preserve the normal live-tail behavior.
|
|
19
|
+
let end = points.length;
|
|
20
|
+
if (endTime !== undefined) {
|
|
21
|
+
let lo = 0;
|
|
22
|
+
let hi = points.length;
|
|
23
|
+
while (lo < hi) {
|
|
24
|
+
const mid = (lo + hi) >> 1;
|
|
25
|
+
if (points[mid].time <= endTime) lo = mid + 1;
|
|
26
|
+
else hi = mid;
|
|
27
|
+
}
|
|
28
|
+
end = lo;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (end < 5) return "flat";
|
|
15
32
|
|
|
16
|
-
const start = Math.max(0,
|
|
33
|
+
const start = Math.max(0, end - lookback);
|
|
17
34
|
|
|
18
35
|
let min = Infinity;
|
|
19
36
|
let max = -Infinity;
|
|
20
|
-
for (let i = start; i <
|
|
37
|
+
for (let i = start; i < end; i++) {
|
|
21
38
|
const v = points[i].value;
|
|
22
39
|
if (v < min) min = v;
|
|
23
40
|
if (v > max) max = v;
|
|
@@ -25,9 +42,9 @@ export function detectMomentum(
|
|
|
25
42
|
const range = max - min;
|
|
26
43
|
if (range === 0) return "flat";
|
|
27
44
|
|
|
28
|
-
const tailStart = Math.max(start,
|
|
45
|
+
const tailStart = Math.max(start, end - 5);
|
|
29
46
|
const first = points[tailStart].value;
|
|
30
|
-
const last = points[
|
|
47
|
+
const last = points[end - 1].value;
|
|
31
48
|
const delta = last - first;
|
|
32
49
|
|
|
33
50
|
const absThreshold = range * threshold;
|
|
@@ -44,6 +44,44 @@ export function collectReferenceValues(lines: ReferenceLine[]): number[] {
|
|
|
44
44
|
return out;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Stable React keys for an ordered reference-line list. A caller-supplied `id`
|
|
49
|
+
* keeps the key stable across reordering; the visual config is a deterministic
|
|
50
|
+
* fallback for legacy lines. Equal fallback signatures receive a suffix so they
|
|
51
|
+
* remain distinct siblings.
|
|
52
|
+
*/
|
|
53
|
+
export function referenceLineReactKeys(
|
|
54
|
+
lines: readonly ReferenceLine[],
|
|
55
|
+
): string[] {
|
|
56
|
+
const occurrences = new Map<string, number>();
|
|
57
|
+
const keys: string[] = [];
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const base = line.id ?? stableReferenceLineSignature(line);
|
|
60
|
+
const occurrence = occurrences.get(base) ?? 0;
|
|
61
|
+
occurrences.set(base, occurrence + 1);
|
|
62
|
+
keys.push(`${base}:${occurrence}`);
|
|
63
|
+
}
|
|
64
|
+
return keys;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Serializes render-relevant config without callback identity churn. */
|
|
68
|
+
function stableReferenceLineSignature(value: unknown): string {
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
return `[${value.map(stableReferenceLineSignature).join(",")}]`;
|
|
71
|
+
}
|
|
72
|
+
if (value !== null && typeof value === "object") {
|
|
73
|
+
const record = value as Record<string, unknown>;
|
|
74
|
+
const entries: string[] = [];
|
|
75
|
+
for (const key of Object.keys(record).sort()) {
|
|
76
|
+
const entry = record[key];
|
|
77
|
+
if (typeof entry === "function" || entry === undefined) continue;
|
|
78
|
+
entries.push(`${key}:${stableReferenceLineSignature(entry)}`);
|
|
79
|
+
}
|
|
80
|
+
return `{${entries.join(",")}}`;
|
|
81
|
+
}
|
|
82
|
+
return JSON.stringify(value) ?? "undefined";
|
|
83
|
+
}
|
|
84
|
+
|
|
47
85
|
/**
|
|
48
86
|
* Fully-resolved badge **style/shape** + anchor — the resolved counterpart of
|
|
49
87
|
* {@link BadgeStyleConfig} (`position` / `icon` / `showText` plus the style knobs).
|