react-native-livechart 4.16.0 → 4.18.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/CustomMarkerOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +7 -1
- 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/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +9 -0
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/draw/line.d.ts +2 -2
- 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 +4 -1
- package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
- package/dist/hooks/useCandlePaths.d.ts +22 -11
- package/dist/hooks/useCandlePaths.d.ts.map +1 -1
- package/dist/hooks/useChartReveal.d.ts +3 -2
- package/dist/hooks/useChartReveal.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/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/usePanScroll.d.ts +8 -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/markerCluster.d.ts +9 -0
- package/dist/math/markerCluster.d.ts.map +1 -1
- package/dist/types.d.ts +71 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CustomMarkerOverlay.tsx +5 -1
- package/src/components/DotOverlay.tsx +9 -2
- package/src/components/LiveChart.tsx +128 -17
- package/src/components/LiveChartSeries.tsx +4 -0
- package/src/components/MarkerOverlay.tsx +5 -1
- package/src/core/liveChartEngineTick.ts +5 -1
- package/src/core/liveChartSeriesEngineTick.ts +5 -1
- package/src/core/resolveConfig.ts +20 -0
- package/src/core/useLiveChartEngine.ts +6 -2
- package/src/core/useLiveChartSeriesEngine.ts +4 -2
- package/src/draw/line.ts +4 -3
- package/src/hooks/crosshairShared.ts +31 -0
- package/src/hooks/resolveChartLayout.ts +14 -2
- package/src/hooks/useCandlePaths.ts +41 -27
- package/src/hooks/useChartReveal.ts +11 -3
- package/src/hooks/useCrosshair.ts +29 -2
- package/src/hooks/useLiveDot.ts +28 -3
- package/src/hooks/useMarkers.ts +6 -1
- package/src/hooks/usePanScroll.ts +12 -1
- package/src/index.ts +1 -0
- package/src/math/markerCluster.ts +28 -0
- package/src/types.ts +72 -2
|
@@ -388,7 +388,11 @@ export function MarkerOverlay({
|
|
|
388
388
|
lineData: lineData?.get(),
|
|
389
389
|
lineLinear,
|
|
390
390
|
});
|
|
391
|
-
clusterMarkers(ms, buf, {
|
|
391
|
+
clusterMarkers(ms, buf, {
|
|
392
|
+
config: cluster,
|
|
393
|
+
minY: 0,
|
|
394
|
+
maxY: engine.canvasHeight.get(),
|
|
395
|
+
});
|
|
392
396
|
const atlasFrames = atlasFrameRef.current!;
|
|
393
397
|
atlasFrames.tick = !atlasFrames.tick;
|
|
394
398
|
const frame = atlasFrames.tick ? atlasFrames.a : atlasFrames.b;
|
|
@@ -371,7 +371,11 @@ export function tickLiveChartEngineFrame(
|
|
|
371
371
|
: 1;
|
|
372
372
|
if (yScale !== 1) {
|
|
373
373
|
const scaledMid = (tMin + tMax) / 2;
|
|
374
|
-
|
|
374
|
+
let scaledHalf = ((tMax - tMin) / 2) * yScale;
|
|
375
|
+
// On non-negative charts, cap the zoom-out at the point where the floor
|
|
376
|
+
// reaches 0. Letting it grow and clamping min afterwards keeps only the
|
|
377
|
+
// top expanding, which pins the data to the bottom of the plot.
|
|
378
|
+
if (input.nonNegative && scaledHalf > scaledMid) scaledHalf = scaledMid;
|
|
375
379
|
const scaledMin = scaledMid - scaledHalf;
|
|
376
380
|
const scaledMax = scaledMid + scaledHalf;
|
|
377
381
|
// Reject multiplication overflow and subnormal scales that round the two
|
|
@@ -305,7 +305,11 @@ export function tickLiveChartSeriesEngineFrame(
|
|
|
305
305
|
: 1;
|
|
306
306
|
if (yScale !== 1) {
|
|
307
307
|
const scaledMid = (tMin + tMax) / 2;
|
|
308
|
-
|
|
308
|
+
let scaledHalf = ((tMax - tMin) / 2) * yScale;
|
|
309
|
+
// On non-negative charts, cap the zoom-out at the point where the floor
|
|
310
|
+
// reaches 0. Letting it grow and clamping min afterwards keeps only the
|
|
311
|
+
// top expanding, which pins the data to the bottom of the plot.
|
|
312
|
+
if (input.nonNegative && scaledHalf > scaledMid) scaledHalf = scaledMid;
|
|
309
313
|
const scaledMin = scaledMid - scaledHalf;
|
|
310
314
|
const scaledMax = scaledMid + scaledHalf;
|
|
311
315
|
if (
|
|
@@ -192,6 +192,8 @@ export interface ResolvedScrubConfig {
|
|
|
192
192
|
hideOverlaysOnScrub: boolean;
|
|
193
193
|
/** Reject outside plain-scrub starts and clamp active scrub X to the plot. */
|
|
194
194
|
clampToPlot: boolean;
|
|
195
|
+
/** Candle mode: quantize the scrub X to the hovered candle's center. */
|
|
196
|
+
snapToCandles: boolean;
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
export interface ResolvedPerSeriesTooltipConfig {
|
|
@@ -582,6 +584,18 @@ export function resolveOverscroll(
|
|
|
582
584
|
return v < MAX_OVERSCROLL ? v : MAX_OVERSCROLL;
|
|
583
585
|
}
|
|
584
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Resolves `timeScroll.fling`: `false` disables release inertia so the window
|
|
589
|
+
* stops dead where the finger lifts. Booleans / omitted keep the default (on).
|
|
590
|
+
* See {@link TimeScrollConfig}.
|
|
591
|
+
*/
|
|
592
|
+
export function resolveFling(
|
|
593
|
+
prop: boolean | TimeScrollConfig | undefined,
|
|
594
|
+
): boolean {
|
|
595
|
+
if (prop == null || typeof prop === "boolean") return true;
|
|
596
|
+
return prop.fling ?? true;
|
|
597
|
+
}
|
|
598
|
+
|
|
585
599
|
/**
|
|
586
600
|
* Resolved transition durations. `undefined` for a field means "use the
|
|
587
601
|
* component's built-in default" (so we don't duplicate the default constants
|
|
@@ -722,6 +736,7 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
|
|
|
722
736
|
panGestureDelay: 0,
|
|
723
737
|
hideOverlaysOnScrub: false,
|
|
724
738
|
clampToPlot: false,
|
|
739
|
+
snapToCandles: false,
|
|
725
740
|
};
|
|
726
741
|
|
|
727
742
|
const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
|
|
@@ -875,6 +890,7 @@ export function resolveMarkerCluster(
|
|
|
875
890
|
overlap: clamp01(prop.overlap ?? MARKER_CLUSTER_OVERLAP),
|
|
876
891
|
gap: MARKER_CLUSTER_GAP,
|
|
877
892
|
maxBeforeGroup: prop.maxBeforeGroup ?? MARKER_CLUSTER_MAX_BEFORE_GROUP,
|
|
893
|
+
maxVisible: prop.maxVisible ?? Number.MAX_SAFE_INTEGER,
|
|
878
894
|
groupBadge: prop.groupBadge ?? "count",
|
|
879
895
|
showGroupCount: prop.showGroupCount ?? false,
|
|
880
896
|
};
|
|
@@ -885,6 +901,7 @@ export function resolveMarkerCluster(
|
|
|
885
901
|
overlap: MARKER_CLUSTER_OVERLAP,
|
|
886
902
|
gap: MARKER_CLUSTER_GAP,
|
|
887
903
|
maxBeforeGroup: MARKER_CLUSTER_MAX_BEFORE_GROUP,
|
|
904
|
+
maxVisible: Number.MAX_SAFE_INTEGER,
|
|
888
905
|
groupBadge: "count",
|
|
889
906
|
showGroupCount: false,
|
|
890
907
|
};
|
|
@@ -1188,6 +1205,7 @@ export interface ResolvedDotConfig {
|
|
|
1188
1205
|
ring: ResolvedDotRingConfig | null;
|
|
1189
1206
|
show: boolean;
|
|
1190
1207
|
color: string | undefined;
|
|
1208
|
+
trackWhileParked: boolean;
|
|
1191
1209
|
}
|
|
1192
1210
|
|
|
1193
1211
|
const DOT_DEFAULTS: ResolvedDotConfig = {
|
|
@@ -1195,6 +1213,7 @@ const DOT_DEFAULTS: ResolvedDotConfig = {
|
|
|
1195
1213
|
ring: RING_DEFAULTS,
|
|
1196
1214
|
show: true,
|
|
1197
1215
|
color: undefined,
|
|
1216
|
+
trackWhileParked: false,
|
|
1198
1217
|
};
|
|
1199
1218
|
|
|
1200
1219
|
/**
|
|
@@ -1214,6 +1233,7 @@ export function resolveDot(
|
|
|
1214
1233
|
ring: resolveDotRing(prop.ring),
|
|
1215
1234
|
show: prop.show ?? DOT_DEFAULTS.show,
|
|
1216
1235
|
color: prop.color,
|
|
1236
|
+
trackWhileParked: prop.trackWhileParked ?? DOT_DEFAULTS.trackWhileParked,
|
|
1217
1237
|
};
|
|
1218
1238
|
}
|
|
1219
1239
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @see https://github.com/benjitaylor/liveline
|
|
7
7
|
*/
|
|
8
|
-
import { useEffect, useRef, useState } from "react";
|
|
8
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
9
9
|
import {
|
|
10
10
|
cancelAnimation,
|
|
11
11
|
Easing,
|
|
@@ -497,7 +497,11 @@ export function useLiveChartEngine(
|
|
|
497
497
|
// Compare against the previous key (not a "first render" flag) so React 18
|
|
498
498
|
// StrictMode's double-invoked mount effect can't fire a spurious snap.
|
|
499
499
|
const lastSnapKey = useRef(config.snapKey);
|
|
500
|
-
|
|
500
|
+
// Layout effect, not passive: a passive effect runs after the commit has
|
|
501
|
+
// painted, so the frame between them draws the new data/candleWidth against
|
|
502
|
+
// the OLD framing (a one-frame squeeze on a timeframe switch). Flipping the
|
|
503
|
+
// flag before paint lets the first frame with the new props consume the snap.
|
|
504
|
+
useLayoutEffect(() => {
|
|
501
505
|
if (config.snapKey === lastSnapKey.current) return;
|
|
502
506
|
lastSnapKey.current = config.snapKey;
|
|
503
507
|
snapSV.set(true);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
cancelAnimation,
|
|
4
4
|
Easing,
|
|
@@ -343,7 +343,9 @@ export function useLiveChartSeriesEngine(
|
|
|
343
343
|
// changes, consumed + cleared by the next frame (mirrors useLiveChartEngine).
|
|
344
344
|
const snapSV = useSharedValue(false);
|
|
345
345
|
const lastSnapKey = useRef(config.snapKey);
|
|
346
|
-
|
|
346
|
+
// Layout effect so the snap flag lands before the commit paints — a passive
|
|
347
|
+
// effect leaves one frame of new data in the old framing (mirrors useLiveChartEngine).
|
|
348
|
+
useLayoutEffect(() => {
|
|
347
349
|
if (config.snapKey === lastSnapKey.current) return;
|
|
348
350
|
lastSnapKey.current = config.snapKey;
|
|
349
351
|
snapSV.set(true);
|
package/src/draw/line.ts
CHANGED
|
@@ -27,8 +27,8 @@ export const DEFAULT_PADDING: ChartPadding = {
|
|
|
27
27
|
* The pill body starts `tl` px to the right of the gutter left edge (= dot x),
|
|
28
28
|
* so the tail spans the gap between the dot and the pill body.
|
|
29
29
|
*
|
|
30
|
-
* When `showTail` is false
|
|
31
|
-
*
|
|
30
|
+
* When `showTail` is false there is no tail geometry at all, so the pill body
|
|
31
|
+
* starts flush at the gutter left edge (+ dotGap) and no cap inset is reserved.
|
|
32
32
|
*/
|
|
33
33
|
export function badgeTailAndCap(
|
|
34
34
|
fontSize: number,
|
|
@@ -36,8 +36,9 @@ export function badgeTailAndCap(
|
|
|
36
36
|
badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
37
37
|
): number {
|
|
38
38
|
"worklet";
|
|
39
|
+
if (!showTail) return 0;
|
|
39
40
|
const pillH = fontSize + badge.padY * 2;
|
|
40
|
-
return
|
|
41
|
+
return badge.tailLength + pillH / 2;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -4,6 +4,7 @@ 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";
|
|
7
|
+
import { pickCandleAtTime } from "../math/pickCandle";
|
|
7
8
|
import type { CandlePoint } from "../types";
|
|
8
9
|
|
|
9
10
|
const TOOLTIP_PAD_X = 8;
|
|
@@ -290,6 +291,36 @@ export function computeScrubTime(
|
|
|
290
291
|
return winStart + fraction * windowSecs;
|
|
291
292
|
}
|
|
292
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Quantizes a scrub X to the center of the candle whose time bucket contains
|
|
296
|
+
* it (`scrub.snapToCandles`) — the inverse of {@link computeScrubTime}
|
|
297
|
+
* followed by the forward mapping of the picked candle's center. Returns the
|
|
298
|
+
* raw X unchanged when the position falls in a gap between candles or when
|
|
299
|
+
* the plot has no horizontal extent / time window yet.
|
|
300
|
+
*/
|
|
301
|
+
export function snapScrubXToCandleCenter(
|
|
302
|
+
x: number,
|
|
303
|
+
candles: CandlePoint[],
|
|
304
|
+
liveCandle: CandlePoint | null,
|
|
305
|
+
candleWidthSecs: number,
|
|
306
|
+
padding: ChartPadding,
|
|
307
|
+
canvasWidth: number,
|
|
308
|
+
timestamp: number,
|
|
309
|
+
windowSecs: number,
|
|
310
|
+
): number {
|
|
311
|
+
"worklet";
|
|
312
|
+
const chartW = canvasWidth - padding.left - padding.right;
|
|
313
|
+
if (chartW <= 0 || windowSecs <= 0) return x;
|
|
314
|
+
const winStart = timestamp - windowSecs;
|
|
315
|
+
const t = winStart + ((x - padding.left) / chartW) * windowSecs;
|
|
316
|
+
const candle = pickCandleAtTime(candles, liveCandle, t, candleWidthSecs);
|
|
317
|
+
if (!candle) return x;
|
|
318
|
+
return (
|
|
319
|
+
padding.left +
|
|
320
|
+
((candle.time + candleWidthSecs / 2 - winStart) / windowSecs) * chartW
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
293
324
|
/**
|
|
294
325
|
* Crosshair opacity: fades 1→0 over `fadeDistance` px as the crosshair
|
|
295
326
|
* approaches the live dot at the right chart edge. A zero distance removes
|
|
@@ -46,7 +46,10 @@ export interface ChartLayoutConfig {
|
|
|
46
46
|
* is not clipped. An explicit `insetsOverride` on a side wins over this floor.
|
|
47
47
|
*/
|
|
48
48
|
pulse?: { maxRadius: number; strokeWidth: number } | null;
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* When false and the badge uses the right gutter, omit the tail and
|
|
51
|
+
* round-cap inset from the right padding.
|
|
52
|
+
*/
|
|
50
53
|
badgeShowTail?: boolean;
|
|
51
54
|
/** Multi-series dot radius — used to add spacing between dots and Y-axis labels. */
|
|
52
55
|
multiSeriesDotRadius?: number;
|
|
@@ -117,7 +120,16 @@ export function resolveChartLayout(
|
|
|
117
120
|
);
|
|
118
121
|
}
|
|
119
122
|
|
|
120
|
-
|
|
123
|
+
// A right-gutter badge uses its pill layout instead of the bare centered label
|
|
124
|
+
// column, so the bare-axis pulse/label floor must not override badge sizing.
|
|
125
|
+
// The badge renders above the pulse; the general outlet floor below still
|
|
126
|
+
// prevents canvas-edge clipping.
|
|
127
|
+
if (
|
|
128
|
+
config.pulse &&
|
|
129
|
+
config.yAxis &&
|
|
130
|
+
!badgeUsesRightGutter &&
|
|
131
|
+
config.insetsOverride?.right == null
|
|
132
|
+
) {
|
|
121
133
|
const outlet = pulseRadialOutset(
|
|
122
134
|
config.pulse.maxRadius,
|
|
123
135
|
config.pulse.strokeWidth,
|
|
@@ -17,33 +17,25 @@ import { usePathBuilder } from "./usePathBuilder";
|
|
|
17
17
|
const CANDLE_WIDTH_LERP_SPEED = 0.08;
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Bridges the `candleWidth` prop to the UI thread and eases the displayed
|
|
21
|
+
* width toward it each frame. Must be called from the chart's OUTER component
|
|
22
|
+
* (the controller), not from inside the Skia canvas: canvas children render
|
|
23
|
+
* through Skia's own reconciler one commit behind the outer tree, so a width
|
|
24
|
+
* bridged in there lands one frame after the engine's framing targets — a
|
|
25
|
+
* timeframe switch then draws one frame of new data at the old width. See #176.
|
|
24
26
|
*/
|
|
25
|
-
export function
|
|
26
|
-
engine: SingleEngineState,
|
|
27
|
-
padding: ChartPadding,
|
|
28
|
-
candles: SharedValue<CandlePoint[]> | undefined,
|
|
29
|
-
liveCandle: SharedValue<CandlePoint | null> | undefined,
|
|
27
|
+
export function useCandleWidthLerp(
|
|
30
28
|
candleWidthSecs: number,
|
|
31
|
-
active: boolean,
|
|
32
|
-
candleMetrics: CandleMetrics = CANDLE_METRICS_DEFAULTS,
|
|
33
|
-
/** Reserved volume-band height (px). `0` = no volume bars. */
|
|
34
|
-
volumeBandHeight = 0,
|
|
35
|
-
/** Corner radius (px) of the volume bars. */
|
|
36
|
-
volumeRadius = 0,
|
|
37
|
-
/** Static charts run no loops: register without starting. Default `true`. */
|
|
38
|
-
autostart = true,
|
|
39
29
|
/**
|
|
40
30
|
* Per-frame lerp speed (0–1) for the candle-body width as it eases toward
|
|
41
31
|
* `candleWidthSecs`. `1` snaps in one frame (no "fat → thin" slide on a
|
|
42
32
|
* timeframe / bucket change); `undefined` uses {@link CANDLE_WIDTH_LERP_SPEED}.
|
|
43
33
|
* Resolved from `transitions.candleLerpSpeed`. See #176.
|
|
44
34
|
*/
|
|
45
|
-
candleLerpSpeed
|
|
46
|
-
|
|
35
|
+
candleLerpSpeed: number | undefined,
|
|
36
|
+
/** Static charts run no loops: register without starting. */
|
|
37
|
+
autostart: boolean,
|
|
38
|
+
): SharedValue<number> {
|
|
47
39
|
const targetCandleWidth = useDerivedValue(() => candleWidthSecs);
|
|
48
40
|
const displayCandleWidth = useSharedValue(candleWidthSecs);
|
|
49
41
|
// `transitions.candleLerpSpeed` overrides the built-in speed (0–1, already
|
|
@@ -54,16 +46,8 @@ export function useCandlePaths(
|
|
|
54
46
|
() => candleLerpSpeed ?? CANDLE_WIDTH_LERP_SPEED,
|
|
55
47
|
);
|
|
56
48
|
|
|
57
|
-
const upBodiesBuilder = usePathBuilder();
|
|
58
|
-
const downBodiesBuilder = usePathBuilder();
|
|
59
|
-
const upWicksBuilder = usePathBuilder();
|
|
60
|
-
const downWicksBuilder = usePathBuilder();
|
|
61
|
-
const upBarsBuilder = usePathBuilder();
|
|
62
|
-
const downBarsBuilder = usePathBuilder();
|
|
63
|
-
|
|
64
49
|
useFrameCallback((frameInfo) => {
|
|
65
50
|
"worklet";
|
|
66
|
-
if (!active) return;
|
|
67
51
|
const dt = frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS;
|
|
68
52
|
displayCandleWidth.set(
|
|
69
53
|
lerp(
|
|
@@ -75,6 +59,36 @@ export function useCandlePaths(
|
|
|
75
59
|
);
|
|
76
60
|
}, autostart);
|
|
77
61
|
|
|
62
|
+
return displayCandleWidth;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Candle paths (up/down bodies + up/down wicks). Each is built into a reused
|
|
67
|
+
* `Skia.PathBuilder` and finalized with `detach()` each frame — a fresh
|
|
68
|
+
* immutable `SkPath`, so Skia repaints without a per-curve ping-pong and no
|
|
69
|
+
* mutable `SkPath` is retained across frames.
|
|
70
|
+
*/
|
|
71
|
+
export function useCandlePaths(
|
|
72
|
+
engine: SingleEngineState,
|
|
73
|
+
padding: ChartPadding,
|
|
74
|
+
candles: SharedValue<CandlePoint[]> | undefined,
|
|
75
|
+
liveCandle: SharedValue<CandlePoint | null> | undefined,
|
|
76
|
+
/** Displayed bucket width from {@link useCandleWidthLerp} (outer component). */
|
|
77
|
+
displayCandleWidth: SharedValue<number>,
|
|
78
|
+
active: boolean,
|
|
79
|
+
candleMetrics: CandleMetrics = CANDLE_METRICS_DEFAULTS,
|
|
80
|
+
/** Reserved volume-band height (px). `0` = no volume bars. */
|
|
81
|
+
volumeBandHeight = 0,
|
|
82
|
+
/** Corner radius (px) of the volume bars. */
|
|
83
|
+
volumeRadius = 0,
|
|
84
|
+
) {
|
|
85
|
+
const upBodiesBuilder = usePathBuilder();
|
|
86
|
+
const downBodiesBuilder = usePathBuilder();
|
|
87
|
+
const upWicksBuilder = usePathBuilder();
|
|
88
|
+
const downWicksBuilder = usePathBuilder();
|
|
89
|
+
const upBarsBuilder = usePathBuilder();
|
|
90
|
+
const downBarsBuilder = usePathBuilder();
|
|
91
|
+
|
|
78
92
|
/* istanbul ignore next -- worklet */
|
|
79
93
|
const geometry = useDerivedValue(() => {
|
|
80
94
|
if (!active || !candles) return { bodies: [], wicks: [] };
|
|
@@ -51,8 +51,9 @@ export interface ChartRevealState {
|
|
|
51
51
|
/**
|
|
52
52
|
* Drives loading / empty / live visibility.
|
|
53
53
|
*
|
|
54
|
-
* Chart is fully revealed only when `!loading && hasData`.
|
|
55
|
-
*
|
|
54
|
+
* Chart is fully revealed only when `!loading && hasData`. Data appearing
|
|
55
|
+
* grows `morphT` from 0 to 1; data disappearing snaps it to 0 so stale paths
|
|
56
|
+
* never visibly flatten over a loading or empty shell. `isEmpty` is derived as
|
|
56
57
|
* `!loading && !hasData` for the empty overlay label.
|
|
57
58
|
*/
|
|
58
59
|
export function useChartReveal(
|
|
@@ -87,11 +88,18 @@ export function useChartReveal(
|
|
|
87
88
|
return;
|
|
88
89
|
}
|
|
89
90
|
if (prev !== chartVisible) {
|
|
91
|
+
// Collapse (live → loading/empty) snaps: the data is already gone, so
|
|
92
|
+
// animating would draw the stale line morphing flat over the loading
|
|
93
|
+
// shell.
|
|
94
|
+
if (!chartVisible) {
|
|
95
|
+
morphT.set(0);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
90
98
|
// 0ms → withTiming resolves on the next frame (effectively a snap), so an
|
|
91
99
|
// explicit `transitions={{ reveal: 0 }}` / `transitions={false}` removes
|
|
92
100
|
// the grow-in without a special-case branch.
|
|
93
101
|
morphT.set(
|
|
94
|
-
withTiming(
|
|
102
|
+
withTiming(1, {
|
|
95
103
|
duration: revealDuration,
|
|
96
104
|
easing: Easing.out(Easing.cubic),
|
|
97
105
|
}),
|
|
@@ -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/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
|
@@ -28,6 +28,7 @@ const ANCHORED_CLUSTER: ResolvedMarkerCluster = {
|
|
|
28
28
|
overlap: 0.75,
|
|
29
29
|
gap: 2,
|
|
30
30
|
maxBeforeGroup: 5,
|
|
31
|
+
maxVisible: Number.MAX_SAFE_INTEGER,
|
|
31
32
|
groupBadge: "count",
|
|
32
33
|
showGroupCount: false,
|
|
33
34
|
};
|
|
@@ -101,7 +102,11 @@ export function useMarkers(
|
|
|
101
102
|
lineData: lineData?.get(),
|
|
102
103
|
lineLinear,
|
|
103
104
|
});
|
|
104
|
-
clusterMarkers(markers.get(), buf, {
|
|
105
|
+
clusterMarkers(markers.get(), buf, {
|
|
106
|
+
config: cluster,
|
|
107
|
+
minY: 0,
|
|
108
|
+
maxY: engine.canvasHeight.get(),
|
|
109
|
+
});
|
|
105
110
|
projected.set(buf);
|
|
106
111
|
},
|
|
107
112
|
autostart,
|
|
@@ -81,6 +81,13 @@ export interface UsePanScrollOptions {
|
|
|
81
81
|
* `timeScroll.overscroll` (see `resolveOverscroll`).
|
|
82
82
|
*/
|
|
83
83
|
overscroll?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Fling inertia on release: a fast drag keeps scrolling and decays to a stop.
|
|
86
|
+
* `false` stops the window dead where the finger lifts (a release near the
|
|
87
|
+
* live edge still re-attaches to live). Resolved from `timeScroll.fling`
|
|
88
|
+
* (see `resolveFling`). Default `true`.
|
|
89
|
+
*/
|
|
90
|
+
fling?: boolean;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
/**
|
|
@@ -218,6 +225,7 @@ export function usePanScroll({
|
|
|
218
225
|
scrollActive,
|
|
219
226
|
scrubActive,
|
|
220
227
|
overscroll = 0,
|
|
228
|
+
fling = true,
|
|
221
229
|
}: UsePanScrollOptions): ReturnType<typeof Gesture.Pan> {
|
|
222
230
|
const { viewEnd, liveEdge, displayWindow, canvasWidth, canvasHeight } = engine;
|
|
223
231
|
const padLeft = padding.left;
|
|
@@ -307,7 +315,10 @@ export function usePanScroll({
|
|
|
307
315
|
// to FOLLOW_SNAP of the window around it — this release callback is the
|
|
308
316
|
// ONLY place that re-attaches to live (never mid-drag, see nextViewEnd).
|
|
309
317
|
const snapZone = overscroll > 0 ? win * FOLLOW_SNAP : 1e-3;
|
|
310
|
-
|
|
318
|
+
// `fling: false` → zero velocity: the decay resolves immediately where
|
|
319
|
+
// the finger lifted, and the completion callback below still re-attaches
|
|
320
|
+
// to live when the release lands inside the snap zone.
|
|
321
|
+
const velocity = fling ? flingVelocity(e.velocityX, chartW, win) : 0;
|
|
311
322
|
cancelAnimation(viewEnd);
|
|
312
323
|
viewEnd.set(
|
|
313
324
|
withDecay({ velocity, clamp: [lo, hi] }, (finished) => {
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,9 @@ export interface ResolvedMarkerCluster {
|
|
|
20
20
|
gap: number;
|
|
21
21
|
/** Collapse a co-located run to a single count badge once it exceeds this many. */
|
|
22
22
|
maxBeforeGroup: number;
|
|
23
|
+
/** Cap a `"vertical"` column at this many glyphs: the newest overflow is
|
|
24
|
+
* hidden instead of the column growing unbounded. */
|
|
25
|
+
maxVisible: number;
|
|
23
26
|
/** What a collapsed group draws: `"count"` = the round count badge (default);
|
|
24
27
|
* `"marker"` = the representative marker's own glyph; a {@link MarkerGroupBadge}
|
|
25
28
|
* = a dedicated badge (custom image/icon) independent of the members. */
|
|
@@ -31,6 +34,12 @@ export interface ResolvedMarkerCluster {
|
|
|
31
34
|
|
|
32
35
|
export interface ClusterMarkersOpts {
|
|
33
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;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
/** Glyph box used when `marker.size` is unset — mirrors `markerAtlas.DEFAULT_ICON_SIZE`. */
|
|
@@ -131,9 +140,28 @@ function layoutBucket(
|
|
|
131
140
|
// AWAY from the line in the side direction (above → up, below → down,
|
|
132
141
|
// center → up from the line). `j` runs in time order, so the newest sits
|
|
133
142
|
// furthest out — and, drawn last in array order, paints over the one below it.
|
|
143
|
+
//
|
|
144
|
+
// `maxVisible` caps the column: the oldest glyphs keep their slots and the
|
|
145
|
+
// newest overflow is simply hidden.
|
|
134
146
|
const dir = side === "below" ? 1 : -1;
|
|
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
|
+
}
|
|
135
156
|
for (let j = 0; j < count; j++) {
|
|
136
157
|
const p = proj[idx[s + j]];
|
|
158
|
+
if (j >= cap) {
|
|
159
|
+
p.hidden = true;
|
|
160
|
+
p.isGrouped = false;
|
|
161
|
+
p.groupCount = 0;
|
|
162
|
+
p.groupRep = -1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
137
165
|
p.x = anchorX;
|
|
138
166
|
p.y = anchorY + sideDy + dir * j * step;
|
|
139
167
|
p.hidden = false;
|