feat(helix): 3D rotation with rAF + hover-to-pause

The static front/back occlusion needed real motion to read as 3D.
Now the helix actually rotates around its long axis — one full turn
every 24 s by default.

Mechanics
- A 2D projection of a 3D helix face-on is `x = R * sin(k*y + φ)`.
  Animating φ via requestAnimationFrame is exactly equivalent to
  spinning the helix around its vertical axis.
- All derived geometry (strand front/back splits, decorative rungs,
  per-project base-pair rungs, slot strandAx/Bx) now reads `phase`
  so they recompute every frame.
- buildStrandSplit's crossing snap also takes phase: y_c moves down
  the helix as it spins, so the seamless front/back joints track it.

Performance
- Path resampled at H/1.8 (~310 points / strand at 3 projects) and
  redrawn 60×/s on a modern device. Coords are .toFixed(3) to keep
  diff-friendly strings short.
- dt is clamped to 100 ms so tab-resumes don't jump.

Interaction
- A 280 px-wide transparent hover strip down the centre catches
  pointerenter/leave on the helix without stealing clicks from the
  cards. While hovered, `paused = true` freezes phase so users can
  read the labels comfortably.
- prefers-reduced-motion disables the rAF loop entirely — phase
  stays at 0, helix is a still 3D portrait.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-20 15:35:58 +02:00
parent a9f7434445
commit cfdccb1b17

View File

@@ -16,6 +16,7 @@
* Palette: Waterschap Brabantse Delta — #0d4f9e, #1fa0db, #bed137.
*/
import { onMount, onDestroy } from 'svelte';
import LinkChip from './LinkChip.svelte';
type ProjectLink = {
@@ -54,6 +55,37 @@
const PERIOD = 320;
const k = (2 * Math.PI) / PERIOD;
// Rotation: phase advances continuously, giving the strands the look of
// a 3D helix spinning around its long axis. One full rotation every
// SECONDS_PER_TURN seconds. requestAnimationFrame loop runs only when
// prefers-reduced-motion is "no-preference".
const SECONDS_PER_TURN = 24;
let phase = $state(0);
let raf = 0;
let lastT = 0;
let paused = $state(false);
function tick(t: number) {
if (lastT && !paused) {
const dt = Math.min(0.1, (t - lastT) / 1000); // clamp dt for tab-resume
phase = (phase + (2 * Math.PI * dt) / SECONDS_PER_TURN) % (2 * Math.PI);
}
lastT = t;
raf = requestAnimationFrame(tick);
}
onMount(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
if (!mq.matches) {
raf = requestAnimationFrame(tick);
}
});
onDestroy(() => {
if (raf) cancelAnimationFrame(raf);
});
type Rung = { y: number; x1: number; x2: number; depth: number; aFront: boolean };
type Slot = {
project: StrandProject;
@@ -73,16 +105,18 @@
/**
* Splits a strand into "front" or "back" segments for true 3D occlusion.
*
* A strand is in front of the other where its absolute distance from CX
* has the same sign as the convention: strand A (sign=+1) is in front when
* sin(ky) > 0; strand B (sign=-1) is in front when sin(ky) < 0. In both
* cases the strand is in front when sign * sin(ky) > 0.
* Strand is in front when sign * sin(k*y + phase) > 0. Segments are cut
* at crossing points (where sin(k*y + phase) = 0) so consecutive front
* and back runs meet seamlessly at (CX, y_crossing).
*
* Output is a single SVG path string with multiple `M…L…` runs separated
* at every crossing point so each run lives entirely on one side of the
* "in front" boundary.
* Crossings: k*y_c + phase = n*π → y_c = (n*π - phase) / k
*/
function buildStrandSplit(sign: 1 | -1, partition: 'front' | 'back', height: number): string {
function buildStrandSplit(
sign: 1 | -1,
partition: 'front' | 'back',
height: number,
ph: number
): string {
const steps = Math.max(240, Math.floor(height / 1.8));
const dy = height / steps;
const runs: string[] = [];
@@ -91,21 +125,19 @@
for (let i = 0; i <= steps; i++) {
const y = dy * i;
const s = Math.sin(k * y);
const s = Math.sin(k * y + ph);
const x = CX + sign * AMP * s;
const inFront = sign * s > 0;
const want = (partition === 'front') === inFront;
if (want) {
const cmd = inSeg ? 'L' : 'M';
buf.push(`${cmd}${x.toFixed(3)} ${y.toFixed(3)}`);
buf.push(`${inSeg ? 'L' : 'M'}${x.toFixed(3)} ${y.toFixed(3)}`);
inSeg = true;
} else if (inSeg) {
// Close out the run AT the crossing point so consecutive
// front/back segments meet seamlessly at (CX, y_crossing).
const lastY = parseFloat(buf[buf.length - 1].slice(1).split(' ')[1]);
// Solve sin(k*y_c) = 0 in (lastY, y] → y_c = round(y / (PERIOD/2)) * PERIOD/2
const yc = Math.round(((lastY + y) / 2) / (PERIOD / 2)) * (PERIOD / 2);
const midY = (lastY + y) / 2;
const n = Math.round((k * midY + ph) / Math.PI);
const yc = (n * Math.PI - ph) / k;
buf.push(`L${CX.toFixed(3)} ${yc.toFixed(3)}`);
runs.push(buf.join(' '));
buf = [];
@@ -117,16 +149,16 @@
return runs.join(' ');
}
const strandAFront = $derived(buildStrandSplit(1, 'front', H));
const strandABack = $derived(buildStrandSplit(1, 'back', H));
const strandBFront = $derived(buildStrandSplit(-1, 'front', H));
const strandBBack = $derived(buildStrandSplit(-1, 'back', H));
const strandAFront = $derived(buildStrandSplit(1, 'front', H, phase));
const strandABack = $derived(buildStrandSplit(1, 'back', H, phase));
const strandBFront = $derived(buildStrandSplit(-1, 'front', H, phase));
const strandBBack = $derived(buildStrandSplit(-1, 'back', H, phase));
const rungs = $derived.by<Rung[]>(() => {
const out: Rung[] = [];
const RUNG_SPACING = 22;
for (let y = 0; y <= H; y += RUNG_SPACING) {
const sA = Math.sin(k * y);
const sA = Math.sin(k * y + phase);
out.push({
y,
x1: CX + AMP * sA,
@@ -142,7 +174,7 @@
projects.map((p, i) => {
// Y is slot-based; nodes sit on the rung centre at this Y.
const y = TOP_PAD + i * SLOT_HEIGHT + SLOT_HEIGHT / 2;
const sA = Math.sin(k * y);
const sA = Math.sin(k * y + phase);
return {
project: p,
index: i,
@@ -164,6 +196,15 @@
style:--vh-h="{H}px"
aria-label="Projects bound to the R&D-lab strands"
>
<!-- Hover-to-pause hit area, transparent — sits behind cards but covers the
central helix column so users can pause to read the cards comfortably. -->
<div
class="vhelix-hover-zone"
role="presentation"
onpointerenter={() => (paused = true)}
onpointerleave={() => (paused = false)}
></div>
<svg
class="vhelix-svg"
viewBox="0 0 {W} {H}"
@@ -367,6 +408,18 @@
pointer-events: none;
}
/* Hover area is a narrow vertical strip down the centre — covers the
helix without stealing pointer events from the cards on either side. */
.vhelix-hover-zone {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 280px;
transform: translateX(-50%);
z-index: 0;
}
.vhelix-cards {
position: relative;
width: 100%;