react-native-livechart 4.7.0 → 4.9.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 (35) hide show
  1. package/dist/components/LiveChart.d.ts.map +1 -1
  2. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  3. package/dist/components/LoadingOverlay.d.ts +3 -1
  4. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  5. package/dist/components/ThresholdLineOverlay.d.ts +4 -1
  6. package/dist/components/ThresholdLineOverlay.d.ts.map +1 -1
  7. package/dist/components/ThresholdSplitShader.d.ts +26 -0
  8. package/dist/components/ThresholdSplitShader.d.ts.map +1 -0
  9. package/dist/core/liveChartEngineTick.d.ts +9 -0
  10. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  11. package/dist/core/resolveConfig.d.ts +24 -5
  12. package/dist/core/resolveConfig.d.ts.map +1 -1
  13. package/dist/core/useLiveChartEngine.d.ts +14 -0
  14. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  15. package/dist/hooks/useChartPaths.d.ts +6 -1
  16. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  17. package/dist/hooks/useThreshold.d.ts +63 -6
  18. package/dist/hooks/useThreshold.d.ts.map +1 -1
  19. package/dist/math/threshold.d.ts +82 -0
  20. package/dist/math/threshold.d.ts.map +1 -1
  21. package/dist/types.d.ts +80 -19
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +2 -2
  24. package/src/components/LiveChart.tsx +284 -46
  25. package/src/components/LiveChartSeries.tsx +12 -3
  26. package/src/components/LoadingOverlay.tsx +35 -28
  27. package/src/components/ThresholdLineOverlay.tsx +35 -5
  28. package/src/components/ThresholdSplitShader.tsx +92 -0
  29. package/src/core/liveChartEngineTick.ts +28 -0
  30. package/src/core/resolveConfig.ts +35 -6
  31. package/src/core/useLiveChartEngine.ts +35 -0
  32. package/src/hooks/useChartPaths.ts +51 -7
  33. package/src/hooks/useThreshold.ts +267 -10
  34. package/src/math/threshold.ts +250 -0
  35. package/src/types.ts +81 -19
