react-native-livechart 3.12.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/components/BadgeOverlay.d.ts +9 -1
  2. package/dist/components/BadgeOverlay.d.ts.map +1 -1
  3. package/dist/components/ChartOverlayLayer.d.ts +19 -0
  4. package/dist/components/ChartOverlayLayer.d.ts.map +1 -0
  5. package/dist/components/CustomMarkerOverlay.d.ts +16 -6
  6. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomReferenceLineOverlay.d.ts +36 -0
  8. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -0
  9. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  10. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  11. package/dist/components/DotOverlay.d.ts +7 -1
  12. package/dist/components/DotOverlay.d.ts.map +1 -1
  13. package/dist/components/LiveChart.d.ts.map +1 -1
  14. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  15. package/dist/components/MarkerOverlay.d.ts +6 -3
  16. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  17. package/dist/components/MultiSeriesDots.d.ts +4 -1
  18. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  19. package/dist/components/ReferenceLineGroupOverlay.d.ts +27 -0
  20. package/dist/components/ReferenceLineGroupOverlay.d.ts.map +1 -0
  21. package/dist/components/ReferenceLineOverlay.d.ts +25 -2
  22. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  23. package/dist/components/XAxisOverlay.d.ts +7 -1
  24. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  25. package/dist/components/YAxisOverlay.d.ts +12 -1
  26. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  27. package/dist/constants.d.ts +7 -0
  28. package/dist/constants.d.ts.map +1 -1
  29. package/dist/core/liveChartEngineTick.d.ts +29 -0
  30. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  31. package/dist/core/liveChartSeriesEngineTick.d.ts +18 -0
  32. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  33. package/dist/core/resolveConfig.d.ts +56 -2
  34. package/dist/core/resolveConfig.d.ts.map +1 -1
  35. package/dist/core/useLiveChartEngine.d.ts +58 -7
  36. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  37. package/dist/core/useLiveChartSeriesEngine.d.ts +8 -2
  38. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  39. package/dist/draw/grid.d.ts.map +1 -1
  40. package/dist/draw/markerAtlas.d.ts +20 -2
  41. package/dist/draw/markerAtlas.d.ts.map +1 -1
  42. package/dist/draw/volume.d.ts +25 -0
  43. package/dist/draw/volume.d.ts.map +1 -0
  44. package/dist/hooks/crosshairShared.d.ts +12 -6
  45. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  46. package/dist/hooks/overlayScale.d.ts +22 -0
  47. package/dist/hooks/overlayScale.d.ts.map +1 -0
  48. package/dist/hooks/resolveChartLayout.d.ts +9 -0
  49. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  50. package/dist/hooks/useBadge.d.ts +22 -1
  51. package/dist/hooks/useBadge.d.ts.map +1 -1
  52. package/dist/hooks/useCandlePaths.d.ts +6 -0
  53. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  54. package/dist/hooks/useChartOverlayContext.d.ts +45 -0
  55. package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
  56. package/dist/hooks/useChartPaths.d.ts +8 -1
  57. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  58. package/dist/hooks/useCrosshair.d.ts +9 -2
  59. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  60. package/dist/hooks/useLiveDot.d.ts +6 -1
  61. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  62. package/dist/hooks/useMarkers.d.ts +9 -4
  63. package/dist/hooks/useMarkers.d.ts.map +1 -1
  64. package/dist/hooks/usePanScroll.d.ts +82 -0
  65. package/dist/hooks/usePanScroll.d.ts.map +1 -0
  66. package/dist/hooks/usePinchZoom.d.ts +74 -0
  67. package/dist/hooks/usePinchZoom.d.ts.map +1 -0
  68. package/dist/hooks/useReferenceDrag.d.ts +24 -0
  69. package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
  70. package/dist/hooks/useReferenceLine.d.ts +23 -4
  71. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  72. package/dist/hooks/useReferenceLinePress.d.ts +5 -1
  73. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
  74. package/dist/hooks/useVisibleRange.d.ts +39 -0
  75. package/dist/hooks/useVisibleRange.d.ts.map +1 -0
  76. package/dist/index.d.ts +2 -1
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/math/markerCluster.d.ts +47 -0
  79. package/dist/math/markerCluster.d.ts.map +1 -0
  80. package/dist/math/markers.d.ts +10 -0
  81. package/dist/math/markers.d.ts.map +1 -1
  82. package/dist/math/referenceDrag.d.ts +24 -0
  83. package/dist/math/referenceDrag.d.ts.map +1 -0
  84. package/dist/math/referenceGroup.d.ts +31 -0
  85. package/dist/math/referenceGroup.d.ts.map +1 -0
  86. package/dist/math/referenceLines.d.ts +33 -7
  87. package/dist/math/referenceLines.d.ts.map +1 -1
  88. package/dist/types.d.ts +505 -21
  89. package/dist/types.d.ts.map +1 -1
  90. package/package.json +1 -1
  91. package/src/components/BadgeOverlay.tsx +26 -1
  92. package/src/components/ChartOverlayLayer.tsx +32 -0
  93. package/src/components/CustomMarkerOverlay.tsx +150 -57
  94. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  95. package/src/components/CustomTooltipOverlay.tsx +27 -7
  96. package/src/components/DotOverlay.tsx +9 -0
  97. package/src/components/LiveChart.tsx +549 -32
  98. package/src/components/LiveChartSeries.tsx +110 -5
  99. package/src/components/MarkerOverlay.tsx +92 -6
  100. package/src/components/MultiSeriesDots.tsx +12 -3
  101. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  102. package/src/components/ReferenceLineOverlay.tsx +105 -32
  103. package/src/components/XAxisOverlay.tsx +9 -2
  104. package/src/components/YAxisOverlay.tsx +48 -26
  105. package/src/constants.ts +10 -0
  106. package/src/core/liveChartEngineTick.ts +81 -8
  107. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  108. package/src/core/resolveConfig.ts +122 -1
  109. package/src/core/useLiveChartEngine.ts +103 -10
  110. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  111. package/src/draw/grid.ts +13 -1
  112. package/src/draw/markerAtlas.ts +117 -3
  113. package/src/draw/volume.ts +190 -0
  114. package/src/hooks/crosshairShared.ts +37 -9
  115. package/src/hooks/overlayScale.ts +59 -0
  116. package/src/hooks/resolveChartLayout.ts +33 -0
  117. package/src/hooks/useBadge.ts +89 -21
  118. package/src/hooks/useCandlePaths.ts +104 -7
  119. package/src/hooks/useChartOverlayContext.ts +93 -0
  120. package/src/hooks/useChartPaths.ts +14 -1
  121. package/src/hooks/useCrosshair.ts +29 -2
  122. package/src/hooks/useLiveDot.ts +16 -5
  123. package/src/hooks/useMarkers.ts +39 -11
  124. package/src/hooks/usePanScroll.ts +253 -0
  125. package/src/hooks/usePinchZoom.ts +189 -0
  126. package/src/hooks/useReferenceDrag.ts +254 -0
  127. package/src/hooks/useReferenceLine.ts +56 -9
  128. package/src/hooks/useReferenceLinePress.ts +16 -2
  129. package/src/hooks/useVisibleRange.ts +119 -0
  130. package/src/index.ts +17 -1
  131. package/src/math/markerCluster.ts +214 -0
  132. package/src/math/markers.ts +35 -3
  133. package/src/math/referenceDrag.ts +66 -0
  134. package/src/math/referenceGroup.ts +65 -0
  135. package/src/math/referenceLines.ts +76 -19
  136. package/src/types.ts +525 -21
