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:
+366
-6
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user