@@ -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 { useThreshold } from "../hooks/useThreshold";
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
- * {@link THRESHOLD_FILL_OPACITY} for the band. Defaults to the palette's semantic
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}, ${THRESHOLD_FILL_OPACITY})`;
179
- const belowFill = `rgba(${br}, ${bg}, ${bb}, ${THRESHOLD_FILL_OPACITY})`;
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) return EMPTY_NUMS;
379
- const dv = dragValues.get();
441
+ if (draggableRefIdx.length === 0 && thresholdRangeValueSV === null)
442
+ return EMPTY_NUMS;
380
443
  const out: number[] = [];
381
- for (let k = 0; k < draggableRefIdx.length; k++) {
382
- const v = dv[draggableRefIdx[k]];
383
- if (v != null) out.push(v);
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 (shared by stroke gradient, fill band, marker line).
662
- // Called unconditionally with a stand-in value when no threshold is set.
663
- const thresholdGeom = useThreshold(
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
- thresholdCfg?.value ?? emptyThresholdValue,
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
- // Only build the band path when the fill is actually on.
682
- thresholdCfg?.fill ? thresholdGeom.lineY : undefined,
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
@@ -912,6 +1096,12 @@ function useLiveChartController({
912
1096
  : 0,
913
1097
  );
914
1098
 
1099
+ // Capture only the shared value in the worklets below. Referencing
1100
+ // `crosshair.scrubActive` inside a worklet closes over the whole `crosshair`
1101
+ // object (which holds a non-serializable `gesture`), throwing
1102
+ // "[Worklets] Cannot copy value of type `PanGesture`" on worklets >=0.10.
1103
+ const crosshairScrubActive = crosshair.scrubActive;
1104
+
915
1105
  // ── Time-scroll (drag back through history) ───────────────────────────────
916
1106
  // Experimental: a pan freezes the window at an absolute time and resumes
917
1107
  // following once dragged back to the live edge. Pan is clamped to the earliest
@@ -929,7 +1119,7 @@ function useLiveChartController({
929
1119
  // Clear any live crosshair when a scroll drag takes over.
930
1120
  onScrollStart: () => {
931
1121
  "worklet";
932
- crosshair.scrubActive.set(false);
1122
+ crosshairScrubActive.set(false);
933
1123
  },
934
1124
  });
935
1125
 
@@ -946,7 +1136,7 @@ function useLiveChartController({
946
1136
  maxTimeWindow: zoomCfg?.maxTimeWindow,
947
1137
  onZoomStart: () => {
948
1138
  "worklet";
949
- crosshair.scrubActive.set(false);
1139
+ crosshairScrubActive.set(false);
950
1140
  },
951
1141
  });
952
1142
 
@@ -1054,7 +1244,7 @@ function useLiveChartController({
1054
1244
  const liveDotOpacity = useDerivedValue(
1055
1245
  () =>
1056
1246
  reveal.dotOpacity.value *
1057
- (selectionDotDuringScrub && crosshair.scrubActive.value ? 0 : 1),
1247
+ (selectionDotDuringScrub && crosshairScrubActive.value ? 0 : 1),
1058
1248
  );
1059
1249
 
1060
1250
  // Fade the annotation overlays (markers + reference lines) out while scrubbing
@@ -1066,7 +1256,7 @@ function useLiveChartController({
1066
1256
  !isStatic && scrubCfg !== null && scrubCfg.hideOverlaysOnScrub === true;
1067
1257
  const overlayScrubFade = useDerivedValue(() =>
1068
1258
  fadeOverlaysOnScrub
1069
- ? withTiming(crosshair.scrubActive.get() ? 0 : 1, {
1259
+ ? withTiming(crosshairScrubActive.get() ? 0 : 1, {
1070
1260
  duration: SCRUB_OVERLAY_FADE_MS,
1071
1261
  })
1072
1262
  : 1,
@@ -1127,6 +1317,17 @@ function useLiveChartController({
1127
1317
  thresholdGeom,
1128
1318
  thresholdStrokeColors: thresholdStopColors?.stroke ?? null,
1129
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,
1130
1331
  badgeUsesRightGutter,
1131
1332
  // theme / layout / fonts
1132
1333
  palette,
@@ -1144,6 +1345,7 @@ function useLiveChartController({
1144
1345
  loadingStrokeWidth: loadingCfg?.strokeWidth,
1145
1346
  loadingAmplitude: loadingCfg?.amplitude,
1146
1347
  loadingSpeed: loadingCfg?.speed,
1348
+ loadingAxisLabels: loadingCfg?.axisLabels ?? true,
1147
1349
  // derived render values
1148
1350
  backgroundColor,
1149
1351
  gradientEnd,
@@ -1240,6 +1442,9 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
1240
1442
  thresholdGeom,
1241
1443
  thresholdFillPath,
1242
1444
  thresholdFillColors,
1445
+ thresholdIsSeries,
1446
+ thresholdSeriesHasPoints,
1447
+ thresholdFillUniforms,
1243
1448
  } = model;
1244
1449
 
1245
1450
  return (
@@ -1293,20 +1498,33 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
1293
1498
  )}
1294
1499
 
1295
1500
  {/* Threshold profit/loss band — the area between the line and the threshold,
1296
- hard-split at the threshold Y into the above/below colors. Independent of
1297
- the baseline area fill above (set `gradient={false}` for the band alone). */}
1298
- {thresholdCfg?.fill && thresholdFillColors && (
1299
- <Group opacity={reveal.fillOpacity}>
1300
- <Path path={thresholdFillPath} style="fill">
1301
- <LinearGradient
1302
- start={vec(0, 0)}
1303
- end={thresholdGeom.gradientEnd}
1304
- colors={thresholdFillColors}
1305
- positions={thresholdGeom.splitPositions}
1306
- />
1307
- </Path>
1308
- </Group>
1309
- )}
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)}
1310
1528
  </Group>
1311
1529
  );
1312
1530
  }
@@ -1335,6 +1553,14 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1335
1553
  thresholdCfg,
1336
1554
  thresholdGeom,
1337
1555
  thresholdStrokeColors,
1556
+ thresholdIsSeries,
1557
+ thresholdSeriesHasPoints,
1558
+ thresholdStrokeUniforms,
1559
+ thresholdMarkerLineY,
1560
+ thresholdMarkerVisible,
1561
+ thresholdBadgeVisible,
1562
+ thresholdMarkerValue,
1563
+ thresholdSeriesPts,
1338
1564
  formatValue,
1339
1565
  lineGroupOpacity,
1340
1566
  linePath,
@@ -1369,6 +1595,7 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1369
1595
  overlayScrubFade,
1370
1596
  renderMarker,
1371
1597
  emptyText,
1598
+ loadingAxisLabels,
1372
1599
  metricsCfg,
1373
1600
  layoutWidth,
1374
1601
  yAxisCfg,
@@ -1431,18 +1658,20 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1431
1658
  ))}
1432
1659
  </Group>
1433
1660
 
1434
- {/* 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. */}
1435
1663
  {thresholdCfg?.line && (
1436
1664
  <ThresholdLineOverlay
1437
1665
  engine={engine}
1438
1666
  padding={effectivePadding}
1439
- lineY={thresholdGeom.lineY}
1440
- visible={thresholdGeom.visible}
1441
- value={thresholdCfg.value}
1667
+ lineY={thresholdMarkerLineY}
1668
+ visible={thresholdMarkerVisible}
1669
+ value={thresholdMarkerValue}
1442
1670
  cfg={thresholdCfg.line}
1443
1671
  palette={palette}
1444
1672
  font={skiaFont}
1445
1673
  formatValue={formatValue}
1674
+ seriesPts={thresholdSeriesPts}
1446
1675
  />
1447
1676
  )}
1448
1677
 
@@ -1459,7 +1688,15 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1459
1688
  strokeJoin={lineProp?.join ?? "round"}
1460
1689
  color={lineProp?.color ?? palette.line}
1461
1690
  >
1462
- {thresholdCfg && thresholdStrokeColors ? (
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 ? (
1463
1700
  // Vertical hard split at the threshold Y — supersedes line.colors and
1464
1701
  // segment recoloring for the stroke while a threshold is set.
1465
1702
  <LinearGradient
@@ -1609,9 +1846,9 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1609
1846
  <ThresholdBadgeOverlay
1610
1847
  engine={engine}
1611
1848
  padding={effectivePadding}
1612
- lineY={thresholdGeom.lineY}
1613
- visible={thresholdGeom.visible}
1614
- value={thresholdCfg.value}
1849
+ lineY={thresholdMarkerLineY}
1850
+ visible={thresholdBadgeVisible}
1851
+ value={thresholdMarkerValue}
1615
1852
  cfg={thresholdCfg.line}
1616
1853
  palette={palette}
1617
1854
  font={skiaFont}
@@ -1635,6 +1872,7 @@ function ChartStack({ model }: { model: LiveChartModel }) {
1635
1872
  badgeTail={badgeCfg?.tail ?? true}
1636
1873
  badgeMetrics={metricsCfg.badge}
1637
1874
  emptyMetrics={metricsCfg.emptyState}
1875
+ showAxisLabels={loadingAxisLabels}
1638
1876
  lineColor={loadingLineColor}
1639
1877
  lineStrokeWidth={loadingStrokeWidth}
1640
1878
  waveAmplitude={loadingAmplitude}
@@ -391,6 +391,12 @@ function useLiveChartSeriesController({
391
391
  onGestureEnd,
392
392
  );
393
393
 
394
+ // Capture only the shared value in the worklets below. Referencing
395
+ // `crosshair.scrubActive` inside a worklet closes over the whole `crosshair`
396
+ // object (which holds a non-serializable `gesture`), throwing
397
+ // "[Worklets] Cannot copy value of type `PanGesture`" on worklets >=0.10.
398
+ const crosshairScrubActive = crosshair.scrubActive;
399
+
394
400
  // `projected` is used internally by the hit-test gesture; the overlay
395
401
  // self-projects, so we only need the gesture here.
396
402
  const { tapGesture: markerTapGesture } = useMarkers(
@@ -428,7 +434,7 @@ function useLiveChartSeriesController({
428
434
  mode: scrollGestureMode,
429
435
  onScrollStart: () => {
430
436
  "worklet";
431
- crosshair.scrubActive.set(false);
437
+ crosshairScrubActive.set(false);
432
438
  },
433
439
  });
434
440
 
@@ -442,7 +448,7 @@ function useLiveChartSeriesController({
442
448
  maxTimeWindow: zoomCfg?.maxTimeWindow,
443
449
  onZoomStart: () => {
444
450
  "worklet";
445
- crosshair.scrubActive.set(false);
451
+ crosshairScrubActive.set(false);
446
452
  },
447
453
  });
448
454
 
@@ -480,7 +486,7 @@ function useLiveChartSeriesController({
480
486
  scrubCfg !== null && scrubCfg.hideOverlaysOnScrub === true;
481
487
  const overlayScrubFade = useDerivedValue(() =>
482
488
  fadeOverlaysOnScrub
483
- ? withTiming(crosshair.scrubActive.get() ? 0 : 1, {
489
+ ? withTiming(crosshairScrubActive.get() ? 0 : 1, {
484
490
  duration: SCRUB_OVERLAY_FADE_MS,
485
491
  })
486
492
  : 1,
@@ -525,6 +531,7 @@ function useLiveChartSeriesController({
525
531
  loadingStrokeWidth: loadingCfg?.strokeWidth,
526
532
  loadingAmplitude: loadingCfg?.amplitude,
527
533
  loadingSpeed: loadingCfg?.speed,
534
+ loadingAxisLabels: loadingCfg?.axisLabels ?? true,
528
535
  effectiveSeries,
529
536
  layoutHeight,
530
537
  onLayout,
@@ -593,6 +600,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
593
600
  renderMarker,
594
601
  series,
595
602
  emptyText,
603
+ loadingAxisLabels,
596
604
  metricsCfg,
597
605
  loadingLineColor,
598
606
  loadingStrokeWidth,
@@ -732,6 +740,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
732
740
  strokeWidth={strokeWidth}
733
741
  badge={false}
734
742
  emptyMetrics={metricsCfg.emptyState}
743
+ showAxisLabels={loadingAxisLabels}
735
744
  lineColor={loadingLineColor}
736
745
  lineStrokeWidth={loadingStrokeWidth}
737
746
  waveAmplitude={loadingAmplitude}