react-native-livechart 4.9.2 → 4.11.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/README.md +24 -0
- package/dist/components/CrosshairLine.d.ts +3 -1
- package/dist/components/CrosshairLine.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +3 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/CustomReferenceLineOverlay.d.ts +4 -2
- package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
- package/dist/components/LeftEdgeFade.d.ts +3 -1
- package/dist/components/LeftEdgeFade.d.ts.map +1 -1
- 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/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/MultiSeriesDots.d.ts +3 -1
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/components/MultiSeriesTooltipStack.d.ts.map +1 -1
- package/dist/components/MultiSeriesValueLabels.d.ts +3 -1
- package/dist/components/MultiSeriesValueLabels.d.ts.map +1 -1
- package/dist/components/MultiSeriesValueLines.d.ts +3 -1
- package/dist/components/MultiSeriesValueLines.d.ts.map +1 -1
- package/dist/components/ThresholdSplitShader.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts +9 -1
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- package/dist/components/thresholdSplitShaderSource.d.ts +13 -0
- package/dist/components/thresholdSplitShaderSource.d.ts.map +1 -0
- package/dist/core/useLiveChartEngine.d.ts +9 -1
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +10 -5
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/draw/markerAtlas.d.ts +2 -0
- package/dist/draw/markerAtlas.d.ts.map +1 -1
- package/dist/draw/particleAtlas.d.ts +3 -2
- package/dist/draw/particleAtlas.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +6 -6
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/delayedPanGuard.d.ts +31 -0
- package/dist/hooks/delayedPanGuard.d.ts.map +1 -0
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
- package/dist/hooks/useMultiSeriesLinePaths.d.ts +4 -4
- package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/squiggly.d.ts +6 -3
- package/dist/math/squiggly.d.ts.map +1 -1
- package/dist/math/threshold.d.ts +2 -2
- package/dist/types.d.ts +35 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairLine.tsx +17 -0
- package/src/components/CrosshairOverlay.tsx +54 -36
- package/src/components/CustomReferenceLineOverlay.tsx +16 -7
- package/src/components/DegenParticlesOverlay.tsx +40 -5
- package/src/components/LeftEdgeFade.tsx +12 -6
- package/src/components/LiveChart.tsx +34 -16
- package/src/components/LiveChartSeries.tsx +94 -21
- package/src/components/LoadingOverlay.tsx +26 -12
- package/src/components/MarkerOverlay.tsx +31 -19
- package/src/components/MultiSeriesDots.tsx +4 -2
- package/src/components/MultiSeriesTooltipStack.tsx +4 -2
- package/src/components/MultiSeriesValueLabels.tsx +4 -2
- package/src/components/MultiSeriesValueLines.tsx +4 -2
- package/src/components/ThresholdSplitShader.tsx +5 -37
- package/src/components/YAxisOverlay.tsx +43 -1
- package/src/components/thresholdSplitShaderSource.ts +70 -0
- package/src/core/useLiveChartEngine.ts +110 -42
- package/src/core/useLiveChartSeriesEngine.ts +137 -70
- package/src/draw/markerAtlas.ts +22 -2
- package/src/draw/particleAtlas.ts +21 -10
- package/src/hooks/crosshairShared.ts +24 -18
- package/src/hooks/delayedPanGuard.ts +80 -0
- package/src/hooks/useChartPaths.ts +11 -0
- package/src/hooks/useCrosshair.ts +53 -10
- package/src/hooks/useCrosshairSeries.ts +49 -6
- package/src/hooks/useMultiSeriesLinePaths.ts +15 -9
- package/src/index.ts +1 -0
- package/src/math/squiggly.ts +28 -11
- package/src/math/threshold.ts +2 -2
- package/src/types.ts +40 -5
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
vec,
|
|
9
9
|
type SkFont,
|
|
10
10
|
} from "@shopify/react-native-skia";
|
|
11
|
+
import { useRef } from "react";
|
|
11
12
|
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
12
13
|
import {
|
|
13
14
|
BADGE_METRICS_DEFAULTS,
|
|
@@ -67,6 +68,7 @@ export function LoadingOverlay({
|
|
|
67
68
|
lineStrokeWidth,
|
|
68
69
|
waveAmplitude = 14,
|
|
69
70
|
waveSpeed = 1,
|
|
71
|
+
opaqueCanvas = false,
|
|
70
72
|
}: {
|
|
71
73
|
engine: ChartEngineLayout;
|
|
72
74
|
padding: ChartPadding;
|
|
@@ -86,6 +88,8 @@ export function LoadingOverlay({
|
|
|
86
88
|
waveAmplitude?: number;
|
|
87
89
|
/** Breathing-wave speed multiplier. */
|
|
88
90
|
waveSpeed?: number;
|
|
91
|
+
/** Paint the owned background instead of erasing destination alpha. */
|
|
92
|
+
opaqueCanvas?: boolean;
|
|
89
93
|
/** Mirror the badge prop so labels align with GridOverlay's label positions. */
|
|
90
94
|
badge?: boolean;
|
|
91
95
|
/** Whether the badge tail spike is shown; affects the left inset used for skeleton alignment. */
|
|
@@ -99,15 +103,26 @@ export function LoadingOverlay({
|
|
|
99
103
|
}) {
|
|
100
104
|
// Same left-inset formula as GridOverlay (only used when badge=true)
|
|
101
105
|
const leftInset =
|
|
102
|
-
badgeMetrics.dotGap +
|
|
106
|
+
badgeMetrics.dotGap +
|
|
107
|
+
badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
|
|
103
108
|
|
|
104
109
|
// Loading-shell color (squiggle + skeleton placeholders) and squiggle stroke,
|
|
105
110
|
// both overridable via `loading={{ color, strokeWidth }}`.
|
|
106
111
|
const loadingColor = lineColor ?? palette.gridLine;
|
|
107
112
|
const loadingStroke = lineStrokeWidth ?? strokeWidth;
|
|
113
|
+
const [bgR, bgG, bgB] = palette.bgRgb;
|
|
114
|
+
const gapMaskColors = opaqueCanvas
|
|
115
|
+
? [
|
|
116
|
+
`rgba(${bgR},${bgG},${bgB},0)`,
|
|
117
|
+
`rgb(${bgR},${bgG},${bgB})`,
|
|
118
|
+
`rgb(${bgR},${bgG},${bgB})`,
|
|
119
|
+
`rgba(${bgR},${bgG},${bgB},0)`,
|
|
120
|
+
]
|
|
121
|
+
: ["rgba(0,0,0,0)", "rgba(0,0,0,1)", "rgba(0,0,0,1)", "rgba(0,0,0,0)"];
|
|
108
122
|
|
|
109
123
|
// Squiggly path — built into a reused PathBuilder and detach()-ed each frame.
|
|
110
124
|
const squigglyBuilder = usePathBuilder();
|
|
125
|
+
const squigglyPtsRef = useRef<number[]>([]);
|
|
111
126
|
|
|
112
127
|
// Squiggly path — animated each frame via timestamp
|
|
113
128
|
const squigglyPath = useDerivedValue(() => {
|
|
@@ -122,6 +137,7 @@ export function LoadingOverlay({
|
|
|
122
137
|
engine.timestamp.get(),
|
|
123
138
|
waveAmplitude,
|
|
124
139
|
waveSpeed,
|
|
140
|
+
squigglyPtsRef.current,
|
|
125
141
|
);
|
|
126
142
|
return buildSplineDetached(b, pts);
|
|
127
143
|
});
|
|
@@ -254,17 +270,15 @@ export function LoadingOverlay({
|
|
|
254
270
|
/>
|
|
255
271
|
|
|
256
272
|
{/* Erase a soft horizontal band through the squiggle for the label (dstOut) */}
|
|
257
|
-
<Group
|
|
273
|
+
<Group
|
|
274
|
+
opacity={showGapGroup}
|
|
275
|
+
blendMode={opaqueCanvas ? undefined : "dstOut"}
|
|
276
|
+
>
|
|
258
277
|
<Rect x={gapLeft} y={gapTop} width={gapWidth} height={gapHeight}>
|
|
259
278
|
<LinearGradient
|
|
260
279
|
start={vec(0, 0)}
|
|
261
280
|
end={gapGradEnd}
|
|
262
|
-
colors={
|
|
263
|
-
"rgba(0,0,0,0)",
|
|
264
|
-
"rgba(0,0,0,1)",
|
|
265
|
-
"rgba(0,0,0,1)",
|
|
266
|
-
"rgba(0,0,0,0)",
|
|
267
|
-
]}
|
|
281
|
+
colors={gapMaskColors}
|
|
268
282
|
positions={gapGradientPositions}
|
|
269
283
|
/>
|
|
270
284
|
</Rect>
|
|
@@ -279,7 +293,7 @@ export function LoadingOverlay({
|
|
|
279
293
|
width={RECT_W}
|
|
280
294
|
height={RECT_H}
|
|
281
295
|
r={RECT_R}
|
|
282
|
-
|
|
296
|
+
color={loadingColor}
|
|
283
297
|
/>
|
|
284
298
|
<RoundedRect
|
|
285
299
|
x={lx}
|
|
@@ -287,7 +301,7 @@ export function LoadingOverlay({
|
|
|
287
301
|
width={RECT_W}
|
|
288
302
|
height={RECT_H}
|
|
289
303
|
r={RECT_R}
|
|
290
|
-
|
|
304
|
+
color={loadingColor}
|
|
291
305
|
/>
|
|
292
306
|
<RoundedRect
|
|
293
307
|
x={lx}
|
|
@@ -295,7 +309,7 @@ export function LoadingOverlay({
|
|
|
295
309
|
width={RECT_W}
|
|
296
310
|
height={RECT_H}
|
|
297
311
|
r={RECT_R}
|
|
298
|
-
|
|
312
|
+
color={loadingColor}
|
|
299
313
|
/>
|
|
300
314
|
<RoundedRect
|
|
301
315
|
x={lx}
|
|
@@ -303,7 +317,7 @@ export function LoadingOverlay({
|
|
|
303
317
|
width={RECT_W}
|
|
304
318
|
height={RECT_H}
|
|
305
319
|
r={RECT_R}
|
|
306
|
-
|
|
320
|
+
color={loadingColor}
|
|
307
321
|
/>
|
|
308
322
|
</>
|
|
309
323
|
) : null}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
GROUP_BADGE_SIG,
|
|
26
26
|
groupBgSig,
|
|
27
27
|
groupCountText,
|
|
28
|
+
groupCountTextWidth,
|
|
28
29
|
groupGlyphSig,
|
|
29
30
|
isConnectorMarker,
|
|
30
31
|
markerAppearanceSig,
|
|
@@ -58,7 +59,7 @@ const GROUP_CORNER_INSET = 0.82;
|
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* Append a collapsed-cluster count badge (round bg + centered, proportionally
|
|
61
|
-
*
|
|
62
|
+
* laid-out digits) to the atlas blit lists at `(cx, cy)`, scaled by `mul` — full
|
|
62
63
|
* size as the default group badge, shrunk in a glyph's corner for `showGroupCount`.
|
|
63
64
|
*
|
|
64
65
|
* A **module-level** worklet (not a closure inside the per-frame derived value):
|
|
@@ -77,6 +78,7 @@ function pushGroupCountBadge(
|
|
|
77
78
|
repColor: string,
|
|
78
79
|
count: number,
|
|
79
80
|
mul: number,
|
|
81
|
+
letterSpacing: number,
|
|
80
82
|
): void {
|
|
81
83
|
"worklet";
|
|
82
84
|
const text = groupCountText(count);
|
|
@@ -87,20 +89,11 @@ function pushGroupCountBadge(
|
|
|
87
89
|
);
|
|
88
90
|
sprites.push(bg.rect);
|
|
89
91
|
}
|
|
90
|
-
// Lay digits out proportionally (each by its own ink width),
|
|
91
|
-
//
|
|
92
|
-
//
|
|
92
|
+
// Lay digits out proportionally (each by its own ink width), centered. The
|
|
93
|
+
// configurable spacing is applied between glyph ink bounds, not inferred from
|
|
94
|
+
// the widest digit — proportional fonts otherwise overlap narrow digits.
|
|
93
95
|
const S = BADGE_TEXT_SCALE;
|
|
94
|
-
|
|
95
|
-
for (let d = 0; d < 10; d++) {
|
|
96
|
-
const dw = digitWidths["" + d] ?? 0;
|
|
97
|
-
if (dw > maxDW) maxDW = dw;
|
|
98
|
-
}
|
|
99
|
-
const kern = -maxDW * S * 0.42;
|
|
100
|
-
let totalW = 0;
|
|
101
|
-
for (let c = 0; c < text.length; c++) {
|
|
102
|
-
totalW += (digitWidths[text[c]] ?? 0) * S + (c > 0 ? kern : 0);
|
|
103
|
-
}
|
|
96
|
+
const totalW = groupCountTextWidth(text, digitWidths, letterSpacing);
|
|
104
97
|
let dx = cx - (totalW * mul) / 2;
|
|
105
98
|
for (let c = 0; c < text.length; c++) {
|
|
106
99
|
const w = (digitWidths[text[c]] ?? 0) * S * mul;
|
|
@@ -117,7 +110,7 @@ function pushGroupCountBadge(
|
|
|
117
110
|
);
|
|
118
111
|
sprites.push(gc.rect);
|
|
119
112
|
}
|
|
120
|
-
dx += w +
|
|
113
|
+
dx += w + letterSpacing * mul;
|
|
121
114
|
}
|
|
122
115
|
}
|
|
123
116
|
|
|
@@ -326,7 +319,7 @@ export function MarkerOverlay({
|
|
|
326
319
|
typeof cluster.groupBadge === "object" ? cluster.groupBadge : undefined;
|
|
327
320
|
const groupBadgeImage = groupBadgeCfg?.image;
|
|
328
321
|
const groupBadgeKey = groupBadgeCfg
|
|
329
|
-
? `${groupBadgeCfg.icon ?? ""}|${groupBadgeCfg.color ?? ""}|${groupBadgeCfg.pill ? 1 : 0}|${groupBadgeCfg.size ?? ""}`
|
|
322
|
+
? `${groupBadgeCfg.icon ?? ""}|${groupBadgeCfg.color ?? ""}|${groupBadgeCfg.pill ? 1 : 0}|${groupBadgeCfg.size ?? ""}|${groupBadgeCfg.letterSpacing ?? ""}`
|
|
330
323
|
: "";
|
|
331
324
|
const atlas = useMemo(
|
|
332
325
|
() =>
|
|
@@ -358,6 +351,18 @@ export function MarkerOverlay({
|
|
|
358
351
|
if (projRef.current === null) {
|
|
359
352
|
projRef.current = { a: [], b: [], tick: false };
|
|
360
353
|
}
|
|
354
|
+
const atlasFrameRef = useRef<{
|
|
355
|
+
a: { transforms: SkRSXform[]; sprites: SkRect[] };
|
|
356
|
+
b: { transforms: SkRSXform[]; sprites: SkRect[] };
|
|
357
|
+
tick: boolean;
|
|
358
|
+
} | null>(null);
|
|
359
|
+
if (atlasFrameRef.current === null) {
|
|
360
|
+
atlasFrameRef.current = {
|
|
361
|
+
a: { transforms: [], sprites: [] },
|
|
362
|
+
b: { transforms: [], sprites: [] },
|
|
363
|
+
tick: false,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
361
366
|
|
|
362
367
|
// Single per-frame worklet: project all markers, then emit a transform +
|
|
363
368
|
// source rect for each visible atlas marker. Three mappers total regardless
|
|
@@ -384,8 +389,13 @@ export function MarkerOverlay({
|
|
|
384
389
|
lineLinear,
|
|
385
390
|
});
|
|
386
391
|
clusterMarkers(ms, buf, { config: cluster });
|
|
387
|
-
const
|
|
388
|
-
|
|
392
|
+
const atlasFrames = atlasFrameRef.current!;
|
|
393
|
+
atlasFrames.tick = !atlasFrames.tick;
|
|
394
|
+
const frame = atlasFrames.tick ? atlasFrames.a : atlasFrames.b;
|
|
395
|
+
const transforms = frame.transforms;
|
|
396
|
+
const sprites = frame.sprites;
|
|
397
|
+
transforms.length = 0;
|
|
398
|
+
sprites.length = 0;
|
|
389
399
|
for (let i = 0; i < ms.length; i++) {
|
|
390
400
|
const pt = buf[i];
|
|
391
401
|
// Collapsed-cluster members fold into their representative's badge.
|
|
@@ -431,6 +441,7 @@ export function MarkerOverlay({
|
|
|
431
441
|
repColor,
|
|
432
442
|
pt.groupCount,
|
|
433
443
|
GROUP_CORNER_SCALE,
|
|
444
|
+
groupBadgeCfg?.letterSpacing ?? 0,
|
|
434
445
|
);
|
|
435
446
|
}
|
|
436
447
|
continue;
|
|
@@ -448,6 +459,7 @@ export function MarkerOverlay({
|
|
|
448
459
|
repColor,
|
|
449
460
|
pt.groupCount,
|
|
450
461
|
1,
|
|
462
|
+
groupBadgeCfg?.letterSpacing ?? 0,
|
|
451
463
|
);
|
|
452
464
|
continue;
|
|
453
465
|
}
|
|
@@ -460,7 +472,7 @@ export function MarkerOverlay({
|
|
|
460
472
|
);
|
|
461
473
|
sprites.push(cell.rect);
|
|
462
474
|
}
|
|
463
|
-
return
|
|
475
|
+
return frame;
|
|
464
476
|
},
|
|
465
477
|
[
|
|
466
478
|
cells,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Circle, Group } from "@shopify/react-native-skia";
|
|
2
2
|
|
|
3
3
|
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
4
|
-
import { MAX_MULTI_SERIES } from "../constants";
|
|
5
4
|
import type { ChartPadding } from "../draw/line";
|
|
6
5
|
import type {
|
|
7
6
|
ResolvedDotRingConfig,
|
|
@@ -118,6 +117,7 @@ export function MultiSeriesDots({
|
|
|
118
117
|
color,
|
|
119
118
|
pulse,
|
|
120
119
|
viewEnd,
|
|
120
|
+
seriesCount,
|
|
121
121
|
}: {
|
|
122
122
|
engine: MultiEngineState;
|
|
123
123
|
padding: ChartPadding;
|
|
@@ -132,10 +132,12 @@ export function MultiSeriesDots({
|
|
|
132
132
|
pulse: ResolvedPulseConfig | null;
|
|
133
133
|
/** Pan/zoom right-edge override — pulse is frozen-out while scrolled back. */
|
|
134
134
|
viewEnd?: SharedValue<number | null>;
|
|
135
|
+
/** Number of live series slots to mount. */
|
|
136
|
+
seriesCount: number;
|
|
135
137
|
}) {
|
|
136
138
|
return (
|
|
137
139
|
<Group>
|
|
138
|
-
{Array.from({ length:
|
|
140
|
+
{Array.from({ length: seriesCount }, (_, i) => (
|
|
139
141
|
<SeriesDotAtIndex
|
|
140
142
|
key={i}
|
|
141
143
|
index={i}
|
|
@@ -4,11 +4,13 @@ import {
|
|
|
4
4
|
type SkFont,
|
|
5
5
|
} from "@shopify/react-native-skia";
|
|
6
6
|
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
7
|
-
import { MAX_MULTI_SERIES } from "../constants";
|
|
8
7
|
import type { TooltipLayout } from "../hooks/crosshairShared";
|
|
9
8
|
import type { LiveChartPalette } from "../types";
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
// Candle tooltips have exactly O/H/L/C + time. This component is only mounted
|
|
11
|
+
// for candle mode, so reserving the multi-series capacity registered 40 unused
|
|
12
|
+
// derived-value mappers (8 empty rows × 5 mappers).
|
|
13
|
+
const TOOLTIP_STACK_SLOTS = 5;
|
|
12
14
|
|
|
13
15
|
function TooltipStackLine({
|
|
14
16
|
index,
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
} from "@shopify/react-native-skia";
|
|
6
6
|
import { useDerivedValue } from "react-native-reanimated";
|
|
7
7
|
|
|
8
|
-
import { MAX_MULTI_SERIES } from "../constants";
|
|
9
8
|
import type { ChartPadding } from "../draw/line";
|
|
10
9
|
import type { MultiEngineState } from "../core/useLiveChartEngine";
|
|
11
10
|
|
|
@@ -78,16 +77,19 @@ export function MultiSeriesValueLabels({
|
|
|
78
77
|
colors,
|
|
79
78
|
font,
|
|
80
79
|
dotRadius,
|
|
80
|
+
seriesCount,
|
|
81
81
|
}: {
|
|
82
82
|
engine: MultiEngineState;
|
|
83
83
|
padding: ChartPadding;
|
|
84
84
|
colors: string[];
|
|
85
85
|
font: SkFont;
|
|
86
86
|
dotRadius: number;
|
|
87
|
+
/** Number of live series slots to mount. */
|
|
88
|
+
seriesCount: number;
|
|
87
89
|
}) {
|
|
88
90
|
return (
|
|
89
91
|
<Group>
|
|
90
|
-
{Array.from({ length:
|
|
92
|
+
{Array.from({ length: seriesCount }, (_, i) => (
|
|
91
93
|
<SeriesValueLabelAtIndex
|
|
92
94
|
key={i}
|
|
93
95
|
index={i}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { DashPathEffect, Group, Path } from "@shopify/react-native-skia";
|
|
2
2
|
|
|
3
3
|
import { useDerivedValue } from "react-native-reanimated";
|
|
4
|
-
import { MAX_MULTI_SERIES } from "../constants";
|
|
5
4
|
import type { ChartPadding } from "../draw/line";
|
|
6
5
|
import type { ResolvedValueLineConfig } from "../core/resolveConfig";
|
|
7
6
|
import type { MultiEngineState } from "../core/useLiveChartEngine";
|
|
@@ -72,15 +71,18 @@ export function MultiSeriesValueLines({
|
|
|
72
71
|
padding,
|
|
73
72
|
colors,
|
|
74
73
|
config,
|
|
74
|
+
seriesCount,
|
|
75
75
|
}: {
|
|
76
76
|
engine: MultiEngineState;
|
|
77
77
|
padding: ChartPadding;
|
|
78
78
|
colors: string[];
|
|
79
79
|
config: ResolvedValueLineConfig;
|
|
80
|
+
/** Number of live series slots to mount. */
|
|
81
|
+
seriesCount: number;
|
|
80
82
|
}) {
|
|
81
83
|
return (
|
|
82
84
|
<Group>
|
|
83
|
-
{Array.from({ length:
|
|
85
|
+
{Array.from({ length: seriesCount }, (_, i) => (
|
|
84
86
|
<SeriesValueLineAtIndex
|
|
85
87
|
key={i}
|
|
86
88
|
index={i}
|
|
@@ -2,6 +2,7 @@ import { Shader, Skia, type Uniforms } from "@shopify/react-native-skia";
|
|
|
2
2
|
import type { SharedValue } from "react-native-reanimated";
|
|
3
3
|
|
|
4
4
|
import { THRESHOLD_SAMPLE_COUNT } from "../math/threshold";
|
|
5
|
+
import { buildThresholdSplitShaderSource } from "./thresholdSplitShaderSource";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Per-fragment split shader for a *time-varying* threshold. For each fragment it
|
|
@@ -15,49 +16,16 @@ import { THRESHOLD_SAMPLE_COUNT } from "../math/threshold";
|
|
|
15
16
|
* alpha-reduced colors). Output is premultiplied — Skia's shader convention, as in
|
|
16
17
|
* `AreaDotsOverlay`. One GPU draw, no per-frame clip / `saveLayer`.
|
|
17
18
|
*/
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
}`;
|
|
19
|
+
const THRESHOLD_SPLIT_SKSL = buildThresholdSplitShaderSource(
|
|
20
|
+
THRESHOLD_SAMPLE_COUNT,
|
|
21
|
+
);
|
|
54
22
|
|
|
55
23
|
// Compiled once. `RuntimeEffect.Make` THROWS on a compile error, so guard it —
|
|
56
24
|
// a shader failure must degrade to "no split" (the line keeps its plain color),
|
|
57
25
|
// never crash every chart screen at import. Null → the component no-ops.
|
|
58
26
|
let SPLIT_EFFECT: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
|
|
59
27
|
try {
|
|
60
|
-
SPLIT_EFFECT = Skia.RuntimeEffect.Make(
|
|
28
|
+
SPLIT_EFFECT = Skia.RuntimeEffect.Make(THRESHOLD_SPLIT_SKSL);
|
|
61
29
|
} catch {
|
|
62
30
|
SPLIT_EFFECT = null;
|
|
63
31
|
}
|
|
@@ -24,6 +24,22 @@ import { AnimatedLabel } from "./AnimatedLabel";
|
|
|
24
24
|
/** Right margin (px) for floating labels — keeps them just off the canvas edge. */
|
|
25
25
|
const FLOAT_LABEL_RIGHT_MARGIN = 6;
|
|
26
26
|
|
|
27
|
+
/** Returns true when a Y-axis label's line box intersects the live badge pill. */
|
|
28
|
+
export function yAxisLabelIntersectsBadge(
|
|
29
|
+
labelCenterY: number,
|
|
30
|
+
labelHeight: number,
|
|
31
|
+
badgeCenterY: number,
|
|
32
|
+
badgeHeight: number,
|
|
33
|
+
) {
|
|
34
|
+
"worklet";
|
|
35
|
+
const labelHalfHeight = labelHeight / 2;
|
|
36
|
+
const badgeHalfHeight = badgeHeight / 2;
|
|
37
|
+
return (
|
|
38
|
+
labelCenterY + labelHalfHeight > badgeCenterY - badgeHalfHeight &&
|
|
39
|
+
labelCenterY - labelHalfHeight < badgeCenterY + badgeHalfHeight
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
export function YAxisOverlay({
|
|
28
44
|
entries,
|
|
29
45
|
engine,
|
|
@@ -33,6 +49,9 @@ export function YAxisOverlay({
|
|
|
33
49
|
badge = false,
|
|
34
50
|
badgeTail = true,
|
|
35
51
|
badgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
52
|
+
badgeCenterY,
|
|
53
|
+
badgeFontSize,
|
|
54
|
+
badgeOffsetY = 0,
|
|
36
55
|
seriesLabelInset = 0,
|
|
37
56
|
gridStyle,
|
|
38
57
|
variant = "all",
|
|
@@ -49,6 +68,12 @@ export function YAxisOverlay({
|
|
|
49
68
|
badgeTail?: boolean;
|
|
50
69
|
/** Badge pill geometry tokens (kept in sync with useBadge). */
|
|
51
70
|
badgeMetrics?: BadgeMetrics;
|
|
71
|
+
/** Live badge center before its configured Y offset. Enables label collision suppression. */
|
|
72
|
+
badgeCenterY?: SharedValue<number>;
|
|
73
|
+
/** Live badge font size, used to reconstruct the pill's vertical bounds. */
|
|
74
|
+
badgeFontSize?: number;
|
|
75
|
+
/** Configured live badge Y offset. */
|
|
76
|
+
badgeOffsetY?: number;
|
|
52
77
|
/** When > 0, series labels occupy the left portion of the gutter; Y-axis labels right-align. */
|
|
53
78
|
seriesLabelInset?: number;
|
|
54
79
|
/** Grid-line styling overrides. Omit for the legacy solid 1px line. */
|
|
@@ -92,6 +117,14 @@ export function YAxisOverlay({
|
|
|
92
117
|
const w = engine.canvasWidth.get();
|
|
93
118
|
const fm = font.getMetrics();
|
|
94
119
|
const baselineOffset = (fm.ascent + fm.descent) / 2;
|
|
120
|
+
const labelHeight = fm.descent - fm.ascent;
|
|
121
|
+
const resolvedBadgeCenterY = badgeCenterY
|
|
122
|
+
? badgeCenterY.get() + badgeOffsetY
|
|
123
|
+
: null;
|
|
124
|
+
const badgeHeight =
|
|
125
|
+
badgeFontSize === undefined
|
|
126
|
+
? 0
|
|
127
|
+
: badgeFontSize + badgeMetrics.padY * 2;
|
|
95
128
|
const result: { x: number; y: number; label: string; alpha: number }[] = [];
|
|
96
129
|
for (let i = 0; i < items.length; i++) {
|
|
97
130
|
const e = items[i];
|
|
@@ -107,7 +140,16 @@ export function YAxisOverlay({
|
|
|
107
140
|
x,
|
|
108
141
|
y: e.y - baselineOffset,
|
|
109
142
|
label: e.label,
|
|
110
|
-
alpha:
|
|
143
|
+
alpha:
|
|
144
|
+
resolvedBadgeCenterY !== null &&
|
|
145
|
+
yAxisLabelIntersectsBadge(
|
|
146
|
+
e.y,
|
|
147
|
+
labelHeight,
|
|
148
|
+
resolvedBadgeCenterY,
|
|
149
|
+
badgeHeight,
|
|
150
|
+
)
|
|
151
|
+
? 0
|
|
152
|
+
: e.alpha,
|
|
111
153
|
});
|
|
112
154
|
}
|
|
113
155
|
return result;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a balanced constant-index lookup for the threshold sample array.
|
|
3
|
+
*
|
|
4
|
+
* SkSL does not allow a runtime value to index a uniform array. A linear loop
|
|
5
|
+
* works around that restriction, but makes every covered fragment visit every
|
|
6
|
+
* segment. This tree keeps every array index constant while selecting the same
|
|
7
|
+
* segment in at most `ceil(log2(sampleCount - 1))` comparisons (six for the
|
|
8
|
+
* production 64-sample shader).
|
|
9
|
+
*/
|
|
10
|
+
export function buildThresholdLookupTree(sampleCount: number): string {
|
|
11
|
+
if (!Number.isInteger(sampleCount) || sampleCount < 2) {
|
|
12
|
+
throw new Error("Threshold shader needs at least two samples");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const build = (first: number, end: number, indent: string): string => {
|
|
16
|
+
if (end - first === 1) {
|
|
17
|
+
return `${indent}return mix(samples[${first}], samples[${first + 1}], clamp(u - float(${first}), 0.0, 1.0));`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const middle = Math.floor((first + end) / 2);
|
|
21
|
+
const childIndent = `${indent} `;
|
|
22
|
+
return `${indent}if (u < float(${middle})) {
|
|
23
|
+
${build(first, middle, childIndent)}
|
|
24
|
+
${indent}} else {
|
|
25
|
+
${build(middle, end, childIndent)}
|
|
26
|
+
${indent}}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return build(0, sampleCount - 1, " ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build the complete time-varying threshold split shader. */
|
|
33
|
+
export function buildThresholdSplitShaderSource(sampleCount: number): string {
|
|
34
|
+
const lookup = buildThresholdLookupTree(sampleCount);
|
|
35
|
+
return `
|
|
36
|
+
// Pixel-X span of the sample grid (thresholdSampleSpanX) — overhangs the plot
|
|
37
|
+
// and glides with the window so features move fluidly, frame to frame.
|
|
38
|
+
uniform float sampleLeft;
|
|
39
|
+
uniform float sampleRight;
|
|
40
|
+
// X where the threshold ends (extendToNow: false → the last point's pixel-X,
|
|
41
|
+
// else a huge sentinel). Fragments right of it paint restColor: the plain line
|
|
42
|
+
// color for the stroke, transparent for the band.
|
|
43
|
+
uniform float clipRight;
|
|
44
|
+
uniform vec4 aboveColor; // straight-alpha rgba, 0..1
|
|
45
|
+
uniform vec4 belowColor; // straight-alpha rgba, 0..1
|
|
46
|
+
uniform vec4 restColor; // straight-alpha rgba, 0..1
|
|
47
|
+
uniform float samples[${sampleCount}];
|
|
48
|
+
|
|
49
|
+
float thresholdAt(float u) {
|
|
50
|
+
${lookup}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
half4 main(vec2 xy) {
|
|
54
|
+
if (xy.x > clipRight) {
|
|
55
|
+
return half4(half3(restColor.rgb) * restColor.a, restColor.a);
|
|
56
|
+
}
|
|
57
|
+
float span = sampleRight - sampleLeft;
|
|
58
|
+
float u = span > 0.0 ? (xy.x - sampleLeft) / span : 0.0;
|
|
59
|
+
u = clamp(u, 0.0, 1.0) * float(${sampleCount - 1});
|
|
60
|
+
// Uniform-array indices must stay compile-time constants in SkSL. The
|
|
61
|
+
// generated branch tree selects the same segment as floor(u), including the
|
|
62
|
+
// clamped final endpoint, in logarithmic comparisons instead of walking every
|
|
63
|
+
// segment for every fragment.
|
|
64
|
+
float thrY = thresholdAt(u);
|
|
65
|
+
// 1 above the boundary, 0 below, with ~1px antialiasing across it.
|
|
66
|
+
float above = clamp(thrY - xy.y + 0.5, 0.0, 1.0);
|
|
67
|
+
vec4 c = mix(belowColor, aboveColor, above);
|
|
68
|
+
return half4(half3(c.rgb) * c.a, c.a);
|
|
69
|
+
}`;
|
|
70
|
+
}
|