feat: three.js atom hub island
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user