react-native-livechart 3.12.0 → 4.0.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 (136) hide show
  1. package/dist/components/BadgeOverlay.d.ts +9 -1
  2. package/dist/components/BadgeOverlay.d.ts.map +1 -1
  3. package/dist/components/ChartOverlayLayer.d.ts +19 -0
  4. package/dist/components/ChartOverlayLayer.d.ts.map +1 -0
  5. package/dist/components/CustomMarkerOverlay.d.ts +16 -6
  6. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomReferenceLineOverlay.d.ts +36 -0
  8. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -0
  9. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  10. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  11. package/dist/components/DotOverlay.d.ts +7 -1
  12. package/dist/components/DotOverlay.d.ts.map +1 -1
  13. package/dist/components/LiveChart.d.ts.map +1 -1
  14. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  15. package/dist/components/MarkerOverlay.d.ts +6 -3
  16. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  17. package/dist/components/MultiSeriesDots.d.ts +4 -1
  18. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  19. package/dist/components/ReferenceLineGroupOverlay.d.ts +27 -0
  20. package/dist/components/ReferenceLineGroupOverlay.d.ts.map +1 -0
  21. package/dist/components/ReferenceLineOverlay.d.ts +25 -2
  22. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  23. package/dist/components/XAxisOverlay.d.ts +7 -1
  24. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  25. package/dist/components/YAxisOverlay.d.ts +12 -1
  26. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  27. package/dist/constants.d.ts +7 -0
  28. package/dist/constants.d.ts.map +1 -1
  29. package/dist/core/liveChartEngineTick.d.ts +29 -0
  30. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  31. package/dist/core/liveChartSeriesEngineTick.d.ts +18 -0
  32. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  33. package/dist/core/resolveConfig.d.ts +56 -2
  34. package/dist/core/resolveConfig.d.ts.map +1 -1
  35. package/dist/core/useLiveChartEngine.d.ts +58 -7
  36. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  37. package/dist/core/useLiveChartSeriesEngine.d.ts +8 -2
  38. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  39. package/dist/draw/grid.d.ts.map +1 -1
  40. package/dist/draw/markerAtlas.d.ts +20 -2
  41. package/dist/draw/markerAtlas.d.ts.map +1 -1
  42. package/dist/draw/volume.d.ts +25 -0
  43. package/dist/draw/volume.d.ts.map +1 -0
  44. package/dist/hooks/crosshairShared.d.ts +12 -6
  45. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  46. package/dist/hooks/overlayScale.d.ts +22 -0
  47. package/dist/hooks/overlayScale.d.ts.map +1 -0
  48. package/dist/hooks/resolveChartLayout.d.ts +9 -0
  49. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  50. package/dist/hooks/useBadge.d.ts +22 -1
  51. package/dist/hooks/useBadge.d.ts.map +1 -1
  52. package/dist/hooks/useCandlePaths.d.ts +6 -0
  53. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  54. package/dist/hooks/useChartOverlayContext.d.ts +45 -0
  55. package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
  56. package/dist/hooks/useChartPaths.d.ts +8 -1
  57. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  58. package/dist/hooks/useCrosshair.d.ts +9 -2
  59. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  60. package/dist/hooks/useLiveDot.d.ts +6 -1
  61. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  62. package/dist/hooks/useMarkers.d.ts +9 -4
  63. package/dist/hooks/useMarkers.d.ts.map +1 -1
  64. package/dist/hooks/usePanScroll.d.ts +82 -0
  65. package/dist/hooks/usePanScroll.d.ts.map +1 -0
  66. package/dist/hooks/usePinchZoom.d.ts +74 -0
  67. package/dist/hooks/usePinchZoom.d.ts.map +1 -0
  68. package/dist/hooks/useReferenceDrag.d.ts +24 -0
  69. package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
  70. package/dist/hooks/useReferenceLine.d.ts +23 -4
  71. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  72. package/dist/hooks/useReferenceLinePress.d.ts +5 -1
  73. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
  74. package/dist/hooks/useVisibleRange.d.ts +39 -0
  75. package/dist/hooks/useVisibleRange.d.ts.map +1 -0
  76. package/dist/index.d.ts +2 -1
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/math/markerCluster.d.ts +47 -0
  79. package/dist/math/markerCluster.d.ts.map +1 -0
  80. package/dist/math/markers.d.ts +10 -0
  81. package/dist/math/markers.d.ts.map +1 -1
  82. package/dist/math/referenceDrag.d.ts +24 -0
  83. package/dist/math/referenceDrag.d.ts.map +1 -0
  84. package/dist/math/referenceGroup.d.ts +31 -0
  85. package/dist/math/referenceGroup.d.ts.map +1 -0
  86. package/dist/math/referenceLines.d.ts +33 -7
  87. package/dist/math/referenceLines.d.ts.map +1 -1
  88. package/dist/types.d.ts +505 -21
  89. package/dist/types.d.ts.map +1 -1
  90. package/package.json +1 -1
  91. package/src/components/BadgeOverlay.tsx +26 -1
  92. package/src/components/ChartOverlayLayer.tsx +32 -0
  93. package/src/components/CustomMarkerOverlay.tsx +150 -57
  94. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  95. package/src/components/CustomTooltipOverlay.tsx +27 -7
  96. package/src/components/DotOverlay.tsx +9 -0
  97. package/src/components/LiveChart.tsx +549 -32
  98. package/src/components/LiveChartSeries.tsx +110 -5
  99. package/src/components/MarkerOverlay.tsx +92 -6
  100. package/src/components/MultiSeriesDots.tsx +12 -3
  101. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  102. package/src/components/ReferenceLineOverlay.tsx +105 -32
  103. package/src/components/XAxisOverlay.tsx +9 -2
  104. package/src/components/YAxisOverlay.tsx +48 -26
  105. package/src/constants.ts +10 -0
  106. package/src/core/liveChartEngineTick.ts +81 -8
  107. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  108. package/src/core/resolveConfig.ts +122 -1
  109. package/src/core/useLiveChartEngine.ts +103 -10
  110. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  111. package/src/draw/grid.ts +13 -1
  112. package/src/draw/markerAtlas.ts +117 -3
  113. package/src/draw/volume.ts +190 -0
  114. package/src/hooks/crosshairShared.ts +37 -9
  115. package/src/hooks/overlayScale.ts +59 -0
  116. package/src/hooks/resolveChartLayout.ts +33 -0
  117. package/src/hooks/useBadge.ts +89 -21
  118. package/src/hooks/useCandlePaths.ts +104 -7
  119. package/src/hooks/useChartOverlayContext.ts +93 -0
  120. package/src/hooks/useChartPaths.ts +14 -1
  121. package/src/hooks/useCrosshair.ts +29 -2
  122. package/src/hooks/useLiveDot.ts +16 -5
  123. package/src/hooks/useMarkers.ts +39 -11
  124. package/src/hooks/usePanScroll.ts +253 -0
  125. package/src/hooks/usePinchZoom.ts +189 -0
  126. package/src/hooks/useReferenceDrag.ts +254 -0
  127. package/src/hooks/useReferenceLine.ts +56 -9
  128. package/src/hooks/useReferenceLinePress.ts +16 -2
  129. package/src/hooks/useVisibleRange.ts +119 -0
  130. package/src/index.ts +17 -1
  131. package/src/math/markerCluster.ts +214 -0
  132. package/src/math/markers.ts +35 -3
  133. package/src/math/referenceDrag.ts +66 -0
  134. package/src/math/referenceGroup.ts +65 -0
  135. package/src/math/referenceLines.ts +76 -19
  136. package/src/types.ts +525 -21
