react-native-livechart 4.11.0 → 4.13.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/dist/components/CrosshairLine.d.ts +12 -2
  2. package/dist/components/CrosshairLine.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts +12 -2
  4. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  5. package/dist/components/CustomReferenceLineOverlay.d.ts +15 -10
  6. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  8. package/dist/components/CustomTooltipOverlay.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/LiveChart.d.ts.map +1 -1
  12. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  13. package/dist/components/PerSeriesTooltipOverlay.d.ts +21 -0
  14. package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
  15. package/dist/components/ReferenceLineOverlay.d.ts +27 -6
  16. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  17. package/dist/components/YAxisOverlay.d.ts +8 -2
  18. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  19. package/dist/core/liveIndicatorVisibility.d.ts +9 -0
  20. package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
  21. package/dist/core/resolveConfig.d.ts +45 -0
  22. package/dist/core/resolveConfig.d.ts.map +1 -1
  23. package/dist/draw/grid.d.ts +13 -0
  24. package/dist/draw/grid.d.ts.map +1 -1
  25. package/dist/hooks/crosshairSeries.d.ts +10 -2
  26. package/dist/hooks/crosshairSeries.d.ts.map +1 -1
  27. package/dist/hooks/crosshairShared.d.ts +55 -5
  28. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  29. package/dist/hooks/delayedPanGuard.d.ts +66 -8
  30. package/dist/hooks/delayedPanGuard.d.ts.map +1 -1
  31. package/dist/hooks/useChartPaths.d.ts +5 -9
  32. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  33. package/dist/hooks/useCrosshair.d.ts +13 -1
  34. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  35. package/dist/hooks/useCrosshairSeries.d.ts +27 -3
  36. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  37. package/dist/hooks/useCrosshairVisibleOpacity.d.ts +8 -0
  38. package/dist/hooks/useCrosshairVisibleOpacity.d.ts.map +1 -0
  39. package/dist/hooks/useMomentum.d.ts +1 -1
  40. package/dist/hooks/useMomentum.d.ts.map +1 -1
  41. package/dist/hooks/usePanScroll.d.ts +15 -1
  42. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  43. package/dist/hooks/useReferenceLine.d.ts +10 -1
  44. package/dist/hooks/useReferenceLine.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/lerp.d.ts.map +1 -1
  48. package/dist/math/momentum.d.ts +1 -1
  49. package/dist/math/momentum.d.ts.map +1 -1
  50. package/dist/math/referenceLines.d.ts +7 -0
  51. package/dist/math/referenceLines.d.ts.map +1 -1
  52. package/dist/types.d.ts +207 -18
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/components/CrosshairLine.tsx +39 -8
  56. package/src/components/CrosshairOverlay.tsx +49 -15
  57. package/src/components/CustomReferenceLineOverlay.tsx +42 -13
  58. package/src/components/CustomTooltipOverlay.tsx +15 -3
  59. package/src/components/DotOverlay.tsx +24 -8
  60. package/src/components/LiveChart.tsx +182 -39
  61. package/src/components/LiveChartSeries.tsx +195 -16
  62. package/src/components/PerSeriesTooltipOverlay.tsx +290 -0
  63. package/src/components/ReferenceLineOverlay.tsx +288 -141
  64. package/src/components/YAxisOverlay.tsx +39 -10
  65. package/src/core/liveIndicatorVisibility.ts +25 -0
  66. package/src/core/resolveConfig.ts +115 -0
  67. package/src/draw/grid.ts +33 -0
  68. package/src/hooks/crosshairSeries.ts +262 -33
  69. package/src/hooks/crosshairShared.ts +117 -6
  70. package/src/hooks/delayedPanGuard.ts +111 -6
  71. package/src/hooks/useChartPaths.ts +25 -15
  72. package/src/hooks/useCrosshair.ts +103 -32
  73. package/src/hooks/useCrosshairSeries.ts +126 -11
  74. package/src/hooks/useCrosshairVisibleOpacity.ts +57 -0
  75. package/src/hooks/useMomentum.ts +8 -1
  76. package/src/hooks/usePanScroll.ts +46 -5
  77. package/src/hooks/useReferenceLine.ts +26 -1
  78. package/src/index.ts +1 -0
  79. package/src/math/lerp.ts +7 -0
  80. package/src/math/momentum.ts +22 -5
  81. package/src/math/referenceLines.ts +38 -0
  82. package/src/types.ts +215 -18
