react-native-livechart 3.3.0 → 3.5.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 (56) hide show
  1. package/README.md +5 -1
  2. package/dist/components/LiveChart.d.ts.map +1 -1
  3. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  4. package/dist/components/ReferenceLineOverlay.d.ts +10 -3
  5. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  6. package/dist/components/ScrubActionOverlay.d.ts +32 -0
  7. package/dist/components/ScrubActionOverlay.d.ts.map +1 -0
  8. package/dist/components/ThresholdLineOverlay.d.ts +34 -0
  9. package/dist/components/ThresholdLineOverlay.d.ts.map +1 -0
  10. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  11. package/dist/constants.d.ts +4 -0
  12. package/dist/constants.d.ts.map +1 -1
  13. package/dist/core/resolveConfig.d.ts +55 -1
  14. package/dist/core/resolveConfig.d.ts.map +1 -1
  15. package/dist/hooks/crosshairShared.d.ts +110 -0
  16. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  17. package/dist/hooks/useChartPaths.d.ts +5 -1
  18. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  19. package/dist/hooks/useCrosshair.d.ts +14 -2
  20. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  21. package/dist/hooks/useMarkers.d.ts +2 -0
  22. package/dist/hooks/useMarkers.d.ts.map +1 -1
  23. package/dist/hooks/useReferenceLine.d.ts +36 -3
  24. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  25. package/dist/hooks/useReferenceLinePress.d.ts +23 -0
  26. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -0
  27. package/dist/hooks/useThreshold.d.ts +22 -0
  28. package/dist/hooks/useThreshold.d.ts.map +1 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/math/referenceLines.d.ts +23 -0
  32. package/dist/math/referenceLines.d.ts.map +1 -1
  33. package/dist/math/threshold.d.ts +25 -0
  34. package/dist/math/threshold.d.ts.map +1 -0
  35. package/dist/types.d.ts +190 -6
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/components/LiveChart.tsx +362 -61
  39. package/src/components/LiveChartSeries.tsx +39 -2
  40. package/src/components/ReferenceLineOverlay.tsx +121 -94
  41. package/src/components/ScrubActionOverlay.tsx +187 -0
  42. package/src/components/ThresholdLineOverlay.tsx +172 -0
  43. package/src/components/XAxisOverlay.tsx +2 -1
  44. package/src/constants.ts +5 -0
  45. package/src/core/resolveConfig.ts +102 -0
  46. package/src/hooks/crosshairShared.ts +293 -0
  47. package/src/hooks/useChartPaths.ts +25 -1
  48. package/src/hooks/useCrosshair.ts +340 -6
  49. package/src/hooks/useMarkers.ts +18 -2
  50. package/src/hooks/useReferenceLine.ts +259 -14
  51. package/src/hooks/useReferenceLinePress.ts +92 -0
  52. package/src/hooks/useThreshold.ts +63 -0
  53. package/src/index.ts +5 -0
  54. package/src/math/referenceLines.ts +55 -0
  55. package/src/math/threshold.ts +59 -0
  56. package/src/types.ts +195 -6
@@ -29,12 +29,15 @@ import {
29
29
  resolveMetrics,
30
30
  resolvePulse,
31
31
  resolveScrub,
32
+ resolveScrubAction,
32
33
  resolveSelectionDot,
34
+ resolveThreshold,
33
35
  resolveTradeStream,
34
36
  resolveValueLine,
35
37
  resolveXAxis,
36
38
  resolveYAxis,
37
39
  } from "../core/resolveConfig";
40
+ import type { ResolvedThresholdConfig } from "../core/resolveConfig";
38
41
  import { resolveSegment } from "../core/resolveSegment";
39
42
  import { useLiveChartEngine } from "../core/useLiveChartEngine";
40
43
  import { pulseRadialOutset } from "../draw/line";
@@ -51,10 +54,12 @@ import { useDegen } from "../hooks/useDegen";
51
54
  import { useLiveChartHasData } from "../hooks/useLiveChartHasData";
52
55
  import { useLiveDot } from "../hooks/useLiveDot";
53
56
  import { useMarkers } from "../hooks/useMarkers";
57
+ import { useReferenceLinePress } from "../hooks/useReferenceLinePress";
54
58
  import { useModeBlend } from "../hooks/useModeBlend";
55
59
  import { useMomentum } from "../hooks/useMomentum";
56
60
  import { useSegmentLineGradient } from "../hooks/useSegmentLineGradient";
