feat(sim-builder): фаза 5 — каталог custom-sims в /lab (LabCustom: ленивая регистрация, секция, deep-link)

This commit is contained in:
Maxim Dolgolyov
2026-06-13 12:48:21 +03:00
parent a13c0b77fa
commit 1bee332ae1
6 changed files with 410 additions and 20 deletions
+9
View File
@@ -105,3 +105,12 @@ git push origin master
- **Клиентская валидация зеркалит серверную** (Ф3 лимиты) и показывает дружелюбную модалку-список ДО запроса — экономит round-trip и даёт понятные ошибки вместо сырого 400. - **Клиентская валидация зеркалит серверную** (Ф3 лимиты) и показывает дружелюбную модалку-список ДО запроса — экономит round-trip и даёт понятные ошибки вместо сырого 400.
- **Сайдбар — аддитивно**: пункт `/sim-builder` в `js/sidebar.js` в группе `G('practice',...)` после `/lab`, паттерн `{ cls:'sb-teacher-only', hidden:!isTch }`. `isActive('/sim-builder')` подсвечивает на странице (clean URL без .html). НЕ ломает остальной сайдбар. - **Сайдбар — аддитивно**: пункт `/sim-builder` в `js/sidebar.js` в группе `G('practice',...)` после `/lab`, паттерн `{ cls:'sb-teacher-only', hidden:!isTch }`. `isActive('/sim-builder')` подсвечивает на странице (clean URL без .html). НЕ ломает остальной сайдбар.
- **Верификация без jsdom**: headless-смоук — `vm.createContext` + ручной DOM/window/Blob-стаб (canvas getContext-заглушка, setTimeout no-op чтобы debounce не стрелял), грузим `_sim_expr.js`+`sim-builder.js`, дёргаем `buildSpec()`/`validate()`/`loadFromSim()` напрямую (рендер не нужен для логики). 23/23. - **Верификация без jsdom**: headless-смоук — `vm.createContext` + ручной DOM/window/Blob-стаб (canvas getContext-заглушка, setTimeout no-op чтобы debounce не стрелял), грузим `_sim_expr.js`+`sim-builder.js`, дёргаем `buildSpec()`/`validate()`/`loadFromSim()` напрямую (рендер не нужен для логики). 23/23.
### Phase 5 — Learnings
- **id-неймспейс custom: гочи LabRegistry**. `LabRegistry.get/has` обрезают часть после `:` (`_baseId`), т.к. встроенные используют `base:arg` (`emfield:E`, `stereo:cube`). Поэтому регистрировать `custom:42` НЕЛЬЗЯ — `has('custom:42')` искал бы `_byId['custom']`. Решение: в реестре id **без двоеточия** `customsim_<dbid>`, а наружу (deep-link/клик/`data-open`) — `custom:<dbid>`. Конвертация одной функцией `LabCustom.resolveId` через хук в начале `openSim` (lab-init.js, +7 строк).
- **Ленивый манифест-заглушка вместо ранней загрузки spec**. На старте /lab грузим только мету (`customSimsList`, без spec) и регистрируем заглушку с асинхронным `open()`. При первом открытии: `ensureSpec(dbid)` (`customSimGet`, кэш+дедуп) → `registerSpecSim(spec)` (Ф0-адаптер) **заменяет заглушку на месте** (`LabRegistry.register` сохраняет позицию по тому же id) → `setActive(real)` + `real.open(ctx)`. Дисп. в `openSim` уже умеет Promise-возврат `open()` (Ф3). Повторное открытие — синхронно (реальный манифест в реестре). Движок `_sim_*` уже eager (Ф0) → ленивый файл не нужен, `_sim_deps.js` не трогаем.
- **Аддитивность в чужих файлах**: вся логика — в новом IIFE `window.LabCustom` в КОНЦЕ lab-glue.js; в существующий код добавлены только хуки: `renderSims()` merge +`&& !m._custom` (1 терм) + вызов `renderSection`; init зовёт `init()`. Секция «Мои симуляции» (`#custom-sim-section`) создаётся **динамически** в `#lab-home` — без правок lab.html/CSS (тот же приём, что `_loadRelated` в Ф-каталоге). Карточки переиспользуют `.sim-card/.sim-cat/.sim-preview`; бейджи/кнопки — inline-стиль + SVG `.ic` (без эмодзи).
- **Owner-only действия**: `owner_id === user.id` (user из `LS.initPage()`, поле `id` — канон всего фронта, ср. `t.createdBy === user.id` в theory.html). Edit → `location.href='/sim-builder?id='+dbid`; Delete → `LS.customSimDelete` + убрать карточку. Делегированный клик по контейнеру секции: `data-act` (edit/del, `stopPropagation`) vs `data-open` (открыть). Видимость draft/published обеспечивает сервер Ф3 (список = свои+чужие published).
- **Embed/Ф7 заметка**: для `?sim=custom:*` открытие отложено до `LabCustom.init()` (и в обычном, и в embed-режиме). `_loadRelated('customsim_<id>')` дергает `/api/lab/sims/.../related` (404, тихо). LabRegistry не имеет unregister → удалённая custom остаётся заглушкой в реестре (карточки нет, ensureSpec вернёт 404). Источник spec для доски (Ф7): `LabCustom.ensureSpec(dbid)`.
- **Смоук на РЕАЛЬНОМ registry/adapter**: harness грузит настоящие `_registry.js`+`_sim_adapter.js` в `vm`-контекст, стабит только SimEngine/LS/DOM, извлекает IIFE `LabCustom` из lab-glue по маркеру и прогоняет init→open→del. Гочи стаба: реальный код проверяет `window.LS` (api.js ставит и `window.LS`, и глобал `LS`) — в стабе надо ставить ОБА; `document.getElementById` стаба должен находить и динамически `appendChild`-нутые элементы (регистрировать по id в appendChild). 22/22.
+290 -3
View File
@@ -32,7 +32,9 @@
_merged.push(s.id && _regById[s.id] ? _regById[s.id] : s); _merged.push(s.id && _regById[s.id] ? _regById[s.id] : s);
if (s.id) _seen[s.id] = 1; if (s.id) _seen[s.id] = 1;
}); });
_reg.forEach(m => { if (!_seen[m.id]) _merged.push(m); }); // Конструктор симуляций (Фаза 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 base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter);
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id)); const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
@@ -46,6 +48,10 @@
</div> </div>
${!s.id ? '<div class="sim-soon-badge">Скоро</div>' : ''} ${!s.id ? '<div class="sim-soon-badge">Скоро</div>' : ''}
</div>`).join(''); </div>`).join('');
// Конструктор симуляций (Фаза 5): дорисовать секцию «Мои симуляции».
if (window.LabCustom && typeof window.LabCustom.renderSection === 'function') {
try { window.LabCustom.renderSection(_catFilter); } catch (e) {}
}
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
} }
@@ -412,7 +418,14 @@ const SIMS = [
document.getElementById('lab-sim').classList.add('open'); document.getElementById('lab-sim').classList.add('open');
document.querySelector('.sim-topbar').style.display = 'none'; document.querySelector('.sim-topbar').style.display = 'none';
// defer until all external scripts are loaded // defer until all external scripts are loaded
window.addEventListener('load', () => openSim(_autoSim)); // Конструктор симуляций (Фаза 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 { } else {
/* init — fetch sim settings + permissions in parallel, then render */ /* init — fetch sim settings + permissions in parallel, then render */
@@ -455,7 +468,14 @@ const SIMS = [
</div>`; </div>`;
} else { } else {
renderSims(); renderSims();
if (_autoSim) openSim(_autoSim); // Конструктор симуляций (Фаза 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 // hash-router: activate sim from URL fragment after catalogue renders
else _activateFromHash(); else _activateFromHash();
} }
@@ -560,3 +580,270 @@ const SIMS = [
_origCloseSim(); _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
};
})();
+7
View File
@@ -105,6 +105,13 @@
} }
function openSim(id) { function openSim(id) {
// Конструктор симуляций (Фаза 5): custom-sims регистрируются в LabRegistry под
// id без двоеточия (LabRegistry.get/has обрезают часть после ':'). Хук resolveId
// переводит deep-link/клик 'custom:<dbid>' в реестровый id и лениво подтягивает
// спеку при первом открытии. Для встроенных симуляций id не меняется.
if (window.LabCustom && typeof window.LabCustom.resolveId === 'function') {
id = window.LabCustom.resolveId(id) || id;
}
if (_disabledSimIds.has(id.split(':')[0])) return; if (_disabledSimIds.has(id.split(':')[0])) return;
document.getElementById('lab-home').style.display = 'none'; document.getElementById('lab-home').style.display = 'none';
document.getElementById('lab-sim').classList.add('open'); document.getElementById('lab-sim').classList.add('open');
+34 -2
View File
@@ -1,6 +1,33 @@
# Feature Context: Конструктор симуляций (SimForge) # Feature Context: Конструктор симуляций (SimForge)
## Current State ## Current State
- **Фаза 5 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только
**аддитивные** правки двух файлов параллельной сессии (без рефактора их кода): рабочее дерево
по ним было ЧИСТЫМ до начала. classroom.html / backend / `_sim_deps.js` НЕ тронуты.
- **`frontend/js/labs/lab-init.js`** (+7 строк): в начало `openSim(id)` добавлен хук
`if (window.LabCustom && LabCustom.resolveId) id = LabCustom.resolveId(id) || id;`
переводит deep-link/клик `custom:<dbid>` в реестровый id `customsim_<dbid>` (LabRegistry.get/has
обрезают часть после `:`, поэтому в реестре двоеточие недопустимо). Для встроенных id — no-op.
- **`frontend/js/labs/lab-glue.js`**: (а) `renderSims()` merge +`&& !m._custom` (custom не в
основной сетке) и вызов `LabCustom.renderSection(_catFilter)`; (б) init-блок (non-embed и embed)
зовёт `LabCustom.init()`, отложенное открытие `?sim=custom:*` до загрузки списка; (в) новый
IIFE **`window.LabCustom`** в конце файла.
- **Поток**: `LS.customSimsList()` (мета без spec) → `_registerLazy` кладёт в LabRegistry
манифест-заглушку `customsim_<dbid>` (`_custom:true`) с ленивым `open()`. Секция «Мои симуляции»
`#custom-sim-section` (создаётся динамически в `#lab-home`, без правок lab.html/CSS) рендерит
карточки из `_meta`. Открытие: `resolveId` → дисп. реестра → `open()` заглушки →
`ensureSpec(dbid)` (`LS.customSimGet`, кэш+дедуп) → `spec.id=regId``registerSpecSim(spec)`
(Ф0-адаптер, заменяет заглушку на месте) → `setActive(real)`+`real.open(ctx)` (монтирует SimEngine).
**spec лениво** — на старте /lab не грузится. Движок (`_sim_*`) уже eager (Ф0), ленивый файл не нужен.
- **Карточка**: preview-SVG + cat-бейдж + бейджи «Моя»(owner)/«Опубликована»(status)/«Черновик»
+ кнопки «Редактировать»→`/sim-builder?id=<dbid>` / «Удалить»→`LS.customSimDelete` (владельцу,
`owner_id===user.id`). Делегированный клик по `#custom-sim-section`. Иконки — inline SVG `.ic`.
- Верификация: `node --check` обоих изменённых файлов OK; эмодзи нет (скан кодпойнтов — только
math/box-drawing глифы ∑/═/─/→, как в существующем коде); eval/Function нет; headless-смоук
(vm + DOM/SimEngine/LS-стабы, РЕАЛЬНЫЕ `_registry.js`+`_sim_adapter.js`) 22/22: resolveId,
регистрация ленивых манифестов+флаг `_custom`, секция/карточки, бейджи, owner-only edit/del,
deep-link `data-open`, lazy spec→registerSpecSim→mount, reopen синхронно, delete, встроенные не сломаны.
git status: изменены только lab-init.js/lab-glue.js (+ плановые .md); classroom.html/backend чисты.
- **Фаза 4 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только - **Фаза 4 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только
новые файлы `frontend/sim-builder.html` + `frontend/js/sim-builder.js` + аддитивная правка новые файлы `frontend/sim-builder.html` + `frontend/js/sim-builder.js` + аддитивная правка
`js/sidebar.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии). `js/sidebar.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
@@ -84,8 +111,13 @@
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`. - Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
## RESUME STATE ## RESUME STATE
- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 + Ф3 + Ф4 реализованы, ещё не закоммичены — ждут оркестратора) - Последний коммит фичи: — (Ф0 + Ф1 + Ф2 + Ф3 + Ф4 + Ф5 реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 4Builder UI (✅ Implemented, pending commit) → дальше Phase 5Каталог (custom-sims в /lab) - Текущая фаза: Phase 5Каталог (✅ Implemented, pending commit) → дальше Phase 6Раздача / шаблоны / клон / программа
- Файлы Ф5 (аддитивные правки зоны параллельной сессии — БЕЗ рефактора): `frontend/js/labs/lab-init.js`
(+7 строк: хук `LabCustom.resolveId` в `openSim`), `frontend/js/labs/lab-glue.js` (renderSims +`!m._custom`
и вызов renderSection; init зовёт `LabCustom.init()`; новый IIFE `window.LabCustom`). `_sim_deps.js`,
classroom.html, backend — НЕ тронуты. Публичное API: `window.LabCustom.{init,resolveId,renderSection,ensureSpec,del}`.
- id-неймспейс custom: deep-link/клик/`data-open` = `custom:<dbid>`; LabRegistry/host = `customsim_<dbid>`.
- Режим: Automated / Orchestrator / Incremental - Режим: Automated / Orchestrator / Incremental
- Файлы Ф4 (несведённые с параллельной сессией): `frontend/sim-builder.html` (new), - Файлы Ф4 (несведённые с параллельной сессией): `frontend/sim-builder.html` (new),
`frontend/js/sim-builder.js` (new), `js/sidebar.js` (modify, аддитивный пункт `/sim-builder`). `frontend/js/sim-builder.js` (new), `js/sidebar.js` (modify, аддитивный пункт `/sim-builder`).
+2 -2
View File
@@ -44,7 +44,7 @@
- [x] Phase 2: Физический интегратор [domain: frontend] → [subplan](./phase-2-physics.md) - [x] Phase 2: Физический интегратор [domain: frontend] → [subplan](./phase-2-physics.md)
- [x] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md) - [x] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md)
- [x] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md) - [x] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md)
- [ ] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md) - [x] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md)
- [ ] Phase 6: Раздача / шаблоны / клон / программа [domain: fullstack] → [subplan](./phase-6-sharing.md) - [ ] Phase 6: Раздача / шаблоны / клон / программа [domain: fullstack] → [subplan](./phase-6-sharing.md)
- [ ] Phase 7: Доска онлайн-урока [domain: fullstack] → [subplan](./phase-7-classroom.md) - [ ] Phase 7: Доска онлайн-урока [domain: fullstack] → [subplan](./phase-7-classroom.md)
@@ -57,7 +57,7 @@
| Phase 2: Physics | frontend | ✅ Done | ✅ | ✅ | ✅ | | Phase 2: Physics | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 3: Persistence + API | backend | ✅ Done | ✅ | ✅ | ✅ | | Phase 3: Persistence + API | backend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 4: Builder UI | frontend | ✅ Done | ✅ | ✅ | ✅ | | Phase 4: Builder UI | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 5: Catalog | fullstack | ⬜ Not Started | | | | | Phase 5: Catalog | fullstack | ✅ Done | | | |
| Phase 6: Sharing | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Sharing | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 7: Classroom | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 7: Classroom | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+68 -13
View File
@@ -1,6 +1,6 @@
# Phase 5: Каталог (custom-sims в /lab) # Phase 5: Каталог (custom-sims в /lab)
**Status:** ⬜ Not Started **Status:** ✅ Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором)
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack **Domain:** fullstack
@@ -9,13 +9,21 @@
раздел «Мои симуляции», редактирование/удаление из каталога, deep-link. раздел «Мои симуляции», редактирование/удаление из каталога, deep-link.
## Tasks ## Tasks
- [ ] При загрузке /lab подтягивать custom-sims (свои + published) из `GET /api/custom-sims` - [x] При загрузке /lab подтягивать custom-sims (свои + published) из `GET /api/custom-sims`
и регистрировать через `registerSpecSim` (Ф0-адаптер) с id `custom:<dbid>`. (`LS.customSimsList()`) и регистрировать ЛЕНИВЫЕ манифесты в LabRegistry. spec НЕ грузится
- [ ] Карточки в каталоге: категория/предмет/класс из меты; бейдж «Моя»/«Опубликована». на старте — тянется при первом открытии (`LS.customSimGet``registerSpecSim` Ф0-адаптера).
- [ ] Раздел/фильтр «Мои симуляции» в /lab. - [x] Карточки в каталоге: категория/предмет(grade)/из меты; бейдж «Моя» (owner) / «Опубликована»
- [ ] Кнопки на карточке custom-sim: «Редактировать» → `sim-builder.html?id=<id>`, «Удалить» (владельцу). (status) / «Черновик» (own draft).
- [ ] Deep-link `/lab?sim=custom:<id>` открывает напрямую (расширить существующий `LAB_SIM_ALIASES`/openSim). - [x] Раздел «Мои симуляции» в /lab — отдельная секция `#custom-sim-section` в `#lab-home`
- [ ] Ленивая загрузка движка (`_sim_*.js`) — только когда открыта custom-sim (через `_loader`/`_sim_deps`). (создаётся динамически, без правок lab.html/CSS); уважает текущий фильтр категорий.
- [x] Кнопки на карточке custom-sim: «Редактировать» → `/sim-builder?id=<dbid>`, «Удалить»
(`LS.customSimDelete`) — только владельцу (`owner_id === user.id`).
- [x] Deep-link `/lab?sim=custom:<dbid>` открывает напрямую: хук `LabCustom.resolveId` в `openSim`
(lab-init.js) переводит `custom:<dbid>` → реестровый id `customsim_<dbid>`; для custom deep-link
открытие отложено до загрузки списка (и в обычном, и в embed-режиме).
- [x] Ленивая загрузка: движок (`_sim_expr/_sim_engine/_sim_adapter`) уже eager в lab.html (Ф0),
поэтому отдельный ленивый файл не нужен; лениво грузится только spec (тяжёлый JSON) при открытии.
`_sim_deps.js` НЕ тронут.
## Files to Modify/Create ## Files to Modify/Create
- `frontend/js/labs/lab-glue.js` и/или `lab-init.js` — загрузка+регистрация custom-sims, карточки, фильтр (modify) - `frontend/js/labs/lab-glue.js` и/или `lab-init.js` — загрузка+регистрация custom-sims, карточки, фильтр (modify)
@@ -33,10 +41,57 @@
- id-неймспейс `custom:` чтобы не конфликтовать со встроенными. - id-неймспейс `custom:` чтобы не конфликтовать со встроенными.
## Review Checklist ## Review Checklist
- [ ] Все задачи выполнены - [x] Все задачи выполнены
- [ ] Встроенные симуляции и старт /lab не регрессировали - [x] Встроенные симуляции и старт /lab не регрессировали (custom исключены из основной сетки
- [ ] Draft видит только владелец; published — все по флагу `_custom`; падение загрузки списка не ломает каталог — try/catch + мягкий warn)
- [ ] Ленивая загрузка движка работает - [x] Draft видит только владелец; published — все (видимость обеспечивает сервер Ф3:
`customSimsList` отдаёт свои любого статуса + чужие published; бейджи/кнопки — по `owner_id`)
- [x] Ленивая загрузка spec работает (кэш + дедуп; на старте спеки не грузятся)
## Handoff to Next Phase ## Handoff to Next Phase
<!-- Заполняет реализатор -->
### Что реализовано (Phase 5)
Только аддитивные правки двух файлов параллельной сессии — без рефактора их кода:
- **`frontend/js/labs/lab-init.js`** (+7 строк): в начало `openSim(id)` добавлен хук —
`if (window.LabCustom && LabCustom.resolveId) id = LabCustom.resolveId(id) || id;`
Переводит `custom:<dbid>` → реестровый id `customsim_<dbid>` (LabRegistry.get/has обрезают
часть после `:`, поэтому в реестре двоеточие недопустимо). Для встроенных id — no-op.
- **`frontend/js/labs/lab-glue.js`**:
- `renderSims()`: 1 строка в merge — `&& !m._custom` (custom-манифесты не попадают в основную
сетку встроенных) + вызов `LabCustom.renderSection(_catFilter)` после рендера грида.
- init-блок (non-embed): после `renderSims()` зовёт `LabCustom.init()`; для `?sim=custom:*`
открытие отложено до резолва списка. Аналогично в embed-ветке (на `load`).
- **`window.LabCustom`** (новый IIFE в конце файла): `init()` (fetch списка, регистрация
ленивых манифестов, рендер секции), `resolveId`, `renderSection`, `ensureSpec`, `del`.
### Как custom-sims попадают в каталог и открываются
1. `LS.customSimsList()` → мета (без spec). Для каждой — `_registerLazy(meta)`: в LabRegistry
кладётся манифест-заглушка `id='customsim_<dbid>'`, `_custom:true`, мета-поля, и `open()`,
который при первом вызове лениво тянет spec.
2. Секция «Мои симуляции» (`#custom-sim-section`) рендерится из `_meta` (НЕ из реестра): карточки
`.sim-card` с `data-open="custom:<dbid>"`, бейджами и (владельцу) кнопками edit/del. Делегат
кликов на секции: открыть / `/sim-builder?id=` / `LS.customSimDelete`.
3. Открытие: `openSim('custom:<dbid>')``resolveId``customsim_<dbid>` → дисп. реестра →
`open()` заглушки → `ensureSpec(dbid)` (`LS.customSimGet`, кэш+дедуп) → `spec.id=<regId>`
`registerSpecSim(spec)` (Ф0-адаптер строит реальный SimEngine-манифест, ЗАМЕНЯЕТ заглушку на
месте) → `setActive(real)` + `real.open(ctx)` (монтирует SimEngine). Повторное открытие —
синхронно, реальный манифест уже в реестре, spec из кэша.
### id-неймспейс
- Deep-link / клик / `data-open`: **`custom:<dbid>`**.
- LabRegistry / host: **`customsim_<dbid>`** (без `:`). Конвертация — только в `resolveId`.
### Формат карточки
preview-SVG (плейсхолдер) + cat-бейдж (`.sim-cat`) + бейджи «Моя»/«Опубликована»/«Черновик»
+ title + desc (+ «N класс») + (владельцу) ряд кнопок «Редактировать»/«Удалить» (inline SVG `.ic`).
### Риски / заметки для Ф6
- `_loadRelated('customsim_<dbid>')` дергает `/api/lab/sims/.../related` (404 для custom) — тихо
глотается. Если Ф6 заведёт связи custom-sim с программой — учесть этот id.
- Удаление: после `customSimDelete` карточка убирается из секции, но манифест-заглушка остаётся
в LabRegistry (LabRegistry не имеет unregister). Не критично (карточки нет, deep-link на
удалённую вернёт 404 при ensureSpec). Если Ф6/Ф7 потребуют чистку — добавить unregister в реестр.
- Ф6 (раздача/публикация/клон/шаблоны): кнопку «Поделиться/Раздать классу» добавлять в `_cardHtml`
(ещё один `data-act`); публикацию toggle — там же. Клон — новый `LS.customSimCreate` со spec
из `ensureSpec`. Источник spec для доски (Ф7) — `LabCustom.ensureSpec(dbid)` или живой
`LabRegistry.get('customsim_<dbid>').instance()`.