react-native-livechart 4.21.0 → 4.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) 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 +3 -1
  7. package/dist/components/CustomThresholdBadgeOverlay.d.ts.map +1 -1
  8. package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
  9. package/dist/components/ExtremaConnectorOverlay.d.ts +5 -1
  10. package/dist/components/ExtremaConnectorOverlay.d.ts.map +1 -1
  11. package/dist/components/LiveChart.d.ts.map +1 -1
  12. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  13. package/dist/components/MarkerOverlay.d.ts +4 -2
  14. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  15. package/dist/components/ReferenceLineOverlay.d.ts +1 -1
  16. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  17. package/dist/components/ReferenceLineSeriesOverlay.d.ts.map +1 -1
  18. package/dist/components/ThresholdLineOverlay.d.ts +3 -1
  19. package/dist/components/ThresholdLineOverlay.d.ts.map +1 -1
  20. package/dist/components/thresholdSplitShaderSource.d.ts.map +1 -1
  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 +3 -0
  24. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  25. package/dist/core/resolveConfig.d.ts +2 -0
  26. package/dist/core/resolveConfig.d.ts.map +1 -1
  27. package/dist/core/useLiveChartEngine.d.ts +4 -0
  28. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  29. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  30. package/dist/draw/markerAtlas.d.ts +3 -2
  31. package/dist/draw/markerAtlas.d.ts.map +1 -1
  32. package/dist/hooks/useChartReveal.d.ts +1 -1
  33. package/dist/hooks/useChartReveal.d.ts.map +1 -1
  34. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  35. package/dist/hooks/useDegen.d.ts.map +1 -1
  36. package/dist/hooks/useLiveChartHasData.d.ts +2 -2
  37. package/dist/hooks/useLiveChartHasData.d.ts.map +1 -1
  38. package/dist/hooks/useReferenceLineSeries.d.ts +1 -1
  39. package/dist/hooks/useReferenceLineSeries.d.ts.map +1 -1
  40. package/dist/hooks/useReverseMorphEngineInputs.d.ts.map +1 -1
  41. package/dist/hooks/useThreshold.d.ts +10 -5
  42. package/dist/hooks/useThreshold.d.ts.map +1 -1
  43. package/dist/math/referenceLineSeries.d.ts +1 -1
  44. package/dist/math/referenceLineSeries.d.ts.map +1 -1
  45. package/dist/math/threshold.d.ts +4 -3
  46. package/dist/math/threshold.d.ts.map +1 -1
  47. package/dist/types.d.ts +23 -8
  48. package/dist/types.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/components/AxisLabelOverlay.tsx +162 -81
  51. package/src/components/CrosshairOverlay.tsx +394 -147
  52. package/src/components/CustomMarkerOverlay.tsx +9 -6
  53. package/src/components/CustomThresholdBadgeOverlay.tsx +11 -4
  54. package/src/components/DegenParticlesOverlay.tsx +34 -23
  55. package/src/components/ExtremaConnectorOverlay.tsx +43 -3
  56. package/src/components/LiveChart.tsx +1709 -1025
  57. package/src/components/LiveChartSeries.tsx +445 -296
  58. package/src/components/MarkerOverlay.tsx +215 -156
  59. package/src/components/ReferenceLineOverlay.tsx +77 -50
  60. package/src/components/ReferenceLineSeriesOverlay.tsx +2 -0
  61. package/src/components/ThresholdLineOverlay.tsx +16 -6
  62. package/src/components/thresholdSplitShaderSource.ts +4 -1
  63. package/src/core/chartDataPresence.ts +24 -0
  64. package/src/core/liveChartEngineTick.ts +4 -0
  65. package/src/core/resolveConfig.ts +3 -0
  66. package/src/core/useLiveChartEngine.ts +16 -3
  67. package/src/core/useLiveChartSeriesEngine.ts +2 -0
  68. package/src/draw/markerAtlas.ts +8 -3
  69. package/src/hooks/useChartReveal.ts +1 -1
  70. package/src/hooks/useCrosshair.ts +69 -46
  71. package/src/hooks/useDegen.ts +82 -53
  72. package/src/hooks/useLiveChartHasData.ts +8 -4
  73. package/src/hooks/useReferenceLineSeries.ts +5 -0
  74. package/src/hooks/useReverseMorphEngineInputs.ts +11 -20
  75. package/src/hooks/useThreshold.ts +41 -15
  76. package/src/math/referenceLineSeries.ts +9 -4
  77. package/src/math/threshold.ts +10 -3
  78. package/src/types.ts +23 -8
@@ -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,
@@ -87,7 +86,11 @@ import {
87
86
  computeCandleFocusClip,
88
87
  HIDDEN_CANDLE_FOCUS_CLIP,
89
88
  } from "../draw/candle";
90
- import { dotGlowRadialOutset, pulseRadialOutset } from "../draw/line";
89
+ import {
90
+ dotGlowRadialOutset,
91
+ pulseRadialOutset,
92
+ type ChartPadding,
93
+ } from "../draw/line";
91
94
  import { resolveChartLayout } from "../hooks/resolveChartLayout";
92
95
  import { useBadge } from "../hooks/useBadge";
93
96
  import { useCandleGapPaths } from "../hooks/useCandleGapPaths";
