react-native-livechart 4.10.0 → 4.12.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 (114) hide show
  1. package/README.md +24 -0
  2. package/dist/components/CrosshairLine.d.ts +5 -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 +19 -12
  7. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
  8. package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
  9. package/dist/components/DotOverlay.d.ts +3 -5
  10. package/dist/components/DotOverlay.d.ts.map +1 -1
  11. package/dist/components/LeftEdgeFade.d.ts +3 -1
  12. package/dist/components/LeftEdgeFade.d.ts.map +1 -1
  13. package/dist/components/LiveChart.d.ts.map +1 -1
  14. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  15. package/dist/components/LoadingOverlay.d.ts +3 -1
  16. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  17. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  18. package/dist/components/MultiSeriesDots.d.ts +3 -1
  19. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  20. package/dist/components/MultiSeriesTooltipStack.d.ts.map +1 -1
  21. package/dist/components/MultiSeriesValueLabels.d.ts +3 -1
  22. package/dist/components/MultiSeriesValueLabels.d.ts.map +1 -1
  23. package/dist/components/MultiSeriesValueLines.d.ts +3 -1
  24. package/dist/components/MultiSeriesValueLines.d.ts.map +1 -1
  25. package/dist/components/PerSeriesTooltipOverlay.d.ts +19 -0
  26. package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
  27. package/dist/components/ReferenceLineOverlay.d.ts +20 -6
  28. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  29. package/dist/components/ThresholdSplitShader.d.ts.map +1 -1
  30. package/dist/components/YAxisOverlay.d.ts +3 -1
  31. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  32. package/dist/components/thresholdSplitShaderSource.d.ts +13 -0
  33. package/dist/components/thresholdSplitShaderSource.d.ts.map +1 -0
  34. package/dist/core/liveIndicatorVisibility.d.ts +9 -0
  35. package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
  36. package/dist/core/resolveConfig.d.ts +29 -0
  37. package/dist/core/resolveConfig.d.ts.map +1 -1
  38. package/dist/core/useLiveChartEngine.d.ts +9 -1
  39. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  40. package/dist/core/useLiveChartSeriesEngine.d.ts +10 -5
  41. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  42. package/dist/draw/particleAtlas.d.ts +3 -2
  43. package/dist/draw/particleAtlas.d.ts.map +1 -1
  44. package/dist/hooks/crosshairSeries.d.ts +10 -2
  45. package/dist/hooks/crosshairSeries.d.ts.map +1 -1
  46. package/dist/hooks/crosshairShared.d.ts +37 -2
  47. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  48. package/dist/hooks/delayedPanGuard.d.ts +66 -8
  49. package/dist/hooks/delayedPanGuard.d.ts.map +1 -1
  50. package/dist/hooks/useChartPaths.d.ts +5 -9
  51. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  52. package/dist/hooks/useCrosshair.d.ts +7 -1
  53. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  54. package/dist/hooks/useCrosshairSeries.d.ts +22 -3
  55. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  56. package/dist/hooks/useMomentum.d.ts +1 -1
  57. package/dist/hooks/useMomentum.d.ts.map +1 -1
  58. package/dist/hooks/useMultiSeriesLinePaths.d.ts +4 -4
  59. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  60. package/dist/hooks/usePanScroll.d.ts +15 -1
  61. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/math/lerp.d.ts.map +1 -1
  65. package/dist/math/momentum.d.ts +1 -1
  66. package/dist/math/momentum.d.ts.map +1 -1
  67. package/dist/math/referenceLines.d.ts +7 -0
  68. package/dist/math/referenceLines.d.ts.map +1 -1
  69. package/dist/math/squiggly.d.ts +6 -3
  70. package/dist/math/squiggly.d.ts.map +1 -1
  71. package/dist/math/threshold.d.ts +2 -2
  72. package/dist/types.d.ts +184 -16
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +1 -1
  75. package/src/components/CrosshairLine.tsx +21 -1
  76. package/src/components/CrosshairOverlay.tsx +48 -35
  77. package/src/components/CustomReferenceLineOverlay.tsx +58 -20
  78. package/src/components/DegenParticlesOverlay.tsx +40 -5
  79. package/src/components/DotOverlay.tsx +24 -8
  80. package/src/components/LeftEdgeFade.tsx +12 -6
  81. package/src/components/LiveChart.tsx +186 -53
  82. package/src/components/LiveChartSeries.tsx +214 -29
  83. package/src/components/LoadingOverlay.tsx +26 -12
  84. package/src/components/MarkerOverlay.tsx +20 -3
  85. package/src/components/MultiSeriesDots.tsx +4 -2
  86. package/src/components/MultiSeriesTooltipStack.tsx +4 -2
  87. package/src/components/MultiSeriesValueLabels.tsx +4 -2
  88. package/src/components/MultiSeriesValueLines.tsx +4 -2
  89. package/src/components/PerSeriesTooltipOverlay.tsx +287 -0
  90. package/src/components/ReferenceLineOverlay.tsx +274 -141
  91. package/src/components/ThresholdSplitShader.tsx +5 -37
  92. package/src/components/YAxisOverlay.tsx +5 -1
  93. package/src/components/thresholdSplitShaderSource.ts +70 -0
  94. package/src/core/liveIndicatorVisibility.ts +25 -0
  95. package/src/core/resolveConfig.ts +86 -0
  96. package/src/core/useLiveChartEngine.ts +110 -42
  97. package/src/core/useLiveChartSeriesEngine.ts +137 -70
  98. package/src/draw/particleAtlas.ts +21 -10
  99. package/src/hooks/crosshairSeries.ts +262 -33
  100. package/src/hooks/crosshairShared.ts +41 -2
  101. package/src/hooks/delayedPanGuard.ts +111 -6
  102. package/src/hooks/useChartPaths.ts +36 -15
  103. package/src/hooks/useCrosshair.ts +73 -14
  104. package/src/hooks/useCrosshairSeries.ts +106 -13
  105. package/src/hooks/useMomentum.ts +8 -1
  106. package/src/hooks/useMultiSeriesLinePaths.ts +15 -9
  107. package/src/hooks/usePanScroll.ts +46 -5
  108. package/src/index.ts +2 -0
  109. package/src/math/lerp.ts +7 -0
  110. package/src/math/momentum.ts +22 -5
  111. package/src/math/referenceLines.ts +38 -0
  112. package/src/math/squiggly.ts +28 -11
  113. package/src/math/threshold.ts +2 -2
  114. package/src/types.ts +197 -17
