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.
Files changed (58) hide show
  1. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  2. package/dist/components/DotOverlay.d.ts +7 -1
  3. package/dist/components/DotOverlay.d.ts.map +1 -1
  4. package/dist/components/LiveChart.d.ts.map +1 -1
  5. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  6. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  7. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  8. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  9. package/dist/core/resolveConfig.d.ts +9 -0
  10. package/dist/core/resolveConfig.d.ts.map +1 -1
  11. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  12. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  13. package/dist/draw/line.d.ts +2 -2
  14. package/dist/draw/line.d.ts.map +1 -1
  15. package/dist/hooks/crosshairShared.d.ts +8 -0
  16. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  17. package/dist/hooks/resolveChartLayout.d.ts +4 -1
  18. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  19. package/dist/hooks/useCandlePaths.d.ts +22 -11
  20. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  21. package/dist/hooks/useChartReveal.d.ts +3 -2
  22. package/dist/hooks/useChartReveal.d.ts.map +1 -1
  23. package/dist/hooks/useCrosshair.d.ts +7 -1
  24. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  25. package/dist/hooks/useLiveDot.d.ts +8 -2
  26. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  27. package/dist/hooks/useMarkers.d.ts.map +1 -1
  28. package/dist/hooks/usePanScroll.d.ts +8 -1
  29. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/math/markerCluster.d.ts +9 -0
  33. package/dist/math/markerCluster.d.ts.map +1 -1
  34. package/dist/types.d.ts +71 -2
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/CustomMarkerOverlay.tsx +5 -1
  38. package/src/components/DotOverlay.tsx +9 -2
  39. package/src/components/LiveChart.tsx +128 -17
  40. package/src/components/LiveChartSeries.tsx +4 -0
  41. package/src/components/MarkerOverlay.tsx +5 -1
  42. package/src/core/liveChartEngineTick.ts +5 -1
  43. package/src/core/liveChartSeriesEngineTick.ts +5 -1
  44. package/src/core/resolveConfig.ts +20 -0
  45. package/src/core/useLiveChartEngine.ts +6 -2
  46. package/src/core/useLiveChartSeriesEngine.ts +4 -2
  47. package/src/draw/line.ts +4 -3
  48. package/src/hooks/crosshairShared.ts +31 -0
  49. package/src/hooks/resolveChartLayout.ts +14 -2
  50. package/src/hooks/useCandlePaths.ts +41 -27
  51. package/src/hooks/useChartReveal.ts +11 -3
  52. package/src/hooks/useCrosshair.ts +29 -2
  53. package/src/hooks/useLiveDot.ts +28 -3
  54. package/src/hooks/useMarkers.ts +6 -1
  55. package/src/hooks/usePanScroll.ts +12 -1
  56. package/src/index.ts +1 -0
  57. package/src/math/markerCluster.ts +28 -0
  58. 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, { config: cluster });
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
- const scaledHalf = ((tMax - tMin) / 2) * yScale;
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
- const scaledHalf = ((tMax - tMin) / 2) * yScale;
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
- useEffect(() => {
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 { useEffect, useRef, useState } from "react";
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
- useEffect(() => {
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 the tail spike is omitted and only the round cap
31
- * radius is returned, letting callers shrink the right gutter.
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 (showTail ? badge.tailLength : 0) + pillH / 2;
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
- /** When false and badge uses the right gutter, omit BADGE_TAIL_LEN from the right padding. */
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
- if (config.pulse && config.yAxis && config.insetsOverride?.right == null) {
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
- * Candle paths (up/down bodies + up/down wicks). Each is built into a reused
21
- * `Skia.PathBuilder` and finalized with `detach()` each frame — a fresh
22
- * immutable `SkPath`, so Skia repaints without a per-curve ping-pong and no
23
- * mutable `SkPath` is retained across frames.
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 useCandlePaths(
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?: number,
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`. `morphT` animates
55
- * between 0 and 1 when that condition changes. `isEmpty` is derived as
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(chartVisible ? 1 : 0, {
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,
@@ -1,5 +1,8 @@
1
1
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
2
- import type { SingleEngineState } from "../core/useLiveChartEngine";
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
- return w - padding.right;
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(() => {
@@ -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, { config: cluster });
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
- const velocity = flingVelocity(e.velocityX, chartW, win);
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
@@ -33,6 +33,7 @@ export { usePriceY, useTimeX } from "./hooks/useChartOverlayContext";
33
33
 
34
34
  export type {
35
35
  AreaDotsConfig,
36
+ AxisAutoHideConfig,
36
37
  AxisLabelConfig,
37
38
  BadgeConfig,
38
39
  BadgeMetrics,
@@ -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;