Files
Learn_System/frontend/js/labs/graphtransform.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
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>
2026-04-13 18:04:59 +03:00

357 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ══════════════════════════════════════════════════════════════
GraphTransformSim — graph transformations explorer
y = a·f(k·x + b) + c with sliders for a, k, b, c
Original f(x) shown faded, transformed shown bold.
══════════════════════════════════════════════════════════════ */
class GraphTransformSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* base function */
this._baseFn = x => Math.sin(x);
this._baseLabel = 'sin(x)';
/* transform params */
this.a = 1;
this.k = 1;
this.b = 0;
this.c = 0;
/* view */
this.ox = 0;
this.oy = 0;
this.scl = 40;
this.hx = null;
this._drag = null;
this.onUpdate = null;
this._bind();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public ──────────────────────────────────────── */
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, k: this.k, b: this.b, c: this.c }; }
setParams({ a, k, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (k !== undefined) this.k = +k;
if (b !== undefined) this.b = +b;
if (c !== undefined) this.c = +c;
this.draw();
this._emit();
}
setBase(name) {
const BASES = {
'sin': { fn: x => Math.sin(x), label: 'sin(x)' },
'cos': { fn: x => Math.cos(x), label: 'cos(x)' },
'x^2': { fn: x => x * x, label: 'x²' },
'sqrt': { fn: x => x >= 0 ? Math.sqrt(x) : NaN, label: '√x' },
'|x|': { fn: x => Math.abs(x), label: '|x|' },
'1/x': { fn: x => x !== 0 ? 1 / x : NaN, label: '1/x' },
'x^3': { fn: x => x * x * x, label: 'x³' },
};
const b = BASES[name];
if (b) { this._baseFn = b.fn; this._baseLabel = b.label; 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, k, b, c } = this;
const parts = [];
if (a !== 1) parts.push(a === -1 ? '' : a.toFixed(1) + '·');
parts.push(this._baseLabel.replace('x', this._innerStr()));
if (c > 0) parts.push(' + ' + c.toFixed(1));
if (c < 0) parts.push(' ' + Math.abs(c).toFixed(1));
return {
base: this._baseLabel,
equation: 'y = ' + parts.join(''),
a: a.toFixed(1),
k: k.toFixed(1),
b: b.toFixed(1),
c: c.toFixed(1),
};
}
/* ── internals ──────────────────────────────────── */
_innerStr() {
const { k, b } = this;
let s = '';
if (k !== 1) s += (k === -1 ? '' : k.toFixed(1) + '·');
s += 'x';
if (b > 0) s += ' + ' + b.toFixed(1);
if (b < 0) s += ' ' + Math.abs(b).toFixed(1);
return s;
}
_fBase(x) { try { return this._baseFn(x); } catch { return NaN; } }
_fTransformed(x) {
const inner = this.k * x + this.b;
const base = this._fBase(inner);
return this.a * base + 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._drawCurve(ctx, W, H, x => this._fBase(x), 'rgba(255,255,255,0.18)', 2); // original faded
this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5); // transformed bold
this._drawEquation(ctx, W, H);
if (this.hx !== null) this._drawHover(ctx, W, H);
}
_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();
}
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);
}
}
_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;
}
_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();
ctx.fillStyle = 'rgba(255,255,255,0.4)';
const s = 5;
// x arrow
ctx.save(); ctx.translate(W - 8, ay); 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();
// y arrow
ctx.save(); ctx.translate(ax, 6); ctx.rotate(-Math.PI / 2); 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();
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);
}
_drawCurve(ctx, W, H, fn, color, lw) {
const steps = Math.min(W * 2, 2000);
const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0);
const dx = (x1 - x0) / steps;
const maxJmp = (H / this.scl) * 2;
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineJoin = 'round';
ctx.beginPath();
let pen = false, pyPrev = null;
for (let i = 0; i <= steps; i++) {
const mx = x0 + i * dx;
const my = fn(mx);
if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }
if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) pen = false;
const [px, py] = this._toPx(mx, my);
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
pen = true; pyPrev = my;
}
ctx.stroke();
}
_drawEquation(ctx, W, H) {
const info = this.info();
ctx.font = 'bold 13px Manrope, sans-serif';
const text = info.equation;
const tw = ctx.measureText(text).width;
const x = W - tw - 24, y = 14;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(x, y, tw + 16, 26, 8); ctx.fill();
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(x, y, tw + 16, 26, 8); ctx.stroke();
ctx.fillStyle = '#ddd';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(text, x + 8, y + 13);
// base function label (faded)
const base = 'f(x) = ' + this._baseLabel;
ctx.font = '11px Manrope, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillText(base, x + 8, y + 38);
}
_drawHover(ctx, W, H) {
const [px] = this._toPx(this.hx, 0);
const myOrig = this._fBase(this.hx);
const myTrans = this._fTransformed(this.hx);
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([]);
// original point
if (isFinite(myOrig)) {
const [, py] = this._toPx(this.hx, myOrig);
if (py > -20 && py < H + 20) {
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2); ctx.fill();
}
}
// transformed point
if (isFinite(myTrans)) {
const [, py2] = this._toPx(this.hx, myTrans);
if (py2 > -20 && py2 < H + 20) {
ctx.fillStyle = '#9B5DE5';
ctx.beginPath(); ctx.arc(px, py2, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
}
}
}
/* ── 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';
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; });
}
}