@@ -11,6 +11,7 @@ import type { ReactNode } from "react";
11
11
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
12
12
  import { type ChartPadding } from "../draw/line";
13
13
  import { type TooltipLayout } from "../hooks/crosshairShared";
14
+ import { useCrosshairVisibleOpacity } from "../hooks/useCrosshairVisibleOpacity";
14
15
  import type { LiveChartPalette } from "../types";
15
16
  import type { ResolvedSelectionDotConfig } from "../core/resolveConfig";
16
17
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
@@ -35,6 +36,11 @@ export function CrosshairOverlay({
35
36
  dimOpacity = 0.3,
36
37
  liveDotExtent = 0,
37
38
  crosshairLineColor,
39
+ crosshairStrokeWidth = 1,
40
+ crosshairOvershoot = 0,
41
+ crosshairFade = true,
42
+ crosshairFadeDistance = 4,
43
+ crosshairLineCap,
38
44
  crosshairDash,
39
45
  crosshairDimColor,
40
46
  tooltipBackground,
@@ -70,7 +76,7 @@ export function CrosshairOverlay({
70
76
  /** Scrub intersection Y in canvas px (the value the dot marks); -1 hides it. */
71
77
  selectionY?: SharedValue<number>;
72
78
  /** Whether scrubbing is active (passed through to a custom dot). */
73
- scrubActive?: SharedValue<number> | SharedValue<boolean>;
79
+ scrubActive: SharedValue<number> | SharedValue<boolean>;
74
80
  /** Fallback selection-dot color (accent / leading-series color), used when the
75
81
  * config's own `color` is unset. */
76
82
  selectionColor?: string;
@@ -83,6 +89,16 @@ export function CrosshairOverlay({
83
89
  * reserves beyond it. Default 0. */
84
90
  liveDotExtent?: number;
85
91
  crosshairLineColor?: string;
92
+ /** Vertical crosshair line width in px. Default 1. */
93
+ crosshairStrokeWidth?: number;
94
+ /** Resolved extension past the top and bottom plot edges in px. Default 0. */
95
+ crosshairOvershoot?: number;
96
+ /** Fade the crosshair near the live edge. Default true. */
97
+ crosshairFade?: boolean;
98
+ /** Visible-crosshair fade distance near the live edge in px. Default 4. */
99
+ crosshairFadeDistance?: number;
100
+ /** Cap style for the vertical crosshair line. */
101
+ crosshairLineCap?: "butt" | "round" | "square";
86
102
  /** Dash intervals `[on, off, …]` for the crosshair line; omit → solid. */
87
103
  crosshairDash?: number[];
88
104
  crosshairDimColor?: string;
@@ -104,21 +120,27 @@ export function CrosshairOverlay({
104
120
  // React's "final argument changed size between renders" error. Listing the
105
121
  // captured plain values keeps the dependency array a constant size. SharedValue
106
122
  // reads stay reactive regardless of this list.
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]);
123
+ const p1 = useDerivedValue(
124
+ () => {
125
+ // A top-pinned custom tooltip pushes the line's start down to its measured
126
+ // bottom (lineTop) so the line stops at the label; -1 → no top tooltip.
127
+ const lt = lineTop?.value ?? -1;
128
+ return {
129
+ x: scrubX.value,
130
+ y: lt >= 0 ? lt : padding.top - crosshairOvershoot,
131
+ };
132
+ },
133
+ [scrubX, padding.top, lineTop, crosshairOvershoot],
134
+ );
116
135
  const p2 = useDerivedValue(
117
136
  () => ({
118
137
  x: scrubX.value,
119
- y: engine.canvasHeight.value - padding.bottom,
138
+ y:
139
+ engine.canvasHeight.value -
140
+ padding.bottom +
141
+ crosshairOvershoot,
120
142
  }),
121
- [scrubX, engine.canvasHeight, padding.bottom],
143
+ [scrubX, engine.canvasHeight, padding.bottom, crosshairOvershoot],
122
144
  );
123
145
 
124
146
  const dimWidth = useDerivedValue(() => {
@@ -159,6 +181,17 @@ export function CrosshairOverlay({
159
181
  );
160
182
  const backgroundColor = `rgb(${palette.bgRgb[0]},${palette.bgRgb[1]},${palette.bgRgb[2]})`;
161
183
 
184
+ // Keep the trailing dim on its original edge fade. Only the visible
185
+ // crosshair group (line, dot, tooltip) uses the configurable distance.
186
+ const visibleOpacity = useCrosshairVisibleOpacity(
187
+ scrubX,
188
+ engine.canvasWidth,
189
+ padding.right,
190
+ scrubActive,
191
+ crosshairFade,
192
+ crosshairFadeDistance,
193
+ );
194
+
162
195
  return (
163
196
  <>
164
197
  {crosshairDimColor !== undefined ? (
@@ -195,12 +228,13 @@ export function CrosshairOverlay({
195
228
  </Group>
196
229
  ) : null}
197
230
 
198
- <Group opacity={crosshairOpacity}>
231
+ <Group opacity={visibleOpacity}>
199
232
  <Line
200
233
  p1={p1}
201
234
  p2={p2}
202
235
  color={crosshairLineColor ?? palette.crosshairLine}
203
- strokeWidth={1}
236
+ strokeWidth={crosshairStrokeWidth}
237
+ strokeCap={crosshairLineCap}
204
238
  >
205
239
  {crosshairDash ? <DashPathEffect intervals={crosshairDash} /> : null}
206
240
  </Line>
@@ -212,7 +246,7 @@ export function CrosshairOverlay({
212
246
  x={scrubX}
213
247
  y={selectionY}
214
248
  active={scrubActive}
215
- opacity={crosshairOpacity}
249
+ opacity={visibleOpacity}
216
250
  color={selectionColor ?? palette.line}
217
251
  />
218
252
 
@@ -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;
@@ -101,6 +110,10 @@ function CustomReferenceLineView({
101
110
  dragValues?: SharedValue<number[]>;
102
111
  /** Optional drag state used by draggable single-series lines. */
103
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;
104
117
  }) {
105
118
  const staticValue = line.value ?? 0;
106
119
 
@@ -147,7 +160,14 @@ function CustomReferenceLineView({
147
160
  const size = useSharedValue({ width: 0, height: 0 });
148
161
  const onLayout = (e: LayoutChangeEvent) => {
149
162
  const { width, height } = e.nativeEvent.layout;
150
- 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
+ }
151
171
  };
152
172
 
153
173
  const anchor = resolveAnchor(line);
@@ -155,7 +175,7 @@ function CustomReferenceLineView({
155
175
  const yy = y.get();
156
176
  const s = size.get();
157
177
  const w = engine.canvasWidth.get();
158
- const visible = yy >= 0 && w > 0;
178
+ const visible = yy >= 0 && w > 0 && (!offAxisOnly || edge.get() !== "in");
159
179
  const x1 = padding.left;
160
180
  const x2 = w - padding.right;
161
181
  let tx: number;
@@ -195,9 +215,9 @@ function CustomReferenceLineView({
195
215
  * React Native overlay (NOT Skia) that floats `renderReferenceLine` elements over
196
216
  * the canvas, one per Form-A line the consumer customizes. Rendered as a sibling
197
217
  * of `<Canvas>` (like {@link CustomMarkerOverlay}) so the tags can be any RN view
198
- * and stay crisp at native resolution. Lines whose render returns an element have
199
- * their built-in Skia tag suppressed upstream (see {@link customReferenceLineFlags}),
200
- * 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.
201
221
  */
202
222
  export function CustomReferenceLineOverlay({
203
223
  lines,
@@ -208,6 +228,8 @@ export function CustomReferenceLineOverlay({
208
228
  formatValue,
209
229
  dragValues,
210
230
  dragActive,
231
+ tagWidths,
232
+ offAxisOnly = false,
211
233
  }: {
212
234
  lines: ReferenceLine[];
213
235
  renderReferenceLine: (
@@ -226,13 +248,18 @@ export function CustomReferenceLineOverlay({
226
248
  dragValues?: SharedValue<number[]>;
227
249
  /** Optional drag state used by draggable single-series lines. */
228
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;
229
255
  }) {
230
256
  const children: React.ReactElement[] = [];
257
+ const lineKeys = referenceLineReactKeys(lines);
231
258
  for (let i = 0; i < lines.length; i++) {
232
259
  if (!custom[i]) continue;
233
260
  children.push(
234
261
  <CustomReferenceLineView
235
- key={i}
262
+ key={lineKeys[i]}
236
263
  line={lines[i]}
237
264
  index={i}
238
265
  render={renderReferenceLine}
@@ -241,6 +268,8 @@ export function CustomReferenceLineOverlay({
241
268
  formatValue={formatValue}
242
269
  dragValues={dragValues}
243
270
  dragActive={dragActive}
271
+ tagWidths={tagWidths}
272
+ offAxisOnly={offAxisOnly}
244
273
  />,
245
274
  );
246
275
  }
@@ -9,6 +9,7 @@ import Animated, {
9
9
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
10
10
  import type { ChartPadding } from "../draw/line";
11
11
  import type { TooltipLayout } from "../hooks/crosshairShared";
12
+ import { resolveCrosshairVisibleOpacity } from "../hooks/useCrosshairVisibleOpacity";
12
13
  import type { CandlePoint, TooltipRenderProps } from "../types";
13
14
 
14
15
  // Mirror the Skia tooltip's offsets (see crosshairShared.ts) so a custom pill
@@ -41,12 +42,13 @@ export function CustomTooltipOverlay({
41
42
  scrubTime,
42
43
  scrubActive,
43
44
  scrubCandle,
44
- crosshairOpacity,
45
45
  tooltipLayout,
46
46
  engine,
47
47
  padding,
48
48
  placement,
49
49
  margin = 8,
50
+ crosshairFade = true,
51
+ crosshairFadeDistance = 4,
50
52
  lineTop,
51
53
  scrubDotY,
52
54
  }: {
@@ -57,13 +59,16 @@ export function CustomTooltipOverlay({
57
59
  scrubActive: SharedValue<boolean>;
58
60
  /** OHLC candle under the crosshair (candle mode); omitted/`null` in line mode. */
59
61
  scrubCandle?: SharedValue<CandlePoint | null>;
60
- crosshairOpacity: SharedValue<number>;
61
62
  tooltipLayout: SharedValue<TooltipLayout>;
62
63
  engine: ChartEngineLayout;
63
64
  padding: ChartPadding;
64
65
  placement: "side" | "top" | "bottom" | "point";
65
66
  /** Gap (px) between the pill and the plot edge it's pinned to. Default 8. */
66
67
  margin?: number;
68
+ /** Fade the tooltip near the live edge. Default true. */
69
+ crosshairFade?: boolean;
70
+ /** Tooltip fade distance near the live edge in px. Default 4. */
71
+ crosshairFadeDistance?: number;
67
72
  /** When `placement` is `"top"`, the overlay publishes the label's bottom edge
68
73
  * (canvas Y) here so {@link CrosshairOverlay} can stop the crosshair line at
69
74
  * the label instead of running through it; -1 when not top-pinned/active. */
@@ -154,7 +159,14 @@ export function CustomTooltipOverlay({
154
159
  }
155
160
 
156
161
  return {
157
- opacity: active ? crosshairOpacity.get() : 0,
162
+ opacity: resolveCrosshairVisibleOpacity(
163
+ active,
164
+ sx,
165
+ cw,
166
+ padding.right,
167
+ crosshairFade,
168
+ crosshairFadeDistance,
169
+ ),
158
170
  transform: [{ translateX: x }, { translateY: y }],
159
171
  };
160
172
  });
@@ -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);