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>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+342
View File
@@ -0,0 +1,342 @@
'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);
}
}