merge: feature/lab-content-engine → master

Контент-движок лаборатории (фазы 0-5): LabRegistry, data-driven регистрация,
вынос тел в labs-bodies.html, ленивая загрузка кода, БД-каталог lab_sims + API +
админка, курикулумные связи lab_sim_links + двусторонняя навигация.
Плюс накопленная работа параллельных сессий (chemistry-8, phys7, biochem, optics).

Разрешение конфликтов: frontend/lab.html — версия feature (контент-движок);
opticsbench.js / seed_biochem_challenges.js / BIOCHEM_UPGRADE.md /
biochem-pathways-plan.md — версия master (более свежая работа парал. сессий).

Тесты: 160, 157 pass, 3 fail (pre-existing baseline auth.test.js).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 17:53:58 +03:00
52 changed files with 13088 additions and 4605 deletions
+76
View File
@@ -0,0 +1,76 @@
'use strict';
/*
* LabLoader — ленивый загрузчик кода симуляций (контент-движок, Фаза 3).
*
* Тяжёлый код симуляций (~2.5 МБ) и three.js (~600 КБ) больше НЕ грузятся на старте.
* При открытии симуляции LabLoader.ensure(id) подгружает её файлы (по манифесту
* window.SIM_DEPS из _sim_deps.js) и, при необходимости, three.js — затем резолвит.
*
* Гарантия корректности (самовосстановление): если после загрузки указанных файлов
* глобальная open-функция (SIM_DEPS[id].open, напр. "_openPendulum") всё ещё не
* определена, грузятся ВСЕ ленивые файлы (window.LAB_LAZY_FILES). Поэтому ошибка в
* манифесте не может «сломать» симуляцию — в худшем случае грузится больше файлов
* (поведение как до Фазы 3). Манифест лишь оптимизирует объём загрузки.
*
* Все загрузки кешируются (по URL) и дедуплицируются.
*/
(function () {
var BASE = '/js/labs/';
var THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js';
var _cache = {}; // url -> Promise
var _allLoaded = false;
function loadScript(url) {
if (_cache[url]) return _cache[url];
_cache[url] = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = url;
s.async = false; // сохранить порядок при добавлении нескольких сразу
s.onload = function () { resolve(url); };
s.onerror = function () { delete _cache[url]; reject(new Error('LabLoader: не удалось загрузить ' + url)); };
document.head.appendChild(s);
});
return _cache[url];
}
function ensureThree() {
if (typeof window.THREE !== 'undefined') return Promise.resolve();
return loadScript(THREE_URL);
}
function loadFiles(files) {
return Promise.all((files || []).map(function (f) { return loadScript(BASE + f); }));
}
function loadAllLazy() {
if (_allLoaded) return Promise.resolve();
var list = window.LAB_LAZY_FILES || [];
return loadFiles(list).then(function () { _allLoaded = true; });
}
// ensure(id): загрузить всё необходимое для симуляции id, вернуть Promise.
function ensure(id) {
var dep = (window.SIM_DEPS && window.SIM_DEPS[id]) || null;
if (!dep) {
// нет манифеста для id — безопасно грузим всё
return loadAllLazy();
}
var p = dep.three ? ensureThree() : Promise.resolve();
return p
.then(function () { return loadFiles(dep.files); })
.then(function () {
var openName = dep.open;
if (openName && typeof window[openName] !== 'function') {
if (window.console) console.warn('[LabLoader] самовосстановление для "' + id + '": ' + openName + ' не найдена после загрузки ' + JSON.stringify(dep.files) + ' — гружу все ленивые файлы');
return loadAllLazy();
}
});
}
window.LabLoader = {
ensure: ensure,
ensureThree: ensureThree,
loadScript: loadScript,
loadAllLazy: loadAllLazy
};
})();
+109
View File
@@ -0,0 +1,109 @@
'use strict';
/*
* Контент-движок, Фаза 1 — data-driven регистрация ВСЕХ симуляций в LabRegistry.
*
* Вместо ручного переписывания 40 манифестов модуль строит их из единых источников:
* - метаданные (id/cat/title/desc) и preview — из массива SIMS (lab-glue.js);
* - теория — из объекта THEORY (lab-init.js);
* - поведение open(ctx) — из карты OPEN ниже (обёртки над глобальными _openXxx).
* Это структурно гарантирует паритет с прежним каталогом и диспетчеризацией.
*
* Подключается ПОСЛЕДНИМ среди labs-скриптов (defer), поэтому SIMS, THEORY и все
* _openXxx уже определены. Останов/закрытие симуляций по-прежнему выполняет
* «дробовик» _pauseAllSims()/closeSim() (точный паритет) — поэтому stop/destroy
* в манифестах не задаются на этом этапе.
*
* После регистрации if-цепочка в openSim() становится мёртвой и удалена.
*
* В Фазе 1 заменил пилотный _pilots.js. SIMS/THEORY остаются источниками данных
* (SIMS → БД в Фазе 4, THEORY сворачивается в манифесты позже).
*/
(function () {
if (!window.LabRegistry) return;
if (typeof SIMS === 'undefined') return;
var R = window.LabRegistry;
var T = (typeof THEORY !== 'undefined') ? THEORY : {};
// id -> open(ctx). ctx.arg — параметр deep-link (после двоеточия): stereo:cube и т.п.
var OPEN = {
graph: function (c) { _openGraph(); },
projectile: function (c) { _openProjectile(); },
collision: function (c) { _openCollision(); },
triangle: function (c) { _openTriangle(); },
trigcircle: function (c) { _openTrigCircle(); },
emfield: function (c) { _openEMField(c.arg || 'E'); },
molphys: function (c) { _openMolPhys(c.arg); },
circuit: function (c) { _openCircuit(); },
chemistry: function (c) { _openChemistry(c.arg); },
dynamics: function (c) { _openDynamics(c.arg); },
crystal: function (c) { _openCrystal(); },
orbitals: function (c) { _openOrbitals(); },
stereo: function (c) { _openStereo(c.arg); },
chemsandbox: function (c) { _openChemSandbox(); },
celldivision: function (c) { _openCellDivision(); },
photosynthesis: function (c) { _openPhotosynthesis(); },
angrybirds: function (c) { _openAngryBirds(); },
quadratic: function (c) { _openQuadratic(); },
normaldist: function (c) { _openNormalDist(); },
graphtransform: function (c) { _openGraphTransform(); },
pendulum: function (c) { _openPendulum(); },
equilibrium: function (c) { _openEquilibrium(); },
opticsbench: function (c) { _openOpticsBench(c.arg || 'lens'); },
isoprocess: function (c) { _openIsoprocess(); },
titration: function (c) { _openTitration(); },
probability: function (c) { _openProbability(); },
bohratom: function (c) { _openBohrAtom(); },
electrolysis: function (c) { _openElectrolysis(); },
race: function (c) { _openRace(); },
waves: function (c) { _openWaves(); },
hydrostatics: function (c) { _openHydro(c.arg); },
radioactive: function (c) { _openRadioactive(); },
geometry: function (c) { _openGeometry(); },
logic: function (c) { _openLogic(); },
heatengine: function (c) { _openHeatEngine(); },
stoichiometry: function (c) { _openStoich(); },
qualanalysis: function (c) { _openQualAnalysis(); },
periodic: function (c) { _openPeriodic(); },
organic: function (c) { _openOrganic(); },
solutions: function (c) { _openSolutions(); }
};
SIMS.forEach(function (s) {
if (!s.id) return; // "Скоро" — карточка без id
var open = OPEN[s.id];
if (!open) { // подстраховка: незамапленный id оставляем legacy-пути
if (window.console) console.warn('[LabRegistry] нет open() для', s.id);
return;
}
R.register({
id: s.id,
cat: s.cat,
title: s.title,
desc: s.desc,
preview: s.preview, // уже готовая SVG-строка (P_* вычислены в SIMS)
theory: T[s.id] || null,
// Фаза 3: ленивая загрузка кода. LabLoader.ensure(id) подгружает файлы
// симуляции (+ three.js при необходимости), затем выполняется raw-open.
// Если LabLoader недоступен — открываем синхронно как раньше (фолбэк).
open: (function (rawOpen, simId) {
return function (c) {
if (window.LabLoader && window.LabLoader.ensure) {
return window.LabLoader.ensure(simId).then(function () { rawOpen(c); });
}
rawOpen(c);
};
})(open, s.id)
// stop/destroy: глобальный «дробовик» _pauseAllSims()/closeSim() — паритет
});
});
// Алиасы deep-link → канонический id[:arg]. Диспетчер openSim() нормализует их
// перед обращением к реестру (карточек у алиасов нет — только прямые ссылки).
window.LAB_SIM_ALIASES = {
magnetic: 'emfield:B',
coulomb: 'emfield:E',
thinlens: 'opticsbench:lens',
mirrors: 'opticsbench:mirror',
refraction: 'opticsbench:refraction'
};
})();
+101
View File
@@ -0,0 +1,101 @@
'use strict';
/*
* LabRegistry — единый реестр симуляций лаборатории (контент-движок).
*
* Цель: симуляции описываются декларативным манифестом и сами себя регистрируют,
* вместо захардкоженных массивов (SIMS), if-цепочек (openSim) и объектов (THEORY).
*
* Манифест:
* {
* id: 'pendulum', // уникальный, без ':arg'
* cat: 'phys', // math | phys | chem | bio | game
* title: 'Маятник',
* desc: 'Колебания, период…',
* preview: string | function(), // SVG-разметка карточки (функция вычисляется лениво)
* theory: { title, sections[] },// объект для панели теории (как в THEORY)
* bodyId: 'sim-pendulum', // (опц.) id тела; mount() — для ленивого создания DOM (Фаза 2)
* mount: function(host){}, // (опц.) ленивое монтирование тела
* open: function(ctx){}, // ctx = { id, arg } — открыть/инициализировать
* stop: function(){}, // (опц.) остановить анимации (не разрушая)
* destroy: function(){}, // (опц.) полностью закрыть; по умолчанию == stop
* subject, grade, topics // (опц.) курикулумные поля (Фаза 5)
* }
*
* Загружается ПЕРВЫМ среди labs-скриптов, чтобы window.LabRegistry существовал
* к моменту исполнения тел остальных модулей.
*/
(function () {
var _list = []; // манифесты в порядке регистрации
var _byId = {}; // id -> манифест
var _active = null; // текущая открытая симуляция
function _baseId(id) {
return id == null ? id : String(id).split(':')[0];
}
function register(m) {
if (!m || !m.id) return null;
if (Object.prototype.hasOwnProperty.call(_byId, m.id)) {
// перерегистрация: заменить на месте, сохранив позицию
for (var i = 0; i < _list.length; i++) {
if (_list[i].id === m.id) { _list[i] = m; break; }
}
} else {
_list.push(m);
}
_byId[m.id] = m;
return m;
}
function get(id) {
var b = _baseId(id);
return Object.prototype.hasOwnProperty.call(_byId, b) ? _byId[b] : null;
}
function has(id) { return !!get(id); }
function all() { return _list.slice(); }
function setActive(m) { _active = m || null; }
function stopActive() {
if (_active && typeof _active.stop === 'function') {
try { _active.stop(); } catch (e) { /* noop */ }
}
}
function destroyActive() {
if (_active) {
if (typeof _active.destroy === 'function') {
try { _active.destroy(); } catch (e) { /* noop */ }
} else if (typeof _active.stop === 'function') {
try { _active.stop(); } catch (e) { /* noop */ }
}
}
_active = null;
}
function active() { return _active; }
// Разрешить preview (строка или функция) в готовую разметку.
function resolvePreview(m) {
if (!m) return '';
var p = m.preview;
if (typeof p === 'function') {
try { return p() || ''; } catch (e) { return ''; }
}
return p || '';
}
window.LabRegistry = {
register: register,
get: get,
has: has,
all: all,
setActive: setActive,
stopActive: stopActive,
destroyActive: destroyActive,
active: active,
resolvePreview: resolvePreview
};
})();
+300
View File
@@ -0,0 +1,300 @@
'use strict';
/* Контент-движок, Фаза 3 — манифест зависимостей симуляций (СГЕНЕРИРОВАН).
id -> { open: имя глобальной _openX, files: [ленивые файлы], three: нужен ли three.js }.
Файлы загружаются лениво по клику (см. _loader.js). three.js — только для 3D-симуляций.
Самовосстановление в _loader: если после загрузки open-функция не определена,
грузятся ВСЕ ленивые файлы -> корректность не зависит от точности манифеста.
Регенерация: node tools/gen-sim-deps.js (см. CONTEXT). НЕ редактировать вручную. */
window.SIM_DEPS = {
"graph": {
"open": "_openGraph",
"files": [],
"three": false
},
"projectile": {
"open": "_openProjectile",
"files": [
"projectile.js"
],
"three": false
},
"collision": {
"open": "_openCollision",
"files": [
"collision.js"
],
"three": false
},
"triangle": {
"open": "_openTriangle",
"files": [
"triangle.js"
],
"three": false
},
"trigcircle": {
"open": "_openTrigCircle",
"files": [
"trigcircle.js"
],
"three": false
},
"emfield": {
"open": "_openEMField",
"files": [
"emfield.js",
"logic.js"
],
"three": false
},
"molphys": {
"open": "_openMolPhys",
"files": [
"brownian.js",
"diffusion.js",
"gas.js",
"states.js"
],
"three": false
},
"circuit": {
"open": "_openCircuit",
"files": [
"circuit.js"
],
"three": false
},
"chemistry": {
"open": "_openChemistry",
"files": [
"circuit.js",
"flask.js",
"ionexchange.js",
"reactions.js",
"redox.js"
],
"three": false
},
"dynamics": {
"open": "_openDynamics",
"files": [
"forcesandbox.js",
"newton.js"
],
"three": false
},
"crystal": {
"open": "_openCrystal",
"files": [
"crystal.js"
],
"three": true
},
"orbitals": {
"open": "_openOrbitals",
"files": [
"orbitals.js"
],
"three": true
},
"stereo": {
"open": "_openStereo",
"files": [
"stereo.js"
],
"three": true
},
"chemsandbox": {
"open": "_openChemSandbox",
"files": [
"chemsandbox.js",
"collision.js"
],
"three": false
},
"celldivision": {
"open": "_openCellDivision",
"files": [
"celldivision.js"
],
"three": false
},
"photosynthesis": {
"open": "_openPhotosynthesis",
"files": [
"photosynthesis.js"
],
"three": false
},
"angrybirds": {
"open": "_openAngryBirds",
"files": [
"angrybirds.js"
],
"three": false
},
"quadratic": {
"open": "_openQuadratic",
"files": [
"quadratic.js"
],
"three": false
},
"normaldist": {
"open": "_openNormalDist",
"files": [
"normaldist.js"
],
"three": false
},
"graphtransform": {
"open": "_openGraphTransform",
"files": [
"graphtransform.js"
],
"three": false
},
"pendulum": {
"open": "_openPendulum",
"files": [
"pendulum.js"
],
"three": false
},
"equilibrium": {
"open": "_openEquilibrium",
"files": [
"equilibrium.js"
],
"three": false
},
"opticsbench": {
"open": "_openOpticsBench",
"files": [
"opticsbench.js"
],
"three": false
},
"isoprocess": {
"open": "_openIsoprocess",
"files": [
"isoprocess.js"
],
"three": false
},
"titration": {
"open": "_openTitration",
"files": [
"titration.js"
],
"three": false
},
"probability": {
"open": "_openProbability",
"files": [
"probability.js"
],
"three": false
},
"bohratom": {
"open": "_openBohrAtom",
"files": [
"bohratom.js"
],
"three": false
},
"electrolysis": {
"open": "_openElectrolysis",
"files": [
"electrolysis.js"
],
"three": false
},
"race": {
"open": "_openRace",
"files": [
"race.js"
],
"three": false
},
"waves": {
"open": "_openWaves",
"files": [
"waves.js"
],
"three": false
},
"hydrostatics": {
"open": "_openHydro",
"files": [
"hydrostatics.js"
],
"three": false
},
"radioactive": {
"open": "_openRadioactive",
"files": [
"radioactive.js"
],
"three": false
},
"geometry": {
"open": "_openGeometry",
"files": [
"geometry.js",
"triangle.js"
],
"three": false
},
"logic": {
"open": "_openLogic",
"files": [
"logic.js"
],
"three": false
},
"heatengine": {
"open": "_openHeatEngine",
"files": [
"heatengine.js"
],
"three": false
},
"stoichiometry": {
"open": "_openStoich",
"files": [
"stoichiometry.js"
],
"three": false
},
"qualanalysis": {
"open": "_openQualAnalysis",
"files": [
"qualanalysis.js"
],
"three": false
},
"periodic": {
"open": "_openPeriodic",
"files": [
"_periodic_data.js",
"periodic.js"
],
"three": true
},
"organic": {
"open": "_openOrganic",
"files": [
"organic.js"
],
"three": false
},
"solutions": {
"open": "_openSolutions",
"files": [
"solutions.js"
],
"three": false
}
};
window.LAB_LAZY_FILES = ["angrybirds.js","bohratom.js","brownian.js","celldivision.js","chemsandbox.js","circuit.js","collision.js","crystal.js","diffusion.js","electrolysis.js","emfield.js","equilibrium.js","flask.js","forcesandbox.js","gas.js","geometry.js","graphtransform.js","heatengine.js","hydrostatics.js","ionexchange.js","isoprocess.js","logic.js","newton.js","normaldist.js","opticsbench.js","orbitals.js","organic.js","pendulum.js","periodic.js","photosynthesis.js","probability.js","projectile.js","quadratic.js","qualanalysis.js","race.js","radioactive.js","reactions.js","redox.js","solutions.js","states.js","stereo.js","stoichiometry.js","titration.js","triangle.js","trigcircle.js","waves.js","_periodic_data.js"];
+71 -3
View File
@@ -20,11 +20,25 @@
}
function renderSims() {
const base = _catFilter === 'all' ? SIMS : SIMS.filter(s => s.cat === _catFilter);
// Контент-движок: мёрж код-реестра поверх legacy SIMS.
// Порядок берём из SIMS; для мигрированных id используем манифест реестра;
// registry-only записи добавляем в конец.
const _reg = (window.LabRegistry ? window.LabRegistry.all() : []);
const _regById = {};
_reg.forEach(m => { _regById[m.id] = m; });
const _seen = {};
const _merged = [];
SIMS.forEach(s => {
_merged.push(s.id && _regById[s.id] ? _regById[s.id] : s);
if (s.id) _seen[s.id] = 1;
});
_reg.forEach(m => { if (!_seen[m.id]) _merged.push(m); });
const base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter);
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
document.getElementById('sim-grid').innerHTML = list.map(s => `
<div class="sim-card ${s.id ? '' : 'soon'}" ${s.id ? `onclick="openSim('${s.id}')"` : ''}>
${s.preview}
${window.LabRegistry ? window.LabRegistry.resolvePreview(s) : s.preview}
<div class="sim-body">
<div class="sim-cat ${s.cat}">${s.cat === 'math' ? '∑ Математика' : s.cat === 'chem' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Химия' : s.cat === 'bio' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/><path d="M9 22c1.798-2 2.518-4 2.807-6"/><path d="M15 2c-1.798 2-2.518 4-2.807 6"/><path d="m17 6-2.5-2.5M14 8 13 7M7 18l2.5 2.5M3.5 14.5l.5.5M20 9l.5.5M6.5 12.5l1 1M16.5 10.5l1 1M10 16l1.5 1.5"/></svg> Биология' : s.cat === 'game' ? '<svg class="ic" viewBox="0 0 24 24"><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="15" y1="13" x2="15.01" y2="13"/><line x1="18" y1="11" x2="18.01" y2="11"/><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры' : LS.icon('zap',14) + ' Физика'}</div>
<div class="sim-title">${s.title}</div>
@@ -935,7 +949,9 @@
}
function loadTheory(simId) {
const t = THEORY[simId];
// Контент-движок: теория мигрированных симуляций берётся из манифеста реестра.
const _rm = window.LabRegistry ? window.LabRegistry.get(simId) : null;
const t = (_rm && _rm.theory) ? _rm.theory : THEORY[simId];
const el = document.getElementById('theory-content');
if (!t) { el.innerHTML = '<div class="tp-text" style="text-align:center;padding:40px 0;color:var(--text-3)">Теория для этой симуляции пока не добавлена</div>'; return; }
let html = `<div class="tp-title">${LS.icon('book-open',16)} ${t.title}</div>`;
@@ -955,6 +971,58 @@
});
}
/* ── Контент-движок, Фаза 5: чип «Связано с программой» ──────────────────
Подтягивает курикулумные связи симуляции (GET /api/lab/sims/:id/related) и
рендерит чипы-ссылки рядом с заголовком симуляции. Самодостаточно: создаёт
контейнер #sim-related динамически (без правок lab.html/CSS — меньше риск
конфликта с параллельными сессиями). Тихо прячется, если связей нет/ошибка. */
var _LAB_LINK_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
function _labRelEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function _ensureRelatedHost() {
var host = document.getElementById('sim-related');
if (host) return host;
host = document.createElement('div');
host.id = 'sim-related';
host.style.cssText = 'display:none;align-items:center;gap:6px;flex-wrap:wrap;margin-left:14px;min-width:0';
var title = document.getElementById('sim-topbar-title');
if (title && title.parentNode) title.parentNode.insertBefore(host, title.nextSibling);
return host;
}
function _loadRelated(simId) {
var host = _ensureRelatedHost();
host.style.display = 'none';
host.innerHTML = '';
if (!window.LS || !LS.api) return;
LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related')
.then(function (data) {
var links = (data && data.links) || {};
var all = [].concat(links.textbook || [], links.topic || [], links.kmap || [], links.question || []);
if (!all.length) return;
var chipBase = 'display:inline-flex;align-items:center;gap:4px;font-size:.72rem;padding:3px 9px;border-radius:999px;';
var html = '<span style="font-size:.68rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.05em">'
+ _LAB_LINK_ICON + ' Связано с программой</span>';
all.forEach(function (l) {
var label = _labRelEsc(l.label || (l.kind + ':' + l.ref_id));
if (l.href) {
html += '<a href="' + _labRelEsc(l.href) + '" title="Открыть в учебнике" style="' + chipBase
+ 'background:rgba(155,93,229,.14);color:var(--violet);text-decoration:none;border:1px solid rgba(155,93,229,.32)">' + label + '</a>';
} else {
html += '<span style="' + chipBase
+ 'background:rgba(255,255,255,.06);color:var(--text-2);border:1px solid rgba(255,255,255,.12)">' + label + '</span>';
}
});
host.innerHTML = html;
host.style.display = 'flex';
if (window.lucide) lucide.createIcons();
})
.catch(function () { /* нет связей или ошибка — чип просто не показываем */ });
}
window._loadRelated = _loadRelated;
/* ── embed mode + auto-open from ?sim= ── */
const _qp = new URLSearchParams(location.search);
var _embedMode = _qp.get('embed') === '1';
+43 -52
View File
@@ -30,6 +30,19 @@
var geomSim = null;
var qualSim = null;
/* Контент-движок, Фаза 3 (ленивая загрузка): часть глобалов с экземплярами
симуляций объявляется внутри их собственных НЫНЕ ЛЕНИВЫХ файлов, поэтому до
первого открытия такой симуляции они не существуют. Legacy-«дробовик»
_pauseAllSims()/closeSim() ссылается на них по голому имени, что до загрузки
любого файла бросало ReferenceError (напр. cirSim). Предсоздаём эти имена как
свойства window (null), чтобы guard'ы безопасно давали false; при загрузке
файла симуляции его собственный var/присваивание обновит тот же глобал. */
['cirSim','reacSim','flaskSim','newtonSim','sandboxSim','crystalSim','orbitalsSim',
'stereoSim','angryBirdsSim','trigSim','pendSim','radioactiveSim','heSim',
'periodicSim','organicSim','_solutionsSim','mirrorSim'].forEach(function (_n) {
if (!(_n in window)) window[_n] = null;
});
var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-emfield',
'sim-molphys',
'sim-circuit','sim-chemistry','sim-dynamics',
@@ -52,6 +65,7 @@
// Pause all animation-loop sims (non-destructive). Called when switching
// between sims so a previously opened sim doesn't keep rendering offscreen.
function _pauseAllSims() {
if (window.LabRegistry) window.LabRegistry.stopActive();
if (pSim) pSim.pause();
if (cSim) cSim.pause();
if (gasSim) gasSim.stop();
@@ -105,58 +119,34 @@
// load theory for this sim
loadTheory(id.includes(':') ? id.split(':')[0] : id);
if (id === 'graph') _openGraph();
if (id === 'projectile') _openProjectile();
if (id === 'collision') _openCollision();
if (id === 'triangle') _openTriangle();
if (id === 'trigcircle') _openTrigCircle();
if (id === 'magnetic') _openEMField('B'); // backward compat: #magnetic → emfield B-mode
if (id === 'coulomb') _openEMField('E'); // backward compat: #coulomb → emfield E-mode
if (id === 'emfield') _openEMField('E');
if (id.startsWith('emfield:')) { _openEMField(id.split(':')[1]); }
if (id === 'molphys') _openMolPhys();
if (id.startsWith('molphys:')) { _openMolPhys(id.split(':')[1]); }
if (id === 'circuit') _openCircuit();
if (id === 'chemistry') _openChemistry();
if (id.startsWith('chemistry:')) { _openChemistry(id.split(':')[1]); }
if (id === 'dynamics') _openDynamics();
if (id.startsWith('dynamics:')) { _openDynamics(id.split(':')[1]); }
if (id === 'crystal') _openCrystal();
if (id === 'orbitals') _openOrbitals();
if (id === 'stereo') _openStereo();
if (id.startsWith('stereo:')) { _openStereo(id.split(':')[1]); }
if (id === 'chemsandbox') _openChemSandbox();
if (id === 'celldivision') _openCellDivision();
if (id === 'photosynthesis') _openPhotosynthesis();
if (id === 'angrybirds') _openAngryBirds();
if (id === 'quadratic') _openQuadratic();
if (id === 'normaldist') _openNormalDist();
if (id === 'graphtransform') _openGraphTransform();
if (id === 'pendulum') _openPendulum();
if (id === 'equilibrium') _openEquilibrium();
if (id === 'opticsbench') _openOpticsBench('lens');
if (id.startsWith('opticsbench:')) _openOpticsBench(id.split(':')[1]);
if (id === 'thinlens') _openOpticsBench('lens'); // backward compat
if (id === 'mirrors') _openOpticsBench('mirror'); // backward compat
if (id === 'refraction') _openOpticsBench('refraction'); // backward compat
if (id === 'isoprocess') _openIsoprocess();
if (id === 'titration') _openTitration();
if (id === 'probability') _openProbability();
if (id === 'bohratom') _openBohrAtom();
if (id === 'electrolysis') _openElectrolysis();
if (id === 'race') _openRace();
if (id === 'waves') _openWaves();
if (id === 'hydrostatics') _openHydro();
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
if (id === 'radioactive') _openRadioactive();
if (id === 'geometry') _openGeometry();
if (id === 'logic') _openLogic();
if (id === 'heatengine') _openHeatEngine();
if (id === 'stoichiometry') _openStoich();
if (id === 'qualanalysis') _openQualAnalysis();
if (id === 'periodic') _openPeriodic();
if (id === 'organic') _openOrganic();
if (id === 'solutions') _openSolutions();
// Фаза 5: чип «Связано с программой» (курикулумные связи симуляции).
if (typeof _loadRelated === 'function') _loadRelated(id.includes(':') ? id.split(':')[0] : id);
// ── Контент-движок (Фаза 1): диспетчеризация через реестр ──
// Все каталожные симуляции зарегистрированы в _register-all.js.
// Алиасы deep-link (magnetic/coulomb/thinlens/mirrors/refraction) нормализуем
// в канонический id[:arg] перед обращением к реестру.
var _aliases = window.LAB_SIM_ALIASES || {};
var _cid = _aliases[id.split(':')[0]] || id;
if (window.LabRegistry && window.LabRegistry.has(_cid)) {
const _m = window.LabRegistry.get(_cid);
const _arg = _cid.includes(':') ? _cid.split(':')[1] : undefined;
window.LabRegistry.setActive(_m);
// Фаза 3: open() может вернуть Promise (ленивая загрузка кода). Иконки
// перерисовываем после фактической инициализации тела симуляции; ошибку
// асинхронной загрузки ловим через .catch (sync try/catch её не поймает).
try {
const _r = _m.open({ id: _cid, arg: _arg });
if (_r && typeof _r.then === 'function') {
_r.then(function () { if (window.lucide) lucide.createIcons(); })
.catch(function (e) { console.error('[LabRegistry] open failed:', _cid, e); });
} else if (window.lucide) {
lucide.createIcons();
}
} catch (e) { console.error('[LabRegistry] open failed:', _cid, e); }
return;
}
if (window.console) console.warn('[LabRegistry] неизвестная симуляция:', id);
}
function _simShow(elId) {
@@ -210,6 +200,7 @@
}
function closeSim() {
if (window.LabRegistry) window.LabRegistry.destroyActive();
if (pSim) pSim.pause();
if (cSim) cSim.pause();
if (mSim && mSim.particleOn) mSim.toggleParticle();