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:
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user