react-native-livechart 4.7.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/LoadingOverlay.d.ts +3 -1
- package/dist/components/LoadingOverlay.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 +24 -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 +80 -19
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/LiveChart.tsx +284 -46
- package/src/components/LiveChartSeries.tsx +12 -3
- package/src/components/LoadingOverlay.tsx +35 -28
- 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 +35 -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 +81 -19
|
@@ -62,6 +62,7 @@ export function LoadingOverlay({
|
|
|
62
62
|
badgeTail = true,
|
|
63
63
|
badgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
64
64
|
emptyMetrics = EMPTY_STATE_METRICS_DEFAULTS,
|
|
65
|
+
showAxisLabels = true,
|
|
65
66
|
lineColor,
|
|
66
67
|
lineStrokeWidth,
|
|
67
68
|
waveAmplitude = 14,
|
|
@@ -93,6 +94,8 @@ export function LoadingOverlay({
|
|
|
93
94
|
badgeMetrics?: BadgeMetrics;
|
|
94
95
|
/** Empty-state layout tokens. */
|
|
95
96
|
emptyMetrics?: EmptyStateMetrics;
|
|
97
|
+
/** Draw the skeleton Y-axis label placeholders. Default `true`. */
|
|
98
|
+
showAxisLabels?: boolean;
|
|
96
99
|
}) {
|
|
97
100
|
// Same left-inset formula as GridOverlay (only used when badge=true)
|
|
98
101
|
const leftInset =
|
|
@@ -268,38 +271,42 @@ export function LoadingOverlay({
|
|
|
268
271
|
</Group>
|
|
269
272
|
|
|
270
273
|
{/* Skeleton Y-axis label placeholders */}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
274
|
+
{showAxisLabels ? (
|
|
275
|
+
<>
|
|
276
|
+
<RoundedRect
|
|
277
|
+
x={lx}
|
|
278
|
+
y={ly0}
|
|
279
|
+
width={RECT_W}
|
|
280
|
+
height={RECT_H}
|
|
281
|
+
r={RECT_R}
|
|
277
282
|
color={loadingColor}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
283
|
+
/>
|
|
284
|
+
<RoundedRect
|
|
285
|
+
x={lx}
|
|
286
|
+
y={ly1}
|
|
287
|
+
width={RECT_W}
|
|
288
|
+
height={RECT_H}
|
|
289
|
+
r={RECT_R}
|
|
285
290
|
color={loadingColor}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
291
|
+
/>
|
|
292
|
+
<RoundedRect
|
|
293
|
+
x={lx}
|
|
294
|
+
y={ly2}
|
|
295
|
+
width={RECT_W}
|
|
296
|
+
height={RECT_H}
|
|
297
|
+
r={RECT_R}
|
|
293
298
|
color={loadingColor}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
/>
|
|
300
|
+
<RoundedRect
|
|
301
|
+
x={lx}
|
|
302
|
+
y={ly3}
|
|
303
|
+
width={RECT_W}
|
|
304
|
+
height={RECT_H}
|
|
305
|
+
r={RECT_R}
|
|
301
306
|
color={loadingColor}
|
|
302
|
-
|
|
307
|
+
/>
|
|
308
|
+
</>
|
|
309
|
+
) : null}
|
|
303
310
|
|
|
304
311
|
{/* Empty state label */}
|
|
305
312
|
<SkiaText
|
|
@@ -12,6 +12,7 @@ import type { ResolvedThresholdLineConfig } from "../core/resolveConfig";
|
|
|
12
12
|
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
13
13
|
import type { ChartPadding } from "../draw/line";
|
|
14
14
|
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
15
|
+
import { thresholdDashPhase } from "../math/threshold";
|
|
15
16
|
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
16
17
|
import type { LiveChartPalette } from "../types";
|
|
17
18
|
|
|
@@ -32,6 +33,9 @@ interface ThresholdMarkerProps {
|
|
|
32
33
|
palette: LiveChartPalette;
|
|
33
34
|
font: SkFont;
|
|
34
35
|
formatValue: (v: number) => string;
|
|
36
|
+
/** When set, draw the marker as this time-varying threshold polyline (`[x, y, …]`)
|
|
37
|
+
* instead of a horizontal line at `lineY`. */
|
|
38
|
+
seriesPts?: SharedValue<number[]>;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/**
|
|
@@ -47,18 +51,44 @@ export function ThresholdLineOverlay({
|
|
|
47
51
|
visible,
|
|
48
52
|
cfg,
|
|
49
53
|
palette,
|
|
54
|
+
seriesPts,
|
|
50
55
|
}: ThresholdMarkerProps) {
|
|
51
56
|
const lineColor = cfg.color ?? palette.refLine;
|
|
52
57
|
const { strokeWidth, intervals } = cfg;
|
|
58
|
+
const isSeries = seriesPts !== undefined;
|
|
59
|
+
const dashCycle = intervals[0] + intervals[1];
|
|
60
|
+
|
|
61
|
+
// Series marker: advance the dash phase with the content scroll so the
|
|
62
|
+
// pattern rides the staircase instead of sitting screen-fixed under it (the
|
|
63
|
+
// path start is pinned at the plot edge, so a static phase reads as the dots
|
|
64
|
+
// marching right along the gliding line). The constant marker is a static
|
|
65
|
+
// level — its dashes stay screen-fixed (phase 0).
|
|
66
|
+
const dashPhase = useDerivedValue(() => {
|
|
67
|
+
if (!isSeries) return 0;
|
|
68
|
+
return thresholdDashPhase(
|
|
69
|
+
engine.timestamp.get(),
|
|
70
|
+
engine.displayWindow.get(),
|
|
71
|
+
padding.left,
|
|
72
|
+
engine.canvasWidth.get() - padding.right,
|
|
73
|
+
dashCycle,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
53
76
|
|
|
54
77
|
const builder = usePathBuilder();
|
|
55
78
|
|
|
56
79
|
const linePath = useDerivedValue(() => {
|
|
57
80
|
const b = builder.value;
|
|
58
81
|
if (visible.get()) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
const sp = seriesPts?.get();
|
|
83
|
+
if (sp && sp.length >= 4) {
|
|
84
|
+
// Time-varying threshold: trace the polyline across the plot.
|
|
85
|
+
b.moveTo(sp[0], sp[1]);
|
|
86
|
+
for (let i = 2; i < sp.length; i += 2) b.lineTo(sp[i], sp[i + 1]);
|
|
87
|
+
} else {
|
|
88
|
+
const y = lineY.get();
|
|
89
|
+
b.moveTo(padding.left, y);
|
|
90
|
+
b.lineTo(engine.canvasWidth.get() - padding.right, y);
|
|
91
|
+
}
|
|
62
92
|
}
|
|
63
93
|
return b.detach();
|
|
64
94
|
});
|
|
@@ -73,7 +103,7 @@ export function ThresholdLineOverlay({
|
|
|
73
103
|
strokeWidth={strokeWidth}
|
|
74
104
|
color={lineColor}
|
|
75
105
|
>
|
|
76
|
-
<DashPathEffect intervals={intervals} />
|
|
106
|
+
<DashPathEffect intervals={intervals} phase={dashPhase} />
|
|
77
107
|
</Path>
|
|
78
108
|
</Group>
|
|
79
109
|
);
|
|
@@ -100,7 +130,7 @@ export function ThresholdBadgeOverlay({
|
|
|
100
130
|
const { label, labelPosition, showValue } = cfg;
|
|
101
131
|
const badgeBackground = palette.tooltipBg;
|
|
102
132
|
const badgeBorderColor = cfg.color ?? palette.refLine;
|
|
103
|
-
const labelColor = cfg.color ?? palette.refLabel;
|
|
133
|
+
const labelColor = cfg.labelColor ?? cfg.color ?? palette.refLabel;
|
|
104
134
|
|
|
105
135
|
// Font metrics are stable → read once (getMetrics allocates + crosses JSI).
|
|
106
136
|
const { fontAscent, baselineOffset, pillH } = (() => {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Shader, Skia, type Uniforms } from "@shopify/react-native-skia";
|
|
2
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
3
|
+
|
|
4
|
+
import { THRESHOLD_SAMPLE_COUNT } from "../math/threshold";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-fragment split shader for a *time-varying* threshold. For each fragment it
|
|
8
|
+
* reads the threshold's Y at the fragment's X — linearly interpolated from
|
|
9
|
+
* `samples[]`, evenly spaced across `[plotLeft, plotRight]` — and paints
|
|
10
|
+
* `aboveColor` above the boundary (smaller Y), `belowColor` below. This is the
|
|
11
|
+
* polyline equivalent of the constant case's vertical hard-stop `LinearGradient`,
|
|
12
|
+
* which can only express a *horizontal* split.
|
|
13
|
+
*
|
|
14
|
+
* Used for both the line stroke and the profit/loss fill band (the band passes
|
|
15
|
+
* alpha-reduced colors). Output is premultiplied — Skia's shader convention, as in
|
|
16
|
+
* `AreaDotsOverlay`. One GPU draw, no per-frame clip / `saveLayer`.
|
|
17
|
+
*/
|
|
18
|
+
const SPLIT_SKSL = `
|
|
19
|
+
// Pixel-X span of the sample grid (thresholdSampleSpanX) — overhangs the plot
|
|
20
|
+
// and glides with the window so features move fluidly, frame to frame.
|
|
21
|
+
uniform float sampleLeft;
|
|
22
|
+
uniform float sampleRight;
|
|
23
|
+
// X where the threshold ends (extendToNow: false → the last point's pixel-X,
|
|
24
|
+
// else a huge sentinel). Fragments right of it paint restColor: the plain line
|
|
25
|
+
// color for the stroke, transparent for the band.
|
|
26
|
+
uniform float clipRight;
|
|
27
|
+
uniform vec4 aboveColor; // straight-alpha rgba, 0..1
|
|
28
|
+
uniform vec4 belowColor; // straight-alpha rgba, 0..1
|
|
29
|
+
uniform vec4 restColor; // straight-alpha rgba, 0..1
|
|
30
|
+
uniform float samples[${THRESHOLD_SAMPLE_COUNT}];
|
|
31
|
+
|
|
32
|
+
half4 main(vec2 xy) {
|
|
33
|
+
if (xy.x > clipRight) {
|
|
34
|
+
return half4(half3(restColor.rgb) * restColor.a, restColor.a);
|
|
35
|
+
}
|
|
36
|
+
float span = sampleRight - sampleLeft;
|
|
37
|
+
float u = span > 0.0 ? (xy.x - sampleLeft) / span : 0.0;
|
|
38
|
+
u = clamp(u, 0.0, 1.0) * float(${THRESHOLD_SAMPLE_COUNT - 1});
|
|
39
|
+
// SkSL forbids indexing a uniform array with a non-constant index, so walk the
|
|
40
|
+
// segments in an unrolled, constant-bound loop — the index is the loop variable
|
|
41
|
+
// (a compile-time constant per unrolled iteration) — and keep the last segment
|
|
42
|
+
// whose left edge is at or before the fragment's u.
|
|
43
|
+
float thrY = samples[0];
|
|
44
|
+
for (int j = 0; j < ${THRESHOLD_SAMPLE_COUNT - 1}; j++) {
|
|
45
|
+
if (u >= float(j)) {
|
|
46
|
+
thrY = mix(samples[j], samples[j + 1], clamp(u - float(j), 0.0, 1.0));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// 1 above the boundary, 0 below, with ~1px antialiasing across it.
|
|
50
|
+
float above = clamp(thrY - xy.y + 0.5, 0.0, 1.0);
|
|
51
|
+
vec4 c = mix(belowColor, aboveColor, above);
|
|
52
|
+
return half4(half3(c.rgb) * c.a, c.a);
|
|
53
|
+
}`;
|
|
54
|
+
|
|
55
|
+
// Compiled once. `RuntimeEffect.Make` THROWS on a compile error, so guard it —
|
|
56
|
+
// a shader failure must degrade to "no split" (the line keeps its plain color),
|
|
57
|
+
// never crash every chart screen at import. Null → the component no-ops.
|
|
58
|
+
let SPLIT_EFFECT: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
|
|
59
|
+
try {
|
|
60
|
+
SPLIT_EFFECT = Skia.RuntimeEffect.Make(SPLIT_SKSL);
|
|
61
|
+
} catch {
|
|
62
|
+
SPLIT_EFFECT = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether the split effect compiled. Callers must gate any `<Path>` whose ONLY
|
|
67
|
+
* paint is this shader on it — without the shader child the path fills with the
|
|
68
|
+
* default paint (opaque black). A fallback `color` prop can't stand in instead:
|
|
69
|
+
* Skia multiplies the shader output by the paint alpha, so a transparent color
|
|
70
|
+
* erases the shader's own output too.
|
|
71
|
+
*/
|
|
72
|
+
export const THRESHOLD_SPLIT_AVAILABLE = SPLIT_EFFECT !== null;
|
|
73
|
+
|
|
74
|
+
interface ThresholdSplitShaderProps {
|
|
75
|
+
/**
|
|
76
|
+
* Per-frame uniforms (built in `useThresholdSplitUniforms`): `sampleLeft` /
|
|
77
|
+
* `sampleRight` (the gliding sample-grid span, px), `aboveColor` / `belowColor`
|
|
78
|
+
* (straight-alpha `[r, g, b, a]` vec4s, channels 0..1) and `samples`
|
|
79
|
+
* (`THRESHOLD_SAMPLE_COUNT` threshold pixel-Y values on the grid).
|
|
80
|
+
*/
|
|
81
|
+
uniforms: SharedValue<Uniforms>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The split `RuntimeShader` as a paint child — drop it inside a stroked line
|
|
86
|
+
* `<Path>` or a filled band `<Path>`, like a `<LinearGradient>`.
|
|
87
|
+
*/
|
|
88
|
+
export function ThresholdSplitShader({ uniforms }: ThresholdSplitShaderProps) {
|
|
89
|
+
/* istanbul ignore next -- defensive: SPLIT_SKSL is static and compiles */
|
|
90
|
+
if (!SPLIT_EFFECT) return null;
|
|
91
|
+
return <Shader source={SPLIT_EFFECT} uniforms={uniforms} />;
|
|
92
|
+
}
|
|
@@ -2,6 +2,10 @@ import type { CandlePoint, LiveChartPoint } from "../types";
|
|
|
2
2
|
|
|
3
3
|
import { MOTION_METRICS_DEFAULTS } from "../constants";
|
|
4
4
|
import { lerp } from "../math/lerp";
|
|
5
|
+
import { thresholdRangeMinMax } from "../math/threshold";
|
|
6
|
+
|
|
7
|
+
/** Scratch for the threshold range fold — only alive within one tick call. */
|
|
8
|
+
const THRESHOLD_RANGE_SCRATCH: [number, number] = [0, 0];
|
|
5
9
|
|
|
6
10
|
export interface EngineTickMutable {
|
|
7
11
|
displayValue: number;
|
|
@@ -48,6 +52,15 @@ export interface EngineTickInput {
|
|
|
48
52
|
referenceValue: number | undefined;
|
|
49
53
|
/** Additional reference values (lines + bands) folded into the Y range. */
|
|
50
54
|
referenceValues?: number[];
|
|
55
|
+
/**
|
|
56
|
+
* Time-varying threshold series (`threshold.includeInRange`): its min/max over
|
|
57
|
+
* the visible window is folded into the Y range, like {@link referenceValues}
|
|
58
|
+
* but windowed each tick.
|
|
59
|
+
*/
|
|
60
|
+
thresholdRangePoints?: LiveChartPoint[];
|
|
61
|
+
/** Whether {@link thresholdRangePoints} extends flat past its last point to
|
|
62
|
+
* "now" (`threshold.extendToNow`). Default `true`. */
|
|
63
|
+
thresholdRangeExtendToNow?: boolean;
|
|
51
64
|
/** Clamp the computed lower bound at 0. */
|
|
52
65
|
nonNegative?: boolean;
|
|
53
66
|
/** Hard cap for the computed upper bound. */
|
|
@@ -285,6 +298,21 @@ export function tickLiveChartEngineFrame(
|
|
|
285
298
|
}
|
|
286
299
|
}
|
|
287
300
|
|
|
301
|
+
const thrPts = input.thresholdRangePoints;
|
|
302
|
+
if (thrPts !== undefined && thrPts.length > 0) {
|
|
303
|
+
const mm = thresholdRangeMinMax(
|
|
304
|
+
thrPts,
|
|
305
|
+
state.timestamp,
|
|
306
|
+
state.displayWindow,
|
|
307
|
+
input.thresholdRangeExtendToNow ?? true,
|
|
308
|
+
THRESHOLD_RANGE_SCRATCH,
|
|
309
|
+
);
|
|
310
|
+
if (mm !== null) {
|
|
311
|
+
if (mm[0] < tMin) tMin = mm[0];
|
|
312
|
+
if (mm[1] > tMax) tMax = mm[1];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
288
316
|
const isExaggerate = input.exaggerate;
|
|
289
317
|
if (tMin !== Infinity && tMax !== -Infinity) {
|
|
290
318
|
const rawRange = tMax - tMin;
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
LineStyleConfig,
|
|
15
15
|
LiveChartMetrics,
|
|
16
16
|
LiveChartMetricsOverride,
|
|
17
|
+
LiveChartPoint,
|
|
17
18
|
LoadingConfig,
|
|
18
19
|
MarkerClusterConfig,
|
|
19
20
|
DotConfig,
|
|
@@ -326,16 +327,31 @@ export interface ResolvedThresholdLineConfig {
|
|
|
326
327
|
intervals: [number, number];
|
|
327
328
|
strokeWidth: number;
|
|
328
329
|
showValue: boolean;
|
|
330
|
+
/** undefined → fall back to `color`, then palette.refLabel at render time. */
|
|
331
|
+
labelColor: string | undefined;
|
|
329
332
|
}
|
|
330
333
|
|
|
331
334
|
export interface ResolvedThresholdConfig {
|
|
332
|
-
/**
|
|
333
|
-
|
|
335
|
+
/**
|
|
336
|
+
* The split value (Y-axis units). A `SharedValue<number>` for a single live
|
|
337
|
+
* benchmark (read on the UI thread each frame), or a `LiveChartPoint[]` for a
|
|
338
|
+
* time-varying threshold the split follows point-for-point. `undefined` when
|
|
339
|
+
* {@link series} carries the threshold instead.
|
|
340
|
+
*/
|
|
341
|
+
value: SharedValue<number> | LiveChartPoint[] | undefined;
|
|
342
|
+
/** Live time-varying threshold (`SharedValue<LiveChartPoint[]>`); wins over `value`. */
|
|
343
|
+
series: SharedValue<LiveChartPoint[]> | null;
|
|
334
344
|
/** undefined → use palette.candleUp (up-green) at render time. */
|
|
335
345
|
aboveColor: string | undefined;
|
|
336
346
|
/** undefined → use palette.candleDown (down-red) at render time. */
|
|
337
347
|
belowColor: string | undefined;
|
|
338
348
|
fill: boolean;
|
|
349
|
+
/** Band opacity (0–1); {@link THRESHOLD_FILL_OPACITY_DEFAULT} unless tuned. */
|
|
350
|
+
fillOpacity: number;
|
|
351
|
+
/** Fold the threshold into the Y-axis range fit. */
|
|
352
|
+
includeInRange: boolean;
|
|
353
|
+
/** Series forms: extend flat past the last point to "now". */
|
|
354
|
+
extendToNow: boolean;
|
|
339
355
|
line: ResolvedThresholdLineConfig | null;
|
|
340
356
|
}
|
|
341
357
|
|
|
@@ -346,8 +362,12 @@ const THRESHOLD_LINE_DEFAULTS: ResolvedThresholdLineConfig = {
|
|
|
346
362
|
intervals: [4, 4],
|
|
347
363
|
strokeWidth: 1,
|
|
348
364
|
showValue: false,
|
|
365
|
+
labelColor: undefined,
|
|
349
366
|
};
|
|
350
367
|
|
|
368
|
+
/** Default threshold band opacity — matches reference-line bands. */
|
|
369
|
+
export const THRESHOLD_FILL_OPACITY_DEFAULT = 0.16;
|
|
370
|
+
|
|
351
371
|
/**
|
|
352
372
|
* Resolves the `threshold.line` sub-prop to a config or null (no marker line).
|
|
353
373
|
* `true` → dashed defaults, object → merged, falsy/undefined → null.
|
|
@@ -360,18 +380,24 @@ export function resolveThresholdLine(
|
|
|
360
380
|
|
|
361
381
|
/**
|
|
362
382
|
* Resolves the `threshold` prop to a fully-typed config or null (disabled).
|
|
363
|
-
* Presence-gated (like `referenceLines`/`markers`):
|
|
364
|
-
*
|
|
383
|
+
* Presence-gated (like `referenceLines`/`markers`): a config object with a
|
|
384
|
+
* `value` or `series` enables it — there is no boolean form.
|
|
365
385
|
*/
|
|
366
386
|
export function resolveThreshold(
|
|
367
387
|
prop: ThresholdConfig | undefined,
|
|
368
388
|
): ResolvedThresholdConfig | null {
|
|
369
|
-
if (!prop) return null;
|
|
389
|
+
if (!prop || (prop.value == null && prop.series == null)) return null;
|
|
370
390
|
return {
|
|
371
391
|
value: prop.value,
|
|
392
|
+
series: prop.series ?? null,
|
|
372
393
|
aboveColor: prop.aboveColor,
|
|
373
394
|
belowColor: prop.belowColor,
|
|
374
|
-
fill: prop.fill
|
|
395
|
+
fill: !!prop.fill,
|
|
396
|
+
fillOpacity:
|
|
397
|
+
(typeof prop.fill === "object" ? prop.fill.opacity : undefined) ??
|
|
398
|
+
THRESHOLD_FILL_OPACITY_DEFAULT,
|
|
399
|
+
includeInRange: prop.includeInRange ?? false,
|
|
400
|
+
extendToNow: prop.extendToNow ?? true,
|
|
375
401
|
line: resolveThresholdLine(prop.line),
|
|
376
402
|
};
|
|
377
403
|
}
|
|
@@ -644,6 +670,8 @@ export interface ResolvedLoadingConfig {
|
|
|
644
670
|
amplitude: number;
|
|
645
671
|
/** Breathing-wave speed multiplier. */
|
|
646
672
|
speed: number;
|
|
673
|
+
/** Draw the skeleton Y-axis label placeholders. */
|
|
674
|
+
axisLabels: boolean;
|
|
647
675
|
}
|
|
648
676
|
|
|
649
677
|
const LOADING_DEFAULTS: ResolvedLoadingConfig = {
|
|
@@ -651,6 +679,7 @@ const LOADING_DEFAULTS: ResolvedLoadingConfig = {
|
|
|
651
679
|
strokeWidth: undefined,
|
|
652
680
|
amplitude: LOADING_WAVE_AMPLITUDE,
|
|
653
681
|
speed: LOADING_WAVE_SPEED,
|
|
682
|
+
axisLabels: true,
|
|
654
683
|
};
|
|
655
684
|
|
|
656
685
|
/**
|
|
@@ -37,6 +37,17 @@ export interface EngineConfig {
|
|
|
37
37
|
* `referenceValues`). Read on the UI thread each frame; omit for non-draggable charts.
|
|
38
38
|
*/
|
|
39
39
|
liveReferenceValues?: SharedValue<number[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Time-varying threshold series folded into the axis-range fit
|
|
42
|
+
* (`threshold.includeInRange`): each tick contributes the series' min/max over
|
|
43
|
+
* the visible window — like {@link referenceValues}, but windowed. A
|
|
44
|
+
* `SharedValue` for the live `threshold.series` form, a plain array for the
|
|
45
|
+
* `LiveChartPoint[]` form.
|
|
46
|
+
*/
|
|
47
|
+
thresholdRangePoints?: SharedValue<LiveChartPoint[]> | LiveChartPoint[];
|
|
48
|
+
/** With {@link thresholdRangePoints}: whether the series extends flat past its
|
|
49
|
+
* last point to "now" (`threshold.extendToNow`). Default `true`. */
|
|
50
|
+
thresholdRangeExtendToNow?: boolean;
|
|
40
51
|
nonNegative?: boolean;
|
|
41
52
|
maxValue?: number;
|
|
42
53
|
nowOverride?: number;
|
|
@@ -183,6 +194,9 @@ export interface EngineFrameRefs {
|
|
|
183
194
|
exaggerateSV: SharedValue<boolean>;
|
|
184
195
|
referenceValue: SharedValue<number | undefined>;
|
|
185
196
|
referenceValues?: SharedValue<number[] | undefined>;
|
|
197
|
+
/** Series threshold folded into the range fit (`threshold.includeInRange`). */
|
|
198
|
+
thresholdRangePoints?: SharedValue<LiveChartPoint[] | undefined>;
|
|
199
|
+
thresholdRangeExtendToNow?: SharedValue<boolean>;
|
|
186
200
|
nonNegativeSV?: SharedValue<boolean>;
|
|
187
201
|
maxValueSV?: SharedValue<number | undefined>;
|
|
188
202
|
nowOverrideSV?: SharedValue<number | undefined>;
|
|
@@ -251,6 +265,8 @@ export function applyLiveChartEngineFrame(
|
|
|
251
265
|
exaggerate: sv.exaggerateSV.value,
|
|
252
266
|
referenceValue: sv.referenceValue.value,
|
|
253
267
|
referenceValues: sv.referenceValues?.value,
|
|
268
|
+
thresholdRangePoints: sv.thresholdRangePoints?.value,
|
|
269
|
+
thresholdRangeExtendToNow: sv.thresholdRangeExtendToNow?.value ?? true,
|
|
254
270
|
nonNegative: sv.nonNegativeSV?.value ?? false,
|
|
255
271
|
maxValue: sv.maxValueSV?.value,
|
|
256
272
|
nowOverride: sv.nowOverrideSV?.value,
|
|
@@ -325,6 +341,23 @@ export function useLiveChartEngine(
|
|
|
325
341
|
if (!live || live.length === 0) return base;
|
|
326
342
|
return base && base.length > 0 ? base.concat(live) : live;
|
|
327
343
|
});
|
|
344
|
+
// Threshold range fold (`threshold.includeInRange`, series forms). Captured
|
|
345
|
+
// directly (not via `config.`) like `liveReferenceValues`, so the live
|
|
346
|
+
// `threshold.series` SharedValue is tracked and the tick sees fresh points.
|
|
347
|
+
const thresholdRangeSourceSV =
|
|
348
|
+
config.thresholdRangePoints !== undefined &&
|
|
349
|
+
!Array.isArray(config.thresholdRangePoints)
|
|
350
|
+
? config.thresholdRangePoints
|
|
351
|
+
: null;
|
|
352
|
+
const thresholdRangePoints = useDerivedValue<LiveChartPoint[] | undefined>(
|
|
353
|
+
() =>
|
|
354
|
+
thresholdRangeSourceSV
|
|
355
|
+
? thresholdRangeSourceSV.value
|
|
356
|
+
: (config.thresholdRangePoints as LiveChartPoint[] | undefined),
|
|
357
|
+
);
|
|
358
|
+
const thresholdRangeExtendToNow = useDerivedValue(
|
|
359
|
+
() => config.thresholdRangeExtendToNow ?? true,
|
|
360
|
+
);
|
|
328
361
|
const nonNegativeSV = useDerivedValue(() => config.nonNegative ?? false);
|
|
329
362
|
const maxValueSV = useDerivedValue(() => config.maxValue);
|
|
330
363
|
const nowOverrideSV = useDerivedValue(() => config.nowOverride);
|
|
@@ -412,6 +445,8 @@ export function useLiveChartEngine(
|
|
|
412
445
|
exaggerateSV,
|
|
413
446
|
referenceValue,
|
|
414
447
|
referenceValues,
|
|
448
|
+
thresholdRangePoints,
|
|
449
|
+
thresholdRangeExtendToNow,
|
|
415
450
|
nonNegativeSV,
|
|
416
451
|
maxValueSV,
|
|
417
452
|
nowOverrideSV,
|
|
@@ -4,6 +4,7 @@ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
|
4
4
|
import type { SingleEngineState } from "../core/useLiveChartEngine";
|
|
5
5
|
import { buildLinePoints, type ChartPadding } from "../draw/line";
|
|
6
6
|
import { drawSpline, makeSplineScratch } from "../math/spline";
|
|
7
|
+
import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
|
|
7
8
|
import { blendPtsY, squigglifyPts } from "../math/squiggly";
|
|
8
9
|
import { usePathBuilder } from "./usePathBuilder";
|
|
9
10
|
|
|
@@ -39,6 +40,11 @@ export function useChartPaths(
|
|
|
39
40
|
squiggleAmplitude = 14,
|
|
40
41
|
/** Loading squiggle wave speed multiplier for the reveal morph. Default 1. */
|
|
41
42
|
squiggleSpeed = 1,
|
|
43
|
+
/** When set, build `thresholdFillPath` as the band between the line and this
|
|
44
|
+
* *time-varying* threshold — the split shader's evenly-spaced pixel-Y
|
|
45
|
+
* `samples[]` (so band geometry matches the shader exactly). Takes precedence
|
|
46
|
+
* over `thresholdY`, the constant (horizontal) case. */
|
|
47
|
+
thresholdSamples?: SharedValue<number[]>,
|
|
42
48
|
) {
|
|
43
49
|
const lineBuilder = usePathBuilder();
|
|
44
50
|
const fillBuilder = usePathBuilder();
|
|
@@ -134,17 +140,55 @@ export function useChartPaths(
|
|
|
134
140
|
return b.detach();
|
|
135
141
|
});
|
|
136
142
|
|
|
137
|
-
// Threshold-anchored fill: the same spline, closed
|
|
138
|
-
// baseline, so the band lies between the line and the threshold (the
|
|
139
|
-
// area). Painted with the
|
|
140
|
-
// shows the above-color and the part below shows the below-color.
|
|
143
|
+
// Threshold-anchored fill: the same spline, closed along the threshold instead
|
|
144
|
+
// of the baseline, so the band lies between the line and the threshold (the
|
|
145
|
+
// profit/loss area). Painted with the split gradient/shader, the part above the
|
|
146
|
+
// split shows the above-color and the part below shows the below-color.
|
|
147
|
+
//
|
|
148
|
+
// `thresholdPts` (a time-varying series) closes the band along that polyline,
|
|
149
|
+
// right-to-left; otherwise `thresholdY` closes it at a single horizontal Y.
|
|
141
150
|
const thresholdFillPath = useDerivedValue(() => {
|
|
142
151
|
const cache = cacheRef.current!;
|
|
143
|
-
if (!thresholdY) return cache.emptyPath;
|
|
144
|
-
const yT = thresholdY.get();
|
|
145
152
|
const pts = flatPts.get();
|
|
146
153
|
const n = pts.length >> 1;
|
|
147
|
-
if (n < 2
|
|
154
|
+
if (n < 2) return cache.emptyPath;
|
|
155
|
+
|
|
156
|
+
const tsamples = thresholdSamples?.get();
|
|
157
|
+
if (tsamples && tsamples.length >= 2) {
|
|
158
|
+
const b = thresholdFillBuilder.value;
|
|
159
|
+
b.moveTo(pts[0], pts[1]);
|
|
160
|
+
drawSpline(b, pts, cache.scratch, linear);
|
|
161
|
+
// Band bottom = the SAMPLED threshold (identical to what the split shader
|
|
162
|
+
// reads), pinned to the LINE's x-range. Because the geometry and the shader
|
|
163
|
+
// use the same evenly-spaced, linearly-interpolated samples, a step riser
|
|
164
|
+
// ramps the same way in both — no green/red sliver bleeds through — and the
|
|
165
|
+
// x-range pin keeps the band closing with clean vertical sides (no wedge).
|
|
166
|
+
const leftX = pts[0];
|
|
167
|
+
const rightX = pts[(n - 1) * 2];
|
|
168
|
+
const count = tsamples.length;
|
|
169
|
+
// The samples live on the time-anchored, gliding grid — interpolate them
|
|
170
|
+
// across that span (same as the shader), not the static plot edges.
|
|
171
|
+
const [x0, x1] = thresholdSampleSpanX(
|
|
172
|
+
engine.timestamp.get(),
|
|
173
|
+
engine.displayWindow.get(),
|
|
174
|
+
padding.left,
|
|
175
|
+
engine.canvasWidth.get() - padding.right,
|
|
176
|
+
count,
|
|
177
|
+
);
|
|
178
|
+
const step = (x1 - x0) / (count - 1);
|
|
179
|
+
b.lineTo(rightX, sampleThresholdYAt(tsamples, x0, x1, rightX));
|
|
180
|
+
for (let i = count - 1; i >= 0; i--) {
|
|
181
|
+
const sx = x0 + step * i;
|
|
182
|
+
if (sx > leftX && sx < rightX) b.lineTo(sx, tsamples[i]);
|
|
183
|
+
}
|
|
184
|
+
b.lineTo(leftX, sampleThresholdYAt(tsamples, x0, x1, leftX));
|
|
185
|
+
b.close();
|
|
186
|
+
return b.detach();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!thresholdY) return cache.emptyPath;
|
|
190
|
+
const yT = thresholdY.get();
|
|
191
|
+
if (!Number.isFinite(yT)) return cache.emptyPath;
|
|
148
192
|
const b = thresholdFillBuilder.value;
|
|
149
193
|
b.moveTo(pts[0], pts[1]);
|
|
150
194
|
drawSpline(b, pts, cache.scratch, linear);
|