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
+315
View File
@@ -0,0 +1,315 @@
'use strict';
/* ═══════════════════════════════════════════════
CrystalSim — 3D crystal lattice (Three.js)
NaCl, Diamond, BCC metal, FCC metal
═══════════════════════════════════════════════ */
class CrystalSim {
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.5));
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
dir.position.set(5, 8, 6);
this.scene.add(dir);
const pt = new THREE.PointLight(0x9B5DE5, 0.4, 50);
pt.position.set(-4, 3, 5);
this.scene.add(pt);
this.camera.position.set(8, 6, 8);
this.camera.lookAt(0, 0, 0);
/* orbit-like manual controls */
this._drag = false;
this._prevX = 0;
this._prevY = 0;
this._rotY = 0.6;
this._rotX = 0.4;
this._dist = 12;
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(5, Math.min(30, 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._lattice = 'nacl';
this._group = new THREE.Group();
this.scene.add(this._group);
this._buildLattice('nacl');
this.fit();
this.play();
}
/* ── public ── */
setLattice(type) {
this._lattice = type;
this._buildLattice(type);
}
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; }
/* ── lattice builders ── */
_buildLattice(type) {
// clear
while (this._group.children.length) {
const c = this._group.children[0];
c.geometry?.dispose(); c.material?.dispose();
this._group.remove(c);
}
const builders = {
nacl: () => this._buildNaCl(),
diamond: () => this._buildDiamond(),
bcc: () => this._buildBCC(),
fcc: () => this._buildFCC(),
};
(builders[type] || builders.nacl)();
}
_sphere(r, color) {
const geo = new THREE.SphereGeometry(r, 24, 24);
const mat = new THREE.MeshPhysicalMaterial({
color, metalness: 0.1, roughness: 0.3,
clearcoat: 0.6, clearcoatRoughness: 0.2,
});
return new THREE.Mesh(geo, mat);
}
_bond(from, to, color = 0x555555) {
const dir = new THREE.Vector3().subVectors(to, from);
const len = dir.length();
const geo = new THREE.CylinderGeometry(0.04, 0.04, len, 8);
const mat = new THREE.MeshStandardMaterial({ color, opacity: 0.5, transparent: true });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(from).add(dir.multiplyScalar(0.5));
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
return mesh;
}
_buildNaCl() {
const n = 3; // 3×3×3 unit cells
const a = 2.0; // lattice constant
const offset = -(n - 1) * a / 2;
const positions = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
for (let k = 0; k < n; k++) {
const x = offset + i * a, y = offset + j * a, z = offset + k * a;
const isNa = (i + j + k) % 2 === 0;
const s = this._sphere(isNa ? 0.28 : 0.38, isNa ? 0x9B5DE5 : 0x06D6E0);
s.position.set(x, y, z);
this._group.add(s);
positions.push({ x, y, z });
}
// bonds to nearest neighbors
for (let i = 0; i < positions.length; i++)
for (let j = i + 1; j < positions.length; j++) {
const dx = positions[i].x - positions[j].x;
const dy = positions[i].y - positions[j].y;
const dz = positions[i].z - positions[j].z;
const d2 = dx * dx + dy * dy + dz * dz;
if (Math.abs(d2 - a * a) < 0.01) {
const b = this._bond(
new THREE.Vector3(positions[i].x, positions[i].y, positions[i].z),
new THREE.Vector3(positions[j].x, positions[j].y, positions[j].z),
0x444466
);
this._group.add(b);
}
}
}
_buildDiamond() {
const a = 2.5;
const basis = [
[0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5],
[0.25, 0.25, 0.25], [0.75, 0.75, 0.25], [0.75, 0.25, 0.75], [0.25, 0.75, 0.75],
];
const n = 2;
const offset = -(n * a) / 2;
const allPos = [];
for (let ci = 0; ci < n; ci++)
for (let cj = 0; cj < n; cj++)
for (let ck = 0; ck < n; ck++)
for (const [bx, by, bz] of basis) {
const x = offset + (ci + bx) * a;
const y = offset + (cj + by) * a;
const z = offset + (ck + bz) * a;
const s = this._sphere(0.22, 0x34d399);
s.position.set(x, y, z);
this._group.add(s);
allPos.push(new THREE.Vector3(x, y, z));
}
// bonds
const bondLen = a * Math.sqrt(3) / 4;
const tol = bondLen * 0.15;
for (let i = 0; i < allPos.length; i++)
for (let j = i + 1; j < allPos.length; j++) {
const d = allPos[i].distanceTo(allPos[j]);
if (Math.abs(d - bondLen) < tol) {
this._group.add(this._bond(allPos[i], allPos[j], 0x228866));
}
}
}
_buildBCC() {
const a = 2.2, n = 3;
const offset = -(n - 1) * a / 2;
const allPos = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
for (let k = 0; k < n; k++) {
// corner atoms
const x1 = offset + i * a, y1 = offset + j * a, z1 = offset + k * a;
const s1 = this._sphere(0.3, 0xF15BB5);
s1.position.set(x1, y1, z1);
this._group.add(s1);
allPos.push(new THREE.Vector3(x1, y1, z1));
// body center (except last cell in each dimension)
if (i < n - 1 && j < n - 1 && k < n - 1) {
const cx = x1 + a / 2, cy = y1 + a / 2, cz = z1 + a / 2;
const s2 = this._sphere(0.3, 0xF59E0B);
s2.position.set(cx, cy, cz);
this._group.add(s2);
allPos.push(new THREE.Vector3(cx, cy, cz));
}
}
// bonds
const bondLen = a * Math.sqrt(3) / 2;
const tol = bondLen * 0.1;
for (let i = 0; i < allPos.length; i++)
for (let j = i + 1; j < allPos.length; j++) {
const d = allPos[i].distanceTo(allPos[j]);
if (Math.abs(d - bondLen) < tol) {
this._group.add(this._bond(allPos[i], allPos[j], 0x664444));
}
}
}
_buildFCC() {
const a = 2.4, n = 3;
const offset = -(n - 1) * a / 2;
const allPos = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
for (let k = 0; k < n; k++) {
const x = offset + i * a, y = offset + j * a, z = offset + k * a;
// corner
const s = this._sphere(0.25, 0x60a5fa);
s.position.set(x, y, z);
this._group.add(s);
allPos.push(new THREE.Vector3(x, y, z));
// face centers (only for cell interiors)
if (i < n - 1 && j < n - 1) {
const f1 = this._sphere(0.25, 0x60a5fa);
f1.position.set(x + a / 2, y + a / 2, z);
this._group.add(f1);
allPos.push(new THREE.Vector3(x + a / 2, y + a / 2, z));
}
if (i < n - 1 && k < n - 1) {
const f2 = this._sphere(0.25, 0x60a5fa);
f2.position.set(x + a / 2, y, z + a / 2);
this._group.add(f2);
allPos.push(new THREE.Vector3(x + a / 2, y, z + a / 2));
}
if (j < n - 1 && k < n - 1) {
const f3 = this._sphere(0.25, 0x60a5fa);
f3.position.set(x, y + a / 2, z + a / 2);
this._group.add(f3);
allPos.push(new THREE.Vector3(x, y + a / 2, z + a / 2));
}
}
// bonds to nearest neighbors (a/√2)
const bondLen = a / Math.SQRT2;
const tol = bondLen * 0.1;
for (let i = 0; i < allPos.length; i++)
for (let j = i + 1; j < allPos.length; j++) {
const d = allPos[i].distanceTo(allPos[j]);
if (Math.abs(d - bondLen) < tol) {
this._group.add(this._bond(allPos[i], allPos[j], 0x334466));
}
}
}
/* ── animation ── */
_loop() {
if (!this._running) return;
requestAnimationFrame(() => this._loop());
if (this._autoSpin) this._rotY += 0.003;
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);
}
}