import { Atlas, Skia, type SkColor, type SkRSXform, type SkRect, } from "@shopify/react-native-skia"; import { useRef } from "react"; import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import { buildParticleInstances, buildParticleSprite, } from "../draw/particleAtlas"; import { parseColorRgb } from "../theme"; import type { LiveChartPalette } from "../types"; import type { ChartEngineLayout } from "../core/useLiveChartEngine"; type DegenPack = SharedValue>; type ParticlePool = { a: { transforms: SkRSXform[]; sprites: SkRect[]; colors: SkColor[] }; b: { transforms: SkRSXform[]; sprites: SkRect[]; colors: SkColor[] }; tick: boolean; instances: ReturnType; instancePool: ReturnType; spriteRect: SkRect; }; function makeParticlePool( particleSlotCount: number, spriteSize: number, ): ParticlePool { const instancePool = Array.from({ length: particleSlotCount }, () => ({ x: 0, y: 0, scale: 0, alpha: 0, colorIndex: 0, })); return { a: { transforms: [], sprites: [], colors: [] }, b: { transforms: [], sprites: [], colors: [] }, tick: false, instances: [], instancePool, spriteRect: Skia.XYWHRect(0, 0, spriteSize, spriteSize), }; } /** * Renders the degen particle burst with a single `drawAtlas` call. * * Every active particle shares one pre-rasterized white-circle sprite and * differs only in position, scale, color, and opacity — the ideal `drawAtlas` * case. A single per-frame worklet projects the packed ring buffer (layout: * `[x, y, vx, vy, t0, active, size, colorIndex]`, stride = DEGEN_STRIDE = 8; * see `math/degenTick.ts`) into transforms + per-sprite colors, replacing the * old O(slots × 4 derived values + 1 `` each) fan-out with O(1) mappers * and one draw. * * Per-particle color/opacity is baked into the sprite color (`rgba(r,g,b,a)`) * and applied with the `"modulate"` blend mode, which multiplies the white * sprite by each color — so a white AA circle becomes a soft, correctly-tinted, * correctly-faded particle, visually equivalent to the old ``. */ export function DegenParticlesOverlay({ pack, packRevision, engine, palette, particleSlotCount, particleBurstDurationSec, particleOpacity, colors, }: { pack: DegenPack; packRevision: SharedValue; engine: ChartEngineLayout; palette: LiveChartPalette; particleSlotCount: number; particleBurstDurationSec: number; particleOpacity: number; colors: string[] | null; }) { /* istanbul ignore next -- branch depends on render-time props */ const colorList = colors && colors.length > 0 ? colors : [palette.line]; // White-circle sprite has no per-render inputs; React Compiler memoizes it. const sprite = buildParticleSprite(); // Pre-parse the color list to [r,g,b] tuples so the per-frame worklet never // parses arbitrary color strings (only formats `rgba(...)`). React Compiler // memoizes this on `colorList`. const colorRgb = colorList.map((c) => parseColorRgb(c)); const poolRef = useRef(null); if (poolRef.current === null) { // React permits this predictable lazy-ref initialization: https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents // react-doctor-disable-next-line react-doctor/no-ref-current-in-render -- false positive for React's documented lazy-ref exception poolRef.current = makeParticlePool(particleSlotCount, sprite.size); } // Single per-frame worklet. Reads `packRevision` so it re-runs each frame // while a burst is alive (the pack buffer is mutated in place, so its // reference is stable and wouldn't otherwise re-notify). const atlasData = useDerivedValue(() => { const rev = packRevision.get(); const pool = poolRef.current!; pool.tick = !pool.tick; const frame = pool.tick ? pool.a : pool.b; const transforms = frame.transforms; const sprites = frame.sprites; const colorsOut = frame.colors; transforms.length = 0; sprites.length = 0; colorsOut.length = 0; if (rev >= 0) { const instances = buildParticleInstances( pack.get(), particleSlotCount, engine.timestamp.get(), particleBurstDurationSec, particleOpacity, sprite.radius, pool.instances, pool.instancePool, ); const half = sprite.size / 2; for (let i = 0; i < instances.length; i++) { const inst = instances[i]; // Center the scaled sprite on (x, y); RSXform has no rotation. transforms.push( Skia.RSXform( inst.scale, 0, inst.x - inst.scale * half, inst.y - inst.scale * half, ), ); sprites.push(pool.spriteRect); const [r, g, b] = colorRgb[inst.colorIndex % colorRgb.length]; colorsOut.push(Skia.Color(`rgba(${r}, ${g}, ${b}, ${inst.alpha})`)); } } return frame; }, [ pack, packRevision, engine, particleSlotCount, particleBurstDurationSec, particleOpacity, sprite, colorRgb, ]); const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]); const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]); const atlasColors = useDerivedValue(() => atlasData.get().colors, [atlasData]); return ( ); }