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,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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `math/threshold` so
|
|
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:
|
|
51
|
+
value: ThresholdValue,
|
|
33
52
|
): ThresholdGeometry {
|
|
34
|
-
const lineY = useDerivedValue(() =>
|
|
35
|
-
|
|
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
|
+
}
|
package/src/math/threshold.ts
CHANGED
|
@@ -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
|
+
}
|