react-native-livechart 3.2.0 → 3.4.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 (59) hide show
  1. package/README.md +6 -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/SegmentDividerOverlay.d.ts +19 -0
  9. package/dist/components/SegmentDividerOverlay.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 +23 -1
  14. package/dist/core/resolveConfig.d.ts.map +1 -1
  15. package/dist/core/resolveSegment.d.ts +42 -0
  16. package/dist/core/resolveSegment.d.ts.map +1 -0
  17. package/dist/hooks/crosshairShared.d.ts +110 -0
  18. package/dist/hooks/crosshairShared.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/useSegmentDivider.d.ts +28 -0
  28. package/dist/hooks/useSegmentDivider.d.ts.map +1 -0
  29. package/dist/hooks/useSegmentLineGradient.d.ts +20 -0
  30. package/dist/hooks/useSegmentLineGradient.d.ts.map +1 -0
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/math/referenceLines.d.ts +23 -0
  34. package/dist/math/referenceLines.d.ts.map +1 -1
  35. package/dist/math/segments.d.ts +46 -0
  36. package/dist/math/segments.d.ts.map +1 -0
  37. package/dist/types.d.ts +184 -6
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/components/LiveChart.tsx +242 -26
  41. package/src/components/LiveChartSeries.tsx +39 -2
  42. package/src/components/ReferenceLineOverlay.tsx +121 -94
  43. package/src/components/ScrubActionOverlay.tsx +187 -0
  44. package/src/components/SegmentDividerOverlay.tsx +84 -0
  45. package/src/components/XAxisOverlay.tsx +2 -1
  46. package/src/constants.ts +5 -0
  47. package/src/core/resolveConfig.ts +40 -0
  48. package/src/core/resolveSegment.ts +62 -0
  49. package/src/hooks/crosshairShared.ts +293 -0
  50. package/src/hooks/useCrosshair.ts +340 -6
  51. package/src/hooks/useMarkers.ts +18 -2
  52. package/src/hooks/useReferenceLine.ts +259 -14
  53. package/src/hooks/useReferenceLinePress.ts +92 -0
  54. package/src/hooks/useSegmentDivider.ts +84 -0
  55. package/src/hooks/useSegmentLineGradient.ts +57 -0
  56. package/src/index.ts +4 -0
  57. package/src/math/referenceLines.ts +55 -0
  58. package/src/math/segments.ts +173 -0
  59. package/src/types.ts +192 -6
@@ -1,7 +1,7 @@
1
1
  import { useLayoutEffect, useState } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
- import { useSharedValue } from "react-native-reanimated";
4
+ import { useDerivedValue, useSharedValue } from "react-native-reanimated";
5
5
 
