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>
This commit is contained in:
@@ -20,12 +20,14 @@
|
||||
* rtc.destroy();
|
||||
*/
|
||||
class ClassroomRTC {
|
||||
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive }) {
|
||||
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive, onMicLevel, vadThreshold }) {
|
||||
this._sid = sessionId;
|
||||
this._uid = userId;
|
||||
this._onSignal = onSignal; // fn(targetUid, payload)
|
||||
this._onScreen = onScreenStream; // fn(stream | null)
|
||||
this._onMicActive = onMicActive; // fn(uid, bool) — optional
|
||||
this._onMicLevel = onMicLevel; // fn(uid, level 0-100) — optional, fires every tick
|
||||
this._vadThreshold = vadThreshold ?? 12;
|
||||
|
||||
this._peers = new Map(); // uid → PeerState
|
||||
this._localStream = null; // mic audio
|
||||
@@ -62,15 +64,17 @@ class ClassroomRTC {
|
||||
src.connect(analyser);
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
let speaking = false;
|
||||
const THRESHOLD = 12;
|
||||
const THRESHOLD = this._vadThreshold;
|
||||
const timer = setInterval(() => {
|
||||
analyser.getByteFrequencyData(buf);
|
||||
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
|
||||
const level = Math.min(100, Math.round((avg / 64) * 100));
|
||||
const now = avg > THRESHOLD;
|
||||
if (now !== speaking) {
|
||||
speaking = now;
|
||||
try { this._onMicActive(uid, speaking); } catch {}
|
||||
try { if (this._onMicActive) this._onMicActive(uid, speaking); } catch {}
|
||||
}
|
||||
try { if (this._onMicLevel) this._onMicLevel(uid, level); } catch {}
|
||||
}, 120);
|
||||
this._vadTimers.set(uid, { ctx, timer });
|
||||
} catch {}
|
||||
@@ -307,10 +311,11 @@ class ClassroomRTC {
|
||||
|
||||
/* ── Screen sharing (teacher) ───────────────────────���─────────────────── */
|
||||
|
||||
async startScreenShare() {
|
||||
async startScreenShare(constraints = {}) {
|
||||
try {
|
||||
this._screenStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { cursor: 'always' }, audio: false,
|
||||
video: { cursor: 'always', ...constraints.video },
|
||||
audio: constraints.audio ?? false,
|
||||
});
|
||||
} catch { return null; }
|
||||
|
||||
@@ -340,6 +345,23 @@ class ClassroomRTC {
|
||||
}
|
||||
}
|
||||
|
||||
/** Take an already-acquired MediaStream (e.g. from a pre-picker) and share it. */
|
||||
async useExistingScreenStream(stream) {
|
||||
if (this._screenStream) {
|
||||
this._screenStream.getTracks().forEach(t => t.stop());
|
||||
this._screenStream = null;
|
||||
}
|
||||
this._screenStream = stream;
|
||||
const vt = this._screenStream.getVideoTracks()[0];
|
||||
if (!vt) return stream;
|
||||
for (const [, peer] of this._peers) {
|
||||
if (!peer.screenSender) {
|
||||
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
|
||||
}
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
isSharing() { return !!this._screenStream; }
|
||||
|
||||
/* ── Cleanup ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -792,7 +792,7 @@ class AngryBirdsSim {
|
||||
|
||||
/* Planet + g — second line, readable */
|
||||
ctx.font = '13px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.72)';
|
||||
ctx.fillText(`${pl.label} g = ${pl.g} м/с²`, 14, 50);
|
||||
ctx.fillText(`${pl.label.replace(/<svg[\s\S]*?<\/svg>/g, '').trim()} g = ${pl.g} м/с²`, 14, 50);
|
||||
|
||||
/* Wind reminder */
|
||||
if (lvl?.wind) {
|
||||
|
||||
@@ -50,6 +50,7 @@ class BohrAtomSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { level: this.level }; }
|
||||
setParams({ level } = {}) {
|
||||
if (level !== undefined) {
|
||||
const n = Math.max(1, Math.min(6, Math.round(+level)));
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */
|
||||
function _csClean(s) {
|
||||
if (!s || !s.includes('<svg')) return s;
|
||||
return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
|
||||
if (m.includes('x1="5" y1="12" x2="19"')) return '\u2192'; // → right arrow
|
||||
if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '\u2193'; // ↓ down (precip)
|
||||
if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '\u2191'; // ↑ up (gas)
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
ChemSandboxSim v2 — «Химическая песочница»
|
||||
• Колба Эрленмейера с реалистичным стеклом
|
||||
@@ -1089,7 +1101,7 @@ class ChemSandboxSim {
|
||||
// ── Молекулярное уравнение ──
|
||||
ctx.font = 'bold 17px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = rx.fx.none ? 'rgba(255,100,100,0.75)' : 'rgba(255,255,255,0.95)';
|
||||
ctx.fillText(rx.eq, W / 2, y);
|
||||
ctx.fillText(_csClean(rx.eq), W / 2, y);
|
||||
y += 22;
|
||||
|
||||
// ── Тип реакции + пояснение ──
|
||||
@@ -1109,7 +1121,7 @@ class ChemSandboxSim {
|
||||
if (rx.why) {
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||||
ctx.fillText(rx.why, W / 2, y);
|
||||
ctx.fillText(_csClean(rx.why), W / 2, y);
|
||||
y += 17;
|
||||
}
|
||||
|
||||
@@ -1117,7 +1129,7 @@ class ChemSandboxSim {
|
||||
if (rx.ionFull) {
|
||||
ctx.font = '13px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = 'rgba(155,200,255,0.60)';
|
||||
ctx.fillText('Полн.: ' + rx.ionFull, W / 2, y);
|
||||
ctx.fillText('Полн.: ' + _csClean(rx.ionFull), W / 2, y);
|
||||
y += 16;
|
||||
}
|
||||
|
||||
@@ -1125,7 +1137,7 @@ class ChemSandboxSim {
|
||||
if (rx.ionNet) {
|
||||
ctx.font = 'bold 13px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = 'rgba(123,245,164,0.75)';
|
||||
ctx.fillText('Сокр.: ' + rx.ionNet, W / 2, y);
|
||||
ctx.fillText('Сокр.: ' + _csClean(rx.ionNet), W / 2, y);
|
||||
y += 16;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ class CollisionSim {
|
||||
this.c.height = r.height || 420;
|
||||
}
|
||||
|
||||
getParams() { return { m1: this.m1, m2: this.m2, v1: this.v1, v2: this.v2, angle: this.angle, e: this.e }; }
|
||||
setParams(p) {
|
||||
if (p.m1 !== undefined) this.m1 = +p.m1;
|
||||
if (p.m2 !== undefined) this.m2 = +p.m2;
|
||||
|
||||
@@ -86,6 +86,7 @@ class ElectrolysisSim {
|
||||
this._initIons();
|
||||
}
|
||||
|
||||
getParams() { return { voltage: this.voltage, electrolyte: this.electrolyte }; }
|
||||
setParams({ voltage, electrolyte } = {}) {
|
||||
if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage));
|
||||
if (electrolyte !== undefined) {
|
||||
|
||||
@@ -48,6 +48,7 @@ class EquilibriumSim {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
getParams() { return { T: this.T, nA: this.nA, nB: this.nB, Ea_f: this.Ea_f, Ea_r: this.Ea_r }; }
|
||||
setParams({ T, nA, nB, Ea_f, Ea_r } = {}) {
|
||||
let needReset = false;
|
||||
if (T !== undefined) this.T = Math.max(200, Math.min(500, +T));
|
||||
|
||||
@@ -1787,7 +1787,7 @@ class ForceSandboxSim {
|
||||
|
||||
// Угловая скорость ω — фиолетовая метка справа от тела
|
||||
if (hasOmg) {
|
||||
const sym = b.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
|
||||
const sym = b.omega > 0 ? '\u21BB' : '\u21BA';
|
||||
const labX = b.x + halfW + 7;
|
||||
const labY = b.type === 'box' ? b.y - b.h * 0.12 : b.y - b.r * 0.15;
|
||||
ctx.save();
|
||||
@@ -1852,7 +1852,7 @@ class ForceSandboxSim {
|
||||
ctx.fillStyle = '#EF476F';
|
||||
ctx.fillText(`p=${(body.mass * spd / S).toFixed(1)} кг·м/с`, cx, cy + r + 12);
|
||||
if (Math.abs(body.omega) > 0.05) {
|
||||
const sym = body.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
|
||||
const sym = body.omega > 0 ? '\u21BB' : '\u21BA';
|
||||
ctx.fillStyle = '#9B5DE5';
|
||||
ctx.fillText(`${sym} ω=${Math.abs(body.omega).toFixed(2)} рад/с`, cx, cy + r + 22);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ class GraphTransformSim {
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,7 @@ class IsoprocessSim {
|
||||
|
||||
setProcess(p) { this.process = p; this.draw(); this._emit(); }
|
||||
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
|
||||
getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; }
|
||||
setParams({ P1, V1 } = {}) {
|
||||
if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1));
|
||||
if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1));
|
||||
|
||||
@@ -84,6 +84,7 @@ class MirrorSim {
|
||||
this.draw(); this._emit();
|
||||
}
|
||||
|
||||
getParams() { return { f: this.f, d: this.d, h: this.h }; }
|
||||
setParams({ f, d, h } = {}) {
|
||||
if (f !== undefined) this.f = Math.max(30, Math.min(300, +f));
|
||||
if (d !== undefined) this.d = Math.max(30, Math.min(490, +d));
|
||||
|
||||
@@ -34,6 +34,7 @@ class NormalDistSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { mu: this.mu, sigma: this.sigma, shade: this.shade, zLow: this.zLow, zHigh: this.zHigh }; }
|
||||
setParams({ mu, sigma, shade, zLow, zHigh } = {}) {
|
||||
if (mu !== undefined) this.mu = +mu;
|
||||
if (sigma !== undefined) this.sigma = Math.max(0.1, +sigma);
|
||||
|
||||
@@ -51,6 +51,9 @@ class PendulumSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() {
|
||||
return { L: this.L, g: this.g, theta: +(this.theta * 180 / Math.PI).toFixed(3), damping: this.damping };
|
||||
}
|
||||
setParams({ L, g, theta, damping } = {}) {
|
||||
if (L !== undefined) this.L = +L;
|
||||
if (g !== undefined) this.g = +g;
|
||||
|
||||
@@ -60,6 +60,7 @@ class ProbabilitySim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { mode: this.mode, trials: this.trials, speed: this.speed }; }
|
||||
setParams({ mode, trials, speed } = {}) {
|
||||
if (mode !== undefined) this.mode = mode;
|
||||
if (trials !== undefined) this.trials = Math.max(1, +trials);
|
||||
|
||||
@@ -88,6 +88,11 @@ class ProjectileSim {
|
||||
this._cw = w; this._ch = h;
|
||||
}
|
||||
|
||||
getParams() {
|
||||
return { v0: this.v0, angle: this.angle, h0: this.h0, g: this.g,
|
||||
drag: this.drag, Cd: this.Cd, mass: this.mass, wind: this.wind,
|
||||
bounce: this.bounce, restitution: this.restitution };
|
||||
}
|
||||
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) {
|
||||
if (v0 !== undefined) this.v0 = +v0;
|
||||
if (angle !== undefined) this.angle = +angle;
|
||||
|
||||
@@ -43,6 +43,7 @@ class QuadraticSim {
|
||||
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;
|
||||
|
||||
@@ -42,6 +42,7 @@ class RefractionSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
|
||||
setParams({ n1, n2, angle, dispersion } = {}) {
|
||||
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
|
||||
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
|
||||
|
||||
@@ -39,6 +39,7 @@ class ThinLensSim {
|
||||
this.W = w; this.H = h;
|
||||
}
|
||||
|
||||
getParams() { return { f: this.f, d: this.d, h: this.h }; }
|
||||
setParams({ f, d, h } = {}) {
|
||||
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
|
||||
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
|
||||
|
||||
@@ -67,6 +67,7 @@ class TitrationSim {
|
||||
|
||||
/* ── Public API ─────────────────────────────────────────── */
|
||||
|
||||
getParams() { return { acidConc: this.acidConc, baseConc: this.baseConc, acidVol: this.acidVol, indicator: this.indicator, acidType: this.acidType }; }
|
||||
setParams({ acidConc, baseConc, acidVol, indicator, acidType } = {}) {
|
||||
if (acidConc !== undefined) this.acidConc = Math.max(0.05, Math.min(1.0, +acidConc));
|
||||
if (baseConc !== undefined) this.baseConc = Math.max(0.05, Math.min(1.0, +baseConc));
|
||||
|
||||
@@ -792,7 +792,7 @@ class TriangleSim {
|
||||
|
||||
// Formula
|
||||
this._drawFormulaBox(ctx, this.W, this.H,
|
||||
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² − 2·${adjSide1Name}·${adjSide2Name}·cos${angName} <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ${c2.toFixed(2)} = ${check.toFixed(2)}`,
|
||||
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² \u2212 2\u00B7${adjSide1Name}\u00B7${adjSide2Name}\u00B7cos${angName} \u2192 ${c2.toFixed(2)} = ${check.toFixed(2)}`,
|
||||
'#fbbf24');
|
||||
|
||||
ctx.restore();
|
||||
@@ -845,7 +845,7 @@ class TriangleSim {
|
||||
const diff = Math.abs(hypArea - (leg1Area + leg2Area));
|
||||
const statusCol = isRight ? '#22d55e' : '#f59e0b';
|
||||
const statusText = isRight
|
||||
? `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${leg1Name}² + ${leg2Name}² = ${hypName}² (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
|
||||
? `\u2713 ${leg1Name}\u00B2 + ${leg2Name}\u00B2 = ${hypName}\u00B2 (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
|
||||
: `${leg1Name}² + ${leg2Name}² = ${(leg1Area + leg2Area).toFixed(2)} ≠ ${hypName}² = ${hypArea.toFixed(2)} (Δ = ${diff.toFixed(2)})`;
|
||||
|
||||
this._drawFormulaBox(ctx, this.W, this.H, statusText, statusCol);
|
||||
|
||||
@@ -59,6 +59,10 @@ class WavesSim {
|
||||
this._emit();
|
||||
}
|
||||
|
||||
getParams() {
|
||||
return { A1: this._A1, f1: this._f1, phi1: this._phi1, A2: this._A2, f2: this._f2, phi2: this._phi2,
|
||||
n: this._n, speed: this._speed, mode: this._mode };
|
||||
}
|
||||
setParams({ A1, f1, phi1, A2, f2, phi2, n, speed } = {}) {
|
||||
if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1));
|
||||
if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1));
|
||||
|
||||
+1523
-192
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user