icon-targets.ts (3307B)
1 /** 2 * From an icon's outline to a landing spot for every particle. 3 * 4 * The icon arrives as SVG markup (a lucide component's `outerHTML`), 5 * is drawn once into a small offscreen canvas, and its alpha becomes a 6 * grid of "ink here" cells. Each particle then needs the nearest inked 7 * cell to its own resting place: a multi-source flood fill from every 8 * inked cell gives that for the whole grid at once, in a millisecond, 9 * which is near enough to true nearest at this resolution. The spot is 10 * jittered inside its cell so a stroke does not read as a row of beads. 11 * 12 * Coordinates are icon-local: `[-0.5, 0.5]` on both axes, y up. The 13 * field places and scales them per frame. 14 */ 15 16 export const GRID = 128; 17 const PAD = 8; 18 const INK = 96; 19 20 /** Draw the markup and read its alpha. Async: image decode. */ 21 export async function rasterise(svg: string, canvas: HTMLCanvasElement): Promise<Uint8Array> { 22 const img = new Image(); 23 img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; 24 await img.decode(); 25 canvas.width = GRID; 26 canvas.height = GRID; 27 const ctx = canvas.getContext("2d", { willReadFrequently: true }); 28 if (!ctx) throw new Error("no 2d context"); 29 ctx.clearRect(0, 0, GRID, GRID); 30 ctx.drawImage(img, PAD, PAD, GRID - PAD * 2, GRID - PAD * 2); 31 const { data } = ctx.getImageData(0, 0, GRID, GRID); 32 const out = new Uint8Array(GRID * GRID); 33 for (let i = 0; i < out.length; i++) out[i] = data[i * 4 + 3] > INK ? 1 : 0; 34 return out; 35 } 36 37 /** For every cell, the index of the nearest inked cell (8-connected BFS). */ 38 export function nearestInked(ink: Uint8Array): Int32Array { 39 const n = GRID * GRID; 40 const src = new Int32Array(n).fill(-1); 41 const queue = new Int32Array(n); 42 let head = 0; 43 let tail = 0; 44 for (let i = 0; i < n; i++) { 45 if (ink[i]) { 46 src[i] = i; 47 queue[tail++] = i; 48 } 49 } 50 while (head < tail) { 51 const c = queue[head++]; 52 const cx = c % GRID; 53 const cy = (c - cx) / GRID; 54 for (let dy = -1; dy <= 1; dy++) { 55 const y = cy + dy; 56 if (y < 0 || y >= GRID) continue; 57 for (let dx = -1; dx <= 1; dx++) { 58 const x = cx + dx; 59 if (x < 0 || x >= GRID) continue; 60 const i = y * GRID + x; 61 if (src[i] >= 0) continue; 62 src[i] = src[c]; 63 queue[tail++] = i; 64 } 65 } 66 } 67 return src; 68 } 69 70 /** 71 * Landing spots for `rest` (icon-local pairs), one per particle, from 72 * the nearest-ink map. Returns icon-local pairs, y up. 73 */ 74 export function targetsFor(rest: Float32Array, nearest: Int32Array, hash: (i: number) => number): Float32Array { 75 const count = rest.length / 2; 76 const out = new Float32Array(rest.length); 77 for (let i = 0; i < count; i++) { 78 const rx = rest[i * 2]; 79 const ry = rest[i * 2 + 1]; 80 const cx = Math.min(GRID - 1, Math.max(0, Math.floor((rx + 0.5) * GRID))); 81 const cy = Math.min(GRID - 1, Math.max(0, Math.floor((0.5 - ry) * GRID))); 82 const s = nearest[cy * GRID + cx]; 83 if (s < 0) { 84 out[i * 2] = rx; 85 out[i * 2 + 1] = ry; 86 continue; 87 } 88 const sx = s % GRID; 89 const sy = (s - sx) / GRID; 90 const jx = hash(i * 2) - 0.5; 91 const jy = hash(i * 2 + 1) - 0.5; 92 out[i * 2] = (sx + 0.5 + jx * 0.9) / GRID - 0.5; 93 out[i * 2 + 1] = 0.5 - (sy + 0.5 + jy * 0.9) / GRID; 94 } 95 return out; 96 }