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

997 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
}
/* Конструктор симуляций (Фаза 7): подключить custom-sim (SimEngine-инстанс через
адаптерный манифест real.instance()) к тому же мосту sim_state/apply_sim_state,
что и встроенные. Состояние = { params, running } — параметры слайдеров +
признак воспроизведения. applyState проигрывает их у ученика через setParam/
play/pause (время жёстко не синхронится — параметры и play/pause достаточны).
Регистрируем под ключом _autoSim ('custom:<dbid>'), т.к. обработчик
apply_sim_state у ученика берёт _simStateRegistry[_autoSim]. */
function _bridgeCustomSimState(real) {
if (!_embedMode || !real || typeof real.instance !== 'function') return;
var key = _autoSim;
if (!key || _simStateRegistry[key]) return; // уже подключено
function getState() {
var inst = real.instance();
if (!inst || !inst.params) return null;
var p = {};
for (var k in inst.params) {
if (Object.prototype.hasOwnProperty.call(inst.params, k)) {
var v = inst.params[k];
if (typeof v === 'number' && isFinite(v)) p[k] = v;
}
}
return { params: p, running: !!(inst.isRunning && inst.isRunning()) };
}
function applyState(st) {
var inst = real.instance();
if (!inst || !st) return;
if (st.params) {
for (var k in st.params) {
if (Object.prototype.hasOwnProperty.call(st.params, k)) inst.setParam(k, st.params[k]);
}
}
var run = !!st.running, isRun = !!(inst.isRunning && inst.isRunning());
if (run && !isRun && inst.play) inst.play();
else if (!run && isRun && inst.pause) inst.pause();
}
_registerSimState(key, getState, applyState);
_startStateEmit(key);
}
// 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);
var _r = real.open(ctx);
// Конструктор симуляций (Фаза 7): синхрон параметров/play на доске
// онлайн-урока. В embed подключаем custom-sim к общему мосту
// sim_state/apply_sim_state — тем же каналом, что и встроенные.
// Ключ — исходный _autoSim ('custom:<dbid>'), т.к. apply_sim_state
// у ученика берёт _simStateRegistry[_autoSim].
try { _bridgeCustomSimState(real); } catch (e) {}
return _r;
}
});
}
};
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>';
var _SHARE_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
var _CLONE_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
var _PUB_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
var _UNPUB_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M2 2l20 20"/><path d="M12 2a15.3 15.3 0 0 1 4 10c0 1.3-.2 2.6-.5 3.8M6.5 6.5A15.3 15.3 0 0 0 12 22a15.3 15.3 0 0 0 3.3-5"/><path d="M2 12h7m6 0h7"/></svg>';
function _isTeacherUser() {
try { return typeof user !== 'undefined' && user && (user.role === 'teacher' || user.role === 'admin'); }
catch (e) { return false; }
}
function _btn(act, id, html, extra, title) {
return '<button type="button" data-act="' + act + '" data-id="' + _esc(id) + '" ' +
(title ? 'title="' + _esc(title) + '" ' : '') +
'style="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;' + (extra || '') + '">' +
html + '</button>';
}
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) {
var STYLE_PRI = 'flex:1;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)';
var STYLE_GHOST = 'background:rgba(255,255,255,.05);color:var(--text-2);border:1px solid rgba(255,255,255,.16)';
var STYLE_DEL = 'background:rgba(244,91,105,.1);color:#f45b69;border:1px solid rgba(244,91,105,.28)';
var pubBtn = published
? _btn('unpublish', m.id, _UNPUB_ICON, STYLE_GHOST, 'Снять с публикации')
: _btn('publish', m.id, _PUB_ICON, STYLE_GHOST, 'Опубликовать');
actions =
'<div style="display:flex;gap:8px;margin-top:12px">' +
_btn('edit', m.id, _EDIT_ICON + 'Редактировать', STYLE_PRI) +
_btn('del', m.id, _DEL_ICON, STYLE_DEL, 'Удалить') +
'</div>' +
'<div style="display:flex;gap:8px;margin-top:8px">' +
_btn('share', m.id, _SHARE_ICON + 'Раздать классу', STYLE_GHOST + ';flex:1') +
pubBtn +
'</div>';
} else if (published && _isTeacherUser()) {
actions =
'<div style="display:flex;gap:8px;margin-top:12px">' +
_btn('clone', m.id, _CLONE_ICON + 'Клонировать к себе',
'flex:1;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)') +
'</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);
} else if (act === 'share') {
shareToClass(id);
} else if (act === 'clone') {
clone(id);
} else if (act === 'publish') {
setStatus(id, 'published');
} else if (act === 'unpublish') {
setStatus(id, 'draft');
}
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);
});
}
// Опубликовать / снять с публикации (владельцу). PUT status.
function setStatus(dbid, status) {
if (!window.LS || !LS.customSimUpdate) return;
LS.customSimUpdate(dbid, { status: status }).then(function () {
if (_meta[dbid]) _meta[dbid].status = status;
renderSection(_catFilter);
if (LS.toast) LS.toast(status === 'published' ? 'Опубликовано' : 'Снято с публикации', 'success');
}).catch(function (e) {
if (LS.toast) LS.toast((e && e.message) || 'Не удалось изменить статус', 'error');
});
}
// Клонировать чужую (published) симуляцию к себе как черновик и открыть в билдере.
function clone(dbid) {
if (!window.LS || !LS.customSimClone) return;
LS.customSimClone(dbid).then(function (res) {
var newId = res && res.id;
if (newId) {
if (LS.toast) LS.toast('Скопировано в ваши черновики', 'success');
location.href = '/sim-builder?id=' + encodeURIComponent(newId);
}
}).catch(function (e) {
if (LS.toast) LS.toast((e && e.message) || 'Не удалось клонировать', 'error');
});
}
// Раздать классу: модалка выбора класса -> LS.customSimShare (авто-публикует
// и шлёт уведомление ученикам со ссылкой /lab?sim=custom:<id>).
function shareToClass(dbid) {
if (!window.LS || !LS.customSimShare || !LS.getClasses || !LS.modal) return;
LS.getClasses().then(function (classes) {
if (!Array.isArray(classes) || !classes.length) {
if (LS.toast) LS.toast('Нет классов для раздачи', 'warn');
return;
}
var opts = classes.map(function (c) {
return '<option value="' + _esc(c.id) + '">' + _esc(c.name) + '</option>';
}).join('');
var content = '<div style="display:flex;flex-direction:column;gap:8px">' +
'<label style="font-size:.8rem;color:var(--text-3)">Класс</label>' +
'<select id="cs-share-class" style="width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:9px;font:inherit;background:var(--surface);color:var(--text)">' + opts + '</select>' +
'<div style="font-size:.78rem;color:var(--text-3)">Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.</div>' +
'</div>';
var m = LS.modal({ title: 'Раздать классу', content: content, size: 'sm', actions: [
{ label: 'Отмена', onClick: function () { m.close(); } },
{ label: 'Раздать', primary: true, onClick: function () {
var sel = m.body.querySelector('#cs-share-class');
var classId = sel ? Number(sel.value) : NaN;
LS.customSimShare(dbid, { classId: classId }).then(function (r) {
m.close();
if (_meta[dbid]) _meta[dbid].status = 'published';
renderSection(_catFilter);
if (LS.toast) LS.toast('Отправлено ученикам: ' + ((r && r.sent) || 0), 'success');
}).catch(function (e) {
if (LS.toast) LS.toast((e && e.message) || 'Ошибка раздачи', 'error');
});
} }
] });
}).catch(function () {
if (LS.toast) LS.toast('Не удалось загрузить классы', 'error');
});
}
// Загрузить список 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,
share: shareToClass,
clone: clone,
setStatus: setStatus
};
})();