Files
Learn_System/frontend/js/labs/orbitals.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
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>
2026-04-12 10:10:37 +03:00

343 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
}
}