react-native-livechart 3.12.0 → 4.1.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 (140) 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 +13 -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 +62 -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 +13 -1
  40. package/dist/draw/grid.d.ts.map +1 -1
  41. package/dist/draw/markerAtlas.d.ts +20 -2
  42. package/dist/draw/markerAtlas.d.ts.map +1 -1
  43. package/dist/draw/volume.d.ts +25 -0
  44. package/dist/draw/volume.d.ts.map +1 -0
  45. package/dist/hooks/crosshairShared.d.ts +12 -6
  46. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  47. package/dist/hooks/overlayScale.d.ts +22 -0
  48. package/dist/hooks/overlayScale.d.ts.map +1 -0
  49. package/dist/hooks/resolveChartLayout.d.ts +9 -0
  50. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  51. package/dist/hooks/useBadge.d.ts +22 -1
  52. package/dist/hooks/useBadge.d.ts.map +1 -1
  53. package/dist/hooks/useCandlePaths.d.ts +6 -0
  54. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  55. package/dist/hooks/useChartOverlayContext.d.ts +45 -0
  56. package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
  57. package/dist/hooks/useChartPaths.d.ts +8 -1
  58. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  59. package/dist/hooks/useCrosshair.d.ts +9 -2
  60. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  61. package/dist/hooks/useLiveDot.d.ts +6 -1
  62. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  63. package/dist/hooks/useMarkers.d.ts +9 -4
  64. package/dist/hooks/useMarkers.d.ts.map +1 -1
  65. package/dist/hooks/usePanScroll.d.ts +82 -0
  66. package/dist/hooks/usePanScroll.d.ts.map +1 -0
  67. package/dist/hooks/usePinchZoom.d.ts +74 -0
  68. package/dist/hooks/usePinchZoom.d.ts.map +1 -0
  69. package/dist/hooks/useReferenceDrag.d.ts +24 -0
  70. package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
  71. package/dist/hooks/useReferenceLine.d.ts +23 -4
  72. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  73. package/dist/hooks/useReferenceLinePress.d.ts +5 -1
  74. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
  75. package/dist/hooks/useVisibleRange.d.ts +39 -0
  76. package/dist/hooks/useVisibleRange.d.ts.map +1 -0
  77. package/dist/hooks/useYAxis.d.ts +1 -1
  78. package/dist/hooks/useYAxis.d.ts.map +1 -1
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/math/markerCluster.d.ts +47 -0
  82. package/dist/math/markerCluster.d.ts.map +1 -0
  83. package/dist/math/markers.d.ts +10 -0
  84. package/dist/math/markers.d.ts.map +1 -1
  85. package/dist/math/referenceDrag.d.ts +24 -0
  86. package/dist/math/referenceDrag.d.ts.map +1 -0
  87. package/dist/math/referenceGroup.d.ts +31 -0
  88. package/dist/math/referenceGroup.d.ts.map +1 -0
  89. package/dist/math/referenceLines.d.ts +33 -7
  90. package/dist/math/referenceLines.d.ts.map +1 -1
  91. package/dist/types.d.ts +524 -21
  92. package/dist/types.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/components/BadgeOverlay.tsx +26 -1
  95. package/src/components/ChartOverlayLayer.tsx +32 -0
  96. package/src/components/CustomMarkerOverlay.tsx +150 -57
  97. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  98. package/src/components/CustomTooltipOverlay.tsx +27 -7
  99. package/src/components/DotOverlay.tsx +9 -0
  100. package/src/components/LiveChart.tsx +550 -32
  101. package/src/components/LiveChartSeries.tsx +111 -5
  102. package/src/components/MarkerOverlay.tsx +92 -6
  103. package/src/components/MultiSeriesDots.tsx +12 -3
  104. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  105. package/src/components/ReferenceLineOverlay.tsx +105 -32
  106. package/src/components/XAxisOverlay.tsx +9 -2
  107. package/src/components/YAxisOverlay.tsx +49 -28
  108. package/src/constants.ts +17 -0
  109. package/src/core/liveChartEngineTick.ts +81 -8
  110. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  111. package/src/core/resolveConfig.ts +133 -2
  112. package/src/core/useLiveChartEngine.ts +111 -11
  113. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  114. package/src/draw/grid.ts +71 -2
  115. package/src/draw/markerAtlas.ts +117 -3
  116. package/src/draw/volume.ts +190 -0
  117. package/src/hooks/crosshairShared.ts +37 -9
  118. package/src/hooks/overlayScale.ts +59 -0
  119. package/src/hooks/resolveChartLayout.ts +33 -0
  120. package/src/hooks/useBadge.ts +89 -21
  121. package/src/hooks/useCandlePaths.ts +104 -7
  122. package/src/hooks/useChartOverlayContext.ts +93 -0
  123. package/src/hooks/useChartPaths.ts +14 -1
  124. package/src/hooks/useCrosshair.ts +33 -2
  125. package/src/hooks/useLiveDot.ts +16 -5
  126. package/src/hooks/useMarkers.ts +39 -11
  127. package/src/hooks/usePanScroll.ts +253 -0
  128. package/src/hooks/usePinchZoom.ts +189 -0
  129. package/src/hooks/useReferenceDrag.ts +254 -0
  130. package/src/hooks/useReferenceLine.ts +56 -9
  131. package/src/hooks/useReferenceLinePress.ts +16 -2
  132. package/src/hooks/useVisibleRange.ts +119 -0
  133. package/src/hooks/useYAxis.ts +2 -0
  134. package/src/index.ts +17 -1
  135. package/src/math/markerCluster.ts +214 -0
  136. package/src/math/markers.ts +35 -3
  137. package/src/math/referenceDrag.ts +66 -0
  138. package/src/math/referenceGroup.ts +65 -0
  139. package/src/math/referenceLines.ts +76 -19
  140. package/src/types.ts +544 -21
