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).
This commit is contained in:
Linus Olofsson
2026-05-20 23:34:49 +02:00
committed by GitHub
parent a71883215b
commit d6d062a3b2
+24 -1
View File
@@ -395,6 +395,8 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
requestAnimationFrame(step); requestAnimationFrame(step);
} }
const SPLIT_PROBABILITY = 0.55;
function pickAndPulse() { function pickAndPulse() {
if (isPulsing) { if (isPulsing) {
schedule(); schedule();
@@ -404,7 +406,28 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
schedule(); schedule();
return; 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); runThreadPulse(choice, 18000 + Math.random() * 8000);
schedule(); schedule();
} }