'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 => `
${window.LabRegistry ? window.LabRegistry.resolvePreview(s) : s.preview}
${s.cat === 'math' ? '∑ Математика' : s.cat === 'chem' ? ' Химия' : s.cat === 'bio' ? ' Биология' : s.cat === 'game' ? ' Игры' : LS.icon('zap',14) + ' Физика'}
${s.title}
${s.desc}
${!s.id ? '
Скоро
' : ''}
`).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 = '
Теория для этой симуляции пока не добавлена
' + tasksHtml; if (tasksHtml) LabTasks.afterMount(simId); return; } let html = `
${LS.icon('book-open',16)} ${t.title}
`; for (const s of t.sections) { html += '
'; if (s.head) html += `
${s.head}
`; if (s.formula) html += `
`; if (s.text) html += `
${s.text}
`; if (s.vars) html += `
${s.vars.map(([v,d]) => `
${v} — ${d}
`).join('')}
`; html += '
'; } 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 = ''; function _labRelEsc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = '' + _LAB_LINK_ICON + ' Связано с программой'; all.forEach(function (l) { var label = _labRelEsc(l.label || (l.kind + ':' + l.ref_id)); if (l.href) { html += '' + label + ''; } else { html += '' + label + ''; } }); 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:'), т.к. обработчик 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 = `
Доступ к симуляциям закрыт
Администратор ограничил доступ к лаборатории
`; 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 = `
Модуль симуляций отключён
Администратор временно отключил лабораторию
`; } 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/ 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:'; в LabRegistry — 'customsim_' (реестр обрезает часть после ':' в 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function _isOwner(m) { var uid = _uid(); return m && uid != null && String(m.owner_id) === String(uid); } // deep-link/клик 'custom:' -> реестровый 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:'), т.к. 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 ' Химия'; if (cat === 'bio') return ' Биология'; if (cat === 'game') return ' Игры'; 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 = ''; var _DEL_ICON = ''; var _SHARE_ICON = ''; var _CLONE_ICON = ''; var _PUB_ICON = ''; var _UNPUB_ICON = ''; 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 ''; } function _cardHtml(m) { var owner = _isOwner(m); var published = m.status === 'published'; var rid = _regId(m.id); var badges = ''; if (owner) badges += 'Моя'; if (published) badges += 'Опубликована'; else if (owner) badges += 'Черновик'; 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 = '
' + _btn('edit', m.id, _EDIT_ICON + 'Редактировать', STYLE_PRI) + _btn('del', m.id, _DEL_ICON, STYLE_DEL, 'Удалить') + '
' + '
' + _btn('share', m.id, _SHARE_ICON + 'Раздать классу', STYLE_GHOST + ';flex:1') + pubBtn + '
'; } else if (published && _isTeacherUser()) { actions = '
' + _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)') + '
'; } var preview = '' + '' + '' + '' + '' + ''; return '' + '
' + preview + '
' + '
' + '' + _catLabel(m.cat) + '' + badges + '
' + '
' + _esc(m.title || ('Симуляция #' + m.id)) + '
' + '
' + _esc(m.description || 'Пользовательская симуляция') + (m.grade != null && m.grade !== '' ? ' · ' + _esc(m.grade) + ' класс' : '') + '
' + actions + '
' + '
'; } // Видимые в данной вкладке записи (фильтр категорий применяем и к 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 = '
' + '' + 'Мои симуляции' + 'собранные в конструкторе
'; host.innerHTML = head + '
' + list.map(_cardHtml).join('') + '
'; 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:). 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 ''; }).join(''); var content = '
' + '' + '' + '
Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.
' + '
'; 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 }; })();