From 11c75caaad0b57ef7894b61fcc9c41f414983cf5 Mon Sep 17 00:00:00 2001 From: Linus Olofsson <61837116+LinusOlofsson-maker@users.noreply.github.com> Date: Thu, 21 May 2026 04:28:39 +0200 Subject: [PATCH] fix(logo-pulse): dynamic viewport-anchored paths (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pulse): split halves now meet simultaneously + no backtracking Two bugs fixed after Linus feedback: 1. Path-form: top/bottom split halves used Bezier curves that could loop back on themselves when entry.x lay outside box X-range. Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder. 2. Synchronized meeting: previously each blob moved at fixed velocity along its own path → shorter path arrived first and waited. Now both blobs share a phase2T (0→1) progress fraction, so they meet at exit-point simultaneously. The path with different length simply moves at a slightly different speed (averaged). Phase2 duration based on avg(topLen,bottomLen)/velocity so total animation time stays consistent. * feat: logo-pulse with pink->green color transition Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo. 20% chance per pulse-slot when logo is visible (footer scrolled in). Flow: 1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right) 2. Pulse glides in along purple thread to logo-left edge 3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds pink text-shadow halo on h1 4. Mid-passage: tint transitions to green (#4fd1c5) — matches default smoke + cyan glow 5. Pulse exits logo via wave1 (existing cyan thread on right) 6. Logo tint reverts to default (white smokeShift animation) CSS-side: data-logo-tint attribute on #text-wrapper drives stroke + text-shadow overrides with 0.6-0.8s transitions for smooth fade. * fix(logo-pulse): dynamic paths anchored to logo + 50% probability Previous version tried to use existing wave1+threadPurple paths for logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates that don't align with logo's viewport position when hero is scrolled into view. wave1 has translateX(2283) which pushes it off-screen for ultrawide viewports. Fix: build dynamic Bezier paths anchored to the logo's actual getBoundingClientRect() position at pulse-time: - Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left - Exit path: from logo-right curves up to (offRight, logoY-riseOffset) Paths are appended to box-trail-group, used for one pulse, then removed in cleanup. No reliance on fixed wave-thread coordinates. Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more often when hero is in view. --- src/components/WaveBackground.astro | 82 ++++++++++++++++------------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/src/components/WaveBackground.astro b/src/components/WaveBackground.astro index ea877b1..98f5b58 100644 --- a/src/components/WaveBackground.astro +++ b/src/components/WaveBackground.astro @@ -410,7 +410,7 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; } const SPLIT_PROBABILITY = 0.55; - const LOGO_PROBABILITY = 0.20; + const LOGO_PROBABILITY = 0.50; function isLogoTargetVisible(): HTMLElement | null { const el = document.querySelector('[data-logo-target]'); @@ -422,36 +422,54 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; } function runLogoPulse(durationMs: number) { - if (!blob1) return; + if (!blob1 || !boxGroup) return; const logo = isLogoTargetVisible(); - const purpleThread = document.getElementById('threadPurple') as SVGPathElement | null; - const greenThread = document.getElementById('wave1') as SVGPathElement | null; - if (!logo || !purpleThread || !greenThread) { + if (!logo) { schedule(); return; } isPulsing = true; - updatePulseColors(false); // start cyan-themed (basis) + updatePulseColors(false); const rect = logo.getBoundingClientRect(); + const vw = window.innerWidth; const logoLeftX = rect.left + 8; const logoRightX = rect.right - 8; const logoY = rect.top + rect.height / 2; - // PHASE 1: glide in på purple thread fram till logo-left - const purpleLen = purpleThread.getTotalLength(); - // PHASE 2: in i logon — color shift - // PHASE 3: ut på wave1 (grön) - const greenLen = greenThread.getTotalLength(); + // DYNAMIC PATHS — bygg paths som GARANTERAT passar logo-positionen. + // Pulsen kommer från vänster off-screen, lite under logon, kurvar UPP in i logo-left, + // glider genom logo, kurvar UT mot höger off-screen, lite över logon + const offLeft = -100; + const offRight = vw + 100; + const dipY = logoY + rect.height * 0.7; // entry kommer underifrån vänster + const riseY = logoY - rect.height * 0.7; // exit lämnar uppåt höger - const phase1Duration = durationMs * 0.35; // 35% glide in - const phase2Duration = durationMs * 0.30; // 30% inside logo - const phase3Duration = durationMs * 0.35; // 35% glide ut + const NS_local = 'http://www.w3.org/2000/svg'; + // Entry path (lila): från off-screen vänster (dipY) kurvar upp till logo-left + const entryPath = document.createElementNS(NS_local, 'path'); + entryPath.setAttribute('d', `M ${offLeft} ${dipY} C ${logoLeftX - 200} ${dipY}, ${logoLeftX - 80} ${logoY}, ${logoLeftX} ${logoY}`); + entryPath.setAttribute('fill', 'none'); + entryPath.setAttribute('stroke', 'transparent'); + boxGroup.appendChild(entryPath); + + // Exit path (grön): från logo-right kurvar upp till off-screen höger (riseY) + const exitPath = document.createElementNS(NS_local, 'path'); + exitPath.setAttribute('d', `M ${logoRightX} ${logoY} C ${logoRightX + 80} ${logoY}, ${logoRightX + 200} ${riseY}, ${offRight} ${riseY}`); + exitPath.setAttribute('fill', 'none'); + exitPath.setAttribute('stroke', 'transparent'); + boxGroup.appendChild(exitPath); + + const entryLen = entryPath.getTotalLength(); + const exitLen = exitPath.getTotalLength(); + + const phase1Duration = durationMs * 0.35; + const phase2Duration = durationMs * 0.30; + const phase3Duration = durationMs * 0.35; const totalTime = phase1Duration + phase2Duration + phase3Duration; const startTime = performance.now(); - // Förbered purple gradient på pulse const pinkColor = '#e879f9'; const greenColor = '#4fd1c5'; @@ -469,20 +487,6 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; setPulseTint(pinkColor); blob2 && blob2.setAttribute('opacity', '0'); - // Find on purpleThread where it crosses logoLeftX (approx) - // Sample to find length when pt.x ~ logoLeftX - let purpleEntryLen = purpleLen; - for (let l = 0; l <= purpleLen; l += 4) { - const pt = purpleThread.getPointAtLength(l); - if (pt.x >= logoLeftX) { purpleEntryLen = l; break; } - } - let greenStartLen = 0; - for (let l = 0; l <= greenLen; l += 4) { - const pt = greenThread.getPointAtLength(l); - if (pt.x >= logoRightX) { greenStartLen = l; break; } - } - const greenEndLen = Math.min(greenStartLen + (greenLen - greenStartLen) * 0.55, greenLen); - function step(now: number) { if (!blob1) return; const elapsed = now - startTime; @@ -490,11 +494,11 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; const fade = fadeFactor(globalT); if (elapsed <= phase1Duration) { - // Glide in på purple thread fram till logo-left + // Glide in på entry-path (lila) const t = elapsed / phase1Duration; - const len = t * purpleEntryLen; - const pt = purpleThread!.getPointAtLength(len); - const angle = tangentAngle(purpleThread!, len, purpleLen); + const len = t * entryLen; + const pt = entryPath.getPointAtLength(len); + const angle = tangentAngle(entryPath, len, entryLen); applyTransform(blob1!, pt.x, pt.y, angle); blob1!.setAttribute('opacity', String(fade)); } else if (elapsed <= phase1Duration + phase2Duration) { @@ -515,15 +519,14 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; setPulseTint(greenColor); } } else { - // Glide ut på wave1 (grön) + // Glide ut på exit-path (grön) const t = Math.min((elapsed - phase1Duration - phase2Duration) / phase3Duration, 1); - const len = greenStartLen + (greenEndLen - greenStartLen) * t; - const pt = greenThread!.getPointAtLength(len); - const angle = tangentAngle(greenThread!, len, greenLen); + const len = t * exitLen; + const pt = exitPath.getPointAtLength(len); + const angle = tangentAngle(exitPath, len, exitLen); applyTransform(blob1!, pt.x, pt.y, angle); blob1!.setAttribute('opacity', String(fade)); if (logo!.getAttribute('data-logo-tint')) { - // Fade tillbaka till basis logo!.removeAttribute('data-logo-tint'); } } @@ -533,6 +536,9 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; } else { blob1!.setAttribute('opacity', '0'); logo!.removeAttribute('data-logo-tint'); + // Cleanup dynamic paths + if (entryPath.parentNode) entryPath.parentNode.removeChild(entryPath); + if (exitPath.parentNode) exitPath.parentNode.removeChild(exitPath); isPulsing = false; } }