@@ -1,7 +1,12 @@
1
- import { useLayoutEffect, useState } from "react";
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
- import { useDerivedValue, useSharedValue } from "react-native-reanimated";
4
+ import {
5
+ useAnimatedReaction,
6
+ useDerivedValue,
7
+ useSharedValue,
8
+ } from "react-native-reanimated";
9
+ import { scheduleOnRN } from "react-native-worklets";
5
10
 
6
11
  /**
7
12
  * Single-series live chart. UX and prop vocabulary parallel Benji Taylor’s
@@ -17,7 +22,7 @@ import {
17
22
  vec,
18
23
  } from "@shopify/react-native-skia";
19
24
 
20
- import { DEFAULT_ACCENT_COLOR } from "../constants";
25
+ import { DEFAULT_ACCENT_COLOR, HOLD_TO_SCRUB_MS } from "../constants";
21
26
  import {
22
27
  resolveAreaDots,
23
28
  resolveAxisLabel,
@@ -27,6 +32,7 @@ import {
27
32
  resolveGradient,
28
33
  resolveGridStyle,
29
34
  resolveLeftEdgeFade,
35
+ resolveMarkerCluster,
30
36
  resolveMetrics,
31
37
  resolvePulse,
32
38
  resolveScrub,
@@ -35,6 +41,8 @@ import {
35
41
  resolveThreshold,
36
42
  resolveTradeStream,
37
43
  resolveValueLine,
44
+ resolveVolume,
45
+ resolveZoom,
38
46
  resolveXAxis,
39
47
  resolveYAxis,
40
48
  } from "../core/resolveConfig";
@@ -47,6 +55,7 @@ import { useBadge } from "../hooks/useBadge";
47
55
  import { useCandlePaths } from "../hooks/useCandlePaths";
48
56
  import { useCanvasLayout } from "../hooks/useCanvasLayout";
49
57
  import { useChartColors } from "../hooks/useChartColors";
58
+ import { useChartOverlayContext } from "../hooks/useChartOverlayContext";
50
59
  import { useChartPaths } from "../hooks/useChartPaths";
51
60
  import { useChartReveal } from "../hooks/useChartReveal";
52
61
  import { useChartSkiaFont } from "../hooks/useChartSkiaFont";
@@ -55,9 +64,13 @@ import { useDegen } from "../hooks/useDegen";
55
64
  import { useLiveChartHasData } from "../hooks/useLiveChartHasData";
56
65
  import { useLiveDot } from "../hooks/useLiveDot";
57
66
  import { useMarkers } from "../hooks/useMarkers";
67
+ import { useReferenceDrag } from "../hooks/useReferenceDrag";
58
68
  import { useReferenceLinePress } from "../hooks/useReferenceLinePress";
59
69
  import { useModeBlend } from "../hooks/useModeBlend";
60
70
  import { useMomentum } from "../hooks/useMomentum";
71
+ import { AXIS_GRAB_MIN_PX, usePanScroll } from "../hooks/usePanScroll";
72
+ import { usePinchZoom } from "../hooks/usePinchZoom";
73
+ import { useVisibleRange } from "../hooks/useVisibleRange";
61
74
  import { useSegmentLineGradient } from "../hooks/useSegmentLineGradient";
62
75
  import { useSingleChartReverseMorphInputs } from "../hooks/useReverseMorphEngineInputs";
63
76
  import { useThreshold } from "../hooks/useThreshold";
@@ -69,7 +82,16 @@ import {
69
82
  formatValue as defaultFormatValue,
70
83
  } from "../lib/format";
71
84
  import { MONO_FONT_FAMILY } from "../lib/monoFontFamily";
72
- import { collectReferenceValues } from "../math/referenceLines";
85
+ import { computeScrubDotY } from "../hooks/crosshairShared";
86
+ import {
87
+ groupReferenceLines,
88
+ type ReferenceGrouping,
89
+ } from "../math/referenceGroup";
90
+ import {
91
+ collectReferenceValues,
92
+ referenceLineForm,
93
+ resolveReferenceGroupBadge,
94
+ } from "../math/referenceLines";
73
95
  import {
74
96
  applyPaletteOverride,
75
97
  leftEdgeFadeColorsFromBgRgb,
@@ -94,8 +116,13 @@ import {
94
116
  labelConnector,
95
117
  } from "./ExtremaConnectorOverlay";
96
118
  import { CustomMarkerOverlay } from "./CustomMarkerOverlay";
119
+ import {
120
+ CustomReferenceLineOverlay,
121
+ customReferenceLineFlags,
122
+ } from "./CustomReferenceLineOverlay";
97
123
  import { CustomTooltipOverlay } from "./CustomTooltipOverlay";
98
124
  import { BadgeOverlay } from "./BadgeOverlay";
125
+ import { ChartOverlayLayer } from "./ChartOverlayLayer";
99
126
  import { CrosshairOverlay } from "./CrosshairOverlay";
100
127
  import { DegenParticlesOverlay } from "./DegenParticlesOverlay";
101
128
  import { DotOverlay } from "./DotOverlay";
@@ -104,6 +131,7 @@ import { LoadingOverlay } from "./LoadingOverlay";
104
131
  import { MarkerOverlay } from "./MarkerOverlay";
105
132
  import { MultiSeriesTooltipStack } from "./MultiSeriesTooltipStack";
106
133
  import { ValueTextOverlay } from "./ValueTextOverlay";
134
+ import { ReferenceLineGroupOverlay } from "./ReferenceLineGroupOverlay";
107
135
  import { ReferenceLineOverlay } from "./ReferenceLineOverlay";
108
136
  import { ScrubActionOverlay } from "./ScrubActionOverlay";
109
137
  import { SegmentDividerOverlay } from "./SegmentDividerOverlay";
@@ -115,6 +143,14 @@ import { YAxisOverlay } from "./YAxisOverlay";
115
143
  /** Translucent fill alpha for the threshold profit/loss band. */
116
144
  const THRESHOLD_FILL_OPACITY = 0.16;
117
145
 
146
+ /** Stable empty grouping result (identity-stable so downstream worklets don't
147
+ * re-run) used when reference-line grouping is off. */
148
+ const EMPTY_GROUPING: ReferenceGrouping = { hidden: [], groups: [] };
149
+
150
+ /** Stable empty number array so the live-reference-values worklet stays
151
+ * referentially stable (no engine re-fit) when no line is draggable. */
152
+ const EMPTY_NUMS: number[] = [];
153
+
118
154
  /**
119
155
  * Color stops for the threshold's hard-split vertical gradient. Both arrays pair
120
156
  * with the `[0, t, t, 1]` split positions: index 0–1 paint above the split,
@@ -144,6 +180,7 @@ function thresholdStops(
144
180
  * here so the rendered pieces (`ChartStack`, `ChartScrubLayer`, `LiveChart`) stay
145
181
  * small and presentational.
146
182
  */
