react-native-livechart 4.20.0 → 4.21.1

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 (111) hide show
  1. package/dist/components/AxisLabelOverlay.d.ts +5 -1
  2. package/dist/components/AxisLabelOverlay.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts +4 -2
  4. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  5. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  6. package/dist/components/CustomThresholdBadgeOverlay.d.ts +18 -0
  7. package/dist/components/CustomThresholdBadgeOverlay.d.ts.map +1 -0
  8. package/dist/components/CustomTooltipOverlay.d.ts +4 -2
  9. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  10. package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
  11. package/dist/components/ExtremaConnectorOverlay.d.ts +5 -1
  12. package/dist/components/ExtremaConnectorOverlay.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 +4 -2
  16. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  17. package/dist/components/ReferenceLineOverlay.d.ts +10 -5
  18. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  19. package/dist/components/ReferenceLineSeriesOverlay.d.ts +19 -0
  20. package/dist/components/ReferenceLineSeriesOverlay.d.ts.map +1 -0
  21. package/dist/core/chartDataPresence.d.ts +8 -0
  22. package/dist/core/chartDataPresence.d.ts.map +1 -0
  23. package/dist/core/liveChartEngineTick.d.ts +6 -1
  24. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  25. package/dist/core/resolveConfig.d.ts +32 -1
  26. package/dist/core/resolveConfig.d.ts.map +1 -1
  27. package/dist/core/useLiveChartEngine.d.ts +9 -1
  28. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  29. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  30. package/dist/draw/candle.d.ts +3 -1
  31. package/dist/draw/candle.d.ts.map +1 -1
  32. package/dist/draw/candleGap.d.ts +14 -0
  33. package/dist/draw/candleGap.d.ts.map +1 -0
  34. package/dist/draw/lineGap.d.ts +25 -0
  35. package/dist/draw/lineGap.d.ts.map +1 -0
  36. package/dist/draw/markerAtlas.d.ts +3 -2
  37. package/dist/draw/markerAtlas.d.ts.map +1 -1
  38. package/dist/hooks/crosshairShared.d.ts +7 -3
  39. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  40. package/dist/hooks/useCandleGapPaths.d.ts +12 -0
  41. package/dist/hooks/useCandleGapPaths.d.ts.map +1 -0
  42. package/dist/hooks/useChartPaths.d.ts +4 -1
  43. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  44. package/dist/hooks/useChartReveal.d.ts +1 -1
  45. package/dist/hooks/useChartReveal.d.ts.map +1 -1
  46. package/dist/hooks/useCrosshair.d.ts +9 -5
  47. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  48. package/dist/hooks/useDegen.d.ts.map +1 -1
  49. package/dist/hooks/useLineGapPaths.d.ts +10 -0
  50. package/dist/hooks/useLineGapPaths.d.ts.map +1 -0
  51. package/dist/hooks/useLiveChartHasData.d.ts +2 -2
  52. package/dist/hooks/useLiveChartHasData.d.ts.map +1 -1
  53. package/dist/hooks/useReferenceLine.d.ts +4 -3
  54. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  55. package/dist/hooks/useReferenceLineSeries.d.ts +23 -0
  56. package/dist/hooks/useReferenceLineSeries.d.ts.map +1 -0
  57. package/dist/hooks/useReverseMorphEngineInputs.d.ts.map +1 -1
  58. package/dist/hooks/useThreshold.d.ts +13 -13
  59. package/dist/hooks/useThreshold.d.ts.map +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/math/candleGaps.d.ts +18 -0
  63. package/dist/math/candleGaps.d.ts.map +1 -0
  64. package/dist/math/referenceLineSeries.d.ts +12 -0
  65. package/dist/math/referenceLineSeries.d.ts.map +1 -0
  66. package/dist/math/referenceLines.d.ts +5 -5
  67. package/dist/math/referenceLines.d.ts.map +1 -1
  68. package/dist/math/spline.d.ts +5 -1
  69. package/dist/math/spline.d.ts.map +1 -1
  70. package/dist/types.d.ts +191 -17
  71. package/dist/types.d.ts.map +1 -1
  72. package/package.json +1 -1
  73. package/src/components/AxisLabelOverlay.tsx +162 -81
  74. package/src/components/CrosshairOverlay.tsx +394 -147
  75. package/src/components/CustomMarkerOverlay.tsx +9 -6
  76. package/src/components/CustomThresholdBadgeOverlay.tsx +74 -0
  77. package/src/components/CustomTooltipOverlay.tsx +7 -3
  78. package/src/components/DegenParticlesOverlay.tsx +34 -23
  79. package/src/components/ExtremaConnectorOverlay.tsx +43 -3
  80. package/src/components/LiveChart.tsx +1918 -974
  81. package/src/components/LiveChartSeries.tsx +445 -296
  82. package/src/components/MarkerOverlay.tsx +215 -156
  83. package/src/components/ReferenceLineOverlay.tsx +113 -55
  84. package/src/components/ReferenceLineSeriesOverlay.tsx +141 -0
  85. package/src/core/chartDataPresence.ts +24 -0
  86. package/src/core/liveChartEngineTick.ts +34 -1
  87. package/src/core/resolveConfig.ts +186 -0
  88. package/src/core/useLiveChartEngine.ts +36 -1
  89. package/src/core/useLiveChartSeriesEngine.ts +2 -0
  90. package/src/draw/candle.ts +1 -1
  91. package/src/draw/candleGap.ts +132 -0
  92. package/src/draw/lineGap.ts +180 -0
  93. package/src/draw/markerAtlas.ts +8 -3
  94. package/src/hooks/crosshairShared.ts +92 -11
  95. package/src/hooks/useCandleGapPaths.ts +88 -0
  96. package/src/hooks/useChartPaths.ts +81 -22
  97. package/src/hooks/useChartReveal.ts +1 -1
  98. package/src/hooks/useCrosshair.ts +178 -69
  99. package/src/hooks/useDegen.ts +82 -53
  100. package/src/hooks/useLineGapPaths.ts +76 -0
  101. package/src/hooks/useLiveChartHasData.ts +8 -4
  102. package/src/hooks/useReferenceLine.ts +6 -5
  103. package/src/hooks/useReferenceLineSeries.ts +101 -0
  104. package/src/hooks/useReverseMorphEngineInputs.ts +11 -20
  105. package/src/hooks/useThreshold.ts +31 -22
  106. package/src/index.ts +15 -0
  107. package/src/math/candleGaps.ts +120 -0
  108. package/src/math/referenceLineSeries.ts +83 -0
  109. package/src/math/referenceLines.ts +34 -6
  110. package/src/math/spline.ts +15 -5
  111. package/src/types.ts +202 -17