@@ -8,6 +8,7 @@ import {
8
8
  import { CANDLE_METRICS_DEFAULTS, MS_PER_FRAME_60FPS } from "../constants";
9
9
  import type { SingleEngineState } from "../core/useLiveChartEngine";
10
10
  import { buildCandleGeometry } from "../draw/candle";
11
+ import { buildVolumeGeometry } from "../draw/volume";
11
12
  import type { ChartPadding } from "../draw/line";
12
13
  import { lerp } from "../math/lerp";
13
14
  import type { CandleMetrics, CandlePoint } from "../types";
@@ -29,6 +30,10 @@ export function useCandlePaths(
29
30
  candleWidthSecs: number,
30
31
  active: boolean,
31
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,
32
37
  /** Static charts run no loops: register without starting. Default `true`. */
33
38
  autostart = true,
34
39
  ) {
@@ -39,6 +44,8 @@ export function useCandlePaths(
39
44
  const downBodiesBuilder = usePathBuilder();
40
45
  const upWicksBuilder = usePathBuilder();
41
46
  const downWicksBuilder = usePathBuilder();
47
+ const upBarsBuilder = usePathBuilder();
48
+ const downBarsBuilder = usePathBuilder();
42
49
 
43
50
  useFrameCallback((frameInfo) => {
44
51
  "worklet";
@@ -76,11 +83,20 @@ export function useCandlePaths(
76
83
  const upBodiesPath = useDerivedValue(() => {
77
84
  const b = upBodiesBuilder.value;
78
85
  const { bodies } = geometry.value;
86
+ const radius = candleMetrics.bodyRadius;
79
87
  for (let i = 0; i < bodies.length; i++) {
80
88
  if (bodies[i].up) {
81
- b.addRect(
82
- Skia.XYWHRect(bodies[i].x, bodies[i].y, bodies[i].w, bodies[i].h),
83
- );
89
+ const bd = bodies[i];
90
+ const rr = radius > 0 ? Math.min(radius, bd.w / 2, bd.h / 2) : 0;
91
+ if (rr > 0) {
92
+ b.addRRect({
93
+ rect: { x: bd.x, y: bd.y, width: bd.w, height: bd.h },
94
+ rx: rr,
95
+ ry: rr,
96
+ });
97
+ } else {
98
+ b.addRect(Skia.XYWHRect(bd.x, bd.y, bd.w, bd.h));
99
+ }
84
100
  }
85
101
  }
86
102
  return b.detach();
@@ -90,11 +106,20 @@ export function useCandlePaths(
90
106
  const downBodiesPath = useDerivedValue(() => {
91
107
  const b = downBodiesBuilder.value;
92
108
  const { bodies } = geometry.value;
109
+ const radius = candleMetrics.bodyRadius;
93
110
  for (let i = 0; i < bodies.length; i++) {
94
111
  if (!bodies[i].up) {
95
- b.addRect(
96
- Skia.XYWHRect(bodies[i].x, bodies[i].y, bodies[i].w, bodies[i].h),
97
- );
112
+ const bd = bodies[i];
113
+ const rr = radius > 0 ? Math.min(radius, bd.w / 2, bd.h / 2) : 0;
114
+ if (rr > 0) {
115
+ b.addRRect({
116
+ rect: { x: bd.x, y: bd.y, width: bd.w, height: bd.h },
117
+ rx: rr,
118
+ ry: rr,
119
+ });
120
+ } else {
121
+ b.addRect(Skia.XYWHRect(bd.x, bd.y, bd.w, bd.h));
122
+ }
98
123
  }
99
124
  }
100
125
  return b.detach();
@@ -126,5 +151,77 @@ export function useCandlePaths(
126
151
  return b.detach();
127
152
  });
128
153
 
129
- return { upBodiesPath, downBodiesPath, upWicksPath, downWicksPath } as const;
154
+ // Volume bars share the candle window + (lerped) candle width so each bar sits
155
+ // directly under its candle body. Empty unless a volume band is reserved.
156
+ /* istanbul ignore next -- worklet */
157
+ const volumeGeometry = useDerivedValue(() => {
158
+ if (!active || !candles || volumeBandHeight <= 0) return { bars: [] };
159
+ return buildVolumeGeometry(
160
+ candles.value,
161
+ liveCandle?.value ?? null,
162
+ padding,
163
+ engine.canvasWidth.value,
164
+ engine.canvasHeight.value,
165
+ engine.timestamp.value - engine.displayWindow.value,
166
+ engine.displayWindow.value,
167
+ volumeBandHeight,
168
+ displayCandleWidth.get(),
169
+ candleMetrics,
170
+ );
171
+ });
172
+
173
+ /* istanbul ignore next -- worklet */
174
+ const upBarsPath = useDerivedValue(() => {
175
+ const b = upBarsBuilder.value;
176
+ const { bars } = volumeGeometry.value;
177
+ for (let i = 0; i < bars.length; i++) {
178
+ if (bars[i].up) {
179
+ const bar = bars[i];
180
+ const rr =
181
+ volumeRadius > 0 ? Math.min(volumeRadius, bar.w / 2, bar.h / 2) : 0;
182
+ if (rr > 0) {
183
+ b.addRRect({
184
+ rect: { x: bar.x, y: bar.y, width: bar.w, height: bar.h },
185
+ rx: rr,
186
+ ry: rr,
187
+ });
188
+ } else {
189
+ b.addRect(Skia.XYWHRect(bar.x, bar.y, bar.w, bar.h));
190
+ }
191
+ }
192
+ }
193
+ return b.detach();
194
+ });
195
+
196
+ /* istanbul ignore next -- worklet */
197
+ const downBarsPath = useDerivedValue(() => {
198
+ const b = downBarsBuilder.value;
199
+ const { bars } = volumeGeometry.value;
200
+ for (let i = 0; i < bars.length; i++) {
201
+ if (!bars[i].up) {
202
+ const bar = bars[i];
203
+ const rr =
204
+ volumeRadius > 0 ? Math.min(volumeRadius, bar.w / 2, bar.h / 2) : 0;
205
+ if (rr > 0) {
206
+ b.addRRect({
207
+ rect: { x: bar.x, y: bar.y, width: bar.w, height: bar.h },
208
+ rx: rr,
209
+ ry: rr,
210
+ });
211
+ } else {
212
+ b.addRect(Skia.XYWHRect(bar.x, bar.y, bar.w, bar.h));
213
+ }
214
+ }
215
+ }
216
+ return b.detach();
217
+ });
218
+
219
+ return {
220
+ upBodiesPath,
221
+ downBodiesPath,
222
+ upWicksPath,
223
+ downWicksPath,
224
+ upBarsPath,
225
+ downBarsPath,
226
+ } as const;
130
227
  }
@@ -0,0 +1,93 @@
1
+ import { useMemo } from "react";
2
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
3
+
4
+ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
5
+ import type { ChartPadding } from "../draw/line";
6
+ import type { ChartOverlayContext, ChartScale } from "../types";
7
+ import { priceToY, timeToX, xToTime, yToPrice } from "./overlayScale";
8
+
9
+ /**
10
+ * Builds the {@link ChartOverlayContext} handed to a custom
11
+ * {@link LiveChartProps.renderOverlay}: a single per-frame {@link ChartScale}
12
+ * SharedValue plus the pure price↔pixel / time↔pixel mapping worklets.
13
+ *
14
+ * The `scale` derived value reads the engine SharedValues (range, window, "now")
15
+ * and the padding each frame, so a consumer's `useAnimatedStyle` becomes reactive
16
+ * just by reading `scale.get()` — that read subscribes it to per-frame updates
17
+ * (Reanimated only observes SharedValues found directly in a worklet's closure,
18
+ * not those hidden behind a function call). The mappings stay pure, taking the
19
+ * snapshot as an argument.
20
+ */
21
+ export function useChartOverlayContext(
22
+ engine: ChartEngineLayout,
23
+ padding: ChartPadding,
24
+ ): ChartOverlayContext {
25
+ const { top: padTop, bottom: padBottom, left: padLeft, right: padRight } = padding;
26
+
27
+ const scale = useDerivedValue<ChartScale>(() => {
28
+ const width = engine.canvasWidth.get();
29
+ const height = engine.canvasHeight.get();
30
+ return {
31
+ min: engine.displayMin.get(),
32
+ max: engine.displayMax.get(),
33
+ window: engine.displayWindow.get(),
34
+ now: engine.timestamp.get(),
35
+ plot: {
36
+ left: padLeft,
37
+ top: padTop,
38
+ right: width - padRight,
39
+ bottom: height - padBottom,
40
+ width,
41
+ height,
42
+ },
43
+ };
44
+ }, [engine, padLeft, padTop, padRight, padBottom]);
45
+
46
+ return useMemo<ChartOverlayContext>(
47
+ () => ({ scale, priceToY, yToPrice, timeToX, xToTime }),
48
+ [scale],
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Reactive Y for a price: projects `price` to its canvas Y pixel and returns it as
54
+ * a `SharedValue<number>` that tracks the live axis on the UI thread. The
55
+ * **recommended** way to place something at a price in a {@link LiveChartProps.renderOverlay}
56
+ * — read the returned value in your `useAnimatedStyle` like any SharedValue; the
57
+ * subscription to the chart's scale is handled here, so it can't be forgotten.
58
+ *
59
+ * ```tsx
60
+ * function PriceLevel({ ctx, price }) {
61
+ * const y = usePriceY(ctx, price);
62
+ * const style = useAnimatedStyle(() => ({ transform: [{ translateY: y.get() }] }));
63
+ * return <Animated.View style={style} />;
64
+ * }
65
+ * ```
66
+ *
67
+ * It's a hook, so call it once per level (render one component per price). For
68
+ * one-off math off the render path (e.g. a gesture handler), use the pure
69
+ * {@link ChartOverlayContext.priceToY} with `ctx.scale.get()` instead.
70
+ */
71
+ export function usePriceY(
72
+ ctx: ChartOverlayContext,
73
+ price: number,
74
+ ): SharedValue<number> {
75
+ const { scale, priceToY: toY } = ctx;
76
+ // Destructure `scale` so it sits directly in the worklet's closure (Reanimated
77
+ // observes top-level closure SharedValues, not ones reached via `ctx.scale`).
78
+ return useDerivedValue(() => toY(price, scale.get()), [scale, toY, price]);
79
+ }
80
+
81
+ /**
82
+ * Reactive X for a timestamp — the time↔pixel sibling of {@link usePriceY}.
83
+ * Projects `time` (unix seconds) to its canvas X pixel as a `SharedValue<number>`
84
+ * that tracks the scrolling window on the UI thread. Read it in your
85
+ * `useAnimatedStyle`; call once per element.
86
+ */
87
+ export function useTimeX(
88
+ ctx: ChartOverlayContext,
89
+ time: number,
90
+ ): SharedValue<number> {
91
+ const { scale, timeToX: toX } = ctx;
92
+ return useDerivedValue(() => toX(time, scale.get()), [scale, toX, time]);
93
+ }
@@ -27,6 +27,14 @@ export function useChartPaths(
27
27
  thresholdY?: SharedValue<number>,
28
28
  /** Draw the line/fill as a straight polyline instead of the monotone cubic. */
29
29
  linear = false,
30
+ /**
31
+ * Value at the visible window's right edge (engine `edgeValue`). With
32
+ * `followViewEdge`, the line's right-edge tip uses this instead of the live
33
+ * `displayValue` — so while time-scrolled the line ends at the last visible
34
+ * price rather than dropping to the (off-screen) live value.
35
+ */
36
+ edgeValue?: SharedValue<number>,
37
+ followViewEdge = false,
30
38
  ) {
31
39
  const lineBuilder = usePathBuilder();
32
40
  const fillBuilder = usePathBuilder();
@@ -53,9 +61,14 @@ export function useChartPaths(
53
61
  const cache = cacheRef.current!;
54
62
  cache.ptsTick = !cache.ptsTick;
55
63
  const buf = cache.ptsTick ? cache.ptsA : cache.ptsB;
64
+ // While scrolled back with `followViewEdge`, tip the line at the view-edge
65
+ // price (engine `edgeValue`) instead of the live value — otherwise the line
66
+ // drops from the last visible point to the off-screen live value at the edge.
67
+ const tipValue =
68
+ followViewEdge && edgeValue ? edgeValue.get() : engine.displayValue.get();
56
69
  const realPts = buildLinePoints(
57
70
  engine.data.get(),
58
- engine.displayValue.get(),
71
+ tipValue,
59
72
  engine.timestamp.get(),
60
73
  engine.displayWindow.get(),
61
74
  engine.displayMin.get(),
@@ -130,13 +130,20 @@ export function useCrosshair(
130
130
  */
131
131
  deferTapHit?: (x: number, y: number) => boolean,
132
132
  /** Where the tooltip pill sits relative to the scrub line. Default `"side"`. */
133
- tooltipPlacement: "side" | "top" | "bottom" = "side",
133
+ tooltipPlacement: "side" | "top" | "bottom" | "point" = "side",
134
134
  /** Render the value row in the default tooltip. Default `true`. */
135
135
  tooltipShowValue = true,
136
136
  /** Render the time row in the default tooltip. Default `true`. */
137
137
  tooltipShowTime = true,
138
138
  /** Gap (px) between the tooltip and the plot edge it's pinned to. Default `8`. */
139
139
  tooltipMargin = 8,
140
+ /**
141
+ * Height (px) of a bottom band to exclude from scrub recognition — the
142
+ * axis-drag "grab the time ruler" strip, so a drag there scrolls instead of
143
+ * scrubbing (and a vertical drag falls through to the parent). `0` = no
144
+ * exclusion (the default; scrub covers the whole plot).
145
+ */
146
+ scrubBottomExclude = 0,
140
147
  ): CrosshairState {
141
148
  const scrubX = useSharedValue(-1);
142
149
  const scrubActive = useSharedValue(false);
@@ -264,6 +271,7 @@ export function useCrosshair(
264
271
  tooltipShowTime,
265
272
  engine.canvasHeight.get(),
266
273
  tooltipMargin,
274
+ scrubDotY.get(),
267
275
  );
268
276
  });
269
277
 
@@ -506,6 +514,12 @@ export function useCrosshair(
506
514
  }
507
515
  return;
508
516
  }
517
+ // Defer to a marker / pressable badge under the finger: don't start a
518
+ // live scrub (or drop a crosshair) where the press is routed to an
519
+ // overlay tap. `scrubActive` is only set here, so bailing in `onStart`
520
+ // also keeps a follow-on drag from showing a crosshair — no `onUpdate`
521
+ // guard needed. (Plain-scrub counterpart of the scrub-action tap defer.)
522
+ if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
509
523
  scrubX.set(e.x);
510
524
  scrubActive.set(true);
511
525
  gestureStarted.set(true);
@@ -562,6 +576,14 @@ export function useCrosshair(
562
576
  gesture = gesture.activeOffsetX([-25, 25]).failOffsetY([-25, 25]);
563
577
  }
564
578
 
579
+ // Axis-drag time-scroll: carve the bottom "time ruler" band out of the scrub's
580
+ // hit area so a drag starting there scrolls (the pan-scroll gesture owns it)
581
+ // and never trips the crosshair. `shouldCancelWhenOutside(false)` above keeps a
582
+ // scrub that *started* in the plot tracking on into the band.
583
+ if (scrubBottomExclude > 0) {
584
+ gesture = gesture.hitSlop({ bottom: -scrubBottomExclude });
585
+ }
586
+
565
587
  // Tap: place/move the reticle, press the action badge, or dismiss the lock.
566
588
  // Composed ahead of the pan by the controller, so a tap is never swallowed.
567
589
  // Built only in scrub-action mode (the plain-scrub path never constructs a Tap).
@@ -626,12 +648,17 @@ export function useCrosshair(
626
648
  lockActive.set(true);
627
649
  };
628
650
 
629
- const tapGesture = hasScrubAction
651
+ let tapGesture = hasScrubAction
630
652
  ? Gesture.Tap()
631
653
  .maxDuration(250)
632
654
  .maxDistance(SCRUB_ACTION_TAP_SLOP)
633
655
  .onEnd(handleActionTap)
634
656
  : undefined;
657
+ // Keep the axis-drag band scroll-only for taps too — a tap there shouldn't
658
+ // drop an order-ticket reticle.
659
+ if (tapGesture && scrubBottomExclude > 0) {
660
+ tapGesture = tapGesture.hitSlop({ bottom: -scrubBottomExclude });
661
+ }
635
662
 
636
663
  return {
637
664
  scrubX,
@@ -1,4 +1,4 @@
1
- import { useDerivedValue } from "react-native-reanimated";
1
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
2
2
  import type { SingleEngineState } from "../core/useLiveChartEngine";
3
3
  import type { ChartPadding } from "../draw/line";
4
4
 
@@ -6,8 +6,17 @@ import type { ChartPadding } from "../draw/line";
6
6
  * Derive the live dot position (right edge of the chart, mapped to current value).
7
7
  * Returns `{ dotX, dotY }` as shared values. Coordinates are set to `-100`
8
8
  * (off-screen sentinel) when canvas dimensions are unavailable.
9
+ *
10
+ * With `followViewEdge` + `edgeValue`, the dot (and the value line that shares
11
+ * `dotY`) tracks the visible window's right-edge price while scrolled back, so it
12
+ * stays aligned with a `followViewEdge` badge instead of marking the live value.
9
13
  */
10
- export function useLiveDot(engine: SingleEngineState, padding: ChartPadding) {
14
+ export function useLiveDot(
15
+ engine: SingleEngineState,
16
+ padding: ChartPadding,
17
+ edgeValue?: SharedValue<number>,
18
+ followViewEdge = false,
19
+ ) {
11
20
  const dotX = useDerivedValue(() => {
12
21
  const w = engine.canvasWidth.value;
13
22
  if (w === 0) return -100;
@@ -22,9 +31,11 @@ export function useLiveDot(engine: SingleEngineState, padding: ChartPadding) {
22
31
  const dMax = engine.displayMax.value;
23
32
  const valRange = dMax - dMin;
24
33
  if (valRange === 0) return padding.top + chartH / 2;
25
- return (
26
- padding.top + ((dMax - engine.displayValue.value) / valRange) * chartH
27
- );
34
+ const v =
35
+ followViewEdge && edgeValue
36
+ ? edgeValue.value
37
+ : engine.displayValue.value;
38
+ return padding.top + ((dMax - v) / valRange) * chartH;
28
39
  });
29
40
 
30
41
  return { dotX, dotY } as const;
@@ -10,16 +10,30 @@ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
10
10
  import type { ChartPadding } from "../draw/line";
11
11
  import { projectMarkers, type ProjectedMarker } from "../math/markers";
12
12
  import { nearestMarkerIndex } from "../math/markers";
13
+ import {
14
+ clusterMarkers,
15
+ clusterMembers,
16
+ type ResolvedMarkerCluster,
17
+ } from "../math/markerCluster";
13
18
  import type {
14
19
  LiveChartPoint,
15
20
  Marker,
16
- MarkerHoverEvent,
21
+ MarkerPressEvent,
17
22
  SeriesConfig,
18
23
  } from "../types";
19
24
 
25
+ const ANCHORED_CLUSTER: ResolvedMarkerCluster = {
26
+ mode: "anchored",
27
+ overlap: 0.75,
28
+ gap: 2,
29
+ maxBeforeGroup: 5,
30
+ };
31
+
20
32
  /**
21
33
  * Projects markers to screen positions each frame and builds a tap gesture that
22
- * hit-tests against them, firing `onMarkerHover` (or `null` on a miss).
34
+ * hit-tests against them, firing `onMarkerPress` (or `null` on a miss). Applies
35
+ * the {@link clusterMarkers} collision pass each frame so hit-testing matches the
36
+ * drawn (stacked / collapsed) positions.
23
37
  */
24
38
  export function useMarkers(
25
39
  engine: ChartEngineLayout,
@@ -27,7 +41,7 @@ export function useMarkers(
27
41
  markers: SharedValue<Marker[]>,
28
42
  active: boolean,
29
43
  hitRadius: number,
30
- onMarkerHover?: (event: MarkerHoverEvent | null) => void,
44
+ onMarkerPress?: (event: MarkerPressEvent | null) => void,
31
45
  seriesSV?: SharedValue<SeriesConfig[]>,
32
46
  lineData?: SharedValue<LiveChartPoint[]>,
33
47
  /** Static charts run no loops: register without starting. Default `true`. */
@@ -35,6 +49,8 @@ export function useMarkers(
35
49
  /** Single-series line is drawn linear (`line.curve === "linear"`) — anchor
36
50
  * `lineData` markers on the straight chord rather than the spline. */
37
51
  lineLinear = false,
52
+ /** Collision config; default `"anchored"` (no stacking). */
53
+ cluster: ResolvedMarkerCluster = ANCHORED_CLUSTER,
38
54
  ): {
39
55
  projected: SharedValue<ProjectedMarker[]>;
40
56
  tapGesture: ReturnType<typeof Gesture.Tap>;
@@ -51,10 +67,10 @@ export function useMarkers(
51
67
  cacheRef.current = { a: [] as ProjectedMarker[], b: [] as ProjectedMarker[], tick: false };
52
68
  }
53
69
 
54
- const emitHover =
70
+ const emitPress =
55
71
  /* istanbul ignore next -- invoked only from the UI-thread tap worklet */
56
- (event: MarkerHoverEvent | null) => {
57
- onMarkerHover?.(event);
72
+ (event: MarkerPressEvent | null) => {
73
+ onMarkerPress?.(event);
58
74
  };
59
75
 
60
76
  useFrameCallback(
@@ -82,6 +98,7 @@ export function useMarkers(
82
98
  lineData: lineData?.get(),
83
99
  lineLinear,
84
100
  });
101
+ clusterMarkers(markers.get(), buf, { config: cluster });
85
102
  projected.set(buf);
86
103
  },
87
104
  autostart,
@@ -92,14 +109,25 @@ export function useMarkers(
92
109
  e,
93
110
  ) => {
94
111
  "worklet";
95
- const idx = nearestMarkerIndex(projected.get(), e.x, e.y, hitRadius);
112
+ const proj = projected.get();
113
+ const idx = nearestMarkerIndex(proj, e.x, e.y, hitRadius);
96
114
  if (idx < 0) {
97
- runOnJS(emitHover)(null);
115
+ runOnJS(emitPress)(null);
98
116
  return;
99
117
  }
100
- const m = markers.get()[idx];
101
- const p = projected.get()[idx];
102
- runOnJS(emitHover)({ marker: m, point: { x: p.x, y: p.y } });
118
+ const ms = markers.get();
119
+ const m = ms[idx];
120
+ const p = proj[idx];
121
+ const isGrouped = p.isGrouped;
122
+ // Collapsed cluster: surface the whole bucket so the consumer can list it.
123
+ const members = isGrouped ? clusterMembers(ms, proj, idx) : undefined;
124
+ runOnJS(emitPress)({
125
+ marker: m,
126
+ point: { x: p.x, y: p.y },
127
+ index: idx,
128
+ isGrouped,
129
+ members,
130
+ });
103
131
  },
104
132
  );
105
133