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.
- package/dist/components/BadgeOverlay.d.ts +9 -1
- package/dist/components/BadgeOverlay.d.ts.map +1 -1
- package/dist/components/ChartOverlayLayer.d.ts +19 -0
- package/dist/components/ChartOverlayLayer.d.ts.map +1 -0
- package/dist/components/CustomMarkerOverlay.d.ts +16 -6
- package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
- package/dist/components/CustomReferenceLineOverlay.d.ts +36 -0
- package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -0
- package/dist/components/CustomTooltipOverlay.d.ts +5 -2
- package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +7 -1
- package/dist/components/DotOverlay.d.ts.map +1 -1
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/MarkerOverlay.d.ts +6 -3
- package/dist/components/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/MultiSeriesDots.d.ts +4 -1
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/components/ReferenceLineGroupOverlay.d.ts +27 -0
- package/dist/components/ReferenceLineGroupOverlay.d.ts.map +1 -0
- package/dist/components/ReferenceLineOverlay.d.ts +25 -2
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/XAxisOverlay.d.ts +7 -1
- package/dist/components/XAxisOverlay.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts +12 -1
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/liveChartEngineTick.d.ts +29 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts +18 -0
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +56 -2
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +58 -7
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +8 -2
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/draw/grid.d.ts.map +1 -1
- package/dist/draw/markerAtlas.d.ts +20 -2
- package/dist/draw/markerAtlas.d.ts.map +1 -1
- package/dist/draw/volume.d.ts +25 -0
- package/dist/draw/volume.d.ts.map +1 -0
- package/dist/hooks/crosshairShared.d.ts +12 -6
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/overlayScale.d.ts +22 -0
- package/dist/hooks/overlayScale.d.ts.map +1 -0
- package/dist/hooks/resolveChartLayout.d.ts +9 -0
- package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
- package/dist/hooks/useBadge.d.ts +22 -1
- package/dist/hooks/useBadge.d.ts.map +1 -1
- package/dist/hooks/useCandlePaths.d.ts +6 -0
- package/dist/hooks/useCandlePaths.d.ts.map +1 -1
- package/dist/hooks/useChartOverlayContext.d.ts +45 -0
- package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
- package/dist/hooks/useChartPaths.d.ts +8 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +9 -2
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useLiveDot.d.ts +6 -1
- package/dist/hooks/useLiveDot.d.ts.map +1 -1
- package/dist/hooks/useMarkers.d.ts +9 -4
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/hooks/usePanScroll.d.ts +82 -0
- package/dist/hooks/usePanScroll.d.ts.map +1 -0
- package/dist/hooks/usePinchZoom.d.ts +74 -0
- package/dist/hooks/usePinchZoom.d.ts.map +1 -0
- package/dist/hooks/useReferenceDrag.d.ts +24 -0
- package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
- package/dist/hooks/useReferenceLine.d.ts +23 -4
- package/dist/hooks/useReferenceLine.d.ts.map +1 -1
- package/dist/hooks/useReferenceLinePress.d.ts +5 -1
- package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
- package/dist/hooks/useVisibleRange.d.ts +39 -0
- package/dist/hooks/useVisibleRange.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/markerCluster.d.ts +47 -0
- package/dist/math/markerCluster.d.ts.map +1 -0
- package/dist/math/markers.d.ts +10 -0
- package/dist/math/markers.d.ts.map +1 -1
- package/dist/math/referenceDrag.d.ts +24 -0
- package/dist/math/referenceDrag.d.ts.map +1 -0
- package/dist/math/referenceGroup.d.ts +31 -0
- package/dist/math/referenceGroup.d.ts.map +1 -0
- package/dist/math/referenceLines.d.ts +33 -7
- package/dist/math/referenceLines.d.ts.map +1 -1
- package/dist/types.d.ts +505 -21
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/BadgeOverlay.tsx +26 -1
- package/src/components/ChartOverlayLayer.tsx +32 -0
- package/src/components/CustomMarkerOverlay.tsx +150 -57
- package/src/components/CustomReferenceLineOverlay.tsx +250 -0
- package/src/components/CustomTooltipOverlay.tsx +27 -7
- package/src/components/DotOverlay.tsx +9 -0
- package/src/components/LiveChart.tsx +549 -32
- package/src/components/LiveChartSeries.tsx +110 -5
- package/src/components/MarkerOverlay.tsx +92 -6
- package/src/components/MultiSeriesDots.tsx +12 -3
- package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
- package/src/components/ReferenceLineOverlay.tsx +105 -32
- package/src/components/XAxisOverlay.tsx +9 -2
- package/src/components/YAxisOverlay.tsx +48 -26
- package/src/constants.ts +10 -0
- package/src/core/liveChartEngineTick.ts +81 -8
- package/src/core/liveChartSeriesEngineTick.ts +50 -9
- package/src/core/resolveConfig.ts +122 -1
- package/src/core/useLiveChartEngine.ts +103 -10
- package/src/core/useLiveChartSeriesEngine.ts +26 -3
- package/src/draw/grid.ts +13 -1
- package/src/draw/markerAtlas.ts +117 -3
- package/src/draw/volume.ts +190 -0
- package/src/hooks/crosshairShared.ts +37 -9
- package/src/hooks/overlayScale.ts +59 -0
- package/src/hooks/resolveChartLayout.ts +33 -0
- package/src/hooks/useBadge.ts +89 -21
- package/src/hooks/useCandlePaths.ts +104 -7
- package/src/hooks/useChartOverlayContext.ts +93 -0
- package/src/hooks/useChartPaths.ts +14 -1
- package/src/hooks/useCrosshair.ts +29 -2
- package/src/hooks/useLiveDot.ts +16 -5
- package/src/hooks/useMarkers.ts +39 -11
- package/src/hooks/usePanScroll.ts +253 -0
- package/src/hooks/usePinchZoom.ts +189 -0
- package/src/hooks/useReferenceDrag.ts +254 -0
- package/src/hooks/useReferenceLine.ts +56 -9
- package/src/hooks/useReferenceLinePress.ts +16 -2
- package/src/hooks/useVisibleRange.ts +119 -0
- package/src/index.ts +17 -1
- package/src/math/markerCluster.ts +214 -0
- package/src/math/markers.ts +35 -3
- package/src/math/referenceDrag.ts +66 -0
- package/src/math/referenceGroup.ts +65 -0
- package/src/math/referenceLines.ts +76 -19
- 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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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,
|
package/src/hooks/useBadge.ts
CHANGED
|
@@ -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(
|
|
105
|
+
const text = formatValue(liveVal);
|
|
81
106
|
const textW = measureFontTextWidth(font, text);
|
|
82
107
|
|
|
83
108
|
const pillH = font.getSize() + badgeMetrics.padY * 2;
|
|
84
|
-
|
|
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 (
|
|
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:
|
|
99
|
-
ry:
|
|
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
|
|
159
|
+
const bodyRightX = badgeX + tl + pillW;
|
|
119
160
|
|
|
120
161
|
b.moveTo(badgeX + tl, badgeY);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 +
|
|
200
|
+
badgeY + midY + badgeMetrics.tailSpread,
|
|
134
201
|
badgeX,
|
|
135
|
-
badgeY +
|
|
202
|
+
badgeY + midY,
|
|
136
203
|
);
|
|
137
204
|
b.cubicTo(
|
|
138
205
|
badgeX + 3,
|
|
139
|
-
badgeY +
|
|
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:
|
|
150
|
-
ry:
|
|
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
|
-
|
|
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
|
});
|