Files
imagesbyolofsson-se/src/components/WaveBackground.astro
T
Linus Olofsson 7bad958a38 feat(pulse): slower, brighter, comet-shaped pulse along paths
Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.
2026-05-20 22:56:35 +02:00

288 lines
13 KiB
Plaintext

---
// src/components/WaveBackground.astro
const { opacity = "opacity-30", theme = "cyan" } = Astro.props;
const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5';
const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee';
const pulseMain = theme === 'purple' ? '#f0abfc' : '#a5f3fc';
const pulseSec = theme === 'purple' ? '#fbcfe8' : '#cffafe';
const pulseCore = '#ffffff';
---
<div
class={`fixed inset-0 w-full h-full pointer-events-none -z-10 bg-neutral-900 ${opacity}`}
data-wave-theme={theme}
data-pulse-main={pulseMain}
data-pulse-sec={pulseSec}
>
<svg id="wave-svg" class="w-full h-full" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs>
<linearGradient id="gradMain" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style={`stop-color:${mainColor};stop-opacity:0`} />
<stop offset="50%" style={`stop-color:${mainColor};stop-opacity:0.5`} />
<stop offset="100%" style={`stop-color:${mainColor};stop-opacity:0`} />
</linearGradient>
<linearGradient id="gradSec" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style={`stop-color:${secondaryColor};stop-opacity:0`} />
<stop offset="30%" style={`stop-color:${secondaryColor};stop-opacity:0.3`} />
<stop offset="100%" style={`stop-color:${secondaryColor};stop-opacity:0`} />
</linearGradient>
<filter id="pulseGlowSoft" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="22" />
</filter>
<filter id="pulseGlowMid" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="9" />
</filter>
<filter id="pulseGlowSharp" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="2.5" />
</filter>
<radialGradient id="pulseHaloGradient">
<stop offset="0%" style={`stop-color:${pulseMain};stop-opacity:0.85`} />
<stop offset="40%" style={`stop-color:${pulseMain};stop-opacity:0.35`} />
<stop offset="100%" style={`stop-color:${pulseMain};stop-opacity:0`} />
</radialGradient>
<radialGradient id="pulseMidGradient">
<stop offset="0%" style={`stop-color:${pulseSec};stop-opacity:1`} />
<stop offset="60%" style={`stop-color:${pulseSec};stop-opacity:0.5`} />
<stop offset="100%" style={`stop-color:${pulseSec};stop-opacity:0`} />
</radialGradient>
<radialGradient id="pulseCoreGradient">
<stop offset="0%" style={`stop-color:${pulseCore};stop-opacity:1`} />
<stop offset="50%" style={`stop-color:${pulseCore};stop-opacity:0.6`} />
<stop offset="100%" style={`stop-color:${pulseCore};stop-opacity:0`} />
</radialGradient>
</defs>
<g id="wave-group">
<path id="wave1" d="M-1000 300 Q 250 100 800 300 T 2600 300" fill="none" stroke="url(#gradMain)" stroke-width="1" data-pulse-path="true" />
<path id="wave2" d="M-1000 500 Q 250 700 800 500 T 2600 500" fill="none" stroke="url(#gradMain)" stroke-width="1.5" data-pulse-path="true" />
<path id="wave3" d="M-1000 700 Q 250 500 800 700 T 2600 700" fill="none" stroke="url(#gradMain)" stroke-width="0.5" data-pulse-path="true" />
<path id="thread1" d="M-1000 200 Q 400 400 900 200 T 2600 200" fill="none" stroke="url(#gradSec)" stroke-width="0.5" class="opacity-50" data-pulse-path="true" />
<path id="thread2" d="M-1000 800 Q 600 600 1100 800 T 2600 800" fill="none" stroke="url(#gradSec)" stroke-width="0.8" class="opacity-60" data-pulse-path="true" />
</g>
<g id="box-trail-group"></g>
<g id="pulse-group">
<g id="pulse-blob" opacity="0">
<ellipse id="pulse-halo" rx="55" ry="22" fill="url(#pulseHaloGradient)" filter="url(#pulseGlowSoft)" />
<ellipse id="pulse-mid" rx="32" ry="11" fill="url(#pulseMidGradient)" filter="url(#pulseGlowMid)" />
<ellipse id="pulse-core" rx="14" ry="4" fill="url(#pulseCoreGradient)" filter="url(#pulseGlowSharp)" />
</g>
</g>
</svg>
</div>
<script>
type Speeds = { w1: number; w2: number; w3: number; t1: number; t2: number };
const wave1 = document.getElementById('wave1') as SVGPathElement | null;
const wave2 = document.getElementById('wave2') as SVGPathElement | null;
const wave3 = document.getElementById('wave3') as SVGPathElement | null;
const thread1 = document.getElementById('thread1') as SVGPathElement | null;
const thread2 = document.getElementById('thread2') as SVGPathElement | null;
const svg = document.getElementById('wave-svg') as unknown as SVGSVGElement | null;
const blobGroup = document.getElementById('pulse-blob') as SVGGElement | null;
const halo = document.getElementById('pulse-halo') as SVGEllipseElement | null;
const mid = document.getElementById('pulse-mid') as SVGEllipseElement | null;
const core = document.getElementById('pulse-core') as SVGEllipseElement | null;
const boxGroup = document.getElementById('box-trail-group') as SVGGElement | null;
const wrapper = svg?.parentElement;
const speeds: Speeds = { w1: 0.5, w2: -0.8, w3: 1.2, t1: 1.8, t2: -1.5 };
function getSpeedKey(id: string): keyof Speeds | null {
if (id === 'wave1') return 'w1';
if (id === 'wave2') return 'w2';
if (id === 'wave3') return 'w3';
if (id === 'thread1') return 't1';
if (id === 'thread2') return 't2';
return null;
}
function onScroll() {
const scrollY = window.scrollY;
if (wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`;
if (wave2) wave2.style.transform = `translateX(${scrollY * speeds.w2}px)`;
if (wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}px)`;
if (thread1) thread1.style.transform = `translateX(${scrollY * speeds.t1}px)`;
if (thread2) thread2.style.transform = `translateX(${scrollY * speeds.t2}px)`;
}
window.addEventListener('scroll', onScroll, { passive: true });
if (svg && blobGroup && halo && mid && core && wrapper) {
const pulseMain = wrapper.getAttribute('data-pulse-main') || '#a5f3fc';
const pulseSec = wrapper.getAttribute('data-pulse-sec') || '#cffafe';
const wavePaths = Array.from(document.querySelectorAll<SVGPathElement>('[data-pulse-path="true"]'));
const NS = 'http://www.w3.org/2000/svg';
function buildBoxPath(rect: DOMRect): string {
const r = 16;
const x = rect.left;
const y = rect.top;
const w = rect.width;
const h = rect.height;
return [
`M ${x + r} ${y}`,
`H ${x + w - r}`,
`A ${r} ${r} 0 0 1 ${x + w} ${y + r}`,
`V ${y + h - r}`,
`A ${r} ${r} 0 0 1 ${x + w - r} ${y + h}`,
`H ${x + r}`,
`A ${r} ${r} 0 0 1 ${x} ${y + h - r}`,
`V ${y + r}`,
`A ${r} ${r} 0 0 1 ${x + r} ${y}`,
`Z`,
].join(' ');
}
function clearBoxGroup() {
if (!boxGroup) return;
while (boxGroup.firstChild) boxGroup.removeChild(boxGroup.firstChild);
}
function refreshBoxPaths(): SVGPathElement[] {
if (!boxGroup) return [];
clearBoxGroup();
const targets = document.querySelectorAll<HTMLElement>('[data-trail-box]');
const created: SVGPathElement[] = [];
targets.forEach((el, idx) => {
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return;
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
const p = document.createElementNS(NS, 'path');
p.setAttribute('id', `trail-box-${idx}`);
p.setAttribute('d', buildBoxPath(rect));
p.setAttribute('fill', 'none');
p.setAttribute('stroke', 'transparent');
p.setAttribute('data-trail-box-path', 'true');
boxGroup.appendChild(p);
created.push(p);
});
return created;
}
function updatePulseColors(isSecondary: boolean) {
const haloColor = isSecondary ? pulseSec : pulseMain;
const midColor = isSecondary ? pulseMain : pulseSec;
const haloStops = document.querySelectorAll('#pulseHaloGradient stop');
if (haloStops[0]) haloStops[0].setAttribute('style', `stop-color:${haloColor};stop-opacity:0.85`);
if (haloStops[1]) haloStops[1].setAttribute('style', `stop-color:${haloColor};stop-opacity:0.35`);
if (haloStops[2]) haloStops[2].setAttribute('style', `stop-color:${haloColor};stop-opacity:0`);
const midStops = document.querySelectorAll('#pulseMidGradient stop');
if (midStops[0]) midStops[0].setAttribute('style', `stop-color:${midColor};stop-opacity:1`);
if (midStops[1]) midStops[1].setAttribute('style', `stop-color:${midColor};stop-opacity:0.5`);
if (midStops[2]) midStops[2].setAttribute('style', `stop-color:${midColor};stop-opacity:0`);
}
let currentTimeout: number | null = null;
let isPulsing = false;
function runPulse(path: SVGPathElement, duration = 9000) {
if (!blobGroup || !halo || !mid || !core) return;
isPulsing = true;
const length = path.getTotalLength();
const startTime = performance.now();
const isSecondary =
(path.getAttribute('stroke') || '').includes('gradSec') ||
path.id === 'thread1' ||
path.id === 'thread2';
updatePulseColors(isSecondary);
const speedKey = getSpeedKey(path.id);
function step(now: number) {
if (!blobGroup || !halo || !mid || !core) return;
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const eased = t < 0.5
? 2 * t * t
: 1 - Math.pow(-2 * t + 2, 2) / 2;
const pt = path.getPointAtLength(eased * length);
const sampleDelta = Math.max(1, length * 0.002);
const before = path.getPointAtLength(Math.max(0, eased * length - sampleDelta));
const after = path.getPointAtLength(Math.min(length, eased * length + sampleDelta));
const dx = after.x - before.x;
const dy = after.y - before.y;
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
const cx = pt.x + pathTx;
const cy = pt.y;
blobGroup.setAttribute('transform', `translate(${cx} ${cy}) rotate(${angle})`);
let opacity = 0;
if (t < 0.18) opacity = (t / 0.18);
else if (t > 0.82) opacity = ((1 - t) / 0.18);
else opacity = 1;
opacity = Math.max(0, Math.min(1, opacity));
blobGroup.setAttribute('opacity', String(opacity));
if (t < 1) {
requestAnimationFrame(step);
} else {
blobGroup.setAttribute('opacity', '0');
isPulsing = false;
}
}
requestAnimationFrame(step);
}
function pickAndPulse() {
if (isPulsing) {
schedule();
return;
}
const boxes = refreshBoxPaths();
const allTargets: SVGPathElement[] = [...wavePaths, ...boxes];
if (allTargets.length === 0) {
schedule();
return;
}
const choice = allTargets[Math.floor(Math.random() * allTargets.length)];
const isBox = choice.getAttribute('data-trail-box-path') === 'true';
const duration = isBox
? 7000 + Math.random() * 2000
: 9000 + Math.random() * 4000;
runPulse(choice, duration);
schedule();
}
function schedule() {
if (currentTimeout) clearTimeout(currentTimeout);
const delay = 3500 + Math.random() * 5500;
currentTimeout = window.setTimeout(pickAndPulse, delay);
}
const startDelay = 1500 + Math.random() * 2000;
currentTimeout = window.setTimeout(pickAndPulse, startDelay);
}
</script>
<style>
path {
transition: transform 0.1s linear;
will-change: transform;
}
#pulse-blob {
mix-blend-mode: screen;
}
#pulse-blob ellipse {
transform-box: fill-box;
transform-origin: center;
}
</style>