Files
imagesbyolofsson-se/src/components/WaveBackground.astro
T
Linus Olofsson 11c75caaad 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.
2026-05-21 04:28:39 +02:00

616 lines
29 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 = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
---
<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>
<linearGradient id="gradPurple" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#c026d3;stop-opacity:0" />
<stop offset="50%" style="stop-color:#c026d3;stop-opacity:0.45" />
<stop offset="100%" style="stop-color:#c026d3;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" />
<path id="threadPurple" d="M-1000 850 Q 400 600 900 850 T 2600 850" fill="none" stroke="url(#gradPurple)" stroke-width="0.7" class="opacity-55" data-pulse-path="true" data-purple-thread="true" />
</g>
<g id="box-trail-group"></g>
<g id="pulse-group">
<g id="pulse-blob-1" opacity="0">
<ellipse rx="55" ry="22" fill="url(#pulseHaloGradient)" filter="url(#pulseGlowSoft)" />
<ellipse rx="32" ry="11" fill="url(#pulseMidGradient)" filter="url(#pulseGlowMid)" />
<ellipse rx="10" ry="3" fill="url(#pulseCoreGradient)" filter="url(#pulseGlowSharp)" opacity="0.75" />
</g>
<g id="pulse-blob-2" opacity="0">
<ellipse rx="55" ry="22" fill="url(#pulseHaloGradient)" filter="url(#pulseGlowSoft)" />
<ellipse rx="32" ry="11" fill="url(#pulseMidGradient)" filter="url(#pulseGlowMid)" />
<ellipse rx="10" ry="3" fill="url(#pulseCoreGradient)" filter="url(#pulseGlowSharp)" opacity="0.75" />
</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 blob1 = document.getElementById('pulse-blob-1') as SVGGElement | null;
const blob2 = document.getElementById('pulse-blob-2') as SVGGElement | 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 });
type BoxRect = { left: number; right: number; top: number; bottom: number; cx: number; cy: number; r: number };
if (svg && blob1 && blob2 && 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 clearBoxGroup() {
if (!boxGroup) return;
while (boxGroup.firstChild) boxGroup.removeChild(boxGroup.firstChild);
}
function refreshBoxes(): BoxRect[] {
const targets = document.querySelectorAll<HTMLElement>('[data-trail-box]');
const out: BoxRect[] = [];
targets.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return;
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
out.push({
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
cx: rect.left + rect.width / 2,
cy: rect.top + rect.height / 2,
r: 16,
});
});
return out;
}
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`);
}
function pointInBox(x: number, y: number, b: BoxRect): boolean {
return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom;
}
type Intersection = {
boxIdx: number;
entryLen: number;
exitLen: number;
entryX: number;
entryY: number;
exitX: number;
exitY: number;
};
function findBoxIntersection(
path: SVGPathElement,
pathTx: number,
boxes: BoxRect[]
): Intersection | null {
const total = path.getTotalLength();
const step = 3;
let activeBox = -1;
let entryLen = -1;
let entryX = 0, entryY = 0;
for (let len = 0; len <= total; len += step) {
const pt = path.getPointAtLength(len);
const x = pt.x + pathTx;
const y = pt.y;
if (activeBox === -1) {
for (let bi = 0; bi < boxes.length; bi++) {
if (pointInBox(x, y, boxes[bi])) {
activeBox = bi;
entryLen = len;
entryX = x;
entryY = y;
break;
}
}
} else {
if (!pointInBox(x, y, boxes[activeBox])) {
return {
boxIdx: activeBox,
entryLen,
exitLen: len,
entryX,
entryY,
exitX: x,
exitY: y,
};
}
}
}
return null;
}
function buildSplitHalfPath(
box: BoxRect,
entry: { x: number; y: number },
exit: { x: number; y: number },
half: 'top' | 'bottom'
): SVGPathElement {
// Path goes: entry → mjuk kurva mot box-kant (top eller bottom) → traverserar horisontellt över box-kanten → mjuk kurva från box-kant till exit-punkt
// KRITISKT: pathen ska BARA röra sig framåt (entry.x → exit.x), aldrig vända tillbaka
const xL = box.left, xR = box.right, yT = box.top, yB = box.bottom, r = box.r;
// För att undvika "tillbaka-rörelse" när entry/exit ligger UTANFÖR boxens X-range:
// Använd entry.x och exit.x som start/end-punkter på den horisontella delen,
// clampad till box-kanten med hörn-radius
const startX = Math.max(xL + r, Math.min(xR - r, entry.x));
const endX = Math.max(xL + r, Math.min(xR - r, exit.x));
// Top/bottom Y-koordinat för traversal
const traverseY = half === 'top' ? yT : yB;
// Distans från entry/exit till traverseY (för smooth bezier-overgång)
const entryToTraverseY = Math.abs(entry.y - traverseY);
const exitToTraverseY = Math.abs(exit.y - traverseY);
// Kontrollpunkter för Bezier — låt dem peka i rätt riktning (top eller bottom)
// cp1: nära entry, dragen mot traverseY
const cp1x = entry.x;
const cp1y = entry.y + (half === 'top' ? -entryToTraverseY * 0.5 : entryToTraverseY * 0.5);
// cp2: vid startX, redan på traverseY-nivå
const cp2x = startX;
const cp2y = traverseY;
// cp3: vid endX, fortfarande på traverseY
const cp3x = endX;
const cp3y = traverseY;
// cp4: nära exit, dragen från traverseY
const cp4x = exit.x;
const cp4y = exit.y + (half === 'top' ? -exitToTraverseY * 0.5 : exitToTraverseY * 0.5);
const d = [
`M ${entry.x} ${entry.y}`,
`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${startX} ${traverseY}`,
`L ${endX} ${traverseY}`,
`C ${cp3x} ${cp3y}, ${cp4x} ${cp4y}, ${exit.x} ${exit.y}`,
].join(' ');
const p = document.createElementNS(NS, 'path');
p.setAttribute('d', d);
p.setAttribute('fill', 'none');
p.setAttribute('stroke', 'transparent');
return p;
}
function applyTransform(blob: SVGGElement, x: number, y: number, angle: number) {
blob.setAttribute('transform', `translate(${x} ${y}) rotate(${angle})`);
}
function tangentAngle(path: SVGPathElement, len: number, total: number): number {
const delta = Math.max(1, total * 0.002);
const before = path.getPointAtLength(Math.max(0, len - delta));
const after = path.getPointAtLength(Math.min(total, len + delta));
return Math.atan2(after.y - before.y, after.x - before.x) * (180 / Math.PI);
}
function fadeFactor(t: number): number {
if (t < 0.18) return t / 0.18;
if (t > 0.82) return (1 - t) / 0.18;
return 1;
}
let currentTimeout: number | null = null;
let isPulsing = false;
function runThreadPulse(path: SVGPathElement, durationMs: number) {
if (!blob1 || !blob2 || !boxGroup) return;
isPulsing = true;
clearBoxGroup();
const total = path.getTotalLength();
const isSecondary =
(path.getAttribute('stroke') || '').includes('gradSec') ||
path.id === 'thread1' ||
path.id === 'thread2';
updatePulseColors(isSecondary);
const speedKey = getSpeedKey(path.id);
const boxes = refreshBoxes();
const pathTx0 = speedKey ? window.scrollY * speeds[speedKey] : 0;
const hit = findBoxIntersection(path, pathTx0, boxes);
const velocity = total / durationMs;
const startTime = performance.now();
blob2.setAttribute('opacity', '0');
const entryLen = hit ? hit.entryLen : total;
const exitLen = hit ? hit.exitLen : total;
const phase1Duration = entryLen / velocity;
let topPath: SVGPathElement | null = null;
let bottomPath: SVGPathElement | null = null;
let phase2Duration = 0;
// För TIME-SYNCED meeting: båda blobs animeras med samma t (0→1), olika path-längder = olika faktisk hastighet
// Phase2-duration baseras på genomsnittlig path-längd så hastighets-skillnaden hålls liten
if (hit) {
const entryPt = { x: hit.entryX, y: hit.entryY };
const exitPt = { x: hit.exitX, y: hit.exitY };
topPath = buildSplitHalfPath(boxes[hit.boxIdx], entryPt, exitPt, 'top');
bottomPath = buildSplitHalfPath(boxes[hit.boxIdx], entryPt, exitPt, 'bottom');
boxGroup.appendChild(topPath);
boxGroup.appendChild(bottomPath);
const topLen = topPath.getTotalLength();
const bottomLen = bottomPath.getTotalLength();
// Genomsnittlig längd för fart-beräkning (så snabba/långsamma delas jämnt)
const avgLen = (topLen + bottomLen) / 2;
phase2Duration = avgLen / velocity;
}
const phase3Duration = hit ? (total - exitLen) / velocity : 0;
const totalTime = phase1Duration + phase2Duration + phase3Duration;
function step(now: number) {
if (!blob1 || !blob2) return;
const elapsed = now - startTime;
const globalT = Math.min(elapsed / totalTime, 1);
const fade = fadeFactor(globalT);
const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
if (elapsed <= phase1Duration) {
const len = elapsed * velocity;
const pt = path.getPointAtLength(Math.min(len, total));
const angle = tangentAngle(path, Math.min(len, total), total);
applyTransform(blob1, pt.x + pathTx, pt.y, angle);
blob1.setAttribute('opacity', String(fade));
blob2.setAttribute('opacity', '0');
} else if (hit && elapsed <= phase1Duration + phase2Duration) {
const inPhase2 = elapsed - phase1Duration;
if (topPath && bottomPath) {
const topLen = topPath.getTotalLength();
const bottomLen = bottomPath.getTotalLength();
// TIME-SYNCED: båda blobs har samma t (0→1) inom phase2 → möts garanterat samtidigt
// Hastighets-skillnad uppstår automatiskt om topLen ≠ bottomLen
const phase2T = Math.min(inPhase2 / phase2Duration, 1);
const tDist = phase2T * topLen;
const bDist = phase2T * bottomLen;
const tp = topPath.getPointAtLength(tDist);
const ta = tangentAngle(topPath, tDist, topLen);
applyTransform(blob1, tp.x, tp.y, ta);
blob1.setAttribute('opacity', String(fade));
const bp = bottomPath.getPointAtLength(bDist);
const ba = tangentAngle(bottomPath, bDist, bottomLen);
applyTransform(blob2, bp.x, bp.y, ba);
blob2.setAttribute('opacity', String(fade));
}
} else if (hit) {
const inPhase3 = elapsed - phase1Duration - phase2Duration;
const len = exitLen + inPhase3 * velocity;
const clamped = Math.min(len, total);
const pt = path.getPointAtLength(clamped);
const angle = tangentAngle(path, clamped, total);
applyTransform(blob1, pt.x + pathTx, pt.y, angle);
blob1.setAttribute('opacity', String(fade));
blob2.setAttribute('opacity', '0');
}
if (elapsed < totalTime && globalT < 1) {
requestAnimationFrame(step);
} else {
blob1.setAttribute('opacity', '0');
blob2.setAttribute('opacity', '0');
clearBoxGroup();
isPulsing = false;
}
}
requestAnimationFrame(step);
}
const SPLIT_PROBABILITY = 0.55;
const LOGO_PROBABILITY = 0.50;
function isLogoTargetVisible(): HTMLElement | null {
const el = document.querySelector<HTMLElement>('[data-logo-target]');
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
if (rect.width < 50 || rect.height < 50) return null;
return el;
}
function runLogoPulse(durationMs: number) {
if (!blob1 || !boxGroup) return;
const logo = isLogoTargetVisible();
if (!logo) {
schedule();
return;
}
isPulsing = true;
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;
// 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 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();
const pinkColor = '#e879f9';
const greenColor = '#4fd1c5';
function setPulseTint(color: string) {
const haloStops = document.querySelectorAll('#pulseHaloGradient stop');
if (haloStops[0]) haloStops[0].setAttribute('style', `stop-color:${color};stop-opacity:0.85`);
if (haloStops[1]) haloStops[1].setAttribute('style', `stop-color:${color};stop-opacity:0.35`);
if (haloStops[2]) haloStops[2].setAttribute('style', `stop-color:${color};stop-opacity:0`);
const midStops = document.querySelectorAll('#pulseMidGradient stop');
if (midStops[0]) midStops[0].setAttribute('style', `stop-color:${color};stop-opacity:1`);
if (midStops[1]) midStops[1].setAttribute('style', `stop-color:${color};stop-opacity:0.5`);
if (midStops[2]) midStops[2].setAttribute('style', `stop-color:${color};stop-opacity:0`);
}
setPulseTint(pinkColor);
blob2 && blob2.setAttribute('opacity', '0');
function step(now: number) {
if (!blob1) return;
const elapsed = now - startTime;
const globalT = Math.min(elapsed / totalTime, 1);
const fade = fadeFactor(globalT);
if (elapsed <= phase1Duration) {
// Glide in på entry-path (lila)
const t = elapsed / phase1Duration;
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) {
// INSIDE logo — pulse vid logo-center, ändra logo-färg
const t = (elapsed - phase1Duration) / phase2Duration;
// Pulse-position: glide från logoLeft till logoRight
const cx = logoLeftX + (logoRightX - logoLeftX) * t;
const cy = logoY;
applyTransform(blob1!, cx, cy, 0);
blob1!.setAttribute('opacity', String(fade * 0.6)); // dimmare i logo (logo-färg tar över visuellt)
// Färg-shift: rosa första halvan, grön andra halvan
if (t < 0.5) {
logo!.setAttribute('data-logo-tint', 'pink');
setPulseTint(pinkColor);
} else {
logo!.setAttribute('data-logo-tint', 'green');
setPulseTint(greenColor);
}
} else {
// Glide ut på exit-path (grön)
const t = Math.min((elapsed - phase1Duration - phase2Duration) / phase3Duration, 1);
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')) {
logo!.removeAttribute('data-logo-tint');
}
}
if (elapsed < totalTime && globalT < 1) {
requestAnimationFrame(step);
} 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;
}
}
requestAnimationFrame(step);
}
function pickAndPulse() {
if (isPulsing) {
schedule();
return;
}
if (wavePaths.length === 0) {
schedule();
return;
}
// 20% chans för logo-pulse OM logo är synlig
const wantLogo = Math.random() < LOGO_PROBABILITY;
if (wantLogo && isLogoTargetVisible()) {
runLogoPulse(14000 + Math.random() * 4000);
schedule();
return;
}
const boxes = refreshBoxes();
const intersecting: SVGPathElement[] = [];
const nonIntersecting: SVGPathElement[] = [];
for (const p of wavePaths) {
const speedKey = getSpeedKey(p.id);
const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
const hit = boxes.length > 0 ? findBoxIntersection(p, pathTx, boxes) : null;
if (hit) intersecting.push(p);
else nonIntersecting.push(p);
}
let choice: SVGPathElement;
const wantSplit = Math.random() < SPLIT_PROBABILITY;
if (wantSplit && intersecting.length > 0) {
choice = intersecting[Math.floor(Math.random() * intersecting.length)];
} else if (!wantSplit && nonIntersecting.length > 0) {
choice = nonIntersecting[Math.floor(Math.random() * nonIntersecting.length)];
} else if (intersecting.length > 0) {
choice = intersecting[Math.floor(Math.random() * intersecting.length)];
} else {
choice = nonIntersecting[Math.floor(Math.random() * nonIntersecting.length)];
}
runThreadPulse(choice, 18000 + Math.random() * 8000);
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-1, #pulse-blob-2 {
mix-blend-mode: screen;
}
#pulse-blob-1 ellipse, #pulse-blob-2 ellipse {
transform-box: fill-box;
transform-origin: center;
}
</style>