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.
|
* Palette: Waterschap Brabantse Delta — #0d4f9e, #1fa0db, #bed137.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import LinkChip from './LinkChip.svelte';
|
import LinkChip from './LinkChip.svelte';
|
||||||
|
|
||||||
type ProjectLink = {
|
type ProjectLink = {
|
||||||
@@ -54,6 +55,37 @@
|
|||||||
const PERIOD = 320;
|
const PERIOD = 320;
|
||||||
const k = (2 * Math.PI) / PERIOD;
|
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 Rung = { y: number; x1: number; x2: number; depth: number; aFront: boolean };
|
||||||
type Slot = {
|
type Slot = {
|
||||||
project: StrandProject;
|
project: StrandProject;
|
||||||
@@ -73,16 +105,18 @@
|
|||||||
/**
|
/**
|
||||||
* Splits a strand into "front" or "back" segments for true 3D occlusion.
|
* 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
|
* Strand is in front when sign * sin(k*y + phase) > 0. Segments are cut
|
||||||
* has the same sign as the convention: strand A (sign=+1) is in front when
|
* at crossing points (where sin(k*y + phase) = 0) so consecutive front
|
||||||
* sin(ky) > 0; strand B (sign=-1) is in front when sin(ky) < 0. In both
|
* and back runs meet seamlessly at (CX, y_crossing).
|
||||||
* cases the strand is in front when sign * sin(ky) > 0.
|
|
||||||
*
|
*
|
||||||
* Output is a single SVG path string with multiple `M…L…` runs separated
|
* Crossings: k*y_c + phase = n*π → y_c = (n*π - phase) / k
|
||||||
* at every crossing point so each run lives entirely on one side of the
|
|
||||||
* "in front" boundary.
|
|
||||||
*/
|
*/
|
||||||
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 steps = Math.max(240, Math.floor(height / 1.8));
|
||||||
const dy = height / steps;
|
const dy = height / steps;
|
||||||
const runs: string[] = [];
|
const runs: string[] = [];
|
||||||
@@ -91,21 +125,19 @@
|
|||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
for (let i = 0; i <= steps; i++) {
|
||||||
const y = dy * 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 x = CX + sign * AMP * s;
|
||||||
const inFront = sign * s > 0;
|
const inFront = sign * s > 0;
|
||||||
const want = (partition === 'front') === inFront;
|
const want = (partition === 'front') === inFront;
|
||||||
|
|
||||||
if (want) {
|
if (want) {
|
||||||
const cmd = inSeg ? 'L' : 'M';
|
buf.push(`${inSeg ? 'L' : 'M'}${x.toFixed(3)} ${y.toFixed(3)}`);
|
||||||
buf.push(`${cmd}${x.toFixed(3)} ${y.toFixed(3)}`);
|
|
||||||
inSeg = true;
|
inSeg = true;
|
||||||
} else if (inSeg) {
|
} 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]);
|
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 midY = (lastY + y) / 2;
|
||||||
const yc = Math.round(((lastY + y) / 2) / (PERIOD / 2)) * (PERIOD / 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)}`);
|
buf.push(`L${CX.toFixed(3)} ${yc.toFixed(3)}`);
|
||||||
runs.push(buf.join(' '));
|
runs.push(buf.join(' '));
|
||||||
buf = [];
|
buf = [];
|
||||||
@@ -117,16 +149,16 @@
|
|||||||
return runs.join(' ');
|
return runs.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
const strandAFront = $derived(buildStrandSplit(1, 'front', H));
|
const strandAFront = $derived(buildStrandSplit(1, 'front', H, phase));
|
||||||
const strandABack = $derived(buildStrandSplit(1, 'back', H));
|
const strandABack = $derived(buildStrandSplit(1, 'back', H, phase));
|
||||||
const strandBFront = $derived(buildStrandSplit(-1, 'front', H));
|
const strandBFront = $derived(buildStrandSplit(-1, 'front', H, phase));
|
||||||
const strandBBack = $derived(buildStrandSplit(-1, 'back', H));
|
const strandBBack = $derived(buildStrandSplit(-1, 'back', H, phase));
|
||||||
|
|
||||||
const rungs = $derived.by<Rung[]>(() => {
|
const rungs = $derived.by<Rung[]>(() => {
|
||||||
const out: Rung[] = [];
|
const out: Rung[] = [];
|
||||||
const RUNG_SPACING = 22;
|
const RUNG_SPACING = 22;
|
||||||
for (let y = 0; y <= H; y += RUNG_SPACING) {
|
for (let y = 0; y <= H; y += RUNG_SPACING) {
|
||||||
const sA = Math.sin(k * y);
|
const sA = Math.sin(k * y + phase);
|
||||||
out.push({
|
out.push({
|
||||||
y,
|
y,
|
||||||
x1: CX + AMP * sA,
|
x1: CX + AMP * sA,
|
||||||
@@ -142,7 +174,7 @@
|
|||||||
projects.map((p, i) => {
|
projects.map((p, i) => {
|
||||||
// Y is slot-based; nodes sit on the rung centre at this Y.
|
// Y is slot-based; nodes sit on the rung centre at this Y.
|
||||||
const y = TOP_PAD + i * SLOT_HEIGHT + SLOT_HEIGHT / 2;
|
const y = TOP_PAD + i * SLOT_HEIGHT + SLOT_HEIGHT / 2;
|
||||||
const sA = Math.sin(k * y);
|
const sA = Math.sin(k * y + phase);
|
||||||
return {
|
return {
|
||||||
project: p,
|
project: p,
|
||||||
index: i,
|
index: i,
|
||||||
@@ -164,6 +196,15 @@
|
|||||||
style:--vh-h="{H}px"
|
style:--vh-h="{H}px"
|
||||||
aria-label="Projects bound to the R&D-lab strands"
|
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
|
<svg
|
||||||
class="vhelix-svg"
|
class="vhelix-svg"
|
||||||
viewBox="0 0 {W} {H}"
|
viewBox="0 0 {W} {H}"
|
||||||
@@ -367,6 +408,18 @@
|
|||||||
pointer-events: none;
|
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 {
|
.vhelix-cards {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user