57
61
  import { useSingleChartReverseMorphInputs } from "../hooks/useReverseMorphEngineInputs";
62
+ import { useThreshold } from "../hooks/useThreshold";
58
63
  import { useTradeStream } from "../hooks/useTradeStream";
59
64
  import { useXAxis } from "../hooks/useXAxis";
60
65
  import { useYAxis } from "../hooks/useYAxis";
@@ -67,9 +72,19 @@ import { collectReferenceValues } from "../math/referenceLines";
67
72
  import {
68
73
  applyPaletteOverride,
69
74
  leftEdgeFadeColorsFromBgRgb,
75
+ parseColorRgb,
70
76
  resolveTheme,
71
77
  } from "../theme";
72
- import type { LiveChartProps, Marker, TradeEvent } from "../types";
78
+ import type {
79
+ LiveChartPalette,
80
+ LiveChartProps,
81
+ Marker,
82
+ TradeEvent,
83
+ } from "../types";
84
+ import {
85
+ ThresholdBadgeOverlay,
86
+ ThresholdLineOverlay,
87
+ } from "./ThresholdLineOverlay";
73
88
  import { AxisLabelOverlay } from "./AxisLabelOverlay";
74
89
  import { BadgeOverlay } from "./BadgeOverlay";
75
90
  import { CrosshairOverlay } from "./CrosshairOverlay";
@@ -81,12 +96,39 @@ import { MarkerOverlay } from "./MarkerOverlay";
81
96
  import { MultiSeriesTooltipStack } from "./MultiSeriesTooltipStack";
82
97
  import { ValueTextOverlay } from "./ValueTextOverlay";
83
98
  import { ReferenceLineOverlay } from "./ReferenceLineOverlay";
99
+ import { ScrubActionOverlay } from "./ScrubActionOverlay";
84
100
  import { SegmentDividerOverlay } from "./SegmentDividerOverlay";
85
101
  import { TradeStreamOverlay } from "./TradeStreamOverlay";
86
102
  import { ValueLineOverlay } from "./ValueLineOverlay";
87
103
  import { XAxisOverlay } from "./XAxisOverlay";
88
104
  import { YAxisOverlay } from "./YAxisOverlay";
89
105
 
