diff --git a/frontend/js/labs/angrybirds.js b/frontend/js/labs/angrybirds.js
index f3005f7..94d1b41 100644
--- a/frontend/js/labs/angrybirds.js
+++ b/frontend/js/labs/angrybirds.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════════════════════════════
AngryBirdsSim — Angry Birds Physics
@@ -853,3 +853,52 @@ class AngryBirdsSim {
return `rgb(${r},${g},${b})`;
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var angryBirdsSim = null;
+
+ function _openAngryBirds() {
+ document.getElementById('sim-topbar-title').textContent = 'Angry Birds Physics';
+ _simShow('sim-angrybirds');
+ _simShow('ctrl-angrybirds');
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const c = document.getElementById('angrybirds-canvas');
+ if (!angryBirdsSim) {
+ angryBirdsSim = new AngryBirdsSim(c);
+ angryBirdsSim.onUpdate = _abUpdateUI;
+ c.addEventListener('mousedown', e => angryBirdsSim.handleMouseDown(e));
+ c.addEventListener('mousemove', e => angryBirdsSim.handleMouseMove(e));
+ c.addEventListener('mouseup', e => angryBirdsSim.handleMouseUp(e));
+ c.addEventListener('mouseleave', e => angryBirdsSim.handleMouseUp(e));
+ _addTouchSupport(c, angryBirdsSim);
+ }
+ angryBirdsSim.fit();
+ angryBirdsSim.start();
+ }));
+ }
+
+ function abLevel(n, btn) {
+ document.querySelectorAll('.ab-lvl-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ if (angryBirdsSim) angryBirdsSim.loadLevel(n);
+ }
+
+ function angryBirdsRestart() {
+ if (angryBirdsSim) angryBirdsSim.restart();
+ }
+
+ function _abUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('abbar-v1', info.level);
+ v('abbar-v2', info.birds);
+ v('abbar-v3', info.pigs);
+ v('abbar-v4', info.score.toLocaleString('ru'));
+ v('abbar-v5', info.planet);
+ /* sync level button highlight */
+ document.querySelectorAll('.ab-lvl-btn').forEach((b, i) => {
+ b.classList.toggle('active', i === (info.level - 1));
+ });
+ }
+
+ /* ── quadratic ── */
+
diff --git a/frontend/js/labs/bohratom.js b/frontend/js/labs/bohratom.js
index 9e365ad..b5c78cf 100644
--- a/frontend/js/labs/bohratom.js
+++ b/frontend/js/labs/bohratom.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
BohrAtomSim — Bohr atomic model simulation (hydrogen)
E_n = −13.6 / n² eV λ = 1240 / ΔE nm
@@ -638,3 +638,43 @@ class BohrAtomSim {
});
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ 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'));
+ bohrSim.onUpdate = _bohrUpdateUI;
+ }
+ bohrSim.fit();
+ bohrSim.play();
+ }));
+ }
+
+ function bohrLevel(n) {
+ if (bohrSim) {
+ const from = bohrSim.info().level;
+ if (from !== n) bohrSim.transition(from, n);
+ }
+ }
+
+ function bohrTransition(from, to) {
+ if (bohrSim) bohrSim.transition(from, to);
+ }
+
+ function _bohrUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('bohrbar-v1', info.level);
+ v('bohrbar-v2', info.energy.toFixed(2));
+ if (info.lastTransition) {
+ v('bohrbar-v3', info.lastTransition.wavelength.toFixed(0));
+ v('bohrbar-v4', info.lastTransition.series || '—');
+ }
+ }
+
+ /* ── electrolysis ── */
+
diff --git a/frontend/js/labs/celldivision.js b/frontend/js/labs/celldivision.js
index 6a76b7a..3df2436 100644
--- a/frontend/js/labs/celldivision.js
+++ b/frontend/js/labs/celldivision.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ════════════════════════════════════════════════════════════════
CellDivisionSim v2 — интерактивное деление клетки
Митоз и мейоз · анимация · частицы · скрабинг · клик
@@ -813,3 +813,80 @@ function _cdRRect(ctx, x, y, w, h, r) {
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openCellDivision(mode) {
+ document.getElementById('sim-topbar-title').textContent = 'Деление клетки';
+ _simShow('sim-celldivision');
+ _simShow('ctrl-celldivision');
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const canvas = document.getElementById('celldiv-canvas');
+ if (!cellDivSim) {
+ cellDivSim = new CellDivisionSim(canvas);
+ cellDivSim.onUpdate = _cdUpdateUI;
+ }
+ cellDivSim.fit();
+ cellDivSim.setMode(mode || 'mitosis');
+ cellDivSim.start();
+ _cdBuildDots(cellDivSim._phaseIdx);
+ // sync auto button state
+ const autoBtn = document.getElementById('cd-auto-btn');
+ if (autoBtn) { autoBtn.innerHTML = cellDivSim._autoPlay ? ' Пауза' : ' Авто'; }
+ _cdUpdateUI(cellDivSim.info());
+ }));
+ }
+
+ function _cdBuildDots(activeIdx) {
+ const box = document.getElementById('cd-phase-dots');
+ if (!box || !cellDivSim) return;
+ const phases = cellDivSim._phases();
+ box.innerHTML = phases.map((p, i) =>
+ `
`
+ ).join('');
+ }
+
+ function cdSetMode(mode, btn) {
+ document.querySelectorAll('.cd-mode-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ if (!cellDivSim) return;
+ cellDivSim.setMode(mode);
+ _cdBuildDots(cellDivSim._phaseIdx);
+ _cdUpdateUI(cellDivSim.info());
+ }
+
+ function cdAutoPlay(btn) {
+ if (!cellDivSim) return;
+ cellDivSim.toggleAutoPlay();
+ btn.classList.toggle('active', cellDivSim._autoPlay);
+ btn.innerHTML = cellDivSim._autoPlay ? ' Пауза' : ' Авто';
+ }
+
+ function cdPrevPhase() {
+ if (!cellDivSim) return;
+ cellDivSim.prevPhase();
+ _cdBuildDots(cellDivSim._phaseIdx);
+ }
+
+ function cdNextPhase() {
+ if (!cellDivSim) return;
+ cellDivSim.nextPhase();
+ _cdBuildDots(cellDivSim._phaseIdx);
+ }
+
+ function cdJumpPhase(idx) {
+ if (!cellDivSim) return;
+ cellDivSim.jumpToPhase(idx);
+ _cdBuildDots(idx);
+ }
+
+ function _cdUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('cdbar-v1', info.phase || '—');
+ v('cdbar-v2', info.chromN || '—');
+ v('cdbar-v3', info.dna || '—');
+ v('cdbar-v4', (info.index + 1) + ' / ' + info.total);
+ v('cdbar-v5', info.mode === 'mitosis' ? 'Митоз' : 'Мейоз');
+ _cdBuildDots(info.index);
+ }
+
+ /* ── Photosynthesis / Respiration ── */
diff --git a/frontend/js/labs/chemsandbox.js b/frontend/js/labs/chemsandbox.js
index 3e07ef1..bf1eeaa 100644
--- a/frontend/js/labs/chemsandbox.js
+++ b/frontend/js/labs/chemsandbox.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */
function _csClean(s) {
@@ -1680,3 +1680,158 @@ class ChemSandboxSim {
if (this.onUpdate) this.onUpdate(this.info());
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openChemSandbox() {
+ document.getElementById('sim-topbar-title').textContent = 'Химическая песочница';
+ _simShow('sim-chemsandbox');
+ _simShow('ctrl-chemsandbox');
+
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const c = document.getElementById('chemsandbox-canvas');
+ if (!chemSandSim) {
+ chemSandSim = new ChemSandboxSim(c);
+ chemSandSim.onUpdate = _chemSandUpdateUI;
+ chemSandSim.onQuizUpdate = _chemSandQuizUI;
+ c.addEventListener('click', e => chemSandSim.handleClick(e));
+ c.addEventListener('mousedown', e => chemSandSim.handleMouseDown(e));
+ c.addEventListener('mousemove', e => chemSandSim.handleMouseMove(e));
+ c.addEventListener('mouseup', e => chemSandSim.handleMouseUp(e));
+ c.addEventListener('wheel', e => chemSandSim.handleWheel(e), { passive: false });
+ c.addEventListener('contextmenu', e => chemSandSim.handleContextMenu(e));
+ _addTouchSupport(c, chemSandSim);
+ _chemSandBuildReagents('all');
+ }
+ chemSandSim.fit();
+ chemSandSim.start();
+ chemSandSim.draw();
+ }));
+ }
+
+ function chemSandCat(cat, el) {
+ document.querySelectorAll('.chemsand-cat').forEach(b => b.classList.remove('active'));
+ el.classList.add('active');
+ if (chemSandSim) chemSandSim.setCategory(cat);
+ _chemSandBuildReagents(cat);
+ if (chemSandSim) chemSandSim.draw();
+ }
+
+ function chemSandPreset(name) { if (chemSandSim) { chemSandSim.preset(name); _chemSandBuildReagents(chemSandSim.filterCat); } }
+ function chemSandReset() { if (chemSandSim) { chemSandSim.reset(); _chemSandBuildReagents(chemSandSim.filterCat); } }
+ function chemSandResetReaction() { if (chemSandSim) { chemSandSim.resetReaction(); _chemSandBuildReagents(chemSandSim.filterCat); } }
+
+ function chemSandConcChange() {
+ const v = +document.getElementById('sl-csand-conc').value;
+ document.getElementById('csand-conc-val').textContent = v + '%';
+ }
+ function chemSandTempChange() {
+ const v = +document.getElementById('sl-csand-temp').value;
+ document.getElementById('csand-temp-val').textContent = v + '°C';
+ }
+
+ function chemSandAdd(formula) {
+ if (!chemSandSim) return;
+ // toggle: if already in mix — remove, else add
+ if (chemSandSim.mixContents.includes(formula)) {
+ chemSandSim.removeFromMix(formula);
+ } else {
+ chemSandSim.addToMix(formula);
+ }
+ _chemSandBuildReagents(chemSandSim.filterCat);
+ }
+
+ function _chemSandBuildReagents(cat) {
+ const box = document.getElementById('chemsand-reagents');
+ if (!box) return;
+ const subs = ChemSandboxSim.SUBSTANCES;
+ const keys = Object.keys(subs).filter(k => cat === 'all' || subs[k].cat === cat);
+ const inMix = chemSandSim ? chemSandSim.mixContents : [];
+ box.innerHTML = keys.map(k => {
+ const s = subs[k];
+ const active = inMix.includes(k);
+ const cls = active ? 'proj-preset-chip reac-mode-btn active' : 'proj-preset-chip reac-mode-btn';
+ const sf = chemSandSim ? chemSandSim._shortFormula(k) : k;
+ const removeHint = active ? ' (клик — убрать)' : '';
+ return ``;
+ }).join('');
+ }
+
+ function chemSandSetMode(mode, el) {
+ document.querySelectorAll('.chemsand-mode').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ if (!chemSandSim) return;
+ if (mode === 'quiz') {
+ if (window._simQuizAllowed === false) {
+ LS.toast('Режим заданий недоступен — администратор ограничил доступ', 'error');
+ // revert button state
+ document.querySelectorAll('.chemsand-mode').forEach(b => b.classList.remove('active'));
+ document.getElementById('csand-mode-free')?.classList.add('active');
+ return;
+ }
+ chemSandSim.startQuiz();
+ // reset category filter to 'all' so all reagents are accessible
+ document.querySelectorAll('.chemsand-cat').forEach(b => b.classList.remove('active'));
+ const allBtn = document.querySelector('.chemsand-cat');
+ if (allBtn) allBtn.classList.add('active');
+ _chemSandBuildReagents('all');
+ } else {
+ chemSandSim.stopQuiz();
+ document.getElementById('csand-quiz-question').style.display = 'none';
+ document.getElementById('csand-quiz-result').style.display = 'none';
+ document.getElementById('csand-quiz-next').style.display = 'none';
+ document.getElementById('csand-quiz-score').textContent = '';
+ }
+ }
+
+ function chemSandQuizNext() {
+ if (chemSandSim && chemSandSim._quizMode) {
+ chemSandSim._nextQuizTask();
+ _chemSandBuildReagents(chemSandSim.filterCat);
+ }
+ }
+
+ function _chemSandQuizUI(qi) {
+ const qEl = document.getElementById('csand-quiz-question');
+ const rEl = document.getElementById('csand-quiz-result');
+ const nEl = document.getElementById('csand-quiz-next');
+ const sEl = document.getElementById('csand-quiz-score');
+ if (!qi.active) {
+ qEl.style.display = 'none'; rEl.style.display = 'none'; nEl.style.display = 'none';
+ sEl.textContent = '';
+ return;
+ }
+ qEl.style.display = 'block';
+ qEl.textContent = qi.question || '';
+ sEl.textContent = qi.total > 0 ? `${qi.score}/${qi.total}` : '';
+ if (qi.result) {
+ rEl.style.display = 'block';
+ rEl.style.color = qi.result === 'correct' ? '#7BF5A4' : '#EF476F';
+ rEl.textContent = qi.result === 'correct' ? 'Верно!' : 'Неверно — ' + (qi.answer || '');
+ nEl.style.display = qi.result === 'wrong' ? 'inline-block' : 'none';
+ } else {
+ rEl.style.display = 'none'; nEl.style.display = 'none';
+ }
+ }
+
+ let _lastReportedEquation = null;
+ function _chemSandUpdateUI(info) {
+ document.getElementById('csbar-v1').textContent = info.mixed;
+ document.getElementById('csbar-v3').textContent = info.type || '—';
+ const eqEl = document.getElementById('csbar-v4');
+ eqEl.innerHTML = info.equation || '—';
+ eqEl.title = (info.equation || '').replace(/<[^>]*>/g, '');
+ document.getElementById('csbar-v5').textContent = info.products || '—';
+ const ionEl = document.getElementById('csbar-v6');
+ ionEl.innerHTML = info.ionNet || '—';
+ ionEl.title = (info.ionNet || '').replace(/<[^>]*>/g, '');
+ // rebuild reagent buttons to reflect active state
+ _chemSandBuildReagents(chemSandSim ? chemSandSim.filterCat : 'all');
+ // Report lab activity for gamification (once per unique reaction)
+ if (info.reaction && info.equation && info.equation !== _lastReportedEquation) {
+ _lastReportedEquation = info.equation;
+ if (window.LS?.reportLabActivity) LS.reportLabActivity(1).catch(() => {});
+ }
+ }
+
+ /* ── Cell Division ── */
diff --git a/frontend/js/labs/circuit.js b/frontend/js/labs/circuit.js
index f0dac00..dfbc359 100644
--- a/frontend/js/labs/circuit.js
+++ b/frontend/js/labs/circuit.js
@@ -1,4 +1,4 @@
-/**
+/**
* CircuitSim — Enhanced Electric Circuits Simulation v2
* MNA solver · L-shape wires · Drag · Undo/Redo · Tooltip
* New: Capacitor · Diode · LED · AC source · Junction dots
@@ -1306,3 +1306,88 @@ class CircuitSim {
if (this.onUpdate) this.onUpdate(this.info());
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var cirSim = null;
+ var reacSim = null;
+ var flaskSim = null;
+
+ function _openCircuit() {
+ document.getElementById('sim-topbar-title').textContent = 'Электрические цепи';
+ _simShow('sim-circuit');
+ _simShow('ctrl-circuit');
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const canvas = document.getElementById('circuit-canvas');
+ if (!cirSim) {
+ cirSim = new CircuitSim(canvas);
+ cirSim.onUpdate = _circUpdateUI;
+ cirSim.onModeChange = (mode) => {
+ document.querySelectorAll('.circ-tool-btn').forEach(b => {
+ b.classList.toggle('active', b.dataset.tool === mode);
+ });
+ document.querySelectorAll('.circ-top-btn').forEach(b => {
+ b.classList.toggle('active', b.id === 'ctool-' + mode);
+ });
+ };
+ } else {
+ cirSim.stop();
+ }
+ cirSim.fit();
+ if (cirSim.components.length === 0) cirSim.preset('serial');
+ cirSim.start();
+ _circUpdateUI(cirSim.info());
+ }));
+ }
+
+ function circTool(tool, el) {
+ if (cirSim) cirSim.addMode = tool;
+ document.querySelectorAll('.circ-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === tool));
+ document.querySelectorAll('.circ-top-btn').forEach(b => b.classList.toggle('active', b.id === 'ctool-' + tool));
+ }
+
+ function circPreset(name) {
+ if (!cirSim) return;
+ cirSim.preset(name);
+ }
+
+ function circRChange() {
+ const v = +document.getElementById('sl-circR').value;
+ document.getElementById('circ-R-val').textContent = v + ' Ω';
+ if (cirSim) cirSim.R_value = v;
+ }
+
+ function circUChange() {
+ const v = +document.getElementById('sl-circU').value;
+ document.getElementById('circ-U-val').textContent = v + ' В';
+ if (cirSim) cirSim.U_value = v;
+ }
+
+ function circCChange() {
+ const v = +document.getElementById('sl-circC').value;
+ document.getElementById('circ-C-val').textContent = v + ' µF';
+ if (cirSim) cirSim.C_value = v;
+ }
+
+ function circFChange() {
+ const v = +document.getElementById('sl-circF').value;
+ document.getElementById('circ-F-val').textContent = v + ' Гц';
+ if (cirSim) cirSim.acFreq = v;
+ }
+
+ function _circUpdateUI(info) {
+ if (!info) return;
+ document.getElementById('cirbar-comps').textContent = info.components;
+ document.getElementById('cirbar-U').textContent = info.voltage ? info.voltage + ' В' : '—';
+ document.getElementById('cirbar-I').textContent = info.current ? info.current + ' А' : '—';
+ document.getElementById('cirbar-P').textContent = info.power ? info.power + ' Вт' : '—';
+ const st = document.getElementById('cirbar-status');
+ st.textContent = info.solved ? 'Замкнута' : 'Разомкнута';
+ st.style.color = info.solved ? '#7BF5A4' : '#EF476F';
+ }
+
+ /* ════════════════════════════════
+ ХИМИЯ (unified: кинетика + колба + ОВР + ионный обмен)
+ ════════════════════════════════ */
+
+ let _chemMode = 'kinetics'; // 'kinetics' | 'flask' | 'redox' | 'ionex'
+
diff --git a/frontend/js/labs/collision.js b/frontend/js/labs/collision.js
index d49bda6..df07e31 100644
--- a/frontend/js/labs/collision.js
+++ b/frontend/js/labs/collision.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════════
CollisionSim — 2D elastic/inelastic ball collision
@@ -1008,3 +1008,123 @@ function _roundRect(ctx, x, y, w, h, r) {
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x+r, y, r);
ctx.closePath();
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openCollision() {
+ 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) {
+ cSim = new CollisionSim(document.getElementById('coll-canvas'));
+ cSim.onUpdate = _collUpdateUI;
+ cSim.onPlayPause = collPlayPause;
+ }
+ cSim.fit();
+ cSim.setSpeed(+document.getElementById('sl-speed').value);
+ collParam();
+ cSim.draw();
+ _collUpdateUI(cSim.stats());
+ }));
+ }
+
+ function collPlayPause() {
+ if (!cSim) return;
+ if (cSim.playing) { cSim.pause(); } else { cSim.play(); }
+ _collSyncBtn();
+ }
+
+ function _collSyncBtn() {
+ const tb = document.getElementById('coll-play-btn');
+ const lb = document.getElementById('coll-launch-main');
+ const lbl = document.getElementById('coll-launch-label');
+ const lic = document.getElementById('coll-launch-icon');
+ if (!cSim) return;
+ const playing = cSim.playing;
+
+ if (tb) {
+ tb.innerHTML = playing
+ ? ''
+ : '';
+ tb.title = playing ? 'Пауза' : 'Запустить';
+ tb.classList.toggle('active', playing);
+ }
+
+ if (lb && lbl && lic) {
+ lb.classList.toggle('paused', playing);
+ lb.classList.remove('done');
+ if (playing) {
+ lic.innerHTML = '';
+ lbl.textContent = 'Пауза';
+ } else {
+ lic.innerHTML = '';
+ lbl.textContent = 'Запустить';
+ }
+ }
+ }
+
+ function collParam() {
+ const m1 = +document.getElementById('sl-m1').value;
+ const m2 = +document.getElementById('sl-m2').value;
+ const v1 = +document.getElementById('sl-cv1').value;
+ const v2 = +document.getElementById('sl-cv2').value;
+ const angle = +document.getElementById('sl-cangle').value;
+ const e = +document.getElementById('sl-e').value;
+ const spd = +document.getElementById('sl-speed').value;
+
+ document.getElementById('c-m1').textContent = m1 + ' кг';
+ document.getElementById('c-m2').textContent = m2 + ' кг';
+ document.getElementById('c-v1').textContent = v1 + ' м/с';
+ document.getElementById('c-v2').textContent = v2 + ' м/с';
+ document.getElementById('c-angle').textContent = angle + '°';
+ document.getElementById('c-e').textContent = e.toFixed(2);
+ document.getElementById('c-speed').textContent = spd.toFixed(2) + '×';
+
+ if (cSim) {
+ /* speed change doesn't require a reset */
+ const speedChanged = Math.abs(cSim.speed - spd) > 0.001;
+ if (speedChanged) cSim.setSpeed(spd);
+
+ const physChanged = cSim.m1 !== m1 || cSim.m2 !== m2 ||
+ cSim.v1 !== v1 || cSim.v2 !== v2 ||
+ cSim.angle !== angle || cSim.e !== e;
+ if (physChanged) cSim.setParams({ m1, m2, v1, v2, angle, e });
+ _collSyncBtn();
+ }
+ }
+
+ function collPreset(m1, m2, v1, v2, angle, e) {
+ document.getElementById('sl-m1').value = m1;
+ document.getElementById('sl-m2').value = m2;
+ document.getElementById('sl-cv1').value = v1;
+ document.getElementById('sl-cv2').value = v2;
+ document.getElementById('sl-cangle').value = angle;
+ document.getElementById('sl-e').value = e;
+ collParam();
+ }
+
+ function _collUpdateUI(s) {
+ // before/after are arrays [{m, vx, vy, ke}, ...]
+ function snapKE(arr) { return arr ? arr.reduce((t, b) => t + b.ke, 0) : null; }
+ function snapP(arr) {
+ if (!arr) return null;
+ return Math.hypot(arr.reduce((t, b) => t + b.m * b.vx, 0),
+ arr.reduce((t, b) => t + b.m * b.vy, 0));
+ }
+ const bKE = snapKE(s.before), bP = snapP(s.before);
+ const aKE = snapKE(s.after), aP = snapP(s.after);
+ const f2 = v => v !== null ? v.toFixed(2) : '—';
+
+ document.getElementById('cs-pbefore').textContent = bP !== null ? f2(bP) + ' кг·м/с' : '—';
+ document.getElementById('cs-pafter').textContent = aP !== null ? f2(aP) + ' кг·м/с' : '—';
+ document.getElementById('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж' : '—';
+ document.getElementById('cs-keafter').textContent = aKE !== null ? f2(aKE) + ' Дж' : '—';
+ document.getElementById('cs-count').textContent = s.colCount;
+ _collSyncBtn();
+ }
+
+ /* ── magnetic ── */
+
diff --git a/frontend/js/labs/coulomb.js b/frontend/js/labs/coulomb.js
index 53c1367..82c9452 100644
--- a/frontend/js/labs/coulomb.js
+++ b/frontend/js/labs/coulomb.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════
CoulombSim — Coulomb's Law interactive simulation
• Click canvas to place charge (+ or −)
@@ -746,3 +746,67 @@ class CoulombSim {
}, { passive: false });
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var csSim = null;
+
+ function _openCoulomb() {
+ document.getElementById('sim-topbar-title').textContent = 'Закон Кулона';
+ _simShow('sim-coulomb');
+ _simShow('ctrl-coulomb');
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const canvas = document.getElementById('coulomb-canvas');
+ if (!csSim) {
+ csSim = new CoulombSim(canvas);
+ csSim.onUpdate = _coulombUpdateUI;
+ }
+ csSim.fit();
+ if (csSim.charges.length === 0) csSim.preset('dipole');
+ _coulombUpdateUI(csSim.info());
+ }));
+ }
+
+ function coulombSign(s) {
+ if (!csSim) return;
+ csSim.setSign(s);
+ document.getElementById('cbtn-pos').classList.toggle('active', s > 0);
+ document.getElementById('cbtn-neg').classList.toggle('active', s < 0);
+ document.getElementById('csign-pos').style.opacity = s > 0 ? '1' : '0.45';
+ document.getElementById('csign-neg').style.opacity = s < 0 ? '1' : '0.45';
+ }
+
+ function coulombLayer(name, rowEl) {
+ if (!csSim) return;
+ csSim.toggleLayer(name);
+ const on = csSim.layers[name];
+ rowEl.classList.toggle('active', on);
+ const tog = rowEl.querySelector('.tri-toggle');
+ if (tog) {
+ tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
+ const dot = tog.querySelector('span');
+ if (dot) dot.style.marginLeft = on ? '14px' : '2px';
+ }
+ csSim.draw();
+ }
+
+ function coulombPreset(name) {
+ if (!csSim) return;
+ csSim.preset(name);
+ }
+
+ function _coulombUpdateUI(info) {
+ if (!info) return;
+ document.getElementById('cs-total').textContent = info.total;
+ document.getElementById('cs-curE').textContent = info.cursorE;
+ document.getElementById('cs-curV').textContent = info.cursorV;
+ document.getElementById('csbar-total').textContent = info.total;
+ document.getElementById('csbar-pos').textContent = info.positive;
+ document.getElementById('csbar-neg').textContent = info.negative;
+ document.getElementById('csbar-maxE').textContent = info.maxE;
+ document.getElementById('csbar-curE').textContent = info.cursorE;
+ }
+
+ /* ════════════════════════════════
+ ЭЛЕКТРИЧЕСКИЕ ЦЕПИ
+ ════════════════════════════════ */
+
diff --git a/frontend/js/labs/crystal.js b/frontend/js/labs/crystal.js
index 93d91cf..1cc8cf7 100644
--- a/frontend/js/labs/crystal.js
+++ b/frontend/js/labs/crystal.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════════
CrystalSim — 3D crystal lattice (Three.js)
@@ -313,3 +313,26 @@ class CrystalSim {
this.renderer.render(this.scene, this.camera);
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var crystalSim = null;
+ function _openCrystal() {
+ document.getElementById('sim-topbar-title').textContent = 'Кристаллическая решётка';
+ _simShow('sim-crystal');
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (!crystalSim) {
+ crystalSim = new CrystalSim(document.getElementById('crystal-container'));
+ } else {
+ crystalSim.fit();
+ crystalSim.play();
+ }
+ }));
+ }
+ function setCrystal(type, btn) {
+ document.querySelectorAll('.crystal-type-btn').forEach(b => { b.classList.remove('active'); b.style.borderColor = ''; b.style.color = ''; });
+ btn.classList.add('active');
+ btn.style.borderColor = '#9B5DE5'; btn.style.color = '#9B5DE5';
+ if (crystalSim) crystalSim.setLattice(type);
+ }
+
+ /* ── molecular orbitals (3D) ── */
diff --git a/frontend/js/labs/electrolysis.js b/frontend/js/labs/electrolysis.js
index 404b92d..c6133fd 100644
--- a/frontend/js/labs/electrolysis.js
+++ b/frontend/js/labs/electrolysis.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/**
* ElectrolysisSim v2 — Электролиз водных растворов
* Закон Фарадея: m = M·I·t / (n·F), F = 96485 Кл/моль
@@ -539,3 +539,45 @@ class ElectrolysisSim {
}
if (typeof module !== 'undefined') module.exports = ElectrolysisSim;
+
+/* ─── lab UI init ─────────────────────────────────── */
+ 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'));
+ elecSim.onUpdate = _elecUpdateUI;
+ }
+ elecSim.fit();
+ elecSim.reset();
+ elecSim.play();
+ }));
+ }
+
+ function elecParam(name, val) {
+ const v = parseFloat(val);
+ if (name === 'voltage') document.getElementById('elec-V-val').textContent = v;
+ if (elecSim) elecSim.setParams({ [name]: v });
+ }
+
+ function elecPreset(name, btn) {
+ document.querySelectorAll('.elec-type-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ const voltages = { nacl: 6, cuso4: 4, h2so4: 3 };
+ const vt = voltages[name] || 6;
+ document.getElementById('sl-elec-V').value = vt; document.getElementById('elec-V-val').textContent = vt;
+ if (elecSim) { elecSim.setParams({ electrolyte: name, voltage: vt }); elecSim.reset(); elecSim.play(); }
+ }
+
+ function _elecUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('elecbar-v1', typeof info.current === 'number' ? info.current.toFixed(2) : '—');
+ v('elecbar-v2', typeof info.massDeposited === 'number' ? info.massDeposited.toFixed(3) + ' г' : '—');
+ v('elecbar-v3', typeof info.gasVolume === 'number' ? info.gasVolume.toFixed(1) : '—');
+ v('elecbar-v4', typeof info.time === 'number' ? info.time.toFixed(0) + ' с' : '—');
+ }
+
+ /* ── waves ── */
diff --git a/frontend/js/labs/equilibrium.js b/frontend/js/labs/equilibrium.js
index 2009caf..99dcc2f 100644
--- a/frontend/js/labs/equilibrium.js
+++ b/frontend/js/labs/equilibrium.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/**
* EquilibriumSim — Chemical equilibrium simulation.
@@ -474,3 +474,48 @@ class EquilibriumSim {
}
if (typeof module !== 'undefined') module.exports = EquilibriumSim;
+
+/* ─── lab UI init ─────────────────────────────────── */
+ 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'));
+ eqSim.onUpdate = _eqUpdateUI;
+ }
+ eqSim.fit();
+ eqSim.reset();
+ eqSim.play();
+ }));
+ }
+
+ function eqParam(name, val) {
+ const v = parseFloat(val);
+ const ids = { T: 'eq-T-val', Ea_f: 'eq-Eaf-val', Ea_r: 'eq-Ear-val' };
+ const el = document.getElementById(ids[name]);
+ if (el) el.textContent = v;
+ if (eqSim) eqSim.setParams({ [name]: v });
+ }
+
+ function eqPreset(name) {
+ if (eqSim) { eqSim.preset(name); eqSim.play(); }
+ const defs = { default: [300,50,55], exothermic: [280,35,65], endothermic: [350,65,35], excess_A: [300,50,55] };
+ const d = defs[name] || defs.default;
+ document.getElementById('sl-eq-T').value = d[0]; document.getElementById('eq-T-val').textContent = d[0];
+ document.getElementById('sl-eq-Eaf').value = d[1]; document.getElementById('eq-Eaf-val').textContent = d[1];
+ document.getElementById('sl-eq-Ear').value = d[2]; document.getElementById('eq-Ear-val').textContent = d[2];
+ }
+
+ function _eqUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('eqbar-v1', info.keq);
+ v('eqbar-v2', info.Q);
+ v('eqbar-v3', info.direction);
+ v('eqbar-v4', info.nA + '|' + info.nB + '|' + info.nC + '|' + info.nD);
+ }
+
+ /* ── thin lens ── */
+
diff --git a/frontend/js/labs/gas.js b/frontend/js/labs/gas.js
index 84c5546..4f080c0 100644
--- a/frontend/js/labs/gas.js
+++ b/frontend/js/labs/gas.js
@@ -1,4 +1,4 @@
-/**
+/**
* GasSim v2 — Ideal Gas simulation (PV=nRT, Maxwell-Boltzmann distribution)
* v2: hover inspector, velocity vectors, movable piston, v_mp/v_rms markers.
*/
@@ -460,3 +460,267 @@ class GasSim {
ctx.restore();
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openMolPhys(mode) {
+ document.getElementById('sim-topbar-title').textContent = 'Молекулярная физика';
+ _simShow('sim-molphys');
+ _simShow('ctrl-molphys');
+
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ // lazy-init all sims
+ if (!gasSim) { gasSim = new GasSim(document.getElementById('gas-canvas')); gasSim.onUpdate = _gasUpdateUI; }
+ if (!brownSim) { brownSim = new BrownianSim(document.getElementById('brownian-canvas')); brownSim.onUpdate = _brownUpdateUI; }
+ if (!statesSim) { statesSim = new StatesSim(document.getElementById('states-canvas')); statesSim.onUpdate = _statesUpdateUI; }
+ if (!diffSim) { diffSim = new DiffusionSim(document.getElementById('diffusion-canvas')); diffSim.onUpdate = _diffUpdateUI; }
+
+ molMode(mode || 'gas');
+ }));
+ }
+
+ function molMode(mode, btn) {
+ _molMode = mode;
+ // stop all
+ if (gasSim) gasSim.stop();
+ if (brownSim) brownSim.stop();
+ if (statesSim) statesSim.stop();
+ if (diffSim) diffSim.stop();
+
+ // toggle mode buttons
+ document.querySelectorAll('.mol-mode').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ else { const mb = document.getElementById('mol-mode-' + mode); if (mb) mb.classList.add('active'); }
+
+ // toggle panels
+ const panels = ['gas', 'brownian', 'states', 'diffusion'];
+ panels.forEach(p => {
+ document.getElementById('mol-panel-' + p).style.display = p === mode ? '' : 'none';
+ });
+
+ // toggle canvases
+ document.getElementById('gas-canvas').style.display = mode === 'gas' ? 'block' : 'none';
+ document.getElementById('brownian-canvas').style.display = mode === 'brownian' ? 'block' : 'none';
+ document.getElementById('states-canvas').style.display = mode === 'states' ? 'block' : 'none';
+ document.getElementById('diffusion-canvas').style.display = mode === 'diffusion' ? 'block' : 'none';
+
+ // toggle topbar diffusion partition button
+ document.getElementById('ctrl-mol-diff').style.display = mode === 'diffusion' ? 'contents' : 'none';
+
+ // start active sim
+ const titles = { gas: 'Молекулярная физика — Газ', brownian: 'Молекулярная физика — Броуновское', states: 'Молекулярная физика — Фазы', diffusion: 'Молекулярная физика — Диффузия' };
+ document.getElementById('sim-topbar-title').textContent = titles[mode] || 'Молекулярная физика';
+
+ if (mode === 'gas') { gasSim.fit(); gasSim.start(); }
+ if (mode === 'brownian') { brownSim.fit(); brownSim.start(); }
+ if (mode === 'states') { statesSim.fit(); statesSim.start(); }
+ if (mode === 'diffusion') { diffSim.fit(); diffSim.start(); }
+ }
+
+ function molReset() {
+ if (_molMode === 'gas' && gasSim) {
+ gasSim.reset();
+ document.getElementById('sl-gPiston').value = 100;
+ document.getElementById('g-piston').textContent = '100%';
+ }
+ if (_molMode === 'brownian' && brownSim) brownSim.reset();
+ if (_molMode === 'states' && statesSim) {
+ statesSim.reset();
+ document.getElementById('sl-stN').value = 64;
+ document.getElementById('st-N').textContent = '64';
+ const vBtn = document.getElementById('states-vec-btn');
+ if (vBtn) { vBtn.textContent = 'Векторы скоростей: Выкл'; vBtn.style.color = ''; }
+ }
+ if (_molMode === 'diffusion' && diffSim) {
+ diffSim.reset();
+ document.getElementById('diffusion-part-btn').textContent = '‖ Раздел';
+ document.getElementById('df-part-row').classList.add('active');
+ document.getElementById('df-pore-row').classList.remove('active');
+ }
+ }
+
+ function gasNChange() {
+ const n = +document.getElementById('sl-gN').value;
+ document.getElementById('g-N').textContent = n;
+ if (gasSim) { gasSim.setN(n); }
+ }
+
+ function gasTChange() {
+ const raw = +document.getElementById('sl-gT').value;
+ const t = raw / 10;
+ document.getElementById('g-T').textContent = t.toFixed(1) + ' у.е.';
+ if (gasSim) gasSim.setT(t);
+ }
+
+ function gasPistonChange() {
+ const v = +document.getElementById('sl-gPiston').value;
+ document.getElementById('g-piston').textContent = v + '%';
+ if (gasSim) gasSim.setPiston(v / 100);
+ }
+
+ function gasToggleVectors(btn) {
+ if (!gasSim) return;
+ gasSim.toggleVectors();
+ btn.textContent = 'Векторы скоростей: ' + (gasSim._showVectors ? 'Вкл' : 'Выкл');
+ btn.style.color = gasSim._showVectors ? '#7BF5A4' : '';
+ }
+
+ function _gasUpdateUI(info) {
+ document.getElementById('gstat-P').textContent = info.P;
+ document.getElementById('gstat-V').textContent = info.V;
+ document.getElementById('gstat-PV').textContent = info.PV;
+ document.getElementById('gstat-v').textContent = info.avgSpeed + ' у.е.';
+ document.getElementById('mpbar-l1').textContent = 'N';
+ document.getElementById('mpbar-v1').textContent = info.N;
+ document.getElementById('mpbar-l2').textContent = 'T';
+ document.getElementById('mpbar-v2').textContent = info.T.toFixed(1);
+ document.getElementById('mpbar-l3').textContent = 'P';
+ document.getElementById('mpbar-v3').textContent = info.P;
+ document.getElementById('mpbar-l4').textContent = 'V';
+ document.getElementById('mpbar-v4').textContent = info.V;
+ document.getElementById('mpbar-l5').textContent = 'PV';
+ document.getElementById('mpbar-v5').textContent = info.PV;
+ }
+
+ function brownNChange() {
+ const n = +document.getElementById('sl-brN').value;
+ document.getElementById('br-N').textContent = n;
+ if (brownSim) brownSim.setN(n);
+ }
+
+ function brownTChange() {
+ const t = +document.getElementById('sl-brT').value / 10;
+ document.getElementById('br-T').textContent = t.toFixed(1) + ' у.е.';
+ if (brownSim) brownSim.setT(t);
+ }
+
+ function _brownUpdateUI(info) {
+ document.getElementById('brstat-dr').textContent = info.displacement + ' px';
+ document.getElementById('brstat-msd').textContent = info.msd + ' px²';
+ document.getElementById('brstat-v').textContent = info.speed;
+ document.getElementById('brstat-steps').textContent = info.steps;
+ document.getElementById('mpbar-l1').textContent = 'Шагов';
+ document.getElementById('mpbar-v1').textContent = info.steps;
+ document.getElementById('mpbar-l2').textContent = '|Δr|';
+ document.getElementById('mpbar-v2').textContent = info.displacement + ' px';
+ document.getElementById('mpbar-l3').textContent = 'MSD';
+ document.getElementById('mpbar-v3').textContent = info.msd + ' px²';
+ document.getElementById('mpbar-l4').textContent = 'v';
+ document.getElementById('mpbar-v4').textContent = info.speed;
+ document.getElementById('mpbar-l5').textContent = 'N';
+ document.getElementById('mpbar-v5').textContent = info.N;
+ }
+
+ function statesTChange() {
+ const raw = +document.getElementById('sl-stT').value;
+ const t = raw / 100;
+ document.getElementById('st-T').textContent = t.toFixed(2);
+ if (statesSim) statesSim.setT(t);
+ }
+
+ function statesPreset(t) {
+ document.getElementById('sl-stT').value = Math.round(t * 100);
+ document.getElementById('st-T').textContent = t.toFixed(2);
+ if (statesSim) statesSim.setT(t);
+ }
+
+ function statesNChange() {
+ const n = +document.getElementById('sl-stN').value;
+ document.getElementById('st-N').textContent = n;
+ if (statesSim) statesSim.setN(n);
+ }
+
+ function statesToggleVectors(btn) {
+ if (!statesSim) return;
+ statesSim.toggleVectors();
+ btn.textContent = 'Векторы скоростей: ' + (statesSim._showVectors ? 'Вкл' : 'Выкл');
+ btn.style.color = statesSim._showVectors ? '#7BF5A4' : '';
+ }
+
+ function _statesUpdateUI(info) {
+ const phaseColors = { solid: '#4CC9F0', liquid: '#7BF5A4', gas: '#EF476F' };
+ const phaseLabels = { solid: 'Твёрдое', liquid: 'Жидкость', gas: 'Газ' };
+ const c = phaseColors[info.phase] || '#fff';
+ document.getElementById('ststat-phase').textContent = phaseLabels[info.phase] || info.phase;
+ document.getElementById('ststat-phase').style.color = c;
+ document.getElementById('ststat-KE').textContent = info.avgKE;
+ document.getElementById('ststat-PE').textContent = info.avgPE;
+ const pEl = document.getElementById('ststat-P');
+ if (pEl) pEl.textContent = info.P !== undefined ? info.P : '—';
+ document.getElementById('mpbar-l1').textContent = 'Фаза';
+ document.getElementById('mpbar-v1').textContent = phaseLabels[info.phase] || info.phase;
+ document.getElementById('mpbar-v1').style.color = c;
+ document.getElementById('mpbar-l2').textContent = 'T';
+ document.getElementById('mpbar-v2').textContent = info.T.toFixed(2);
+ document.getElementById('mpbar-l3').textContent = 'KE';
+ document.getElementById('mpbar-v3').textContent = info.avgKE;
+ document.getElementById('mpbar-l4').textContent = 'PE';
+ document.getElementById('mpbar-v4').textContent = info.avgPE;
+ document.getElementById('mpbar-l5').textContent = 'P';
+ document.getElementById('mpbar-v5').textContent = info.P !== undefined ? info.P : '—';
+ }
+
+ function diffNChange() {
+ const n = +document.getElementById('sl-dfN').value;
+ document.getElementById('df-N').textContent = n;
+ if (diffSim) diffSim.setN(n);
+ }
+
+ function diffTChange() {
+ const t = +document.getElementById('sl-dfT').value / 10;
+ document.getElementById('df-T').textContent = t.toFixed(1) + ' у.е.';
+ if (diffSim) diffSim.setT(t);
+ }
+
+ function diffPartitionToggle(rowEl) {
+ if (!diffSim) return;
+ diffSim.togglePartition();
+ const on = diffSim.partitionOn;
+ rowEl.classList.toggle('active', on);
+ document.getElementById('diffusion-part-btn').innerHTML = on ? '‖ Раздел' : ' Раздел снят';
+ }
+
+ function diffPartitionBtn() {
+ if (!diffSim) return;
+ const on = diffSim.partitionOn;
+ document.getElementById('diffusion-part-btn').innerHTML = on ? '‖ Раздел' : ' Раздел снят';
+ document.getElementById('df-part-row').classList.toggle('active', on);
+ }
+
+ function diffPoreToggle(rowEl) {
+ if (!diffSim) return;
+ diffSim.togglePore();
+ const pore = diffSim._poreMode;
+ const on = diffSim.partitionOn;
+ rowEl.classList.toggle('active', pore);
+ const tog = document.getElementById('df-pore-toggle');
+ if (tog) tog.style.background = pore ? '#FFB347' : 'rgba(255,255,255,0.15)';
+ const span = tog && tog.querySelector('span');
+ if (span) span.style.marginLeft = pore ? '14px' : '2px';
+ // Also sync partition row
+ document.getElementById('df-part-row').classList.toggle('active', on);
+ }
+
+ function _diffUpdateUI(info) {
+ document.getElementById('dfstat-LA').textContent = info.leftA;
+ document.getElementById('dfstat-LB').textContent = info.leftB;
+ document.getElementById('dfstat-RA').textContent = info.rightA;
+ document.getElementById('dfstat-RB').textContent = info.rightB;
+ document.getElementById('dfstat-mix').textContent = info.mixed + '%';
+ document.getElementById('mpbar-l1').textContent = 'Смешивание';
+ document.getElementById('mpbar-v1').textContent = info.mixed + '%';
+ document.getElementById('mpbar-l2').textContent = 'Лево A/B';
+ document.getElementById('mpbar-v2').textContent = info.leftA + '/' + info.leftB;
+ document.getElementById('mpbar-l3').textContent = 'Право A/B';
+ document.getElementById('mpbar-v3').textContent = info.rightA + '/' + info.rightB;
+ document.getElementById('mpbar-l4').textContent = 'Раздел';
+ const partLabel = !info.partitionOn ? 'снят' : info.poreMode ? 'пора' : 'вкл';
+ document.getElementById('mpbar-v4').textContent = partLabel;
+ document.getElementById('mpbar-v4').style.color = !info.partitionOn ? '#34d399' : info.poreMode ? '#FFB347' : '#fff';
+ document.getElementById('mpbar-l5').textContent = 'Шагов';
+ document.getElementById('mpbar-v5').textContent = info.steps;
+ }
+
+ /* ════════════════════════════════
+ ЗАКОН КУЛОНА
+ ════════════════════════════════ */
+
diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js
index 54dab81..55813f0 100644
--- a/frontend/js/labs/geometry.js
+++ b/frontend/js/labs/geometry.js
@@ -1,4 +1,4 @@
-/* ═══════════════════════════════════════════════════════════════════════
+/* ═══════════════════════════════════════════════════════════════════════
geometry.js — Интерактивная планиметрия для LearnSpace
Phase 1: точки, отрезки, прямые, лучи, окружности, многоугольники
Phase 2: инструменты построения (середина, биссектрисы, параллельные,
@@ -2581,3 +2581,150 @@ class GeoSim {
}, 'image/png');
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function geoSetTool(name, btnEl) {
+ if (!geomSim) return;
+ geomSim.setTool(name);
+ document.querySelectorAll('.geo-tool-btn').forEach(b => b.classList.remove('active'));
+ if (btnEl) btnEl.classList.add('active');
+ _geoShowHint(name);
+ }
+
+ const _GEO_PHASE_HINTS = {
+ parallel_2: 'Теперь кликни на точку — через неё проведём прямую',
+ perpendicular_2: 'Теперь кликни на точку — через неё проведём перпендикуляр',
+ intersect_2: 'Теперь кликни на вторую прямую',
+ foot_2: 'Теперь кликни на точку — найдём основание перпендикуляра',
+ reflect_2: 'Теперь кликни на точку — получишь её симметричное отражение',
+ tangent_2: 'Теперь кликни на внешнюю точку — получишь две касательные',
+ translate_2: 'Теперь кликни конец вектора B',
+ translate_3: 'Теперь кликни точку P — она будет перенесена',
+ midline_2: 'Кликни вершину B (конец первой стороны)',
+ midline_3: 'Кликни вершину C (конец второй стороны) — построим среднюю линию',
+ parallelogram_2: 'Кликни вершину B (смежная с A)',
+ parallelogram_3: 'Кликни вершину C — построим параллелограмм ABCD',
+ scale_2: 'Кликни точку P — построим P\' = O + k·(P − O)',
+ thales_2: 'Кликни точку A (на первом луче)',
+ thales_3: 'Кликни точку B (на втором луче) — построим A\'B\' ∥ AB',
+ };
+
+ function _geoShowHint(name, phase) {
+ const hint = document.getElementById('geo-hint');
+ if (!hint) return;
+ if (phase && phase > 1) {
+ hint.textContent = _GEO_PHASE_HINTS[`${name}_${phase}`] || _GEO_HINTS[name] || '';
+ } else {
+ hint.textContent = _GEO_HINTS[name] || '';
+ }
+ }
+
+ function geoNgonN(delta) {
+ if (!geomSim) return;
+ geomSim.setNgonSides(geomSim._ngonSides + delta);
+ const el = document.getElementById('geo-ngon-n');
+ if (el) el.textContent = geomSim._ngonSides;
+ }
+
+ function geoScaleK(delta) {
+ if (!geomSim) return;
+ const k = Math.round((geomSim._scaleK + delta) * 10) / 10;
+ if (k < 0.1) return;
+ geomSim.setScaleK(k);
+ const el = document.getElementById('geo-scale-k');
+ if (el) el.textContent = k;
+ }
+
+ function geoToggle(prop, rowEl) {
+ if (!geomSim) return;
+ geomSim[prop] = !geomSim[prop];
+ const tog = rowEl.querySelector('.geo-toggle');
+ if (tog) tog.classList.toggle('on', geomSim[prop]);
+ geomSim.render();
+ }
+
+ function _geoUpdateStats() {
+ if (!geomSim) return;
+ const s = geomSim.getStats();
+ document.getElementById('geo-st-pts').textContent = s.pts;
+ document.getElementById('geo-st-segs').textContent = s.segs;
+ document.getElementById('geo-st-circs').textContent = s.circs;
+ document.getElementById('geo-st-polys').textContent = s.polys;
+ const cEl = document.getElementById('geo-st-constr');
+ if (cEl) cEl.textContent = s.constructions || 0;
+ }
+
+ /* Диалог подтверждения удаления объекта с зависимыми */
+ let _geoDelSoftFn = null, _geoDelHardFn = null;
+ function _geoShowDeleteConfirm(obj, deps, softFn, hardFn) {
+ const panel = document.getElementById('geo-del-confirm');
+ const msg = document.getElementById('geo-del-msg');
+ if (!panel || !msg) { hardFn(); return; }
+ const names = { point:'точка', segment:'отрезок', line:'прямая', ray:'луч',
+ circle:'окружность', polygon:'многоугольник', derived_line:'построение' };
+ const n = names[obj.type] || 'объект';
+ msg.textContent = `Удалить ${n}? Зависимых: ${deps.length}.`;
+ _geoDelSoftFn = softFn;
+ _geoDelHardFn = hardFn;
+ panel.classList.add('visible');
+ }
+ function _geoHideDeleteConfirm() {
+ document.getElementById('geo-del-confirm')?.classList.remove('visible');
+ _geoDelSoftFn = _geoDelHardFn = null;
+ }
+ // Кнопки диалога — подключаем после DOM ready
+ document.addEventListener('DOMContentLoaded', () => {
+ document.getElementById('geo-del-soft')?.addEventListener('click', () => {
+ _geoDelSoftFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
+ });
+ document.getElementById('geo-del-hard')?.addEventListener('click', () => {
+ _geoDelHardFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
+ });
+ document.getElementById('geo-del-cancel')?.addEventListener('click', _geoHideDeleteConfirm);
+ });
+
+ function _openGeometry() {
+ document.getElementById('sim-topbar-title').textContent = 'Планиметрия';
+ _simShow('sim-geometry');
+ _simShow('ctrl-geometry');
+
+ _registerSimState(
+ 'geometry',
+ () => geomSim?.exportState(),
+ st => { if (geomSim && st) { geomSim.importState(st); _geoUpdateStats(); } }
+ );
+ if (_embedMode) _startStateEmit('geometry');
+
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const canvas = document.getElementById('geo-canvas');
+ if (!geomSim) {
+ geomSim = new GeoSim(canvas);
+ geomSim.onUpdate = _geoUpdateStats;
+ geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase);
+ geomSim.onDeleteRequest = _geoShowDeleteConfirm;
+
+ // keyboard shortcuts
+ canvas.setAttribute('tabindex', '0');
+ canvas.addEventListener('keydown', e => {
+ if (!geomSim) return;
+ if (e.key === 'Escape') { geoSetTool('select', document.getElementById('geo-btn-select')); }
+ if ((e.ctrlKey||e.metaKey) && e.key === 'z') { e.preventDefault(); geomSim.undo(); _geoUpdateStats(); }
+ if ((e.ctrlKey||e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key==='z'))) { e.preventDefault(); geomSim.redo(); _geoUpdateStats(); }
+ if (e.key === 'Delete' || e.key === 'Backspace') { geomSim.deleteSelected(); _geoUpdateStats(); }
+ if (e.key === 'Enter') { geomSim._finishPolygon?.(); _geoUpdateStats(); }
+ });
+ }
+ geomSim.fit();
+ geomSim.render();
+ _geoUpdateStats();
+
+ // sync toggle UI to current state
+ ['showGrid','showAxes','showLabels','showLengths','showAngles'].forEach(p => {
+ const el = document.getElementById('geo-tog-' + p);
+ if (el) el.classList.toggle('on', !!geomSim[p]);
+ });
+ }));
+ }
+
+ /* ── trig circle ── */
+
diff --git a/frontend/js/labs/graph.js b/frontend/js/labs/graph.js
index edc42f4..4828aa0 100644
--- a/frontend/js/labs/graph.js
+++ b/frontend/js/labs/graph.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════════
GraphSim — interactive function plotter
@@ -491,3 +491,147 @@ class GraphSim {
this.onHover(this.hx, vals);
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openGraph() {
+ document.getElementById('sim-topbar-title').textContent = 'График функции';
+ _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'));
+ gSim.onHover = updateInfoBar;
+ if (!document.getElementById('fn0').value.trim()) {
+ document.getElementById('fn0').value = 'sin(x)';
+ renderPreview(0);
+ gSim.fit();
+ gSim.setFn(0, 'sin(x)', FN_COLORS[0]);
+ return;
+ }
+ }
+ gSim.fit();
+ gSim.draw();
+ }));
+ }
+
+ /* ── projectile ── */
+
+ function toLatex(expr) {
+ if (!expr) return '';
+ return expr
+ // strip leading y= if typed
+ .replace(/^\s*y\s*=\s*/i, '')
+ // inverse trig (before sin/cos/tan)
+ .replace(/\barcsin\b/g, '\\arcsin').replace(/\barccos\b/g, '\\arccos')
+ .replace(/\b(arctan|arctg|atan|acos|asin)\b/g, (_, w) =>
+ w === 'asin' ? '\\arcsin' : w === 'acos' ? '\\arccos' : '\\arctan')
+ // trig
+ .replace(/\bctg\b/g, '\\cot').replace(/\btg\b/g, '\\tan')
+ .replace(/\b(sin|cos|tan)\b/g, '\\$1')
+ // log / exp
+ .replace(/\bln\b/g, '\\ln').replace(/\blog2\b/g, '\\log_2')
+ .replace(/\blog\b/g, '\\log').replace(/\bexp\b/g, '\\exp')
+ // special functions: f(inner) LaTeX form
+ .replace(/\bsqrt\(([^()]*)\)/g, '\\sqrt{$1}')
+ .replace(/\babs\(([^()]*)\)/g, '\\left|$1\\right|')
+ .replace(/\bfloor\(([^()]*)\)/g, '\\lfloor $1 \\rfloor')
+ .replace(/\bceil\(([^()]*)\)/g, '\\lceil $1 \\rceil')
+ .replace(/\b(round|sign)\b/g, '\\operatorname{$1}')
+ // constants
+ .replace(/\bpi\b/gi, '\\pi')
+ // power: wrap exponent in braces for multi-char
+ .replace(/\^(-?\d{2,})/g, '^{$1}')
+ // clean up multiplication
+ .replace(/([0-9])\s*\*\s*([a-zA-Z\\])/g, '$1\\,$2')
+ .replace(/\*/g, '\\cdot ');
+ }
+
+ function renderPreview(idx) {
+ const inp = document.getElementById('fn' + idx);
+ const prev = document.getElementById('fn' + idx + '-prev');
+ const raw = inp?.value?.trim() || '';
+ if (!raw || typeof katex === 'undefined') {
+ prev.innerHTML = ''; prev.classList.remove('has-content'); return;
+ }
+ try {
+ prev.innerHTML = katex.renderToString(toLatex(raw), {
+ throwOnError: false, strict: false, displayMode: false,
+ });
+ prev.classList.add('has-content');
+ } catch { prev.innerHTML = ''; prev.classList.remove('has-content'); }
+ }
+
+ /* debounced formula update */
+ const _debounce = {};
+ function updateFn(idx) {
+ clearTimeout(_debounce[idx]);
+ renderPreview(idx); // instant preview
+ _debounce[idx] = setTimeout(() => {
+ if (!gSim) return;
+ const raw = document.getElementById('fn' + idx).value;
+ const val = raw.replace(/^\s*y\s*=\s*/i, '');
+ const err = gSim.setFn(idx, val, FN_COLORS[idx]);
+ const errEl = document.getElementById('fn' + idx + '-err');
+ errEl.classList.toggle('show', !!err && !!val.trim());
+ }, 350);
+ }
+
+ function applyPreset(expr) {
+ for (let i = 0; i < 3; i++) {
+ const inp = document.getElementById('fn' + i);
+ if (!inp.value.trim()) {
+ inp.value = expr; updateFn(i); inp.focus(); return;
+ }
+ }
+ document.getElementById('fn0').value = expr; updateFn(0);
+ }
+
+ function clearAll() {
+ for (let i = 0; i < 3; i++) {
+ document.getElementById('fn' + i).value = '';
+ document.getElementById('fn' + i + '-prev').innerHTML = '';
+ document.getElementById('fn' + i + '-prev').classList.remove('has-content');
+ document.getElementById('fn' + i + '-err').classList.remove('show');
+ if (gSim) gSim.setFn(i, '', FN_COLORS[i]);
+ }
+ }
+
+ /* hover info bar */
+ function fmtVal(v) {
+ if (v === null || v === undefined) return '—';
+ if (!isFinite(v)) return '∞';
+ const abs = Math.abs(v);
+ if (abs === 0) return '0';
+ if (abs < 0.001 || abs >= 1e6) return v.toExponential(3);
+ return parseFloat(v.toPrecision(6)).toString();
+ }
+
+ function updateInfoBar(mx, vals) {
+ document.getElementById('info-x').textContent = mx !== null ? fmtVal(mx) : '—';
+ document.getElementById('info-y0').textContent = vals ? fmtVal(vals[0]) : '—';
+ document.getElementById('info-y1').textContent = vals ? fmtVal(vals[1]) : '—';
+ document.getElementById('info-y2').textContent = vals ? fmtVal(vals[2]) : '—';
+ }
+
+ /* ════════════════════════════════
+ МОЛЕКУЛЯРНАЯ ФИЗИКА (unified: gas + brownian + states + diffusion)
+ ════════════════════════════════ */
+
+ let _molMode = 'gas'; // 'gas' | 'brownian' | 'states' | 'diffusion'
+
diff --git a/frontend/js/labs/graphtransform.js b/frontend/js/labs/graphtransform.js
index 9c900d4..afd82d7 100644
--- a/frontend/js/labs/graphtransform.js
+++ b/frontend/js/labs/graphtransform.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
GraphTransformSim — graph transformations explorer
y = a·f(k·x + b) + c with sliders for a, k, b, c
@@ -354,3 +354,53 @@ class GraphTransformSim {
cv.addEventListener('touchend', () => { t0 = null; });
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var gtSim = null;
+
+ 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'));
+ gtSim.onUpdate = _gtUpdateUI;
+ }
+ gtSim.fit();
+ gtSim.draw();
+ gtSim._emit();
+ }));
+ }
+
+ function gtParam(name, val) {
+ const v = parseFloat(val);
+ document.getElementById('gt-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1);
+ if (gtSim) gtSim.setParams({ [name]: v });
+ }
+
+ function gtBase(name, btn) {
+ document.querySelectorAll('.gt-base-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ if (gtSim) gtSim.setBase(name);
+ }
+
+ function gtEffect(a, k, b, c) {
+ document.getElementById('sl-gt-a').value = a; document.getElementById('gt-a-val').textContent = a;
+ document.getElementById('sl-gt-k').value = k; document.getElementById('gt-k-val').textContent = k;
+ document.getElementById('sl-gt-b').value = b; document.getElementById('gt-b-val').textContent = b;
+ document.getElementById('sl-gt-c').value = c; document.getElementById('gt-c-val').textContent = c;
+ if (gtSim) gtSim.setParams({ a, k, b, c });
+ }
+
+ function _gtUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('gtbar-v1', info.base);
+ v('gtbar-v2', info.a);
+ v('gtbar-v3', info.k);
+ v('gtbar-v4', info.b);
+ v('gtbar-v5', info.c);
+ }
+
+ /* ── pendulum ── */
diff --git a/frontend/js/labs/hydrostatics.js b/frontend/js/labs/hydrostatics.js
index f3e3d0d..c34ce58 100644
--- a/frontend/js/labs/hydrostatics.js
+++ b/frontend/js/labs/hydrostatics.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════════════════════════════
HydroSim v2 — Гидростатика
Модули: давление · поверхностное натяжение · сообщающиеся сосуды · Архимед
@@ -1348,3 +1348,119 @@ class HydroSim {
}
_notify() { if (this.onUpdate) try { this.onUpdate(this.getInfo()); } catch {} }
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var 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(`${info.formula}`);
+ if (info.liqName) lines.push(`Жидкость: ${info.liqName}${info.rho ? ' (ρ=' + info.rho + ')' : ''}`);
+ if (info.matName) lines.push(`Материал: ${info.matName}`);
+ if (info.FA) lines.push(`F_A = ${info.FA} Н`);
+ if (info.mg) lines.push(`mg = ${info.mg} Н`);
+ if (info.sigma) lines.push(`σ = ${info.sigma} Н/м, θ = ${info.theta}°`);
+ if (info.h && !info.FA) lines.push(`h_подъём = ${info.h} мм`);
+ el.innerHTML = lines.join('
');
+ // 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';
+ }
+ }
+
diff --git a/frontend/js/labs/isoprocess.js b/frontend/js/labs/isoprocess.js
index 04c96aa..a35dd84 100644
--- a/frontend/js/labs/isoprocess.js
+++ b/frontend/js/labs/isoprocess.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
IsoprocessSim — PV-diagram for 4 ideal-gas isoprocesses
n = 1, R = 0.0821 L·atm/mol·K; energies in Joules
@@ -462,3 +462,74 @@ class IsoprocessSim {
});
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var isoSim = null;
+
+ 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'));
+ isoSim.onUpdate = _isoUpdateUI;
+ isoSim.setGamma(1.667);
+ }
+ isoSim.fit();
+ isoSim.draw();
+ isoSim._emit();
+ }));
+ }
+
+ function isoProc(proc, el) {
+ document.querySelectorAll('.iso-proc-btn').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ if (isoSim) isoSim.setProcess(proc);
+ }
+
+ function isoGamma(g, el) {
+ document.querySelectorAll('.iso-gamma-btn').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ if (isoSim) isoSim.setGamma(g);
+ }
+
+ function isoParam(name, val) {
+ const v = parseFloat(val);
+ if (name === 'P1') { document.getElementById('iso-p1-val').textContent = v.toFixed(1); if (isoSim) isoSim.setParams({ P1: v }); }
+ if (name === 'V1') { document.getElementById('iso-v1-val').textContent = v; if (isoSim) isoSim.setParams({ V1: v }); }
+ }
+
+ function isoRatio(val) { if (isoSim) isoSim.setRatio(parseFloat(val)); }
+
+ function isoPreset(name) {
+ const P = {
+ iso_expand: { proc:'isothermal', P1:4, V1:8, ratio:0.75, gamma:1.4 },
+ iso_comp: { proc:'isothermal', P1:1.5, V1:20, ratio:0.25, gamma:1.4 },
+ heat_iso: { proc:'isochoric', P1:2, V1:10, ratio:0.72, gamma:1.667 },
+ adiab_exp: { proc:'adiabatic', P1:5, V1:6, ratio:0.7, gamma:1.667 },
+ };
+ const p = P[name]; if (!p) return;
+ document.querySelectorAll('.iso-proc-btn').forEach(b => b.classList.remove('active'));
+ const pb = document.getElementById(`iproc-${p.proc}`); if (pb) pb.classList.add('active');
+ document.querySelectorAll('.iso-gamma-btn').forEach(b => b.classList.remove('active'));
+ const gb = document.getElementById(p.gamma === 1.4 ? 'igamma-14' : 'igamma-167'); if (gb) gb.classList.add('active');
+ document.getElementById('sl-iso-p1').value = p.P1; document.getElementById('iso-p1-val').textContent = p.P1.toFixed(1);
+ document.getElementById('sl-iso-v1').value = p.V1; document.getElementById('iso-v1-val').textContent = p.V1;
+ document.getElementById('sl-iso-ratio').value = p.ratio;
+ if (isoSim) { isoSim.setGamma(p.gamma); isoSim.setProcess(p.proc); isoSim.setParams({ P1: p.P1, V1: p.V1 }); isoSim.setRatio(p.ratio); }
+ }
+
+ function _isoUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('isobar-t1', info.T1);
+ v('isobar-t2', info.T2);
+ v('isobar-w', info.W);
+ v('isobar-q', info.Q);
+ v('isobar-du', info.dU);
+ }
+
+ /* ── titration ── */
+
diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js
index 834e3af..4a4987a 100644
--- a/frontend/js/labs/lab-init.js
+++ b/frontend/js/labs/lab-init.js
@@ -192,3447 +192,6 @@
/* ── graph ── */
- function _openGraph() {
- document.getElementById('sim-topbar-title').textContent = 'График функции';
- _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'));
- gSim.onHover = updateInfoBar;
- if (!document.getElementById('fn0').value.trim()) {
- document.getElementById('fn0').value = 'sin(x)';
- renderPreview(0);
- gSim.fit();
- gSim.setFn(0, 'sin(x)', FN_COLORS[0]);
- return;
- }
- }
- gSim.fit();
- gSim.draw();
- }));
- }
-
- /* ── projectile ── */
-
- function _openProjectile() {
- 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) {
- pSim = new ProjectileSim(document.getElementById('proj-canvas'));
- pSim.onUpdate = _projUpdateUI;
- pSim.onPlayPause = projPlayPause;
- }
- pSim.fit();
- projParam(); // sync sliders sim
- pSim.draw();
- _projUpdateUI(pSim.stats());
- }));
- }
-
- function projPlayPause() {
- if (!pSim) return;
- if (pSim.playing) {
- pSim.pause();
- } else {
- pSim.play();
- }
- _projSyncPlayBtn();
- }
-
- function _projSyncPlayBtn() {
- /* small topbar button */
- const tb = document.getElementById('proj-play-btn');
- /* big launch button */
- const lb = document.getElementById('proj-launch-main');
- const lbl = document.getElementById('proj-launch-label');
- const lic = document.getElementById('proj-launch-icon');
- if (!pSim) return;
-
- const tf = pSim._curTFlight();
- const done = !pSim.playing && pSim.t >= tf && pSim.t > 0;
- const playing = pSim.playing;
-
- /* topbar */
- if (tb) {
- tb.innerHTML = playing
- ? ''
- : '';
- tb.title = playing ? 'Пауза' : 'Запустить';
- tb.classList.toggle('active', playing);
- }
-
- /* big button */
- if (lb && lbl && lic) {
- lb.classList.toggle('paused', playing);
- lb.classList.toggle('done', done && !playing);
- if (playing) {
- lic.innerHTML = '';
- lbl.textContent = 'Пауза';
- } else if (done) {
- lic.innerHTML = '';
- lbl.textContent = 'Повторить';
- } else {
- lic.innerHTML = '';
- lbl.textContent = 'Запустить';
- }
- }
- }
-
- function projParam() {
- const v0 = +document.getElementById('sl-v0').value;
- const angle = +document.getElementById('sl-angle').value;
- const h0 = +document.getElementById('sl-h0').value;
- const g = +document.getElementById('sl-g').value;
-
- document.getElementById('p-v0').textContent = v0 + ' м/с';
- document.getElementById('p-angle').textContent = angle + '°';
- document.getElementById('p-h0').textContent = h0 + ' м';
- document.getElementById('p-g').textContent = g.toFixed(2) + ' м/с²';
-
- if (pSim) { pSim.setParams({ v0, angle, h0, g }); _projSyncPlayBtn(); }
- }
-
- function projPreset(v0, angle, h0, g) {
- document.getElementById('sl-v0').value = v0;
- document.getElementById('sl-angle').value = angle;
- document.getElementById('sl-h0').value = h0;
- document.getElementById('sl-g').value = g;
- projParam();
- }
-
- function projToggleDrag(rowEl) {
- if (!pSim) return;
- pSim.drag = !pSim.drag;
- const on = pSim.drag;
- rowEl.classList.toggle('active', on);
- const tog = document.getElementById('drag-toggle');
- tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
- tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
- document.getElementById('drag-params').style.display = on ? '' : 'none';
- document.getElementById('ps-loss-wrap').style.display = on ? '' : 'none';
- if (on) {
- const cd = +document.getElementById('sl-cd').value / 100;
- const mass = +document.getElementById('sl-mass').value;
- pSim.setParams({ drag: true, Cd: cd, mass });
- } else {
- pSim.setParams({ drag: false });
- }
- }
-
- function projCdChange() {
- const cd = +document.getElementById('sl-cd').value / 100;
- document.getElementById('p-cd').textContent = cd.toFixed(2);
- if (pSim) pSim.setParams({ Cd: cd });
- }
-
- function projMassChange() {
- const mass = +document.getElementById('sl-mass').value;
- document.getElementById('p-mass').textContent = mass + ' кг';
- if (pSim) pSim.setParams({ mass });
- }
-
- function projWindChange() {
- const wind = +document.getElementById('sl-wind').value;
- const label = wind === 0 ? '0 м/с' : (wind > 0 ? ' +' : ' ') + Math.abs(wind) + ' м/с';
- document.getElementById('p-wind').textContent = label;
- document.getElementById('ps-loss-wrap').style.display = wind !== 0 ? '' : (pSim && pSim.drag ? '' : 'none');
- if (pSim) { pSim.setParams({ wind }); _projSyncPlayBtn(); }
- }
-
- function projToggleBounce(rowEl) {
- if (!pSim) return;
- pSim.bounce = !pSim.bounce;
- const on = pSim.bounce;
- rowEl.classList.toggle('active', on);
- const tog = document.getElementById('bounce-toggle');
- tog.style.background = on ? 'rgba(123,245,164,0.8)' : 'rgba(255,255,255,0.12)';
- tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
- document.getElementById('bounce-params').style.display = on ? '' : 'none';
- const e = +document.getElementById('sl-restitution').value / 100;
- pSim.setParams({ bounce: on, restitution: e });
- }
-
- function projRestitutionChange() {
- const e = +document.getElementById('sl-restitution').value / 100;
- document.getElementById('p-restitution').textContent = e.toFixed(2);
- if (pSim) pSim.setParams({ restitution: e });
- }
-
- function projSetSpeed(s, el) {
- if (pSim) pSim.setSpeed(s);
- document.querySelectorAll('.proj-speed').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- }
-
- function projSaveGhost() {
- if (pSim) pSim.saveGhost();
- }
-
- function projClearGhosts() {
- if (pSim) pSim.clearGhosts();
- }
-
- function _projUpdateUI(s) {
- const fmt = (n, unit) => n < 10000 ? n.toFixed(2) + ' ' + unit : (n/1000).toFixed(2) + ' к' + unit;
- document.getElementById('ps-range').textContent = fmt(s.range, 'м');
- document.getElementById('ps-hmax').textContent = fmt(s.hMax, 'м');
- document.getElementById('ps-tf').textContent = s.tf.toFixed(2) + ' с';
- document.getElementById('ps-vland').textContent = fmt(s.vLand, 'м/с');
- document.getElementById('ps-t').textContent = s.t.toFixed(2) + ' с';
- const laEl = document.getElementById('ps-land-angle');
- if (laEl) laEl.textContent = s.landAngle > 0.5 ? s.landAngle.toFixed(1) + '°' : '—';
- if (s.hasMod) {
- const lossEl = document.getElementById('ps-loss');
- if (lossEl) {
- const sign = s.rangeLoss > 0 ? '+' : '';
- lossEl.textContent = s.rangeLoss !== 0 ? sign + s.rangeLoss + '%' : '0%';
- lossEl.style.color = s.rangeLoss < 0 ? '#EF476F' : '#7BF5A4';
- }
- }
- _projSyncPlayBtn();
- }
-
- /* ── collision ── */
-
- function _openCollision() {
- 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) {
- cSim = new CollisionSim(document.getElementById('coll-canvas'));
- cSim.onUpdate = _collUpdateUI;
- cSim.onPlayPause = collPlayPause;
- }
- cSim.fit();
- cSim.setSpeed(+document.getElementById('sl-speed').value);
- collParam();
- cSim.draw();
- _collUpdateUI(cSim.stats());
- }));
- }
-
- function collPlayPause() {
- if (!cSim) return;
- if (cSim.playing) { cSim.pause(); } else { cSim.play(); }
- _collSyncBtn();
- }
-
- function _collSyncBtn() {
- const tb = document.getElementById('coll-play-btn');
- const lb = document.getElementById('coll-launch-main');
- const lbl = document.getElementById('coll-launch-label');
- const lic = document.getElementById('coll-launch-icon');
- if (!cSim) return;
- const playing = cSim.playing;
-
- if (tb) {
- tb.innerHTML = playing
- ? ''
- : '';
- tb.title = playing ? 'Пауза' : 'Запустить';
- tb.classList.toggle('active', playing);
- }
-
- if (lb && lbl && lic) {
- lb.classList.toggle('paused', playing);
- lb.classList.remove('done');
- if (playing) {
- lic.innerHTML = '';
- lbl.textContent = 'Пауза';
- } else {
- lic.innerHTML = '';
- lbl.textContent = 'Запустить';
- }
- }
- }
-
- function collParam() {
- const m1 = +document.getElementById('sl-m1').value;
- const m2 = +document.getElementById('sl-m2').value;
- const v1 = +document.getElementById('sl-cv1').value;
- const v2 = +document.getElementById('sl-cv2').value;
- const angle = +document.getElementById('sl-cangle').value;
- const e = +document.getElementById('sl-e').value;
- const spd = +document.getElementById('sl-speed').value;
-
- document.getElementById('c-m1').textContent = m1 + ' кг';
- document.getElementById('c-m2').textContent = m2 + ' кг';
- document.getElementById('c-v1').textContent = v1 + ' м/с';
- document.getElementById('c-v2').textContent = v2 + ' м/с';
- document.getElementById('c-angle').textContent = angle + '°';
- document.getElementById('c-e').textContent = e.toFixed(2);
- document.getElementById('c-speed').textContent = spd.toFixed(2) + '×';
-
- if (cSim) {
- /* speed change doesn't require a reset */
- const speedChanged = Math.abs(cSim.speed - spd) > 0.001;
- if (speedChanged) cSim.setSpeed(spd);
-
- const physChanged = cSim.m1 !== m1 || cSim.m2 !== m2 ||
- cSim.v1 !== v1 || cSim.v2 !== v2 ||
- cSim.angle !== angle || cSim.e !== e;
- if (physChanged) cSim.setParams({ m1, m2, v1, v2, angle, e });
- _collSyncBtn();
- }
- }
-
- function collPreset(m1, m2, v1, v2, angle, e) {
- document.getElementById('sl-m1').value = m1;
- document.getElementById('sl-m2').value = m2;
- document.getElementById('sl-cv1').value = v1;
- document.getElementById('sl-cv2').value = v2;
- document.getElementById('sl-cangle').value = angle;
- document.getElementById('sl-e').value = e;
- collParam();
- }
-
- function _collUpdateUI(s) {
- // before/after are arrays [{m, vx, vy, ke}, ...]
- function snapKE(arr) { return arr ? arr.reduce((t, b) => t + b.ke, 0) : null; }
- function snapP(arr) {
- if (!arr) return null;
- return Math.hypot(arr.reduce((t, b) => t + b.m * b.vx, 0),
- arr.reduce((t, b) => t + b.m * b.vy, 0));
- }
- const bKE = snapKE(s.before), bP = snapP(s.before);
- const aKE = snapKE(s.after), aP = snapP(s.after);
- const f2 = v => v !== null ? v.toFixed(2) : '—';
-
- document.getElementById('cs-pbefore').textContent = bP !== null ? f2(bP) + ' кг·м/с' : '—';
- document.getElementById('cs-pafter').textContent = aP !== null ? f2(aP) + ' кг·м/с' : '—';
- document.getElementById('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж' : '—';
- document.getElementById('cs-keafter').textContent = aKE !== null ? f2(aKE) + ' Дж' : '—';
- document.getElementById('cs-count').textContent = s.colCount;
- _collSyncBtn();
- }
-
- /* ── magnetic ── */
-
- function _openMagnetic() {
- document.getElementById('sim-topbar-title').textContent = 'Магнитное поле токов';
- _simShow('sim-mag');
- _simShow('ctrl-mag');
-
- requestAnimationFrame(() => requestAnimationFrame(() => {
- if (!mSim) {
- mSim = new MagneticSim(document.getElementById('mag-canvas'));
- mSim.onUpdate = _magUpdateUI;
- }
- mSim.fit();
- // default preset on first open
- if (mSim.sources.length === 0) mSim.preset('anti');
- _magUpdateUI(mSim.info());
- }));
- }
-
- function magMode(dir) {
- if (!mSim) return;
- mSim.addMode = dir;
- document.getElementById('mag-add-out').classList.toggle('active', dir === 'out');
- document.getElementById('mag-add-in').classList.toggle('active', dir === 'in');
- document.getElementById('mag-mode-out').classList.toggle('active', dir === 'out');
- document.getElementById('mag-mode-in').classList.toggle('active', dir === 'in');
- }
-
- function magCurrentChange() {
- const I = +document.getElementById('sl-curI').value;
- document.getElementById('m-curI').textContent = I + ' А';
- document.getElementById('mbar-I').textContent = I + ' А';
- if (mSim) mSim.setCurrentAll(I);
- }
-
- function magLayer(name, rowEl) {
- if (!mSim) return;
- mSim.layers[name] = !mSim.layers[name];
- rowEl.classList.toggle('active', mSim.layers[name]);
- mSim._invalidateCache();
- mSim.draw();
- }
-
- function magParticle(rowEl) {
- if (!mSim) return;
- mSim.toggleParticle();
- rowEl.classList.toggle('active', mSim.particleOn);
- _magUpdateUI(mSim.info());
- }
-
- function magCondToggle(rowEl) {
- if (!mSim) return;
- mSim.toggleConductor();
- const on = mSim._cond.on;
- rowEl.classList.toggle('active', on);
- document.getElementById('cond-I-block').style.display = on ? '' : 'none';
- _magUpdateUI(mSim.info());
- }
-
- function magCondCurrentChange() {
- if (!mSim) return;
- const I = parseFloat(document.getElementById('sl-condI').value);
- document.getElementById('m-condI').textContent = I + ' А';
- mSim.setConductorI(I);
- }
-
- function magFluxToggle(rowEl) {
- if (!mSim) return;
- mSim.toggleFlux();
- rowEl.classList.toggle('active', mSim._flux.on);
- _magUpdateUI(mSim.info());
- }
-
- function _magUpdateUI(info) {
- document.getElementById('ms-out').textContent = info.out;
- document.getElementById('ms-in').textContent = info.inn;
- document.getElementById('mbar-total').textContent = info.total;
- document.getElementById('mbar-out').textContent = info.out;
- document.getElementById('mbar-in').textContent = info.inn;
- document.getElementById('mbar-particle').textContent = info.particleOn ? 'вкл' : 'выкл';
- document.getElementById('mbar-particle').style.color = info.particleOn ? '#ffff50' : '';
- // Ampere force
- const fEl = document.getElementById('mbar-ampere');
- if (info.condOn && info.Fz !== 0) {
- const dir = info.Fz > 0 ? '⊙' : '⊗';
- fEl.textContent = dir + ' ' + Math.abs(info.Fz).toFixed(3);
- fEl.style.color = '#fbbf24';
- } else {
- fEl.textContent = '—';
- fEl.style.color = '#fbbf24';
- }
- // Flux
- const phEl = document.getElementById('mbar-flux');
- if (info.fluxOn) {
- phEl.textContent = info.flux.toExponential(2) + ' Вб';
- phEl.style.color = '#34d399';
- } else {
- phEl.textContent = '—';
- phEl.style.color = '#34d399';
- }
- }
-
- /* ── triangle ── */
-
- function _openTriangle() {
- document.getElementById('sim-topbar-title').textContent = 'Геометрия треугольника';
- _simShow('sim-tri');
- _simShow('ctrl-tri');
-
- requestAnimationFrame(() => requestAnimationFrame(() => {
- if (!tSim) {
- tSim = new TriangleSim(document.getElementById('tri-canvas'));
- tSim.onUpdate = _triUpdateUI;
- }
- tSim.fit();
- tSim.draw();
- _triUpdateUI(tSim.stats());
- }));
- }
-
- function triToggle(layer, rowEl) {
- if (!tSim) return;
- tSim.toggleLayer(layer);
- rowEl.classList.toggle('active', tSim.layers[layer]);
- }
-
- function _triUpdateUI(s) {
- const f2 = v => v.toFixed(2);
- const deg = v => v.toFixed(1) + '°';
- const unit = v => f2(v) + ' ед';
-
- // panel
- document.getElementById('ts-a').textContent = unit(s.a);
- document.getElementById('ts-b').textContent = unit(s.b);
- document.getElementById('ts-c').textContent = unit(s.c);
- document.getElementById('ts-A').textContent = deg(s.A);
- document.getElementById('ts-B').textContent = deg(s.B);
- document.getElementById('ts-C').textContent = deg(s.C);
- document.getElementById('ts-S').textContent = f2(s.S) + ' ед²';
- document.getElementById('ts-P').textContent = unit(s.perim);
- document.getElementById('ts-R').textContent = unit(s.R);
- document.getElementById('ts-r').textContent = unit(s.r);
- document.getElementById('ts-type').textContent = s.type;
-
- // stats bar
- document.getElementById('tbar-a').textContent = unit(s.a);
- document.getElementById('tbar-b').textContent = unit(s.b);
- document.getElementById('tbar-c').textContent = unit(s.c);
- document.getElementById('tbar-S').textContent = f2(s.S) + ' ед²';
- document.getElementById('tbar-P').textContent = unit(s.perim);
- document.getElementById('tbar-Rr').textContent = f2(s.R) + ' / ' + f2(s.r);
- }
-
- /* ── geometry (planimetry) ── */
-
- const _GEO_HINTS = {
- select: 'Клик — выбрать объект, перетащи точку для перемещения',
- point: 'Клик — поставить точку',
- segment: 'Кликни 2 точки для отрезка',
- line: 'Кликни 2 точки для прямой',
- ray: 'Кликни: начало, затем направление',
- circle: 'Клик — центр; второй клик — радиус',
- triangle: 'Кликни 3 точки для треугольника',
- quad: 'Кликни 4 точки для четырёхугольника',
- polygon: 'Кликай точки; двойной клик или Enter — завершить',
- midpoint: 'Кликни 2 точки — получи середину отрезка',
- perpbisect: 'Кликни 2 точки — получи серединный перпендикуляр',
- anglebisect: 'Кликни: точку A, затем вершину угла, затем точку B',
- parallel: 'Сначала кликни на прямую/отрезок, затем на точку',
- perpendicular:'Сначала кликни на прямую/отрезок, затем на точку',
- intersect: 'Кликни на первую прямую, затем на вторую',
- foot: 'Сначала кликни на прямую/отрезок',
- circumcircle: 'Кликни 3 точки треугольника — получи описанную окружность',
- incircle: 'Кликни 3 точки треугольника — получи вписанную окружность',
- reflect: 'Сначала кликни на ось симметрии (прямую/отрезок)',
- ngon: 'Клик — центр правильного многоугольника; второй клик — вершина',
- tangent: 'Кликни на окружность — построим касательные',
- translate: 'Кликни начало вектора A',
- tick: 'Кликни на отрезок или сторону — добавить штрих (1–3; ещё раз — убрать)',
- arcmark: 'Кликни на вершину полигона — добавить дугу (1–3; ещё раз — убрать)',
- parallelmark: 'Кликни на отрезок или сторону — добавить метку параллельности (1–2; ещё раз — убрать)',
- altitude: 'Кликни на вершину треугольника — построим высоту из неё',
- median: 'Кликни на вершину треугольника — построим медиану из неё',
- centroid: 'Кликни на треугольник или внутри него — построим все 3 медианы и центроид G',
- orthocenter: 'Кликни на треугольник или внутри него — построим все 3 высоты и ортоцентр H',
- thales: 'Кликни центр подобия O (начало лучей)',
- midline: 'Кликни вершину A треугольника',
- parallelogram:'Кликни вершину A параллелограмма',
- diagonal: 'Кликни внутри четырёхугольника — построим диагонали',
- scale: 'Кликни центр подобия O',
- };
-
- function geoSetTool(name, btnEl) {
- if (!geomSim) return;
- geomSim.setTool(name);
- document.querySelectorAll('.geo-tool-btn').forEach(b => b.classList.remove('active'));
- if (btnEl) btnEl.classList.add('active');
- _geoShowHint(name);
- }
-
- const _GEO_PHASE_HINTS = {
- parallel_2: 'Теперь кликни на точку — через неё проведём прямую',
- perpendicular_2: 'Теперь кликни на точку — через неё проведём перпендикуляр',
- intersect_2: 'Теперь кликни на вторую прямую',
- foot_2: 'Теперь кликни на точку — найдём основание перпендикуляра',
- reflect_2: 'Теперь кликни на точку — получишь её симметричное отражение',
- tangent_2: 'Теперь кликни на внешнюю точку — получишь две касательные',
- translate_2: 'Теперь кликни конец вектора B',
- translate_3: 'Теперь кликни точку P — она будет перенесена',
- midline_2: 'Кликни вершину B (конец первой стороны)',
- midline_3: 'Кликни вершину C (конец второй стороны) — построим среднюю линию',
- parallelogram_2: 'Кликни вершину B (смежная с A)',
- parallelogram_3: 'Кликни вершину C — построим параллелограмм ABCD',
- scale_2: 'Кликни точку P — построим P\' = O + k·(P − O)',
- thales_2: 'Кликни точку A (на первом луче)',
- thales_3: 'Кликни точку B (на втором луче) — построим A\'B\' ∥ AB',
- };
-
- function _geoShowHint(name, phase) {
- const hint = document.getElementById('geo-hint');
- if (!hint) return;
- if (phase && phase > 1) {
- hint.textContent = _GEO_PHASE_HINTS[`${name}_${phase}`] || _GEO_HINTS[name] || '';
- } else {
- hint.textContent = _GEO_HINTS[name] || '';
- }
- }
-
- function geoNgonN(delta) {
- if (!geomSim) return;
- geomSim.setNgonSides(geomSim._ngonSides + delta);
- const el = document.getElementById('geo-ngon-n');
- if (el) el.textContent = geomSim._ngonSides;
- }
-
- function geoScaleK(delta) {
- if (!geomSim) return;
- const k = Math.round((geomSim._scaleK + delta) * 10) / 10;
- if (k < 0.1) return;
- geomSim.setScaleK(k);
- const el = document.getElementById('geo-scale-k');
- if (el) el.textContent = k;
- }
-
- function geoToggle(prop, rowEl) {
- if (!geomSim) return;
- geomSim[prop] = !geomSim[prop];
- const tog = rowEl.querySelector('.geo-toggle');
- if (tog) tog.classList.toggle('on', geomSim[prop]);
- geomSim.render();
- }
-
- function _geoUpdateStats() {
- if (!geomSim) return;
- const s = geomSim.getStats();
- document.getElementById('geo-st-pts').textContent = s.pts;
- document.getElementById('geo-st-segs').textContent = s.segs;
- document.getElementById('geo-st-circs').textContent = s.circs;
- document.getElementById('geo-st-polys').textContent = s.polys;
- const cEl = document.getElementById('geo-st-constr');
- if (cEl) cEl.textContent = s.constructions || 0;
- }
-
- /* Диалог подтверждения удаления объекта с зависимыми */
- let _geoDelSoftFn = null, _geoDelHardFn = null;
- function _geoShowDeleteConfirm(obj, deps, softFn, hardFn) {
- const panel = document.getElementById('geo-del-confirm');
- const msg = document.getElementById('geo-del-msg');
- if (!panel || !msg) { hardFn(); return; }
- const names = { point:'точка', segment:'отрезок', line:'прямая', ray:'луч',
- circle:'окружность', polygon:'многоугольник', derived_line:'построение' };
- const n = names[obj.type] || 'объект';
- msg.textContent = `Удалить ${n}? Зависимых: ${deps.length}.`;
- _geoDelSoftFn = softFn;
- _geoDelHardFn = hardFn;
- panel.classList.add('visible');
- }
- function _geoHideDeleteConfirm() {
- document.getElementById('geo-del-confirm')?.classList.remove('visible');
- _geoDelSoftFn = _geoDelHardFn = null;
- }
- // Кнопки диалога — подключаем после DOM ready
- document.addEventListener('DOMContentLoaded', () => {
- document.getElementById('geo-del-soft')?.addEventListener('click', () => {
- _geoDelSoftFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
- });
- document.getElementById('geo-del-hard')?.addEventListener('click', () => {
- _geoDelHardFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
- });
- document.getElementById('geo-del-cancel')?.addEventListener('click', _geoHideDeleteConfirm);
- });
-
- function _openGeometry() {
- document.getElementById('sim-topbar-title').textContent = 'Планиметрия';
- _simShow('sim-geometry');
- _simShow('ctrl-geometry');
-
- _registerSimState(
- 'geometry',
- () => geomSim?.exportState(),
- st => { if (geomSim && st) { geomSim.importState(st); _geoUpdateStats(); } }
- );
- if (_embedMode) _startStateEmit('geometry');
-
- requestAnimationFrame(() => requestAnimationFrame(() => {
- const canvas = document.getElementById('geo-canvas');
- if (!geomSim) {
- geomSim = new GeoSim(canvas);
- geomSim.onUpdate = _geoUpdateStats;
- geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase);
- geomSim.onDeleteRequest = _geoShowDeleteConfirm;
-
- // keyboard shortcuts
- canvas.setAttribute('tabindex', '0');
- canvas.addEventListener('keydown', e => {
- if (!geomSim) return;
- if (e.key === 'Escape') { geoSetTool('select', document.getElementById('geo-btn-select')); }
- if ((e.ctrlKey||e.metaKey) && e.key === 'z') { e.preventDefault(); geomSim.undo(); _geoUpdateStats(); }
- if ((e.ctrlKey||e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key==='z'))) { e.preventDefault(); geomSim.redo(); _geoUpdateStats(); }
- if (e.key === 'Delete' || e.key === 'Backspace') { geomSim.deleteSelected(); _geoUpdateStats(); }
- if (e.key === 'Enter') { geomSim._finishPolygon?.(); _geoUpdateStats(); }
- });
- }
- geomSim.fit();
- geomSim.render();
- _geoUpdateStats();
-
- // sync toggle UI to current state
- ['showGrid','showAxes','showLabels','showLengths','showAngles'].forEach(p => {
- const el = document.getElementById('geo-tog-' + p);
- if (el) el.classList.toggle('on', !!geomSim[p]);
- });
- }));
- }
-
- /* ── trig circle ── */
-
- var trigSim = null;
-
- function _openTrigCircle() {
- document.getElementById('sim-topbar-title').textContent = 'Тригонометрическая окружность';
- _simShow('sim-trigcircle');
- _simShow('ctrl-trigcircle');
-
- requestAnimationFrame(() => requestAnimationFrame(() => {
- if (!trigSim) {
- trigSim = new TrigCircleSim(document.getElementById('trigcircle-canvas'));
- trigSim.onUpdate = _trigUpdateUI;
- }
- trigSim.fit();
- trigSim.start();
- _trigUpdateUI(trigSim.stats());
- }));
- }
-
- function trigToggle(layer, rowEl) {
- if (!trigSim) return;
- const isActive = rowEl.classList.toggle('active');
- trigSim.toggleLayer(layer, isActive);
- }
-
- function trigSetGraphFn(fn, el) {
- if (!trigSim) return;
- document.querySelectorAll('.trig-fn-btn').forEach(b => b.classList.remove('active'));
- el.classList.add('active');
- trigSim.setGraphFn(fn);
- }
-
- function trigGoTo(rad) {
- if (!trigSim) return;
- trigSim.goToAngle(rad);
- }
-
- function trigReset() {
- if (!trigSim) return;
- trigSim.setAngle(Math.PI / 4);
- }
-
- function _trigUpdateUI(s) {
- const _f = v => {
- if (v === undefined) return '—';
- const a = Math.abs(v), sg = v < 0 ? '−' : '';
- if (a < 5e-4) return '0';
- if (Math.abs(a - 0.5) < 1e-3) return sg + '½';
- if (Math.abs(a - 1) < 1e-3) return sg + '1';
- if (Math.abs(a - Math.SQRT2/2) < 1e-3) return sg + '√2/2';
- if (Math.abs(a - Math.sqrt(3)/2) < 1e-3) return sg + '√3/2';
- if (Math.abs(a - Math.sqrt(3)/3) < 1e-3) return sg + '√3/3';
- if (Math.abs(a - Math.sqrt(3)) < 1e-3) return sg + '√3';
- return v.toFixed(4);
- };
- const degStr = s.deg.toFixed(1) + '°';
-
- // Panel values (nice fractions)
- document.getElementById('trig-v-sin').textContent = _f(s.sin);
- document.getElementById('trig-v-cos').textContent = _f(s.cos);
- document.getElementById('trig-v-tan').textContent = _f(s.tan);
- document.getElementById('trig-v-cot').textContent = _f(s.cot);
-
- // Angle badge
- document.getElementById('trig-angle-badge').innerHTML =
- `${degStr} = ${s.radLabel}
${s.angle.toFixed(4)} рад`;
-
- // Stats bar (nice fractions)
- document.getElementById('trigbar-angle').textContent = degStr;
- document.getElementById('trigbar-sin').textContent = _f(s.sin);
- document.getElementById('trigbar-cos').textContent = _f(s.cos);
- document.getElementById('trigbar-tan').textContent = _f(s.tan);
- document.getElementById('trigbar-cot').textContent = _f(s.cot);
- document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
- }
-
- /* ── KaTeX live preview ── */
-
- /** Convert user ascii expression LaTeX string for KaTeX preview */
- function toLatex(expr) {
- if (!expr) return '';
- return expr
- // strip leading y= if typed
- .replace(/^\s*y\s*=\s*/i, '')
- // inverse trig (before sin/cos/tan)
- .replace(/\barcsin\b/g, '\\arcsin').replace(/\barccos\b/g, '\\arccos')
- .replace(/\b(arctan|arctg|atan|acos|asin)\b/g, (_, w) =>
- w === 'asin' ? '\\arcsin' : w === 'acos' ? '\\arccos' : '\\arctan')
- // trig
- .replace(/\bctg\b/g, '\\cot').replace(/\btg\b/g, '\\tan')
- .replace(/\b(sin|cos|tan)\b/g, '\\$1')
- // log / exp
- .replace(/\bln\b/g, '\\ln').replace(/\blog2\b/g, '\\log_2')
- .replace(/\blog\b/g, '\\log').replace(/\bexp\b/g, '\\exp')
- // special functions: f(inner) LaTeX form
- .replace(/\bsqrt\(([^()]*)\)/g, '\\sqrt{$1}')
- .replace(/\babs\(([^()]*)\)/g, '\\left|$1\\right|')
- .replace(/\bfloor\(([^()]*)\)/g, '\\lfloor $1 \\rfloor')
- .replace(/\bceil\(([^()]*)\)/g, '\\lceil $1 \\rceil')
- .replace(/\b(round|sign)\b/g, '\\operatorname{$1}')
- // constants
- .replace(/\bpi\b/gi, '\\pi')
- // power: wrap exponent in braces for multi-char
- .replace(/\^(-?\d{2,})/g, '^{$1}')
- // clean up multiplication
- .replace(/([0-9])\s*\*\s*([a-zA-Z\\])/g, '$1\\,$2')
- .replace(/\*/g, '\\cdot ');
- }
-
- function renderPreview(idx) {
- const inp = document.getElementById('fn' + idx);
- const prev = document.getElementById('fn' + idx + '-prev');
- const raw = inp?.value?.trim() || '';
- if (!raw || typeof katex === 'undefined') {
- prev.innerHTML = ''; prev.classList.remove('has-content'); return;
- }
- try {
- prev.innerHTML = katex.renderToString(toLatex(raw), {
- throwOnError: false, strict: false, displayMode: false,
- });
- prev.classList.add('has-content');
- } catch { prev.innerHTML = ''; prev.classList.remove('has-content'); }
- }
-
- /* debounced formula update */
- const _debounce = {};
- function updateFn(idx) {
- clearTimeout(_debounce[idx]);
- renderPreview(idx); // instant preview
- _debounce[idx] = setTimeout(() => {
- if (!gSim) return;
- const raw = document.getElementById('fn' + idx).value;
- const val = raw.replace(/^\s*y\s*=\s*/i, '');
- const err = gSim.setFn(idx, val, FN_COLORS[idx]);
- const errEl = document.getElementById('fn' + idx + '-err');
- errEl.classList.toggle('show', !!err && !!val.trim());
- }, 350);
- }
-
- function applyPreset(expr) {
- for (let i = 0; i < 3; i++) {
- const inp = document.getElementById('fn' + i);
- if (!inp.value.trim()) {
- inp.value = expr; updateFn(i); inp.focus(); return;
- }
- }
- document.getElementById('fn0').value = expr; updateFn(0);
- }
-
- function clearAll() {
- for (let i = 0; i < 3; i++) {
- document.getElementById('fn' + i).value = '';
- document.getElementById('fn' + i + '-prev').innerHTML = '';
- document.getElementById('fn' + i + '-prev').classList.remove('has-content');
- document.getElementById('fn' + i + '-err').classList.remove('show');
- if (gSim) gSim.setFn(i, '', FN_COLORS[i]);
- }
- }
-
- /* hover info bar */
- function fmtVal(v) {
- if (v === null || v === undefined) return '—';
- if (!isFinite(v)) return '∞';
- const abs = Math.abs(v);
- if (abs === 0) return '0';
- if (abs < 0.001 || abs >= 1e6) return v.toExponential(3);
- return parseFloat(v.toPrecision(6)).toString();
- }
-
- function updateInfoBar(mx, vals) {
- document.getElementById('info-x').textContent = mx !== null ? fmtVal(mx) : '—';
- document.getElementById('info-y0').textContent = vals ? fmtVal(vals[0]) : '—';
- document.getElementById('info-y1').textContent = vals ? fmtVal(vals[1]) : '—';
- document.getElementById('info-y2').textContent = vals ? fmtVal(vals[2]) : '—';
- }
-
- /* ════════════════════════════════
- МОЛЕКУЛЯРНАЯ ФИЗИКА (unified: gas + brownian + states + diffusion)
- ════════════════════════════════ */
-
- let _molMode = 'gas'; // 'gas' | 'brownian' | 'states' | 'diffusion'
-
- function _openMolPhys(mode) {
- document.getElementById('sim-topbar-title').textContent = 'Молекулярная физика';
- _simShow('sim-molphys');
- _simShow('ctrl-molphys');
-
- requestAnimationFrame(() => requestAnimationFrame(() => {
- // lazy-init all sims
- if (!gasSim) { gasSim = new GasSim(document.getElementById('gas-canvas')); gasSim.onUpdate = _gasUpdateUI; }
- if (!brownSim) { brownSim = new BrownianSim(document.getElementById('brownian-canvas')); brownSim.onUpdate = _brownUpdateUI; }
- if (!statesSim) { statesSim = new StatesSim(document.getElementById('states-canvas')); statesSim.onUpdate = _statesUpdateUI; }
- if (!diffSim) { diffSim = new DiffusionSim(document.getElementById('diffusion-canvas')); diffSim.onUpdate = _diffUpdateUI; }
-
- molMode(mode || 'gas');
- }));
- }
-
- function molMode(mode, btn) {
- _molMode = mode;
- // stop all
- if (gasSim) gasSim.stop();
- if (brownSim) brownSim.stop();
- if (statesSim) statesSim.stop();
- if (diffSim) diffSim.stop();
-
- // toggle mode buttons
- document.querySelectorAll('.mol-mode').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- else { const mb = document.getElementById('mol-mode-' + mode); if (mb) mb.classList.add('active'); }
-
- // toggle panels
- const panels = ['gas', 'brownian', 'states', 'diffusion'];
- panels.forEach(p => {
- document.getElementById('mol-panel-' + p).style.display = p === mode ? '' : 'none';
- });
-
- // toggle canvases
- document.getElementById('gas-canvas').style.display = mode === 'gas' ? 'block' : 'none';
- document.getElementById('brownian-canvas').style.display = mode === 'brownian' ? 'block' : 'none';
- document.getElementById('states-canvas').style.display = mode === 'states' ? 'block' : 'none';
- document.getElementById('diffusion-canvas').style.display = mode === 'diffusion' ? 'block' : 'none';
-
- // toggle topbar diffusion partition button
- document.getElementById('ctrl-mol-diff').style.display = mode === 'diffusion' ? 'contents' : 'none';
-
- // start active sim
- const titles = { gas: 'Молекулярная физика — Газ', brownian: 'Молекулярная физика — Броуновское', states: 'Молекулярная физика — Фазы', diffusion: 'Молекулярная физика — Диффузия' };
- document.getElementById('sim-topbar-title').textContent = titles[mode] || 'Молекулярная физика';
-
- if (mode === 'gas') { gasSim.fit(); gasSim.start(); }
- if (mode === 'brownian') { brownSim.fit(); brownSim.start(); }
- if (mode === 'states') { statesSim.fit(); statesSim.start(); }
- if (mode === 'diffusion') { diffSim.fit(); diffSim.start(); }
- }
-
- function molReset() {
- if (_molMode === 'gas' && gasSim) {
- gasSim.reset();
- document.getElementById('sl-gPiston').value = 100;
- document.getElementById('g-piston').textContent = '100%';
- }
- if (_molMode === 'brownian' && brownSim) brownSim.reset();
- if (_molMode === 'states' && statesSim) {
- statesSim.reset();
- document.getElementById('sl-stN').value = 64;
- document.getElementById('st-N').textContent = '64';
- const vBtn = document.getElementById('states-vec-btn');
- if (vBtn) { vBtn.textContent = 'Векторы скоростей: Выкл'; vBtn.style.color = ''; }
- }
- if (_molMode === 'diffusion' && diffSim) {
- diffSim.reset();
- document.getElementById('diffusion-part-btn').textContent = '‖ Раздел';
- document.getElementById('df-part-row').classList.add('active');
- document.getElementById('df-pore-row').classList.remove('active');
- }
- }
-
- function gasNChange() {
- const n = +document.getElementById('sl-gN').value;
- document.getElementById('g-N').textContent = n;
- if (gasSim) { gasSim.setN(n); }
- }
-
- function gasTChange() {
- const raw = +document.getElementById('sl-gT').value;
- const t = raw / 10;
- document.getElementById('g-T').textContent = t.toFixed(1) + ' у.е.';
- if (gasSim) gasSim.setT(t);
- }
-
- function gasPistonChange() {
- const v = +document.getElementById('sl-gPiston').value;
- document.getElementById('g-piston').textContent = v + '%';
- if (gasSim) gasSim.setPiston(v / 100);
- }
-
- function gasToggleVectors(btn) {
- if (!gasSim) return;
- gasSim.toggleVectors();
- btn.textContent = 'Векторы скоростей: ' + (gasSim._showVectors ? 'Вкл' : 'Выкл');
- btn.style.color = gasSim._showVectors ? '#7BF5A4' : '';
- }
-
- function _gasUpdateUI(info) {
- document.getElementById('gstat-P').textContent = info.P;
- document.getElementById('gstat-V').textContent = info.V;
- document.getElementById('gstat-PV').textContent = info.PV;
- document.getElementById('gstat-v').textContent = info.avgSpeed + ' у.е.';
- document.getElementById('mpbar-l1').textContent = 'N';
- document.getElementById('mpbar-v1').textContent = info.N;
- document.getElementById('mpbar-l2').textContent = 'T';
- document.getElementById('mpbar-v2').textContent = info.T.toFixed(1);
- document.getElementById('mpbar-l3').textContent = 'P';
- document.getElementById('mpbar-v3').textContent = info.P;
- document.getElementById('mpbar-l4').textContent = 'V';
- document.getElementById('mpbar-v4').textContent = info.V;
- document.getElementById('mpbar-l5').textContent = 'PV';
- document.getElementById('mpbar-v5').textContent = info.PV;
- }
-
- function brownNChange() {
- const n = +document.getElementById('sl-brN').value;
- document.getElementById('br-N').textContent = n;
- if (brownSim) brownSim.setN(n);
- }
-
- function brownTChange() {
- const t = +document.getElementById('sl-brT').value / 10;
- document.getElementById('br-T').textContent = t.toFixed(1) + ' у.е.';
- if (brownSim) brownSim.setT(t);
- }
-
- function _brownUpdateUI(info) {
- document.getElementById('brstat-dr').textContent = info.displacement + ' px';
- document.getElementById('brstat-msd').textContent = info.msd + ' px²';
- document.getElementById('brstat-v').textContent = info.speed;
- document.getElementById('brstat-steps').textContent = info.steps;
- document.getElementById('mpbar-l1').textContent = 'Шагов';
- document.getElementById('mpbar-v1').textContent = info.steps;
- document.getElementById('mpbar-l2').textContent = '|Δr|';
- document.getElementById('mpbar-v2').textContent = info.displacement + ' px';
- document.getElementById('mpbar-l3').textContent = 'MSD';
- document.getElementById('mpbar-v3').textContent = info.msd + ' px²';
- document.getElementById('mpbar-l4').textContent = 'v';
- document.getElementById('mpbar-v4').textContent = info.speed;
- document.getElementById('mpbar-l5').textContent = 'N';
- document.getElementById('mpbar-v5').textContent = info.N;
- }
-
- function statesTChange() {
- const raw = +document.getElementById('sl-stT').value;
- const t = raw / 100;
- document.getElementById('st-T').textContent = t.toFixed(2);
- if (statesSim) statesSim.setT(t);
- }
-
- function statesPreset(t) {
- document.getElementById('sl-stT').value = Math.round(t * 100);
- document.getElementById('st-T').textContent = t.toFixed(2);
- if (statesSim) statesSim.setT(t);
- }
-
- function statesNChange() {
- const n = +document.getElementById('sl-stN').value;
- document.getElementById('st-N').textContent = n;
- if (statesSim) statesSim.setN(n);
- }
-
- function statesToggleVectors(btn) {
- if (!statesSim) return;
- statesSim.toggleVectors();
- btn.textContent = 'Векторы скоростей: ' + (statesSim._showVectors ? 'Вкл' : 'Выкл');
- btn.style.color = statesSim._showVectors ? '#7BF5A4' : '';
- }
-
- function _statesUpdateUI(info) {
- const phaseColors = { solid: '#4CC9F0', liquid: '#7BF5A4', gas: '#EF476F' };
- const phaseLabels = { solid: 'Твёрдое', liquid: 'Жидкость', gas: 'Газ' };
- const c = phaseColors[info.phase] || '#fff';
- document.getElementById('ststat-phase').textContent = phaseLabels[info.phase] || info.phase;
- document.getElementById('ststat-phase').style.color = c;
- document.getElementById('ststat-KE').textContent = info.avgKE;
- document.getElementById('ststat-PE').textContent = info.avgPE;
- const pEl = document.getElementById('ststat-P');
- if (pEl) pEl.textContent = info.P !== undefined ? info.P : '—';
- document.getElementById('mpbar-l1').textContent = 'Фаза';
- document.getElementById('mpbar-v1').textContent = phaseLabels[info.phase] || info.phase;
- document.getElementById('mpbar-v1').style.color = c;
- document.getElementById('mpbar-l2').textContent = 'T';
- document.getElementById('mpbar-v2').textContent = info.T.toFixed(2);
- document.getElementById('mpbar-l3').textContent = 'KE';
- document.getElementById('mpbar-v3').textContent = info.avgKE;
- document.getElementById('mpbar-l4').textContent = 'PE';
- document.getElementById('mpbar-v4').textContent = info.avgPE;
- document.getElementById('mpbar-l5').textContent = 'P';
- document.getElementById('mpbar-v5').textContent = info.P !== undefined ? info.P : '—';
- }
-
- function diffNChange() {
- const n = +document.getElementById('sl-dfN').value;
- document.getElementById('df-N').textContent = n;
- if (diffSim) diffSim.setN(n);
- }
-
- function diffTChange() {
- const t = +document.getElementById('sl-dfT').value / 10;
- document.getElementById('df-T').textContent = t.toFixed(1) + ' у.е.';
- if (diffSim) diffSim.setT(t);
- }
-
- function diffPartitionToggle(rowEl) {
- if (!diffSim) return;
- diffSim.togglePartition();
- const on = diffSim.partitionOn;
- rowEl.classList.toggle('active', on);
- document.getElementById('diffusion-part-btn').innerHTML = on ? '‖ Раздел' : ' Раздел снят';
- }
-
- function diffPartitionBtn() {
- if (!diffSim) return;
- const on = diffSim.partitionOn;
- document.getElementById('diffusion-part-btn').innerHTML = on ? '‖ Раздел' : ' Раздел снят';
- document.getElementById('df-part-row').classList.toggle('active', on);
- }
-
- function diffPoreToggle(rowEl) {
- if (!diffSim) return;
- diffSim.togglePore();
- const pore = diffSim._poreMode;
- const on = diffSim.partitionOn;
- rowEl.classList.toggle('active', pore);
- const tog = document.getElementById('df-pore-toggle');
- if (tog) tog.style.background = pore ? '#FFB347' : 'rgba(255,255,255,0.15)';
- const span = tog && tog.querySelector('span');
- if (span) span.style.marginLeft = pore ? '14px' : '2px';
- // Also sync partition row
- document.getElementById('df-part-row').classList.toggle('active', on);
- }
-
- function _diffUpdateUI(info) {
- document.getElementById('dfstat-LA').textContent = info.leftA;
- document.getElementById('dfstat-LB').textContent = info.leftB;
- document.getElementById('dfstat-RA').textContent = info.rightA;
- document.getElementById('dfstat-RB').textContent = info.rightB;
- document.getElementById('dfstat-mix').textContent = info.mixed + '%';
- document.getElementById('mpbar-l1').textContent = 'Смешивание';
- document.getElementById('mpbar-v1').textContent = info.mixed + '%';
- document.getElementById('mpbar-l2').textContent = 'Лево A/B';
- document.getElementById('mpbar-v2').textContent = info.leftA + '/' + info.leftB;
- document.getElementById('mpbar-l3').textContent = 'Право A/B';
- document.getElementById('mpbar-v3').textContent = info.rightA + '/' + info.rightB;
- document.getElementById('mpbar-l4').textContent = 'Раздел';
- const partLabel = !info.partitionOn ? 'снят' : info.poreMode ? 'пора' : 'вкл';
- document.getElementById('mpbar-v4').textContent = partLabel;
- document.getElementById('mpbar-v4').style.color = !info.partitionOn ? '#34d399' : info.poreMode ? '#FFB347' : '#fff';
- document.getElementById('mpbar-l5').textContent = 'Шагов';
- document.getElementById('mpbar-v5').textContent = info.steps;
- }
-
- /* ════════════════════════════════
- ЗАКОН КУЛОНА
- ════════════════════════════════ */
-
- var csSim = null;
-
- function _openCoulomb() {
- document.getElementById('sim-topbar-title').textContent = 'Закон Кулона';
- _simShow('sim-coulomb');
- _simShow('ctrl-coulomb');
- requestAnimationFrame(() => requestAnimationFrame(() => {
- const canvas = document.getElementById('coulomb-canvas');
- if (!csSim) {
- csSim = new CoulombSim(canvas);
- csSim.onUpdate = _coulombUpdateUI;
- }
- csSim.fit();
- if (csSim.charges.length === 0) csSim.preset('dipole');
- _coulombUpdateUI(csSim.info());
- }));
- }
-
- function coulombSign(s) {
- if (!csSim) return;
- csSim.setSign(s);
- document.getElementById('cbtn-pos').classList.toggle('active', s > 0);
- document.getElementById('cbtn-neg').classList.toggle('active', s < 0);
- document.getElementById('csign-pos').style.opacity = s > 0 ? '1' : '0.45';
- document.getElementById('csign-neg').style.opacity = s < 0 ? '1' : '0.45';
- }
-
- function coulombLayer(name, rowEl) {
- if (!csSim) return;
- csSim.toggleLayer(name);
- const on = csSim.layers[name];
- rowEl.classList.toggle('active', on);
- const tog = rowEl.querySelector('.tri-toggle');
- if (tog) {
- tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
- const dot = tog.querySelector('span');
- if (dot) dot.style.marginLeft = on ? '14px' : '2px';
- }
- csSim.draw();
- }
-
- function coulombPreset(name) {
- if (!csSim) return;
- csSim.preset(name);
- }
-
- function _coulombUpdateUI(info) {
- if (!info) return;
- document.getElementById('cs-total').textContent = info.total;
- document.getElementById('cs-curE').textContent = info.cursorE;
- document.getElementById('cs-curV').textContent = info.cursorV;
- document.getElementById('csbar-total').textContent = info.total;
- document.getElementById('csbar-pos').textContent = info.positive;
- document.getElementById('csbar-neg').textContent = info.negative;
- document.getElementById('csbar-maxE').textContent = info.maxE;
- document.getElementById('csbar-curE').textContent = info.cursorE;
- }
-
- /* ════════════════════════════════
- ЭЛЕКТРИЧЕСКИЕ ЦЕПИ
- ════════════════════════════════ */
-
- var cirSim = null;
- var reacSim = null;
- var flaskSim = null;
-
- function _openCircuit() {
- document.getElementById('sim-topbar-title').textContent = 'Электрические цепи';
- _simShow('sim-circuit');
- _simShow('ctrl-circuit');
- requestAnimationFrame(() => requestAnimationFrame(() => {
- const canvas = document.getElementById('circuit-canvas');
- if (!cirSim) {
- cirSim = new CircuitSim(canvas);
- cirSim.onUpdate = _circUpdateUI;
- cirSim.onModeChange = (mode) => {
- document.querySelectorAll('.circ-tool-btn').forEach(b => {
- b.classList.toggle('active', b.dataset.tool === mode);
- });
- document.querySelectorAll('.circ-top-btn').forEach(b => {
- b.classList.toggle('active', b.id === 'ctool-' + mode);
- });
- };
- } else {
- cirSim.stop();
- }
- cirSim.fit();
- if (cirSim.components.length === 0) cirSim.preset('serial');
- cirSim.start();
- _circUpdateUI(cirSim.info());
- }));
- }
-
- function circTool(tool, el) {
- if (cirSim) cirSim.addMode = tool;
- document.querySelectorAll('.circ-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === tool));
- document.querySelectorAll('.circ-top-btn').forEach(b => b.classList.toggle('active', b.id === 'ctool-' + tool));
- }
-
- function circPreset(name) {
- if (!cirSim) return;
- cirSim.preset(name);
- }
-
- function circRChange() {
- const v = +document.getElementById('sl-circR').value;
- document.getElementById('circ-R-val').textContent = v + ' Ω';
- if (cirSim) cirSim.R_value = v;
- }
-
- function circUChange() {
- const v = +document.getElementById('sl-circU').value;
- document.getElementById('circ-U-val').textContent = v + ' В';
- if (cirSim) cirSim.U_value = v;
- }
-
- function circCChange() {
- const v = +document.getElementById('sl-circC').value;
- document.getElementById('circ-C-val').textContent = v + ' µF';
- if (cirSim) cirSim.C_value = v;
- }
-
- function circFChange() {
- const v = +document.getElementById('sl-circF').value;
- document.getElementById('circ-F-val').textContent = v + ' Гц';
- if (cirSim) cirSim.acFreq = v;
- }
-
- function _circUpdateUI(info) {
- if (!info) return;
- document.getElementById('cirbar-comps').textContent = info.components;
- document.getElementById('cirbar-U').textContent = info.voltage ? info.voltage + ' В' : '—';
- document.getElementById('cirbar-I').textContent = info.current ? info.current + ' А' : '—';
- document.getElementById('cirbar-P').textContent = info.power ? info.power + ' Вт' : '—';
- const st = document.getElementById('cirbar-status');
- st.textContent = info.solved ? 'Замкнута' : 'Разомкнута';
- st.style.color = info.solved ? '#7BF5A4' : '#EF476F';
- }
-
- /* ════════════════════════════════
- ХИМИЯ (unified: кинетика + колба + ОВР + ионный обмен)
- ════════════════════════════════ */
-
- let _chemMode = 'kinetics'; // 'kinetics' | 'flask' | 'redox' | 'ionex'
-
- function _openChemistry(mode) {
- document.getElementById('sim-topbar-title').textContent = 'Химические реакции';
- _simShow('sim-chemistry');
- _simShow('ctrl-chemistry');
- if (mode) _chemMode = mode;
- requestAnimationFrame(() => requestAnimationFrame(() => {
- chemMode(_chemMode);
- }));
- }
-
- function chemMode(mode, btn) {
- _chemMode = mode;
- const MODES = ['kinetics', 'flask', 'redox', 'ionex'];
- const CANVASES = { kinetics: 'reactions-canvas', flask: 'flask-canvas', redox: 'redox-canvas', ionex: 'ionexchange-canvas' };
-
- // toggle mode buttons
- document.querySelectorAll('.chem-mode').forEach(b => b.classList.remove('active'));
- const mb = document.getElementById('chem-mode-' + mode);
- if (mb) mb.classList.add('active');
-
- // toggle panels
- MODES.forEach(m => {
- const p = document.getElementById('chem-panel-' + m);
- if (p) p.style.display = m === mode ? '' : 'none';
- });
-
- // toggle canvases
- Object.entries(CANVASES).forEach(([m, cid]) => {
- document.getElementById(cid).style.display = m === mode ? 'block' : 'none';
- });
-
- // toggle topbar tool groups
- const modeToCtrl = { kinetics:'kin', flask:'flask', redox:'redox', ionex:'ionex' };
- ['kin', 'flask', 'redox', 'ionex'].forEach(k => {
- const el = document.getElementById('ctrl-chem-' + k);
- if (el) el.style.display = k === modeToCtrl[mode] ? 'contents' : 'none';
- });
-
- // stop all sims
- if (reacSim) reacSim.stop();
- if (flaskSim) flaskSim.stop();
- if (rdxSim) rdxSim.stop();
- if (ioxSim) ioxSim.stop();
-
- // start the active one
- if (mode === 'kinetics') {
- const c = document.getElementById('reactions-canvas');
- if (!reacSim) { reacSim = new ReactionSim(c); reacSim.onUpdate = _reacUpdateUI; }
- reacSim.fit(); reacSim.start();
- _reacUpdateUI(reacSim.info());
- } else if (mode === 'flask') {
- const c = document.getElementById('flask-canvas');
- if (!flaskSim) { flaskSim = new FlaskSim(c); flaskSim.onUpdate = _flaskUpdateUI; }
- flaskSim.fit(); flaskSim.start();
- _flaskUpdateUI(flaskSim.info());
- } else if (mode === 'redox') {
- const c = document.getElementById('redox-canvas');
- if (!rdxSim) { rdxSim = new RedoxSim(c); rdxSim.onUpdate = _redoxUpdateUI; }
- rdxSim.fit(); rdxSim.draw();
- _redoxUpdateUI(rdxSim.info());
- } else if (mode === 'ionex') {
- const c = document.getElementById('ionexchange-canvas');
- if (!ioxSim) { ioxSim = new IonExSim(c); ioxSim.onUpdate = _ionexUpdateUI; }
- ioxSim.fit(); ioxSim.draw();
- _ionexUpdateUI(ioxSim.info());
- }
- }
-
- function chemReset() {
- if (_chemMode === 'kinetics' && reacSim) reacSim.reset();
- if (_chemMode === 'flask' && flaskSim) flaskSim.reset();
- if (_chemMode === 'redox') redoxReset();
- if (_chemMode === 'ionex') ionexReset();
- }
-
- // _openReactions is now handled by _openChemistry + chemMode
-
- function reacNChange() {
- const v = +document.getElementById('sl-reacN').value;
- document.getElementById('reac-N-val').textContent = v;
- if (reacSim) reacSim.setN(v);
- }
-
- function reacTChange() {
- const raw = +document.getElementById('sl-reacT').value;
- const t = (raw / 10).toFixed(1);
- document.getElementById('reac-T-val').textContent = t;
- if (reacSim) reacSim.setT(+t);
- }
-
- function reacEaChange() {
- const raw = +document.getElementById('sl-reacEa').value;
- const ea = (raw / 10).toFixed(1);
- document.getElementById('reac-Ea-val').textContent = ea;
- if (reacSim) reacSim.setEa(+ea);
- }
-
- function reacMode(mode, el) {
- if (reacSim) reacSim.setMode(mode);
- document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- }
-
- function reacPreset(name) {
- if (!reacSim) return;
- reacSim.preset(name);
- // Sync sliders and mode buttons
- document.getElementById('sl-reacN').value = reacSim.N;
- document.getElementById('reac-N-val').textContent = reacSim.N;
- document.getElementById('sl-reacT').value = Math.round(reacSim.T * 10);
- document.getElementById('reac-T-val').textContent = reacSim.T.toFixed(1);
- document.getElementById('sl-reacEa').value = Math.round(reacSim.Ea * 10);
- document.getElementById('reac-Ea-val').textContent = reacSim.Ea.toFixed(1);
- document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active'));
- const mBtn = document.getElementById('rmode-' + reacSim.mode);
- if (mBtn) mBtn.classList.add('active');
- _reacUpdateUI(reacSim.info());
- }
-
- function reacTogglePause() {
- if (!reacSim) return;
- reacSim.toggleReaction();
- const btn = document.getElementById('reac-pause-btn');
- btn.innerHTML = reacSim.reactionOn ? ' Пауза' : ' Реакции';
- }
-
- function _reacUpdateUI(info) {
- if (!info) return;
- document.getElementById('chbar-l1').textContent = 'A молекул';
- document.getElementById('chbar-v1').textContent = info.nA;
- document.getElementById('chbar-l2').textContent = 'B молекул';
- document.getElementById('chbar-v2').textContent = info.nB;
- document.getElementById('chbar-l3').textContent = 'C продукт';
- document.getElementById('chbar-v3').textContent = info.nC;
- document.getElementById('chbar-l4').textContent = 'Реакций';
- document.getElementById('chbar-v4').textContent = info.reactions;
- document.getElementById('chbar-l5').textContent = 'Скорость';
- document.getElementById('chbar-v5').textContent = info.rate > 0
- ? (info.rate * 30).toFixed(1) + '/с' : '—';
- }
-
- // _openFlask is now handled by _openChemistry('flask')
-
- function flaskMetal(type, el) {
- if (flaskSim) { flaskSim.setMetal(type); flaskSim.reset(); }
- document.querySelectorAll('.flask-metal-btn').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- }
-
- function flaskAcid(type, el) {
- if (flaskSim) flaskSim.setAcid(type);
- document.querySelectorAll('.flask-acid-btn').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- }
-
- function flaskConcChange() {
- const v = +document.getElementById('sl-flask-conc').value;
- document.getElementById('flask-conc-val').textContent = v + '%';
- if (flaskSim) flaskSim.setConc(v / 100);
- }
-
- function flaskTempChange() {
- const v = +document.getElementById('sl-flask-temp').value;
- document.getElementById('flask-temp-val').textContent = v + '°C';
- if (flaskSim) flaskSim.setEnvTemp(v);
- }
-
- function flaskToggleFlame() {
- if (!flaskSim) return;
- flaskSim.toggleFlame();
- const active = flaskSim._flameOn;
- document.getElementById('flask-flame-btn').style.opacity = active ? '1' : '0.5';
- document.getElementById('flask-flame-panel').style.opacity = active ? '1' : '0.5';
- document.getElementById('flask-flame-panel').style.background = active ? 'rgba(239,71,111,0.22)' : '';
- }
-
- function flaskTogglePause() {
- if (!flaskSim) return;
- flaskSim.togglePause();
- document.getElementById('flask-pause-btn').innerHTML = flaskSim._paused ? '' : '';
- }
-
- function _flaskUpdateUI(info) {
- if (!info) return;
- document.getElementById('chbar-l1').textContent = 'Металл';
- document.getElementById('chbar-v1').textContent = info.metal;
- document.getElementById('chbar-l2').textContent = 'Масса';
- document.getElementById('chbar-v2').textContent = info.mass + ' г';
- document.getElementById('chbar-l3').textContent = 'T (°C)';
- document.getElementById('chbar-v3').textContent = info.temp + '°C';
- document.getElementById('chbar-l4').textContent = 'pH';
- document.getElementById('chbar-v4').textContent = info.pH;
- document.getElementById('chbar-l5').textContent = 'H₂ (%)';
- document.getElementById('chbar-v5').textContent = info.h2pct + '%';
- }
-
- // _openRedox is now handled by _openChemistry('redox')
-
- function redoxRxn(id, el) {
- document.querySelectorAll('.redox-rxn-btn').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- if (rdxSim) { rdxSim.setReaction(id); }
- }
-
- function redoxStart() {
- if (rdxSim) rdxSim.start();
- }
-
- function redoxReset() {
- if (rdxSim) rdxSim.reset();
- }
-
- function _redoxUpdateUI(info) {
- if (!info) return;
- const phaseMap = { idle: 'ожидание', mixing: 'смешивание', reacting: 'реакция', done: 'завершена' };
- document.getElementById('chbar-l1').textContent = 'Реакция';
- document.getElementById('chbar-v1').textContent = info.rxn || '—';
- document.getElementById('chbar-l2').textContent = 'Фаза';
- document.getElementById('chbar-v2').textContent = phaseMap[info.phase] || info.phase;
- document.getElementById('chbar-l3').textContent = 'Прогресс';
- document.getElementById('chbar-v3').textContent = info.phase === 'done' ? '100%' : info.prog + '%';
- document.getElementById('chbar-l4').textContent = 'Электронов';
- document.getElementById('chbar-v4').textContent = info.e + ' e⁻';
- document.getElementById('chbar-l5').textContent = 'Тип';
- document.getElementById('chbar-v5').innerHTML = info.phase === 'done' ? '' : '—';
- }
-
- // _openIonExchange is now handled by _openChemistry('ionex')
-
- function ionexRxn(id, el) {
- document.querySelectorAll('.ionex-rxn-btn').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- if (ioxSim) { ioxSim.setReaction(id); }
- }
-
- function ionexStart() {
- if (ioxSim) ioxSim.start();
- }
-
- function ionexReset() {
- if (ioxSim) ioxSim.reset();
- }
-
- function _ionexUpdateUI(info) {
- if (!info) return;
- const phaseMap = { idle: 'ожидание', mixing: 'смешивание', pairing: 'реакция', done: 'завершена' };
- const rxn = IonExSim.RXN[ioxSim.rxnId];
- document.getElementById('chbar-l1').textContent = 'Реакция';
- document.getElementById('chbar-v1').textContent = info.rxn || '—';
- document.getElementById('chbar-l2').textContent = 'Фаза';
- document.getElementById('chbar-v2').textContent = phaseMap[info.phase] || info.phase;
- document.getElementById('chbar-l3').textContent = 'Прогресс';
- document.getElementById('chbar-v3').textContent = info.phase === 'done' ? '100%' : info.prog + '%';
- document.getElementById('chbar-l4').textContent = 'Осадок';
- document.getElementById('chbar-v4').textContent = info.precip > 0 ? info.precip + ' ч.' : '—';
- document.getElementById('chbar-l5').textContent = 'Продукт';
- document.getElementById('chbar-v5').textContent = rxn ? (rxn.sign || '—') : '—';
- }
-
- /* ════════════════════════════════
- ЗАКОНЫ НЬЮТОНА
- ════════════════════════════════ */
-
- /* ══════════════════════════════
- DYNAMICS (unified Newton + Sandbox)
- ══════════════════════════════ */
-
- var newtonSim = null;
- var sandboxSim = null;
- let _dynMode = 'sandbox'; // current mode: 'sandbox' | 'law1' | 'law2' | 'law3'
-
- function _openDynamics(preset) {
- document.getElementById('sim-topbar-title').textContent = 'Динамика';
- _simShow('sim-dynamics');
- _simShow('ctrl-dynamics');
- requestAnimationFrame(() => requestAnimationFrame(() => {
- // init sandbox
- const sbCanvas = document.getElementById('sandbox-canvas');
- if (!sandboxSim) {
- sandboxSim = new ForceSandboxSim(sbCanvas);
- sandboxSim.onUpdate = _sbUpdateUI;
- }
- // init newton
- const nwCanvas = document.getElementById('newton-canvas');
- if (!newtonSim) {
- newtonSim = new NewtonSim(nwCanvas);
- newtonSim.onUpdate = _newtonUpdateUI;
- }
- // activate current mode
- dynMode(_dynMode);
- if (preset) setTimeout(() => sbPreset(preset), 120);
- }));
- }
-
- function dynMode(mode, btn) {
- _dynMode = mode;
- const isSandbox = mode === 'sandbox';
-
- // toggle mode buttons
- document.querySelectorAll('.dyn-mode').forEach(b => b.classList.remove('active'));
- const modeBtn = document.getElementById('dyn-mode-' + mode);
- if (modeBtn) modeBtn.classList.add('active');
-
- // toggle panels
- document.getElementById('dyn-sandbox-panel').style.display = isSandbox ? '' : 'none';
- document.getElementById('dyn-newton-panel').style.display = isSandbox ? 'none' : '';
-
- // toggle canvases
- document.getElementById('sandbox-canvas').style.display = isSandbox ? 'block' : 'none';
- document.getElementById('newton-canvas').style.display = isSandbox ? 'none' : 'block';
-
- // toggle topbar tool groups
- document.getElementById('ctrl-dyn-sb').style.display = isSandbox ? 'contents' : 'none';
- document.getElementById('ctrl-dyn-nw').style.display = isSandbox ? 'none' : 'contents';
-
- if (isSandbox) {
- // stop newton, start sandbox
- if (newtonSim) newtonSim.stop();
- if (sandboxSim) { sandboxSim.fit(); sandboxSim.start(); }
- _sbUpdateUI(sandboxSim ? sandboxSim.info() : null);
- } else {
- // stop sandbox, switch newton law
- if (sandboxSim) sandboxSim.stop();
- const lawN = mode === 'law1' ? 1 : mode === 'law2' ? 2 : 3;
- if (newtonSim) {
- newtonSim.setLaw(lawN);
- newtonSim.fit();
- newtonSim.start();
- _newtonSyncUI();
- _newtonUpdateUI(newtonSim.info());
- }
- }
- }
-
- function dynPause() {
- if (_dynMode === 'sandbox') {
- if (sandboxSim) sandboxSim.togglePause();
- } else {
- if (newtonSim) newtonSim.togglePause();
- }
- }
-
- function dynReset() {
- if (_dynMode === 'sandbox') {
- sbReset();
- } else {
- _resetNewtonScene();
- }
- }
-
- const _NEWTON_SCENES = {
- 1: {
- A: { desc: 'Закон инерции: тело скользит по поверхности. Нажми на canvas — толкни блок.', action: null },
- B: { desc: 'Инерция в орбите: шар вращается на нити. Отруби нить — полетит по касательной!', action: ' Отрубить нить' },
- C: { desc: 'Инерция в космосе: тело движется равномерно, нет сил — нет ускорения.', action: null },
- },
- 2: {
- A: { desc: 'Второй закон: F = ma. Прикладывай силу и следи за ускорением и скоростью.', action: ' Запустить' },
- B: { desc: 'Два тела, разные массы — одинаковая сила. Сравни ускорения!', action: ' Запустить' },
- C: { desc: 'Второй закон: изменяй силу и массу ползунками, наблюдай в реальном времени.', action: ' Запустить' },
- },
- 3: {
- A: { desc: 'Третий закон: пушка выстрелила — отдача. Импульс сохраняется!', action: 'Выстрел' },
- B: { desc: 'Третий закон: два шара сталкиваются — силы равны и противоположны.', action: ' Столкнуть' },
- C: { desc: 'Реактивное движение: ракета выбрасывает газ — летит в обратную сторону.', action: 'Двигатель' },
- },
- };
-
- const _NEWTON_PRESETS = {
- 1: [
- { label: 'Космос', fn: 'space' },
- { label: 'Лёд', fn: 'ice' },
- { label: 'Асфальт', fn: 'asphalt' },
- { label: 'Резина', fn: 'rubber' },
- ],
- 2: [
- { label: 'Лёгкий', fn: 'light' },
- { label: 'Тяжёлый', fn: 'heavy' },
- { label: 'Сравнить', fn: 'compare' },
- ],
- 3: [
- { label: 'Большая пушка', fn: 'big_cannon' },
- { label: 'Маленькая', fn: 'small_cannon' },
- { label: 'Равные шары', fn: 'equal_balls' },
- ],
- };
-
- // _openNewton is now handled by _openDynamics + dynMode
-
- // newtonLaw is now handled by dynMode('law1'/'law2'/'law3')
-
- function newtonScene(s, topBtn, panelBtn) {
- if (!newtonSim) return;
- newtonSim.setScene(s);
- document.querySelectorAll('.nscene-btn').forEach(b => {
- b.classList.toggle('active', b.id === 'nscn-' + s || b.id === 'nscn-panel-' + s);
- });
- _newtonSyncUI();
- _newtonUpdateUI(newtonSim.info());
- }
-
- function _newtonSyncUI() {
- if (!newtonSim) return;
- const law = newtonSim.law;
- const scene = newtonSim.scene;
- const sceneData = (_NEWTON_SCENES[law] || {})[scene] || {};
-
- // description
- const desc = document.getElementById('newton-scene-desc');
- if (desc) desc.textContent = sceneData.desc || '';
-
- // action button label
- const lbl = sceneData.action || (law === 1 ? ' Нить' : ' Действие');
- document.getElementById('newton-action-label').textContent = lbl;
- document.getElementById('newton-action-top').textContent = lbl;
-
- // show/hide sliders
- document.getElementById('newton-mu-block').style.display = law === 1 && scene === 'A' ? '' : 'none';
- document.getElementById('newton-mass1-block').style.display = (law === 2 || law === 3) ? '' : 'none';
- document.getElementById('newton-mass2-block').style.display = law === 3 ? '' : 'none';
- document.getElementById('newton-force-block').style.display = law === 2 ? '' : 'none';
-
- // sync slider values from sim
- document.getElementById('sl-newton-mu').value = newtonSim.mu;
- document.getElementById('newton-mu-val').textContent = newtonSim.mu.toFixed(2);
- document.getElementById('sl-newton-m1').value = newtonSim.mass1;
- document.getElementById('newton-m1-val').textContent = newtonSim.mass1 + ' кг';
- document.getElementById('sl-newton-m2').value = newtonSim.mass2;
- document.getElementById('newton-m2-val').textContent = newtonSim.mass2 + ' кг';
- document.getElementById('sl-newton-F').value = newtonSim.force;
- document.getElementById('newton-F-val').textContent = newtonSim.force + ' Н';
-
- // sync scene highlight buttons in both topbar and panel
- ['A','B','C'].forEach(s => {
- const tb = document.getElementById('nscn-' + s);
- const pb = document.getElementById('nscn-panel-' + s);
- const on = s === scene;
- if (tb) tb.classList.toggle('active', on);
- if (pb) pb.classList.toggle('active', on);
- });
-
- // presets
- const presetsEl = document.getElementById('newton-presets');
- const presets = _NEWTON_PRESETS[law] || [];
- presetsEl.innerHTML = presets.map(p =>
- ``
- ).join('');
-
- // scene B/C visibility for law I (B = orbital, C = space — but law I only has A,B)
- // scene C doesn't exist for law I/II panel scene picker visibility
- const cBtn = document.getElementById('nscn-panel-C');
- const cTopBtn = document.getElementById('nscn-C');
- const showC = law === 3;
- if (cBtn) cBtn.style.display = showC ? '' : 'none';
- if (cTopBtn) cTopBtn.style.display = showC ? '' : 'none';
- const bBtn = document.getElementById('nscn-panel-B');
- const bTopBtn = document.getElementById('nscn-B');
- const showB = law !== 2 || true; // law 2 has compare scene B
- if (bBtn) bBtn.style.display = '';
- if (bTopBtn) bTopBtn.style.display = '';
- }
-
- function newtonAction() {
- if (!newtonSim) return;
- const law = newtonSim.law;
- const scene = newtonSim.scene;
- if (law === 1 && scene === 'B') newtonSim.cutString();
- else if (law === 2) newtonSim.startL2();
- else if (law === 3 && scene === 'A') newtonSim.fireCannon();
- else if (law === 3 && scene === 'B') newtonSim._reset3B ? newtonSim._reset3B() : null;
- else if (law === 3 && scene === 'C') newtonSim.toggleRocket();
- _newtonUpdateUI(newtonSim.info());
- }
-
- function _resetNewtonScene() {
- if (!newtonSim) return;
- const law = newtonSim.law;
- const scene = newtonSim.scene;
- if (law === 1 && scene === 'A') newtonSim.preset('ice');
- else if (law === 1) newtonSim.setScene(scene);
- else if (law === 2) newtonSim.resetL2 ? newtonSim.resetL2() : newtonSim.setScene(scene);
- else newtonSim.setScene(scene);
- _newtonUpdateUI(newtonSim.info());
- }
-
- function newtonMuChange() {
- const v = +document.getElementById('sl-newton-mu').value;
- document.getElementById('newton-mu-val').textContent = v.toFixed(2);
- if (newtonSim) newtonSim.setMu(v);
- }
-
- function newtonMass1Change() {
- const v = +document.getElementById('sl-newton-m1').value;
- document.getElementById('newton-m1-val').textContent = v + ' кг';
- if (newtonSim) newtonSim.setMass1(v);
- }
-
- function newtonMass2Change() {
- const v = +document.getElementById('sl-newton-m2').value;
- document.getElementById('newton-m2-val').textContent = v + ' кг';
- if (newtonSim) newtonSim.setMass2(v);
- }
-
- function newtonForceChange() {
- const v = +document.getElementById('sl-newton-F').value;
- document.getElementById('newton-F-val').textContent = v + ' Н';
- if (newtonSim) newtonSim.setForce(v);
- }
-
- function newtonPreset(name) {
- if (!newtonSim) return;
- newtonSim.preset(name);
- _newtonSyncUI();
- _newtonUpdateUI(newtonSim.info());
- }
-
- function _newtonUpdateUI(info) {
- if (!info) return;
- const law = info.law;
- const scene = info.scene;
-
- if (law === 1 && scene === 'A') {
- document.getElementById('dbar-l1').textContent = 'Закон I-A';
- document.getElementById('dbar-v1').textContent = 'Скольжение';
- document.getElementById('dbar-l2').textContent = 'Скорость';
- document.getElementById('dbar-v2').textContent = info.v + ' м/с';
- document.getElementById('dbar-l3').textContent = 'Сила трения';
- document.getElementById('dbar-v3').textContent = info.fFr + ' Н';
- document.getElementById('dbar-l4').textContent = 'Масса';
- document.getElementById('dbar-v4').textContent = info.m + ' кг';
- document.getElementById('dbar-l5').textContent = 'μ';
- document.getElementById('dbar-v5').textContent = info.mu;
- } else if (law === 1) {
- document.getElementById('dbar-l1').textContent = 'Закон I-B';
- document.getElementById('dbar-v1').textContent = info.cut ? 'Нить срублена' : 'Вращение';
- document.getElementById('dbar-l2').textContent = 'Скорость';
- document.getElementById('dbar-v2').textContent = info.v + ' м/с';
- document.getElementById('dbar-l3').textContent = '';
- document.getElementById('dbar-v3').textContent = '—';
- document.getElementById('dbar-l4').textContent = '';
- document.getElementById('dbar-v4').textContent = '—';
- document.getElementById('dbar-l5').textContent = '';
- document.getElementById('dbar-v5').textContent = '—';
- } else if (law === 2) {
- document.getElementById('dbar-l1').textContent = 'Закон II';
- document.getElementById('dbar-v1').textContent = 'F = ma';
- document.getElementById('dbar-l2').textContent = 'Сила F';
- document.getElementById('dbar-v2').textContent = info.F + ' Н';
- document.getElementById('dbar-l3').textContent = 'Масса m';
- document.getElementById('dbar-v3').textContent = info.m + ' кг';
- document.getElementById('dbar-l4').textContent = 'Ускор. a';
- document.getElementById('dbar-v4').textContent = info.a + ' м/с²';
- document.getElementById('dbar-l5').textContent = 'Скорость';
- document.getElementById('dbar-v5').textContent = info.v + ' м/с';
- } else if (scene === 'A') {
- document.getElementById('dbar-l1').textContent = 'Закон III-A';
- document.getElementById('dbar-v1').textContent = 'Пушка';
- document.getElementById('dbar-l2').textContent = 'v снаряда';
- document.getElementById('dbar-v2').textContent = info.vBall !== '—' ? info.vBall + ' м/с' : '—';
- document.getElementById('dbar-l3').textContent = 'v пушки';
- document.getElementById('dbar-v3').textContent = info.vCannon + ' м/с';
- document.getElementById('dbar-l4').textContent = 'm снаряда';
- document.getElementById('dbar-v4').textContent = info.m1 + ' кг';
- document.getElementById('dbar-l5').textContent = 'm пушки';
- document.getElementById('dbar-v5').textContent = info.m2 + ' кг';
- } else if (scene === 'B') {
- document.getElementById('dbar-l1').textContent = 'Закон III-B';
- document.getElementById('dbar-v1').textContent = 'Удар';
- document.getElementById('dbar-l2').textContent = 'p₁';
- document.getElementById('dbar-v2').textContent = info.p1 + ' кг·м/с';
- document.getElementById('dbar-l3').textContent = 'p₂';
- document.getElementById('dbar-v3').textContent = info.p2 + ' кг·м/с';
- document.getElementById('dbar-l4').textContent = 'p суммарный';
- document.getElementById('dbar-v4').textContent = info.pt + ' кг·м/с';
- document.getElementById('dbar-l5').textContent = '';
- document.getElementById('dbar-v5').textContent = '—';
- } else {
- document.getElementById('dbar-l1').textContent = 'Закон III-C';
- document.getElementById('dbar-v1').textContent = 'Ракета';
- document.getElementById('dbar-l2').textContent = 'Ускорение';
- document.getElementById('dbar-v2').textContent = info.a + ' м/с²';
- document.getElementById('dbar-l3').textContent = 'Скорость';
- document.getElementById('dbar-v3').textContent = info.v + ' м/с';
- document.getElementById('dbar-l4').textContent = 'Масса';
- document.getElementById('dbar-v4').textContent = info.m + ' кг';
- document.getElementById('dbar-l5').textContent = 'Топливо';
- document.getElementById('dbar-v5').textContent = info.fuel + '%';
- }
- }
-
- // _openSandbox is now handled by _openDynamics + dynMode
-
- function sbTool(t, btn) {
- if (!sandboxSim) return;
- sandboxSim.tool = t;
- sandboxSim._springStart = null;
- sandboxSim._ropeStart = null;
- document.querySelectorAll('.sb-tool-btn').forEach(b => b.classList.toggle('active', b.id === 'sbt-' + t));
- document.querySelectorAll('.sb-panel-tool').forEach(b => b.classList.toggle('active', b.id === 'sbpt-' + t));
- const canvas = document.getElementById('sandbox-canvas');
- canvas.style.cursor = t === 'erase' ? 'not-allowed'
- : (t === 'spring' || t === 'rope') ? 'cell'
- : t === 'anchor' ? 'copy'
- : 'crosshair';
- document.getElementById('sb-spring-block').style.display = t === 'spring' ? '' : 'none';
- }
-
- function sbSpringKChange() {
- const v = +document.getElementById('sl-sb-springk').value;
- document.getElementById('sb-springk-val').textContent = v + ' Н/м';
- if (sandboxSim) sandboxSim.newSpringK = v;
- }
-
- function sbForceMode(m, btn) {
- if (!sandboxSim) return;
- sandboxSim.forceMode = m;
- document.querySelectorAll('.sb-fmode').forEach(b => b.classList.toggle('active', b.id === 'sbfm-' + m));
- }
-
- function sbMassChange() {
- const v = +document.getElementById('sl-sb-mass').value;
- document.getElementById('sb-mass-val').textContent = v + ' кг';
- if (sandboxSim) sandboxSim.newMass = v;
- }
-
- function sbRestChange() {
- const v = +document.getElementById('sl-sb-rest').value;
- document.getElementById('sb-rest-val').textContent = v.toFixed(2);
- if (sandboxSim) sandboxSim.newRestitution = v;
- }
-
- function sbFloorMuChange() {
- const v = +document.getElementById('sl-sb-floormu').value;
- document.getElementById('sb-floormu-val').textContent = v.toFixed(2);
- if (sandboxSim) sandboxSim.floorMu = v;
- }
-
- function sbWorldToggle() {
- if (!sandboxSim) return;
- sandboxSim.gravity = document.getElementById('sb-gravity').checked;
- sandboxSim.hasFloor = document.getElementById('sb-floor').checked;
- sandboxSim.hasWalls = document.getElementById('sb-walls').checked;
- sandboxSim.airDrag = document.getElementById('sb-airdrag').checked;
- }
-
- function sbRampToggle() {
- if (!sandboxSim) return;
- const on = document.getElementById('sb-ramp').checked;
- sandboxSim.setRamp(on);
- document.getElementById('sb-ramp-block').style.display = on ? '' : 'none';
- }
-
- function sbAngleChange() {
- const v = +document.getElementById('sl-sb-angle').value;
- document.getElementById('sb-angle-val').textContent = v + '°';
- if (sandboxSim) sandboxSim.setRampAngle(v);
- }
-
- function sbRampMuChange() {
- const v = +document.getElementById('sl-sb-rampmu').value;
- document.getElementById('sb-rampmu-val').textContent = v.toFixed(2);
- if (sandboxSim) sandboxSim.setRampMu(v);
- }
-
- function sbDecompToggle() {
- if (!sandboxSim) return;
- sandboxSim.showDecomp = document.getElementById('sb-decomp').checked;
- }
-
- function sbDisplayToggle() {
- if (!sandboxSim) return;
- sandboxSim.showForces = document.getElementById('sb-forces').checked;
- sandboxSim.showVelocity = document.getElementById('sb-vel').checked;
- sandboxSim.showFBD = document.getElementById('sb-fbd').checked;
- sandboxSim.showEnergy = document.getElementById('sb-energy').checked;
- sandboxSim.showTrail = document.getElementById('sb-trail').checked;
- }
-
- function sbTimeScale(v, btn) {
- if (!sandboxSim) return;
- sandboxSim.timeScale = v;
- document.querySelectorAll('.sb-time').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- }
-
- function sbPreset(name) {
- if (!sandboxSim) return;
- sandboxSim.preset(name);
- // sync world checkboxes
- document.getElementById('sb-gravity').checked = sandboxSim.gravity;
- document.getElementById('sb-floor').checked = sandboxSim.hasFloor;
- document.getElementById('sb-walls').checked = sandboxSim.hasWalls;
- document.getElementById('sb-airdrag').checked = sandboxSim.airDrag;
- document.getElementById('sl-sb-floormu').value = sandboxSim.floorMu;
- document.getElementById('sb-floormu-val').textContent = sandboxSim.floorMu.toFixed(2);
- // sync ramp
- document.getElementById('sb-ramp').checked = sandboxSim.ramp;
- document.getElementById('sb-ramp-block').style.display = sandboxSim.ramp ? '' : 'none';
- document.getElementById('sl-sb-angle').value = sandboxSim.rampAngle;
- document.getElementById('sb-angle-val').textContent = sandboxSim.rampAngle + '°';
- document.getElementById('sl-sb-rampmu').value = sandboxSim.rampMu;
- document.getElementById('sb-rampmu-val').textContent = sandboxSim.rampMu.toFixed(2);
- _sbUpdateUI(sandboxSim.info());
- }
-
- function sbReset() {
- if (!sandboxSim) return;
- sandboxSim.reset();
- _sbUpdateUI(sandboxSim.info());
- }
-
- function _sbUpdateUI(info) {
- if (!info) return;
- document.getElementById('dbar-l1').textContent = 'Тел / связей';
- document.getElementById('dbar-v1').textContent = info.bodies + ' / ' + (info.springs + info.ropes);
- document.getElementById('dbar-l2').textContent = 'KE (Дж)';
- document.getElementById('dbar-v2').textContent = info.KE;
- document.getElementById('dbar-l3').textContent = 'PE (Дж)';
- document.getElementById('dbar-v3').textContent = info.PE;
- document.getElementById('dbar-l4').textContent = 'ΣF';
- document.getElementById('dbar-v4').textContent = info.netF;
- document.getElementById('dbar-l5').textContent = 'Время';
- document.getElementById('dbar-v5').textContent = info.time + ' с';
- }
-
- /* ── chem sandbox ── */
-
- function _openChemSandbox() {
- document.getElementById('sim-topbar-title').textContent = 'Химическая песочница';
- _simShow('sim-chemsandbox');
- _simShow('ctrl-chemsandbox');
-
- requestAnimationFrame(() => requestAnimationFrame(() => {
- const c = document.getElementById('chemsandbox-canvas');
- if (!chemSandSim) {
- chemSandSim = new ChemSandboxSim(c);
- chemSandSim.onUpdate = _chemSandUpdateUI;
- chemSandSim.onQuizUpdate = _chemSandQuizUI;
- c.addEventListener('click', e => chemSandSim.handleClick(e));
- c.addEventListener('mousedown', e => chemSandSim.handleMouseDown(e));
- c.addEventListener('mousemove', e => chemSandSim.handleMouseMove(e));
- c.addEventListener('mouseup', e => chemSandSim.handleMouseUp(e));
- c.addEventListener('wheel', e => chemSandSim.handleWheel(e), { passive: false });
- c.addEventListener('contextmenu', e => chemSandSim.handleContextMenu(e));
- _addTouchSupport(c, chemSandSim);
- _chemSandBuildReagents('all');
- }
- chemSandSim.fit();
- chemSandSim.start();
- chemSandSim.draw();
- }));
- }
-
- function chemSandCat(cat, el) {
- document.querySelectorAll('.chemsand-cat').forEach(b => b.classList.remove('active'));
- el.classList.add('active');
- if (chemSandSim) chemSandSim.setCategory(cat);
- _chemSandBuildReagents(cat);
- if (chemSandSim) chemSandSim.draw();
- }
-
- function chemSandPreset(name) { if (chemSandSim) { chemSandSim.preset(name); _chemSandBuildReagents(chemSandSim.filterCat); } }
- function chemSandReset() { if (chemSandSim) { chemSandSim.reset(); _chemSandBuildReagents(chemSandSim.filterCat); } }
- function chemSandResetReaction() { if (chemSandSim) { chemSandSim.resetReaction(); _chemSandBuildReagents(chemSandSim.filterCat); } }
-
- function chemSandConcChange() {
- const v = +document.getElementById('sl-csand-conc').value;
- document.getElementById('csand-conc-val').textContent = v + '%';
- }
- function chemSandTempChange() {
- const v = +document.getElementById('sl-csand-temp').value;
- document.getElementById('csand-temp-val').textContent = v + '°C';
- }
-
- function chemSandAdd(formula) {
- if (!chemSandSim) return;
- // toggle: if already in mix — remove, else add
- if (chemSandSim.mixContents.includes(formula)) {
- chemSandSim.removeFromMix(formula);
- } else {
- chemSandSim.addToMix(formula);
- }
- _chemSandBuildReagents(chemSandSim.filterCat);
- }
-
- function _chemSandBuildReagents(cat) {
- const box = document.getElementById('chemsand-reagents');
- if (!box) return;
- const subs = ChemSandboxSim.SUBSTANCES;
- const keys = Object.keys(subs).filter(k => cat === 'all' || subs[k].cat === cat);
- const inMix = chemSandSim ? chemSandSim.mixContents : [];
- box.innerHTML = keys.map(k => {
- const s = subs[k];
- const active = inMix.includes(k);
- const cls = active ? 'proj-preset-chip reac-mode-btn active' : 'proj-preset-chip reac-mode-btn';
- const sf = chemSandSim ? chemSandSim._shortFormula(k) : k;
- const removeHint = active ? ' (клик — убрать)' : '';
- return ``;
- }).join('');
- }
-
- function chemSandSetMode(mode, el) {
- document.querySelectorAll('.chemsand-mode').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- if (!chemSandSim) return;
- if (mode === 'quiz') {
- if (window._simQuizAllowed === false) {
- LS.toast('Режим заданий недоступен — администратор ограничил доступ', 'error');
- // revert button state
- document.querySelectorAll('.chemsand-mode').forEach(b => b.classList.remove('active'));
- document.getElementById('csand-mode-free')?.classList.add('active');
- return;
- }
- chemSandSim.startQuiz();
- // reset category filter to 'all' so all reagents are accessible
- document.querySelectorAll('.chemsand-cat').forEach(b => b.classList.remove('active'));
- const allBtn = document.querySelector('.chemsand-cat');
- if (allBtn) allBtn.classList.add('active');
- _chemSandBuildReagents('all');
- } else {
- chemSandSim.stopQuiz();
- document.getElementById('csand-quiz-question').style.display = 'none';
- document.getElementById('csand-quiz-result').style.display = 'none';
- document.getElementById('csand-quiz-next').style.display = 'none';
- document.getElementById('csand-quiz-score').textContent = '';
- }
- }
-
- function chemSandQuizNext() {
- if (chemSandSim && chemSandSim._quizMode) {
- chemSandSim._nextQuizTask();
- _chemSandBuildReagents(chemSandSim.filterCat);
- }
- }
-
- function _chemSandQuizUI(qi) {
- const qEl = document.getElementById('csand-quiz-question');
- const rEl = document.getElementById('csand-quiz-result');
- const nEl = document.getElementById('csand-quiz-next');
- const sEl = document.getElementById('csand-quiz-score');
- if (!qi.active) {
- qEl.style.display = 'none'; rEl.style.display = 'none'; nEl.style.display = 'none';
- sEl.textContent = '';
- return;
- }
- qEl.style.display = 'block';
- qEl.textContent = qi.question || '';
- sEl.textContent = qi.total > 0 ? `${qi.score}/${qi.total}` : '';
- if (qi.result) {
- rEl.style.display = 'block';
- rEl.style.color = qi.result === 'correct' ? '#7BF5A4' : '#EF476F';
- rEl.textContent = qi.result === 'correct' ? 'Верно!' : 'Неверно — ' + (qi.answer || '');
- nEl.style.display = qi.result === 'wrong' ? 'inline-block' : 'none';
- } else {
- rEl.style.display = 'none'; nEl.style.display = 'none';
- }
- }
-
- let _lastReportedEquation = null;
- function _chemSandUpdateUI(info) {
- document.getElementById('csbar-v1').textContent = info.mixed;
- document.getElementById('csbar-v3').textContent = info.type || '—';
- const eqEl = document.getElementById('csbar-v4');
- eqEl.innerHTML = info.equation || '—';
- eqEl.title = (info.equation || '').replace(/<[^>]*>/g, '');
- document.getElementById('csbar-v5').textContent = info.products || '—';
- const ionEl = document.getElementById('csbar-v6');
- ionEl.innerHTML = info.ionNet || '—';
- ionEl.title = (info.ionNet || '').replace(/<[^>]*>/g, '');
- // rebuild reagent buttons to reflect active state
- _chemSandBuildReagents(chemSandSim ? chemSandSim.filterCat : 'all');
- // Report lab activity for gamification (once per unique reaction)
- if (info.reaction && info.equation && info.equation !== _lastReportedEquation) {
- _lastReportedEquation = info.equation;
- if (window.LS?.reportLabActivity) LS.reportLabActivity(1).catch(() => {});
- }
- }
-
- /* ── Cell Division ── */
- function _openCellDivision(mode) {
- document.getElementById('sim-topbar-title').textContent = 'Деление клетки';
- _simShow('sim-celldivision');
- _simShow('ctrl-celldivision');
- requestAnimationFrame(() => requestAnimationFrame(() => {
- const canvas = document.getElementById('celldiv-canvas');
- if (!cellDivSim) {
- cellDivSim = new CellDivisionSim(canvas);
- cellDivSim.onUpdate = _cdUpdateUI;
- }
- cellDivSim.fit();
- cellDivSim.setMode(mode || 'mitosis');
- cellDivSim.start();
- _cdBuildDots(cellDivSim._phaseIdx);
- // sync auto button state
- const autoBtn = document.getElementById('cd-auto-btn');
- if (autoBtn) { autoBtn.innerHTML = cellDivSim._autoPlay ? ' Пауза' : ' Авто'; }
- _cdUpdateUI(cellDivSim.info());
- }));
- }
-
- function _cdBuildDots(activeIdx) {
- const box = document.getElementById('cd-phase-dots');
- if (!box || !cellDivSim) return;
- const phases = cellDivSim._phases();
- box.innerHTML = phases.map((p, i) =>
- ``
- ).join('');
- }
-
- function cdSetMode(mode, btn) {
- document.querySelectorAll('.cd-mode-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- if (!cellDivSim) return;
- cellDivSim.setMode(mode);
- _cdBuildDots(cellDivSim._phaseIdx);
- _cdUpdateUI(cellDivSim.info());
- }
-
- function cdAutoPlay(btn) {
- if (!cellDivSim) return;
- cellDivSim.toggleAutoPlay();
- btn.classList.toggle('active', cellDivSim._autoPlay);
- btn.innerHTML = cellDivSim._autoPlay ? ' Пауза' : ' Авто';
- }
-
- function cdPrevPhase() {
- if (!cellDivSim) return;
- cellDivSim.prevPhase();
- _cdBuildDots(cellDivSim._phaseIdx);
- }
-
- function cdNextPhase() {
- if (!cellDivSim) return;
- cellDivSim.nextPhase();
- _cdBuildDots(cellDivSim._phaseIdx);
- }
-
- function cdJumpPhase(idx) {
- if (!cellDivSim) return;
- cellDivSim.jumpToPhase(idx);
- _cdBuildDots(idx);
- }
-
- function _cdUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('cdbar-v1', info.phase || '—');
- v('cdbar-v2', info.chromN || '—');
- v('cdbar-v3', info.dna || '—');
- v('cdbar-v4', (info.index + 1) + ' / ' + info.total);
- v('cdbar-v5', info.mode === 'mitosis' ? 'Митоз' : 'Мейоз');
- _cdBuildDots(info.index);
- }
-
- /* ── Photosynthesis / Respiration ── */
- function _openPhotosynthesis(mode) {
- document.getElementById('sim-topbar-title').textContent = 'Фотосинтез и дыхание';
- _simShow('sim-photosynthesis');
- _simShow('ctrl-photosynthesis');
- requestAnimationFrame(() => requestAnimationFrame(() => {
- const canvas = document.getElementById('photosyn-canvas');
- if (!photosynSim) {
- photosynSim = new PhotosynthesisSim(canvas);
- photosynSim.onUpdate = _psUpdateUI;
- }
- photosynSim.fit();
- photosynSim.setMode(mode || 'photo');
- photosynSim.start();
- }));
- }
-
- function psSetMode(mode, btn) {
- document.querySelectorAll('.ps-mode-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- if (photosynSim) photosynSim.setMode(mode);
- }
-
- function psLightChange() {
- const v = +document.getElementById('sl-ps-light').value;
- document.getElementById('ps-light-val').textContent = v + '%';
- if (photosynSim) photosynSim.setLightIntensity(v);
- }
-
- function psCO2Change() {
- const v = +document.getElementById('sl-ps-co2').value;
- document.getElementById('ps-co2-val').textContent = v + '%';
- if (photosynSim) photosynSim.setCO2(v);
- }
-
- function psReset() {
- if (photosynSim) photosynSim.reset();
- }
-
- function _psUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('psbar-v1', info.atpRate || '0');
- v('psbar-v2', info.o2 || '0');
- v('psbar-v3', info.co2 || '0');
- v('psbar-v4', info.efficiency ? info.efficiency + '%' : '—');
- v('psbar-v5', info.mode === 'photo' ? 'Фотосинтез' : 'Дыхание');
- }
-
- /* ── Angry Birds ── */
- var angryBirdsSim = null;
-
- function _openAngryBirds() {
- document.getElementById('sim-topbar-title').textContent = 'Angry Birds Physics';
- _simShow('sim-angrybirds');
- _simShow('ctrl-angrybirds');
- requestAnimationFrame(() => requestAnimationFrame(() => {
- const c = document.getElementById('angrybirds-canvas');
- if (!angryBirdsSim) {
- angryBirdsSim = new AngryBirdsSim(c);
- angryBirdsSim.onUpdate = _abUpdateUI;
- c.addEventListener('mousedown', e => angryBirdsSim.handleMouseDown(e));
- c.addEventListener('mousemove', e => angryBirdsSim.handleMouseMove(e));
- c.addEventListener('mouseup', e => angryBirdsSim.handleMouseUp(e));
- c.addEventListener('mouseleave', e => angryBirdsSim.handleMouseUp(e));
- _addTouchSupport(c, angryBirdsSim);
- }
- angryBirdsSim.fit();
- angryBirdsSim.start();
- }));
- }
-
- function abLevel(n, btn) {
- document.querySelectorAll('.ab-lvl-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- if (angryBirdsSim) angryBirdsSim.loadLevel(n);
- }
-
- function angryBirdsRestart() {
- if (angryBirdsSim) angryBirdsSim.restart();
- }
-
- function _abUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('abbar-v1', info.level);
- v('abbar-v2', info.birds);
- v('abbar-v3', info.pigs);
- v('abbar-v4', info.score.toLocaleString('ru'));
- v('abbar-v5', info.planet);
- /* sync level button highlight */
- document.querySelectorAll('.ab-lvl-btn').forEach((b, i) => {
- b.classList.toggle('active', i === (info.level - 1));
- });
- }
-
- /* ── quadratic ── */
-
- 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'));
- quadSim.onUpdate = _quadUpdateUI;
- }
- quadSim.fit();
- quadSim.draw();
- quadSim._emit();
- }));
- }
-
- function quadParam(name, val) {
- const v = parseFloat(val);
- document.getElementById('quad-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1);
- if (quadSim) quadSim.setParams({ [name]: v });
- }
-
- function quadPreset(a, b, c) {
- document.getElementById('sl-quad-a').value = a; document.getElementById('quad-a-val').textContent = a;
- document.getElementById('sl-quad-b').value = b; document.getElementById('quad-b-val').textContent = b;
- document.getElementById('sl-quad-c').value = c; document.getElementById('quad-c-val').textContent = c;
- if (quadSim) quadSim.setParams({ a, b, c });
- }
-
- function _quadUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('qbar-v1', 'D = ' + info.D);
- v('qbar-v2', info.roots);
- v('qbar-v3', info.vertex);
- v('qbar-v4', info.equation);
- }
-
- /* ── normal distribution ── */
- var ndSim = null;
-
- 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'));
- ndSim.onUpdate = _ndUpdateUI;
- }
- ndSim.fit();
- ndSim.draw();
- ndSim._emit();
- }));
- }
-
- function ndParam(name, val) {
- const v = parseFloat(val);
- const elId = name === 'mu' ? 'nd-mu-val' : 'nd-sigma-val';
- document.getElementById(elId).textContent = v % 1 === 0 ? v : v.toFixed(1);
- if (ndSim) ndSim.setParams({ [name]: v });
- }
-
- function ndShade(mode, btn) {
- document.querySelectorAll('.nd-shade-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- if (ndSim) ndSim.setParams({ shade: mode });
- }
-
- function ndPreset(mu, sigma) {
- document.getElementById('sl-nd-mu').value = mu; document.getElementById('nd-mu-val').textContent = mu;
- document.getElementById('sl-nd-sigma').value = sigma; document.getElementById('nd-sigma-val').textContent = sigma;
- if (ndSim) ndSim.setParams({ mu, sigma });
- }
-
- function _ndUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('ndbar-v1', info.mu);
- v('ndbar-v2', info.sigma);
- v('ndbar-v3', info.peak);
- v('ndbar-v4', info.area);
- }
-
- /* ── graph transform ── */
- var gtSim = null;
-
- 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'));
- gtSim.onUpdate = _gtUpdateUI;
- }
- gtSim.fit();
- gtSim.draw();
- gtSim._emit();
- }));
- }
-
- function gtParam(name, val) {
- const v = parseFloat(val);
- document.getElementById('gt-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1);
- if (gtSim) gtSim.setParams({ [name]: v });
- }
-
- function gtBase(name, btn) {
- document.querySelectorAll('.gt-base-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- if (gtSim) gtSim.setBase(name);
- }
-
- function gtEffect(a, k, b, c) {
- document.getElementById('sl-gt-a').value = a; document.getElementById('gt-a-val').textContent = a;
- document.getElementById('sl-gt-k').value = k; document.getElementById('gt-k-val').textContent = k;
- document.getElementById('sl-gt-b').value = b; document.getElementById('gt-b-val').textContent = b;
- document.getElementById('sl-gt-c').value = c; document.getElementById('gt-c-val').textContent = c;
- if (gtSim) gtSim.setParams({ a, k, b, c });
- }
-
- function _gtUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('gtbar-v1', info.base);
- v('gtbar-v2', info.a);
- v('gtbar-v3', info.k);
- v('gtbar-v4', info.b);
- v('gtbar-v5', info.c);
- }
-
- /* ── pendulum ── */
- var pendSim = null;
-
- 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'));
- pendSim.onUpdate = _pendUpdateUI;
- }
- pendSim.fit();
- pendSim.play();
- }));
- }
-
- function pendParam(name, val) {
- const v = parseFloat(val);
- const ids = { theta: 'pend-theta-val', L: 'pend-L-val', g: 'pend-g-val', damping: 'pend-damp-val' };
- const el = document.getElementById(ids[name]);
- if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(name === 'g' ? 2 : 1);
- if (pendSim) pendSim.setParams({ [name]: v });
- }
-
- function pendPreset(theta, L, g, damp) {
- document.getElementById('sl-pend-theta').value = theta; document.getElementById('pend-theta-val').textContent = theta;
- document.getElementById('sl-pend-L').value = L; document.getElementById('pend-L-val').textContent = L;
- document.getElementById('sl-pend-g').value = g; document.getElementById('pend-g-val').textContent = g;
- document.getElementById('sl-pend-damp').value = damp; document.getElementById('pend-damp-val').textContent = damp;
- if (pendSim) {
- pendSim.setParams({ theta, L, g, damping: damp });
- pendSim.play();
- }
- }
-
- function _pendUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('pendbar-v1', info.angle);
- v('pendbar-v2', info.omega);
- v('pendbar-v3', info.period);
- v('pendbar-v4', info.energy);
- }
-
- /* ── equilibrium ── */
-
- 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'));
- eqSim.onUpdate = _eqUpdateUI;
- }
- eqSim.fit();
- eqSim.reset();
- eqSim.play();
- }));
- }
-
- function eqParam(name, val) {
- const v = parseFloat(val);
- const ids = { T: 'eq-T-val', Ea_f: 'eq-Eaf-val', Ea_r: 'eq-Ear-val' };
- const el = document.getElementById(ids[name]);
- if (el) el.textContent = v;
- if (eqSim) eqSim.setParams({ [name]: v });
- }
-
- function eqPreset(name) {
- if (eqSim) { eqSim.preset(name); eqSim.play(); }
- const defs = { default: [300,50,55], exothermic: [280,35,65], endothermic: [350,65,35], excess_A: [300,50,55] };
- const d = defs[name] || defs.default;
- document.getElementById('sl-eq-T').value = d[0]; document.getElementById('eq-T-val').textContent = d[0];
- document.getElementById('sl-eq-Eaf').value = d[1]; document.getElementById('eq-Eaf-val').textContent = d[1];
- document.getElementById('sl-eq-Ear').value = d[2]; document.getElementById('eq-Ear-val').textContent = d[2];
- }
-
- function _eqUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('eqbar-v1', info.keq);
- v('eqbar-v2', info.Q);
- v('eqbar-v3', info.direction);
- v('eqbar-v4', info.nA + '|' + info.nB + '|' + info.nC + '|' + info.nD);
- }
-
- /* ── thin lens ── */
-
- 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'));
- lensSim.onUpdate = _lensUpdateUI;
- }
- lensSim.fit();
- lensSim.draw();
- lensSim._emit();
- }));
- }
-
- function lensParam(name, val) {
- const v = parseFloat(val);
- const ids = { f: 'lens-f-val', d: 'lens-d-val', h: 'lens-h-val' };
- const el = document.getElementById(ids[name]);
- if (el) el.textContent = v;
- if (lensSim) lensSim.setParams({ [name]: v });
- }
-
- function lensPreset(f, d, h) {
- document.getElementById('sl-lens-f').value = f; document.getElementById('lens-f-val').textContent = f;
- document.getElementById('sl-lens-d').value = d; document.getElementById('lens-d-val').textContent = d;
- document.getElementById('sl-lens-h').value = h; document.getElementById('lens-h-val').textContent = h;
- if (lensSim) lensSim.setParams({ f, d, h });
- }
-
- function _lensUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('lensbar-v1', info.f);
- v('lensbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
- v('lensbar-v3', info.M === Infinity ? '∞' : info.M);
- v('lensbar-v4', info.imageType);
- }
-
- /* ── mirrors ── */
-
- var mirrorSim = null;
-
- 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'));
- mirrorSim.onUpdate = _mirrorUpdateUI;
- mirrorSim.onAnimate = (d) => {
- const sl = document.getElementById('sl-mirror-d');
- const lbl = document.getElementById('mirror-d-val');
- if (sl) sl.value = Math.round(d);
- if (lbl) lbl.textContent = Math.round(d);
- };
- }
- mirrorSim.fit();
- mirrorSim.draw();
- mirrorSim._emit();
- if (mirrorSim._showPhotons && !mirrorSim._photonRaf) mirrorSim._startPhotons();
- }));
- }
-
- function mirrorType(type, el) {
- document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- const fRow = document.getElementById('mirror-f-row');
- if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex';
- if (mirrorSim) mirrorSim.setType(type);
- const pb = document.getElementById('mirror-play-btn');
- if (pb) { pb.textContent = '▶ Анимация'; }
- const sl = document.getElementById('sl-mirror-d');
- if (sl) sl.disabled = false;
- }
-
- function mirrorParam(name, val) {
- const v = parseFloat(val);
- const ids = { f: 'mirror-f-val', d: 'mirror-d-val', h: 'mirror-h-val' };
- const el = document.getElementById(ids[name]);
- if (el) el.textContent = v;
- if (mirrorSim) mirrorSim.setParams({ [name]: v });
- }
-
- function mirrorPreset(name) {
- const P = {
- flat: { type: 'flat', f: 120, d: 200, h: 60 },
- far: { type: 'concave', f: 100, d: 280, h: 60 },
- '2f': { type: 'concave', f: 100, d: 200, h: 60 },
- between: { type: 'concave', f: 100, d: 140, h: 60 },
- near: { type: 'concave', f: 100, d: 60, h: 60 },
- convex: { type: 'convex', f: 100, d: 200, h: 60 },
- };
- const p = P[name]; if (!p) return;
- document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active'));
- const tb = document.getElementById(`mtype-${p.type}`);
- if (tb) tb.classList.add('active');
- const fRow = document.getElementById('mirror-f-row');
- if (fRow) fRow.style.display = p.type === 'flat' ? 'none' : 'flex';
- document.getElementById('sl-mirror-f').value = p.f; document.getElementById('mirror-f-val').textContent = p.f;
- document.getElementById('sl-mirror-d').value = p.d; document.getElementById('mirror-d-val').textContent = p.d;
- document.getElementById('sl-mirror-h').value = p.h; document.getElementById('mirror-h-val').textContent = p.h;
- if (mirrorSim) { mirrorSim.setType(p.type); mirrorSim.setParams({ f: p.f, d: p.d, h: p.h }); }
- }
-
- function mirrorTogglePlay(btn) {
- if (!mirrorSim) return;
- mirrorSim.togglePlay();
- const playing = mirrorSim._playing;
- if (btn) btn.textContent = playing ? '⏸ Стоп' : '▶ Анимация';
- const sl = document.getElementById('sl-mirror-d');
- if (sl) sl.disabled = playing;
- }
-
- function mirrorSetSpeed(val) { if (mirrorSim) mirrorSim.setAnimSpeed(parseFloat(val)); }
- function mirrorToggle(name, val) { if (mirrorSim) mirrorSim.setToggle(name, val); }
- function mirrorStepNext() { if (mirrorSim) mirrorSim.stepNext(); }
- function mirrorStepReset() { if (mirrorSim) mirrorSim.stepReset(); }
- function mirrorSetPointMode(val) { if (mirrorSim) mirrorSim.setPointMode(val); }
-
- function _mirrorUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('mirrorbar-v1', info.f);
- v('mirrorbar-v5', Math.round(info.d));
- v('mirrorbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
- v('mirrorbar-v3', info.M === Infinity ? '∞' : info.M);
- v('mirrorbar-v4', info.imageType);
- }
-
- /* ── isoprocesses ── */
-
- var isoSim = null;
-
- 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'));
- isoSim.onUpdate = _isoUpdateUI;
- isoSim.setGamma(1.667);
- }
- isoSim.fit();
- isoSim.draw();
- isoSim._emit();
- }));
- }
-
- function isoProc(proc, el) {
- document.querySelectorAll('.iso-proc-btn').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- if (isoSim) isoSim.setProcess(proc);
- }
-
- function isoGamma(g, el) {
- document.querySelectorAll('.iso-gamma-btn').forEach(b => b.classList.remove('active'));
- if (el) el.classList.add('active');
- if (isoSim) isoSim.setGamma(g);
- }
-
- function isoParam(name, val) {
- const v = parseFloat(val);
- if (name === 'P1') { document.getElementById('iso-p1-val').textContent = v.toFixed(1); if (isoSim) isoSim.setParams({ P1: v }); }
- if (name === 'V1') { document.getElementById('iso-v1-val').textContent = v; if (isoSim) isoSim.setParams({ V1: v }); }
- }
-
- function isoRatio(val) { if (isoSim) isoSim.setRatio(parseFloat(val)); }
-
- function isoPreset(name) {
- const P = {
- iso_expand: { proc:'isothermal', P1:4, V1:8, ratio:0.75, gamma:1.4 },
- iso_comp: { proc:'isothermal', P1:1.5, V1:20, ratio:0.25, gamma:1.4 },
- heat_iso: { proc:'isochoric', P1:2, V1:10, ratio:0.72, gamma:1.667 },
- adiab_exp: { proc:'adiabatic', P1:5, V1:6, ratio:0.7, gamma:1.667 },
- };
- const p = P[name]; if (!p) return;
- document.querySelectorAll('.iso-proc-btn').forEach(b => b.classList.remove('active'));
- const pb = document.getElementById(`iproc-${p.proc}`); if (pb) pb.classList.add('active');
- document.querySelectorAll('.iso-gamma-btn').forEach(b => b.classList.remove('active'));
- const gb = document.getElementById(p.gamma === 1.4 ? 'igamma-14' : 'igamma-167'); if (gb) gb.classList.add('active');
- document.getElementById('sl-iso-p1').value = p.P1; document.getElementById('iso-p1-val').textContent = p.P1.toFixed(1);
- document.getElementById('sl-iso-v1').value = p.V1; document.getElementById('iso-v1-val').textContent = p.V1;
- document.getElementById('sl-iso-ratio').value = p.ratio;
- if (isoSim) { isoSim.setGamma(p.gamma); isoSim.setProcess(p.proc); isoSim.setParams({ P1: p.P1, V1: p.V1 }); isoSim.setRatio(p.ratio); }
- }
-
- function _isoUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('isobar-t1', info.T1);
- v('isobar-t2', info.T2);
- v('isobar-w', info.W);
- v('isobar-q', info.Q);
- v('isobar-du', info.dU);
- }
-
- /* ── titration ── */
-
- 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'));
- titrSim.onUpdate = _titrUpdateUI;
- }
- titrSim.fit();
- titrSim.reset();
- titrSim.play();
- }));
- }
-
- function titrParam(name, val) {
- const v = parseFloat(val);
- const ids = { acidConc: 'titr-ac-val', baseConc: 'titr-bc-val', acidVol: 'titr-vol-val' };
- const el = document.getElementById(ids[name]);
- if (el) el.textContent = name === 'acidVol' ? v : v.toFixed(2);
- if (titrSim) titrSim.setParams({ [name]: v });
- }
-
- function titrIndicator(name, btn) {
- document.querySelectorAll('.titr-ind-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- if (titrSim) titrSim.setParams({ indicator: name });
- }
-
- function titrPreset(name) {
- if (titrSim) { titrSim.preset(name); titrSim.play(); }
- const defs = { strong_strong: [0.1,0.1,50], weak_strong: [0.1,0.1,50], concentrated: [0.5,0.5,25] };
- const d = defs[name] || defs.strong_strong;
- document.getElementById('sl-titr-ac').value = d[0]; document.getElementById('titr-ac-val').textContent = d[0].toFixed(2);
- document.getElementById('sl-titr-bc').value = d[1]; document.getElementById('titr-bc-val').textContent = d[1].toFixed(2);
- document.getElementById('sl-titr-vol').value = d[2]; document.getElementById('titr-vol-val').textContent = d[2];
- }
-
- function _titrUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('titrbar-v1', info.pH);
- v('titrbar-v2', info.baseAdded + ' мл');
- v('titrbar-v3', info.eqPoint + ' мл');
- const indNames = { phenolphthalein: 'Фенолф.', methyl_orange: 'Метилор.', litmus: 'Лакмус' };
- v('titrbar-v4', indNames[info.indicator] || info.indicator);
- }
-
- /* ── refraction ── */
-
- 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'));
- refrSim.onUpdate = _refrUpdateUI;
- }
- refrSim.fit();
- refrSim.draw();
- refrSim._emit();
- }));
- }
-
- function refrParam(name, val) {
- const v = parseFloat(val);
- const ids = { n1: 'refr-n1-val', n2: 'refr-n2-val', angle: 'refr-angle-val' };
- const el = document.getElementById(ids[name]);
- if (el) el.textContent = name === 'angle' ? v : v.toFixed(2);
- if (refrSim) refrSim.setParams({ [name]: v });
- }
-
- function refrPreset(n1, n2, angle) {
- document.getElementById('sl-refr-n1').value = n1; document.getElementById('refr-n1-val').textContent = n1.toFixed(2);
- document.getElementById('sl-refr-n2').value = n2; document.getElementById('refr-n2-val').textContent = n2.toFixed(2);
- document.getElementById('sl-refr-angle').value = angle; document.getElementById('refr-angle-val').textContent = angle;
- if (refrSim) refrSim.setParams({ n1, n2, angle });
- }
-
- function _refrUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('refrbar-v1', info.angle1 + '°');
- v('refrbar-v2', info.isTIR ? 'ПВО' : info.angle2 + '°');
- v('refrbar-v3', info.criticalAngle !== null ? info.criticalAngle + '°' : '—');
- v('refrbar-v4', info.isTIR ? 'Да' : 'Нет');
- }
-
- /* ── probability ── */
-
- 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'));
- probSim.onUpdate = _probUpdateUI;
- }
- probSim.fit();
- probSim.reset();
- probSim.play();
- }));
- }
-
- function probMode(mode, btn) {
- document.querySelectorAll('.prob-mode-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- if (probSim) { probSim.setParams({ mode }); probSim.reset(); probSim.play(); }
- }
-
- function probPreset(mode, trials) {
- document.querySelectorAll('.prob-mode-btn').forEach(b => {
- b.classList.toggle('active', b.textContent.toLowerCase().includes(mode === 'coin' ? 'монет' : mode === 'dice2' ? '2 куб' : 'кубик'));
- });
- if (probSim) { probSim.setParams({ mode, trials }); probSim.reset(); probSim.play(); }
- }
-
- function _probUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('probbar-v1', info.totalTrials);
- v('probbar-v2', typeof info.maxDeviation === 'number' ? (info.maxDeviation * 100).toFixed(1) + '%' : '—');
- v('probbar-v3', typeof info.chiSquare === 'number' ? info.chiSquare.toFixed(2) : '—');
- const modeNames = { coin: 'Монета', dice: 'Кубик', dice2: '2 кубика' };
- v('probbar-v4', modeNames[info.mode] || info.mode);
- }
-
- /* ── bohr atom ── */
-
- 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'));
- bohrSim.onUpdate = _bohrUpdateUI;
- }
- bohrSim.fit();
- bohrSim.play();
- }));
- }
-
- function bohrLevel(n) {
- if (bohrSim) {
- const from = bohrSim.info().level;
- if (from !== n) bohrSim.transition(from, n);
- }
- }
-
- function bohrTransition(from, to) {
- if (bohrSim) bohrSim.transition(from, to);
- }
-
- function _bohrUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('bohrbar-v1', info.level);
- v('bohrbar-v2', info.energy.toFixed(2));
- if (info.lastTransition) {
- v('bohrbar-v3', info.lastTransition.wavelength.toFixed(0));
- v('bohrbar-v4', info.lastTransition.series || '—');
- }
- }
-
- /* ── electrolysis ── */
-
- 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'));
- elecSim.onUpdate = _elecUpdateUI;
- }
- elecSim.fit();
- elecSim.reset();
- elecSim.play();
- }));
- }
-
- function elecParam(name, val) {
- const v = parseFloat(val);
- if (name === 'voltage') document.getElementById('elec-V-val').textContent = v;
- if (elecSim) elecSim.setParams({ [name]: v });
- }
-
- function elecPreset(name, btn) {
- document.querySelectorAll('.elec-type-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- const voltages = { nacl: 6, cuso4: 4, h2so4: 3 };
- const vt = voltages[name] || 6;
- document.getElementById('sl-elec-V').value = vt; document.getElementById('elec-V-val').textContent = vt;
- if (elecSim) { elecSim.setParams({ electrolyte: name, voltage: vt }); elecSim.reset(); elecSim.play(); }
- }
-
- function _elecUpdateUI(info) {
- const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
- v('elecbar-v1', typeof info.current === 'number' ? info.current.toFixed(2) : '—');
- v('elecbar-v2', typeof info.massDeposited === 'number' ? info.massDeposited.toFixed(3) + ' г' : '—');
- v('elecbar-v3', typeof info.gasVolume === 'number' ? info.gasVolume.toFixed(1) : '—');
- v('elecbar-v4', typeof info.time === 'number' ? info.time.toFixed(0) + ' с' : '—');
- }
-
- /* ── waves ── */
- function _openWaves() {
- 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'));
- wavesSim.onUpdate = _wavesUpdateUI;
- }
- wavesSim.fit();
- wavesSim.reset();
- wavesSim.play();
- _wavesUpdateUI(wavesSim.info());
- }));
- }
-
- function wavesMode(mode, btn) {
- document.querySelectorAll('.wave-mode-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- document.getElementById('waves-w2-section').style.display = mode === 'superposition' ? '' : 'none';
- document.getElementById('waves-n-section').style.display = mode === 'standing' ? '' : 'none';
- if (wavesSim) wavesSim.setMode(mode);
- }
-
- function wavesParam(name, val) {
- const v = parseFloat(val);
- const el = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; };
- if (name === 'A1') el('waves-A1-val', v);
- if (name === 'f1') el('waves-f1-val', v.toFixed(1) + ' Гц');
- if (name === 'phi1') el('waves-phi1-val', v.toFixed(1));
- if (name === 'A2') el('waves-A2-val', v);
- if (name === 'f2') el('waves-f2-val', v.toFixed(1) + ' Гц');
- if (name === 'phi2') el('waves-phi2-val', v.toFixed(1));
- if (name === 'speed') el('waves-speed-val', '\u00d7' + v.toFixed(1));
- if (wavesSim) wavesSim.setParams({ [name]: v });
- }
-
- function wavesN(n, btn) {
- document.querySelectorAll('.wave-n-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- if (wavesSim) wavesSim.setParams({ n });
- }
-
- function wavesPreset(name) {
- const presets = {
- constructive: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.0, phi2: 0 },
- destructive: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.0, phi2: 3.14 },
- beats: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.3, phi2: 0 },
- };
- const p = presets[name]; if (!p) return;
- document.getElementById('sl-waves-A1').value = p.A1;
- document.getElementById('sl-waves-f1').value = p.f1;
- document.getElementById('sl-waves-phi1').value = p.phi1;
- document.getElementById('sl-waves-A2').value = p.A2;
- document.getElementById('sl-waves-f2').value = p.f2;
- document.getElementById('sl-waves-phi2').value = p.phi2;
- document.getElementById('waves-A1-val').textContent = p.A1;
- document.getElementById('waves-f1-val').textContent = p.f1.toFixed(1) + ' Гц';
- document.getElementById('waves-phi1-val').textContent = p.phi1.toFixed(1);
- document.getElementById('waves-A2-val').textContent = p.A2;
- document.getElementById('waves-f2-val').textContent = p.f2.toFixed(1) + ' Гц';
- document.getElementById('waves-phi2-val').textContent = p.phi2.toFixed(1);
- if (wavesSim) wavesSim.setParams({ A1: p.A1, f1: p.f1, phi1: p.phi1, A2: p.A2, f2: p.f2, phi2: p.phi2 });
- }
-
- function wavesPlayPause() {
- if (!wavesSim) return;
- const btn = document.getElementById('waves-play-btn');
- if (wavesSim._paused) {
- wavesSim.play();
- btn.innerHTML = '';
- } else {
- wavesSim.pause();
- btn.innerHTML = '';
- }
- }
-
- function _wavesUpdateUI(info) {
- const v = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
- v('wavesbar-T', info.T);
- v('wavesbar-lam', info.lambda);
- v('wavesbar-v', info.v);
- v('wavesbar-f', (+info.f1).toFixed(1));
- }
-
- /* ── crystal lattice (3D) ── */
- var crystalSim = null;
- function _openCrystal() {
- document.getElementById('sim-topbar-title').textContent = 'Кристаллическая решётка';
- _simShow('sim-crystal');
- requestAnimationFrame(() => requestAnimationFrame(() => {
- if (!crystalSim) {
- crystalSim = new CrystalSim(document.getElementById('crystal-container'));
- } else {
- crystalSim.fit();
- crystalSim.play();
- }
- }));
- }
- function setCrystal(type, btn) {
- document.querySelectorAll('.crystal-type-btn').forEach(b => { b.classList.remove('active'); b.style.borderColor = ''; b.style.color = ''; });
- btn.classList.add('active');
- btn.style.borderColor = '#9B5DE5'; btn.style.color = '#9B5DE5';
- if (crystalSim) crystalSim.setLattice(type);
- }
-
- /* ── molecular orbitals (3D) ── */
- var orbitalsSim = null;
- function _openOrbitals() {
- document.getElementById('sim-topbar-title').textContent = 'Молекулярные орбитали';
- _simShow('sim-orbitals');
- requestAnimationFrame(() => requestAnimationFrame(() => {
- if (!orbitalsSim) {
- orbitalsSim = new OrbitalsSim(document.getElementById('orbitals-container'));
- } else {
- orbitalsSim.fit();
- orbitalsSim.play();
- }
- }));
- }
- function setOrbital(mode, btn) {
- document.querySelectorAll('.orbital-mode-btn').forEach(b => { b.classList.remove('active'); b.style.borderColor = ''; b.style.color = ''; });
- btn.classList.add('active');
- btn.style.borderColor = '#9B5DE5'; btn.style.color = '#9B5DE5';
- if (orbitalsSim) orbitalsSim.setMode(mode);
- }
-
- /* ── stereometry 3D ── */
- var stereoSim = null;
-
- // which params are relevant per figure type
- const STEREO_PARAM_MAP = {
- cube: ['a'],
- parallelepiped: ['a','b','c'],
- pyramid: ['a','n','h'],
- tetrahedron: ['a'],
- cylinder: ['r','h'],
- cone: ['r','h'],
- trunccone: ['R','r','h'],
- sphere: ['r'],
- prism: ['a','n','h'],
- truncpyramid: ['a','b','n','h'],
- octahedron: ['a'],
- icosahedron: ['a'],
- dodecahedron: ['a'],
- };
-
- function _openStereo() {
- document.getElementById('sim-topbar-title').textContent = 'Стереометрия 3D';
- _simShow('sim-stereo');
- document.getElementById('stereo-stats').style.display = '';
- requestAnimationFrame(() => requestAnimationFrame(() => {
- if (!stereoSim) {
- stereoSim = new StereoSim(document.getElementById('stereo-container'));
- stereoSim.onUpdate = _stereoUpdateUI;
- } else {
- stereoSim.fit();
- stereoSim.play();
- }
- _stereoShowParams(stereoSim.figureType || 'cube');
- _stereoUpdateUI(stereoSim.info());
- _stereoUpdateFormulas();
- }));
- }
-
- function setStereoFigure(type, btn) {
- document.querySelectorAll('.stereo-fig-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- if (stereoSim) {
- stereoSim.setFigure(type);
- _stereoShowParams(type);
- _stereoUpdateFormulas();
- // reset toggles and tool buttons
- document.getElementById('sect-toggle').classList.remove('active');
- document.getElementById('stereo-unfold-btn').classList.remove('active');
- document.getElementById('stereo-measure-btn').classList.remove('active');
- // reset element toggles
- ['stg-height','stg-apothem','stg-diagonals','stg-midpoints','stg-inscribed','stg-circumscribed','stg-edgelengths'].forEach(id => {
- document.getElementById(id)?.classList.remove('on');
- });
- _stereoDeactivateTools();
- }
- }
-
- function _stereoShowParams(type) {
- const show = STEREO_PARAM_MAP[type] || ['a'];
- ['a','b','c','h','r','R','n'].forEach(k => {
- document.getElementById('sp-' + k + '-row').style.display = show.includes(k) ? '' : 'none';
- });
- }
-
- function stereoParamChange(key, val) {
- val = +val;
- const label = document.getElementById('sp-' + key + '-val');
- if (label) label.textContent = val;
- if (stereoSim) {
- stereoSim.setParam(key, val);
- _stereoUpdateFormulas();
- }
- }
-
- function stereoOpacityChange(val) {
- val = +val;
- document.getElementById('sp-opacity-val').textContent = val.toFixed(2);
- if (stereoSim) stereoSim.setOpacity(val);
- }
-
- // legacy (used nowhere now but kept for safety)
- function stereoToggle(layer, btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (!stereoSim) return;
- if (layer === 'edges') stereoSim.toggleEdges(on);
- if (layer === 'vertices') stereoSim.toggleVertices(on);
- if (layer === 'labels') stereoSim.toggleLabels(on);
- if (layer === 'axes') stereoSim.toggleAxes(on);
- if (layer === 'grid') stereoSim.toggleGrid(on);
- }
-
- // new toggle-row style
- function stereoToggleSt(layer, toggle) {
- const on = !toggle.classList.contains('on');
- toggle.classList.toggle('on', on);
- if (!stereoSim) return;
- if (layer === 'edges') stereoSim.toggleEdges(on);
- if (layer === 'vertices') stereoSim.toggleVertices(on);
- if (layer === 'labels') stereoSim.toggleLabels(on);
- if (layer === 'axes') stereoSim.toggleAxes(on);
- if (layer === 'grid') stereoSim.toggleGrid(on);
- }
-
- function stereoToggleElem(layer, toggle) {
- const on = !toggle.classList.contains('on');
- toggle.classList.toggle('on', on);
- if (!stereoSim) return;
- if (layer === 'height') stereoSim.toggleHeight(on);
- if (layer === 'apothem') stereoSim.toggleApothem(on);
- if (layer === 'diagonals') stereoSim.toggleDiagonals(on);
- if (layer === 'midpoints') stereoSim.toggleMidpoints(on);
- if (layer === 'inscribed') stereoSim.toggleInscribed(on);
- if (layer === 'circumscribed') stereoSim.toggleCircumscribed(on);
- if (layer === 'edgelengths') stereoSim.toggleEdgeLengths(on);
- }
-
- // n-stepper for prism/pyramid
- function stereoNChange(delta) {
- if (!stereoSim) return;
- const cur = stereoSim.params.n || 4;
- const nv = Math.max(3, Math.min(12, cur + delta));
- document.getElementById('sp-n-val').textContent = nv;
- stereoSim.setParam('n', nv);
- _stereoUpdateFormulas();
- }
-
- function stereoSectionToggle(btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleSection(on);
- }
-
- function stereoSectionType(t, btn) {
- document.querySelectorAll('.stereo-sect-type').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- // Show/hide angle slider for diagonal
- document.getElementById('sp-angle-row').style.display = t === 'diagonal' ? '' : 'none';
- if (stereoSim) stereoSim.setSectionType(t);
- }
-
- function stereoSectionHeight(val) {
- document.getElementById('sp-sect-val').textContent = val + '%';
- if (stereoSim) stereoSim.setSectionHeight(+val / 100);
- }
-
- function stereoSectionAngle(val) {
- document.getElementById('sp-angle-val').textContent = val + '%';
- if (stereoSim) stereoSim.setSectionAngle(+val / 100);
- }
-
- function stereoUnfold(btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleUnfold(on);
- }
-
- function _stereoDeactivateTools() {
- ['stereo-measure-btn','stereo-point-btn','stereo-connect-btn',
- 'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn',
- 'stereo-mark-tick-btn','stereo-mark-par-btn',
- 'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn'].forEach(id => {
- document.getElementById(id)?.classList.remove('active');
- });
- if (stereoSim) {
- stereoSim.toggleMeasure(false);
- stereoSim.togglePointMode(false);
- stereoSim.toggleConnectMode(false);
- stereoSim.setAngleMode(null);
- stereoSim.setMarkMode(null);
- stereoSim.setDeriveMode(null);
- }
- const hint = document.getElementById('angle-hint');
- if (hint) hint.textContent = '';
- }
-
- function stereoMeasure(btn) {
- const on = !btn.classList.contains('active');
- _stereoDeactivateTools();
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleMeasure(on);
- }
-
- function stereoMeasureUndo() {
- if (stereoSim) stereoSim.removeLastMeasurement();
- }
-
- function stereoMeasureClear() {
- if (stereoSim) stereoSim.clearMeasurements();
- }
-
- function stereoToggleHeight(btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleHeight(on);
- }
-
- function stereoToggleApothem(btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleApothem(on);
- }
-
- function stereoToggleDiag(btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleDiagonals(on);
- }
-
- function stereoToggleMid(btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleMidpoints(on);
- }
-
- const ANGLE_HINTS = {
- edge: 'Кликните 3 точки: A, B (вершина угла), C',
- linePlane: 'Кликните 2 точки (прямая), затем — грань',
- dihedral: 'Кликните 2 точки общего ребра двух граней',
- pointPlane: 'Кликните точку, затем — грань',
- skewLines: 'P1, P2 (прямая 1) → P3, P4 (прямая 2): угол и расстояние',
- };
-
- function stereoAngleMode(mode, btn) {
- const on = !btn.classList.contains('active');
- _stereoDeactivateTools();
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.setAngleMode(on ? mode : null);
- const hint = document.getElementById('angle-hint');
- if (hint) hint.textContent = on ? ANGLE_HINTS[mode] : '';
- }
-
- function stereoAngleClear() {
- _stereoDeactivateTools();
- if (stereoSim) {
- stereoSim.setAngleMode(null);
- stereoSim._clearGroup(stereoSim._angleGroup);
- }
- }
-
- /* ── Edge marks ── */
- function stereoMarkMode(mode, btn) {
- const on = !btn.classList.contains('active');
- _stereoDeactivateTools();
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.setMarkMode(on ? mode : null);
- }
-
- function stereoMarkClear() {
- _stereoDeactivateTools();
- if (stereoSim) stereoSim.clearMarks();
- }
-
- function stereoToggleEdgeLengths(btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleEdgeLengths(on);
- }
-
- /* ── Derived points ── */
- function stereoDerive(mode, btn) {
- const on = !btn.classList.contains('active');
- _stereoDeactivateTools();
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.setDeriveMode(on ? mode : null);
- }
-
- function stereoDeriveUndo() {
- if (stereoSim) stereoSim.removeLastDerived();
- }
-
- function stereoDeriveClear() {
- _stereoDeactivateTools();
- if (stereoSim) stereoSim.clearDerived();
- }
-
- function stereoPointMode(btn) {
- const on = !btn.classList.contains('active');
- _stereoDeactivateTools();
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.togglePointMode(on);
- }
-
- function stereoConnectMode(btn) {
- const on = !btn.classList.contains('active');
- _stereoDeactivateTools();
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleConnectMode(on);
- }
-
- function stereoUndoPoint() {
- if (stereoSim) stereoSim.removeLastPoint();
- }
-
- function stereoClearPoints() {
- if (stereoSim) stereoSim.clearCustomPoints();
- _stereoUpdatePointsInfo();
- }
-
- function stereoInscribed(btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleInscribed(on);
- }
-
- function stereoCircumscribed(btn) {
- const on = !btn.classList.contains('active');
- btn.classList.toggle('active', on);
- if (stereoSim) stereoSim.toggleCircumscribed(on);
- }
-
- function _stereoUpdateFormulas() {
- if (!stereoSim) return;
- const f = stereoSim.getFormulas();
- const el = document.getElementById('stereo-formulas');
- if (!f || !f.formulas) { el.innerHTML = ''; return; }
- const colors = ['#7BF5A4','#60a5fa','#c4b5fd','#fbbf24','#f9a8d4','#F59E0B','#EF476F'];
- el.innerHTML = f.formulas.map((s, i) =>
- '' + s + '
'
- ).join('');
- }
-
- function _stereoUpdateUI(info) {
- if (!info) return;
- document.getElementById('stbar-vol').textContent = info.V !== undefined ? info.V.toFixed(2) : '—';
- document.getElementById('stbar-area').textContent = info.S !== undefined ? info.S.toFixed(2) : '—';
- document.getElementById('stbar-side').textContent = info.S_side !== undefined ? info.S_side.toFixed(2) : '—';
- document.getElementById('stbar-h').textContent = info.h !== undefined ? info.h.toFixed(2) : '—';
- document.getElementById('stbar-d').textContent = info.d !== undefined && info.d > 0 ? info.d.toFixed(2) : '—';
-
- // Section area
- const sectEl = document.getElementById('sect-area-display');
- if (info.sectionArea && info.sectionArea > 0) {
- sectEl.style.display = '';
- sectEl.textContent = 'S сечения = ' + info.sectionArea.toFixed(2);
- } else {
- sectEl.style.display = 'none';
- }
-
- // Inscribed / Circumscribed radius info
- const rInfo = document.getElementById('sphere-radius-info');
- if (rInfo) {
- const parts = [];
- if (info.inscribedR != null) parts.push('r_вп = ' + info.inscribedR.toFixed(2));
- if (info.circumscribedR != null) parts.push('R_оп = ' + info.circumscribedR.toFixed(2));
- rInfo.textContent = parts.join(' · ');
- rInfo.style.display = parts.length ? '' : 'none';
- }
-
- // Points info
- _stereoUpdatePointsInfo(info);
- }
-
- function _stereoUpdatePointsInfo(info) {
- const el = document.getElementById('points-info');
- if (!el) return;
- if (!info) info = stereoSim?.info();
- if (!info) { el.textContent = ''; return; }
- let txt = '';
- if (info.customPoints > 0) txt += `Точек: ${info.customPoints}`;
- if (info.connections > 0) txt += ` · Линий: ${info.connections}`;
- el.textContent = txt;
- }
-
/* ── theory panel ── */
const THEORY = {
graph: {
@@ -3982,117 +541,3 @@
/* ══════════════════════════════════════════════
HYDROSTATICS
══════════════════════════════════════════════ */
- var 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(`${info.formula}`);
- if (info.liqName) lines.push(`Жидкость: ${info.liqName}${info.rho ? ' (ρ=' + info.rho + ')' : ''}`);
- if (info.matName) lines.push(`Материал: ${info.matName}`);
- if (info.FA) lines.push(`F_A = ${info.FA} Н`);
- if (info.mg) lines.push(`mg = ${info.mg} Н`);
- if (info.sigma) lines.push(`σ = ${info.sigma} Н/м, θ = ${info.theta}°`);
- if (info.h && !info.FA) lines.push(`h_подъём = ${info.h} мм`);
- el.innerHTML = lines.join('
');
- // 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';
- }
- }
-
diff --git a/frontend/js/labs/magnetic.js b/frontend/js/labs/magnetic.js
index b2e3cae..ced9fda 100644
--- a/frontend/js/labs/magnetic.js
+++ b/frontend/js/labs/magnetic.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════
MagneticSim — magnetic field of current-carrying wires
• Click canvas to place wire (• out / × in)
@@ -1053,3 +1053,107 @@ class MagneticSim {
ctx.restore();
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openMagnetic() {
+ document.getElementById('sim-topbar-title').textContent = 'Магнитное поле токов';
+ _simShow('sim-mag');
+ _simShow('ctrl-mag');
+
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (!mSim) {
+ mSim = new MagneticSim(document.getElementById('mag-canvas'));
+ mSim.onUpdate = _magUpdateUI;
+ }
+ mSim.fit();
+ // default preset on first open
+ if (mSim.sources.length === 0) mSim.preset('anti');
+ _magUpdateUI(mSim.info());
+ }));
+ }
+
+ function magMode(dir) {
+ if (!mSim) return;
+ mSim.addMode = dir;
+ document.getElementById('mag-add-out').classList.toggle('active', dir === 'out');
+ document.getElementById('mag-add-in').classList.toggle('active', dir === 'in');
+ document.getElementById('mag-mode-out').classList.toggle('active', dir === 'out');
+ document.getElementById('mag-mode-in').classList.toggle('active', dir === 'in');
+ }
+
+ function magCurrentChange() {
+ const I = +document.getElementById('sl-curI').value;
+ document.getElementById('m-curI').textContent = I + ' А';
+ document.getElementById('mbar-I').textContent = I + ' А';
+ if (mSim) mSim.setCurrentAll(I);
+ }
+
+ function magLayer(name, rowEl) {
+ if (!mSim) return;
+ mSim.layers[name] = !mSim.layers[name];
+ rowEl.classList.toggle('active', mSim.layers[name]);
+ mSim._invalidateCache();
+ mSim.draw();
+ }
+
+ function magParticle(rowEl) {
+ if (!mSim) return;
+ mSim.toggleParticle();
+ rowEl.classList.toggle('active', mSim.particleOn);
+ _magUpdateUI(mSim.info());
+ }
+
+ function magCondToggle(rowEl) {
+ if (!mSim) return;
+ mSim.toggleConductor();
+ const on = mSim._cond.on;
+ rowEl.classList.toggle('active', on);
+ document.getElementById('cond-I-block').style.display = on ? '' : 'none';
+ _magUpdateUI(mSim.info());
+ }
+
+ function magCondCurrentChange() {
+ if (!mSim) return;
+ const I = parseFloat(document.getElementById('sl-condI').value);
+ document.getElementById('m-condI').textContent = I + ' А';
+ mSim.setConductorI(I);
+ }
+
+ function magFluxToggle(rowEl) {
+ if (!mSim) return;
+ mSim.toggleFlux();
+ rowEl.classList.toggle('active', mSim._flux.on);
+ _magUpdateUI(mSim.info());
+ }
+
+ function _magUpdateUI(info) {
+ document.getElementById('ms-out').textContent = info.out;
+ document.getElementById('ms-in').textContent = info.inn;
+ document.getElementById('mbar-total').textContent = info.total;
+ document.getElementById('mbar-out').textContent = info.out;
+ document.getElementById('mbar-in').textContent = info.inn;
+ document.getElementById('mbar-particle').textContent = info.particleOn ? 'вкл' : 'выкл';
+ document.getElementById('mbar-particle').style.color = info.particleOn ? '#ffff50' : '';
+ // Ampere force
+ const fEl = document.getElementById('mbar-ampere');
+ if (info.condOn && info.Fz !== 0) {
+ const dir = info.Fz > 0 ? '⊙' : '⊗';
+ fEl.textContent = dir + ' ' + Math.abs(info.Fz).toFixed(3);
+ fEl.style.color = '#fbbf24';
+ } else {
+ fEl.textContent = '—';
+ fEl.style.color = '#fbbf24';
+ }
+ // Flux
+ const phEl = document.getElementById('mbar-flux');
+ if (info.fluxOn) {
+ phEl.textContent = info.flux.toExponential(2) + ' Вб';
+ phEl.style.color = '#34d399';
+ } else {
+ phEl.textContent = '—';
+ phEl.style.color = '#34d399';
+ }
+ }
+
+ /* ── triangle ── */
+
diff --git a/frontend/js/labs/mirror.js b/frontend/js/labs/mirror.js
index 34b5062..e9fbfe6 100644
--- a/frontend/js/labs/mirror.js
+++ b/frontend/js/labs/mirror.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
MirrorSim v3
Flat / Concave / Convex · 1/f = 1/d + 1/d' · M = -d'/d
@@ -1003,3 +1003,97 @@ class MirrorSim {
cv.addEventListener('touchend', () => { this._drag=null; });
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var mirrorSim = null;
+
+ 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'));
+ mirrorSim.onUpdate = _mirrorUpdateUI;
+ mirrorSim.onAnimate = (d) => {
+ const sl = document.getElementById('sl-mirror-d');
+ const lbl = document.getElementById('mirror-d-val');
+ if (sl) sl.value = Math.round(d);
+ if (lbl) lbl.textContent = Math.round(d);
+ };
+ }
+ mirrorSim.fit();
+ mirrorSim.draw();
+ mirrorSim._emit();
+ if (mirrorSim._showPhotons && !mirrorSim._photonRaf) mirrorSim._startPhotons();
+ }));
+ }
+
+ function mirrorType(type, el) {
+ document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ const fRow = document.getElementById('mirror-f-row');
+ if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex';
+ if (mirrorSim) mirrorSim.setType(type);
+ const pb = document.getElementById('mirror-play-btn');
+ if (pb) { pb.textContent = '▶ Анимация'; }
+ const sl = document.getElementById('sl-mirror-d');
+ if (sl) sl.disabled = false;
+ }
+
+ function mirrorParam(name, val) {
+ const v = parseFloat(val);
+ const ids = { f: 'mirror-f-val', d: 'mirror-d-val', h: 'mirror-h-val' };
+ const el = document.getElementById(ids[name]);
+ if (el) el.textContent = v;
+ if (mirrorSim) mirrorSim.setParams({ [name]: v });
+ }
+
+ function mirrorPreset(name) {
+ const P = {
+ flat: { type: 'flat', f: 120, d: 200, h: 60 },
+ far: { type: 'concave', f: 100, d: 280, h: 60 },
+ '2f': { type: 'concave', f: 100, d: 200, h: 60 },
+ between: { type: 'concave', f: 100, d: 140, h: 60 },
+ near: { type: 'concave', f: 100, d: 60, h: 60 },
+ convex: { type: 'convex', f: 100, d: 200, h: 60 },
+ };
+ const p = P[name]; if (!p) return;
+ document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active'));
+ const tb = document.getElementById(`mtype-${p.type}`);
+ if (tb) tb.classList.add('active');
+ const fRow = document.getElementById('mirror-f-row');
+ if (fRow) fRow.style.display = p.type === 'flat' ? 'none' : 'flex';
+ document.getElementById('sl-mirror-f').value = p.f; document.getElementById('mirror-f-val').textContent = p.f;
+ document.getElementById('sl-mirror-d').value = p.d; document.getElementById('mirror-d-val').textContent = p.d;
+ document.getElementById('sl-mirror-h').value = p.h; document.getElementById('mirror-h-val').textContent = p.h;
+ if (mirrorSim) { mirrorSim.setType(p.type); mirrorSim.setParams({ f: p.f, d: p.d, h: p.h }); }
+ }
+
+ function mirrorTogglePlay(btn) {
+ if (!mirrorSim) return;
+ mirrorSim.togglePlay();
+ const playing = mirrorSim._playing;
+ if (btn) btn.textContent = playing ? '⏸ Стоп' : '▶ Анимация';
+ const sl = document.getElementById('sl-mirror-d');
+ if (sl) sl.disabled = playing;
+ }
+
+ function mirrorSetSpeed(val) { if (mirrorSim) mirrorSim.setAnimSpeed(parseFloat(val)); }
+ function mirrorToggle(name, val) { if (mirrorSim) mirrorSim.setToggle(name, val); }
+ function mirrorStepNext() { if (mirrorSim) mirrorSim.stepNext(); }
+ function mirrorStepReset() { if (mirrorSim) mirrorSim.stepReset(); }
+ function mirrorSetPointMode(val) { if (mirrorSim) mirrorSim.setPointMode(val); }
+
+ function _mirrorUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('mirrorbar-v1', info.f);
+ v('mirrorbar-v5', Math.round(info.d));
+ v('mirrorbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
+ v('mirrorbar-v3', info.M === Infinity ? '∞' : info.M);
+ v('mirrorbar-v4', info.imageType);
+ }
+
+ /* ── isoprocesses ── */
+
diff --git a/frontend/js/labs/newton.js b/frontend/js/labs/newton.js
index d82c7cf..eb4d453 100644
--- a/frontend/js/labs/newton.js
+++ b/frontend/js/labs/newton.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ════════════════════════════════════════════════════════════════
NewtonSim — три закона Ньютона
Закон I : A — скользящий блок, B — шар на нити
@@ -1202,3 +1202,465 @@ function _nwt_lighten(hex, d) {
const c = v => Math.max(0, Math.min(255, v));
return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`;
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var newtonSim = null;
+ var sandboxSim = null;
+ let _dynMode = 'sandbox'; // current mode: 'sandbox' | 'law1' | 'law2' | 'law3'
+
+ function _openDynamics(preset) {
+ document.getElementById('sim-topbar-title').textContent = 'Динамика';
+ _simShow('sim-dynamics');
+ _simShow('ctrl-dynamics');
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ // init sandbox
+ const sbCanvas = document.getElementById('sandbox-canvas');
+ if (!sandboxSim) {
+ sandboxSim = new ForceSandboxSim(sbCanvas);
+ sandboxSim.onUpdate = _sbUpdateUI;
+ }
+ // init newton
+ const nwCanvas = document.getElementById('newton-canvas');
+ if (!newtonSim) {
+ newtonSim = new NewtonSim(nwCanvas);
+ newtonSim.onUpdate = _newtonUpdateUI;
+ }
+ // activate current mode
+ dynMode(_dynMode);
+ if (preset) setTimeout(() => sbPreset(preset), 120);
+ }));
+ }
+
+ function dynMode(mode, btn) {
+ _dynMode = mode;
+ const isSandbox = mode === 'sandbox';
+
+ // toggle mode buttons
+ document.querySelectorAll('.dyn-mode').forEach(b => b.classList.remove('active'));
+ const modeBtn = document.getElementById('dyn-mode-' + mode);
+ if (modeBtn) modeBtn.classList.add('active');
+
+ // toggle panels
+ document.getElementById('dyn-sandbox-panel').style.display = isSandbox ? '' : 'none';
+ document.getElementById('dyn-newton-panel').style.display = isSandbox ? 'none' : '';
+
+ // toggle canvases
+ document.getElementById('sandbox-canvas').style.display = isSandbox ? 'block' : 'none';
+ document.getElementById('newton-canvas').style.display = isSandbox ? 'none' : 'block';
+
+ // toggle topbar tool groups
+ document.getElementById('ctrl-dyn-sb').style.display = isSandbox ? 'contents' : 'none';
+ document.getElementById('ctrl-dyn-nw').style.display = isSandbox ? 'none' : 'contents';
+
+ if (isSandbox) {
+ // stop newton, start sandbox
+ if (newtonSim) newtonSim.stop();
+ if (sandboxSim) { sandboxSim.fit(); sandboxSim.start(); }
+ _sbUpdateUI(sandboxSim ? sandboxSim.info() : null);
+ } else {
+ // stop sandbox, switch newton law
+ if (sandboxSim) sandboxSim.stop();
+ const lawN = mode === 'law1' ? 1 : mode === 'law2' ? 2 : 3;
+ if (newtonSim) {
+ newtonSim.setLaw(lawN);
+ newtonSim.fit();
+ newtonSim.start();
+ _newtonSyncUI();
+ _newtonUpdateUI(newtonSim.info());
+ }
+ }
+ }
+
+ function dynPause() {
+ if (_dynMode === 'sandbox') {
+ if (sandboxSim) sandboxSim.togglePause();
+ } else {
+ if (newtonSim) newtonSim.togglePause();
+ }
+ }
+
+ function dynReset() {
+ if (_dynMode === 'sandbox') {
+ sbReset();
+ } else {
+ _resetNewtonScene();
+ }
+ }
+
+ const _NEWTON_SCENES = {
+ 1: {
+ A: { desc: 'Закон инерции: тело скользит по поверхности. Нажми на canvas — толкни блок.', action: null },
+ B: { desc: 'Инерция в орбите: шар вращается на нити. Отруби нить — полетит по касательной!', action: ' Отрубить нить' },
+ C: { desc: 'Инерция в космосе: тело движется равномерно, нет сил — нет ускорения.', action: null },
+ },
+ 2: {
+ A: { desc: 'Второй закон: F = ma. Прикладывай силу и следи за ускорением и скоростью.', action: ' Запустить' },
+ B: { desc: 'Два тела, разные массы — одинаковая сила. Сравни ускорения!', action: ' Запустить' },
+ C: { desc: 'Второй закон: изменяй силу и массу ползунками, наблюдай в реальном времени.', action: ' Запустить' },
+ },
+ 3: {
+ A: { desc: 'Третий закон: пушка выстрелила — отдача. Импульс сохраняется!', action: 'Выстрел' },
+ B: { desc: 'Третий закон: два шара сталкиваются — силы равны и противоположны.', action: ' Столкнуть' },
+ C: { desc: 'Реактивное движение: ракета выбрасывает газ — летит в обратную сторону.', action: 'Двигатель' },
+ },
+ };
+
+ const _NEWTON_PRESETS = {
+ 1: [
+ { label: 'Космос', fn: 'space' },
+ { label: 'Лёд', fn: 'ice' },
+ { label: 'Асфальт', fn: 'asphalt' },
+ { label: 'Резина', fn: 'rubber' },
+ ],
+ 2: [
+ { label: 'Лёгкий', fn: 'light' },
+ { label: 'Тяжёлый', fn: 'heavy' },
+ { label: 'Сравнить', fn: 'compare' },
+ ],
+ 3: [
+ { label: 'Большая пушка', fn: 'big_cannon' },
+ { label: 'Маленькая', fn: 'small_cannon' },
+ { label: 'Равные шары', fn: 'equal_balls' },
+ ],
+ };
+
+ // _openNewton is now handled by _openDynamics + dynMode
+
+ // newtonLaw is now handled by dynMode('law1'/'law2'/'law3')
+
+ function newtonScene(s, topBtn, panelBtn) {
+ if (!newtonSim) return;
+ newtonSim.setScene(s);
+ document.querySelectorAll('.nscene-btn').forEach(b => {
+ b.classList.toggle('active', b.id === 'nscn-' + s || b.id === 'nscn-panel-' + s);
+ });
+ _newtonSyncUI();
+ _newtonUpdateUI(newtonSim.info());
+ }
+
+ function _newtonSyncUI() {
+ if (!newtonSim) return;
+ const law = newtonSim.law;
+ const scene = newtonSim.scene;
+ const sceneData = (_NEWTON_SCENES[law] || {})[scene] || {};
+
+ // description
+ const desc = document.getElementById('newton-scene-desc');
+ if (desc) desc.textContent = sceneData.desc || '';
+
+ // action button label
+ const lbl = sceneData.action || (law === 1 ? ' Нить' : ' Действие');
+ document.getElementById('newton-action-label').textContent = lbl;
+ document.getElementById('newton-action-top').textContent = lbl;
+
+ // show/hide sliders
+ document.getElementById('newton-mu-block').style.display = law === 1 && scene === 'A' ? '' : 'none';
+ document.getElementById('newton-mass1-block').style.display = (law === 2 || law === 3) ? '' : 'none';
+ document.getElementById('newton-mass2-block').style.display = law === 3 ? '' : 'none';
+ document.getElementById('newton-force-block').style.display = law === 2 ? '' : 'none';
+
+ // sync slider values from sim
+ document.getElementById('sl-newton-mu').value = newtonSim.mu;
+ document.getElementById('newton-mu-val').textContent = newtonSim.mu.toFixed(2);
+ document.getElementById('sl-newton-m1').value = newtonSim.mass1;
+ document.getElementById('newton-m1-val').textContent = newtonSim.mass1 + ' кг';
+ document.getElementById('sl-newton-m2').value = newtonSim.mass2;
+ document.getElementById('newton-m2-val').textContent = newtonSim.mass2 + ' кг';
+ document.getElementById('sl-newton-F').value = newtonSim.force;
+ document.getElementById('newton-F-val').textContent = newtonSim.force + ' Н';
+
+ // sync scene highlight buttons in both topbar and panel
+ ['A','B','C'].forEach(s => {
+ const tb = document.getElementById('nscn-' + s);
+ const pb = document.getElementById('nscn-panel-' + s);
+ const on = s === scene;
+ if (tb) tb.classList.toggle('active', on);
+ if (pb) pb.classList.toggle('active', on);
+ });
+
+ // presets
+ const presetsEl = document.getElementById('newton-presets');
+ const presets = _NEWTON_PRESETS[law] || [];
+ presetsEl.innerHTML = presets.map(p =>
+ ``
+ ).join('');
+
+ // scene B/C visibility for law I (B = orbital, C = space — but law I only has A,B)
+ // scene C doesn't exist for law I/II panel scene picker visibility
+ const cBtn = document.getElementById('nscn-panel-C');
+ const cTopBtn = document.getElementById('nscn-C');
+ const showC = law === 3;
+ if (cBtn) cBtn.style.display = showC ? '' : 'none';
+ if (cTopBtn) cTopBtn.style.display = showC ? '' : 'none';
+ const bBtn = document.getElementById('nscn-panel-B');
+ const bTopBtn = document.getElementById('nscn-B');
+ const showB = law !== 2 || true; // law 2 has compare scene B
+ if (bBtn) bBtn.style.display = '';
+ if (bTopBtn) bTopBtn.style.display = '';
+ }
+
+ function newtonAction() {
+ if (!newtonSim) return;
+ const law = newtonSim.law;
+ const scene = newtonSim.scene;
+ if (law === 1 && scene === 'B') newtonSim.cutString();
+ else if (law === 2) newtonSim.startL2();
+ else if (law === 3 && scene === 'A') newtonSim.fireCannon();
+ else if (law === 3 && scene === 'B') newtonSim._reset3B ? newtonSim._reset3B() : null;
+ else if (law === 3 && scene === 'C') newtonSim.toggleRocket();
+ _newtonUpdateUI(newtonSim.info());
+ }
+
+ function _resetNewtonScene() {
+ if (!newtonSim) return;
+ const law = newtonSim.law;
+ const scene = newtonSim.scene;
+ if (law === 1 && scene === 'A') newtonSim.preset('ice');
+ else if (law === 1) newtonSim.setScene(scene);
+ else if (law === 2) newtonSim.resetL2 ? newtonSim.resetL2() : newtonSim.setScene(scene);
+ else newtonSim.setScene(scene);
+ _newtonUpdateUI(newtonSim.info());
+ }
+
+ function newtonMuChange() {
+ const v = +document.getElementById('sl-newton-mu').value;
+ document.getElementById('newton-mu-val').textContent = v.toFixed(2);
+ if (newtonSim) newtonSim.setMu(v);
+ }
+
+ function newtonMass1Change() {
+ const v = +document.getElementById('sl-newton-m1').value;
+ document.getElementById('newton-m1-val').textContent = v + ' кг';
+ if (newtonSim) newtonSim.setMass1(v);
+ }
+
+ function newtonMass2Change() {
+ const v = +document.getElementById('sl-newton-m2').value;
+ document.getElementById('newton-m2-val').textContent = v + ' кг';
+ if (newtonSim) newtonSim.setMass2(v);
+ }
+
+ function newtonForceChange() {
+ const v = +document.getElementById('sl-newton-F').value;
+ document.getElementById('newton-F-val').textContent = v + ' Н';
+ if (newtonSim) newtonSim.setForce(v);
+ }
+
+ function newtonPreset(name) {
+ if (!newtonSim) return;
+ newtonSim.preset(name);
+ _newtonSyncUI();
+ _newtonUpdateUI(newtonSim.info());
+ }
+
+ function _newtonUpdateUI(info) {
+ if (!info) return;
+ const law = info.law;
+ const scene = info.scene;
+
+ if (law === 1 && scene === 'A') {
+ document.getElementById('dbar-l1').textContent = 'Закон I-A';
+ document.getElementById('dbar-v1').textContent = 'Скольжение';
+ document.getElementById('dbar-l2').textContent = 'Скорость';
+ document.getElementById('dbar-v2').textContent = info.v + ' м/с';
+ document.getElementById('dbar-l3').textContent = 'Сила трения';
+ document.getElementById('dbar-v3').textContent = info.fFr + ' Н';
+ document.getElementById('dbar-l4').textContent = 'Масса';
+ document.getElementById('dbar-v4').textContent = info.m + ' кг';
+ document.getElementById('dbar-l5').textContent = 'μ';
+ document.getElementById('dbar-v5').textContent = info.mu;
+ } else if (law === 1) {
+ document.getElementById('dbar-l1').textContent = 'Закон I-B';
+ document.getElementById('dbar-v1').textContent = info.cut ? 'Нить срублена' : 'Вращение';
+ document.getElementById('dbar-l2').textContent = 'Скорость';
+ document.getElementById('dbar-v2').textContent = info.v + ' м/с';
+ document.getElementById('dbar-l3').textContent = '';
+ document.getElementById('dbar-v3').textContent = '—';
+ document.getElementById('dbar-l4').textContent = '';
+ document.getElementById('dbar-v4').textContent = '—';
+ document.getElementById('dbar-l5').textContent = '';
+ document.getElementById('dbar-v5').textContent = '—';
+ } else if (law === 2) {
+ document.getElementById('dbar-l1').textContent = 'Закон II';
+ document.getElementById('dbar-v1').textContent = 'F = ma';
+ document.getElementById('dbar-l2').textContent = 'Сила F';
+ document.getElementById('dbar-v2').textContent = info.F + ' Н';
+ document.getElementById('dbar-l3').textContent = 'Масса m';
+ document.getElementById('dbar-v3').textContent = info.m + ' кг';
+ document.getElementById('dbar-l4').textContent = 'Ускор. a';
+ document.getElementById('dbar-v4').textContent = info.a + ' м/с²';
+ document.getElementById('dbar-l5').textContent = 'Скорость';
+ document.getElementById('dbar-v5').textContent = info.v + ' м/с';
+ } else if (scene === 'A') {
+ document.getElementById('dbar-l1').textContent = 'Закон III-A';
+ document.getElementById('dbar-v1').textContent = 'Пушка';
+ document.getElementById('dbar-l2').textContent = 'v снаряда';
+ document.getElementById('dbar-v2').textContent = info.vBall !== '—' ? info.vBall + ' м/с' : '—';
+ document.getElementById('dbar-l3').textContent = 'v пушки';
+ document.getElementById('dbar-v3').textContent = info.vCannon + ' м/с';
+ document.getElementById('dbar-l4').textContent = 'm снаряда';
+ document.getElementById('dbar-v4').textContent = info.m1 + ' кг';
+ document.getElementById('dbar-l5').textContent = 'm пушки';
+ document.getElementById('dbar-v5').textContent = info.m2 + ' кг';
+ } else if (scene === 'B') {
+ document.getElementById('dbar-l1').textContent = 'Закон III-B';
+ document.getElementById('dbar-v1').textContent = 'Удар';
+ document.getElementById('dbar-l2').textContent = 'p₁';
+ document.getElementById('dbar-v2').textContent = info.p1 + ' кг·м/с';
+ document.getElementById('dbar-l3').textContent = 'p₂';
+ document.getElementById('dbar-v3').textContent = info.p2 + ' кг·м/с';
+ document.getElementById('dbar-l4').textContent = 'p суммарный';
+ document.getElementById('dbar-v4').textContent = info.pt + ' кг·м/с';
+ document.getElementById('dbar-l5').textContent = '';
+ document.getElementById('dbar-v5').textContent = '—';
+ } else {
+ document.getElementById('dbar-l1').textContent = 'Закон III-C';
+ document.getElementById('dbar-v1').textContent = 'Ракета';
+ document.getElementById('dbar-l2').textContent = 'Ускорение';
+ document.getElementById('dbar-v2').textContent = info.a + ' м/с²';
+ document.getElementById('dbar-l3').textContent = 'Скорость';
+ document.getElementById('dbar-v3').textContent = info.v + ' м/с';
+ document.getElementById('dbar-l4').textContent = 'Масса';
+ document.getElementById('dbar-v4').textContent = info.m + ' кг';
+ document.getElementById('dbar-l5').textContent = 'Топливо';
+ document.getElementById('dbar-v5').textContent = info.fuel + '%';
+ }
+ }
+
+ // _openSandbox is now handled by _openDynamics + dynMode
+
+ function sbTool(t, btn) {
+ if (!sandboxSim) return;
+ sandboxSim.tool = t;
+ sandboxSim._springStart = null;
+ sandboxSim._ropeStart = null;
+ document.querySelectorAll('.sb-tool-btn').forEach(b => b.classList.toggle('active', b.id === 'sbt-' + t));
+ document.querySelectorAll('.sb-panel-tool').forEach(b => b.classList.toggle('active', b.id === 'sbpt-' + t));
+ const canvas = document.getElementById('sandbox-canvas');
+ canvas.style.cursor = t === 'erase' ? 'not-allowed'
+ : (t === 'spring' || t === 'rope') ? 'cell'
+ : t === 'anchor' ? 'copy'
+ : 'crosshair';
+ document.getElementById('sb-spring-block').style.display = t === 'spring' ? '' : 'none';
+ }
+
+ function sbSpringKChange() {
+ const v = +document.getElementById('sl-sb-springk').value;
+ document.getElementById('sb-springk-val').textContent = v + ' Н/м';
+ if (sandboxSim) sandboxSim.newSpringK = v;
+ }
+
+ function sbForceMode(m, btn) {
+ if (!sandboxSim) return;
+ sandboxSim.forceMode = m;
+ document.querySelectorAll('.sb-fmode').forEach(b => b.classList.toggle('active', b.id === 'sbfm-' + m));
+ }
+
+ function sbMassChange() {
+ const v = +document.getElementById('sl-sb-mass').value;
+ document.getElementById('sb-mass-val').textContent = v + ' кг';
+ if (sandboxSim) sandboxSim.newMass = v;
+ }
+
+ function sbRestChange() {
+ const v = +document.getElementById('sl-sb-rest').value;
+ document.getElementById('sb-rest-val').textContent = v.toFixed(2);
+ if (sandboxSim) sandboxSim.newRestitution = v;
+ }
+
+ function sbFloorMuChange() {
+ const v = +document.getElementById('sl-sb-floormu').value;
+ document.getElementById('sb-floormu-val').textContent = v.toFixed(2);
+ if (sandboxSim) sandboxSim.floorMu = v;
+ }
+
+ function sbWorldToggle() {
+ if (!sandboxSim) return;
+ sandboxSim.gravity = document.getElementById('sb-gravity').checked;
+ sandboxSim.hasFloor = document.getElementById('sb-floor').checked;
+ sandboxSim.hasWalls = document.getElementById('sb-walls').checked;
+ sandboxSim.airDrag = document.getElementById('sb-airdrag').checked;
+ }
+
+ function sbRampToggle() {
+ if (!sandboxSim) return;
+ const on = document.getElementById('sb-ramp').checked;
+ sandboxSim.setRamp(on);
+ document.getElementById('sb-ramp-block').style.display = on ? '' : 'none';
+ }
+
+ function sbAngleChange() {
+ const v = +document.getElementById('sl-sb-angle').value;
+ document.getElementById('sb-angle-val').textContent = v + '°';
+ if (sandboxSim) sandboxSim.setRampAngle(v);
+ }
+
+ function sbRampMuChange() {
+ const v = +document.getElementById('sl-sb-rampmu').value;
+ document.getElementById('sb-rampmu-val').textContent = v.toFixed(2);
+ if (sandboxSim) sandboxSim.setRampMu(v);
+ }
+
+ function sbDecompToggle() {
+ if (!sandboxSim) return;
+ sandboxSim.showDecomp = document.getElementById('sb-decomp').checked;
+ }
+
+ function sbDisplayToggle() {
+ if (!sandboxSim) return;
+ sandboxSim.showForces = document.getElementById('sb-forces').checked;
+ sandboxSim.showVelocity = document.getElementById('sb-vel').checked;
+ sandboxSim.showFBD = document.getElementById('sb-fbd').checked;
+ sandboxSim.showEnergy = document.getElementById('sb-energy').checked;
+ sandboxSim.showTrail = document.getElementById('sb-trail').checked;
+ }
+
+ function sbTimeScale(v, btn) {
+ if (!sandboxSim) return;
+ sandboxSim.timeScale = v;
+ document.querySelectorAll('.sb-time').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ }
+
+ function sbPreset(name) {
+ if (!sandboxSim) return;
+ sandboxSim.preset(name);
+ // sync world checkboxes
+ document.getElementById('sb-gravity').checked = sandboxSim.gravity;
+ document.getElementById('sb-floor').checked = sandboxSim.hasFloor;
+ document.getElementById('sb-walls').checked = sandboxSim.hasWalls;
+ document.getElementById('sb-airdrag').checked = sandboxSim.airDrag;
+ document.getElementById('sl-sb-floormu').value = sandboxSim.floorMu;
+ document.getElementById('sb-floormu-val').textContent = sandboxSim.floorMu.toFixed(2);
+ // sync ramp
+ document.getElementById('sb-ramp').checked = sandboxSim.ramp;
+ document.getElementById('sb-ramp-block').style.display = sandboxSim.ramp ? '' : 'none';
+ document.getElementById('sl-sb-angle').value = sandboxSim.rampAngle;
+ document.getElementById('sb-angle-val').textContent = sandboxSim.rampAngle + '°';
+ document.getElementById('sl-sb-rampmu').value = sandboxSim.rampMu;
+ document.getElementById('sb-rampmu-val').textContent = sandboxSim.rampMu.toFixed(2);
+ _sbUpdateUI(sandboxSim.info());
+ }
+
+ function sbReset() {
+ if (!sandboxSim) return;
+ sandboxSim.reset();
+ _sbUpdateUI(sandboxSim.info());
+ }
+
+ function _sbUpdateUI(info) {
+ if (!info) return;
+ document.getElementById('dbar-l1').textContent = 'Тел / связей';
+ document.getElementById('dbar-v1').textContent = info.bodies + ' / ' + (info.springs + info.ropes);
+ document.getElementById('dbar-l2').textContent = 'KE (Дж)';
+ document.getElementById('dbar-v2').textContent = info.KE;
+ document.getElementById('dbar-l3').textContent = 'PE (Дж)';
+ document.getElementById('dbar-v3').textContent = info.PE;
+ document.getElementById('dbar-l4').textContent = 'ΣF';
+ document.getElementById('dbar-v4').textContent = info.netF;
+ document.getElementById('dbar-l5').textContent = 'Время';
+ document.getElementById('dbar-v5').textContent = info.time + ' с';
+ }
+
+ /* ── chem sandbox ── */
+
diff --git a/frontend/js/labs/normaldist.js b/frontend/js/labs/normaldist.js
index 3d6bd6e..dc63ff5 100644
--- a/frontend/js/labs/normaldist.js
+++ b/frontend/js/labs/normaldist.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/**
* NormalDistSim v2 — интерактивное нормальное распределение
* μ, σ · правило 68-95-99.7 · Z-score · закрашивание области
@@ -392,3 +392,51 @@ class NormalDistSim {
cv.addEventListener('touchend', () => { this.hx = null; this.draw(); });
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var ndSim = null;
+
+ 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'));
+ ndSim.onUpdate = _ndUpdateUI;
+ }
+ ndSim.fit();
+ ndSim.draw();
+ ndSim._emit();
+ }));
+ }
+
+ function ndParam(name, val) {
+ const v = parseFloat(val);
+ const elId = name === 'mu' ? 'nd-mu-val' : 'nd-sigma-val';
+ document.getElementById(elId).textContent = v % 1 === 0 ? v : v.toFixed(1);
+ if (ndSim) ndSim.setParams({ [name]: v });
+ }
+
+ function ndShade(mode, btn) {
+ document.querySelectorAll('.nd-shade-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ if (ndSim) ndSim.setParams({ shade: mode });
+ }
+
+ function ndPreset(mu, sigma) {
+ document.getElementById('sl-nd-mu').value = mu; document.getElementById('nd-mu-val').textContent = mu;
+ document.getElementById('sl-nd-sigma').value = sigma; document.getElementById('nd-sigma-val').textContent = sigma;
+ if (ndSim) ndSim.setParams({ mu, sigma });
+ }
+
+ function _ndUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('ndbar-v1', info.mu);
+ v('ndbar-v2', info.sigma);
+ v('ndbar-v3', info.peak);
+ v('ndbar-v4', info.area);
+ }
+
+ /* ── graph transform ── */
diff --git a/frontend/js/labs/orbitals.js b/frontend/js/labs/orbitals.js
index 1d7d2ab..2048934 100644
--- a/frontend/js/labs/orbitals.js
+++ b/frontend/js/labs/orbitals.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════════
OrbitalsSim — 3D molecular orbitals (Three.js)
@@ -340,3 +340,26 @@ class OrbitalsSim {
this.renderer.render(this.scene, this.camera);
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var orbitalsSim = null;
+ function _openOrbitals() {
+ document.getElementById('sim-topbar-title').textContent = 'Молекулярные орбитали';
+ _simShow('sim-orbitals');
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (!orbitalsSim) {
+ orbitalsSim = new OrbitalsSim(document.getElementById('orbitals-container'));
+ } else {
+ orbitalsSim.fit();
+ orbitalsSim.play();
+ }
+ }));
+ }
+ function setOrbital(mode, btn) {
+ document.querySelectorAll('.orbital-mode-btn').forEach(b => { b.classList.remove('active'); b.style.borderColor = ''; b.style.color = ''; });
+ btn.classList.add('active');
+ btn.style.borderColor = '#9B5DE5'; btn.style.color = '#9B5DE5';
+ if (orbitalsSim) orbitalsSim.setMode(mode);
+ }
+
+ /* ── stereometry 3D ── */
diff --git a/frontend/js/labs/pendulum.js b/frontend/js/labs/pendulum.js
index 88f337a..4c64a01 100644
--- a/frontend/js/labs/pendulum.js
+++ b/frontend/js/labs/pendulum.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
PendulumSim — simple pendulum simulation
θ'' = -(g/L)sin(θ) − γ·θ'
@@ -402,3 +402,51 @@ class PendulumSim {
});
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var pendSim = null;
+
+ 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'));
+ pendSim.onUpdate = _pendUpdateUI;
+ }
+ pendSim.fit();
+ pendSim.play();
+ }));
+ }
+
+ function pendParam(name, val) {
+ const v = parseFloat(val);
+ const ids = { theta: 'pend-theta-val', L: 'pend-L-val', g: 'pend-g-val', damping: 'pend-damp-val' };
+ const el = document.getElementById(ids[name]);
+ if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(name === 'g' ? 2 : 1);
+ if (pendSim) pendSim.setParams({ [name]: v });
+ }
+
+ function pendPreset(theta, L, g, damp) {
+ document.getElementById('sl-pend-theta').value = theta; document.getElementById('pend-theta-val').textContent = theta;
+ document.getElementById('sl-pend-L').value = L; document.getElementById('pend-L-val').textContent = L;
+ document.getElementById('sl-pend-g').value = g; document.getElementById('pend-g-val').textContent = g;
+ document.getElementById('sl-pend-damp').value = damp; document.getElementById('pend-damp-val').textContent = damp;
+ if (pendSim) {
+ pendSim.setParams({ theta, L, g, damping: damp });
+ pendSim.play();
+ }
+ }
+
+ function _pendUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('pendbar-v1', info.angle);
+ v('pendbar-v2', info.omega);
+ v('pendbar-v3', info.period);
+ v('pendbar-v4', info.energy);
+ }
+
+ /* ── equilibrium ── */
+
diff --git a/frontend/js/labs/photosynthesis.js b/frontend/js/labs/photosynthesis.js
index 70352a0..37248d1 100644
--- a/frontend/js/labs/photosynthesis.js
+++ b/frontend/js/labs/photosynthesis.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ════════════════════════════════════════════════════════════════
PhotosynthesisSim — Фотосинтез и клеточное дыхание
Световые реакции · цикл Кальвина · митохондриальное дыхание
@@ -809,3 +809,53 @@ function _psRRect(ctx, x, y, w, h, r) {
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openPhotosynthesis(mode) {
+ document.getElementById('sim-topbar-title').textContent = 'Фотосинтез и дыхание';
+ _simShow('sim-photosynthesis');
+ _simShow('ctrl-photosynthesis');
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const canvas = document.getElementById('photosyn-canvas');
+ if (!photosynSim) {
+ photosynSim = new PhotosynthesisSim(canvas);
+ photosynSim.onUpdate = _psUpdateUI;
+ }
+ photosynSim.fit();
+ photosynSim.setMode(mode || 'photo');
+ photosynSim.start();
+ }));
+ }
+
+ function psSetMode(mode, btn) {
+ document.querySelectorAll('.ps-mode-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ if (photosynSim) photosynSim.setMode(mode);
+ }
+
+ function psLightChange() {
+ const v = +document.getElementById('sl-ps-light').value;
+ document.getElementById('ps-light-val').textContent = v + '%';
+ if (photosynSim) photosynSim.setLightIntensity(v);
+ }
+
+ function psCO2Change() {
+ const v = +document.getElementById('sl-ps-co2').value;
+ document.getElementById('ps-co2-val').textContent = v + '%';
+ if (photosynSim) photosynSim.setCO2(v);
+ }
+
+ function psReset() {
+ if (photosynSim) photosynSim.reset();
+ }
+
+ function _psUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('psbar-v1', info.atpRate || '0');
+ v('psbar-v2', info.o2 || '0');
+ v('psbar-v3', info.co2 || '0');
+ v('psbar-v4', info.efficiency ? info.efficiency + '%' : '—');
+ v('psbar-v5', info.mode === 'photo' ? 'Фотосинтез' : 'Дыхание');
+ }
+
+ /* ── Angry Birds ── */
diff --git a/frontend/js/labs/probability.js b/frontend/js/labs/probability.js
index 45455e3..7e84e17 100644
--- a/frontend/js/labs/probability.js
+++ b/frontend/js/labs/probability.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
ProbabilitySim — probability & law of large numbers
coin flip · single die · two-dice sum
@@ -569,3 +569,45 @@ class ProbabilitySim {
}
if (typeof module !== 'undefined') module.exports = ProbabilitySim;
+
+/* ─── lab UI init ─────────────────────────────────── */
+ 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'));
+ probSim.onUpdate = _probUpdateUI;
+ }
+ probSim.fit();
+ probSim.reset();
+ probSim.play();
+ }));
+ }
+
+ function probMode(mode, btn) {
+ document.querySelectorAll('.prob-mode-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ if (probSim) { probSim.setParams({ mode }); probSim.reset(); probSim.play(); }
+ }
+
+ function probPreset(mode, trials) {
+ document.querySelectorAll('.prob-mode-btn').forEach(b => {
+ b.classList.toggle('active', b.textContent.toLowerCase().includes(mode === 'coin' ? 'монет' : mode === 'dice2' ? '2 куб' : 'кубик'));
+ });
+ if (probSim) { probSim.setParams({ mode, trials }); probSim.reset(); probSim.play(); }
+ }
+
+ function _probUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('probbar-v1', info.totalTrials);
+ v('probbar-v2', typeof info.maxDeviation === 'number' ? (info.maxDeviation * 100).toFixed(1) + '%' : '—');
+ v('probbar-v3', typeof info.chiSquare === 'number' ? info.chiSquare.toFixed(2) : '—');
+ const modeNames = { coin: 'Монета', dice: 'Кубик', dice2: '2 кубика' };
+ v('probbar-v4', modeNames[info.mode] || info.mode);
+ }
+
+ /* ── bohr atom ── */
+
diff --git a/frontend/js/labs/projectile.js b/frontend/js/labs/projectile.js
index b9cfd9a..aa1be0c 100644
--- a/frontend/js/labs/projectile.js
+++ b/frontend/js/labs/projectile.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════════════════════════════
ProjectileSim v2 — physics simulation
@@ -1061,3 +1061,190 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
ctx.closePath(); ctx.fill();
ctx.restore();
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openProjectile() {
+ 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) {
+ pSim = new ProjectileSim(document.getElementById('proj-canvas'));
+ pSim.onUpdate = _projUpdateUI;
+ pSim.onPlayPause = projPlayPause;
+ }
+ pSim.fit();
+ projParam(); // sync sliders sim
+ pSim.draw();
+ _projUpdateUI(pSim.stats());
+ }));
+ }
+
+ function projPlayPause() {
+ if (!pSim) return;
+ if (pSim.playing) {
+ pSim.pause();
+ } else {
+ pSim.play();
+ }
+ _projSyncPlayBtn();
+ }
+
+ function _projSyncPlayBtn() {
+ /* small topbar button */
+ const tb = document.getElementById('proj-play-btn');
+ /* big launch button */
+ const lb = document.getElementById('proj-launch-main');
+ const lbl = document.getElementById('proj-launch-label');
+ const lic = document.getElementById('proj-launch-icon');
+ if (!pSim) return;
+
+ const tf = pSim._curTFlight();
+ const done = !pSim.playing && pSim.t >= tf && pSim.t > 0;
+ const playing = pSim.playing;
+
+ /* topbar */
+ if (tb) {
+ tb.innerHTML = playing
+ ? ''
+ : '';
+ tb.title = playing ? 'Пауза' : 'Запустить';
+ tb.classList.toggle('active', playing);
+ }
+
+ /* big button */
+ if (lb && lbl && lic) {
+ lb.classList.toggle('paused', playing);
+ lb.classList.toggle('done', done && !playing);
+ if (playing) {
+ lic.innerHTML = '';
+ lbl.textContent = 'Пауза';
+ } else if (done) {
+ lic.innerHTML = '';
+ lbl.textContent = 'Повторить';
+ } else {
+ lic.innerHTML = '';
+ lbl.textContent = 'Запустить';
+ }
+ }
+ }
+
+ function projParam() {
+ const v0 = +document.getElementById('sl-v0').value;
+ const angle = +document.getElementById('sl-angle').value;
+ const h0 = +document.getElementById('sl-h0').value;
+ const g = +document.getElementById('sl-g').value;
+
+ document.getElementById('p-v0').textContent = v0 + ' м/с';
+ document.getElementById('p-angle').textContent = angle + '°';
+ document.getElementById('p-h0').textContent = h0 + ' м';
+ document.getElementById('p-g').textContent = g.toFixed(2) + ' м/с²';
+
+ if (pSim) { pSim.setParams({ v0, angle, h0, g }); _projSyncPlayBtn(); }
+ }
+
+ function projPreset(v0, angle, h0, g) {
+ document.getElementById('sl-v0').value = v0;
+ document.getElementById('sl-angle').value = angle;
+ document.getElementById('sl-h0').value = h0;
+ document.getElementById('sl-g').value = g;
+ projParam();
+ }
+
+ function projToggleDrag(rowEl) {
+ if (!pSim) return;
+ pSim.drag = !pSim.drag;
+ const on = pSim.drag;
+ rowEl.classList.toggle('active', on);
+ const tog = document.getElementById('drag-toggle');
+ tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
+ tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
+ document.getElementById('drag-params').style.display = on ? '' : 'none';
+ document.getElementById('ps-loss-wrap').style.display = on ? '' : 'none';
+ if (on) {
+ const cd = +document.getElementById('sl-cd').value / 100;
+ const mass = +document.getElementById('sl-mass').value;
+ pSim.setParams({ drag: true, Cd: cd, mass });
+ } else {
+ pSim.setParams({ drag: false });
+ }
+ }
+
+ function projCdChange() {
+ const cd = +document.getElementById('sl-cd').value / 100;
+ document.getElementById('p-cd').textContent = cd.toFixed(2);
+ if (pSim) pSim.setParams({ Cd: cd });
+ }
+
+ function projMassChange() {
+ const mass = +document.getElementById('sl-mass').value;
+ document.getElementById('p-mass').textContent = mass + ' кг';
+ if (pSim) pSim.setParams({ mass });
+ }
+
+ function projWindChange() {
+ const wind = +document.getElementById('sl-wind').value;
+ const label = wind === 0 ? '0 м/с' : (wind > 0 ? ' +' : ' ') + Math.abs(wind) + ' м/с';
+ document.getElementById('p-wind').textContent = label;
+ document.getElementById('ps-loss-wrap').style.display = wind !== 0 ? '' : (pSim && pSim.drag ? '' : 'none');
+ if (pSim) { pSim.setParams({ wind }); _projSyncPlayBtn(); }
+ }
+
+ function projToggleBounce(rowEl) {
+ if (!pSim) return;
+ pSim.bounce = !pSim.bounce;
+ const on = pSim.bounce;
+ rowEl.classList.toggle('active', on);
+ const tog = document.getElementById('bounce-toggle');
+ tog.style.background = on ? 'rgba(123,245,164,0.8)' : 'rgba(255,255,255,0.12)';
+ tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
+ document.getElementById('bounce-params').style.display = on ? '' : 'none';
+ const e = +document.getElementById('sl-restitution').value / 100;
+ pSim.setParams({ bounce: on, restitution: e });
+ }
+
+ function projRestitutionChange() {
+ const e = +document.getElementById('sl-restitution').value / 100;
+ document.getElementById('p-restitution').textContent = e.toFixed(2);
+ if (pSim) pSim.setParams({ restitution: e });
+ }
+
+ function projSetSpeed(s, el) {
+ if (pSim) pSim.setSpeed(s);
+ document.querySelectorAll('.proj-speed').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ }
+
+ function projSaveGhost() {
+ if (pSim) pSim.saveGhost();
+ }
+
+ function projClearGhosts() {
+ if (pSim) pSim.clearGhosts();
+ }
+
+ function _projUpdateUI(s) {
+ const fmt = (n, unit) => n < 10000 ? n.toFixed(2) + ' ' + unit : (n/1000).toFixed(2) + ' к' + unit;
+ document.getElementById('ps-range').textContent = fmt(s.range, 'м');
+ document.getElementById('ps-hmax').textContent = fmt(s.hMax, 'м');
+ document.getElementById('ps-tf').textContent = s.tf.toFixed(2) + ' с';
+ document.getElementById('ps-vland').textContent = fmt(s.vLand, 'м/с');
+ document.getElementById('ps-t').textContent = s.t.toFixed(2) + ' с';
+ const laEl = document.getElementById('ps-land-angle');
+ if (laEl) laEl.textContent = s.landAngle > 0.5 ? s.landAngle.toFixed(1) + '°' : '—';
+ if (s.hasMod) {
+ const lossEl = document.getElementById('ps-loss');
+ if (lossEl) {
+ const sign = s.rangeLoss > 0 ? '+' : '';
+ lossEl.textContent = s.rangeLoss !== 0 ? sign + s.rangeLoss + '%' : '0%';
+ lossEl.style.color = s.rangeLoss < 0 ? '#EF476F' : '#7BF5A4';
+ }
+ }
+ _projSyncPlayBtn();
+ }
+
+ /* ── collision ── */
+
diff --git a/frontend/js/labs/quadratic.js b/frontend/js/labs/quadratic.js
index 99ecda2..e70152a 100644
--- a/frontend/js/labs/quadratic.js
+++ b/frontend/js/labs/quadratic.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
QuadraticSim — interactive quadratic equation explorer
y = ax² + bx + c · discriminant, roots, vertex
@@ -432,3 +432,43 @@ class QuadraticSim {
cv.addEventListener('touchend', () => { t0 = null; });
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ 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'));
+ quadSim.onUpdate = _quadUpdateUI;
+ }
+ quadSim.fit();
+ quadSim.draw();
+ quadSim._emit();
+ }));
+ }
+
+ function quadParam(name, val) {
+ const v = parseFloat(val);
+ document.getElementById('quad-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1);
+ if (quadSim) quadSim.setParams({ [name]: v });
+ }
+
+ function quadPreset(a, b, c) {
+ document.getElementById('sl-quad-a').value = a; document.getElementById('quad-a-val').textContent = a;
+ document.getElementById('sl-quad-b').value = b; document.getElementById('quad-b-val').textContent = b;
+ document.getElementById('sl-quad-c').value = c; document.getElementById('quad-c-val').textContent = c;
+ if (quadSim) quadSim.setParams({ a, b, c });
+ }
+
+ function _quadUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('qbar-v1', 'D = ' + info.D);
+ v('qbar-v2', info.roots);
+ v('qbar-v3', info.vertex);
+ v('qbar-v4', info.equation);
+ }
+
+ /* ── normal distribution ── */
diff --git a/frontend/js/labs/reactions.js b/frontend/js/labs/reactions.js
index 0945c40..7f89eba 100644
--- a/frontend/js/labs/reactions.js
+++ b/frontend/js/labs/reactions.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/**
* ReactionSim — Chemical reaction kinetics simulation.
@@ -616,3 +616,272 @@ class ReactionSim {
ctx.closePath();
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openChemistry(mode) {
+ document.getElementById('sim-topbar-title').textContent = 'Химические реакции';
+ _simShow('sim-chemistry');
+ _simShow('ctrl-chemistry');
+ if (mode) _chemMode = mode;
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ chemMode(_chemMode);
+ }));
+ }
+
+ function chemMode(mode, btn) {
+ _chemMode = mode;
+ const MODES = ['kinetics', 'flask', 'redox', 'ionex'];
+ const CANVASES = { kinetics: 'reactions-canvas', flask: 'flask-canvas', redox: 'redox-canvas', ionex: 'ionexchange-canvas' };
+
+ // toggle mode buttons
+ document.querySelectorAll('.chem-mode').forEach(b => b.classList.remove('active'));
+ const mb = document.getElementById('chem-mode-' + mode);
+ if (mb) mb.classList.add('active');
+
+ // toggle panels
+ MODES.forEach(m => {
+ const p = document.getElementById('chem-panel-' + m);
+ if (p) p.style.display = m === mode ? '' : 'none';
+ });
+
+ // toggle canvases
+ Object.entries(CANVASES).forEach(([m, cid]) => {
+ document.getElementById(cid).style.display = m === mode ? 'block' : 'none';
+ });
+
+ // toggle topbar tool groups
+ const modeToCtrl = { kinetics:'kin', flask:'flask', redox:'redox', ionex:'ionex' };
+ ['kin', 'flask', 'redox', 'ionex'].forEach(k => {
+ const el = document.getElementById('ctrl-chem-' + k);
+ if (el) el.style.display = k === modeToCtrl[mode] ? 'contents' : 'none';
+ });
+
+ // stop all sims
+ if (reacSim) reacSim.stop();
+ if (flaskSim) flaskSim.stop();
+ if (rdxSim) rdxSim.stop();
+ if (ioxSim) ioxSim.stop();
+
+ // start the active one
+ if (mode === 'kinetics') {
+ const c = document.getElementById('reactions-canvas');
+ if (!reacSim) { reacSim = new ReactionSim(c); reacSim.onUpdate = _reacUpdateUI; }
+ reacSim.fit(); reacSim.start();
+ _reacUpdateUI(reacSim.info());
+ } else if (mode === 'flask') {
+ const c = document.getElementById('flask-canvas');
+ if (!flaskSim) { flaskSim = new FlaskSim(c); flaskSim.onUpdate = _flaskUpdateUI; }
+ flaskSim.fit(); flaskSim.start();
+ _flaskUpdateUI(flaskSim.info());
+ } else if (mode === 'redox') {
+ const c = document.getElementById('redox-canvas');
+ if (!rdxSim) { rdxSim = new RedoxSim(c); rdxSim.onUpdate = _redoxUpdateUI; }
+ rdxSim.fit(); rdxSim.draw();
+ _redoxUpdateUI(rdxSim.info());
+ } else if (mode === 'ionex') {
+ const c = document.getElementById('ionexchange-canvas');
+ if (!ioxSim) { ioxSim = new IonExSim(c); ioxSim.onUpdate = _ionexUpdateUI; }
+ ioxSim.fit(); ioxSim.draw();
+ _ionexUpdateUI(ioxSim.info());
+ }
+ }
+
+ function chemReset() {
+ if (_chemMode === 'kinetics' && reacSim) reacSim.reset();
+ if (_chemMode === 'flask' && flaskSim) flaskSim.reset();
+ if (_chemMode === 'redox') redoxReset();
+ if (_chemMode === 'ionex') ionexReset();
+ }
+
+ // _openReactions is now handled by _openChemistry + chemMode
+
+ function reacNChange() {
+ const v = +document.getElementById('sl-reacN').value;
+ document.getElementById('reac-N-val').textContent = v;
+ if (reacSim) reacSim.setN(v);
+ }
+
+ function reacTChange() {
+ const raw = +document.getElementById('sl-reacT').value;
+ const t = (raw / 10).toFixed(1);
+ document.getElementById('reac-T-val').textContent = t;
+ if (reacSim) reacSim.setT(+t);
+ }
+
+ function reacEaChange() {
+ const raw = +document.getElementById('sl-reacEa').value;
+ const ea = (raw / 10).toFixed(1);
+ document.getElementById('reac-Ea-val').textContent = ea;
+ if (reacSim) reacSim.setEa(+ea);
+ }
+
+ function reacMode(mode, el) {
+ if (reacSim) reacSim.setMode(mode);
+ document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ }
+
+ function reacPreset(name) {
+ if (!reacSim) return;
+ reacSim.preset(name);
+ // Sync sliders and mode buttons
+ document.getElementById('sl-reacN').value = reacSim.N;
+ document.getElementById('reac-N-val').textContent = reacSim.N;
+ document.getElementById('sl-reacT').value = Math.round(reacSim.T * 10);
+ document.getElementById('reac-T-val').textContent = reacSim.T.toFixed(1);
+ document.getElementById('sl-reacEa').value = Math.round(reacSim.Ea * 10);
+ document.getElementById('reac-Ea-val').textContent = reacSim.Ea.toFixed(1);
+ document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active'));
+ const mBtn = document.getElementById('rmode-' + reacSim.mode);
+ if (mBtn) mBtn.classList.add('active');
+ _reacUpdateUI(reacSim.info());
+ }
+
+ function reacTogglePause() {
+ if (!reacSim) return;
+ reacSim.toggleReaction();
+ const btn = document.getElementById('reac-pause-btn');
+ btn.innerHTML = reacSim.reactionOn ? ' Пауза' : ' Реакции';
+ }
+
+ function _reacUpdateUI(info) {
+ if (!info) return;
+ document.getElementById('chbar-l1').textContent = 'A молекул';
+ document.getElementById('chbar-v1').textContent = info.nA;
+ document.getElementById('chbar-l2').textContent = 'B молекул';
+ document.getElementById('chbar-v2').textContent = info.nB;
+ document.getElementById('chbar-l3').textContent = 'C продукт';
+ document.getElementById('chbar-v3').textContent = info.nC;
+ document.getElementById('chbar-l4').textContent = 'Реакций';
+ document.getElementById('chbar-v4').textContent = info.reactions;
+ document.getElementById('chbar-l5').textContent = 'Скорость';
+ document.getElementById('chbar-v5').textContent = info.rate > 0
+ ? (info.rate * 30).toFixed(1) + '/с' : '—';
+ }
+
+ // _openFlask is now handled by _openChemistry('flask')
+
+ function flaskMetal(type, el) {
+ if (flaskSim) { flaskSim.setMetal(type); flaskSim.reset(); }
+ document.querySelectorAll('.flask-metal-btn').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ }
+
+ function flaskAcid(type, el) {
+ if (flaskSim) flaskSim.setAcid(type);
+ document.querySelectorAll('.flask-acid-btn').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ }
+
+ function flaskConcChange() {
+ const v = +document.getElementById('sl-flask-conc').value;
+ document.getElementById('flask-conc-val').textContent = v + '%';
+ if (flaskSim) flaskSim.setConc(v / 100);
+ }
+
+ function flaskTempChange() {
+ const v = +document.getElementById('sl-flask-temp').value;
+ document.getElementById('flask-temp-val').textContent = v + '°C';
+ if (flaskSim) flaskSim.setEnvTemp(v);
+ }
+
+ function flaskToggleFlame() {
+ if (!flaskSim) return;
+ flaskSim.toggleFlame();
+ const active = flaskSim._flameOn;
+ document.getElementById('flask-flame-btn').style.opacity = active ? '1' : '0.5';
+ document.getElementById('flask-flame-panel').style.opacity = active ? '1' : '0.5';
+ document.getElementById('flask-flame-panel').style.background = active ? 'rgba(239,71,111,0.22)' : '';
+ }
+
+ function flaskTogglePause() {
+ if (!flaskSim) return;
+ flaskSim.togglePause();
+ document.getElementById('flask-pause-btn').innerHTML = flaskSim._paused ? '' : '';
+ }
+
+ function _flaskUpdateUI(info) {
+ if (!info) return;
+ document.getElementById('chbar-l1').textContent = 'Металл';
+ document.getElementById('chbar-v1').textContent = info.metal;
+ document.getElementById('chbar-l2').textContent = 'Масса';
+ document.getElementById('chbar-v2').textContent = info.mass + ' г';
+ document.getElementById('chbar-l3').textContent = 'T (°C)';
+ document.getElementById('chbar-v3').textContent = info.temp + '°C';
+ document.getElementById('chbar-l4').textContent = 'pH';
+ document.getElementById('chbar-v4').textContent = info.pH;
+ document.getElementById('chbar-l5').textContent = 'H₂ (%)';
+ document.getElementById('chbar-v5').textContent = info.h2pct + '%';
+ }
+
+ // _openRedox is now handled by _openChemistry('redox')
+
+ function redoxRxn(id, el) {
+ document.querySelectorAll('.redox-rxn-btn').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ if (rdxSim) { rdxSim.setReaction(id); }
+ }
+
+ function redoxStart() {
+ if (rdxSim) rdxSim.start();
+ }
+
+ function redoxReset() {
+ if (rdxSim) rdxSim.reset();
+ }
+
+ function _redoxUpdateUI(info) {
+ if (!info) return;
+ const phaseMap = { idle: 'ожидание', mixing: 'смешивание', reacting: 'реакция', done: 'завершена' };
+ document.getElementById('chbar-l1').textContent = 'Реакция';
+ document.getElementById('chbar-v1').textContent = info.rxn || '—';
+ document.getElementById('chbar-l2').textContent = 'Фаза';
+ document.getElementById('chbar-v2').textContent = phaseMap[info.phase] || info.phase;
+ document.getElementById('chbar-l3').textContent = 'Прогресс';
+ document.getElementById('chbar-v3').textContent = info.phase === 'done' ? '100%' : info.prog + '%';
+ document.getElementById('chbar-l4').textContent = 'Электронов';
+ document.getElementById('chbar-v4').textContent = info.e + ' e⁻';
+ document.getElementById('chbar-l5').textContent = 'Тип';
+ document.getElementById('chbar-v5').innerHTML = info.phase === 'done' ? '' : '—';
+ }
+
+ // _openIonExchange is now handled by _openChemistry('ionex')
+
+ function ionexRxn(id, el) {
+ document.querySelectorAll('.ionex-rxn-btn').forEach(b => b.classList.remove('active'));
+ if (el) el.classList.add('active');
+ if (ioxSim) { ioxSim.setReaction(id); }
+ }
+
+ function ionexStart() {
+ if (ioxSim) ioxSim.start();
+ }
+
+ function ionexReset() {
+ if (ioxSim) ioxSim.reset();
+ }
+
+ function _ionexUpdateUI(info) {
+ if (!info) return;
+ const phaseMap = { idle: 'ожидание', mixing: 'смешивание', pairing: 'реакция', done: 'завершена' };
+ const rxn = IonExSim.RXN[ioxSim.rxnId];
+ document.getElementById('chbar-l1').textContent = 'Реакция';
+ document.getElementById('chbar-v1').textContent = info.rxn || '—';
+ document.getElementById('chbar-l2').textContent = 'Фаза';
+ document.getElementById('chbar-v2').textContent = phaseMap[info.phase] || info.phase;
+ document.getElementById('chbar-l3').textContent = 'Прогресс';
+ document.getElementById('chbar-v3').textContent = info.phase === 'done' ? '100%' : info.prog + '%';
+ document.getElementById('chbar-l4').textContent = 'Осадок';
+ document.getElementById('chbar-v4').textContent = info.precip > 0 ? info.precip + ' ч.' : '—';
+ document.getElementById('chbar-l5').textContent = 'Продукт';
+ document.getElementById('chbar-v5').textContent = rxn ? (rxn.sign || '—') : '—';
+ }
+
+ /* ════════════════════════════════
+ ЗАКОНЫ НЬЮТОНА
+ ════════════════════════════════ */
+
+ /* ══════════════════════════════
+ DYNAMICS (unified Newton + Sandbox)
+ ══════════════════════════════ */
+
diff --git a/frontend/js/labs/refraction.js b/frontend/js/labs/refraction.js
index 77babb3..f503c43 100644
--- a/frontend/js/labs/refraction.js
+++ b/frontend/js/labs/refraction.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
RefractionSim — light refraction simulation (Snell's law)
n₁·sin(θ₁) = n₂·sin(θ₂)
@@ -496,3 +496,46 @@ class RefractionSim {
});
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ 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'));
+ refrSim.onUpdate = _refrUpdateUI;
+ }
+ refrSim.fit();
+ refrSim.draw();
+ refrSim._emit();
+ }));
+ }
+
+ function refrParam(name, val) {
+ const v = parseFloat(val);
+ const ids = { n1: 'refr-n1-val', n2: 'refr-n2-val', angle: 'refr-angle-val' };
+ const el = document.getElementById(ids[name]);
+ if (el) el.textContent = name === 'angle' ? v : v.toFixed(2);
+ if (refrSim) refrSim.setParams({ [name]: v });
+ }
+
+ function refrPreset(n1, n2, angle) {
+ document.getElementById('sl-refr-n1').value = n1; document.getElementById('refr-n1-val').textContent = n1.toFixed(2);
+ document.getElementById('sl-refr-n2').value = n2; document.getElementById('refr-n2-val').textContent = n2.toFixed(2);
+ document.getElementById('sl-refr-angle').value = angle; document.getElementById('refr-angle-val').textContent = angle;
+ if (refrSim) refrSim.setParams({ n1, n2, angle });
+ }
+
+ function _refrUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('refrbar-v1', info.angle1 + '°');
+ v('refrbar-v2', info.isTIR ? 'ПВО' : info.angle2 + '°');
+ v('refrbar-v3', info.criticalAngle !== null ? info.criticalAngle + '°' : '—');
+ v('refrbar-v4', info.isTIR ? 'Да' : 'Нет');
+ }
+
+ /* ── probability ── */
+
diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js
index 43fa899..d336e93 100644
--- a/frontend/js/labs/stereo.js
+++ b/frontend/js/labs/stereo.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════════════════════
StereoSim — 3D Stereometry (Three.js)
@@ -3028,3 +3028,368 @@ class StereoSim {
this.renderer.render(this.scene, this.camera);
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var stereoSim = null;
+
+ // which params are relevant per figure type
+ const STEREO_PARAM_MAP = {
+ cube: ['a'],
+ parallelepiped: ['a','b','c'],
+ pyramid: ['a','n','h'],
+ tetrahedron: ['a'],
+ cylinder: ['r','h'],
+ cone: ['r','h'],
+ trunccone: ['R','r','h'],
+ sphere: ['r'],
+ prism: ['a','n','h'],
+ truncpyramid: ['a','b','n','h'],
+ octahedron: ['a'],
+ icosahedron: ['a'],
+ dodecahedron: ['a'],
+ };
+
+ function _openStereo() {
+ document.getElementById('sim-topbar-title').textContent = 'Стереометрия 3D';
+ _simShow('sim-stereo');
+ document.getElementById('stereo-stats').style.display = '';
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (!stereoSim) {
+ stereoSim = new StereoSim(document.getElementById('stereo-container'));
+ stereoSim.onUpdate = _stereoUpdateUI;
+ } else {
+ stereoSim.fit();
+ stereoSim.play();
+ }
+ _stereoShowParams(stereoSim.figureType || 'cube');
+ _stereoUpdateUI(stereoSim.info());
+ _stereoUpdateFormulas();
+ }));
+ }
+
+ function setStereoFigure(type, btn) {
+ document.querySelectorAll('.stereo-fig-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ if (stereoSim) {
+ stereoSim.setFigure(type);
+ _stereoShowParams(type);
+ _stereoUpdateFormulas();
+ // reset toggles and tool buttons
+ document.getElementById('sect-toggle').classList.remove('active');
+ document.getElementById('stereo-unfold-btn').classList.remove('active');
+ document.getElementById('stereo-measure-btn').classList.remove('active');
+ // reset element toggles
+ ['stg-height','stg-apothem','stg-diagonals','stg-midpoints','stg-inscribed','stg-circumscribed','stg-edgelengths'].forEach(id => {
+ document.getElementById(id)?.classList.remove('on');
+ });
+ _stereoDeactivateTools();
+ }
+ }
+
+ function _stereoShowParams(type) {
+ const show = STEREO_PARAM_MAP[type] || ['a'];
+ ['a','b','c','h','r','R','n'].forEach(k => {
+ document.getElementById('sp-' + k + '-row').style.display = show.includes(k) ? '' : 'none';
+ });
+ }
+
+ function stereoParamChange(key, val) {
+ val = +val;
+ const label = document.getElementById('sp-' + key + '-val');
+ if (label) label.textContent = val;
+ if (stereoSim) {
+ stereoSim.setParam(key, val);
+ _stereoUpdateFormulas();
+ }
+ }
+
+ function stereoOpacityChange(val) {
+ val = +val;
+ document.getElementById('sp-opacity-val').textContent = val.toFixed(2);
+ if (stereoSim) stereoSim.setOpacity(val);
+ }
+
+ // legacy (used nowhere now but kept for safety)
+ function stereoToggle(layer, btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (!stereoSim) return;
+ if (layer === 'edges') stereoSim.toggleEdges(on);
+ if (layer === 'vertices') stereoSim.toggleVertices(on);
+ if (layer === 'labels') stereoSim.toggleLabels(on);
+ if (layer === 'axes') stereoSim.toggleAxes(on);
+ if (layer === 'grid') stereoSim.toggleGrid(on);
+ }
+
+ // new toggle-row style
+ function stereoToggleSt(layer, toggle) {
+ const on = !toggle.classList.contains('on');
+ toggle.classList.toggle('on', on);
+ if (!stereoSim) return;
+ if (layer === 'edges') stereoSim.toggleEdges(on);
+ if (layer === 'vertices') stereoSim.toggleVertices(on);
+ if (layer === 'labels') stereoSim.toggleLabels(on);
+ if (layer === 'axes') stereoSim.toggleAxes(on);
+ if (layer === 'grid') stereoSim.toggleGrid(on);
+ }
+
+ function stereoToggleElem(layer, toggle) {
+ const on = !toggle.classList.contains('on');
+ toggle.classList.toggle('on', on);
+ if (!stereoSim) return;
+ if (layer === 'height') stereoSim.toggleHeight(on);
+ if (layer === 'apothem') stereoSim.toggleApothem(on);
+ if (layer === 'diagonals') stereoSim.toggleDiagonals(on);
+ if (layer === 'midpoints') stereoSim.toggleMidpoints(on);
+ if (layer === 'inscribed') stereoSim.toggleInscribed(on);
+ if (layer === 'circumscribed') stereoSim.toggleCircumscribed(on);
+ if (layer === 'edgelengths') stereoSim.toggleEdgeLengths(on);
+ }
+
+ // n-stepper for prism/pyramid
+ function stereoNChange(delta) {
+ if (!stereoSim) return;
+ const cur = stereoSim.params.n || 4;
+ const nv = Math.max(3, Math.min(12, cur + delta));
+ document.getElementById('sp-n-val').textContent = nv;
+ stereoSim.setParam('n', nv);
+ _stereoUpdateFormulas();
+ }
+
+ function stereoSectionToggle(btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleSection(on);
+ }
+
+ function stereoSectionType(t, btn) {
+ document.querySelectorAll('.stereo-sect-type').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ // Show/hide angle slider for diagonal
+ document.getElementById('sp-angle-row').style.display = t === 'diagonal' ? '' : 'none';
+ if (stereoSim) stereoSim.setSectionType(t);
+ }
+
+ function stereoSectionHeight(val) {
+ document.getElementById('sp-sect-val').textContent = val + '%';
+ if (stereoSim) stereoSim.setSectionHeight(+val / 100);
+ }
+
+ function stereoSectionAngle(val) {
+ document.getElementById('sp-angle-val').textContent = val + '%';
+ if (stereoSim) stereoSim.setSectionAngle(+val / 100);
+ }
+
+ function stereoUnfold(btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleUnfold(on);
+ }
+
+ function _stereoDeactivateTools() {
+ ['stereo-measure-btn','stereo-point-btn','stereo-connect-btn',
+ 'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn',
+ 'stereo-mark-tick-btn','stereo-mark-par-btn',
+ 'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn'].forEach(id => {
+ document.getElementById(id)?.classList.remove('active');
+ });
+ if (stereoSim) {
+ stereoSim.toggleMeasure(false);
+ stereoSim.togglePointMode(false);
+ stereoSim.toggleConnectMode(false);
+ stereoSim.setAngleMode(null);
+ stereoSim.setMarkMode(null);
+ stereoSim.setDeriveMode(null);
+ }
+ const hint = document.getElementById('angle-hint');
+ if (hint) hint.textContent = '';
+ }
+
+ function stereoMeasure(btn) {
+ const on = !btn.classList.contains('active');
+ _stereoDeactivateTools();
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleMeasure(on);
+ }
+
+ function stereoMeasureUndo() {
+ if (stereoSim) stereoSim.removeLastMeasurement();
+ }
+
+ function stereoMeasureClear() {
+ if (stereoSim) stereoSim.clearMeasurements();
+ }
+
+ function stereoToggleHeight(btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleHeight(on);
+ }
+
+ function stereoToggleApothem(btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleApothem(on);
+ }
+
+ function stereoToggleDiag(btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleDiagonals(on);
+ }
+
+ function stereoToggleMid(btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleMidpoints(on);
+ }
+
+ const ANGLE_HINTS = {
+ edge: 'Кликните 3 точки: A, B (вершина угла), C',
+ linePlane: 'Кликните 2 точки (прямая), затем — грань',
+ dihedral: 'Кликните 2 точки общего ребра двух граней',
+ pointPlane: 'Кликните точку, затем — грань',
+ skewLines: 'P1, P2 (прямая 1) → P3, P4 (прямая 2): угол и расстояние',
+ };
+
+ function stereoAngleMode(mode, btn) {
+ const on = !btn.classList.contains('active');
+ _stereoDeactivateTools();
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.setAngleMode(on ? mode : null);
+ const hint = document.getElementById('angle-hint');
+ if (hint) hint.textContent = on ? ANGLE_HINTS[mode] : '';
+ }
+
+ function stereoAngleClear() {
+ _stereoDeactivateTools();
+ if (stereoSim) {
+ stereoSim.setAngleMode(null);
+ stereoSim._clearGroup(stereoSim._angleGroup);
+ }
+ }
+
+ /* ── Edge marks ── */
+ function stereoMarkMode(mode, btn) {
+ const on = !btn.classList.contains('active');
+ _stereoDeactivateTools();
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.setMarkMode(on ? mode : null);
+ }
+
+ function stereoMarkClear() {
+ _stereoDeactivateTools();
+ if (stereoSim) stereoSim.clearMarks();
+ }
+
+ function stereoToggleEdgeLengths(btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleEdgeLengths(on);
+ }
+
+ /* ── Derived points ── */
+ function stereoDerive(mode, btn) {
+ const on = !btn.classList.contains('active');
+ _stereoDeactivateTools();
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.setDeriveMode(on ? mode : null);
+ }
+
+ function stereoDeriveUndo() {
+ if (stereoSim) stereoSim.removeLastDerived();
+ }
+
+ function stereoDeriveClear() {
+ _stereoDeactivateTools();
+ if (stereoSim) stereoSim.clearDerived();
+ }
+
+ function stereoPointMode(btn) {
+ const on = !btn.classList.contains('active');
+ _stereoDeactivateTools();
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.togglePointMode(on);
+ }
+
+ function stereoConnectMode(btn) {
+ const on = !btn.classList.contains('active');
+ _stereoDeactivateTools();
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleConnectMode(on);
+ }
+
+ function stereoUndoPoint() {
+ if (stereoSim) stereoSim.removeLastPoint();
+ }
+
+ function stereoClearPoints() {
+ if (stereoSim) stereoSim.clearCustomPoints();
+ _stereoUpdatePointsInfo();
+ }
+
+ function stereoInscribed(btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleInscribed(on);
+ }
+
+ function stereoCircumscribed(btn) {
+ const on = !btn.classList.contains('active');
+ btn.classList.toggle('active', on);
+ if (stereoSim) stereoSim.toggleCircumscribed(on);
+ }
+
+ function _stereoUpdateFormulas() {
+ if (!stereoSim) return;
+ const f = stereoSim.getFormulas();
+ const el = document.getElementById('stereo-formulas');
+ if (!f || !f.formulas) { el.innerHTML = ''; return; }
+ const colors = ['#7BF5A4','#60a5fa','#c4b5fd','#fbbf24','#f9a8d4','#F59E0B','#EF476F'];
+ el.innerHTML = f.formulas.map((s, i) =>
+ '' + s + '
'
+ ).join('');
+ }
+
+ function _stereoUpdateUI(info) {
+ if (!info) return;
+ document.getElementById('stbar-vol').textContent = info.V !== undefined ? info.V.toFixed(2) : '—';
+ document.getElementById('stbar-area').textContent = info.S !== undefined ? info.S.toFixed(2) : '—';
+ document.getElementById('stbar-side').textContent = info.S_side !== undefined ? info.S_side.toFixed(2) : '—';
+ document.getElementById('stbar-h').textContent = info.h !== undefined ? info.h.toFixed(2) : '—';
+ document.getElementById('stbar-d').textContent = info.d !== undefined && info.d > 0 ? info.d.toFixed(2) : '—';
+
+ // Section area
+ const sectEl = document.getElementById('sect-area-display');
+ if (info.sectionArea && info.sectionArea > 0) {
+ sectEl.style.display = '';
+ sectEl.textContent = 'S сечения = ' + info.sectionArea.toFixed(2);
+ } else {
+ sectEl.style.display = 'none';
+ }
+
+ // Inscribed / Circumscribed radius info
+ const rInfo = document.getElementById('sphere-radius-info');
+ if (rInfo) {
+ const parts = [];
+ if (info.inscribedR != null) parts.push('r_вп = ' + info.inscribedR.toFixed(2));
+ if (info.circumscribedR != null) parts.push('R_оп = ' + info.circumscribedR.toFixed(2));
+ rInfo.textContent = parts.join(' · ');
+ rInfo.style.display = parts.length ? '' : 'none';
+ }
+
+ // Points info
+ _stereoUpdatePointsInfo(info);
+ }
+
+ function _stereoUpdatePointsInfo(info) {
+ const el = document.getElementById('points-info');
+ if (!el) return;
+ if (!info) info = stereoSim?.info();
+ if (!info) { el.textContent = ''; return; }
+ let txt = '';
+ if (info.customPoints > 0) txt += `Точек: ${info.customPoints}`;
+ if (info.connections > 0) txt += ` · Линий: ${info.connections}`;
+ el.textContent = txt;
+ }
+
diff --git a/frontend/js/labs/thinlens.js b/frontend/js/labs/thinlens.js
index b384099..3986102 100644
--- a/frontend/js/labs/thinlens.js
+++ b/frontend/js/labs/thinlens.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
ThinLensSim — thin lens ray tracing simulation
1/f = 1/d + 1/d' M = -d'/d
@@ -444,3 +444,46 @@ class ThinLensSim {
});
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ 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'));
+ lensSim.onUpdate = _lensUpdateUI;
+ }
+ lensSim.fit();
+ lensSim.draw();
+ lensSim._emit();
+ }));
+ }
+
+ function lensParam(name, val) {
+ const v = parseFloat(val);
+ const ids = { f: 'lens-f-val', d: 'lens-d-val', h: 'lens-h-val' };
+ const el = document.getElementById(ids[name]);
+ if (el) el.textContent = v;
+ if (lensSim) lensSim.setParams({ [name]: v });
+ }
+
+ function lensPreset(f, d, h) {
+ document.getElementById('sl-lens-f').value = f; document.getElementById('lens-f-val').textContent = f;
+ document.getElementById('sl-lens-d').value = d; document.getElementById('lens-d-val').textContent = d;
+ document.getElementById('sl-lens-h').value = h; document.getElementById('lens-h-val').textContent = h;
+ if (lensSim) lensSim.setParams({ f, d, h });
+ }
+
+ function _lensUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('lensbar-v1', info.f);
+ v('lensbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
+ v('lensbar-v3', info.M === Infinity ? '∞' : info.M);
+ v('lensbar-v4', info.imageType);
+ }
+
+ /* ── mirrors ── */
+
diff --git a/frontend/js/labs/titration.js b/frontend/js/labs/titration.js
index d97be34..95655fa 100644
--- a/frontend/js/labs/titration.js
+++ b/frontend/js/labs/titration.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════════════
TitrationSim — acid-base titration simulation
Strong acid (HCl) / weak acid (CH₃COOH) + strong base (NaOH)
@@ -656,3 +656,55 @@ class TitrationSim {
}
if (typeof module !== 'undefined') module.exports = TitrationSim;
+
+/* ─── lab UI init ─────────────────────────────────── */
+ 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'));
+ titrSim.onUpdate = _titrUpdateUI;
+ }
+ titrSim.fit();
+ titrSim.reset();
+ titrSim.play();
+ }));
+ }
+
+ function titrParam(name, val) {
+ const v = parseFloat(val);
+ const ids = { acidConc: 'titr-ac-val', baseConc: 'titr-bc-val', acidVol: 'titr-vol-val' };
+ const el = document.getElementById(ids[name]);
+ if (el) el.textContent = name === 'acidVol' ? v : v.toFixed(2);
+ if (titrSim) titrSim.setParams({ [name]: v });
+ }
+
+ function titrIndicator(name, btn) {
+ document.querySelectorAll('.titr-ind-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ if (titrSim) titrSim.setParams({ indicator: name });
+ }
+
+ function titrPreset(name) {
+ if (titrSim) { titrSim.preset(name); titrSim.play(); }
+ const defs = { strong_strong: [0.1,0.1,50], weak_strong: [0.1,0.1,50], concentrated: [0.5,0.5,25] };
+ const d = defs[name] || defs.strong_strong;
+ document.getElementById('sl-titr-ac').value = d[0]; document.getElementById('titr-ac-val').textContent = d[0].toFixed(2);
+ document.getElementById('sl-titr-bc').value = d[1]; document.getElementById('titr-bc-val').textContent = d[1].toFixed(2);
+ document.getElementById('sl-titr-vol').value = d[2]; document.getElementById('titr-vol-val').textContent = d[2];
+ }
+
+ function _titrUpdateUI(info) {
+ const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
+ v('titrbar-v1', info.pH);
+ v('titrbar-v2', info.baseAdded + ' мл');
+ v('titrbar-v3', info.eqPoint + ' мл');
+ const indNames = { phenolphthalein: 'Фенолф.', methyl_orange: 'Метилор.', litmus: 'Лакмус' };
+ v('titrbar-v4', indNames[info.indicator] || info.indicator);
+ }
+
+ /* ── refraction ── */
+
diff --git a/frontend/js/labs/triangle.js b/frontend/js/labs/triangle.js
index 574aa9c..82a34ad 100644
--- a/frontend/js/labs/triangle.js
+++ b/frontend/js/labs/triangle.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ══════════════════════════════════════════════════════
TriangleSim — interactive triangle geometry simulation
Draggable vertices A / B / C, toggleable layers:
@@ -959,3 +959,93 @@ class TriangleSim {
ctx.restore();
}
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openTriangle() {
+ document.getElementById('sim-topbar-title').textContent = 'Геометрия треугольника';
+ _simShow('sim-tri');
+ _simShow('ctrl-tri');
+
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (!tSim) {
+ tSim = new TriangleSim(document.getElementById('tri-canvas'));
+ tSim.onUpdate = _triUpdateUI;
+ }
+ tSim.fit();
+ tSim.draw();
+ _triUpdateUI(tSim.stats());
+ }));
+ }
+
+ function triToggle(layer, rowEl) {
+ if (!tSim) return;
+ tSim.toggleLayer(layer);
+ rowEl.classList.toggle('active', tSim.layers[layer]);
+ }
+
+ function _triUpdateUI(s) {
+ const f2 = v => v.toFixed(2);
+ const deg = v => v.toFixed(1) + '°';
+ const unit = v => f2(v) + ' ед';
+
+ // panel
+ document.getElementById('ts-a').textContent = unit(s.a);
+ document.getElementById('ts-b').textContent = unit(s.b);
+ document.getElementById('ts-c').textContent = unit(s.c);
+ document.getElementById('ts-A').textContent = deg(s.A);
+ document.getElementById('ts-B').textContent = deg(s.B);
+ document.getElementById('ts-C').textContent = deg(s.C);
+ document.getElementById('ts-S').textContent = f2(s.S) + ' ед²';
+ document.getElementById('ts-P').textContent = unit(s.perim);
+ document.getElementById('ts-R').textContent = unit(s.R);
+ document.getElementById('ts-r').textContent = unit(s.r);
+ document.getElementById('ts-type').textContent = s.type;
+
+ // stats bar
+ document.getElementById('tbar-a').textContent = unit(s.a);
+ document.getElementById('tbar-b').textContent = unit(s.b);
+ document.getElementById('tbar-c').textContent = unit(s.c);
+ document.getElementById('tbar-S').textContent = f2(s.S) + ' ед²';
+ document.getElementById('tbar-P').textContent = unit(s.perim);
+ document.getElementById('tbar-Rr').textContent = f2(s.R) + ' / ' + f2(s.r);
+ }
+
+ /* ── geometry (planimetry) ── */
+
+ const _GEO_HINTS = {
+ select: 'Клик — выбрать объект, перетащи точку для перемещения',
+ point: 'Клик — поставить точку',
+ segment: 'Кликни 2 точки для отрезка',
+ line: 'Кликни 2 точки для прямой',
+ ray: 'Кликни: начало, затем направление',
+ circle: 'Клик — центр; второй клик — радиус',
+ triangle: 'Кликни 3 точки для треугольника',
+ quad: 'Кликни 4 точки для четырёхугольника',
+ polygon: 'Кликай точки; двойной клик или Enter — завершить',
+ midpoint: 'Кликни 2 точки — получи середину отрезка',
+ perpbisect: 'Кликни 2 точки — получи серединный перпендикуляр',
+ anglebisect: 'Кликни: точку A, затем вершину угла, затем точку B',
+ parallel: 'Сначала кликни на прямую/отрезок, затем на точку',
+ perpendicular:'Сначала кликни на прямую/отрезок, затем на точку',
+ intersect: 'Кликни на первую прямую, затем на вторую',
+ foot: 'Сначала кликни на прямую/отрезок',
+ circumcircle: 'Кликни 3 точки треугольника — получи описанную окружность',
+ incircle: 'Кликни 3 точки треугольника — получи вписанную окружность',
+ reflect: 'Сначала кликни на ось симметрии (прямую/отрезок)',
+ ngon: 'Клик — центр правильного многоугольника; второй клик — вершина',
+ tangent: 'Кликни на окружность — построим касательные',
+ translate: 'Кликни начало вектора A',
+ tick: 'Кликни на отрезок или сторону — добавить штрих (1–3; ещё раз — убрать)',
+ arcmark: 'Кликни на вершину полигона — добавить дугу (1–3; ещё раз — убрать)',
+ parallelmark: 'Кликни на отрезок или сторону — добавить метку параллельности (1–2; ещё раз — убрать)',
+ altitude: 'Кликни на вершину треугольника — построим высоту из неё',
+ median: 'Кликни на вершину треугольника — построим медиану из неё',
+ centroid: 'Кликни на треугольник или внутри него — построим все 3 медианы и центроид G',
+ orthocenter: 'Кликни на треугольник или внутри него — построим все 3 высоты и ортоцентр H',
+ thales: 'Кликни центр подобия O (начало лучей)',
+ midline: 'Кликни вершину A треугольника',
+ parallelogram:'Кликни вершину A параллелограмма',
+ diagonal: 'Кликни внутри четырёхугольника — построим диагонали',
+ scale: 'Кликни центр подобия O',
+ };
+
diff --git a/frontend/js/labs/trigcircle.js b/frontend/js/labs/trigcircle.js
index c51b339..86151c1 100644
--- a/frontend/js/labs/trigcircle.js
+++ b/frontend/js/labs/trigcircle.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════════════════════════════════
TrigCircleSim — premium interactive unit-circle + graph visualisation
@@ -967,3 +967,83 @@ class TrigCircleSim {
}
if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
+
+/* ─── lab UI init ─────────────────────────────────── */
+ var trigSim = null;
+
+ function _openTrigCircle() {
+ document.getElementById('sim-topbar-title').textContent = 'Тригонометрическая окружность';
+ _simShow('sim-trigcircle');
+ _simShow('ctrl-trigcircle');
+
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (!trigSim) {
+ trigSim = new TrigCircleSim(document.getElementById('trigcircle-canvas'));
+ trigSim.onUpdate = _trigUpdateUI;
+ }
+ trigSim.fit();
+ trigSim.start();
+ _trigUpdateUI(trigSim.stats());
+ }));
+ }
+
+ function trigToggle(layer, rowEl) {
+ if (!trigSim) return;
+ const isActive = rowEl.classList.toggle('active');
+ trigSim.toggleLayer(layer, isActive);
+ }
+
+ function trigSetGraphFn(fn, el) {
+ if (!trigSim) return;
+ document.querySelectorAll('.trig-fn-btn').forEach(b => b.classList.remove('active'));
+ el.classList.add('active');
+ trigSim.setGraphFn(fn);
+ }
+
+ function trigGoTo(rad) {
+ if (!trigSim) return;
+ trigSim.goToAngle(rad);
+ }
+
+ function trigReset() {
+ if (!trigSim) return;
+ trigSim.setAngle(Math.PI / 4);
+ }
+
+ function _trigUpdateUI(s) {
+ const _f = v => {
+ if (v === undefined) return '—';
+ const a = Math.abs(v), sg = v < 0 ? '−' : '';
+ if (a < 5e-4) return '0';
+ if (Math.abs(a - 0.5) < 1e-3) return sg + '½';
+ if (Math.abs(a - 1) < 1e-3) return sg + '1';
+ if (Math.abs(a - Math.SQRT2/2) < 1e-3) return sg + '√2/2';
+ if (Math.abs(a - Math.sqrt(3)/2) < 1e-3) return sg + '√3/2';
+ if (Math.abs(a - Math.sqrt(3)/3) < 1e-3) return sg + '√3/3';
+ if (Math.abs(a - Math.sqrt(3)) < 1e-3) return sg + '√3';
+ return v.toFixed(4);
+ };
+ const degStr = s.deg.toFixed(1) + '°';
+
+ // Panel values (nice fractions)
+ document.getElementById('trig-v-sin').textContent = _f(s.sin);
+ document.getElementById('trig-v-cos').textContent = _f(s.cos);
+ document.getElementById('trig-v-tan').textContent = _f(s.tan);
+ document.getElementById('trig-v-cot').textContent = _f(s.cot);
+
+ // Angle badge
+ document.getElementById('trig-angle-badge').innerHTML =
+ `${degStr} = ${s.radLabel}
${s.angle.toFixed(4)} рад`;
+
+ // Stats bar (nice fractions)
+ document.getElementById('trigbar-angle').textContent = degStr;
+ document.getElementById('trigbar-sin').textContent = _f(s.sin);
+ document.getElementById('trigbar-cos').textContent = _f(s.cos);
+ document.getElementById('trigbar-tan').textContent = _f(s.tan);
+ document.getElementById('trigbar-cot').textContent = _f(s.cot);
+ document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
+ }
+
+ /* ── KaTeX live preview ── */
+
+ /** Convert user ascii expression LaTeX string for KaTeX preview */
diff --git a/frontend/js/labs/waves.js b/frontend/js/labs/waves.js
index c2be230..1e597e0 100644
--- a/frontend/js/labs/waves.js
+++ b/frontend/js/labs/waves.js
@@ -1,4 +1,4 @@
-'use strict';
+'use strict';
/* ═══════════════════════════════════════════
WavesSim v2 — Волны и звук
Modes: transverse | longitudinal | superposition | standing
@@ -454,3 +454,94 @@ class WavesSim {
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
}
+
+/* ─── lab UI init ─────────────────────────────────── */
+ function _openWaves() {
+ 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'));
+ wavesSim.onUpdate = _wavesUpdateUI;
+ }
+ wavesSim.fit();
+ wavesSim.reset();
+ wavesSim.play();
+ _wavesUpdateUI(wavesSim.info());
+ }));
+ }
+
+ function wavesMode(mode, btn) {
+ document.querySelectorAll('.wave-mode-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ document.getElementById('waves-w2-section').style.display = mode === 'superposition' ? '' : 'none';
+ document.getElementById('waves-n-section').style.display = mode === 'standing' ? '' : 'none';
+ if (wavesSim) wavesSim.setMode(mode);
+ }
+
+ function wavesParam(name, val) {
+ const v = parseFloat(val);
+ const el = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; };
+ if (name === 'A1') el('waves-A1-val', v);
+ if (name === 'f1') el('waves-f1-val', v.toFixed(1) + ' Гц');
+ if (name === 'phi1') el('waves-phi1-val', v.toFixed(1));
+ if (name === 'A2') el('waves-A2-val', v);
+ if (name === 'f2') el('waves-f2-val', v.toFixed(1) + ' Гц');
+ if (name === 'phi2') el('waves-phi2-val', v.toFixed(1));
+ if (name === 'speed') el('waves-speed-val', '\u00d7' + v.toFixed(1));
+ if (wavesSim) wavesSim.setParams({ [name]: v });
+ }
+
+ function wavesN(n, btn) {
+ document.querySelectorAll('.wave-n-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ if (wavesSim) wavesSim.setParams({ n });
+ }
+
+ function wavesPreset(name) {
+ const presets = {
+ constructive: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.0, phi2: 0 },
+ destructive: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.0, phi2: 3.14 },
+ beats: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.3, phi2: 0 },
+ };
+ const p = presets[name]; if (!p) return;
+ document.getElementById('sl-waves-A1').value = p.A1;
+ document.getElementById('sl-waves-f1').value = p.f1;
+ document.getElementById('sl-waves-phi1').value = p.phi1;
+ document.getElementById('sl-waves-A2').value = p.A2;
+ document.getElementById('sl-waves-f2').value = p.f2;
+ document.getElementById('sl-waves-phi2').value = p.phi2;
+ document.getElementById('waves-A1-val').textContent = p.A1;
+ document.getElementById('waves-f1-val').textContent = p.f1.toFixed(1) + ' Гц';
+ document.getElementById('waves-phi1-val').textContent = p.phi1.toFixed(1);
+ document.getElementById('waves-A2-val').textContent = p.A2;
+ document.getElementById('waves-f2-val').textContent = p.f2.toFixed(1) + ' Гц';
+ document.getElementById('waves-phi2-val').textContent = p.phi2.toFixed(1);
+ if (wavesSim) wavesSim.setParams({ A1: p.A1, f1: p.f1, phi1: p.phi1, A2: p.A2, f2: p.f2, phi2: p.phi2 });
+ }
+
+ function wavesPlayPause() {
+ if (!wavesSim) return;
+ const btn = document.getElementById('waves-play-btn');
+ if (wavesSim._paused) {
+ wavesSim.play();
+ btn.innerHTML = '';
+ } else {
+ wavesSim.pause();
+ btn.innerHTML = '';
+ }
+ }
+
+ function _wavesUpdateUI(info) {
+ const v = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
+ v('wavesbar-T', info.T);
+ v('wavesbar-lam', info.lambda);
+ v('wavesbar-v', info.v);
+ v('wavesbar-f', (+info.f1).toFixed(1));
+ }
+
+ /* ── crystal lattice (3D) ── */