Files
Learn_System/frontend/js/labs/equilibrium.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

477 lines
16 KiB
JavaScript

'use strict';
/**
* EquilibriumSim — Chemical equilibrium simulation.
* A + B ⇌ C + D with Arrhenius kinetics, Le Chatelier principle.
* Left: particle animation with collisions & reactions.
* Right (30%): live concentration graph over time.
*/
class EquilibriumSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.particles = [];
this.flashes = []; // [{x, y, t, maxT, color}]
this._history = []; // [{step, nA, nB, nC, nD}]
this._nextId = 0;
/* parameters */
this.T = 300; // temperature K
this.nA = 20; // initial A count
this.nB = 20; // initial B count
this.Ea_f = 50; // forward activation energy
this.Ea_r = 55; // reverse activation energy
/* runtime */
this._steps = 0;
this._raf = null;
this._dpr = 1;
this.playing = false;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ═══════════════════════ public API ═══════════════════════ */
fit() {
const dpr = window.devicePixelRatio || 1;
this._dpr = dpr;
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;
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));
if (Ea_f !== undefined) this.Ea_f = +Ea_f;
if (Ea_r !== undefined) this.Ea_r = +Ea_r;
if (nA !== undefined) { this.nA = Math.max(10, Math.min(40, +nA)); needReset = true; }
if (nB !== undefined) { this.nB = Math.max(10, Math.min(40, +nB)); needReset = true; }
if (needReset) this.reset();
this.draw();
this._emit();
}
preset(name) {
const presets = {
default: { T: 300, nA: 20, nB: 20, Ea_f: 50, Ea_r: 55 },
exothermic: { T: 280, nA: 20, nB: 20, Ea_f: 35, Ea_r: 65 },
endothermic: { T: 350, nA: 20, nB: 20, Ea_f: 65, Ea_r: 35 },
excess_A: { T: 300, nA: 35, nB: 15, Ea_f: 50, Ea_r: 55 },
};
const p = presets[name] || presets.default;
Object.assign(this, p);
this.reset();
}
reset() {
this.pause();
const { W, H } = this;
if (!W || !H) return;
this.particles = [];
this.flashes = [];
this._history = [];
this._steps = 0;
this._nextId = 0;
const simW = W * 0.7;
this._spawnType('A', this.nA, simW);
this._spawnType('B', this.nB, simW);
this._recordHistory();
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
info() {
let nA = 0, nB = 0, nC = 0, nD = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else if (p.type === 'C') nC++;
else nD++;
}
const cA = nA || 0.001, cB = nB || 0.001;
const cC = nC || 0.001, cD = nD || 0.001;
const Q = (cC * cD) / (cA * cB);
const keq = Math.exp((this.Ea_f - this.Ea_r) / (this.T * 0.05));
const direction = Q < keq * 0.95 ? '\u2192' : Q > keq * 1.05 ? '\u2190' : '\u21CC';
return { keq: +keq.toFixed(3), Q: +Q.toFixed(3), direction, nA, nB, nC, nD };
}
/* ═══════════════════════ internals ═══════════════════════ */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(() => {
for (let i = 0; i < 3; i++) this._step();
this.draw();
this._tick();
});
}
_color(type) {
return { A: '#EF476F', B: '#9B5DE5', C: '#7BF5A4', D: '#FFD166' }[type] || '#aaa';
}
_radius() { return 5; }
_spawnType(type, count, maxX) {
const { H } = this;
const r = this._radius();
const margin = 10;
let placed = 0, att = 0;
while (placed < count && att < count * 60) {
att++;
const x = margin + r + Math.random() * (maxX - 2 * r - margin * 2);
const y = margin + r + Math.random() * (H - 2 * r - margin * 2);
let overlap = false;
for (const p of this.particles) {
if ((p.x - x) ** 2 + (p.y - y) ** 2 < (p.r + r + 1) ** 2) { overlap = true; break; }
}
if (overlap) continue;
const a = Math.random() * Math.PI * 2;
const spd = 1.5 + Math.random() * 1.5;
this.particles.push({ x, y, vx: Math.cos(a) * spd, vy: Math.sin(a) * spd, r, type, id: this._nextId++ });
placed++;
}
}
_step() {
const { W, H } = this;
const simW = W * 0.7;
const dt = 0.6;
/* move + walls */
for (const p of this.particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); }
if (p.x > simW - p.r) { p.x = simW - p.r; p.vx = -Math.abs(p.vx); }
if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); }
if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); }
}
/* spatial grid */
const cs = 18;
const cols = Math.ceil(simW / cs) + 1;
const grid = new Map();
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
const k = Math.floor(p.x / cs) + Math.floor(p.y / cs) * cols;
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
const toRemove = new Set();
const toAdd = [];
/* collisions + reactions */
for (let i = 0; i < this.particles.length; i++) {
const p1 = this.particles[i];
if (toRemove.has(p1.id)) continue;
const cx = Math.floor(p1.x / cs), cy = Math.floor(p1.y / cs);
for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) {
const cell = grid.get((cx + dcx) + (cy + dcy) * cols);
if (!cell) continue;
for (const j of cell) {
if (j <= i) continue;
const p2 = this.particles[j];
if (toRemove.has(p2.id)) continue;
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const dist2 = dx * dx + dy * dy;
const minD = p1.r + p2.r;
if (dist2 >= minD * minD) continue;
const dist = Math.sqrt(dist2);
/* forward: A + B <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> C + D */
const isAB = (p1.type === 'A' && p2.type === 'B') || (p1.type === 'B' && p2.type === 'A');
if (isAB) {
const kf = Math.exp(-this.Ea_f / (this.T * 0.08)) * 0.35;
if (Math.random() < kf) {
toRemove.add(p1.id); toRemove.add(p2.id);
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
const a1 = Math.random() * Math.PI * 2;
const spd = 1.2 + Math.random();
toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'C', id: this._nextId++ });
toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'D', id: this._nextId++ });
this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '123,245,164' });
continue;
}
}
/* reverse: C + D <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> A + B */
const isCD = (p1.type === 'C' && p2.type === 'D') || (p1.type === 'D' && p2.type === 'C');
if (isCD) {
const kr = Math.exp(-this.Ea_r / (this.T * 0.08)) * 0.35;
if (Math.random() < kr) {
toRemove.add(p1.id); toRemove.add(p2.id);
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
const a1 = Math.random() * Math.PI * 2;
const spd = 1.2 + Math.random();
toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'A', id: this._nextId++ });
toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'B', id: this._nextId++ });
this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '239,71,111' });
continue;
}
}
/* elastic bounce */
if (dist > 0.001) {
const nx = dx / dist, ny = dy / dist;
const dvn = (p1.vx - p2.vx) * nx + (p1.vy - p2.vy) * ny;
if (dvn > 0) {
p1.vx -= dvn * nx; p1.vy -= dvn * ny;
p2.vx += dvn * nx; p2.vy += dvn * ny;
}
const ov = (minD - dist) * 0.5;
p1.x -= nx * ov; p1.y -= ny * ov;
p2.x += nx * ov; p2.y += ny * ov;
}
}
}
}
if (toRemove.size) this.particles = this.particles.filter(p => !toRemove.has(p.id));
for (const p of toAdd) this.particles.push(p);
this.flashes = this.flashes.filter(f => ++f.t < f.maxT);
this._steps++;
if (this._steps % 20 === 0) {
this._recordHistory();
this._emit();
}
}
_recordHistory() {
let nA = 0, nB = 0, nC = 0, nD = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else if (p.type === 'C') nC++;
else nD++;
}
this._history.push({ step: this._steps, nA, nB, nC, nD });
if (this._history.length > 300) this._history.shift();
}
/* ═══════════════════════ rendering ═══════════════════════ */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
const simW = W * 0.7;
/* background */
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
/* dot grid */
ctx.fillStyle = 'rgba(255,255,255,0.025)';
for (let x = 30; x < simW; x += 30)
for (let y = 30; y < H; y += 30) {
ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill();
}
/* divider */
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(simW - 1, 0, 2, H);
/* flashes */
for (const f of this.flashes) {
const prog = f.t / f.maxT;
const radius = prog * 38 + 4;
const alpha = (1 - prog) * 0.55;
const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, radius);
g.addColorStop(0, `rgba(${f.color},${alpha * 1.5})`);
g.addColorStop(0.4, `rgba(${f.color},${alpha * 0.4})`);
g.addColorStop(1, `rgba(${f.color},0)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(f.x, f.y, radius, 0, Math.PI * 2); ctx.fill();
}
/* particles */
for (const p of this.particles) this._drawParticle(ctx, p);
/* right panel: concentration graph */
this._drawGraph(ctx, simW, W, H);
/* stats overlay */
this._drawStats(ctx);
/* equation label */
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.font = "bold 11px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center';
ctx.fillText('A + B \u21CC C + D', simW / 2, H - 12);
}
_drawParticle(ctx, p) {
const col = this._color(p.type);
const { x, y, r } = p;
/* outer glow */
const glow = ctx.createRadialGradient(x, y, 0, x, y, r * 3.2);
glow.addColorStop(0, col + '44');
glow.addColorStop(1, col + '00');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(x, y, r * 3.2, 0, Math.PI * 2); ctx.fill();
/* body gradient */
const body = ctx.createRadialGradient(x - r * 0.25, y - r * 0.25, r * 0.05, x, y, r);
body.addColorStop(0, col + 'ff');
body.addColorStop(0.6, col + 'cc');
body.addColorStop(1, col + '88');
ctx.fillStyle = body;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
/* specular */
ctx.fillStyle = 'rgba(255,255,255,0.38)';
ctx.beginPath(); ctx.arc(x - r * 0.28, y - r * 0.28, r * 0.28, 0, Math.PI * 2); ctx.fill();
/* label */
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.font = `bold ${Math.round(r * 1.1)}px sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(p.type, x, y + 0.5);
ctx.textBaseline = 'alphabetic';
}
_drawGraph(ctx, x0, W, H) {
const gW = W - x0, pad = { l: 36, r: 10, t: 32, b: 28 };
const px = x0 + pad.l, py = pad.t;
const pw = gW - pad.l - pad.r;
const ph = H - pad.t - pad.b;
/* panel bg */
ctx.fillStyle = 'rgba(5,5,20,0.85)';
ctx.fillRect(x0, 0, gW, H);
/* title */
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left';
ctx.fillText('\u041A\u043E\u043D\u0446\u0435\u043D\u0442\u0440\u0430\u0446\u0438\u044F', x0 + 10, 16);
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const yl = py + ph * (i / 4);
ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke();
}
/* y-axis labels */
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right';
const maxN = Math.max(this.nA, this.nB) * 1.2 + 2;
for (let i = 0; i <= 4; i++) {
const v = Math.round(maxN * (4 - i) / 4);
ctx.fillText(v, px - 4, py + ph * (i / 4) + 3);
}
if (this._history.length < 2) return;
const n = this._history.length;
const lines = [
{ key: 'nA', color: '#EF476F', label: 'A' },
{ key: 'nB', color: '#9B5DE5', label: 'B' },
{ key: 'nC', color: '#7BF5A4', label: 'C' },
{ key: 'nD', color: '#FFD166', label: 'D' },
];
for (const { key, color } of lines) {
ctx.beginPath();
ctx.strokeStyle = color; ctx.lineWidth = 1.6;
for (let i = 0; i < n; i++) {
const lx = px + (i / Math.max(n - 1, 1)) * pw;
const ly = py + ph - Math.min(this._history[i][key] / maxN, 1) * ph;
i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly);
}
ctx.stroke();
}
/* legend */
lines.forEach(({ color, label }, i) => {
const lx = x0 + 10 + i * 38;
const ly = H - 14;
ctx.fillStyle = color;
ctx.fillRect(lx, ly, 10, 2.5);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left';
ctx.fillText(label, lx + 13, ly + 3);
});
/* current values */
const last = this._history[n - 1];
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = "8px monospace";
ctx.textAlign = 'right';
ctx.fillText(`A:${last.nA} B:${last.nB} C:${last.nC} D:${last.nD}`, x0 + gW - 8, H - 14);
}
_drawStats(ctx) {
const info = this.info();
const px = 10, py = 10, pw = 160, ph = 82;
ctx.fillStyle = 'rgba(5,5,20,0.82)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
const lh = 16;
ctx.fillStyle = '#7BF5A4';
ctx.fillText(`K\u2091\u2071 = ${info.keq}`, px + 10, py + 8);
ctx.fillStyle = '#FFD166';
ctx.fillText(`Q = ${info.Q}`, px + 10, py + 8 + lh);
ctx.fillStyle = '#06D6E0';
ctx.fillText(`\u041D\u0430\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435: ${info.direction}`, px + 10, py + 8 + lh * 2);
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.fillText(`T = ${this.T} K`, px + 10, py + 8 + lh * 3);
}
/* ═══════════════════════ utility ═══════════════════════ */
_rrect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
}
if (typeof module !== 'undefined') module.exports = EquilibriumSim;