feat: three.js atom hub island

This commit is contained in:
electron-rare
2026-06-10 21:56:27 +02:00
parent b2d89f4a8c
commit 87be757935
2 changed files with 195 additions and 0 deletions
+18
View File
@@ -34,3 +34,21 @@ const hubData = {
<Sommaire slug={course.slug} modules={modules} />
</main>
</Base>
<script>
const mount = document.getElementById('atom-mount');
const sommaire = document.getElementById('sommaire');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const webgl = (() => { try { const c = document.createElement('canvas');
return !!(c.getContext('webgl2') || c.getContext('webgl')); } catch { return false; } })();
if (mount && sommaire && !reduced && webgl && innerWidth >= 768) {
import('../../../scripts/atom-hub')
.then(({ mountAtomHub }) => {
mountAtomHub(mount, JSON.parse(mount.dataset.hub!));
mount.classList.remove('hidden');
sommaire.classList.add('md:max-w-prose', 'md:mx-auto', 'mt-12', 'opacity-90');
})
.catch((err) => console.error('[atom-hub] fallback to sommaire:', err));
}
</script>
+177
View File
@@ -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);
}