fix(logo-pulse): dynamic viewport-anchored paths (#17)
* 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.
This commit is contained in:
@@ -410,7 +410,7 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SPLIT_PROBABILITY = 0.55;
|
const SPLIT_PROBABILITY = 0.55;
|
||||||
const LOGO_PROBABILITY = 0.20;
|
const LOGO_PROBABILITY = 0.50;
|
||||||
|
|
||||||
function isLogoTargetVisible(): HTMLElement | null {
|
function isLogoTargetVisible(): HTMLElement | null {
|
||||||
const el = document.querySelector<HTMLElement>('[data-logo-target]');
|
const el = document.querySelector<HTMLElement>('[data-logo-target]');
|
||||||
@@ -422,36 +422,54 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runLogoPulse(durationMs: number) {
|
function runLogoPulse(durationMs: number) {
|
||||||
if (!blob1) return;
|
if (!blob1 || !boxGroup) return;
|
||||||
const logo = isLogoTargetVisible();
|
const logo = isLogoTargetVisible();
|
||||||
const purpleThread = document.getElementById('threadPurple') as SVGPathElement | null;
|
if (!logo) {
|
||||||
const greenThread = document.getElementById('wave1') as SVGPathElement | null;
|
|
||||||
if (!logo || !purpleThread || !greenThread) {
|
|
||||||
schedule();
|
schedule();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isPulsing = true;
|
isPulsing = true;
|
||||||
updatePulseColors(false); // start cyan-themed (basis)
|
updatePulseColors(false);
|
||||||
|
|
||||||
const rect = logo.getBoundingClientRect();
|
const rect = logo.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
const logoLeftX = rect.left + 8;
|
const logoLeftX = rect.left + 8;
|
||||||
const logoRightX = rect.right - 8;
|
const logoRightX = rect.right - 8;
|
||||||
const logoY = rect.top + rect.height / 2;
|
const logoY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
// PHASE 1: glide in på purple thread fram till logo-left
|
// DYNAMIC PATHS — bygg paths som GARANTERAT passar logo-positionen.
|
||||||
const purpleLen = purpleThread.getTotalLength();
|
// Pulsen kommer från vänster off-screen, lite under logon, kurvar UPP in i logo-left,
|
||||||
// PHASE 2: in i logon — color shift
|
// glider genom logo, kurvar UT mot höger off-screen, lite över logon
|
||||||
// PHASE 3: ut på wave1 (grön)
|
const offLeft = -100;
|
||||||
const greenLen = greenThread.getTotalLength();
|
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 NS_local = 'http://www.w3.org/2000/svg';
|
||||||
const phase2Duration = durationMs * 0.30; // 30% inside logo
|
// Entry path (lila): från off-screen vänster (dipY) kurvar upp till logo-left
|
||||||
const phase3Duration = durationMs * 0.35; // 35% glide ut
|
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 totalTime = phase1Duration + phase2Duration + phase3Duration;
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
// Förbered purple gradient på pulse
|
|
||||||
const pinkColor = '#e879f9';
|
const pinkColor = '#e879f9';
|
||||||
const greenColor = '#4fd1c5';
|
const greenColor = '#4fd1c5';
|
||||||
|
|
||||||
@@ -469,20 +487,6 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
setPulseTint(pinkColor);
|
setPulseTint(pinkColor);
|
||||||
blob2 && blob2.setAttribute('opacity', '0');
|
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) {
|
function step(now: number) {
|
||||||
if (!blob1) return;
|
if (!blob1) return;
|
||||||
const elapsed = now - startTime;
|
const elapsed = now - startTime;
|
||||||
@@ -490,11 +494,11 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
const fade = fadeFactor(globalT);
|
const fade = fadeFactor(globalT);
|
||||||
|
|
||||||
if (elapsed <= phase1Duration) {
|
if (elapsed <= phase1Duration) {
|
||||||
// Glide in på purple thread fram till logo-left
|
// Glide in på entry-path (lila)
|
||||||
const t = elapsed / phase1Duration;
|
const t = elapsed / phase1Duration;
|
||||||
const len = t * purpleEntryLen;
|
const len = t * entryLen;
|
||||||
const pt = purpleThread!.getPointAtLength(len);
|
const pt = entryPath.getPointAtLength(len);
|
||||||
const angle = tangentAngle(purpleThread!, len, purpleLen);
|
const angle = tangentAngle(entryPath, len, entryLen);
|
||||||
applyTransform(blob1!, pt.x, pt.y, angle);
|
applyTransform(blob1!, pt.x, pt.y, angle);
|
||||||
blob1!.setAttribute('opacity', String(fade));
|
blob1!.setAttribute('opacity', String(fade));
|
||||||
} else if (elapsed <= phase1Duration + phase2Duration) {
|
} else if (elapsed <= phase1Duration + phase2Duration) {
|
||||||
@@ -515,15 +519,14 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
setPulseTint(greenColor);
|
setPulseTint(greenColor);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Glide ut på wave1 (grön)
|
// Glide ut på exit-path (grön)
|
||||||
const t = Math.min((elapsed - phase1Duration - phase2Duration) / phase3Duration, 1);
|
const t = Math.min((elapsed - phase1Duration - phase2Duration) / phase3Duration, 1);
|
||||||
const len = greenStartLen + (greenEndLen - greenStartLen) * t;
|
const len = t * exitLen;
|
||||||
const pt = greenThread!.getPointAtLength(len);
|
const pt = exitPath.getPointAtLength(len);
|
||||||
const angle = tangentAngle(greenThread!, len, greenLen);
|
const angle = tangentAngle(exitPath, len, exitLen);
|
||||||
applyTransform(blob1!, pt.x, pt.y, angle);
|
applyTransform(blob1!, pt.x, pt.y, angle);
|
||||||
blob1!.setAttribute('opacity', String(fade));
|
blob1!.setAttribute('opacity', String(fade));
|
||||||
if (logo!.getAttribute('data-logo-tint')) {
|
if (logo!.getAttribute('data-logo-tint')) {
|
||||||
// Fade tillbaka till basis
|
|
||||||
logo!.removeAttribute('data-logo-tint');
|
logo!.removeAttribute('data-logo-tint');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -533,6 +536,9 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
} else {
|
} else {
|
||||||
blob1!.setAttribute('opacity', '0');
|
blob1!.setAttribute('opacity', '0');
|
||||||
logo!.removeAttribute('data-logo-tint');
|
logo!.removeAttribute('data-logo-tint');
|
||||||
|
// Cleanup dynamic paths
|
||||||
|
if (entryPath.parentNode) entryPath.parentNode.removeChild(entryPath);
|
||||||
|
if (exitPath.parentNode) exitPath.parentNode.removeChild(exitPath);
|
||||||
isPulsing = false;
|
isPulsing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user