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.
Files changed (82) hide show
  1. package/README.md +24 -0
  2. package/dist/components/CrosshairLine.d.ts +3 -1
  3. package/dist/components/CrosshairLine.d.ts.map +1 -1
  4. package/dist/components/CrosshairOverlay.d.ts +3 -1
  5. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  6. package/dist/components/CustomReferenceLineOverlay.d.ts +4 -2
  7. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
  8. package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
  9. package/dist/components/LeftEdgeFade.d.ts +3 -1
  10. package/dist/components/LeftEdgeFade.d.ts.map +1 -1
  11. package/dist/components/LiveChart.d.ts.map +1 -1
  12. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  13. package/dist/components/LoadingOverlay.d.ts +3 -1
  14. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  15. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  16. package/dist/components/MultiSeriesDots.d.ts +3 -1
  17. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  18. package/dist/components/MultiSeriesTooltipStack.d.ts.map +1 -1
  19. package/dist/components/MultiSeriesValueLabels.d.ts +3 -1
  20. package/dist/components/MultiSeriesValueLabels.d.ts.map +1 -1
  21. package/dist/components/MultiSeriesValueLines.d.ts +3 -1
  22. package/dist/components/MultiSeriesValueLines.d.ts.map +1 -1
  23. package/dist/components/ThresholdSplitShader.d.ts.map +1 -1
  24. package/dist/components/YAxisOverlay.d.ts +9 -1
  25. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  26. package/dist/components/thresholdSplitShaderSource.d.ts +13 -0
  27. package/dist/components/thresholdSplitShaderSource.d.ts.map +1 -0
  28. package/dist/core/useLiveChartEngine.d.ts +9 -1
  29. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  30. package/dist/core/useLiveChartSeriesEngine.d.ts +10 -5
  31. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  32. package/dist/draw/markerAtlas.d.ts +2 -0
  33. package/dist/draw/markerAtlas.d.ts.map +1 -1
  34. package/dist/draw/particleAtlas.d.ts +3 -2
  35. package/dist/draw/particleAtlas.d.ts.map +1 -1
  36. package/dist/hooks/crosshairShared.d.ts +6 -6
  37. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  38. package/dist/hooks/delayedPanGuard.d.ts +31 -0
  39. package/dist/hooks/delayedPanGuard.d.ts.map +1 -0
  40. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  41. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  42. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  43. package/dist/hooks/useMultiSeriesLinePaths.d.ts +4 -4
  44. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/math/squiggly.d.ts +6 -3
  48. package/dist/math/squiggly.d.ts.map +1 -1
  49. package/dist/math/threshold.d.ts +2 -2
  50. package/dist/types.d.ts +35 -4
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +1 -1
  53. package/src/components/CrosshairLine.tsx +17 -0
  54. package/src/components/CrosshairOverlay.tsx +54 -36
  55. package/src/components/CustomReferenceLineOverlay.tsx +16 -7
  56. package/src/components/DegenParticlesOverlay.tsx +40 -5
  57. package/src/components/LeftEdgeFade.tsx +12 -6
  58. package/src/components/LiveChart.tsx +34 -16
  59. package/src/components/LiveChartSeries.tsx +94 -21
  60. package/src/components/LoadingOverlay.tsx +26 -12
  61. package/src/components/MarkerOverlay.tsx +31 -19
  62. package/src/components/MultiSeriesDots.tsx +4 -2
  63. package/src/components/MultiSeriesTooltipStack.tsx +4 -2
  64. package/src/components/MultiSeriesValueLabels.tsx +4 -2
  65. package/src/components/MultiSeriesValueLines.tsx +4 -2
  66. package/src/components/ThresholdSplitShader.tsx +5 -37
  67. package/src/components/YAxisOverlay.tsx +43 -1
  68. package/src/components/thresholdSplitShaderSource.ts +70 -0
  69. package/src/core/useLiveChartEngine.ts +110 -42
  70. package/src/core/useLiveChartSeriesEngine.ts +137 -70
  71. package/src/draw/markerAtlas.ts +22 -2
  72. package/src/draw/particleAtlas.ts +21 -10
  73. package/src/hooks/crosshairShared.ts +24 -18
  74. package/src/hooks/delayedPanGuard.ts +80 -0
  75. package/src/hooks/useChartPaths.ts +11 -0
  76. package/src/hooks/useCrosshair.ts +53 -10
  77. package/src/hooks/useCrosshairSeries.ts +49 -6
  78. package/src/hooks/useMultiSeriesLinePaths.ts +15 -9
  79. package/src/index.ts +1 -0
  80. package/src/math/squiggly.ts +28 -11
  81. package/src/math/threshold.ts +2 -2
  82. 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 + badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
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 opacity={showGapGroup} blendMode="dstOut">
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
- color={loadingColor}
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
- color={loadingColor}
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
- color={loadingColor}
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
- color={loadingColor}
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
- * kerned digits) to the atlas blit lists at `(cx, cy)`, scaled by `mul` — full
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), scaled to fit the
91
- // small round badge, centered. A negative kern (fraction of a digit) closes the
92
- // font's side-bearing gap so "12" reads tight, not "1 2".
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
- let maxDW = 1;
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 + kern * mul;
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 transforms: SkRSXform[] = [];
388
- const sprites: SkRect[] = [];
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 { transforms, sprites };
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: MAX_MULTI_SERIES }, (_, i) => (
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
- const TOOLTIP_STACK_SLOTS = MAX_MULTI_SERIES + 1;
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: MAX_MULTI_SERIES }, (_, i) => (
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: MAX_MULTI_SERIES }, (_, i) => (
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 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
- }`;
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(SPLIT_SKSL);
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: e.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
+ }