From 7bad958a3879ccd87fd9d37613d75326764ed08d Mon Sep 17 00:00:00 2001 From: Linus Olofsson <61837116+LinusOlofsson-maker@users.noreply.github.com> Date: Wed, 20 May 2026 22:56:26 +0200 Subject: [PATCH] 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. --- src/components/WaveBackground.astro | 141 ++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 41 deletions(-) diff --git a/src/components/WaveBackground.astro b/src/components/WaveBackground.astro index d343e09..5a5a137 100644 --- a/src/components/WaveBackground.astro +++ b/src/components/WaveBackground.astro @@ -4,8 +4,9 @@ const { opacity = "opacity-30", theme = "cyan" } = Astro.props; const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5'; const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; -const pulseMain = theme === 'purple' ? '#e879f9' : '#67e8f9'; -const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc'; +const pulseMain = theme === 'purple' ? '#f0abfc' : '#a5f3fc'; +const pulseSec = theme === 'purple' ? '#fbcfe8' : '#cffafe'; +const pulseCore = '#ffffff'; ---
- + @@ -28,15 +29,33 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc'; - - + + + + + + + + - - - + + + + + + + + + + + + + + + @@ -51,7 +70,11 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc'; - + + + + +
@@ -65,12 +88,24 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc'; 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 blob = document.getElementById('pulse-blob') as SVGCircleElement | 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)`; @@ -81,15 +116,15 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc'; } window.addEventListener('scroll', onScroll, { passive: true }); - if (svg && blob && wrapper) { - const pulseMain = wrapper.getAttribute('data-pulse-main') || '#67e8f9'; - const pulseSec = wrapper.getAttribute('data-pulse-sec') || '#a5f3fc'; + 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('[data-pulse-path="true"]')); const NS = 'http://www.w3.org/2000/svg'; function buildBoxPath(rect: DOMRect): string { - const r = 14; + const r = 16; const x = rect.left; const y = rect.top; const w = rect.width; @@ -134,11 +169,26 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc'; 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 = 4500) { - if (!blob) return; + function runPulse(path: SVGPathElement, duration = 9000) { + if (!blobGroup || !halo || !mid || !core) return; isPulsing = true; const length = path.getTotalLength(); const startTime = performance.now(); @@ -146,42 +196,45 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc'; (path.getAttribute('stroke') || '').includes('gradSec') || path.id === 'thread1' || path.id === 'thread2'; - const color = isSecondary ? pulseSec : pulseMain; + updatePulseColors(isSecondary); - const grad = document.querySelector('#pulseGradient stop:first-child') as SVGStopElement | null; - const gradMid = document.querySelectorAll('#pulseGradient stop')[1] as SVGStopElement | null; - if (grad) grad.setAttribute('style', `stop-color:${color};stop-opacity:1`); - if (gradMid) gradMid.setAttribute('style', `stop-color:${color};stop-opacity:0.4`); + const speedKey = getSpeedKey(path.id); function step(now: number) { - if (!blob) return; + if (!blobGroup || !halo || !mid || !core) return; const elapsed = now - startTime; const t = Math.min(elapsed / duration, 1); - const pt = path.getPointAtLength(t * length); + const eased = t < 0.5 + ? 2 * t * t + : 1 - Math.pow(-2 * t + 2, 2) / 2; - let pathTx = 0; - const speedKey = - path.id === 'wave1' ? 'w1' - : path.id === 'wave2' ? 'w2' - : path.id === 'wave3' ? 'w3' - : path.id === 'thread1' ? 't1' - : path.id === 'thread2' ? 't2' - : null; - if (speedKey) pathTx = window.scrollY * speeds[speedKey as keyof Speeds]; + const pt = path.getPointAtLength(eased * length); - blob.setAttribute('cx', String(pt.x + pathTx)); - blob.setAttribute('cy', String(pt.y)); + 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.15) opacity = t / 0.15; - else if (t > 0.85) opacity = (1 - t) / 0.15; + if (t < 0.18) opacity = (t / 0.18); + else if (t > 0.82) opacity = ((1 - t) / 0.18); else opacity = 1; - blob.setAttribute('opacity', String(opacity * 0.9)); + opacity = Math.max(0, Math.min(1, opacity)); + + blobGroup.setAttribute('opacity', String(opacity)); if (t < 1) { requestAnimationFrame(step); } else { - blob.setAttribute('opacity', '0'); + blobGroup.setAttribute('opacity', '0'); isPulsing = false; } } @@ -201,13 +254,16 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc'; } const choice = allTargets[Math.floor(Math.random() * allTargets.length)]; const isBox = choice.getAttribute('data-trail-box-path') === 'true'; - runPulse(choice, isBox ? 3500 : 4500 + Math.random() * 2000); + const duration = isBox + ? 7000 + Math.random() * 2000 + : 9000 + Math.random() * 4000; + runPulse(choice, duration); schedule(); } function schedule() { if (currentTimeout) clearTimeout(currentTimeout); - const delay = 2500 + Math.random() * 4500; + const delay = 3500 + Math.random() * 5500; currentTimeout = window.setTimeout(pickAndPulse, delay); } @@ -222,7 +278,10 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc'; will-change: transform; } #pulse-blob { - transition: opacity 0.05s linear; mix-blend-mode: screen; } + #pulse-blob ellipse { + transform-box: fill-box; + transform-origin: center; + }