/** * flow-field.ts — Canvas2D flow-field particle system, a faithful TypeScript * port of the a-immersive playground visualiser * (`/home/w1n5t0n/deployments/meml-aimmersive/js/ui/visualizer.js`). * * FAITHFULNESS (verified 2026-06-28 against the original): * • 400 particles, identical permutation-table value noise (`PERM`/`noise2D`). * • The first 20 model outputs (each ∈ [0,1]) map to the visual ranges exactly * as the original `setParams` (p0..p19 — see the per-line comments below). * • The advection/attractor/dispersion/repulsor integration in `draw()` is a * line-for-line port of the original. * • Deliberate improvements over the original (NOT drift): * - `resize()` resets the transform before `scale(dpr)` so repeated resizes * don't compound the device-pixel-ratio scale (the original double-scaled * on every resize); it also repaints the background so a resize doesn't * leave stale trails. * - `draw()` early-returns while the canvas has zero size (pre-layout). * * The simulation advances on its own clock, so particles keep flowing between * inferences — only the *field* changes when the MLP outputs do. */ // Simple value noise (no dependencies) — identical permutation scheme to the // a-immersive port. The table is shuffled once at module load. const PERM = new Uint8Array(512); { const p = new Uint8Array(256); for (let i = 0; i < 256; i++) p[i] = i; for (let i = 255; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [p[i], p[j]] = [p[j], p[i]]; } for (let i = 0; i < 512; i++) PERM[i] = p[i & 255]; } function fade(t: number): number { return t * t * t * (t * (t * 6 - 15) + 10); } function lerp(a: number, b: number, t: number): number { return a + t * (b - a); } function grad(hash: number, x: number, y: number): number { const h = hash & 3; const u = h < 2 ? x : y; const v = h < 2 ? y : x; return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); } function noise2D(x: number, y: number): number { const X = Math.floor(x) & 255; const Y = Math.floor(y) & 255; const xf = x - Math.floor(x); const yf = y - Math.floor(y); const u = fade(xf); const v = fade(yf); const aa = PERM[PERM[X] + Y]; const ab = PERM[PERM[X] + Y + 1]; const ba = PERM[PERM[X + 1] + Y]; const bb = PERM[PERM[X + 1] + Y + 1]; return lerp( lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u), lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u), v, ); } const TWO_PI = Math.PI * 2; /** * The 20 visual output params, in output order (p0..p19). Names are verbatim * from the a-immersive original (`VISUAL_PARAM_NAMES`, a-app.js:46). */ export const VISUAL_PARAM_NAMES = [ 'Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate', ] as const; export const N_VISUAL_OUTPUTS = VISUAL_PARAM_NAMES.length; /** * Heatmap-strip bar colours. Rather than the a-immersive original's clashing * 20-colour palette, these are an on-theme ramp interpolated between Manifold's * two brand accents — warm-orange `--accent` (#ff6a00) → cool-cyan `--accent-2` * (#00ccff) — so the strip reads as one cohesive instrument that belongs to the * rest of the app's colour language. Each bar still gets a distinct hue. */ function hslToHex(h: number, s: number, l: number): string { const sN = s / 100; const lN = l / 100; const c = (1 - Math.abs(2 * lN - 1)) * sN; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = lN - c / 2; let r = 0; let g = 0; let b = 0; if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0]; else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c]; else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; const to2 = (v: number) => Math.round((v + m) * 255) .toString(16) .padStart(2, '0'); return `#${to2(r)}${to2(g)}${to2(b)}`; } // Brand anchors in HSL: --accent ≈ hsl(25,100,50), --accent-2 ≈ hsl(192,100,50). // The sweep passes through gold (--warn) and green (--good) — all theme hues. // Saturation/lightness sit in the app's semantic-token register (S≈70, not neon) // so the strip feels native on the dark canvas rather than electric. export const VISUAL_PARAM_COLORS = Array.from({ length: N_VISUAL_OUTPUTS }, (_, i) => { const t = N_VISUAL_OUTPUTS > 1 ? i / (N_VISUAL_OUTPUTS - 1) : 0; const hue = 25 + t * (192 - 25); // warm-orange → cool-cyan across the two accents return hslToHex(hue, 72, 60); }); interface Particle { x: number; y: number; id: number; age: number; life: number; vx: number; vy: number; } interface FlowParams { angleOffset: number; scale: number; speed: number; hueBase: number; hueSpread: number; particleSize: number; fadeRate: number; turbulence: number; attractStrength: number; attractRadius: number; dispersionRate: number; dispersionAmount: number; particleLifetime: number; respawnStyle: number; advectionMode: number; inertia: number; drag: number; repulsorStrength: number; repulsorCount: number; repulsorOrbitRate: number; } export class FlowFieldVisualizer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private particles: Particle[] = []; private numParticles = 400; private time = 0; private width = 0; private height = 0; private params: FlowParams = { angleOffset: 0, // p0: flow direction scale: 0.005, // p1: pattern size speed: 2, // p2: particle speed hueBase: 180, // p3: base colour hueSpread: 60, // p4: colour variation particleSize: 3, // p5: dot radius fadeRate: 0.05, // p6: trail length turbulence: 1, // p7: chaos attractStrength: 0.8, // p8: pull toward screen centre attractRadius: 200, // p9: radius where attraction is strongest dispersionRate: 2.0, // p10: speed of outward dispersion pulses dispersionAmount: 1.0, // p11: strength of outward dispersion particleLifetime: 220, // p12: average frames before respawn respawnStyle: 0.0, // p13: 0=random, 1=edge, 2=center-burst advectionMode: 0.0, // p14: flow->orbit->radial blend inertia: 0.2, // p15: velocity memory drag: 0.02, // p16: velocity damping repulsorStrength: 0.0, // p17: repulsor force amount repulsorCount: 0, // p18: number of active repulsors repulsorOrbitRate: 0.8, // p19: repulsor orbital speed }; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('FlowFieldVisualizer: 2D context unavailable'); this.ctx = ctx; this.resize(); this.initParticles(); } resize(): void { const rect = this.canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; this.canvas.width = Math.max(1, Math.round(rect.width * dpr)); this.canvas.height = Math.max(1, Math.round(rect.height * dpr)); this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.scale(dpr, dpr); this.width = rect.width; this.height = rect.height; // Clear to background so a resize doesn't leave stale trails. this.ctx.fillStyle = '#0d0d0d'; this.ctx.fillRect(0, 0, this.width, this.height); } private initParticles(): void { this.particles = []; for (let i = 0; i < this.numParticles; i++) { this.particles.push(this.makeParticle(i)); } this.ctx.fillStyle = '#0d0d0d'; this.ctx.fillRect(0, 0, this.width, this.height); } private makeParticle(id: number): Particle { return { x: Math.random() * this.width, y: Math.random() * this.height, id, age: Math.floor(Math.random() * this.params.particleLifetime), life: this.computeLifetime(), vx: 0, vy: 0, }; } private computeLifetime(): number { const variance = 0.65 + Math.random() * 0.7; return Math.max(10, Math.floor(this.params.particleLifetime * variance)); } private respawnParticle(p: Particle): void { const mode = Math.min(2, Math.floor(this.params.respawnStyle * 2.999)); const { width, height } = this; if (mode === 1) { // Edge respawn const side = Math.floor(Math.random() * 4); if (side === 0) { p.x = Math.random() * width; p.y = 0; } if (side === 1) { p.x = width; p.y = Math.random() * height; } if (side === 2) { p.x = Math.random() * width; p.y = height; } if (side === 3) { p.x = 0; p.y = Math.random() * height; } // Give edge spawns an inward impulse. const towardCenterX = width * 0.5 - p.x; const towardCenterY = height * 0.5 - p.y; const inwardDist = Math.hypot(towardCenterX, towardCenterY) + 1e-6; p.vx = (towardCenterX / inwardDist) * 2.0; p.vy = (towardCenterY / inwardDist) * 2.0; } else if (mode === 2) { // Center-burst respawn const angle = Math.random() * TWO_PI; const r = Math.random() * Math.min(width, height) * 0.08; p.x = width * 0.5 + Math.cos(angle) * r; p.y = height * 0.5 + Math.sin(angle) * r; p.vx = Math.cos(angle) * 2.5; p.vy = Math.sin(angle) * 2.5; } else { // Random respawn p.x = Math.random() * width; p.y = Math.random() * height; p.vx = (Math.random() * 2 - 1) * 0.5; p.vy = (Math.random() * 2 - 1) * 0.5; } p.age = 0; p.life = this.computeLifetime(); } /** Set parameters from the model output (first 20 values, each ∈ [0,1]). */ setParams(outputs: ArrayLike | null | undefined): void { if (!outputs || outputs.length < 20) return; this.params.angleOffset = outputs[0] * TWO_PI; this.params.scale = 0.001 + outputs[1] * 0.009; this.params.speed = 0.5 + outputs[2] * 4.5; this.params.hueBase = outputs[3] * 360; this.params.hueSpread = outputs[4] * 120; this.params.particleSize = 1 + outputs[5] * 5; this.params.fadeRate = 0.01 + outputs[6] * 0.14; this.params.turbulence = outputs[7] * 2; this.params.attractStrength = 0.1 + outputs[8] * 2.9; this.params.attractRadius = 40 + outputs[9] * 420; this.params.dispersionRate = 0.2 + outputs[10] * 8; this.params.dispersionAmount = outputs[11] * 3; this.params.particleLifetime = 30 + outputs[12] * 470; this.params.respawnStyle = outputs[13]; this.params.advectionMode = outputs[14]; this.params.inertia = outputs[15] * 0.98; this.params.drag = outputs[16] * 0.35; this.params.repulsorStrength = outputs[17] * 4.5; this.params.repulsorCount = Math.floor(outputs[18] * 4.999); this.params.repulsorOrbitRate = 0.1 + outputs[19] * 2.9; } draw(): void { const { ctx, width, height, params } = this; if (width === 0 || height === 0) return; this.time += 0.003; // Fade existing content (creates trails) ctx.fillStyle = `rgba(13, 13, 13, ${params.fadeRate})`; ctx.fillRect(0, 0, width, height); for (const p of this.particles) { const cx = width * 0.5; const cy = height * 0.5; // Sample flow field const nx = p.x * params.scale; const ny = p.y * params.scale; const angle = noise2D(nx + this.time, ny) * TWO_PI + params.angleOffset; const curl = noise2D(nx + 100, ny + 100 + this.time * 0.5) * params.turbulence; // Mix between three advection fields for larger visual mode changes. const flowVx = Math.cos(angle + curl) * params.speed; const flowVy = Math.sin(angle + curl) * params.speed; const fromCenterX = p.x - cx; const fromCenterY = p.y - cy; const centerDist = Math.hypot(fromCenterX, fromCenterY) + 1e-6; const radialX = fromCenterX / centerDist; const radialY = fromCenterY / centerDist; const orbitX = -radialY; const orbitY = radialX; const orbitVx = orbitX * params.speed; const orbitVy = orbitY * params.speed; const radialVx = radialX * params.speed; const radialVy = radialY * params.speed; const modeBlend = params.advectionMode * 2; let targetVx: number; let targetVy: number; if (modeBlend < 1) { targetVx = lerp(flowVx, orbitVx, modeBlend); targetVy = lerp(flowVy, orbitVy, modeBlend); } else { targetVx = lerp(orbitVx, radialVx, modeBlend - 1); targetVy = lerp(orbitVy, radialVy, modeBlend - 1); } p.vx = p.vx * params.inertia + targetVx * (1 - params.inertia); p.vy = p.vy * params.inertia + targetVy * (1 - params.inertia); p.vx *= 1 - params.drag; p.vy *= 1 - params.drag; let nextX = p.x + p.vx; let nextY = p.y + p.vy; // Central attractor keeps trajectories from sticking to the outer edges. const dx = cx - nextX; const dy = cy - nextY; const dist = Math.hypot(dx, dy) + 1e-6; const nxCenter = dx / dist; const nyCenter = dy / dist; const normalizedDist = Math.min(dist / params.attractRadius, 2); const falloff = 1 / (1 + normalizedDist * normalizedDist); nextX += nxCenter * params.attractStrength * falloff; nextY += nyCenter * params.attractStrength * falloff; // Time-varying dispersion pushes particles outward near the centre. const dispersionPulse = 0.5 + 0.5 * Math.sin(this.time * params.dispersionRate + p.id * 0.07); const dispersionForce = params.dispersionAmount * dispersionPulse * falloff; nextX -= nxCenter * dispersionForce; nextY -= nyCenter * dispersionForce; // Orbiting repulsor points carve dynamic voids and bursts. const repulsorRadius = Math.min(width, height) * 0.28; for (let r = 0; r < params.repulsorCount; r++) { const phase = this.time * params.repulsorOrbitRate + (r / 4) * TWO_PI; const wobble = 0.6 + 0.15 * r; const rx = cx + Math.cos(phase * (1.0 + wobble)) * repulsorRadius; const ry = cy + Math.sin(phase * (1.3 + wobble)) * repulsorRadius; const repulseDx = nextX - rx; const repulseDy = nextY - ry; const distSq = repulseDx * repulseDx + repulseDy * repulseDy + 160; const distInv = 1 / Math.sqrt(distSq); const force = params.repulsorStrength * (650 / distSq); nextX += repulseDx * distInv * force; nextY += repulseDy * distInv * force; } p.x = nextX; p.y = nextY; // Wrap around edges if (p.x < 0) p.x += width; if (p.x > width) p.x -= width; if (p.y < 0) p.y += height; if (p.y > height) p.y -= height; p.age += 1; if (p.age >= p.life) this.respawnParticle(p); // Colour based on particle id + hue params const hue = (params.hueBase + (p.id / this.numParticles) * params.hueSpread) % 360; const lightness = 50 + Math.sin(p.id * 0.1 + this.time) * 15; ctx.fillStyle = `hsl(${hue}, 75%, ${lightness}%)`; ctx.beginPath(); ctx.arc(p.x, p.y, params.particleSize, 0, TWO_PI); ctx.fill(); } } }