AkurAI Build
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();
  };
}