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);
+}