106
+ /** Translucent fill alpha for the threshold profit/loss band. */
107
+ const THRESHOLD_FILL_OPACITY = 0.16;
108
+
109
+ /**
110
+ * Color stops for the threshold's hard-split vertical gradient. Both arrays pair
111
+ * with the `[0, t, t, 1]` split positions: index 0–1 paint above the split,
112
+ * 2–3 below. `stroke` is full-strength; `fill` is the same hues at
113
+ * {@link THRESHOLD_FILL_OPACITY} for the band. Defaults to the palette's semantic
114
+ * up-green / down-red when colors are omitted.
115
+ */
116
+ function thresholdStops(
117
+ cfg: ResolvedThresholdConfig,
118
+ palette: LiveChartPalette,
119
+ ): { stroke: string[]; fill: string[] } {
120
+ const above = cfg.aboveColor ?? palette.candleUp;
121
+ const below = cfg.belowColor ?? palette.candleDown;
122
+ const [ar, ag, ab] = parseColorRgb(above);
123
+ const [br, bg, bb] = parseColorRgb(below);
124
+ const aboveFill = `rgba(${ar}, ${ag}, ${ab}, ${THRESHOLD_FILL_OPACITY})`;
125
+ const belowFill = `rgba(${br}, ${bg}, ${bb}, ${THRESHOLD_FILL_OPACITY})`;
126
+ return {
127
+ stroke: [above, above, below, below],
128
+ fill: [aboveFill, aboveFill, belowFill, belowFill],
129
+ };
130
+ }
131
+
90
132
  /**
91
133
  * Resolves props → configs → theme/layout → engine → per-frame derived values and
92
134
  * overlay hooks, returning a single render model. All the chart's wiring lives
@@ -145,10 +187,12 @@ function useLiveChartController({
145
187
  valueMomentumColor = false,
146
188
  referenceLines,
147
189
  segments,
190
+ threshold,
148
191
  gridStyle,
149
192
  palette: paletteOverride,
150
193
  metrics,
151
194
  scrub = true,
195
+ scrubAction,
152
196
  selectionDot,
153
197
  tradeStream,
154
198
  degen,
@@ -159,6 +203,8 @@ function useLiveChartController({
159
203
 
160
204
  // ── Callbacks ───────────────────────────────────────────────────────────
161
205
  onScrub,
206
+ onScrubAction,
207
+ onReferenceLinePress,
162
208
  onGestureStart,
163
209
  onGestureEnd,
164
210
  onDegenShake,
@@ -167,6 +213,9 @@ function useLiveChartController({
167
213
  const tradeStreamSV = tradeStream ?? emptyTradeStream;
168
214
  const emptyMarkers = useSharedValue<Marker[]>([]);
169
215
  const markersSV = markers ?? emptyMarkers;
216
+ // Stand-in threshold value so `useThreshold` can be called unconditionally
217
+ // (hooks can't be); the geometry is ignored when no threshold is configured.
218
+ const emptyThresholdValue = useSharedValue(0);
170
219
  const isCandle = mode === "candle";
171
220
 
172
221
  // ── Resolve feature configs ────────────────────────────────────────────
@@ -176,7 +225,12 @@ function useLiveChartController({
176
225
  const bottomLabelCfg = resolveAxisLabel(bottomLabel);
177
226
  const badgeCfg = resolveBadge(badge);
178
227
  const scrubCfg = resolveScrub(scrub);
228
+ // Static charts run no gestures, so scrub-action (tap/drag to lock a price) is off.
229
+ const scrubActionCfg = isStatic ? null : resolveScrubAction(scrubAction);
179
230
  const gradientCfg = isCandle ? null : resolveGradient(gradient);
231
+ // Threshold split is a line-mode feature (candle bodies carry their own up/down
232
+ // colors), so it's inert in candle mode — same as the area gradient.
233
+ const thresholdCfg = isCandle ? null : resolveThreshold(threshold);
180
234
  const valueLineCfg = resolveValueLine(valueLine);
181
235
  // Static charts run zero loops: force the pulse off so its `withRepeat`-driven
182
236
  // ring never starts (the DotOverlay reads `pulseCfg`, so null = no pulse).
@@ -327,10 +381,23 @@ function useLiveChartController({
327
381
  // ── Per-frame derived values ───────────────────────────────────────────
328
382
  const { layoutWidth, layoutHeight, onLayout } = useCanvasLayout(engine);
329
383
 
330
- const { linePath, fillPath } = useChartPaths(
384
+ // Threshold split geometry (shared by stroke gradient, fill band, marker line).
385
+ // Called unconditionally with a stand-in value when no threshold is set.
386
+ const thresholdGeom = useThreshold(
387
+ engine,
388
+ effectivePadding,
389
+ thresholdCfg?.value ?? emptyThresholdValue,
390
+ );
391
+ const thresholdStopColors = thresholdCfg
392
+ ? thresholdStops(thresholdCfg, palette)
393
+ : null;
394
+
395
+ const { linePath, fillPath, thresholdFillPath } = useChartPaths(
331
396
  engine,
332
397
  effectivePadding,
333
398
  reveal.morphT,
399
+ // Only build the band path when the fill is actually on.
400
+ thresholdCfg?.fill ? thresholdGeom.lineY : undefined,
334
401
  );
335
402
  const { upBodiesPath, downBodiesPath, upWicksPath, downWicksPath } =
336
403
  useCandlePaths(
@@ -404,6 +471,46 @@ function useLiveChartController({
404
471
  }
405
472
  : undefined;
406
473
 
474
+ const markersActive = markers != null;
475
+ // `projected` is used internally by the hit-test gesture; the overlay
476
+ // self-projects, so we only need the gesture + hit-test here. Built BEFORE
477
+ // `useCrosshair` so the scrub-action tap can defer to a marker under the finger.
478
+ const { tapGesture: markerTapGesture, hitTest: markerHitTest } = useMarkers(
479
+ engine,
480
+ effectivePadding,
481
+ markersSV,
482
+ !isStatic && markersActive,
483
+ markerHitRadius,
484
+ onMarkerHover,
485
+ undefined, // seriesSV — single-series has none
486
+ engine.data, // anchor value-less markers to the line
487
+ !isStatic, // static: no marker-projection loop
488
+ );
489
+
490
+ // Pressable reference-line badges (working orders / alerts). Built before
491
+ // `useCrosshair` so the scrub-action tap can defer to a badge under the finger.
492
+ const refPressActive = onReferenceLinePress != null && allRefLines.length > 0;
493
+ const { tapGesture: refLineTapGesture, hitTest: refLineHitTest } =
494
+ useReferenceLinePress(
495
+ engine,
496
+ effectivePadding,
497
+ allRefLines,
498
+ skiaFont,
499
+ formatValue,
500
+ !isStatic && refPressActive,
501
+ markerHitRadius,
502
+ onReferenceLinePress,
503
+ );
504
+
505
+ // Combined "defer" hit-test for the scrub-action place-tap: yield to a marker or
506
+ // a pressable badge under the finger so the tap is routed there instead of
507
+ // dropping a reticle. (Both hit-tests return false when their feature is off.)
508
+ /* istanbul ignore next -- worklet runs on the UI thread, not in Jest */
509
+ const deferTapHit = (x: number, y: number): boolean => {
510
+ "worklet";
511
+ return markerHitTest(x, y) || refLineHitTest(x, y);
512
+ };
513
+
407
514
  const crosshair = useCrosshair(
408
515
  engine,
409
516
  effectivePadding,
@@ -412,32 +519,51 @@ function useLiveChartController({
412
519
  formatTime,
413
520
  skiaFont,
414
521
  // Static charts have an inert pan gesture — no scrub work on the UI thread.
415
- !isStatic && scrubCfg !== null,
522
+ // Scrub-action also needs the gesture live even when plain scrub is off.
523
+ !isStatic && (scrubCfg !== null || scrubActionCfg !== null),
416
524
  onScrub,
417
525
  candleOpts,
418
526
  scrubCfg?.panGestureDelay ?? 0,
419
527
  onGestureStart,
420
528
  onGestureEnd,
529
+ scrubActionCfg,
530
+ onScrubAction,
531
+ metricsCfg.badge,
532
+ markersActive || refPressActive ? deferTapHit : undefined,
421
533
  );
422
534
 
423
- const markersActive = markers != null;
424
- // `projected` is used internally by the hit-test gesture; the overlay
425
- // self-projects, so we only need the gesture here.
426
- const { tapGesture: markerTapGesture } = useMarkers(
427
- engine,
428
- effectivePadding,
429
- markersSV,
430
- !isStatic && markersActive,
431
- markerHitRadius,
432
- onMarkerHover,
433
- undefined, // seriesSV single-series has none
434
- engine.data, // anchor value-less markers to the line
435
- !isStatic, // static: no marker-projection loop
436
- );
437
-
438
- const rootGesture = markersActive
439
- ? Gesture.Race(crosshair.gesture, markerTapGesture)
440
- : crosshair.gesture;
535
+ // Scrub-action composes a Tap (place/move the reticle, press the badge, dismiss)
536
+ // ahead of the pan via Exclusive, so a tap is tried first and only becomes a
537
+ // drag (live-scrub, or lock-adjust once placed) if the finger moves. `Exclusive`
538
+ // (not `Race`) prevents a jittery tap from being swallowed by the pan.
539
+ const baseGesture =
540
+ scrubActionCfg !== null && crosshair.tapGesture
541
+ ? Gesture.Exclusive(crosshair.tapGesture, crosshair.gesture)
542
+ : crosshair.gesture;
543
+
544
+ // Overlay taps that hit-test discrete targets (marker dots, reference-line
545
+ // badges). They must all see each tap, so they're combined with `Simultaneous`
546
+ // (not `Race`, which cancels the loser and would drop one). The scrub-action
547
+ // action tap defers to them via `deferTapHit`, so a tap on an overlay is routed
548
+ // there instead of placing a reticle.
549
+ const overlayTaps = [
550
+ markersActive ? markerTapGesture : null,
551
+ refPressActive ? refLineTapGesture : null,
552
+ ].filter((g): g is NonNullable<typeof g> => g !== null);
553
+
554
+ let rootGesture = baseGesture;
555
+ if (overlayTaps.length > 0) {
556
+ const tapGroup =
557
+ overlayTaps.length === 1
558
+ ? overlayTaps[0]
559
+ : Gesture.Simultaneous(overlayTaps[0], overlayTaps[1]);
560
+ // With scrub-action the action tap shares the gesture space with the overlay
561
+ // taps (Simultaneous); without it the pan only needs to race the tap group.
562
+ rootGesture =
563
+ scrubActionCfg !== null
564
+ ? Gesture.Simultaneous(baseGesture, tapGroup)
565
+ : Gesture.Race(baseGesture, tapGroup);
566
+ }
441
567
 
442
568
  // ── Derived render values ──────────────────────────────────────────────
443
569
  const {
@@ -494,6 +620,7 @@ function useLiveChartController({
494
620
  xAxisCfg,
495
621
  badgeCfg,
496
622
  scrubCfg,
623
+ scrubActionCfg,
497
624
  gradientCfg,
498
625
  valueLineCfg,
499
626
  pulseCfg,
@@ -508,6 +635,10 @@ function useLiveChartController({
508
635
  resolvedSegments,
509
636
  hasRecolorSegments,
510
637
  segmentGradient,
638
+ thresholdCfg,
639
+ thresholdGeom,
640
+ thresholdStrokeColors: thresholdStopColors?.stroke ?? null,
641
+ thresholdFillColors: thresholdStopColors?.fill ?? null,
511
642
  badgeUsesRightGutter,
512
643
  // theme / layout / fonts
513
644
  palette,
@@ -531,6 +662,7 @@ function useLiveChartController({
531
662
  onLayout,
532
663
  linePath,
533
664
  fillPath,
665
+ thresholdFillPath,
534
666
  upBodiesPath,
535
667
  downBodiesPath,
536
668
  upWicksPath,
@@ -561,10 +693,13 @@ function useLiveChartController({
561
693
 
562
694
  type LiveChartModel = ReturnType<typeof useLiveChartController>;
563
695
 
564
- /** Main shaken chart stack: grid, fill, line/candles, axes, badge, dot, degen,
565
- * markers, and the loading/empty art. The live value text is a separate layer
566
- * (ChartValueOverlay) so it sits above the left-edge fade. */
567
- function ChartStack({ model }: { model: LiveChartModel }) {
696
+ /**
697
+ * Background fills drawn BENEATH the left-edge fade: the y-axis grid, the area
698
+ * gradient, and the threshold profit/loss band. Split out from `ChartStack` so
699
+ * the fade's `dstOut` only softens the fills — the line and everything above it
700
+ * (drawn in `ChartStack`, after the fade) stay crisp at the left edge.
701
+ */
702
+ function ChartFillLayer({ model }: { model: LiveChartModel }) {
568
703
  const {
569
704
  degenShakeTransform,
570
705
  yAxisCfg,
@@ -577,41 +712,16 @@ function ChartStack({ model }: { model: LiveChartModel }) {
577
712
  badgeUsesRightGutter,
578
713
  badgeCfg,
579
714
  gridStyleCfg,
715
+ metricsCfg,
580
716
  gradientCfg,
581
717
  fillPath,
582
718
  gradientEnd,
583
719
  gradientColors,
584
720
  gradientPositions,
585
- valueLineCfg,
586
- dotY,
587
- allRefLines,
588
- resolvedSegments,
589
- hasRecolorSegments,
590
- segmentGradient,
591
- formatValue,
592
- lineGroupOpacity,
593
- linePath,
594
- strokeWidth,
595
- lineProp,
596
- candleGroupOpacity,
597
- upWicksPath,
598
- downWicksPath,
599
- upBodiesPath,
600
- downBodiesPath,
601
- xAxisCfg,
602
- xAxisEntries,
603
- dotX,
604
- liveDotOpacity,
605
- pulseCfg,
606
- dotCfg,
607
- degenCfg,
608
- degenPack,
609
- degenPackRevision,
610
- markersActive,
611
- markersSV,
612
- emptyText,
613
- metricsCfg,
614
- layoutWidth,
721
+ thresholdCfg,
722
+ thresholdGeom,
723
+ thresholdFillPath,
724
+ thresholdFillColors,
615
725
  } = model;
616
726
 
617
727
  return (
@@ -647,6 +757,75 @@ function ChartStack({ model }: { model: LiveChartModel }) {
647
757
  </Group>
648
758
  )}
649
759
 
760
+ {/* Threshold profit/loss band — the area between the line and the threshold,
761
+ hard-split at the threshold Y into the above/below colors. Independent of
762
+ the baseline area fill above (set `gradient={false}` for the band alone). */}
763
+ {thresholdCfg?.fill && thresholdFillColors && (
764
+ <Group opacity={reveal.fillOpacity}>
765
+ <Path path={thresholdFillPath} style="fill">
766
+ <LinearGradient
767
+ start={vec(0, 0)}
768
+ end={thresholdGeom.gradientEnd}
769
+ colors={thresholdFillColors}
770
+ positions={thresholdGeom.splitPositions}
771
+ />
772
+ </Path>
773
+ </Group>
774
+ )}
775
+ </Group>
776
+ );
777
+ }
778
+
779
+ /** Main shaken chart stack drawn ABOVE the left-edge fade so the line stays crisp:
780
+ * segment dividers, value/reference lines, the line/candles, axes, dot, degen,
781
+ * markers, and the loading/empty art. Background fills are in `ChartFillLayer`
782
+ * (below the fade); the live value text is `ChartValueOverlay` (above the fade). */
783
+ function ChartStack({ model }: { model: LiveChartModel }) {
784
+ const {
785
+ degenShakeTransform,
786
+ reveal,
787
+ engine,
788
+ effectivePadding,
789
+ palette,
790
+ skiaFont,
791
+ badgeCfg,
792
+ valueLineCfg,
793
+ dotY,
794
+ allRefLines,
795
+ resolvedSegments,
796
+ hasRecolorSegments,
797
+ segmentGradient,
798
+ thresholdCfg,
799
+ thresholdGeom,
800
+ thresholdStrokeColors,
801
+ formatValue,
802
+ lineGroupOpacity,
803
+ linePath,
804
+ strokeWidth,
805
+ lineProp,
806
+ candleGroupOpacity,
807
+ upWicksPath,
808
+ downWicksPath,
809
+ upBodiesPath,
810
+ downBodiesPath,
811
+ xAxisCfg,
812
+ xAxisEntries,
813
+ dotX,
814
+ liveDotOpacity,
815
+ pulseCfg,
816
+ dotCfg,
817
+ degenCfg,
818
+ degenPack,
819
+ degenPackRevision,
820
+ markersActive,
821
+ markersSV,
822
+ emptyText,
823
+ metricsCfg,
824
+ layoutWidth,
825
+ } = model;
826
+
827
+ return (
828
+ <Group transform={degenShakeTransform}>
650
829
  {/* Segment dividers + labels (behind the line). The scrub-focus emphasis is
651
830
  painted on the line stroke itself, below — this overlay draws no fill. */}
652
831
  {resolvedSegments.map((seg, i) => (
@@ -673,9 +852,12 @@ function ChartStack({ model }: { model: LiveChartModel }) {
673
852
  </Group>
674
853
  )}
675
854
 
676
- {allRefLines.map((rl) => (
855
+ {/* Index keys: reference lines are a positional array and two may share
856
+ value + label (e.g. duplicate working orders at the same price), which a
857
+ content-derived key would collapse to one. */}
858
+ {allRefLines.map((rl, i) => (
677
859
  <ReferenceLineOverlay
678
- key={`${rl.value ?? ""}:${rl.valueFrom ?? ""}:${rl.valueTo ?? ""}:${rl.from ?? ""}:${rl.to ?? ""}:${rl.label ?? ""}`}
860
+ key={i}
679
861
  engine={engine}
680
862
  padding={effectivePadding}
681
863
  line={rl}
@@ -685,6 +867,21 @@ function ChartStack({ model }: { model: LiveChartModel }) {
685
867
  />
686
868
  ))}
687
869
 
870
+ {/* Threshold marker line + label (behind the chart line). */}
871
+ {thresholdCfg?.line && (
872
+ <ThresholdLineOverlay
873
+ engine={engine}
874
+ padding={effectivePadding}
875
+ lineY={thresholdGeom.lineY}
876
+ visible={thresholdGeom.visible}
877
+ value={thresholdCfg.value}
878
+ cfg={thresholdCfg.line}
879
+ palette={palette}
880
+ font={skiaFont}
881
+ formatValue={formatValue}
882
+ />
883
+ )}
884
+
688
885
  {/* Chart line (fades out in candle mode). When segments recolor the line, a
689
886
  full-width gradient paints the base color outside segments and each
690
887
  segment's color within — so the line itself is recolored/faded (alpha in
@@ -698,7 +895,16 @@ function ChartStack({ model }: { model: LiveChartModel }) {
698
895
  strokeJoin="round"
699
896
  color={lineProp?.color ?? palette.line}
700
897
  >
701
- {hasRecolorSegments ? (
898
+ {thresholdCfg && thresholdStrokeColors ? (
899
+ // Vertical hard split at the threshold Y — supersedes line.colors and
900
+ // segment recoloring for the stroke while a threshold is set.
901
+ <LinearGradient
902
+ start={vec(0, 0)}
903
+ end={thresholdGeom.gradientEnd}
904
+ colors={thresholdStrokeColors}
905
+ positions={thresholdGeom.splitPositions}
906
+ />
907
+ ) : hasRecolorSegments ? (
702
908
  <LinearGradient
703
909
  start={vec(0, 0)}
704
910
  end={segmentGradient.gradientEnd}
@@ -794,7 +1000,24 @@ function ChartStack({ model }: { model: LiveChartModel }) {
794
1000
  </Group>
795
1001
  )}
796
1002
 
797
- {/* Loading / empty state before left-edge fade so the squiggle/empty art fades like the chart */}
1003
+ {/* Threshold label badgeon top of the line/dot/markers so it's never
1004
+ painted over (the dashed marker line itself stays behind the line, above). */}
1005
+ {thresholdCfg?.line && (
1006
+ <ThresholdBadgeOverlay
1007
+ engine={engine}
1008
+ padding={effectivePadding}
1009
+ lineY={thresholdGeom.lineY}
1010
+ visible={thresholdGeom.visible}
1011
+ value={thresholdCfg.value}
1012
+ cfg={thresholdCfg.line}
1013
+ palette={palette}
1014
+ font={skiaFont}
1015
+ formatValue={formatValue}
1016
+ />
1017
+ )}
1018
+
1019
+ {/* Loading / empty state — drawn with the line stack (above the fade) so the
1020
+ squiggle/empty art stays crisp, consistent with the line. */}
798
1021
  <LoadingOverlay
799
1022
  engine={engine}
800
1023
  padding={effectivePadding}
@@ -945,6 +1168,73 @@ function ChartBadgeLayer({ model }: { model: LiveChartModel }) {
945
1168
  );
946
1169
  }
947
1170
 
1171
+ /** Reference-line badges + labels, drawn ABOVE the left-edge fade so a
1172
+ * left-pinned badge (off-axis / `labelBadge`) and any label stay crisp instead
1173
+ * of being erased by the fade's dstOut. The lines/bands themselves render in the
1174
+ * base pass inside ChartStack (behind the chart content). */
1175
+ function ChartRefBadgeLayer({ model }: { model: LiveChartModel }) {
1176
+ const {
1177
+ allRefLines,
1178
+ engine,
1179
+ effectivePadding,
1180
+ palette,
1181
+ formatValue,
1182
+ skiaFont,
1183
+ degenShakeTransform,
1184
+ } = model;
1185
+ if (allRefLines.length === 0) return null;
1186
+ return (
1187
+ <Group transform={degenShakeTransform}>
1188
+ {allRefLines.map((rl, i) => (
1189
+ <ReferenceLineOverlay
1190
+ key={i}
1191
+ engine={engine}
1192
+ padding={effectivePadding}
1193
+ line={rl}
1194
+ palette={palette}
1195
+ formatValue={formatValue}
1196
+ font={skiaFont}
1197
+ badgeLayer
1198
+ />
1199
+ ))}
1200
+ </Group>
1201
+ );
1202
+ }
1203
+
1204
+ /** Scrub-action ("order ticket") reticle + action badge. Drawn OUTSIDE the degen
1205
+ * shake group so the rendered badge stays aligned with the untransformed tap
1206
+ * hit-test; it tracks the locked reticle, not the shaken stack. */
1207
+ function ChartScrubActionLayer({ model }: { model: LiveChartModel }) {
1208
+ const { scrubActionCfg, crosshair, engine, effectivePadding, palette, skiaFont } =
1209
+ model;
1210
+ if (
1211
+ !scrubActionCfg ||
1212
+ !crosshair.lockActive ||
1213
+ !crosshair.lockX ||
1214
+ !crosshair.lockY ||
1215
+ !crosshair.actionBadge
1216
+ ) {
1217
+ return null;
1218
+ }
1219
+ return (
1220
+ <ScrubActionOverlay
1221
+ lockActive={crosshair.lockActive}
1222
+ lockX={crosshair.lockX}
1223
+ lockY={crosshair.lockY}
1224
+ actionBadge={crosshair.actionBadge}
1225
+ timeBadge={crosshair.timeBadge}
1226
+ engine={engine}
1227
+ padding={effectivePadding}
1228
+ palette={palette}
1229
+ font={skiaFont}
1230
+ icon={scrubActionCfg.icon}
1231
+ lineColor={scrubActionCfg.lineColor}
1232
+ background={scrubActionCfg.background}
1233
+ iconColor={scrubActionCfg.iconColor}
1234
+ />
1235
+ );
1236
+ }
1237
+
948
1238
  export function LiveChart(props: LiveChartProps) {
949
1239
  const model = useLiveChartController(props);
950
1240
  const {
@@ -973,8 +1263,10 @@ export function LiveChart(props: LiveChartProps) {
973
1263
  accessibilityRole={accessibilityRole}
974
1264
  >
975
1265
  <Canvas style={{ flex: 1 }}>
976
- {/* Shaken chart stack left-edge fade is a sibling below so dstOut runs in canvas space */}
977
- <ChartStack model={model} />
1266
+ {/* Background fills first, then the left-edge fade (a canvas-space sibling
1267
+ so dstOut blends correctly), then the line stack on top — so the fade
1268
+ softens only the fills and the line stays crisp at the left edge. */}
1269
+ <ChartFillLayer model={model} />
978
1270
 
979
1271
  {leftEdgeFadeCfg && (
980
1272
  <LeftEdgeFade
@@ -986,6 +1278,12 @@ export function LiveChart(props: LiveChartProps) {
986
1278
  />
987
1279
  )}
988
1280
 
1281
+ {/* Line stack above the fade so the line stays crisp at the left edge. */}
1282
+ <ChartStack model={model} />
1283
+
1284
+ {/* Reference-line badges + labels above the fade so they stay crisp. */}
1285
+ <ChartRefBadgeLayer model={model} />
1286
+
989
1287
  <ChartValueOverlay model={model} />
990
1288
 
991
1289
  <ChartScrubLayer model={model} />
@@ -993,6 +1291,9 @@ export function LiveChart(props: LiveChartProps) {
993
1291
  {/* Live-price badge on top of the scrub dim so the dim never clips
994
1292
  its left edge (the badge tracks the live value, not the scrub). */}
995
1293
  <ChartBadgeLayer model={model} />
1294
+
1295
+ {/* Scrub-action reticle + action badge — top-most, no shake transform. */}
1296
+ <ChartScrubActionLayer model={model} />
996
1297
  </Canvas>
997
1298
 
998
1299
  {/* RN labels floated over the canvas (sibling of <Canvas>, an RN view).
@@ -448,9 +448,12 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
448
448
  </Group>
449
449
  )}
450
450
 
451
- {allRefLines.map((rl) => (
451
+ {/* Index keys: reference lines are a positional array and two may share
452
+ value + label (e.g. duplicate working orders at the same price), which a
453
+ content-derived key would collapse to one. */}
454
+ {allRefLines.map((rl, i) => (
452
455
  <ReferenceLineOverlay
453
- key={`${rl.value ?? ""}:${rl.valueFrom ?? ""}:${rl.valueTo ?? ""}:${rl.from ?? ""}:${rl.to ?? ""}:${rl.label ?? ""}`}
456
+ key={i}
454
457
  engine={engine}
455
458
  padding={effectivePadding}
456
459
  line={rl}
@@ -589,6 +592,37 @@ function SeriesValueLabelLayer({ model }: { model: LiveChartSeriesModel }) {
589
592
  );
590
593
  }
591
594
 
595
+ /** Reference-line badges + labels, drawn ABOVE the left-edge fade so they stay
596
+ * crisp (the lines/bands render in the base pass inside SeriesChartStack). */
597
+ function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
598
+ const {
599
+ allRefLines,
600
+ engine,
601
+ effectivePadding,
602
+ palette,
603
+ formatValue,
604
+ skiaFont,
605
+ degenShakeTransform,
606
+ } = model;
607
+ if (allRefLines.length === 0) return null;
608
+ return (
609
+ <Group transform={degenShakeTransform}>
610
+ {allRefLines.map((rl, i) => (
611
+ <ReferenceLineOverlay
612
+ key={i}
613
+ engine={engine}
614
+ padding={effectivePadding}
615
+ line={rl}
616
+ palette={palette}
617
+ formatValue={formatValue}
618
+ font={skiaFont}
619
+ badgeLayer
620
+ />
621
+ ))}
622
+ </Group>
623
+ );
624
+ }
625
+
592
626
  export function LiveChartSeries(props: LiveChartSeriesProps) {
593
627
  const model = useLiveChartSeriesController(props);
594
628
  const {
@@ -664,6 +698,9 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
664
698
  />
665
699
  )}
666
700
 
701
+ {/* Reference-line badges + labels above the fade so they stay crisp. */}
702
+ <SeriesRefBadgeLayer model={model} />
703
+
667
704
  {scrubCfg && (
668
705
  <CrosshairLine
669
706
  scrubX={crosshair.scrubX}