@@ -269,170 +272,119 @@ function thresholdSplitColorVecs(
269
272
  /** Transparent vec4 — the band shader's "threshold ended" rest color. */
270
273
  const TRANSPARENT_VEC4 = [0, 0, 0, 0];
271
274
 
272
- /**
273
- * Resolves props → configs → theme/layout → engine → per-frame derived values and
274
- * overlay hooks, returning a single render model. All the chart's wiring lives
275
- * here so the rendered pieces (`ChartStack`, `ChartScrubLayer`, `LiveChart`) stay
276
- * small and presentational.
277
- */
278
-
279
- function useLiveChartController({
280
- // ── Data ────────────────────────────────────────────────────────────────
281
- data,
282
- value,
283
-
284
- // ── Appearance ──────────────────────────────────────────────────────────
285
- theme = "dark",
286
- accentColor = DEFAULT_ACCENT_COLOR,
287
- gradient = true,
288
- areaDots,
289
- line: lineProp,
290
- font: fontProp,
291
- insets,
292
- style,
293
- seriesOpacity,
294
- canvasMode = "transparent",
295
-
296
- // ── Candlestick ─────────────────────────────────────────────────────────
297
- mode = "line",
298
- candles,
299
- candleWidth = 60,
300
- liveCandle,
301
- candleGaps,
302
- lineGaps,
303
- volume,
304
-
305
- // ── Behaviour ───────────────────────────────────────────────────────────
306
- timeWindow = 30,
307
- paused = false,
308
- loading = false,
309
- transitions,
310
- // `static` is a reserved word — alias it so the destructure parses.
311
- static: isStatic = false,
312
- snapKey,
313
- smoothing = 0.08,
314
- exaggerate = false,
315
- nonNegative = false,
316
- maxValue,
317
- yRangeScale,
318
- windowBuffer = 0,
319
- nowOverride,
320
- timeScroll = false,
321
- returnToLive,
322
- zoom = false,
323
- accessibilityLabel,
324
- accessibilityRole = "image",
325
- emptyText = "No data",
326
- formatValue = defaultFormatValue,
327
- formatTime = defaultFormatTime,
328
-
329
- // ── Overlays ────────────────────────────────────────────────────────────
330
- yAxis = true,
331
- xAxis = true,
332
- axisAutoHide = false,
275
+ function resolveLiveChartFeatureConfig({
276
+ mode,
277
+ yAxis,
278
+ xAxis,
333
279
  topLabel,
334
280
  bottomLabel,
335
- badge = true,
336
- momentum = true,
337
- pulse = true,
338
- dot,
339
- valueLine = true,
340
- showValue = false,
341
- valueMomentumColor = false,
342
- referenceLines,
343
- segments,
344
- threshold,
345
- gridStyle,
346
- palette: paletteOverride,
347
- metrics,
348
- scrub = true,
281
+ badge,
282
+ scrub,
349
283
  scrubAction,
284
+ volume,
285
+ candleGaps,
286
+ lineGaps,
287
+ gradient,
288
+ areaDots,
289
+ threshold,
290
+ valueLine,
291
+ isStatic,
292
+ pulse,
293
+ dot,
350
294
  selectionDot,
351
- tradeStream,
295
+ gridStyle,
352
296
  degen,
353
- markers,
354
- onMarkerPress,
355
- markerHitRadius = 16,
356
- markerCluster,
357
- renderMarker,
358
- renderTooltip,
359
- renderOverlay,
360
- renderThresholdBadge,
361
- renderReferenceLine,
362
- renderOffAxisReferenceLine,
363
- referenceLineGrouping,
364
- leftEdgeFade = true,
365
-
366
- // ── Callbacks ───────────────────────────────────────────────────────────
367
- onScrub,
368
- onScrubAction,
369
- onReferenceLinePress,
370
- onGestureStart,
371
- onGestureEnd,
372
- onVisibleRangeChange,
373
- onReachStart,
374
- onDegenShake,
375
- }: LiveChartProps) {
376
- const fullSeriesOpacity = useSharedValue(1);
377
- const resolvedSeriesOpacity = seriesOpacity ?? fullSeriesOpacity;
378
- const emptyMarkers = useSharedValue<Marker[]>([]);
379
- const markersSV = markers ?? emptyMarkers;
380
- // Stand-in threshold value so `useThreshold` can be called unconditionally
381
- // (hooks can't be); the geometry is ignored when no threshold is configured.
382
- 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 }) {
383
324
  const isCandle = mode === "candle";
384
-
385
- // ── Resolve feature configs ────────────────────────────────────────────
386
- const yAxisCfg = resolveYAxis(yAxis);
387
- const xAxisCfg = resolveXAxis(xAxis);
388
- const topLabelCfg = resolveAxisLabel(topLabel);
389
- const bottomLabelCfg = resolveAxisLabel(bottomLabel);
390
325
  const badgeCfg = resolveBadge(badge);
391
- const scrubCfg = resolveScrub(scrub);
392
- // Scrub + scrub-action are on-demand touch gestures (event-driven, no per-frame
393
- // loop), so they stay live on static charts — `static` suppresses the continuous
394
- // render loop, not interaction. A still sparkline in a list can still be scrubbed.
395
- const scrubActionCfg = resolveScrubAction(scrubAction);
396
- // Volume bars sit below the candles — a candle-mode-only feature (inert in
397
- // line mode, like the candle paths themselves).
398
- const volumeCfg = isCandle ? resolveVolume(volume) : null;
399
326
  const chartGapsCfg = resolveCandleGaps(isCandle ? candleGaps : lineGaps);
400
- const candleGapsCfg = isCandle ? chartGapsCfg : null;
401
- const lineGapsCfg = isCandle ? null : chartGapsCfg;
402
- const volumeBandHeight = volumeCfg?.maxHeight ?? 0;
403
- const gradientCfg = isCandle ? null : resolveGradient(gradient);
404
- // Dot-lattice area fill (clipped to the under-line region). Inert in candle
405
- // mode, same as the gradient fill.
406
- const areaDotsCfg = isCandle ? null : resolveAreaDots(areaDots);
407
- // Threshold split is a line-mode feature (candle bodies carry their own up/down
408
- // colors), so it's inert in candle mode — same as the area gradient.
409
327
  const thresholdCfg = isCandle ? null : resolveThreshold(threshold);
410
328
  const thresholdSeriesSV = thresholdCfg?.series ?? null;
411
- const thresholdIsSeries =
412
- thresholdSeriesSV !== null || Array.isArray(thresholdCfg?.value);
413
- const valueLineCfg = resolveValueLine(valueLine);
414
- // Static charts run zero loops: force the pulse off so its `withRepeat`-driven
415
- // ring never starts (the DotOverlay reads `pulseCfg`, so null = no pulse).
416
- const pulseCfg = isStatic ? null : resolvePulse(pulse);
417
329
  const dotCfg = resolveDot(dot);
418
- // `badge.followViewEdge` wins over `dot.trackWhileParked`: an edge-pinned dot
419
- // must stay aligned with its badge, so the tracking flag is ignored.
420
- const dotTracksParked =
421
- dotCfg.trackWhileParked && !(badgeCfg?.followViewEdge ?? false);
422
- const selectionDotCfg = resolveSelectionDot(selectionDot);
423
- // Outer visible footprint of the dot, including its crisp ring and optional
424
- // blurred glow. Used by scrub dimming so neither effect leaks through.
425
- const dotOuterRadius = Math.max(
426
- dotCfg.radius + (dotCfg.ring?.width ?? 0),
427
- dotCfg.glow ? dotGlowRadialOutset(dotCfg.glow.radius, dotCfg.glow.blur) : 0,
428
- );
429
- const gridStyleCfg = resolveGridStyle(gridStyle);
430
- // Static charts run zero loops: force degen off so `useDegen`'s frame callback
431
- // never starts (also passed `isStatic` below as a belt-and-braces autostart gate).
432
- const degenCfg = isStatic ? null : resolveDegen(degen);
433
- const tradeStreamResolved = resolveTradeStream(tradeStream);
434
- const metricsCfg = resolveMetrics(metrics);
330
+ const volumeCfg = isCandle ? resolveVolume(volume) : null;
331
+
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
+ }
435
370
 
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
+ }) {
436
388
  const chartGapBands: ReferenceLine[] =
437
389
  chartGapsCfg?.gaps.flatMap((gap) => {
438
390
  const gapStyle = chartGapsCfg.styles[gap.kind];
@@ -459,54 +411,12 @@ function useLiveChartController({
459
411
  },
460
412
  ];
461
413
  }) ?? [];
462
- // Gap bands append after consumer reference lines so public line indices stay
463
- // stable for callbacks. Time bands contribute no Y values or press targets.
464
- const allRefLines = [...(referenceLines ?? []), ...chartGapBands];
465
- const refValues = collectReferenceValues(allRefLines);
466
-
467
- // Per-line live value overrides + drag flags for draggable lines and the custom
468
- // `renderReferenceLine` slot. One array SharedValue each (the line count varies,
469
- // so a single SharedValue beats N hooks). Seeded from each line's static `value`;
470
- // the drag gesture overwrites a slot, and the effect re-seeds slots not being
471
- // dragged when the props change (so a controlled `value` flows back in).
472
- const dragValues = useSharedValue<number[]>([]);
473
- const dragActive = useSharedValue<boolean[]>([]);
474
- // Last `value` props used to seed each slot, so reconciliation can tell a
475
- // controlled prop change (adopt it) from an unchanged prop (keep the dragged
476
- // value — uncontrolled persistence) without resetting a drop on every re-render.
477
- const seededRef = useRef<(number | undefined)[]>([]);
478
- const refValueSig = allRefLines.map((l) => l.value ?? "_").join(",");
479
- useEffect(() => {
480
- const active = dragActive.get();
481
- const cur = dragValues.get();
482
- const seeded = seededRef.current;
483
- dragValues.set(
484
- allRefLines.map((l, i) => {
485
- const prop = l.value ?? 0;
486
- if (active[i]) return cur[i] ?? prop; // mid-drag → keep the dragged value
487
- if (l.value !== seeded[i]) return prop; // prop changed → adopt (controlled)
488
- return cur[i] ?? prop; // unchanged → keep current (uncontrolled persist)
489
- }),
490
- );
491
- seededRef.current = allRefLines.map((l) => l.value);
492
- if (dragActive.get().length !== allRefLines.length) {
493
- dragActive.set(allRefLines.map((_, i) => active[i] ?? false));
494
- }
495
- // allRefLines is rebuilt every render; key off the value signature + length.
496
- // eslint-disable-next-line react-hooks/exhaustive-deps
497
- }, [refValueSig, allRefLines.length]);
498
-
499
- // Form-A lines a custom `renderReferenceLine` owns → suppress their built-in tag
500
- // (no double-draw). Probed on the JS thread, index-aligned with `allRefLines`.
501
- // Semantic gap bands are library-owned annotations: do not leak them through
502
- // the consumer's reference-line renderer or change its callback cardinality.
503
414
  const consumerRefLines = referenceLines ?? [];
415
+ const allRefLines = [...consumerRefLines, ...chartGapBands];
504
416
  const refLineCustom = [
505
417
  ...customReferenceLineFlags(consumerRefLines, renderReferenceLine),
506
418
  ...chartGapBands.map(() => false),
507
419
  ];
508
- // An off-axis renderer replaces only the edge-pinned tag. A full custom tag
509
- // takes precedence for the same line to avoid mounting two native tags.
510
420
  const refLineOffAxisCustom = [
511
421
  ...customReferenceLineFlags(
512
422
  consumerRefLines,
@@ -515,123 +425,169 @@ function useLiveChartController({
515
425
  ),
516
426
  ...chartGapBands.map(() => false),
517
427
  ].map((custom, index) => custom && !refLineCustom[index]);
518
- const refLineKeys = referenceLineReactKeys(allRefLines);
519
- // RN custom tags report their measured widths here so the Skia connector can
520
- // start after the native badge instead of the hidden built-in pill.
521
- const refLineCustomTagWidths = useSharedValue<number[]>([]);
522
-
523
- // Live Y values of the *draggable* Form-A lines, folded into the engine's
524
- // axis-range fit so dragging a line toward / past the visible edge expands the
525
- // range and the axis follows the finger in one motion (the committed values are
526
- // already in `refValues`). `excludeFromRange` lines opt out, matching the static
527
- // fit. Identity-stable (no re-fit) when nothing is draggable.
528
- //
529
- // `threshold.includeInRange` rides the same channel: the constant benchmark
530
- // contributes its live value; a series contributes its window min/max
531
- // (respecting `extendToNow`), so an off-range break-even expands the axis and
532
- // stays on-plot like a reference line would.
533
428
  const draggableRefIdx: number[] = [];
534
- for (let i = 0; i < allRefLines.length; i++) {
535
- const line = allRefLines[i];
429
+ for (let index = 0; index < allRefLines.length; index++) {
430
+ const line = allRefLines[index];
536
431
  if (
537
432
  line.draggable &&
538
433
  !line.excludeFromRange &&
539
434
  referenceLineForm(line) === "line"
540
435
  ) {
541
- draggableRefIdx.push(i);
436
+ draggableRefIdx.push(index);
542
437
  }
543
438
  }
544
439
  const thresholdInRange = thresholdCfg?.includeInRange === true;
545
- // Constant benchmark → its live value rides this channel. A series threshold
546
- // is folded inside the engine tick instead (`thresholdRangePoints`), which
547
- // already knows the window bounds for the min/max.
548
440
  const thresholdRangeValueSV =
549
441
  thresholdInRange &&
550
442
  thresholdCfg?.value != null &&
551
443
  !Array.isArray(thresholdCfg.value)
552
444
  ? thresholdCfg.value
553
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[]>([]);
554
506
  const liveRefValues = useDerivedValue<number[]>(() => {
555
- if (draggableRefIdx.length === 0 && thresholdRangeValueSV === null)
507
+ if (draggableRefIdx.length === 0 && thresholdRangeValueSV === null) {
556
508
  return EMPTY_NUMS;
557
- const out: number[] = [];
509
+ }
510
+ const output: number[] = [];
558
511
  if (draggableRefIdx.length > 0) {
559
- const dv = dragValues.get();
560
- for (let k = 0; k < draggableRefIdx.length; k++) {
561
- const v = dv[draggableRefIdx[k]];
562
- 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);
563
516
  }
564
517
  }
565
518
  if (thresholdRangeValueSV !== null) {
566
- const v = thresholdRangeValueSV.get();
567
- if (Number.isFinite(v)) out.push(v);
519
+ const value = thresholdRangeValueSV.get();
520
+ if (Number.isFinite(value)) output.push(value);
568
521
  }
569
- return out;
522
+ return output;
570
523
  });
571
524
 
572
- // Reference-line grouping (collapse near-value handles). Resolved once; the
573
- // per-frame clustering runs on the UI thread (see ReferenceLineGroupOverlay).
574
- const refGroupingCfg =
575
- typeof referenceLineGrouping === "object"
576
- ? referenceLineGrouping
577
- : undefined;
578
- const refGroupingRadius = referenceLineGrouping
579
- ? (refGroupingCfg?.radius ?? 18)
580
- : null;
581
- // Count-pill styling (same style/shape config as a per-line badge) + count
582
- // formatter. Resolved once; theme color defaults are applied in the overlay.
583
- const refGroupBadge = resolveReferenceGroupBadge(refGroupingCfg?.badge);
584
- const refGroupFormat = refGroupingCfg?.format;
585
-
586
- const badgeUsesRightGutter =
587
- badgeCfg !== null && (badgeCfg.position ?? "right") === "right";
588
-
589
- // ── 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
+ }) {
590
559
  const palette = applyPaletteOverride(
591
560
  resolveTheme(accentColor, theme),
592
561
  paletteOverride,
593
562
  );
594
-
595
- // Time-range segments (sessions, after-hours, …). Resolved once per render like
596
- // reference lines; the divider/label/muted colors default to the chart palette
597
- // (no per-segment base color needed). `hasRecolorSegments` is a render-time gate
598
- // for the line-recolor gradient pass (per-frame visibility is handled by the
599
- // gradient's transparent stops, not by mounting/unmounting the Path).
600
- const resolvedSegments = (segments ?? []).map((s) =>
601
- resolveSegment(s, {
563
+ const resolvedSegments = (segments ?? []).map((segment) =>
564
+ resolveSegment(segment, {
602
565
  muted: palette.gridLabel,
603
566
  divider: palette.refLine,
604
567
  label: palette.refLabel,
605
568
  }),
606
569
  );
607
- const hasRecolorSegments = resolvedSegments.some((s) => s.recolorLine);
608
-
609
- const leftEdgeFadeCfg = resolveLeftEdgeFade(
610
- leftEdgeFade,
611
- leftEdgeFadeColorsFromBgRgb(palette.bgRgb),
612
- );
613
-
614
- const skiaFont = useChartSkiaFont(
615
- fontProp,
616
- MONO_FONT_FAMILY,
617
- palette.labelFontSize,
618
- );
619
-
620
- // Larger font for the optional live-value text overlay (showValue).
621
- const valueFont = useChartSkiaFont(
622
- fontProp,
623
- MONO_FONT_FAMILY,
624
- palette.valueFontSize * 2,
625
- );
626
-
627
- // Per-badge font (size/family/weight) override; reuses the chart font when
628
- // none of the badge font knobs are set, so the gutter sizing is unchanged.
629
570
  const badgeHasFontOverride =
630
571
  badgeCfg?.fontSize != null ||
631
572
  badgeCfg?.fontFamily != null ||
632
573
  badgeCfg?.fontWeight != null;
633
- const badgeFontOverride = useChartSkiaFont(
634
- 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
635
591
  ? {
636
592
  ...fontProp,
637
593
  fontFamily: badgeCfg?.fontFamily ?? fontProp?.fontFamily,
@@ -639,18 +595,8 @@ function useLiveChartController({
639
595
  fontWeight: badgeCfg?.fontWeight ?? fontProp?.fontWeight,
640
596
  }
641
597
  : fontProp,
642
- MONO_FONT_FAMILY,
643
- palette.labelFontSize,
644
- );
645
- const badgeFont = badgeHasFontOverride ? badgeFontOverride : skiaFont;
646
-
647
- // Per-badge font for the grouping count pill (same override pattern as above).
648
- const refGroupBadgeHasFont =
649
- refGroupBadge.fontSize != null ||
650
- refGroupBadge.fontFamily != null ||
651
- refGroupBadge.fontWeight != null;
652
- const refGroupBadgeFontOverride = useChartSkiaFont(
653
- refGroupBadgeHasFont
598
+ refGroupBadgeHasFont,
599
+ refGroupBadgeFontConfig: refGroupBadgeHasFont
654
600
  ? {
655
601
  ...fontProp,
656
602
  fontFamily: refGroupBadge.fontFamily ?? fontProp?.fontFamily,
@@ -658,82 +604,1072 @@ function useLiveChartController({
658
604
  fontWeight: refGroupBadge.fontWeight ?? fontProp?.fontWeight,
659
605
  }
660
606
  : fontProp,
661
- MONO_FONT_FAMILY,
662
- palette.labelFontSize,
663
- );
664
- const refGroupBadgeFont = refGroupBadgeHasFont
665
- ? refGroupBadgeFontOverride
666
- : 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
+ }
667
622
 
668
- const pulseConfig = pulseCfg
669
- ? {
670
- maxRadius: pulseCfg.maxRadius,
671
- 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);
672
661
  }
673
- : null;
674
-
675
- // Snapshot the live value off the render path to size the right gutter to the
676
- // value label. Reading a SharedValue during render trips Reanimated's
677
- // strict-mode warning, and the gutter only needs a representative magnitude —
678
- // so read it in a layout effect (re-measures before paint, no visible reflow)
679
- // once on mount, re-synced if the `value` SharedValue identity changes.
680
- // react-doctor's "derive during render" fix is exactly what Reanimated forbids
681
- // here, so suppress its effect-read rules at this seed.
682
- const [valueLayoutSample, setValueLayoutSample] = useState<
683
- number | undefined
684
- >(undefined);
685
- // react-doctor-disable-next-line react-doctor/no-derived-state-effect -- Reanimated: must read the SharedValue off the render path
686
- useLayoutEffect(() => {
687
- // react-doctor-disable-next-line react-hooks-js/set-state-in-effect -- Reanimated: seeding from a SharedValue off render is the warning-free path
688
- // eslint-disable-next-line react-hooks/set-state-in-effect -- Reanimated: seed from the SharedValue outside render to avoid strict-mode access warnings
689
- setValueLayoutSample(value.get());
690
- }, [value]);
691
-
692
- // `scrolledBack` mirrors the UI-thread scroll state (engine.viewEnd != null)
693
- // onto the JS thread (set by the reaction below, after the engine exists).
694
- const [scrolledBack, setScrolledBack] = useState(false);
695
- const timeScrollEnabled = Boolean(timeScroll) && !isStatic;
696
- // Glide duration for the return-to-live animation (0 = instant). A sibling of
697
- // `timeScroll` so it survives `timeScroll={false}` (the disable that triggers it).
698
- const returnToLiveMs = resolveReturnToLiveMs(returnToLive);
699
- // Overscroll fraction ([0, 1)) — how far pan/zoom may travel past the data
700
- // bounds into blank space. 0 (the default) keeps the classic hard stops.
701
- const timeScrollOverscroll = timeScrollEnabled
702
- ? resolveOverscroll(timeScroll)
703
- : 0;
704
- // Release inertia (fling) — `timeScroll.fling: false` stops the pan dead.
705
- const timeScrollFling = resolveFling(timeScroll);
706
- const zoomCfg = resolveZoom(zoom);
707
- 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?.extendToStart ?? true,
676
+ thresholdCfg?.line?.labelAnchor ?? "last",
677
+ ),
678
+ };
679
+ }
680
+
681
+ function useThresholdShaderUniforms({
682
+ engine,
683
+ padding,
684
+ samples,
685
+ clipLeftX,
686
+ clipRightX,
687
+ thresholdCfg,
688
+ palette,
689
+ lineProp,
690
+ }: {
691
+ engine: ReturnType<typeof useLiveChartEngine>;
692
+ padding: ChartPadding;
693
+ samples: SharedValue<number[]>;
694
+ clipLeftX: SharedValue<number>;
695
+ clipRightX: SharedValue<number>;
696
+ thresholdCfg: ReturnType<typeof resolveThreshold>;
697
+ palette: LiveChartPalette;
698
+ lineProp: LiveChartProps["line"];
699
+ }) {
700
+ const { stopColors, splitAbove, splitBelow, fillOpacity, lineColor } =
701
+ resolveThresholdColorConfig(thresholdCfg, palette, lineProp);
702
+ const vectors = useMemo(() => {
703
+ if (splitAbove === null || splitBelow === null) return null;
704
+ const [r, g, b, a] = parseColorRgba(lineColor);
705
+ return {
706
+ ...thresholdSplitColorVecs(splitAbove, splitBelow, fillOpacity),
707
+ strokeRest: [r / 255, g / 255, b / 255, a],
708
+ };
709
+ }, [splitAbove, splitBelow, fillOpacity, lineColor]);
710
+ return {
711
+ thresholdStrokeColors: stopColors?.stroke ?? null,
712
+ thresholdFillColors: stopColors?.fill ?? null,
713
+ thresholdStrokeUniforms: useThresholdSplitUniforms(
714
+ samples,
715
+ engine,
716
+ padding,
717
+ vectors?.strokeAbove ?? THRESHOLD_FALLBACK_COLOR,
718
+ vectors?.strokeBelow ?? THRESHOLD_FALLBACK_COLOR,
719
+ vectors?.strokeRest ?? THRESHOLD_FALLBACK_COLOR,
720
+ clipLeftX,
721
+ clipRightX,
722
+ ),
723
+ thresholdFillUniforms: useThresholdSplitUniforms(
724
+ samples,
725
+ engine,
726
+ padding,
727
+ vectors?.fillAbove ?? THRESHOLD_FALLBACK_COLOR,
728
+ vectors?.fillBelow ?? THRESHOLD_FALLBACK_COLOR,
729
+ TRANSPARENT_VEC4,
730
+ clipLeftX,
731
+ clipRightX,
732
+ ),
733
+ };
734
+ }
735
+
736
+ function useThresholdBadgeProjection({
737
+ thresholdCfg,
738
+ thresholdIsSeries,
739
+ thresholdGeom,
740
+ thresholdSeriesGeom,
741
+ renderThresholdBadge,
742
+ formatValue,
743
+ }: {
744
+ thresholdCfg: ReturnType<typeof resolveThreshold>;
745
+ thresholdIsSeries: boolean;
746
+ thresholdGeom: ReturnType<typeof useThreshold>;
747
+ thresholdSeriesGeom: ReturnType<typeof useThresholdSeries>;
748
+ renderThresholdBadge: LiveChartProps["renderThresholdBadge"];
749
+ formatValue: (value: number) => string;
750
+ }) {
751
+ const lineY = thresholdIsSeries
752
+ ? thresholdSeriesGeom.badgeLineY
753
+ : thresholdGeom.lineY;
754
+ const markerVisible = thresholdIsSeries
755
+ ? thresholdSeriesGeom.visible
756
+ : thresholdGeom.visible;
757
+ const badgeVisible = thresholdIsSeries
758
+ ? thresholdSeriesGeom.badgeVisible
759
+ : thresholdGeom.visible;
760
+ const markerValue =
761
+ thresholdCfg && !thresholdIsSeries && !Array.isArray(thresholdCfg.value)
762
+ ? (thresholdCfg.value ?? thresholdSeriesGeom.badgeValue)
763
+ : thresholdSeriesGeom.badgeValue;
764
+ const custom = thresholdCfg?.line != null && renderThresholdBadge != null;
765
+ const valueStr = useDerivedValue(() =>
766
+ custom ? formatValue(markerValue.get()) : "",
767
+ );
768
+ return {
769
+ thresholdMarkerLineY: lineY,
770
+ thresholdMarkerVisible: markerVisible,
771
+ thresholdBadgeVisible: badgeVisible,
772
+ thresholdMarkerValue: markerValue,
773
+ thresholdCustomBadge:
774
+ thresholdCfg?.line && renderThresholdBadge
775
+ ? renderThresholdBadge({
776
+ line: thresholdCfg.line,
777
+ value: markerValue,
778
+ valueStr,
779
+ y: lineY,
780
+ visible: badgeVisible,
781
+ })
782
+ : null,
783
+ thresholdSeriesPts: thresholdIsSeries
784
+ ? thresholdSeriesGeom.screenPts
785
+ : undefined,
786
+ thresholdBadgeLeftAnchorX:
787
+ thresholdIsSeries && thresholdCfg?.extendToStart === false
788
+ ? thresholdSeriesGeom.clipLeftX
789
+ : undefined,
790
+ };
791
+ }
792
+
793
+ function useLiveChartThresholdModel({
794
+ engine,
795
+ padding,
796
+ thresholdCfg,
797
+ thresholdSeriesSV,
798
+ thresholdIsSeries,
799
+ emptyThresholdValue,
800
+ palette,
801
+ lineProp,
802
+ renderThresholdBadge,
803
+ formatValue,
804
+ }: {
805
+ engine: ReturnType<typeof useLiveChartEngine>;
806
+ padding: ChartPadding;
807
+ thresholdCfg: ReturnType<typeof resolveThreshold>;
808
+ thresholdSeriesSV: SharedValue<LiveChartPoint[]> | null;
809
+ thresholdIsSeries: boolean;
810
+ emptyThresholdValue: SharedValue<number>;
811
+ palette: LiveChartPalette;
812
+ lineProp: LiveChartProps["line"];
813
+ renderThresholdBadge: LiveChartProps["renderThresholdBadge"];
814
+ formatValue: (value: number) => string;
815
+ }) {
816
+ const { thresholdSeriesHasPoints, thresholdGeom, thresholdSeriesGeom } =
817
+ useThresholdGeometryState({
818
+ engine,
819
+ padding,
820
+ thresholdCfg,
821
+ thresholdSeriesSV,
822
+ emptyThresholdValue,
823
+ });
824
+ const shaderModel = useThresholdShaderUniforms({
825
+ engine,
826
+ padding,
827
+ samples: thresholdSeriesGeom.samples,
828
+ clipLeftX: thresholdSeriesGeom.clipLeftX,
829
+ clipRightX: thresholdSeriesGeom.clipRightX,
830
+ thresholdCfg,
831
+ palette,
832
+ lineProp,
833
+ });
834
+ const badgeModel = useThresholdBadgeProjection({
835
+ thresholdCfg,
836
+ thresholdIsSeries,
837
+ thresholdGeom,
838
+ thresholdSeriesGeom,
839
+ renderThresholdBadge,
840
+ formatValue,
841
+ });
842
+
843
+ return {
844
+ thresholdGeom,
845
+ thresholdSeriesHasPoints,
846
+ ...shaderModel,
847
+ ...badgeModel,
848
+ thresholdFillLineY:
849
+ thresholdCfg?.fill && !thresholdIsSeries
850
+ ? thresholdGeom.lineY
851
+ : undefined,
852
+ thresholdFillSamples:
853
+ thresholdCfg?.fill && thresholdSeriesHasPoints
854
+ ? thresholdSeriesGeom.samples
855
+ : undefined,
856
+ };
857
+ }
858
+
859
+ function resolveLiveChartInteractionConfig({
860
+ isCandle,
861
+ mode,
862
+ candlesEngine,
863
+ liveEngine,
864
+ candleWidth,
865
+ candleGapsCfg,
866
+ lineGapsCfg,
867
+ markers,
868
+ markerCluster,
869
+ onReferenceLinePress,
870
+ allRefLines,
871
+ isStatic,
872
+ timeScroll,
873
+ timeScrollEnabled,
874
+ scrubCfg,
875
+ }: {
876
+ isCandle: boolean;
877
+ mode: "line" | "candle";
878
+ candlesEngine: SharedValue<CandlePoint[]>;
879
+ liveEngine: SharedValue<CandlePoint | null>;
880
+ candleWidth: number;
881
+ candleGapsCfg: ResolvedCandleGapsConfig | null;
882
+ lineGapsCfg: ResolvedCandleGapsConfig | null;
883
+ markers: LiveChartProps["markers"];
884
+ markerCluster: LiveChartProps["markerCluster"];
885
+ onReferenceLinePress: LiveChartProps["onReferenceLinePress"];
886
+ allRefLines: ReferenceLine[];
887
+ isStatic: boolean;
888
+ timeScroll: LiveChartProps["timeScroll"];
889
+ timeScrollEnabled: boolean;
890
+ scrubCfg: ReturnType<typeof resolveScrub>;
891
+ }) {
892
+ const crosshairChartOpts = isCandle
893
+ ? {
894
+ mode,
895
+ candles: candlesEngine,
896
+ liveCandle: liveEngine,
897
+ candleWidthSecs: candleWidth,
898
+ gaps: candleGapsCfg?.gaps,
899
+ bridgeNoTrades: Boolean(candleGapsCfg?.styles["no-trades"].bridge),
900
+ bridgeUnavailable: Boolean(candleGapsCfg?.styles.unavailable.bridge),
901
+ bridgeUnknown: Boolean(candleGapsCfg?.styles.unknown.bridge),
902
+ }
903
+ : lineGapsCfg
904
+ ? {
905
+ mode,
906
+ gaps: lineGapsCfg.gaps,
907
+ bridgeNoTrades: Boolean(lineGapsCfg.styles["no-trades"].bridge),
908
+ bridgeUnavailable: Boolean(lineGapsCfg.styles.unavailable.bridge),
909
+ bridgeUnknown: Boolean(lineGapsCfg.styles.unknown.bridge),
910
+ }
911
+ : undefined;
912
+ const scrollGestureMode =
913
+ typeof timeScroll === "object"
914
+ ? (timeScroll.gesture ?? "holdToScrub")
915
+ : "holdToScrub";
916
+ const timeScrollHoldMs =
917
+ typeof timeScroll === "object" ? timeScroll.scrubHoldMs : undefined;
918
+
919
+ return {
920
+ crosshairChartOpts,
921
+ markersActive: markers != null,
922
+ markerClusterCfg: resolveMarkerCluster(markerCluster),
923
+ refPressActive: onReferenceLinePress != null && allRefLines.length > 0,
924
+ refDragEnabled:
925
+ !isStatic && allRefLines.some((line) => line.draggable === true),
926
+ scrollGestureMode,
927
+ scrubHoldMs:
928
+ timeScrollEnabled && scrollGestureMode === "holdToScrub"
929
+ ? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
930
+ : (scrubCfg?.panGestureDelay ?? 0),
931
+ };
932
+ }
933
+
934
+ function composeLiveChartRootGesture({
935
+ crosshair,
936
+ markerTapGesture,
937
+ refLineTapGesture,
938
+ refDragGesture,
939
+ panScrollGesture,
940
+ pinchZoomGesture,
941
+ markersActive,
942
+ refPressActive,
943
+ refDragEnabled,
944
+ scrubActionActive,
945
+ timeScrollEnabled,
946
+ scrollGestureMode,
947
+ zoomEnabled,
948
+ }: {
949
+ crosshair: ReturnType<typeof useCrosshair>;
950
+ markerTapGesture: ReturnType<typeof useMarkers>["tapGesture"];
951
+ refLineTapGesture: ReturnType<typeof useReferenceLinePress>["tapGesture"];
952
+ refDragGesture: ReturnType<typeof useReferenceDrag>["gesture"];
953
+ panScrollGesture: ReturnType<typeof usePanScroll>;
954
+ pinchZoomGesture: ReturnType<typeof usePinchZoom>;
955
+ markersActive: boolean;
956
+ refPressActive: boolean;
957
+ refDragEnabled: boolean;
958
+ scrubActionActive: boolean;
959
+ timeScrollEnabled: boolean;
960
+ scrollGestureMode: "holdToScrub" | "axisDrag";
961
+ zoomEnabled: boolean;
962
+ }) {
963
+ const baseGesture =
964
+ scrubActionActive && crosshair.tapGesture
965
+ ? Gesture.Exclusive(crosshair.tapGesture, crosshair.gesture)
966
+ : crosshair.gesture;
967
+ const overlayTaps = [
968
+ markersActive ? markerTapGesture : null,
969
+ refPressActive ? refLineTapGesture : null,
970
+ ].filter(
971
+ (gesture): gesture is NonNullable<typeof gesture> => gesture !== null,
972
+ );
973
+ let rootGesture = baseGesture;
974
+ if (overlayTaps.length > 0) {
975
+ const tapGroup =
976
+ overlayTaps.length === 1
977
+ ? overlayTaps[0]
978
+ : Gesture.Simultaneous(overlayTaps[0], overlayTaps[1]);
979
+ rootGesture = Gesture.Simultaneous(baseGesture, tapGroup);
980
+ }
981
+ if (timeScrollEnabled) {
982
+ rootGesture =
983
+ scrollGestureMode === "axisDrag"
984
+ ? Gesture.Exclusive(panScrollGesture, rootGesture)
985
+ : Gesture.Race(panScrollGesture, rootGesture);
986
+ }
987
+ if (refDragEnabled) {
988
+ rootGesture = Gesture.Exclusive(refDragGesture, rootGesture);
989
+ }
990
+ return zoomEnabled
991
+ ? Gesture.Simultaneous(rootGesture, pinchZoomGesture)
992
+ : rootGesture;
993
+ }
994
+
995
+ function useAxisAutoHide({
996
+ config,
997
+ scrollActive,
998
+ scrubActive,
999
+ engine,
1000
+ }: {
1001
+ config: LiveChartProps["axisAutoHide"];
1002
+ scrollActive: SharedValue<boolean>;
1003
+ scrubActive: SharedValue<boolean>;
1004
+ engine: ReturnType<typeof useLiveChartEngine>;
1005
+ }) {
1006
+ const resolved = config === true ? {} : config === false ? null : config;
1007
+ const idleOpacity = resolved?.idleOpacity ?? 0;
1008
+ const fadeInMs = resolved?.fadeInMs ?? 60;
1009
+ const fadeOutMs = resolved?.fadeOutMs ?? 250;
1010
+ const hideAfterMs = resolved?.hideAfterMs ?? 3000;
1011
+ const opacity = useSharedValue(resolved ? idleOpacity : 1);
1012
+ const enabled = resolved !== null;
1013
+ const last = useRef({ enabled, idleOpacity });
1014
+ useEffect(() => {
1015
+ if (
1016
+ last.current.enabled === enabled &&
1017
+ last.current.idleOpacity === idleOpacity
1018
+ ) {
1019
+ return;
1020
+ }
1021
+ last.current = { enabled, idleOpacity };
1022
+ cancelAnimation(opacity);
1023
+ opacity.value = enabled ? idleOpacity : 1;
1024
+ }, [enabled, idleOpacity, opacity]);
1025
+ useAnimatedReaction(
1026
+ () => ({
1027
+ gesture: scrollActive.value || scrubActive.value,
1028
+ viewEnd: engine.viewEnd.value,
1029
+ viewWindow: engine.viewWindow.value,
1030
+ }),
1031
+ (current, previous) => {
1032
+ if (!enabled || previous === null) return;
1033
+ const moved =
1034
+ current.gesture !== previous.gesture ||
1035
+ current.viewEnd !== previous.viewEnd ||
1036
+ current.viewWindow !== previous.viewWindow;
1037
+ if (!moved) return;
1038
+ cancelAnimation(opacity);
1039
+ opacity.value = current.gesture
1040
+ ? withTiming(1, { duration: fadeInMs })
1041
+ : withSequence(
1042
+ withTiming(1, { duration: fadeInMs }),
1043
+ withDelay(
1044
+ hideAfterMs,
1045
+ withTiming(idleOpacity, { duration: fadeOutMs }),
1046
+ ),
1047
+ );
1048
+ },
1049
+ [enabled, idleOpacity, fadeInMs, fadeOutMs, hideAfterMs],
1050
+ );
1051
+ return opacity;
1052
+ }
1053
+
1054
+ function resolveLiveEngineModeInputs({
1055
+ isCandle,
1056
+ data,
1057
+ lineEngineData,
1058
+ candles,
1059
+ candlesEngine,
1060
+ liveCandle,
1061
+ liveEngine,
1062
+ candleGapsCfg,
1063
+ thresholdInRange,
1064
+ thresholdIsSeries,
1065
+ thresholdSeriesSV,
1066
+ thresholdCfg,
1067
+ }: {
1068
+ isCandle: boolean;
1069
+ data: LiveChartProps["data"];
1070
+ lineEngineData: LiveChartProps["data"];
1071
+ candles: LiveChartProps["candles"];
1072
+ candlesEngine: SharedValue<CandlePoint[]>;
1073
+ liveCandle: LiveChartProps["liveCandle"];
1074
+ liveEngine: SharedValue<CandlePoint | null>;
1075
+ candleGapsCfg: ResolvedCandleGapsConfig | null;
1076
+ thresholdInRange: boolean;
1077
+ thresholdIsSeries: boolean;
1078
+ thresholdSeriesSV: SharedValue<LiveChartPoint[]> | null;
1079
+ thresholdCfg: ResolvedThresholdConfig | null;
1080
+ }) {
1081
+ return {
1082
+ data: isCandle ? data : lineEngineData,
1083
+ thresholdRangePoints:
1084
+ thresholdInRange && thresholdIsSeries
1085
+ ? (thresholdSeriesSV ?? (thresholdCfg?.value as LiveChartPoint[]))
1086
+ : undefined,
1087
+ thresholdRangeExtendToStart: thresholdCfg?.extendToStart ?? true,
1088
+ thresholdRangeExtendToNow: thresholdCfg?.extendToNow ?? true,
1089
+ candles: isCandle ? candlesEngine : candles,
1090
+ liveCandle: isCandle ? liveEngine : liveCandle,
1091
+ candleGaps: candleGapsCfg?.gaps,
1092
+ candleGapBridgeNoTrades: Boolean(candleGapsCfg?.styles["no-trades"].bridge),
1093
+ candleGapBridgeUnavailable: Boolean(
1094
+ candleGapsCfg?.styles.unavailable.bridge,
1095
+ ),
1096
+ candleGapBridgeUnknown: Boolean(candleGapsCfg?.styles.unknown.bridge),
1097
+ };
1098
+ }
1099
+
1100
+ function resolveCrosshairControllerSettings({
1101
+ scrubCfg,
1102
+ scrubActionCfg,
1103
+ markersActive,
1104
+ refPressActive,
1105
+ refDragEnabled,
1106
+ deferTapHit,
1107
+ timeScrollEnabled,
1108
+ scrollGestureMode,
1109
+ effectivePadding,
1110
+ }: {
1111
+ scrubCfg: ReturnType<typeof resolveScrub>;
1112
+ scrubActionCfg: ReturnType<typeof resolveScrubAction>;
1113
+ markersActive: boolean;
1114
+ refPressActive: boolean;
1115
+ refDragEnabled: boolean;
1116
+ deferTapHit: (x: number, y: number) => boolean;
1117
+ timeScrollEnabled: boolean;
1118
+ scrollGestureMode: "holdToScrub" | "axisDrag";
1119
+ effectivePadding: ChartPadding;
1120
+ }) {
1121
+ return {
1122
+ enabled: scrubCfg !== null || scrubActionCfg !== null,
1123
+ deferTapHit:
1124
+ markersActive || refPressActive || refDragEnabled
1125
+ ? deferTapHit
1126
+ : undefined,
1127
+ tooltipPlacement: scrubCfg?.tooltipPlacement ?? "side",
1128
+ tooltipShowValue: scrubCfg?.tooltipShowValue ?? true,
1129
+ tooltipShowTime: scrubCfg?.tooltipShowTime ?? true,
1130
+ tooltipMargin: scrubCfg?.tooltipMargin ?? 8,
1131
+ scrubBottomExclude:
1132
+ timeScrollEnabled && scrollGestureMode === "axisDrag"
1133
+ ? Math.max(effectivePadding.bottom, AXIS_GRAB_MIN_PX)
1134
+ : 0,
1135
+ clampToPlot: scrubCfg?.clampToPlot ?? false,
1136
+ snapToCandles: scrubCfg?.snapToCandles ?? false,
1137
+ };
1138
+ }
1139
+
1140
+ function resolveAreaDotColorVec(
1141
+ areaDotsCfg: ReturnType<typeof resolveAreaDots>,
1142
+ lineProp: LiveChartProps["line"],
1143
+ palette: LiveChartPalette,
1144
+ ) {
1145
+ const rgb = parseColorRgb(lineProp?.color ?? palette.line);
1146
+ const [r, g, b, a] = parseColorRgba(
1147
+ areaDotsCfg?.color ?? `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.22)`,
1148
+ );
1149
+ return [r / 255, g / 255, b / 255, a * (areaDotsCfg?.opacity ?? 1)];
1150
+ }
1151
+
1152
+ function useLiveIndicatorOpacities({
1153
+ reveal,
1154
+ seriesIndicatorOpacity,
1155
+ seriesOpacity,
1156
+ scrubActive,
1157
+ selectionDotDuringScrub,
1158
+ hideLiveOnScrollBack,
1159
+ dotTracksParked,
1160
+ viewEnd,
1161
+ fadeOverlaysOnScrub,
1162
+ }: {
1163
+ reveal: ReturnType<typeof useChartReveal>;
1164
+ seriesIndicatorOpacity: SharedValue<number>;
1165
+ seriesOpacity: SharedValue<number>;
1166
+ scrubActive: SharedValue<boolean>;
1167
+ selectionDotDuringScrub: boolean;
1168
+ hideLiveOnScrollBack: boolean;
1169
+ dotTracksParked: boolean;
1170
+ viewEnd: SharedValue<number | null>;
1171
+ fadeOverlaysOnScrub: boolean;
1172
+ }) {
1173
+ const liveDotOpacity = useDerivedValue(
1174
+ () =>
1175
+ reveal.dotOpacity.value *
1176
+ (selectionDotDuringScrub && scrubActive.value ? 0 : 1) *
1177
+ liveIndicatorScrollOpacity(
1178
+ hideLiveOnScrollBack && !dotTracksParked,
1179
+ viewEnd.value,
1180
+ ) *
1181
+ seriesIndicatorOpacity.value,
1182
+ );
1183
+ const valueLineOpacity = useDerivedValue(
1184
+ () =>
1185
+ reveal.lineOpacity.value *
1186
+ liveIndicatorScrollOpacity(hideLiveOnScrollBack, viewEnd.value) *
1187
+ seriesOpacity.value,
1188
+ );
1189
+ const liveBadgeOpacity = useDerivedValue(
1190
+ () =>
1191
+ reveal.badgeOpacity.value *
1192
+ liveIndicatorScrollOpacity(hideLiveOnScrollBack, viewEnd.value) *
1193
+ seriesOpacity.value,
1194
+ );
1195
+ const overlayScrubFade = useDerivedValue(() =>
1196
+ fadeOverlaysOnScrub
1197
+ ? withTiming(scrubActive.get() ? 0 : 1, {
1198
+ duration: SCRUB_OVERLAY_FADE_MS,
1199
+ })
1200
+ : 1,
1201
+ );
1202
+ const markerGroupOpacity = useDerivedValue(
1203
+ () => reveal.dotOpacity.get() * overlayScrubFade.get(),
1204
+ );
1205
+ return {
1206
+ liveDotOpacity,
1207
+ valueLineOpacity,
1208
+ liveBadgeOpacity,
1209
+ overlayScrubFade,
1210
+ markerGroupOpacity,
1211
+ };
1212
+ }
1213
+
1214
+ function useReferenceLineGrouping({
1215
+ radius,
1216
+ engine,
1217
+ padding,
1218
+ lines,
1219
+ custom,
1220
+ offAxisCustom,
1221
+ dragValues,
1222
+ }: {
1223
+ radius: number | null;
1224
+ engine: ReturnType<typeof useLiveChartEngine>;
1225
+ padding: ChartPadding;
1226
+ lines: ReferenceLine[];
1227
+ custom: boolean[];
1228
+ offAxisCustom: boolean[];
1229
+ dragValues: SharedValue<number[]>;
1230
+ }) {
1231
+ const result = useDerivedValue<ReferenceGrouping>(() => {
1232
+ if (radius == null) return EMPTY_GROUPING;
1233
+ const canvasHeight = engine.canvasHeight.get();
1234
+ const displayMin = engine.displayMin.get();
1235
+ const displayMax = engine.displayMax.get();
1236
+ const top = padding.top;
1237
+ const bottom = canvasHeight - padding.bottom;
1238
+ const yPositions: number[] = [];
1239
+ for (let index = 0; index < lines.length; index++) {
1240
+ const line = lines[index];
1241
+ if (
1242
+ referenceLineForm(line) !== "line" ||
1243
+ line.value === undefined ||
1244
+ custom[index] ||
1245
+ offAxisCustom[index]
1246
+ ) {
1247
+ yPositions.push(-1);
1248
+ continue;
1249
+ }
1250
+ const value = dragValues.get()[index] ?? line.value;
1251
+ const y = computeScrubDotY(
1252
+ value,
1253
+ displayMin,
1254
+ displayMax,
1255
+ canvasHeight,
1256
+ top,
1257
+ padding.bottom,
1258
+ );
1259
+ yPositions.push(y < 0 ? -1 : Math.min(bottom, Math.max(top, y)));
1260
+ }
1261
+ return groupReferenceLines(yPositions, radius);
1262
+ });
1263
+ const hidden = useDerivedValue<boolean[]>(() => result.get().hidden);
1264
+ return { refGroupResult: result, groupHidden: hidden };
1265
+ }
1266
+
1267
+ function useLiveChartLayoutResources({
1268
+ value,
1269
+ fontProp,
1270
+ palette,
1271
+ badgeHasFontOverride,
1272
+ badgeFontConfig,
1273
+ refGroupBadgeHasFont,
1274
+ refGroupBadgeFontConfig,
1275
+ yAxisFloat,
1276
+ timeScrollEnabled,
1277
+ lineProp,
1278
+ insets,
1279
+ yAxisCfg,
1280
+ badgeCfg,
1281
+ metricsCfg,
1282
+ badgeUsesRightGutter,
1283
+ xAxisCfg,
1284
+ formatValue,
1285
+ pulseConfig,
1286
+ dotCfg,
1287
+ volumeBandHeight,
1288
+ }: {
1289
+ value: LiveChartProps["value"];
1290
+ fontProp: LiveChartProps["font"];
1291
+ palette: LiveChartPalette;
1292
+ badgeHasFontOverride: boolean;
1293
+ badgeFontConfig: LiveChartProps["font"];
1294
+ refGroupBadgeHasFont: boolean;
1295
+ refGroupBadgeFontConfig: LiveChartProps["font"];
1296
+ yAxisFloat: boolean;
1297
+ timeScrollEnabled: boolean;
1298
+ lineProp: LiveChartProps["line"];
1299
+ insets: LiveChartProps["insets"];
1300
+ yAxisCfg: ReturnType<typeof resolveYAxis>;
1301
+ badgeCfg: ReturnType<typeof resolveBadge>;
1302
+ metricsCfg: ReturnType<typeof resolveMetrics>;
1303
+ badgeUsesRightGutter: boolean;
1304
+ xAxisCfg: ReturnType<typeof resolveXAxis>;
1305
+ formatValue: (value: number) => string;
1306
+ pulseConfig: { maxRadius: number; strokeWidth: number } | null;
1307
+ dotCfg: ReturnType<typeof resolveDot>;
1308
+ volumeBandHeight: number;
1309
+ }) {
1310
+ const skiaFont = useChartSkiaFont(
1311
+ fontProp,
1312
+ MONO_FONT_FAMILY,
1313
+ palette.labelFontSize,
1314
+ );
1315
+ const valueFont = useChartSkiaFont(
1316
+ fontProp,
1317
+ MONO_FONT_FAMILY,
1318
+ palette.valueFontSize * 2,
1319
+ );
1320
+ const badgeFontOverride = useChartSkiaFont(
1321
+ badgeFontConfig,
1322
+ MONO_FONT_FAMILY,
1323
+ palette.labelFontSize,
1324
+ );
1325
+ const badgeFont = badgeHasFontOverride ? badgeFontOverride : skiaFont;
1326
+ const refGroupBadgeFontOverride = useChartSkiaFont(
1327
+ refGroupBadgeFontConfig,
1328
+ MONO_FONT_FAMILY,
1329
+ palette.labelFontSize,
1330
+ );
1331
+ const refGroupBadgeFont = refGroupBadgeHasFont
1332
+ ? refGroupBadgeFontOverride
1333
+ : skiaFont;
1334
+ const valueLayoutSample = useInitialSharedValueSample(value);
1335
+ const [scrolledBack, setScrolledBack] = useState(false);
1336
+ const effectiveYAxisFloat =
1337
+ yAxisFloat && (!timeScrollEnabled || scrolledBack);
1338
+ const layout = resolveChartLayout({
1339
+ palette,
1340
+ lineWidthOverride: lineProp?.width,
1341
+ insetsOverride: insets,
1342
+ yAxis: yAxisCfg !== null,
1343
+ yAxisFloat: effectiveYAxisFloat,
1344
+ badge: badgeCfg !== null,
1345
+ badgeMetrics: metricsCfg.badge,
1346
+ badgeUsesRightGutter,
1347
+ badgeShowTail: badgeCfg?.tail ?? true,
1348
+ xAxis: xAxisCfg !== null,
1349
+ font: skiaFont,
1350
+ formatValue,
1351
+ currentValue: valueLayoutSample,
1352
+ pulse: pulseConfig,
1353
+ dotGlow: dotCfg.glow,
1354
+ volumeBandHeight,
1355
+ });
1356
+ return {
1357
+ skiaFont,
1358
+ valueFont,
1359
+ badgeFont,
1360
+ refGroupBadgeFont,
1361
+ effectiveYAxisFloat,
1362
+ setScrolledBack,
1363
+ ...layout,
1364
+ };
1365
+ }
1366
+
1367
+ function useInitialSharedValueSample(value: SharedValue<number>) {
1368
+ const [sample, setSample] = useState<number | undefined>(undefined);
1369
+ const captured = useSharedValue(false);
1370
+ useAnimatedReaction(
1371
+ () => (captured.get() ? null : value.get()),
1372
+ (current) => {
1373
+ if (current === null) return;
1374
+ captured.set(true);
1375
+ scheduleOnRN(setSample, current);
1376
+ },
1377
+ [captured, value],
1378
+ );
1379
+ return sample;
1380
+ }
1381
+
1382
+ function resolveLiveChartModelDefaults({
1383
+ isCandle,
1384
+ candleWidth,
1385
+ loadingCfg,
1386
+ volumeCfg,
1387
+ palette,
1388
+ lineProp,
1389
+ }: {
1390
+ isCandle: boolean;
1391
+ candleWidth: number;
1392
+ loadingCfg: ReturnType<typeof resolveLoading>;
1393
+ volumeCfg: ReturnType<typeof resolveVolume>;
1394
+ palette: LiveChartPalette;
1395
+ lineProp: LiveChartProps["line"];
1396
+ }) {
1397
+ return {
1398
+ extremaTimeOffset: isCandle ? candleWidth / 2 : 0,
1399
+ loadingLineColor: loadingCfg?.color,
1400
+ loadingStrokeWidth: loadingCfg?.strokeWidth,
1401
+ loadingAmplitude: loadingCfg?.amplitude,
1402
+ loadingSpeed: loadingCfg?.speed,
1403
+ loadingAxisLabels: loadingCfg?.axisLabels ?? true,
1404
+ volumeOpacity: volumeCfg?.opacity ?? 1,
1405
+ volumeUpColor: volumeCfg?.upColor ?? palette.candleUp,
1406
+ volumeDownColor: volumeCfg?.downColor ?? palette.candleDown,
1407
+ selectionColor: lineProp?.color ?? palette.line,
1408
+ };
1409
+ }
1410
+
1411
+ /**
1412
+ * Resolves props → configs → theme/layout → engine → per-frame derived values and
1413
+ * overlay hooks, returning a single render model. All the chart's wiring lives
1414
+ * here so the rendered pieces (`ChartStack`, `ChartScrubLayer`, `LiveChart`) stay
1415
+ * small and presentational.
1416
+ */
1417
+
1418
+ function useLiveChartController({
1419
+ // ── Data ────────────────────────────────────────────────────────────────
1420
+ data,
1421
+ value,
1422
+
1423
+ // ── Appearance ──────────────────────────────────────────────────────────
1424
+ theme = "dark",
1425
+ accentColor = DEFAULT_ACCENT_COLOR,
1426
+ gradient = true,
1427
+ areaDots,
1428
+ line: lineProp,
1429
+ font: fontProp,
1430
+ insets,
1431
+ style,
1432
+ seriesOpacity,
1433
+ canvasMode = "transparent",
1434
+
1435
+ // ── Candlestick ─────────────────────────────────────────────────────────
1436
+ mode = "line",
1437
+ candles,
1438
+ candleWidth = 60,
1439
+ liveCandle,
1440
+ candleGaps,
1441
+ lineGaps,
1442
+ volume,
1443
+
1444
+ // ── Behaviour ───────────────────────────────────────────────────────────
1445
+ timeWindow = 30,
1446
+ paused = false,
1447
+ loading = false,
1448
+ transitions,
1449
+ // `static` is a reserved word — alias it so the destructure parses.
1450
+ static: isStatic = false,
1451
+ snapKey,
1452
+ smoothing = 0.08,
1453
+ exaggerate = false,
1454
+ nonNegative = false,
1455
+ maxValue,
1456
+ yRangeScale,
1457
+ windowBuffer = 0,
1458
+ nowOverride,
1459
+ timeScroll = false,
1460
+ returnToLive,
1461
+ zoom = false,
1462
+ accessibilityLabel,
1463
+ accessibilityRole = "image",
1464
+ emptyText = "No data",
1465
+ formatValue = defaultFormatValue,
1466
+ formatTime = defaultFormatTime,
1467
+
1468
+ // ── Overlays ────────────────────────────────────────────────────────────
1469
+ yAxis = true,
1470
+ xAxis = true,
1471
+ axisAutoHide = false,
1472
+ topLabel,
1473
+ bottomLabel,
1474
+ badge = true,
1475
+ momentum = true,
1476
+ pulse = true,
1477
+ dot,
1478
+ valueLine = true,
1479
+ showValue = false,
1480
+ valueMomentumColor = false,
1481
+ referenceLines,
1482
+ segments,
1483
+ threshold,
1484
+ gridStyle,
1485
+ palette: paletteOverride,
1486
+ metrics,
1487
+ scrub = true,
1488
+ scrubAction,
1489
+ selectionDot,
1490
+ tradeStream,
1491
+ degen,
1492
+ markers,
1493
+ onMarkerPress,
1494
+ markerHitRadius = 16,
1495
+ markerCluster,
1496
+ renderMarker,
1497
+ renderTooltip,
1498
+ renderOverlay,
1499
+ renderThresholdBadge,
1500
+ renderReferenceLine,
1501
+ renderOffAxisReferenceLine,
1502
+ referenceLineGrouping,
1503
+ leftEdgeFade = true,
1504
+
1505
+ // ── Callbacks ───────────────────────────────────────────────────────────
1506
+ onScrub,
1507
+ onScrubAction,
1508
+ onReferenceLinePress,
1509
+ onGestureStart,
1510
+ onGestureEnd,
1511
+ onVisibleRangeChange,
1512
+ onReachStart,
1513
+ onDegenShake,
1514
+ }: LiveChartProps) {
1515
+ const fullSeriesOpacity = useSharedValue(1);
1516
+ const resolvedSeriesOpacity = seriesOpacity ?? fullSeriesOpacity;
1517
+ const emptyMarkers = useSharedValue<Marker[]>([]);
1518
+ const markersSV = markers ?? emptyMarkers;
1519
+ // Stand-in threshold value so `useThreshold` can be called unconditionally
1520
+ // (hooks can't be); the geometry is ignored when no threshold is configured.
1521
+ const emptyThresholdValue = useSharedValue(0);
1522
+ const {
1523
+ isCandle,
1524
+ yAxisCfg,
1525
+ xAxisCfg,
1526
+ topLabelCfg,
1527
+ bottomLabelCfg,
1528
+ badgeCfg,
1529
+ scrubCfg,
1530
+ scrubActionCfg,
1531
+ volumeCfg,
1532
+ chartGapsCfg,
1533
+ candleGapsCfg,
1534
+ lineGapsCfg,
1535
+ volumeBandHeight,
1536
+ gradientCfg,
1537
+ areaDotsCfg,
1538
+ thresholdCfg,
1539
+ thresholdSeriesSV,
1540
+ thresholdIsSeries,
1541
+ valueLineCfg,
1542
+ pulseCfg,
1543
+ dotCfg,
1544
+ dotTracksParked,
1545
+ selectionDotCfg,
1546
+ dotOuterRadius,
1547
+ gridStyleCfg,
1548
+ degenCfg,
1549
+ tradeStreamResolved,
1550
+ metricsCfg,
1551
+ } = resolveLiveChartFeatureConfig({
1552
+ mode,
1553
+ yAxis,
1554
+ xAxis,
1555
+ topLabel,
1556
+ bottomLabel,
1557
+ badge,
1558
+ scrub,
1559
+ scrubAction,
1560
+ volume,
1561
+ candleGaps,
1562
+ lineGaps,
1563
+ gradient,
1564
+ areaDots,
1565
+ threshold,
1566
+ valueLine,
1567
+ isStatic,
1568
+ pulse,
1569
+ dot,
1570
+ selectionDot,
1571
+ gridStyle,
1572
+ degen,
1573
+ tradeStream,
1574
+ metrics,
1575
+ });
1576
+
1577
+ const {
1578
+ chartGapBands,
1579
+ allRefLines,
1580
+ refValues,
1581
+ refLineCustom,
1582
+ refLineOffAxisCustom,
1583
+ refLineKeys,
1584
+ draggableRefIdx,
1585
+ thresholdInRange,
1586
+ thresholdRangeValueSV,
1587
+ refGroupingCfg,
1588
+ refGroupingRadius,
1589
+ refGroupBadge,
1590
+ refGroupFormat,
1591
+ badgeUsesRightGutter,
1592
+ } = resolveLiveChartReferenceConfig({
1593
+ chartGapsCfg,
1594
+ referenceLines,
1595
+ renderReferenceLine,
1596
+ renderOffAxisReferenceLine,
1597
+ thresholdCfg,
1598
+ referenceLineGrouping,
1599
+ badgeCfg,
1600
+ });
1601
+
1602
+ const { dragValues, dragActive, refLineCustomTagWidths, liveRefValues } =
1603
+ useLiveReferenceState(allRefLines, draggableRefIdx, thresholdRangeValueSV);
1604
+
1605
+ const {
1606
+ palette,
1607
+ resolvedSegments,
1608
+ hasRecolorSegments,
1609
+ leftEdgeFadeCfg,
1610
+ badgeHasFontOverride,
1611
+ badgeFontConfig,
1612
+ refGroupBadgeHasFont,
1613
+ refGroupBadgeFontConfig,
1614
+ pulseConfig,
1615
+ timeScrollEnabled,
1616
+ returnToLiveMs,
1617
+ timeScrollOverscroll,
1618
+ timeScrollFling,
1619
+ zoomCfg,
1620
+ zoomEnabled,
1621
+ yAxisFloat,
1622
+ } = resolveLiveChartPresentationConfig({
1623
+ accentColor,
1624
+ theme,
1625
+ paletteOverride,
1626
+ segments,
1627
+ leftEdgeFade,
1628
+ fontProp,
1629
+ badgeCfg,
1630
+ refGroupBadge,
1631
+ pulseCfg,
1632
+ timeScroll,
1633
+ isStatic,
1634
+ returnToLive,
1635
+ zoom,
1636
+ yAxisCfg,
1637
+ });
708
1638
 
709
- const yAxisFloat = yAxisCfg?.float ?? false;
710
- // With timeScroll, the floating full-width plot engages only while scrolled
711
- // back; at the live edge the chart keeps a normal right gutter so the
712
- // line/candles don't sit under the floating y-axis labels + badge. Without
713
- // timeScroll, float behaves as before (always full-width).
714
- const effectiveYAxisFloat =
715
- yAxisFloat && (!timeScrollEnabled || scrolledBack);
716
- const { strokeWidth, padding: effectivePadding } = resolveChartLayout({
1639
+ const {
1640
+ skiaFont,
1641
+ valueFont,
1642
+ badgeFont,
1643
+ refGroupBadgeFont,
1644
+ effectiveYAxisFloat,
1645
+ setScrolledBack,
1646
+ strokeWidth,
1647
+ padding: effectivePadding,
1648
+ } = useLiveChartLayoutResources({
1649
+ value,
1650
+ fontProp,
717
1651
  palette,
718
- lineWidthOverride: lineProp?.width,
719
- insetsOverride: insets,
720
- yAxis: yAxisCfg !== null,
721
- yAxisFloat: effectiveYAxisFloat,
722
- badge: badgeCfg !== null,
723
- badgeMetrics: metricsCfg.badge,
1652
+ badgeHasFontOverride,
1653
+ badgeFontConfig,
1654
+ refGroupBadgeHasFont,
1655
+ refGroupBadgeFontConfig,
1656
+ yAxisFloat,
1657
+ timeScrollEnabled,
1658
+ lineProp,
1659
+ insets,
1660
+ yAxisCfg,
1661
+ badgeCfg,
1662
+ metricsCfg,
724
1663
  badgeUsesRightGutter,
725
- badgeShowTail: badgeCfg?.tail ?? true,
726
- xAxis: xAxisCfg !== null,
727
- font: skiaFont,
1664
+ xAxisCfg,
728
1665
  formatValue,
729
- currentValue: valueLayoutSample,
730
- pulse: pulseConfig,
731
- dotGlow: dotCfg.glow,
1666
+ pulseConfig,
1667
+ dotCfg,
732
1668
  volumeBandHeight,
733
1669
  });
734
1670
 
735
1671
  // ── Reveal state ────────────────────────────────────────────
736
- // ≥2 line points or ≥2 committed candles; morphT=1 only when !loading && hasData.
1672
+ // ≥1 line point or ≥1 committed candle; morphT=1 only when !loading && hasData.
737
1673
  const { hasData } = useLiveChartHasData({
738
1674
  isCandle,
739
1675
  data,
@@ -770,8 +1706,22 @@ function useLiveChartController({
770
1706
  // ── Engine ─────────────────────────────────────────────────────────────
771
1707
  // Line mode: tick + paths use `lineEngineData` (stash when reversing). Candle mode:
772
1708
  // parent `data` stays tick/line-morph input; OHLC uses candlesEngine + liveEngine.
1709
+ const engineModeInputs = resolveLiveEngineModeInputs({
1710
+ isCandle,
1711
+ data,
1712
+ lineEngineData,
1713
+ candles,
1714
+ candlesEngine,
1715
+ liveCandle,
1716
+ liveEngine,
1717
+ candleGapsCfg,
1718
+ thresholdInRange,
1719
+ thresholdIsSeries,
1720
+ thresholdSeriesSV,
1721
+ thresholdCfg,
1722
+ });
773
1723
  const engine = useLiveChartEngine({
774
- data: isCandle ? data : lineEngineData,
1724
+ ...engineModeInputs,
775
1725
  value,
776
1726
  timeWindow,
777
1727
  paused,
@@ -785,28 +1735,12 @@ function useLiveChartController({
785
1735
  exaggerate,
786
1736
  referenceValues: refValues,
787
1737
  liveReferenceValues: liveRefValues,
788
- // Series threshold with `includeInRange`: the tick folds its window min/max
789
- // into the Y-range fit (the constant form rides `liveReferenceValues`).
790
- thresholdRangePoints:
791
- thresholdInRange && thresholdIsSeries
792
- ? (thresholdSeriesSV ?? (thresholdCfg?.value as LiveChartPoint[]))
793
- : undefined,
794
- thresholdRangeExtendToNow: thresholdCfg?.extendToNow ?? true,
795
1738
  nonNegative,
796
1739
  maxValue,
797
1740
  yRangeScale,
798
1741
  windowBuffer,
799
1742
  nowOverride,
800
1743
  mode,
801
- candles: isCandle ? candlesEngine : candles,
802
- liveCandle: isCandle ? liveEngine : liveCandle,
803
- candleGaps: candleGapsCfg?.gaps,
804
- candleGapBridgeNoTrades:
805
- Boolean(candleGapsCfg?.styles["no-trades"].bridge),
806
- candleGapBridgeUnavailable:
807
- Boolean(candleGapsCfg?.styles.unavailable.bridge),
808
- candleGapBridgeUnknown:
809
- Boolean(candleGapsCfg?.styles.unknown.bridge),
810
1744
  });
811
1745
 
812
1746
  // Mirror the UI-thread scroll state to React so the floating y-axis can keep
@@ -836,174 +1770,49 @@ function useLiveChartController({
836
1770
  // ── Per-frame derived values ───────────────────────────────────────────
837
1771
  const { layoutWidth, layoutHeight, onLayout } = useCanvasLayout(engine);
838
1772
 
839
- // Reference-line grouping: cluster Form-A lines by their per-frame value-Y so a
840
- // stack of nearby orders collapses into one count handle. `groupHidden` suppresses
841
- // the clustered lines' individual tags; the count pills read `refGroupResult`.
842
- // Identity-stable (no work) when grouping is off.
843
- const refGroupResult = useDerivedValue<ReferenceGrouping>(() => {
844
- if (refGroupingRadius == null) return EMPTY_GROUPING;
845
- const ch = engine.canvasHeight.get();
846
- const dMin = engine.displayMin.get();
847
- const dMax = engine.displayMax.get();
848
- const top = effectivePadding.top;
849
- const bottom = ch - effectivePadding.bottom;
850
- const ys: number[] = [];
851
- for (let i = 0; i < allRefLines.length; i++) {
852
- const l = allRefLines[i];
853
- // Skip bands and lines a custom `renderReferenceLine` owns — a custom tag
854
- // draws itself and isn't suppressed by grouping, so folding it into a
855
- // built-in count pill would double-count it (counted *and* still shown).
856
- if (
857
- referenceLineForm(l) !== "line" ||
858
- l.value === undefined ||
859
- refLineCustom[i] ||
860
- refLineOffAxisCustom[i]
861
- ) {
862
- ys.push(-1);
863
- continue;
864
- }
865
- const v = dragValues.get()[i] ?? l.value;
866
- const y = computeScrubDotY(
867
- v,
868
- dMin,
869
- dMax,
870
- ch,
871
- top,
872
- effectivePadding.bottom,
873
- );
874
- ys.push(y < 0 ? -1 : Math.min(bottom, Math.max(top, y)));
875
- }
876
- return groupReferenceLines(ys, refGroupingRadius);
1773
+ const { refGroupResult, groupHidden } = useReferenceLineGrouping({
1774
+ radius: refGroupingRadius,
1775
+ engine,
1776
+ padding: effectivePadding,
1777
+ lines: allRefLines,
1778
+ custom: refLineCustom,
1779
+ offAxisCustom: refLineOffAxisCustom,
1780
+ dragValues,
877
1781
  });
878
- const groupHidden = useDerivedValue<boolean[]>(
879
- () => refGroupResult.get().hidden,
880
- );
881
1782
 
882
1783
  // Threshold split geometry. Two forms, picked at render by `Array.isArray` (no
883
1784
  // SharedValue read): a constant `SharedValue<number>` benchmark drives the
884
1785
  // vertical hard-stop gradient (`thresholdGeom`); a time-varying `LiveChartPoint[]`
885
1786
  // series drives the per-fragment split shader (`thresholdSeriesGeom`). Both hooks
886
1787
  // run unconditionally — the unused one short-circuits cheaply on the UI thread.
887
- const thresholdValue = thresholdCfg?.value ?? emptyThresholdValue;
888
- // An empty series (e.g. threshold history not fetched yet) must not paint: no
889
- // band, no shader-forced stroke color — unlike the constant form, "no points
890
- // yet" is a reachable state and should look like "no threshold". For the live
891
- // `series` SharedValue form the emptiness lives on the UI thread, so mirror it
892
- // to React state (fires only on empty↔non-empty transitions).
893
- const [svSeriesNonEmpty, setSvSeriesNonEmpty] = useState(false);
894
- useAnimatedReaction(
895
- () => (thresholdSeriesSV ? thresholdSeriesSV.get().length > 0 : false),
896
- /* istanbul ignore next -- Reanimated reaction; state mirrored on the JS thread, not exercised under Jest */
897
- (hasPts, prev) => {
898
- if (hasPts !== prev) scheduleOnRN(setSvSeriesNonEmpty, hasPts);
899
- },
900
- );
901
- const thresholdSeriesHasPoints = thresholdSeriesSV
902
- ? svSeriesNonEmpty
903
- : Array.isArray(thresholdCfg?.value) && thresholdCfg.value.length > 0;
904
- const thresholdGeom = useThreshold(engine, effectivePadding, thresholdValue);
905
- const thresholdSeriesGeom = useThresholdSeries(
1788
+ const {
1789
+ thresholdGeom,
1790
+ thresholdStrokeColors,
1791
+ thresholdFillColors,
1792
+ thresholdSeriesHasPoints,
1793
+ thresholdStrokeUniforms,
1794
+ thresholdFillUniforms,
1795
+ thresholdMarkerLineY,
1796
+ thresholdMarkerVisible,
1797
+ thresholdBadgeVisible,
1798
+ thresholdMarkerValue,
1799
+ thresholdCustomBadge,
1800
+ thresholdSeriesPts,
1801
+ thresholdBadgeLeftAnchorX,
1802
+ thresholdFillLineY,
1803
+ thresholdFillSamples,
1804
+ } = useLiveChartThresholdModel({
906
1805
  engine,
907
- effectivePadding,
908
- thresholdValue,
1806
+ padding: effectivePadding,
1807
+ thresholdCfg,
909
1808
  thresholdSeriesSV,
910
- thresholdCfg?.extendToNow ?? true,
911
- thresholdCfg?.line?.labelAnchor ?? "last",
912
- );
913
- const thresholdStopColors = thresholdCfg
914
- ? thresholdStops(thresholdCfg, palette)
915
- : null;
916
-
917
- // Split colors as vec4s for the series shader, memoized on the *resolved*
918
- // color strings + opacity (so a threshold added after mount with default
919
- // colors still computes, and the per-frame uniforms worklet isn't rebuilt
920
- // every render). `strokeRest` is the plain line color painted right of the
921
- // `extendToNow: false` cutoff.
922
- const thresholdSplitAbove = thresholdCfg
923
- ? (thresholdCfg.aboveColor ?? palette.candleUp)
924
- : null;
925
- const thresholdSplitBelow = thresholdCfg
926
- ? (thresholdCfg.belowColor ?? palette.candleDown)
927
- : null;
928
- const thresholdFillOpacity =
929
- thresholdCfg?.fillOpacity ?? THRESHOLD_FILL_OPACITY_DEFAULT;
930
- const thresholdLineColorStr = lineProp?.color ?? palette.line;
931
- const thresholdVecs = useMemo(
932
- () =>
933
- thresholdSplitAbove !== null && thresholdSplitBelow !== null
934
- ? {
935
- ...thresholdSplitColorVecs(
936
- thresholdSplitAbove,
937
- thresholdSplitBelow,
938
- thresholdFillOpacity,
939
- ),
940
- strokeRest: (() => {
941
- const [r, g, b, a] = parseColorRgba(thresholdLineColorStr);
942
- return [r / 255, g / 255, b / 255, a];
943
- })(),
944
- }
945
- : null,
946
- [
947
- thresholdSplitAbove,
948
- thresholdSplitBelow,
949
- thresholdFillOpacity,
950
- thresholdLineColorStr,
951
- ],
952
- );
953
- const thresholdStrokeUniforms = useThresholdSplitUniforms(
954
- thresholdSeriesGeom.samples,
955
- engine,
956
- effectivePadding,
957
- thresholdVecs?.strokeAbove ?? THRESHOLD_FALLBACK_COLOR,
958
- thresholdVecs?.strokeBelow ?? THRESHOLD_FALLBACK_COLOR,
959
- thresholdVecs?.strokeRest ?? THRESHOLD_FALLBACK_COLOR,
960
- thresholdSeriesGeom.clipRightX,
961
- );
962
- const thresholdFillUniforms = useThresholdSplitUniforms(
963
- thresholdSeriesGeom.samples,
964
- engine,
965
- effectivePadding,
966
- thresholdVecs?.fillAbove ?? THRESHOLD_FALLBACK_COLOR,
967
- thresholdVecs?.fillBelow ?? THRESHOLD_FALLBACK_COLOR,
968
- TRANSPARENT_VEC4,
969
- thresholdSeriesGeom.clipRightX,
970
- );
971
-
972
- // Marker line + badge sources: a series badge independently selects its first
973
- // or last visible endpoint; the constant case stays at the single benchmark Y.
974
- // The badge gets its own visibility because its selected endpoint can be
975
- // off-plot while older polyline segments are still visible.
976
- const thresholdMarkerLineY = thresholdIsSeries
977
- ? thresholdSeriesGeom.badgeLineY
978
- : thresholdGeom.lineY;
979
- const thresholdMarkerVisible = thresholdIsSeries
980
- ? thresholdSeriesGeom.visible
981
- : thresholdGeom.visible;
982
- const thresholdBadgeVisible = thresholdIsSeries
983
- ? thresholdSeriesGeom.badgeVisible
984
- : thresholdGeom.visible;
985
- const thresholdMarkerValue =
986
- thresholdCfg && !thresholdIsSeries && !Array.isArray(thresholdCfg.value)
987
- ? (thresholdCfg.value ?? thresholdSeriesGeom.badgeValue)
988
- : thresholdSeriesGeom.badgeValue;
989
- const hasCustomThresholdBadge =
990
- thresholdCfg?.line != null && renderThresholdBadge != null;
991
- const thresholdMarkerValueStr = useDerivedValue(() =>
992
- hasCustomThresholdBadge ? formatValue(thresholdMarkerValue.get()) : "",
993
- );
994
- const thresholdCustomBadge =
995
- thresholdCfg?.line && renderThresholdBadge
996
- ? renderThresholdBadge({
997
- line: thresholdCfg.line,
998
- value: thresholdMarkerValue,
999
- valueStr: thresholdMarkerValueStr,
1000
- y: thresholdMarkerLineY,
1001
- visible: thresholdBadgeVisible,
1002
- })
1003
- : null;
1004
- const thresholdSeriesPts = thresholdIsSeries
1005
- ? thresholdSeriesGeom.screenPts
1006
- : undefined;
1809
+ thresholdIsSeries,
1810
+ emptyThresholdValue,
1811
+ palette,
1812
+ lineProp,
1813
+ renderThresholdBadge,
1814
+ formatValue,
1815
+ });
1007
1816
 
1008
1817
  // Straight polyline instead of the monotone cubic when line.curve === "linear".
1009
1818
  // Shared by the path builders and the marker anchoring so glyphs sit on the
@@ -1016,7 +1825,7 @@ function useLiveChartController({
1016
1825
  reveal.morphT,
1017
1826
  // Constant threshold band closes at a single Y (the series closes along the
1018
1827
  // polyline passed as the last arg below).
1019
- thresholdCfg?.fill && !thresholdIsSeries ? thresholdGeom.lineY : undefined,
1828
+ thresholdFillLineY,
1020
1829
  lineIsLinear,
1021
1830
  // The plotted line independently selects the visible edge while historical;
1022
1831
  // badge/live-indicator options affect overlays only.
@@ -1027,9 +1836,7 @@ function useLiveChartController({
1027
1836
  // (so the band matches the shader and doesn't bleed at step risers). An empty
1028
1837
  // series builds no band (its samples are all the far-below fallback, which
1029
1838
  // would tint the entire area under the line).
1030
- thresholdCfg?.fill && thresholdSeriesHasPoints
1031
- ? thresholdSeriesGeom.samples
1032
- : undefined,
1839
+ thresholdFillSamples,
1033
1840
  lineProp?.simplify,
1034
1841
  lineGapsCfg?.gaps,
1035
1842
  );
@@ -1037,17 +1844,11 @@ function useLiveChartController({
1037
1844
  // Area-dots fill shader color as a vec4 (channels 0..1), with the config
1038
1845
  // `opacity` folded into the alpha. Defaults to a faint tint of the line/accent
1039
1846
  // color (theme-aware) so out-of-the-box dots read as a subtle field.
1040
- const areaDotRgb = parseColorRgb(lineProp?.color ?? palette.line);
1041
- const [adR, adG, adB, adA] = parseColorRgba(
1042
- areaDotsCfg?.color ??
1043
- `rgba(${areaDotRgb[0]}, ${areaDotRgb[1]}, ${areaDotRgb[2]}, 0.22)`,
1847
+ const areaDotColorVec = resolveAreaDotColorVec(
1848
+ areaDotsCfg,
1849
+ lineProp,
1850
+ palette,
1044
1851
  );
1045
- const areaDotColorVec = [
1046
- adR / 255,
1047
- adG / 255,
1048
- adB / 255,
1049
- adA * (areaDotsCfg?.opacity ?? 1),
1050
- ];
1051
1852
 
1052
1853
  const { dotX, dotY } = useLiveDot(
1053
1854
  engine,
@@ -1078,33 +1879,31 @@ function useLiveChartController({
1078
1879
  isCandle,
1079
1880
  );
1080
1881
 
1081
- // ── Overlay hooks ─────────────────────────────────────────────────────
1082
- // Scrub/crosshair must see the same stash-backed candles as the engine.
1083
- const crosshairChartOpts = isCandle
1084
- ? {
1085
- mode,
1086
- candles: candlesEngine,
1087
- liveCandle: liveEngine,
1088
- candleWidthSecs: candleWidth,
1089
- gaps: candleGapsCfg?.gaps,
1090
- bridgeNoTrades:
1091
- Boolean(candleGapsCfg?.styles["no-trades"].bridge),
1092
- bridgeUnavailable:
1093
- Boolean(candleGapsCfg?.styles.unavailable.bridge),
1094
- bridgeUnknown: Boolean(candleGapsCfg?.styles.unknown.bridge),
1095
- }
1096
- : lineGapsCfg
1097
- ? {
1098
- mode,
1099
- gaps: lineGapsCfg.gaps,
1100
- bridgeNoTrades: Boolean(lineGapsCfg.styles["no-trades"].bridge),
1101
- bridgeUnavailable: Boolean(lineGapsCfg.styles.unavailable.bridge),
1102
- bridgeUnknown: Boolean(lineGapsCfg.styles.unknown.bridge),
1103
- }
1104
- : undefined;
1105
-
1106
- const markersActive = markers != null;
1107
- const markerClusterCfg = resolveMarkerCluster(markerCluster);
1882
+ const {
1883
+ crosshairChartOpts,
1884
+ markersActive,
1885
+ markerClusterCfg,
1886
+ refPressActive,
1887
+ refDragEnabled,
1888
+ scrollGestureMode,
1889
+ scrubHoldMs,
1890
+ } = resolveLiveChartInteractionConfig({
1891
+ isCandle,
1892
+ mode,
1893
+ candlesEngine,
1894
+ liveEngine,
1895
+ candleWidth,
1896
+ candleGapsCfg,
1897
+ lineGapsCfg,
1898
+ markers,
1899
+ markerCluster,
1900
+ onReferenceLinePress,
1901
+ allRefLines,
1902
+ isStatic,
1903
+ timeScroll,
1904
+ timeScrollEnabled,
1905
+ scrubCfg,
1906
+ });
1108
1907
  // `projected` is used internally by the hit-test gesture; the overlay
1109
1908
  // self-projects, so we only need the gesture + hit-test here. Built BEFORE
1110
1909
  // `useCrosshair` so the scrub-action tap can defer to a marker under the finger.
@@ -1124,7 +1923,6 @@ function useLiveChartController({
1124
1923
 
1125
1924
  // Pressable reference-line badges (working orders / alerts). Built before
1126
1925
  // `useCrosshair` so the scrub-action tap can defer to a badge under the finger.
1127
- const refPressActive = onReferenceLinePress != null && allRefLines.length > 0;
1128
1926
  const { tapGesture: refLineTapGesture, hitTest: refLineHitTest } =
1129
1927
  useReferenceLinePress(
1130
1928
  engine,
@@ -1143,8 +1941,6 @@ function useLiveChartController({
1143
1941
  // unconditionally for stable hook order (and before `useCrosshair` so the scrub
1144
1942
  // can defer to a line under the finger); the gesture self-disables when no line
1145
1943
  // opts in, and it's only composed into the root when `refDragEnabled`.
1146
- const refDragEnabled =
1147
- !isStatic && allRefLines.some((l) => l.draggable === true);
1148
1944
  const { gesture: refDragGesture, hitTest: refDragHitTest } = useReferenceDrag(
1149
1945
  engine,
1150
1946
  effectivePadding,
@@ -1164,24 +1960,6 @@ function useLiveChartController({
1164
1960
  return markerHitTest(x, y) || refLineHitTest(x, y) || refDragHitTest(x, y);
1165
1961
  };
1166
1962
 
1167
- // Time-scroll activation. `holdToScrub`: a quick one-finger drag scrolls while
1168
- // scrub engages on press-and-hold — so the scrub gesture needs a long-press
1169
- // delay (unless the caller set its own `panGestureDelay`). `timeScrollEnabled`
1170
- // is computed earlier (it gates the float gutter).
1171
- const scrollGestureMode =
1172
- typeof timeScroll === "object"
1173
- ? (timeScroll.gesture ?? "holdToScrub")
1174
- : "holdToScrub";
1175
- // In holdToScrub the scrub MUST require a hold so a quick drag scrolls instead.
1176
- // Precedence: explicit timeScroll.scrubHoldMs, then scrub.panGestureDelay, then
1177
- // the default. `||` (not `??`) skips the resolved panGestureDelay's 0 default.
1178
- const timeScrollHoldMs =
1179
- typeof timeScroll === "object" ? timeScroll.scrubHoldMs : undefined;
1180
- const scrubHoldMs =
1181
- timeScrollEnabled && scrollGestureMode === "holdToScrub"
1182
- ? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
1183
- : (scrubCfg?.panGestureDelay ?? 0);
1184
-
1185
1963
  // Cross-gesture arbitration for the one-finger touch. `Gesture.Race` below is
1186
1964
  // NOT arbitration — RNGH's Race adds no relation between its children, so both
1187
1965
  // pans recognize independently and each can activate while the other already
@@ -1190,6 +1968,18 @@ function useLiveChartController({
1190
1968
  // (written by the crosshair, read by the scroll pan) is the mirror image.
1191
1969
  const scrollActive = useSharedValue(false);
1192
1970
 
1971
+ const crosshairSettings = resolveCrosshairControllerSettings({
1972
+ scrubCfg,
1973
+ scrubActionCfg,
1974
+ markersActive,
1975
+ refPressActive,
1976
+ refDragEnabled,
1977
+ deferTapHit,
1978
+ timeScrollEnabled,
1979
+ scrollGestureMode,
1980
+ effectivePadding,
1981
+ });
1982
+
1193
1983
  const crosshair = useCrosshair(
1194
1984
  engine,
1195
1985
  effectivePadding,
@@ -1200,7 +1990,7 @@ function useLiveChartController({
1200
1990
  // Scrub / scrub-action stay live even on static charts: the gesture is
1201
1991
  // event-driven (no per-frame loop), so a settled chart costs nothing at rest
1202
1992
  // yet becomes scrubbable on touch. `static` only kills the continuous loop.
1203
- scrubCfg !== null || scrubActionCfg !== null,
1993
+ crosshairSettings.enabled,
1204
1994
  onScrub,
1205
1995
  crosshairChartOpts,
1206
1996
  scrubHoldMs,
@@ -1209,20 +1999,15 @@ function useLiveChartController({
1209
1999
  scrubActionCfg,
1210
2000
  onScrubAction,
1211
2001
  metricsCfg.badge,
1212
- markersActive || refPressActive || refDragEnabled ? deferTapHit : undefined,
1213
- scrubCfg?.tooltipPlacement ?? "side",
1214
- scrubCfg?.tooltipShowValue ?? true,
1215
- scrubCfg?.tooltipShowTime ?? true,
1216
- scrubCfg?.tooltipMargin ?? 8,
1217
- // Axis-drag time-scroll: keep the bottom "time ruler" band scroll-only so a
1218
- // drag there never trips the scrub crosshair.
1219
- timeScrollEnabled && scrollGestureMode === "axisDrag"
1220
- ? Math.max(effectivePadding.bottom, AXIS_GRAB_MIN_PX)
1221
- : 0,
2002
+ crosshairSettings.deferTapHit,
2003
+ crosshairSettings.tooltipPlacement,
2004
+ crosshairSettings.tooltipShowValue,
2005
+ crosshairSettings.tooltipShowTime,
2006
+ crosshairSettings.tooltipMargin,
2007
+ crosshairSettings.scrubBottomExclude,
1222
2008
  scrollActive,
1223
- scrubCfg?.clampToPlot ?? false,
1224
- // Candle mode: snap the crosshair to candle centers (tick-to-tick).
1225
- scrubCfg?.snapToCandles ?? false,
2009
+ crosshairSettings.clampToPlot,
2010
+ crosshairSettings.snapToCandles,
1226
2011
  );
1227
2012
 
1228
2013
  // Capture only the shared value in the worklets below. Referencing
@@ -1276,77 +2061,12 @@ function useLiveChartController({
1276
2061
  },
1277
2062
  });
1278
2063
 
1279
- // Axis auto-hide: fade both axes out at rest and back in while the user
1280
- // interacts. Any movement — scrub / time-scroll touch, or a viewEnd /
1281
- // viewWindow change (fling momentum, pinch-zoom) — shows the axes; once the
1282
- // touch lifts and the view settles, they fade back out after `hideAfterMs`.
1283
- // Only the axis groups' opacity animates; the axis worklets keep running
1284
- // underneath so a fade-in shows current labels.
1285
- const axisAutoHideCfg =
1286
- axisAutoHide === true ? {} : axisAutoHide === false ? null : axisAutoHide;
1287
- const axisIdleOpacity = axisAutoHideCfg?.idleOpacity ?? 0;
1288
- const axisFadeInMs = axisAutoHideCfg?.fadeInMs ?? 60;
1289
- const axisFadeOutMs = axisAutoHideCfg?.fadeOutMs ?? 250;
1290
- const axisHideAfterMs = axisAutoHideCfg?.hideAfterMs ?? 3000;
1291
- const axisAutoHideOpacity = useSharedValue(
1292
- axisAutoHideCfg ? axisIdleOpacity : 1,
1293
- );
1294
- const axisAutoHideEnabled = axisAutoHideCfg !== null;
1295
- const lastAxisAutoHide = useRef({
1296
- enabled: axisAutoHideEnabled,
1297
- idleOpacity: axisIdleOpacity,
2064
+ const axisAutoHideOpacity = useAxisAutoHide({
2065
+ config: axisAutoHide,
2066
+ scrollActive,
2067
+ scrubActive: crosshairScrubActive,
2068
+ engine,
1298
2069
  });
1299
- // `useSharedValue` only reads its initial value on mount. Keep prop changes
1300
- // in sync as well: turning auto-hide off must immediately restore the axes,
1301
- // and turning it on starts from the configured idle opacity.
1302
- useEffect(() => {
1303
- if (
1304
- lastAxisAutoHide.current.enabled === axisAutoHideEnabled &&
1305
- lastAxisAutoHide.current.idleOpacity === axisIdleOpacity
1306
- ) {
1307
- return;
1308
- }
1309
- lastAxisAutoHide.current = {
1310
- enabled: axisAutoHideEnabled,
1311
- idleOpacity: axisIdleOpacity,
1312
- };
1313
- cancelAnimation(axisAutoHideOpacity);
1314
- axisAutoHideOpacity.value = axisAutoHideEnabled ? axisIdleOpacity : 1;
1315
- }, [axisAutoHideEnabled, axisAutoHideOpacity, axisIdleOpacity]);
1316
- useAnimatedReaction(
1317
- () => ({
1318
- gesture: scrollActive.value || crosshairScrubActive.value,
1319
- viewEnd: engine.viewEnd.value,
1320
- viewWindow: engine.viewWindow.value,
1321
- }),
1322
- (curr, prev) => {
1323
- if (!axisAutoHideEnabled || prev === null) return;
1324
- const moved =
1325
- curr.gesture !== prev.gesture ||
1326
- curr.viewEnd !== prev.viewEnd ||
1327
- curr.viewWindow !== prev.viewWindow;
1328
- if (!moved) return;
1329
- cancelAnimation(axisAutoHideOpacity);
1330
- axisAutoHideOpacity.value = curr.gesture
1331
- ? withTiming(1, { duration: axisFadeInMs })
1332
- : // Movement without a held touch (gesture end, fling, pinch): show,
1333
- // then fade back out once the chart goes untouched for a while.
1334
- withSequence(
1335
- withTiming(1, { duration: axisFadeInMs }),
1336
- withDelay(
1337
- axisHideAfterMs,
1338
- withTiming(axisIdleOpacity, { duration: axisFadeOutMs }),
1339
- ),
1340
- );
1341
- },
1342
- [
1343
- axisAutoHideEnabled,
1344
- axisIdleOpacity,
1345
- axisFadeInMs,
1346
- axisFadeOutMs,
1347
- axisHideAfterMs,
1348
- ],
1349
- );
1350
2070
 
1351
2071
  // Paging callbacks: report the visible range / proximity to the oldest data so
1352
2072
  // a host can lazily load history. Inert unless a callback is supplied.
@@ -1361,59 +2081,21 @@ function useLiveChartController({
1361
2081
  // ahead of the pan via Exclusive, so a tap is tried first and only becomes a
1362
2082
  // drag (live-scrub, or lock-adjust once placed) if the finger moves. `Exclusive`
1363
2083
  // (not `Race`) prevents a jittery tap from being swallowed by the pan.
1364
- const baseGesture =
1365
- scrubActionCfg !== null && crosshair.tapGesture
1366
- ? Gesture.Exclusive(crosshair.tapGesture, crosshair.gesture)
1367
- : crosshair.gesture;
1368
-
1369
- // Overlay taps that hit-test discrete targets (marker dots, reference-line
1370
- // badges). They must all see each tap, so they're combined with `Simultaneous`
1371
- // (not `Race`, which cancels the loser and would drop one). The scrub-action
1372
- // action tap defers to them via `deferTapHit`, so a tap on an overlay is routed
1373
- // there instead of placing a reticle.
1374
- const overlayTaps = [
1375
- markersActive ? markerTapGesture : null,
1376
- refPressActive ? refLineTapGesture : null,
1377
- ].filter((g): g is NonNullable<typeof g> => g !== null);
1378
-
1379
- let rootGesture = baseGesture;
1380
- if (overlayTaps.length > 0) {
1381
- const tapGroup =
1382
- overlayTaps.length === 1
1383
- ? overlayTaps[0]
1384
- : Gesture.Simultaneous(overlayTaps[0], overlayTaps[1]);
1385
- // Always `Simultaneous`, never `Race`: on iOS the scrub pan uses
1386
- // `minDistance(0)`, so in a `Race` it activates on touch-down and cancels the
1387
- // overlay tap before it can recognize — `onMarkerPress`/`onReferenceLinePress`
1388
- // would never fire. Sharing the gesture space lets the tap recognize; the pan
1389
- // defers to a marker/badge under the finger via `deferTapHit` (see
1390
- // `useCrosshair`'s scrub `onStart`) so no stray crosshair is dropped there.
1391
- rootGesture = Gesture.Simultaneous(baseGesture, tapGroup);
1392
- }
1393
-
1394
- // Compose the pan-scroll gesture. holdToScrub races the scrub/tap gestures (a
1395
- // quick drag scrolls; a still press-hold falls through to scrub). Axis-drag
1396
- // goes first via Exclusive: it fails instantly outside the axis band, so scrub
1397
- // runs everywhere else.
1398
- if (timeScrollEnabled) {
1399
- rootGesture =
1400
- scrollGestureMode === "axisDrag"
1401
- ? Gesture.Exclusive(panScrollGesture, rootGesture)
1402
- : Gesture.Race(panScrollGesture, rootGesture);
1403
- }
1404
-
1405
- // Draggable reference lines take priority: a vertical grab on a line drags it.
1406
- // The manual-activation pan fails fast off any line (or on horizontal intent), so
1407
- // Exclusive falls through to scrub / scroll everywhere else.
1408
- if (refDragEnabled) {
1409
- rootGesture = Gesture.Exclusive(refDragGesture, rootGesture);
1410
- }
1411
-
1412
- // Pinch runs alongside everything else (two-finger, so it never competes with
1413
- // the one-finger pan/scrub/tap gestures for the same touch).
1414
- if (zoomEnabled) {
1415
- rootGesture = Gesture.Simultaneous(rootGesture, pinchZoomGesture);
1416
- }
2084
+ const rootGesture = composeLiveChartRootGesture({
2085
+ crosshair,
2086
+ markerTapGesture,
2087
+ refLineTapGesture,
2088
+ refDragGesture,
2089
+ panScrollGesture,
2090
+ pinchZoomGesture,
2091
+ markersActive,
2092
+ refPressActive,
2093
+ refDragEnabled,
2094
+ scrubActionActive: scrubActionCfg !== null,
2095
+ timeScrollEnabled,
2096
+ scrollGestureMode,
2097
+ zoomEnabled,
2098
+ });
1417
2099
 
1418
2100
  // ── Derived render values ──────────────────────────────────────────────
1419
2101
  const {
@@ -1446,49 +2128,33 @@ function useLiveChartController({
1446
2128
  // A dot that tracks the true live point while parked is exempt from the
1447
2129
  // scroll-back hide: it no longer marks an off-screen price, and it hides
1448
2130
  // itself once the live point leaves the window (`useLiveDot`'s sentinel).
1449
- const liveDotOpacity = useDerivedValue(
1450
- () =>
1451
- reveal.dotOpacity.value *
1452
- (selectionDotDuringScrub && crosshairScrubActive.value ? 0 : 1) *
1453
- liveIndicatorScrollOpacity(
1454
- hideLiveOnScrollBack && !dotTracksParked,
1455
- engine.viewEnd.value,
1456
- ) *
1457
- seriesIndicatorOpacity.value,
1458
- );
1459
- // Same scrolled-back gating for the value line: it would draw a dashed line
1460
- // at the live value's Y — a price that isn't in the scrolled-back view.
1461
- const valueLineOpacity = useDerivedValue(
1462
- () =>
1463
- reveal.lineOpacity.value *
1464
- liveIndicatorScrollOpacity(hideLiveOnScrollBack, engine.viewEnd.value) *
1465
- resolvedSeriesOpacity.value,
1466
- );
1467
- const liveBadgeOpacity = useDerivedValue(
1468
- () =>
1469
- reveal.badgeOpacity.value *
1470
- liveIndicatorScrollOpacity(hideLiveOnScrollBack, engine.viewEnd.value) *
1471
- resolvedSeriesOpacity.value,
1472
- );
1473
-
1474
- // Fade the annotation overlays (markers + reference lines) out while scrubbing
1475
- // when `scrub.hideOverlays` is set. Eased off the scrub-ACTIVE flag — not the
1476
- // crosshair's edge-proximity fade, which drops to 0 near the live dot and would
1477
- // resurface the overlays mid-scrub. Only this group opacity animates; the
1478
- // marker atlas / reference-line draws stay intact (one batched draw each).
1479
2131
  const fadeOverlaysOnScrub =
1480
2132
  !isStatic && scrubCfg !== null && scrubCfg.hideOverlaysOnScrub === true;
1481
- const overlayScrubFade = useDerivedValue(() =>
1482
- fadeOverlaysOnScrub
1483
- ? withTiming(crosshairScrubActive.get() ? 0 : 1, {
1484
- duration: SCRUB_OVERLAY_FADE_MS,
1485
- })
1486
- : 1,
1487
- );
1488
- // Markers already fade with the dot reveal; fold the scrub-hide fade in too.
1489
- const markerGroupOpacity = useDerivedValue(
1490
- () => reveal.dotOpacity.get() * overlayScrubFade.get(),
1491
- );
2133
+ const {
2134
+ liveDotOpacity,
2135
+ valueLineOpacity,
2136
+ liveBadgeOpacity,
2137
+ overlayScrubFade,
2138
+ markerGroupOpacity,
2139
+ } = useLiveIndicatorOpacities({
2140
+ reveal,
2141
+ seriesIndicatorOpacity,
2142
+ seriesOpacity: resolvedSeriesOpacity,
2143
+ scrubActive: crosshairScrubActive,
2144
+ selectionDotDuringScrub,
2145
+ hideLiveOnScrollBack,
2146
+ dotTracksParked,
2147
+ viewEnd: engine.viewEnd,
2148
+ fadeOverlaysOnScrub,
2149
+ });
2150
+ const modelDefaults = resolveLiveChartModelDefaults({
2151
+ isCandle,
2152
+ candleWidth,
2153
+ loadingCfg,
2154
+ volumeCfg,
2155
+ palette,
2156
+ lineProp,
2157
+ });
1492
2158
 
1493
2159
  return {
1494
2160
  // passthrough props the render needs
@@ -1505,9 +2171,7 @@ function useLiveChartController({
1505
2171
  formatTime,
1506
2172
  isCandle,
1507
2173
  isStatic,
1508
- // Half a candle width (seconds) so an "extrema" axis label's dot lands on the
1509
- // candle's drawn center, not its bucket-start (left) edge. 0 in line mode.
1510
- extremaTimeOffset: isCandle ? candleWidth / 2 : 0,
2174
+ ...modelDefaults,
1511
2175
  // configs
1512
2176
  yAxisCfg,
1513
2177
  yAxisFloat: effectiveYAxisFloat,
@@ -1548,8 +2212,8 @@ function useLiveChartController({
1548
2212
  hasRecolorSegments,
1549
2213
  thresholdCfg,
1550
2214
  thresholdGeom,
1551
- thresholdStrokeColors: thresholdStopColors?.stroke ?? null,
1552
- thresholdFillColors: thresholdStopColors?.fill ?? null,
2215
+ thresholdStrokeColors,
2216
+ thresholdFillColors,
1553
2217
  // Time-varying threshold (a `LiveChartPoint[]` series): the per-fragment split
1554
2218
  // shader + polyline marker, vs. the constant case's gradient.
1555
2219
  thresholdIsSeries,
@@ -1562,6 +2226,7 @@ function useLiveChartController({
1562
2226
  thresholdMarkerValue,
1563
2227
  thresholdCustomBadge,
1564
2228
  thresholdSeriesPts,
2229
+ thresholdBadgeLeftAnchorX,
1565
2230
  badgeUsesRightGutter,
1566
2231
  // theme / layout / fonts
1567
2232
  palette,
@@ -1574,13 +2239,8 @@ function useLiveChartController({
1574
2239
  // engine + reveal
1575
2240
  engine,
1576
2241
  reveal,
2242
+ loadingActive,
1577
2243
  axisAutoHideOpacity,
1578
- // loading shell styling (null → not loading)
1579
- loadingLineColor: loadingCfg?.color,
1580
- loadingStrokeWidth: loadingCfg?.strokeWidth,
1581
- loadingAmplitude: loadingCfg?.amplitude,
1582
- loadingSpeed: loadingCfg?.speed,
1583
- loadingAxisLabels: loadingCfg?.axisLabels ?? true,
1584
2244
  // derived render values
1585
2245
  backgroundColor,
1586
2246
  gradientEnd,
@@ -1608,9 +2268,6 @@ function useLiveChartController({
1608
2268
  // the candle palette). The reserved band height is read by the x-axis.
1609
2269
  volumeActive: volumeCfg !== null,
1610
2270
  volumeBandHeight,
1611
- volumeOpacity: volumeCfg?.opacity ?? 1,
1612
- volumeUpColor: volumeCfg?.upColor ?? palette.candleUp,
1613
- volumeDownColor: volumeCfg?.downColor ?? palette.candleDown,
1614
2271
  dotX,
1615
2272
  dotY,
1616
2273
  liveDotOpacity,
@@ -1631,7 +2288,6 @@ function useLiveChartController({
1631
2288
  renderOverlay,
1632
2289
  // selection dot: resolved config + fallback color (the chart line/accent color)
1633
2290
  selectionDot: selectionDotCfg,
1634
- selectionColor: lineProp?.color ?? palette.line,
1635
2291
  // RN axis edge labels (floated over the canvas as a sibling layer)
1636
2292
  topLabelCfg,
1637
2293
  bottomLabelCfg,
@@ -1980,11 +2636,7 @@ function ChartCandleGapLayer({
1980
2636
  model.metricsCfg.candle,
1981
2637
  );
1982
2638
  const batch = (
1983
- <GapBridgePathBatch
1984
- paths={paths}
1985
- config={config}
1986
- palette={model.palette}
1987
- />
2639
+ <GapBridgePathBatch paths={paths} config={config} palette={model.palette} />
1988
2640
  );
1989
2641
  return focusOtherCandles ? (
1990
2642
  <>
@@ -2001,17 +2653,9 @@ function ChartCandleGapLayer({
2001
2653
  /** Line-mode bridge paths live in a child so candle charts register no worklets. */
2002
2654
  function ChartLineGapLayer({ model }: { model: LiveChartModel }) {
2003
2655
  const config = model.lineGapsCfg!;
2004
- const paths = useLineGapPaths(
2005
- model.engine,
2006
- model.effectivePadding,
2007
- config,
2008
- );
2656
+ const paths = useLineGapPaths(model.engine, model.effectivePadding, config);
2009
2657
  return (
2010
- <GapBridgePathBatch
2011
- paths={paths}
2012
- config={config}
2013
- palette={model.palette}
2014
- />
2658
+ <GapBridgePathBatch paths={paths} config={config} palette={model.palette} />
2015
2659
  );
2016
2660
  }
2017
2661
 
@@ -2236,6 +2880,113 @@ function ChartCandleLayer({ model }: { model: LiveChartModel }) {
2236
2880
  );
2237
2881
  }
2238
2882
 
2883
+ function ChartLineStrokeShader({ model }: { model: LiveChartModel }) {
2884
+ const {
2885
+ engine,
2886
+ effectivePadding,
2887
+ palette,
2888
+ resolvedSegments,
2889
+ hasRecolorSegments,
2890
+ crosshair,
2891
+ thresholdCfg,
2892
+ thresholdGeom,
2893
+ thresholdStrokeColors,
2894
+ thresholdIsSeries,
2895
+ thresholdSeriesHasPoints,
2896
+ thresholdStrokeUniforms,
2897
+ lineProp,
2898
+ layoutWidth,
2899
+ } = model;
2900
+ if (thresholdIsSeries) {
2901
+ return thresholdSeriesHasPoints ? (
2902
+ <ThresholdSplitShader uniforms={thresholdStrokeUniforms} />
2903
+ ) : null;
2904
+ }
2905
+ if (thresholdCfg && thresholdStrokeColors) {
2906
+ return (
2907
+ <LinearGradient
2908
+ start={vec(0, 0)}
2909
+ end={thresholdGeom.gradientEnd}
2910
+ colors={thresholdStrokeColors}
2911
+ positions={thresholdGeom.splitPositions}
2912
+ />
2913
+ );
2914
+ }
2915
+ if (hasRecolorSegments) {
2916
+ return (
2917
+ <SegmentLineGradient
2918
+ engine={engine}
2919
+ segments={resolvedSegments}
2920
+ padding={effectivePadding}
2921
+ baseColor={lineProp?.color ?? palette.line}
2922
+ scrubX={crosshair.scrubX}
2923
+ scrubActive={crosshair.scrubActive}
2924
+ />
2925
+ );
2926
+ }
2927
+ if (!lineProp?.colors?.length) return null;
2928
+ return (
2929
+ <LinearGradient
2930
+ start={vec(0, 0)}
2931
+ end={vec(layoutWidth, 0)}
2932
+ colors={lineProp.colors}
2933
+ />
2934
+ );
2935
+ }
2936
+
2937
+ function ChartMainPlotLayer({
2938
+ model,
2939
+ yAxisEntries,
2940
+ }: {
2941
+ model: LiveChartModel;
2942
+ yAxisEntries: YAxisEntries | null;
2943
+ }) {
2944
+ const {
2945
+ engine,
2946
+ effectivePadding,
2947
+ palette,
2948
+ lineGroupOpacity,
2949
+ seriesOpacity,
2950
+ linePath,
2951
+ lineGapsCfg,
2952
+ strokeWidth,
2953
+ lineProp,
2954
+ isCandle,
2955
+ xAxisCfg,
2956
+ yAxisCfg,
2957
+ yAxisFloat,
2958
+ } = model;
2959
+
2960
+ return (
2961
+ <>
2962
+ <Group opacity={seriesOpacity}>
2963
+ <Group opacity={lineGroupOpacity}>
2964
+ <Path
2965
+ path={linePath}
2966
+ style="stroke"
2967
+ strokeWidth={strokeWidth}
2968
+ strokeCap={lineProp?.cap ?? "round"}
2969
+ strokeJoin={lineProp?.join ?? "round"}
2970
+ color={lineProp?.color ?? palette.line}
2971
+ >
2972
+ <ChartLineStrokeShader model={model} />
2973
+ </Path>
2974
+ {lineGapsCfg ? <ChartLineGapLayer model={model} /> : null}
2975
+ </Group>
2976
+ </Group>
2977
+ {isCandle ? <ChartCandleLayer model={model} /> : null}
2978
+ {yAxisCfg && yAxisFloat ? (
2979
+ <ChartYAxisLayer
2980
+ model={model}
2981
+ variant="labels"
2982
+ entries={yAxisEntries!}
2983
+ />
2984
+ ) : null}
2985
+ {xAxisCfg ? <ChartXAxisLayer model={model} /> : null}
2986
+ </>
2987
+ );
2988
+ }
2989
+
2239
2990
  /** Main shaken chart stack drawn ABOVE the left-edge fade so the line stays crisp:
2240
2991
  * segment dividers, value/reference lines, the line/candles, axes, dot, degen,
2241
2992
  * markers, and the loading/empty art. Background fills are in `ChartFillLayer`
@@ -2278,6 +3029,7 @@ function ChartStack({
2278
3029
  thresholdMarkerValue,
2279
3030
  thresholdCustomBadge,
2280
3031
  thresholdSeriesPts,
3032
+ thresholdBadgeLeftAnchorX,
2281
3033
  formatValue,
2282
3034
  lineGroupOpacity,
2283
3035
  seriesOpacity,
@@ -2379,74 +3131,7 @@ function ChartStack({
2379
3131
  />
2380
3132
  )}
2381
3133
 
2382
- {/* Chart line (fades out in candle mode). When segments recolor the line, a
2383
- full-width gradient paints the base color outside segments and each
2384
- segment's color within — so the line itself is recolored/faded (alpha in
2385
- the segment color reduces the line's opacity), not covered by an overlay. */}
2386
- <Group opacity={seriesOpacity}>
2387
- <Group opacity={lineGroupOpacity}>
2388
- <Path
2389
- path={linePath}
2390
- style="stroke"
2391
- strokeWidth={strokeWidth}
2392
- strokeCap={lineProp?.cap ?? "round"}
2393
- strokeJoin={lineProp?.join ?? "round"}
2394
- color={lineProp?.color ?? palette.line}
2395
- >
2396
- {thresholdIsSeries ? (
2397
- // Time-varying split: a per-fragment shader colors the stroke above/
2398
- // below the threshold polyline. Supersedes line.colors + segments.
2399
- // An empty series renders no paint child → the plain stroke color
2400
- // (null here keeps the empty case out of the NaN constant gradient).
2401
- thresholdSeriesHasPoints ? (
2402
- <ThresholdSplitShader uniforms={thresholdStrokeUniforms} />
2403
- ) : null
2404
- ) : thresholdCfg && thresholdStrokeColors ? (
2405
- // Vertical hard split at the threshold Y — supersedes line.colors and
2406
- // segment recoloring for the stroke while a threshold is set.
2407
- <LinearGradient
2408
- start={vec(0, 0)}
2409
- end={thresholdGeom.gradientEnd}
2410
- colors={thresholdStrokeColors}
2411
- positions={thresholdGeom.splitPositions}
2412
- />
2413
- ) : hasRecolorSegments ? (
2414
- <SegmentLineGradient
2415
- engine={engine}
2416
- segments={resolvedSegments}
2417
- padding={effectivePadding}
2418
- baseColor={lineProp?.color ?? palette.line}
2419
- scrubX={crosshair.scrubX}
2420
- scrubActive={crosshair.scrubActive}
2421
- />
2422
- ) : lineProp?.colors?.length ? (
2423
- <LinearGradient
2424
- start={vec(0, 0)}
2425
- end={vec(layoutWidth, 0)}
2426
- colors={lineProp.colors}
2427
- />
2428
- ) : null}
2429
- </Path>
2430
- {lineGapsCfg && <ChartLineGapLayer model={model} />}
2431
- </Group>
2432
- </Group>
2433
-
2434
- {isCandle && <ChartCandleLayer model={model} />}
2435
-
2436
- {/* Floating axis: the labels float ABOVE the candles (right-aligned at the
2437
- edge) so the plot runs full-width and candles stay fully visible behind
2438
- them. (Default non-floating axis draws grid + labels in ChartFillLayer.) */}
2439
- {yAxisCfg && yAxisFloat && (
2440
- <ChartYAxisLayer
2441
- model={model}
2442
- variant="labels"
2443
- entries={yAxisEntries!}
2444
- />
2445
- )}
2446
-
2447
- {/* X-axis time labels. With a volume band the bottom padding is inflated by
2448
- the band height; pass it so the axis shifts back to the very bottom. */}
2449
- {xAxisCfg && <ChartXAxisLayer model={model} />}
3134
+ <ChartMainPlotLayer model={model} yAxisEntries={yAxisEntries} />
2450
3135
 
2451
3136
  {/* Live dot — the badge is drawn later (after the scrub layer) so the
2452
3137
  scrub dim never clips the live-price badge's left edge. Hidden while
@@ -2514,6 +3199,7 @@ function ChartStack({
2514
3199
  palette={palette}
2515
3200
  font={skiaFont}
2516
3201
  formatValue={formatValue}
3202
+ leftAnchorX={thresholdBadgeLeftAnchorX}
2517
3203
  />
2518
3204
  )}
2519
3205
 
@@ -3001,7 +3687,7 @@ function ChartCustomConsumerOverlay({ model }: { model: LiveChartModel }) {
3001
3687
  return <ChartOverlayLayer render={renderOverlay!} context={overlayContext} />;
3002
3688
  }
3003
3689
 
3004
- function ChartView({
3690
+ function ChartCanvas({
3005
3691
  model,
3006
3692
  yAxisEntries,
3007
3693
  degen,
@@ -3011,36 +3697,173 @@ function ChartView({
3011
3697
  degen: DegenState | null;
3012
3698
  }) {
3013
3699
  const {
3014
- rootGesture,
3700
+ canvasMode,
3701
+ engine,
3015
3702
  backgroundColor,
3016
- style,
3017
- onLayout,
3018
- accessibilityLabel,
3019
- accessibilityRole,
3020
3703
  leftEdgeFadeCfg,
3021
3704
  effectivePadding,
3022
- engine,
3023
3705
  palette,
3024
- formatValue,
3706
+ topConnector,
3707
+ bottomConnector,
3708
+ extremaTimeOffset,
3709
+ loadingActive,
3710
+ topLabelCfg,
3711
+ } = model;
3712
+ return (
3713
+ <Canvas
3714
+ key={canvasMode}
3715
+ style={{ flex: 1 }}
3716
+ opaque={canvasMode === "opaque"}
3717
+ >
3718
+ {canvasMode === "opaque" ? (
3719
+ <Rect
3720
+ x={0}
3721
+ y={0}
3722
+ width={engine.canvasWidth}
3723
+ height={engine.canvasHeight}
3724
+ color={backgroundColor}
3725
+ />
3726
+ ) : null}
3727
+ <ChartFillLayer model={model} yAxisEntries={yAxisEntries} degen={degen} />
3728
+ {leftEdgeFadeCfg ? (
3729
+ <LeftEdgeFade
3730
+ paddingLeft={effectivePadding.left}
3731
+ fadeWidth={leftEdgeFadeCfg.width}
3732
+ startColor={leftEdgeFadeCfg.startColor}
3733
+ endColor={leftEdgeFadeCfg.endColor}
3734
+ engine={engine}
3735
+ opaqueBackgroundRgb={
3736
+ canvasMode === "opaque" ? palette.bgRgb : undefined
3737
+ }
3738
+ />
3739
+ ) : null}
3740
+ <ChartStack model={model} yAxisEntries={yAxisEntries} degen={degen} />
3741
+ {topConnector || bottomConnector ? (
3742
+ <ExtremaConnectorOverlay
3743
+ engine={engine}
3744
+ padding={effectivePadding}
3745
+ extremaTimeOffset={extremaTimeOffset}
3746
+ top={topConnector}
3747
+ bottom={bottomConnector}
3748
+ hideExtrema={loadingActive}
3749
+ suppressBottomWhenCoincident={
3750
+ topLabelCfg?.position === "extrema" ||
3751
+ topLabelCfg?.position === "extrema-edge"
3752
+ }
3753
+ />
3754
+ ) : null}
3755
+ <ChartRefBadgeLayer
3756
+ model={model}
3757
+ degen={degen}
3758
+ yAxisEntries={yAxisEntries}
3759
+ />
3760
+ <ChartValueOverlay model={model} degen={degen} />
3761
+ {model.tradeStreamResolved && model.tradeStream ? (
3762
+ <ChartTradeStreamLayer model={model} degen={degen} />
3763
+ ) : null}
3764
+ <ChartScrubLayer model={model} degen={degen} />
3765
+ {model.badgeCfg ? <ChartBadgeLayer model={model} degen={degen} /> : null}
3766
+ <ChartScrubActionLayer model={model} />
3767
+ </Canvas>
3768
+ );
3769
+ }
3770
+
3771
+ function ChartNativeOverlays({ model }: { model: LiveChartModel }) {
3772
+ const {
3025
3773
  topLabelCfg,
3026
3774
  bottomLabelCfg,
3775
+ engine,
3776
+ formatValue,
3777
+ palette,
3778
+ effectivePadding,
3779
+ extremaTimeOffset,
3780
+ loadingActive,
3781
+ thresholdCustomBadge,
3027
3782
  thresholdCfg,
3028
3783
  thresholdMarkerLineY,
3029
3784
  thresholdBadgeVisible,
3030
- thresholdCustomBadge,
3785
+ thresholdBadgeLeftAnchorX,
3031
3786
  markersActive,
3032
3787
  renderMarker,
3033
- renderTooltip,
3034
- renderOverlay,
3035
3788
  renderReferenceLine,
3036
3789
  renderOffAxisReferenceLine,
3037
3790
  allRefLines,
3038
3791
  scrubCfg,
3792
+ renderTooltip,
3039
3793
  crosshair,
3040
- extremaTimeOffset,
3041
- topConnector,
3042
- bottomConnector,
3043
- canvasMode,
3794
+ renderOverlay,
3795
+ } = model;
3796
+ const hasAnnotations =
3797
+ (markersActive && renderMarker != null) ||
3798
+ ((renderReferenceLine != null || renderOffAxisReferenceLine != null) &&
3799
+ allRefLines.length > 0);
3800
+ return (
3801
+ <>
3802
+ {topLabelCfg || bottomLabelCfg ? (
3803
+ <AxisLabelOverlay
3804
+ topLabel={topLabelCfg}
3805
+ bottomLabel={bottomLabelCfg}
3806
+ engine={engine}
3807
+ formatValue={formatValue}
3808
+ defaultColor={palette.gridLabel}
3809
+ padding={effectivePadding}
3810
+ extremaTimeOffset={extremaTimeOffset}
3811
+ hideExtrema={loadingActive}
3812
+ />
3813
+ ) : null}
3814
+ {thresholdCustomBadge && thresholdCfg?.line ? (
3815
+ <CustomThresholdBadgeOverlay
3816
+ element={thresholdCustomBadge}
3817
+ engine={engine}
3818
+ padding={effectivePadding}
3819
+ y={thresholdMarkerLineY}
3820
+ visible={thresholdBadgeVisible}
3821
+ position={thresholdCfg.line.labelPosition}
3822
+ leftAnchorX={thresholdBadgeLeftAnchorX}
3823
+ />
3824
+ ) : null}
3825
+ {hasAnnotations ? <ChartCustomAnnotations model={model} /> : null}
3826
+ {scrubCfg && renderTooltip ? (
3827
+ <CustomTooltipOverlay
3828
+ renderTooltip={renderTooltip}
3829
+ scrubX={crosshair.scrubX}
3830
+ scrubValue={crosshair.scrubValue}
3831
+ scrubTime={crosshair.scrubTime}
3832
+ scrubActive={crosshair.scrubActive}
3833
+ scrubCandle={crosshair.scrubCandle}
3834
+ scrubGap={crosshair.scrubGap}
3835
+ tooltipLayout={crosshair.tooltipLayout}
3836
+ engine={engine}
3837
+ padding={effectivePadding}
3838
+ placement={scrubCfg.tooltipPlacement}
3839
+ margin={scrubCfg.tooltipMargin}
3840
+ crosshairFade={scrubCfg.crosshairFade}
3841
+ crosshairFadeDistance={scrubCfg.crosshairFadeDistance}
3842
+ lineTop={crosshair.tooltipLineTop}
3843
+ scrubDotY={crosshair.scrubDotY}
3844
+ />
3845
+ ) : null}
3846
+ {renderOverlay ? <ChartCustomConsumerOverlay model={model} /> : null}
3847
+ </>
3848
+ );
3849
+ }
3850
+
3851
+ function ChartView({
3852
+ model,
3853
+ yAxisEntries,
3854
+ degen,
3855
+ }: {
3856
+ model: LiveChartModel;
3857
+ yAxisEntries: YAxisEntries | null;
3858
+ degen: DegenState | null;
3859
+ }) {
3860
+ const {
3861
+ rootGesture,
3862
+ backgroundColor,
3863
+ style,
3864
+ onLayout,
3865
+ accessibilityLabel,
3866
+ accessibilityRole,
3044
3867
  } = model;
3045
3868
 
3046
3869
  return (
@@ -3052,147 +3875,8 @@ function ChartView({
3052
3875
  accessibilityLabel={accessibilityLabel}
3053
3876
  accessibilityRole={accessibilityRole}
3054
3877
  >
3055
- {/* Skia chooses TextureView vs SurfaceView when the native Canvas mounts. */}
3056
- <Canvas
3057
- key={canvasMode}
3058
- style={{ flex: 1 }}
3059
- opaque={canvasMode === "opaque"}
3060
- >
3061
- {canvasMode === "opaque" && (
3062
- <Rect
3063
- x={0}
3064
- y={0}
3065
- width={engine.canvasWidth}
3066
- height={engine.canvasHeight}
3067
- color={backgroundColor}
3068
- />
3069
- )}
3070
- {/* Background fills first, then the left-edge fade (a canvas-space sibling
3071
- so dstOut blends correctly), then the line stack on top — so the fade
3072
- softens only the fills and the line stays crisp at the left edge. */}
3073
- <ChartFillLayer
3074
- model={model}
3075
- yAxisEntries={yAxisEntries}
3076
- degen={degen}
3077
- />
3078
-
3079
- {leftEdgeFadeCfg && (
3080
- <LeftEdgeFade
3081
- paddingLeft={effectivePadding.left}
3082
- fadeWidth={leftEdgeFadeCfg.width}
3083
- startColor={leftEdgeFadeCfg.startColor}
3084
- endColor={leftEdgeFadeCfg.endColor}
3085
- engine={engine}
3086
- opaqueBackgroundRgb={
3087
- canvasMode === "opaque" ? palette.bgRgb : undefined
3088
- }
3089
- />
3090
- )}
3091
-
3092
- {/* Line stack above the fade so the line stays crisp at the left edge. */}
3093
- <ChartStack model={model} yAxisEntries={yAxisEntries} degen={degen} />
3094
-
3095
- {/* "extrema-edge" connector lines (dot → edge readout), above the chart
3096
- content so the dashed guide reads over the line / candles. */}
3097
- {(topConnector || bottomConnector) && (
3098
- <ExtremaConnectorOverlay
3099
- engine={engine}
3100
- padding={effectivePadding}
3101
- extremaTimeOffset={extremaTimeOffset}
3102
- top={topConnector}
3103
- bottom={bottomConnector}
3104
- />
3105
- )}
3106
-
3107
- {/* Reference-line badges + labels above the fade so they stay crisp. */}
3108
- <ChartRefBadgeLayer
3109
- model={model}
3110
- degen={degen}
3111
- yAxisEntries={yAxisEntries}
3112
- />
3113
-
3114
- <ChartValueOverlay model={model} degen={degen} />
3115
-
3116
- {model.tradeStreamResolved && model.tradeStream && (
3117
- <ChartTradeStreamLayer model={model} degen={degen} />
3118
- )}
3119
-
3120
- <ChartScrubLayer model={model} degen={degen} />
3121
-
3122
- {/* Live-price badge on top of the scrub dim so the dim never clips
3123
- its left edge (the badge tracks the live value, not the scrub). */}
3124
- {model.badgeCfg && <ChartBadgeLayer model={model} degen={degen} />}
3125
-
3126
- {/* Scrub-action reticle + action badge — top-most, no shake transform. */}
3127
- <ChartScrubActionLayer model={model} />
3128
- </Canvas>
3129
-
3130
- {/* RN labels floated over the canvas (sibling of <Canvas>, an RN view).
3131
- Pinned to the plot's top/bottom edges via the resolved padding. */}
3132
- {(topLabelCfg || bottomLabelCfg) && (
3133
- <AxisLabelOverlay
3134
- topLabel={topLabelCfg}
3135
- bottomLabel={bottomLabelCfg}
3136
- engine={engine}
3137
- formatValue={formatValue}
3138
- defaultColor={palette.gridLabel}
3139
- padding={effectivePadding}
3140
- extremaTimeOffset={extremaTimeOffset}
3141
- />
3142
- )}
3143
-
3144
- {/* Custom threshold badge — a native view positioned from the same live
3145
- value/Y SharedValues as the built-in Skia badge. */}
3146
- {thresholdCustomBadge && thresholdCfg?.line && (
3147
- <CustomThresholdBadgeOverlay
3148
- element={thresholdCustomBadge}
3149
- engine={engine}
3150
- padding={effectivePadding}
3151
- y={thresholdMarkerLineY}
3152
- visible={thresholdBadgeVisible}
3153
- position={thresholdCfg.line.labelPosition}
3154
- />
3155
- )}
3156
-
3157
- {/* Custom-rendered markers — RN views floated over the canvas (non-Skia),
3158
- pinned to each marker's live position. Sibling of <Canvas>. Wrapped in
3159
- a box-none fade layer so `scrub.hideOverlaysOnScrub` hides them with the
3160
- Skia markers (the wrapper is full-bleed; children keep their own
3161
- absolute positions). */}
3162
- {((markersActive && renderMarker) ||
3163
- ((renderReferenceLine || renderOffAxisReferenceLine) &&
3164
- allRefLines.length > 0)) && (
3165
- <ChartCustomAnnotations model={model} />
3166
- )}
3167
-
3168
- {/* Custom scrub tooltip — an RN view floated over the canvas (non-Skia),
3169
- positioned on the UI thread. Sibling of <Canvas>. Works in line and
3170
- candle mode (candle exposes the OHLC via `scrubCandle`). */}
3171
- {scrubCfg && renderTooltip && (
3172
- <CustomTooltipOverlay
3173
- renderTooltip={renderTooltip}
3174
- scrubX={crosshair.scrubX}
3175
- scrubValue={crosshair.scrubValue}
3176
- scrubTime={crosshair.scrubTime}
3177
- scrubActive={crosshair.scrubActive}
3178
- scrubCandle={crosshair.scrubCandle}
3179
- scrubGap={crosshair.scrubGap}
3180
- tooltipLayout={crosshair.tooltipLayout}
3181
- engine={engine}
3182
- padding={effectivePadding}
3183
- placement={scrubCfg.tooltipPlacement}
3184
- margin={scrubCfg.tooltipMargin}
3185
- crosshairFade={scrubCfg.crosshairFade}
3186
- crosshairFadeDistance={scrubCfg.crosshairFadeDistance}
3187
- lineTop={crosshair.tooltipLineTop}
3188
- scrubDotY={crosshair.scrubDotY}
3189
- />
3190
- )}
3191
-
3192
- {/* Custom consumer overlay — an RN view tree floated over the canvas with
3193
- the price↔pixel / time↔pixel bridge, for order / avg-entry / liquidation
3194
- tags etc. Topmost RN sibling; `box-none` so empty areas still scrub. */}
3195
- {renderOverlay && <ChartCustomConsumerOverlay model={model} />}
3878
+ <ChartCanvas model={model} yAxisEntries={yAxisEntries} degen={degen} />
3879
+ <ChartNativeOverlays model={model} />
3196
3880
  </View>
3197
3881
  </GestureDetector>
3198
3882
  );