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
+463
View File
@@ -0,0 +1,463 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
IsoprocessSim — PV-diagram for 4 ideal-gas isoprocesses
n = 1, R = 0.0821 L·atm/mol·K; energies in Joules
Isothermal PV = const ΔU=0, W=nRT·ln(V2/V1), Q=W
Isochoric V = const W=0, ΔU=νCvΔT, Q=ΔU
Isobaric P = const W=PΔV, ΔU=νCvΔT, Q=ΔU+W
Adiabatic PV^γ = const Q=0, ΔU=-W, W=PΔV/(γ-1)
══════════════════════════════════════════════════════════════ */
class IsoprocessSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.n = 1;
this.R = 0.0821; // L·atm / mol·K
this.R_J = 8.314; // J / mol·K
this.gamma = 1.4; // 7/5 diatomic default
/* state */
this.P1 = 3.0; // atm
this.V1 = 10.0; // L
this._ratio = 0.5; // 0..1, maps end state position along process
/* process */
this.process = 'isothermal';
/* axis range */
this.Vmin = 1; this.Vmax = 33;
this.Pmin = 0.2; this.Pmax = 9.5;
/* margins */
this.ML = 52; this.MB = 46; this.MT = 20; this.MR = 18;
this._drag = null; // 'state1' | 'state2'
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setProcess(p) { this.process = p; this.draw(); this._emit(); }
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
setParams({ P1, V1 } = {}) {
if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1));
if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1));
this.draw(); this._emit();
}
setRatio(r) { this._ratio = Math.max(0.01, Math.min(0.99, +r)); this.draw(); this._emit(); }
/* ── coordinate transforms ─────────────────── */
_pw() { return this.W - this.ML - this.MR; }
_ph() { return this.H - this.MT - this.MB; }
_vx(v) { return this.ML + (v - this.Vmin) / (this.Vmax - this.Vmin) * this._pw(); }
_py(p) { return this.MT + (1 - (p - this.Pmin) / (this.Pmax - this.Pmin)) * this._ph(); }
_xv(x) { return this.Vmin + (x - this.ML) / this._pw() * (this.Vmax - this.Vmin); }
_yp(y) { return this.Pmin + (1 - (y - this.MT) / this._ph()) * (this.Pmax - this.Pmin); }
/* ── physics ───────────────────────────────── */
_T(P, V) { return P * V / (this.n * this.R); }
_state2() {
const { P1, V1, _ratio, gamma } = this;
/* ratio in [0..1] → multiplier in [0.2..3.5] for V2/V1 or P2/P1 */
const mult = 0.2 + _ratio * 3.3;
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
const clampP = p => Math.max(this.Pmin + 0.05, Math.min(this.Pmax - 0.1, p));
switch (this.process) {
case 'isothermal': {
const V2 = clampV(V1 * mult);
return { P2: clampP(P1 * V1 / V2), V2 };
}
case 'isochoric': {
return { P2: clampP(P1 * mult), V2: V1 };
}
case 'isobaric': {
const V2 = clampV(V1 * mult);
return { P2: P1, V2 };
}
case 'adiabatic': {
const V2 = clampV(V1 * mult);
return { P2: clampP(P1 * Math.pow(V1 / V2, gamma)), V2 };
}
}
return { P2: P1, V2: V1 };
}
info() {
const { P1, V1, n, R_J, gamma } = this;
const T1 = this._T(P1, V1);
const { P2, V2 } = this._state2();
const T2 = this._T(P2, V2);
/* internal energy: ΔU = νCvΔT, Cv = R/(γ-1) */
const Cv_J = R_J / (gamma - 1);
const dU_J = n * Cv_J * (T2 - T1);
/* P in Pa = P_atm * 101325, V in m³ = V_L * 0.001 */
const P1Pa = P1 * 101325, P2Pa = P2 * 101325;
const V1m3 = V1 * 0.001, V2m3 = V2 * 0.001;
let W_J = 0, Q_J = 0;
switch (this.process) {
case 'isothermal':
W_J = n * R_J * T1 * Math.log(V2 / V1);
Q_J = W_J; break;
case 'isochoric':
W_J = 0; Q_J = dU_J; break;
case 'isobaric':
W_J = P1Pa * (V2m3 - V1m3);
Q_J = dU_J + W_J; break;
case 'adiabatic':
Q_J = 0;
W_J = -dU_J; break;
}
const fmt = x => (x >= 0 ? '+' : '') + Math.round(x);
return {
P1: P1.toFixed(2), V1: V1.toFixed(1), T1: Math.round(T1),
P2: P2.toFixed(2), V2: V2.toFixed(1), T2: Math.round(T2),
W: fmt(W_J), Q: fmt(Q_J), dU: fmt(Math.round(dU_J)),
W_raw: W_J, Q_raw: Q_J, dU_raw: dU_J,
process: this.process,
};
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── draw ──────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
this._drawGrid(ctx);
this._drawBgCurves(ctx);
this._drawActiveCurve(ctx);
this._drawPoints(ctx);
this._drawInfoBox(ctx);
}
_drawGrid(ctx) {
const { ML, MT, MR, MB } = this;
const pw = this._pw(), ph = this._ph();
/* plot background */
ctx.fillStyle = 'rgba(255,255,255,0.018)';
ctx.fillRect(ML, MT, pw, ph);
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,0.055)';
ctx.lineWidth = 1; ctx.setLineDash([]);
for (let v = 5; v <= 30; v += 5) {
const x = this._vx(v);
ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke();
}
for (let p = 1; p <= 9; p++) {
const y = this._py(p);
ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(ML + pw, y); ctx.stroke();
}
/* axes */
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph);
ctx.stroke();
/* tick labels */
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let p = 1; p <= 9; p++) {
const y = this._py(p);
if (y < MT + 2 || y > MT + ph - 2) continue;
ctx.fillText(p, ML - 6, y);
}
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let v = 5; v <= 30; v += 5) {
const x = this._vx(v);
ctx.fillText(v, x, MT + ph + 5);
}
/* axis titles */
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = '12px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('V, л', ML + pw / 2, MT + ph + 32);
ctx.save();
ctx.translate(13, MT + ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('P, атм', 0, 0);
ctx.restore();
}
_COLORS = {
isothermal: '#EF476F',
isochoric: '#06D6E0',
isobaric: '#7BF5A4',
adiabatic: '#FFD166',
};
/* draw one process curve through (P1,V1) */
_curve(ctx, process, alpha, lw, dashed) {
const { P1, V1, gamma, Vmin, Vmax, Pmin, Pmax } = this;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = this._COLORS[process];
ctx.lineWidth = lw;
ctx.setLineDash(dashed ? [5, 4] : []);
ctx.beginPath();
if (process === 'isochoric') {
const x = this._vx(V1);
ctx.moveTo(x, this._py(Pmax));
ctx.lineTo(x, this._py(Pmin));
} else {
let started = false;
const steps = 300;
for (let i = 0; i <= steps; i++) {
const v = Vmin + (Vmax - Vmin) * i / steps;
let p;
if (process === 'isothermal') p = P1 * V1 / v;
else if (process === 'isobaric') p = P1;
else p = P1 * Math.pow(V1 / v, gamma); // adiabatic
if (p < Pmin || p > Pmax + 0.1) { started = false; continue; }
const x = this._vx(v), y = this._py(Math.min(p, Pmax));
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
_drawBgCurves(ctx) {
for (const p of ['isothermal', 'isochoric', 'isobaric', 'adiabatic']) {
if (p !== this.process) this._curve(ctx, p, 0.14, 1.2, true);
}
/* legend dots */
const names = { isothermal: 'Изотерма', isochoric: 'Изохора', isobaric: 'Изобара', adiabatic: 'Адиабата' };
ctx.font = '10px Manrope, system-ui, sans-serif';
let lx = this.ML + this._pw() - 8, ly = this.MT + 8;
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
for (const [proc, label] of Object.entries(names)) {
const col = this._COLORS[proc];
const isCur = proc === this.process;
ctx.globalAlpha = isCur ? 0.85 : 0.3;
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(lx + 5, ly + 4, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillText(label, lx - 3, ly);
ly += 16;
}
ctx.globalAlpha = 1;
}
_drawActiveCurve(ctx) {
/* full curve dimmed */
this._curve(ctx, this.process, 0.3, 1.5, false);
/* highlighted segment state1 → state2 */
const { P1, V1, gamma } = this;
const { P2, V2 } = this._state2();
const color = this._COLORS[this.process];
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2.8;
ctx.setLineDash([]);
const steps = 200;
const [Vs, Ve] = V2 >= V1 ? [V1, V2] : [V2, V1];
if (this.process === 'isochoric') {
const x = this._vx(V1);
const y1c = this._py(P1), y2c = this._py(P2);
ctx.beginPath(); ctx.moveTo(x, y1c); ctx.lineTo(x, y2c); ctx.stroke();
this._arrowHead(ctx, x, y1c, x, y2c, color);
} else {
ctx.beginPath();
let started = false;
for (let i = 0; i <= steps; i++) {
const v = Vs + (Ve - Vs) * i / steps;
let p;
if (this.process === 'isothermal') p = P1 * V1 / v;
else if (this.process === 'isobaric') p = P1;
else p = P1 * Math.pow(V1 / v, gamma);
const x = this._vx(v), y = this._py(p);
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
ctx.stroke();
/* arrow at ~80% of segment */
const vArr = Vs + (Ve - Vs) * 0.8;
const vArr2 = Vs + (Ve - Vs) * 0.82;
let p1a, p2a;
if (this.process === 'isothermal') { p1a = P1*V1/vArr; p2a = P1*V1/vArr2; }
else if (this.process === 'isobaric') { p1a = P1; p2a = P1; }
else { p1a = P1*Math.pow(V1/vArr,gamma); p2a = P1*Math.pow(V1/vArr2,gamma); }
/* ensure arrow points from 1→2 */
const dir = V2 > V1 ? 1 : -1;
this._arrowHead(ctx,
this._vx(vArr + dir*0), this._py(p1a + dir*0),
this._vx(vArr2 + dir*0), this._py(p2a + dir*0), color);
}
ctx.restore();
}
_arrowHead(ctx, x1, y1, x2, y2, color) {
const angle = Math.atan2(y2 - y1, x2 - x1);
const s = 10;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - s * Math.cos(angle - 0.4), y2 - s * Math.sin(angle - 0.4));
ctx.lineTo(x2 - s * Math.cos(angle + 0.4), y2 - s * Math.sin(angle + 0.4));
ctx.closePath(); ctx.fill();
}
_drawPoints(ctx) {
const { P2, V2 } = this._state2();
const color = this._COLORS[this.process];
const dot = (x, y, fill, label, textX, textY) => {
ctx.fillStyle = fill;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.stroke();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.fillStyle = fill; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(label, textX, textY);
};
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
const x2 = this._vx(V2), y2 = this._py(P2);
dot(x1, y1, '#9B5DE5', '1', x1 - 12, y1 - 4);
dot(x2, y2, color, '2', x2 + 12, y2 - 4);
}
_drawInfoBox(ctx) {
const info = this.info();
const color = this._COLORS[info.process];
const names = { isothermal:'Изотермический', isochoric:'Изохорный', isobaric:'Изобарный', adiabatic:'Адиабатический' };
const formulas = { isothermal:'PV = const', isochoric:'V = const', isobaric:'P = const', adiabatic:'PV^γ = const' };
const bx = this.ML + 6, by = this.MT + 6;
const boxW = 205, boxH = 98;
ctx.fillStyle = 'rgba(13,13,26,0.9)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(`${names[info.process]} ${formulas[info.process]}`, bx + 10, by + 8);
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillText(`T₁ = ${info.T1} K → T₂ = ${info.T2} K`, bx + 10, by + 28);
const wColor = info.W_raw > 0 ? '#7BF5A4' : info.W_raw < 0 ? '#EF476F' : 'rgba(255,255,255,0.4)';
const qColor = info.Q_raw > 0 ? '#FFD166' : info.Q_raw < 0 ? '#06D6E0' : 'rgba(255,255,255,0.4)';
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('W =', bx + 10, by + 48);
ctx.fillStyle = wColor; ctx.fillText(`${info.W} Дж`, bx + 38, by + 48);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('Q =', bx + 10, by + 65);
ctx.fillStyle = qColor; ctx.fillText(`${info.Q} Дж`, bx + 38, by + 65);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('ΔU =', bx + 10, by + 82);
ctx.fillStyle = 'rgba(255,255,255,0.65)'; ctx.fillText(`${info.dU} Дж`, bx + 40, by + 82);
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
const pos = e => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return {
px: (t.clientX - r.left) * (this.W / r.width),
py: (t.clientY - r.top) * (this.H / r.height),
};
};
const hit = (px, py) => {
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
if (Math.hypot(px - x1, py - y1) < 18) return 'state1';
const { P2, V2 } = this._state2();
const x2 = this._vx(V2), y2 = this._py(P2);
if (Math.hypot(px - x2, py - y2) < 18) return 'state2';
return null;
};
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
const clampP = p => Math.max(this.Pmin + 0.1, Math.min(this.Pmax - 0.1, p));
const onDown = e => { const { px, py } = pos(e); this._drag = hit(px, py); };
const onMove = e => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { px, py } = pos(e);
const v = this._xv(px), p = this._yp(py);
if (this._drag === 'state1') {
this.V1 = clampV(v); this.P1 = clampP(p);
} else {
/* constrain state2 to current process curve */
switch (this.process) {
case 'isothermal': case 'isobaric': case 'adiabatic': {
const V2 = clampV(v);
this._ratio = Math.max(0.01, Math.min(0.99, (V2 / this.V1 - 0.2) / 3.3));
break;
}
case 'isochoric': {
const P2 = clampP(p);
this._ratio = Math.max(0.01, Math.min(0.99, (P2 / this.P1 - 0.2) / 3.3));
break;
}
}
}
this.draw(); this._emit();
};
const onUp = () => { this._drag = null; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { px, py } = pos(e);
cv.style.cursor = hit(px, py) ? 'grab' : 'default';
});
}
}