react-native-livechart 3.11.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 +18 -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 +8 -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 +12 -4
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/hooks/useMultiSeriesLinePaths.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 +15 -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 +512 -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 +153 -53
- 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 +561 -34
- package/src/components/LiveChartSeries.tsx +110 -5
- package/src/components/MarkerOverlay.tsx +100 -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 +43 -11
- package/src/hooks/useMultiSeriesLinePaths.ts +2 -1
- 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 +54 -9
- 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 +532 -21
package/src/draw/grid.ts
CHANGED
|
@@ -114,7 +114,19 @@ export function computeGridEntries(
|
|
|
114
114
|
// Phase 1: compute target alpha for every grid line
|
|
115
115
|
const targets: Record<number, number> = {};
|
|
116
116
|
const first = Math.ceil(displayMin / fine) * fine;
|
|
117
|
-
|
|
117
|
+
// Guard against a non-advancing loop. On a near-flat (but not bit-identical)
|
|
118
|
+
// range, `fine` can drop below ulp(val), so `val += fine === val` and the loop
|
|
119
|
+
// never terminates — freezing the UI thread, since this runs in a worklet every
|
|
120
|
+
// frame. `stepResolves` skips the degenerate range (also catches fine === 0);
|
|
121
|
+
// MAX_GRID_LINES is a hard backstop in case the step stalls mid-loop (real
|
|
122
|
+
// grids only have a handful of lines, so it never bites normal data).
|
|
123
|
+
const stepResolves = first + fine !== first;
|
|
124
|
+
const MAX_GRID_LINES = 1000;
|
|
125
|
+
for (
|
|
126
|
+
let val = first, lineCount = 0;
|
|
127
|
+
stepResolves && val <= displayMax && lineCount < MAX_GRID_LINES;
|
|
128
|
+
val += fine, lineCount++
|
|
129
|
+
) {
|
|
118
130
|
const y = gridValueToY(val, displayMax, valRange, padTop, chartH);
|
|
119
131
|
/* istanbul ignore next -- y stays in chart band for v in [first, displayMax] with consistent toY */
|
|
120
132
|
if (y < padTop - 2 || y > canvasHeight - padBottom + 2) continue;
|
package/src/draw/markerAtlas.ts
CHANGED
|
@@ -21,11 +21,42 @@ const PILL_TEXT_COLOR = "#ffffff";
|
|
|
21
21
|
/** Anti-alias breathing room baked around every cell so sprites don't clip. */
|
|
22
22
|
const CELL_MARGIN = 2;
|
|
23
23
|
|
|
24
|
-
/**
|
|
24
|
+
/** Glyphs composed into a collapsed-cluster count badge (digits only; capped "99"). */
|
|
25
|
+
const GROUP_CHARS = "0123456789";
|
|
26
|
+
/** Padding (px) between the digits and the inner circle edge. */
|
|
27
|
+
const BADGE_PAD = 2;
|
|
28
|
+
/** Background-colored halo ring around the badge — matches the icon-pill border. */
|
|
29
|
+
const BADGE_BORDER = 2;
|
|
30
|
+
/** Count-badge digits render at this fraction of the chart font, so two digits fit
|
|
31
|
+
* a small round badge. Shared with the overlay's per-frame digit layout. */
|
|
32
|
+
export const BADGE_TEXT_SCALE = 0.75;
|
|
33
|
+
|
|
34
|
+
/** Atlas cell sig for one count-badge glyph (`0`-`9`), drawn white. */
|
|
35
|
+
export function groupGlyphSig(ch: string): string {
|
|
36
|
+
"worklet";
|
|
37
|
+
return `gd\x1f${ch}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Atlas cell sig for the round count-badge background (one fixed circle per color). */
|
|
41
|
+
export function groupBgSig(color: string): string {
|
|
42
|
+
"worklet";
|
|
43
|
+
return `gbg\x1f${color}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Text shown in a collapsed-cluster count badge — exact up to `"99"`, capped there
|
|
47
|
+
* so it always fits the fixed round badge (≤ 2 digits). */
|
|
48
|
+
export function groupCountText(count: number): string {
|
|
49
|
+
"worklet";
|
|
50
|
+
return count > 99 ? "99" : `${count}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Default glyph color per kind when `marker.color` is unset. Worklet-safe so the
|
|
54
|
+
* per-frame overlay worklet can resolve a collapsed cluster's badge color. */
|
|
25
55
|
export function defaultMarkerColor(
|
|
26
56
|
kind: MarkerKind,
|
|
27
57
|
palette: LiveChartPalette,
|
|
28
58
|
): string {
|
|
59
|
+
"worklet";
|
|
29
60
|
switch (kind) {
|
|
30
61
|
case "trade":
|
|
31
62
|
return palette.line;
|
|
@@ -93,9 +124,13 @@ export interface MarkerAtlas {
|
|
|
93
124
|
* hi-res cell back to its logical on-canvas size. See `buildMarkerAtlas`.
|
|
94
125
|
*/
|
|
95
126
|
scale: number;
|
|
127
|
+
/** Logical ink width (px) of each count-badge digit `"0"`–`"9"`, for laying them
|
|
128
|
+
* out proportionally (so narrow digits like `1` don't leave gaps). Empty when
|
|
129
|
+
* the atlas carries no group cells (clustering off). */
|
|
130
|
+
digitWidths: Record<string, number>;
|
|
96
131
|
}
|
|
97
132
|
|
|
98
|
-
const EMPTY_ATLAS: MarkerAtlas = { image: null, cells: {}, scale: 1 };
|
|
133
|
+
const EMPTY_ATLAS: MarkerAtlas = { image: null, cells: {}, scale: 1, digitWidths: {} };
|
|
99
134
|
|
|
100
135
|
function fillPaint(color: string): SkPaint {
|
|
101
136
|
const p = Skia.Paint();
|
|
@@ -260,6 +295,51 @@ function cellSpec(m: Marker, palette: LiveChartPalette, font: SkFont): CellSpec
|
|
|
260
295
|
};
|
|
261
296
|
}
|
|
262
297
|
|
|
298
|
+
/** One count-badge glyph (`0`-`9` / `+`) in white, on a uniform-width box so the
|
|
299
|
+
* digits lay out tabular. `uw`/`gh` are the shared logical glyph metrics. */
|
|
300
|
+
function groupGlyphCellSpec(
|
|
301
|
+
ch: string,
|
|
302
|
+
uw: number,
|
|
303
|
+
gh: number,
|
|
304
|
+
font: SkFont,
|
|
305
|
+
): CellSpec {
|
|
306
|
+
const b = font.measureText(ch);
|
|
307
|
+
const dx = b.x + b.width / 2;
|
|
308
|
+
const dy = b.y + b.height / 2;
|
|
309
|
+
const m2 = CELL_MARGIN * 2;
|
|
310
|
+
return {
|
|
311
|
+
w: uw + m2,
|
|
312
|
+
h: gh + m2,
|
|
313
|
+
draw: (canvas, cx, cy) =>
|
|
314
|
+
canvas.drawText(ch, cx - dx, cy - dy, fillPaint(PILL_TEXT_COLOR), font),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Round count-badge background — a single fixed-size circle (independent of digit
|
|
319
|
+
* count) with a background-colored halo ring like the icon pills. Sized to fit the
|
|
320
|
+
* scaled two-digit text, so the badge stays small, round, and never changes size.
|
|
321
|
+
* `uw`/`gh` are the shared (full-size) glyph metrics; the text is drawn scaled. */
|
|
322
|
+
function groupBgCellSpec(
|
|
323
|
+
color: string,
|
|
324
|
+
bgColor: string,
|
|
325
|
+
uw: number,
|
|
326
|
+
gh: number,
|
|
327
|
+
): CellSpec {
|
|
328
|
+
const m2 = CELL_MARGIN * 2;
|
|
329
|
+
// Inner circle fits a centered two-digit string at BADGE_TEXT_SCALE plus padding.
|
|
330
|
+
const innerR = Math.ceil(Math.max(2 * uw, gh) * BADGE_TEXT_SCALE) / 2 + BADGE_PAD;
|
|
331
|
+
const outerR = innerR + BADGE_BORDER;
|
|
332
|
+
const diameter = 2 * outerR;
|
|
333
|
+
return {
|
|
334
|
+
w: diameter + m2,
|
|
335
|
+
h: diameter + m2,
|
|
336
|
+
draw: (canvas, cx, cy) => {
|
|
337
|
+
canvas.drawCircle(cx, cy, outerR, fillPaint(bgColor)); // halo ring
|
|
338
|
+
canvas.drawCircle(cx, cy, innerR, fillPaint(color)); // colored badge
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
263
343
|
/**
|
|
264
344
|
* Rasterize every distinct marker appearance into a single packed atlas image,
|
|
265
345
|
* once per appearance-set change (NOT per frame). The per-frame worklet then
|
|
@@ -279,6 +359,9 @@ export function buildMarkerAtlas(
|
|
|
279
359
|
palette: LiveChartPalette,
|
|
280
360
|
font: SkFont,
|
|
281
361
|
scale = 1,
|
|
362
|
+
/** Also bake count-badge cells (digits + per-color backgrounds) for
|
|
363
|
+
* `markerCluster: "stacked"` collapse. Off by default — no atlas bloat. */
|
|
364
|
+
withGroups = false,
|
|
282
365
|
): MarkerAtlas {
|
|
283
366
|
const seen = new Set<string>();
|
|
284
367
|
const specs: { sig: string; spec: CellSpec }[] = [];
|
|
@@ -290,6 +373,37 @@ export function buildMarkerAtlas(
|
|
|
290
373
|
seen.add(sig);
|
|
291
374
|
specs.push({ sig, spec: cellSpec(m, palette, font) });
|
|
292
375
|
}
|
|
376
|
+
|
|
377
|
+
// Count-badge cells: 10 uniform white digit cells shared across colors, plus one
|
|
378
|
+
// fixed round background per cluster color. A collapsed group composes its number
|
|
379
|
+
// (centered, ≤ 2 digits) over the circle in the same `drawAtlas`.
|
|
380
|
+
const digitWidths: Record<string, number> = {};
|
|
381
|
+
if (withGroups && specs.length > 0) {
|
|
382
|
+
let uw = 1;
|
|
383
|
+
let gh = 1;
|
|
384
|
+
for (const ch of GROUP_CHARS) {
|
|
385
|
+
const b = font.measureText(ch);
|
|
386
|
+
digitWidths[ch] = Math.max(1, Math.ceil(b.width));
|
|
387
|
+
if (b.width > uw) uw = b.width;
|
|
388
|
+
if (b.height > gh) gh = b.height;
|
|
389
|
+
}
|
|
390
|
+
uw = Math.ceil(uw);
|
|
391
|
+
gh = Math.ceil(gh);
|
|
392
|
+
for (const ch of GROUP_CHARS) {
|
|
393
|
+
specs.push({ sig: groupGlyphSig(ch), spec: groupGlyphCellSpec(ch, uw, gh, font) });
|
|
394
|
+
}
|
|
395
|
+
const bgColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
|
|
396
|
+
const colors = new Set<string>();
|
|
397
|
+
for (let i = 0; i < markers.length; i++) {
|
|
398
|
+
const m = markers[i];
|
|
399
|
+
if (isConnectorMarker(m)) continue;
|
|
400
|
+
colors.add(m.color ?? defaultMarkerColor(m.kind, palette));
|
|
401
|
+
}
|
|
402
|
+
for (const color of colors) {
|
|
403
|
+
specs.push({ sig: groupBgSig(color), spec: groupBgCellSpec(color, bgColor, uw, gh) });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
293
407
|
if (specs.length === 0) return EMPTY_ATLAS;
|
|
294
408
|
|
|
295
409
|
let totalW = 0;
|
|
@@ -326,5 +440,5 @@ export function buildMarkerAtlas(
|
|
|
326
440
|
}
|
|
327
441
|
const picture = recorder.finishRecordingAsPicture();
|
|
328
442
|
const image = drawAsImageFromPicture(picture, { width: texW, height: texH });
|
|
329
|
-
return { image, cells, scale };
|
|
443
|
+
return { image, cells, scale, digitWidths };
|
|
330
444
|
}
|
|
@@ -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,
|