react-native-livechart 4.8.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.
@@ -1,13 +1,27 @@
1
- import { vec } from "@shopify/react-native-skia";
1
+ import { vec, type Uniforms } from "@shopify/react-native-skia";
2
+ import { useRef } from "react";
2
3
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
3
4
 
4
5
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
5
6
  import type { ChartPadding } from "../draw/line";
7
+ import { interpolateAtTime } from "../math/interpolate";
6
8
  import {
9
+ sampleThresholdY,
10
+ sampleThresholdYAt,
11
+ THRESHOLD_SAMPLE_COUNT,
7
12
  thresholdLineY,
13
+ thresholdSampleSpanX,
14
+ thresholdSeriesVisible,
8
15
  thresholdSplitPositions,
9
16
  thresholdVisible,
10
17
  } from "../math/threshold";
18
+ import type { LiveChartPoint } from "../types";
19
+
20
+ /** A threshold value that is either a single live benchmark or a time-varying series. */
21
+ export type ThresholdValue = SharedValue<number> | LiveChartPoint[];
22
+
23
+ /** `clipRight` sentinel when the threshold extends to "now" (no forward cutoff). */
24
+ export const THRESHOLD_NO_CLIP = 1e9;
11
25
 
12
26
  export interface ThresholdGeometry {
13
27
  /** Threshold pixel-Y within the canvas, or NaN before layout / degenerate range. */
@@ -21,26 +35,33 @@ export interface ThresholdGeometry {
21
35
  }
22
36
 
23
37
  /**
24
- * Per-frame screen geometry for the threshold split, shared by the stroke
25
- * gradient, the profit/loss fill band, and the dashed marker line. Reads the live
26
- * threshold value + engine Y-range on the UI thread; the math lives in
27
- * `math/threshold` so it stays unit-testable without Reanimated.
38
+ * Per-frame screen geometry for the **constant** threshold split (a single
39
+ * `SharedValue<number>` benchmark), shared by the stroke gradient, the
40
+ * profit/loss fill band, and the dashed marker line. Reads the live threshold
41
+ * value + engine Y-range on the UI thread; the math lives in `math/threshold` so
42
+ * it stays unit-testable without Reanimated.
43
+ *
44
+ * When the threshold is a time-varying series this geometry is unused — the
45
+ * worklets short-circuit to a NaN `lineY` (→ off-screen, solid-above fallback)
46
+ * and {@link useThresholdSeries} drives the render instead.
28
47
  */
29
48
  export function useThreshold(
30
49
  engine: ChartEngineLayout,
31
50
  padding: ChartPadding,
32
- value: SharedValue<number>,
51
+ value: ThresholdValue,
33
52
  ): ThresholdGeometry {
34
- const lineY = useDerivedValue(() =>
35
- thresholdLineY(
53
+ const lineY = useDerivedValue(() => {
54
+ // Series thresholds are handled by `useThresholdSeries`; bail cheaply here.
55
+ if (Array.isArray(value)) return NaN;
56
+ return thresholdLineY(
36
57
  value.get(),
37
58
  engine.displayMin.get(),
38
59
  engine.displayMax.get(),
39
60
  engine.canvasHeight.get(),
40
61
  padding.top,
41
62
  padding.bottom,
42
- ),
43
- );
63
+ );
64
+ });
44
65
 
45
66
  const visible = useDerivedValue(() =>
46
67
  thresholdVisible(
@@ -61,3 +82,239 @@ export function useThreshold(
61
82
 
62
83
  return { lineY, visible, gradientEnd, splitPositions };
63
84
  }
85
+
86
+ export interface ThresholdSeriesGeometry {
87
+ /** Marker-line polyline in screen space `[x, y, …]`, projected from `samples`
88
+ * (so it aligns exactly with the band edge + stroke split) and pinned to the
89
+ * plot's left edge and to `min(plot right, clipRightX)`. */
90
+ screenPts: SharedValue<number[]>;
91
+ /** `THRESHOLD_SAMPLE_COUNT` threshold pixel-Y values on the time-anchored
92
+ * sample grid — the split shader's `samples[]`, and the bottom edge of the
93
+ * profit/loss band. Their pixel-X span is `thresholdSampleSpanX(...)`, which
94
+ * glides with the window (NOT the static plot edges). */
95
+ samples: SharedValue<number[]>;
96
+ /** Whether any of the polyline is on-screen (drives marker-line opacity). */
97
+ visible: SharedValue<boolean>;
98
+ /** Threshold value at `now` (flat-extended past the last point) — the badge label. */
99
+ currentValue: SharedValue<number>;
100
+ /** Pixel-Y of `currentValue` — anchors the badge. */
101
+ currentLineY: SharedValue<number>;
102
+ /** Whether the badge should show: `currentLineY` on-plot AND, with
103
+ * `extendToNow` off, "now" not past the series' last point. (`visible` can be
104
+ * true for the polyline while the value-at-now sits outside the plot; the
105
+ * badge must not draw into the gutters then.) */
106
+ currentVisible: SharedValue<boolean>;
107
+ /** Pixel-X where the threshold ends: the last point's X with `extendToNow`
108
+ * off, else {@link THRESHOLD_NO_CLIP}. The shader paints its plain
109
+ * `restColor` right of it; the marker polyline stops there. */
110
+ clipRightX: SharedValue<number>;
111
+ }
112
+
113
+ /** Stable empties so the constant-mode short-circuit returns a churn-free reference. */
114
+ const EMPTY_PTS: number[] = [];
115
+ const EMPTY_SAMPLES: number[] = new Array(THRESHOLD_SAMPLE_COUNT).fill(0);
116
+
117
+ /**
118
+ * Per-frame screen geometry for a **time-varying** threshold — a plain
119
+ * `LiveChartPoint[]` `value` or a live `SharedValue<LiveChartPoint[]>` `series`
120
+ * (which wins when both are given): the screen polyline (marker line +
121
+ * fill-band bottom), the shader's pixel-Y `samples[]`, the current value/anchor
122
+ * for the badge, and the `extendToNow` cutoff X. The array buffers ping-pong
123
+ * (Reanimated only re-notifies subscribers when the returned reference
124
+ * changes). When the threshold is a constant `SharedValue<number>` every
125
+ * worklet short-circuits cheaply and {@link useThreshold} drives the render
126
+ * instead.
127
+ */
128
+ export function useThresholdSeries(
129
+ engine: ChartEngineLayout,
130
+ padding: ChartPadding,
131
+ value: ThresholdValue,
132
+ series: SharedValue<LiveChartPoint[]> | null = null,
133
+ extendToNow = true,
134
+ ): ThresholdSeriesGeometry {
135
+ const cacheRef = useRef<{
136
+ ptsA: number[];
137
+ ptsB: number[];
138
+ ptsTick: boolean;
139
+ sampA: number[];
140
+ sampB: number[];
141
+ sampTick: boolean;
142
+ } | null>(null);
143
+ if (cacheRef.current === null) {
144
+ cacheRef.current = {
145
+ ptsA: [],
146
+ ptsB: [],
147
+ ptsTick: false,
148
+ sampA: [],
149
+ sampB: [],
150
+ sampTick: false,
151
+ };
152
+ }
153
+
154
+ // Per-worklet inline: the live SharedValue form wins, then the plain-array
155
+ // form; null in constant / no-threshold mode. Inlined (not a shared helper)
156
+ // so `series` sits directly in each worklet's closure and Reanimated tracks
157
+ // it as a mapper input.
158
+ const samples = useDerivedValue(() => {
159
+ const pts = series ? series.get() : Array.isArray(value) ? value : null;
160
+ if (pts === null) return EMPTY_SAMPLES;
161
+ const cache = cacheRef.current!;
162
+ cache.sampTick = !cache.sampTick;
163
+ const buf = cache.sampTick ? cache.sampA : cache.sampB;
164
+ return sampleThresholdY(
165
+ pts,
166
+ engine.timestamp.get(),
167
+ engine.displayWindow.get(),
168
+ engine.displayMin.get(),
169
+ engine.displayMax.get(),
170
+ engine.canvasHeight.get(),
171
+ padding.top,
172
+ padding.bottom,
173
+ THRESHOLD_SAMPLE_COUNT,
174
+ buf,
175
+ );
176
+ });
177
+
178
+ const clipRightX = useDerivedValue(() => {
179
+ if (extendToNow) return THRESHOLD_NO_CLIP;
180
+ const pts = series ? series.get() : Array.isArray(value) ? value : null;
181
+ if (pts === null || pts.length === 0) return THRESHOLD_NO_CLIP;
182
+ const win = engine.displayWindow.get();
183
+ const plotLeft = padding.left;
184
+ const plotRight = engine.canvasWidth.get() - padding.right;
185
+ if (!(win > 0) || plotRight <= plotLeft) return THRESHOLD_NO_CLIP;
186
+ const winStart = engine.timestamp.get() - win;
187
+ const lastT = pts[pts.length - 1].time;
188
+ return plotLeft + ((lastT - winStart) / win) * (plotRight - plotLeft);
189
+ });
190
+
191
+ // Marker-line polyline projected from the SAME `samples` the band + shader read
192
+ // (the time-anchored, gliding grid). Tracing the exact threshold vertices
193
+ // instead would put the marker a fraction of a sample off the band/stroke
194
+ // split — the "gradient lagging the line" — so the marker, band edge and
195
+ // stroke colour all share one source of truth here. The grid overhangs the
196
+ // plot, so the ends are pinned to the exact plot edges via interpolation —
197
+ // which also keeps the dash pattern's anchor (the path start) fixed while the
198
+ // geometry glides. With `extendToNow` off the right end pins to the
199
+ // threshold's end X instead.
200
+ const screenPts = useDerivedValue(() => {
201
+ const pts = series ? series.get() : Array.isArray(value) ? value : null;
202
+ if (pts === null || pts.length === 0) return EMPTY_PTS;
203
+ const s = samples.get();
204
+ const n = s.length;
205
+ if (n < 2) return EMPTY_PTS;
206
+ const plotLeft = padding.left;
207
+ const plotRight = engine.canvasWidth.get() - padding.right;
208
+ const endX = Math.min(plotRight, clipRightX.get());
209
+ if (endX <= plotLeft) return EMPTY_PTS;
210
+ const cache = cacheRef.current!;
211
+ cache.ptsTick = !cache.ptsTick;
212
+ const buf = cache.ptsTick ? cache.ptsA : cache.ptsB;
213
+ buf.length = 0;
214
+ const [x0, x1] = thresholdSampleSpanX(
215
+ engine.timestamp.get(),
216
+ engine.displayWindow.get(),
217
+ plotLeft,
218
+ plotRight,
219
+ n,
220
+ );
221
+ buf.push(plotLeft, sampleThresholdYAt(s, x0, x1, plotLeft));
222
+ const step = (x1 - x0) / (n - 1);
223
+ for (let i = 0; i < n; i++) {
224
+ const sx = x0 + step * i;
225
+ if (sx > plotLeft && sx < endX) buf.push(sx, s[i]);
226
+ }
227
+ buf.push(endX, sampleThresholdYAt(s, x0, x1, endX));
228
+ return buf;
229
+ });
230
+
231
+ const visible = useDerivedValue(() => {
232
+ if (series === null && !Array.isArray(value)) return false;
233
+ return thresholdSeriesVisible(
234
+ screenPts.get(),
235
+ engine.canvasHeight.get(),
236
+ padding.top,
237
+ padding.bottom,
238
+ );
239
+ });
240
+
241
+ const currentValue = useDerivedValue(() => {
242
+ const pts = series ? series.get() : Array.isArray(value) ? value : null;
243
+ if (pts === null) return NaN;
244
+ return interpolateAtTime(pts, engine.timestamp.get()) ?? NaN;
245
+ });
246
+
247
+ const currentLineY = useDerivedValue(() =>
248
+ thresholdLineY(
249
+ currentValue.get(),
250
+ engine.displayMin.get(),
251
+ engine.displayMax.get(),
252
+ engine.canvasHeight.get(),
253
+ padding.top,
254
+ padding.bottom,
255
+ ),
256
+ );
257
+
258
+ const currentVisible = useDerivedValue(() => {
259
+ if (!extendToNow) {
260
+ // The threshold ends at its last point — no badge past it.
261
+ const pts = series ? series.get() : Array.isArray(value) ? value : null;
262
+ if (pts === null || pts.length === 0) return false;
263
+ if (pts[pts.length - 1].time < engine.timestamp.get()) return false;
264
+ }
265
+ return thresholdVisible(
266
+ currentLineY.get(),
267
+ engine.canvasHeight.get(),
268
+ padding.top,
269
+ padding.bottom,
270
+ );
271
+ });
272
+
273
+ return {
274
+ screenPts,
275
+ samples,
276
+ visible,
277
+ currentValue,
278
+ currentLineY,
279
+ currentVisible,
280
+ clipRightX,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Pack a series geometry + a color set into the {@link ThresholdSplitShader}'s
286
+ * uniforms (one `SharedValue` per paint — stroke vs. the alpha-reduced fill band).
287
+ * The object is rebuilt each frame so the shader re-paints as `samples` advance;
288
+ * `sampleLeft`/`sampleRight` are the gliding pixel-X span of the sample grid and
289
+ * `clipRight`/`restColor` implement the `extendToNow: false` cutoff (plain line
290
+ * color for the stroke, transparent for the band).
291
+ */
292
+ export function useThresholdSplitUniforms(
293
+ samples: SharedValue<number[]>,
294
+ engine: ChartEngineLayout,
295
+ padding: ChartPadding,
296
+ aboveColor: number[],
297
+ belowColor: number[],
298
+ restColor: number[],
299
+ clipRightX: SharedValue<number>,
300
+ ): SharedValue<Uniforms> {
301
+ return useDerivedValue<Uniforms>(() => {
302
+ const s = samples.get();
303
+ const [x0, x1] = thresholdSampleSpanX(
304
+ engine.timestamp.get(),
305
+ engine.displayWindow.get(),
306
+ padding.left,
307
+ engine.canvasWidth.get() - padding.right,
308
+ s.length,
309
+ );
310
+ return {
311
+ sampleLeft: x0,
312
+ sampleRight: x1,
313
+ clipRight: clipRightX.get(),
314
+ aboveColor,
315
+ belowColor,
316
+ restColor,
317
+ samples: s,
318
+ };
319
+ });
320
+ }
@@ -4,6 +4,19 @@
4
4
  * wires the live SharedValues into them.
5
5
  */
6
6
 
7
+ import { interpolateAtTime } from "./interpolate";
8
+ import type { LiveChartPoint } from "../types";
9
+
10
+ /**
11
+ * Sample resolution for the time-varying split shader: the threshold series is
12
+ * projected to this many evenly-spaced pixel-Y values across the plot, which the
13
+ * shader linearly interpolates between. ~one sample per 6px on a phone plot —
14
+ * fine enough that the line's crossing point is coloured accurately. The shader
15
+ * walks these in an unrolled loop (`THRESHOLD_SAMPLE_COUNT - 1` iterations), so
16
+ * keep it modest to stay within SkSL's unroll limits.
17
+ */
18
+ export const THRESHOLD_SAMPLE_COUNT = 64;
19
+
7
20
  /**
8
21
  * Pixel Y of a threshold value within the plot. Mirrors the line path's value→Y
9
22
  * mapping in `buildLinePoints` (`top + (max - v) / range * chartH`). Returns NaN
@@ -57,3 +70,240 @@ export function thresholdSplitPositions(
57
70
  else if (t > 1) t = 1;
58
71
  return [0, t, t, 1];
59
72
  }
73
+
74
+ /* ------------------------------------------------------------------------- *
75
+ * Time-varying threshold (a `LiveChartPoint[]` series, not a constant value)
76
+ * ------------------------------------------------------------------------- */
77
+
78
+ /**
79
+ * Spacing of the threshold sample grid, in seconds. `count - 2` (not `count - 1`)
80
+ * so the `count` samples cover the window plus up to one spacing of overhang on
81
+ * each side — the grid is anchored to absolute time (see
82
+ * {@link thresholdSampleStart}) and must keep covering the plot as it glides.
83
+ */
84
+ export function thresholdSampleStep(windowSecs: number, count: number): number {
85
+ "worklet";
86
+ return windowSecs / Math.max(count - 2, 1);
87
+ }
88
+
89
+ /**
90
+ * First sample time of the grid: the greatest multiple of the spacing at or
91
+ * before the window start. Anchoring sample TIMES to an absolute grid (instead
92
+ * of evenly across the current window) keeps each sample's *value* stable while
93
+ * the window scrolls — the grid's *pixel* positions glide left each frame, so a
94
+ * step riser translates fluidly instead of popping from one fixed sample bin to
95
+ * the next (~plotWidth/63 px at a time) while the data line glides beside it.
96
+ */
97
+ export function thresholdSampleStart(
98
+ now: number,
99
+ windowSecs: number,
100
+ count: number,
101
+ ): number {
102
+ "worklet";
103
+ const dt = thresholdSampleStep(windowSecs, count);
104
+ return Math.floor((now - windowSecs) / dt) * dt;
105
+ }
106
+
107
+ /**
108
+ * Pixel-X endpoints `[x0, x1]` of the sample grid for the current frame — the
109
+ * span the shader / band / marker interpolate {@link sampleThresholdY}'s output
110
+ * across. Overhangs `[plotLeft, plotRight]` by up to one spacing per side and
111
+ * glides with the window. Falls back to the plot edges when the window or plot
112
+ * is degenerate (matching `sampleThresholdY`'s degenerate fill).
113
+ */
114
+ export function thresholdSampleSpanX(
115
+ now: number,
116
+ windowSecs: number,
117
+ plotLeft: number,
118
+ plotRight: number,
119
+ count: number,
120
+ ): [number, number] {
121
+ "worklet";
122
+ const span = plotRight - plotLeft;
123
+ if (!(windowSecs > 0) || span <= 0 || count < 2) return [plotLeft, plotRight];
124
+ const dt = thresholdSampleStep(windowSecs, count);
125
+ const t0 = thresholdSampleStart(now, windowSecs, count);
126
+ const winStart = now - windowSecs;
127
+ const xScale = span / windowSecs;
128
+ const x0 = plotLeft + (t0 - winStart) * xScale;
129
+ return [x0, x0 + (count - 1) * dt * xScale];
130
+ }
131
+
132
+ /**
133
+ * Sample the threshold series to `count` pixel-Y values on the time-anchored
134
+ * grid ({@link thresholdSampleStart}/{@link thresholdSampleSpanX}), for the
135
+ * split shader's `samples[]` uniform (it linearly interpolates between them and
136
+ * compares each fragment's Y). Clamps to the series' first/last value outside
137
+ * its range (flat extension). When the canvas or range is degenerate — or a
138
+ * series value is NaN — it fills with a far-below Y, so the shader paints the
139
+ * above-color everywhere — matching the constant split's pre-layout fallback
140
+ * (`thresholdSplitPositions` → solid above).
141
+ */
142
+ export function sampleThresholdY(
143
+ points: LiveChartPoint[],
144
+ now: number,
145
+ windowSecs: number,
146
+ displayMin: number,
147
+ displayMax: number,
148
+ canvasHeight: number,
149
+ paddingTop: number,
150
+ paddingBottom: number,
151
+ count: number,
152
+ out?: number[],
153
+ ): number[] {
154
+ "worklet";
155
+ const arr: number[] = out ?? [];
156
+ if (out) out.length = 0;
157
+ const chartH = canvasHeight - paddingTop - paddingBottom;
158
+ const valRange = displayMax - displayMin;
159
+ // A Y safely below every fragment → shader compares "above" everywhere.
160
+ // (canvasHeight * 2 stays below the canvas even when chartH is negative.)
161
+ const farBelow = canvasHeight > 0 ? canvasHeight * 2 : 1e6;
162
+ // !(valRange > 0) also catches a NaN display range.
163
+ if (points.length === 0 || !(valRange > 0) || chartH <= 0 || windowSecs <= 0) {
164
+ for (let i = 0; i < count; i++) arr.push(farBelow);
165
+ return arr;
166
+ }
167
+ const dt = thresholdSampleStep(windowSecs, count);
168
+ const t0 = thresholdSampleStart(now, windowSecs, count);
169
+ for (let i = 0; i < count; i++) {
170
+ const v = interpolateAtTime(points, t0 + i * dt)!;
171
+ const y = thresholdLineY(
172
+ v,
173
+ displayMin,
174
+ displayMax,
175
+ canvasHeight,
176
+ paddingTop,
177
+ paddingBottom,
178
+ );
179
+ // A NaN series value degrades per-sample to the solid-above fallback
180
+ // instead of leaking NaN into the shader uniform / band vertices.
181
+ arr.push(Number.isFinite(y) ? y : farBelow);
182
+ }
183
+ return arr;
184
+ }
185
+
186
+ /**
187
+ * Evaluate the threshold at pixel-x `x` from the evenly-spaced `samples[]` (the
188
+ * same array the split shader reads), linearly interpolated across the sample
189
+ * span `[spanLeft, spanRight]` (see {@link thresholdSampleSpanX}) and clamped
190
+ * outside it. The profit/loss band's bottom edge and the marker polyline's
191
+ * plot-edge pins are built from this so their geometry matches the shader
192
+ * **exactly** — a sharp step in the threshold becomes the same ~1-sample ramp in
193
+ * all of them, so no green/red sliver bleeds through at a step riser.
194
+ */
195
+ export function sampleThresholdYAt(
196
+ samples: number[],
197
+ spanLeft: number,
198
+ spanRight: number,
199
+ x: number,
200
+ ): number {
201
+ "worklet";
202
+ const count = samples.length;
203
+ if (count === 0) return 0;
204
+ if (count === 1) return samples[0];
205
+ const span = spanRight - spanLeft;
206
+ if (span <= 0) return samples[0];
207
+ const u = ((x - spanLeft) / span) * (count - 1);
208
+ if (u <= 0) return samples[0];
209
+ if (u >= count - 1) return samples[count - 1];
210
+ const i = Math.floor(u);
211
+ return samples[i] + (samples[i + 1] - samples[i]) * (u - i);
212
+ }
213
+
214
+ /**
215
+ * Dash-phase (px) for the series marker line so its dash pattern travels WITH
216
+ * the scrolling threshold instead of staying screen-fixed. The marker path
217
+ * starts at the static plot edge, so without a moving phase the dashes sit
218
+ * still while the staircase glides left — reading as the dots "marching" right
219
+ * along the line. Advancing the phase at exactly the content scroll speed
220
+ * (`span/windowSecs` px per second, mod the dash cycle) pins the pattern to the
221
+ * geometry. Returns 0 for a degenerate window/plot/cycle (static dashes).
222
+ */
223
+ export function thresholdDashPhase(
224
+ now: number,
225
+ windowSecs: number,
226
+ plotLeft: number,
227
+ plotRight: number,
228
+ dashCycle: number,
229
+ ): number {
230
+ "worklet";
231
+ const span = plotRight - plotLeft;
232
+ if (!(windowSecs > 0) || span <= 0 || !(dashCycle > 0)) return 0;
233
+ return ((now * span) / windowSecs) % dashCycle;
234
+ }
235
+
236
+ /**
237
+ * Min/max of a threshold series over the visible window `[now - windowSecs,
238
+ * now]` — the values folded into the engine's Y-range fit when
239
+ * `threshold.includeInRange` is set (like reference-line values). Uses the
240
+ * clamped window-edge values plus every interior point; with `extendToNow` off,
241
+ * the window end clamps to the series' last point (nothing projects forward).
242
+ * Writes `[min, max]` into `out` and returns it, or returns null when the
243
+ * series is empty / entirely outside the effective window / non-finite.
244
+ */
245
+ export function thresholdRangeMinMax(
246
+ points: LiveChartPoint[],
247
+ now: number,
248
+ windowSecs: number,
249
+ extendToNow: boolean,
250
+ out: [number, number],
251
+ ): [number, number] | null {
252
+ "worklet";
253
+ if (points.length === 0 || !(windowSecs > 0)) return null;
254
+ const tStart = now - windowSecs;
255
+ let tEnd = now;
256
+ if (!extendToNow) {
257
+ const lastT = points[points.length - 1].time;
258
+ if (lastT < tStart) return null;
259
+ if (lastT < tEnd) tEnd = lastT;
260
+ }
261
+ let mn = interpolateAtTime(points, tStart)!;
262
+ let mx = mn;
263
+ const vEnd = interpolateAtTime(points, tEnd)!;
264
+ if (vEnd < mn) mn = vEnd;
265
+ else if (vEnd > mx) mx = vEnd;
266
+ for (let i = 0; i < points.length; i++) {
267
+ const t = points[i].time;
268
+ if (t <= tStart) continue;
269
+ if (t >= tEnd) break;
270
+ const v = points[i].value;
271
+ if (v < mn) mn = v;
272
+ else if (v > mx) mx = v;
273
+ }
274
+ if (!Number.isFinite(mn) || !Number.isFinite(mx)) return null;
275
+ out[0] = mn;
276
+ out[1] = mx;
277
+ return out;
278
+ }
279
+
280
+ /**
281
+ * True when any part of a threshold screen polyline crosses the plot: a vertex
282
+ * inside it, or a segment whose endpoints straddle it (a step riser can jump
283
+ * from below the bottom edge to above the top edge between two samples without
284
+ * landing a vertex inside).
285
+ */
286
+ export function thresholdSeriesVisible(
287
+ screenPts: number[],
288
+ canvasHeight: number,
289
+ paddingTop: number,
290
+ paddingBottom: number,
291
+ ): boolean {
292
+ "worklet";
293
+ if (canvasHeight <= 0) return false;
294
+ let prev = NaN;
295
+ for (let i = 1; i < screenPts.length; i += 2) {
296
+ const y = screenPts[i];
297
+ if (!Number.isFinite(y)) {
298
+ prev = NaN;
299
+ continue;
300
+ }
301
+ if (thresholdVisible(y, canvasHeight, paddingTop, paddingBottom)) return true;
302
+ // Both endpoints are outside the plot band — on opposite sides means the
303
+ // segment crosses straight through it.
304
+ if (Number.isFinite(prev) && prev < paddingTop !== y < paddingTop)
305
+ return true;
306
+ prev = y;
307
+ }
308
+ return false;
309
+ }
package/src/types.ts CHANGED
@@ -431,32 +431,77 @@ export interface LineConfig {
431
431
  }
432
432
 
433
433
  /**
434
- * Color the line above vs. below a live threshold value — green above, red below
435
- * by default. The threshold is **always** a `SharedValue` so it can track a live
436
- * benchmark (break-even / average cost, VWAP, previous close, a peg) on the UI
437
- * thread without re-rendering. Drives a hard-split line stroke and, optionally, a
438
- * tinted profit/loss fill band and a dashed marker line at the threshold.
434
+ * Color the line above vs. below a threshold — green above, red below by
435
+ * default. The threshold is either a single **live benchmark** (a
436
+ * `SharedValue<number>` that tracks on the UI thread without re-rendering) or a
437
+ * **time-varying series** (a `LiveChartPoint[]` the split follows point-for-point
438
+ * a stepped break-even, a historical VWAP). Drives a hard-split line stroke
439
+ * and, optionally, a tinted profit/loss fill band and a marker line at the
440
+ * threshold.
439
441
  *
440
442
  * The split stroke supersedes `LineConfig.color`/`colors` and segment recoloring
441
443
  * for the main line while a threshold is set.
442
444
  */
443
445
  export interface ThresholdConfig {
444
446
  /**
445
- * The split value, in Y-axis (price) units. A `SharedValue` — update it with
446
- * `.set()` and the split tracks live on the UI thread (break-even, VWAP, the
447
- * previous close, a peg, …).
447
+ * The split value, in Y-axis (price) units. Two forms:
448
+ *
449
+ * - **`SharedValue<number>`** a single live benchmark. Update it with
450
+ * `.set()` and the horizontal split tracks on the UI thread without
451
+ * re-rendering (break-even / average cost, VWAP, the previous close, a peg).
452
+ * - **`LiveChartPoint[]`** — a *time-varying* threshold (e.g. a historical
453
+ * break-even that steps up as you average in). The stroke split, fill band
454
+ * and marker line follow the series point-for-point. The series clamps to its
455
+ * first/last value outside its own time range, so a threshold whose last
456
+ * point sits behind the live edge extends as a flat line to "now" (see
457
+ * {@link extendToNow}). Flows in on re-render — pass a stable (memoized)
458
+ * array and reserve it for thresholds that change occasionally; for a
459
+ * threshold series that updates live, use {@link series} instead.
460
+ *
461
+ * Provide `value` or {@link series} (not both) — `series` wins if both are set.
462
+ */
463
+ value?: SharedValue<number> | LiveChartPoint[];
464
+ /**
465
+ * A **live** time-varying threshold: like the `LiveChartPoint[]` form of
466
+ * {@link value}, but a `SharedValue` — update it with `.set()`/`.modify()` and
467
+ * the split tracks on the UI thread without re-rendering (a VWAP that updates
468
+ * every tick, mirroring how the chart's own `data` prop works). Points must be
469
+ * sorted by `time`, like `data`. Takes precedence over {@link value}.
470
+ */
471
+ series?: SharedValue<LiveChartPoint[]>;
472
+ /**
473
+ * Stroke color where the line is at/above `value`. Default: palette up-green
474
+ * (`candleUp`). With a series `value`, use hex (`#rgb`/`#rrggbb`), `rgb()` or
475
+ * `rgba()` — the split shader parses these; named CSS colors and 8-digit hex
476
+ * are only supported by the constant form.
448
477
  */
449
- value: SharedValue<number>;
450
- /** Stroke color where the line is at/above `value`. Default: palette up-green (`candleUp`). */
451
478
  aboveColor?: string;
452
- /** Stroke color where the line is below `value`. Default: palette down-red (`candleDown`). */
479
+ /** Stroke color where the line is below `value`. Default: palette down-red
480
+ * (`candleDown`). Same format support as `aboveColor`. */
453
481
  belowColor?: string;
454
482
  /**
455
- * Tint the area between the line and `value` (the profit/loss band) toward the
456
- * above/below colors. Independent of the baseline `gradient` fill — set
457
- * `gradient={false}` for the threshold band alone. Default `false`.
483
+ * Tint the area between the line and the threshold (the profit/loss band)
484
+ * toward the above/below colors. Independent of the baseline `gradient` fill —
485
+ * set `gradient={false}` for the threshold band alone. `true` → default band
486
+ * opacity (`0.16`), or an object to tune it. Default `false`.
458
487
  */
459
- fill?: boolean;
488
+ fill?: boolean | ThresholdFillConfig;
489
+ /**
490
+ * Fold the threshold into the Y-axis range fit — like reference lines — so a
491
+ * benchmark outside the data's own range stays on-plot instead of rendering
492
+ * invisibly (marker off-plot, whole line one color). For a series, the values
493
+ * visible in the current window count (respecting {@link extendToNow}).
494
+ * Default `false` (range fits the data only).
495
+ */
496
+ includeInRange?: boolean;
497
+ /**
498
+ * Series forms only: extend the threshold **flat past its last point to
499
+ * "now"**, carrying the last known benchmark forward. Set `false` for a
500
+ * benchmark that must not project into the future (a closed session's VWAP) —
501
+ * right of the last point the stroke keeps its plain line color and the band /
502
+ * marker / badge end. Default `true`.
503
+ */
504
+ extendToNow?: boolean;
460
505
  /**
461
506
  * Dashed marker line + optional gutter label at the threshold. `true` → a dashed
462
507
  * line in the palette reference color; object → styled; omit/`false` → none.
@@ -483,6 +528,16 @@ export interface ThresholdLineConfig {
483
528
  strokeWidth?: number;
484
529
  /** Append the formatted threshold value to the label. Default `false`. */
485
530
  showValue?: boolean;
531
+ /** Label text color. Defaults to {@link color}, then palette `refLabel` —
532
+ * mirroring `ReferenceLine.labelColor`. */
533
+ labelColor?: string;
534
+ }
535
+
536
+ /** Object form of {@link ThresholdConfig.fill} — band tuning. */
537
+ export interface ThresholdFillConfig {
538
+ /** Band fill opacity (0–1), applied to the above/below colors. Multiplies an
539
+ * `rgba()` color's own alpha. Default `0.16` (matching reference-line bands). */
540
+ opacity?: number;
486
541
  }
487
542
 
488
543
  /** Area fill gradient beneath the chart line. */