From 87be75793548c2063b5236dbbee6dfd1745ffd65 Mon Sep 17 00:00:00 2001 From: electron-rare <108685187+electron-rare@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:56:27 +0200 Subject: [PATCH] feat: three.js atom hub island --- src/pages/cours/[slug]/index.astro | 18 +++ src/scripts/atom-hub.ts | 177 +++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/scripts/atom-hub.ts diff --git a/src/pages/cours/[slug]/index.astro b/src/pages/cours/[slug]/index.astro index 3cdaa66..bb0b04a 100644 --- a/src/pages/cours/[slug]/index.astro +++ b/src/pages/cours/[slug]/index.astro @@ -34,3 +34,21 @@ const hubData = { + + diff --git a/src/scripts/atom-hub.ts b/src/scripts/atom-hub.ts new file mode 100644 index 0000000..51da9f3 --- /dev/null +++ b/src/scripts/atom-hub.ts @@ -0,0 +1,177 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'; + +interface HubChapter { title: string; url: string; } +interface HubModule { title: string; chapters: HubChapter[]; } +interface HubData { slug: string; modules: HubModule[]; } + +const COPPER = 0xf97316; +const COPPER_SOFT = 0xfdba74; + +export function mountAtomHub(mount: HTMLElement, data: HubData): void { + const W = mount.clientWidth; + const H = Math.min(620, Math.max(420, window.innerHeight * 0.6)); + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(50, W / H, 0.1, 100); + camera.position.set(0, 2.2, 7.5); + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + renderer.setSize(W, H); + mount.appendChild(renderer.domElement); + + const labels = new CSS2DRenderer(); + labels.setSize(W, H); + labels.domElement.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;'; + mount.style.position = 'relative'; + mount.appendChild(labels.domElement); + + // Nucleus: glowing copper sphere + const nucleus = new THREE.Mesh( + new THREE.SphereGeometry(0.42, 32, 32), + new THREE.MeshBasicMaterial({ color: COPPER_SOFT }) + ); + scene.add(nucleus); + const glowTex = makeGlowTexture(); + const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: COPPER, transparent: true, opacity: 0.85, depthWrite: false })); + glow.scale.setScalar(2.6); + scene.add(glow); + + // Orbits: one tilted ellipse per module, electrons = chapters + const electronMeshes: THREE.Mesh[] = []; + const electronGeo = new THREE.SphereGeometry(0.09, 16, 16); + const electronMat = new THREE.MeshBasicMaterial({ color: COPPER_SOFT }); + + data.modules.forEach((mod, mi) => { + const group = new THREE.Group(); + group.rotation.x = 0.95 - mi * 0.42; + group.rotation.z = mi * (Math.PI / data.modules.length); + const rx = 1.9 + mi * 0.75; + const rz = rx * 0.62; + + const curve = new THREE.EllipseCurve(0, 0, rx, rz, 0, Math.PI * 2); + const pts = curve.getPoints(96).map((p) => new THREE.Vector3(p.x, 0, p.y)); + const orbit = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints(pts), + new THREE.LineBasicMaterial({ color: COPPER, transparent: true, opacity: 0.32 }) + ); + group.add(orbit); + + // Module label anchored on the orbit + const labelEl = document.createElement('div'); + labelEl.textContent = mod.title.replace(/^Module (\d+) ?: ?/, 'M$1 ยท '); + labelEl.style.cssText = 'color:#fdba74;font-size:12px;background:rgba(11,15,23,.7);border:1px solid rgba(249,115,22,.35);border-radius:99px;padding:2px 10px;white-space:nowrap;'; + const label = new CSS2DObject(labelEl); + label.position.set(rx, 0, 0); + group.add(label); + + mod.chapters.forEach((ch, ci) => { + const e = new THREE.Mesh(electronGeo, electronMat.clone()); + const angle = (ci / mod.chapters.length) * Math.PI * 2; + e.position.set(Math.cos(angle) * rx, 0, Math.sin(angle) * rz); + e.userData = { url: ch.url, title: ch.title, baseAngle: angle, rx, rz, speed: 0.05 + mi * 0.012 }; + const eGlow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: COPPER, transparent: true, opacity: 0.55, depthWrite: false })); + eGlow.scale.setScalar(0.5); + e.add(eGlow); + group.add(e); + electronMeshes.push(e); + }); + scene.add(group); + }); + + // Tooltip + const tip = document.createElement('div'); + tip.style.cssText = 'position:absolute;pointer-events:none;display:none;color:#fff;font-size:13px;background:rgba(11,15,23,.92);border:1px solid rgba(249,115,22,.5);border-radius:10px;padding:6px 12px;z-index:10;max-width:280px;'; + mount.appendChild(tip); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.enablePan = false; + controls.minDistance = 4; + controls.maxDistance = 12; + controls.autoRotate = true; + controls.autoRotateSpeed = 0.5; + + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(-2, -2); + let hovered: THREE.Mesh | null = null; + + renderer.domElement.addEventListener('pointermove', (ev) => { + const r = renderer.domElement.getBoundingClientRect(); + pointer.set(((ev.clientX - r.left) / r.width) * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1); + tip.style.left = `${ev.clientX - r.left + 14}px`; + tip.style.top = `${ev.clientY - r.top + 14}px`; + }); + renderer.domElement.addEventListener('click', () => { + if (hovered) location.href = (hovered.userData as { url: string }).url; + }); + + let raf = 0; + const clock = new THREE.Clock(); + function frame() { + raf = requestAnimationFrame(frame); + const t = clock.getElapsedTime(); + controls.update(); + nucleus.scale.setScalar(1 + Math.sin(t * 1.6) * 0.06); + for (const e of electronMeshes) { + const d = e.userData as { baseAngle: number; rx: number; rz: number; speed: number }; + const a = d.baseAngle + t * d.speed; + e.position.set(Math.cos(a) * d.rx, 0, Math.sin(a) * d.rz); + } + raycaster.setFromCamera(pointer, camera); + const hit = raycaster.intersectObjects(electronMeshes, false)[0]; + const target = (hit?.object as THREE.Mesh) ?? null; + if (target !== hovered) { + if (hovered) hovered.scale.setScalar(1); + hovered = target; + if (hovered) { + hovered.scale.setScalar(1.8); + tip.textContent = (hovered.userData as { title: string }).title; + tip.style.display = 'block'; + renderer.domElement.style.cursor = 'pointer'; + controls.autoRotate = false; + } else { + tip.style.display = 'none'; + renderer.domElement.style.cursor = 'grab'; + controls.autoRotate = true; + } + } + renderer.render(scene, camera); + labels.render(scene, camera); + } + + // Pause when hidden/offscreen + const io = new IntersectionObserver((es) => { + if (es[0].isIntersecting && !document.hidden) { if (!raf) frame(); } + else { cancelAnimationFrame(raf); raf = 0; } + }); + io.observe(mount); + document.addEventListener('visibilitychange', () => { + if (document.hidden) { cancelAnimationFrame(raf); raf = 0; } else if (!raf) frame(); + }); + + addEventListener('resize', () => { + const w = mount.clientWidth; + camera.aspect = w / H; + camera.updateProjectionMatrix(); + renderer.setSize(w, H); + labels.setSize(w, H); + }); + + frame(); +} + +function makeGlowTexture(): THREE.Texture { + const c = document.createElement('canvas'); + c.width = c.height = 64; + const g = c.getContext('2d')!; + const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32); + grad.addColorStop(0, 'rgba(253,186,116,1)'); + grad.addColorStop(0.35, 'rgba(249,115,22,0.45)'); + grad.addColorStop(1, 'rgba(249,115,22,0)'); + g.fillStyle = grad; + g.fillRect(0, 0, 64, 64); + return new THREE.CanvasTexture(c); +}