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.
Files changed (138) 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 +18 -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 +8 -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 +12 -4
  63. package/dist/hooks/useMarkers.d.ts.map +1 -1
  64. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  65. package/dist/hooks/usePanScroll.d.ts +82 -0
  66. package/dist/hooks/usePanScroll.d.ts.map +1 -0
  67. package/dist/hooks/usePinchZoom.d.ts +74 -0
  68. package/dist/hooks/usePinchZoom.d.ts.map +1 -0
  69. package/dist/hooks/useReferenceDrag.d.ts +24 -0
  70. package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
  71. package/dist/hooks/useReferenceLine.d.ts +23 -4
  72. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  73. package/dist/hooks/useReferenceLinePress.d.ts +5 -1
  74. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
  75. package/dist/hooks/useVisibleRange.d.ts +39 -0
  76. package/dist/hooks/useVisibleRange.d.ts.map +1 -0
  77. package/dist/index.d.ts +2 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/math/markerCluster.d.ts +47 -0
  80. package/dist/math/markerCluster.d.ts.map +1 -0
  81. package/dist/math/markers.d.ts +15 -0
  82. package/dist/math/markers.d.ts.map +1 -1
  83. package/dist/math/referenceDrag.d.ts +24 -0
  84. package/dist/math/referenceDrag.d.ts.map +1 -0
  85. package/dist/math/referenceGroup.d.ts +31 -0
  86. package/dist/math/referenceGroup.d.ts.map +1 -0
  87. package/dist/math/referenceLines.d.ts +33 -7
  88. package/dist/math/referenceLines.d.ts.map +1 -1
  89. package/dist/types.d.ts +512 -21
  90. package/dist/types.d.ts.map +1 -1
  91. package/package.json +1 -1
  92. package/src/components/BadgeOverlay.tsx +26 -1
  93. package/src/components/ChartOverlayLayer.tsx +32 -0
  94. package/src/components/CustomMarkerOverlay.tsx +153 -53
  95. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  96. package/src/components/CustomTooltipOverlay.tsx +27 -7
  97. package/src/components/DotOverlay.tsx +9 -0
  98. package/src/components/LiveChart.tsx +561 -34
  99. package/src/components/LiveChartSeries.tsx +110 -5
  100. package/src/components/MarkerOverlay.tsx +100 -6
  101. package/src/components/MultiSeriesDots.tsx +12 -3
  102. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  103. package/src/components/ReferenceLineOverlay.tsx +105 -32
  104. package/src/components/XAxisOverlay.tsx +9 -2
  105. package/src/components/YAxisOverlay.tsx +48 -26
  106. package/src/constants.ts +10 -0
  107. package/src/core/liveChartEngineTick.ts +81 -8
  108. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  109. package/src/core/resolveConfig.ts +122 -1
  110. package/src/core/useLiveChartEngine.ts +103 -10
  111. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  112. package/src/draw/grid.ts +13 -1
  113. package/src/draw/markerAtlas.ts +117 -3
  114. package/src/draw/volume.ts +190 -0
  115. package/src/hooks/crosshairShared.ts +37 -9
  116. package/src/hooks/overlayScale.ts +59 -0
  117. package/src/hooks/resolveChartLayout.ts +33 -0
  118. package/src/hooks/useBadge.ts +89 -21
  119. package/src/hooks/useCandlePaths.ts +104 -7
  120. package/src/hooks/useChartOverlayContext.ts +93 -0
  121. package/src/hooks/useChartPaths.ts +14 -1
  122. package/src/hooks/useCrosshair.ts +29 -2
  123. package/src/hooks/useLiveDot.ts +16 -5
  124. package/src/hooks/useMarkers.ts +43 -11
  125. package/src/hooks/useMultiSeriesLinePaths.ts +2 -1
  126. package/src/hooks/usePanScroll.ts +253 -0
  127. package/src/hooks/usePinchZoom.ts +189 -0
  128. package/src/hooks/useReferenceDrag.ts +254 -0
  129. package/src/hooks/useReferenceLine.ts +56 -9
  130. package/src/hooks/useReferenceLinePress.ts +16 -2
  131. package/src/hooks/useVisibleRange.ts +119 -0
  132. package/src/index.ts +17 -1
  133. package/src/math/markerCluster.ts +214 -0
  134. package/src/math/markers.ts +54 -9
  135. package/src/math/referenceDrag.ts +66 -0
  136. package/src/math/referenceGroup.ts +65 -0
  137. package/src/math/referenceLines.ts +76 -19
  138. 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
- for (let val = first; val <= displayMax; val += fine) {
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;
@@ -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
- /** Default glyph color per kind when `marker.color` is unset. */
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
- 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,