fd29acbbdd
Classroom performance: - WebSocket server (ws-server.js) for low-latency cursor & stroke preview Replaces HTTP POST per event → eliminates per-message auth overhead Session member cache (30s TTL) avoids SQLite query per WS message Fallback to HTTP POST when WS not connected - Cursor throttle reduced 100ms → 33ms (~30fps) - Stroke preview throttle reduced 50ms → 20ms - whiteboard.js: render() is now rAF-gated (_doRender/_rafPending) Multiple render() calls within one frame collapse into one repaint document.hidden check — zero CPU when tab is in background visibilitychange listener restores canvas on tab focus Guest board: - guestClassroom.js route: public token-based read-only access - guest-board.html: name entry + read-only whiteboard + SSE - SSE: addGuestClient/removeGuestClient/emitToGuests Screen share picker: - Discord-style modal with tab switching (screen/window/tab) - Live video preview before confirming share - useExistingScreenStream() in ClassroomRTC Fullscreen exit overlay: - #cr-fs-exit-overlay button inside cr-board-wrap - Visible only via CSS :fullscreen selector (touchpad users) File sharing from library: - Teacher picks file from library, sends as styled card in chat - crDownloadLibraryFile() fetches with Bearer auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
435 lines
14 KiB
JavaScript
435 lines
14 KiB
JavaScript
'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;
|
||
}
|
||
|
||
getParams() { return { a: this.a, b: this.b, c: this.c }; }
|
||
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; });
|
||
}
|
||
}
|