Hero: lysbue's paths paint worklet, animated (WIP branch)
This commit is contained in:
@@ -11,11 +11,15 @@ The full schema reference lives in
|
||||
this repo only notes what's distinctive here:
|
||||
|
||||
- **`site.yaml`** rebrands the instance: title `redoal`, its own
|
||||
wordmark, and a `module` hero — `hero.js` in this repo, the drifting
|
||||
"sine swings" band grown from lysbue's logo (pure SVG + CSS, colors
|
||||
from portal's own custom properties, still under
|
||||
prefers-reduced-motion). No input up there: the form's gesture
|
||||
canvas below is the one place a visitor draws. The wordmark is
|
||||
wordmark, and a `module` hero — `hero.js` in this repo, lysbue's
|
||||
"paths" paint worklet brought back and animated: 47 generative
|
||||
turning-function curves (`paths.js`, seeded so every repaint agrees)
|
||||
tracing themselves in from the center. A real CSS Paint Worklet
|
||||
where the API exists (`paths-worklet.js`, driven by a registered
|
||||
`--redoal-t` CSS animation), a canvas with the same generator
|
||||
elsewhere; colors from portal's custom properties, frozen under
|
||||
prefers-reduced-motion. No input up there: the form's gesture canvas
|
||||
below is the one place a visitor draws. The wordmark is
|
||||
white-stroked like uhhm's, because portal's light theme lands
|
||||
wordmarks as ink via an invert filter.
|
||||
- **`type: gesture`** on `index.yaml`'s "Draw a path and see who's near" — the
|
||||
|
||||
@@ -1,66 +1,56 @@
|
||||
// redoal.com's hero: "sine swings" - the drifting layered sine paths
|
||||
// from lysbue's logo (the site this content descends from), grown from
|
||||
// a clipped 400px circle into a full-width band. No input: the form's
|
||||
// gesture canvas below is the one place a visitor draws. Content-owned:
|
||||
// portal knows only site.yaml's `hero: {kind: module, module: hero.js}`
|
||||
// and the contract - `mount(container) -> handle` with `stop()`; it
|
||||
// serves this file same-origin at /site/hero.js, starts it at HTML
|
||||
// parse time, adopts it on hydration, and stops it on navigation.
|
||||
// redoal.com's hero: lysbue's "paths" paint worklet, animated - the
|
||||
// site this content descends from painted its pages with generative
|
||||
// turning-function curves (static/worklet/paint/paths.js); this is
|
||||
// that field, alive. No input up here: the form's gesture canvas is
|
||||
// the one place a visitor draws.
|
||||
//
|
||||
// Pure SVG + CSS: the strokes take their color from portal's own
|
||||
// custom properties (--accent, --ink-dim), so both themes come for
|
||||
// free and there's no palette copy to keep in sync. Motion is a CSS
|
||||
// keyframe on each layer's transform, disabled under
|
||||
// prefers-reduced-motion.
|
||||
// Content-owned: portal knows only site.yaml's `hero: {kind: module,
|
||||
// module: hero.js}` and the contract - `mount(container) -> handle`
|
||||
// with `stop()` - and serves this repo's files same-origin under
|
||||
// /site/. Two renderers, one generator (paths.js):
|
||||
//
|
||||
// - Where the CSS Paint API exists (Chromium), the real thing: the
|
||||
// worklet paints the box's background and a CSS animation on the
|
||||
// registered --redoal-t property drives repaints - no JS per frame.
|
||||
// - Elsewhere (Safari, Firefox, WebKitGTK have no Paint API), a
|
||||
// canvas with a requestAnimationFrame loop draws the identical
|
||||
// field, colors read from the page's custom properties.
|
||||
//
|
||||
// prefers-reduced-motion freezes the field fully drawn either way.
|
||||
|
||||
import { makePaths, drawPaths } from './paths.js';
|
||||
|
||||
const STYLE_ID = 'redoal-hero-style';
|
||||
|
||||
// One period is 400 units wide; the path is written two periods long
|
||||
// so translating by exactly one period loops seamlessly.
|
||||
const SWING =
|
||||
'M 0 200 Q 100 100 200 200 Q 300 300 400 200 ' +
|
||||
'Q 500 100 600 200 Q 700 300 800 200 ' +
|
||||
'Q 900 100 1000 200 Q 1100 300 1200 200';
|
||||
|
||||
// (y offset, stroke width, opacity, seconds per period, direction)
|
||||
const LAYERS = [
|
||||
[0, 6.5, 0.55, 9, 1],
|
||||
[7, 3, 0.45, 11, 1],
|
||||
[9, 1, 0.5, 13, 1],
|
||||
[15, 0.5, 0.6, 17, 1],
|
||||
[-60, 1.5, 0.18, 23, -1],
|
||||
[70, 1, 0.14, 29, -1],
|
||||
];
|
||||
const CYCLE_MS = 28000;
|
||||
const SEED = 7;
|
||||
const COUNT = 47;
|
||||
|
||||
const CSS = `
|
||||
.redoal-hero {
|
||||
.redoal-paths {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
--redoal-t: 0;
|
||||
--redoal-seed: ${SEED};
|
||||
--redoal-count: ${COUNT};
|
||||
}
|
||||
.redoal-hero svg {
|
||||
.redoal-paths.worklet {
|
||||
background: paint(redoal-paths);
|
||||
animation: redoal-t ${CYCLE_MS}ms linear infinite;
|
||||
}
|
||||
.redoal-paths canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.redoal-hero .swing {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-linecap: round;
|
||||
animation: redoal-swing var(--dur) linear infinite;
|
||||
animation-direction: var(--dir);
|
||||
}
|
||||
.redoal-hero .swing.far {
|
||||
stroke: var(--ink-dim);
|
||||
}
|
||||
@keyframes redoal-swing {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-400px); }
|
||||
@keyframes redoal-t {
|
||||
from { --redoal-t: 0; }
|
||||
to { --redoal-t: 1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.redoal-hero .swing { animation: none; }
|
||||
.redoal-paths.worklet { animation: none; --redoal-t: 0.999; }
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -72,63 +62,101 @@ function ensureStyle() {
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
class SineSwings {
|
||||
function hasPaintApi() {
|
||||
return typeof CSS !== 'undefined' && 'paintWorklet' in CSS && typeof CSS.registerProperty === 'function';
|
||||
}
|
||||
|
||||
class PathsHero {
|
||||
constructor(container) {
|
||||
ensureStyle();
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'redoal-hero';
|
||||
this.root.className = 'redoal-paths';
|
||||
this.root.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// viewBox 400 wide per period; preserveAspectRatio "slice" so
|
||||
// the band always fills the box and the loop is seamless at
|
||||
// any width - the SVG is 1200 wide but only translates by 400.
|
||||
const svg = document.createElementNS(NS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 800 400');
|
||||
svg.setAttribute('preserveAspectRatio', 'xMidYMid slice');
|
||||
|
||||
// The logo's clipped circle, centered - the near layers live
|
||||
// inside it; the far layers drift across the whole band.
|
||||
const defs = document.createElementNS(NS, 'defs');
|
||||
const clip = document.createElementNS(NS, 'clipPath');
|
||||
clip.id = 'redoal-hero-clip';
|
||||
const circle = document.createElementNS(NS, 'circle');
|
||||
circle.setAttribute('cx', '400');
|
||||
circle.setAttribute('cy', '200');
|
||||
circle.setAttribute('r', '150');
|
||||
clip.appendChild(circle);
|
||||
defs.appendChild(clip);
|
||||
svg.appendChild(defs);
|
||||
|
||||
const far = document.createElementNS(NS, 'g');
|
||||
const near = document.createElementNS(NS, 'g');
|
||||
near.setAttribute('clip-path', 'url(#redoal-hero-clip)');
|
||||
// The logo's tilt.
|
||||
near.setAttribute('transform', 'rotate(-13 400 200)');
|
||||
|
||||
for (const [dy, width, opacity, dur, dir] of LAYERS) {
|
||||
const path = document.createElementNS(NS, 'path');
|
||||
path.setAttribute('d', SWING);
|
||||
path.setAttribute('stroke-width', String(width));
|
||||
path.setAttribute('opacity', String(opacity));
|
||||
path.setAttribute('transform', `translate(-200 ${dy})`);
|
||||
const isFar = Math.abs(dy) > 30;
|
||||
path.setAttribute('class', isFar ? 'swing far' : 'swing');
|
||||
path.style.setProperty('--dur', `${dur}s`);
|
||||
path.style.setProperty('--dir', dir < 0 ? 'reverse' : 'normal');
|
||||
(isFar ? far : near).appendChild(path);
|
||||
}
|
||||
svg.append(far, near);
|
||||
this.root.appendChild(svg);
|
||||
container.appendChild(this.root);
|
||||
this.stopped = false;
|
||||
|
||||
if (hasPaintApi()) {
|
||||
this.startWorklet();
|
||||
} else {
|
||||
this.startCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
startWorklet() {
|
||||
// Registering makes --redoal-t a real <number> the animation
|
||||
// can interpolate; a second mount in the same document sees it
|
||||
// already registered, which throws and is fine.
|
||||
try {
|
||||
CSS.registerProperty({
|
||||
name: '--redoal-t',
|
||||
syntax: '<number>',
|
||||
inherits: false,
|
||||
initialValue: '0',
|
||||
});
|
||||
} catch {
|
||||
/* already registered */
|
||||
}
|
||||
const url = new URL('./paths-worklet.js', import.meta.url).href;
|
||||
CSS.paintWorklet
|
||||
.addModule(url)
|
||||
.then(() => {
|
||||
if (!this.stopped) this.root.classList.add('worklet');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!this.stopped) this.startCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
startCanvas() {
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.root.appendChild(this.canvas);
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.paths = makePaths(SEED, COUNT);
|
||||
this._onResize = () => this.size();
|
||||
window.addEventListener('resize', this._onResize);
|
||||
this.size();
|
||||
if (reducedMotion()) {
|
||||
this.frame(0.999);
|
||||
return;
|
||||
}
|
||||
this.start = performance.now();
|
||||
const loop = (now) => {
|
||||
if (this.stopped) return;
|
||||
this.frame(((now - this.start) % CYCLE_MS) / CYCLE_MS);
|
||||
this.raf = requestAnimationFrame(loop);
|
||||
};
|
||||
this.raf = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
size() {
|
||||
const rect = this.root.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.w = Math.max(1, rect.width);
|
||||
this.h = Math.max(1, rect.height);
|
||||
this.canvas.width = Math.round(this.w * dpr);
|
||||
this.canvas.height = Math.round(this.h * dpr);
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
this.frame(this.lastT || 0);
|
||||
}
|
||||
|
||||
frame(t) {
|
||||
this.lastT = t;
|
||||
const cs = getComputedStyle(this.root);
|
||||
const accent = cs.getPropertyValue('--accent').trim() || '#47807e';
|
||||
const dim = cs.getPropertyValue('--ink-dim').trim() || '#6d6c66';
|
||||
drawPaths(this.ctx, this.w, this.h, this.paths, t, [accent, dim]);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
if (this.raf) cancelAnimationFrame(this.raf);
|
||||
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
||||
this.root.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function mount(container) {
|
||||
return new SineSwings(container);
|
||||
return new PathsHero(container);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// CSS Paint Worklet entry for redoal.com's hero: `background:
|
||||
// paint(redoal-paths)` on the hero box, animated by the page changing
|
||||
// --redoal-t (a registered <number> custom property driven by a CSS
|
||||
// animation - every change repaints). Colors arrive as computed input
|
||||
// properties, so the worklet follows the theme without a palette copy.
|
||||
import { makePaths, drawPaths } from './paths.js';
|
||||
|
||||
registerPaint(
|
||||
'redoal-paths',
|
||||
class {
|
||||
static get inputProperties() {
|
||||
return ['--redoal-t', '--redoal-seed', '--redoal-count', '--accent', '--ink-dim'];
|
||||
}
|
||||
paint(ctx, size, props) {
|
||||
const num = (name, fallback) => {
|
||||
const v = parseFloat(props.get(name).toString());
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
};
|
||||
const seed = num('--redoal-seed', 7);
|
||||
const count = num('--redoal-count', 47);
|
||||
const t = num('--redoal-t', 0);
|
||||
if (!this.paths || this.seed !== seed || this.count !== count) {
|
||||
this.paths = makePaths(seed, count);
|
||||
this.seed = seed;
|
||||
this.count = count;
|
||||
}
|
||||
const accent = props.get('--accent').toString().trim() || '#47807e';
|
||||
const dim = props.get('--ink-dim').toString().trim() || '#6d6c66';
|
||||
drawPaths(ctx, size.width, size.height, this.paths, t % 1, [accent, dim]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function makePaths(seed, count, stops = 10) {
|
||||
const rand = mulberry32(seed);
|
||||
const paths = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
paths.push(turningFunctionToPoints(createTurningFunction(rand, stops)));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
// t in [0, 1) is the animation phase. Each path traces itself in over
|
||||
// one cycle, offset by its index so the field is always mid-draw, and
|
||||
// fades as it completes; the whole field drifts slowly and a soft
|
||||
// wave runs through every line. `colors` is [accent, dim] - the
|
||||
// palette comes from the page's own custom properties either way.
|
||||
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.rotate(t * Math.PI * 2 * 0.08);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
const n = paths.length;
|
||||
const wave = t * Math.PI * 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const pts = paths[i];
|
||||
const phase = (t + i / n) % 1;
|
||||
// ease: quick to appear, long to complete
|
||||
const progress = Math.min(1, phase * 1.35);
|
||||
const count = Math.max(2, Math.floor((pts.length / 2) * progress));
|
||||
const alpha = phase < 0.85 ? 0.75 : 0.75 * (1 - (phase - 0.85) / 0.15);
|
||||
ctx.strokeStyle = i % 3 === 0 ? colors[0] : colors[1];
|
||||
ctx.globalAlpha = alpha * (i % 3 === 0 ? 1 : 0.55);
|
||||
ctx.beginPath();
|
||||
for (let k = 0; k < count; k++) {
|
||||
const x = pts[k * 2], y = pts[k * 2 + 1];
|
||||
const wy = y + Math.sin(x * 7 + wave + i) * 0.008;
|
||||
if (k === 0) ctx.moveTo(x * s, wy * s);
|
||||
else ctx.lineTo(x * s, wy * s);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
Reference in New Issue
Block a user