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
+433
View File
@@ -0,0 +1,433 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
QuadraticSim — interactive quadratic equation explorer
y = ax² + bx + c · discriminant, roots, vertex
══════════════════════════════════════════════════════════════ */
class QuadraticSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* coefficients */
this.a = 1;
this.b = 0;
this.c = -1;
/* view */
this.ox = 0;
this.oy = 0;
this.scl = 40; // px per unit
/* interaction */
this._drag = null;
this.hx = null; // hovered math x
/* callback */
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({ a, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (b !== undefined) this.b = +b;
if (c !== undefined) this.c = +c;
this.draw();
this._emit();
}
resetView() { this.ox = 0; this.oy = 0; this.scl = 40; this.draw(); }
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
info() {
const { a, b, c } = this;
const D = b * b - 4 * a * c;
let roots = '—';
let rootCount = 0;
if (a === 0) {
roots = b !== 0 ? `x = ${this._fmt(-c / b)}` : '—';
rootCount = b !== 0 ? 1 : 0;
} else if (D > 0.0001) {
const sqD = Math.sqrt(D);
const x1 = (-b - sqD) / (2 * a);
const x2 = (-b + sqD) / (2 * a);
roots = `x₁=${this._fmt(x1)}, x₂=${this._fmt(x2)}`;
rootCount = 2;
} else if (Math.abs(D) <= 0.0001) {
roots = `x = ${this._fmt(-b / (2 * a))}`;
rootCount = 1;
}
const vx = a !== 0 ? -b / (2 * a) : 0;
const vy = a !== 0 ? c - b * b / (4 * a) : 0;
return {
D: this._fmt(D),
rootCount,
roots,
vertex: a !== 0 ? `(${this._fmt(vx)}; ${this._fmt(vy)})` : '—',
equation: `y = ${a !== 1 ? (a === -1 ? '' : this._fmt(a)) : ''}${b >= 0 ? '+' : ''} ${this._fmt(Math.abs(b))}x ${c >= 0 ? '+' : ''} ${this._fmt(Math.abs(c))}`,
};
}
/* ── internals ────────────────────────────────────── */
_fmt(n) {
if (Number.isInteger(n)) return String(n);
return Math.abs(n) < 0.005 ? '0' : n.toFixed(2).replace(/\.?0+$/, '');
}
_f(x) {
return this.a * x * x + this.b * x + this.c;
}
_emit() {
if (this.onUpdate) this.onUpdate(this.info());
}
/* ── coordinate transforms ─────────────────────────── */
_toPx(mx, my) {
return [
this.W / 2 + (mx - this.ox) * this.scl,
this.H / 2 - (my - this.oy) * this.scl,
];
}
_toMath(px, py) {
return [
(px - this.W / 2) / this.scl + this.ox,
-(py - this.H / 2) / this.scl + this.oy,
];
}
/* ── draw ─────────────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
this._drawGrid(ctx, W, H);
this._drawAxes(ctx, W, H);
this._drawParabola(ctx, W, H);
this._drawFeatures(ctx, W, H);
if (this.hx !== null) this._drawHover(ctx, W, H);
}
/* ── grid & axes ──────────────────────────────────── */
_niceStep() {
const raw = this.W / this.scl / 8;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
return p;
}
_drawGrid(ctx, W, H) {
const step = this._niceStep();
const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0);
const [, y0] = this._toMath(0, H), [, y1] = this._toMath(0, 0);
const gx = Math.floor(x0 / step) * step;
const gy = Math.floor(y0 / step) * step;
ctx.strokeStyle = 'rgba(255,255,255,0.065)';
ctx.lineWidth = 1;
for (let x = gx; x <= x1 + step; x += step) {
const [px] = this._toPx(x, 0);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
}
for (let y = gy; y <= y1 + step; y += step) {
const [, py] = this._toPx(0, y);
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
}
// labels
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
const [axX, axY] = this._toPx(0, 0);
const lblY = Math.max(4, Math.min(H - 18, axY + 5));
const lblX = Math.max(28, Math.min(W - 6, axX - 5));
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let x = gx; x <= x1; x += step) {
if (Math.abs(x) < step * 0.01) continue;
const [px] = this._toPx(x, 0);
if (px < 18 || px > W - 18) continue;
ctx.fillText(this._fmtLabel(x, step), px, lblY);
}
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let y = gy; y <= y1; y += step) {
if (Math.abs(y) < step * 0.01) continue;
const [, py] = this._toPx(0, y);
if (py < 12 || py > H - 12) continue;
ctx.fillText(this._fmtLabel(y, step), lblX, py);
}
}
_fmtLabel(n, step) {
if (n === 0) return '0';
if (step >= 1 && Number.isInteger(n)) return String(n);
if (step < 0.001) return n.toExponential(1);
const dec = Math.max(0, -Math.floor(Math.log10(step)));
return n.toFixed(dec);
}
_drawAxes(ctx, W, H) {
const [ax, ay] = this._toPx(0, 0);
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W - 10, ay); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ax, H); ctx.lineTo(ax, 8); ctx.stroke();
// arrowheads
ctx.fillStyle = 'rgba(255,255,255,0.4)';
this._arrowHead(ctx, W - 8, ay, 0);
this._arrowHead(ctx, ax, 6, -Math.PI / 2);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = 'bold 12px Manrope, sans-serif';
ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
ctx.fillText('x', W - 10, ay - 13);
ctx.textBaseline = 'top'; ctx.textAlign = 'left';
ctx.fillText('y', ax + 7, 4);
}
_arrowHead(ctx, x, y, angle) {
const s = 5;
ctx.save(); ctx.translate(x, y); ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6);
ctx.closePath(); ctx.fill();
ctx.restore();
}
/* ── parabola curve ───────────────────────────────── */
_drawParabola(ctx, W, H) {
const steps = Math.min(W * 2, 2000);
const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0);
const dx = (x1 - x0) / steps;
// glow
ctx.strokeStyle = 'rgba(155,93,229,0.15)';
ctx.lineWidth = 8;
ctx.lineJoin = 'round';
ctx.beginPath();
let pen = false;
for (let i = 0; i <= steps; i++) {
const mx = x0 + i * dx;
const my = this._f(mx);
if (!isFinite(my)) { pen = false; continue; }
const [px, py] = this._toPx(mx, my);
if (py < -200 || py > H + 200) { pen = false; continue; }
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
pen = true;
}
ctx.stroke();
// main curve
ctx.strokeStyle = '#9B5DE5';
ctx.lineWidth = 2.5;
ctx.beginPath();
pen = false;
for (let i = 0; i <= steps; i++) {
const mx = x0 + i * dx;
const my = this._f(mx);
if (!isFinite(my)) { pen = false; continue; }
const [px, py] = this._toPx(mx, my);
if (py < -200 || py > H + 200) { pen = false; continue; }
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
pen = true;
}
ctx.stroke();
}
/* ── vertex, roots, axis of symmetry ──────────────── */
_drawFeatures(ctx, W, H) {
const { a, b, c } = this;
if (a === 0) return; // linear — no features
const vx = -b / (2 * a);
const vy = this._f(vx);
const D = b * b - 4 * a * c;
// axis of symmetry
const [symPx] = this._toPx(vx, 0);
ctx.strokeStyle = 'rgba(6,214,224,0.25)';
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(symPx, 0); ctx.lineTo(symPx, H); ctx.stroke();
ctx.setLineDash([]);
// vertex point
const [vpx, vpy] = this._toPx(vx, vy);
if (vpy > -20 && vpy < H + 20) {
ctx.fillStyle = '#06D6E0';
ctx.beginPath(); ctx.arc(vpx, vpy, 6, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
// label
ctx.fillStyle = '#06D6E0';
ctx.font = 'bold 12px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(`(${this._fmt(vx)}; ${this._fmt(vy)})`, vpx, vpy - 12);
}
// roots
if (D >= -0.0001) {
ctx.fillStyle = '#EF476F';
const roots = [];
if (D > 0.0001) {
const sqD = Math.sqrt(D);
roots.push((-b - sqD) / (2 * a));
roots.push((-b + sqD) / (2 * a));
} else {
roots.push(-b / (2 * a));
}
for (const rx of roots) {
const [rpx, rpy] = this._toPx(rx, 0);
if (rpx < -20 || rpx > W + 20) continue;
// root dot
ctx.fillStyle = '#EF476F';
ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
// label
ctx.fillStyle = '#EF476F';
ctx.font = '11px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(this._fmt(rx), rpx, rpy + 10);
}
}
// discriminant badge
const badgeColor = D > 0.0001 ? '#7BF5A4' : (D < -0.0001 ? '#EF476F' : '#FFD166');
const badgeText = D > 0.0001 ? `D = ${this._fmt(D)} > 0 (2 корня)` :
D < -0.0001 ? `D = ${this._fmt(D)} < 0 (нет корней)` :
`D = 0 (1 корень)`;
ctx.font = 'bold 12px Manrope, sans-serif';
const tw = ctx.measureText(badgeText).width;
const bx = W - tw - 28, by = 16;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(bx, by, tw + 20, 28, 8); ctx.fill();
ctx.strokeStyle = badgeColor; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, tw + 20, 28, 8); ctx.stroke();
ctx.fillStyle = badgeColor;
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(badgeText, bx + 10, by + 14);
}
/* ── hover crosshair ──────────────────────────────── */
_drawHover(ctx, W, H) {
const [px] = this._toPx(this.hx, 0);
const my = this._f(this.hx);
if (!isFinite(my)) return;
const [, py] = this._toPx(this.hx, my);
// vertical line
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
ctx.setLineDash([]);
if (py < -20 || py > H + 20) return;
// point
ctx.fillStyle = '#FFD166';
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
// tooltip
ctx.fillStyle = 'rgba(22,22,38,0.9)';
const text = `(${this._fmt(this.hx)}, ${this._fmt(my)})`;
ctx.font = '12px Manrope, sans-serif';
const tw2 = ctx.measureText(text).width;
const tx = px + 14, ty = py - 14;
ctx.beginPath(); ctx.roundRect(tx, ty - 10, tw2 + 16, 22, 6); ctx.fill();
ctx.fillStyle = '#FFD166';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(text, tx + 8, ty + 1);
}
/* ── events ───────────────────────────────────────── */
_bind() {
const cv = this.canvas;
cv.addEventListener('wheel', e => {
e.preventDefault();
const [mx, my] = this._toMath(e.offsetX, e.offsetY);
this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
const [nx, ny] = this._toMath(e.offsetX, e.offsetY);
this.ox -= nx - mx; this.oy -= ny - my;
this.draw();
}, { passive: false });
cv.addEventListener('mousedown', e => {
this._drag = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy };
cv.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', e => {
if (this._drag) {
this.ox = this._drag.ox - (e.clientX - this._drag.x) / this.scl;
this.oy = this._drag.oy + (e.clientY - this._drag.y) / this.scl;
this.draw();
} else {
const r = cv.getBoundingClientRect();
const lx = e.clientX - r.left, ly = e.clientY - r.top;
if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) {
this.hx = this._toMath(lx, ly)[0];
this.draw();
}
}
});
window.addEventListener('mouseup', () => {
this._drag = null;
cv.style.cursor = 'crosshair';
});
cv.addEventListener('mouseleave', () => {
this.hx = null; this.draw();
});
cv.style.cursor = 'crosshair';
// touch
let t0 = null;
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1)
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy };
}, { passive: true });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 1 && t0) {
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
this.draw();
}
}, { passive: false });
cv.addEventListener('touchend', () => { t0 = null; });
}
}