import { BlurMask, Circle, Group } from "@shopify/react-native-skia"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import type { ChartPadding } from "../draw/line"; import type { ResolvedDotGlowConfig, ResolvedDotRingConfig, ResolvedPulseConfig, } from "../core/resolveConfig"; import type { MultiEngineState } from "../core/useLiveChartEngine"; const MIN_PULSE_RADIUS = 6; const PULSE_STAGGER_MS = 200; function SeriesDotAtIndex({ index, engine, padding, color, radius, ring, glow = null, ringColor, pulse, viewEnd, }: { index: number; engine: MultiEngineState; padding: ChartPadding; color: string; radius: number; /** Crisp outer backing ring, or `null` for a flat circle. */ ring: ResolvedDotRingConfig | null; /** Optional soft static glow; unlike `pulse`, it has no animation clock. */ glow?: ResolvedDotGlowConfig | null; /** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */ ringColor: string; pulse: ResolvedPulseConfig | null; /** Pan/zoom right-edge override — pulse is frozen-out while scrolled back. */ viewEnd?: SharedValue; }) { const dotX = useDerivedValue(() => { const w = engine.canvasWidth.value; if (w === 0) return -100; return w - padding.right; }); const dotY = useDerivedValue(() => { const h = engine.canvasHeight.value; if (h === 0) return -100; const s = engine.series.value; const displays = engine.displaySeriesValues.value; if (index >= s.length) return -100; const chartH = h - padding.top - padding.bottom; const dMin = engine.displayMin.value; const dMax = engine.displayMax.value; const valRange = dMax - dMin; const v = displays[index] ?? s[index].value; if (valRange === 0) return padding.top + chartH / 2; return padding.top + ((dMax - v) / valRange) * chartH; }); const opacity = useDerivedValue(() => { const s = engine.series.value; const op = engine.seriesOpacities.value; if (index >= s.length || index >= op.length) return 0; return op[index] ?? 0; }); const pulseRadius = useDerivedValue(() => { // A live heartbeat on a frozen historical point is wrong (and `timestamp` is // frozen, so it wouldn't animate) — suppress the pulse while scrolled back. if (!pulse || viewEnd?.value != null) return 0; const nowMs = engine.timestamp.value * 1000 + index * PULSE_STAGGER_MS; const t = (nowMs % pulse.interval) / pulse.duration; if (t >= 1) return 0; return MIN_PULSE_RADIUS + t * (pulse.maxRadius - MIN_PULSE_RADIUS); }); const pulseOpacity = useDerivedValue(() => { if (!pulse || viewEnd?.value != null) return 0; const nowMs = engine.timestamp.value * 1000 + index * PULSE_STAGGER_MS; const t = (nowMs % pulse.interval) / pulse.duration; if (t >= 1) return 0; return pulse.opacity * (1 - t); }); return ( {pulse && ( )} {glow && ( )} {ring && ( )} ); } export function MultiSeriesDots({ engine, padding, colors, radius, ring, glow = null, ringColor, color, pulse, viewEnd, seriesCount, }: { engine: MultiEngineState; padding: ChartPadding; colors: string[]; radius: number; /** Crisp outer backing ring, or `null` for flat circles. */ ring: ResolvedDotRingConfig | null; /** Optional soft static glow behind every series dot. */ glow?: ResolvedDotGlowConfig | null; /** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */ ringColor: string; /** Fill color override; falls back to each series' line color. */ color: string | undefined; pulse: ResolvedPulseConfig | null; /** Pan/zoom right-edge override — pulse is frozen-out while scrolled back. */ viewEnd?: SharedValue; /** Number of live series slots to mount. */ seriesCount: number; }) { return ( {Array.from({ length: seriesCount }, (_, i) => ( ))} ); }