From d6d062a3b28e84c6a99839c2e499779fa617e3b5 Mon Sep 17 00:00:00 2001 From: Linus Olofsson <61837116+LinusOlofsson-maker@users.noreply.github.com> Date: Wed, 20 May 2026 23:34:49 +0200 Subject: [PATCH] feat(pulse): 55% probability of split via box (#13) * feat: random light pulse along threads + box outlines A blurred light pulse glides along one of the 5 wave threads (wave1/2/3, thread1/2) every 2.5-7s. Color matches the active theme (cyan on home/portfolio/about, pink/magenta on contact). Pulse also follows the outline of designated boxes marked with data-trail-box: - Home: 'Looking for inspiration?' + 'Ready to collaborate?' - Contact: 'Waiting to hear from you' card Boxes are rebuilt on every pulse so they stay aligned during scroll and respect viewport (off-screen boxes are skipped). Pulse uses requestAnimationFrame + path.getPointAtLength for native smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend mode so it blends with the dark background. * feat(pulse): slower, brighter, comet-shaped pulse along paths Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape (soft halo + mid glow + bright core), rotated to path tangent, ease-in-out timing, mix-blend-mode screen. Fixes: pulse not tracking path was a parallax-offset coordinate mismatch. Cleaned up via getSpeedKey() helper. * fix(pulse): tone down white core so theme color dominates Core was pure white (#ffffff) which washed out the theme color. Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller (10x3 vs 14x4) with 75% opacity so the purple/pink and cyan glow layers come through more visibly. * feat(pulse): double the glide duration Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s). * feat(pulse): split into two halves around boxes When a thread-pulse hits a data-trail-box bound, it splits: one half glides along the top of the box outline, the other along the bottom. They re-merge on the far side and continue as one along the rest of the thread. Implementation: - Two pulse-blob groups (blob-1, blob-2) in SVG - Box-intersection scan samples path every 4px to find entry + exit points - Phase 1: blob-1 glides from start to entry - Phase 2: blob-1 follows top-half of box outline, blob-2 follows bottom-half (both spawned dynamically) - Phase 3: blob-1 continues from exit to end, blob-2 hides - Velocity kept constant across phases so split makes the animation longer rather than faster * fix(pulse): remove direct box-pulses + smooth split transition Two fixes after review: 1. Removed runBoxPulse() entirely. Pulses now spawn ONLY on the 5 wave threads. Box outlines are followed only via the split mechanism when a thread-pulse intersects a box. 2. Smooth split transition via Bezier curves: split-path now starts at the EXACT thread intersection point (entryX/Y) and ends at exact exit point, curving smoothly up/down to the box top/bottom edge. Previously the path jumped to box.cy which caused a visible teleport when entering the split phase. * feat(pulse): bias 55% of pulses to threads that hit a box Before each pulse, split wavePaths into intersecting (those that currently cross a data-trail-box) and non-intersecting. Roll a die: 55% of the time pick from intersecting, 45% from non-intersecting. Falls back to the other bucket if one is empty (e.g. no boxes visible on the page). --- src/components/WaveBackground.astro | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/components/WaveBackground.astro b/src/components/WaveBackground.astro index 2bb8051..457b5a9 100644 --- a/src/components/WaveBackground.astro +++ b/src/components/WaveBackground.astro @@ -395,6 +395,8 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; requestAnimationFrame(step); } + const SPLIT_PROBABILITY = 0.55; + function pickAndPulse() { if (isPulsing) { schedule(); @@ -404,7 +406,28 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa'; schedule(); return; } - const choice = wavePaths[Math.floor(Math.random() * wavePaths.length)]; + 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(); }