135 lines
4.6 KiB
JavaScript
135 lines
4.6 KiB
JavaScript
// 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.
|
||
|
|
//
|
||
|
|
// 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.
|
||
|
|
|
||
|
|
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 CSS = `
|
||
|
|
.redoal-hero {
|
||
|
|
position: absolute;
|
||
|
|
inset: 0;
|
||
|
|
overflow: hidden;
|
||
|
|
pointer-events: none;
|
||
|
|
}
|
||
|
|
.redoal-hero svg {
|
||
|
|
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); }
|
||
|
|
}
|
||
|
|
@media (prefers-reduced-motion: reduce) {
|
||
|
|
.redoal-hero .swing { animation: none; }
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
|
||
|
|
function ensureStyle() {
|
||
|
|
if (document.getElementById(STYLE_ID)) return;
|
||
|
|
const style = document.createElement('style');
|
||
|
|
style.id = STYLE_ID;
|
||
|
|
style.textContent = CSS;
|
||
|
|
document.head.appendChild(style);
|
||
|
|
}
|
||
|
|
|
||
|
|
const NS = 'http://www.w3.org/2000/svg';
|
||
|
|
|
||
|
|
class SineSwings {
|
||
|
|
constructor(container) {
|
||
|
|
ensureStyle();
|
||
|
|
this.root = document.createElement('div');
|
||
|
|
this.root.className = 'redoal-hero';
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
stop() {
|
||
|
|
this.root.remove();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function mount(container) {
|
||
|
|
return new SineSwings(container);
|
||
|
|
}
|