react-native-livechart 3.4.0 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/ThresholdLineOverlay.d.ts +34 -0
- package/dist/components/ThresholdLineOverlay.d.ts.map +1 -0
- package/dist/core/resolveConfig.d.ts +33 -1
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/draw/markerAtlas.d.ts +16 -1
- package/dist/draw/markerAtlas.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts +5 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useThreshold.d.ts +22 -0
- package/dist/hooks/useThreshold.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/threshold.d.ts +25 -0
- package/dist/math/threshold.d.ts.map +1 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/LiveChart.tsx +197 -40
- package/src/components/MarkerOverlay.tsx +13 -5
- package/src/components/ThresholdLineOverlay.tsx +172 -0
- package/src/core/resolveConfig.ts +62 -0
- package/src/draw/markerAtlas.ts +31 -5
- package/src/hooks/useChartPaths.ts +25 -1
- package/src/hooks/useThreshold.ts +63 -0
- package/src/index.ts +2 -0
- package/src/math/threshold.ts +59 -0
- package/src/types.ts +61 -0
|
@@ -31,11 +31,13 @@ import {
|
|
|
31
31
|
resolveScrub,
|
|
32
32
|
resolveScrubAction,
|
|
33
33
|
resolveSelectionDot,
|
|
34
|
+
resolveThreshold,
|
|
34
35
|
resolveTradeStream,
|
|
35
36
|
resolveValueLine,
|
|
36
37
|
resolveXAxis,
|
|
37
38
|
resolveYAxis,
|
|
38
39
|
} from "../core/resolveConfig";
|
|
40
|
+
import type { ResolvedThresholdConfig } from "../core/resolveConfig";
|
|
39
41
|
import { resolveSegment } from "../core/resolveSegment";
|
|
40
42
|
import { useLiveChartEngine } from "../core/useLiveChartEngine";
|
|
41
43
|
import { pulseRadialOutset } from "../draw/line";
|
|
@@ -57,6 +59,7 @@ import { useModeBlend } from "../hooks/useModeBlend";
|
|
|
57
59
|
import { useMomentum } from "../hooks/useMomentum";
|
|
58
60
|
import { useSegmentLineGradient } from "../hooks/useSegmentLineGradient";
|
|
59
61
|
import { useSingleChartReverseMorphInputs } from "../hooks/useReverseMorphEngineInputs";
|
|
62
|
+
import { useThreshold } from "../hooks/useThreshold";
|
|
60
63
|
import { useTradeStream } from "../hooks/useTradeStream";
|
|
61
64
|
import { useXAxis } from "../hooks/useXAxis";
|
|
62
65
|
import { useYAxis } from "../hooks/useYAxis";
|
|
@@ -69,9 +72,19 @@ import { collectReferenceValues } from "../math/referenceLines";
|
|
|
69
72
|
import {
|
|
70
73
|
applyPaletteOverride,
|
|
71
74
|
leftEdgeFadeColorsFromBgRgb,
|
|
75
|
+
parseColorRgb,
|
|
72
76
|
resolveTheme,
|
|
73
77
|
} from "../theme";
|
|
74
|
-
import type {
|
|
78
|
+
import type {
|
|
79
|
+
LiveChartPalette,
|
|
80
|
+
LiveChartProps,
|
|
81
|
+
Marker,
|
|
82
|
+
TradeEvent,
|
|
83
|
+
} from "../types";
|
|
84
|
+
import {
|
|
85
|
+
ThresholdBadgeOverlay,
|
|
86
|
+
ThresholdLineOverlay,
|
|
87
|
+
} from "./ThresholdLineOverlay";
|
|
75
88
|
import { AxisLabelOverlay } from "./AxisLabelOverlay";
|
|
76
89
|
import { BadgeOverlay } from "./BadgeOverlay";
|
|
77
90
|
import { CrosshairOverlay } from "./CrosshairOverlay";
|
|
@@ -90,6 +103,32 @@ import { ValueLineOverlay } from "./ValueLineOverlay";
|
|
|
90
103
|
import { XAxisOverlay } from "./XAxisOverlay";
|
|
91
104
|
import { YAxisOverlay } from "./YAxisOverlay";
|
|
92
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
|
+
|
|
93
132
|
/**
|
|
94
133
|
* Resolves props → configs → theme/layout → engine → per-frame derived values and
|
|
95
134
|
* overlay hooks, returning a single render model. All the chart's wiring lives
|
|
@@ -148,6 +187,7 @@ function useLiveChartController({
|
|
|
148
187
|
valueMomentumColor = false,
|
|
149
188
|
referenceLines,
|
|
150
189
|
segments,
|
|
190
|
+
threshold,
|
|
151
191
|
gridStyle,
|
|
152
192
|
palette: paletteOverride,
|
|
153
193
|
metrics,
|
|
@@ -173,6 +213,9 @@ function useLiveChartController({
|
|
|
173
213
|
const tradeStreamSV = tradeStream ?? emptyTradeStream;
|
|
174
214
|
const emptyMarkers = useSharedValue<Marker[]>([]);
|
|
175
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);
|
|
176
219
|
const isCandle = mode === "candle";
|
|
177
220
|
|
|
178
221
|
// ── Resolve feature configs ────────────────────────────────────────────
|
|
@@ -185,6 +228,9 @@ function useLiveChartController({
|
|
|
185
228
|
// Static charts run no gestures, so scrub-action (tap/drag to lock a price) is off.
|
|
186
229
|
const scrubActionCfg = isStatic ? null : resolveScrubAction(scrubAction);
|
|
187
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);
|
|
188
234
|
const valueLineCfg = resolveValueLine(valueLine);
|
|
189
235
|
// Static charts run zero loops: force the pulse off so its `withRepeat`-driven
|
|
190
236
|
// ring never starts (the DotOverlay reads `pulseCfg`, so null = no pulse).
|
|
@@ -335,10 +381,23 @@ function useLiveChartController({
|
|
|
335
381
|
// ── Per-frame derived values ───────────────────────────────────────────
|
|
336
382
|
const { layoutWidth, layoutHeight, onLayout } = useCanvasLayout(engine);
|
|
337
383
|
|
|
338
|
-
|
|
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(
|
|
339
396
|
engine,
|
|
340
397
|
effectivePadding,
|
|
341
398
|
reveal.morphT,
|
|
399
|
+
// Only build the band path when the fill is actually on.
|
|
400
|
+
thresholdCfg?.fill ? thresholdGeom.lineY : undefined,
|
|
342
401
|
);
|
|
343
402
|
const { upBodiesPath, downBodiesPath, upWicksPath, downWicksPath } =
|
|
344
403
|
useCandlePaths(
|
|
@@ -576,6 +635,10 @@ function useLiveChartController({
|
|
|
576
635
|
resolvedSegments,
|
|
577
636
|
hasRecolorSegments,
|
|
578
637
|
segmentGradient,
|
|
638
|
+
thresholdCfg,
|
|
639
|
+
thresholdGeom,
|
|
640
|
+
thresholdStrokeColors: thresholdStopColors?.stroke ?? null,
|
|
641
|
+
thresholdFillColors: thresholdStopColors?.fill ?? null,
|
|
579
642
|
badgeUsesRightGutter,
|
|
580
643
|
// theme / layout / fonts
|
|
581
644
|
palette,
|
|
@@ -599,6 +662,7 @@ function useLiveChartController({
|
|
|
599
662
|
onLayout,
|
|
600
663
|
linePath,
|
|
601
664
|
fillPath,
|
|
665
|
+
thresholdFillPath,
|
|
602
666
|
upBodiesPath,
|
|
603
667
|
downBodiesPath,
|
|
604
668
|
upWicksPath,
|
|
@@ -629,10 +693,13 @@ function useLiveChartController({
|
|
|
629
693
|
|
|
630
694
|
type LiveChartModel = ReturnType<typeof useLiveChartController>;
|
|
631
695
|
|
|
632
|
-
/**
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
|
|
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 }) {
|
|
636
703
|
const {
|
|
637
704
|
degenShakeTransform,
|
|
638
705
|
yAxisCfg,
|
|
@@ -645,41 +712,16 @@ function ChartStack({ model }: { model: LiveChartModel }) {
|
|
|
645
712
|
badgeUsesRightGutter,
|
|
646
713
|
badgeCfg,
|
|
647
714
|
gridStyleCfg,
|
|
715
|
+
metricsCfg,
|
|
648
716
|
gradientCfg,
|
|
649
717
|
fillPath,
|
|
650
718
|
gradientEnd,
|
|
651
719
|
gradientColors,
|
|
652
720
|
gradientPositions,
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
hasRecolorSegments,
|
|
658
|
-
segmentGradient,
|
|
659
|
-
formatValue,
|
|
660
|
-
lineGroupOpacity,
|
|
661
|
-
linePath,
|
|
662
|
-
strokeWidth,
|
|
663
|
-
lineProp,
|
|
664
|
-
candleGroupOpacity,
|
|
665
|
-
upWicksPath,
|
|
666
|
-
downWicksPath,
|
|
667
|
-
upBodiesPath,
|
|
668
|
-
downBodiesPath,
|
|
669
|
-
xAxisCfg,
|
|
670
|
-
xAxisEntries,
|
|
671
|
-
dotX,
|
|
672
|
-
liveDotOpacity,
|
|
673
|
-
pulseCfg,
|
|
674
|
-
dotCfg,
|
|
675
|
-
degenCfg,
|
|
676
|
-
degenPack,
|
|
677
|
-
degenPackRevision,
|
|
678
|
-
markersActive,
|
|
679
|
-
markersSV,
|
|
680
|
-
emptyText,
|
|
681
|
-
metricsCfg,
|
|
682
|
-
layoutWidth,
|
|
721
|
+
thresholdCfg,
|
|
722
|
+
thresholdGeom,
|
|
723
|
+
thresholdFillPath,
|
|
724
|
+
thresholdFillColors,
|
|
683
725
|
} = model;
|
|
684
726
|
|
|
685
727
|
return (
|
|
@@ -715,6 +757,75 @@ function ChartStack({ model }: { model: LiveChartModel }) {
|
|
|
715
757
|
</Group>
|
|
716
758
|
)}
|
|
717
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}>
|
|
718
829
|
{/* Segment dividers + labels (behind the line). The scrub-focus emphasis is
|
|
719
830
|
painted on the line stroke itself, below — this overlay draws no fill. */}
|
|
720
831
|
{resolvedSegments.map((seg, i) => (
|
|
@@ -756,6 +867,21 @@ function ChartStack({ model }: { model: LiveChartModel }) {
|
|
|
756
867
|
/>
|
|
757
868
|
))}
|
|
758
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
|
+
|
|
759
885
|
{/* Chart line (fades out in candle mode). When segments recolor the line, a
|
|
760
886
|
full-width gradient paints the base color outside segments and each
|
|
761
887
|
segment's color within — so the line itself is recolored/faded (alpha in
|
|
@@ -769,7 +895,16 @@ function ChartStack({ model }: { model: LiveChartModel }) {
|
|
|
769
895
|
strokeJoin="round"
|
|
770
896
|
color={lineProp?.color ?? palette.line}
|
|
771
897
|
>
|
|
772
|
-
{
|
|
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 ? (
|
|
773
908
|
<LinearGradient
|
|
774
909
|
start={vec(0, 0)}
|
|
775
910
|
end={segmentGradient.gradientEnd}
|
|
@@ -865,7 +1000,24 @@ function ChartStack({ model }: { model: LiveChartModel }) {
|
|
|
865
1000
|
</Group>
|
|
866
1001
|
)}
|
|
867
1002
|
|
|
868
|
-
{/*
|
|
1003
|
+
{/* Threshold label badge — on 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. */}
|
|
869
1021
|
<LoadingOverlay
|
|
870
1022
|
engine={engine}
|
|
871
1023
|
padding={effectivePadding}
|
|
@@ -1111,8 +1263,10 @@ export function LiveChart(props: LiveChartProps) {
|
|
|
1111
1263
|
accessibilityRole={accessibilityRole}
|
|
1112
1264
|
>
|
|
1113
1265
|
<Canvas style={{ flex: 1 }}>
|
|
1114
|
-
{/*
|
|
1115
|
-
|
|
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} />
|
|
1116
1270
|
|
|
1117
1271
|
{leftEdgeFadeCfg && (
|
|
1118
1272
|
<LeftEdgeFade
|
|
@@ -1124,6 +1278,9 @@ export function LiveChart(props: LiveChartProps) {
|
|
|
1124
1278
|
/>
|
|
1125
1279
|
)}
|
|
1126
1280
|
|
|
1281
|
+
{/* Line stack above the fade so the line stays crisp at the left edge. */}
|
|
1282
|
+
<ChartStack model={model} />
|
|
1283
|
+
|
|
1127
1284
|
{/* Reference-line badges + labels above the fade so they stay crisp. */}
|
|
1128
1285
|
<ChartRefBadgeLayer model={model} />
|
|
1129
1286
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type SkFont,
|
|
7
7
|
} from "@shopify/react-native-skia";
|
|
8
8
|
import { useMemo, useRef, useState } from "react";
|
|
9
|
+
import { PixelRatio } from "react-native";
|
|
9
10
|
import {
|
|
10
11
|
useDerivedValue,
|
|
11
12
|
useAnimatedReaction,
|
|
@@ -189,13 +190,19 @@ export function MarkerOverlay({
|
|
|
189
190
|
}, [snapshot]);
|
|
190
191
|
// Cells bake in resolved colors; include the palette fields they depend on.
|
|
191
192
|
const paletteKey = `${palette.bgRgb.join(",")}|${palette.line}|${palette.refLine}|${palette.dotUp}|${palette.refLabel}`;
|
|
193
|
+
// Rasterize at the screen's device-pixel ratio so sprites stay crisp on
|
|
194
|
+
// retina canvases instead of being upscaled from a logical-sized texture.
|
|
195
|
+
const dpr = PixelRatio.get();
|
|
192
196
|
const atlas = useMemo(
|
|
193
|
-
() => buildMarkerAtlas(snapshot, palette, font),
|
|
197
|
+
() => buildMarkerAtlas(snapshot, palette, font, dpr),
|
|
194
198
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey capture the inputs that change cell pixels
|
|
195
|
-
[appearanceKey, paletteKey, font],
|
|
199
|
+
[appearanceKey, paletteKey, font, dpr],
|
|
196
200
|
);
|
|
197
201
|
const cells: Record<string, AtlasCell> = atlas.cells;
|
|
198
202
|
const atlasImage = atlas.image;
|
|
203
|
+
// Inverse of the texture's device-pixel scale; shrinks each hi-res cell back
|
|
204
|
+
// to its logical on-canvas size in the per-frame blit.
|
|
205
|
+
const invScale = 1 / atlas.scale;
|
|
199
206
|
|
|
200
207
|
// One pooled, ping-ponged projection buffer reused every frame (no per-frame
|
|
201
208
|
// array allocation for the projection itself).
|
|
@@ -240,15 +247,16 @@ export function MarkerOverlay({
|
|
|
240
247
|
if (isConnectorMarker(m)) continue;
|
|
241
248
|
const cell = cells[markerAppearanceSig(m)];
|
|
242
249
|
if (!cell) continue;
|
|
243
|
-
// Center the cell on the projected point
|
|
250
|
+
// Center the cell on the projected point. The cell's source rect is in
|
|
251
|
+
// device pixels, so scale by 1/dpr to land it at its logical size.
|
|
244
252
|
transforms.push(
|
|
245
|
-
Skia.RSXform(
|
|
253
|
+
Skia.RSXform(invScale, 0, pt.x - cell.w / 2, pt.y - cell.h / 2),
|
|
246
254
|
);
|
|
247
255
|
sprites.push(cell.rect);
|
|
248
256
|
}
|
|
249
257
|
return { transforms, sprites };
|
|
250
258
|
},
|
|
251
|
-
[cells, markers, engine, padding, series, lineData],
|
|
259
|
+
[cells, invScale, markers, engine, padding, series, lineData],
|
|
252
260
|
);
|
|
253
261
|
const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]);
|
|
254
262
|
const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DashPathEffect,
|
|
3
|
+
Group,
|
|
4
|
+
Path,
|
|
5
|
+
RoundedRect,
|
|
6
|
+
Text as SkiaText,
|
|
7
|
+
type SkFont,
|
|
8
|
+
} from "@shopify/react-native-skia";
|
|
9
|
+
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
10
|
+
|
|
11
|
+
import type { ResolvedThresholdLineConfig } from "../core/resolveConfig";
|
|
12
|
+
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
13
|
+
import type { ChartPadding } from "../draw/line";
|
|
14
|
+
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
15
|
+
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
16
|
+
import type { LiveChartPalette } from "../types";
|
|
17
|
+
|
|
18
|
+
/** Padding inside the marker badge pill, in px (mirrors `ReferenceLineOverlay`). */
|
|
19
|
+
const BADGE_PAD_X = 6;
|
|
20
|
+
const BADGE_PAD_Y = 3;
|
|
21
|
+
const BADGE_RADIUS = 5;
|
|
22
|
+
/** Inset from the canvas edge — small so the badge sits as flush to the edge as possible. */
|
|
23
|
+
const BADGE_EDGE_INSET = 2;
|
|
24
|
+
|
|
25
|
+
interface ThresholdMarkerProps {
|
|
26
|
+
engine: ChartEngineLayout;
|
|
27
|
+
padding: ChartPadding;
|
|
28
|
+
lineY: SharedValue<number>;
|
|
29
|
+
visible: SharedValue<boolean>;
|
|
30
|
+
value: SharedValue<number>;
|
|
31
|
+
cfg: ResolvedThresholdLineConfig;
|
|
32
|
+
palette: LiveChartPalette;
|
|
33
|
+
font: SkFont;
|
|
34
|
+
formatValue: (v: number) => string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The dashed horizontal marker line at a live threshold value, tracking a
|
|
39
|
+
* `SharedValue` pixel-Y on the UI thread. Drawn **behind** the chart line so the
|
|
40
|
+
* data line stays the focus; the label badge ({@link ThresholdBadgeOverlay}) is
|
|
41
|
+
* a separate layer drawn on top. Hidden when the threshold is off-screen.
|
|
42
|
+
*/
|
|
43
|
+
export function ThresholdLineOverlay({
|
|
44
|
+
engine,
|
|
45
|
+
padding,
|
|
46
|
+
lineY,
|
|
47
|
+
visible,
|
|
48
|
+
cfg,
|
|
49
|
+
palette,
|
|
50
|
+
}: ThresholdMarkerProps) {
|
|
51
|
+
const lineColor = cfg.color ?? palette.refLine;
|
|
52
|
+
const { strokeWidth, intervals } = cfg;
|
|
53
|
+
|
|
54
|
+
const builder = usePathBuilder();
|
|
55
|
+
|
|
56
|
+
const linePath = useDerivedValue(() => {
|
|
57
|
+
const b = builder.value;
|
|
58
|
+
if (visible.get()) {
|
|
59
|
+
const y = lineY.get();
|
|
60
|
+
b.moveTo(padding.left, y);
|
|
61
|
+
b.lineTo(engine.canvasWidth.get() - padding.right, y);
|
|
62
|
+
}
|
|
63
|
+
return b.detach();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const opacity = useDerivedValue(() => (visible.get() ? 1 : 0));
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Group opacity={opacity}>
|
|
70
|
+
<Path
|
|
71
|
+
path={linePath}
|
|
72
|
+
style="stroke"
|
|
73
|
+
strokeWidth={strokeWidth}
|
|
74
|
+
color={lineColor}
|
|
75
|
+
>
|
|
76
|
+
<DashPathEffect intervals={intervals} />
|
|
77
|
+
</Path>
|
|
78
|
+
</Group>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The threshold's label as an opaque badge pill (rounded background + colored
|
|
84
|
+
* border, like `ReferenceLineOverlay`'s off-axis badge). Drawn **on top** of the
|
|
85
|
+
* chart line so the label is never painted over, and anchored hard to the plot's
|
|
86
|
+
* left edge by default (clear of the y-axis labels + live badge) or the right
|
|
87
|
+
* gutter. Renders nothing when there is no label and no `showValue`.
|
|
88
|
+
*/
|
|
89
|
+
export function ThresholdBadgeOverlay({
|
|
90
|
+
engine,
|
|
91
|
+
padding,
|
|
92
|
+
lineY,
|
|
93
|
+
visible,
|
|
94
|
+
value,
|
|
95
|
+
cfg,
|
|
96
|
+
palette,
|
|
97
|
+
font,
|
|
98
|
+
formatValue,
|
|
99
|
+
}: ThresholdMarkerProps) {
|
|
100
|
+
const { label, labelPosition, showValue } = cfg;
|
|
101
|
+
const badgeBackground = palette.tooltipBg;
|
|
102
|
+
const badgeBorderColor = cfg.color ?? palette.refLine;
|
|
103
|
+
const labelColor = cfg.color ?? palette.refLabel;
|
|
104
|
+
|
|
105
|
+
// Font metrics are stable → read once (getMetrics allocates + crosses JSI).
|
|
106
|
+
const { fontAscent, baselineOffset, pillH } = (() => {
|
|
107
|
+
const fm = font.getMetrics();
|
|
108
|
+
return {
|
|
109
|
+
fontAscent: fm.ascent,
|
|
110
|
+
baselineOffset: (fm.ascent + fm.descent) / 2,
|
|
111
|
+
pillH: fm.descent - fm.ascent + BADGE_PAD_Y * 2,
|
|
112
|
+
};
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
const opacity = useDerivedValue(() => (visible.get() ? 1 : 0));
|
|
116
|
+
|
|
117
|
+
const labelText = useDerivedValue(() => {
|
|
118
|
+
if (showValue) {
|
|
119
|
+
const v = formatValue(value.get());
|
|
120
|
+
return label ? `${label} ${v}` : v;
|
|
121
|
+
}
|
|
122
|
+
return label ?? "";
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const pillW = useDerivedValue(
|
|
126
|
+
() => measureFontTextWidth(font, labelText.get()) + BADGE_PAD_X * 2,
|
|
127
|
+
);
|
|
128
|
+
// Left: flush to the canvas edge. Right: flush to the right plot edge.
|
|
129
|
+
const pillX = useDerivedValue(() =>
|
|
130
|
+
labelPosition === "right"
|
|
131
|
+
? engine.canvasWidth.get() - padding.right - BADGE_EDGE_INSET - pillW.get()
|
|
132
|
+
: BADGE_EDGE_INSET,
|
|
133
|
+
);
|
|
134
|
+
const pillY = useDerivedValue(
|
|
135
|
+
() => lineY.get() - baselineOffset + fontAscent - BADGE_PAD_Y,
|
|
136
|
+
);
|
|
137
|
+
const labelX = useDerivedValue(() => pillX.get() + BADGE_PAD_X);
|
|
138
|
+
const labelY = useDerivedValue(() => lineY.get() - baselineOffset);
|
|
139
|
+
|
|
140
|
+
// No label and no value → nothing to draw (hooks above still run, in order).
|
|
141
|
+
if (label === undefined && !showValue) return null;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Group opacity={opacity}>
|
|
145
|
+
<RoundedRect
|
|
146
|
+
x={pillX}
|
|
147
|
+
y={pillY}
|
|
148
|
+
width={pillW}
|
|
149
|
+
height={pillH}
|
|
150
|
+
r={BADGE_RADIUS}
|
|
151
|
+
color={badgeBackground}
|
|
152
|
+
/>
|
|
153
|
+
<RoundedRect
|
|
154
|
+
x={pillX}
|
|
155
|
+
y={pillY}
|
|
156
|
+
width={pillW}
|
|
157
|
+
height={pillH}
|
|
158
|
+
r={BADGE_RADIUS}
|
|
159
|
+
color={badgeBorderColor}
|
|
160
|
+
style="stroke"
|
|
161
|
+
strokeWidth={1}
|
|
162
|
+
/>
|
|
163
|
+
<SkiaText
|
|
164
|
+
x={labelX}
|
|
165
|
+
y={labelY}
|
|
166
|
+
text={labelText}
|
|
167
|
+
font={font}
|
|
168
|
+
color={labelColor}
|
|
169
|
+
/>
|
|
170
|
+
</Group>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
SelectionDotConfig,
|
|
23
23
|
SelectionDotProps,
|
|
24
24
|
SelectionDotRingConfig,
|
|
25
|
+
ThresholdConfig,
|
|
26
|
+
ThresholdLineConfig,
|
|
25
27
|
TradeEvent,
|
|
26
28
|
ValueLineConfig,
|
|
27
29
|
XAxisConfig,
|
|
@@ -224,6 +226,66 @@ export function resolveValueLine(
|
|
|
224
226
|
return resolveToggle(prop, VALUE_LINE_DEFAULTS, false);
|
|
225
227
|
}
|
|
226
228
|
|
|
229
|
+
export interface ResolvedThresholdLineConfig {
|
|
230
|
+
/** undefined → no label. */
|
|
231
|
+
label: string | undefined;
|
|
232
|
+
/** Label side; `"left"` sits inside the plot (clear of the y-axis gutter). */
|
|
233
|
+
labelPosition: "left" | "right";
|
|
234
|
+
/** undefined → use palette.refLine (line) / palette.refLabel (label) at render time. */
|
|
235
|
+
color: string | undefined;
|
|
236
|
+
intervals: [number, number];
|
|
237
|
+
strokeWidth: number;
|
|
238
|
+
showValue: boolean;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface ResolvedThresholdConfig {
|
|
242
|
+
/** The live split value (Y-axis units). Read on the UI thread each frame. */
|
|
243
|
+
value: SharedValue<number>;
|
|
244
|
+
/** undefined → use palette.candleUp (up-green) at render time. */
|
|
245
|
+
aboveColor: string | undefined;
|
|
246
|
+
/** undefined → use palette.candleDown (down-red) at render time. */
|
|
247
|
+
belowColor: string | undefined;
|
|
248
|
+
fill: boolean;
|
|
249
|
+
line: ResolvedThresholdLineConfig | null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const THRESHOLD_LINE_DEFAULTS: ResolvedThresholdLineConfig = {
|
|
253
|
+
label: undefined,
|
|
254
|
+
labelPosition: "left",
|
|
255
|
+
color: undefined,
|
|
256
|
+
intervals: [4, 4],
|
|
257
|
+
strokeWidth: 1,
|
|
258
|
+
showValue: false,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Resolves the `threshold.line` sub-prop to a config or null (no marker line).
|
|
263
|
+
* `true` → dashed defaults, object → merged, falsy/undefined → null.
|
|
264
|
+
*/
|
|
265
|
+
export function resolveThresholdLine(
|
|
266
|
+
prop: boolean | ThresholdLineConfig | undefined,
|
|
267
|
+
): ResolvedThresholdLineConfig | null {
|
|
268
|
+
return resolveToggle(prop, THRESHOLD_LINE_DEFAULTS, false);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolves the `threshold` prop to a fully-typed config or null (disabled).
|
|
273
|
+
* Presence-gated (like `referenceLines`/`markers`): the required `value`
|
|
274
|
+
* SharedValue means there is no boolean form — a config object enables it.
|
|
275
|
+
*/
|
|
276
|
+
export function resolveThreshold(
|
|
277
|
+
prop: ThresholdConfig | undefined,
|
|
278
|
+
): ResolvedThresholdConfig | null {
|
|
279
|
+
if (!prop) return null;
|
|
280
|
+
return {
|
|
281
|
+
value: prop.value,
|
|
282
|
+
aboveColor: prop.aboveColor,
|
|
283
|
+
belowColor: prop.belowColor,
|
|
284
|
+
fill: prop.fill ?? false,
|
|
285
|
+
line: resolveThresholdLine(prop.line),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
227
289
|
const BADGE_DEFAULTS: ResolvedBadgeConfig = {
|
|
228
290
|
variant: "default",
|
|
229
291
|
tail: true,
|