183
+
147
184
  function useLiveChartController({
148
185
  // ── Data ────────────────────────────────────────────────────────────────
149
186
  data,
@@ -164,6 +201,7 @@ function useLiveChartController({
164
201
  candles,
165
202
  candleWidth = 60,
166
203
  liveCandle,
204
+ volume,
167
205
 
168
206
  // ── Behaviour ───────────────────────────────────────────────────────────
169
207
  timeWindow = 30,
@@ -177,6 +215,8 @@ function useLiveChartController({
177
215
  maxValue,
178
216
  windowBuffer = 0,
179
217
  nowOverride,
218
+ timeScroll = false,
219
+ zoom = false,
180
220
  accessibilityLabel,
181
221
  accessibilityRole = "image",
182
222
  emptyText = "No data",
@@ -207,10 +247,14 @@ function useLiveChartController({
207
247
  tradeStream,
208
248
  degen,
209
249
  markers,
210
- onMarkerHover,
250
+ onMarkerPress,
211
251
  markerHitRadius = 16,
252
+ markerCluster,
212
253
  renderMarker,
213
254
  renderTooltip,
255
+ renderOverlay,
256
+ renderReferenceLine,
257
+ referenceLineGrouping,
214
258
  leftEdgeFade = true,
215
259
 
216
260
  // ── Callbacks ───────────────────────────────────────────────────────────
@@ -219,6 +263,8 @@ function useLiveChartController({
219
263
  onReferenceLinePress,
220
264
  onGestureStart,
221
265
  onGestureEnd,
266
+ onVisibleRangeChange,
267
+ onReachStart,
222
268
  onDegenShake,
223
269
  }: LiveChartProps) {
224
270
  const emptyTradeStream = useSharedValue<TradeEvent[]>([]);
@@ -239,6 +285,10 @@ function useLiveChartController({
239
285
  const scrubCfg = resolveScrub(scrub);
240
286
  // Static charts run no gestures, so scrub-action (tap/drag to lock a price) is off.
241
287
  const scrubActionCfg = isStatic ? null : resolveScrubAction(scrubAction);
288
+ // Volume bars sit below the candles — a candle-mode-only feature (inert in
289
+ // line mode, like the candle paths themselves).
290
+ const volumeCfg = isCandle ? resolveVolume(volume) : null;
291
+ const volumeBandHeight = volumeCfg?.maxHeight ?? 0;
242
292
  const gradientCfg = isCandle ? null : resolveGradient(gradient);
243
293
  // Dot-lattice area fill (clipped to the under-line region). Inert in candle
244
294
  // mode, same as the gradient fill.
@@ -264,6 +314,77 @@ function useLiveChartController({
264
314
  const allRefLines = referenceLines ?? [];
265
315
  const refValues = collectReferenceValues(allRefLines);
266
316
 
317
+ // Per-line live value overrides + drag flags for draggable lines and the custom
318
+ // `renderReferenceLine` slot. One array SharedValue each (the line count varies,
319
+ // so a single SharedValue beats N hooks). Seeded from each line's static `value`;
320
+ // the drag gesture overwrites a slot, and the effect re-seeds slots not being
321
+ // dragged when the props change (so a controlled `value` flows back in).
322
+ const dragValues = useSharedValue<number[]>([]);
323
+ const dragActive = useSharedValue<boolean[]>([]);
324
+ // Last `value` props used to seed each slot, so reconciliation can tell a
325
+ // controlled prop change (adopt it) from an unchanged prop (keep the dragged
326
+ // value — uncontrolled persistence) without resetting a drop on every re-render.
327
+ const seededRef = useRef<(number | undefined)[]>([]);
328
+ const refValueSig = allRefLines.map((l) => l.value ?? "_").join(",");
329
+ useEffect(() => {
330
+ const active = dragActive.value;
331
+ const cur = dragValues.value;
332
+ const seeded = seededRef.current;
333
+ dragValues.value = allRefLines.map((l, i) => {
334
+ const prop = l.value ?? 0;
335
+ if (active[i]) return cur[i] ?? prop; // mid-drag → keep the dragged value
336
+ if (l.value !== seeded[i]) return prop; // prop changed → adopt (controlled)
337
+ return cur[i] ?? prop; // unchanged → keep current (uncontrolled persist)
338
+ });
339
+ seededRef.current = allRefLines.map((l) => l.value);
340
+ if (dragActive.value.length !== allRefLines.length) {
341
+ dragActive.value = allRefLines.map((_, i) => active[i] ?? false);
342
+ }
343
+ // allRefLines is rebuilt every render; key off the value signature + length.
344
+ // eslint-disable-next-line react-hooks/exhaustive-deps
345
+ }, [refValueSig, allRefLines.length]);
346
+
347
+ // Form-A lines a custom `renderReferenceLine` owns → suppress their built-in tag
348
+ // (no double-draw). Probed on the JS thread, index-aligned with `allRefLines`.
349
+ const refLineCustom = customReferenceLineFlags(allRefLines, renderReferenceLine);
350
+
351
+ // Live Y values of the *draggable* Form-A lines, folded into the engine's
352
+ // axis-range fit so dragging a line toward / past the visible edge expands the
353
+ // range and the axis follows the finger in one motion (the committed values are
354
+ // already in `refValues`). `excludeFromRange` lines opt out, matching the static
355
+ // fit. Identity-stable (no re-fit) when nothing is draggable.
356
+ const draggableRefIdx = allRefLines
357
+ .map((l, i) =>
358
+ l.draggable && !l.excludeFromRange && referenceLineForm(l) === "line"
359
+ ? i
360
+ : -1,
361
+ )
362
+ .filter((i) => i >= 0);
363
+ const liveRefValues = useDerivedValue<number[]>(() => {
364
+ if (draggableRefIdx.length === 0) return EMPTY_NUMS;
365
+ const dv = dragValues.get();
366
+ const out: number[] = [];
367
+ for (let k = 0; k < draggableRefIdx.length; k++) {
368
+ const v = dv[draggableRefIdx[k]];
369
+ if (v != null) out.push(v);
370
+ }
371
+ return out;
372
+ });
373
+
374
+ // Reference-line grouping (collapse near-value handles). Resolved once; the
375
+ // per-frame clustering runs on the UI thread (see ReferenceLineGroupOverlay).
376
+ const refGroupingCfg =
377
+ typeof referenceLineGrouping === "object"
378
+ ? referenceLineGrouping
379
+ : undefined;
380
+ const refGroupingRadius = referenceLineGrouping
381
+ ? (refGroupingCfg?.radius ?? 18)
382
+ : null;
383
+ // Count-pill styling (same style/shape config as a per-line badge) + count
384
+ // formatter. Resolved once; theme color defaults are applied in the overlay.
385
+ const refGroupBadge = resolveReferenceGroupBadge(refGroupingCfg?.badge);
386
+ const refGroupFormat = refGroupingCfg?.format;
387
+
267
388
  const badgeUsesRightGutter =
268
389
  badgeCfg !== null && (badgeCfg.position ?? "right") === "right";
269
390
 
@@ -305,6 +426,47 @@ function useLiveChartController({
305
426
  palette.valueFontSize * 2,
306
427
  );
307
428
 
429
+ // Per-badge font (size/family/weight) override; reuses the chart font when
430
+ // none of the badge font knobs are set, so the gutter sizing is unchanged.
431
+ const badgeHasFontOverride =
432
+ badgeCfg?.fontSize != null ||
433
+ badgeCfg?.fontFamily != null ||
434
+ badgeCfg?.fontWeight != null;
435
+ const badgeFontOverride = useChartSkiaFont(
436
+ badgeHasFontOverride
437
+ ? {
438
+ ...fontProp,
439
+ fontFamily: badgeCfg?.fontFamily ?? fontProp?.fontFamily,
440
+ fontSize: badgeCfg?.fontSize ?? fontProp?.fontSize,
441
+ fontWeight: badgeCfg?.fontWeight ?? fontProp?.fontWeight,
442
+ }
443
+ : fontProp,
444
+ MONO_FONT_FAMILY,
445
+ palette.labelFontSize,
446
+ );
447
+ const badgeFont = badgeHasFontOverride ? badgeFontOverride : skiaFont;
448
+
449
+ // Per-badge font for the grouping count pill (same override pattern as above).
450
+ const refGroupBadgeHasFont =
451
+ refGroupBadge.fontSize != null ||
452
+ refGroupBadge.fontFamily != null ||
453
+ refGroupBadge.fontWeight != null;
454
+ const refGroupBadgeFontOverride = useChartSkiaFont(
455
+ refGroupBadgeHasFont
456
+ ? {
457
+ ...fontProp,
458
+ fontFamily: refGroupBadge.fontFamily ?? fontProp?.fontFamily,
459
+ fontSize: refGroupBadge.fontSize ?? fontProp?.fontSize,
460
+ fontWeight: refGroupBadge.fontWeight ?? fontProp?.fontWeight,
461
+ }
462
+ : fontProp,
463
+ MONO_FONT_FAMILY,
464
+ palette.labelFontSize,
465
+ );
466
+ const refGroupBadgeFont = refGroupBadgeHasFont
467
+ ? refGroupBadgeFontOverride
468
+ : skiaFont;
469
+
308
470
  const pulseConfig = pulseCfg
309
471
  ? {
310
472
  maxRadius: pulseCfg.maxRadius,
@@ -328,11 +490,26 @@ function useLiveChartController({
328
490
  setValueLayoutSample(value.get());
329
491
  }, [value]);
330
492
 
493
+ // `scrolledBack` mirrors the UI-thread scroll state (engine.viewEnd != null)
494
+ // onto the JS thread (set by the reaction below, after the engine exists).
495
+ const [scrolledBack, setScrolledBack] = useState(false);
496
+ const timeScrollEnabled = Boolean(timeScroll) && !isStatic;
497
+ const zoomCfg = resolveZoom(zoom);
498
+ const zoomEnabled = zoomCfg !== null && !isStatic;
499
+
500
+ const yAxisFloat = yAxisCfg?.float ?? false;
501
+ // With timeScroll, the floating full-width plot engages only while scrolled
502
+ // back; at the live edge the chart keeps a normal right gutter so the
503
+ // line/candles don't sit under the floating y-axis labels + badge. Without
504
+ // timeScroll, float behaves as before (always full-width).
505
+ const effectiveYAxisFloat =
506
+ yAxisFloat && (!timeScrollEnabled || scrolledBack);
331
507
  const { strokeWidth, padding: effectivePadding } = resolveChartLayout({
332
508
  palette,
333
509
  lineWidthOverride: lineProp?.width,
334
510
  insetsOverride: insets,
335
511
  yAxis: yAxisCfg !== null,
512
+ yAxisFloat: effectiveYAxisFloat,
336
513
  badge: badgeCfg !== null,
337
514
  badgeMetrics: metricsCfg.badge,
338
515
  badgeUsesRightGutter,
@@ -342,6 +519,7 @@ function useLiveChartController({
342
519
  formatValue,
343
520
  currentValue: valueLayoutSample,
344
521
  pulse: pulseConfig,
522
+ volumeBandHeight,
345
523
  });
346
524
 
347
525
  // ── Reveal state ────────────────────────────────────────────
@@ -378,6 +556,7 @@ function useLiveChartController({
378
556
  adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
379
557
  exaggerate,
380
558
  referenceValues: refValues,
559
+ liveReferenceValues: liveRefValues,
381
560
  nonNegative,
382
561
  maxValue,
383
562
  windowBuffer,
@@ -387,6 +566,19 @@ function useLiveChartController({
387
566
  liveCandle: isCandle ? liveEngine : liveCandle,
388
567
  });
389
568
 
569
+ // Mirror the UI-thread scroll state to React so the floating y-axis can keep
570
+ // a right gutter at the live edge and collapse it only while scrolled back.
571
+ // Fires once per null↔frozen transition, and only matters for float+timeScroll.
572
+ useAnimatedReaction(
573
+ () => engine.viewEnd.value != null,
574
+ /* istanbul ignore next -- Reanimated reaction; state mirrored on the JS thread, not exercised under Jest */
575
+ (isScrolled, prev) => {
576
+ if (isScrolled !== prev && yAxisFloat && timeScrollEnabled) {
577
+ scheduleOnRN(setScrolledBack, isScrolled);
578
+ }
579
+ },
580
+ );
581
+
390
582
  // ── Mode crossfade (line ↔ candle) ──────────────────────────────────
391
583
  const { lineGroupOpacity, candleGroupOpacity } = useModeBlend(
392
584
  isCandle,
@@ -396,6 +588,41 @@ function useLiveChartController({
396
588
  // ── Per-frame derived values ───────────────────────────────────────────
397
589
  const { layoutWidth, layoutHeight, onLayout } = useCanvasLayout(engine);
398
590
 
591
+ // Reference-line grouping: cluster Form-A lines by their per-frame value-Y so a
592
+ // stack of nearby orders collapses into one count handle. `groupHidden` suppresses
593
+ // the clustered lines' individual tags; the count pills read `refGroupResult`.
594
+ // Identity-stable (no work) when grouping is off.
595
+ const refGroupResult = useDerivedValue<ReferenceGrouping>(() => {
596
+ if (refGroupingRadius == null) return EMPTY_GROUPING;
597
+ const ch = engine.canvasHeight.get();
598
+ const dMin = engine.displayMin.get();
599
+ const dMax = engine.displayMax.get();
600
+ const top = effectivePadding.top;
601
+ const bottom = ch - effectivePadding.bottom;
602
+ const ys: number[] = [];
603
+ for (let i = 0; i < allRefLines.length; i++) {
604
+ const l = allRefLines[i];
605
+ // Skip bands and lines a custom `renderReferenceLine` owns — a custom tag
606
+ // draws itself and isn't suppressed by grouping, so folding it into a
607
+ // built-in count pill would double-count it (counted *and* still shown).
608
+ if (
609
+ referenceLineForm(l) !== "line" ||
610
+ l.value === undefined ||
611
+ refLineCustom[i]
612
+ ) {
613
+ ys.push(-1);
614
+ continue;
615
+ }
616
+ const v = dragValues.get()[i] ?? l.value;
617
+ const y = computeScrubDotY(v, dMin, dMax, ch, top, effectivePadding.bottom);
618
+ ys.push(y < 0 ? -1 : Math.min(bottom, Math.max(top, y)));
619
+ }
620
+ return groupReferenceLines(ys, refGroupingRadius);
621
+ });
622
+ const groupHidden = useDerivedValue<boolean[]>(
623
+ () => refGroupResult.get().hidden,
624
+ );
625
+
399
626
  // Threshold split geometry (shared by stroke gradient, fill band, marker line).
400
627
  // Called unconditionally with a stand-in value when no threshold is set.
401
628
  const thresholdGeom = useThreshold(
@@ -419,6 +646,11 @@ function useLiveChartController({
419
646
  // Only build the band path when the fill is actually on.
420
647
  thresholdCfg?.fill ? thresholdGeom.lineY : undefined,
421
648
  lineIsLinear,
649
+ // Tip the line at the view-edge price (not the live value) while scrolled,
650
+ // matching the live dot — so the right edge doesn't drop to the off-screen
651
+ // live value when `followViewEdge` tracks the scrolled-back window.
652
+ engine.edgeValue,
653
+ badgeCfg?.followViewEdge ?? false,
422
654
  );
423
655
 
424
656
  // Area-dots fill shader color as a vec4 (channels 0..1), with the config
@@ -436,19 +668,36 @@ function useLiveChartController({
436
668
  adA * (areaDotsCfg?.opacity ?? 1),
437
669
  ];
438
670
 
439
- const { upBodiesPath, downBodiesPath, upWicksPath, downWicksPath } =
440
- useCandlePaths(
441
- engine,
442
- effectivePadding,
443
- // Match engine: stashed candles while reverse-morphing in candle mode.
444
- isCandle ? candlesEngine : candles,
445
- isCandle ? liveEngine : liveCandle,
446
- candleWidth,
447
- isCandle,
448
- metricsCfg.candle,
449
- !isStatic, // static: no candle-width lerp loop
450
- );
451
- const { dotX, dotY } = useLiveDot(engine, effectivePadding);
671
+ const {
672
+ upBodiesPath,
673
+ downBodiesPath,
674
+ upWicksPath,
675
+ downWicksPath,
676
+ upBarsPath,
677
+ downBarsPath,
678
+ } = useCandlePaths(
679
+ engine,
680
+ effectivePadding,
681
+ // Match engine: stashed candles while reverse-morphing in candle mode.
682
+ isCandle ? candlesEngine : candles,
683
+ isCandle ? liveEngine : liveCandle,
684
+ candleWidth,
685
+ isCandle,
686
+ metricsCfg.candle,
687
+ volumeBandHeight,
688
+ volumeCfg?.radius ?? 0,
689
+ !isStatic, // static: no candle-width lerp loop
690
+ );
691
+ const { dotX, dotY } = useLiveDot(
692
+ engine,
693
+ effectivePadding,
694
+ engine.edgeValue,
695
+ badgeCfg?.followViewEdge ?? false,
696
+ );
697
+
698
+ // Price↔pixel / time↔pixel bridge for a custom `renderOverlay`. Built
699
+ // unconditionally (hooks rule); only mounted when `renderOverlay` is provided.
700
+ const overlayContext = useChartOverlayContext(engine, effectivePadding);
452
701
 
453
702
  const momentumSV = useMomentum(engine, momentum);
454
703
 
@@ -488,7 +737,7 @@ function useLiveChartController({
488
737
  effectivePadding,
489
738
  palette,
490
739
  formatValue,
491
- skiaFont,
740
+ badgeFont,
492
741
  badgeCfg?.variant ?? "default",
493
742
  badgeCfg?.tail ?? true,
494
743
  momentumSV,
@@ -496,6 +745,11 @@ function useLiveChartController({
496
745
  badgeCfg?.background,
497
746
  metricsCfg.badge,
498
747
  metricsCfg.motion.badgeColorSpeed,
748
+ effectiveYAxisFloat,
749
+ engine.edgeValue,
750
+ badgeCfg?.followViewEdge ?? false,
751
+ badgeCfg?.radius,
752
+ badgeCfg?.textColor,
499
753
  );
500
754
 
501
755
  // Scrub/crosshair must see the same stash-backed candles as the engine.
@@ -509,6 +763,7 @@ function useLiveChartController({
509
763
  : undefined;
510
764
 
511
765
  const markersActive = markers != null;
766
+ const markerClusterCfg = resolveMarkerCluster(markerCluster);
512
767
  // `projected` is used internally by the hit-test gesture; the overlay
513
768
  // self-projects, so we only need the gesture + hit-test here. Built BEFORE
514
769
  // `useCrosshair` so the scrub-action tap can defer to a marker under the finger.
@@ -518,11 +773,12 @@ function useLiveChartController({
518
773
  markersSV,
519
774
  !isStatic && markersActive,
520
775
  markerHitRadius,
521
- onMarkerHover,
776
+ onMarkerPress,
522
777
  undefined, // seriesSV — single-series has none
523
778
  engine.data, // anchor value-less markers to the line
524
779
  !isStatic, // static: no marker-projection loop
525
780
  lineIsLinear, // match marker anchoring to the rendered curve
781
+ markerClusterCfg, // co-located marker stacking / collapse
526
782
  );
527
783
 
528
784
  // Pressable reference-line badges (working orders / alerts). Built before
@@ -538,6 +794,7 @@ function useLiveChartController({
538
794
  !isStatic && refPressActive,
539
795
  markerHitRadius,
540
796
  onReferenceLinePress,
797
+ dragValues,
541
798
  );
542
799
 
543
800
  // Combined "defer" hit-test for the scrub-action place-tap: yield to a marker or
@@ -549,6 +806,24 @@ function useLiveChartController({
549
806
  return markerHitTest(x, y) || refLineHitTest(x, y);
550
807
  };
551
808
 
809
+ // Time-scroll activation. `holdToScrub`: a quick one-finger drag scrolls while
810
+ // scrub engages on press-and-hold — so the scrub gesture needs a long-press
811
+ // delay (unless the caller set its own `panGestureDelay`). `timeScrollEnabled`
812
+ // is computed earlier (it gates the float gutter).
813
+ const scrollGestureMode =
814
+ typeof timeScroll === "object"
815
+ ? (timeScroll.gesture ?? "holdToScrub")
816
+ : "holdToScrub";
817
+ // In holdToScrub the scrub MUST require a hold so a quick drag scrolls instead.
818
+ // Precedence: explicit timeScroll.scrubHoldMs, then scrub.panGestureDelay, then
819
+ // the default. `||` (not `??`) skips the resolved panGestureDelay's 0 default.
820
+ const timeScrollHoldMs =
821
+ typeof timeScroll === "object" ? timeScroll.scrubHoldMs : undefined;
822
+ const scrubHoldMs =
823
+ timeScrollEnabled && scrollGestureMode === "holdToScrub"
824
+ ? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
825
+ : (scrubCfg?.panGestureDelay ?? 0);
826
+
552
827
  const crosshair = useCrosshair(
553
828
  engine,
554
829
  effectivePadding,
@@ -561,7 +836,7 @@ function useLiveChartController({
561
836
  !isStatic && (scrubCfg !== null || scrubActionCfg !== null),
562
837
  onScrub,
563
838
  candleOpts,
564
- scrubCfg?.panGestureDelay ?? 0,
839
+ scrubHoldMs,
565
840
  onGestureStart,
566
841
  onGestureEnd,
567
842
  scrubActionCfg,
@@ -572,8 +847,75 @@ function useLiveChartController({
572
847
  scrubCfg?.tooltipShowValue ?? true,
573
848
  scrubCfg?.tooltipShowTime ?? true,
574
849
  scrubCfg?.tooltipMargin ?? 8,
850
+ // Axis-drag time-scroll: keep the bottom "time ruler" band scroll-only so a
851
+ // drag there never trips the scrub crosshair.
852
+ timeScrollEnabled && scrollGestureMode === "axisDrag"
853
+ ? Math.max(effectivePadding.bottom, AXIS_GRAB_MIN_PX)
854
+ : 0,
575
855
  );
576
856
 
857
+ // ── Time-scroll (drag back through history) ───────────────────────────────
858
+ // Experimental: a pan freezes the window at an absolute time and resumes
859
+ // following once dragged back to the live edge. Pan is clamped to the earliest
860
+ // retained point (line or candle). See `timeScroll` for the gesture model.
861
+ const scrollMinTime = useDerivedValue(() => {
862
+ const src = isCandle ? candlesEngine.get() : lineEngineData.get();
863
+ return src.length > 0 ? src[0].time : engine.liveEdge.get();
864
+ });
865
+ const panScrollGesture = usePanScroll({
866
+ engine,
867
+ padding: effectivePadding,
868
+ minTime: scrollMinTime,
869
+ enabled: timeScrollEnabled,
870
+ mode: scrollGestureMode,
871
+ // Clear any live crosshair when a scroll drag takes over.
872
+ onScrollStart: () => {
873
+ "worklet";
874
+ crosshair.scrubActive.set(false);
875
+ },
876
+ });
877
+
878
+ // Draggable reference lines: a per-line vertical pan that grabs a line near its
879
+ // value and drags it along the Y-axis (with snap / bounds / callbacks). Built
880
+ // unconditionally for stable hook order; the gesture self-disables when no line
881
+ // opts in, and it's only composed into the root when `refDragEnabled`.
882
+ const refDragEnabled =
883
+ !isStatic && allRefLines.some((l) => l.draggable === true);
884
+ const refDragGesture = useReferenceDrag(
885
+ engine,
886
+ effectivePadding,
887
+ allRefLines,
888
+ dragValues,
889
+ dragActive,
890
+ !isStatic,
891
+ );
892
+
893
+ // Pinch-to-zoom the visible window (two-finger). Anchors at the focal point and
894
+ // writes viewWindow + viewEnd; composes via Simultaneous (it's two-finger, so
895
+ // disjoint from the one-finger pan/scrub). See `zoom`.
896
+ const pinchZoomGesture = usePinchZoom({
897
+ engine,
898
+ padding: effectivePadding,
899
+ minTime: scrollMinTime,
900
+ timeWindow,
901
+ enabled: zoomEnabled,
902
+ minTimeWindow: zoomCfg?.minTimeWindow,
903
+ maxTimeWindow: zoomCfg?.maxTimeWindow,
904
+ onZoomStart: () => {
905
+ "worklet";
906
+ crosshair.scrubActive.set(false);
907
+ },
908
+ });
909
+
910
+ // Paging callbacks: report the visible range / proximity to the oldest data so
911
+ // a host can lazily load history. Inert unless a callback is supplied.
912
+ useVisibleRange({
913
+ engine,
914
+ minTime: scrollMinTime,
915
+ onVisibleRangeChange,
916
+ onReachStart,
917
+ });
918
+
577
919
  // Scrub-action composes a Tap (place/move the reticle, press the badge, dismiss)
578
920
  // ahead of the pan via Exclusive, so a tap is tried first and only becomes a
579
921
  // drag (live-scrub, or lock-adjust once placed) if the finger moves. `Exclusive`
@@ -599,12 +941,37 @@ function useLiveChartController({
599
941
  overlayTaps.length === 1
600
942
  ? overlayTaps[0]
601
943
  : Gesture.Simultaneous(overlayTaps[0], overlayTaps[1]);
602
- // With scrub-action the action tap shares the gesture space with the overlay
603
- // taps (Simultaneous); without it the pan only needs to race the tap group.
944
+ // Always `Simultaneous`, never `Race`: on iOS the scrub pan uses
945
+ // `minDistance(0)`, so in a `Race` it activates on touch-down and cancels the
946
+ // overlay tap before it can recognize — `onMarkerPress`/`onReferenceLinePress`
947
+ // would never fire. Sharing the gesture space lets the tap recognize; the pan
948
+ // defers to a marker/badge under the finger via `deferTapHit` (see
949
+ // `useCrosshair`'s scrub `onStart`) so no stray crosshair is dropped there.
950
+ rootGesture = Gesture.Simultaneous(baseGesture, tapGroup);
951
+ }
952
+
953
+ // Compose the pan-scroll gesture. holdToScrub races the scrub/tap gestures (a
954
+ // quick drag scrolls; a still press-hold falls through to scrub). Axis-drag
955
+ // goes first via Exclusive: it fails instantly outside the axis band, so scrub
956
+ // runs everywhere else.
957
+ if (timeScrollEnabled) {
604
958
  rootGesture =
605
- scrubActionCfg !== null
606
- ? Gesture.Simultaneous(baseGesture, tapGroup)
607
- : Gesture.Race(baseGesture, tapGroup);
959
+ scrollGestureMode === "axisDrag"
960
+ ? Gesture.Exclusive(panScrollGesture, rootGesture)
961
+ : Gesture.Race(panScrollGesture, rootGesture);
962
+ }
963
+
964
+ // Draggable reference lines take priority: a vertical grab on a line drags it.
965
+ // The manual-activation pan fails fast off any line (or on horizontal intent), so
966
+ // Exclusive falls through to scrub / scroll everywhere else.
967
+ if (refDragEnabled) {
968
+ rootGesture = Gesture.Exclusive(refDragGesture, rootGesture);
969
+ }
970
+
971
+ // Pinch runs alongside everything else (two-finger, so it never competes with
972
+ // the one-finger pan/scrub/tap gestures for the same touch).
973
+ if (zoomEnabled) {
974
+ rootGesture = Gesture.Simultaneous(rootGesture, pinchZoomGesture);
608
975
  }
609
976
 
610
977
  // ── Derived render values ──────────────────────────────────────────────
@@ -662,6 +1029,7 @@ function useLiveChartController({
662
1029
  extremaTimeOffset: isCandle ? candleWidth / 2 : 0,
663
1030
  // configs
664
1031
  yAxisCfg,
1032
+ yAxisFloat: effectiveYAxisFloat,
665
1033
  xAxisCfg,
666
1034
  badgeCfg,
667
1035
  scrubCfg,
@@ -679,6 +1047,16 @@ function useLiveChartController({
679
1047
  leftEdgeFadeCfg,
680
1048
  metricsCfg,
681
1049
  allRefLines,
1050
+ refLineCustom,
1051
+ dragValues,
1052
+ dragActive,
1053
+ renderReferenceLine,
1054
+ refGroupingActive: refGroupingRadius != null,
1055
+ refGroupResult,
1056
+ groupHidden,
1057
+ refGroupBadge,
1058
+ refGroupBadgeFont,
1059
+ refGroupFormat,
682
1060
  resolvedSegments,
683
1061
  hasRecolorSegments,
684
1062
  segmentGradient,
@@ -690,7 +1068,9 @@ function useLiveChartController({
690
1068
  // theme / layout / fonts
691
1069
  palette,
692
1070
  skiaFont,
1071
+ fontProp,
693
1072
  valueFont,
1073
+ badgeFont,
694
1074
  strokeWidth,
695
1075
  effectivePadding,
696
1076
  // engine + reveal
@@ -715,6 +1095,15 @@ function useLiveChartController({
715
1095
  downBodiesPath,
716
1096
  upWicksPath,
717
1097
  downWicksPath,
1098
+ upBarsPath,
1099
+ downBarsPath,
1100
+ // Volume bars: active flag, fade-in opacity, and resolved colors (default to
1101
+ // the candle palette). The reserved band height is read by the x-axis.
1102
+ volumeActive: volumeCfg !== null,
1103
+ volumeBandHeight,
1104
+ volumeOpacity: volumeCfg?.opacity ?? 1,
1105
+ volumeUpColor: volumeCfg?.upColor ?? palette.candleUp,
1106
+ volumeDownColor: volumeCfg?.downColor ?? palette.candleDown,
718
1107
  dotX,
719
1108
  dotY,
720
1109
  liveDotOpacity,
@@ -730,8 +1119,11 @@ function useLiveChartController({
730
1119
  rootGesture,
731
1120
  markersActive,
732
1121
  markersSV,
1122
+ markerClusterCfg,
733
1123
  renderMarker,
734
1124
  renderTooltip,
1125
+ renderOverlay,
1126
+ overlayContext,
735
1127
  // selection dot: resolved config + fallback color (the chart line/accent color)
736
1128
  selectionDot: selectionDotCfg,
737
1129
  selectionColor: lineProp?.color ?? palette.line,
@@ -756,6 +1148,7 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
756
1148
  const {
757
1149
  degenShakeTransform,
758
1150
  yAxisCfg,
1151
+ yAxisFloat,
759
1152
  reveal,
760
1153
  yAxisEntries,
761
1154
  engine,
@@ -781,10 +1174,13 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
781
1174
 
782
1175
  return (
783
1176
  <Group transform={degenShakeTransform}>
784
- {/* Y-axis grid */}
1177
+ {/* Y-axis. Default: grid + labels here (in a reserved gutter). Floating
1178
+ mode: grid only — the labels + a soft edge fade draw above the candles
1179
+ in ChartStack so the plot runs full-width and candles dim under them. */}
785
1180
  {yAxisCfg && (
786
1181
  <Group opacity={reveal.yAxisOpacity}>
787
1182
  <YAxisOverlay
1183
+ variant={yAxisFloat ? "grid" : "all"}
788
1184
  entries={yAxisEntries}
789
1185
  engine={engine}
790
1186
  padding={effectivePadding}
@@ -857,10 +1253,12 @@ function ChartStack({ model }: { model: LiveChartModel }) {
857
1253
  effectivePadding,
858
1254
  palette,
859
1255
  skiaFont,
1256
+ fontProp,
860
1257
  badgeCfg,
861
1258
  valueLineCfg,
862
1259
  dotY,
863
1260
  allRefLines,
1261
+ dragValues,
864
1262
  resolvedSegments,
865
1263
  hasRecolorSegments,
866
1264
  segmentGradient,
@@ -878,6 +1276,13 @@ function ChartStack({ model }: { model: LiveChartModel }) {
878
1276
  downWicksPath,
879
1277
  upBodiesPath,
880
1278
  downBodiesPath,
1279
+ upBarsPath,
1280
+ downBarsPath,
1281
+ volumeActive,
1282
+ volumeBandHeight,
1283
+ volumeOpacity,
1284
+ volumeUpColor,
1285
+ volumeDownColor,
881
1286
  xAxisCfg,
882
1287
  xAxisEntries,
883
1288
  dotX,
@@ -889,10 +1294,16 @@ function ChartStack({ model }: { model: LiveChartModel }) {
889
1294
  degenPackRevision,
890
1295
  markersActive,
891
1296
  markersSV,
1297
+ markerClusterCfg,
892
1298
  renderMarker,
893
1299
  emptyText,
894
1300
  metricsCfg,
895
1301
  layoutWidth,
1302
+ yAxisCfg,
1303
+ yAxisFloat,
1304
+ yAxisEntries,
1305
+ badgeUsesRightGutter,
1306
+ gridStyleCfg,
896
1307
  } = model;
897
1308
 
898
1309
  return (
@@ -935,6 +1346,9 @@ function ChartStack({ model }: { model: LiveChartModel }) {
935
1346
  palette={palette}
936
1347
  formatValue={formatValue}
937
1348
  font={skiaFont}
1349
+ fontProp={fontProp}
1350
+ dragValues={dragValues}
1351
+ index={i}
938
1352
  />
939
1353
  ))}
940
1354
 
@@ -997,13 +1411,13 @@ function ChartStack({ model }: { model: LiveChartModel }) {
997
1411
  <Path
998
1412
  path={upWicksPath}
999
1413
  style="stroke"
1000
- strokeWidth={1}
1414
+ strokeWidth={metricsCfg.candle.wickWidth}
1001
1415
  color={palette.wickUp}
1002
1416
  />
1003
1417
  <Path
1004
1418
  path={downWicksPath}
1005
1419
  style="stroke"
1006
- strokeWidth={1}
1420
+ strokeWidth={metricsCfg.candle.wickWidth}
1007
1421
  color={palette.wickDown}
1008
1422
  />
1009
1423
  <Path path={upBodiesPath} style="fill" color={palette.candleUp} />
@@ -1014,7 +1428,41 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1014
1428
  />
1015
1429
  </Group>
1016
1430
 
1017
- {/* X-axis time labels */}
1431
+ {/* Volume bars in the reserved band below the candles. Fades in with the
1432
+ candle group (outer opacity); the config opacity dims the whole band
1433
+ (inner). Up/down bars carry their own colors (default candle palette). */}
1434
+ {volumeActive && (
1435
+ <Group opacity={candleGroupOpacity}>
1436
+ <Group opacity={volumeOpacity}>
1437
+ <Path path={upBarsPath} style="fill" color={volumeUpColor} />
1438
+ <Path path={downBarsPath} style="fill" color={volumeDownColor} />
1439
+ </Group>
1440
+ </Group>
1441
+ )}
1442
+
1443
+ {/* Floating axis: the labels float ABOVE the candles (right-aligned at the
1444
+ edge) so the plot runs full-width and candles stay fully visible behind
1445
+ them. (Default non-floating axis draws grid + labels in ChartFillLayer.) */}
1446
+ {yAxisCfg && yAxisFloat && (
1447
+ <Group opacity={reveal.yAxisOpacity}>
1448
+ <YAxisOverlay
1449
+ variant="labels"
1450
+ float
1451
+ entries={yAxisEntries}
1452
+ engine={engine}
1453
+ padding={effectivePadding}
1454
+ palette={palette}
1455
+ font={skiaFont}
1456
+ badge={badgeUsesRightGutter}
1457
+ badgeTail={badgeCfg?.tail ?? true}
1458
+ badgeMetrics={metricsCfg.badge}
1459
+ gridStyle={gridStyleCfg}
1460
+ />
1461
+ </Group>
1462
+ )}
1463
+
1464
+ {/* X-axis time labels. With a volume band the bottom padding is inflated by
1465
+ the band height; pass it so the axis shifts back to the very bottom. */}
1018
1466
  {xAxisCfg && (
1019
1467
  <XAxisOverlay
1020
1468
  entries={xAxisEntries}
@@ -1022,6 +1470,7 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1022
1470
  padding={effectivePadding}
1023
1471
  palette={palette}
1024
1472
  font={skiaFont}
1473
+ volumeBandHeight={volumeBandHeight}
1025
1474
  />
1026
1475
  )}
1027
1476
 
@@ -1039,6 +1488,7 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1039
1488
  radius={dotCfg.radius}
1040
1489
  ring={dotCfg.ring}
1041
1490
  color={dotCfg.color}
1491
+ viewEnd={engine.viewEnd}
1042
1492
  />
1043
1493
  </Group>
1044
1494
  )}
@@ -1069,6 +1519,7 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1069
1519
  lineData={engine.data}
1070
1520
  lineLinear={lineIsLinear}
1071
1521
  renderMarker={renderMarker}
1522
+ cluster={markerClusterCfg}
1072
1523
  />
1073
1524
  </Group>
1074
1525
  )}
@@ -1242,12 +1693,19 @@ function ChartValueOverlay({ model }: { model: LiveChartModel }) {
1242
1693
  /** Live-price badge, drawn above the scrub dim so the dim never clips its left
1243
1694
  * edge. Shares the degen shake transform so it tracks the shaken stack. */
1244
1695
  function ChartBadgeLayer({ model }: { model: LiveChartModel }) {
1245
- const { badgeCfg, badgeData, skiaFont, reveal, degenShakeTransform } = model;
1696
+ const { badgeCfg, badgeData, badgeFont, reveal, degenShakeTransform } = model;
1246
1697
  if (!badgeCfg) return null;
1247
1698
  return (
1248
1699
  <Group transform={degenShakeTransform}>
1249
1700
  <Group opacity={reveal.badgeOpacity}>
1250
- <BadgeOverlay badge={badgeData} font={skiaFont} />
1701
+ <BadgeOverlay
1702
+ badge={badgeData}
1703
+ font={badgeFont}
1704
+ borderColor={badgeCfg.borderColor}
1705
+ borderWidth={badgeCfg.borderWidth}
1706
+ offsetX={badgeCfg.offsetX}
1707
+ offsetY={badgeCfg.offsetY}
1708
+ />
1251
1709
  </Group>
1252
1710
  </Group>
1253
1711
  );
@@ -1260,11 +1718,20 @@ function ChartBadgeLayer({ model }: { model: LiveChartModel }) {
1260
1718
  function ChartRefBadgeLayer({ model }: { model: LiveChartModel }) {
1261
1719
  const {
1262
1720
  allRefLines,
1721
+ refLineCustom,
1722
+ dragValues,
1723
+ groupHidden,
1724
+ refGroupResult,
1725
+ refGroupingActive,
1726
+ refGroupBadge,
1727
+ refGroupBadgeFont,
1728
+ refGroupFormat,
1263
1729
  engine,
1264
1730
  effectivePadding,
1265
1731
  palette,
1266
1732
  formatValue,
1267
1733
  skiaFont,
1734
+ fontProp,
1268
1735
  degenShakeTransform,
1269
1736
  } = model;
1270
1737
  if (allRefLines.length === 0) return null;
@@ -1279,9 +1746,26 @@ function ChartRefBadgeLayer({ model }: { model: LiveChartModel }) {
1279
1746
  palette={palette}
1280
1747
  formatValue={formatValue}
1281
1748
  font={skiaFont}
1749
+ fontProp={fontProp}
1282
1750
  badgeLayer
1751
+ dragValues={dragValues}
1752
+ index={i}
1753
+ suppressTag={refLineCustom[i]}
1754
+ groupHidden={refGroupingActive ? groupHidden : undefined}
1283
1755
  />
1284
1756
  ))}
1757
+ {/* Collapsed count handles for grouped (near-value) lines. */}
1758
+ {refGroupingActive && (
1759
+ <ReferenceLineGroupOverlay
1760
+ grouping={refGroupResult}
1761
+ padding={effectivePadding}
1762
+ canvasWidth={engine.canvasWidth}
1763
+ palette={palette}
1764
+ font={refGroupBadgeFont}
1765
+ badge={refGroupBadge}
1766
+ format={refGroupFormat}
1767
+ />
1768
+ )}
1285
1769
  </Group>
1286
1770
  );
1287
1771
  }
@@ -1338,8 +1822,16 @@ export function LiveChart(props: LiveChartProps) {
1338
1822
  bottomLabelCfg,
1339
1823
  markersActive,
1340
1824
  markersSV,
1825
+ markerClusterCfg,
1341
1826
  renderMarker,
1342
1827
  renderTooltip,
1828
+ renderOverlay,
1829
+ renderReferenceLine,
1830
+ allRefLines,
1831
+ refLineCustom,
1832
+ dragValues,
1833
+ dragActive,
1834
+ overlayContext,
1343
1835
  scrubCfg,
1344
1836
  crosshair,
1345
1837
  isCandle,
@@ -1424,6 +1916,23 @@ export function LiveChart(props: LiveChartProps) {
1424
1916
  padding={effectivePadding}
1425
1917
  lineData={engine.data}
1426
1918
  lineLinear={lineIsLinear}
1919
+ cluster={markerClusterCfg}
1920
+ />
1921
+ )}
1922
+
1923
+ {/* Custom-rendered reference-line tags — RN views floated over the canvas
1924
+ (non-Skia), pinned to each Form-A line's value. Sibling of <Canvas>.
1925
+ Built-in Skia tags for these lines are suppressed (no double-draw). */}
1926
+ {renderReferenceLine && allRefLines.length > 0 && (
1927
+ <CustomReferenceLineOverlay
1928
+ lines={allRefLines}
1929
+ renderReferenceLine={renderReferenceLine}
1930
+ custom={refLineCustom}
1931
+ engine={engine}
1932
+ padding={effectivePadding}
1933
+ formatValue={formatValue}
1934
+ dragValues={dragValues}
1935
+ dragActive={dragActive}
1427
1936
  />
1428
1937
  )}
1429
1938
 
@@ -1445,8 +1954,16 @@ export function LiveChart(props: LiveChartProps) {
1445
1954
  placement={scrubCfg.tooltipPlacement}
1446
1955
  margin={scrubCfg.tooltipMargin}
1447
1956
  lineTop={crosshair.tooltipLineTop}
1957
+ scrubDotY={crosshair.scrubDotY}
1448
1958
  />
1449
1959
  )}
1960
+
1961
+ {/* Custom consumer overlay — an RN view tree floated over the canvas with
1962
+ the price↔pixel / time↔pixel bridge, for order / avg-entry / liquidation
1963
+ tags etc. Topmost RN sibling; `box-none` so empty areas still scrub. */}
1964
+ {renderOverlay && (
1965
+ <ChartOverlayLayer render={renderOverlay} context={overlayContext} />
1966
+ )}
1450
1967
  </View>
1451
1968
  </GestureDetector>
1452
1969
  );