@@ -43,6 +43,7 @@ export function CrosshairOverlay({
43
43
  tooltipBorderRadius = 5,
44
44
  tooltipShowValue = true,
45
45
  tooltipShowTime = true,
46
+ opaqueCanvas = false,
46
47
  }: {
47
48
  scrubX: SharedValue<number>;
48
49
  crosshairOpacity: SharedValue<number>;
@@ -94,6 +95,8 @@ export function CrosshairOverlay({
94
95
  tooltipShowValue?: boolean;
95
96
  /** Draw the time row of the default tooltip body. Default true. */
96
97
  tooltipShowTime?: boolean;
98
+ /** Paint the owned background instead of erasing destination alpha. */
99
+ opaqueCanvas?: boolean;
97
100
  }) {
98
101
  // Explicit dependency arrays: with React Compiler enabled, Reanimated's
99
102
  // auto-detected worklet dependencies can change array size between renders
@@ -101,18 +104,15 @@ export function CrosshairOverlay({
101
104
  // React's "final argument changed size between renders" error. Listing the
102
105
  // captured plain values keeps the dependency array a constant size. SharedValue
103
106
  // reads stay reactive regardless of this list.
104
- const p1 = useDerivedValue(
105
- () => {
106
- // A top-pinned custom tooltip pushes the line's start down to its measured
107
- // bottom (lineTop) so the line stops at the label; -1 → no top tooltip.
108
- const lt = lineTop?.value ?? -1;
109
- return {
110
- x: scrubX.value,
111
- y: lt >= 0 ? lt : padding.top,
112
- };
113
- },
114
- [scrubX, padding.top, lineTop],
115
- );
107
+ const p1 = useDerivedValue(() => {
108
+ // A top-pinned custom tooltip pushes the line's start down to its measured
109
+ // bottom (lineTop) so the line stops at the label; -1 no top tooltip.
110
+ const lt = lineTop?.value ?? -1;
111
+ return {
112
+ x: scrubX.value,
113
+ y: lt >= 0 ? lt : padding.top,
114
+ };
115
+ }, [scrubX, padding.top, lineTop]);
116
116
  const p2 = useDerivedValue(
117
117
  () => ({
118
118
  x: scrubX.value,
@@ -153,6 +153,11 @@ export function CrosshairOverlay({
153
153
  () => `rgba(0,0,0,${(1 - dimOpacity) * crosshairOpacity.value})`,
154
154
  [dimOpacity, crosshairOpacity],
155
155
  );
156
+ const opaqueDimOpacity = useDerivedValue(
157
+ () => (1 - dimOpacity) * crosshairOpacity.value,
158
+ [dimOpacity, crosshairOpacity],
159
+ );
160
+ const backgroundColor = `rgb(${palette.bgRgb[0]},${palette.bgRgb[1]},${palette.bgRgb[2]})`;
156
161
 
157
162
  return (
158
163
  <>
@@ -167,6 +172,15 @@ export function CrosshairOverlay({
167
172
  color={crosshairDimColor}
168
173
  />
169
174
  </Group>
175
+ ) : dimOpacity < 1 && opaqueCanvas ? (
176
+ <Rect
177
+ x={scrubX}
178
+ y={padding.top}
179
+ width={dimWidth}
180
+ height={dimHeight}
181
+ color={backgroundColor}
182
+ opacity={opaqueDimOpacity}
183
+ />
170
184
  ) : dimOpacity < 1 ? (
171
185
  // Erase the trailing content's alpha so it fades to the real background
172
186
  // (works on any background color, unlike a colored mask).
@@ -203,29 +217,28 @@ export function CrosshairOverlay({
203
217
  />
204
218
 
205
219
  {showTooltip && (
206
- <>
207
- <RoundedRect
208
- x={tipX}
209
- y={tipY}
210
- width={tipW}
211
- height={tipH}
212
- r={tooltipBorderRadius}
213
- color={tooltipBackground ?? palette.tooltipBg}
214
- />
220
+ <>
221
+ <RoundedRect
222
+ x={tipX}
223
+ y={tipY}
224
+ width={tipW}
225
+ height={tipH}
226
+ r={tooltipBorderRadius}
227
+ color={tooltipBackground ?? palette.tooltipBg}
228
+ />
215
229
 
216
- <RoundedRect
217
- x={tipX}
218
- y={tipY}
219
- width={tipW}
220
- height={tipH}
221
- r={tooltipBorderRadius}
222
- color={tooltipBorderColor ?? palette.tooltipBorder}
223
- style="stroke"
224
- strokeWidth={1}
225
- />
230
+ <RoundedRect
231
+ x={tipX}
232
+ y={tipY}
233
+ width={tipW}
234
+ height={tipH}
235
+ r={tooltipBorderRadius}
236
+ color={tooltipBorderColor ?? palette.tooltipBorder}
237
+ style="stroke"
238
+ strokeWidth={1}
239
+ />
226
240
 
227
- {children ??
228
- renderTooltip?.() ?? (
241
+ {children ?? renderTooltip?.() ?? (
229
242
  <Group>
230
243
  {tooltipShowValue && (
231
244
  <SkiaText
@@ -247,8 +260,8 @@ export function CrosshairOverlay({
247
260
  )}
248
261
  </Group>
249
262
  )}
250
- </>
251
- )}
263
+ </>
264
+ )}
252
265
  </Group>
253
266
  </>
254
267
  );
@@ -12,6 +12,7 @@ import { computeScrubDotY } from "../hooks/crosshairShared";
12
12
  import {
13
13
  classifyReferenceEdge,
14
14
  referenceLineForm,
15
+ referenceLineReactKeys,
15
16
  resolveReferenceBadge,
16
17
  } from "../math/referenceLines";
17
18
  import type { ReferenceLine, ReferenceLineRenderProps } from "../types";
@@ -39,20 +40,24 @@ function stub<T>(v: T): SharedValue<T> {
39
40
  return { value: v, get: () => v } as unknown as SharedValue<T>;
40
41
  }
41
42
 
43
+ /** Whether a custom tag owns every state or only an off-axis tag. */
44
+ export type CustomReferenceLineMode = "always" | "off-axis";
45
+
42
46
  /**
43
- * Which Form-A reference lines a `renderReferenceLine` returns an element for
44
- * the set whose built-in Skia tag is suppressed (so there's no double-draw). The
45
- * render fn is probed with placeholder SharedValues; it must decide null-ness from
46
- * `line` / `index`, not from live values (mirrors how `renderMarker` is probed for
47
- * the marker-atlas exclusion set). Index-aligned with `lines`.
47
+ * Which Form-A reference lines a custom renderer returns an element for. This
48
+ * initial probe determines a line's static eligibility only; `"off-axis"` still
49
+ * switches the built-in tag and the RN tag on the UI thread as the edge changes.
50
+ * Index-aligned with `lines`.
48
51
  */
49
52
  export function customReferenceLineFlags(
50
53
  lines: ReferenceLine[],
51
54
  render?: (
52
55
  ctx: ReferenceLineRenderProps,
53
56
  ) => React.ReactElement | null | undefined,
57
+ mode: CustomReferenceLineMode = "always",
54
58
  ): boolean[] {
55
59
  if (!render) return lines.map(() => false);
60
+ const offAxisOnly = mode === "off-axis";
56
61
  return lines.map((line, index) => {
57
62
  if (referenceLineForm(line) !== "line") return false;
58
63
  return (
@@ -62,8 +67,10 @@ export function customReferenceLineFlags(
62
67
  value: stub(line.value ?? 0),
63
68
  valueStr: stub(""),
64
69
  y: stub(-1),
65
- inRange: stub(true),
66
- edge: stub<"above" | "in" | "below">("in"),
70
+ inRange: stub(!offAxisOnly),
71
+ edge: stub<"above" | "in" | "below">(
72
+ offAxisOnly ? "above" : "in",
73
+ ),
67
74
  dragging: stub(false),
68
75
  }) != null
69
76
  );
@@ -88,6 +95,8 @@ function CustomReferenceLineView({
88
95
  formatValue,
89
96
  dragValues,
90
97
  dragActive,
98
+ tagWidths,
99
+ offAxisOnly,
91
100
  }: {
92
101
  line: ReferenceLine;
93
102
  index: number;
@@ -97,14 +106,20 @@ function CustomReferenceLineView({
97
106
  engine: ChartEngineLayout;
98
107
  padding: ChartPadding;
99
108
  formatValue: (v: number) => string;
100
- dragValues: SharedValue<number[]>;
101
- dragActive: SharedValue<boolean[]>;
109
+ /** Optional live overrides used by draggable single-series lines. */
110
+ dragValues?: SharedValue<number[]>;
111
+ /** Optional drag state used by draggable single-series lines. */
112
+ dragActive?: SharedValue<boolean[]>;
113
+ /** Measured custom-tag widths, index-aligned for the Skia connector. */
114
+ tagWidths?: SharedValue<number[]>;
115
+ /** Show this custom tag only while its line is pinned above or below the plot. */
116
+ offAxisOnly: boolean;
102
117
  }) {
103
118
  const staticValue = line.value ?? 0;
104
119
 
105
120
  // Live value: a drag override (if present) else the static prop value.
106
121
  const value = useDerivedValue<number>(() => {
107
- const dv = dragValues.get()[index];
122
+ const dv = dragValues?.get()[index];
108
123
  return dv != null ? dv : staticValue;
109
124
  });
110
125
  const valueStr = useDerivedValue(() => formatValue(value.get()));
@@ -116,12 +131,18 @@ function CustomReferenceLineView({
116
131
  ),
117
132
  );
118
133
  const inRange = useDerivedValue(() => edge.get() === "in");
119
- const dragging = useDerivedValue(() => dragActive.get()[index] === true);
134
+ const dragging = useDerivedValue(
135
+ () => dragActive?.get()[index] === true,
136
+ );
120
137
 
121
138
  // Canvas Y of the value (the element's vertical center). Off-screen values pin
122
139
  // to the nearest plot edge (inset), matching the built-in off-axis badge.
123
140
  const y = useDerivedValue(() => {
124
141
  const ch = engine.canvasHeight.get();
142
+ // `computeScrubDotY` also returns negative values for legitimate prices above
143
+ // the visible range, so use the layout dimensions—not the projected Y—to
144
+ // distinguish an unmeasured canvas from an off-axis line.
145
+ if (ch - padding.top - padding.bottom <= 0) return -1;
125
146
  const raw = computeScrubDotY(
126
147
  value.get(),
127
148
  engine.displayMin.get(),
@@ -130,7 +151,6 @@ function CustomReferenceLineView({
130
151
  padding.top,
131
152
  padding.bottom,
132
153
  );
133
- if (raw < 0) return -1;
134
154
  const top = padding.top + EDGE_INSET;
135
155
  const bottom = ch - padding.bottom - EDGE_INSET;
136
156
  return Math.min(bottom, Math.max(top, raw));
@@ -140,7 +160,14 @@ function CustomReferenceLineView({
140
160
  const size = useSharedValue({ width: 0, height: 0 });
141
161
  const onLayout = (e: LayoutChangeEvent) => {
142
162
  const { width, height } = e.nativeEvent.layout;
143
- size.value = { width, height };
163
+ size.set({ width, height });
164
+ if (tagWidths) {
165
+ const previous = tagWidths.get();
166
+ if (previous[index] === width) return;
167
+ const widths = previous.slice();
168
+ widths[index] = width;
169
+ tagWidths.set(widths);
170
+ }
144
171
  };
145
172
 
146
173
  const anchor = resolveAnchor(line);
@@ -148,7 +175,7 @@ function CustomReferenceLineView({
148
175
  const yy = y.get();
149
176
  const s = size.get();
150
177
  const w = engine.canvasWidth.get();
151
- const visible = yy >= 0 && w > 0;
178
+ const visible = yy >= 0 && w > 0 && (!offAxisOnly || edge.get() !== "in");
152
179
  const x1 = padding.left;
153
180
  const x2 = w - padding.right;
154
181
  let tx: number;
@@ -188,9 +215,9 @@ function CustomReferenceLineView({
188
215
  * React Native overlay (NOT Skia) that floats `renderReferenceLine` elements over
189
216
  * the canvas, one per Form-A line the consumer customizes. Rendered as a sibling
190
217
  * of `<Canvas>` (like {@link CustomMarkerOverlay}) so the tags can be any RN view
191
- * and stay crisp at native resolution. Lines whose render returns an element have
192
- * their built-in Skia tag suppressed upstream (see {@link customReferenceLineFlags}),
193
- * so there's no double-draw.
218
+ * and stay crisp at native resolution. Lines whose render returns an element hide
219
+ * their built-in pill / label upstream (see {@link customReferenceLineFlags}); a
220
+ * badged line's Skia connector remains, measured against the native tag's width.
194
221
  */
195
222
  export function CustomReferenceLineOverlay({
196
223
  lines,
@@ -201,6 +228,8 @@ export function CustomReferenceLineOverlay({
201
228
  formatValue,
202
229
  dragValues,
203
230
  dragActive,
231
+ tagWidths,
232
+ offAxisOnly = false,
204
233
  }: {
205
234
  lines: ReferenceLine[];
206
235
  renderReferenceLine: (
@@ -215,15 +244,22 @@ export function CustomReferenceLineOverlay({
215
244
  engine: ChartEngineLayout;
216
245
  padding: ChartPadding;
217
246
  formatValue: (v: number) => string;
218
- dragValues: SharedValue<number[]>;
219
- dragActive: SharedValue<boolean[]>;
247
+ /** Optional live overrides used by draggable single-series lines. */
248
+ dragValues?: SharedValue<number[]>;
249
+ /** Optional drag state used by draggable single-series lines. */
250
+ dragActive?: SharedValue<boolean[]>;
251
+ /** Measured custom-tag widths, index-aligned for the Skia connector. */
252
+ tagWidths?: SharedValue<number[]>;
253
+ /** Keep the built-in in-range tag and show this RN tag only off-axis. */
254
+ offAxisOnly?: boolean;
220
255
  }) {
221
256
  const children: React.ReactElement[] = [];
257
+ const lineKeys = referenceLineReactKeys(lines);
222
258
  for (let i = 0; i < lines.length; i++) {
223
259
  if (!custom[i]) continue;
224
260
  children.push(
225
261
  <CustomReferenceLineView
226
- key={i}
262
+ key={lineKeys[i]}
227
263
  line={lines[i]}
228
264
  index={i}
229
265
  render={renderReferenceLine}
@@ -232,6 +268,8 @@ export function CustomReferenceLineOverlay({
232
268
  formatValue={formatValue}
233
269
  dragValues={dragValues}
234
270
  dragActive={dragActive}
271
+ tagWidths={tagWidths}
272
+ offAxisOnly={offAxisOnly}
235
273
  />,
236
274
  );
237
275
  }
@@ -5,6 +5,7 @@ import {
5
5
  type SkRSXform,
6
6
  type SkRect,
7
7
  } from "@shopify/react-native-skia";
8
+ import { useRef } from "react";
8
9
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
9
10
  import {
10
11
  buildParticleInstances,
@@ -63,14 +64,46 @@ export function DegenParticlesOverlay({
63
64
  // memoizes this on `colorList`.
64
65
  const colorRgb = colorList.map((c) => parseColorRgb(c));
65
66
 
67
+ const poolRef = useRef<{
68
+ a: { transforms: SkRSXform[]; sprites: SkRect[]; colors: SkColor[] };
69
+ b: { transforms: SkRSXform[]; sprites: SkRect[]; colors: SkColor[] };
70
+ tick: boolean;
71
+ instances: ReturnType<typeof buildParticleInstances>;
72
+ instancePool: ReturnType<typeof buildParticleInstances>;
73
+ spriteRect: SkRect;
74
+ } | null>(null);
75
+ if (poolRef.current === null) {
76
+ const instancePool = Array.from({ length: particleSlotCount }, () => ({
77
+ x: 0,
78
+ y: 0,
79
+ scale: 0,
80
+ alpha: 0,
81
+ colorIndex: 0,
82
+ }));
83
+ poolRef.current = {
84
+ a: { transforms: [], sprites: [], colors: [] },
85
+ b: { transforms: [], sprites: [], colors: [] },
86
+ tick: false,
87
+ instances: [],
88
+ instancePool,
89
+ spriteRect: Skia.XYWHRect(0, 0, sprite.size, sprite.size),
90
+ };
91
+ }
92
+
66
93
  // Single per-frame worklet. Reads `packRevision` so it re-runs each frame
67
94
  // while a burst is alive (the pack buffer is mutated in place, so its
68
95
  // reference is stable and wouldn't otherwise re-notify).
69
96
  const atlasData = useDerivedValue(() => {
70
97
  const rev = packRevision.get();
71
- const transforms: SkRSXform[] = [];
72
- const sprites: SkRect[] = [];
73
- const colorsOut: SkColor[] = [];
98
+ const pool = poolRef.current!;
99
+ pool.tick = !pool.tick;
100
+ const frame = pool.tick ? pool.a : pool.b;
101
+ const transforms = frame.transforms;
102
+ const sprites = frame.sprites;
103
+ const colorsOut = frame.colors;
104
+ transforms.length = 0;
105
+ sprites.length = 0;
106
+ colorsOut.length = 0;
74
107
  if (rev >= 0) {
75
108
  const instances = buildParticleInstances(
76
109
  pack.get(),
@@ -79,6 +112,8 @@ export function DegenParticlesOverlay({
79
112
  particleBurstDurationSec,
80
113
  particleOpacity,
81
114
  sprite.radius,
115
+ pool.instances,
116
+ pool.instancePool,
82
117
  );
83
118
  const half = sprite.size / 2;
84
119
  for (let i = 0; i < instances.length; i++) {
@@ -92,12 +127,12 @@ export function DegenParticlesOverlay({
92
127
  inst.y - inst.scale * half,
93
128
  ),
94
129
  );
95
- sprites.push(Skia.XYWHRect(0, 0, sprite.size, sprite.size));
130
+ sprites.push(pool.spriteRect);
96
131
  const [r, g, b] = colorRgb[inst.colorIndex % colorRgb.length];
97
132
  colorsOut.push(Skia.Color(`rgba(${r}, ${g}, ${b}, ${inst.alpha})`));
98
133
  }
99
134
  }
100
- return { transforms, sprites, colors: colorsOut };
135
+ return frame;
101
136
  }, [
102
137
  pack,
103
138
  packRevision,
@@ -1,11 +1,16 @@
1
+ import { useEffect } from "react";
1
2
  import { Circle, Group } from "@shopify/react-native-skia";
2
- import { useDerivedValue, type SharedValue } from "react-native-reanimated";
3
+ import {
4
+ useDerivedValue,
5
+ useFrameCallback,
6
+ useSharedValue,
7
+ type SharedValue,
8
+ } from "react-native-reanimated";
3
9
  import type {
4
10
  ResolvedDotRingConfig,
5
11
  ResolvedPulseConfig,
6
12
  } from "../core/resolveConfig";
7
13
  import type { LiveChartPalette } from "../types";
8
- import type { ChartEngineLayout } from "../core/useLiveChartEngine";
9
14
 
10
15
  const MIN_PULSE_RADIUS = 9;
11
16
 
@@ -19,7 +24,6 @@ export function DotOverlay({
19
24
  dotX,
20
25
  dotY,
21
26
  palette,
22
- engine,
23
27
  pulse,
24
28
  radius,
25
29
  ring,
@@ -29,7 +33,6 @@ export function DotOverlay({
29
33
  dotX: SharedValue<number>;
30
34
  dotY: SharedValue<number>;
31
35
  palette: LiveChartPalette;
32
- engine: ChartEngineLayout;
33
36
  pulse: ResolvedPulseConfig | null;
34
37
  /** Radius of the color-filled dot in pixels. */
35
38
  radius: number;
@@ -39,17 +42,30 @@ export function DotOverlay({
39
42
  color: string | undefined;
40
43
  /**
41
44
  * Time-scroll right edge (`null` = following live). While scrolled back the
42
- * pulse is suppressed it's driven by the (now frozen) view timestamp, so it
43
- * would stick or flicker, and a "live" heartbeat on a historical point is wrong.
45
+ * pulse is suppressed because a "live" heartbeat on a historical point is
46
+ * misleading.
44
47
  */
45
48
  viewEnd?: SharedValue<number | null>;
46
49
  }) {
47
50
  const dotColor = color ?? palette.line;
48
51
 
52
+ // Pulse clock: wall time, not `engine.timestamp` — a `nowOverride` freezes
53
+ // the engine clock between data updates, which freezes the pulse with it.
54
+ // Runs only while a pulse is configured (resolvePulse returns null when the
55
+ // chart is static, so a suspended chart burns no frames here).
56
+ const pulseClockMs = useSharedValue(0);
57
+ /* istanbul ignore next -- frame-callback worklet runs on the UI thread, not in Jest */
58
+ const pulseClock = useFrameCallback((frame) => {
59
+ pulseClockMs.value = frame.timestamp;
60
+ }, pulse != null);
61
+ useEffect(() => {
62
+ pulseClock.setActive(pulse != null);
63
+ }, [pulse, pulseClock]);
64
+
49
65
  const pulseRadius = useDerivedValue(() => {
50
66
  if (!pulse) return 0;
51
67
  if (viewEnd?.value != null) return 0; // scrolled back — no live pulse
52
- const nowMs = engine.timestamp.value * 1000;
68
+ const nowMs = pulseClockMs.value;
53
69
  const t = (nowMs % pulse.interval) / pulse.duration;
54
70
  if (t >= 1) return 0;
55
71
  return MIN_PULSE_RADIUS + t * (pulse.maxRadius - MIN_PULSE_RADIUS);
@@ -58,7 +74,7 @@ export function DotOverlay({
58
74
  const pulseOpacity = useDerivedValue(() => {
59
75
  if (!pulse) return 0;
60
76
  if (viewEnd?.value != null) return 0; // scrolled back — no live pulse
61
- const nowMs = engine.timestamp.value * 1000;
77
+ const nowMs = pulseClockMs.value;
62
78
  const t = (nowMs % pulse.interval) / pulse.duration;
63
79
  if (t >= 1) return 0;
64
80
  return pulse.opacity * (1 - t);
@@ -2,6 +2,7 @@ import { Group, LinearGradient, Rect, vec } from "@shopify/react-native-skia";
2
2
 
3
3
  import { useDerivedValue } from "react-native-reanimated";
4
4
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
5
+ import { parseColorRgba } from "../theme";
5
6
 
6
7
  export function LeftEdgeFade({
7
8
  paddingLeft,
@@ -9,27 +10,32 @@ export function LeftEdgeFade({
9
10
  startColor,
10
11
  endColor,
11
12
  engine,
13
+ opaqueBackgroundRgb,
12
14
  }: {
13
15
  paddingLeft: number;
14
16
  fadeWidth: number;
15
17
  startColor: string;
16
18
  endColor: string;
17
19
  engine: ChartEngineLayout;
20
+ /** When set, paint the owned background instead of erasing destination alpha. */
21
+ opaqueBackgroundRgb?: [number, number, number];
18
22
  }) {
19
23
  const rectWidth = paddingLeft + fadeWidth;
20
24
  const height = useDerivedValue(() => engine.canvasHeight.value);
21
25
 
22
26
  const gStart = vec(paddingLeft, 0);
23
27
  const gEnd = vec(paddingLeft + fadeWidth, 0);
28
+ const colors = opaqueBackgroundRgb
29
+ ? [
30
+ `rgba(${opaqueBackgroundRgb[0]},${opaqueBackgroundRgb[1]},${opaqueBackgroundRgb[2]},${parseColorRgba(startColor)[3]})`,
31
+ `rgba(${opaqueBackgroundRgb[0]},${opaqueBackgroundRgb[1]},${opaqueBackgroundRgb[2]},${parseColorRgba(endColor)[3]})`,
32
+ ]
33
+ : [startColor, endColor];
24
34
 
25
35
  return (
26
- <Group blendMode="dstOut">
36
+ <Group blendMode={opaqueBackgroundRgb ? undefined : "dstOut"}>
27
37
  <Rect x={0} y={0} width={rectWidth} height={height}>
28
- <LinearGradient
29
- start={gStart}
30
- end={gEnd}
31
- colors={[startColor, endColor]}
32
- />
38
+ <LinearGradient start={gStart} end={gEnd} colors={colors} />
33
39
  </Rect>
34
40
  </Group>
35
41
  );