be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
'use strict';
|
||
|
||
/* ═══════════════════════════════════════════════
|
||
OrbitalsSim — 3D molecular orbitals (Three.js)
|
||
s, p, d orbitals + H₂ / H₂O molecular bonding
|
||
═══════════════════════════════════════════════ */
|
||
|
||
class OrbitalsSim {
|
||
constructor(container) {
|
||
this.container = container;
|
||
this._running = false;
|
||
|
||
/* Three.js */
|
||
this.scene = new THREE.Scene();
|
||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
|
||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
this.renderer.setClearColor(0x0D0D1A, 1);
|
||
container.appendChild(this.renderer.domElement);
|
||
|
||
/* lighting */
|
||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.45));
|
||
const dir = new THREE.DirectionalLight(0xffffff, 0.7);
|
||
dir.position.set(5, 8, 6);
|
||
this.scene.add(dir);
|
||
const pt = new THREE.PointLight(0x9B5DE5, 0.3, 50);
|
||
pt.position.set(-4, 3, 5);
|
||
this.scene.add(pt);
|
||
|
||
this.camera.position.set(6, 4, 6);
|
||
this.camera.lookAt(0, 0, 0);
|
||
|
||
/* orbit controls (manual) */
|
||
this._drag = false;
|
||
this._prevX = 0;
|
||
this._prevY = 0;
|
||
this._rotY = 0.6;
|
||
this._rotX = 0.3;
|
||
this._dist = 8;
|
||
this._autoSpin = true;
|
||
|
||
const el = this.renderer.domElement;
|
||
el.style.cursor = 'grab';
|
||
el.addEventListener('pointerdown', e => { this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; el.style.cursor = 'grabbing'; });
|
||
window.addEventListener('pointerup', () => { this._drag = false; el.style.cursor = 'grab'; });
|
||
window.addEventListener('pointermove', e => {
|
||
if (!this._drag) return;
|
||
this._rotY += (e.clientX - this._prevX) * 0.008;
|
||
this._rotX += (e.clientY - this._prevY) * 0.008;
|
||
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
|
||
this._prevX = e.clientX; this._prevY = e.clientY;
|
||
});
|
||
el.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
this._dist = Math.max(3, Math.min(20, this._dist + e.deltaY * 0.02));
|
||
}, { passive: false });
|
||
|
||
/* touch */
|
||
el.addEventListener('touchstart', e => {
|
||
if (e.touches.length === 1) {
|
||
this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false;
|
||
}
|
||
}, { passive: true });
|
||
el.addEventListener('touchmove', e => {
|
||
if (!this._drag || e.touches.length !== 1) return;
|
||
const t = e.touches[0];
|
||
this._rotY += (t.clientX - this._prevX) * 0.008;
|
||
this._rotX += (t.clientY - this._prevY) * 0.008;
|
||
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
|
||
this._prevX = t.clientX; this._prevY = t.clientY;
|
||
}, { passive: true });
|
||
el.addEventListener('touchend', () => { this._drag = false; });
|
||
|
||
/* resize */
|
||
this._ro = new ResizeObserver(() => this.fit());
|
||
this._ro.observe(container);
|
||
|
||
/* state */
|
||
this._mode = 's';
|
||
this._group = new THREE.Group();
|
||
this.scene.add(this._group);
|
||
|
||
this._buildOrbital('s');
|
||
this.fit();
|
||
this.play();
|
||
}
|
||
|
||
/* ── public ── */
|
||
setMode(mode) {
|
||
this._mode = mode;
|
||
this._buildOrbital(mode);
|
||
}
|
||
|
||
fit() {
|
||
const w = this.container.clientWidth || 600;
|
||
const h = this.container.clientHeight || 400;
|
||
this.camera.aspect = w / h;
|
||
this.camera.updateProjectionMatrix();
|
||
this.renderer.setSize(w, h);
|
||
}
|
||
|
||
play() { if (!this._running) { this._running = true; this._loop(); } }
|
||
stop() { this._running = false; }
|
||
pause() { this._running = false; }
|
||
|
||
/* ── clear scene group ── */
|
||
_clear() {
|
||
while (this._group.children.length) {
|
||
const c = this._group.children[0];
|
||
if (c.geometry) c.geometry.dispose();
|
||
if (c.material) {
|
||
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
|
||
else c.material.dispose();
|
||
}
|
||
this._group.remove(c);
|
||
}
|
||
}
|
||
|
||
/* ── orbital builders ── */
|
||
_buildOrbital(mode) {
|
||
this._clear();
|
||
const b = {
|
||
s: () => this._buildS(),
|
||
p: () => this._buildP(),
|
||
d: () => this._buildD(),
|
||
h2: () => this._buildH2(),
|
||
h2o: () => this._buildH2O(),
|
||
};
|
||
(b[mode] || b.s)();
|
||
}
|
||
|
||
/* nucleus dot */
|
||
_nucleus(pos, color = 0xffffff) {
|
||
const geo = new THREE.SphereGeometry(0.12, 16, 16);
|
||
const mat = new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.5 });
|
||
const m = new THREE.Mesh(geo, mat);
|
||
m.position.copy(pos);
|
||
this._group.add(m);
|
||
return m;
|
||
}
|
||
|
||
/* cloud lobe (ellipsoid) */
|
||
_lobe(pos, scale, color, opacity = 0.3) {
|
||
const geo = new THREE.SphereGeometry(1, 32, 32);
|
||
const mat = new THREE.MeshPhysicalMaterial({
|
||
color, transparent: true, opacity,
|
||
metalness: 0, roughness: 0.6,
|
||
clearcoat: 0.3, side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.position.copy(pos);
|
||
mesh.scale.set(scale.x, scale.y, scale.z);
|
||
this._group.add(mesh);
|
||
return mesh;
|
||
}
|
||
|
||
/* particle cloud (points) */
|
||
_cloud(center, radius, count, color) {
|
||
const positions = new Float32Array(count * 3);
|
||
for (let i = 0; i < count; i++) {
|
||
// gaussian distribution
|
||
const r = radius * Math.pow(Math.random(), 0.33);
|
||
const theta = Math.random() * Math.PI * 2;
|
||
const phi = Math.acos(2 * Math.random() - 1);
|
||
positions[i * 3] = center.x + r * Math.sin(phi) * Math.cos(theta);
|
||
positions[i * 3 + 1] = center.y + r * Math.sin(phi) * Math.sin(theta);
|
||
positions[i * 3 + 2] = center.z + r * Math.cos(phi);
|
||
}
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
const mat = new THREE.PointsMaterial({ color, size: 0.04, transparent: true, opacity: 0.6, depthWrite: false });
|
||
const pts = new THREE.Points(geo, mat);
|
||
this._group.add(pts);
|
||
return pts;
|
||
}
|
||
|
||
/* ── s orbital: spherical ── */
|
||
_buildS() {
|
||
this._nucleus(new THREE.Vector3(0, 0, 0));
|
||
this._lobe(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.5, 1.5, 1.5), 0x9B5DE5, 0.18);
|
||
this._cloud(new THREE.Vector3(0, 0, 0), 1.5, 2000, 0x9B5DE5);
|
||
}
|
||
|
||
/* ── p orbitals: 3 dumbbell shapes ── */
|
||
_buildP() {
|
||
this._nucleus(new THREE.Vector3(0, 0, 0));
|
||
|
||
// px (red)
|
||
this._lobe(new THREE.Vector3(1.2, 0, 0), new THREE.Vector3(0.7, 0.5, 0.5), 0xF15BB5, 0.25);
|
||
this._lobe(new THREE.Vector3(-1.2, 0, 0), new THREE.Vector3(0.7, 0.5, 0.5), 0xF15BB5, 0.25);
|
||
|
||
// py (green)
|
||
this._lobe(new THREE.Vector3(0, 1.2, 0), new THREE.Vector3(0.5, 0.7, 0.5), 0x34d399, 0.25);
|
||
this._lobe(new THREE.Vector3(0, -1.2, 0), new THREE.Vector3(0.5, 0.7, 0.5), 0x34d399, 0.25);
|
||
|
||
// pz (blue)
|
||
this._lobe(new THREE.Vector3(0, 0, 1.2), new THREE.Vector3(0.5, 0.5, 0.7), 0x60a5fa, 0.25);
|
||
this._lobe(new THREE.Vector3(0, 0, -1.2), new THREE.Vector3(0.5, 0.5, 0.7), 0x60a5fa, 0.25);
|
||
|
||
// axis labels
|
||
this._addLabel('px', 2, 0, 0, 0xF15BB5);
|
||
this._addLabel('py', 0, 2, 0, 0x34d399);
|
||
this._addLabel('pz', 0, 0, 2, 0x60a5fa);
|
||
}
|
||
|
||
/* ── d orbital: dxy cloverleaf ── */
|
||
_buildD() {
|
||
this._nucleus(new THREE.Vector3(0, 0, 0));
|
||
|
||
// four lobes in xy plane
|
||
const r = 1.1, s = 0.45;
|
||
const angles = [Math.PI / 4, 3 * Math.PI / 4, 5 * Math.PI / 4, 7 * Math.PI / 4];
|
||
const colors = [0xF59E0B, 0x9B5DE5, 0xF59E0B, 0x9B5DE5]; // alternating sign
|
||
|
||
for (let i = 0; i < 4; i++) {
|
||
const x = r * Math.cos(angles[i]);
|
||
const y = r * Math.sin(angles[i]);
|
||
this._lobe(new THREE.Vector3(x, y, 0), new THREE.Vector3(s, s, s * 0.6), colors[i], 0.28);
|
||
}
|
||
|
||
// dz² torus + lobes
|
||
this._lobe(new THREE.Vector3(0, 0, 1.3), new THREE.Vector3(0.35, 0.35, 0.6), 0x06D6E0, 0.2);
|
||
this._lobe(new THREE.Vector3(0, 0, -1.3), new THREE.Vector3(0.35, 0.35, 0.6), 0x06D6E0, 0.2);
|
||
// torus ring
|
||
const tGeo = new THREE.TorusGeometry(0.7, 0.18, 16, 32);
|
||
const tMat = new THREE.MeshPhysicalMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.15, side: THREE.DoubleSide, depthWrite: false });
|
||
const torus = new THREE.Mesh(tGeo, tMat);
|
||
this._group.add(torus);
|
||
|
||
this._addLabel('d_{xy}', 1.8, 1.8, 0, 0xF59E0B);
|
||
this._addLabel('d_{z²}', 0, 0, 2.2, 0x06D6E0);
|
||
}
|
||
|
||
/* ── H₂ sigma bond ── */
|
||
_buildH2() {
|
||
const sep = 1.5;
|
||
this._nucleus(new THREE.Vector3(-sep / 2, 0, 0), 0xffffff);
|
||
this._nucleus(new THREE.Vector3(sep / 2, 0, 0), 0xffffff);
|
||
|
||
// individual 1s orbitals (faded)
|
||
this._lobe(new THREE.Vector3(-sep / 2, 0, 0), new THREE.Vector3(0.8, 0.8, 0.8), 0x9B5DE5, 0.1);
|
||
this._lobe(new THREE.Vector3(sep / 2, 0, 0), new THREE.Vector3(0.8, 0.8, 0.8), 0x9B5DE5, 0.1);
|
||
|
||
// bonding σ (overlap region — elongated ellipsoid)
|
||
this._lobe(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.4, 0.6, 0.6), 0x06D6E0, 0.22);
|
||
this._cloud(new THREE.Vector3(0, 0, 0), 1.0, 3000, 0x06D6E0);
|
||
|
||
// bond line
|
||
const bGeo = new THREE.CylinderGeometry(0.03, 0.03, sep, 8);
|
||
const bMat = new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.3 });
|
||
const bond = new THREE.Mesh(bGeo, bMat);
|
||
bond.rotation.z = Math.PI / 2;
|
||
this._group.add(bond);
|
||
|
||
this._addLabel('H', -sep / 2 - 0.5, 0.6, 0, 0xffffff);
|
||
this._addLabel('H', sep / 2 + 0.3, 0.6, 0, 0xffffff);
|
||
this._addLabel('σ', 0, -0.9, 0, 0x06D6E0);
|
||
}
|
||
|
||
/* ── H₂O bent molecule ── */
|
||
_buildH2O() {
|
||
const angle = 104.5 * Math.PI / 180;
|
||
const bondLen = 1.6;
|
||
|
||
const oPos = new THREE.Vector3(0, 0, 0);
|
||
const h1 = new THREE.Vector3(-bondLen * Math.sin(angle / 2), -bondLen * Math.cos(angle / 2), 0);
|
||
const h2 = new THREE.Vector3(bondLen * Math.sin(angle / 2), -bondLen * Math.cos(angle / 2), 0);
|
||
|
||
// nuclei
|
||
const oNuc = this._nucleus(oPos, 0xF15BB5);
|
||
oNuc.scale.set(1.5, 1.5, 1.5);
|
||
this._nucleus(h1, 0xffffff);
|
||
this._nucleus(h2, 0xffffff);
|
||
|
||
// O lone pairs (above)
|
||
this._lobe(new THREE.Vector3(-0.5, 0.8, 0), new THREE.Vector3(0.4, 0.5, 0.35), 0xF59E0B, 0.2);
|
||
this._lobe(new THREE.Vector3(0.5, 0.8, 0), new THREE.Vector3(0.4, 0.5, 0.35), 0xF59E0B, 0.2);
|
||
|
||
// O-H σ bonds (electron density)
|
||
const mid1 = new THREE.Vector3().addVectors(oPos, h1).multiplyScalar(0.5);
|
||
const mid2 = new THREE.Vector3().addVectors(oPos, h2).multiplyScalar(0.5);
|
||
this._lobe(mid1, new THREE.Vector3(0.4, 0.7, 0.35), 0x06D6E0, 0.2);
|
||
this._lobe(mid2, new THREE.Vector3(0.4, 0.7, 0.35), 0x06D6E0, 0.2);
|
||
|
||
// bond lines
|
||
for (const hPos of [h1, h2]) {
|
||
const d = new THREE.Vector3().subVectors(hPos, oPos);
|
||
const len = d.length();
|
||
const bGeo = new THREE.CylinderGeometry(0.04, 0.04, len, 8);
|
||
const bMat = new THREE.MeshStandardMaterial({ color: 0xaaaaaa });
|
||
const bond = new THREE.Mesh(bGeo, bMat);
|
||
bond.position.copy(oPos).add(d.clone().multiplyScalar(0.5));
|
||
bond.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), d.normalize());
|
||
this._group.add(bond);
|
||
}
|
||
|
||
// electron cloud
|
||
this._cloud(oPos, 1.2, 1500, 0xF15BB5);
|
||
|
||
this._addLabel('O', 0.3, 0.3, 0, 0xF15BB5);
|
||
this._addLabel('H', h1.x - 0.4, h1.y - 0.3, 0, 0xffffff);
|
||
this._addLabel('H', h2.x + 0.2, h2.y - 0.3, 0, 0xffffff);
|
||
this._addLabel('104.5°', 0, -0.5, 0, 0x888888);
|
||
}
|
||
|
||
/* ── text label (using sprite) ── */
|
||
_addLabel(text, x, y, z, color) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 128; canvas.height = 48;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.font = 'bold 28px Manrope, sans-serif';
|
||
ctx.fillStyle = '#' + color.toString(16).padStart(6, '0');
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(text, 64, 24);
|
||
|
||
const tex = new THREE.CanvasTexture(canvas);
|
||
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false });
|
||
const sprite = new THREE.Sprite(mat);
|
||
sprite.position.set(x, y, z);
|
||
sprite.scale.set(1, 0.4, 1);
|
||
this._group.add(sprite);
|
||
}
|
||
|
||
/* ── animation ── */
|
||
_loop() {
|
||
if (!this._running) return;
|
||
requestAnimationFrame(() => this._loop());
|
||
|
||
if (this._autoSpin) this._rotY += 0.004;
|
||
|
||
this.camera.position.set(
|
||
this._dist * Math.sin(this._rotY) * Math.cos(this._rotX),
|
||
this._dist * Math.sin(this._rotX),
|
||
this._dist * Math.cos(this._rotY) * Math.cos(this._rotX)
|
||
);
|
||
this.camera.lookAt(0, 0, 0);
|
||
|
||
this.renderer.render(this.scene, this.camera);
|
||
}
|
||
}
|