* 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.
616 lines
29 KiB
Plaintext
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>
|