react-native-livechart 4.8.0 → 4.9.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/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/ThresholdLineOverlay.d.ts +4 -1
- package/dist/components/ThresholdLineOverlay.d.ts.map +1 -1
- package/dist/components/ThresholdSplitShader.d.ts +26 -0
- package/dist/components/ThresholdSplitShader.d.ts.map +1 -0
- package/dist/core/liveChartEngineTick.d.ts +9 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +22 -5
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +14 -0
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts +6 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useChartSkiaFont.d.ts +2 -1
- package/dist/hooks/useChartSkiaFont.d.ts.map +1 -1
- package/dist/hooks/useThreshold.d.ts +63 -6
- package/dist/hooks/useThreshold.d.ts.map +1 -1
- package/dist/math/threshold.d.ts +82 -0
- package/dist/math/threshold.d.ts.map +1 -1
- package/dist/types.d.ts +77 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/LiveChart.tsx +271 -42
- package/src/components/LiveChartSeries.tsx +5 -1
- package/src/components/ThresholdLineOverlay.tsx +35 -5
- package/src/components/ThresholdSplitShader.tsx +92 -0
- package/src/core/liveChartEngineTick.ts +45 -4
- package/src/core/liveChartSeriesEngineTick.ts +6 -0
- package/src/core/resolveConfig.ts +32 -6
- package/src/core/useLiveChartEngine.ts +35 -0
- package/src/hooks/useChartPaths.ts +51 -7
- package/src/hooks/useChartSkiaFont.ts +31 -5
- package/src/hooks/useThreshold.ts +267 -10
- package/src/math/threshold.ts +250 -0
- package/src/types.ts +78 -18
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { StyleSheet, View } from "react-native";
|
|
3
3
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
4
4
|
import Animated, {
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
resolveReturnToLiveMs,
|
|
49
49
|
resolveSelectionDot,
|
|
50
50
|
resolveThreshold,
|
|
51
|
+
THRESHOLD_FILL_OPACITY_DEFAULT,
|
|
51
52
|
resolveTradeStream,
|
|
52
53
|
resolveValueLine,
|
|
53
54
|
resolveVolume,
|
|
@@ -82,7 +83,11 @@ import { usePinchZoom } from "../hooks/usePinchZoom";
|
|
|
82
83
|
import { useVisibleRange } from "../hooks/useVisibleRange";
|
|
83
84
|
import { useSegmentLineGradient } from "../hooks/useSegmentLineGradient";
|
|
84
85
|
import { useSingleChartReverseMorphInputs } from "../hooks/useReverseMorphEngineInputs";
|
|
85
|
-
import {
|
|
86
|
+
import {
|
|
87
|
+
useThreshold,
|
|
88
|
+
useThresholdSeries,
|
|
89
|
+
useThresholdSplitUniforms,
|
|
90
|
+
} from "../hooks/useThreshold";
|
|
86
91
|
import { useTradeStream } from "../hooks/useTradeStream";
|
|
87
92
|
import { useXAxis } from "../hooks/useXAxis";
|
|
88
93
|
import { useYAxis } from "../hooks/useYAxis";
|
|
@@ -110,6 +115,7 @@ import {
|
|
|
110
115
|
} from "../theme";
|
|
111
116
|
import type {
|
|
112
117
|
LiveChartPalette,
|
|
118
|
+
LiveChartPoint,
|
|
113
119
|
LiveChartProps,
|
|
114
120
|
Marker,
|
|
115
121
|
TradeEvent,
|
|
@@ -118,6 +124,10 @@ import {
|
|
|
118
124
|
ThresholdBadgeOverlay,
|
|
119
125
|
ThresholdLineOverlay,
|
|
120
126
|
} from "./ThresholdLineOverlay";
|
|
127
|
+
import {
|
|
128
|
+
THRESHOLD_SPLIT_AVAILABLE,
|
|
129
|
+
ThresholdSplitShader,
|
|
130
|
+
} from "./ThresholdSplitShader";
|
|
121
131
|
import { AreaDotsOverlay } from "./AreaDotsOverlay";
|
|
122
132
|
import { AxisLabelOverlay } from "./AxisLabelOverlay";
|
|
123
133
|
import {
|
|
@@ -149,8 +159,6 @@ import { ValueLineOverlay } from "./ValueLineOverlay";
|
|
|
149
159
|
import { XAxisOverlay } from "./XAxisOverlay";
|
|
150
160
|
import { YAxisOverlay } from "./YAxisOverlay";
|
|
151
161
|
|
|
152
|
-
/** Translucent fill alpha for the threshold profit/loss band. */
|
|
153
|
-
const THRESHOLD_FILL_OPACITY = 0.16;
|
|
154
162
|
|
|
155
163
|
/** Stable empty grouping result (identity-stable so downstream worklets don't
|
|
156
164
|
* re-run) used when reference-line grouping is off. */
|
|
@@ -163,9 +171,9 @@ const EMPTY_NUMS: number[] = [];
|
|
|
163
171
|
/**
|
|
164
172
|
* Color stops for the threshold's hard-split vertical gradient. Both arrays pair
|
|
165
173
|
* with the `[0, t, t, 1]` split positions: index 0–1 paint above the split,
|
|
166
|
-
* 2–3 below. `stroke` is full-strength; `fill` is the same hues at
|
|
167
|
-
* {
|
|
168
|
-
* up-green / down-red when colors are omitted.
|
|
174
|
+
* 2–3 below. `stroke` is full-strength; `fill` is the same hues at the config's
|
|
175
|
+
* band opacity (`fill: { opacity }`, default `0.16`). Defaults to the palette's
|
|
176
|
+
* semantic up-green / down-red when colors are omitted.
|
|
169
177
|
*/
|
|
170
178
|
function thresholdStops(
|
|
171
179
|
cfg: ResolvedThresholdConfig,
|
|
@@ -175,14 +183,51 @@ function thresholdStops(
|
|
|
175
183
|
const below = cfg.belowColor ?? palette.candleDown;
|
|
176
184
|
const [ar, ag, ab] = parseColorRgb(above);
|
|
177
185
|
const [br, bg, bb] = parseColorRgb(below);
|
|
178
|
-
const aboveFill = `rgba(${ar}, ${ag}, ${ab}, ${
|
|
179
|
-
const belowFill = `rgba(${br}, ${bg}, ${bb}, ${
|
|
186
|
+
const aboveFill = `rgba(${ar}, ${ag}, ${ab}, ${cfg.fillOpacity})`;
|
|
187
|
+
const belowFill = `rgba(${br}, ${bg}, ${bb}, ${cfg.fillOpacity})`;
|
|
180
188
|
return {
|
|
181
189
|
stroke: [above, above, below, below],
|
|
182
190
|
fill: [aboveFill, aboveFill, belowFill, belowFill],
|
|
183
191
|
};
|
|
184
192
|
}
|
|
185
193
|
|
|
194
|
+
/** Stand-in split color when no threshold is set (the shader is never rendered then). */
|
|
195
|
+
const THRESHOLD_FALLBACK_COLOR = [0, 0, 0, 1];
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Above/below split colors as straight-alpha `[r, g, b, a]` vec4s (channels 0..1)
|
|
199
|
+
* for the time-varying {@link ThresholdSplitShader}: full-strength `stroke*` for
|
|
200
|
+
* the line, band-opacity `fill*` for the band — the vec4 equivalent of
|
|
201
|
+
* {@link thresholdStops}' gradient color array. Takes the already
|
|
202
|
+
* palette-defaulted color strings; an rgba() alpha carries into the stroke and
|
|
203
|
+
* multiplies the band opacity (matching what the constant gradient's raw-string
|
|
204
|
+
* stroke does).
|
|
205
|
+
*/
|
|
206
|
+
function thresholdSplitColorVecs(
|
|
207
|
+
above: string,
|
|
208
|
+
below: string,
|
|
209
|
+
fillOpacity: number,
|
|
210
|
+
): {
|
|
211
|
+
strokeAbove: number[];
|
|
212
|
+
strokeBelow: number[];
|
|
213
|
+
fillAbove: number[];
|
|
214
|
+
fillBelow: number[];
|
|
215
|
+
} {
|
|
216
|
+
const [ar, ag, ab, aa] = parseColorRgba(above);
|
|
217
|
+
const [br, bg, bb, ba] = parseColorRgba(below);
|
|
218
|
+
const a = [ar / 255, ag / 255, ab / 255];
|
|
219
|
+
const b = [br / 255, bg / 255, bb / 255];
|
|
220
|
+
return {
|
|
221
|
+
strokeAbove: [a[0], a[1], a[2], aa],
|
|
222
|
+
strokeBelow: [b[0], b[1], b[2], ba],
|
|
223
|
+
fillAbove: [a[0], a[1], a[2], aa * fillOpacity],
|
|
224
|
+
fillBelow: [b[0], b[1], b[2], ba * fillOpacity],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Transparent vec4 — the band shader's "threshold ended" rest color. */
|
|
229
|
+
const TRANSPARENT_VEC4 = [0, 0, 0, 0];
|
|
230
|
+
|
|
186
231
|
/**
|
|
187
232
|
* Resolves props → configs → theme/layout → engine → per-frame derived values and
|
|
188
233
|
* overlay hooks, returning a single render model. All the chart's wiring lives
|
|
@@ -310,6 +355,9 @@ function useLiveChartController({
|
|
|
310
355
|
// Threshold split is a line-mode feature (candle bodies carry their own up/down
|
|
311
356
|
// colors), so it's inert in candle mode — same as the area gradient.
|
|
312
357
|
const thresholdCfg = isCandle ? null : resolveThreshold(threshold);
|
|
358
|
+
const thresholdSeriesSV = thresholdCfg?.series ?? null;
|
|
359
|
+
const thresholdIsSeries =
|
|
360
|
+
thresholdSeriesSV !== null || Array.isArray(thresholdCfg?.value);
|
|
313
361
|
const valueLineCfg = resolveValueLine(valueLine);
|
|
314
362
|
// Static charts run zero loops: force the pulse off so its `withRepeat`-driven
|
|
315
363
|
// ring never starts (the DotOverlay reads `pulseCfg`, so null = no pulse).
|
|
@@ -367,6 +415,11 @@ function useLiveChartController({
|
|
|
367
415
|
// range and the axis follows the finger in one motion (the committed values are
|
|
368
416
|
// already in `refValues`). `excludeFromRange` lines opt out, matching the static
|
|
369
417
|
// fit. Identity-stable (no re-fit) when nothing is draggable.
|
|
418
|
+
//
|
|
419
|
+
// `threshold.includeInRange` rides the same channel: the constant benchmark
|
|
420
|
+
// contributes its live value; a series contributes its window min/max
|
|
421
|
+
// (respecting `extendToNow`), so an off-range break-even expands the axis and
|
|
422
|
+
// stays on-plot like a reference line would.
|
|
370
423
|
const draggableRefIdx = allRefLines
|
|
371
424
|
.map((l, i) =>
|
|
372
425
|
l.draggable && !l.excludeFromRange && referenceLineForm(l) === "line"
|
|
@@ -374,13 +427,30 @@ function useLiveChartController({
|
|
|
374
427
|
: -1,
|
|
375
428
|
)
|
|
376
429
|
.filter((i) => i >= 0);
|
|
430
|
+
const thresholdInRange = thresholdCfg?.includeInRange === true;
|
|
431
|
+
// Constant benchmark → its live value rides this channel. A series threshold
|
|
432
|
+
// is folded inside the engine tick instead (`thresholdRangePoints`), which
|
|
433
|
+
// already knows the window bounds for the min/max.
|
|
434
|
+
const thresholdRangeValueSV =
|
|
435
|
+
thresholdInRange &&
|
|
436
|
+
thresholdCfg?.value != null &&
|
|
437
|
+
!Array.isArray(thresholdCfg.value)
|
|
438
|
+
? thresholdCfg.value
|
|
439
|
+
: null;
|
|
377
440
|
const liveRefValues = useDerivedValue<number[]>(() => {
|
|
378
|
-
if (draggableRefIdx.length === 0
|
|
379
|
-
|
|
441
|
+
if (draggableRefIdx.length === 0 && thresholdRangeValueSV === null)
|
|
442
|
+
return EMPTY_NUMS;
|
|
380
443
|
const out: number[] = [];
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
444
|
+
if (draggableRefIdx.length > 0) {
|
|
445
|
+
const dv = dragValues.get();
|
|
446
|
+
for (let k = 0; k < draggableRefIdx.length; k++) {
|
|
447
|
+
const v = dv[draggableRefIdx[k]];
|
|
448
|
+
if (v != null) out.push(v);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (thresholdRangeValueSV !== null) {
|
|
452
|
+
const v = thresholdRangeValueSV.get();
|
|
453
|
+
if (Number.isFinite(v)) out.push(v);
|
|
384
454
|
}
|
|
385
455
|
return out;
|
|
386
456
|
});
|
|
@@ -587,6 +657,13 @@ function useLiveChartController({
|
|
|
587
657
|
exaggerate,
|
|
588
658
|
referenceValues: refValues,
|
|
589
659
|
liveReferenceValues: liveRefValues,
|
|
660
|
+
// Series threshold with `includeInRange`: the tick folds its window min/max
|
|
661
|
+
// into the Y-range fit (the constant form rides `liveReferenceValues`).
|
|
662
|
+
thresholdRangePoints:
|
|
663
|
+
thresholdInRange && thresholdIsSeries
|
|
664
|
+
? (thresholdSeriesSV ?? (thresholdCfg?.value as LiveChartPoint[]))
|
|
665
|
+
: undefined,
|
|
666
|
+
thresholdRangeExtendToNow: thresholdCfg?.extendToNow ?? true,
|
|
590
667
|
nonNegative,
|
|
591
668
|
maxValue,
|
|
592
669
|
windowBuffer,
|
|
@@ -658,17 +735,116 @@ function useLiveChartController({
|
|
|
658
735
|
() => refGroupResult.get().hidden,
|
|
659
736
|
);
|
|
660
737
|
|
|
661
|
-
// Threshold split geometry
|
|
662
|
-
//
|
|
663
|
-
|
|
738
|
+
// Threshold split geometry. Two forms, picked at render by `Array.isArray` (no
|
|
739
|
+
// SharedValue read): a constant `SharedValue<number>` benchmark drives the
|
|
740
|
+
// vertical hard-stop gradient (`thresholdGeom`); a time-varying `LiveChartPoint[]`
|
|
741
|
+
// series drives the per-fragment split shader (`thresholdSeriesGeom`). Both hooks
|
|
742
|
+
// run unconditionally — the unused one short-circuits cheaply on the UI thread.
|
|
743
|
+
const thresholdValue = thresholdCfg?.value ?? emptyThresholdValue;
|
|
744
|
+
// An empty series (e.g. threshold history not fetched yet) must not paint: no
|
|
745
|
+
// band, no shader-forced stroke color — unlike the constant form, "no points
|
|
746
|
+
// yet" is a reachable state and should look like "no threshold". For the live
|
|
747
|
+
// `series` SharedValue form the emptiness lives on the UI thread, so mirror it
|
|
748
|
+
// to React state (fires only on empty↔non-empty transitions).
|
|
749
|
+
const [svSeriesNonEmpty, setSvSeriesNonEmpty] = useState(false);
|
|
750
|
+
useAnimatedReaction(
|
|
751
|
+
() => (thresholdSeriesSV ? thresholdSeriesSV.get().length > 0 : false),
|
|
752
|
+
/* istanbul ignore next -- Reanimated reaction; state mirrored on the JS thread, not exercised under Jest */
|
|
753
|
+
(hasPts, prev) => {
|
|
754
|
+
if (hasPts !== prev) scheduleOnRN(setSvSeriesNonEmpty, hasPts);
|
|
755
|
+
},
|
|
756
|
+
);
|
|
757
|
+
const thresholdSeriesHasPoints = thresholdSeriesSV
|
|
758
|
+
? svSeriesNonEmpty
|
|
759
|
+
: Array.isArray(thresholdCfg?.value) && thresholdCfg.value.length > 0;
|
|
760
|
+
const thresholdGeom = useThreshold(engine, effectivePadding, thresholdValue);
|
|
761
|
+
const thresholdSeriesGeom = useThresholdSeries(
|
|
664
762
|
engine,
|
|
665
763
|
effectivePadding,
|
|
666
|
-
|
|
764
|
+
thresholdValue,
|
|
765
|
+
thresholdSeriesSV,
|
|
766
|
+
thresholdCfg?.extendToNow ?? true,
|
|
667
767
|
);
|
|
668
768
|
const thresholdStopColors = thresholdCfg
|
|
669
769
|
? thresholdStops(thresholdCfg, palette)
|
|
670
770
|
: null;
|
|
671
771
|
|
|
772
|
+
// Split colors as vec4s for the series shader, memoized on the *resolved*
|
|
773
|
+
// color strings + opacity (so a threshold added after mount with default
|
|
774
|
+
// colors still computes, and the per-frame uniforms worklet isn't rebuilt
|
|
775
|
+
// every render). `strokeRest` is the plain line color painted right of the
|
|
776
|
+
// `extendToNow: false` cutoff.
|
|
777
|
+
const thresholdSplitAbove = thresholdCfg
|
|
778
|
+
? (thresholdCfg.aboveColor ?? palette.candleUp)
|
|
779
|
+
: null;
|
|
780
|
+
const thresholdSplitBelow = thresholdCfg
|
|
781
|
+
? (thresholdCfg.belowColor ?? palette.candleDown)
|
|
782
|
+
: null;
|
|
783
|
+
const thresholdFillOpacity =
|
|
784
|
+
thresholdCfg?.fillOpacity ?? THRESHOLD_FILL_OPACITY_DEFAULT;
|
|
785
|
+
const thresholdLineColorStr = lineProp?.color ?? palette.line;
|
|
786
|
+
const thresholdVecs = useMemo(
|
|
787
|
+
() =>
|
|
788
|
+
thresholdSplitAbove !== null && thresholdSplitBelow !== null
|
|
789
|
+
? {
|
|
790
|
+
...thresholdSplitColorVecs(
|
|
791
|
+
thresholdSplitAbove,
|
|
792
|
+
thresholdSplitBelow,
|
|
793
|
+
thresholdFillOpacity,
|
|
794
|
+
),
|
|
795
|
+
strokeRest: (() => {
|
|
796
|
+
const [r, g, b, a] = parseColorRgba(thresholdLineColorStr);
|
|
797
|
+
return [r / 255, g / 255, b / 255, a];
|
|
798
|
+
})(),
|
|
799
|
+
}
|
|
800
|
+
: null,
|
|
801
|
+
[
|
|
802
|
+
thresholdSplitAbove,
|
|
803
|
+
thresholdSplitBelow,
|
|
804
|
+
thresholdFillOpacity,
|
|
805
|
+
thresholdLineColorStr,
|
|
806
|
+
],
|
|
807
|
+
);
|
|
808
|
+
const thresholdStrokeUniforms = useThresholdSplitUniforms(
|
|
809
|
+
thresholdSeriesGeom.samples,
|
|
810
|
+
engine,
|
|
811
|
+
effectivePadding,
|
|
812
|
+
thresholdVecs?.strokeAbove ?? THRESHOLD_FALLBACK_COLOR,
|
|
813
|
+
thresholdVecs?.strokeBelow ?? THRESHOLD_FALLBACK_COLOR,
|
|
814
|
+
thresholdVecs?.strokeRest ?? THRESHOLD_FALLBACK_COLOR,
|
|
815
|
+
thresholdSeriesGeom.clipRightX,
|
|
816
|
+
);
|
|
817
|
+
const thresholdFillUniforms = useThresholdSplitUniforms(
|
|
818
|
+
thresholdSeriesGeom.samples,
|
|
819
|
+
engine,
|
|
820
|
+
effectivePadding,
|
|
821
|
+
thresholdVecs?.fillAbove ?? THRESHOLD_FALLBACK_COLOR,
|
|
822
|
+
thresholdVecs?.fillBelow ?? THRESHOLD_FALLBACK_COLOR,
|
|
823
|
+
TRANSPARENT_VEC4,
|
|
824
|
+
thresholdSeriesGeom.clipRightX,
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
// Marker line + badge sources: the series anchors at the value-at-now (flat-
|
|
828
|
+
// extended past its last point); the constant case at the single benchmark Y.
|
|
829
|
+
// The badge gets its own visibility — it's pinned at the value-at-now Y, which
|
|
830
|
+
// can be off-plot while older polyline segments are still visible.
|
|
831
|
+
const thresholdMarkerLineY = thresholdIsSeries
|
|
832
|
+
? thresholdSeriesGeom.currentLineY
|
|
833
|
+
: thresholdGeom.lineY;
|
|
834
|
+
const thresholdMarkerVisible = thresholdIsSeries
|
|
835
|
+
? thresholdSeriesGeom.visible
|
|
836
|
+
: thresholdGeom.visible;
|
|
837
|
+
const thresholdBadgeVisible = thresholdIsSeries
|
|
838
|
+
? thresholdSeriesGeom.currentVisible
|
|
839
|
+
: thresholdGeom.visible;
|
|
840
|
+
const thresholdMarkerValue =
|
|
841
|
+
thresholdCfg && !thresholdIsSeries && !Array.isArray(thresholdCfg.value)
|
|
842
|
+
? (thresholdCfg.value ?? thresholdSeriesGeom.currentValue)
|
|
843
|
+
: thresholdSeriesGeom.currentValue;
|
|
844
|
+
const thresholdSeriesPts = thresholdIsSeries
|
|
845
|
+
? thresholdSeriesGeom.screenPts
|
|
846
|
+
: undefined;
|
|
847
|
+
|
|
672
848
|
// Straight polyline instead of the monotone cubic when line.curve === "linear".
|
|
673
849
|
// Shared by the path builders and the marker anchoring so glyphs sit on the
|
|
674
850
|
// rendered line rather than the phantom spline.
|
|
@@ -678,8 +854,9 @@ function useLiveChartController({
|
|
|
678
854
|
engine,
|
|
679
855
|
effectivePadding,
|
|
680
856
|
reveal.morphT,
|
|
681
|
-
//
|
|
682
|
-
|
|
857
|
+
// Constant threshold band closes at a single Y (the series closes along the
|
|
858
|
+
// polyline passed as the last arg below).
|
|
859
|
+
thresholdCfg?.fill && !thresholdIsSeries ? thresholdGeom.lineY : undefined,
|
|
683
860
|
lineIsLinear,
|
|
684
861
|
// Tip the line at the view-edge price (not the live value) while scrolled,
|
|
685
862
|
// matching the live dot — so the right edge doesn't drop to the off-screen
|
|
@@ -689,6 +866,13 @@ function useLiveChartController({
|
|
|
689
866
|
// Match the standalone loading squiggle's wave during the reveal morph.
|
|
690
867
|
loadingCfg?.amplitude,
|
|
691
868
|
loadingCfg?.speed,
|
|
869
|
+
// Time-varying threshold band: bottom edge built from the shader's samples
|
|
870
|
+
// (so the band matches the shader and doesn't bleed at step risers). An empty
|
|
871
|
+
// series builds no band (its samples are all the far-below fallback, which
|
|
872
|
+
// would tint the entire area under the line).
|
|
873
|
+
thresholdCfg?.fill && thresholdSeriesHasPoints
|
|
874
|
+
? thresholdSeriesGeom.samples
|
|
875
|
+
: undefined,
|
|
692
876
|
);
|
|
693
877
|
|
|
694
878
|
// Area-dots fill shader color as a vec4 (channels 0..1), with the config
|
|
@@ -1133,6 +1317,17 @@ function useLiveChartController({
|
|
|
1133
1317
|
thresholdGeom,
|
|
1134
1318
|
thresholdStrokeColors: thresholdStopColors?.stroke ?? null,
|
|
1135
1319
|
thresholdFillColors: thresholdStopColors?.fill ?? null,
|
|
1320
|
+
// Time-varying threshold (a `LiveChartPoint[]` series): the per-fragment split
|
|
1321
|
+
// shader + polyline marker, vs. the constant case's gradient.
|
|
1322
|
+
thresholdIsSeries,
|
|
1323
|
+
thresholdSeriesHasPoints,
|
|
1324
|
+
thresholdStrokeUniforms,
|
|
1325
|
+
thresholdFillUniforms,
|
|
1326
|
+
thresholdMarkerLineY,
|
|
1327
|
+
thresholdMarkerVisible,
|
|
1328
|
+
thresholdBadgeVisible,
|
|
1329
|
+
thresholdMarkerValue,
|
|
1330
|
+
thresholdSeriesPts,
|
|
1136
1331
|
badgeUsesRightGutter,
|
|
1137
1332
|
// theme / layout / fonts
|
|
1138
1333
|
palette,
|
|
@@ -1247,6 +1442,9 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
|
|
|
1247
1442
|
thresholdGeom,
|
|
1248
1443
|
thresholdFillPath,
|
|
1249
1444
|
thresholdFillColors,
|
|
1445
|
+
thresholdIsSeries,
|
|
1446
|
+
thresholdSeriesHasPoints,
|
|
1447
|
+
thresholdFillUniforms,
|
|
1250
1448
|
} = model;
|
|
1251
1449
|
|
|
1252
1450
|
return (
|
|
@@ -1300,20 +1498,33 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
|
|
|
1300
1498
|
)}
|
|
1301
1499
|
|
|
1302
1500
|
{/* Threshold profit/loss band — the area between the line and the threshold,
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1501
|
+
split into the above/below colors. Independent of the baseline area fill
|
|
1502
|
+
above (set `gradient={false}` for the band alone). A time-varying series
|
|
1503
|
+
paints with the per-fragment split shader; a constant value with the
|
|
1504
|
+
vertical hard-stop gradient. */}
|
|
1505
|
+
{thresholdCfg?.fill &&
|
|
1506
|
+
(thresholdIsSeries ? (
|
|
1507
|
+
// The availability gate keeps a failed shader compile from filling the
|
|
1508
|
+
// band with the default paint (opaque black) — see THRESHOLD_SPLIT_AVAILABLE.
|
|
1509
|
+
thresholdSeriesHasPoints && THRESHOLD_SPLIT_AVAILABLE ? (
|
|
1510
|
+
<Group opacity={reveal.fillOpacity}>
|
|
1511
|
+
<Path path={thresholdFillPath} style="fill">
|
|
1512
|
+
<ThresholdSplitShader uniforms={thresholdFillUniforms} />
|
|
1513
|
+
</Path>
|
|
1514
|
+
</Group>
|
|
1515
|
+
) : null
|
|
1516
|
+
) : thresholdFillColors ? (
|
|
1517
|
+
<Group opacity={reveal.fillOpacity}>
|
|
1518
|
+
<Path path={thresholdFillPath} style="fill">
|
|
1519
|
+
<LinearGradient
|
|
1520
|
+
start={vec(0, 0)}
|
|
1521
|
+
end={thresholdGeom.gradientEnd}
|
|
1522
|
+
colors={thresholdFillColors}
|
|
1523
|
+
positions={thresholdGeom.splitPositions}
|
|
1524
|
+
/>
|
|
1525
|
+
</Path>
|
|
1526
|
+
</Group>
|
|
1527
|
+
) : null)}
|
|
1317
1528
|
</Group>
|
|
1318
1529
|
);
|
|
1319
1530
|
}
|
|
@@ -1342,6 +1553,14 @@ function ChartStack({ model }: { model: LiveChartModel }) {
|
|
|
1342
1553
|
thresholdCfg,
|
|
1343
1554
|
thresholdGeom,
|
|
1344
1555
|
thresholdStrokeColors,
|
|
1556
|
+
thresholdIsSeries,
|
|
1557
|
+
thresholdSeriesHasPoints,
|
|
1558
|
+
thresholdStrokeUniforms,
|
|
1559
|
+
thresholdMarkerLineY,
|
|
1560
|
+
thresholdMarkerVisible,
|
|
1561
|
+
thresholdBadgeVisible,
|
|
1562
|
+
thresholdMarkerValue,
|
|
1563
|
+
thresholdSeriesPts,
|
|
1345
1564
|
formatValue,
|
|
1346
1565
|
lineGroupOpacity,
|
|
1347
1566
|
linePath,
|
|
@@ -1439,18 +1658,20 @@ function ChartStack({ model }: { model: LiveChartModel }) {
|
|
|
1439
1658
|
))}
|
|
1440
1659
|
</Group>
|
|
1441
1660
|
|
|
1442
|
-
{/* Threshold marker line + label (behind the chart line).
|
|
1661
|
+
{/* Threshold marker line + label (behind the chart line). For a time-varying
|
|
1662
|
+
series it traces the threshold polyline; otherwise a horizontal line. */}
|
|
1443
1663
|
{thresholdCfg?.line && (
|
|
1444
1664
|
<ThresholdLineOverlay
|
|
1445
1665
|
engine={engine}
|
|
1446
1666
|
padding={effectivePadding}
|
|
1447
|
-
lineY={
|
|
1448
|
-
visible={
|
|
1449
|
-
value={
|
|
1667
|
+
lineY={thresholdMarkerLineY}
|
|
1668
|
+
visible={thresholdMarkerVisible}
|
|
1669
|
+
value={thresholdMarkerValue}
|
|
1450
1670
|
cfg={thresholdCfg.line}
|
|
1451
1671
|
palette={palette}
|
|
1452
1672
|
font={skiaFont}
|
|
1453
1673
|
formatValue={formatValue}
|
|
1674
|
+
seriesPts={thresholdSeriesPts}
|
|
1454
1675
|
/>
|
|
1455
1676
|
)}
|
|
1456
1677
|
|
|
@@ -1467,7 +1688,15 @@ function ChartStack({ model }: { model: LiveChartModel }) {
|
|
|
1467
1688
|
strokeJoin={lineProp?.join ?? "round"}
|
|
1468
1689
|
color={lineProp?.color ?? palette.line}
|
|
1469
1690
|
>
|
|
1470
|
-
{
|
|
1691
|
+
{thresholdIsSeries ? (
|
|
1692
|
+
// Time-varying split: a per-fragment shader colors the stroke above/
|
|
1693
|
+
// below the threshold polyline. Supersedes line.colors + segments.
|
|
1694
|
+
// An empty series renders no paint child → the plain stroke color
|
|
1695
|
+
// (null here keeps the empty case out of the NaN constant gradient).
|
|
1696
|
+
thresholdSeriesHasPoints ? (
|
|
1697
|
+
<ThresholdSplitShader uniforms={thresholdStrokeUniforms} />
|
|
1698
|
+
) : null
|
|
1699
|
+
) : thresholdCfg && thresholdStrokeColors ? (
|
|
1471
1700
|
// Vertical hard split at the threshold Y — supersedes line.colors and
|
|
1472
1701
|
// segment recoloring for the stroke while a threshold is set.
|
|
1473
1702
|
<LinearGradient
|
|
@@ -1617,9 +1846,9 @@ function ChartStack({ model }: { model: LiveChartModel }) {
|
|
|
1617
1846
|
<ThresholdBadgeOverlay
|
|
1618
1847
|
engine={engine}
|
|
1619
1848
|
padding={effectivePadding}
|
|
1620
|
-
lineY={
|
|
1621
|
-
visible={
|
|
1622
|
-
value={
|
|
1849
|
+
lineY={thresholdMarkerLineY}
|
|
1850
|
+
visible={thresholdBadgeVisible}
|
|
1851
|
+
value={thresholdMarkerValue}
|
|
1623
1852
|
cfg={thresholdCfg.line}
|
|
1624
1853
|
palette={palette}
|
|
1625
1854
|
font={skiaFont}
|
|
@@ -208,7 +208,11 @@ function useLiveChartSeriesController({
|
|
|
208
208
|
? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
|
|
209
209
|
: (scrubCfg?.panGestureDelay ?? 0);
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
// Multi-series defaults the scrub selection dot OFF: it can only track one
|
|
212
|
+
// line (the leading series), which reads as a bug next to the other series.
|
|
213
|
+
// The crosshair line + per-series tooltip stack already mark the scrub point.
|
|
214
|
+
// Passing `selectionDot` explicitly (true / config) still opts it in.
|
|
215
|
+
const selectionDotCfg = resolveSelectionDot(selectionDot ?? false);
|
|
212
216
|
const gridStyleCfg = resolveGridStyle(gridStyle);
|
|
213
217
|
const dotCfg = resolveMultiSeriesDot(dotProp);
|
|
214
218
|
// Outer footprint of a dot (the color-filled radius plus the halo ring).
|
|
@@ -12,6 +12,7 @@ import type { ResolvedThresholdLineConfig } from "../core/resolveConfig";
|
|
|
12
12
|
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
13
13
|
import type { ChartPadding } from "../draw/line";
|
|
14
14
|
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
15
|
+
import { thresholdDashPhase } from "../math/threshold";
|
|
15
16
|
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
16
17
|
import type { LiveChartPalette } from "../types";
|
|
17
18
|
|
|
@@ -32,6 +33,9 @@ interface ThresholdMarkerProps {
|
|
|
32
33
|
palette: LiveChartPalette;
|
|
33
34
|
font: SkFont;
|
|
34
35
|
formatValue: (v: number) => string;
|
|
36
|
+
/** When set, draw the marker as this time-varying threshold polyline (`[x, y, …]`)
|
|
37
|
+
* instead of a horizontal line at `lineY`. */
|
|
38
|
+
seriesPts?: SharedValue<number[]>;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/**
|
|
@@ -47,18 +51,44 @@ export function ThresholdLineOverlay({
|
|
|
47
51
|
visible,
|
|
48
52
|
cfg,
|
|
49
53
|
palette,
|
|
54
|
+
seriesPts,
|
|
50
55
|
}: ThresholdMarkerProps) {
|
|
51
56
|
const lineColor = cfg.color ?? palette.refLine;
|
|
52
57
|
const { strokeWidth, intervals } = cfg;
|
|
58
|
+
const isSeries = seriesPts !== undefined;
|
|
59
|
+
const dashCycle = intervals[0] + intervals[1];
|
|
60
|
+
|
|
61
|
+
// Series marker: advance the dash phase with the content scroll so the
|
|
62
|
+
// pattern rides the staircase instead of sitting screen-fixed under it (the
|
|
63
|
+
// path start is pinned at the plot edge, so a static phase reads as the dots
|
|
64
|
+
// marching right along the gliding line). The constant marker is a static
|
|
65
|
+
// level — its dashes stay screen-fixed (phase 0).
|
|
66
|
+
const dashPhase = useDerivedValue(() => {
|
|
67
|
+
if (!isSeries) return 0;
|
|
68
|
+
return thresholdDashPhase(
|
|
69
|
+
engine.timestamp.get(),
|
|
70
|
+
engine.displayWindow.get(),
|
|
71
|
+
padding.left,
|
|
72
|
+
engine.canvasWidth.get() - padding.right,
|
|
73
|
+
dashCycle,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
53
76
|
|
|
54
77
|
const builder = usePathBuilder();
|
|
55
78
|
|
|
56
79
|
const linePath = useDerivedValue(() => {
|
|
57
80
|
const b = builder.value;
|
|
58
81
|
if (visible.get()) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
const sp = seriesPts?.get();
|
|
83
|
+
if (sp && sp.length >= 4) {
|
|
84
|
+
// Time-varying threshold: trace the polyline across the plot.
|
|
85
|
+
b.moveTo(sp[0], sp[1]);
|
|
86
|
+
for (let i = 2; i < sp.length; i += 2) b.lineTo(sp[i], sp[i + 1]);
|
|
87
|
+
} else {
|
|
88
|
+
const y = lineY.get();
|
|
89
|
+
b.moveTo(padding.left, y);
|
|
90
|
+
b.lineTo(engine.canvasWidth.get() - padding.right, y);
|
|
91
|
+
}
|
|
62
92
|
}
|
|
63
93
|
return b.detach();
|
|
64
94
|
});
|
|
@@ -73,7 +103,7 @@ export function ThresholdLineOverlay({
|
|
|
73
103
|
strokeWidth={strokeWidth}
|
|
74
104
|
color={lineColor}
|
|
75
105
|
>
|
|
76
|
-
<DashPathEffect intervals={intervals} />
|
|
106
|
+
<DashPathEffect intervals={intervals} phase={dashPhase} />
|
|
77
107
|
</Path>
|
|
78
108
|
</Group>
|
|
79
109
|
);
|
|
@@ -100,7 +130,7 @@ export function ThresholdBadgeOverlay({
|
|
|
100
130
|
const { label, labelPosition, showValue } = cfg;
|
|
101
131
|
const badgeBackground = palette.tooltipBg;
|
|
102
132
|
const badgeBorderColor = cfg.color ?? palette.refLine;
|
|
103
|
-
const labelColor = cfg.color ?? palette.refLabel;
|
|
133
|
+
const labelColor = cfg.labelColor ?? cfg.color ?? palette.refLabel;
|
|
104
134
|
|
|
105
135
|
// Font metrics are stable → read once (getMetrics allocates + crosses JSI).
|
|
106
136
|
const { fontAscent, baselineOffset, pillH } = (() => {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Shader, Skia, type Uniforms } from "@shopify/react-native-skia";
|
|
2
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
3
|
+
|
|
4
|
+
import { THRESHOLD_SAMPLE_COUNT } from "../math/threshold";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-fragment split shader for a *time-varying* threshold. For each fragment it
|
|
8
|
+
* reads the threshold's Y at the fragment's X — linearly interpolated from
|
|
9
|
+
* `samples[]`, evenly spaced across `[plotLeft, plotRight]` — and paints
|
|
10
|
+
* `aboveColor` above the boundary (smaller Y), `belowColor` below. This is the
|
|
11
|
+
* polyline equivalent of the constant case's vertical hard-stop `LinearGradient`,
|
|
12
|
+
* which can only express a *horizontal* split.
|
|
13
|
+
*
|
|
14
|
+
* Used for both the line stroke and the profit/loss fill band (the band passes
|
|
15
|
+
* alpha-reduced colors). Output is premultiplied — Skia's shader convention, as in
|
|
16
|
+
* `AreaDotsOverlay`. One GPU draw, no per-frame clip / `saveLayer`.
|
|
17
|
+
*/
|
|
18
|
+
const SPLIT_SKSL = `
|
|
19
|
+
// Pixel-X span of the sample grid (thresholdSampleSpanX) — overhangs the plot
|
|
20
|
+
// and glides with the window so features move fluidly, frame to frame.
|
|
21
|
+
uniform float sampleLeft;
|
|
22
|
+
uniform float sampleRight;
|
|
23
|
+
// X where the threshold ends (extendToNow: false → the last point's pixel-X,
|
|
24
|
+
// else a huge sentinel). Fragments right of it paint restColor: the plain line
|
|
25
|
+
// color for the stroke, transparent for the band.
|
|
26
|
+
uniform float clipRight;
|
|
27
|
+
uniform vec4 aboveColor; // straight-alpha rgba, 0..1
|
|
28
|
+
uniform vec4 belowColor; // straight-alpha rgba, 0..1
|
|
29
|
+
uniform vec4 restColor; // straight-alpha rgba, 0..1
|
|
30
|
+
uniform float samples[${THRESHOLD_SAMPLE_COUNT}];
|
|
31
|
+
|
|
32
|
+
half4 main(vec2 xy) {
|
|
33
|
+
if (xy.x > clipRight) {
|
|
34
|
+
return half4(half3(restColor.rgb) * restColor.a, restColor.a);
|
|
35
|
+
}
|
|
36
|
+
float span = sampleRight - sampleLeft;
|
|
37
|
+
float u = span > 0.0 ? (xy.x - sampleLeft) / span : 0.0;
|
|
38
|
+
u = clamp(u, 0.0, 1.0) * float(${THRESHOLD_SAMPLE_COUNT - 1});
|
|
39
|
+
// SkSL forbids indexing a uniform array with a non-constant index, so walk the
|
|
40
|
+
// segments in an unrolled, constant-bound loop — the index is the loop variable
|
|
41
|
+
// (a compile-time constant per unrolled iteration) — and keep the last segment
|
|
42
|
+
// whose left edge is at or before the fragment's u.
|
|
43
|
+
float thrY = samples[0];
|
|
44
|
+
for (int j = 0; j < ${THRESHOLD_SAMPLE_COUNT - 1}; j++) {
|
|
45
|
+
if (u >= float(j)) {
|
|
46
|
+
thrY = mix(samples[j], samples[j + 1], clamp(u - float(j), 0.0, 1.0));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// 1 above the boundary, 0 below, with ~1px antialiasing across it.
|
|
50
|
+
float above = clamp(thrY - xy.y + 0.5, 0.0, 1.0);
|
|
51
|
+
vec4 c = mix(belowColor, aboveColor, above);
|
|
52
|
+
return half4(half3(c.rgb) * c.a, c.a);
|
|
53
|
+
}`;
|
|
54
|
+
|
|
55
|
+
// Compiled once. `RuntimeEffect.Make` THROWS on a compile error, so guard it —
|
|
56
|
+
// a shader failure must degrade to "no split" (the line keeps its plain color),
|
|
57
|
+
// never crash every chart screen at import. Null → the component no-ops.
|
|
58
|
+
let SPLIT_EFFECT: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
|
|
59
|
+
try {
|
|
60
|
+
SPLIT_EFFECT = Skia.RuntimeEffect.Make(SPLIT_SKSL);
|
|
61
|
+
} catch {
|
|
62
|
+
SPLIT_EFFECT = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether the split effect compiled. Callers must gate any `<Path>` whose ONLY
|
|
67
|
+
* paint is this shader on it — without the shader child the path fills with the
|
|
68
|
+
* default paint (opaque black). A fallback `color` prop can't stand in instead:
|
|
69
|
+
* Skia multiplies the shader output by the paint alpha, so a transparent color
|
|
70
|
+
* erases the shader's own output too.
|
|
71
|
+
*/
|
|
72
|
+
export const THRESHOLD_SPLIT_AVAILABLE = SPLIT_EFFECT !== null;
|
|
73
|
+
|
|
74
|
+
interface ThresholdSplitShaderProps {
|
|
75
|
+
/**
|
|
76
|
+
* Per-frame uniforms (built in `useThresholdSplitUniforms`): `sampleLeft` /
|
|
77
|
+
* `sampleRight` (the gliding sample-grid span, px), `aboveColor` / `belowColor`
|
|
78
|
+
* (straight-alpha `[r, g, b, a]` vec4s, channels 0..1) and `samples`
|
|
79
|
+
* (`THRESHOLD_SAMPLE_COUNT` threshold pixel-Y values on the grid).
|
|
80
|
+
*/
|
|
81
|
+
uniforms: SharedValue<Uniforms>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The split `RuntimeShader` as a paint child — drop it inside a stroked line
|
|
86
|
+
* `<Path>` or a filled band `<Path>`, like a `<LinearGradient>`.
|
|
87
|
+
*/
|
|
88
|
+
export function ThresholdSplitShader({ uniforms }: ThresholdSplitShaderProps) {
|
|
89
|
+
/* istanbul ignore next -- defensive: SPLIT_SKSL is static and compiles */
|
|
90
|
+
if (!SPLIT_EFFECT) return null;
|
|
91
|
+
return <Shader source={SPLIT_EFFECT} uniforms={uniforms} />;
|
|
92
|
+
}
|