From 71ea7c7ce9205607ad9b3e5ceaae0efd288e69b2 Mon Sep 17 00:00:00 2001 From: Linus Olofsson <61837116+LinusOlofsson-maker@users.noreply.github.com> Date: Thu, 21 May 2026 04:01:20 +0200 Subject: [PATCH] fix(pulse): split halves now meet simultaneously + no backtracking (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- 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 457b5a9..899fd37 100644 --- a/src/components/WaveBackground.astro +++ b/src/components/WaveBackground.astro @@ -229,46 +229,45 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; exit: { x: number; y: number }, half: 'top' | 'bottom' ): SVGPathElement { - // Goal: smooth path from entry-point → out to box-edge → around half → back from box-edge → exit-point. - // We route via the actual box corners + halfway top or bottom of the box. + // 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; - const yMid = box.cy; - // Tangent-out from entry: smoothly leave the thread and head toward box-edge near top-or-bottom - // First waypoint on box-edge — closer to entry's y but rounded into the top/bottom corner - const targetTopY = yT; - const targetBotY = yB; - const apexY = half === 'top' ? targetTopY : targetBotY; + // 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)); - // Two control points to bezier from entry into the corner curve - const transitionDist = 24; - const cp1x = entry.x + (half === 'top' ? -10 : -10); - const cp1y = entry.y + (half === 'top' ? -transitionDist : transitionDist); - const cp2x = xL + r; - const cp2y = apexY; + // Top/bottom Y-koordinat för traversal + const traverseY = half === 'top' ? yT : yB; - // After top/bottom traversal, exit via similar curve back to exit-point - const cp3x = xR - r; - const cp3y = apexY; - const cp4x = exit.x + (half === 'top' ? 10 : 10); - const cp4y = exit.y + (half === 'top' ? -transitionDist : transitionDist); + // 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(' '); - let d: string; - if (half === 'top') { - d = [ - `M ${entry.x} ${entry.y}`, - `C ${cp1x} ${cp1y}, ${cp2x} ${cp2y - r}, ${xL + r} ${yT}`, - `H ${xR - r}`, - `C ${cp3x} ${cp3y - r}, ${cp4x} ${cp4y}, ${exit.x} ${exit.y}`, - ].join(' '); - } else { - d = [ - `M ${entry.x} ${entry.y}`, - `C ${cp1x} ${cp1y}, ${cp2x} ${cp2y + r}, ${xL + r} ${yB}`, - `H ${xR - r}`, - `C ${cp3x} ${cp3y + r}, ${cp4x} ${cp4y}, ${exit.x} ${exit.y}`, - ].join(' '); - } const p = document.createElementNS(NS, 'path'); p.setAttribute('d', d); p.setAttribute('fill', 'none'); @@ -325,6 +324,8 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; 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 }; @@ -334,7 +335,9 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; boxGroup.appendChild(bottomPath); const topLen = topPath.getTotalLength(); const bottomLen = bottomPath.getTotalLength(); - phase2Duration = Math.max(topLen, bottomLen) / velocity; + // 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; @@ -359,8 +362,11 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; if (topPath && bottomPath) { const topLen = topPath.getTotalLength(); const bottomLen = bottomPath.getTotalLength(); - const tDist = Math.min(inPhase2 * velocity, topLen); - const bDist = Math.min(inPhase2 * velocity, bottomLen); + // 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);