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:
Maxim Dolgolyov
2026-04-13 18:04:59 +03:00
parent 074ee5687b
commit fd29acbbdd
70 changed files with 12231 additions and 498 deletions
+366 -6
View File
@@ -136,11 +136,12 @@
.sim-zoom-btns { display: flex; gap: 4px; }
.zoom-btn {
width: 32px; height: 32px; border-radius: 10px;
min-width: 32px; width: auto; height: 32px; border-radius: 10px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-2);
cursor: pointer; font-size: 1.1rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: .8rem; font-weight: 700;
padding: 0 9px; white-space: nowrap;
display: flex; align-items: center; justify-content: center; gap: 4px;
transition: all .15s;
}
.zoom-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.07); }
@@ -668,6 +669,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
@@ -933,6 +935,54 @@
</button>
</div>
<!-- hydrostatics controls -->
<div id="ctrl-hydro" class="sim-zoom-btns" style="display:none">
<select id="hydro-mode-sel" onchange="hydroMode(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
<option value="pressure">Давление P=ρgh</option>
<option value="surface">Пов. натяжение</option>
<option value="communicating">Сообщ. сосуды</option>
<option value="archimedes">Архимед</option>
</select>
<select id="hydro-liq-sel" onchange="hydroSim&&hydroSim.setLiquid(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
<option value="water">Вода</option>
<option value="saltwater">Солёная вода</option>
<option value="oil">Масло</option>
<option value="alcohol">Спирт</option>
<option value="glycerin">Глицерин</option>
<option value="mercury">Ртуть</option>
</select>
<div id="hydro-arch-ctrl" style="display:none;gap:4px;align-items:center">
<select id="hydro-mat-sel" onchange="hydroSim&&hydroSim.setMaterial(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
<option value="styrofoam">Пенопласт</option>
<option value="cork">Пробка</option>
<option value="wood">Дерево</option>
<option value="ice">Лёд</option>
<option value="plastic">Пластик</option>
<option value="glass">Стекло</option>
<option value="aluminum">Алюминий</option>
<option value="iron">Железо</option>
<option value="gold">Золото</option>
</select>
<button class="zoom-btn" onclick="hydroSim&&hydroSim.addBody()" title="Добавить тело">+ Тело</button>
<button class="zoom-btn" onclick="hydroSim&&hydroSim.clearBodies()" title="Очистить">Очистить</button>
</div>
<div id="hydro-comm-ctrl" style="display:none;gap:4px;align-items:center">
<label style="font-size:.72rem;color:rgba(255,255,255,.5)">Сосудов:</label>
<select onchange="hydroSim&&hydroSim.setNumVessels(+this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 6px;font-size:.72rem;cursor:pointer">
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<button class="zoom-btn" id="hydro-valve-btn" onclick="hydroToggleValve()" title="Кран">Кран: откр.</button>
</div>
<div id="hydro-surf-ctrl" style="display:none;gap:4px;align-items:center">
<label style="font-size:.72rem;color:rgba(255,255,255,.5);white-space:nowrap">θ:</label>
<input type="range" min="0" max="160" value="20" step="5" style="width:72px;accent-color:#9B5DE5" oninput="hydroSim&&hydroSim.setContactAngle(+this.value);document.getElementById('hydro-theta-val').textContent=this.value+'\u00B0';document.getElementById('hydro-theta-lbl').textContent=this.value+'\u00B0';document.querySelector('#hydro-panel-theta input[type=range]').value=this.value">
<span id="hydro-theta-val" style="font-size:.72rem;color:#9B5DE5;min-width:28px;white-space:nowrap">20°</span>
<button class="zoom-btn" id="hydro-surf-toggle" onclick="hydroToggleSurface()" title="Переключить: капилляры / капля" style="white-space:nowrap">Капилляры</button>
</div>
</div>
<!-- theory toggle (all sims) -->
<button class="zoom-btn" id="theory-toggle" onclick="toggleTheory()" title="Теория и формулы" style="margin-left:auto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
@@ -3537,6 +3587,97 @@
<div class="pstat"><div class="pstat-label">Диагональ</div><div class="pstat-val" id="stbar-d"></div></div>
</div>
<!-- ── HYDROSTATICS sim body ── -->
<div id="sim-hydro" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<!-- left panel -->
<div class="proj-panel" style="width:230px;gap:0;overflow-y:auto">
<div class="gp-section-title" style="margin-bottom:8px">Параметры</div>
<!-- liquid -->
<div style="margin-bottom:10px">
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Жидкость</div>
<select onchange="hydroSim&&hydroSim.setLiquid(this.value);document.getElementById('hydro-liq-sel').value=this.value" style="width:100%;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:5px 8px;font-size:.78rem">
<option value="water">Вода (1000 кг/м³)</option>
<option value="saltwater">Солёная вода (1030)</option>
<option value="oil">Масло (900)</option>
<option value="alcohol">Спирт (790)</option>
<option value="glycerin">Глицерин (1260)</option>
<option value="mercury">Ртуть (13600)</option>
</select>
</div>
<!-- material (Archimedes only) -->
<div id="hydro-panel-mat" style="margin-bottom:10px;display:none">
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Материал тела</div>
<select onchange="hydroSim&&hydroSim.setMaterial(this.value)" style="width:100%;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:5px 8px;font-size:.78rem">
<option value="styrofoam">Пенопласт (30 кг/м³)</option>
<option value="cork">Пробка (120)</option>
<option value="wood">Дерево (500)</option>
<option value="ice">Лёд (900)</option>
<option value="plastic">Пластик (1100)</option>
<option value="glass">Стекло (2500)</option>
<option value="aluminum">Алюминий (2700)</option>
<option value="iron">Железо (7800)</option>
<option value="gold">Золото (19300)</option>
</select>
<div style="display:flex;gap:5px;margin-top:6px">
<button class="gp-btn" onclick="hydroSim&&hydroSim.addBody()" style="flex:1">+ Тело</button>
<button class="gp-btn" onclick="hydroSim&&hydroSim.clearBodies()" style="flex:1">Очистить</button>
</div>
</div>
<!-- contact angle (surface tension) -->
<div id="hydro-panel-theta" style="margin-bottom:10px;display:none">
<div style="display:flex;justify-content:space-between;font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">
<span>Краевой угол θ</span>
<span id="hydro-theta-lbl" style="color:#9B5DE5">20°</span>
</div>
<input type="range" min="0" max="160" value="20" step="5" style="width:100%;accent-color:#9B5DE5" oninput="hydroSim&&hydroSim.setContactAngle(+this.value);document.getElementById('hydro-theta-lbl').textContent=this.value+'\u00B0';document.getElementById('hydro-theta-val').textContent=this.value+'\u00B0';document.querySelector('#hydro-surf-ctrl input[type=range]').value=this.value">
<div style="display:flex;justify-content:space-between;font-size:.65rem;color:rgba(255,255,255,.25);margin-top:2px">
<span>Смачивание</span><span>Несмачивание</span>
</div>
<div style="margin-top:6px">
<button class="gp-btn" id="hydro-surf-toggle-panel" onclick="hydroToggleSurface()" style="width:100%">Капилляры</button>
</div>
</div>
<!-- communicating vessels -->
<div id="hydro-panel-comm" style="margin-bottom:10px;display:none">
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Сосудов</div>
<div style="display:flex;gap:5px">
<button class="gp-btn hydro-nv active" onclick="hydroSetVessels(2,this)" style="flex:1">2</button>
<button class="gp-btn hydro-nv" onclick="hydroSetVessels(3,this)" style="flex:1">3</button>
<button class="gp-btn hydro-nv" onclick="hydroSetVessels(4,this)" style="flex:1">4</button>
</div>
<div style="margin-top:8px">
<button class="gp-btn" id="hydro-valve-panel-btn" onclick="hydroToggleValve()" style="width:100%;color:#06D6A0;border-color:rgba(6,214,160,.3)">Кран: открыт</button>
</div>
<div style="margin-top:6px;display:flex;gap:5px">
<button class="gp-btn" onclick="hydroSim&&hydroSim.addLiquid(0)" style="flex:1">+ Жидкость</button>
<button class="gp-btn" onclick="hydroSim&&hydroSim.removeLiquid()" style="flex:1">- Жидкость</button>
</div>
</div>
<!-- formula display -->
<div class="gp-section-title" style="margin-top:4px;margin-bottom:6px">Формулы</div>
<div id="hydro-formulas" style="font-size:.72rem;font-family:'JetBrains Mono',monospace;color:rgba(255,255,255,.6);line-height:1.7;background:rgba(255,255,255,.03);border-radius:8px;padding:8px 10px;min-height:80px"></div>
<!-- result badge -->
<div id="hydro-result" style="margin-top:8px;font-size:.82rem;font-weight:700;text-align:center;padding:8px;border-radius:8px;display:none"></div>
</div><!-- /.proj-panel -->
<!-- canvas area -->
<div style="flex:1;min-width:0;position:relative">
<canvas id="hydro-canvas" style="width:100%;height:100%;display:block"></canvas>
</div>
</div><!-- /.sim-body-wrap -->
</div><!-- /#sim-hydro -->
<!-- ── Theory panel (overlay right) ── -->
<div class="theory-panel" id="theory-panel">
<div class="theory-panel-inner" id="theory-content"></div>
@@ -4152,6 +4293,10 @@
title: 'Закон Кулона',
desc: 'Силовые линии и эквипотенциальные поверхности для системы точечных зарядов.',
preview: P_FIELD },
{ id: 'hydrostatics', cat: 'phys',
title: 'Гидростатика',
desc: 'Давление жидкости P=ρgh, закон Архимеда, сообщающиеся сосуды, поверхностное натяжение и капиллярность.',
preview: P_SANDBOX },
{ id: 'dynamics', cat: 'phys',
title: 'Динамика',
desc: 'Законы Ньютона, песочница сил, наклонная плоскость — всё в одном интерактивном модуле.',
@@ -4269,11 +4414,11 @@
'sim-quadratic','sim-normaldist','sim-graphtransform',
'sim-pendulum','sim-equilibrium','sim-thinlens','sim-titration',
'sim-refraction','sim-mirrors','sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis',
'sim-waves'];
'sim-waves','sim-hydro'];
const ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-mag',
'ctrl-molphys',
'ctrl-coulomb','ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox',
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves'];
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro'];
/* ── sim routing ── */
@@ -4324,6 +4469,8 @@
if (id === 'bohratom') _openBohrAtom();
if (id === 'electrolysis') _openElectrolysis();
if (id === 'waves') _openWaves();
if (id === 'hydrostatics') _openHydro();
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
}
function _simShow(elId) {
@@ -4423,6 +4570,21 @@
_simShow('sim-graph');
_simShow('ctrl-graph');
_registerSimState('graph',
() => ({
fns: [0,1,2].map(i => ({ expr: document.getElementById(`fn${i}`)?.value || '', color: FN_COLORS[i] }))
}),
(st) => {
if (!Array.isArray(st.fns)) return;
st.fns.forEach((fn, i) => {
const el = document.getElementById(`fn${i}`);
if (el) { el.value = fn.expr; }
if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]);
});
}
);
if (_embedMode) _startStateEmit('graph');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!gSim) {
gSim = new GraphSim(document.getElementById('graph-canvas'));
@@ -4446,6 +4608,8 @@
document.getElementById('sim-topbar-title').textContent = 'Бросок тела';
_simShow('sim-proj');
_simShow('ctrl-proj');
_registerSimState('projectile', () => pSim?.getParams(), st => pSim?.setParams(st));
if (_embedMode) _startStateEmit('projectile');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pSim) {
@@ -4629,6 +4793,8 @@
document.getElementById('sim-topbar-title').textContent = 'Столкновение шаров';
_simShow('sim-coll');
_simShow('ctrl-coll');
_registerSimState('collision', () => cSim?.getParams(), st => cSim?.setParams(st));
if (_embedMode) _startStateEmit('collision');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!cSim) {
@@ -6535,6 +6701,8 @@
function _openQuadratic() {
document.getElementById('sim-topbar-title').textContent = 'Корни квадратного уравнения';
_simShow('sim-quadratic');
_registerSimState('quadratic', () => quadSim?.getParams(), st => quadSim?.setParams(st));
if (_embedMode) _startStateEmit('quadratic');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!quadSim) {
quadSim = new QuadraticSim(document.getElementById('quadratic-canvas'));
@@ -6573,6 +6741,8 @@
function _openNormalDist() {
document.getElementById('sim-topbar-title').textContent = 'Нормальное распределение';
_simShow('sim-normaldist');
_registerSimState('normaldist', () => ndSim?.getParams(), st => ndSim?.setParams(st));
if (_embedMode) _startStateEmit('normaldist');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!ndSim) {
ndSim = new NormalDistSim(document.getElementById('normaldist-canvas'));
@@ -6617,6 +6787,8 @@
function _openGraphTransform() {
document.getElementById('sim-topbar-title').textContent = 'Трансформации графиков';
_simShow('sim-graphtransform');
_registerSimState('graphtransform', () => gtSim?.getParams(), st => gtSim?.setParams(st));
if (_embedMode) _startStateEmit('graphtransform');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!gtSim) {
gtSim = new GraphTransformSim(document.getElementById('graphtransform-canvas'));
@@ -6663,6 +6835,8 @@
function _openPendulum() {
document.getElementById('sim-topbar-title').textContent = 'Маятник';
_simShow('sim-pendulum');
_registerSimState('pendulum', () => pendSim?.getParams(), st => pendSim?.setParams(st));
if (_embedMode) _startStateEmit('pendulum');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pendSim) {
pendSim = new PendulumSim(document.getElementById('pendulum-canvas'));
@@ -6705,6 +6879,8 @@
function _openEquilibrium() {
document.getElementById('sim-topbar-title').textContent = 'Химическое равновесие';
_simShow('sim-equilibrium');
_registerSimState('equilibrium', () => eqSim?.getParams(), st => eqSim?.setParams(st));
if (_embedMode) _startStateEmit('equilibrium');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!eqSim) {
eqSim = new EquilibriumSim(document.getElementById('equilibrium-canvas'));
@@ -6746,6 +6922,8 @@
function _openThinLens() {
document.getElementById('sim-topbar-title').textContent = 'Тонкая линза';
_simShow('sim-thinlens');
_registerSimState('thinlens', () => lensSim?.getParams(), st => lensSim?.setParams(st));
if (_embedMode) _startStateEmit('thinlens');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!lensSim) {
lensSim = new ThinLensSim(document.getElementById('thinlens-canvas'));
@@ -6787,6 +6965,8 @@
function _openMirror() {
document.getElementById('sim-topbar-title').textContent = 'Зеркала';
_simShow('sim-mirrors');
_registerSimState('mirrors', () => mirrorSim?.getParams(), st => mirrorSim?.setParams(st));
if (_embedMode) _startStateEmit('mirrors');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!mirrorSim) {
mirrorSim = new MirrorSim(document.getElementById('mirror-canvas'));
@@ -6877,6 +7057,9 @@
function _openIsoprocess() {
document.getElementById('sim-topbar-title').textContent = 'Изопроцессы';
_simShow('sim-isoprocess');
_registerSimState('isoprocess', () => isoSim?.getParams(),
st => { if (isoSim) { isoSim.setParams(st); if (st.process) isoSim.setProcess(st.process); } });
if (_embedMode) _startStateEmit('isoprocess');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!isoSim) {
isoSim = new IsoprocessSim(document.getElementById('isoprocess-canvas'));
@@ -6941,6 +7124,8 @@
function _openTitration() {
document.getElementById('sim-topbar-title').textContent = 'pH и кривая титрования';
_simShow('sim-titration');
_registerSimState('titration', () => titrSim?.getParams(), st => titrSim?.setParams(st));
if (_embedMode) _startStateEmit('titration');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!titrSim) {
titrSim = new TitrationSim(document.getElementById('titration-canvas'));
@@ -6989,6 +7174,8 @@
function _openRefraction() {
document.getElementById('sim-topbar-title').textContent = 'Преломление света';
_simShow('sim-refraction');
_registerSimState('refraction', () => refrSim?.getParams(), st => refrSim?.setParams(st));
if (_embedMode) _startStateEmit('refraction');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!refrSim) {
refrSim = new RefractionSim(document.getElementById('refraction-canvas'));
@@ -7028,6 +7215,8 @@
function _openProbability() {
document.getElementById('sim-topbar-title').textContent = 'Теория вероятностей';
_simShow('sim-probability');
_registerSimState('probability', () => probSim?.getParams(), st => probSim?.setParams(st));
if (_embedMode) _startStateEmit('probability');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!probSim) {
probSim = new ProbabilitySim(document.getElementById('probability-canvas'));
@@ -7066,6 +7255,8 @@
function _openBohrAtom() {
document.getElementById('sim-topbar-title').textContent = 'Атом Бора';
_simShow('sim-bohratom');
_registerSimState('bohratom', () => bohrSim?.getParams(), st => bohrSim?.setParams(st));
if (_embedMode) _startStateEmit('bohratom');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!bohrSim) {
bohrSim = new BohrAtomSim(document.getElementById('bohratom-canvas'));
@@ -7102,6 +7293,8 @@
function _openElectrolysis() {
document.getElementById('sim-topbar-title').textContent = 'Электролиз';
_simShow('sim-electrolysis');
_registerSimState('electrolysis', () => elecSim?.getParams(), st => elecSim?.setParams(st));
if (_embedMode) _startStateEmit('electrolysis');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!elecSim) {
elecSim = new ElectrolysisSim(document.getElementById('electrolysis-canvas'));
@@ -7141,6 +7334,9 @@
document.getElementById('sim-topbar-title').textContent = 'Волны и звук';
document.getElementById('ctrl-waves').style.display = '';
_simShow('sim-waves');
_registerSimState('waves', () => wavesSim?.getParams(),
st => { if (wavesSim) { if (st.mode) wavesSim.setMode(st.mode); wavesSim.setParams(st); } });
if (_embedMode) _startStateEmit('waves');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!wavesSim) {
wavesSim = new WavesSim(document.getElementById('waves-canvas'));
@@ -7887,6 +8083,123 @@
},
};
/* ══════════════════════════════════════════════
HYDROSTATICS
══════════════════════════════════════════════ */
let hydroSim = null;
let _hydroValveOpen = true;
function _openHydro(preset) {
document.getElementById('sim-topbar-title').textContent = 'Гидростатика';
_simShow('sim-hydro');
document.getElementById('ctrl-hydro').style.display = '';
_registerSimState('hydrostatics',
() => ({ mode: hydroSim?.mode, liq: hydroSim?.liquidKey }),
st => { if (st?.mode && hydroSim) hydroMode(st.mode); });
if (_embedMode) _startStateEmit('hydrostatics');
window.addEventListener('load', () => {}, { once: true });
requestAnimationFrame(() => requestAnimationFrame(() => {
const canvas = document.getElementById('hydro-canvas');
const mode = preset || 'pressure';
if (!hydroSim) {
hydroSim = new HydroSim(canvas, mode);
hydroSim.onUpdate = _hydroUpdateUI;
} else {
hydroSim.fit();
hydroSim.play();
}
hydroMode(mode);
}));
}
function hydroMode(mode) {
if (!hydroSim) return;
hydroSim.setMode(mode);
const sel = document.getElementById('hydro-mode-sel');
if (sel) sel.value = mode;
// show/hide sub-controls
['arch','comm','surf','mat'].forEach(k => {
const el = document.getElementById('hydro-panel-' + k);
const el2 = document.getElementById('hydro-' + k + '-ctrl');
if (el) el.style.display = 'none';
if (el2) el2.style.display = 'none';
});
if (mode === 'archimedes') {
const a = document.getElementById('hydro-panel-mat');
const b = document.getElementById('hydro-arch-ctrl');
if (a) a.style.display = '';
if (b) b.style.display = 'flex';
}
if (mode === 'surface') {
const a = document.getElementById('hydro-panel-theta');
const b = document.getElementById('hydro-surf-ctrl');
if (a) a.style.display = '';
if (b) b.style.display = 'flex';
}
if (mode === 'communicating') {
const a = document.getElementById('hydro-panel-comm');
const b = document.getElementById('hydro-comm-ctrl');
if (a) a.style.display = '';
if (b) b.style.display = 'flex';
}
}
function hydroToggleSurface() {
if (!hydroSim) return;
const next = hydroSim._stMode === 'capillary' ? 'drop' : 'capillary';
hydroSim._stMode = next;
const label = next === 'capillary' ? '\u041A\u0430\u043F\u0438\u043B\u043B\u044F\u0440\u044B' : '\u041A\u0430\u043F\u043B\u044F';
['hydro-surf-toggle','hydro-surf-toggle-panel'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = label;
});
}
function hydroToggleValve() {
if (!hydroSim) return;
_hydroValveOpen = !_hydroValveOpen;
hydroSim.setValve(_hydroValveOpen);
const label = _hydroValveOpen ? 'Кран: открыт' : 'Кран: закрыт';
const color = _hydroValveOpen ? '#06D6A0' : '#F15BB5';
['hydro-valve-btn','hydro-valve-panel-btn'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.textContent = label; el.style.color = color; el.style.borderColor = _hydroValveOpen ? 'rgba(6,214,160,.3)' : 'rgba(241,91,181,.3)'; }
});
}
function hydroSetVessels(n, btn) {
if (hydroSim) hydroSim.setNumVessels(n);
document.querySelectorAll('.hydro-nv').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function _hydroUpdateUI(info) {
if (!info) return;
const el = document.getElementById('hydro-formulas');
if (!el) return;
const lines = [];
if (info.formula) lines.push(`<span style="color:#FFD166">${info.formula}</span>`);
if (info.liqName) lines.push(`Жидкость: ${info.liqName}${info.rho ? ' (ρ=' + info.rho + ')' : ''}`);
if (info.matName) lines.push(`Материал: ${info.matName}`);
if (info.FA) lines.push(`<span style="color:#06D6E0">F_A = ${info.FA} Н</span>`);
if (info.mg) lines.push(`<span style="color:#F15BB5">mg = ${info.mg} Н</span>`);
if (info.sigma) lines.push(`σ = ${info.sigma} Н/м, θ = ${info.theta}°`);
if (info.h && !info.FA) lines.push(`h_подъём = ${info.h} мм`);
el.innerHTML = lines.join('<br>');
// result badge
const rb = document.getElementById('hydro-result');
if (rb && info.state) {
const colors = { 'ВСПЛЫВАЕТ': '#06D6A0', 'ТОНЕТ': '#F15BB5', 'ВЗВЕШЕНО': '#FFD166' };
rb.style.display = '';
rb.style.color = colors[info.state] || '#fff';
rb.style.background = (colors[info.state] || '#9B5DE5') + '18';
rb.style.border = '1px solid ' + (colors[info.state] || '#9B5DE5') + '44';
rb.textContent = info.state;
} else if (rb) {
rb.style.display = 'none';
}
}
let _theoryOpen = false;
function toggleTheory() {
_theoryOpen = !_theoryOpen;
@@ -7923,6 +8236,51 @@
const _embedMode = _qp.get('embed') === '1';
const _autoSim = _qp.get('sim');
/* ── Sim state relay (embed mode only) ──────────────────────────────── */
// Map simId → { getState, applyState } registered by openSim handlers
const _simStateRegistry = {};
function _registerSimState(simId, getState, applyState) {
_simStateRegistry[simId] = { getState, applyState };
}
let _lastEmittedState = null;
let _stateEmitInterval = null;
function _startStateEmit(simId) {
if (_stateEmitInterval) clearInterval(_stateEmitInterval);
_lastEmittedState = null;
_stateEmitInterval = setInterval(() => {
const reg = _simStateRegistry[simId];
if (!reg) return;
try {
const state = reg.getState();
const json = JSON.stringify(state);
if (json === _lastEmittedState) return;
_lastEmittedState = json;
window.parent.postMessage({ type: 'sim_state', simId, state }, '*');
} catch {}
}, 400);
}
function _stopStateEmit() {
if (_stateEmitInterval) { clearInterval(_stateEmitInterval); _stateEmitInterval = null; }
_lastEmittedState = null;
}
// Receive apply_sim_state from parent (students)
window.addEventListener('message', e => {
if (!_embedMode) return;
const d = e.data;
if (!d || d.type !== 'apply_sim_state') return;
const reg = _simStateRegistry[_autoSim];
if (!reg) return;
try {
reg.applyState(d.state);
_lastEmittedState = JSON.stringify(d.state); // suppress echo
} catch {}
});
if (_embedMode) {
document.querySelector('.sidebar').style.display = 'none';
document.querySelector('.sb-content').style.marginLeft = '0';
@@ -7932,7 +8290,8 @@
if (_autoSim) {
document.getElementById('lab-sim').classList.add('open');
document.querySelector('.sim-topbar').style.display = 'none';
openSim(_autoSim);
// defer until all external scripts are loaded
window.addEventListener('load', () => openSim(_autoSim));
}
} else {
/* init — fetch sim settings + permissions in parallel, then render */
@@ -8006,5 +8365,6 @@
<script src="/js/labs/probability.js"></script>
<script src="/js/labs/bohratom.js"></script>
<script src="/js/labs/electrolysis.js"></script>
<script src="/js/labs/hydrostatics.js"></script>
</body>
</html>