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
@@ -0,0 +1,80 @@
1
+ import type { SharedValue } from "react-native-reanimated";
2
+
3
+ type BooleanSharedValue = Pick<SharedValue<boolean>, "get" | "set">;
4
+
5
+ type TouchCountEvent = {
6
+ numberOfTouches: number;
7
+ };
8
+
9
+ type GestureStateManager = {
10
+ fail: () => void;
11
+ };
12
+
13
+ /** Mark the first pointer of a delayed pan as down. Zero-delay pans are inert. */
14
+ export function delayedPanTouchDown(
15
+ delayMs: number,
16
+ fingerDown: BooleanSharedValue,
17
+ ): void {
18
+ "worklet";
19
+ if (delayMs > 0) fingerDown.set(true);
20
+ }
21
+
22
+ /**
23
+ * Fail a delayed pan that is still pending when its final pointer lifts.
24
+ *
25
+ * RNGH 2.x on iOS can leave `activateAfterLongPress`'s timer armed after a
26
+ * stationary touch ends. Failing the pending recognizer forces the reset that
27
+ * cancels that timer. An already-active pan is left to finish normally.
28
+ */
29
+ export function delayedPanTouchUp(
30
+ delayMs: number,
31
+ event: TouchCountEvent,
32
+ manager: GestureStateManager,
33
+ fingerDown: BooleanSharedValue,
34
+ panActivated: BooleanSharedValue,
35
+ ): void {
36
+ "worklet";
37
+ if (delayMs <= 0 || event.numberOfTouches > 0) return;
38
+ fingerDown.set(false);
39
+ if (!panActivated.get()) manager.fail();
40
+ }
41
+
42
+ /** Clear pointer state when the native touch stream is cancelled. */
43
+ export function delayedPanTouchCancelled(
44
+ delayMs: number,
45
+ fingerDown: BooleanSharedValue,
46
+ ): void {
47
+ "worklet";
48
+ if (delayMs > 0) fingerDown.set(false);
49
+ }
50
+
51
+ /**
52
+ * Record a real delayed-pan activation, or reject an activation whose timer
53
+ * raced the final pointer-up. The rejected path deliberately keeps
54
+ * `panActivated` false so a missing native FINALIZE cannot poison the next
55
+ * interaction.
56
+ */
57
+ export function shouldStartDelayedPan(
58
+ delayMs: number,
59
+ fingerDown: BooleanSharedValue,
60
+ panActivated: BooleanSharedValue,
61
+ ): boolean {
62
+ "worklet";
63
+ if (delayMs <= 0) return true;
64
+ if (!fingerDown.get()) {
65
+ panActivated.set(false);
66
+ return false;
67
+ }
68
+ panActivated.set(true);
69
+ return true;
70
+ }
71
+
72
+ /** Reset the delayed-pan lifecycle after native finalization. */
73
+ export function resetDelayedPanGuard(
74
+ fingerDown: BooleanSharedValue,
75
+ panActivated: BooleanSharedValue,
76
+ ): void {
77
+ "worklet";
78
+ fingerDown.set(false);
79
+ panActivated.set(false);
80
+ }
@@ -55,6 +55,10 @@ export function useChartPaths(
55
55
  ptsA: number[];
56
56
  ptsB: number[];
57
57
  ptsTick: boolean;
58
+ squigglePts: number[];
59
+ morphA: number[];
60
+ morphB: number[];
61
+ morphTick: boolean;
58
62
  scratch: ReturnType<typeof makeSplineScratch>;
59
63
  } | null>(null);
60
64
  if (cacheRef.current === null) {
@@ -63,6 +67,10 @@ export function useChartPaths(
63
67
  ptsA: [] as number[],
64
68
  ptsB: [] as number[],
65
69
  ptsTick: false,
70
+ squigglePts: [] as number[],
71
+ morphA: [] as number[],
72
+ morphB: [] as number[],
73
+ morphTick: false,
66
74
  scratch: makeSplineScratch(),
67
75
  };
68
76
  }
@@ -102,15 +110,18 @@ export function useChartPaths(
102
110
  centerY,
103
111
  squiggleAmplitude,
104
112
  squiggleSpeed,
113
+ cache.squigglePts,
105
114
  );
106
115
 
107
116
  // Blend center-out: centre of chart reveals first, edges last