@@ -39,6 +39,27 @@ export function useBadge(
39
39
  background?: string,
40
40
  badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
41
41
  badgeColorSpeed: number = MOTION_METRICS_DEFAULTS.badgeColorSpeed,
42
+ /**
43
+ * Floating-axis mode: render a tail-less pill right-aligned at the canvas edge
44
+ * (over a full-width plot) instead of inside a reserved right gutter. See
45
+ * {@link YAxisConfig.float}.
46
+ */
47
+ float = false,
48
+ /**
49
+ * Smoothed value at the visible window's right edge (engine `edgeValue`). When
50
+ * {@link followViewEdge} is set, the badge tracks this instead of the live value.
51
+ */
52
+ edgeValue?: SharedValue<number>,
53
+ /** Track the visible window's right-edge price while scrolled back. */
54
+ followViewEdge = false,
55
+ /**
56
+ * Pill corner radius in pixels. `undefined` → capsule (`pillHeight / 2`).
57
+ * Clamped to `[0, pillHeight / 2]`. The pointed tail (right-gutter mode) keeps
58
+ * anchoring on the vertical center regardless of this value.
59
+ */
60
+ radius?: number,
61
+ /** Label text color override; falls back to the variant/theme rule. */
62
+ textColorOverride?: string,
42
63
  ) {
43
64
  const colorR = useSharedValue(0);
44
65
  const colorG = useSharedValue(0);
@@ -71,21 +92,41 @@ export function useBadge(
71
92
  const dMin = engine.displayMin.get();
72
93
  const dMax = engine.displayMax.get();
73
94
  const valRange = dMax - dMin;
95
+ // Follow the visible window's right-edge price while scrolled back, else the
96
+ // live value (badge.followViewEdge). engine `edgeValue` already collapses to
97
+ // the live value when not scrolled, so the live badge is unchanged.
98
+ const liveVal =
99
+ followViewEdge && edgeValue ? edgeValue.get() : engine.displayValue.get();
74
100
  const dotY =
75
101
  valRange === 0
76
102
  ? padding.top + chartH / 2
77
- : padding.top +
78
- ((dMax - engine.displayValue.get()) / valRange) * chartH;
103
+ : padding.top + ((dMax - liveVal) / valRange) * chartH;
79
104
 
80
- const text = formatValue(engine.displayValue.get());
105
+ const text = formatValue(liveVal);
81
106
  const textW = measureFontTextWidth(font, text);
82
107
 
83
108
  const pillH = font.getSize() + badgeMetrics.padY * 2;
84
- const r = pillH / 2;
109
+ // `midY` is the pill's vertical center (the tail anchors here); `capR` is the
110
+ // corner radius — the capsule (midY) by default, or a clamped custom radius.
111
+ const midY = pillH / 2;
112
+ const capR = radius == null ? midY : Math.max(0, Math.min(radius, midY));
85
113
  const badgeY = dotY - pillH / 2;
86
114
  let textX: number;
87
115
 
88
- if (position === "left") {
116
+ if (float) {
117
+ // Floating price tag: a tail-less pill right-aligned at the canvas edge,
118
+ // floating over the full-width plot (no reserved gutter). Its pill bg keeps
119
+ // the live value readable over the candles.
120
+ const pillW = 2 * badgeMetrics.padX + textW;
121
+ const bodyRight = w - badgeMetrics.marginEdge;
122
+ const bodyLeft = bodyRight - pillW;
123
+ textX = (bodyLeft + bodyRight - textW) / 2;
124
+ b.addRRect({
125
+ rect: { x: bodyLeft, y: badgeY, width: pillW, height: pillH },
126
+ rx: capR,
127
+ ry: capR,
128
+ });
129
+ } else if (position === "left") {
89
130
  // Pill to the left of the live dot; no tail (`showTail` applies to right gutter only).
90
131
  const dotXPos = w - padding.right;
91
132
  const pillW = 2 * badgeMetrics.padX + textW;
@@ -95,8 +136,8 @@ export function useBadge(
95
136
  textX = (bodyLeft + bodyRight - textW) / 2;
96
137
  b.addRRect({
97
138
  rect: { x: bodyLeft, y: badgeY, width: pillBodyW, height: pillH },
98
- rx: r,
99
- ry: r,
139
+ rx: capR,
140
+ ry: capR,
100
141
  });
101
142
  } else {
102
143
  // Right-gutter badge (default): asymmetric layout with optional tail.
@@ -115,28 +156,54 @@ export function useBadge(
115
156
 
116
157
  if (showTail) {
117
158
  const badgeX = w - padding.right + badgeMetrics.dotGap;
118
- const cx = tl + pillW - r;
159
+ const bodyRightX = badgeX + tl + pillW;
119
160
 
120
161
  b.moveTo(badgeX + tl, badgeY);
121
- b.lineTo(badgeX + cx, badgeY);
122
- b.arcToOval(
123
- { x: badgeX + cx - r, y: badgeY, width: r * 2, height: pillH },
124
- -90,
125
- 180,
126
- false,
127
- );
162
+ if (capR >= midY) {
163
+ // Capsule cap: a single right-hand semicircle (the default badge).
164
+ const cx = tl + pillW - capR;
165
+ b.lineTo(badgeX + cx, badgeY);
166
+ b.arcToOval(
167
+ { x: badgeX + cx - capR, y: badgeY, width: capR * 2, height: pillH },
168
+ -90,
169
+ 180,
170
+ false,
171
+ );
172
+ } else {
173
+ // Custom (smaller) corner radius: square body with rounded right corners.
174
+ b.lineTo(bodyRightX - capR, badgeY);
175
+ b.arcToOval(
176
+ { x: bodyRightX - capR * 2, y: badgeY, width: capR * 2, height: capR * 2 },
177
+ -90,
178
+ 90,
179
+ false,
180
+ );
181
+ b.lineTo(bodyRightX, badgeY + pillH - capR);
182
+ b.arcToOval(
183
+ {
184
+ x: bodyRightX - capR * 2,
185
+ y: badgeY + pillH - capR * 2,
186
+ width: capR * 2,
187
+ height: capR * 2,
188
+ },
189
+ 0,
190
+ 90,
191
+ false,
192
+ );
193
+ }
128
194
  b.lineTo(badgeX + tl, badgeY + pillH);
195
+ // Tail tip stays on the vertical center (`midY`) regardless of corner radius.
129
196
  b.cubicTo(
130
197
  badgeX + badgeMetrics.tailLength + 2,
131
198
  badgeY + pillH,
132
199
  badgeX + 3,
133
- badgeY + r + badgeMetrics.tailSpread,
200
+ badgeY + midY + badgeMetrics.tailSpread,
134
201
  badgeX,
135
- badgeY + r,
202
+ badgeY + midY,
136
203
  );
137
204
  b.cubicTo(
138
205
  badgeX + 3,
139
- badgeY + r - badgeMetrics.tailSpread,
206
+ badgeY + midY - badgeMetrics.tailSpread,
140
207
  badgeX + badgeMetrics.tailLength + 2,
141
208
  badgeY,
142
209
  badgeX + tl,
@@ -146,8 +213,8 @@ export function useBadge(
146
213
  } else {
147
214
  b.addRRect({
148
215
  rect: { x: bodyLeft, y: badgeY, width: pillW, height: pillH },
149
- rx: r,
150
- ry: r,
216
+ rx: capR,
217
+ ry: capR,
151
218
  });
152
219
  }
153
220
  }
@@ -182,7 +249,8 @@ export function useBadge(
182
249
  }
183
250
 
184
251
  const textColor =
185
- variant === "minimal" ? "rgba(100,100,100,1)" : palette.badgeText;
252
+ textColorOverride ??
253
+ (variant === "minimal" ? "rgba(100,100,100,1)" : palette.badgeText);
186
254
 
187
255
  return { path: b.detach(), textX, textY, text, bgColor, textColor };
188
256
  });
@@ -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);
@@ -159,6 +166,7 @@ export function useCrosshair(
159
166
  const hasScrubAction = scrubAction != null;
160
167
  const hasOnScrubAction = onScrubAction != null;
161
168
  const dismissOnTapOutside = scrubAction?.dismissOnTapOutside ?? false;
169
+ const dismissOnAction = scrubAction?.dismissOnAction ?? false;
162
170
  const snapIncrement = scrubAction?.snap;
163
171
  const actionIcon = scrubAction?.icon ?? "+";
164
172
  const actionShowText = scrubAction?.text ?? true;
@@ -264,6 +272,7 @@ export function useCrosshair(
264
272
  tooltipShowTime,
265
273
  engine.canvasHeight.get(),
266
274
  tooltipMargin,
275
+ scrubDotY.get(),
267
276
  );
268
277
  });
269
278
 
@@ -506,6 +515,12 @@ export function useCrosshair(
506
515
  }
507
516
  return;
508
517
  }
518
+ // Defer to a marker / pressable badge under the finger: don't start a
519
+ // live scrub (or drop a crosshair) where the press is routed to an
520
+ // overlay tap. `scrubActive` is only set here, so bailing in `onStart`
521
+ // also keeps a follow-on drag from showing a crosshair — no `onUpdate`
522
+ // guard needed. (Plain-scrub counterpart of the scrub-action tap defer.)
523
+ if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
509
524
  scrubX.set(e.x);
510
525
  scrubActive.set(true);
511
526
  gestureStarted.set(true);
@@ -562,6 +577,14 @@ export function useCrosshair(
562
577
  gesture = gesture.activeOffsetX([-25, 25]).failOffsetY([-25, 25]);
563
578
  }
564
579
 
580
+ // Axis-drag time-scroll: carve the bottom "time ruler" band out of the scrub's
581
+ // hit area so a drag starting there scrolls (the pan-scroll gesture owns it)
582
+ // and never trips the crosshair. `shouldCancelWhenOutside(false)` above keeps a
583
+ // scrub that *started* in the plot tracking on into the band.
584
+ if (scrubBottomExclude > 0) {
585
+ gesture = gesture.hitSlop({ bottom: -scrubBottomExclude });
586
+ }
587
+
565
588
  // Tap: place/move the reticle, press the action badge, or dismiss the lock.
566
589
  // Composed ahead of the pan by the controller, so a tap is never swallowed.
567
590
  // Built only in scrub-action mode (the plain-scrub path never constructs a Tap).
@@ -589,6 +612,9 @@ export function useCrosshair(
589
612
  lockY.get(),
590
613
  candleJson,
591
614
  );
615
+ // Clear the reticle once the action fires, so no crosshair lingers after
616
+ // the order is placed via the badge.
617
+ if (dismissOnAction) lockActive.set(false);
592
618
  }
593
619
  return;
594
620
  }
@@ -626,12 +652,17 @@ export function useCrosshair(
626
652
  lockActive.set(true);
627
653
  };
628
654
 
629
- const tapGesture = hasScrubAction
655
+ let tapGesture = hasScrubAction
630
656
  ? Gesture.Tap()
631
657
  .maxDuration(250)
632
658
  .maxDistance(SCRUB_ACTION_TAP_SLOP)
633
659
  .onEnd(handleActionTap)
634
660
  : undefined;
661
+ // Keep the axis-drag band scroll-only for taps too — a tap there shouldn't
662
+ // drop an order-ticket reticle.
663
+ if (tapGesture && scrubBottomExclude > 0) {
664
+ tapGesture = tapGesture.hitSlop({ bottom: -scrubBottomExclude });
665
+ }
635
666
 
636
667
  return {
637
668
  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