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

465 lines
16 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';
/* ══════════════════════════════════════════════════════════════
IsoprocessSim — PV-diagram for 4 ideal-gas isoprocesses
n = 1, R = 0.0821 L·atm/mol·K; energies in Joules
Isothermal PV = const ΔU=0, W=nRT·ln(V2/V1), Q=W
Isochoric V = const W=0, ΔU=νCvΔT, Q=ΔU
Isobaric P = const W=PΔV, ΔU=νCvΔT, Q=ΔU+W
Adiabatic PV^γ = const Q=0, ΔU=-W, W=PΔV/(γ-1)
══════════════════════════════════════════════════════════════ */
class IsoprocessSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.n = 1;
this.R = 0.0821; // L·atm / mol·K
this.R_J = 8.314; // J / mol·K
this.gamma = 1.4; // 7/5 diatomic default
/* state */
this.P1 = 3.0; // atm
this.V1 = 10.0; // L
this._ratio = 0.5; // 0..1, maps end state position along process
/* process */
this.process = 'isothermal';
/* axis range */
this.Vmin = 1; this.Vmax = 33;
this.Pmin = 0.2; this.Pmax = 9.5;
/* margins */
this.ML = 52; this.MB = 46; this.MT = 20; this.MR = 18;
this._drag = null; // 'state1' | 'state2'
this.onUpdate = null;
this._bindEvents();
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;
}
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));
this.draw(); this._emit();
}
setRatio(r) { this._ratio = Math.max(0.01, Math.min(0.99, +r)); this.draw(); this._emit(); }
/* ── coordinate transforms ─────────────────── */
_pw() { return this.W - this.ML - this.MR; }
_ph() { return this.H - this.MT - this.MB; }
_vx(v) { return this.ML + (v - this.Vmin) / (this.Vmax - this.Vmin) * this._pw(); }
_py(p) { return this.MT + (1 - (p - this.Pmin) / (this.Pmax - this.Pmin)) * this._ph(); }
_xv(x) { return this.Vmin + (x - this.ML) / this._pw() * (this.Vmax - this.Vmin); }
_yp(y) { return this.Pmin + (1 - (y - this.MT) / this._ph()) * (this.Pmax - this.Pmin); }
/* ── physics ───────────────────────────────── */
_T(P, V) { return P * V / (this.n * this.R); }
_state2() {
const { P1, V1, _ratio, gamma } = this;
/* ratio in [0..1] → multiplier in [0.2..3.5] for V2/V1 or P2/P1 */
const mult = 0.2 + _ratio * 3.3;
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
const clampP = p => Math.max(this.Pmin + 0.05, Math.min(this.Pmax - 0.1, p));
switch (this.process) {
case 'isothermal': {
const V2 = clampV(V1 * mult);
return { P2: clampP(P1 * V1 / V2), V2 };
}
case 'isochoric': {
return { P2: clampP(P1 * mult), V2: V1 };
}
case 'isobaric': {
const V2 = clampV(V1 * mult);
return { P2: P1, V2 };
}
case 'adiabatic': {
const V2 = clampV(V1 * mult);
return { P2: clampP(P1 * Math.pow(V1 / V2, gamma)), V2 };
}
}
return { P2: P1, V2: V1 };
}
info() {
const { P1, V1, n, R_J, gamma } = this;
const T1 = this._T(P1, V1);
const { P2, V2 } = this._state2();
const T2 = this._T(P2, V2);
/* internal energy: ΔU = νCvΔT, Cv = R/(γ-1) */
const Cv_J = R_J / (gamma - 1);
const dU_J = n * Cv_J * (T2 - T1);
/* P in Pa = P_atm * 101325, V in m³ = V_L * 0.001 */
const P1Pa = P1 * 101325, P2Pa = P2 * 101325;
const V1m3 = V1 * 0.001, V2m3 = V2 * 0.001;
let W_J = 0, Q_J = 0;
switch (this.process) {
case 'isothermal':
W_J = n * R_J * T1 * Math.log(V2 / V1);
Q_J = W_J; break;
case 'isochoric':
W_J = 0; Q_J = dU_J; break;
case 'isobaric':
W_J = P1Pa * (V2m3 - V1m3);
Q_J = dU_J + W_J; break;
case 'adiabatic':
Q_J = 0;
W_J = -dU_J; break;
}
const fmt = x => (x >= 0 ? '+' : '') + Math.round(x);
return {
P1: P1.toFixed(2), V1: V1.toFixed(1), T1: Math.round(T1),
P2: P2.toFixed(2), V2: V2.toFixed(1), T2: Math.round(T2),
W: fmt(W_J), Q: fmt(Q_J), dU: fmt(Math.round(dU_J)),
W_raw: W_J, Q_raw: Q_J, dU_raw: dU_J,
process: this.process,
};
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── draw ──────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
this._drawGrid(ctx);
this._drawBgCurves(ctx);
this._drawActiveCurve(ctx);
this._drawPoints(ctx);
this._drawInfoBox(ctx);
}
_drawGrid(ctx) {
const { ML, MT, MR, MB } = this;
const pw = this._pw(), ph = this._ph();
/* plot background */
ctx.fillStyle = 'rgba(255,255,255,0.018)';
ctx.fillRect(ML, MT, pw, ph);
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,0.055)';
ctx.lineWidth = 1; ctx.setLineDash([]);
for (let v = 5; v <= 30; v += 5) {
const x = this._vx(v);
ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke();
}
for (let p = 1; p <= 9; p++) {
const y = this._py(p);
ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(ML + pw, y); ctx.stroke();
}
/* axes */
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph);
ctx.stroke();
/* tick labels */
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let p = 1; p <= 9; p++) {
const y = this._py(p);
if (y < MT + 2 || y > MT + ph - 2) continue;
ctx.fillText(p, ML - 6, y);
}
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let v = 5; v <= 30; v += 5) {
const x = this._vx(v);
ctx.fillText(v, x, MT + ph + 5);
}
/* axis titles */
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = '12px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('V, л', ML + pw / 2, MT + ph + 32);
ctx.save();
ctx.translate(13, MT + ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('P, атм', 0, 0);
ctx.restore();
}
_COLORS = {
isothermal: '#EF476F',
isochoric: '#06D6E0',
isobaric: '#7BF5A4',
adiabatic: '#FFD166',
};
/* draw one process curve through (P1,V1) */
_curve(ctx, process, alpha, lw, dashed) {
const { P1, V1, gamma, Vmin, Vmax, Pmin, Pmax } = this;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = this._COLORS[process];
ctx.lineWidth = lw;
ctx.setLineDash(dashed ? [5, 4] : []);
ctx.beginPath();
if (process === 'isochoric') {
const x = this._vx(V1);
ctx.moveTo(x, this._py(Pmax));
ctx.lineTo(x, this._py(Pmin));
} else {
let started = false;
const steps = 300;
for (let i = 0; i <= steps; i++) {
const v = Vmin + (Vmax - Vmin) * i / steps;
let p;
if (process === 'isothermal') p = P1 * V1 / v;
else if (process === 'isobaric') p = P1;
else p = P1 * Math.pow(V1 / v, gamma); // adiabatic
if (p < Pmin || p > Pmax + 0.1) { started = false; continue; }
const x = this._vx(v), y = this._py(Math.min(p, Pmax));
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
_drawBgCurves(ctx) {
for (const p of ['isothermal', 'isochoric', 'isobaric', 'adiabatic']) {
if (p !== this.process) this._curve(ctx, p, 0.14, 1.2, true);
}
/* legend dots */
const names = { isothermal: 'Изотерма', isochoric: 'Изохора', isobaric: 'Изобара', adiabatic: 'Адиабата' };
ctx.font = '10px Manrope, system-ui, sans-serif';
let lx = this.ML + this._pw() - 8, ly = this.MT + 8;
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
for (const [proc, label] of Object.entries(names)) {
const col = this._COLORS[proc];
const isCur = proc === this.process;
ctx.globalAlpha = isCur ? 0.85 : 0.3;
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(lx + 5, ly + 4, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillText(label, lx - 3, ly);
ly += 16;
}
ctx.globalAlpha = 1;
}
_drawActiveCurve(ctx) {
/* full curve dimmed */
this._curve(ctx, this.process, 0.3, 1.5, false);
/* highlighted segment state1 → state2 */
const { P1, V1, gamma } = this;
const { P2, V2 } = this._state2();
const color = this._COLORS[this.process];
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2.8;
ctx.setLineDash([]);
const steps = 200;
const [Vs, Ve] = V2 >= V1 ? [V1, V2] : [V2, V1];
if (this.process === 'isochoric') {
const x = this._vx(V1);
const y1c = this._py(P1), y2c = this._py(P2);
ctx.beginPath(); ctx.moveTo(x, y1c); ctx.lineTo(x, y2c); ctx.stroke();
this._arrowHead(ctx, x, y1c, x, y2c, color);
} else {
ctx.beginPath();
let started = false;
for (let i = 0; i <= steps; i++) {
const v = Vs + (Ve - Vs) * i / steps;
let p;
if (this.process === 'isothermal') p = P1 * V1 / v;
else if (this.process === 'isobaric') p = P1;
else p = P1 * Math.pow(V1 / v, gamma);
const x = this._vx(v), y = this._py(p);
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
ctx.stroke();
/* arrow at ~80% of segment */
const vArr = Vs + (Ve - Vs) * 0.8;
const vArr2 = Vs + (Ve - Vs) * 0.82;
let p1a, p2a;
if (this.process === 'isothermal') { p1a = P1*V1/vArr; p2a = P1*V1/vArr2; }
else if (this.process === 'isobaric') { p1a = P1; p2a = P1; }
else { p1a = P1*Math.pow(V1/vArr,gamma); p2a = P1*Math.pow(V1/vArr2,gamma); }
/* ensure arrow points from 1→2 */
const dir = V2 > V1 ? 1 : -1;
this._arrowHead(ctx,
this._vx(vArr + dir*0), this._py(p1a + dir*0),
this._vx(vArr2 + dir*0), this._py(p2a + dir*0), color);
}
ctx.restore();
}
_arrowHead(ctx, x1, y1, x2, y2, color) {
const angle = Math.atan2(y2 - y1, x2 - x1);
const s = 10;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - s * Math.cos(angle - 0.4), y2 - s * Math.sin(angle - 0.4));
ctx.lineTo(x2 - s * Math.cos(angle + 0.4), y2 - s * Math.sin(angle + 0.4));
ctx.closePath(); ctx.fill();
}
_drawPoints(ctx) {
const { P2, V2 } = this._state2();
const color = this._COLORS[this.process];
const dot = (x, y, fill, label, textX, textY) => {
ctx.fillStyle = fill;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.stroke();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.fillStyle = fill; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(label, textX, textY);
};
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
const x2 = this._vx(V2), y2 = this._py(P2);
dot(x1, y1, '#9B5DE5', '1', x1 - 12, y1 - 4);
dot(x2, y2, color, '2', x2 + 12, y2 - 4);
}
_drawInfoBox(ctx) {
const info = this.info();
const color = this._COLORS[info.process];
const names = { isothermal:'Изотермический', isochoric:'Изохорный', isobaric:'Изобарный', adiabatic:'Адиабатический' };
const formulas = { isothermal:'PV = const', isochoric:'V = const', isobaric:'P = const', adiabatic:'PV^γ = const' };
const bx = this.ML + 6, by = this.MT + 6;
const boxW = 205, boxH = 98;
ctx.fillStyle = 'rgba(13,13,26,0.9)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(`${names[info.process]} ${formulas[info.process]}`, bx + 10, by + 8);
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillText(`T₁ = ${info.T1} K → T₂ = ${info.T2} K`, bx + 10, by + 28);
const wColor = info.W_raw > 0 ? '#7BF5A4' : info.W_raw < 0 ? '#EF476F' : 'rgba(255,255,255,0.4)';
const qColor = info.Q_raw > 0 ? '#FFD166' : info.Q_raw < 0 ? '#06D6E0' : 'rgba(255,255,255,0.4)';
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('W =', bx + 10, by + 48);
ctx.fillStyle = wColor; ctx.fillText(`${info.W} Дж`, bx + 38, by + 48);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('Q =', bx + 10, by + 65);
ctx.fillStyle = qColor; ctx.fillText(`${info.Q} Дж`, bx + 38, by + 65);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('ΔU =', bx + 10, by + 82);
ctx.fillStyle = 'rgba(255,255,255,0.65)'; ctx.fillText(`${info.dU} Дж`, bx + 40, by + 82);
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
const pos = e => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return {
px: (t.clientX - r.left) * (this.W / r.width),
py: (t.clientY - r.top) * (this.H / r.height),
};
};
const hit = (px, py) => {
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
if (Math.hypot(px - x1, py - y1) < 18) return 'state1';
const { P2, V2 } = this._state2();
const x2 = this._vx(V2), y2 = this._py(P2);
if (Math.hypot(px - x2, py - y2) < 18) return 'state2';
return null;
};
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
const clampP = p => Math.max(this.Pmin + 0.1, Math.min(this.Pmax - 0.1, p));
const onDown = e => { const { px, py } = pos(e); this._drag = hit(px, py); };
const onMove = e => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { px, py } = pos(e);
const v = this._xv(px), p = this._yp(py);
if (this._drag === 'state1') {
this.V1 = clampV(v); this.P1 = clampP(p);
} else {
/* constrain state2 to current process curve */
switch (this.process) {
case 'isothermal': case 'isobaric': case 'adiabatic': {
const V2 = clampV(v);
this._ratio = Math.max(0.01, Math.min(0.99, (V2 / this.V1 - 0.2) / 3.3));
break;
}
case 'isochoric': {
const P2 = clampP(p);
this._ratio = Math.max(0.01, Math.min(0.99, (P2 / this.P1 - 0.2) / 3.3));
break;
}
}
}
this.draw(); this._emit();
};
const onUp = () => { this._drag = null; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { px, py } = pos(e);
cv.style.cursor = hit(px, py) ? 'grab' : 'default';
});
}
}