Files
questions/paths.js
T
Bendik Aagaard LynghaugandClaude Fable 5 588e35fd44
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Hero: every path moves on its own, and the tip glides
The trace advanced a whole sample at a time (floor of points x
progress) - visible steps; the tip now interpolates between samples.
The single field-wide rotate is gone: each path turns at its own
seeded rate and direction about its own center, drifts on its own
small orbit, carries its own wave and traces itself at its own tempo.
Every rate is an integer number of cycles, so the loop stays
seamless. Same generator feeds the paint worklet and the canvas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:29:39 +02:00

168 lines
6.8 KiB
JavaScript

// 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();
}