6
6
  /**
7
7
  * Single-series live chart. UX and prop vocabulary parallel Benji Taylor’s
@@ -29,12 +29,14 @@ import {
29
29
  resolveMetrics,
30
30
  resolvePulse,
31
31
  resolveScrub,
32
+ resolveScrubAction,
32
33
  resolveSelectionDot,
33
34
  resolveTradeStream,
34
35
  resolveValueLine,
35
36
  resolveXAxis,
36
37
  resolveYAxis,
37
38
  } from "../core/resolveConfig";
39
+ import { resolveSegment } from "../core/resolveSegment";
38
40
  import { useLiveChartEngine } from "../core/useLiveChartEngine";
39
41
  import { pulseRadialOutset } from "../draw/line";
40
42
  import { resolveChartLayout } from "../hooks/resolveChartLayout";
@@ -50,8 +52,10 @@ import { useDegen } from "../hooks/useDegen";
50
52
  import { useLiveChartHasData } from "../hooks/useLiveChartHasData";
51
53
  import { useLiveDot } from "../hooks/useLiveDot";
52
54
  import { useMarkers } from "../hooks/useMarkers";
55
+ import { useReferenceLinePress } from "../hooks/useReferenceLinePress";
53
56
  import { useModeBlend } from "../hooks/useModeBlend";
54
57
  import { useMomentum } from "../hooks/useMomentum";
58
+ import { useSegmentLineGradient } from "../hooks/useSegmentLineGradient";
55
59
  import { useSingleChartReverseMorphInputs } from "../hooks/useReverseMorphEngineInputs";
56
60
  import { useTradeStream } from "../hooks/useTradeStream";
57
61
  import { useXAxis } from "../hooks/useXAxis";
@@ -79,6 +83,8 @@ import { MarkerOverlay } from "./MarkerOverlay";
79
83
  import { MultiSeriesTooltipStack } from "./MultiSeriesTooltipStack";
80
84
  import { ValueTextOverlay } from "./ValueTextOverlay";
81
85
  import { ReferenceLineOverlay } from "./ReferenceLineOverlay";
86
+ import { ScrubActionOverlay } from "./ScrubActionOverlay";
87
+ import { SegmentDividerOverlay } from "./SegmentDividerOverlay";
82
88
  import { TradeStreamOverlay } from "./TradeStreamOverlay";
83
89
  import { ValueLineOverlay } from "./ValueLineOverlay";
84
90
  import { XAxisOverlay } from "./XAxisOverlay";
@@ -141,10 +147,12 @@ function useLiveChartController({
141
147
  showValue = false,
142
148
  valueMomentumColor = false,
143
149
  referenceLines,
150
+ segments,
144
151
  gridStyle,
145
152
  palette: paletteOverride,
146
153
  metrics,
147
154
  scrub = true,
155
+ scrubAction,
148
156
  selectionDot,
149
157
  tradeStream,
150
158
  degen,
@@ -155,6 +163,8 @@ function useLiveChartController({
155
163
 
156
164
  // ── Callbacks ───────────────────────────────────────────────────────────
157
165
  onScrub,
166
+ onScrubAction,
167
+ onReferenceLinePress,
158
168
  onGestureStart,
159
169
  onGestureEnd,
160
170
  onDegenShake,
@@ -172,6 +182,8 @@ function useLiveChartController({
172
182
  const bottomLabelCfg = resolveAxisLabel(bottomLabel);
173
183
  const badgeCfg = resolveBadge(badge);
174
184
  const scrubCfg = resolveScrub(scrub);
185
+ // Static charts run no gestures, so scrub-action (tap/drag to lock a price) is off.
186
+ const scrubActionCfg = isStatic ? null : resolveScrubAction(scrubAction);
175
187
  const gradientCfg = isCandle ? null : resolveGradient(gradient);
176
188
  const valueLineCfg = resolveValueLine(valueLine);
177
189
  // Static charts run zero loops: force the pulse off so its `withRepeat`-driven
@@ -200,6 +212,20 @@ function useLiveChartController({
200
212
  paletteOverride,
201
213
  );
202
214
 
215
+ // Time-range segments (sessions, after-hours, …). Resolved once per render like
216
+ // reference lines; the divider/label/muted colors default to the chart palette
217
+ // (no per-segment base color needed). `hasRecolorSegments` is a render-time gate
218
+ // for the line-recolor gradient pass (per-frame visibility is handled by the
219
+ // gradient's transparent stops, not by mounting/unmounting the Path).
220
+ const resolvedSegments = (segments ?? []).map((s) =>
221
+ resolveSegment(s, {
222
+ muted: palette.gridLabel,
223
+ divider: palette.refLine,
224
+ label: palette.refLabel,
225
+ }),
226
+ );
227
+ const hasRecolorSegments = resolvedSegments.some((s) => s.recolorLine);
228
+
203
229
  const leftEdgeFadeCfg = resolveLeftEdgeFade(
204
230
  leftEdgeFade,
205
231
  leftEdgeFadeColorsFromBgRgb(palette.bgRgb),
@@ -386,6 +412,46 @@ function useLiveChartController({
386
412
  }
387
413
  : undefined;
388
414
 
415
+ const markersActive = markers != null;
416
+ // `projected` is used internally by the hit-test gesture; the overlay
417
+ // self-projects, so we only need the gesture + hit-test here. Built BEFORE
418
+ // `useCrosshair` so the scrub-action tap can defer to a marker under the finger.
419
+ const { tapGesture: markerTapGesture, hitTest: markerHitTest } = useMarkers(
420
+ engine,
421
+ effectivePadding,
422
+ markersSV,
423
+ !isStatic && markersActive,
424
+ markerHitRadius,
425
+ onMarkerHover,
426
+ undefined, // seriesSV — single-series has none
427
+ engine.data, // anchor value-less markers to the line
428
+ !isStatic, // static: no marker-projection loop
429
+ );
430
+
431
+ // Pressable reference-line badges (working orders / alerts). Built before
432
+ // `useCrosshair` so the scrub-action tap can defer to a badge under the finger.
433
+ const refPressActive = onReferenceLinePress != null && allRefLines.length > 0;
434
+ const { tapGesture: refLineTapGesture, hitTest: refLineHitTest } =
435
+ useReferenceLinePress(
436
+ engine,
437
+ effectivePadding,
438
+ allRefLines,
439
+ skiaFont,
440
+ formatValue,
441
+ !isStatic && refPressActive,
442
+ markerHitRadius,
443
+ onReferenceLinePress,
444
+ );
445
+
446
+ // Combined "defer" hit-test for the scrub-action place-tap: yield to a marker or
447
+ // a pressable badge under the finger so the tap is routed there instead of
448
+ // dropping a reticle. (Both hit-tests return false when their feature is off.)
449
+ /* istanbul ignore next -- worklet runs on the UI thread, not in Jest */
450
+ const deferTapHit = (x: number, y: number): boolean => {
451
+ "worklet";
452
+ return markerHitTest(x, y) || refLineHitTest(x, y);
453
+ };
454
+
389
455
  const crosshair = useCrosshair(
390
456
  engine,
391
457
  effectivePadding,
@@ -394,32 +460,51 @@ function useLiveChartController({
394
460
  formatTime,
395
461
  skiaFont,
396
462
  // Static charts have an inert pan gesture — no scrub work on the UI thread.
397
- !isStatic && scrubCfg !== null,
463
+ // Scrub-action also needs the gesture live even when plain scrub is off.
464
+ !isStatic && (scrubCfg !== null || scrubActionCfg !== null),
398
465
  onScrub,
399
466
  candleOpts,
400
467
  scrubCfg?.panGestureDelay ?? 0,
401
468
  onGestureStart,
402
469
  onGestureEnd,
470
+ scrubActionCfg,
471
+ onScrubAction,
472
+ metricsCfg.badge,
473
+ markersActive || refPressActive ? deferTapHit : undefined,
403
474
  );
404
475
 
405
- const markersActive = markers != null;
406
- // `projected` is used internally by the hit-test gesture; the overlay
407
- // self-projects, so we only need the gesture here.
408
- const { tapGesture: markerTapGesture } = useMarkers(
409
- engine,
410
- effectivePadding,
411
- markersSV,
412
- !isStatic && markersActive,
413
- markerHitRadius,
414
- onMarkerHover,
415
- undefined, // seriesSV single-series has none
416
- engine.data, // anchor value-less markers to the line
417
- !isStatic, // static: no marker-projection loop
418
- );
419
-
420
- const rootGesture = markersActive
421
- ? Gesture.Race(crosshair.gesture, markerTapGesture)
422
- : crosshair.gesture;
476
+ // Scrub-action composes a Tap (place/move the reticle, press the badge, dismiss)
477
+ // ahead of the pan via Exclusive, so a tap is tried first and only becomes a
478
+ // drag (live-scrub, or lock-adjust once placed) if the finger moves. `Exclusive`
479
+ // (not `Race`) prevents a jittery tap from being swallowed by the pan.
480
+ const baseGesture =
481
+ scrubActionCfg !== null && crosshair.tapGesture
482
+ ? Gesture.Exclusive(crosshair.tapGesture, crosshair.gesture)
483
+ : crosshair.gesture;
484
+
485
+ // Overlay taps that hit-test discrete targets (marker dots, reference-line
486
+ // badges). They must all see each tap, so they're combined with `Simultaneous`
487
+ // (not `Race`, which cancels the loser and would drop one). The scrub-action
488
+ // action tap defers to them via `deferTapHit`, so a tap on an overlay is routed
489
+ // there instead of placing a reticle.
490
+ const overlayTaps = [
491
+ markersActive ? markerTapGesture : null,
492
+ refPressActive ? refLineTapGesture : null,
493
+ ].filter((g): g is NonNullable<typeof g> => g !== null);
494
+
495
+ let rootGesture = baseGesture;
496
+ if (overlayTaps.length > 0) {
497
+ const tapGroup =
498
+ overlayTaps.length === 1
499
+ ? overlayTaps[0]
500
+ : Gesture.Simultaneous(overlayTaps[0], overlayTaps[1]);
501
+ // With scrub-action the action tap shares the gesture space with the overlay
502
+ // taps (Simultaneous); without it the pan only needs to race the tap group.
503
+ rootGesture =
504
+ scrubActionCfg !== null
505
+ ? Gesture.Simultaneous(baseGesture, tapGroup)
506
+ : Gesture.Race(baseGesture, tapGroup);
507
+ }
423
508
 
424
509
  // ── Derived render values ──────────────────────────────────────────────
425
510
  const {
@@ -437,6 +522,29 @@ function useLiveChartController({
437
522
  effectivePadding,
438
523
  );
439
524
 
525
+ // Scrub-focus gradient painted onto the line stroke: uniform at rest, and while
526
+ // scrubbing (or with an `active` segment) the focused segment stays full while
527
+ // the others are de-emphasized. Declared after `crosshair` so it can read the
528
+ // live scrub state.
529
+ const segmentGradient = useSegmentLineGradient(
530
+ engine,
531
+ resolvedSegments,
532
+ effectivePadding,
533
+ lineProp?.color ?? palette.line,
534
+ crosshair.scrubX,
535
+ crosshair.scrubActive,
536
+ );
537
+
538
+ // Hide the live dot while scrubbing when a selection dot is marking the scrub
539
+ // point instead — otherwise both dots show at once.
540
+ const selectionDotDuringScrub =
541
+ !isStatic && scrubCfg !== null && selectionDotCfg !== null;
542
+ const liveDotOpacity = useDerivedValue(
543
+ () =>
544
+ reveal.dotOpacity.value *
545
+ (selectionDotDuringScrub && crosshair.scrubActive.value ? 0 : 1),
546
+ );
547
+
440
548
  return {
441
549
  // passthrough props the render needs
442
550
  style,
@@ -453,6 +561,7 @@ function useLiveChartController({
453
561
  xAxisCfg,
454
562
  badgeCfg,
455
563
  scrubCfg,
564
+ scrubActionCfg,
456
565
  gradientCfg,
457
566
  valueLineCfg,
458
567
  pulseCfg,
@@ -464,6 +573,9 @@ function useLiveChartController({
464
573
  leftEdgeFadeCfg,
465
574
  metricsCfg,
466
575
  allRefLines,
576
+ resolvedSegments,
577
+ hasRecolorSegments,
578
+ segmentGradient,
467
579
  badgeUsesRightGutter,
468
580
  // theme / layout / fonts
469
581
  palette,
@@ -493,6 +605,7 @@ function useLiveChartController({
493
605
  downWicksPath,
494
606
  dotX,
495
607
  dotY,
608
+ liveDotOpacity,
496
609
  momentumSV,
497
610
  tradeMarkers,
498
611
  degenPack,
@@ -540,6 +653,9 @@ function ChartStack({ model }: { model: LiveChartModel }) {
540
653
  valueLineCfg,
541
654
  dotY,
542
655
  allRefLines,
656
+ resolvedSegments,
657
+ hasRecolorSegments,
658
+ segmentGradient,
543
659
  formatValue,
544
660
  lineGroupOpacity,
545
661
  linePath,
@@ -553,6 +669,7 @@ function ChartStack({ model }: { model: LiveChartModel }) {
553
669
  xAxisCfg,
554
670
  xAxisEntries,
555
671
  dotX,
672
+ liveDotOpacity,
556
673
  pulseCfg,
557
674
  dotCfg,
558
675
  degenCfg,
@@ -598,6 +715,18 @@ function ChartStack({ model }: { model: LiveChartModel }) {
598
715
  </Group>
599
716
  )}
600
717
 
718
+ {/* Segment dividers + labels (behind the line). The scrub-focus emphasis is
719
+ painted on the line stroke itself, below — this overlay draws no fill. */}
720
+ {resolvedSegments.map((seg, i) => (
721
+ <SegmentDividerOverlay
722
+ key={`seg-${seg.from ?? "start"}-${seg.to ?? "end"}-${i}`}
723
+ engine={engine}
724
+ padding={effectivePadding}
725
+ segment={seg}
726
+ font={skiaFont}
727
+ />
728
+ ))}
729
+
601
730
  {/* Value line + reference line (behind chart line) */}
602
731
  {valueLineCfg && (
603
732
  <Group opacity={reveal.lineOpacity}>
@@ -612,9 +741,12 @@ function ChartStack({ model }: { model: LiveChartModel }) {
612
741
  </Group>
613
742
  )}
614
743
 
615
- {allRefLines.map((rl) => (
744
+ {/* Index keys: reference lines are a positional array and two may share
745
+ value + label (e.g. duplicate working orders at the same price), which a
746
+ content-derived key would collapse to one. */}
747
+ {allRefLines.map((rl, i) => (
616
748
  <ReferenceLineOverlay
617
- key={`${rl.value ?? ""}:${rl.valueFrom ?? ""}:${rl.valueTo ?? ""}:${rl.from ?? ""}:${rl.to ?? ""}:${rl.label ?? ""}`}
749
+ key={i}
618
750
  engine={engine}
619
751
  padding={effectivePadding}
620
752
  line={rl}
@@ -624,7 +756,10 @@ function ChartStack({ model }: { model: LiveChartModel }) {
624
756
  />
625
757
  ))}
626
758
 
627
- {/* Chart line (fades out in candle mode) */}
759
+ {/* Chart line (fades out in candle mode). When segments recolor the line, a
760
+ full-width gradient paints the base color outside segments and each
761
+ segment's color within — so the line itself is recolored/faded (alpha in
762
+ the segment color reduces the line's opacity), not covered by an overlay. */}
628
763
  <Group opacity={lineGroupOpacity}>
629
764
  <Path
630
765
  path={linePath}
@@ -634,7 +769,14 @@ function ChartStack({ model }: { model: LiveChartModel }) {
634
769
  strokeJoin="round"
635
770
  color={lineProp?.color ?? palette.line}
636
771
  >
637
- {lineProp?.colors?.length ? (
772
+ {hasRecolorSegments ? (
773
+ <LinearGradient
774
+ start={vec(0, 0)}
775
+ end={segmentGradient.gradientEnd}
776
+ colors={segmentGradient.colors}
777
+ positions={segmentGradient.positions}
778
+ />
779
+ ) : lineProp?.colors?.length ? (
638
780
  <LinearGradient
639
781
  start={vec(0, 0)}
640
782
  end={vec(layoutWidth, 0)}
@@ -678,9 +820,10 @@ function ChartStack({ model }: { model: LiveChartModel }) {
678
820
  )}
679
821
 
680
822
  {/* Live dot — the badge is drawn later (after the scrub layer) so the
681
- scrub dim never clips the live-price badge's left edge. */}
823
+ scrub dim never clips the live-price badge's left edge. Hidden while
824
+ scrubbing when a selection dot marks the scrub point instead. */}
682
825
  {dotCfg.show && (
683
- <Group opacity={reveal.dotOpacity}>
826
+ <Group opacity={liveDotOpacity}>
684
827
  <DotOverlay
685
828
  dotX={dotX}
686
829
  dotY={dotY}
@@ -873,6 +1016,73 @@ function ChartBadgeLayer({ model }: { model: LiveChartModel }) {
873
1016
  );
874
1017
  }
875
1018
 
1019
+ /** Reference-line badges + labels, drawn ABOVE the left-edge fade so a
1020
+ * left-pinned badge (off-axis / `labelBadge`) and any label stay crisp instead
1021
+ * of being erased by the fade's dstOut. The lines/bands themselves render in the
1022
+ * base pass inside ChartStack (behind the chart content). */
1023
+ function ChartRefBadgeLayer({ model }: { model: LiveChartModel }) {
1024
+ const {
1025
+ allRefLines,
1026
+ engine,
1027
+ effectivePadding,
1028
+ palette,
1029
+ formatValue,
1030
+ skiaFont,
1031
+ degenShakeTransform,
1032
+ } = model;
1033
+ if (allRefLines.length === 0) return null;
1034
+ return (
1035
+ <Group transform={degenShakeTransform}>
1036
+ {allRefLines.map((rl, i) => (
1037
+ <ReferenceLineOverlay
1038
+ key={i}
1039
+ engine={engine}
1040
+ padding={effectivePadding}
1041
+ line={rl}
1042
+ palette={palette}
1043
+ formatValue={formatValue}
1044
+ font={skiaFont}
1045
+ badgeLayer
1046
+ />
1047
+ ))}
1048
+ </Group>
1049
+ );
1050
+ }
1051
+
1052
+ /** Scrub-action ("order ticket") reticle + action badge. Drawn OUTSIDE the degen
1053
+ * shake group so the rendered badge stays aligned with the untransformed tap
1054
+ * hit-test; it tracks the locked reticle, not the shaken stack. */
1055
+ function ChartScrubActionLayer({ model }: { model: LiveChartModel }) {
1056
+ const { scrubActionCfg, crosshair, engine, effectivePadding, palette, skiaFont } =
1057
+ model;
1058
+ if (
1059
+ !scrubActionCfg ||
1060
+ !crosshair.lockActive ||
1061
+ !crosshair.lockX ||
1062
+ !crosshair.lockY ||
1063
+ !crosshair.actionBadge
1064
+ ) {
1065
+ return null;
1066
+ }
1067
+ return (
1068
+ <ScrubActionOverlay
1069
+ lockActive={crosshair.lockActive}
1070
+ lockX={crosshair.lockX}
1071
+ lockY={crosshair.lockY}
1072
+ actionBadge={crosshair.actionBadge}
1073
+ timeBadge={crosshair.timeBadge}
1074
+ engine={engine}
1075
+ padding={effectivePadding}
1076
+ palette={palette}
1077
+ font={skiaFont}
1078
+ icon={scrubActionCfg.icon}
1079
+ lineColor={scrubActionCfg.lineColor}
1080
+ background={scrubActionCfg.background}
1081
+ iconColor={scrubActionCfg.iconColor}
1082
+ />
1083
+ );
1084
+ }
1085
+
876
1086
  export function LiveChart(props: LiveChartProps) {
877
1087
  const model = useLiveChartController(props);
878
1088
  const {
@@ -914,6 +1124,9 @@ export function LiveChart(props: LiveChartProps) {
914
1124
  />
915
1125
  )}
916
1126
 
1127
+ {/* Reference-line badges + labels above the fade so they stay crisp. */}
1128
+ <ChartRefBadgeLayer model={model} />
1129
+
917
1130
  <ChartValueOverlay model={model} />
918
1131
 
919
1132
  <ChartScrubLayer model={model} />
@@ -921,6 +1134,9 @@ export function LiveChart(props: LiveChartProps) {
921
1134
  {/* Live-price badge on top of the scrub dim so the dim never clips
922
1135
  its left edge (the badge tracks the live value, not the scrub). */}
923
1136
  <ChartBadgeLayer model={model} />
1137
+
1138
+ {/* Scrub-action reticle + action badge — top-most, no shake transform. */}
1139
+ <ChartScrubActionLayer model={model} />
924
1140
  </Canvas>
925
1141
 
926
1142
  {/* 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}