diff --git a/src/lib/components/VerticalHelix.svelte b/src/lib/components/VerticalHelix.svelte index 0913632..cb72865 100644 --- a/src/lib/components/VerticalHelix.svelte +++ b/src/lib/components/VerticalHelix.svelte @@ -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(() => { 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" > + + +