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.
- package/dist/components/LiveChart.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/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/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 +69 -15
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/LiveChart.tsx +271 -42
- package/src/components/ThresholdLineOverlay.tsx +35 -5
- package/src/components/ThresholdSplitShader.tsx +92 -0
- package/src/core/liveChartEngineTick.ts +28 -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/useThreshold.ts +267 -10
- package/src/math/threshold.ts +250 -0
- package/src/types.ts +70 -15
|
@@ -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
|
+
}
|
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
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
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.
|
|
446
|
-
*
|
|
447
|
-
*
|
|
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
|
|
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
|
|
456
|
-
* above/below colors. Independent of the baseline `gradient` fill —
|
|
457
|
-
* `gradient={false}` for the threshold band alone.
|
|
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. */
|