117
+ cache.morphTick = !cache.morphTick;
108
118
  return blendPtsY(
109
119
  squigglyPts,
110
120
  realPts,
111
121
  t,
112
122
  padding,
113
123
  engine.canvasWidth.get(),
124
+ cache.morphTick ? cache.morphA : cache.morphB,
114
125
  );
115
126
  });
116
127
 
@@ -1,5 +1,4 @@
1
1
  import { type SkFont } from "@shopify/react-native-skia";
2
- import { Platform } from "react-native";
3
2
  import { Gesture } from "react-native-gesture-handler";
4
3
  import {
5
4
  useAnimatedReaction,
@@ -33,9 +32,18 @@ import {
33
32
  HIDDEN_TIME_BADGE,
34
33
  HIDDEN_TOOLTIP,
35
34
  pointInRect,
35
+ SCRUB_ACTIVATE_X_PX,
36
+ SCRUB_FAIL_Y_PX,
36
37
  snapPrice,
37
38
  type CrosshairState,
38
39
  } from "./crosshairShared";
40
+ import {
41
+ delayedPanTouchCancelled,
42
+ delayedPanTouchDown,
43
+ delayedPanTouchUp,
44
+ resetDelayedPanGuard,
45
+ shouldStartDelayedPan,
46
+ } from "./delayedPanGuard";
39
47
 
40
48
  const ACTION_HIT_SLOP = 6;
41
49
  const RETICLE_HIT = 14;
@@ -150,6 +158,10 @@ export function useCrosshair(
150
158
  // Tracks whether the active scrub phase actually began, so a tap that never
151
159
  // activates doesn't emit a spurious onGestureEnd.
152
160
  const gestureStarted = useSharedValue(false);
161
+ // Guard state for RNGH's delayed-pan post-lift activation on iOS. The shared
162
+ // lifecycle helpers are also used by LiveChartSeries and unit-tested directly.
163
+ const fingerDown = useSharedValue(false);
164
+ const panActivated = useSharedValue(false);
153
165
  // Where the crosshair line should start (canvas Y) so it stops at a top-pinned
154
166
  // custom tooltip instead of running through it. -1 = no top tooltip → the line
155
167
  // starts at padding.top. Written by CustomTooltipOverlay, read by CrosshairOverlay.
@@ -490,14 +502,32 @@ export function useCrosshair(
490
502
  : panGestureDelay;
491
503
 
492
504
  let gesture = Gesture.Pan()
493
- // Scrub-action: minDistance 0 — the press-hold above is the sole activator
494
- // (movement during the hold fails the pan), so the crosshair never appears
495
- // from a tap's travel. The Tap (maxDistance SCRUB_ACTION_TAP_SLOP) owns quick
496
- // gestures; only a held press becomes a scrub/adjust drag.
497
- .minDistance(!hasScrubAction && Platform.OS === "android" ? 10 : 0)
498
- .activateAfterLongPress(longPressMs)
499
505
  .maxPointers(1)
500
506
  .shouldCancelWhenOutside(false)
507
+ .onTouchesDown(
508
+ /* istanbul ignore next */ () => {
509
+ "worklet";
510
+ delayedPanTouchDown(longPressMs, fingerDown);
511
+ },
512
+ )
513
+ .onTouchesUp(
514
+ /* istanbul ignore next */ (e, manager) => {
515
+ "worklet";
516
+ delayedPanTouchUp(
517
+ longPressMs,
518
+ e,
519
+ manager,
520
+ fingerDown,
521
+ panActivated,
522
+ );
523
+ },
524
+ )
525
+ .onTouchesCancelled(
526
+ /* istanbul ignore next */ () => {
527
+ "worklet";
528
+ delayedPanTouchCancelled(longPressMs, fingerDown);
529
+ },
530
+ )
501
531
  // Start scrubbing on ACTIVE (onStart), not on touch-down (onBegin):
502
532
  // `activateAfterLongPress` only delays activation, so onBegin still fires
503
533
  // immediately — using it would scrub instantly and ignore panGestureDelay,
@@ -505,6 +535,7 @@ export function useCrosshair(
505
535
  .onStart(
506
536
  /* istanbul ignore next */ (e) => {
507
537
  "worklet";
538
+ if (!shouldStartDelayedPan(longPressMs, fingerDown, panActivated)) return;
508
539
  if (!enabled) return;
509
540
  // Scrub-action: once a reticle is placed, drag adjusts it (2D — the Y is
510
541
  // the price). The ephemeral live-scrub never engages, so scrubActive
@@ -569,6 +600,7 @@ export function useCrosshair(
569
600
  .onFinalize(
570
601
  /* istanbul ignore next */ () => {
571
602
  "worklet";
603
+ resetDelayedPanGuard(fingerDown, panActivated);
572
604
  // A lock-adjust drag leaves the reticle in place (scrubActive was never
573
605
  // set); a live-scrub clears its crosshair. Always clear scrubActive so a
574
606
  // stray scrub can never linger behind a placed reticle.
@@ -583,11 +615,22 @@ export function useCrosshair(
583
615
  },
584
616
  );
585
617
 
586
- /* istanbul ignore next -- Android-only gesture axis config */
587
- if (Platform.OS === "android" && !hasScrubAction) {
618
+ // Passing zero-valued activation modifiers lets RNGH's iOS pan recognizer
619
+ // activate before the axis constraints classify the drag. Only configure a
620
+ // hold when one is requested, and reserve minDistance(0) for scrub-action's
621
+ // deliberate press-hold interaction.
622
+ if (longPressMs > 0) {
623
+ gesture = gesture.activateAfterLongPress(longPressMs);
624
+ }
625
+
626
+ if (hasScrubAction) {
627
+ gesture = gesture.minDistance(0);
628
+ } else {
588
629
  // Lock mode needs free vertical drag (Y = price), so the failOffsetY clamp —
589
630
  // which would kill a vertical adjust — is applied only outside scrub-action.
590
- gesture = gesture.activeOffsetX([-25, 25]).failOffsetY([-25, 25]);
631
+ gesture = gesture
632
+ .activeOffsetX([-SCRUB_ACTIVATE_X_PX, SCRUB_ACTIVATE_X_PX])
633
+ .failOffsetY([-SCRUB_FAIL_Y_PX, SCRUB_FAIL_Y_PX]);
591
634
  }
592
635
 
593
636
  // Axis-drag time-scroll: carve the bottom "time ruler" band out of the scrub's
@@ -1,4 +1,3 @@
1
- import { Platform } from "react-native";
2
1
  import { Gesture } from "react-native-gesture-handler";
3
2
  import {
4
3
  useAnimatedReaction,
@@ -18,8 +17,17 @@ import {
18
17
  computeScrubDotY,
19
18
  computeScrubTime,
20
19
  HIDDEN_TOOLTIP,
20
+ SCRUB_ACTIVATE_X_PX,
21
+ SCRUB_FAIL_Y_PX,
21
22
  type CrosshairState,
22
23
  } from "./crosshairShared";
24
+ import {
25
+ delayedPanTouchCancelled,
26
+ delayedPanTouchDown,
27
+ delayedPanTouchUp,
28
+ resetDelayedPanGuard,
29
+ shouldStartDelayedPan,
30
+ } from "./delayedPanGuard";
23
31
 
24
32
  /**
25
33
  * LiveChartSeries crosshair + scrub. No tooltip — data is delivered via
@@ -40,6 +48,10 @@ export function useCrosshairSeries(
40
48
  // Tracks whether the active scrub phase actually began, so a tap that never
41
49
  // activates doesn't emit a spurious onGestureEnd.
42
50
  const gestureStarted = useSharedValue(false);
51
+ // Mirrors LiveChart's guard against RNGH's delayed-pan timer activating after
52
+ // the final pointer has already lifted on iOS.
53
+ const fingerDown = useSharedValue(false);
54
+ const panActivated = useSharedValue(false);
43
55
 
44
56
  const scrubTime = useDerivedValue(() =>
45
57
  computeScrubTime(
@@ -154,10 +166,32 @@ export function useCrosshairSeries(
154
166
  );
155
167
 
156
168
  let gesture = Gesture.Pan()
157
- .minDistance(Platform.OS === "android" ? 10 : 0)
158
- .activateAfterLongPress(panGestureDelay)
159
169
  .maxPointers(1)
160
170
  .shouldCancelWhenOutside(false)
171
+ .onTouchesDown(
172
+ /* istanbul ignore next */ () => {
173
+ "worklet";
174
+ delayedPanTouchDown(panGestureDelay, fingerDown);
175
+ },
176
+ )
177
+ .onTouchesUp(
178
+ /* istanbul ignore next */ (e, manager) => {
179
+ "worklet";
180
+ delayedPanTouchUp(
181
+ panGestureDelay,
182
+ e,
183
+ manager,
184
+ fingerDown,
185
+ panActivated,
186
+ );
187
+ },
188
+ )
189
+ .onTouchesCancelled(
190
+ /* istanbul ignore next */ () => {
191
+ "worklet";
192
+ delayedPanTouchCancelled(panGestureDelay, fingerDown);
193
+ },
194
+ )
161
195
  // Start scrubbing on ACTIVE (onStart), not on touch-down (onBegin):
162
196
  // `activateAfterLongPress` only delays activation, so onBegin still fires
163
197
  // immediately — using it would scrub instantly and ignore panGestureDelay,
@@ -165,6 +199,8 @@ export function useCrosshairSeries(
165
199
  .onStart(
166
200
  /* istanbul ignore next */ (e) => {
167
201
  "worklet";
202
+ if (!shouldStartDelayedPan(panGestureDelay, fingerDown, panActivated))
203
+ return;
168
204
  if (!enabled) return;
169
205
  scrubX.set(e.x);
170
206
  scrubActive.set(true);
@@ -182,6 +218,7 @@ export function useCrosshairSeries(
182
218
  .onFinalize(
183
219
  /* istanbul ignore next */ () => {
184
220
  "worklet";
221
+ resetDelayedPanGuard(fingerDown, panActivated);
185
222
  scrubActive.set(false);
186
223
  if (gestureStarted.get()) {
187
224
  gestureStarted.set(false);
@@ -190,11 +227,17 @@ export function useCrosshairSeries(
190
227
  },
191
228
  );
192
229
 
193
- /* istanbul ignore next */
194
- if (Platform.OS === "android") {
195
- gesture = gesture.activeOffsetX([-25, 25]).failOffsetY([-25, 25]);
230
+ // Omit zero-valued activation modifiers so RNGH can classify direction before
231
+ // the pan activates on iOS. A vertical drag then remains available to a parent
232
+ // ScrollView/list, while a clear horizontal drag starts scrubbing.
233
+ if (panGestureDelay > 0) {
234
+ gesture = gesture.activateAfterLongPress(panGestureDelay);
196
235
  }
197
236
 
237
+ gesture = gesture
238
+ .activeOffsetX([-SCRUB_ACTIVATE_X_PX, SCRUB_ACTIVATE_X_PX])
239
+ .failOffsetY([-SCRUB_FAIL_Y_PX, SCRUB_FAIL_Y_PX]);
240
+
198
241
  return {
199
242
  scrubX,
200
243
  scrubActive,
@@ -8,30 +8,37 @@ import { drawSpline, makeSplineScratch } from "../math/spline";
8
8
  import { usePathBuilders } from "./usePathBuilder";
9
9
 
10
10
  /**
11
- * One derived shared value holding up to `MAX_MULTI_SERIES` Skia paths (unused
12
- * slots reuse one cached empty path).
11
+ * One derived shared value holding only the active series' Skia paths, capped at
12
+ * `MAX_MULTI_SERIES`.
13
13
  *
14
14
  * Each visible series' path is built into a per-slot `Skia.PathBuilder` (reused
15
15
  * across frames via a SharedValue) and finalized with `detach()` — a fresh
16
16
  * immutable `SkPath` per frame, so `MultiSeriesStroke`'s per-slot
17
17
  * `useDerivedValue(() => paths.value[index])` repaints without the two-SkPath
18
- * ping-pong. Only visible series allocate a path.
18
+ * ping-pong. Only active series allocate and publish a path.
19
19
  */
20
20
  export function useMultiSeriesLinePaths(
21
21
  engine: MultiEngineState,
22
22
  padding: ChartPadding,
23
+ activeSeriesCount = MAX_MULTI_SERIES,
23
24
  ): SharedValue<SkPath[]> {
24
25
  const builders = usePathBuilders(MAX_MULTI_SERIES);
25
26
 
26
27
  const poolRef = useRef<{
27
28
  empty: SkPath;
28
29
  ptsBuf: number[];
30
+ pathsA: SkPath[];
31
+ pathsB: SkPath[];
32
+ pathsTick: boolean;
29
33
  scratch: ReturnType<typeof makeSplineScratch>;
30
34
  } | null>(null);
31
35
  if (poolRef.current === null) {
32
36
  poolRef.current = {
33
37
  empty: Skia.Path.Make(),
34
38
  ptsBuf: [] as number[],
39
+ pathsA: [] as SkPath[],
40
+ pathsB: [] as SkPath[],
41
+ pathsTick: false,
35
42
  scratch: makeSplineScratch(),
36
43
  };
37
44
  }
@@ -41,12 +48,11 @@ export function useMultiSeriesLinePaths(
41
48
  const slots = builders.value;
42
49
  const s = engine.series.get();
43
50
  const displays = engine.displaySeriesValues.get();
44
- const out: SkPath[] = [];
45
- for (let i = 0; i < MAX_MULTI_SERIES; i++) {
46
- if (i >= s.length) {
47
- out.push(pool.empty);
48
- continue;
49
- }
51
+ pool.pathsTick = !pool.pathsTick;
52
+ const out = pool.pathsTick ? pool.pathsA : pool.pathsB;
53
+ out.length = 0;
54
+ const count = Math.min(activeSeriesCount, s.length, MAX_MULTI_SERIES);
55
+ for (let i = 0; i < count; i++) {
50
56
  const pts = buildLinePoints(
51
57
  s[i].data,
52
58
  displays[i] ?? s[i].value,
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export type {
40
40
  BadgeVariant,
41
41
  CandleMetrics,
42
42
  CandlePoint,
43
+ CanvasMode,
43
44
  ChartInsets,
44
45
  ChartOverlayContext,
45
46
  ChartPlotRect,
@@ -31,6 +31,7 @@ export function squigglyYAt(
31
31
  * Build a flat [x0,y0,x1,y1,...] point array for a standalone squiggly line
32
32
  * spanning the full chart area (used in loading / empty state).
33
33
  * Points are spaced ~4px apart for smooth curves.
34
+ * Pass `out` to reuse a caller-owned frame scratch.
34
35
  */
35
36
  export function buildSquigglyPts(
36
37
  canvasWidth: number,
@@ -39,17 +40,22 @@ export function buildSquigglyPts(
39
40
  t: number,
40
41
  base = 14,
41
42
  speed = 1,
43
+ out?: number[],
42
44
  ): number[] {
43
45
  "worklet";
46
+ const pts = out ?? [];
44
47
  const leftEdge = padding.left;
45
48
  const rightEdge = canvasWidth - padding.right;
46
49
  const chartW = rightEdge - leftEdge;
47
- if (chartW <= 0 || canvasHeight <= 0) return [];
50
+ if (chartW <= 0 || canvasHeight <= 0) {
51
+ pts.length = 0;
52
+ return pts;
53
+ }
48
54
 
49
55
  const centerY = (canvasHeight - padding.bottom + padding.top) / 2;
50
56
  const step = 4;
51
57
  const count = Math.ceil(chartW / step) + 1;
52
- const pts: number[] = new Array(count * 2);
58
+ pts.length = count * 2;
53
59
  for (let i = 0; i < count; i++) {
54
60
  const x = leftEdge + Math.min(i * step, chartW);
55
61
  pts[i * 2] = x;
@@ -61,6 +67,7 @@ export function buildSquigglyPts(
61
67
  /**
62
68
  * Given a real flat-pts array (from buildLinePoints), replace each Y with the
63
69
  * squiggly Y at the same X. Used during the morph reveal.
70
+ * Pass `out` to reuse a caller-owned frame scratch.
64
71
  */
65
72
  export function squigglifyPts(
66
73
  flatPts: number[],
@@ -68,15 +75,17 @@ export function squigglifyPts(
68
75
  centerY: number,
69
76
  base = 14,
70
77
  speed = 1,
78
+ out?: number[],
71
79
  ): number[] {
72
80
  "worklet";
73
81
  const n = flatPts.length;
74
- const out: number[] = new Array(n);
82
+ const target = out ?? [];
83
+ target.length = n;
75
84
  for (let i = 0; i < n; i += 2) {
76
- out[i] = flatPts[i];
77
- out[i + 1] = squigglyYAt(flatPts[i], centerY, t, base, speed);
85
+ target[i] = flatPts[i];
86
+ target[i + 1] = squigglyYAt(flatPts[i], centerY, t, base, speed);
78
87
  }
79
- return out;
88
+ return target;
80
89
  }
81
90
 
82
91
  /**
@@ -97,6 +106,7 @@ export function smoothstep(t: number): number {
97
106
  * distFromCentre = |x - chartCentreX| / (chartW / 2) // 0=centre, 1=edge
98
107
  * weight = smoothstep((morphT - distFromCentre * 0.5) / 0.5)
99
108
  * blendedY = fromY + (toY - fromY) * weight
109
+ * Pass `out` to reuse a caller-owned frame scratch.
100
110
  */
101
111
  export function blendPtsY(
102
112
  from: number[],
@@ -104,19 +114,26 @@ export function blendPtsY(
104
114
  morphT: number,
105
115
  padding: ChartPadding,
106
116
  canvasWidth: number,
117
+ out?: number[],
107
118
  ): number[] {
108
119
  "worklet";
109
120
  const n = from.length;
110
- if (n === 0) return to;
121
+ if (n === 0 && !out) return to;
111
122
  const chartCentreX = (padding.left + canvasWidth - padding.right) / 2;
112
123
  const halfChartW = (canvasWidth - padding.left - padding.right) / 2;
113
- const out: number[] = new Array(n);
124
+ const target = out ?? [];
125
+ const outputLength = n === 0 ? to.length : n;
126
+ target.length = outputLength;
127
+ if (n === 0) {
128
+ for (let i = 0; i < to.length; i++) target[i] = to[i];
129
+ return target;
130
+ }
114
131
  for (let i = 0; i < n; i += 2) {
115
- out[i] = to[i]; // X always from real pts
132
+ target[i] = to[i]; // X always from real pts
116
133
  const distFromCentre =
117
134
  halfChartW > 0 ? Math.abs(from[i] - chartCentreX) / halfChartW : 0;
118
135
  const weight = smoothstep((morphT - distFromCentre * 0.5) / 0.5);
119
- out[i + 1] = from[i + 1] + (to[i + 1] - from[i + 1]) * weight;
136
+ target[i + 1] = from[i + 1] + (to[i + 1] - from[i + 1]) * weight;
120
137
  }
121
- return out;
138
+ return target;
122
139
  }
@@ -12,8 +12,8 @@ import type { LiveChartPoint } from "../types";
12
12
  * projected to this many evenly-spaced pixel-Y values across the plot, which the
13
13
  * shader linearly interpolates between. ~one sample per 6px on a phone plot —
14
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.
15
+ * selects between them through a balanced constant-index branch tree, so 64
16
+ * samples require at most six comparisons per covered fragment.
17
17
  */
18
18
  export const THRESHOLD_SAMPLE_COUNT = 64;
19
19
 
package/src/types.ts CHANGED
@@ -44,6 +44,10 @@ export type FontWeight =
44
44
  /** Color scheme for the chart background, grid, and derived palette colors. */
45
45
  export type ThemeMode = "light" | "dark";
46
46
 
47
+ /** Skia canvas composition mode. Android uses TextureView for `"transparent"`
48
+ * and an opaque SurfaceView for `"opaque"`. */
49
+ export type CanvasMode = "transparent" | "opaque";
50
+
47
51
  /**
48
52
  * Badge pill style.
49
53
  * - `"default"` — accent-colored background with white text.
@@ -249,8 +253,9 @@ export interface ReferenceLineBadgeConfig extends BadgeStyleConfig {
249
253
  }
250
254
 
251
255
  /**
252
- * Context passed to a custom {@link LiveChartProps.renderReferenceLine}. The chart
253
- * floats the element you return over the canvas and pins it to the line's value on
256
+ * Context passed to a custom {@link LiveChartProps.renderReferenceLine} or
257
+ * {@link LiveChartSeriesProps.renderReferenceLine}. The chart floats the element
258
+ * you return over the canvas and pins it to the line's value on
254
259
  * the UI thread (vertically centered on the line, horizontally at the badge /
255
260
  * label position) — so it tracks the rescaling axis and any drag smoothly without
256
261
  * JS re-renders, just like {@link TooltipRenderProps}. Replaces the built-in pill
@@ -1122,7 +1127,8 @@ export interface TradeEvent {
1122
1127
  }
1123
1128
 
1124
1129
  /** Built-in marker glyph kinds drawn into the chart canvas. */
1125
- export type MarkerKind = "trade" | "boost" | "graduation" | "winner" | "clawback";
1130
+ export type MarkerKind =
1131
+ "trade" | "boost" | "graduation" | "winner" | "clawback";
1126
1132
 
1127
1133
  /**
1128
1134
  * A marker rendered into the chart at `(time, y)`. Exactly one of `seriesId`
@@ -1236,10 +1242,15 @@ export interface MarkerClusterConfig {
1236
1242
  * A dedicated badge drawn for a collapsed marker cluster — your own Skia design,
1237
1243
  * independent of the member markers. Pass it as
1238
1244
  * {@link MarkerClusterConfig.groupBadge}. Glyph precedence mirrors a single marker:
1239
- * `image` → `icon` (`pill`) → a filled dot. Requires `image` or `icon` (otherwise
1240
- * the group falls back to the count badge).
1245
+ * `image` → `icon` (`pill`) → the built-in count badge. An object containing only
1246
+ * {@link letterSpacing} customizes that built-in count badge.
1241
1247
  */
1242
1248
  export interface MarkerGroupBadge {
1249
+ /**
1250
+ * Extra spacing in px between digits in the built-in count badge. Also applies
1251
+ * to the optional corner count on a custom group badge. Default `0`.
1252
+ */
1253
+ letterSpacing?: number;
1243
1254
  /** Custom Skia image for the collapsed group (e.g. from `useImage`). Takes
1244
1255
  * precedence over {@link icon}. */
1245
1256
  image?: SkImage;
@@ -1743,6 +1754,18 @@ export interface LiveChartCoreProps {
1743
1754
  insets?: ChartInsets;
1744
1755
  /** Container View style. */
1745
1756
  style?: ViewStyle;
1757
+ /**
1758
+ * Canvas composition mode. `"transparent"` keeps Skia's default compositing
1759
+ * path (TextureView on Android) and can reveal content behind the chart.
1760
+ * `"opaque"` makes the canvas own and paint its palette background; on Android
1761
+ * this selects SurfaceView and may reduce RenderThread work, but it cannot be
1762
+ * used when the chart must reveal content behind it. Default `"transparent"`.
1763
+ *
1764
+ * @experimental Profile on representative physical Android hardware before
1765
+ * enabling broadly; SurfaceView ordering, clipping, transforms, screenshots,
1766
+ * and parent scrolling should be validated in the consuming layout.
1767
+ */
1768
+ canvasMode?: CanvasMode;
1746
1769
  /** Visible time window in seconds. Default `30`. */
1747
1770
  timeWindow?: number;
1748
1771
  /** Freeze chart scrolling. Resume catches up to real time. Default `false`. */
@@ -2172,6 +2195,18 @@ export interface LiveChartProps extends LiveChartCoreProps {
2172
2195
  export interface LiveChartSeriesProps extends LiveChartCoreProps {
2173
2196
  /** Array of series definitions. Must be a SharedValue for UI-thread reads. */
2174
2197
  series: SharedValue<SeriesConfig[]>;
2198
+ /**
2199
+ * Render a Form-A reference line's tag as a custom **React Native** element
2200
+ * instead of the built-in Skia pill / gutter label. The chart pins the returned
2201
+ * element to the line's live Y position on the UI thread (see
2202
+ * {@link ReferenceLineRenderProps}), including off-axis edge pinning. Return
2203
+ * `null`/`undefined` to keep that line's built-in tag. The line stroke always
2204
+ * remains visible, and `badge.position` / `labelPosition` controls the custom
2205
+ * tag's left, center, or right anchor.
2206
+ */
2207
+ renderReferenceLine?: (
2208
+ ctx: ReferenceLineRenderProps,
2209
+ ) => ReactElement | null | undefined;
2175
2210
  /** Called when a series toggle chip is tapped. */
2176
2211
  onSeriesToggle?: (id: string, visible: boolean) => void;
2177
2212
  /**