271 lines
9.0 KiB
JavaScript
271 lines
9.0 KiB
JavaScript
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();
|