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
+393
View File
@@ -0,0 +1,393 @@
'use strict';
/**
* NormalDistSim v2 — интерактивное нормальное распределение
* μ, σ · правило 68-95-99.7 · Z-score · закрашивание области
* Чистый рерайт: без SVG-строк в info(), лучшая визуализация.
*/
class NormalDistSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.mu = 0;
this.sigma = 1;
this.shade = '1s'; // 'none' | '1s' | '2s' | '3s' | 'custom'
this.zLow = -1;
this.zHigh = 1;
this.hx = null;
this.onUpdate = null;
this._bind();
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;
}
setParams({ mu, sigma, shade, zLow, zHigh } = {}) {
if (mu !== undefined) this.mu = +mu;
if (sigma !== undefined) this.sigma = Math.max(0.1, +sigma);
if (shade !== undefined) this.shade = shade;
if (zLow !== undefined) this.zLow = +zLow;
if (zHigh !== undefined) this.zHigh = +zHigh;
this.draw(); this._emit();
}
info() {
const { mu, sigma, shade } = this;
let areaLabel = '\u2014', areaPct = 0;
if (shade === '1s') { areaPct = 68.27; areaLabel = '\u03bc \u00b1 1\u03c3 \u2192 68.27%'; }
else if (shade === '2s') { areaPct = 95.45; areaLabel = '\u03bc \u00b1 2\u03c3 \u2192 95.45%'; }
else if (shade === '3s') { areaPct = 99.73; areaLabel = '\u03bc \u00b1 3\u03c3 \u2192 99.73%'; }
else if (shade === 'custom') {
areaPct = (this._phi(this.zHigh) - this._phi(this.zLow)) * 100;
areaLabel = `Z \u2208 [${this.zLow.toFixed(1)}, ${this.zHigh.toFixed(1)}] \u2192 ${areaPct.toFixed(2)}%`;
}
return {
mu: mu.toFixed(1),
sigma: sigma.toFixed(2),
peak: (1 / (sigma * Math.sqrt(2 * Math.PI))).toFixed(4),
area: areaLabel,
areaPct: areaPct.toFixed(2),
};
}
// ── math ─────────────────────────────────────────────────────
_pdf(x) {
const z = (x - this.mu) / this.sigma;
return Math.exp(-0.5 * z * z) / (this.sigma * Math.sqrt(2 * Math.PI));
}
_phi(z) {
const a1=0.254829592, a2=-0.284496736, a3=1.421413741, a4=-1.453152027, a5=1.061405429, p=0.3275911;
const sign = z < 0 ? -1 : 1;
const t = 1 / (1 + p * Math.abs(z) / Math.SQRT2);
const y = 1 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t * Math.exp(-z*z/2);
return 0.5 * (1 + sign * y);
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
// ── coordinate transforms ─────────────────────────────────────
_pad() { return { PL: 52, PR: 22, PT: 38, PB: 50 }; }
_xToP(x, xMin, xMax, PL, pw) { return PL + (x - xMin) / (xMax - xMin) * pw; }
_yToP(y, yMax, PT, ph) { return PT + ph - (y / yMax) * ph; }
_pToX(px, xMin, xMax, PL, pw){ return xMin + (px - PL) / pw * (xMax - xMin); }
// ── draw ─────────────────────────────────────────────────────
draw() {
const { ctx, W, H, mu, sigma } = this;
if (!W || !H) return;
const { PL, PR, PT, PB } = this._pad();
const pw = W - PL - PR, ph = H - PT - PB;
const xMin = mu - 4.5 * sigma, xMax = mu + 4.5 * sigma;
const yMax = this._pdf(mu) * 1.18;
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
this._drawGrid (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawShade (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawCurve (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawLabels (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawBadge (PL, PT, pw, ph);
if (this.hx !== null) this._drawHover(PL, PT, pw, ph, xMin, xMax, yMax);
}
_drawGrid(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx, mu, sigma } = this;
const bottom = PT + ph;
const FN = 'Manrope, sans-serif';
// Horizontal grid
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1;
for (let i = 1; i <= 4; i++) {
const py = PT + ph * (1 - i / 4);
ctx.beginPath(); ctx.moveTo(PL, py); ctx.lineTo(PL + pw, py); ctx.stroke();
}
// Vertical sigma grid lines
for (let s = -4; s <= 4; s++) {
const x = mu + s * sigma;
if (x < xMin || x > xMax) continue;
const px = this._xToP(x, xMin, xMax, PL, pw);
ctx.strokeStyle = s === 0
? 'rgba(6,214,224,0.22)'
: `rgba(255,255,255,${0.04 + (Math.abs(s) <= 2 ? 0.03 : 0)})`;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke();
}
// Axes
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(PL, bottom); ctx.lineTo(PL + pw, bottom); ctx.stroke();
ctx.beginPath(); ctx.moveTo(PL, PT); ctx.lineTo(PL, bottom); ctx.stroke();
// X-axis labels (sigma notation)
ctx.font = `11px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
for (let s = -4; s <= 4; s++) {
const x = mu + s * sigma;
if (x < xMin || x > xMax) continue;
const px = this._xToP(x, xMin, xMax, PL, pw);
const lbl = s === 0 ? '\u03bc' : (s > 0 ? `+${s}\u03c3` : `${s}\u03c3`);
ctx.fillText(lbl, px, bottom + 6);
}
// Actual x values below
ctx.font = `9px ${FN}`; ctx.fillStyle = 'rgba(255,255,255,0.18)';
for (let s = -3; s <= 3; s++) {
const x = mu + s * sigma;
if (x < xMin || x > xMax) continue;
const px = this._xToP(x, xMin, xMax, PL, pw);
const dec = sigma < 1 ? 1 : 0;
ctx.fillText(x.toFixed(dec), px, bottom + 20);
}
// Y-axis labels
ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = `10px ${FN}`;
for (let i = 0; i <= 4; i++) {
const v = (yMax / 4) * i;
const py = PT + ph - (v / yMax) * ph;
ctx.fillText(v.toFixed(2), PL - 6, py);
}
// Axis names
ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.font = `10px ${FN}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('x', PL + pw / 2, PT + ph + 36);
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('f(x)', PL + 6, PT);
}
_drawShade(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx, mu, sigma, shade } = this;
let lo, hi;
if (shade === '1s') { lo = mu - sigma; hi = mu + sigma; }
else if (shade === '2s') { lo = mu - 2 * sigma; hi = mu + 2 * sigma; }
else if (shade === '3s') { lo = mu - 3 * sigma; hi = mu + 3 * sigma; }
else if (shade === 'custom') { lo = mu + this.zLow * sigma; hi = mu + this.zHigh * sigma; }
else return;
const bottom = PT + ph;
const steps = 240;
const dx = (hi - lo) / steps;
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
// Filled area with gradient
const grd = ctx.createLinearGradient(xp(lo), 0, xp(hi), 0);
grd.addColorStop(0, 'rgba(155,93,229,0.10)');
grd.addColorStop(0.5, 'rgba(155,93,229,0.30)');
grd.addColorStop(1, 'rgba(155,93,229,0.10)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.moveTo(xp(lo), bottom);
for (let i = 0; i <= steps; i++) {
const x = lo + i * dx;
ctx.lineTo(xp(x), yp(this._pdf(x)));
}
ctx.lineTo(xp(hi), bottom);
ctx.closePath(); ctx.fill();
// Border dashes
ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
for (const bx of [lo, hi]) {
const px = xp(bx);
ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke();
}
ctx.setLineDash([]);
}
_drawCurve(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx } = this;
const steps = Math.min(pw * 2, 500);
const dx = (xMax - xMin) / steps;
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
// Glow layer
ctx.strokeStyle = 'rgba(155,93,229,0.1)'; ctx.lineWidth = 10; ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const x = xMin + i * dx;
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
}
ctx.stroke();
// Main curve
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const x = xMin + i * dx;
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
}
ctx.stroke();
// μ marker
const muPx = xp(this.mu);
const bottom = PT + ph;
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(muPx, PT); ctx.lineTo(muPx, bottom); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#06D6E0';
ctx.font = 'bold 11px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(`\u03bc = ${this.mu.toFixed(1)}`, muPx, PT - 4);
// Peak label
const peakPx = xp(this.mu);
const peakPy = yp(this._pdf(this.mu));
ctx.fillStyle = 'rgba(155,93,229,0.5)';
ctx.font = '9px Manrope, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
const peakVal = (1 / (this.sigma * Math.sqrt(2 * Math.PI))).toFixed(3);
ctx.fillText('f(μ) = ' + peakVal, peakPx + 6, peakPy - 2);
}
_drawLabels(PL, PT, pw, ph, xMin, xMax, yMax) {
// sigma annotation brackets
const { ctx, mu, sigma, shade } = this;
if (shade === 'none') return;
const nSig = shade === '1s' ? 1 : shade === '2s' ? 2 : shade === '3s' ? 3 : null;
if (!nSig) return;
const bottom = PT + ph;
const FN = 'Manrope, sans-serif';
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
// Annotate ±nσ points with small bracket
const lo = mu - nSig * sigma, hi = mu + nSig * sigma;
const loPx = xp(lo), hiPx = xp(hi);
const midY = bottom + 32;
ctx.save();
ctx.strokeStyle = 'rgba(155,93,229,0.38)'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(loPx, bottom + 4); ctx.lineTo(loPx, midY);
ctx.lineTo(hiPx, midY); ctx.lineTo(hiPx, bottom + 4);
ctx.stroke();
ctx.fillStyle = 'rgba(155,93,229,0.55)';
ctx.font = `10px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('\u00b1' + nSig + '\u03c3', (loPx + hiPx) / 2, midY + 2);
ctx.restore();
}
_drawBadge(PL, PT, pw, ph) {
const { ctx, shade } = this;
if (shade === 'none') return;
const info = this.info();
const pct = parseFloat(info.areaPct);
if (!pct) return;
const FN = 'Manrope, sans-serif';
ctx.save();
ctx.font = `bold 15px ${FN}`;
const text = pct.toFixed(2) + '%';
const tw = ctx.measureText(text).width;
const bw = tw + 24, bh = 28;
const bx = PL + pw - bw - 4, by = PT + 4;
ctx.fillStyle = 'rgba(155,93,229,0.16)';
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.fill();
ctx.strokeStyle = 'rgba(155,93,229,0.38)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.stroke();
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, bx + bw / 2, by + bh / 2);
const shadeNames = { '1s': '\u03bc \u00b1 1\u03c3', '2s': '\u03bc \u00b1 2\u03c3', '3s': '\u03bc \u00b1 3\u03c3', custom: 'произвольный Z' };
ctx.font = `9px ${FN}`; ctx.fillStyle = 'rgba(155,93,229,0.55)';
ctx.fillText(shadeNames[shade] || '', bx + bw / 2, by + bh + 10);
ctx.restore();
}
_drawHover(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx, W } = this;
const x = this.hx;
if (x < xMin || x > xMax) return;
const px = this._xToP(x, xMin, xMax, PL, pw);
const y = this._pdf(x);
const py = this._yToP(y, yMax, PT, ph);
const bottom = PT + ph;
const FN = 'Manrope, sans-serif';
// Vertical crosshair
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke();
ctx.setLineDash([]);
// Point on curve
ctx.fillStyle = '#FFD166'; ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.stroke();
// Tooltip
const z = (x - this.mu) / this.sigma;
const rows = [
['x', x.toFixed(3)],
['z', z.toFixed(3)],
['f(x)', y.toFixed(5)],
['\u03a6(z)', (this._phi(z) * 100).toFixed(2) + '%'],
];
ctx.font = `11px ${FN}`;
const maxKW = Math.max(...rows.map(([k]) => ctx.measureText(k).width));
const maxVW = Math.max(...rows.map(([, v]) => ctx.measureText(v).width));
const tw = maxKW + maxVW + 26, th = rows.length * 18 + 14;
let tx = px + 14, ty = py - th / 2;
if (tx + tw > W - 8) tx = px - tw - 14;
if (ty < PT + 4) ty = PT + 4;
if (ty + th > bottom) ty = bottom - th;
ctx.fillStyle = 'rgba(10,10,28,0.95)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke();
ctx.textBaseline = 'middle';
rows.forEach(([k, v], i) => {
const ry = ty + 7 + i * 18 + 9;
ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.textAlign = 'left'; ctx.fillText(k, tx + 10, ry);
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'right'; ctx.fillText(v, tx + tw - 10, ry);
});
}
// ── events ────────────────────────────────────────────────────
_bind() {
const cv = this.canvas;
const getHx = e => {
const r = cv.getBoundingClientRect();
const { PL, PR } = this._pad();
const pw = this.W - PL - PR;
const xMin = this.mu - 4.5 * this.sigma;
const xMax = this.mu + 4.5 * this.sigma;
return this._pToX(e.clientX - r.left, xMin, xMax, PL, pw);
};
cv.addEventListener('mousemove', e => { this.hx = getHx(e); this.draw(); });
cv.addEventListener('mouseleave', () => { this.hx = null; this.draw(); });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 1) { this.hx = getHx(e.touches[0]); this.draw(); }
}, { passive: false });
cv.addEventListener('touchend', () => { this.hx = null; this.draw(); });
}
}