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
@@ -0,0 +1,190 @@
1
+ import { CANDLE_METRICS_DEFAULTS } from "../constants";
2
+ import type { CandleMetrics, CandlePoint } from "../types";
3
+ import type { ChartPadding } from "./line";
4
+
5
+ export interface VolumeBar {
6
+ x: number;
7
+ y: number;
8
+ w: number;
9
+ h: number;
10
+ up: boolean;
11
+ }
12
+
13
+ export interface VolumeGeometry {
14
+ bars: VolumeBar[];
15
+ }
16
+
17
+ function volumeTimeToX(
18
+ t: number,
19
+ padLeft: number,
20
+ winStart: number,
21
+ windowSecs: number,
22
+ chartW: number,
23
+ ): number {
24
+ "worklet";
25
+ return padLeft + ((t - winStart) / windowSecs) * chartW;
26
+ }
27
+
28
+ /** Whether a candle's bucket overlaps the visible window. Only the live candle
29
+ * needs this — committed candles are pre-filtered by the binary search + break. */
30
+ function candleVisible(
31
+ c: CandlePoint,
32
+ winStart: number,
33
+ winEnd: number,
34
+ candleWidthSecs: number,
35
+ ): boolean {
36
+ "worklet";
37
+ return c.time + candleWidthSecs >= winStart && c.time <= winEnd;
38
+ }
39
+
40
+ /** One candle → a volume bar appended to `bars` (skips zero/empty volume). */
41
+ function appendVolumeBar(
42
+ c: CandlePoint,
43
+ winStart: number,
44
+ windowSecs: number,
45
+ chartW: number,
46
+ chartLeft: number,
47
+ chartRight: number,
48
+ padLeft: number,
49
+ candleWidthSecs: number,
50
+ bodyW: number,
51
+ maxVol: number,
52
+ baseline: number,
53
+ bandHeight: number,
54
+ bars: VolumeBar[],
55
+ ): void {
56
+ "worklet";
57
+ const vol = c.volume ?? 0;
58
+ if (vol <= 0) return;
59
+
60
+ const xCenter = volumeTimeToX(
61
+ c.time + candleWidthSecs / 2,
62
+ padLeft,
63
+ winStart,
64
+ windowSecs,
65
+ chartW,
66
+ );
67
+ if (xCenter < chartLeft - bodyW / 2 || xCenter > chartRight + bodyW / 2)
68
+ return;
69
+
70
+ let bx = xCenter - bodyW / 2;
71
+ let bw = bodyW;
72
+ if (bx < chartLeft) {
73
+ bw -= chartLeft - bx;
74
+ bx = chartLeft;
75
+ }
76
+ if (bx + bw > chartRight) {
77
+ bw = chartRight - bx;
78
+ }
79
+ /* istanbul ignore next -- clipped to zero width */
80
+ if (bw <= 0) return;
81
+
82
+ const h = (vol / maxVol) * bandHeight;
83
+ bars.push({ x: bx, y: baseline - h, w: bw, h, up: c.close >= c.open });
84
+ }
85
+
86
+ /**
87
+ * Build screen-space volume bars for the reserved band below the candles. Bars
88
+ * align under the candle bodies (same width + center) and grow up from the band
89
+ * baseline (the x-axis line). Heights are normalized to the largest *visible*
90
+ * volume so the tallest bar fills `bandHeight`. Pure worklet — no Skia imports,
91
+ * consumed by {@link useCandlePaths}.
92
+ *
93
+ * `padding.bottom` is the already-reserved bottom (it includes `bandHeight`), so
94
+ * `canvasH - padding.bottom` is the candle plot's bottom = the top of the band,
95
+ * and the baseline sits `bandHeight` below it (the x-axis line).
96
+ */
97
+ export function buildVolumeGeometry(
98
+ candles: CandlePoint[],
99
+ liveCandle: CandlePoint | null,
100
+ padding: ChartPadding,
101
+ canvasW: number,
102
+ canvasH: number,
103
+ winStart: number,
104
+ windowSecs: number,
105
+ bandHeight: number,
106
+ candleWidthSecs: number,
107
+ metrics: CandleMetrics = CANDLE_METRICS_DEFAULTS,
108
+ ): VolumeGeometry {
109
+ "worklet";
110
+ const chartW = canvasW - padding.left - padding.right;
111
+ if (bandHeight <= 0 || chartW <= 0) return { bars: [] };
112
+
113
+ const slotPx = (candleWidthSecs / windowSecs) * chartW;
114
+ const bodyW = Math.max(
115
+ 1,
116
+ Math.min(slotPx * metrics.bodyWidthRatio, slotPx - 2, metrics.maxBodyPx),
117
+ );
118
+
119
+ const winEnd = winStart + windowSecs;
120
+ const chartLeft = padding.left;
121
+ const chartRight = canvasW - padding.right;
122
+ const padLeft = padding.left;
123
+ const baseline = canvasH - padding.bottom + bandHeight;
124
+
125
+ // Binary search for the first candle overlapping the window (same as candles).
126
+ let lo = 0;
127
+ let hi = candles.length;
128
+ while (lo < hi) {
129
+ const mid = (lo + hi) >> 1;
130
+ if (candles[mid].time + candleWidthSecs < winStart) lo = mid + 1;
131
+ else hi = mid;
132
+ }
133
+
134
+ // Pass 1 — largest visible volume (the bar that fills the band). The live
135
+ // candle counts too so a tall in-progress bar doesn't overflow the band.
136
+ let maxVol = 0;
137
+ for (let i = lo; i < candles.length; i++) {
138
+ /* istanbul ignore next -- past visible window */
139
+ if (candles[i].time > winEnd) break;
140
+ const v = candles[i].volume ?? 0;
141
+ if (v > maxVol) maxVol = v;
142
+ }
143
+ if (liveCandle && candleVisible(liveCandle, winStart, winEnd, candleWidthSecs)) {
144
+ const v = liveCandle.volume ?? 0;
145
+ if (v > maxVol) maxVol = v;
146
+ }
147
+
148
+ if (maxVol <= 0) return { bars: [] };
149
+
150
+ // Pass 2 — build the bars normalized to maxVol.
151
+ const bars: VolumeBar[] = [];
152
+ for (let i = lo; i < candles.length; i++) {
153
+ /* istanbul ignore next -- past visible window */
154
+ if (candles[i].time > winEnd) break;
155
+ appendVolumeBar(
156
+ candles[i],
157
+ winStart,
158
+ windowSecs,
159
+ chartW,
160
+ chartLeft,
161
+ chartRight,
162
+ padLeft,
163
+ candleWidthSecs,
164
+ bodyW,
165
+ maxVol,
166
+ baseline,
167
+ bandHeight,
168
+ bars,
169
+ );
170
+ }
171
+ if (liveCandle && candleVisible(liveCandle, winStart, winEnd, candleWidthSecs)) {
172
+ appendVolumeBar(
173
+ liveCandle,
174
+ winStart,
175
+ windowSecs,
176
+ chartW,
177
+ chartLeft,
178
+ chartRight,
179
+ padLeft,
180
+ candleWidthSecs,
181
+ bodyW,
182
+ maxVol,
183
+ baseline,
184
+ bandHeight,
185
+ bars,
186
+ );
187
+ }
188
+
189
+ return { bars };
190
+ }
@@ -204,16 +204,22 @@ export function computeTooltipLayout(
204
204
  monoCharWidth = 0,
205
205
  /** Where the pill sits relative to the scrub line. `"side"` offsets it right
206
206
  * (flipping left near the edge); `"top"`/`"bottom"` center it over the line,
207
- * clamped into the plot and pinned to the plot's top/bottom. */
208
- placement: "side" | "top" | "bottom" = "side",
207
+ * clamped into the plot and pinned to the plot's top/bottom; `"point"`
208
+ * centers it over the line and floats it just above the scrub dot (flipping
209
+ * below when there's no room), deriving its Y from `scrubDotY`. */
210
+ placement: "side" | "top" | "bottom" | "point" = "side",
209
211
  /** Render the value row. */
210
212
  showValue = true,
211
213
  /** Render the time row. */
212
214
  showTime = true,
213
- /** Canvas height in px — needed to pin `"bottom"` placement. */
215
+ /** Canvas height in px — needed to pin `"bottom"`/`"point"` placement. */
214
216
  canvasHeight = 0,
215
- /** Gap in px between the pill and the plot edge it's pinned to. */
217
+ /** Gap in px between the pill and the plot edge it's pinned to — and, for
218
+ * `"point"`, between the scrub dot's center and the pill's nearest edge. */
216
219
  margin = TOOLTIP_TOP_MARGIN,
220
+ /** Scrub intersection Y in canvas px (see {@link computeScrubDotY}) — the
221
+ * anchor for `"point"` placement. -1 (default) falls back to a top pin. */
222
+ scrubDotY = -1,
217
223
  ): TooltipLayout {
218
224
  "worklet";
219
225
  if (!scrubActive || scrubValue === null) return HIDDEN_TOOLTIP;
@@ -267,10 +273,30 @@ export function computeTooltipLayout(
267
273
  Math.max(scrubX - pillW / 2, leftBound),
268
274
  Math.max(leftBound, rightBound),
269
275
  );
270
- pillY =
271
- placement === "top"
272
- ? padding.top + margin
273
- : canvasHeight - padding.bottom - margin - totalH;
276
+ if (placement === "point") {
277
+ // Float just above the scrub dot, flipping below when there's no room
278
+ // above; clamp into the plot so the pill never spills past the top/bottom.
279
+ // A missing dot Y (canvas not laid out) falls back to a top pin.
280
+ if (scrubDotY < 0) {
281
+ pillY = padding.top + margin;
282
+ } else {
283
+ const topLimit = padding.top + TOOLTIP_EDGE_GAP;
284
+ const bottomLimit =
285
+ canvasHeight - padding.bottom - TOOLTIP_EDGE_GAP - totalH;
286
+ const aboveY = scrubDotY - margin - totalH;
287
+ const belowY = scrubDotY + margin;
288
+ pillY = aboveY >= topLimit ? aboveY : belowY;
289
+ pillY = Math.min(
290
+ Math.max(pillY, topLimit),
291
+ Math.max(topLimit, bottomLimit),
292
+ );
293
+ }
294
+ } else {
295
+ pillY =
296
+ placement === "top"
297
+ ? padding.top + margin
298
+ : canvasHeight - padding.bottom - margin - totalH;
299
+ }
274
300
  }
275
301
 
276
302
  // line1Y = first rendered row's baseline; line2Y = second (when both shown).
@@ -679,11 +705,12 @@ export function deriveCrosshairTooltipSingle(
679
705
  formatTime: (t: number) => string,
680
706
  font: SkFont,
681
707
  monoCharWidth = 0,
682
- placement: "side" | "top" | "bottom" = "side",
708
+ placement: "side" | "top" | "bottom" | "point" = "side",
683
709
  showValue = true,
684
710
  showTime = true,
685
711
  canvasHeight = 0,
686
712
  margin = TOOLTIP_TOP_MARGIN,
713
+ scrubDotY = -1,
687
714
  ): TooltipLayout {
688
715
  "worklet";
689
716
  if (!scrubActive || scrubTime < 0) return HIDDEN_TOOLTIP;
@@ -703,5 +730,6 @@ export function deriveCrosshairTooltipSingle(
703
730
  showTime,
704
731
  canvasHeight,
705
732
  margin,
733
+ scrubDotY,
706
734
  );
707
735
  }
@@ -0,0 +1,59 @@
1
+ import type { ChartScale } from "../types";
2
+
3
+ /**
4
+ * Pure worklet projections for the `renderOverlay` bridge ({@link ChartOverlayContext}).
5
+ *
6
+ * Each takes a {@link ChartScale} **snapshot** rather than reading any SharedValue
7
+ * itself — so the consumer's `useAnimatedStyle` / `useDerivedValue` becomes
8
+ * reactive simply by reading `scale.get()` (which subscribes it to the per-frame
9
+ * scale), and these stay trivially unit-testable as plain functions of data.
10
+ */
11
+
12
+ /** Maps a price (Y-axis value) to its canvas Y pixel. -1 when not laid out. */
13
+ export function priceToY(price: number, scale: ChartScale): number {
14
+ "worklet";
15
+ const { top, bottom } = scale.plot;
16
+ const chartH = bottom - top;
17
+ if (chartH <= 0) return -1;
18
+ const range = scale.max - scale.min;
19
+ if (range === 0) return top + chartH / 2;
20
+ return top + ((scale.max - price) / range) * chartH;
21
+ }
22
+
23
+ /**
24
+ * Inverse of {@link priceToY}: maps a canvas Y pixel back to a price. Clamps to
25
+ * the visible range (a level can't sit beyond the axis bounds); null when not
26
+ * laid out.
27
+ */
28
+ export function yToPrice(y: number, scale: ChartScale): number | null {
29
+ "worklet";
30
+ const { top, bottom } = scale.plot;
31
+ const chartH = bottom - top;
32
+ if (chartH <= 0) return null;
33
+ const range = scale.max - scale.min;
34
+ if (range === 0) return scale.min;
35
+ const clampedY = Math.min(bottom, Math.max(top, y));
36
+ const frac = (clampedY - top) / chartH; // 0 at top, 1 at bottom
37
+ return scale.max - frac * range;
38
+ }
39
+
40
+ /** Maps a unix-seconds timestamp to its canvas X pixel. -1 when not laid out. */
41
+ export function timeToX(time: number, scale: ChartScale): number {
42
+ "worklet";
43
+ const { left, right } = scale.plot;
44
+ const chartW = right - left;
45
+ if (chartW <= 0) return -1;
46
+ if (scale.window <= 0) return left;
47
+ const winStart = scale.now - scale.window;
48
+ return left + ((time - winStart) / scale.window) * chartW;
49
+ }
50
+
51
+ /** Inverse of {@link timeToX}: maps a canvas X pixel back to a unix-seconds timestamp. */
52
+ export function xToTime(x: number, scale: ChartScale): number {
53
+ "worklet";
54
+ const { left, right } = scale.plot;
55
+ const chartW = right - left;
56
+ if (chartW <= 0) return -1;
57
+ const winStart = scale.now - scale.window;
58
+ return winStart + ((x - left) / chartW) * scale.window;
59
+ }
@@ -16,6 +16,8 @@ export interface ChartLayoutConfig {
16
16
  lineWidthOverride?: number;
17
17
  insetsOverride?: ChartInsets;
18
18
  yAxis: boolean;
19
+ /** Float the y-axis over a full-width plot (no reserved right gutter). */
20
+ yAxisFloat?: boolean;
19
21
  badge: boolean;
20
22
  /** Badge pill geometry tokens. Omit for built-in defaults. */
21
23
  badgeMetrics?: BadgeMetrics;
@@ -26,6 +28,13 @@ export interface ChartLayoutConfig {
26
28
  badgeUsesRightGutter?: boolean;
27
29
  /** When false, bottom inset shrinks (no x-axis labels). Default true. */
28
30
  xAxis?: boolean;
31
+ /**
32
+ * Reserved volume-bar band height (px) below the candles. Added to the bottom
33
+ * padding so the candle/price plot shrinks by this much; the x-axis offsets
34
+ * back down by it so its labels stay pinned to the bottom (see XAxisOverlay).
35
+ * `0`/omitted = no band. An explicit `insetsOverride.bottom` suppresses it.
36
+ */
37
+ volumeBandHeight?: number;
29
38
  /** Skia font for measuring label width. When provided with formatValue + currentValue, padding auto-sizes. */
30
39
  font?: SkFont;
31
40
  /** Worklet formatter — called on JS thread here to measure label width */
@@ -52,6 +61,9 @@ export interface ChartLayoutResult {
52
61
  padding: ChartPadding;
53
62
  }
54
63
 
64
+ /** Right inset (px) for a floating y-axis — just keeps the plot off the edge. */
65
+ const FLOAT_AXIS_RIGHT_INSET = 6;
66
+
55
67
  export function resolveChartLayout(
56
68
  config: ChartLayoutConfig,
57
69
  ): ChartLayoutResult {
@@ -156,6 +168,27 @@ export function resolveChartLayout(
156
168
  };
157
169
  }
158
170
 
171
+ // Floating axis: collapse the right gutter LAST so it wins over the label,
172
+ // pulse, and badge reservations above — the plot runs full-width with the price
173
+ // axis (and the live-value badge) floating on top. An explicit right inset still
174
+ // wins. (The live-dot pulse may clip at the right edge in this mode.)
175
+ if (config.yAxisFloat && config.insetsOverride?.right == null) {
176
+ padding = { ...padding, right: FLOAT_AXIS_RIGHT_INSET };
177
+ }
178
+
179
+ // Volume band: reserve extra space at the bottom by ADDING the band height to
180
+ // the (already finalized) bottom padding, so every price Y-projection shrinks
181
+ // for free. The x-axis is shifted back down by the same amount (XAxisOverlay)
182
+ // so its labels stay at the very bottom, below the band. An explicit bottom
183
+ // inset opts out (the caller owns the full bottom space).
184
+ if (
185
+ config.volumeBandHeight &&
186
+ config.volumeBandHeight > 0 &&
187
+ config.insetsOverride?.bottom == null
188
+ ) {
189
+ padding = { ...padding, bottom: padding.bottom + config.volumeBandHeight };
190
+ }
191
+
159
192
  return {
160
193
  strokeWidth: config.lineWidthOverride ?? config.palette.lineWidth,
161
194
  padding,
@@ -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
  });