@@ -2,7 +2,6 @@ import {
2
2
  forwardRef,
3
3
  useEffect,
4
4
  useImperativeHandle,
5
- useLayoutEffect,
6
5
  useMemo,
7
6
  useRef,
8
7
  useState,
@@ -18,6 +17,7 @@ import Animated, {
18
17
  withDelay,
19
18
  withSequence,
20
19
  withTiming,
20
+ type SharedValue,
21
21
  } from "react-native-reanimated";
22
22
  import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
23
23
 
@@ -45,6 +45,7 @@ import {
45
45
  resolveAreaDots,
46
46
  resolveAxisLabel,
47
47
  resolveBadge,
48
+ resolveCandleGaps,
48
49
  resolveDegen,
49
50
  resolveDot,
50
51
  resolveGradient,
@@ -70,7 +71,10 @@ import {
70
71
  resolveXAxis,
71
72
  resolveYAxis,
72
73
  } from "../core/resolveConfig";
73
- import type { ResolvedThresholdConfig } from "../core/resolveConfig";
74
+ import type {
75
+ ResolvedCandleGapsConfig,
76
+ ResolvedThresholdConfig,
77
+ } from "../core/resolveConfig";
74
78
  import {
75
79
  liveIndicatorScrollOpacity,
76
80
  resolveHideLiveOnScrollBack,
@@ -82,9 +86,14 @@ import {
82
86
  computeCandleFocusClip,
83
87
  HIDDEN_CANDLE_FOCUS_CLIP,
84
88
  } from "../draw/candle";
85
- import { dotGlowRadialOutset, pulseRadialOutset } from "../draw/line";
89
+ import {
90
+ dotGlowRadialOutset,
91
+ pulseRadialOutset,
92
+ type ChartPadding,
93
+ } from "../draw/line";
86
94
  import { resolveChartLayout } from "../hooks/resolveChartLayout";
87
95
  import { useBadge } from "../hooks/useBadge";
96
+ import { useCandleGapPaths } from "../hooks/useCandleGapPaths";
88
97
  import { useCandlePaths, useCandleWidthLerp } from "../hooks/useCandlePaths";
89
98
  import { useCanvasLayout } from "../hooks/useCanvasLayout";
90
99
  import { useChartColors } from "../hooks/useChartColors";
@@ -96,6 +105,7 @@ import { useCrosshair } from "../hooks/useCrosshair";
96
105
  import { useDegen } from "../hooks/useDegen";
97
106
  import { useLiveChartHasData } from "../hooks/useLiveChartHasData";
98
107
  import { useLiveDot } from "../hooks/useLiveDot";
108
+ import { useLineGapPaths } from "../hooks/useLineGapPaths";
99
109
  import { useMarkers } from "../hooks/useMarkers";
100
110
  import { useReferenceDrag } from "../hooks/useReferenceDrag";
101
111
  import { useReferenceLinePress } from "../hooks/useReferenceLinePress";
@@ -122,6 +132,10 @@ import {
122
132
  formatValue as defaultFormatValue,
123
133
  } from "../lib/format";
124
134
  import { MONO_FONT_FAMILY } from "../lib/monoFontFamily";
135
+ import {
136
+ candleGapBucketStartAtTime,
137
+ candleGapDefaultLabel,
138
+ } from "../math/candleGaps";
125
139
  import { computeScrubDotY } from "../hooks/crosshairShared";
126
140
  import {
127
141
  groupReferenceLines,
@@ -147,7 +161,9 @@ import type {
147
161
  LiveChartPoint,
148
162
  LiveChartProps,
149
163
  Marker,
164
+ ReferenceLine,
150
165
  } from "../types";
166
+ import { CustomThresholdBadgeOverlay } from "./CustomThresholdBadgeOverlay";
151
167
  import {
152
168
  ThresholdBadgeOverlay,
153
169
  ThresholdLineOverlay,
@@ -256,329 +272,322 @@ function thresholdSplitColorVecs(
256
272
  /** Transparent vec4 — the band shader's "threshold ended" rest color. */
257
273
  const TRANSPARENT_VEC4 = [0, 0, 0, 0];
258
274
 
259
- /**
260
- * Resolves props → configs → theme/layout → engine → per-frame derived values and
261
- * overlay hooks, returning a single render model. All the chart's wiring lives
262
- * here so the rendered pieces (`ChartStack`, `ChartScrubLayer`, `LiveChart`) stay
263
- * small and presentational.
264
- */
265
-
266
- function useLiveChartController({
267
- // ── Data ────────────────────────────────────────────────────────────────
268
- data,
269
- value,
270
-
271
- // ── Appearance ──────────────────────────────────────────────────────────
272
- theme = "dark",
273
- accentColor = DEFAULT_ACCENT_COLOR,
274
- gradient = true,
275
- areaDots,
276
- line: lineProp,
277
- font: fontProp,
278
- insets,
279
- style,
280
- seriesOpacity,
281
- canvasMode = "transparent",
282
-
283
- // ── Candlestick ─────────────────────────────────────────────────────────
284
- mode = "line",
285
- candles,
286
- candleWidth = 60,
287
- liveCandle,
288
- volume,
289
-
290
- // ── Behaviour ───────────────────────────────────────────────────────────
291
- timeWindow = 30,
292
- paused = false,
293
- loading = false,
294
- transitions,
295
- // `static` is a reserved word — alias it so the destructure parses.
296
- static: isStatic = false,
297
- snapKey,
298
- smoothing = 0.08,
299
- exaggerate = false,
300
- nonNegative = false,
301
- maxValue,
302
- yRangeScale,
303
- windowBuffer = 0,
304
- nowOverride,
305
- timeScroll = false,
306
- returnToLive,
307
- zoom = false,
308
- accessibilityLabel,
309
- accessibilityRole = "image",
310
- emptyText = "No data",
311
- formatValue = defaultFormatValue,
312
- formatTime = defaultFormatTime,
313
-
314
- // ── Overlays ────────────────────────────────────────────────────────────
315
- yAxis = true,
316
- xAxis = true,
317
- axisAutoHide = false,
275
+ function resolveLiveChartFeatureConfig({
276
+ mode,
277
+ yAxis,
278
+ xAxis,
318
279
  topLabel,
319
280
  bottomLabel,
320
- badge = true,
321
- momentum = true,
322
- pulse = true,
323
- dot,
324
- valueLine = true,
325
- showValue = false,
326
- valueMomentumColor = false,
327
- referenceLines,
328
- segments,
329
- threshold,
330
- gridStyle,
331
- palette: paletteOverride,
332
- metrics,
333
- scrub = true,
281
+ badge,
282
+ scrub,
334
283
  scrubAction,
284
+ volume,
285
+ candleGaps,
286
+ lineGaps,
287
+ gradient,
288
+ areaDots,
289
+ threshold,
290
+ valueLine,
291
+ isStatic,
292
+ pulse,
293
+ dot,
335
294
  selectionDot,
336
- tradeStream,
295
+ gridStyle,
337
296
  degen,
338
- markers,
339
- onMarkerPress,
340
- markerHitRadius = 16,
341
- markerCluster,
342
- renderMarker,
343
- renderTooltip,
344
- renderOverlay,
345
- renderReferenceLine,
346
- renderOffAxisReferenceLine,
347
- referenceLineGrouping,
348
- leftEdgeFade = true,
349
-
350
- // ── Callbacks ───────────────────────────────────────────────────────────
351
- onScrub,
352
- onScrubAction,
353
- onReferenceLinePress,
354
- onGestureStart,
355
- onGestureEnd,
356
- onVisibleRangeChange,
357
- onReachStart,
358
- onDegenShake,
359
- }: LiveChartProps) {
360
- const fullSeriesOpacity = useSharedValue(1);
361
- const resolvedSeriesOpacity = seriesOpacity ?? fullSeriesOpacity;
362
- const emptyMarkers = useSharedValue<Marker[]>([]);
363
- const markersSV = markers ?? emptyMarkers;
364
- // Stand-in threshold value so `useThreshold` can be called unconditionally
365
- // (hooks can't be); the geometry is ignored when no threshold is configured.
366
- const emptyThresholdValue = useSharedValue(0);
297
+ tradeStream,
298
+ metrics,
299
+ }: Pick<
300
+ LiveChartProps,
301
+ | "mode"
302
+ | "yAxis"
303
+ | "xAxis"
304
+ | "topLabel"
305
+ | "bottomLabel"
306
+ | "badge"
307
+ | "scrub"
308
+ | "scrubAction"
309
+ | "volume"
310
+ | "candleGaps"
311
+ | "lineGaps"
312
+ | "gradient"
313
+ | "areaDots"
314
+ | "threshold"
315
+ | "valueLine"
316
+ | "pulse"
317
+ | "dot"
318
+ | "selectionDot"
319
+ | "gridStyle"
320
+ | "degen"
321
+ | "tradeStream"
322
+ | "metrics"
323
+ > & { isStatic: boolean }) {
367
324
  const isCandle = mode === "candle";
368
-
369
- // ── Resolve feature configs ────────────────────────────────────────────
370
- const yAxisCfg = resolveYAxis(yAxis);
371
- const xAxisCfg = resolveXAxis(xAxis);
372
- const topLabelCfg = resolveAxisLabel(topLabel);
373
- const bottomLabelCfg = resolveAxisLabel(bottomLabel);
374
325
  const badgeCfg = resolveBadge(badge);
375
- const scrubCfg = resolveScrub(scrub);
376
- // Scrub + scrub-action are on-demand touch gestures (event-driven, no per-frame
377
- // loop), so they stay live on static charts — `static` suppresses the continuous
378
- // render loop, not interaction. A still sparkline in a list can still be scrubbed.
379
- const scrubActionCfg = resolveScrubAction(scrubAction);
380
- // Volume bars sit below the candles — a candle-mode-only feature (inert in
381
- // line mode, like the candle paths themselves).
382
- const volumeCfg = isCandle ? resolveVolume(volume) : null;
383
- const volumeBandHeight = volumeCfg?.maxHeight ?? 0;
384
- const gradientCfg = isCandle ? null : resolveGradient(gradient);
385
- // Dot-lattice area fill (clipped to the under-line region). Inert in candle
386
- // mode, same as the gradient fill.
387
- const areaDotsCfg = isCandle ? null : resolveAreaDots(areaDots);
388
- // Threshold split is a line-mode feature (candle bodies carry their own up/down
389
- // colors), so it's inert in candle mode — same as the area gradient.
326
+ const chartGapsCfg = resolveCandleGaps(isCandle ? candleGaps : lineGaps);
390
327
  const thresholdCfg = isCandle ? null : resolveThreshold(threshold);
391
328
  const thresholdSeriesSV = thresholdCfg?.series ?? null;
392
- const thresholdIsSeries =
393
- thresholdSeriesSV !== null || Array.isArray(thresholdCfg?.value);
394
- const valueLineCfg = resolveValueLine(valueLine);
395
- // Static charts run zero loops: force the pulse off so its `withRepeat`-driven
396
- // ring never starts (the DotOverlay reads `pulseCfg`, so null = no pulse).
397
- const pulseCfg = isStatic ? null : resolvePulse(pulse);
398
329
  const dotCfg = resolveDot(dot);
399
- // `badge.followViewEdge` wins over `dot.trackWhileParked`: an edge-pinned dot
400
- // must stay aligned with its badge, so the tracking flag is ignored.
401
- const dotTracksParked =
402
- dotCfg.trackWhileParked && !(badgeCfg?.followViewEdge ?? false);
403
- const selectionDotCfg = resolveSelectionDot(selectionDot);
404
- // Outer visible footprint of the dot, including its crisp ring and optional
405
- // blurred glow. Used by scrub dimming so neither effect leaks through.
406
- const dotOuterRadius = Math.max(
407
- dotCfg.radius + (dotCfg.ring?.width ?? 0),
408
- dotCfg.glow ? dotGlowRadialOutset(dotCfg.glow.radius, dotCfg.glow.blur) : 0,
409
- );
410
- const gridStyleCfg = resolveGridStyle(gridStyle);
411
- // Static charts run zero loops: force degen off so `useDegen`'s frame callback
412
- // never starts (also passed `isStatic` below as a belt-and-braces autostart gate).
413
- const degenCfg = isStatic ? null : resolveDegen(degen);
414
- const tradeStreamResolved = resolveTradeStream(tradeStream);
415
- const metricsCfg = resolveMetrics(metrics);
416
-
417
- const allRefLines = referenceLines ?? [];
418
- const refValues = collectReferenceValues(allRefLines);
419
-
420
- // Per-line live value overrides + drag flags for draggable lines and the custom
421
- // `renderReferenceLine` slot. One array SharedValue each (the line count varies,
422
- // so a single SharedValue beats N hooks). Seeded from each line's static `value`;
423
- // the drag gesture overwrites a slot, and the effect re-seeds slots not being
424
- // dragged when the props change (so a controlled `value` flows back in).
425
- const dragValues = useSharedValue<number[]>([]);
426
- const dragActive = useSharedValue<boolean[]>([]);
427
- // Last `value` props used to seed each slot, so reconciliation can tell a
428
- // controlled prop change (adopt it) from an unchanged prop (keep the dragged
429
- // value — uncontrolled persistence) without resetting a drop on every re-render.
430
- const seededRef = useRef<(number | undefined)[]>([]);
431
- const refValueSig = allRefLines.map((l) => l.value ?? "_").join(",");
432
- useEffect(() => {
433
- const active = dragActive.get();
434
- const cur = dragValues.get();
435
- const seeded = seededRef.current;
436
- dragValues.set(
437
- allRefLines.map((l, i) => {
438
- const prop = l.value ?? 0;
439
- if (active[i]) return cur[i] ?? prop; // mid-drag → keep the dragged value
440
- if (l.value !== seeded[i]) return prop; // prop changed → adopt (controlled)
441
- return cur[i] ?? prop; // unchanged → keep current (uncontrolled persist)
442
- }),
443
- );
444
- seededRef.current = allRefLines.map((l) => l.value);
445
- if (dragActive.get().length !== allRefLines.length) {
446
- dragActive.set(allRefLines.map((_, i) => active[i] ?? false));
447
- }
448
- // allRefLines is rebuilt every render; key off the value signature + length.
449
- // eslint-disable-next-line react-hooks/exhaustive-deps
450
- }, [refValueSig, allRefLines.length]);
330
+ const volumeCfg = isCandle ? resolveVolume(volume) : null;
451
331
 
452
- // Form-A lines a custom `renderReferenceLine` owns → suppress their built-in tag
453
- // (no double-draw). Probed on the JS thread, index-aligned with `allRefLines`.
454
- const refLineCustom = customReferenceLineFlags(
455
- allRefLines,
456
- renderReferenceLine,
457
- );
458
- // An off-axis renderer replaces only the edge-pinned tag. A full custom tag
459
- // takes precedence for the same line to avoid mounting two native tags.
460
- const refLineOffAxisCustom = customReferenceLineFlags(
461
- allRefLines,
462
- renderOffAxisReferenceLine,
463
- "off-axis",
464
- ).map((custom, index) => custom && !refLineCustom[index]);
465
- const refLineKeys = referenceLineReactKeys(allRefLines);
466
- // RN custom tags report their measured widths here so the Skia connector can
467
- // start after the native badge instead of the hidden built-in pill.
468
- const refLineCustomTagWidths = useSharedValue<number[]>([]);
332
+ return {
333
+ isCandle,
334
+ yAxisCfg: resolveYAxis(yAxis),
335
+ xAxisCfg: resolveXAxis(xAxis),
336
+ topLabelCfg: resolveAxisLabel(topLabel),
337
+ bottomLabelCfg: resolveAxisLabel(bottomLabel),
338
+ badgeCfg,
339
+ scrubCfg: resolveScrub(scrub),
340
+ scrubActionCfg: resolveScrubAction(scrubAction),
341
+ volumeCfg,
342
+ chartGapsCfg,
343
+ candleGapsCfg: isCandle ? chartGapsCfg : null,
344
+ lineGapsCfg: isCandle ? null : chartGapsCfg,
345
+ volumeBandHeight: volumeCfg?.maxHeight ?? 0,
346
+ gradientCfg: isCandle ? null : resolveGradient(gradient),
347
+ areaDotsCfg: isCandle ? null : resolveAreaDots(areaDots),
348
+ thresholdCfg,
349
+ thresholdSeriesSV,
350
+ thresholdIsSeries:
351
+ thresholdSeriesSV !== null || Array.isArray(thresholdCfg?.value),
352
+ valueLineCfg: resolveValueLine(valueLine),
353
+ pulseCfg: isStatic ? null : resolvePulse(pulse),
354
+ dotCfg,
355
+ dotTracksParked:
356
+ dotCfg.trackWhileParked && !(badgeCfg?.followViewEdge ?? false),
357
+ selectionDotCfg: resolveSelectionDot(selectionDot),
358
+ dotOuterRadius: Math.max(
359
+ dotCfg.radius + (dotCfg.ring?.width ?? 0),
360
+ dotCfg.glow
361
+ ? dotGlowRadialOutset(dotCfg.glow.radius, dotCfg.glow.blur)
362
+ : 0,
363
+ ),
364
+ gridStyleCfg: resolveGridStyle(gridStyle),
365
+ degenCfg: isStatic ? null : resolveDegen(degen),
366
+ tradeStreamResolved: resolveTradeStream(tradeStream),
367
+ metricsCfg: resolveMetrics(metrics),
368
+ };
369
+ }
469
370
 
470
- // Live Y values of the *draggable* Form-A lines, folded into the engine's
471
- // axis-range fit so dragging a line toward / past the visible edge expands the
472
- // range and the axis follows the finger in one motion (the committed values are
473
- // already in `refValues`). `excludeFromRange` lines opt out, matching the static
474
- // fit. Identity-stable (no re-fit) when nothing is draggable.
475
- //
476
- // `threshold.includeInRange` rides the same channel: the constant benchmark
477
- // contributes its live value; a series contributes its window min/max
478
- // (respecting `extendToNow`), so an off-range break-even expands the axis and
479
- // stays on-plot like a reference line would.
371
+ function resolveLiveChartReferenceConfig({
372
+ chartGapsCfg,
373
+ referenceLines,
374
+ renderReferenceLine,
375
+ renderOffAxisReferenceLine,
376
+ thresholdCfg,
377
+ referenceLineGrouping,
378
+ badgeCfg,
379
+ }: {
380
+ chartGapsCfg: ReturnType<typeof resolveCandleGaps>;
381
+ referenceLines: LiveChartProps["referenceLines"];
382
+ renderReferenceLine: LiveChartProps["renderReferenceLine"];
383
+ renderOffAxisReferenceLine: LiveChartProps["renderOffAxisReferenceLine"];
384
+ thresholdCfg: ReturnType<typeof resolveThreshold>;
385
+ referenceLineGrouping: LiveChartProps["referenceLineGrouping"];
386
+ badgeCfg: ReturnType<typeof resolveBadge>;
387
+ }) {
388
+ const chartGapBands: ReferenceLine[] =
389
+ chartGapsCfg?.gaps.flatMap((gap) => {
390
+ const gapStyle = chartGapsCfg.styles[gap.kind];
391
+ const band = gapStyle.band;
392
+ if (band === null) return [];
393
+ const label = gapStyle.label;
394
+ return [
395
+ {
396
+ id: `chart-gap:${gap.kind}:${gap.from}:${gap.to}`,
397
+ from: gap.from,
398
+ to: gap.to,
399
+ label:
400
+ label === null
401
+ ? undefined
402
+ : (gap.label ?? candleGapDefaultLabel(gap.kind)),
403
+ color: band.borderColor,
404
+ fillColor: band.fillColor,
405
+ fillOpacity: band.fillOpacity,
406
+ strokeOpacity: band.borderOpacity,
407
+ strokeWidth: band.borderWidth > 0 ? band.borderWidth : undefined,
408
+ intervals: band.intervals,
409
+ labelColor: label?.color,
410
+ labelPosition: label?.position,
411
+ },
412
+ ];
413
+ }) ?? [];
414
+ const consumerRefLines = referenceLines ?? [];
415
+ const allRefLines = [...consumerRefLines, ...chartGapBands];
416
+ const refLineCustom = [
417
+ ...customReferenceLineFlags(consumerRefLines, renderReferenceLine),
418
+ ...chartGapBands.map(() => false),
419
+ ];
420
+ const refLineOffAxisCustom = [
421
+ ...customReferenceLineFlags(
422
+ consumerRefLines,
423
+ renderOffAxisReferenceLine,
424
+ "off-axis",
425
+ ),
426
+ ...chartGapBands.map(() => false),
427
+ ].map((custom, index) => custom && !refLineCustom[index]);
480
428
  const draggableRefIdx: number[] = [];
481
- for (let i = 0; i < allRefLines.length; i++) {
482
- const line = allRefLines[i];
429
+ for (let index = 0; index < allRefLines.length; index++) {
430
+ const line = allRefLines[index];
483
431
  if (
484
432
  line.draggable &&
485
433
  !line.excludeFromRange &&
486
434
  referenceLineForm(line) === "line"
487
435
  ) {
488
- draggableRefIdx.push(i);
436
+ draggableRefIdx.push(index);
489
437
  }
490
438
  }
491
439
  const thresholdInRange = thresholdCfg?.includeInRange === true;
492
- // Constant benchmark → its live value rides this channel. A series threshold
493
- // is folded inside the engine tick instead (`thresholdRangePoints`), which
494
- // already knows the window bounds for the min/max.
495
440
  const thresholdRangeValueSV =
496
441
  thresholdInRange &&
497
442
  thresholdCfg?.value != null &&
498
443
  !Array.isArray(thresholdCfg.value)
499
444
  ? thresholdCfg.value
500
445
  : null;
446
+ const refGroupingCfg =
447
+ typeof referenceLineGrouping === "object"
448
+ ? referenceLineGrouping
449
+ : undefined;
450
+
451
+ return {
452
+ chartGapBands,
453
+ allRefLines,
454
+ refValues: collectReferenceValues(allRefLines),
455
+ refLineCustom,
456
+ refLineOffAxisCustom,
457
+ refLineKeys: referenceLineReactKeys(allRefLines),
458
+ draggableRefIdx,
459
+ thresholdInRange,
460
+ thresholdRangeValueSV,
461
+ refGroupingCfg,
462
+ refGroupingRadius: referenceLineGrouping
463
+ ? (refGroupingCfg?.radius ?? 18)
464
+ : null,
465
+ refGroupBadge: resolveReferenceGroupBadge(refGroupingCfg?.badge),
466
+ refGroupFormat: refGroupingCfg?.format,
467
+ badgeUsesRightGutter:
468
+ badgeCfg !== null && (badgeCfg.position ?? "right") === "right",
469
+ };
470
+ }
471
+
472
+ function useLiveReferenceState(
473
+ allRefLines: ReferenceLine[],
474
+ draggableRefIdx: number[],
475
+ thresholdRangeValueSV: SharedValue<number> | null,
476
+ ) {
477
+ const dragValues = useSharedValue<number[]>([]);
478
+ const dragActive = useSharedValue<boolean[]>([]);
479
+ const seededRef = useRef<(number | undefined)[]>([]);
480
+ const refValueSig = allRefLines.map((line) => line.value ?? "_").join(",");
481
+ const propValues = useMemo<(number | undefined)[]>(() => {
482
+ if (refValueSig === "") return [];
483
+ return refValueSig
484
+ .split(",")
485
+ .map((encoded) => (encoded === "_" ? undefined : Number(encoded)));
486
+ }, [refValueSig]);
487
+ useEffect(() => {
488
+ const active = dragActive.get();
489
+ const current = dragValues.get();
490
+ const seeded = seededRef.current;
491
+ dragValues.set(
492
+ propValues.map((value, index) => {
493
+ const prop = value ?? 0;
494
+ if (active[index]) return current[index] ?? prop;
495
+ if (value !== seeded[index]) return prop;
496
+ return current[index] ?? prop;
497
+ }),
498
+ );
499
+ seededRef.current = propValues;
500
+ if (dragActive.get().length !== propValues.length) {
501
+ dragActive.set(propValues.map((_, index) => active[index] ?? false));
502
+ }
503
+ }, [dragActive, dragValues, propValues]);
504
+
505
+ const refLineCustomTagWidths = useSharedValue<number[]>([]);
501
506
  const liveRefValues = useDerivedValue<number[]>(() => {
502
- if (draggableRefIdx.length === 0 && thresholdRangeValueSV === null)
507
+ if (draggableRefIdx.length === 0 && thresholdRangeValueSV === null) {
503
508
  return EMPTY_NUMS;
504
- const out: number[] = [];
509
+ }
510
+ const output: number[] = [];
505
511
  if (draggableRefIdx.length > 0) {
506
- const dv = dragValues.get();
507
- for (let k = 0; k < draggableRefIdx.length; k++) {
508
- const v = dv[draggableRefIdx[k]];
509
- if (v != null) out.push(v);
512
+ const values = dragValues.get();
513
+ for (let index = 0; index < draggableRefIdx.length; index++) {
514
+ const value = values[draggableRefIdx[index]];
515
+ if (value != null) output.push(value);
510
516
  }
511
517
  }
512
518
  if (thresholdRangeValueSV !== null) {
513
- const v = thresholdRangeValueSV.get();
514
- if (Number.isFinite(v)) out.push(v);
519
+ const value = thresholdRangeValueSV.get();
520
+ if (Number.isFinite(value)) output.push(value);
515
521
  }
516
- return out;
522
+ return output;
517
523
  });
518
524
 
519
- // Reference-line grouping (collapse near-value handles). Resolved once; the
520
- // per-frame clustering runs on the UI thread (see ReferenceLineGroupOverlay).
521
- const refGroupingCfg =
522
- typeof referenceLineGrouping === "object"
523
- ? referenceLineGrouping
524
- : undefined;
525
- const refGroupingRadius = referenceLineGrouping
526
- ? (refGroupingCfg?.radius ?? 18)
527
- : null;
528
- // Count-pill styling (same style/shape config as a per-line badge) + count
529
- // formatter. Resolved once; theme color defaults are applied in the overlay.
530
- const refGroupBadge = resolveReferenceGroupBadge(refGroupingCfg?.badge);
531
- const refGroupFormat = refGroupingCfg?.format;
532
-
533
- const badgeUsesRightGutter =
534
- badgeCfg !== null && (badgeCfg.position ?? "right") === "right";
535
-
536
- // ── Theme, font and layout ─────────────────────────────────────────────
525
+ return { dragValues, dragActive, refLineCustomTagWidths, liveRefValues };
526
+ }
527
+
528
+ function resolveLiveChartPresentationConfig({
529
+ accentColor,
530
+ theme,
531
+ paletteOverride,
532
+ segments,
533
+ leftEdgeFade,
534
+ fontProp,
535
+ badgeCfg,
536
+ refGroupBadge,
537
+ pulseCfg,
538
+ timeScroll,
539
+ isStatic,
540
+ returnToLive,
541
+ zoom,
542
+ yAxisCfg,
543
+ }: {
544
+ accentColor: string;
545
+ theme: NonNullable<LiveChartProps["theme"]>;
546
+ paletteOverride: LiveChartProps["palette"];
547
+ segments: LiveChartProps["segments"];
548
+ leftEdgeFade: LiveChartProps["leftEdgeFade"];
549
+ fontProp: LiveChartProps["font"];
550
+ badgeCfg: ReturnType<typeof resolveBadge>;
551
+ refGroupBadge: ReturnType<typeof resolveReferenceGroupBadge>;
552
+ pulseCfg: ReturnType<typeof resolvePulse>;
553
+ timeScroll: LiveChartProps["timeScroll"];
554
+ isStatic: boolean;
555
+ returnToLive: LiveChartProps["returnToLive"];
556
+ zoom: LiveChartProps["zoom"];
557
+ yAxisCfg: ReturnType<typeof resolveYAxis>;
558
+ }) {
537
559
  const palette = applyPaletteOverride(
538
560
  resolveTheme(accentColor, theme),
539
561
  paletteOverride,
540
562
  );
541
-
542
- // Time-range segments (sessions, after-hours, …). Resolved once per render like
543
- // reference lines; the divider/label/muted colors default to the chart palette
544
- // (no per-segment base color needed). `hasRecolorSegments` is a render-time gate
545
- // for the line-recolor gradient pass (per-frame visibility is handled by the
546
- // gradient's transparent stops, not by mounting/unmounting the Path).
547
- const resolvedSegments = (segments ?? []).map((s) =>
548
- resolveSegment(s, {
563
+ const resolvedSegments = (segments ?? []).map((segment) =>
564
+ resolveSegment(segment, {
549
565
  muted: palette.gridLabel,
550
566
  divider: palette.refLine,
551
567
  label: palette.refLabel,
552
568
  }),
553
569
  );
554
- const hasRecolorSegments = resolvedSegments.some((s) => s.recolorLine);
555
-
556
- const leftEdgeFadeCfg = resolveLeftEdgeFade(
557
- leftEdgeFade,
558
- leftEdgeFadeColorsFromBgRgb(palette.bgRgb),
559
- );
560
-
561
- const skiaFont = useChartSkiaFont(
562
- fontProp,
563
- MONO_FONT_FAMILY,
564
- palette.labelFontSize,
565
- );
566
-
567
- // Larger font for the optional live-value text overlay (showValue).
568
- const valueFont = useChartSkiaFont(
569
- fontProp,
570
- MONO_FONT_FAMILY,
571
- palette.valueFontSize * 2,
572
- );
573
-
574
- // Per-badge font (size/family/weight) override; reuses the chart font when
575
- // none of the badge font knobs are set, so the gutter sizing is unchanged.
576
570
  const badgeHasFontOverride =
577
571
  badgeCfg?.fontSize != null ||
578
572
  badgeCfg?.fontFamily != null ||
579
573
  badgeCfg?.fontWeight != null;
580
- const badgeFontOverride = useChartSkiaFont(
581
- badgeHasFontOverride
574
+ const refGroupBadgeHasFont =
575
+ refGroupBadge.fontSize != null ||
576
+ refGroupBadge.fontFamily != null ||
577
+ refGroupBadge.fontWeight != null;
578
+ const timeScrollEnabled = Boolean(timeScroll) && !isStatic;
579
+ const zoomCfg = resolveZoom(zoom);
580
+
581
+ return {
582
+ palette,
583
+ resolvedSegments,
584
+ hasRecolorSegments: resolvedSegments.some((segment) => segment.recolorLine),
585
+ leftEdgeFadeCfg: resolveLeftEdgeFade(
586
+ leftEdgeFade,
587
+ leftEdgeFadeColorsFromBgRgb(palette.bgRgb),
588
+ ),
589
+ badgeHasFontOverride,
590
+ badgeFontConfig: badgeHasFontOverride
582
591
  ? {
583
592
  ...fontProp,
584
593
  fontFamily: badgeCfg?.fontFamily ?? fontProp?.fontFamily,
@@ -586,18 +595,8 @@ function useLiveChartController({
586
595
  fontWeight: badgeCfg?.fontWeight ?? fontProp?.fontWeight,
587
596
  }
588
597
  : fontProp,
589
- MONO_FONT_FAMILY,
590
- palette.labelFontSize,
591
- );
592
- const badgeFont = badgeHasFontOverride ? badgeFontOverride : skiaFont;
593
-
594
- // Per-badge font for the grouping count pill (same override pattern as above).
595
- const refGroupBadgeHasFont =
596
- refGroupBadge.fontSize != null ||
597
- refGroupBadge.fontFamily != null ||
598
- refGroupBadge.fontWeight != null;
599
- const refGroupBadgeFontOverride = useChartSkiaFont(
600
- refGroupBadgeHasFont
598
+ refGroupBadgeHasFont,
599
+ refGroupBadgeFontConfig: refGroupBadgeHasFont
601
600
  ? {
602
601
  ...fontProp,
603
602
  fontFamily: refGroupBadge.fontFamily ?? fontProp?.fontFamily,
@@ -605,62 +604,727 @@ function useLiveChartController({
605
604
  fontWeight: refGroupBadge.fontWeight ?? fontProp?.fontWeight,
606
605
  }
607
606
  : fontProp,
608
- MONO_FONT_FAMILY,
609
- palette.labelFontSize,
610
- );
611
- const refGroupBadgeFont = refGroupBadgeHasFont
612
- ? refGroupBadgeFontOverride
613
- : skiaFont;
607
+ pulseConfig: pulseCfg
608
+ ? {
609
+ maxRadius: pulseCfg.maxRadius,
610
+ strokeWidth: pulseCfg.strokeWidth,
611
+ }
612
+ : null,
613
+ timeScrollEnabled,
614
+ returnToLiveMs: resolveReturnToLiveMs(returnToLive),
615
+ timeScrollOverscroll: timeScrollEnabled ? resolveOverscroll(timeScroll) : 0,
616
+ timeScrollFling: resolveFling(timeScroll),
617
+ zoomCfg,
618
+ zoomEnabled: zoomCfg !== null && !isStatic,
619
+ yAxisFloat: yAxisCfg?.float ?? false,
620
+ };
621
+ }
614
622
 
615
- const pulseConfig = pulseCfg
616
- ? {
617
- maxRadius: pulseCfg.maxRadius,
618
- strokeWidth: pulseCfg.strokeWidth,
623
+ function resolveThresholdColorConfig(
624
+ thresholdCfg: ReturnType<typeof resolveThreshold>,
625
+ palette: LiveChartPalette,
626
+ lineProp: LiveChartProps["line"],
627
+ ) {
628
+ return {
629
+ stopColors: thresholdCfg ? thresholdStops(thresholdCfg, palette) : null,
630
+ splitAbove: thresholdCfg
631
+ ? (thresholdCfg.aboveColor ?? palette.candleUp)
632
+ : null,
633
+ splitBelow: thresholdCfg
634
+ ? (thresholdCfg.belowColor ?? palette.candleDown)
635
+ : null,
636
+ fillOpacity: thresholdCfg?.fillOpacity ?? THRESHOLD_FILL_OPACITY_DEFAULT,
637
+ lineColor: lineProp?.color ?? palette.line,
638
+ };
639
+ }
640
+
641
+ function useThresholdGeometryState({
642
+ engine,
643
+ padding,
644
+ thresholdCfg,
645
+ thresholdSeriesSV,
646
+ emptyThresholdValue,
647
+ }: {
648
+ engine: ReturnType<typeof useLiveChartEngine>;
649
+ padding: ChartPadding;
650
+ thresholdCfg: ReturnType<typeof resolveThreshold>;
651
+ thresholdSeriesSV: SharedValue<LiveChartPoint[]> | null;
652
+ emptyThresholdValue: SharedValue<number>;
653
+ }) {
654
+ const thresholdValue = thresholdCfg?.value ?? emptyThresholdValue;
655
+ const [svSeriesNonEmpty, setSvSeriesNonEmpty] = useState(false);
656
+ useAnimatedReaction(
657
+ () => (thresholdSeriesSV ? thresholdSeriesSV.get().length > 0 : false),
658
+ /* istanbul ignore next -- UI-thread reaction */ (hasPoints, previous) => {
659
+ if (hasPoints !== previous) {
660
+ scheduleOnRN(setSvSeriesNonEmpty, hasPoints);
619
661
  }
620
- : null;
621
-
622
- // Snapshot the live value off the render path to size the right gutter to the
623
- // value label. Reading a SharedValue during render trips Reanimated's
624
- // strict-mode warning, and the gutter only needs a representative magnitude —
625
- // so read it in a layout effect (re-measures before paint, no visible reflow)
626
- // once on mount, re-synced if the `value` SharedValue identity changes.
627
- // react-doctor's "derive during render" fix is exactly what Reanimated forbids
628
- // here, so suppress its effect-read rules at this seed.
629
- const [valueLayoutSample, setValueLayoutSample] = useState<
630
- number | undefined
631
- >(undefined);
632
- // react-doctor-disable-next-line react-doctor/no-derived-state-effect -- Reanimated: must read the SharedValue off the render path
633
- useLayoutEffect(() => {
634
- // react-doctor-disable-next-line react-hooks-js/set-state-in-effect -- Reanimated: seeding from a SharedValue off render is the warning-free path
635
- // eslint-disable-next-line react-hooks/set-state-in-effect -- Reanimated: seed from the SharedValue outside render to avoid strict-mode access warnings
636
- setValueLayoutSample(value.get());
637
- }, [value]);
638
-
639
- // `scrolledBack` mirrors the UI-thread scroll state (engine.viewEnd != null)
640
- // onto the JS thread (set by the reaction below, after the engine exists).
641
- const [scrolledBack, setScrolledBack] = useState(false);
642
- const timeScrollEnabled = Boolean(timeScroll) && !isStatic;
643
- // Glide duration for the return-to-live animation (0 = instant). A sibling of
644
- // `timeScroll` so it survives `timeScroll={false}` (the disable that triggers it).
645
- const returnToLiveMs = resolveReturnToLiveMs(returnToLive);
646
- // Overscroll fraction ([0, 1)) — how far pan/zoom may travel past the data
647
- // bounds into blank space. 0 (the default) keeps the classic hard stops.
648
- const timeScrollOverscroll = timeScrollEnabled
649
- ? resolveOverscroll(timeScroll)
650
- : 0;
651
- // Release inertia (fling) — `timeScroll.fling: false` stops the pan dead.
652
- const timeScrollFling = resolveFling(timeScroll);
653
- const zoomCfg = resolveZoom(zoom);
654
- const zoomEnabled = zoomCfg !== null && !isStatic;
662
+ },
663
+ );
664
+ return {
665
+ thresholdSeriesHasPoints: thresholdSeriesSV
666
+ ? svSeriesNonEmpty
667
+ : Array.isArray(thresholdCfg?.value) && thresholdCfg.value.length > 0,
668
+ thresholdGeom: useThreshold(engine, padding, thresholdValue),
669
+ thresholdSeriesGeom: useThresholdSeries(
670
+ engine,
671
+ padding,
672
+ thresholdValue,
673
+ thresholdSeriesSV,
674
+ thresholdCfg?.extendToNow ?? true,
675
+ thresholdCfg?.line?.labelAnchor ?? "last",
676
+ ),
677
+ };
678
+ }
655
679
 
656
- const yAxisFloat = yAxisCfg?.float ?? false;
657
- // With timeScroll, the floating full-width plot engages only while scrolled
658
- // back; at the live edge the chart keeps a normal right gutter so the
659
- // line/candles don't sit under the floating y-axis labels + badge. Without
660
- // timeScroll, float behaves as before (always full-width).
661
- const effectiveYAxisFloat =
680
+ function useThresholdShaderUniforms({
681
+ engine,
682
+ padding,
683
+ samples,
684
+ clipRightX,
685
+ thresholdCfg,
686
+ palette,
687
+ lineProp,
688
+ }: {
689
+ engine: ReturnType<typeof useLiveChartEngine>;
690
+ padding: ChartPadding;
691
+ samples: SharedValue<number[]>;
692
+ clipRightX: SharedValue<number>;
693
+ thresholdCfg: ReturnType<typeof resolveThreshold>;
694
+ palette: LiveChartPalette;
695
+ lineProp: LiveChartProps["line"];
696
+ }) {
697
+ const { stopColors, splitAbove, splitBelow, fillOpacity, lineColor } =
698
+ resolveThresholdColorConfig(thresholdCfg, palette, lineProp);
699
+ const vectors = useMemo(() => {
700
+ if (splitAbove === null || splitBelow === null) return null;
701
+ const [r, g, b, a] = parseColorRgba(lineColor);
702
+ return {
703
+ ...thresholdSplitColorVecs(splitAbove, splitBelow, fillOpacity),
704
+ strokeRest: [r / 255, g / 255, b / 255, a],
705
+ };
706
+ }, [splitAbove, splitBelow, fillOpacity, lineColor]);
707
+ return {
708
+ thresholdStrokeColors: stopColors?.stroke ?? null,
709
+ thresholdFillColors: stopColors?.fill ?? null,
710
+ thresholdStrokeUniforms: useThresholdSplitUniforms(
711
+ samples,
712
+ engine,
713
+ padding,
714
+ vectors?.strokeAbove ?? THRESHOLD_FALLBACK_COLOR,
715
+ vectors?.strokeBelow ?? THRESHOLD_FALLBACK_COLOR,
716
+ vectors?.strokeRest ?? THRESHOLD_FALLBACK_COLOR,
717
+ clipRightX,
718
+ ),
719
+ thresholdFillUniforms: useThresholdSplitUniforms(
720
+ samples,
721
+ engine,
722
+ padding,
723
+ vectors?.fillAbove ?? THRESHOLD_FALLBACK_COLOR,
724
+ vectors?.fillBelow ?? THRESHOLD_FALLBACK_COLOR,
725
+ TRANSPARENT_VEC4,
726
+ clipRightX,
727
+ ),
728
+ };
729
+ }
730
+
731
+ function useThresholdBadgeProjection({
732
+ thresholdCfg,
733
+ thresholdIsSeries,
734
+ thresholdGeom,
735
+ thresholdSeriesGeom,
736
+ renderThresholdBadge,
737
+ formatValue,
738
+ }: {
739
+ thresholdCfg: ReturnType<typeof resolveThreshold>;
740
+ thresholdIsSeries: boolean;
741
+ thresholdGeom: ReturnType<typeof useThreshold>;
742
+ thresholdSeriesGeom: ReturnType<typeof useThresholdSeries>;
743
+ renderThresholdBadge: LiveChartProps["renderThresholdBadge"];
744
+ formatValue: (value: number) => string;
745
+ }) {
746
+ const lineY = thresholdIsSeries
747
+ ? thresholdSeriesGeom.badgeLineY
748
+ : thresholdGeom.lineY;
749
+ const markerVisible = thresholdIsSeries
750
+ ? thresholdSeriesGeom.visible
751
+ : thresholdGeom.visible;
752
+ const badgeVisible = thresholdIsSeries
753
+ ? thresholdSeriesGeom.badgeVisible
754
+ : thresholdGeom.visible;
755
+ const markerValue =
756
+ thresholdCfg && !thresholdIsSeries && !Array.isArray(thresholdCfg.value)
757
+ ? (thresholdCfg.value ?? thresholdSeriesGeom.badgeValue)
758
+ : thresholdSeriesGeom.badgeValue;
759
+ const custom = thresholdCfg?.line != null && renderThresholdBadge != null;
760
+ const valueStr = useDerivedValue(() =>
761
+ custom ? formatValue(markerValue.get()) : "",
762
+ );
763
+ return {
764
+ thresholdMarkerLineY: lineY,
765
+ thresholdMarkerVisible: markerVisible,
766
+ thresholdBadgeVisible: badgeVisible,
767
+ thresholdMarkerValue: markerValue,
768
+ thresholdCustomBadge:
769
+ thresholdCfg?.line && renderThresholdBadge
770
+ ? renderThresholdBadge({
771
+ line: thresholdCfg.line,
772
+ value: markerValue,
773
+ valueStr,
774
+ y: lineY,
775
+ visible: badgeVisible,
776
+ })
777
+ : null,
778
+ thresholdSeriesPts: thresholdIsSeries
779
+ ? thresholdSeriesGeom.screenPts
780
+ : undefined,
781
+ };
782
+ }
783
+
784
+ function useLiveChartThresholdModel({
785
+ engine,
786
+ padding,
787
+ thresholdCfg,
788
+ thresholdSeriesSV,
789
+ thresholdIsSeries,
790
+ emptyThresholdValue,
791
+ palette,
792
+ lineProp,
793
+ renderThresholdBadge,
794
+ formatValue,
795
+ }: {
796
+ engine: ReturnType<typeof useLiveChartEngine>;
797
+ padding: ChartPadding;
798
+ thresholdCfg: ReturnType<typeof resolveThreshold>;
799
+ thresholdSeriesSV: SharedValue<LiveChartPoint[]> | null;
800
+ thresholdIsSeries: boolean;
801
+ emptyThresholdValue: SharedValue<number>;
802
+ palette: LiveChartPalette;
803
+ lineProp: LiveChartProps["line"];
804
+ renderThresholdBadge: LiveChartProps["renderThresholdBadge"];
805
+ formatValue: (value: number) => string;
806
+ }) {
807
+ const { thresholdSeriesHasPoints, thresholdGeom, thresholdSeriesGeom } =
808
+ useThresholdGeometryState({
809
+ engine,
810
+ padding,
811
+ thresholdCfg,
812
+ thresholdSeriesSV,
813
+ emptyThresholdValue,
814
+ });
815
+ const shaderModel = useThresholdShaderUniforms({
816
+ engine,
817
+ padding,
818
+ samples: thresholdSeriesGeom.samples,
819
+ clipRightX: thresholdSeriesGeom.clipRightX,
820
+ thresholdCfg,
821
+ palette,
822
+ lineProp,
823
+ });
824
+ const badgeModel = useThresholdBadgeProjection({
825
+ thresholdCfg,
826
+ thresholdIsSeries,
827
+ thresholdGeom,
828
+ thresholdSeriesGeom,
829
+ renderThresholdBadge,
830
+ formatValue,
831
+ });
832
+
833
+ return {
834
+ thresholdGeom,
835
+ thresholdSeriesHasPoints,
836
+ ...shaderModel,
837
+ ...badgeModel,
838
+ thresholdFillLineY:
839
+ thresholdCfg?.fill && !thresholdIsSeries
840
+ ? thresholdGeom.lineY
841
+ : undefined,
842
+ thresholdFillSamples:
843
+ thresholdCfg?.fill && thresholdSeriesHasPoints
844
+ ? thresholdSeriesGeom.samples
845
+ : undefined,
846
+ };
847
+ }
848
+
849
+ function resolveLiveChartInteractionConfig({
850
+ isCandle,
851
+ mode,
852
+ candlesEngine,
853
+ liveEngine,
854
+ candleWidth,
855
+ candleGapsCfg,
856
+ lineGapsCfg,
857
+ markers,
858
+ markerCluster,
859
+ onReferenceLinePress,
860
+ allRefLines,
861
+ isStatic,
862
+ timeScroll,
863
+ timeScrollEnabled,
864
+ scrubCfg,
865
+ }: {
866
+ isCandle: boolean;
867
+ mode: "line" | "candle";
868
+ candlesEngine: SharedValue<CandlePoint[]>;
869
+ liveEngine: SharedValue<CandlePoint | null>;
870
+ candleWidth: number;
871
+ candleGapsCfg: ResolvedCandleGapsConfig | null;
872
+ lineGapsCfg: ResolvedCandleGapsConfig | null;
873
+ markers: LiveChartProps["markers"];
874
+ markerCluster: LiveChartProps["markerCluster"];
875
+ onReferenceLinePress: LiveChartProps["onReferenceLinePress"];
876
+ allRefLines: ReferenceLine[];
877
+ isStatic: boolean;
878
+ timeScroll: LiveChartProps["timeScroll"];
879
+ timeScrollEnabled: boolean;
880
+ scrubCfg: ReturnType<typeof resolveScrub>;
881
+ }) {
882
+ const crosshairChartOpts = isCandle
883
+ ? {
884
+ mode,
885
+ candles: candlesEngine,
886
+ liveCandle: liveEngine,
887
+ candleWidthSecs: candleWidth,
888
+ gaps: candleGapsCfg?.gaps,
889
+ bridgeNoTrades: Boolean(candleGapsCfg?.styles["no-trades"].bridge),
890
+ bridgeUnavailable: Boolean(candleGapsCfg?.styles.unavailable.bridge),
891
+ bridgeUnknown: Boolean(candleGapsCfg?.styles.unknown.bridge),
892
+ }
893
+ : lineGapsCfg
894
+ ? {
895
+ mode,
896
+ gaps: lineGapsCfg.gaps,
897
+ bridgeNoTrades: Boolean(lineGapsCfg.styles["no-trades"].bridge),
898
+ bridgeUnavailable: Boolean(lineGapsCfg.styles.unavailable.bridge),
899
+ bridgeUnknown: Boolean(lineGapsCfg.styles.unknown.bridge),
900
+ }
901
+ : undefined;
902
+ const scrollGestureMode =
903
+ typeof timeScroll === "object"
904
+ ? (timeScroll.gesture ?? "holdToScrub")
905
+ : "holdToScrub";
906
+ const timeScrollHoldMs =
907
+ typeof timeScroll === "object" ? timeScroll.scrubHoldMs : undefined;
908
+
909
+ return {
910
+ crosshairChartOpts,
911
+ markersActive: markers != null,
912
+ markerClusterCfg: resolveMarkerCluster(markerCluster),
913
+ refPressActive: onReferenceLinePress != null && allRefLines.length > 0,
914
+ refDragEnabled:
915
+ !isStatic && allRefLines.some((line) => line.draggable === true),
916
+ scrollGestureMode,
917
+ scrubHoldMs:
918
+ timeScrollEnabled && scrollGestureMode === "holdToScrub"
919
+ ? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
920
+ : (scrubCfg?.panGestureDelay ?? 0),
921
+ };
922
+ }
923
+
924
+ function composeLiveChartRootGesture({
925
+ crosshair,
926
+ markerTapGesture,
927
+ refLineTapGesture,
928
+ refDragGesture,
929
+ panScrollGesture,
930
+ pinchZoomGesture,
931
+ markersActive,
932
+ refPressActive,
933
+ refDragEnabled,
934
+ scrubActionActive,
935
+ timeScrollEnabled,
936
+ scrollGestureMode,
937
+ zoomEnabled,
938
+ }: {
939
+ crosshair: ReturnType<typeof useCrosshair>;
940
+ markerTapGesture: ReturnType<typeof useMarkers>["tapGesture"];
941
+ refLineTapGesture: ReturnType<typeof useReferenceLinePress>["tapGesture"];
942
+ refDragGesture: ReturnType<typeof useReferenceDrag>["gesture"];
943
+ panScrollGesture: ReturnType<typeof usePanScroll>;
944
+ pinchZoomGesture: ReturnType<typeof usePinchZoom>;
945
+ markersActive: boolean;
946
+ refPressActive: boolean;
947
+ refDragEnabled: boolean;
948
+ scrubActionActive: boolean;
949
+ timeScrollEnabled: boolean;
950
+ scrollGestureMode: "holdToScrub" | "axisDrag";
951
+ zoomEnabled: boolean;
952
+ }) {
953
+ const baseGesture =
954
+ scrubActionActive && crosshair.tapGesture
955
+ ? Gesture.Exclusive(crosshair.tapGesture, crosshair.gesture)
956
+ : crosshair.gesture;
957
+ const overlayTaps = [
958
+ markersActive ? markerTapGesture : null,
959
+ refPressActive ? refLineTapGesture : null,
960
+ ].filter(
961
+ (gesture): gesture is NonNullable<typeof gesture> => gesture !== null,
962
+ );
963
+ let rootGesture = baseGesture;
964
+ if (overlayTaps.length > 0) {
965
+ const tapGroup =
966
+ overlayTaps.length === 1
967
+ ? overlayTaps[0]
968
+ : Gesture.Simultaneous(overlayTaps[0], overlayTaps[1]);
969
+ rootGesture = Gesture.Simultaneous(baseGesture, tapGroup);
970
+ }
971
+ if (timeScrollEnabled) {
972
+ rootGesture =
973
+ scrollGestureMode === "axisDrag"
974
+ ? Gesture.Exclusive(panScrollGesture, rootGesture)
975
+ : Gesture.Race(panScrollGesture, rootGesture);
976
+ }
977
+ if (refDragEnabled) {
978
+ rootGesture = Gesture.Exclusive(refDragGesture, rootGesture);
979
+ }
980
+ return zoomEnabled
981
+ ? Gesture.Simultaneous(rootGesture, pinchZoomGesture)
982
+ : rootGesture;
983
+ }
984
+
985
+ function useAxisAutoHide({
986
+ config,
987
+ scrollActive,
988
+ scrubActive,
989
+ engine,
990
+ }: {
991
+ config: LiveChartProps["axisAutoHide"];
992
+ scrollActive: SharedValue<boolean>;
993
+ scrubActive: SharedValue<boolean>;
994
+ engine: ReturnType<typeof useLiveChartEngine>;
995
+ }) {
996
+ const resolved = config === true ? {} : config === false ? null : config;
997
+ const idleOpacity = resolved?.idleOpacity ?? 0;
998
+ const fadeInMs = resolved?.fadeInMs ?? 60;
999
+ const fadeOutMs = resolved?.fadeOutMs ?? 250;
1000
+ const hideAfterMs = resolved?.hideAfterMs ?? 3000;
1001
+ const opacity = useSharedValue(resolved ? idleOpacity : 1);
1002
+ const enabled = resolved !== null;
1003
+ const last = useRef({ enabled, idleOpacity });
1004
+ useEffect(() => {
1005
+ if (
1006
+ last.current.enabled === enabled &&
1007
+ last.current.idleOpacity === idleOpacity
1008
+ ) {
1009
+ return;
1010
+ }
1011
+ last.current = { enabled, idleOpacity };
1012
+ cancelAnimation(opacity);
1013
+ opacity.value = enabled ? idleOpacity : 1;
1014
+ }, [enabled, idleOpacity, opacity]);
1015
+ useAnimatedReaction(
1016
+ () => ({
1017
+ gesture: scrollActive.value || scrubActive.value,
1018
+ viewEnd: engine.viewEnd.value,
1019
+ viewWindow: engine.viewWindow.value,
1020
+ }),
1021
+ (current, previous) => {
1022
+ if (!enabled || previous === null) return;
1023
+ const moved =
1024
+ current.gesture !== previous.gesture ||
1025
+ current.viewEnd !== previous.viewEnd ||
1026
+ current.viewWindow !== previous.viewWindow;
1027
+ if (!moved) return;
1028
+ cancelAnimation(opacity);
1029
+ opacity.value = current.gesture
1030
+ ? withTiming(1, { duration: fadeInMs })
1031
+ : withSequence(
1032
+ withTiming(1, { duration: fadeInMs }),
1033
+ withDelay(
1034
+ hideAfterMs,
1035
+ withTiming(idleOpacity, { duration: fadeOutMs }),
1036
+ ),
1037
+ );
1038
+ },
1039
+ [enabled, idleOpacity, fadeInMs, fadeOutMs, hideAfterMs],
1040
+ );
1041
+ return opacity;
1042
+ }
1043
+
1044
+ function resolveLiveEngineModeInputs({
1045
+ isCandle,
1046
+ data,
1047
+ lineEngineData,
1048
+ candles,
1049
+ candlesEngine,
1050
+ liveCandle,
1051
+ liveEngine,
1052
+ candleGapsCfg,
1053
+ thresholdInRange,
1054
+ thresholdIsSeries,
1055
+ thresholdSeriesSV,
1056
+ thresholdCfg,
1057
+ }: {
1058
+ isCandle: boolean;
1059
+ data: LiveChartProps["data"];
1060
+ lineEngineData: LiveChartProps["data"];
1061
+ candles: LiveChartProps["candles"];
1062
+ candlesEngine: SharedValue<CandlePoint[]>;
1063
+ liveCandle: LiveChartProps["liveCandle"];
1064
+ liveEngine: SharedValue<CandlePoint | null>;
1065
+ candleGapsCfg: ResolvedCandleGapsConfig | null;
1066
+ thresholdInRange: boolean;
1067
+ thresholdIsSeries: boolean;
1068
+ thresholdSeriesSV: SharedValue<LiveChartPoint[]> | null;
1069
+ thresholdCfg: ResolvedThresholdConfig | null;
1070
+ }) {
1071
+ return {
1072
+ data: isCandle ? data : lineEngineData,
1073
+ thresholdRangePoints:
1074
+ thresholdInRange && thresholdIsSeries
1075
+ ? (thresholdSeriesSV ?? (thresholdCfg?.value as LiveChartPoint[]))
1076
+ : undefined,
1077
+ thresholdRangeExtendToNow: thresholdCfg?.extendToNow ?? true,
1078
+ candles: isCandle ? candlesEngine : candles,
1079
+ liveCandle: isCandle ? liveEngine : liveCandle,
1080
+ candleGaps: candleGapsCfg?.gaps,
1081
+ candleGapBridgeNoTrades: Boolean(candleGapsCfg?.styles["no-trades"].bridge),
1082
+ candleGapBridgeUnavailable: Boolean(
1083
+ candleGapsCfg?.styles.unavailable.bridge,
1084
+ ),
1085
+ candleGapBridgeUnknown: Boolean(candleGapsCfg?.styles.unknown.bridge),
1086
+ };
1087
+ }
1088
+
1089
+ function resolveCrosshairControllerSettings({
1090
+ scrubCfg,
1091
+ scrubActionCfg,
1092
+ markersActive,
1093
+ refPressActive,
1094
+ refDragEnabled,
1095
+ deferTapHit,
1096
+ timeScrollEnabled,
1097
+ scrollGestureMode,
1098
+ effectivePadding,
1099
+ }: {
1100
+ scrubCfg: ReturnType<typeof resolveScrub>;
1101
+ scrubActionCfg: ReturnType<typeof resolveScrubAction>;
1102
+ markersActive: boolean;
1103
+ refPressActive: boolean;
1104
+ refDragEnabled: boolean;
1105
+ deferTapHit: (x: number, y: number) => boolean;
1106
+ timeScrollEnabled: boolean;
1107
+ scrollGestureMode: "holdToScrub" | "axisDrag";
1108
+ effectivePadding: ChartPadding;
1109
+ }) {
1110
+ return {
1111
+ enabled: scrubCfg !== null || scrubActionCfg !== null,
1112
+ deferTapHit:
1113
+ markersActive || refPressActive || refDragEnabled
1114
+ ? deferTapHit
1115
+ : undefined,
1116
+ tooltipPlacement: scrubCfg?.tooltipPlacement ?? "side",
1117
+ tooltipShowValue: scrubCfg?.tooltipShowValue ?? true,
1118
+ tooltipShowTime: scrubCfg?.tooltipShowTime ?? true,
1119
+ tooltipMargin: scrubCfg?.tooltipMargin ?? 8,
1120
+ scrubBottomExclude:
1121
+ timeScrollEnabled && scrollGestureMode === "axisDrag"
1122
+ ? Math.max(effectivePadding.bottom, AXIS_GRAB_MIN_PX)
1123
+ : 0,
1124
+ clampToPlot: scrubCfg?.clampToPlot ?? false,
1125
+ snapToCandles: scrubCfg?.snapToCandles ?? false,
1126
+ };
1127
+ }
1128
+
1129
+ function resolveAreaDotColorVec(
1130
+ areaDotsCfg: ReturnType<typeof resolveAreaDots>,
1131
+ lineProp: LiveChartProps["line"],
1132
+ palette: LiveChartPalette,
1133
+ ) {
1134
+ const rgb = parseColorRgb(lineProp?.color ?? palette.line);
1135
+ const [r, g, b, a] = parseColorRgba(
1136
+ areaDotsCfg?.color ?? `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.22)`,
1137
+ );
1138
+ return [r / 255, g / 255, b / 255, a * (areaDotsCfg?.opacity ?? 1)];
1139
+ }
1140
+
1141
+ function useLiveIndicatorOpacities({
1142
+ reveal,
1143
+ seriesIndicatorOpacity,
1144
+ seriesOpacity,
1145
+ scrubActive,
1146
+ selectionDotDuringScrub,
1147
+ hideLiveOnScrollBack,
1148
+ dotTracksParked,
1149
+ viewEnd,
1150
+ fadeOverlaysOnScrub,
1151
+ }: {
1152
+ reveal: ReturnType<typeof useChartReveal>;
1153
+ seriesIndicatorOpacity: SharedValue<number>;
1154
+ seriesOpacity: SharedValue<number>;
1155
+ scrubActive: SharedValue<boolean>;
1156
+ selectionDotDuringScrub: boolean;
1157
+ hideLiveOnScrollBack: boolean;
1158
+ dotTracksParked: boolean;
1159
+ viewEnd: SharedValue<number | null>;
1160
+ fadeOverlaysOnScrub: boolean;
1161
+ }) {
1162
+ const liveDotOpacity = useDerivedValue(
1163
+ () =>
1164
+ reveal.dotOpacity.value *
1165
+ (selectionDotDuringScrub && scrubActive.value ? 0 : 1) *
1166
+ liveIndicatorScrollOpacity(
1167
+ hideLiveOnScrollBack && !dotTracksParked,
1168
+ viewEnd.value,
1169
+ ) *
1170
+ seriesIndicatorOpacity.value,
1171
+ );
1172
+ const valueLineOpacity = useDerivedValue(
1173
+ () =>
1174
+ reveal.lineOpacity.value *
1175
+ liveIndicatorScrollOpacity(hideLiveOnScrollBack, viewEnd.value) *
1176
+ seriesOpacity.value,
1177
+ );
1178
+ const liveBadgeOpacity = useDerivedValue(
1179
+ () =>
1180
+ reveal.badgeOpacity.value *
1181
+ liveIndicatorScrollOpacity(hideLiveOnScrollBack, viewEnd.value) *
1182
+ seriesOpacity.value,
1183
+ );
1184
+ const overlayScrubFade = useDerivedValue(() =>
1185
+ fadeOverlaysOnScrub
1186
+ ? withTiming(scrubActive.get() ? 0 : 1, {
1187
+ duration: SCRUB_OVERLAY_FADE_MS,
1188
+ })
1189
+ : 1,
1190
+ );
1191
+ const markerGroupOpacity = useDerivedValue(
1192
+ () => reveal.dotOpacity.get() * overlayScrubFade.get(),
1193
+ );
1194
+ return {
1195
+ liveDotOpacity,
1196
+ valueLineOpacity,
1197
+ liveBadgeOpacity,
1198
+ overlayScrubFade,
1199
+ markerGroupOpacity,
1200
+ };
1201
+ }
1202
+
1203
+ function useReferenceLineGrouping({
1204
+ radius,
1205
+ engine,
1206
+ padding,
1207
+ lines,
1208
+ custom,
1209
+ offAxisCustom,
1210
+ dragValues,
1211
+ }: {
1212
+ radius: number | null;
1213
+ engine: ReturnType<typeof useLiveChartEngine>;
1214
+ padding: ChartPadding;
1215
+ lines: ReferenceLine[];
1216
+ custom: boolean[];
1217
+ offAxisCustom: boolean[];
1218
+ dragValues: SharedValue<number[]>;
1219
+ }) {
1220
+ const result = useDerivedValue<ReferenceGrouping>(() => {
1221
+ if (radius == null) return EMPTY_GROUPING;
1222
+ const canvasHeight = engine.canvasHeight.get();
1223
+ const displayMin = engine.displayMin.get();
1224
+ const displayMax = engine.displayMax.get();
1225
+ const top = padding.top;
1226
+ const bottom = canvasHeight - padding.bottom;
1227
+ const yPositions: number[] = [];
1228
+ for (let index = 0; index < lines.length; index++) {
1229
+ const line = lines[index];
1230
+ if (
1231
+ referenceLineForm(line) !== "line" ||
1232
+ line.value === undefined ||
1233
+ custom[index] ||
1234
+ offAxisCustom[index]
1235
+ ) {
1236
+ yPositions.push(-1);
1237
+ continue;
1238
+ }
1239
+ const value = dragValues.get()[index] ?? line.value;
1240
+ const y = computeScrubDotY(
1241
+ value,
1242
+ displayMin,
1243
+ displayMax,
1244
+ canvasHeight,
1245
+ top,
1246
+ padding.bottom,
1247
+ );
1248
+ yPositions.push(y < 0 ? -1 : Math.min(bottom, Math.max(top, y)));
1249
+ }
1250
+ return groupReferenceLines(yPositions, radius);
1251
+ });
1252
+ const hidden = useDerivedValue<boolean[]>(() => result.get().hidden);
1253
+ return { refGroupResult: result, groupHidden: hidden };
1254
+ }
1255
+
1256
+ function useLiveChartLayoutResources({
1257
+ value,
1258
+ fontProp,
1259
+ palette,
1260
+ badgeHasFontOverride,
1261
+ badgeFontConfig,
1262
+ refGroupBadgeHasFont,
1263
+ refGroupBadgeFontConfig,
1264
+ yAxisFloat,
1265
+ timeScrollEnabled,
1266
+ lineProp,
1267
+ insets,
1268
+ yAxisCfg,
1269
+ badgeCfg,
1270
+ metricsCfg,
1271
+ badgeUsesRightGutter,
1272
+ xAxisCfg,
1273
+ formatValue,
1274
+ pulseConfig,
1275
+ dotCfg,
1276
+ volumeBandHeight,
1277
+ }: {
1278
+ value: LiveChartProps["value"];
1279
+ fontProp: LiveChartProps["font"];
1280
+ palette: LiveChartPalette;
1281
+ badgeHasFontOverride: boolean;
1282
+ badgeFontConfig: LiveChartProps["font"];
1283
+ refGroupBadgeHasFont: boolean;
1284
+ refGroupBadgeFontConfig: LiveChartProps["font"];
1285
+ yAxisFloat: boolean;
1286
+ timeScrollEnabled: boolean;
1287
+ lineProp: LiveChartProps["line"];
1288
+ insets: LiveChartProps["insets"];
1289
+ yAxisCfg: ReturnType<typeof resolveYAxis>;
1290
+ badgeCfg: ReturnType<typeof resolveBadge>;
1291
+ metricsCfg: ReturnType<typeof resolveMetrics>;
1292
+ badgeUsesRightGutter: boolean;
1293
+ xAxisCfg: ReturnType<typeof resolveXAxis>;
1294
+ formatValue: (value: number) => string;
1295
+ pulseConfig: { maxRadius: number; strokeWidth: number } | null;
1296
+ dotCfg: ReturnType<typeof resolveDot>;
1297
+ volumeBandHeight: number;
1298
+ }) {
1299
+ const skiaFont = useChartSkiaFont(
1300
+ fontProp,
1301
+ MONO_FONT_FAMILY,
1302
+ palette.labelFontSize,
1303
+ );
1304
+ const valueFont = useChartSkiaFont(
1305
+ fontProp,
1306
+ MONO_FONT_FAMILY,
1307
+ palette.valueFontSize * 2,
1308
+ );
1309
+ const badgeFontOverride = useChartSkiaFont(
1310
+ badgeFontConfig,
1311
+ MONO_FONT_FAMILY,
1312
+ palette.labelFontSize,
1313
+ );
1314
+ const badgeFont = badgeHasFontOverride ? badgeFontOverride : skiaFont;
1315
+ const refGroupBadgeFontOverride = useChartSkiaFont(
1316
+ refGroupBadgeFontConfig,
1317
+ MONO_FONT_FAMILY,
1318
+ palette.labelFontSize,
1319
+ );
1320
+ const refGroupBadgeFont = refGroupBadgeHasFont
1321
+ ? refGroupBadgeFontOverride
1322
+ : skiaFont;
1323
+ const valueLayoutSample = useInitialSharedValueSample(value);
1324
+ const [scrolledBack, setScrolledBack] = useState(false);
1325
+ const effectiveYAxisFloat =
662
1326
  yAxisFloat && (!timeScrollEnabled || scrolledBack);
663
- const { strokeWidth, padding: effectivePadding } = resolveChartLayout({
1327
+ const layout = resolveChartLayout({
664
1328
  palette,
665
1329
  lineWidthOverride: lineProp?.width,
666
1330
  insetsOverride: insets,
@@ -669,18 +1333,332 @@ function useLiveChartController({
669
1333
  badge: badgeCfg !== null,
670
1334
  badgeMetrics: metricsCfg.badge,
671
1335
  badgeUsesRightGutter,
672
- badgeShowTail: badgeCfg?.tail ?? true,
673
- xAxis: xAxisCfg !== null,
674
- font: skiaFont,
1336
+ badgeShowTail: badgeCfg?.tail ?? true,
1337
+ xAxis: xAxisCfg !== null,
1338
+ font: skiaFont,
1339
+ formatValue,
1340
+ currentValue: valueLayoutSample,
1341
+ pulse: pulseConfig,
1342
+ dotGlow: dotCfg.glow,
1343
+ volumeBandHeight,
1344
+ });
1345
+ return {
1346
+ skiaFont,
1347
+ valueFont,
1348
+ badgeFont,
1349
+ refGroupBadgeFont,
1350
+ effectiveYAxisFloat,
1351
+ setScrolledBack,
1352
+ ...layout,
1353
+ };
1354
+ }
1355
+
1356
+ function useInitialSharedValueSample(value: SharedValue<number>) {
1357
+ const [sample, setSample] = useState<number | undefined>(undefined);
1358
+ const captured = useSharedValue(false);
1359
+ useAnimatedReaction(
1360
+ () => (captured.get() ? null : value.get()),
1361
+ (current) => {
1362
+ if (current === null) return;
1363
+ captured.set(true);
1364
+ scheduleOnRN(setSample, current);
1365
+ },
1366
+ [captured, value],
1367
+ );
1368
+ return sample;
1369
+ }
1370
+
1371
+ function resolveLiveChartModelDefaults({
1372
+ isCandle,
1373
+ candleWidth,
1374
+ loadingCfg,
1375
+ volumeCfg,
1376
+ palette,
1377
+ lineProp,
1378
+ }: {
1379
+ isCandle: boolean;
1380
+ candleWidth: number;
1381
+ loadingCfg: ReturnType<typeof resolveLoading>;
1382
+ volumeCfg: ReturnType<typeof resolveVolume>;
1383
+ palette: LiveChartPalette;
1384
+ lineProp: LiveChartProps["line"];
1385
+ }) {
1386
+ return {
1387
+ extremaTimeOffset: isCandle ? candleWidth / 2 : 0,
1388
+ loadingLineColor: loadingCfg?.color,
1389
+ loadingStrokeWidth: loadingCfg?.strokeWidth,
1390
+ loadingAmplitude: loadingCfg?.amplitude,
1391
+ loadingSpeed: loadingCfg?.speed,
1392
+ loadingAxisLabels: loadingCfg?.axisLabels ?? true,
1393
+ volumeOpacity: volumeCfg?.opacity ?? 1,
1394
+ volumeUpColor: volumeCfg?.upColor ?? palette.candleUp,
1395
+ volumeDownColor: volumeCfg?.downColor ?? palette.candleDown,
1396
+ selectionColor: lineProp?.color ?? palette.line,
1397
+ };
1398
+ }
1399
+
1400
+ /**
1401
+ * Resolves props → configs → theme/layout → engine → per-frame derived values and
1402
+ * overlay hooks, returning a single render model. All the chart's wiring lives
1403
+ * here so the rendered pieces (`ChartStack`, `ChartScrubLayer`, `LiveChart`) stay
1404
+ * small and presentational.
1405
+ */
1406
+
1407
+ function useLiveChartController({
1408
+ // ── Data ────────────────────────────────────────────────────────────────
1409
+ data,
1410
+ value,
1411
+
1412
+ // ── Appearance ──────────────────────────────────────────────────────────
1413
+ theme = "dark",
1414
+ accentColor = DEFAULT_ACCENT_COLOR,
1415
+ gradient = true,
1416
+ areaDots,
1417
+ line: lineProp,
1418
+ font: fontProp,
1419
+ insets,
1420
+ style,
1421
+ seriesOpacity,
1422
+ canvasMode = "transparent",
1423
+
1424
+ // ── Candlestick ─────────────────────────────────────────────────────────
1425
+ mode = "line",
1426
+ candles,
1427
+ candleWidth = 60,
1428
+ liveCandle,
1429
+ candleGaps,
1430
+ lineGaps,
1431
+ volume,
1432
+
1433
+ // ── Behaviour ───────────────────────────────────────────────────────────
1434
+ timeWindow = 30,
1435
+ paused = false,
1436
+ loading = false,
1437
+ transitions,
1438
+ // `static` is a reserved word — alias it so the destructure parses.
1439
+ static: isStatic = false,
1440
+ snapKey,
1441
+ smoothing = 0.08,
1442
+ exaggerate = false,
1443
+ nonNegative = false,
1444
+ maxValue,
1445
+ yRangeScale,
1446
+ windowBuffer = 0,
1447
+ nowOverride,
1448
+ timeScroll = false,
1449
+ returnToLive,
1450
+ zoom = false,
1451
+ accessibilityLabel,
1452
+ accessibilityRole = "image",
1453
+ emptyText = "No data",
1454
+ formatValue = defaultFormatValue,
1455
+ formatTime = defaultFormatTime,
1456
+
1457
+ // ── Overlays ────────────────────────────────────────────────────────────
1458
+ yAxis = true,
1459
+ xAxis = true,
1460
+ axisAutoHide = false,
1461
+ topLabel,
1462
+ bottomLabel,
1463
+ badge = true,
1464
+ momentum = true,
1465
+ pulse = true,
1466
+ dot,
1467
+ valueLine = true,
1468
+ showValue = false,
1469
+ valueMomentumColor = false,
1470
+ referenceLines,
1471
+ segments,
1472
+ threshold,
1473
+ gridStyle,
1474
+ palette: paletteOverride,
1475
+ metrics,
1476
+ scrub = true,
1477
+ scrubAction,
1478
+ selectionDot,
1479
+ tradeStream,
1480
+ degen,
1481
+ markers,
1482
+ onMarkerPress,
1483
+ markerHitRadius = 16,
1484
+ markerCluster,
1485
+ renderMarker,
1486
+ renderTooltip,
1487
+ renderOverlay,
1488
+ renderThresholdBadge,
1489
+ renderReferenceLine,
1490
+ renderOffAxisReferenceLine,
1491
+ referenceLineGrouping,
1492
+ leftEdgeFade = true,
1493
+
1494
+ // ── Callbacks ───────────────────────────────────────────────────────────
1495
+ onScrub,
1496
+ onScrubAction,
1497
+ onReferenceLinePress,
1498
+ onGestureStart,
1499
+ onGestureEnd,
1500
+ onVisibleRangeChange,
1501
+ onReachStart,
1502
+ onDegenShake,
1503
+ }: LiveChartProps) {
1504
+ const fullSeriesOpacity = useSharedValue(1);
1505
+ const resolvedSeriesOpacity = seriesOpacity ?? fullSeriesOpacity;
1506
+ const emptyMarkers = useSharedValue<Marker[]>([]);
1507
+ const markersSV = markers ?? emptyMarkers;
1508
+ // Stand-in threshold value so `useThreshold` can be called unconditionally
1509
+ // (hooks can't be); the geometry is ignored when no threshold is configured.
1510
+ const emptyThresholdValue = useSharedValue(0);
1511
+ const {
1512
+ isCandle,
1513
+ yAxisCfg,
1514
+ xAxisCfg,
1515
+ topLabelCfg,
1516
+ bottomLabelCfg,
1517
+ badgeCfg,
1518
+ scrubCfg,
1519
+ scrubActionCfg,
1520
+ volumeCfg,
1521
+ chartGapsCfg,
1522
+ candleGapsCfg,
1523
+ lineGapsCfg,
1524
+ volumeBandHeight,
1525
+ gradientCfg,
1526
+ areaDotsCfg,
1527
+ thresholdCfg,
1528
+ thresholdSeriesSV,
1529
+ thresholdIsSeries,
1530
+ valueLineCfg,
1531
+ pulseCfg,
1532
+ dotCfg,
1533
+ dotTracksParked,
1534
+ selectionDotCfg,
1535
+ dotOuterRadius,
1536
+ gridStyleCfg,
1537
+ degenCfg,
1538
+ tradeStreamResolved,
1539
+ metricsCfg,
1540
+ } = resolveLiveChartFeatureConfig({
1541
+ mode,
1542
+ yAxis,
1543
+ xAxis,
1544
+ topLabel,
1545
+ bottomLabel,
1546
+ badge,
1547
+ scrub,
1548
+ scrubAction,
1549
+ volume,
1550
+ candleGaps,
1551
+ lineGaps,
1552
+ gradient,
1553
+ areaDots,
1554
+ threshold,
1555
+ valueLine,
1556
+ isStatic,
1557
+ pulse,
1558
+ dot,
1559
+ selectionDot,
1560
+ gridStyle,
1561
+ degen,
1562
+ tradeStream,
1563
+ metrics,
1564
+ });
1565
+
1566
+ const {
1567
+ chartGapBands,
1568
+ allRefLines,
1569
+ refValues,
1570
+ refLineCustom,
1571
+ refLineOffAxisCustom,
1572
+ refLineKeys,
1573
+ draggableRefIdx,
1574
+ thresholdInRange,
1575
+ thresholdRangeValueSV,
1576
+ refGroupingCfg,
1577
+ refGroupingRadius,
1578
+ refGroupBadge,
1579
+ refGroupFormat,
1580
+ badgeUsesRightGutter,
1581
+ } = resolveLiveChartReferenceConfig({
1582
+ chartGapsCfg,
1583
+ referenceLines,
1584
+ renderReferenceLine,
1585
+ renderOffAxisReferenceLine,
1586
+ thresholdCfg,
1587
+ referenceLineGrouping,
1588
+ badgeCfg,
1589
+ });
1590
+
1591
+ const { dragValues, dragActive, refLineCustomTagWidths, liveRefValues } =
1592
+ useLiveReferenceState(allRefLines, draggableRefIdx, thresholdRangeValueSV);
1593
+
1594
+ const {
1595
+ palette,
1596
+ resolvedSegments,
1597
+ hasRecolorSegments,
1598
+ leftEdgeFadeCfg,
1599
+ badgeHasFontOverride,
1600
+ badgeFontConfig,
1601
+ refGroupBadgeHasFont,
1602
+ refGroupBadgeFontConfig,
1603
+ pulseConfig,
1604
+ timeScrollEnabled,
1605
+ returnToLiveMs,
1606
+ timeScrollOverscroll,
1607
+ timeScrollFling,
1608
+ zoomCfg,
1609
+ zoomEnabled,
1610
+ yAxisFloat,
1611
+ } = resolveLiveChartPresentationConfig({
1612
+ accentColor,
1613
+ theme,
1614
+ paletteOverride,
1615
+ segments,
1616
+ leftEdgeFade,
1617
+ fontProp,
1618
+ badgeCfg,
1619
+ refGroupBadge,
1620
+ pulseCfg,
1621
+ timeScroll,
1622
+ isStatic,
1623
+ returnToLive,
1624
+ zoom,
1625
+ yAxisCfg,
1626
+ });
1627
+
1628
+ const {
1629
+ skiaFont,
1630
+ valueFont,
1631
+ badgeFont,
1632
+ refGroupBadgeFont,
1633
+ effectiveYAxisFloat,
1634
+ setScrolledBack,
1635
+ strokeWidth,
1636
+ padding: effectivePadding,
1637
+ } = useLiveChartLayoutResources({
1638
+ value,
1639
+ fontProp,
1640
+ palette,
1641
+ badgeHasFontOverride,
1642
+ badgeFontConfig,
1643
+ refGroupBadgeHasFont,
1644
+ refGroupBadgeFontConfig,
1645
+ yAxisFloat,
1646
+ timeScrollEnabled,
1647
+ lineProp,
1648
+ insets,
1649
+ yAxisCfg,
1650
+ badgeCfg,
1651
+ metricsCfg,
1652
+ badgeUsesRightGutter,
1653
+ xAxisCfg,
675
1654
  formatValue,
676
- currentValue: valueLayoutSample,
677
- pulse: pulseConfig,
678
- dotGlow: dotCfg.glow,
1655
+ pulseConfig,
1656
+ dotCfg,
679
1657
  volumeBandHeight,
680
1658
  });
681
1659
 
682
1660
  // ── Reveal state ────────────────────────────────────────────
683
- // ≥2 line points or ≥2 committed candles; morphT=1 only when !loading && hasData.
1661
+ // ≥1 line point or ≥1 committed candle; morphT=1 only when !loading && hasData.
684
1662
  const { hasData } = useLiveChartHasData({
685
1663
  isCandle,
686
1664
  data,
@@ -717,8 +1695,22 @@ function useLiveChartController({
717
1695
  // ── Engine ─────────────────────────────────────────────────────────────
718
1696
  // Line mode: tick + paths use `lineEngineData` (stash when reversing). Candle mode:
719
1697
  // parent `data` stays tick/line-morph input; OHLC uses candlesEngine + liveEngine.
1698
+ const engineModeInputs = resolveLiveEngineModeInputs({
1699
+ isCandle,
1700
+ data,
1701
+ lineEngineData,
1702
+ candles,
1703
+ candlesEngine,
1704
+ liveCandle,
1705
+ liveEngine,
1706
+ candleGapsCfg,
1707
+ thresholdInRange,
1708
+ thresholdIsSeries,
1709
+ thresholdSeriesSV,
1710
+ thresholdCfg,
1711
+ });
720
1712
  const engine = useLiveChartEngine({
721
- data: isCandle ? data : lineEngineData,
1713
+ ...engineModeInputs,
722
1714
  value,
723
1715
  timeWindow,
724
1716
  paused,
@@ -732,21 +1724,12 @@ function useLiveChartController({
732
1724
  exaggerate,
733
1725
  referenceValues: refValues,
734
1726
  liveReferenceValues: liveRefValues,
735
- // Series threshold with `includeInRange`: the tick folds its window min/max
736
- // into the Y-range fit (the constant form rides `liveReferenceValues`).
737
- thresholdRangePoints:
738
- thresholdInRange && thresholdIsSeries
739
- ? (thresholdSeriesSV ?? (thresholdCfg?.value as LiveChartPoint[]))
740
- : undefined,
741
- thresholdRangeExtendToNow: thresholdCfg?.extendToNow ?? true,
742
1727
  nonNegative,
743
1728
  maxValue,
744
1729
  yRangeScale,
745
1730
  windowBuffer,
746
1731
  nowOverride,
747
1732
  mode,
748
- candles: isCandle ? candlesEngine : candles,
749
- liveCandle: isCandle ? liveEngine : liveCandle,
750
1733
  });
751
1734
 
752
1735
  // Mirror the UI-thread scroll state to React so the floating y-axis can keep
@@ -776,158 +1759,48 @@ function useLiveChartController({
776
1759
  // ── Per-frame derived values ───────────────────────────────────────────
777
1760
  const { layoutWidth, layoutHeight, onLayout } = useCanvasLayout(engine);
778
1761
 
779
- // Reference-line grouping: cluster Form-A lines by their per-frame value-Y so a
780
- // stack of nearby orders collapses into one count handle. `groupHidden` suppresses
781
- // the clustered lines' individual tags; the count pills read `refGroupResult`.
782
- // Identity-stable (no work) when grouping is off.
783
- const refGroupResult = useDerivedValue<ReferenceGrouping>(() => {
784
- if (refGroupingRadius == null) return EMPTY_GROUPING;
785
- const ch = engine.canvasHeight.get();
786
- const dMin = engine.displayMin.get();
787
- const dMax = engine.displayMax.get();
788
- const top = effectivePadding.top;
789
- const bottom = ch - effectivePadding.bottom;
790
- const ys: number[] = [];
791
- for (let i = 0; i < allRefLines.length; i++) {
792
- const l = allRefLines[i];
793
- // Skip bands and lines a custom `renderReferenceLine` owns — a custom tag
794
- // draws itself and isn't suppressed by grouping, so folding it into a
795
- // built-in count pill would double-count it (counted *and* still shown).
796
- if (
797
- referenceLineForm(l) !== "line" ||
798
- l.value === undefined ||
799
- refLineCustom[i] ||
800
- refLineOffAxisCustom[i]
801
- ) {
802
- ys.push(-1);
803
- continue;
804
- }
805
- const v = dragValues.get()[i] ?? l.value;
806
- const y = computeScrubDotY(
807
- v,
808
- dMin,
809
- dMax,
810
- ch,
811
- top,
812
- effectivePadding.bottom,
813
- );
814
- ys.push(y < 0 ? -1 : Math.min(bottom, Math.max(top, y)));
815
- }
816
- return groupReferenceLines(ys, refGroupingRadius);
1762
+ const { refGroupResult, groupHidden } = useReferenceLineGrouping({
1763
+ radius: refGroupingRadius,
1764
+ engine,
1765
+ padding: effectivePadding,
1766
+ lines: allRefLines,
1767
+ custom: refLineCustom,
1768
+ offAxisCustom: refLineOffAxisCustom,
1769
+ dragValues,
817
1770
  });
818
- const groupHidden = useDerivedValue<boolean[]>(
819
- () => refGroupResult.get().hidden,
820
- );
821
1771
 
822
1772
  // Threshold split geometry. Two forms, picked at render by `Array.isArray` (no
823
1773
  // SharedValue read): a constant `SharedValue<number>` benchmark drives the
824
1774
  // vertical hard-stop gradient (`thresholdGeom`); a time-varying `LiveChartPoint[]`
825
1775
  // series drives the per-fragment split shader (`thresholdSeriesGeom`). Both hooks
826
1776
  // run unconditionally — the unused one short-circuits cheaply on the UI thread.
827
- const thresholdValue = thresholdCfg?.value ?? emptyThresholdValue;
828
- // An empty series (e.g. threshold history not fetched yet) must not paint: no
829
- // band, no shader-forced stroke color — unlike the constant form, "no points
830
- // yet" is a reachable state and should look like "no threshold". For the live
831
- // `series` SharedValue form the emptiness lives on the UI thread, so mirror it
832
- // to React state (fires only on empty↔non-empty transitions).
833
- const [svSeriesNonEmpty, setSvSeriesNonEmpty] = useState(false);
834
- useAnimatedReaction(
835
- () => (thresholdSeriesSV ? thresholdSeriesSV.get().length > 0 : false),
836
- /* istanbul ignore next -- Reanimated reaction; state mirrored on the JS thread, not exercised under Jest */
837
- (hasPts, prev) => {
838
- if (hasPts !== prev) scheduleOnRN(setSvSeriesNonEmpty, hasPts);
839
- },
840
- );
841
- const thresholdSeriesHasPoints = thresholdSeriesSV
842
- ? svSeriesNonEmpty
843
- : Array.isArray(thresholdCfg?.value) && thresholdCfg.value.length > 0;
844
- const thresholdGeom = useThreshold(engine, effectivePadding, thresholdValue);
845
- const thresholdSeriesGeom = useThresholdSeries(
1777
+ const {
1778
+ thresholdGeom,
1779
+ thresholdStrokeColors,
1780
+ thresholdFillColors,
1781
+ thresholdSeriesHasPoints,
1782
+ thresholdStrokeUniforms,
1783
+ thresholdFillUniforms,
1784
+ thresholdMarkerLineY,
1785
+ thresholdMarkerVisible,
1786
+ thresholdBadgeVisible,
1787
+ thresholdMarkerValue,
1788
+ thresholdCustomBadge,
1789
+ thresholdSeriesPts,
1790
+ thresholdFillLineY,
1791
+ thresholdFillSamples,
1792
+ } = useLiveChartThresholdModel({
846
1793
  engine,
847
- effectivePadding,
848
- thresholdValue,
1794
+ padding: effectivePadding,
1795
+ thresholdCfg,
849
1796
  thresholdSeriesSV,
850
- thresholdCfg?.extendToNow ?? true,
851
- );
852
- const thresholdStopColors = thresholdCfg
853
- ? thresholdStops(thresholdCfg, palette)
854
- : null;
855
-
856
- // Split colors as vec4s for the series shader, memoized on the *resolved*
857
- // color strings + opacity (so a threshold added after mount with default
858
- // colors still computes, and the per-frame uniforms worklet isn't rebuilt
859
- // every render). `strokeRest` is the plain line color painted right of the
860
- // `extendToNow: false` cutoff.
861
- const thresholdSplitAbove = thresholdCfg
862
- ? (thresholdCfg.aboveColor ?? palette.candleUp)
863
- : null;
864
- const thresholdSplitBelow = thresholdCfg
865
- ? (thresholdCfg.belowColor ?? palette.candleDown)
866
- : null;
867
- const thresholdFillOpacity =
868
- thresholdCfg?.fillOpacity ?? THRESHOLD_FILL_OPACITY_DEFAULT;
869
- const thresholdLineColorStr = lineProp?.color ?? palette.line;
870
- const thresholdVecs = useMemo(
871
- () =>
872
- thresholdSplitAbove !== null && thresholdSplitBelow !== null
873
- ? {
874
- ...thresholdSplitColorVecs(
875
- thresholdSplitAbove,
876
- thresholdSplitBelow,
877
- thresholdFillOpacity,
878
- ),
879
- strokeRest: (() => {
880
- const [r, g, b, a] = parseColorRgba(thresholdLineColorStr);
881
- return [r / 255, g / 255, b / 255, a];
882
- })(),
883
- }
884
- : null,
885
- [
886
- thresholdSplitAbove,
887
- thresholdSplitBelow,
888
- thresholdFillOpacity,
889
- thresholdLineColorStr,
890
- ],
891
- );
892
- const thresholdStrokeUniforms = useThresholdSplitUniforms(
893
- thresholdSeriesGeom.samples,
894
- engine,
895
- effectivePadding,
896
- thresholdVecs?.strokeAbove ?? THRESHOLD_FALLBACK_COLOR,
897
- thresholdVecs?.strokeBelow ?? THRESHOLD_FALLBACK_COLOR,
898
- thresholdVecs?.strokeRest ?? THRESHOLD_FALLBACK_COLOR,
899
- thresholdSeriesGeom.clipRightX,
900
- );
901
- const thresholdFillUniforms = useThresholdSplitUniforms(
902
- thresholdSeriesGeom.samples,
903
- engine,
904
- effectivePadding,
905
- thresholdVecs?.fillAbove ?? THRESHOLD_FALLBACK_COLOR,
906
- thresholdVecs?.fillBelow ?? THRESHOLD_FALLBACK_COLOR,
907
- TRANSPARENT_VEC4,
908
- thresholdSeriesGeom.clipRightX,
909
- );
910
-
911
- // Marker line + badge sources: the series anchors at the value-at-now (flat-
912
- // extended past its last point); the constant case at the single benchmark Y.
913
- // The badge gets its own visibility — it's pinned at the value-at-now Y, which
914
- // can be off-plot while older polyline segments are still visible.
915
- const thresholdMarkerLineY = thresholdIsSeries
916
- ? thresholdSeriesGeom.currentLineY
917
- : thresholdGeom.lineY;
918
- const thresholdMarkerVisible = thresholdIsSeries
919
- ? thresholdSeriesGeom.visible
920
- : thresholdGeom.visible;
921
- const thresholdBadgeVisible = thresholdIsSeries
922
- ? thresholdSeriesGeom.currentVisible
923
- : thresholdGeom.visible;
924
- const thresholdMarkerValue =
925
- thresholdCfg && !thresholdIsSeries && !Array.isArray(thresholdCfg.value)
926
- ? (thresholdCfg.value ?? thresholdSeriesGeom.currentValue)
927
- : thresholdSeriesGeom.currentValue;
928
- const thresholdSeriesPts = thresholdIsSeries
929
- ? thresholdSeriesGeom.screenPts
930
- : undefined;
1797
+ thresholdIsSeries,
1798
+ emptyThresholdValue,
1799
+ palette,
1800
+ lineProp,
1801
+ renderThresholdBadge,
1802
+ formatValue,
1803
+ });
931
1804
 
932
1805
  // Straight polyline instead of the monotone cubic when line.curve === "linear".
933
1806
  // Shared by the path builders and the marker anchoring so glyphs sit on the
@@ -940,7 +1813,7 @@ function useLiveChartController({
940
1813
  reveal.morphT,
941
1814
  // Constant threshold band closes at a single Y (the series closes along the
942
1815
  // polyline passed as the last arg below).
943
- thresholdCfg?.fill && !thresholdIsSeries ? thresholdGeom.lineY : undefined,
1816
+ thresholdFillLineY,
944
1817
  lineIsLinear,
945
1818
  // The plotted line independently selects the visible edge while historical;
946
1819
  // badge/live-indicator options affect overlays only.
@@ -951,26 +1824,19 @@ function useLiveChartController({
951
1824
  // (so the band matches the shader and doesn't bleed at step risers). An empty
952
1825
  // series builds no band (its samples are all the far-below fallback, which
953
1826
  // would tint the entire area under the line).
954
- thresholdCfg?.fill && thresholdSeriesHasPoints
955
- ? thresholdSeriesGeom.samples
956
- : undefined,
1827
+ thresholdFillSamples,
957
1828
  lineProp?.simplify,
1829
+ lineGapsCfg?.gaps,
958
1830
  );
959
1831
 
960
1832
  // Area-dots fill shader color as a vec4 (channels 0..1), with the config
961
1833
  // `opacity` folded into the alpha. Defaults to a faint tint of the line/accent
962
1834
  // color (theme-aware) so out-of-the-box dots read as a subtle field.
963
- const areaDotRgb = parseColorRgb(lineProp?.color ?? palette.line);
964
- const [adR, adG, adB, adA] = parseColorRgba(
965
- areaDotsCfg?.color ??
966
- `rgba(${areaDotRgb[0]}, ${areaDotRgb[1]}, ${areaDotRgb[2]}, 0.22)`,
1835
+ const areaDotColorVec = resolveAreaDotColorVec(
1836
+ areaDotsCfg,
1837
+ lineProp,
1838
+ palette,
967
1839
  );
968
- const areaDotColorVec = [
969
- adR / 255,
970
- adG / 255,
971
- adB / 255,
972
- adA * (areaDotsCfg?.opacity ?? 1),
973
- ];
974
1840
 
975
1841
  const { dotX, dotY } = useLiveDot(
976
1842
  engine,
@@ -1001,19 +1867,31 @@ function useLiveChartController({
1001
1867
  isCandle,
1002
1868
  );
1003
1869
 
1004
- // ── Overlay hooks ─────────────────────────────────────────────────────
1005
- // Scrub/crosshair must see the same stash-backed candles as the engine.
1006
- const candleOpts = isCandle
1007
- ? {
1008
- mode,
1009
- candles: candlesEngine,
1010
- liveCandle: liveEngine,
1011
- candleWidthSecs: candleWidth,
1012
- }
1013
- : undefined;
1014
-
1015
- const markersActive = markers != null;
1016
- const markerClusterCfg = resolveMarkerCluster(markerCluster);
1870
+ const {
1871
+ crosshairChartOpts,
1872
+ markersActive,
1873
+ markerClusterCfg,
1874
+ refPressActive,
1875
+ refDragEnabled,
1876
+ scrollGestureMode,
1877
+ scrubHoldMs,
1878
+ } = resolveLiveChartInteractionConfig({
1879
+ isCandle,
1880
+ mode,
1881
+ candlesEngine,
1882
+ liveEngine,
1883
+ candleWidth,
1884
+ candleGapsCfg,
1885
+ lineGapsCfg,
1886
+ markers,
1887
+ markerCluster,
1888
+ onReferenceLinePress,
1889
+ allRefLines,
1890
+ isStatic,
1891
+ timeScroll,
1892
+ timeScrollEnabled,
1893
+ scrubCfg,
1894
+ });
1017
1895
  // `projected` is used internally by the hit-test gesture; the overlay
1018
1896
  // self-projects, so we only need the gesture + hit-test here. Built BEFORE
1019
1897
  // `useCrosshair` so the scrub-action tap can defer to a marker under the finger.
@@ -1033,7 +1911,6 @@ function useLiveChartController({
1033
1911
 
1034
1912
  // Pressable reference-line badges (working orders / alerts). Built before
1035
1913
  // `useCrosshair` so the scrub-action tap can defer to a badge under the finger.
1036
- const refPressActive = onReferenceLinePress != null && allRefLines.length > 0;
1037
1914
  const { tapGesture: refLineTapGesture, hitTest: refLineHitTest } =
1038
1915
  useReferenceLinePress(
1039
1916
  engine,
@@ -1052,8 +1929,6 @@ function useLiveChartController({
1052
1929
  // unconditionally for stable hook order (and before `useCrosshair` so the scrub
1053
1930
  // can defer to a line under the finger); the gesture self-disables when no line
1054
1931
  // opts in, and it's only composed into the root when `refDragEnabled`.
1055
- const refDragEnabled =
1056
- !isStatic && allRefLines.some((l) => l.draggable === true);
1057
1932
  const { gesture: refDragGesture, hitTest: refDragHitTest } = useReferenceDrag(
1058
1933
  engine,
1059
1934
  effectivePadding,
@@ -1073,24 +1948,6 @@ function useLiveChartController({
1073
1948
  return markerHitTest(x, y) || refLineHitTest(x, y) || refDragHitTest(x, y);
1074
1949
  };
1075
1950
 
1076
- // Time-scroll activation. `holdToScrub`: a quick one-finger drag scrolls while
1077
- // scrub engages on press-and-hold — so the scrub gesture needs a long-press
1078
- // delay (unless the caller set its own `panGestureDelay`). `timeScrollEnabled`
1079
- // is computed earlier (it gates the float gutter).
1080
- const scrollGestureMode =
1081
- typeof timeScroll === "object"
1082
- ? (timeScroll.gesture ?? "holdToScrub")
1083
- : "holdToScrub";
1084
- // In holdToScrub the scrub MUST require a hold so a quick drag scrolls instead.
1085
- // Precedence: explicit timeScroll.scrubHoldMs, then scrub.panGestureDelay, then
1086
- // the default. `||` (not `??`) skips the resolved panGestureDelay's 0 default.
1087
- const timeScrollHoldMs =
1088
- typeof timeScroll === "object" ? timeScroll.scrubHoldMs : undefined;
1089
- const scrubHoldMs =
1090
- timeScrollEnabled && scrollGestureMode === "holdToScrub"
1091
- ? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
1092
- : (scrubCfg?.panGestureDelay ?? 0);
1093
-
1094
1951
  // Cross-gesture arbitration for the one-finger touch. `Gesture.Race` below is
1095
1952
  // NOT arbitration — RNGH's Race adds no relation between its children, so both
1096
1953
  // pans recognize independently and each can activate while the other already
@@ -1099,6 +1956,18 @@ function useLiveChartController({
1099
1956
  // (written by the crosshair, read by the scroll pan) is the mirror image.
1100
1957
  const scrollActive = useSharedValue(false);
1101
1958
 
1959
+ const crosshairSettings = resolveCrosshairControllerSettings({
1960
+ scrubCfg,
1961
+ scrubActionCfg,
1962
+ markersActive,
1963
+ refPressActive,
1964
+ refDragEnabled,
1965
+ deferTapHit,
1966
+ timeScrollEnabled,
1967
+ scrollGestureMode,
1968
+ effectivePadding,
1969
+ });
1970
+
1102
1971
  const crosshair = useCrosshair(
1103
1972
  engine,
1104
1973
  effectivePadding,
@@ -1109,29 +1978,24 @@ function useLiveChartController({
1109
1978
  // Scrub / scrub-action stay live even on static charts: the gesture is
1110
1979
  // event-driven (no per-frame loop), so a settled chart costs nothing at rest
1111
1980
  // yet becomes scrubbable on touch. `static` only kills the continuous loop.
1112
- scrubCfg !== null || scrubActionCfg !== null,
1981
+ crosshairSettings.enabled,
1113
1982
  onScrub,
1114
- candleOpts,
1983
+ crosshairChartOpts,
1115
1984
  scrubHoldMs,
1116
1985
  onGestureStart,
1117
1986
  onGestureEnd,
1118
1987
  scrubActionCfg,
1119
1988
  onScrubAction,
1120
1989
  metricsCfg.badge,
1121
- markersActive || refPressActive || refDragEnabled ? deferTapHit : undefined,
1122
- scrubCfg?.tooltipPlacement ?? "side",
1123
- scrubCfg?.tooltipShowValue ?? true,
1124
- scrubCfg?.tooltipShowTime ?? true,
1125
- scrubCfg?.tooltipMargin ?? 8,
1126
- // Axis-drag time-scroll: keep the bottom "time ruler" band scroll-only so a
1127
- // drag there never trips the scrub crosshair.
1128
- timeScrollEnabled && scrollGestureMode === "axisDrag"
1129
- ? Math.max(effectivePadding.bottom, AXIS_GRAB_MIN_PX)
1130
- : 0,
1990
+ crosshairSettings.deferTapHit,
1991
+ crosshairSettings.tooltipPlacement,
1992
+ crosshairSettings.tooltipShowValue,
1993
+ crosshairSettings.tooltipShowTime,
1994
+ crosshairSettings.tooltipMargin,
1995
+ crosshairSettings.scrubBottomExclude,
1131
1996
  scrollActive,
1132
- scrubCfg?.clampToPlot ?? false,
1133
- // Candle mode: snap the crosshair to candle centers (tick-to-tick).
1134
- scrubCfg?.snapToCandles ?? false,
1997
+ crosshairSettings.clampToPlot,
1998
+ crosshairSettings.snapToCandles,
1135
1999
  );
1136
2000
 
1137
2001
  // Capture only the shared value in the worklets below. Referencing
@@ -1185,77 +2049,12 @@ function useLiveChartController({
1185
2049
  },
1186
2050
  });
1187
2051
 
1188
- // Axis auto-hide: fade both axes out at rest and back in while the user
1189
- // interacts. Any movement — scrub / time-scroll touch, or a viewEnd /
1190
- // viewWindow change (fling momentum, pinch-zoom) — shows the axes; once the
1191
- // touch lifts and the view settles, they fade back out after `hideAfterMs`.
1192
- // Only the axis groups' opacity animates; the axis worklets keep running
1193
- // underneath so a fade-in shows current labels.
1194
- const axisAutoHideCfg =
1195
- axisAutoHide === true ? {} : axisAutoHide === false ? null : axisAutoHide;
1196
- const axisIdleOpacity = axisAutoHideCfg?.idleOpacity ?? 0;
1197
- const axisFadeInMs = axisAutoHideCfg?.fadeInMs ?? 60;
1198
- const axisFadeOutMs = axisAutoHideCfg?.fadeOutMs ?? 250;
1199
- const axisHideAfterMs = axisAutoHideCfg?.hideAfterMs ?? 3000;
1200
- const axisAutoHideOpacity = useSharedValue(
1201
- axisAutoHideCfg ? axisIdleOpacity : 1,
1202
- );
1203
- const axisAutoHideEnabled = axisAutoHideCfg !== null;
1204
- const lastAxisAutoHide = useRef({
1205
- enabled: axisAutoHideEnabled,
1206
- idleOpacity: axisIdleOpacity,
2052
+ const axisAutoHideOpacity = useAxisAutoHide({
2053
+ config: axisAutoHide,
2054
+ scrollActive,
2055
+ scrubActive: crosshairScrubActive,
2056
+ engine,
1207
2057
  });
1208
- // `useSharedValue` only reads its initial value on mount. Keep prop changes
1209
- // in sync as well: turning auto-hide off must immediately restore the axes,
1210
- // and turning it on starts from the configured idle opacity.
1211
- useEffect(() => {
1212
- if (
1213
- lastAxisAutoHide.current.enabled === axisAutoHideEnabled &&
1214
- lastAxisAutoHide.current.idleOpacity === axisIdleOpacity
1215
- ) {
1216
- return;
1217
- }
1218
- lastAxisAutoHide.current = {
1219
- enabled: axisAutoHideEnabled,
1220
- idleOpacity: axisIdleOpacity,
1221
- };
1222
- cancelAnimation(axisAutoHideOpacity);
1223
- axisAutoHideOpacity.value = axisAutoHideEnabled ? axisIdleOpacity : 1;
1224
- }, [axisAutoHideEnabled, axisAutoHideOpacity, axisIdleOpacity]);
1225
- useAnimatedReaction(
1226
- () => ({
1227
- gesture: scrollActive.value || crosshairScrubActive.value,
1228
- viewEnd: engine.viewEnd.value,
1229
- viewWindow: engine.viewWindow.value,
1230
- }),
1231
- (curr, prev) => {
1232
- if (!axisAutoHideEnabled || prev === null) return;
1233
- const moved =
1234
- curr.gesture !== prev.gesture ||
1235
- curr.viewEnd !== prev.viewEnd ||
1236
- curr.viewWindow !== prev.viewWindow;
1237
- if (!moved) return;
1238
- cancelAnimation(axisAutoHideOpacity);
1239
- axisAutoHideOpacity.value = curr.gesture
1240
- ? withTiming(1, { duration: axisFadeInMs })
1241
- : // Movement without a held touch (gesture end, fling, pinch): show,
1242
- // then fade back out once the chart goes untouched for a while.
1243
- withSequence(
1244
- withTiming(1, { duration: axisFadeInMs }),
1245
- withDelay(
1246
- axisHideAfterMs,
1247
- withTiming(axisIdleOpacity, { duration: axisFadeOutMs }),
1248
- ),
1249
- );
1250
- },
1251
- [
1252
- axisAutoHideEnabled,
1253
- axisIdleOpacity,
1254
- axisFadeInMs,
1255
- axisFadeOutMs,
1256
- axisHideAfterMs,
1257
- ],
1258
- );
1259
2058
 
1260
2059
  // Paging callbacks: report the visible range / proximity to the oldest data so
1261
2060
  // a host can lazily load history. Inert unless a callback is supplied.
@@ -1270,59 +2069,21 @@ function useLiveChartController({
1270
2069
  // ahead of the pan via Exclusive, so a tap is tried first and only becomes a
1271
2070
  // drag (live-scrub, or lock-adjust once placed) if the finger moves. `Exclusive`
1272
2071
  // (not `Race`) prevents a jittery tap from being swallowed by the pan.
1273
- const baseGesture =
1274
- scrubActionCfg !== null && crosshair.tapGesture
1275
- ? Gesture.Exclusive(crosshair.tapGesture, crosshair.gesture)
1276
- : crosshair.gesture;
1277
-
1278
- // Overlay taps that hit-test discrete targets (marker dots, reference-line
1279
- // badges). They must all see each tap, so they're combined with `Simultaneous`
1280
- // (not `Race`, which cancels the loser and would drop one). The scrub-action
1281
- // action tap defers to them via `deferTapHit`, so a tap on an overlay is routed
1282
- // there instead of placing a reticle.
1283
- const overlayTaps = [
1284
- markersActive ? markerTapGesture : null,
1285
- refPressActive ? refLineTapGesture : null,
1286
- ].filter((g): g is NonNullable<typeof g> => g !== null);
1287
-
1288
- let rootGesture = baseGesture;
1289
- if (overlayTaps.length > 0) {
1290
- const tapGroup =
1291
- overlayTaps.length === 1
1292
- ? overlayTaps[0]
1293
- : Gesture.Simultaneous(overlayTaps[0], overlayTaps[1]);
1294
- // Always `Simultaneous`, never `Race`: on iOS the scrub pan uses
1295
- // `minDistance(0)`, so in a `Race` it activates on touch-down and cancels the
1296
- // overlay tap before it can recognize — `onMarkerPress`/`onReferenceLinePress`
1297
- // would never fire. Sharing the gesture space lets the tap recognize; the pan
1298
- // defers to a marker/badge under the finger via `deferTapHit` (see
1299
- // `useCrosshair`'s scrub `onStart`) so no stray crosshair is dropped there.
1300
- rootGesture = Gesture.Simultaneous(baseGesture, tapGroup);
1301
- }
1302
-
1303
- // Compose the pan-scroll gesture. holdToScrub races the scrub/tap gestures (a
1304
- // quick drag scrolls; a still press-hold falls through to scrub). Axis-drag
1305
- // goes first via Exclusive: it fails instantly outside the axis band, so scrub
1306
- // runs everywhere else.
1307
- if (timeScrollEnabled) {
1308
- rootGesture =
1309
- scrollGestureMode === "axisDrag"
1310
- ? Gesture.Exclusive(panScrollGesture, rootGesture)
1311
- : Gesture.Race(panScrollGesture, rootGesture);
1312
- }
1313
-
1314
- // Draggable reference lines take priority: a vertical grab on a line drags it.
1315
- // The manual-activation pan fails fast off any line (or on horizontal intent), so
1316
- // Exclusive falls through to scrub / scroll everywhere else.
1317
- if (refDragEnabled) {
1318
- rootGesture = Gesture.Exclusive(refDragGesture, rootGesture);
1319
- }
1320
-
1321
- // Pinch runs alongside everything else (two-finger, so it never competes with
1322
- // the one-finger pan/scrub/tap gestures for the same touch).
1323
- if (zoomEnabled) {
1324
- rootGesture = Gesture.Simultaneous(rootGesture, pinchZoomGesture);
1325
- }
2072
+ const rootGesture = composeLiveChartRootGesture({
2073
+ crosshair,
2074
+ markerTapGesture,
2075
+ refLineTapGesture,
2076
+ refDragGesture,
2077
+ panScrollGesture,
2078
+ pinchZoomGesture,
2079
+ markersActive,
2080
+ refPressActive,
2081
+ refDragEnabled,
2082
+ scrubActionActive: scrubActionCfg !== null,
2083
+ timeScrollEnabled,
2084
+ scrollGestureMode,
2085
+ zoomEnabled,
2086
+ });
1326
2087
 
1327
2088
  // ── Derived render values ──────────────────────────────────────────────
1328
2089
  const {
@@ -1355,49 +2116,33 @@ function useLiveChartController({
1355
2116
  // A dot that tracks the true live point while parked is exempt from the
1356
2117
  // scroll-back hide: it no longer marks an off-screen price, and it hides
1357
2118
  // itself once the live point leaves the window (`useLiveDot`'s sentinel).
1358
- const liveDotOpacity = useDerivedValue(
1359
- () =>
1360
- reveal.dotOpacity.value *
1361
- (selectionDotDuringScrub && crosshairScrubActive.value ? 0 : 1) *
1362
- liveIndicatorScrollOpacity(
1363
- hideLiveOnScrollBack && !dotTracksParked,
1364
- engine.viewEnd.value,
1365
- ) *
1366
- seriesIndicatorOpacity.value,
1367
- );
1368
- // Same scrolled-back gating for the value line: it would draw a dashed line
1369
- // at the live value's Y — a price that isn't in the scrolled-back view.
1370
- const valueLineOpacity = useDerivedValue(
1371
- () =>
1372
- reveal.lineOpacity.value *
1373
- liveIndicatorScrollOpacity(hideLiveOnScrollBack, engine.viewEnd.value) *
1374
- resolvedSeriesOpacity.value,
1375
- );
1376
- const liveBadgeOpacity = useDerivedValue(
1377
- () =>
1378
- reveal.badgeOpacity.value *
1379
- liveIndicatorScrollOpacity(hideLiveOnScrollBack, engine.viewEnd.value) *
1380
- resolvedSeriesOpacity.value,
1381
- );
1382
-
1383
- // Fade the annotation overlays (markers + reference lines) out while scrubbing
1384
- // when `scrub.hideOverlays` is set. Eased off the scrub-ACTIVE flag — not the
1385
- // crosshair's edge-proximity fade, which drops to 0 near the live dot and would
1386
- // resurface the overlays mid-scrub. Only this group opacity animates; the
1387
- // marker atlas / reference-line draws stay intact (one batched draw each).
1388
2119
  const fadeOverlaysOnScrub =
1389
2120
  !isStatic && scrubCfg !== null && scrubCfg.hideOverlaysOnScrub === true;
1390
- const overlayScrubFade = useDerivedValue(() =>
1391
- fadeOverlaysOnScrub
1392
- ? withTiming(crosshairScrubActive.get() ? 0 : 1, {
1393
- duration: SCRUB_OVERLAY_FADE_MS,
1394
- })
1395
- : 1,
1396
- );
1397
- // Markers already fade with the dot reveal; fold the scrub-hide fade in too.
1398
- const markerGroupOpacity = useDerivedValue(
1399
- () => reveal.dotOpacity.get() * overlayScrubFade.get(),
1400
- );
2121
+ const {
2122
+ liveDotOpacity,
2123
+ valueLineOpacity,
2124
+ liveBadgeOpacity,
2125
+ overlayScrubFade,
2126
+ markerGroupOpacity,
2127
+ } = useLiveIndicatorOpacities({
2128
+ reveal,
2129
+ seriesIndicatorOpacity,
2130
+ seriesOpacity: resolvedSeriesOpacity,
2131
+ scrubActive: crosshairScrubActive,
2132
+ selectionDotDuringScrub,
2133
+ hideLiveOnScrollBack,
2134
+ dotTracksParked,
2135
+ viewEnd: engine.viewEnd,
2136
+ fadeOverlaysOnScrub,
2137
+ });
2138
+ const modelDefaults = resolveLiveChartModelDefaults({
2139
+ isCandle,
2140
+ candleWidth,
2141
+ loadingCfg,
2142
+ volumeCfg,
2143
+ palette,
2144
+ lineProp,
2145
+ });
1401
2146
 
1402
2147
  return {
1403
2148
  // passthrough props the render needs
@@ -1414,9 +2159,7 @@ function useLiveChartController({
1414
2159
  formatTime,
1415
2160
  isCandle,
1416
2161
  isStatic,
1417
- // Half a candle width (seconds) so an "extrema" axis label's dot lands on the
1418
- // candle's drawn center, not its bucket-start (left) edge. 0 in line mode.
1419
- extremaTimeOffset: isCandle ? candleWidth / 2 : 0,
2162
+ ...modelDefaults,
1420
2163
  // configs
1421
2164
  yAxisCfg,
1422
2165
  yAxisFloat: effectiveYAxisFloat,
@@ -1457,8 +2200,8 @@ function useLiveChartController({
1457
2200
  hasRecolorSegments,
1458
2201
  thresholdCfg,
1459
2202
  thresholdGeom,
1460
- thresholdStrokeColors: thresholdStopColors?.stroke ?? null,
1461
- thresholdFillColors: thresholdStopColors?.fill ?? null,
2203
+ thresholdStrokeColors,
2204
+ thresholdFillColors,
1462
2205
  // Time-varying threshold (a `LiveChartPoint[]` series): the per-fragment split
1463
2206
  // shader + polyline marker, vs. the constant case's gradient.
1464
2207
  thresholdIsSeries,
@@ -1469,6 +2212,7 @@ function useLiveChartController({
1469
2212
  thresholdMarkerVisible,
1470
2213
  thresholdBadgeVisible,
1471
2214
  thresholdMarkerValue,
2215
+ thresholdCustomBadge,
1472
2216
  thresholdSeriesPts,
1473
2217
  badgeUsesRightGutter,
1474
2218
  // theme / layout / fonts
@@ -1482,13 +2226,8 @@ function useLiveChartController({
1482
2226
  // engine + reveal
1483
2227
  engine,
1484
2228
  reveal,
2229
+ loadingActive,
1485
2230
  axisAutoHideOpacity,
1486
- // loading shell styling (null → not loading)
1487
- loadingLineColor: loadingCfg?.color,
1488
- loadingStrokeWidth: loadingCfg?.strokeWidth,
1489
- loadingAmplitude: loadingCfg?.amplitude,
1490
- loadingSpeed: loadingCfg?.speed,
1491
- loadingAxisLabels: loadingCfg?.axisLabels ?? true,
1492
2231
  // derived render values
1493
2232
  backgroundColor,
1494
2233
  gradientEnd,
@@ -1510,13 +2249,12 @@ function useLiveChartController({
1510
2249
  thresholdFillPath,
1511
2250
  lineIsLinear,
1512
2251
  volumeCfg,
2252
+ candleGapsCfg,
2253
+ lineGapsCfg,
1513
2254
  // Volume bars: active flag, fade-in opacity, and resolved colors (default to
1514
2255
  // the candle palette). The reserved band height is read by the x-axis.
1515
2256
  volumeActive: volumeCfg !== null,
1516
2257
  volumeBandHeight,
1517
- volumeOpacity: volumeCfg?.opacity ?? 1,
1518
- volumeUpColor: volumeCfg?.upColor ?? palette.candleUp,
1519
- volumeDownColor: volumeCfg?.downColor ?? palette.candleDown,
1520
2258
  dotX,
1521
2259
  dotY,
1522
2260
  liveDotOpacity,
@@ -1537,7 +2275,6 @@ function useLiveChartController({
1537
2275
  renderOverlay,
1538
2276
  // selection dot: resolved config + fallback color (the chart line/accent color)
1539
2277
  selectionDot: selectionDotCfg,
1540
- selectionColor: lineProp?.color ?? palette.line,
1541
2278
  // RN axis edge labels (floated over the canvas as a sibling layer)
1542
2279
  topLabelCfg,
1543
2280
  bottomLabelCfg,
@@ -1797,11 +2534,117 @@ function ChartFillLayer({
1797
2534
  </Group>
1798
2535
  ) : null)}
1799
2536
  </Group>
1800
- </Group>
2537
+ </Group>
2538
+ );
2539
+ }
2540
+
2541
+ type CandlePaths = ReturnType<typeof useCandlePaths>;
2542
+ type CandleGapPaths = ReturnType<typeof useCandleGapPaths>;
2543
+ type LineGapPaths = ReturnType<typeof useLineGapPaths>;
2544
+
2545
+ function GapBridgePathBatch({
2546
+ paths,
2547
+ config,
2548
+ palette,
2549
+ }: {
2550
+ paths: CandleGapPaths | LineGapPaths;
2551
+ config: ResolvedCandleGapsConfig;
2552
+ palette: LiveChartPalette;
2553
+ }) {
2554
+ const noTrades = config.styles["no-trades"].bridge;
2555
+ const unavailable = config.styles.unavailable.bridge;
2556
+ const unknown = config.styles.unknown.bridge;
2557
+ return (
2558
+ <>
2559
+ {noTrades !== null && (
2560
+ <Group opacity={noTrades.opacity}>
2561
+ <Path
2562
+ path={paths.noTradesPath}
2563
+ style="stroke"
2564
+ strokeWidth={noTrades.strokeWidth}
2565
+ strokeCap={noTrades.strokeCap}
2566
+ color={noTrades.color ?? palette.refLine}
2567
+ />
2568
+ </Group>
2569
+ )}
2570
+ {unavailable !== null && (
2571
+ <Group opacity={unavailable.opacity}>
2572
+ <Path
2573
+ path={paths.unavailablePath}
2574
+ style="stroke"
2575
+ strokeWidth={unavailable.strokeWidth}
2576
+ strokeCap={unavailable.strokeCap}
2577
+ color={unavailable.color ?? palette.refLine}
2578
+ />
2579
+ </Group>
2580
+ )}
2581
+ {unknown !== null && (
2582
+ <Group opacity={unknown.opacity}>
2583
+ <Path
2584
+ path={paths.unknownPath}
2585
+ style="stroke"
2586
+ strokeWidth={unknown.strokeWidth}
2587
+ strokeCap={unknown.strokeCap}
2588
+ color={unknown.color ?? palette.refLine}
2589
+ />
2590
+ </Group>
2591
+ )}
2592
+ </>
2593
+ );
2594
+ }
2595
+
2596
+ function ChartCandleGapLayer({
2597
+ model,
2598
+ config,
2599
+ focusOtherCandles,
2600
+ inactiveOpacity,
2601
+ focusedOpacity,
2602
+ focusedClip,
2603
+ }: {
2604
+ model: LiveChartModel;
2605
+ config: ResolvedCandleGapsConfig;
2606
+ focusOtherCandles: boolean;
2607
+ inactiveOpacity: SharedValue<number>;
2608
+ focusedOpacity: SharedValue<number>;
2609
+ focusedClip: SharedValue<{
2610
+ x: number;
2611
+ y: number;
2612
+ width: number;
2613
+ height: number;
2614
+ }>;
2615
+ }) {
2616
+ const paths = useCandleGapPaths(
2617
+ model.engine,
2618
+ model.effectivePadding,
2619
+ model.candlesEngine,
2620
+ model.liveEngine,
2621
+ model.displayCandleWidth,
2622
+ config,
2623
+ model.metricsCfg.candle,
2624
+ );
2625
+ const batch = (
2626
+ <GapBridgePathBatch paths={paths} config={config} palette={model.palette} />
2627
+ );
2628
+ return focusOtherCandles ? (
2629
+ <>
2630
+ <Group opacity={inactiveOpacity}>{batch}</Group>
2631
+ <Group opacity={focusedOpacity} clip={focusedClip}>
2632
+ {batch}
2633
+ </Group>
2634
+ </>
2635
+ ) : (
2636
+ batch
1801
2637
  );
1802
2638
  }
1803
2639
 
1804
- type CandlePaths = ReturnType<typeof useCandlePaths>;
2640
+ /** Line-mode bridge paths live in a child so candle charts register no worklets. */
2641
+ function ChartLineGapLayer({ model }: { model: LiveChartModel }) {
2642
+ const config = model.lineGapsCfg!;
2643
+ const paths = useLineGapPaths(model.engine, model.effectivePadding, config);
2644
+ return (
2645
+ <GapBridgePathBatch paths={paths} config={config} palette={model.palette} />
2646
+ );
2647
+ }
1805
2648
 
1806
2649
  /** One batched candle pass: two body paths and two wick paths. */
1807
2650
  function CandlePathBatch({
@@ -1858,6 +2701,7 @@ function ChartCandleLayer({ model }: { model: LiveChartModel }) {
1858
2701
  volumeOpacity,
1859
2702
  volumeUpColor,
1860
2703
  volumeDownColor,
2704
+ candleGapsCfg,
1861
2705
  scrubCfg,
1862
2706
  crosshair,
1863
2707
  } = model;
@@ -1875,24 +2719,42 @@ function ChartCandleLayer({ model }: { model: LiveChartModel }) {
1875
2719
  const focusOtherCandles = scrubCfg?.dimTarget === "otherCandles";
1876
2720
  const candleDimOpacity = scrubCfg?.dimOpacity ?? 1;
1877
2721
  const candleDimFadeMs = scrubCfg?.dimFadeMs ?? 0;
1878
- // Destructure the two SharedValues before entering worklets. Closing over the
2722
+ // Destructure SharedValues before entering worklets. Closing over the
1879
2723
  // whole crosshair object would also serialize its RNGH gesture instances.
1880
2724
  const scrubActive = crosshair.scrubActive;
1881
2725
  // ChartCandleLayer is single-series only; useCrosshair always supplies this
1882
2726
  // value (it is optional on the shared state type only for LiveChartSeries).
1883
2727
  const scrubCandle = crosshair.scrubCandle!;
1884
- // Keep the last selection after scrubCandle clears so its clipped full-
2728
+ const scrubGap = crosshair.scrubGap!;
2729
+ const scrubTime = crosshair.scrubTime;
2730
+ const currentFocusBucketTime = useDerivedValue(() => {
2731
+ if (!focusOtherCandles) return null;
2732
+ const candle = scrubCandle.get();
2733
+ if (candle) return candle.time;
2734
+ const gap = scrubGap.get();
2735
+ if (!gap) return null;
2736
+ return candleGapBucketStartAtTime(
2737
+ gap,
2738
+ scrubTime.get(),
2739
+ candlesEngine.get(),
2740
+ liveEngine.get(),
2741
+ displayCandleWidth.get(),
2742
+ );
2743
+ });
2744
+ // Keep the last selection after the current target clears so its clipped full-
1885
2745
  // strength pass can cover the base batch until the release fade completes.
1886
- const lastScrubCandle = useSharedValue<CandlePoint | null>(null);
2746
+ const lastFocusBucket = useSharedValue<{ time: number } | null>(null);
1887
2747
  useAnimatedReaction(
1888
- () => (focusOtherCandles ? scrubCandle.get() : null),
1889
- (candle) => {
1890
- if (candle) lastScrubCandle.set(candle);
2748
+ () => currentFocusBucketTime.get(),
2749
+ (time) => {
2750
+ if (time !== null) lastFocusBucket.set({ time });
1891
2751
  },
1892
2752
  );
1893
2753
  const inactiveCandleOpacity = useDerivedValue(() => {
1894
2754
  const target =
1895
- focusOtherCandles && scrubActive.get() && scrubCandle.get()
2755
+ focusOtherCandles &&
2756
+ scrubActive.get() &&
2757
+ currentFocusBucketTime.get() !== null
1896
2758
  ? candleDimOpacity
1897
2759
  : 1;
1898
2760
  return candleDimFadeMs > 0
@@ -1901,33 +2763,36 @@ function ChartCandleLayer({ model }: { model: LiveChartModel }) {
1901
2763
  }, [
1902
2764
  focusOtherCandles,
1903
2765
  scrubActive,
1904
- scrubCandle,
2766
+ currentFocusBucketTime,
1905
2767
  candleDimOpacity,
1906
2768
  candleDimFadeMs,
1907
2769
  ]);
1908
2770
  const focusedCandleOpacity = useDerivedValue(() => {
1909
- const currentCandle = scrubCandle.get();
1910
- const focusCandle = currentCandle ?? lastScrubCandle.get();
1911
- if (!focusOtherCandles || !focusCandle) return 0;
2771
+ const currentTime = currentFocusBucketTime.get();
2772
+ const focusBucket =
2773
+ currentTime === null ? lastFocusBucket.get() : { time: currentTime };
2774
+ if (!focusOtherCandles || !focusBucket) return 0;
1912
2775
  return computeCandleFocusPassOpacity(
1913
2776
  scrubActive.get(),
1914
- currentCandle !== null,
2777
+ currentTime !== null,
1915
2778
  inactiveCandleOpacity.get(),
1916
2779
  );
1917
2780
  }, [
1918
2781
  focusOtherCandles,
1919
2782
  scrubActive,
1920
- scrubCandle,
1921
- lastScrubCandle,
2783
+ currentFocusBucketTime,
2784
+ lastFocusBucket,
1922
2785
  inactiveCandleOpacity,
1923
2786
  ]);
1924
2787
  const focusedCandleClip = useDerivedValue(() => {
1925
2788
  if (!focusOtherCandles) {
1926
2789
  return HIDDEN_CANDLE_FOCUS_CLIP;
1927
2790
  }
1928
- const focusCandle = scrubCandle.get() ?? lastScrubCandle.get();
2791
+ const currentTime = currentFocusBucketTime.get();
2792
+ const focusBucket =
2793
+ currentTime === null ? lastFocusBucket.get() : { time: currentTime };
1929
2794
  return computeCandleFocusClip(
1930
- focusCandle,
2795
+ focusBucket,
1931
2796
  effectivePadding,
1932
2797
  engine.canvasWidth.get(),
1933
2798
  engine.canvasHeight.get(),
@@ -1937,8 +2802,8 @@ function ChartCandleLayer({ model }: { model: LiveChartModel }) {
1937
2802
  );
1938
2803
  }, [
1939
2804
  focusOtherCandles,
1940
- scrubCandle,
1941
- lastScrubCandle,
2805
+ currentFocusBucketTime,
2806
+ lastFocusBucket,
1942
2807
  effectivePadding,
1943
2808
  engine.canvasWidth,
1944
2809
  engine.canvasHeight,
@@ -1950,6 +2815,16 @@ function ChartCandleLayer({ model }: { model: LiveChartModel }) {
1950
2815
  return (
1951
2816
  <Group opacity={seriesOpacity}>
1952
2817
  <Group opacity={candleGroupOpacity}>
2818
+ {candleGapsCfg && (
2819
+ <ChartCandleGapLayer
2820
+ model={model}
2821
+ config={candleGapsCfg}
2822
+ focusOtherCandles={focusOtherCandles}
2823
+ inactiveOpacity={inactiveCandleOpacity}
2824
+ focusedOpacity={focusedCandleOpacity}
2825
+ focusedClip={focusedCandleClip}
2826
+ />
2827
+ )}
1953
2828
  {focusOtherCandles ? (
1954
2829
  <>
1955
2830
  <Group opacity={inactiveCandleOpacity}>
@@ -1992,6 +2867,113 @@ function ChartCandleLayer({ model }: { model: LiveChartModel }) {
1992
2867
  );
1993
2868
  }
1994
2869
 
2870
+ function ChartLineStrokeShader({ model }: { model: LiveChartModel }) {
2871
+ const {
2872
+ engine,
2873
+ effectivePadding,
2874
+ palette,
2875
+ resolvedSegments,
2876
+ hasRecolorSegments,
2877
+ crosshair,
2878
+ thresholdCfg,
2879
+ thresholdGeom,
2880
+ thresholdStrokeColors,
2881
+ thresholdIsSeries,
2882
+ thresholdSeriesHasPoints,
2883
+ thresholdStrokeUniforms,
2884
+ lineProp,
2885
+ layoutWidth,
2886
+ } = model;
2887
+ if (thresholdIsSeries) {
2888
+ return thresholdSeriesHasPoints ? (
2889
+ <ThresholdSplitShader uniforms={thresholdStrokeUniforms} />
2890
+ ) : null;
2891
+ }
2892
+ if (thresholdCfg && thresholdStrokeColors) {
2893
+ return (
2894
+ <LinearGradient
2895
+ start={vec(0, 0)}
2896
+ end={thresholdGeom.gradientEnd}
2897
+ colors={thresholdStrokeColors}
2898
+ positions={thresholdGeom.splitPositions}
2899
+ />
2900
+ );
2901
+ }
2902
+ if (hasRecolorSegments) {
2903
+ return (
2904
+ <SegmentLineGradient
2905
+ engine={engine}
2906
+ segments={resolvedSegments}
2907
+ padding={effectivePadding}
2908
+ baseColor={lineProp?.color ?? palette.line}
2909
+ scrubX={crosshair.scrubX}
2910
+ scrubActive={crosshair.scrubActive}
2911
+ />
2912
+ );
2913
+ }
2914
+ if (!lineProp?.colors?.length) return null;
2915
+ return (
2916
+ <LinearGradient
2917
+ start={vec(0, 0)}
2918
+ end={vec(layoutWidth, 0)}
2919
+ colors={lineProp.colors}
2920
+ />
2921
+ );
2922
+ }
2923
+
2924
+ function ChartMainPlotLayer({
2925
+ model,
2926
+ yAxisEntries,
2927
+ }: {
2928
+ model: LiveChartModel;
2929
+ yAxisEntries: YAxisEntries | null;
2930
+ }) {
2931
+ const {
2932
+ engine,
2933
+ effectivePadding,
2934
+ palette,
2935
+ lineGroupOpacity,
2936
+ seriesOpacity,
2937
+ linePath,
2938
+ lineGapsCfg,
2939
+ strokeWidth,
2940
+ lineProp,
2941
+ isCandle,
2942
+ xAxisCfg,
2943
+ yAxisCfg,
2944
+ yAxisFloat,
2945
+ } = model;
2946
+
2947
+ return (
2948
+ <>
2949
+ <Group opacity={seriesOpacity}>
2950
+ <Group opacity={lineGroupOpacity}>
2951
+ <Path
2952
+ path={linePath}
2953
+ style="stroke"
2954
+ strokeWidth={strokeWidth}
2955
+ strokeCap={lineProp?.cap ?? "round"}
2956
+ strokeJoin={lineProp?.join ?? "round"}
2957
+ color={lineProp?.color ?? palette.line}
2958
+ >
2959
+ <ChartLineStrokeShader model={model} />
2960
+ </Path>
2961
+ {lineGapsCfg ? <ChartLineGapLayer model={model} /> : null}
2962
+ </Group>
2963
+ </Group>
2964
+ {isCandle ? <ChartCandleLayer model={model} /> : null}
2965
+ {yAxisCfg && yAxisFloat ? (
2966
+ <ChartYAxisLayer
2967
+ model={model}
2968
+ variant="labels"
2969
+ entries={yAxisEntries!}
2970
+ />
2971
+ ) : null}
2972
+ {xAxisCfg ? <ChartXAxisLayer model={model} /> : null}
2973
+ </>
2974
+ );
2975
+ }
2976
+
1995
2977
  /** Main shaken chart stack drawn ABOVE the left-edge fade so the line stays crisp:
1996
2978
  * segment dividers, value/reference lines, the line/candles, axes, dot, degen,
1997
2979
  * markers, and the loading/empty art. Background fills are in `ChartFillLayer`
@@ -2032,11 +3014,13 @@ function ChartStack({
2032
3014
  thresholdMarkerVisible,
2033
3015
  thresholdBadgeVisible,
2034
3016
  thresholdMarkerValue,
3017
+ thresholdCustomBadge,
2035
3018
  thresholdSeriesPts,
2036
3019
  formatValue,
2037
3020
  lineGroupOpacity,
2038
3021
  seriesOpacity,
2039
3022
  linePath,
3023
+ lineGapsCfg,
2040
3024
  lineIsLinear,
2041
3025
  strokeWidth,
2042
3026
  lineProp,
@@ -2133,73 +3117,7 @@ function ChartStack({
2133
3117
  />
2134
3118
  )}
2135
3119
 
2136
- {/* Chart line (fades out in candle mode). When segments recolor the line, a
2137
- full-width gradient paints the base color outside segments and each
2138
- segment's color within — so the line itself is recolored/faded (alpha in
2139
- the segment color reduces the line's opacity), not covered by an overlay. */}
2140
- <Group opacity={seriesOpacity}>
2141
- <Group opacity={lineGroupOpacity}>
2142
- <Path
2143
- path={linePath}
2144
- style="stroke"
2145
- strokeWidth={strokeWidth}
2146
- strokeCap={lineProp?.cap ?? "round"}
2147
- strokeJoin={lineProp?.join ?? "round"}
2148
- color={lineProp?.color ?? palette.line}
2149
- >
2150
- {thresholdIsSeries ? (
2151
- // Time-varying split: a per-fragment shader colors the stroke above/
2152
- // below the threshold polyline. Supersedes line.colors + segments.
2153
- // An empty series renders no paint child → the plain stroke color
2154
- // (null here keeps the empty case out of the NaN constant gradient).
2155
- thresholdSeriesHasPoints ? (
2156
- <ThresholdSplitShader uniforms={thresholdStrokeUniforms} />
2157
- ) : null
2158
- ) : thresholdCfg && thresholdStrokeColors ? (
2159
- // Vertical hard split at the threshold Y — supersedes line.colors and
2160
- // segment recoloring for the stroke while a threshold is set.
2161
- <LinearGradient
2162
- start={vec(0, 0)}
2163
- end={thresholdGeom.gradientEnd}
2164
- colors={thresholdStrokeColors}
2165
- positions={thresholdGeom.splitPositions}
2166
- />
2167
- ) : hasRecolorSegments ? (
2168
- <SegmentLineGradient
2169
- engine={engine}
2170
- segments={resolvedSegments}
2171
- padding={effectivePadding}
2172
- baseColor={lineProp?.color ?? palette.line}
2173
- scrubX={crosshair.scrubX}
2174
- scrubActive={crosshair.scrubActive}
2175
- />
2176
- ) : lineProp?.colors?.length ? (
2177
- <LinearGradient
2178
- start={vec(0, 0)}
2179
- end={vec(layoutWidth, 0)}
2180
- colors={lineProp.colors}
2181
- />
2182
- ) : null}
2183
- </Path>
2184
- </Group>
2185
- </Group>
2186
-
2187
- {isCandle && <ChartCandleLayer model={model} />}
2188
-
2189
- {/* Floating axis: the labels float ABOVE the candles (right-aligned at the
2190
- edge) so the plot runs full-width and candles stay fully visible behind
2191
- them. (Default non-floating axis draws grid + labels in ChartFillLayer.) */}
2192
- {yAxisCfg && yAxisFloat && (
2193
- <ChartYAxisLayer
2194
- model={model}
2195
- variant="labels"
2196
- entries={yAxisEntries!}
2197
- />
2198
- )}
2199
-
2200
- {/* X-axis time labels. With a volume band the bottom padding is inflated by
2201
- the band height; pass it so the axis shifts back to the very bottom. */}
2202
- {xAxisCfg && <ChartXAxisLayer model={model} />}
3120
+ <ChartMainPlotLayer model={model} yAxisEntries={yAxisEntries} />
2203
3121
 
2204
3122
  {/* Live dot — the badge is drawn later (after the scrub layer) so the
2205
3123
  scrub dim never clips the live-price badge's left edge. Hidden while
@@ -2256,7 +3174,7 @@ function ChartStack({
2256
3174
 
2257
3175
  {/* Threshold label badge — on top of the line/dot/markers so it's never
2258
3176
  painted over (the dashed marker line itself stays behind the line, above). */}
2259
- {thresholdCfg?.line && (
3177
+ {thresholdCfg?.line && thresholdCustomBadge == null && (
2260
3178
  <ThresholdBadgeOverlay
2261
3179
  engine={engine}
2262
3180
  padding={effectivePadding}
@@ -2310,15 +3228,27 @@ function ChartTradeStreamLayer({
2310
3228
  tradeStream,
2311
3229
  tradeStreamResolved,
2312
3230
  effectivePadding,
3231
+ volumeBandHeight,
2313
3232
  palette,
2314
3233
  skiaFont,
2315
3234
  reveal,
2316
3235
  isStatic,
2317
3236
  } = model;
3237
+ // `effectivePadding.bottom` includes the volume reservation so candle prices
3238
+ // stop above the bars. The trade tape should still enter at the chart's true
3239
+ // lower edge (the volume baseline / x-axis), so remove that reservation for
3240
+ // this overlay's coordinate space only.
3241
+ const tradeStreamPadding =
3242
+ volumeBandHeight > 0
3243
+ ? {
3244
+ ...effectivePadding,
3245
+ bottom: effectivePadding.bottom - volumeBandHeight,
3246
+ }
3247
+ : effectivePadding;
2318
3248
  const tradeMarkers = useTradeStream(
2319
3249
  engine,
2320
3250
  tradeStream!,
2321
- effectivePadding,
3251
+ tradeStreamPadding,
2322
3252
  !isStatic,
2323
3253
  !isStatic,
2324
3254
  );
@@ -2327,7 +3257,7 @@ function ChartTradeStreamLayer({
2327
3257
  <TradeStreamOverlay
2328
3258
  markers={tradeMarkers}
2329
3259
  palette={palette}
2330
- padding={effectivePadding}
3260
+ padding={tradeStreamPadding}
2331
3261
  font={skiaFont}
2332
3262
  opacity={reveal.dotOpacity}
2333
3263
  labelOffsetX={tradeStreamResolved!.labelOffsetX}
@@ -2742,7 +3672,7 @@ function ChartCustomConsumerOverlay({ model }: { model: LiveChartModel }) {
2742
3672
  return <ChartOverlayLayer render={renderOverlay!} context={overlayContext} />;
2743
3673
  }
2744
3674
 
2745
- function ChartView({
3675
+ function ChartCanvas({
2746
3676
  model,
2747
3677
  yAxisEntries,
2748
3678
  degen,
@@ -2752,32 +3682,171 @@ function ChartView({
2752
3682
  degen: DegenState | null;
2753
3683
  }) {
2754
3684
  const {
2755
- rootGesture,
3685
+ canvasMode,
3686
+ engine,
2756
3687
  backgroundColor,
2757
- style,
2758
- onLayout,
2759
- accessibilityLabel,
2760
- accessibilityRole,
2761
3688
  leftEdgeFadeCfg,
2762
3689
  effectivePadding,
2763
- engine,
2764
3690
  palette,
2765
- formatValue,
3691
+ topConnector,
3692
+ bottomConnector,
3693
+ extremaTimeOffset,
3694
+ loadingActive,
3695
+ topLabelCfg,
3696
+ } = model;
3697
+ return (
3698
+ <Canvas
3699
+ key={canvasMode}
3700
+ style={{ flex: 1 }}
3701
+ opaque={canvasMode === "opaque"}
3702
+ >
3703
+ {canvasMode === "opaque" ? (
3704
+ <Rect
3705
+ x={0}
3706
+ y={0}
3707
+ width={engine.canvasWidth}
3708
+ height={engine.canvasHeight}
3709
+ color={backgroundColor}
3710
+ />
3711
+ ) : null}
3712
+ <ChartFillLayer model={model} yAxisEntries={yAxisEntries} degen={degen} />
3713
+ {leftEdgeFadeCfg ? (
3714
+ <LeftEdgeFade
3715
+ paddingLeft={effectivePadding.left}
3716
+ fadeWidth={leftEdgeFadeCfg.width}
3717
+ startColor={leftEdgeFadeCfg.startColor}
3718
+ endColor={leftEdgeFadeCfg.endColor}
3719
+ engine={engine}
3720
+ opaqueBackgroundRgb={
3721
+ canvasMode === "opaque" ? palette.bgRgb : undefined
3722
+ }
3723
+ />
3724
+ ) : null}
3725
+ <ChartStack model={model} yAxisEntries={yAxisEntries} degen={degen} />
3726
+ {topConnector || bottomConnector ? (
3727
+ <ExtremaConnectorOverlay
3728
+ engine={engine}
3729
+ padding={effectivePadding}
3730
+ extremaTimeOffset={extremaTimeOffset}
3731
+ top={topConnector}
3732
+ bottom={bottomConnector}
3733
+ hideExtrema={loadingActive}
3734
+ suppressBottomWhenCoincident={
3735
+ topLabelCfg?.position === "extrema" ||
3736
+ topLabelCfg?.position === "extrema-edge"
3737
+ }
3738
+ />
3739
+ ) : null}
3740
+ <ChartRefBadgeLayer
3741
+ model={model}
3742
+ degen={degen}
3743
+ yAxisEntries={yAxisEntries}
3744
+ />
3745
+ <ChartValueOverlay model={model} degen={degen} />
3746
+ {model.tradeStreamResolved && model.tradeStream ? (
3747
+ <ChartTradeStreamLayer model={model} degen={degen} />
3748
+ ) : null}
3749
+ <ChartScrubLayer model={model} degen={degen} />
3750
+ {model.badgeCfg ? <ChartBadgeLayer model={model} degen={degen} /> : null}
3751
+ <ChartScrubActionLayer model={model} />
3752
+ </Canvas>
3753
+ );
3754
+ }
3755
+
3756
+ function ChartNativeOverlays({ model }: { model: LiveChartModel }) {
3757
+ const {
2766
3758
  topLabelCfg,
2767
3759
  bottomLabelCfg,
3760
+ engine,
3761
+ formatValue,
3762
+ palette,
3763
+ effectivePadding,
3764
+ extremaTimeOffset,
3765
+ loadingActive,
3766
+ thresholdCustomBadge,
3767
+ thresholdCfg,
3768
+ thresholdMarkerLineY,
3769
+ thresholdBadgeVisible,
2768
3770
  markersActive,
2769
3771
  renderMarker,
2770
- renderTooltip,
2771
- renderOverlay,
2772
3772
  renderReferenceLine,
2773
3773
  renderOffAxisReferenceLine,
2774
3774
  allRefLines,
2775
3775
  scrubCfg,
3776
+ renderTooltip,
2776
3777
  crosshair,
2777
- extremaTimeOffset,
2778
- topConnector,
2779
- bottomConnector,
2780
- canvasMode,
3778
+ renderOverlay,
3779
+ } = model;
3780
+ const hasAnnotations =
3781
+ (markersActive && renderMarker != null) ||
3782
+ ((renderReferenceLine != null || renderOffAxisReferenceLine != null) &&
3783
+ allRefLines.length > 0);
3784
+ return (
3785
+ <>
3786
+ {topLabelCfg || bottomLabelCfg ? (
3787
+ <AxisLabelOverlay
3788
+ topLabel={topLabelCfg}
3789
+ bottomLabel={bottomLabelCfg}
3790
+ engine={engine}
3791
+ formatValue={formatValue}
3792
+ defaultColor={palette.gridLabel}
3793
+ padding={effectivePadding}
3794
+ extremaTimeOffset={extremaTimeOffset}
3795
+ hideExtrema={loadingActive}
3796
+ />
3797
+ ) : null}
3798
+ {thresholdCustomBadge && thresholdCfg?.line ? (
3799
+ <CustomThresholdBadgeOverlay
3800
+ element={thresholdCustomBadge}
3801
+ engine={engine}
3802
+ padding={effectivePadding}
3803
+ y={thresholdMarkerLineY}
3804
+ visible={thresholdBadgeVisible}
3805
+ position={thresholdCfg.line.labelPosition}
3806
+ />
3807
+ ) : null}
3808
+ {hasAnnotations ? <ChartCustomAnnotations model={model} /> : null}
3809
+ {scrubCfg && renderTooltip ? (
3810
+ <CustomTooltipOverlay
3811
+ renderTooltip={renderTooltip}
3812
+ scrubX={crosshair.scrubX}
3813
+ scrubValue={crosshair.scrubValue}
3814
+ scrubTime={crosshair.scrubTime}
3815
+ scrubActive={crosshair.scrubActive}
3816
+ scrubCandle={crosshair.scrubCandle}
3817
+ scrubGap={crosshair.scrubGap}
3818
+ tooltipLayout={crosshair.tooltipLayout}
3819
+ engine={engine}
3820
+ padding={effectivePadding}
3821
+ placement={scrubCfg.tooltipPlacement}
3822
+ margin={scrubCfg.tooltipMargin}
3823
+ crosshairFade={scrubCfg.crosshairFade}
3824
+ crosshairFadeDistance={scrubCfg.crosshairFadeDistance}
3825
+ lineTop={crosshair.tooltipLineTop}
3826
+ scrubDotY={crosshair.scrubDotY}
3827
+ />
3828
+ ) : null}
3829
+ {renderOverlay ? <ChartCustomConsumerOverlay model={model} /> : null}
3830
+ </>
3831
+ );
3832
+ }
3833
+
3834
+ function ChartView({
3835
+ model,
3836
+ yAxisEntries,
3837
+ degen,
3838
+ }: {
3839
+ model: LiveChartModel;
3840
+ yAxisEntries: YAxisEntries | null;
3841
+ degen: DegenState | null;
3842
+ }) {
3843
+ const {
3844
+ rootGesture,
3845
+ backgroundColor,
3846
+ style,
3847
+ onLayout,
3848
+ accessibilityLabel,
3849
+ accessibilityRole,
2781
3850
  } = model;
2782
3851
 
2783
3852
  return (
@@ -2789,133 +3858,8 @@ function ChartView({
2789
3858
  accessibilityLabel={accessibilityLabel}
2790
3859
  accessibilityRole={accessibilityRole}
2791
3860
  >
2792
- {/* Skia chooses TextureView vs SurfaceView when the native Canvas mounts. */}
2793
- <Canvas
2794
- key={canvasMode}
2795
- style={{ flex: 1 }}
2796
- opaque={canvasMode === "opaque"}
2797
- >
2798
- {canvasMode === "opaque" && (
2799
- <Rect
2800
- x={0}
2801
- y={0}
2802
- width={engine.canvasWidth}
2803
- height={engine.canvasHeight}
2804
- color={backgroundColor}
2805
- />
2806
- )}
2807
- {/* Background fills first, then the left-edge fade (a canvas-space sibling
2808
- so dstOut blends correctly), then the line stack on top — so the fade
2809
- softens only the fills and the line stays crisp at the left edge. */}
2810
- <ChartFillLayer
2811
- model={model}
2812
- yAxisEntries={yAxisEntries}
2813
- degen={degen}
2814
- />
2815
-
2816
- {leftEdgeFadeCfg && (
2817
- <LeftEdgeFade
2818
- paddingLeft={effectivePadding.left}
2819
- fadeWidth={leftEdgeFadeCfg.width}
2820
- startColor={leftEdgeFadeCfg.startColor}
2821
- endColor={leftEdgeFadeCfg.endColor}
2822
- engine={engine}
2823
- opaqueBackgroundRgb={
2824
- canvasMode === "opaque" ? palette.bgRgb : undefined
2825
- }
2826
- />
2827
- )}
2828
-
2829
- {/* Line stack above the fade so the line stays crisp at the left edge. */}
2830
- <ChartStack model={model} yAxisEntries={yAxisEntries} degen={degen} />
2831
-
2832
- {/* "extrema-edge" connector lines (dot → edge readout), above the chart
2833
- content so the dashed guide reads over the line / candles. */}
2834
- {(topConnector || bottomConnector) && (
2835
- <ExtremaConnectorOverlay
2836
- engine={engine}
2837
- padding={effectivePadding}
2838
- extremaTimeOffset={extremaTimeOffset}
2839
- top={topConnector}
2840
- bottom={bottomConnector}
2841
- />
2842
- )}
2843
-
2844
- {/* Reference-line badges + labels above the fade so they stay crisp. */}
2845
- <ChartRefBadgeLayer
2846
- model={model}
2847
- degen={degen}
2848
- yAxisEntries={yAxisEntries}
2849
- />
2850
-
2851
- <ChartValueOverlay model={model} degen={degen} />
2852
-
2853
- {model.tradeStreamResolved && model.tradeStream && (
2854
- <ChartTradeStreamLayer model={model} degen={degen} />
2855
- )}
2856
-
2857
- <ChartScrubLayer model={model} degen={degen} />
2858
-
2859
- {/* Live-price badge on top of the scrub dim so the dim never clips
2860
- its left edge (the badge tracks the live value, not the scrub). */}
2861
- {model.badgeCfg && <ChartBadgeLayer model={model} degen={degen} />}
2862
-
2863
- {/* Scrub-action reticle + action badge — top-most, no shake transform. */}
2864
- <ChartScrubActionLayer model={model} />
2865
- </Canvas>
2866
-
2867
- {/* RN labels floated over the canvas (sibling of <Canvas>, an RN view).
2868
- Pinned to the plot's top/bottom edges via the resolved padding. */}
2869
- {(topLabelCfg || bottomLabelCfg) && (
2870
- <AxisLabelOverlay
2871
- topLabel={topLabelCfg}
2872
- bottomLabel={bottomLabelCfg}
2873
- engine={engine}
2874
- formatValue={formatValue}
2875
- defaultColor={palette.gridLabel}
2876
- padding={effectivePadding}
2877
- extremaTimeOffset={extremaTimeOffset}
2878
- />
2879
- )}
2880
-
2881
- {/* Custom-rendered markers — RN views floated over the canvas (non-Skia),
2882
- pinned to each marker's live position. Sibling of <Canvas>. Wrapped in
2883
- a box-none fade layer so `scrub.hideOverlaysOnScrub` hides them with the
2884
- Skia markers (the wrapper is full-bleed; children keep their own
2885
- absolute positions). */}
2886
- {((markersActive && renderMarker) ||
2887
- ((renderReferenceLine || renderOffAxisReferenceLine) &&
2888
- allRefLines.length > 0)) && (
2889
- <ChartCustomAnnotations model={model} />
2890
- )}
2891
-
2892
- {/* Custom scrub tooltip — an RN view floated over the canvas (non-Skia),
2893
- positioned on the UI thread. Sibling of <Canvas>. Works in line and
2894
- candle mode (candle exposes the OHLC via `scrubCandle`). */}
2895
- {scrubCfg && renderTooltip && (
2896
- <CustomTooltipOverlay
2897
- renderTooltip={renderTooltip}
2898
- scrubX={crosshair.scrubX}
2899
- scrubValue={crosshair.scrubValue}
2900
- scrubTime={crosshair.scrubTime}
2901
- scrubActive={crosshair.scrubActive}
2902
- scrubCandle={crosshair.scrubCandle}
2903
- tooltipLayout={crosshair.tooltipLayout}
2904
- engine={engine}
2905
- padding={effectivePadding}
2906
- placement={scrubCfg.tooltipPlacement}
2907
- margin={scrubCfg.tooltipMargin}
2908
- crosshairFade={scrubCfg.crosshairFade}
2909
- crosshairFadeDistance={scrubCfg.crosshairFadeDistance}
2910
- lineTop={crosshair.tooltipLineTop}
2911
- scrubDotY={crosshair.scrubDotY}
2912
- />
2913
- )}
2914
-
2915
- {/* Custom consumer overlay — an RN view tree floated over the canvas with
2916
- the price↔pixel / time↔pixel bridge, for order / avg-entry / liquidation
2917
- tags etc. Topmost RN sibling; `box-none` so empty areas still scrub. */}
2918
- {renderOverlay && <ChartCustomConsumerOverlay model={model} />}
3861
+ <ChartCanvas model={model} yAxisEntries={yAxisEntries} degen={degen} />
3862
+ <ChartNativeOverlays model={model} />
2919
3863
  </View>
2920
3864
  </GestureDetector>
2921
3865
  );