Files
Learn_System/frontend/js/labs/lab-glue.js
T

850 lines
50 KiB
JavaScript

'use strict';
const { user, isTeacher, isAdmin } = LS.initPage();
window._simQuizAllowed = true; // default; overridden after permission fetch for students
LS.showBoardIfAllowed();
/* ════════════════════════════════
SIM CATALOGUE (defined after P_* consts below)
════════════════════════════════ */
let _catFilter = 'all';
var _disabledSimIds = new Set();
let _simModuleDisabled = false;
function filterSims(cat, btn) {
_catFilter = cat;
document.querySelectorAll('.lab-filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderSims();
}
function renderSims() {
// Контент-движок: мёрж код-реестра поверх 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;
});
// Конструктор симуляций (Фаза 5): custom-sims рендерятся отдельной секцией
// «Мои симуляции» (см. LabCustom) — исключаем их из основной сетки встроенных.
_reg.forEach(m => { if (!_seen[m.id] && !m._custom) _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}')"` : ''}>
${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>
<div class="sim-desc">${s.desc}</div>
</div>
${!s.id ? '<div class="sim-soon-badge">Скоро</div>' : ''}
</div>`).join('');
// Конструктор симуляций (Фаза 5): дорисовать секцию «Мои симуляции».
if (window.LabCustom && typeof window.LabCustom.renderSection === 'function') {
try { window.LabCustom.renderSection(_catFilter); } catch (e) {}
}
if (window.lucide) lucide.createIcons();
}
/* ════════════════════════════════
CARD PREVIEW SVGs — вынесены в /js/lab-previews.js (единый источник).
Берём их по имени из window.__LabP; SIMS ниже ссылается на эти алиасы.
════════════════════════════════ */
var __LP = window.__LabP || {};
var P_GRAPH = __LP.P_GRAPH, P_TRANSFORM = __LP.P_TRANSFORM, P_TRIANGLE = __LP.P_TRIANGLE, P_CIRCLES = __LP.P_CIRCLES, P_QUADRATIC = __LP.P_QUADRATIC, P_3D = __LP.P_3D, P_PROB = __LP.P_PROB, P_NORMAL = __LP.P_NORMAL, P_TRIGCIRCLE = __LP.P_TRIGCIRCLE, P_PROJECTILE = __LP.P_PROJECTILE, P_PENDULUM = __LP.P_PENDULUM, P_COLLISION = __LP.P_COLLISION, P_CIRCUIT = __LP.P_CIRCUIT, P_MAGNETIC = __LP.P_MAGNETIC, P_FIELD = __LP.P_FIELD, P_LENS = __LP.P_LENS, P_REFRACTION = __LP.P_REFRACTION, P_MIRROR = __LP.P_MIRROR, P_ISOPROCESS = __LP.P_ISOPROCESS, P_GAS = __LP.P_GAS, P_NEWTON = __LP.P_NEWTON, P_SANDBOX = __LP.P_SANDBOX, P_HYDRO = __LP.P_HYDRO, P_KINETICS = __LP.P_KINETICS, P_EQUILIBRIUM = __LP.P_EQUILIBRIUM, P_ELECTROLYSIS = __LP.P_ELECTROLYSIS, P_BOHR = __LP.P_BOHR, P_ORBITALS = __LP.P_ORBITALS, P_PH = __LP.P_PH, P_CHEMSANDBOX = __LP.P_CHEMSANDBOX, P_STOICHIOMETRY = __LP.P_STOICHIOMETRY, P_PERIODIC = __LP.P_PERIODIC, P_CRYSTAL = __LP.P_CRYSTAL, P_CELLDIVISION = __LP.P_CELLDIVISION, P_PHOTOSYNTHESIS = __LP.P_PHOTOSYNTHESIS, P_ANGRYBIRDS = __LP.P_ANGRYBIRDS, P_WAVES = __LP.P_WAVES, P_RADIOACTIVE = __LP.P_RADIOACTIVE, P_HEATENGINE = __LP.P_HEATENGINE, P_GEOMETRY = __LP.P_GEOMETRY, P_RACE = __LP.P_RACE, P_LOGIC = __LP.P_LOGIC, P_QUALANALYSIS = __LP.P_QUALANALYSIS, P_ORGANIC = __LP.P_ORGANIC, P_SOLUTIONS = __LP.P_SOLUTIONS;
const SIMS = [
/* ── Математика ── */
{ id: 'graph', cat: 'math',
title: 'График функции',
desc: 'Строй графики функций y = f(x) с параметрами, зумом и курсором координат.',
preview: P_GRAPH },
{ id: 'graphtransform', cat: 'math',
title: 'Трансформации графиков',
desc: 'Наблюдай, как сдвиги, растяжения и отражения меняют вид функции y = a·f(kx+b)+c.',
preview: P_TRANSFORM },
{ id: 'geometry', cat: 'math',
title: 'Планиметрия',
desc: 'Интерактивная среда построений: точки, отрезки, прямые, окружности, многоугольники. Полноценный чертёж с привязкой и измерениями.',
preview: P_GEOMETRY },
{ id: 'triangle', cat: 'math',
title: 'Геометрия треугольника',
desc: 'Интерактивный треугольник: медианы, высоты, биссектрисы, вписанная и описанная окружности.',
preview: P_TRIANGLE },
{ id: 'quadratic', cat: 'math',
title: 'Корни квадратного уравнения',
desc: 'Задай a, b, c ползунками — смотри дискриминант и корни анимированно на числовой оси.',
preview: P_QUADRATIC },
{ id: 'stereo', cat: 'math',
title: 'Стереометрия 3D',
desc: 'Вращаемые объёмные фигуры: куб, пирамида, цилиндр, конус с формулами объёма и площади. Сечения, развёртка, вписанные/описанные сферы.',
preview: P_3D },
{ id: 'probability', cat: 'math',
title: 'Теория вероятностей',
desc: 'Подброс монеты/кубика N раз — гистограмма частот и закон больших чисел в действии.',
preview: P_PROB },
{ id: 'trigcircle', cat: 'math',
title: 'Тригонометрическая окружность',
desc: 'Единичная окружность с sin, cos, tg, ctg. Перетаскивай точку — все функции обновляются мгновенно. График синхронизирован.',
preview: P_TRIGCIRCLE },
{ id: 'normaldist', cat: 'math',
title: 'Нормальное распределение',
desc: 'Двигай μ и σ ползунками — колокол Гаусса и площадь под кривой обновляются мгновенно.',
preview: P_NORMAL },
/* ── Физика ── */
{ id: 'projectile', cat: 'phys',
title: 'Бросок тела',
desc: 'Задай начальную скорость и угол — симулируй траекторию, дальность и высоту полёта.',
preview: P_PROJECTILE },
{ id: 'pendulum', cat: 'phys',
title: 'Маятник',
desc: 'Регулируй длину и угол отклонения — изучай период колебаний и затухание.',
preview: P_PENDULUM },
{ id: 'collision', cat: 'phys',
title: 'Столкновение шаров',
desc: 'Упругий и неупругий удар двух тел: законы сохранения импульса и энергии.',
preview: P_COLLISION },
{ id: 'emfield', cat: 'phys',
title: 'Электромагнитные поля',
desc: 'Электрическое и магнитное поля в одной симуляции: заряды, токи, силовые линии, эквипотенциали, частица Лоренца.',
preview: P_MAGNETIC },
{ id: 'circuit', cat: 'phys',
title: 'Электрические цепи',
desc: 'Конструктор цепей из резисторов и конденсаторов. Законы Ома и Кирхгофа наглядно.',
preview: P_CIRCUIT },
{ id: 'hydrostatics', cat: 'phys',
title: 'Гидростатика',
desc: 'Давление жидкости P=ρgh, закон Архимеда, сообщающиеся сосуды, поверхностное натяжение и капиллярность.',
preview: P_HYDRO },
{ id: 'dynamics', cat: 'phys',
title: 'Динамика',
desc: 'Законы Ньютона, песочница сил, наклонная плоскость — всё в одном интерактивном модуле.',
preview: P_SANDBOX },
{ id: 'opticsbench', cat: 'phys',
title: 'Оптическая скамья',
desc: 'Линза, зеркала и преломление в одной симуляции: формула линзы, зеркальное отражение, закон Снеллиуса, ПВО, дисперсия.',
preview: P_LENS },
{ id: 'isoprocess', cat: 'phys',
title: 'Изопроцессы',
desc: 'PV-диаграмма для четырёх изопроцессов идеального газа. Расчёт работы, теплоты и внутренней энергии.',
preview: P_ISOPROCESS },
{ id: 'waves', cat: 'phys',
title: 'Волны и звук',
desc: 'Поперечные и продольные волны, суперпозиция, стоячие волны. Частота, амплитуда, фаза, гармоники.',
preview: P_WAVES },
{ id: 'radioactive', cat: 'phys',
title: 'Радиоактивный распад',
desc: 'Период полураспада, цепочки распадов, активность. Визуализация ядер + кривая N(t). Радиоуглеродное датирование.',
preview: P_RADIOACTIVE },
{ id: 'race', cat: 'phys',
title: 'Гонка с задачами',
desc: 'Кинематика 1D: встреча, догон, кто первый. Реши задачу — проверь анимацией и графиком x(t).',
preview: P_RACE },
{ id: 'heatengine', cat: 'phys',
title: 'Тепловые двигатели',
desc: 'Циклы Карно, Отто, Дизеля, Брайтона. PV-диаграмма, поршень, КПД.',
preview: P_HEATENGINE },
{ id: 'logic', cat: 'phys',
title: 'Логические схемы',
desc: 'Конструктор цифровых схем: И/ИЛИ/НЕ/XOR, триггеры, сумматоры. Авто-таблица истинности.',
preview: P_LOGIC },
/* ── Химия / Молекулярная физика ── */
{ id: 'molphys', cat: 'chem',
title: 'Молекулярная физика',
desc: 'Идеальный газ, броуновское движение, агрегатные состояния и диффузия — всё в одном модуле.',
preview: P_GAS },
{ id: 'chemistry', cat: 'chem',
title: 'Химические реакции',
desc: 'Кинетика реакций, металл + кислота в колбе, ОВР с переносом электронов, ионный обмен — всё в одном модуле.',
preview: P_KINETICS },
{ id: 'equilibrium', cat: 'chem',
title: 'Химическое равновесие',
desc: 'Прямая и обратная реакция, принцип Ле Шателье: изменяй T, P, концентрацию и наблюдай сдвиг.',
preview: P_EQUILIBRIUM },
{ id: 'electrolysis', cat: 'chem',
title: 'Электролиз',
desc: 'Катод и анод в растворе электролита: движение ионов, выделение газа, закон Фарадея.',
preview: P_ELECTROLYSIS },
/* ── Скоро: Атомная структура ── */
{ id: 'bohratom', cat: 'chem',
title: 'Атом Бора',
desc: 'Электроны на орбитах, квантование энергии, эмиссия и поглощение фотонов при переходах.',
preview: P_BOHR },
{ id: 'orbitals', cat: 'chem',
title: 'Молекулярные орбитали',
desc: 'H₂, H₂O — ковалентная связь, перекрывание орбиталей, 3D-визуализация электронных облаков.',
preview: P_ORBITALS },
/* ── Скоро: Визуальная химия ── */
{ id: 'titration', cat: 'chem',
title: 'pH и кривая титрования',
desc: 'Добавляй кислоту или щёлочь — наблюдай изменение pH, цвет раствора и кривую нейтрализации.',
preview: P_PH },
{ id: 'chemsandbox', cat: 'chem',
title: 'Химическая песочница',
desc: 'Смешивай реагенты, наблюдай реакции: осадки, газы, изменение цвета. Свободное экспериментирование.',
preview: P_CHEMSANDBOX },
{ id: 'stoichiometry', cat: 'chem',
title: 'Стехиометрия',
desc: 'Расчёты по уравнениям: масса, моль, объём. Лимитирующий реагент, выход. 10 реакций.',
preview: P_STOICHIOMETRY },
{ id: 'crystal', cat: 'chem',
title: 'Кристаллическая решётка',
desc: 'NaCl, алмаз, металл — интерактивная 3D-решётка, типы связей, вращение структуры.',
preview: P_CRYSTAL },
{ id: 'qualanalysis', cat: 'chem',
title: 'Качественный анализ',
desc: 'Определяй катионы и анионы качественными реакциями: осадки, газы, пламя. Два режима: guided и свободный эксперимент.',
preview: P_QUALANALYSIS },
{ id: 'periodic', cat: 'chem',
title: 'Периодическая таблица',
desc: '118 элементов: подсветка по типу/блоку, карточка элемента, боровские оболочки, графики свойств.',
preview: P_PERIODIC },
{ id: 'organic', cat: 'chem',
title: 'Органическая химия',
desc: 'Конструктор молекул с проверкой валентности, гомологические ряды с таблицей свойств, качественные реакции (бромная вода, KMnO₄, зеркало Толленса, Cu(OH)₂, FeCl₃, Na).',
preview: P_ORGANIC },
{ id: 'solutions', cat: 'chem',
title: 'Растворы',
desc: 'Калькулятор раствора: ω, ν, C_M, плотность. Разбавление и смешивание с визуализацией. Кривые растворимости S(T) для 8 веществ + задача на перекристаллизацию.',
preview: P_SOLUTIONS },
/* ── Биология ── */
{ id: 'celldivision', cat: 'bio',
title: 'Деление клетки',
desc: 'Митоз и мейоз: анимированные фазы, хромосомы, веретено деления, ядерная оболочка.',
preview: P_CELLDIVISION },
{ id: 'photosynthesis', cat: 'bio',
title: 'Фотосинтез и дыхание',
desc: 'Световые реакции в тилакоидах, цикл Кальвина, митохондриальное дыхание — молекулярная анимация.',
preview: P_PHOTOSYNTHESIS },
/* ── Игры ── */
{ id: 'angrybirds', cat: 'game',
title: 'Angry Birds Physics',
desc: 'Запускай птиц из рогатки, разрушай блоки, побеждай свиней. Реальная физика: гравитация, ветер, импульс. 6 уровней.',
preview: P_ANGRYBIRDS },
];
var _theoryOpen = false;
function toggleTheory() {
_theoryOpen = !_theoryOpen;
document.getElementById('theory-panel').classList.toggle('open', _theoryOpen);
const btn = document.getElementById('theory-toggle');
btn.style.background = _theoryOpen ? 'rgba(155,93,229,0.15)' : '';
btn.style.borderColor = _theoryOpen ? 'var(--violet)' : '';
btn.style.color = _theoryOpen ? 'var(--violet)' : '';
}
function loadTheory(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');
// Фаза 1: панель учебных заданий (если есть для этой симуляции)
const tasksHtml = (window.LabTasks && LabTasks.has(simId)) ? LabTasks.mountHtml(simId) : '';
const tbtn = document.getElementById('theory-toggle');
if (tbtn) tbtn.classList.toggle('lt-has', !!tasksHtml);
if (!t) {
el.innerHTML = '<div class="tp-text" style="text-align:center;padding:40px 0;color:var(--text-3)">Теория для этой симуляции пока не добавлена</div>' + tasksHtml;
if (tasksHtml) LabTasks.afterMount(simId);
return;
}
let html = `<div class="tp-title">${LS.icon('book-open',16)} ${t.title}</div>`;
for (const s of t.sections) {
html += '<div class="tp-section">';
if (s.head) html += `<div class="tp-section-head">${s.head}</div>`;
if (s.formula) html += `<div class="tp-formula" data-formula="${s.formula.replace(/"/g,'&quot;')}"></div>`;
if (s.text) html += `<div class="tp-text">${s.text}</div>`;
if (s.vars) html += `<div class="tp-var-list">${s.vars.map(([v,d]) => `<div class="tp-var"><b>${v}</b> — ${d}</div>`).join('')}</div>`;
html += '</div>';
}
el.innerHTML = html + tasksHtml;
// render KaTeX formulas (теория)
el.querySelectorAll('.tp-formula[data-formula]').forEach(div => {
try { katex.render(div.dataset.formula, div, { displayMode: true, throwOnError: false }); }
catch(e) { div.textContent = div.dataset.formula; }
});
if (tasksHtml) LabTasks.afterMount(simId);
}
/* ── Контент-движок, Фаза 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';
var _autoSim = _qp.get('sim');
/* ── Sim state relay (embed mode only) ──────────────────────────────── */
// Map simId → { getState, applyState } registered by openSim handlers
const _simStateRegistry = {};
/* ── Локальный персист параметров симуляции (Фаза 2) ──────────────────
Поверх того же getState/applyState: в обычном (не embed) режиме сохраняем
состояние активной симуляции в localStorage и восстанавливаем при открытии.
В embed/онлайн-уроке состоянием управляет учитель — персист отключён. */
const _LAB_STATE_KEY = 'lab-sim-state-v1';
function _loadSavedStates() { try { return JSON.parse(localStorage.getItem(_LAB_STATE_KEY) || '{}') || {}; } catch (e) { return {}; } }
function _saveSavedStates(m) { try { localStorage.setItem(_LAB_STATE_KEY, JSON.stringify(m)); } catch (e) {} }
let _persistSimId = null, _persistInterval = null, _lastPersisted = null;
function _stopPersist() { if (_persistInterval) { clearInterval(_persistInterval); _persistInterval = null; } _persistSimId = null; _lastPersisted = null; }
function _persistNow() {
if (!_persistSimId) return;
const reg = _simStateRegistry[_persistSimId];
if (!reg || !reg.getState) return;
try {
const s = reg.getState();
if (s == null) return;
const json = JSON.stringify(s);
if (json === _lastPersisted || json.length > 8000) return;
_lastPersisted = json;
const m = _loadSavedStates(); m[_persistSimId] = s; _saveSavedStates(m);
} catch (e) {}
}
function _startPersist(simId) { _stopPersist(); _persistSimId = simId; _persistInterval = setInterval(_persistNow, 2000); }
window.addEventListener('pagehide', _persistNow);
function _registerSimState(simId, getState, applyState) {
_simStateRegistry[simId] = { getState, applyState };
if (_embedMode) return; // в embed состоянием управляет учитель
// восстановить сохранённые параметры (после инициализации тела) + запустить персист
setTimeout(function () {
try { const saved = _loadSavedStates()[simId]; if (saved != null && applyState) applyState(saved); } catch (e) {}
_startPersist(simId);
}, 0);
}
let _lastEmittedState = null;
let _stateEmitInterval = null;
function _startStateEmit(simId) {
if (_stateEmitInterval) clearInterval(_stateEmitInterval);
_lastEmittedState = null;
_stateEmitInterval = setInterval(() => {
const reg = _simStateRegistry[simId];
if (!reg) return;
try {
const state = reg.getState();
const json = JSON.stringify(state);
if (json === _lastEmittedState) return;
_lastEmittedState = json;
window.parent.postMessage({ type: 'sim_state', simId, state }, '*');
} catch {}
}, 400);
}
function _stopStateEmit() {
if (_stateEmitInterval) { clearInterval(_stateEmitInterval); _stateEmitInterval = null; }
_lastEmittedState = null;
}
// Receive apply_sim_state from parent (students)
window.addEventListener('message', e => {
if (!_embedMode) return;
const d = e.data;
if (!d || d.type !== 'apply_sim_state') return;
const reg = _simStateRegistry[_autoSim];
if (!reg) return;
try {
reg.applyState(d.state);
_lastEmittedState = JSON.stringify(d.state); // suppress echo
} catch {}
});
if (_embedMode) {
document.querySelector('.sidebar').style.display = 'none';
document.querySelector('.sb-content').style.marginLeft = '0';
document.querySelector('.app-layout').classList.add('embed-mode');
document.getElementById('lab-home').style.display = 'none';
document.getElementById('theory-toggle').style.display = 'none';
if (_autoSim) {
document.getElementById('lab-sim').classList.add('open');
document.querySelector('.sim-topbar').style.display = 'none';
// defer until all external scripts are loaded
// Конструктор симуляций (Фаза 5): custom-симуляции требуют предзагрузки спеки.
window.addEventListener('load', function () {
if (/^custom:/i.test(_autoSim) && window.LabCustom && window.LabCustom.init) {
window.LabCustom.init().then(function () { openSim(_autoSim); });
} else {
openSim(_autoSim);
}
});
}
} else {
/* init — fetch sim settings + permissions in parallel, then render */
const _permFetch = (!isTeacher && !isAdmin)
? LS.api('/api/permissions/me').catch(() => null)
: Promise.resolve(null);
Promise.all([
LS.api('/api/settings/sims').catch(() => ({})),
_permFetch,
]).then(([cfg, permData]) => {
_simModuleDisabled = cfg.module_disabled || false;
_disabledSimIds = new Set(cfg.disabled_ids || []);
// check simulations.access for students
if (!isTeacher && !isAdmin && permData) {
const p = permData.permissions?.find(p => p.key === 'simulations.access');
if (p && p.effective === false) {
document.getElementById('sim-grid').innerHTML =
`<div style="grid-column:1/-1;padding:60px 0;text-align:center;color:var(--text-3)">
<div style="font-size:2rem;margin-bottom:12px"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<div style="font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:var(--text);margin-bottom:6px">Доступ к симуляциям закрыт</div>
<div style="font-size:.88rem">Администратор ограничил доступ к лаборатории</div>
</div>`;
return;
}
// store quiz permission for later use
const qp = permData.permissions?.find(p => p.key === 'simulations.quiz');
window._simQuizAllowed = !qp || qp.effective !== false;
} else {
window._simQuizAllowed = true;
}
if (_simModuleDisabled) {
document.getElementById('sim-grid').innerHTML =
`<div style="grid-column:1/-1;padding:60px 0;text-align:center;color:var(--text-3)">
<div style="font-size:2rem;margin-bottom:12px"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<div style="font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:var(--text);margin-bottom:6px">Модуль симуляций отключён</div>
<div style="font-size:.88rem">Администратор временно отключил лабораторию</div>
</div>`;
} else {
renderSims();
// Конструктор симуляций (Фаза 5): подтянуть custom-sims (свои + published),
// зарегистрировать ленивые манифесты и дорисовать секцию «Мои симуляции».
// Если deep-link ведёт на custom-симуляцию — открыть её ПОСЛЕ загрузки списка.
var _customAuto = _autoSim && /^custom:/i.test(_autoSim);
var _customReady = (window.LabCustom && window.LabCustom.init)
? window.LabCustom.init() : Promise.resolve();
if (_autoSim && !_customAuto) openSim(_autoSim);
else if (_customAuto) _customReady.then(function () { openSim(_autoSim); });
// hash-router: activate sim from URL fragment after catalogue renders
else _activateFromHash();
}
});
lucide.createIcons();
LS.notif.init();
}
/* ─── Hash router for sim deep-links ─────────────────────────────────────
URL pattern: /lab#sim/<name>
<name> matches SIMS[i].id (e.g. 'projectile', 'graph', 'chemsandbox').
F5 restores sim. Browser back/forward switches between sims.
Click on sim-card updates URL via wrapped openSim.
──────────────────────────────────────────────────────────────────────── */
// Build valid-id set from SIMS catalogue (filters out "coming soon" entries)
const _SIM_HASH_MAP = {};
SIMS.forEach(function(s) { if (s.id) { _SIM_HASH_MAP[s.id] = s.id; } });
// backward-compat aliases: old URLs redirect to unified emfield sim
_SIM_HASH_MAP['magnetic'] = 'magnetic';
_SIM_HASH_MAP['coulomb'] = 'coulomb';
// backward-compat aliases: old optics sims redirect to opticsbench
_SIM_HASH_MAP['thinlens'] = 'opticsbench';
_SIM_HASH_MAP['mirrors'] = 'opticsbench';
_SIM_HASH_MAP['refraction'] = 'opticsbench';
var _routerNavigating = false;
function _activateFromHash() {
var m = (location.hash || '').match(/^#sim\/([\w-]+)/);
if (!m) return false;
var simName = m[1];
if (!_SIM_HASH_MAP[simName]) {
// eslint-disable-next-line no-console
window.console && window.console.warn('lab-router: unknown sim', simName);
return false;
}
openSim(simName);
return true;
}
// Intercept openSim to push URL hash on user-initiated navigation
var _origOpenSim = openSim;
openSim = function(id) {
_origOpenSim(id);
if (!_routerNavigating && !_embedMode) {
var baseId = id.includes(':') ? id.split(':')[0] : id;
if (_SIM_HASH_MAP[baseId]) {
_routerNavigating = true;
location.hash = '#sim/' + baseId;
// use setTimeout so hashchange fires after flag is set
setTimeout(function() { _routerNavigating = false; }, 0);
}
}
};
/* ─── Sim Fade Transition + View Transitions API ─────────────────────────
Wraps openSim with a fade-out (150ms) → swap → fade-in (200ms) sequence.
If document.startViewTransition is available it is used for GPU-composited
cross-fade; otherwise the manual .sim-fading CSS class is toggled.
The hash-router wrap above runs synchronously during the transition so URL
updates are not delayed.
──────────────────────────────────────────────────────────────────────── */
var _hashRouterOpenSim = openSim; // reference after hash-router wrap
openSim = function(id) {
var labSim = document.getElementById('lab-sim');
if (!labSim) { _hashRouterOpenSim(id); return; }
function _doSwitch() {
labSim.classList.add('sim-fading');
setTimeout(function() {
_hashRouterOpenSim(id);
labSim.classList.remove('sim-fading');
}, 150);
}
if (typeof document.startViewTransition === 'function' && !_embedMode) {
document.startViewTransition(function() {
_hashRouterOpenSim(id);
});
} else {
_doSwitch();
}
};
// Intercept closeSim to clear hash when returning to home grid
var _origCloseSim = closeSim;
closeSim = function() {
_origCloseSim();
if (!_embedMode) {
_routerNavigating = true;
history.pushState(null, '', location.pathname + location.search);
setTimeout(function() { _routerNavigating = false; }, 0);
}
};
// Browser back/forward navigation
window.addEventListener('hashchange', function() {
if (_routerNavigating) return;
var hasHash = _activateFromHash();
if (!hasHash && document.getElementById('lab-sim').classList.contains('open')) {
_origCloseSim();
}
});
/* ════════════════════════════════════════════════════════════════════════
LabCustom — каталог пользовательских симуляций (Конструктор симуляций, Фаза 5)
Подтягивает сохранённые custom-sims (свои любого статуса + чужие published)
через LS.customSimsList(), регистрирует ЛЕНИВЫЕ манифесты в LabRegistry и
рисует отдельную секцию «Мои симуляции» в #lab-home (карточки переиспользуют
стили .sim-card). Спека (тяжёлый JSON) тянется лениво при ПЕРВОМ открытии
(LS.customSimGet -> registerSpecSim из Ф0-адаптера), а не на старте /lab.
id-неймспейс: deep-link/клик — 'custom:<dbid>'; в LabRegistry — 'customsim_<dbid>'
(реестр обрезает часть после ':' в get/has, поэтому двоеточие там недопустимо).
openSim() переводит одно в другое через LabCustom.resolveId (хук в lab-init.js).
Самодостаточно: создаёт контейнер секции динамически, без правок lab.html/CSS —
меньше риск конфликта с параллельными сессиями. Падение загрузки (нет сети/404)
не ломает каталог встроенных — секция просто не появляется (try/catch).
════════════════════════════════════════════════════════════════════════ */
(function () {
var REG_PREFIX = 'customsim_';
var _meta = {}; // dbid -> мета-запись из списка (без spec)
var _order = []; // dbid в порядке выдачи списка
var _specCache = {}; // dbid -> распарсенная spec (кэш ленивой загрузки)
var _specPromise = {}; // dbid -> Promise загрузки spec (дедуп)
var _initPromise = null;
function _uid() { try { return (typeof user !== 'undefined' && user) ? user.id : null; } catch (e) { return null; } }
function _regId(dbid) { return REG_PREFIX + dbid; }
function _dbIdOf(id) {
if (id == null) return null;
var s = String(id);
if (s.indexOf('custom:') === 0) return s.slice(7).split(':')[0];
if (s.indexOf(REG_PREFIX) === 0) return s.slice(REG_PREFIX.length);
return null;
}
function _esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function _isOwner(m) {
var uid = _uid();
return m && uid != null && String(m.owner_id) === String(uid);
}
// deep-link/клик 'custom:<dbid>' -> реестровый id; встроенные id не трогаем.
function resolveId(id) {
var dbid = _dbIdOf(id);
return dbid != null ? _regId(dbid) : id;
}
// Лениво получить spec симуляции (кэш + дедуп параллельных запросов).
function ensureSpec(dbid) {
if (_specCache[dbid]) return Promise.resolve(_specCache[dbid]);
if (_specPromise[dbid]) return _specPromise[dbid];
if (!window.LS || !LS.customSimGet) return Promise.resolve(null);
_specPromise[dbid] = LS.customSimGet(dbid).then(function (data) {
var sim = data && data.sim;
var spec = sim && sim.spec;
if (spec) { _specCache[dbid] = spec; }
delete _specPromise[dbid];
return spec || null;
}).catch(function (e) {
delete _specPromise[dbid];
if (window.console) console.warn('[LabCustom] не удалось загрузить спеку', dbid, e);
return null;
});
return _specPromise[dbid];
}
// Зарегистрировать ЛЕНИВЫЙ манифест-заглушку для одной custom-sim.
// При первом open() — подтянуть spec и заменить заглушку реальным манифестом
// (registerSpecSim из Ф0-адаптера строит полноценный SimEngine-манифест).
function _registerLazy(m) {
if (!window.LabRegistry) return;
var dbid = m.id;
var rid = _regId(dbid);
var manifest = {
id: rid,
cat: m.cat || 'phys',
title: m.title || ('Симуляция #' + dbid),
desc: m.description || '',
subject: m.subject,
grade: m.grade,
_custom: true, // секция рисует их отдельно (см. renderSims)
_customId: dbid,
open: function (ctx) {
return ensureSpec(dbid).then(function (spec) {
if (!spec) {
if (window.console) console.warn('[LabCustom] спека пуста для', dbid);
return;
}
spec.id = rid; // реестровый id без двоеточия
if (!spec.cat) spec.cat = m.cat || 'phys';
if (!spec.subject && m.subject) spec.subject = m.subject;
if (!spec.grade && m.grade != null) spec.grade = m.grade;
var real = window.registerSpecSim
? window.registerSpecSim(spec) // заменит заглушку на месте (тот же id)
: null;
if (real) {
real._custom = true;
real._customId = dbid;
if (window.LabRegistry) window.LabRegistry.setActive(real);
return real.open(ctx);
}
});
}
};
window.LabRegistry.register(manifest);
}
function _catLabel(cat) {
if (cat === 'math') return '∑ Математика';
if (cat === 'chem') return '<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> Химия';
if (cat === 'bio') return '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/></svg> Биология';
if (cat === 'game') return '<svg class="ic" viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры';
return (typeof LS !== 'undefined' && LS.icon ? LS.icon('zap', 14) : '') + ' Физика';
}
function _ensureSectionHost() {
var host = document.getElementById('custom-sim-section');
if (host) return host;
var home = document.getElementById('lab-home');
var grid = document.getElementById('sim-grid');
if (!home) return null;
host = document.createElement('div');
host.id = 'custom-sim-section';
host.style.cssText = 'margin-top:34px';
if (grid && grid.parentNode === home) home.appendChild(host);
else home.appendChild(host);
return host;
}
var _EDIT_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>';
var _DEL_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
function _cardHtml(m) {
var owner = _isOwner(m);
var published = m.status === 'published';
var rid = _regId(m.id);
var badges = '';
if (owner) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(155,93,229,.16);color:var(--violet);border:1px solid rgba(155,93,229,.34)">Моя</span>';
if (published) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(52,211,153,.14);color:#34d399;border:1px solid rgba(52,211,153,.32)">Опубликована</span>';
else if (owner) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(255,255,255,.06);color:var(--text-3);border:1px solid rgba(255,255,255,.14)">Черновик</span>';
var actions = '';
if (owner) {
actions =
'<div style="display:flex;gap:8px;margin-top:12px">' +
'<button type="button" data-act="edit" data-id="' + _esc(m.id) + '" ' +
'style="flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:.78rem;font-weight:700;padding:7px 10px;border-radius:10px;cursor:pointer;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)">' +
_EDIT_ICON + 'Редактировать</button>' +
'<button type="button" data-act="del" data-id="' + _esc(m.id) + '" ' +
'style="display:inline-flex;align-items:center;justify-content:center;padding:7px 11px;border-radius:10px;cursor:pointer;background:rgba(244,91,105,.1);color:#f45b69;border:1px solid rgba(244,91,105,.28)" title="Удалить">' +
_DEL_ICON + '</button>' +
'</div>';
}
var preview = '<svg class="sim-preview" viewBox="0 0 300 140" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">' +
'<rect width="300" height="140" fill="#0D0D1A"/>' +
'<line x1="20" y1="120" x2="280" y2="120" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
'<line x1="30" y1="20" x2="30" y2="130" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
'<path d="M30 120 Q120 30 270 110" fill="none" stroke="#06D6E0" stroke-width="2.5"/>' +
'<circle cx="150" cy="64" r="5" fill="#9B5DE5"/></svg>';
return '' +
'<div class="sim-card" data-open="' + _esc('custom:' + m.id) + '">' +
preview +
'<div class="sim-body">' +
'<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">' +
'<span class="sim-cat ' + _esc(m.cat || 'phys') + '">' + _catLabel(m.cat) + '</span>' + badges +
'</div>' +
'<div class="sim-title">' + _esc(m.title || ('Симуляция #' + m.id)) + '</div>' +
'<div class="sim-desc">' + _esc(m.description || 'Пользовательская симуляция') +
(m.grade != null && m.grade !== '' ? ' · ' + _esc(m.grade) + ' класс' : '') + '</div>' +
actions +
'</div>' +
'</div>';
}
// Видимые в данной вкладке записи (фильтр категорий применяем и к custom).
function _visible(catFilter) {
return _order
.map(function (id) { return _meta[id]; })
.filter(function (m) { return m && (catFilter === 'all' || (m.cat || 'phys') === catFilter); });
}
function renderSection(catFilter) {
var host = _ensureSectionHost();
if (!host) return;
var list = _visible(catFilter || _catFilter);
if (!list.length) { host.innerHTML = ''; host.style.display = 'none'; return; }
host.style.display = '';
var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">' +
'<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;stroke:var(--violet)"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' +
'<span style="font-family:\'Unbounded\',sans-serif;font-size:1rem;font-weight:800">Мои симуляции</span>' +
'<span style="font-size:.78rem;color:var(--text-3)">собранные в конструкторе</span></div>';
host.innerHTML = head + '<div class="sim-grid">' + list.map(_cardHtml).join('') + '</div>';
if (window.lucide) lucide.createIcons();
}
// Делегированные клики по секции: открыть / редактировать / удалить.
document.addEventListener('click', function (ev) {
var host = document.getElementById('custom-sim-section');
if (!host || !host.contains(ev.target)) return;
var actBtn = ev.target.closest ? ev.target.closest('[data-act]') : null;
if (actBtn && host.contains(actBtn)) {
ev.preventDefault();
ev.stopPropagation();
var act = actBtn.getAttribute('data-act');
var id = actBtn.getAttribute('data-id');
if (act === 'edit') {
location.href = '/sim-builder?id=' + encodeURIComponent(id);
} else if (act === 'del') {
del(id);
}
return;
}
var card = ev.target.closest ? ev.target.closest('[data-open]') : null;
if (card && host.contains(card)) {
var openId = card.getAttribute('data-open');
if (openId) openSim(openId);
}
});
function del(dbid) {
var m = _meta[dbid];
var name = (m && m.title) || ('симуляцию #' + dbid);
if (!window.confirm('Удалить «' + name + '»? Это действие необратимо.')) return;
if (!window.LS || !LS.customSimDelete) return;
LS.customSimDelete(dbid).then(function () {
delete _meta[dbid];
delete _specCache[dbid];
_order = _order.filter(function (x) { return String(x) !== String(dbid); });
renderSection(_catFilter);
}).catch(function (e) {
if (window.LS && LS.toast) LS.toast('Не удалось удалить симуляцию', 'error');
else if (window.console) console.warn('[LabCustom] delete failed', dbid, e);
});
}
// Загрузить список custom-sims, зарегистрировать ленивые манифесты, нарисовать секцию.
function init() {
if (_initPromise) return _initPromise;
if (!window.LS || !LS.customSimsList) { _initPromise = Promise.resolve(); return _initPromise; }
_initPromise = LS.customSimsList().then(function (data) {
var sims = (data && data.sims) || [];
_order = [];
sims.forEach(function (s) {
if (s == null || s.id == null) return;
_meta[s.id] = s;
_order.push(s.id);
_registerLazy(s);
});
renderSection(_catFilter);
}).catch(function (e) {
// мягко: нет сети/прав — секция просто не появится, встроенные работают
if (window.console) console.warn('[LabCustom] список custom-sims недоступен', e);
});
return _initPromise;
}
window.LabCustom = {
init: init,
resolveId: resolveId,
renderSection: renderSection,
ensureSpec: ensureSpec,
del: del
};
})();