fix(pulse): split halves now meet simultaneously + no backtracking (#15)

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.
This commit is contained in:
Linus Olofsson
2026-05-21 04:01:20 +02:00
committed by GitHub
parent ece7693ad5
commit 71ea7c7ce9
+44 -38
View File
@@ -229,46 +229,45 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
exit: { x: number; y: number }, exit: { x: number; y: number },
half: 'top' | 'bottom' half: 'top' | 'bottom'
): SVGPathElement { ): SVGPathElement {
// Goal: smooth path from entry-point → out to box-edge → around half → back from box-edge → exit-point. // 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
// We route via the actual box corners + halfway top or bottom of the box. // 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 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 // För att undvika "tillbaka-rörelse" när entry/exit ligger UTANFÖR boxens X-range:
// First waypoint on box-edge — closer to entry's y but rounded into the top/bottom corner // Använd entry.x och exit.x som start/end-punkter på den horisontella delen,
const targetTopY = yT; // clampad till box-kanten med hörn-radius
const targetBotY = yB; const startX = Math.max(xL + r, Math.min(xR - r, entry.x));
const apexY = half === 'top' ? targetTopY : targetBotY; const endX = Math.max(xL + r, Math.min(xR - r, exit.x));
// Two control points to bezier from entry into the corner curve // Top/bottom Y-koordinat för traversal
const transitionDist = 24; const traverseY = half === 'top' ? yT : yB;
const cp1x = entry.x + (half === 'top' ? -10 : -10);
const cp1y = entry.y + (half === 'top' ? -transitionDist : transitionDist);
const cp2x = xL + r;
const cp2y = apexY;
// After top/bottom traversal, exit via similar curve back to exit-point // Distans från entry/exit till traverseY (för smooth bezier-overgång)
const cp3x = xR - r; const entryToTraverseY = Math.abs(entry.y - traverseY);
const cp3y = apexY; const exitToTraverseY = Math.abs(exit.y - traverseY);
const cp4x = exit.x + (half === 'top' ? 10 : 10);
const cp4y = exit.y + (half === 'top' ? -transitionDist : transitionDist); // 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'); const p = document.createElementNS(NS, 'path');
p.setAttribute('d', d); p.setAttribute('d', d);
p.setAttribute('fill', 'none'); p.setAttribute('fill', 'none');
@@ -325,6 +324,8 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
let topPath: SVGPathElement | null = null; let topPath: SVGPathElement | null = null;
let bottomPath: SVGPathElement | null = null; let bottomPath: SVGPathElement | null = null;
let phase2Duration = 0; 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) { if (hit) {
const entryPt = { x: hit.entryX, y: hit.entryY }; const entryPt = { x: hit.entryX, y: hit.entryY };
const exitPt = { x: hit.exitX, y: hit.exitY }; const exitPt = { x: hit.exitX, y: hit.exitY };
@@ -334,7 +335,9 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
boxGroup.appendChild(bottomPath); boxGroup.appendChild(bottomPath);
const topLen = topPath.getTotalLength(); const topLen = topPath.getTotalLength();
const bottomLen = bottomPath.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; const phase3Duration = hit ? (total - exitLen) / velocity : 0;
@@ -359,8 +362,11 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
if (topPath && bottomPath) { if (topPath && bottomPath) {
const topLen = topPath.getTotalLength(); const topLen = topPath.getTotalLength();
const bottomLen = bottomPath.getTotalLength(); const bottomLen = bottomPath.getTotalLength();
const tDist = Math.min(inPhase2 * velocity, topLen); // TIME-SYNCED: båda blobs har samma t (0→1) inom phase2 → möts garanterat samtidigt
const bDist = Math.min(inPhase2 * velocity, bottomLen); // 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 tp = topPath.getPointAtLength(tDist);
const ta = tangentAngle(topPath, tDist, topLen); const ta = tangentAngle(topPath, tDist, topLen);