// The generator behind redoal.com's hero - a port of lysbue's // "paths" CSS paint worklet (static/worklet/paint/paths.js): a path is // a turning function, a list of (angle shift, distance) stops walked // from the center and smoothed into quadratic arcs. Shared verbatim by // the paint worklet (paths-worklet.js, where the browser has the CSS // Paint API) and the canvas fallback (hero.js) so both draw the same // field. Everything is seeded: a worklet is instantiated whenever the // engine likes, and unseeded randomness would reshuffle the picture on // every repaint. export function mulberry32(seed) { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function isInBounds(x, y) { return x > -0.5 && x < 0.5 && y > -0.5 && y < 0.5; } // Flat array of (angle shift, distance) pairs; distance 1 = the // frame's short side. The first pair is the start: absolute angle and // distance from the center. export function createTurningFunction(rand, stopCount) { const turning = [rand() * Math.PI * 2, rand() * 0.3]; let radianSum = turning[0]; let x = Math.cos(turning[0]) * turning[1]; let y = Math.sin(turning[0]) * turning[1]; for (let i = 0; i < stopCount; i++) { let shiftAngle = 0, shiftDistance = 0.1, next = radianSum; for (let j = 0; j < 80; j++) { shiftAngle = Math.pow(1 + rand() * 4, -2) * Math.PI * 2 * (rand() > 0.5 ? -1 : 1); shiftDistance = Math.max(rand() * 0.25, 0.05); next = radianSum + shiftAngle; if (isInBounds(x + Math.cos(next) * shiftDistance, y + Math.sin(next) * shiftDistance)) break; } radianSum = next; x += Math.cos(radianSum) * shiftDistance; y += Math.sin(radianSum) * shiftDistance; turning.push(shiftAngle, shiftDistance); } return turning; } // Smooth the stops into quadratic arcs (control point = half a step // along the previous heading, like the original) and flatten each arc // into `samples` points so the curve can be traced progressively. export function turningFunctionToPoints(turning, samples = 14) { const pts = []; let heading = turning[0]; let x = Math.cos(turning[0]) * turning[1]; let y = Math.sin(turning[0]) * turning[1]; pts.push(x, y); for (let i = 2; i < turning.length; i += 2) { const cx = x + Math.cos(heading) * (turning[i + 1] / 2); const cy = y + Math.sin(heading) * (turning[i + 1] / 2); heading += turning[i]; const nx = x + Math.cos(heading) * turning[i + 1]; const ny = y + Math.sin(heading) * turning[i + 1]; for (let s = 1; s <= samples; s++) { const u = s / samples, v = 1 - u; pts.push(v * v * x + 2 * v * u * cx + u * u * nx, v * v * y + 2 * v * u * cy + u * u * ny); } x = nx; y = ny; } return pts; } // A path plus the motion that is its own: where it sits, how it // spins, how it drifts, how fast it traces itself in. Every rate is // an integer number of cycles so the field loops seamlessly at t = 1. export function makePaths(seed, count, stops = 10) { const rand = mulberry32(seed); const paths = []; for (let i = 0; i < count; i++) { const pts = turningFunctionToPoints(createTurningFunction(rand, stops)); let cx = 0, cy = 0; for (let k = 0; k < pts.length; k += 2) { cx += pts[k]; cy += pts[k + 1]; } cx /= pts.length / 2; cy /= pts.length / 2; const sign = rand() > 0.5 ? 1 : -1; paths.push({ pts, cx, cy, // own turns per cycle: a third sit still, the rest turn one // or two full turns either way over the cycle spin: rand() < 0.33 ? 0 : sign * (1 + Math.floor(rand() * 2)), // slow orbit around its own place, radius in frame units driftR: 0.01 + rand() * 0.03, driftK: 1 + Math.floor(rand() * 3), driftPhase: rand() * Math.PI * 2, // its own wave along the line waveF: 4 + rand() * 6, waveK: 1 + Math.floor(rand() * 2), waveA: 0.004 + rand() * 0.008, // tracing tempo: how many times it redraws itself per cycle tempo: 1 + Math.floor(rand() * 2), phase: rand(), }); } return paths; } // t in [0, 1) is the animation phase. Each path traces itself in at // its own tempo and phase, fades as it completes, turns at its own // rate about its own center and drifts on its own small orbit; a wave // with its own frequency runs along it. The tip is interpolated // between samples so a trace glides instead of stepping. `colors` is // [accent, dim] - the palette comes from the page's custom properties. const smooth = (u) => u * u * (3 - 2 * u); export function drawPaths(ctx, width, height, paths, t, colors) { ctx.clearRect(0, 0, width, height); const s = Math.min(width, height) * 0.9; ctx.save(); ctx.translate(width / 2, height / 2); ctx.lineWidth = 1; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const TAU = Math.PI * 2; for (let i = 0; i < paths.length; i++) { const p = paths[i]; const phase = (t * p.tempo + p.phase) % 1; // ease: quick to appear, long to complete, then fade const progress = smooth(Math.min(1, phase * 1.35)); const alpha = phase < 0.85 ? 0.75 : 0.75 * (1 - (phase - 0.85) / 0.15); const total = p.pts.length / 2; const tip = 1 + (total - 1) * progress; // fractional sample index const whole = Math.floor(tip); const frac = tip - whole; const angle = p.spin * TAU * t; const dx = Math.cos(p.driftPhase + p.driftK * TAU * t) * p.driftR; const dy = Math.sin(p.driftPhase + p.driftK * TAU * t) * p.driftR; const wave = p.waveK * TAU * t; ctx.save(); ctx.translate((p.cx + dx) * s, (p.cy + dy) * s); ctx.rotate(angle); ctx.strokeStyle = i % 3 === 0 ? colors[0] : colors[1]; ctx.globalAlpha = alpha * (i % 3 === 0 ? 1 : 0.55); ctx.beginPath(); const at = (k) => { const x = p.pts[k * 2] - p.cx, y = p.pts[k * 2 + 1] - p.cy; return [x, y + Math.sin(x * p.waveF + wave) * p.waveA]; }; for (let k = 0; k <= Math.min(whole, total - 1); k++) { const [x, y] = at(k); if (k === 0) ctx.moveTo(x * s, y * s); else ctx.lineTo(x * s, y * s); } if (whole < total - 1 && frac > 0) { const [ax, ay] = at(whole), [bx, by] = at(whole + 1); ctx.lineTo((ax + (bx - ax) * frac) * s, (ay + (by - ay) * frac) * s); } ctx.stroke(); ctx.restore(); } ctx.restore(); }