Menu
Public repository / 70c58ab48e67b41342965378857c463fb53f6ab7
AkurAI-Platform / frontend/spatial-scene.js
Latest change 7c2481f3029b7b2f8b01818af3d8e5fa55a3ea0b - OLI-105: versioned immutable three.js path, skip link, focus-visible, responsive grid by Dev Lead
const stage = document.getElementById("spatial-stage");
const canvas = stage?.querySelector(".spatial-canvas");
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const coarse = matchMedia("(pointer: coarse)").matches || innerWidth < 640;
const lowPower = coarse || (navigator.deviceMemory && navigator.deviceMemory <= 4) || (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4);
let started = false;
let teardown = null;
async function start() {
if (started || reducedMotion.matches) return;
started = true;
try {
const THREE = await import("/vendor/three.e2b5ee6bcc.min.js");
teardown = build(THREE);
stage.classList.add("is-spatial-ready");
} catch (err) {
started = false;
stage.classList.remove("is-spatial-ready");
console.warn("spatial scene unavailable, keeping poster", err);
}
}
function stop() {
teardown?.();
teardown = null;
started = false;
stage.classList.remove("is-spatial-ready");
}
if (stage && canvas) {
reducedMotion.addEventListener("change", () => (reducedMotion.matches ? stop() : arm()));
arm();
}
function arm() {
if (reducedMotion.matches) return;
const go = () => ("requestIdleCallback" in window ? requestIdleCallback(start, { timeout: 2500 }) : setTimeout(start, 400));
const io = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) {
io.disconnect();
go();
}
}, { rootMargin: "200px" });
io.observe(stage);
}
function build(THREE) {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
camera.position.set(0, 0, 7);
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: !lowPower, powerPreference: lowPower ? "low-power" : "default" });
let pixelRatio = Math.min(devicePixelRatio, lowPower ? 1 : 1.5);
renderer.setPixelRatio(pixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
const rig = new THREE.Group();
scene.add(rig);
const glass = new THREE.MeshPhysicalMaterial({
color: 0xb9e8ff,
metalness: 0.08,
roughness: 0.12,
...(lowPower ? {} : { transmission: 0.82, thickness: 0.55 }),
transparent: true,
opacity: lowPower ? 0.6 : 0.72,
iridescence: lowPower ? 0 : 0.35,
iridescenceIOR: 1.25,
clearcoat: 1,
clearcoatRoughness: 0.08,
});
const titanium = new THREE.MeshStandardMaterial({ color: 0xd9e2ec, metalness: 0.92, roughness: 0.2 });
const glow = new THREE.MeshStandardMaterial({ color: 0x7ddfff, emissive: 0x159ed4, emissiveIntensity: 5, metalness: 0.1, roughness: 0.15 });
const shell = new THREE.Mesh(new THREE.IcosahedronGeometry(1.48, 1), glass);
shell.scale.set(1.18, 1, 0.55);
rig.add(shell);
const core = new THREE.Mesh(new THREE.OctahedronGeometry(0.72, 0), glow);
core.rotation.set(Math.PI / 4, 0, Math.PI / 4);
rig.add(core);
for (const [radius, tube, rotation] of [
[1.83, 0.028, [1.1, 0.25, 0.3]],
[2.08, 0.022, [0.2, 1.15, -0.4]],
[2.32, 0.018, [0.6, -0.45, 1.15]],
]) {
const orbit = new THREE.Mesh(new THREE.TorusGeometry(radius, tube, 10, lowPower ? 48 : 120), titanium);
orbit.rotation.set(...rotation);
rig.add(orbit);
}
const nodes = [];
for (let index = 0; index < 9; index += 1) {
const node = new THREE.Mesh(new THREE.SphereGeometry(index === 0 ? 0.12 : 0.075, 18, 18), index === 0 ? glow : titanium);
const angle = (index / 9) * Math.PI * 2;
node.userData = { angle, speed: 0.22 + (index % 3) * 0.055, radius: 1.82 + (index % 3) * 0.24 };
rig.add(node);
nodes.push(node);
}
scene.add(new THREE.HemisphereLight(0xbcecff, 0x0a0d18, 2.2));
const key = new THREE.DirectionalLight(0xffffff, 6);
key.position.set(4, 5, 6);
scene.add(key);
const edge = new THREE.PointLight(0x755dff, 18, 14);
edge.position.set(-3, -2, 3);
scene.add(edge);
const pointer = new THREE.Vector2();
let scrollProgress = 0;
let frame = 0;
function resize() {
const { width, height } = stage.getBoundingClientRect();
renderer.setSize(width, height, false);
camera.aspect = width / Math.max(height, 1);
camera.updateProjectionMatrix();
}
function updateScroll() {
const rect = stage.getBoundingClientRect();
scrollProgress = THREE.MathUtils.clamp((innerHeight - rect.top) / (innerHeight + rect.height), 0, 1);
}
stage.addEventListener("pointermove", (event) => {
const rect = stage.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}, { passive: true });
stage.addEventListener("pointerleave", () => pointer.set(0, 0), { passive: true });
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(stage);
addEventListener("scroll", updateScroll, { passive: true });
const onContextLost = (event) => {
event.preventDefault();
cancelAnimationFrame(frame);
frame = 0;
stage.classList.remove("is-spatial-ready");
};
const onContextRestored = () => {
stage.classList.add("is-spatial-ready");
if (!document.hidden && !frame) render();
};
canvas.addEventListener("webglcontextlost", onContextLost);
canvas.addEventListener("webglcontextrestored", onContextRestored);
const onVisibility = () => {
if (document.hidden) {
cancelAnimationFrame(frame);
frame = 0;
} else if (!frame && visible) render();
};
document.addEventListener("visibilitychange", onVisibility);
const clock = new THREE.Clock();
let slow = 0;
let last = performance.now();
const frameBudget = lowPower ? 1000 / 30 : 0;
function render(now = performance.now()) {
frame = requestAnimationFrame(render);
const dt = now - last;
if (dt < frameBudget) return;
last = now;
if (dt > 24 && pixelRatio > 1) {
if (++slow > 20) {
pixelRatio = Math.max(1, pixelRatio - 0.25);
renderer.setPixelRatio(pixelRatio);
resize();
slow = 0;
}
} else slow = Math.max(0, slow - 1);
const time = clock.getElapsedTime();
rig.rotation.y += (pointer.x * 0.24 + scrollProgress * 1.15 - rig.rotation.y) * 0.035;
rig.rotation.x += (-pointer.y * 0.16 + scrollProgress * 0.28 - rig.rotation.x) * 0.035;
rig.position.y = Math.sin(time * 0.72) * 0.08;
shell.rotation.z = time * 0.055;
core.rotation.y = time * 0.42;
core.scale.setScalar(1 + Math.sin(time * 2.1) * 0.045);
nodes.forEach((node) => {
const angle = node.userData.angle + time * node.userData.speed + scrollProgress * Math.PI * 2;
node.position.set(Math.cos(angle) * node.userData.radius, Math.sin(angle * 1.3) * 0.8, Math.sin(angle) * node.userData.radius * 0.38);
});
stage.style.setProperty("--pointer-x", pointer.x.toFixed(3));
stage.style.setProperty("--pointer-y", pointer.y.toFixed(3));
stage.style.setProperty("--scroll-progress", scrollProgress.toFixed(3));
renderer.render(scene, camera);
}
let visible = false;
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (entry.isIntersecting && !frame) render();
if (!entry.isIntersecting && frame) {
cancelAnimationFrame(frame);
frame = 0;
}
});
resize();
updateScroll();
observer.observe(stage);
return () => {
cancelAnimationFrame(frame);
frame = 0;
observer.disconnect();
resizeObserver.disconnect();
removeEventListener("scroll", updateScroll);
document.removeEventListener("visibilitychange", onVisibility);
canvas.removeEventListener("webglcontextlost", onContextLost);
canvas.removeEventListener("webglcontextrestored", onContextRestored);
scene.traverse((o) => {
o.geometry?.dispose();
o.material?.dispose();
});
renderer.dispose();
};
}