react-native-livechart 4.17.0 → 4.19.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/CrosshairOverlay.d.ts +3 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +10 -2
- 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/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/MultiSeriesDots.d.ts +5 -3
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +20 -3
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/draw/candle.d.ts +21 -0
- package/dist/draw/candle.d.ts.map +1 -1
- package/dist/draw/line.d.ts +9 -3
- package/dist/draw/line.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +8 -0
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/resolveChartLayout.d.ts +12 -1
- package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
- package/dist/hooks/useCandlePaths.d.ts +3 -1
- package/dist/hooks/useCandlePaths.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts +3 -1
- 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/useDegen.d.ts +1 -3
- package/dist/hooks/useDegen.d.ts.map +1 -1
- package/dist/hooks/useLiveDot.d.ts +8 -2
- package/dist/hooks/useLiveDot.d.ts.map +1 -1
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/hooks/useMultiSeriesLinePaths.d.ts +3 -1
- package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
- package/dist/hooks/useSeriesIndicatorOpacity.d.ts +13 -0
- package/dist/hooks/useSeriesIndicatorOpacity.d.ts.map +1 -0
- package/dist/hooks/useTradeStream.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/markerCluster.d.ts +6 -0
- package/dist/math/markerCluster.d.ts.map +1 -1
- package/dist/math/simplify.d.ts +20 -0
- package/dist/math/simplify.d.ts.map +1 -0
- package/dist/types.d.ts +106 -17
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairOverlay.tsx +14 -9
- package/src/components/CustomMarkerOverlay.tsx +5 -1
- package/src/components/DotOverlay.tsx +26 -3
- package/src/components/LiveChart.tsx +210 -55
- package/src/components/LiveChartSeries.tsx +28 -10
- package/src/components/MarkerOverlay.tsx +5 -1
- package/src/components/MultiSeriesDots.tsx +22 -3
- package/src/constants.ts +7 -0
- package/src/core/resolveConfig.ts +67 -13
- package/src/core/useLiveChartEngine.ts +6 -1
- package/src/draw/candle.ts +78 -0
- package/src/draw/line.ts +24 -10
- package/src/hooks/crosshairShared.ts +31 -0
- package/src/hooks/resolveChartLayout.ts +40 -16
- package/src/hooks/useCandlePaths.ts +9 -1
- package/src/hooks/useChartPaths.ts +37 -7
- package/src/hooks/useCrosshair.ts +29 -2
- package/src/hooks/useDegen.ts +2 -6
- package/src/hooks/useLiveDot.ts +28 -3
- package/src/hooks/useMarkers.ts +11 -3
- package/src/hooks/useMultiSeriesLinePaths.ts +31 -4
- package/src/hooks/useSeriesIndicatorOpacity.ts +56 -0
- package/src/hooks/useTradeStream.ts +6 -1
- package/src/index.ts +1 -0
- package/src/math/markerCluster.ts +15 -1
- package/src/math/simplify.ts +197 -0
- package/src/types.ts +107 -17
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
SCRUB_ACTIVATE_X_PX,
|
|
38
38
|
SCRUB_FAIL_Y_PX,
|
|
39
39
|
snapPrice,
|
|
40
|
+
snapScrubXToCandleCenter,
|
|
40
41
|
startPlainScrub,
|
|
41
42
|
type CrosshairState,
|
|
42
43
|
updatePlainScrub,
|
|
@@ -157,6 +158,12 @@ export function useCrosshair(
|
|
|
157
158
|
* Default `false`.
|
|
158
159
|
*/
|
|
159
160
|
clampToPlot = false,
|
|
161
|
+
/**
|
|
162
|
+
* Candle mode: quantize the plain-scrub X to the hovered candle's center
|
|
163
|
+
* before it enters `scrubX` (`scrub.snapToCandles`). No-op in line mode.
|
|
164
|
+
* Default `false`.
|
|
165
|
+
*/
|
|
166
|
+
snapToCandles = false,
|
|
160
167
|
): CrosshairState {
|
|
161
168
|
const scrubX = useSharedValue(-1);
|
|
162
169
|
const scrubActive = useSharedValue(false);
|
|
@@ -519,6 +526,26 @@ export function useCrosshair(
|
|
|
519
526
|
? SCRUB_ACTION_PRESS_HOLD_MS
|
|
520
527
|
: panGestureDelay;
|
|
521
528
|
|
|
529
|
+
// `scrub.snapToCandles`: quantize the scrub X to the hovered candle's center
|
|
530
|
+
// before it enters `scrubX`, so the crosshair — and everything derived from
|
|
531
|
+
// it (time, tooltip, dim edge) — jumps candle-to-candle instead of gliding.
|
|
532
|
+
// Line mode and gaps between candles pass the raw X through.
|
|
533
|
+
/* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
|
|
534
|
+
const snapCandleX = (x: number): number => {
|
|
535
|
+
"worklet";
|
|
536
|
+
if (!snapToCandles || !isCandleMode || !candlesSV) return x;
|
|
537
|
+
return snapScrubXToCandleCenter(
|
|
538
|
+
x,
|
|
539
|
+
candlesSV.get(),
|
|
540
|
+
liveCandleSV?.get() ?? null,
|
|
541
|
+
candleWidthSecs,
|
|
542
|
+
padding,
|
|
543
|
+
engine.canvasWidth.get(),
|
|
544
|
+
engine.timestamp.get(),
|
|
545
|
+
engine.displayWindow.get(),
|
|
546
|
+
);
|
|
547
|
+
};
|
|
548
|
+
|
|
522
549
|
let gesture = Gesture.Pan()
|
|
523
550
|
.maxPointers(1)
|
|
524
551
|
.shouldCancelWhenOutside(false)
|
|
@@ -618,7 +645,7 @@ export function useCrosshair(
|
|
|
618
645
|
// guard needed. (Plain-scrub counterpart of the scrub-action tap defer.)
|
|
619
646
|
if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
|
|
620
647
|
startPlainScrub(
|
|
621
|
-
e.x,
|
|
648
|
+
snapCandleX(e.x),
|
|
622
649
|
padding,
|
|
623
650
|
engine.canvasWidth.get(),
|
|
624
651
|
clampPlainScrubToPlot,
|
|
@@ -653,7 +680,7 @@ export function useCrosshair(
|
|
|
653
680
|
return;
|
|
654
681
|
}
|
|
655
682
|
updatePlainScrub(
|
|
656
|
-
e.x,
|
|
683
|
+
snapCandleX(e.x),
|
|
657
684
|
padding,
|
|
658
685
|
engine.canvasWidth.get(),
|
|
659
686
|
clampPlainScrubToPlot,
|
package/src/hooks/useDegen.ts
CHANGED
|
@@ -20,8 +20,6 @@ export function useDegen(
|
|
|
20
20
|
momentumSV: SharedValue<Momentum>,
|
|
21
21
|
cfg: ResolvedDegenConfig | null,
|
|
22
22
|
onShake?: (payload: DegenShakePayload) => void,
|
|
23
|
-
/** Static charts run no loops: register the frame callback without starting it. */
|
|
24
|
-
isStatic = false,
|
|
25
23
|
): {
|
|
26
24
|
pack: SharedValue<Float64Array<ArrayBuffer>>;
|
|
27
25
|
packRevision: SharedValue<number>;
|
|
@@ -154,6 +152,8 @@ export function useDegen(
|
|
|
154
152
|
speedMaxSV,
|
|
155
153
|
]);
|
|
156
154
|
|
|
155
|
+
// ChartWithDegen only mounts while the resolved effect is enabled; static
|
|
156
|
+
// charts force that config to null and unmount this callback entirely.
|
|
157
157
|
useFrameCallback(
|
|
158
158
|
/* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
|
|
159
159
|
"worklet";
|
|
@@ -250,10 +250,6 @@ export function useDegen(
|
|
|
250
250
|
}
|
|
251
251
|
prevActiveCount.set(activeCount);
|
|
252
252
|
},
|
|
253
|
-
// Static charts register the callback but never start it, so no degen frame
|
|
254
|
-
// loop runs. Live charts keep the default (autostart) loop unchanged. The
|
|
255
|
-
// controller also forces `cfg` to null in static, so degen is doubly off.
|
|
256
|
-
!isStatic,
|
|
257
253
|
);
|
|
258
254
|
|
|
259
255
|
const shakeTransform = useDerivedValue(() => {
|
package/src/hooks/useLiveDot.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ChartEngineScroll,
|
|
4
|
+
SingleEngineState,
|
|
5
|
+
} from "../core/useLiveChartEngine";
|
|
3
6
|
import type { ChartPadding } from "../draw/line";
|
|
4
7
|
|
|
5
8
|
/**
|
|
@@ -10,17 +13,39 @@ import type { ChartPadding } from "../draw/line";
|
|
|
10
13
|
* With `followViewEdge` + `edgeValue`, the dot (and the value line that shares
|
|
11
14
|
* `dotY`) tracks the visible window's right-edge price while scrolled back, so it
|
|
12
15
|
* stays aligned with a `followViewEdge` badge instead of marking the live value.
|
|
16
|
+
*
|
|
17
|
+
* With `trackWhileParked` (`dot.trackWhileParked`), the dot instead tracks the
|
|
18
|
+
* **true live point's x** while scrolled back / overscrolled (`viewEnd`
|
|
19
|
+
* frozen), and hides once the live point leaves the visible window.
|
|
20
|
+
* `followViewEdge` wins when both are set — an edge-pinned dot must stay
|
|
21
|
+
* aligned with its badge.
|
|
13
22
|
*/
|
|
14
23
|
export function useLiveDot(
|
|
15
|
-
engine: SingleEngineState,
|
|
24
|
+
engine: SingleEngineState & ChartEngineScroll,
|
|
16
25
|
padding: ChartPadding,
|
|
17
26
|
edgeValue?: SharedValue<number>,
|
|
18
27
|
followViewEdge = false,
|
|
28
|
+
trackWhileParked = false,
|
|
19
29
|
) {
|
|
20
30
|
const dotX = useDerivedValue(() => {
|
|
21
31
|
const w = engine.canvasWidth.value;
|
|
22
32
|
if (w === 0) return -100;
|
|
23
|
-
|
|
33
|
+
const right = w - padding.right;
|
|
34
|
+
// While parked (scrolled back / overscrolled) the live point is not at the
|
|
35
|
+
// plot edge — track its real x so a pan doesn't lose the dot, and hide it
|
|
36
|
+
// only once the point leaves the window.
|
|
37
|
+
if (trackWhileParked && !followViewEdge && engine.viewEnd.value != null) {
|
|
38
|
+
const data = engine.data.value;
|
|
39
|
+
const last = data[data.length - 1];
|
|
40
|
+
const win = engine.displayWindow.value;
|
|
41
|
+
const chartW = w - padding.left - padding.right;
|
|
42
|
+
if (!last || win <= 0 || chartW <= 0) return -100;
|
|
43
|
+
const x =
|
|
44
|
+
padding.left +
|
|
45
|
+
((last.time - (engine.timestamp.value - win)) / win) * chartW;
|
|
46
|
+
return x < padding.left || x > right ? -100 : x;
|
|
47
|
+
}
|
|
48
|
+
return right;
|
|
24
49
|
});
|
|
25
50
|
|
|
26
51
|
const dotY = useDerivedValue(() => {
|
package/src/hooks/useMarkers.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef } from "react";
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
2
|
import { Gesture } from "react-native-gesture-handler";
|
|
3
3
|
import {
|
|
4
4
|
useFrameCallback,
|
|
@@ -77,7 +77,7 @@ export function useMarkers(
|
|
|
77
77
|
onMarkerPress?.(event);
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
-
useFrameCallback(
|
|
80
|
+
const markerFrameCallback = useFrameCallback(
|
|
81
81
|
/* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
|
|
82
82
|
"worklet";
|
|
83
83
|
const cache = cacheRef.current!;
|
|
@@ -102,11 +102,19 @@ export function useMarkers(
|
|
|
102
102
|
lineData: lineData?.get(),
|
|
103
103
|
lineLinear,
|
|
104
104
|
});
|
|
105
|
-
clusterMarkers(markers.get(), buf, {
|
|
105
|
+
clusterMarkers(markers.get(), buf, {
|
|
106
|
+
config: cluster,
|
|
107
|
+
minY: 0,
|
|
108
|
+
maxY: engine.canvasHeight.get(),
|
|
109
|
+
});
|
|
106
110
|
projected.set(buf);
|
|
107
111
|
},
|
|
108
112
|
autostart,
|
|
109
113
|
);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
// Reanimated does not reactively apply later `autostart` values.
|
|
116
|
+
markerFrameCallback.setActive(autostart);
|
|
117
|
+
}, [autostart, markerFrameCallback]);
|
|
110
118
|
|
|
111
119
|
const tapGesture = Gesture.Tap().onEnd(
|
|
112
120
|
/* istanbul ignore next -- gesture worklet runs on UI thread, not in Jest */ (
|
|
@@ -4,6 +4,10 @@ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
|
4
4
|
import { MAX_MULTI_SERIES } from "../constants";
|
|
5
5
|
import type { MultiEngineState } from "../core/useLiveChartEngine";
|
|
6
6
|
import { buildLinePoints, type ChartPadding } from "../draw/line";
|
|
7
|
+
import {
|
|
8
|
+
makeLineSimplifyScratch,
|
|
9
|
+
simplifyLinePoints,
|
|
10
|
+
} from "../math/simplify";
|
|
7
11
|
import { drawSpline, makeSplineScratch } from "../math/spline";
|
|
8
12
|
import { usePathBuilders } from "./usePathBuilder";
|
|
9
13
|
|
|
@@ -21,25 +25,31 @@ export function useMultiSeriesLinePaths(
|
|
|
21
25
|
engine: MultiEngineState,
|
|
22
26
|
padding: ChartPadding,
|
|
23
27
|
activeSeriesCount = MAX_MULTI_SERIES,
|
|
28
|
+
/** Shared screen-space simplification tolerance; a series can override it. */
|
|
29
|
+
simplifyTolerance = 0,
|
|
24
30
|
): SharedValue<SkPath[]> {
|
|
25
31
|
const builders = usePathBuilders(MAX_MULTI_SERIES);
|
|
26
32
|
|
|
27
33
|
const poolRef = useRef<{
|
|
28
34
|
empty: SkPath;
|
|
29
35
|
ptsBuf: number[];
|
|
36
|
+
simplifiedPts: number[];
|
|
30
37
|
pathsA: SkPath[];
|
|
31
38
|
pathsB: SkPath[];
|
|
32
39
|
pathsTick: boolean;
|
|
33
40
|
scratch: ReturnType<typeof makeSplineScratch>;
|
|
41
|
+
simplifyScratch: ReturnType<typeof makeLineSimplifyScratch>;
|
|
34
42
|
} | null>(null);
|
|
35
43
|
if (poolRef.current === null) {
|
|
36
44
|
poolRef.current = {
|
|
37
45
|
empty: Skia.Path.Make(),
|
|
38
46
|
ptsBuf: [] as number[],
|
|
47
|
+
simplifiedPts: [] as number[],
|
|
39
48
|
pathsA: [] as SkPath[],
|
|
40
49
|
pathsB: [] as SkPath[],
|
|
41
50
|
pathsTick: false,
|
|
42
51
|
scratch: makeSplineScratch(),
|
|
52
|
+
simplifyScratch: makeLineSimplifyScratch(),
|
|
43
53
|
};
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -52,19 +62,36 @@ export function useMultiSeriesLinePaths(
|
|
|
52
62
|
const out = pool.pathsTick ? pool.pathsA : pool.pathsB;
|
|
53
63
|
out.length = 0;
|
|
54
64
|
const count = Math.min(activeSeriesCount, s.length, MAX_MULTI_SERIES);
|
|
65
|
+
const now = engine.timestamp.get();
|
|
66
|
+
const windowSecs = engine.displayWindow.get();
|
|
67
|
+
const canvasWidth = engine.canvasWidth.get();
|
|
68
|
+
const chartWidth = canvasWidth - padding.left - padding.right;
|
|
69
|
+
const absoluteXOffset =
|
|
70
|
+
windowSecs > 0 ? (now - windowSecs) * (chartWidth / windowSecs) : 0;
|
|
55
71
|
for (let i = 0; i < count; i++) {
|
|
56
|
-
const
|
|
72
|
+
const rawPts = buildLinePoints(
|
|
57
73
|
s[i].data,
|
|
58
74
|
displays[i] ?? s[i].value,
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
now,
|
|
76
|
+
windowSecs,
|
|
61
77
|
engine.displayMin.get(),
|
|
62
78
|
engine.displayMax.get(),
|
|
63
|
-
|
|
79
|
+
canvasWidth,
|
|
64
80
|
engine.canvasHeight.get(),
|
|
65
81
|
padding,
|
|
66
82
|
pool.ptsBuf,
|
|
67
83
|
);
|
|
84
|
+
const seriesTolerance = s[i].simplify ?? simplifyTolerance;
|
|
85
|
+
const pts =
|
|
86
|
+
Number.isFinite(seriesTolerance) && seriesTolerance > 0
|
|
87
|
+
? simplifyLinePoints(
|
|
88
|
+
rawPts,
|
|
89
|
+
seriesTolerance,
|
|
90
|
+
pool.simplifiedPts,
|
|
91
|
+
pool.simplifyScratch,
|
|
92
|
+
absoluteXOffset,
|
|
93
|
+
)
|
|
94
|
+
: rawPts;
|
|
68
95
|
const n = pts.length >> 1;
|
|
69
96
|
if (n >= 2) {
|
|
70
97
|
const b = slots[i];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Easing,
|
|
3
|
+
useAnimatedReaction,
|
|
4
|
+
useSharedValue,
|
|
5
|
+
withTiming,
|
|
6
|
+
type SharedValue,
|
|
7
|
+
} from "react-native-reanimated";
|
|
8
|
+
|
|
9
|
+
/** Short independent fade used when replacement data dims the plotted series. */
|
|
10
|
+
export const SERIES_INDICATOR_FADE_MS = 180;
|
|
11
|
+
|
|
12
|
+
/** Treat tiny animation residue at the top end as fully restored. */
|
|
13
|
+
const SERIES_OPACITY_VISIBLE_EPSILON = 0.001;
|
|
14
|
+
|
|
15
|
+
/** Worklet-safe target for live dots and their pulse rings. */
|
|
16
|
+
export function seriesIndicatorOpacityTarget(seriesOpacity: number): 0 | 1 {
|
|
17
|
+
"worklet";
|
|
18
|
+
return seriesOpacity >= 1 - SERIES_OPACITY_VISIBLE_EPSILON ? 1 : 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fades live dots and pulse rings away while `seriesOpacity` dims replacement
|
|
23
|
+
* data, then restores them once the plotted series is fully opaque again.
|
|
24
|
+
* Keeping this as a separate opacity avoids compositing a translucent dot over
|
|
25
|
+
* its own line, where the stroke would otherwise shine through.
|
|
26
|
+
*/
|
|
27
|
+
export function useSeriesIndicatorOpacity(
|
|
28
|
+
seriesOpacity: SharedValue<number>,
|
|
29
|
+
duration: number = SERIES_INDICATOR_FADE_MS,
|
|
30
|
+
): SharedValue<number> {
|
|
31
|
+
// Fully visible is the common mount state. The reaction snaps an initially
|
|
32
|
+
// dimmed series to 0 on its first UI-thread run so it never flashes a dot.
|
|
33
|
+
const opacity = useSharedValue(1);
|
|
34
|
+
|
|
35
|
+
useAnimatedReaction(
|
|
36
|
+
() => seriesIndicatorOpacityTarget(seriesOpacity.get()),
|
|
37
|
+
(target, previous) => {
|
|
38
|
+
"worklet";
|
|
39
|
+
if (previous === null || previous === undefined || duration === 0) {
|
|
40
|
+
opacity.set(target);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (target !== previous) {
|
|
44
|
+
opacity.set(
|
|
45
|
+
withTiming(target, {
|
|
46
|
+
duration,
|
|
47
|
+
easing: Easing.inOut(Easing.ease),
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
[seriesOpacity, duration],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return opacity;
|
|
56
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
1
2
|
import {
|
|
2
3
|
useFrameCallback,
|
|
3
4
|
useSharedValue,
|
|
@@ -31,7 +32,7 @@ export function useTradeStream(
|
|
|
31
32
|
const markers = useSharedValue<TradeMarker[]>([]);
|
|
32
33
|
const state = useSharedValue<TradeStreamState>(createTradeStreamState());
|
|
33
34
|
|
|
34
|
-
useFrameCallback(
|
|
35
|
+
const tradeFrameCallback = useFrameCallback(
|
|
35
36
|
/* istanbul ignore next -- worklet runs on UI thread, not in Jest */ (
|
|
36
37
|
frameInfo,
|
|
37
38
|
) => {
|
|
@@ -62,6 +63,10 @@ export function useTradeStream(
|
|
|
62
63
|
},
|
|
63
64
|
autostart,
|
|
64
65
|
);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
// Reanimated treats `autostart` as mount-time state; synchronize changes.
|
|
68
|
+
tradeFrameCallback.setActive(autostart);
|
|
69
|
+
}, [autostart, tradeFrameCallback]);
|
|
65
70
|
|
|
66
71
|
return markers;
|
|
67
72
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface ResolvedMarkerCluster {
|
|
|
34
34
|
|
|
35
35
|
export interface ClusterMarkersOpts {
|
|
36
36
|
config: ResolvedMarkerCluster;
|
|
37
|
+
/** Canvas-space y bounds (typically `0` / canvas height). When set, a
|
|
38
|
+
* `"vertical"` column is additionally capped where the next glyph would
|
|
39
|
+
* cross a bound, so a tall stack can't climb off the chart when its anchor
|
|
40
|
+
* is already near the edge. The base slot always draws. */
|
|
41
|
+
minY?: number;
|
|
42
|
+
maxY?: number;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/** Glyph box used when `marker.size` is unset — mirrors `markerAtlas.DEFAULT_ICON_SIZE`. */
|
|
@@ -138,7 +144,15 @@ function layoutBucket(
|
|
|
138
144
|
// `maxVisible` caps the column: the oldest glyphs keep their slots and the
|
|
139
145
|
// newest overflow is simply hidden.
|
|
140
146
|
const dir = side === "below" ? 1 : -1;
|
|
141
|
-
|
|
147
|
+
let cap = opts.config.maxVisible;
|
|
148
|
+
// Bounds clamp: slot j sits at `base + dir * j * step`; keep only slots
|
|
149
|
+
// whose glyph box stays inside [minY, maxY].
|
|
150
|
+
if (opts.minY !== undefined && opts.maxY !== undefined && step > 0) {
|
|
151
|
+
const base = anchorY + sideDy;
|
|
152
|
+
const room = dir === -1 ? base - h / 2 - opts.minY : opts.maxY - h / 2 - base;
|
|
153
|
+
const fit = 1 + Math.floor(room / step);
|
|
154
|
+
if (fit < cap) cap = Math.max(1, fit);
|
|
155
|
+
}
|
|
142
156
|
for (let j = 0; j < count; j++) {
|
|
143
157
|
const p = proj[idx[s + j]];
|
|
144
158
|
if (j >= cap) {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/** Reusable buffers for {@link simplifyLinePoints}. */
|
|
2
|
+
export interface LineSimplifyScratch {
|
|
3
|
+
keep: number[];
|
|
4
|
+
stack: number[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Allocate simplification scratch once, outside the per-frame worklet. */
|
|
8
|
+
export function makeLineSimplifyScratch(): LineSimplifyScratch {
|
|
9
|
+
return { keep: [], stack: [] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Maximum number of points simplified as one Ramer-Douglas-Peucker range.
|
|
14
|
+
*
|
|
15
|
+
* The visible line is already bounded to roughly two points per horizontal
|
|
16
|
+
* pixel. Splitting that bounded input into overlapping ranges also bounds the
|
|
17
|
+
* simplifier's worst-case work: a pathological zig-zag cannot turn a frame into
|
|
18
|
+
* an O(n²) scan. Range endpoints are retained, so chunking can only keep a few
|
|
19
|
+
* extra points; it cannot erase additional structure.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_RDP_RANGE_POINTS = 64;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Width of each RDP range on the stable, absolute screen-pixel grid.
|
|
25
|
+
*
|
|
26
|
+
* Dense line input contains at most two representatives per pixel bucket. A
|
|
27
|
+
* 30 px range therefore stays within the 64-point work bound even when the
|
|
28
|
+
* decimator and simplifier grids have different phases. Fixed-width ranges
|
|
29
|
+
* also keep their membership stable as the viewport advances.
|
|
30
|
+
*/
|
|
31
|
+
const RDP_RANGE_WIDTH_PX = 30;
|
|
32
|
+
|
|
33
|
+
function pointSegmentDistanceSq(
|
|
34
|
+
px: number,
|
|
35
|
+
py: number,
|
|
36
|
+
ax: number,
|
|
37
|
+
ay: number,
|
|
38
|
+
bx: number,
|
|
39
|
+
by: number,
|
|
40
|
+
): number {
|
|
41
|
+
"worklet";
|
|
42
|
+
const dx = bx - ax;
|
|
43
|
+
const dy = by - ay;
|
|
44
|
+
const lengthSq = dx * dx + dy * dy;
|
|
45
|
+
if (lengthSq === 0) {
|
|
46
|
+
const ex = px - ax;
|
|
47
|
+
const ey = py - ay;
|
|
48
|
+
return ex * ex + ey * ey;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let t = ((px - ax) * dx + (py - ay) * dy) / lengthSq;
|
|
52
|
+
if (t < 0) t = 0;
|
|
53
|
+
else if (t > 1) t = 1;
|
|
54
|
+
const ex = px - (ax + t * dx);
|
|
55
|
+
const ey = py - (ay + t * dy);
|
|
56
|
+
return ex * ex + ey * ey;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function simplifyRdpGroup(
|
|
60
|
+
points: number[],
|
|
61
|
+
keep: number[],
|
|
62
|
+
stack: number[],
|
|
63
|
+
toleranceSq: number,
|
|
64
|
+
rangeStart: number,
|
|
65
|
+
rangeEnd: number,
|
|
66
|
+
): void {
|
|
67
|
+
"worklet";
|
|
68
|
+
if (rangeStart === rangeEnd) {
|
|
69
|
+
keep[rangeStart] = 1;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Oversized fixed-X groups are split into overlapping subranges. The group
|
|
74
|
+
// contents are themselves stable, so this point-count guard cannot move
|
|
75
|
+
// until that absolute group reaches a viewport edge.
|
|
76
|
+
for (let chunkStart = rangeStart; chunkStart < rangeEnd; ) {
|
|
77
|
+
const chunkEnd = Math.min(
|
|
78
|
+
chunkStart + MAX_RDP_RANGE_POINTS - 1,
|
|
79
|
+
rangeEnd,
|
|
80
|
+
);
|
|
81
|
+
keep[chunkStart] = 1;
|
|
82
|
+
keep[chunkEnd] = 1;
|
|
83
|
+
stack.push(chunkStart, chunkEnd);
|
|
84
|
+
|
|
85
|
+
while (stack.length >= 2) {
|
|
86
|
+
const end = stack.pop()!;
|
|
87
|
+
const start = stack.pop()!;
|
|
88
|
+
if (end <= start + 1) continue;
|
|
89
|
+
|
|
90
|
+
const ax = points[start * 2];
|
|
91
|
+
const ay = points[start * 2 + 1];
|
|
92
|
+
const bx = points[end * 2];
|
|
93
|
+
const by = points[end * 2 + 1];
|
|
94
|
+
let furthest = -1;
|
|
95
|
+
let maxDistanceSq = toleranceSq;
|
|
96
|
+
|
|
97
|
+
for (let i = start + 1; i < end; i++) {
|
|
98
|
+
const distanceSq = pointSegmentDistanceSq(
|
|
99
|
+
points[i * 2],
|
|
100
|
+
points[i * 2 + 1],
|
|
101
|
+
ax,
|
|
102
|
+
ay,
|
|
103
|
+
bx,
|
|
104
|
+
by,
|
|
105
|
+
);
|
|
106
|
+
if (distanceSq > maxDistanceSq) {
|
|
107
|
+
maxDistanceSq = distanceSq;
|
|
108
|
+
furthest = i;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (furthest >= 0) {
|
|
113
|
+
keep[furthest] = 1;
|
|
114
|
+
stack.push(start, furthest, furthest, end);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
chunkStart = chunkEnd;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Simplify flat screen-space points (`[x0, y0, x1, y1, ...]`) with a bounded
|
|
124
|
+
* Ramer-Douglas-Peucker pass.
|
|
125
|
+
*
|
|
126
|
+
* `tolerance` is the maximum removable deviation in pixels. The first and last
|
|
127
|
+
* point, larger peaks/valleys, and the original point order are retained. Pass
|
|
128
|
+
* distinct reusable `out` and `scratch` buffers to avoid per-frame allocation.
|
|
129
|
+
* `absoluteXOffset` maps the viewport-relative X coordinates back onto a fixed
|
|
130
|
+
* screen-pixel grid; live chart callers should pass `winStart * xScale` so
|
|
131
|
+
* historical range membership does not change as the window advances.
|
|
132
|
+
*/
|
|
133
|
+
export function simplifyLinePoints(
|
|
134
|
+
points: number[],
|
|
135
|
+
tolerance: number,
|
|
136
|
+
out: number[],
|
|
137
|
+
scratch: LineSimplifyScratch,
|
|
138
|
+
absoluteXOffset = 0,
|
|
139
|
+
): number[] {
|
|
140
|
+
"worklet";
|
|
141
|
+
if (out === points) return points;
|
|
142
|
+
|
|
143
|
+
out.length = 0;
|
|
144
|
+
const count = points.length >> 1;
|
|
145
|
+
if (!(tolerance > 0) || !Number.isFinite(tolerance) || count <= 2) {
|
|
146
|
+
for (let i = 0; i < count * 2; i++) out.push(points[i]);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const keep = scratch.keep;
|
|
151
|
+
keep.length = count;
|
|
152
|
+
for (let i = 0; i < count; i++) keep[i] = 0;
|
|
153
|
+
|
|
154
|
+
const stack = scratch.stack;
|
|
155
|
+
stack.length = 0;
|
|
156
|
+
const toleranceSq = tolerance * tolerance;
|
|
157
|
+
const stableXOffset = Number.isFinite(absoluteXOffset) ? absoluteXOffset : 0;
|
|
158
|
+
|
|
159
|
+
// Partition on a fixed absolute-X grid rather than by the first visible
|
|
160
|
+
// point's array index. Dropping a point from the left edge then affects only
|
|
161
|
+
// that edge range; fully visible historical ranges keep identical endpoints
|
|
162
|
+
// and RDP choices while translating across the viewport.
|
|
163
|
+
let groupStart = 0;
|
|
164
|
+
let groupKey = Math.floor(
|
|
165
|
+
(points[0] + stableXOffset) / RDP_RANGE_WIDTH_PX,
|
|
166
|
+
);
|
|
167
|
+
for (let i = 1; i < count; i++) {
|
|
168
|
+
const nextKey = Math.floor(
|
|
169
|
+
(points[i * 2] + stableXOffset) / RDP_RANGE_WIDTH_PX,
|
|
170
|
+
);
|
|
171
|
+
if (nextKey !== groupKey) {
|
|
172
|
+
simplifyRdpGroup(
|
|
173
|
+
points,
|
|
174
|
+
keep,
|
|
175
|
+
stack,
|
|
176
|
+
toleranceSq,
|
|
177
|
+
groupStart,
|
|
178
|
+
i - 1,
|
|
179
|
+
);
|
|
180
|
+
groupStart = i;
|
|
181
|
+
groupKey = nextKey;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
simplifyRdpGroup(
|
|
185
|
+
points,
|
|
186
|
+
keep,
|
|
187
|
+
stack,
|
|
188
|
+
toleranceSq,
|
|
189
|
+
groupStart,
|
|
190
|
+
count - 1,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
for (let i = 0; i < count; i++) {
|
|
194
|
+
if (keep[i] === 1) out.push(points[i * 2], points[i * 2 + 1]);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|