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
+570
View File
@@ -0,0 +1,570 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
ProbabilitySim — probability & law of large numbers
coin flip · single die · two-dice sum
histogram + convergence chart + animated visuals
══════════════════════════════════════════════════════════════ */
class ProbabilitySim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* parameters */
this.mode = 'coin'; // 'coin' | 'dice' | 'dice2'
this.trials = 100; // target total
this.speed = 5; // trials per frame
/* state */
this.results = []; // outcome per trial
this.distribution = {}; // outcome <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> count
this._convHist = []; // running freq for convergence chart
this._trackKey = null; // key tracked for convergence
/* animation */
this.playing = false;
this._raf = null;
this._animT = 0; // animation phase for coin/dice visual
this._lastOutcome = null;
this._shakeT = 0;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── presets ──────────────────────────────────── */
static PRESETS = {
coin_100: { mode: 'coin', trials: 100, speed: 2 },
coin_1000: { mode: 'coin', trials: 1000, speed: 10 },
dice_100: { mode: 'dice', trials: 100, speed: 2 },
dice2_500: { mode: 'dice2', trials: 500, speed: 5 },
};
preset(name) {
const p = ProbabilitySim.PRESETS[name];
if (p) { this.setParams(p); this.reset(); }
}
/* ── 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;
}
setParams({ mode, trials, speed } = {}) {
if (mode !== undefined) this.mode = mode;
if (trials !== undefined) this.trials = Math.max(1, +trials);
if (speed !== undefined) this.speed = Math.max(1, Math.min(50, +speed));
this._setupMode();
this.draw();
this._emit();
}
reset() {
this.pause();
this.results = [];
this.distribution = {};
this._convHist = [];
this._animT = 0;
this._lastOutcome = null;
this._shakeT = 0;
this._setupMode();
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
info() {
const n = this.results.length;
const dist = { ...this.distribution };
const theo = this._theoretical();
let chiSq = 0, maxDev = 0;
for (const k of Object.keys(theo)) {
const obs = (dist[k] || 0) / (n || 1);
const exp = theo[k];
const dev = Math.abs(obs - exp);
if (dev > maxDev) maxDev = dev;
if (n > 0) chiSq += ((dist[k] || 0) - n * exp) ** 2 / (n * exp || 1);
}
return {
mode: this.mode,
totalTrials: n,
distribution: dist,
chiSquare: +chiSq.toFixed(4),
maxDeviation: +maxDev.toFixed(6),
};
}
/* ── internals ───────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_setupMode() {
const keys = this._outcomeKeys();
for (const k of keys) {
if (!(k in this.distribution)) this.distribution[k] = 0;
}
this._trackKey = keys[0]; // convergence tracks first outcome
}
_outcomeKeys() {
if (this.mode === 'coin') return ['О', 'Р'];
if (this.mode === 'dice') return ['1','2','3','4','5','6'];
// dice2: sums 2..12
const keys = [];
for (let i = 2; i <= 12; i++) keys.push(String(i));
return keys;
}
_theoretical() {
const t = {};
if (this.mode === 'coin') {
t['О'] = 0.5; t['Р'] = 0.5;
} else if (this.mode === 'dice') {
for (let i = 1; i <= 6; i++) t[String(i)] = 1 / 6;
} else {
// dice2: two dice sum probabilities
const ways = [0,0,1,2,3,4,5,6,5,4,3,2,1]; // index 0-12, sum 2-12
for (let s = 2; s <= 12; s++) t[String(s)] = ways[s] / 36;
}
return t;
}
_rollOnce() {
if (this.mode === 'coin') return Math.random() < 0.5 ? 'О' : 'Р';
if (this.mode === 'dice') return String(Math.floor(Math.random() * 6) + 1);
const d1 = Math.floor(Math.random() * 6) + 1;
const d2 = Math.floor(Math.random() * 6) + 1;
return String(d1 + d2);
}
_addTrial() {
if (this.results.length >= this.trials) return false;
const outcome = this._rollOnce();
this.results.push(outcome);
this.distribution[outcome] = (this.distribution[outcome] || 0) + 1;
this._lastOutcome = outcome;
// convergence: running frequency of tracked key
const n = this.results.length;
const freq = (this.distribution[this._trackKey] || 0) / n;
this._convHist.push(freq);
if (this._convHist.length > 500) this._convHist.shift();
return true;
}
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(() => {
let added = 0;
for (let i = 0; i < this.speed; i++) {
if (!this._addTrial()) break;
added++;
}
this._animT += 0.15;
if (added > 0) this._shakeT = 1;
else this._shakeT *= 0.9;
this.draw();
this._emit();
if (this.results.length >= this.trials) {
this.pause();
return;
}
this._tick();
});
}
/* ── drawing ─────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const vizH = H * 0.28;
const histH = H * 0.48;
const convH = H * 0.24;
this._drawVisual(ctx, 0, 0, W, vizH);
this._drawHistogram(ctx, 0, vizH, W, histH);
this._drawConvergence(ctx, 0, vizH + histH, W, convH);
this._drawStats(ctx, W, H);
}
/* ── top visual: coin or dice ──────────────── */
_drawVisual(ctx, x0, y0, w, h) {
const cx = x0 + w / 2, cy = y0 + h / 2;
if (this.mode === 'coin') {
this._drawCoin(ctx, cx, cy, Math.min(w, h) * 0.32);
} else if (this.mode === 'dice') {
this._drawDie(ctx, cx, cy, Math.min(w, h) * 0.34, this._lastOutcome ? +this._lastOutcome : 1);
} else {
// dice2: two dice side by side
const sz = Math.min(w, h) * 0.28;
const gap = sz * 0.3;
const last = this._lastOutcome ? +this._lastOutcome : 7;
const d1 = Math.min(6, Math.max(1, Math.ceil(last / 2)));
const d2 = last - d1;
this._drawDie(ctx, cx - sz / 2 - gap, cy, sz, Math.max(1, Math.min(6, d1)));
this._drawDie(ctx, cx + sz / 2 + gap, cy, sz, Math.max(1, Math.min(6, d2)));
}
// trial counter
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.font = "bold 13px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(`Испытание ${this.results.length} / ${this.trials}`, x0 + w / 2, y0 + h - 6);
}
_drawCoin(ctx, cx, cy, r) {
const phase = this._animT % (Math.PI * 2);
const squeeze = Math.abs(Math.cos(phase));
const showHeads = Math.cos(phase) >= 0;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(Math.max(0.05, squeeze), 1);
// shadow
ctx.fillStyle = 'rgba(155,93,229,0.15)';
ctx.beginPath(); ctx.ellipse(0, r * 0.15, r * 1.1, r * 0.18, 0, 0, Math.PI * 2); ctx.fill();
// coin body
const grad = ctx.createRadialGradient(-r * 0.2, -r * 0.2, 0, 0, 0, r);
if (showHeads) {
grad.addColorStop(0, '#FFD166');
grad.addColorStop(1, '#D4950A');
} else {
grad.addColorStop(0, '#9B5DE5');
grad.addColorStop(1, '#6B2FA0');
}
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill();
// rim
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 2;
ctx.stroke();
// label
ctx.fillStyle = showHeads ? '#5A3000' : '#E0D0FF';
ctx.font = `bold ${Math.round(r * 0.6)}px 'Manrope', system-ui, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(showHeads ? 'О' : 'Р', 0, 2);
ctx.restore();
}
_drawDie(ctx, cx, cy, size, value) {
const half = size / 2;
const shake = this._shakeT * 2;
const sx = shake * (Math.random() - 0.5);
const sy = shake * (Math.random() - 0.5);
ctx.save();
ctx.translate(cx + sx, cy + sy);
// shadow
ctx.fillStyle = 'rgba(6,214,224,0.08)';
ctx.beginPath(); ctx.roundRect(-half + 4, -half + 6, size, size, size * 0.18); ctx.fill();
// body
const grad = ctx.createLinearGradient(-half, -half, half, half);
grad.addColorStop(0, '#1E1E3A');
grad.addColorStop(1, '#12122A');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.roundRect(-half, -half, size, size, size * 0.18); ctx.fill();
// border
ctx.strokeStyle = 'rgba(155,93,229,0.4)';
ctx.lineWidth = 1.5;
ctx.stroke();
// dots
const dotR = size * 0.08;
const off = size * 0.26;
const dots = {
1: [[0, 0]],
2: [[-off, -off], [off, off]],
3: [[-off, -off], [0, 0], [off, off]],
4: [[-off, -off], [off, -off], [-off, off], [off, off]],
5: [[-off, -off], [off, -off], [0, 0], [-off, off], [off, off]],
6: [[-off, -off], [off, -off], [-off, 0], [off, 0], [-off, off], [off, off]],
};
const positions = dots[Math.max(1, Math.min(6, value))] || dots[1];
for (const [dx, dy] of positions) {
const dg = ctx.createRadialGradient(dx, dy, 0, dx, dy, dotR);
dg.addColorStop(0, '#FFFFFF');
dg.addColorStop(1, '#C0C0E0');
ctx.fillStyle = dg;
ctx.beginPath(); ctx.arc(dx, dy, dotR, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
/* ── histogram ─────────────────────────────── */
_drawHistogram(ctx, x0, y0, w, h) {
const keys = this._outcomeKeys();
const theo = this._theoretical();
const n = this.results.length || 1;
const pad = { l: 48, r: 16, t: 20, b: 34 };
const pw = w - pad.l - pad.r;
const ph = h - pad.t - pad.b;
const px = x0 + pad.l, py = y0 + pad.t;
// panel bg
ctx.fillStyle = 'rgba(5,5,20,0.5)';
ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 4, w - 16, h - 8, 8); ctx.fill();
// y-axis: relative frequency
let maxFreq = 0;
for (const k of keys) {
const f = (this.distribution[k] || 0) / n;
if (f > maxFreq) maxFreq = f;
}
for (const k of keys) {
const t = theo[k];
if (t > maxFreq) maxFreq = t;
}
maxFreq = Math.max(maxFreq * 1.15, 0.05);
// grid lines
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const gy = py + ph * (1 - i / 4);
ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
}
// y labels
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let i = 0; i <= 4; i++) {
const v = (maxFreq * i / 4 * 100).toFixed(0) + '%';
ctx.fillText(v, px - 6, py + ph * (1 - i / 4));
}
// bars
const barCount = keys.length;
const gap = Math.max(2, pw * 0.03);
const barW = (pw - gap * (barCount + 1)) / barCount;
const colors = ['#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166',
'#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166','#EF476F'];
for (let i = 0; i < barCount; i++) {
const k = keys[i];
const freq = (this.distribution[k] || 0) / n;
const bh = (freq / maxFreq) * ph;
const bx = px + gap + i * (barW + gap);
const by = py + ph - bh;
// bar gradient
const bg = ctx.createLinearGradient(bx, by, bx, py + ph);
bg.addColorStop(0, colors[i % colors.length]);
bg.addColorStop(1, colors[i % colors.length] + '66');
ctx.fillStyle = bg;
ctx.beginPath(); ctx.roundRect(bx, by, barW, bh, [4, 4, 0, 0]); ctx.fill();
// glow at top
if (bh > 4) {
ctx.fillStyle = colors[i % colors.length] + '33';
ctx.beginPath(); ctx.roundRect(bx - 2, by - 2, barW + 4, 6, 3); ctx.fill();
}
// count + percentage label above bar
const count = this.distribution[k] || 0;
const pct = (freq * 100).toFixed(1);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = "bold 9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
if (bh > 16) {
ctx.fillText(count, bx + barW / 2, by - 2);
} else {
ctx.fillText(count, bx + barW / 2, py + ph - bh - 2);
}
// percentage inside bar
if (bh > 28) {
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textBaseline = 'top';
ctx.fillText(pct + '%', bx + barW / 2, by + 4);
}
// x label
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(k, bx + barW / 2, py + ph + 6);
}
// theoretical probability dashed lines
ctx.setLineDash([5, 4]);
ctx.lineWidth = 1.5;
for (let i = 0; i < barCount; i++) {
const k = keys[i];
const tp = theo[k];
const ly = py + ph - (tp / maxFreq) * ph;
const bx = px + gap + i * (barW + gap);
ctx.strokeStyle = colors[i % colors.length] + '88';
ctx.beginPath();
ctx.moveTo(bx - 2, ly);
ctx.lineTo(bx + barW + 2, ly);
ctx.stroke();
}
ctx.setLineDash([]);
// legend
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
ctx.setLineDash([5, 4]);
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px, y0 + h - 8); ctx.lineTo(px + 18, y0 + h - 8); ctx.stroke();
ctx.setLineDash([]);
ctx.fillText('— теор. вероятность', px + 22, y0 + h - 4);
}
/* ── convergence chart ─────────────────────── */
_drawConvergence(ctx, x0, y0, w, h) {
const pad = { l: 48, r: 16, t: 14, b: 20 };
const pw = w - pad.l - pad.r;
const ph = h - pad.t - pad.b;
const px = x0 + pad.l, py = y0 + pad.t;
// bg
ctx.fillStyle = 'rgba(5,5,20,0.5)';
ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 2, w - 16, h - 4, 8); ctx.fill();
// title
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
const trackLabel = this._trackKey;
ctx.fillText(`Сходимость частоты «${trackLabel}»`, px, y0 + 3);
// theoretical value
const theo = this._theoretical();
const tp = theo[this._trackKey] || 0;
// y range
const yMin = Math.max(0, tp - 0.35);
const yMax = Math.min(1, tp + 0.35);
const yRange = yMax - yMin || 0.01;
// grid
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 3; i++) {
const gy = py + ph * (i / 3);
ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
}
// y labels
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let i = 0; i <= 3; i++) {
const v = yMax - (i / 3) * yRange;
ctx.fillText(v.toFixed(2), px - 5, py + ph * (i / 3));
}
// theoretical dashed line
const theoY = py + ph * (1 - (tp - yMin) / yRange);
ctx.setLineDash([6, 4]);
ctx.strokeStyle = '#FFD166';
ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(px, theoY); ctx.lineTo(px + pw, theoY); ctx.stroke();
ctx.setLineDash([]);
// label for theoretical
ctx.fillStyle = '#FFD166';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
ctx.fillText('p=' + tp.toFixed(4), px + pw, theoY - 3);
// convergence line
const data = this._convHist;
if (data.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = '#06D6E0';
ctx.lineWidth = 1.5;
for (let i = 0; i < data.length; i++) {
const lx = px + (i / (data.length - 1)) * pw;
const ly = py + ph * (1 - (data[i] - yMin) / yRange);
const cly = Math.max(py, Math.min(py + ph, ly));
i === 0 ? ctx.moveTo(lx, cly) : ctx.lineTo(lx, cly);
}
ctx.stroke();
// x label
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('номер испытания →', px + pw / 2, y0 + h - 14);
}
/* ── stats overlay ─────────────────────────── */
_drawStats(ctx, W) {
const info = this.info();
const px = 12, py = 10, pw = 170, ph = 72;
ctx.fillStyle = 'rgba(5,5,20,0.82)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
const lh = 15;
const modeLabel = { coin: 'Монета', dice: 'Кубик', dice2: '2 кубика' }[this.mode] || this.mode;
ctx.fillStyle = '#9B5DE5';
ctx.fillText(`Режим: ${modeLabel}`, px + 10, py + 8);
ctx.fillStyle = '#06D6E0';
ctx.fillText(`N = ${info.totalTrials}`, px + 10, py + 8 + lh);
ctx.fillStyle = '#7BF5A4';
ctx.fillText(`χ² = ${info.chiSquare}`, px + 10, py + 8 + lh * 2);
ctx.fillStyle = '#FFD166';
ctx.fillText(`max Δ = ${info.maxDeviation.toFixed(4)}`, px + 10, py + 8 + lh * 3);
}
}
if (typeof module !== 'undefined') module.exports = ProbabilitySim;