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:
@@ -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)) : ''}x² ${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; });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user