import * as THREE from 'three'; const POOL = ['torusKnot','icosahedron','hyperboloid','mobius','globe','gyroscope','trefoil','saturn']; const COLORS = [0x00e5ff, 0x8f5bff, 0xff2bd6]; const MIN_VW = 1280; // viewport width below which we hide const FADE = 800; // ms — CSS opacity transition duration const cycleMs = () => 20000 + Math.random() * 10000; // 20–30 s // Calculate canvas size and edge offset so the object center sits exactly // halfway between the screen edge and the lightbox edge. function getSizing() { const vw = window.innerWidth; const modal = Math.min(940, 0.92 * vw); // mirrors #np-modal width rule const sideWidth = (vw - modal) / 2; // available space each side const size = Math.min(480, Math.floor(sideWidth * 1.4)); const offset = Math.max(8, Math.floor(sideWidth / 2 - size / 2)); return { size, offset }; } function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } // ── Geometry builders ──────────────────────────────────────────────────────── function buildObject(name, color) { const mat = new THREE.MeshBasicMaterial({ color, wireframe: true }); const lmat = () => new THREE.LineBasicMaterial({ color }); const g = new THREE.Group(); switch (name) { case 'torusKnot': g.add(new THREE.Mesh(new THREE.TorusKnotGeometry(0.8, 0.28, 120, 16), mat)); break; case 'trefoil': g.add(new THREE.Mesh(new THREE.TorusKnotGeometry(0.8, 0.28, 120, 16, 2, 3), mat)); break; case 'icosahedron': g.add(new THREE.Mesh(new THREE.IcosahedronGeometry(1, 1), mat)); break; case 'globe': g.add(new THREE.Mesh(new THREE.SphereGeometry(1, 18, 12), mat)); break; case 'hyperboloid': { const N = 24; const struts = [], topRing = [], botRing = []; for (let i = 0; i < N; i++) { const a0 = (i / N) * Math.PI * 2; const a1 = ((i + 0.5) / N) * Math.PI * 2; struts.push(Math.cos(a0), 1, Math.sin(a0), Math.cos(a1), -1, Math.sin(a1)); topRing.push(Math.cos(a0), 1, Math.sin(a0)); botRing.push(Math.cos(a1), -1, Math.sin(a1)); } // close rings topRing.push(topRing[0], topRing[1], topRing[2]); botRing.push(botRing[0], botRing[1], botRing[2]); const sg = new THREE.BufferGeometry(); sg.setAttribute('position', new THREE.Float32BufferAttribute(struts, 3)); g.add(new THREE.LineSegments(sg, lmat())); const tg = new THREE.BufferGeometry(); tg.setAttribute('position', new THREE.Float32BufferAttribute(topRing, 3)); g.add(new THREE.Line(tg, lmat())); const bg = new THREE.BufferGeometry(); bg.setAttribute('position', new THREE.Float32BufferAttribute(botRing, 3)); g.add(new THREE.Line(bg, lmat())); break; } case 'mobius': { const uSeg = 80, vSeg = 8; const verts = [], idx = []; for (let ui = 0; ui <= uSeg; ui++) { const u = (ui / uSeg) * Math.PI * 2; for (let vi = 0; vi <= vSeg; vi++) { const v = (vi / vSeg - 0.5) * 0.8; verts.push( (1 + v * Math.cos(u / 2)) * Math.cos(u), (1 + v * Math.cos(u / 2)) * Math.sin(u), v * Math.sin(u / 2) ); } } for (let ui = 0; ui < uSeg; ui++) { for (let vi = 0; vi < vSeg; vi++) { const a = ui * (vSeg + 1) + vi; const b = (ui + 1) * (vSeg + 1) + vi; const c = b + 1, d = a + 1; idx.push(a, b, c, a, c, d); } } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); geo.setIndex(idx); g.add(new THREE.Mesh(geo, mat)); break; } case 'gyroscope': { const ringGeo = new THREE.TorusGeometry(1, 0.03, 6, 64); const speeds = [ { rx: 0, ry: 0, rz: 0, ax: 0, ay: 0.009, az: 0 }, { rx: Math.PI / 2, ry: 0, rz: 0, ax: 0.006,ay: 0, az: 0 }, { rx: Math.PI / 4, ry: Math.PI / 4, rz: 0, ax: 0, ay: 0, az: 0.012 }, ]; speeds.forEach(s => { const ring = new THREE.Mesh(ringGeo, mat.clone()); ring.rotation.set(s.rx, s.ry, s.rz); ring.userData = { ax: s.ax, ay: s.ay, az: s.az }; g.add(ring); }); g.userData.isGyroscope = true; break; } case 'saturn': { g.add(new THREE.Mesh(new THREE.SphereGeometry(0.55, 16, 12), mat.clone())); const ring = new THREE.Mesh(new THREE.TorusGeometry(1.05, 0.04, 6, 64), mat.clone()); ring.rotation.x = Math.PI * 0.28; g.add(ring); break; } } g.scale.setScalar(1.1); return g; } // ── SideCanvas ─────────────────────────────────────────────────────────────── class SideCanvas { constructor(side) { this.side = side; this.alive = false; this.animId = null; this.timer = null; this.object = null; this.canvas = document.createElement('canvas'); this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100); this.camera.position.z = 4.2; // far enough that rotating objects don't clip this.renderer = null; // created in mount() once size is known } mount() { this.alive = true; const { size, offset } = getSizing(); this.canvas.width = size; this.canvas.height = size; this.canvas.className = 'oo-canvas'; // z-index managed by CSS sibling rule this.canvas.style.cssText = [ 'position:fixed', 'top:50%', 'transform:translateY(-50%)', this.side === 'left' ? `left:${offset}px` : `right:${offset}px`, `width:${size}px`, `height:${size}px`, 'pointer-events:none', 'opacity:0', `transition:opacity ${FADE}ms ease`, ].join(';'); this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, alpha: true, antialias: true }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setSize(size, size); this.renderer.setClearColor(0x000000, 0); document.body.appendChild(this.canvas); this._spawn(); this._tick(); } unmount() { this.alive = false; cancelAnimationFrame(this.animId); clearTimeout(this.timer); this.canvas.style.opacity = '0'; setTimeout(() => { this.canvas.remove(); this.renderer.dispose(); }, FADE); } _spawn() { if (!this.alive) return; if (this.object) { this.scene.remove(this.object); this.object = null; } this.object = buildObject(pick(POOL), pick(COLORS)); // Give each side a slightly different initial rotation so they don't mirror each other this.object.rotation.y = Math.random() * Math.PI * 2; this.scene.add(this.object); requestAnimationFrame(() => { this.canvas.style.opacity = '1'; }); this.timer = setTimeout(() => this._cycle(), cycleMs()); } _cycle() { if (!this.alive) return; this.canvas.style.opacity = '0'; setTimeout(() => { if (this.alive) this._spawn(); }, FADE); } _tick() { if (!this.alive) return; this.animId = requestAnimationFrame(() => this._tick()); if (this.object) { if (this.object.userData.isGyroscope) { this.object.children.forEach(ring => { ring.rotation.x += ring.userData.ax; ring.rotation.y += ring.userData.ay; ring.rotation.z += ring.userData.az; }); this.object.rotation.y += 0.002; } else { this.object.rotation.y += 0.005; this.object.rotation.x += 0.003; } } this.renderer.render(this.scene, this.camera); } } // ── Orchestration ──────────────────────────────────────────────────────────── let left = null, right = null; function isWide() { return window.innerWidth >= MIN_VW; } function open() { if (left || !isWide()) return; left = new SideCanvas('left'); right = new SideCanvas('right'); left.mount(); right.mount(); } function close() { if (!left) return; left.unmount(); right.unmount(); left = right = null; } function init() { const lb = document.getElementById('lb-overlay'); const np = document.getElementById('np-overlay'); if (!lb || !np) { requestAnimationFrame(init); return; } const check = () => { const anyOpen = lb.classList.contains('lb-open') || np.classList.contains('np-open'); if (anyOpen && !left) open(); if (!anyOpen && left) close(); }; const obs = new MutationObserver(check); obs.observe(lb, { attributes: true, attributeFilter: ['class'] }); obs.observe(np, { attributes: true, attributeFilter: ['class'] }); window.addEventListener('resize', () => { if (!isWide() && left) close(); }); } init();