fix(biochem 3D): корректная глубина + объёмные связи-цилиндры
Два дефекта, из-за которых 3D читался как плоская диаграмма: - painter-сортировка была по возрастанию z (ближние первыми) — дальние атомы рисовались поверх ближних. Теперь единый список примитивов (атомы + половинки связей) сортируется по убыванию z (дальние первыми). - связи были тонкими плоскими линиями. Теперь — затенённые «цилиндры»: толстый штрих с поперечным градиентом (центр светлее, края темнее), двухцветные (каждая половина под цвет своего атома) — фирменный вид ball-and-stick. Ширина зависит от перспективы (ближе — толще). - усилена перспектива (fov 900→700), добавлен тёмный ободок сфер для объёма. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+69
-25
@@ -396,62 +396,106 @@
|
||||
* cam: { rotX, rotY, scale, W, H }
|
||||
* opts: { vdw:false, bg:'#07070f', showSymbols:true }
|
||||
*/
|
||||
// Затенённый «цилиндр» связи: толстый штрих с поперечным градиентом (центр светлее, края темнее)
|
||||
function _stick(ctx, x1, y1, x2, y2, width, baseRgb, alpha) {
|
||||
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy) || 1;
|
||||
const ox = -dy / len, oy = dx / len;
|
||||
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
|
||||
const [r, g, b] = baseRgb;
|
||||
const grd = ctx.createLinearGradient(mx - ox * width, my - oy * width, mx + ox * width, my + oy * width);
|
||||
const dark = `rgba(${Math.round(r*0.35)},${Math.round(g*0.35)},${Math.round(b*0.35)},${alpha})`;
|
||||
const lite = `rgba(${Math.min(255,r+70)},${Math.min(255,g+70)},${Math.min(255,b+70)},${alpha})`;
|
||||
grd.addColorStop(0, dark);
|
||||
grd.addColorStop(0.42, lite);
|
||||
grd.addColorStop(0.5, `rgba(${Math.min(255,r+110)},${Math.min(255,g+110)},${Math.min(255,b+110)},${alpha})`);
|
||||
grd.addColorStop(0.58, lite);
|
||||
grd.addColorStop(1, dark);
|
||||
ctx.strokeStyle = grd;
|
||||
ctx.lineWidth = width * 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||||
}
|
||||
|
||||
function render3D(ctx, atoms3d, bonds, cam, opts) {
|
||||
opts = opts || {};
|
||||
const W = cam.W, H = cam.H;
|
||||
const cxr = Math.cos(cam.rotX), sxr = Math.sin(cam.rotX);
|
||||
const cyr = Math.cos(cam.rotY), syr = Math.sin(cam.rotY);
|
||||
const fov = 900, sc = cam.scale || 1;
|
||||
const fov = 700, sc = cam.scale || 1;
|
||||
|
||||
if (opts.bg !== null) { ctx.fillStyle = opts.bg || '#07070f'; ctx.fillRect(0, 0, W, H); }
|
||||
|
||||
if (!atoms3d || !atoms3d.length) return;
|
||||
|
||||
const proj = atoms3d.map(a => {
|
||||
// проекция: sz — глубина (больше = дальше от камеры)
|
||||
const pm = {};
|
||||
for (const a of atoms3d) {
|
||||
const x = a.x * sc, y = a.y * sc, z = a.z * sc;
|
||||
const x1 = x * cyr + z * syr;
|
||||
const z1 = -x * syr + z * cyr;
|
||||
const y2 = y * cxr - z1 * sxr;
|
||||
const z2 = y * sxr + z1 * cxr;
|
||||
const persp = fov / (fov + z2);
|
||||
return { a, sx: x1 * persp + W / 2, sy: y2 * persp + H / 2, sz: z2, persp };
|
||||
});
|
||||
const pm = {}; for (const p of proj) pm[p.a.id] = p;
|
||||
proj.sort((p, q) => p.sz - q.sz); // дальние раньше (painter)
|
||||
pm[a.id] = { a, sx: x1 * persp + W / 2, sy: y2 * persp + H / 2, sz: z2, persp };
|
||||
}
|
||||
|
||||
const vdw = !!opts.vdw;
|
||||
// единый список примитивов (атомы + половинки связей) для корректной сортировки по глубине
|
||||
const prims = [];
|
||||
|
||||
if (!vdw) {
|
||||
// связи рисуем в порядке глубины вместе с атомами — упрощённо рисуем все связи,
|
||||
// затем атомы поверх (сортированные). Для корректной глубины интерполируем z.
|
||||
for (const b of bonds || []) {
|
||||
const p1 = pm[bF(b)], p2 = pm[bT(b)];
|
||||
if (!p1 || !p2) continue;
|
||||
const avg = (p1.persp + p2.persp) / 2;
|
||||
const o = bO(b);
|
||||
const dx = p2.sx - p1.sx, dy = p2.sy - p1.sy, len = Math.hypot(dx, dy) || 1;
|
||||
const ox = -dy / len, oy = dx / len;
|
||||
ctx.strokeStyle = `rgba(190,195,210,${0.30 + avg * 0.55})`;
|
||||
ctx.lineWidth = Math.max(1.4, 4 * avg);
|
||||
ctx.lineCap = 'round';
|
||||
const seg = (k) => { ctx.beginPath(); ctx.moveTo(p1.sx + ox*k, p1.sy + oy*k); ctx.lineTo(p2.sx + ox*k, p2.sy + oy*k); ctx.stroke(); };
|
||||
if (o === 1) seg(0);
|
||||
else { const off = 3.2 * avg; for (let i = -(o-1); i <= (o-1); i += 2) seg(off * i); }
|
||||
const ox = -dy / len, oy = dx / len; // перпендикуляр для кратных связей
|
||||
const c1 = _hexRgb(el(p1.a.s).color), c2 = _hexRgb(el(p2.a.s).color);
|
||||
const mxs = (p1.sx + p2.sx) / 2, mys = (p1.sy + p2.sy) / 2;
|
||||
// ширина связи зависит от перспективы (ближе — толще)
|
||||
const wAvg = (p1.persp + p2.persp) / 2;
|
||||
const baseW = Math.max(1.6, 3.4 * wAvg);
|
||||
// смещения для двойных/тройных связей
|
||||
const offs = o === 1 ? [0] : o === 2 ? [-1, 1] : [-1.5, 0, 1.5];
|
||||
const ow = baseW * 1.7;
|
||||
for (const k of offs) {
|
||||
const sxo = ox * k * ow, syo = oy * k * ow;
|
||||
const w = o === 1 ? baseW : baseW * 0.62;
|
||||
// половина к атому 1
|
||||
prims.push({ t: 'stick', z: (p1.sz * 3 + p2.sz) / 4,
|
||||
x1: p1.sx + sxo, y1: p1.sy + syo, x2: mxs + sxo, y2: mys + syo, w, c: c1, persp: p1.persp });
|
||||
// половина к атому 2
|
||||
prims.push({ t: 'stick', z: (p2.sz * 3 + p1.sz) / 4,
|
||||
x1: mxs + sxo, y1: mys + syo, x2: p2.sx + sxo, y2: p2.sy + syo, w, c: c2, persp: p2.persp });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of proj) {
|
||||
const { a, sx, sy, persp } = p;
|
||||
for (const id in pm) {
|
||||
const p = pm[id];
|
||||
prims.push({ t: 'atom', z: p.sz, p });
|
||||
}
|
||||
|
||||
prims.sort((a, b) => b.z - a.z); // дальние раньше (painter): больший z рисуется первым
|
||||
|
||||
for (const pr of prims) {
|
||||
if (pr.t === 'stick') {
|
||||
_stick(ctx, pr.x1, pr.y1, pr.x2, pr.y2, pr.w, pr.c, 0.55 + pr.persp * 0.4);
|
||||
continue;
|
||||
}
|
||||
const { a, sx, sy, persp } = pr.p;
|
||||
const e = el(a.s);
|
||||
const baseR = vdw ? (e.vdw / 100) * 16 : (e.cov / 100) * 16 + 5;
|
||||
const r = Math.max(3, baseR * persp * sc * (vdw ? 1.0 : 0.9));
|
||||
const baseR = vdw ? (e.vdw / 100) * 16 : (e.cov / 100) * 11 + 6;
|
||||
const r = Math.max(3, baseR * persp * sc * (vdw ? 1.0 : 0.95));
|
||||
const [r0, g0, b0] = _hexRgb(e.color);
|
||||
const grd = ctx.createRadialGradient(sx - r*0.32, sy - r*0.38, r*0.06, sx, sy, r);
|
||||
grd.addColorStop(0, `rgb(${Math.min(255,r0+115)},${Math.min(255,g0+115)},${Math.min(255,b0+115)})`);
|
||||
grd.addColorStop(0.42, e.color);
|
||||
grd.addColorStop(1, `rgb(${Math.round(r0*0.2)},${Math.round(g0*0.2)},${Math.round(b0*0.2)})`);
|
||||
// глянцевый блик смещён к свету (верх-лево)
|
||||
const grd = ctx.createRadialGradient(sx - r*0.35, sy - r*0.4, r*0.05, sx, sy, r * 1.05);
|
||||
grd.addColorStop(0, `rgb(${Math.min(255,r0+135)},${Math.min(255,g0+135)},${Math.min(255,b0+135)})`);
|
||||
grd.addColorStop(0.4, e.color);
|
||||
grd.addColorStop(1, `rgb(${Math.round(r0*0.18)},${Math.round(g0*0.18)},${Math.round(b0*0.18)})`);
|
||||
// мягкая тень-ободок для объёма
|
||||
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grd; ctx.fill();
|
||||
ctx.lineWidth = 0.8; ctx.strokeStyle = `rgba(0,0,0,0.35)`; ctx.stroke();
|
||||
if (opts.showSymbols !== false && !vdw && (a.s !== 'H' || r > 12)) {
|
||||
ctx.fillStyle = e.text || '#fff';
|
||||
ctx.font = `bold ${Math.max(8, Math.round(r * 0.72))}px Manrope, sans-serif`;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
/*
|
||||
* LabRegistry — единый реестр симуляций лаборатории (контент-движок).
|
||||
*
|
||||
* Цель: симуляции описываются декларативным манифестом и сами себя регистрируют,
|
||||
* вместо захардкоженных массивов (SIMS), if-цепочек (openSim) и объектов (THEORY).
|
||||
*
|
||||
* Манифест:
|
||||
* {
|
||||
* id: 'pendulum', // уникальный, без ':arg'
|
||||
* cat: 'phys', // math | phys | chem | bio | game
|
||||
* title: 'Маятник',
|
||||
* desc: 'Колебания, период…',
|
||||
* preview: string | function(), // SVG-разметка карточки (функция вычисляется лениво)
|
||||
* theory: { title, sections[] },// объект для панели теории (как в THEORY)
|
||||
* bodyId: 'sim-pendulum', // (опц.) id тела; mount() — для ленивого создания DOM (Фаза 2)
|
||||
* mount: function(host){}, // (опц.) ленивое монтирование тела
|
||||
* open: function(ctx){}, // ctx = { id, arg } — открыть/инициализировать
|
||||
* stop: function(){}, // (опц.) остановить анимации (не разрушая)
|
||||
* destroy: function(){}, // (опц.) полностью закрыть; по умолчанию == stop
|
||||
* subject, grade, topics // (опц.) курикулумные поля (Фаза 5)
|
||||
* }
|
||||
*
|
||||
* Загружается ПЕРВЫМ среди labs-скриптов, чтобы window.LabRegistry существовал
|
||||
* к моменту исполнения тел остальных модулей.
|
||||
*/
|
||||
(function () {
|
||||
var _list = []; // манифесты в порядке регистрации
|
||||
var _byId = {}; // id -> манифест
|
||||
var _active = null; // текущая открытая симуляция
|
||||
|
||||
function _baseId(id) {
|
||||
return id == null ? id : String(id).split(':')[0];
|
||||
}
|
||||
|
||||
function register(m) {
|
||||
if (!m || !m.id) return null;
|
||||
if (Object.prototype.hasOwnProperty.call(_byId, m.id)) {
|
||||
// перерегистрация: заменить на месте, сохранив позицию
|
||||
for (var i = 0; i < _list.length; i++) {
|
||||
if (_list[i].id === m.id) { _list[i] = m; break; }
|
||||
}
|
||||
} else {
|
||||
_list.push(m);
|
||||
}
|
||||
_byId[m.id] = m;
|
||||
return m;
|
||||
}
|
||||
|
||||
function get(id) {
|
||||
var b = _baseId(id);
|
||||
return Object.prototype.hasOwnProperty.call(_byId, b) ? _byId[b] : null;
|
||||
}
|
||||
|
||||
function has(id) { return !!get(id); }
|
||||
|
||||
function all() { return _list.slice(); }
|
||||
|
||||
function setActive(m) { _active = m || null; }
|
||||
|
||||
function stopActive() {
|
||||
if (_active && typeof _active.stop === 'function') {
|
||||
try { _active.stop(); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
function destroyActive() {
|
||||
if (_active) {
|
||||
if (typeof _active.destroy === 'function') {
|
||||
try { _active.destroy(); } catch (e) { /* noop */ }
|
||||
} else if (typeof _active.stop === 'function') {
|
||||
try { _active.stop(); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
_active = null;
|
||||
}
|
||||
|
||||
function active() { return _active; }
|
||||
|
||||
// Разрешить preview (строка или функция) в готовую разметку.
|
||||
function resolvePreview(m) {
|
||||
if (!m) return '';
|
||||
var p = m.preview;
|
||||
if (typeof p === 'function') {
|
||||
try { return p() || ''; } catch (e) { return ''; }
|
||||
}
|
||||
return p || '';
|
||||
}
|
||||
|
||||
window.LabRegistry = {
|
||||
register: register,
|
||||
get: get,
|
||||
has: has,
|
||||
all: all,
|
||||
setActive: setActive,
|
||||
stopActive: stopActive,
|
||||
destroyActive: destroyActive,
|
||||
active: active,
|
||||
resolvePreview: resolvePreview
|
||||
};
|
||||
})();
|
||||
@@ -20,11 +20,25 @@
|
||||
}
|
||||
|
||||
function renderSims() {
|
||||
const base = _catFilter === 'all' ? SIMS : SIMS.filter(s => s.cat === _catFilter);
|
||||
// Контент-движок: мёрж код-реестра поверх legacy SIMS.
|
||||
// Порядок берём из SIMS; для мигрированных id используем манифест реестра;
|
||||
// registry-only записи добавляем в конец.
|
||||
const _reg = (window.LabRegistry ? window.LabRegistry.all() : []);
|
||||
const _regById = {};
|
||||
_reg.forEach(m => { _regById[m.id] = m; });
|
||||
const _seen = {};
|
||||
const _merged = [];
|
||||
SIMS.forEach(s => {
|
||||
_merged.push(s.id && _regById[s.id] ? _regById[s.id] : s);
|
||||
if (s.id) _seen[s.id] = 1;
|
||||
});
|
||||
_reg.forEach(m => { if (!_seen[m.id]) _merged.push(m); });
|
||||
|
||||
const base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter);
|
||||
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
|
||||
document.getElementById('sim-grid').innerHTML = list.map(s => `
|
||||
<div class="sim-card ${s.id ? '' : 'soon'}" ${s.id ? `onclick="openSim('${s.id}')"` : ''}>
|
||||
${s.preview}
|
||||
${window.LabRegistry ? window.LabRegistry.resolvePreview(s) : s.preview}
|
||||
<div class="sim-body">
|
||||
<div class="sim-cat ${s.cat}">${s.cat === 'math' ? '∑ Математика' : s.cat === 'chem' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Химия' : s.cat === 'bio' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/><path d="M9 22c1.798-2 2.518-4 2.807-6"/><path d="M15 2c-1.798 2-2.518 4-2.807 6"/><path d="m17 6-2.5-2.5M14 8 13 7M7 18l2.5 2.5M3.5 14.5l.5.5M20 9l.5.5M6.5 12.5l1 1M16.5 10.5l1 1M10 16l1.5 1.5"/></svg> Биология' : s.cat === 'game' ? '<svg class="ic" viewBox="0 0 24 24"><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="15" y1="13" x2="15.01" y2="13"/><line x1="18" y1="11" x2="18.01" y2="11"/><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры' : LS.icon('zap',14) + ' Физика'}</div>
|
||||
<div class="sim-title">${s.title}</div>
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
// Pause all animation-loop sims (non-destructive). Called when switching
|
||||
// between sims so a previously opened sim doesn't keep rendering offscreen.
|
||||
function _pauseAllSims() {
|
||||
if (window.LabRegistry) window.LabRegistry.stopActive();
|
||||
if (pSim) pSim.pause();
|
||||
if (cSim) cSim.pause();
|
||||
if (gasSim) gasSim.stop();
|
||||
@@ -105,6 +106,16 @@
|
||||
// load theory for this sim
|
||||
loadTheory(id.includes(':') ? id.split(':')[0] : id);
|
||||
|
||||
// Контент-движок: мигрированные симуляции открываются через реестр.
|
||||
if (window.LabRegistry && window.LabRegistry.has(id)) {
|
||||
const _m = window.LabRegistry.get(id);
|
||||
const _arg = id.includes(':') ? id.split(':')[1] : undefined;
|
||||
window.LabRegistry.setActive(_m);
|
||||
try { _m.open({ id: id, arg: _arg }); } catch (e) { console.error('[LabRegistry] open failed:', id, e); }
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === 'graph') _openGraph();
|
||||
if (id === 'projectile') _openProjectile();
|
||||
if (id === 'collision') _openCollision();
|
||||
@@ -210,6 +221,7 @@
|
||||
}
|
||||
|
||||
function closeSim() {
|
||||
if (window.LabRegistry) window.LabRegistry.destroyActive();
|
||||
if (pSim) pSim.pause();
|
||||
if (cSim) cSim.pause();
|
||||
if (mSim && mSim.particleOn) mSim.toggleParticle();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Feature Context: Контент-движок лаборатории
|
||||
|
||||
## Current State
|
||||
- Лаборатория работает на захардкоженной регистрации (см. PLAN.md Summary).
|
||||
- Ветка `feature/lab-content-engine` создана от `master`.
|
||||
|
||||
## Architecture map (как было ДО рефактора)
|
||||
- `frontend/lab.html` — sim-тела `<div id="sim-xxx">` (inline HTML, ~3000 строк) + 58 `<script>` тегов (4800-4861) + three.js.
|
||||
- `frontend/js/labs/lab-glue.js`:
|
||||
- `_catFilter`, `_disabledSimIds`, `_simModuleDisabled` (вкл/выкл из админки)
|
||||
- `filterSims()`, `renderSims()` (карточки каталога)
|
||||
- preview-хелперы `_grid/_axes/_svg` + ~60 констант `P_*`
|
||||
- массив `SIMS` (821-866), `window.SIMS`/`window.LAB_SIMS`
|
||||
- `frontend/js/labs/lab-init.js`:
|
||||
- объявления переменных симуляций (gSim, pSim, …)
|
||||
- `ALL_SIM_BODIES` / `ALL_CTRL_BARS` (33-48)
|
||||
- `_pauseAllSims()` (54-91), `openSim(id)` if-цепочка (93-160), `closeSim()` (212-258)
|
||||
- `_simShow()`, `_addTouchSupport()` (touch-bridge + ResizeObserver)
|
||||
- объект `THEORY` + `loadTheory()` + `_theoryToggle()`
|
||||
- функции `_openXxx()` (603-756) — единый шаблон: `_simShow('sim-xxx')` + ленивое `new XxxSim(...)` + показ `ctrl-xxx`
|
||||
- `frontend/js/admin/sections/sims.js` — админ-секция (пока только вкл/выкл, `_disabledSimIds`).
|
||||
|
||||
## Загрузочный порядок (КРИТИЧНО)
|
||||
В lab.html: движки `_fx_*`, `_phys_visuals`, `_graph_panel`, `_chem_visuals` грузятся ПЕРЕД симуляциями.
|
||||
`lab-init.js` (4826) грузится ПЕРЕД `lab-glue.js` (4827). `renderSims()` вызывается в конце lab-glue.
|
||||
Некоторые sim-файлы (graph.js) грузятся РАНЬШЕ lab-glue.js → preview `P_*` ещё не определены на момент исполнения их тел.
|
||||
=> В манифестах `preview` поддерживает функцию (ленивое вычисление в renderSims), не только строку.
|
||||
|
||||
## Контракт LabRegistry (Фаза 0)
|
||||
```
|
||||
LabRegistry.register(manifest) // manifest.id уникален; повторная регистрация перезаписывает
|
||||
LabRegistry.get(id) // по base-id (без ':arg')
|
||||
LabRegistry.has(id)
|
||||
LabRegistry.all() // в порядке регистрации
|
||||
LabRegistry.setActive(sim) / stopActive() / destroyActive() // менеджер жизненного цикла
|
||||
```
|
||||
manifest: `{ id, cat, title, desc, preview(string|fn), theory?, bodyId?, mount?(host), open(ctx), stop?(), destroy?(), subject?, grade?, topics? }`
|
||||
|
||||
## Адаптер (Фаза 0): реестр в приоритете, иначе legacy
|
||||
- `renderSims()` — порядок берём из исходного `SIMS`; для id, который есть в реестре, используем манифест (resolve preview), иначе legacy-запись; в конце добавляем registry-only записи, которых нет в SIMS.
|
||||
- `openSim(id)` — `base = id.split(':')[0]`; если `LabRegistry.has(base)` → `stopActive()`; `get(base).open({arg})`; `setActive`; иначе старый if-путь.
|
||||
- `loadTheory(id)` — если `get(base).theory` есть → рендерим из него; иначе `THEORY[base]`.
|
||||
- `closeSim()`/`_pauseAllSims()` — дополнительно `LabRegistry.stopActive()` / `destroyActive()`.
|
||||
|
||||
## Temporary Workarounds
|
||||
- (пока нет)
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Фаза 1 опирается на ядро реестра из Фазы 0.
|
||||
- Фаза 3 (ленивая загрузка) опирается на манифесты с зависимостями движков (Фаза 1/2).
|
||||
- Фаза 4 (БД) мёржит код-манифесты Фазы 1 с оверрайдами.
|
||||
- Фаза 5 использует поля subject/grade/topics из манифестов.
|
||||
|
||||
## Deep-links (сохранить!)
|
||||
`openSim('stereo:figure')`, `?stereofig=`, обратная совместимость `magnetic/coulomb→emfield`, `thinlens/mirrors/refraction→opticsbench`.
|
||||
|
||||
## Проектные правила (НЕ нарушать)
|
||||
- Иконки: только inline SVG `.ic`, НЕ эмоджи.
|
||||
- Поиск по коду: ast-index, НЕ Grep tool.
|
||||
- БД: встроенный `node:sqlite` DatabaseSync, НЕ better-sqlite3.
|
||||
- Git: коммитить только изменённые файлы.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Feature: Контент-движок лаборатории (симуляции как данные)
|
||||
|
||||
**Branch:** `feature/lab-content-engine`
|
||||
**Base branch:** `master`
|
||||
**Created:** 2026-05-30
|
||||
**Status:** 🟡 In Progress
|
||||
**Strategy:** Big Bang
|
||||
**Mode:** Automated
|
||||
**Execution:** Direct
|
||||
|
||||
## Summary
|
||||
|
||||
Превратить захардкоженную в 6 местах регистрацию ~49 симуляций лаборатории в единый
|
||||
декларативный манифест + реестр (`LabRegistry`). Каждая симуляция сама себя регистрирует
|
||||
объектом `{id, cat, title, desc, preview, theory, bodyId/mount, open, stop, destroy, subject, grade, topics}`.
|
||||
Ядро (renderSims/openSim/closeSim/loadTheory) работает с реестром, а не с массивами и
|
||||
if-цепочками. Далее — ленивая загрузка кода, БД-бэкенд с админкой и курикулумная привязка.
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build:** — (фронт без сборки, статика через Express)
|
||||
- **Test:** `cd backend && npm test` (актуально для Фаз 4-5; Фазы 0-3 — статическая проверка + ревью по диффу)
|
||||
- **Lint:** `cd backend && npm run lint:routes` (актуально для Фаз 4-5)
|
||||
|
||||
## Phases
|
||||
|
||||
- [ ] Phase 0: Ядро реестра + адаптер + 3 пилота [domain: frontend] → [subplan](./phase-0-registry-core.md)
|
||||
- [ ] Phase 1: Миграция всех симуляций на манифесты [domain: frontend] → [subplan](./phase-1-migrate-all.md)
|
||||
- [ ] Phase 2: Тела симуляций как шаблоны + ленивый mount [domain: frontend] → [subplan](./phase-2-lazy-mount.md)
|
||||
- [ ] Phase 3: Ленивая загрузка кода симуляций [domain: frontend] → [subplan](./phase-3-lazy-load.md)
|
||||
- [ ] Phase 4: Реестр в БД + API + админка [domain: fullstack] → [subplan](./phase-4-db-admin.md)
|
||||
- [ ] Phase 5: Курикулумная привязка [domain: fullstack] → [subplan](./phase-5-curriculum.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 0: Ядро реестра | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 1: Миграция всех | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 2: Ленивый mount | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 3: Ленивая загрузка | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 4: БД + админка | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 5: Курикулум | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review
|
||||
- [ ] Full build passes
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Merged to `master`
|
||||
|
||||
## Notes (Big Bang temporary breakage map)
|
||||
- Фаза 1 может временно ломать каталог/открытие симуляций пока миграция не завершена — устраняется внутри Фазы 1.
|
||||
- Фаза 2 временно меняет структуру lab.html (вынос тел) — устраняется внутри Фазы 2.
|
||||
- Полная работоспособность лаборатории гарантируется после ФИНАЛЬНОЙ фазы.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Phase 0: Ядро реестра + адаптер + 3 пилота
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Создать `LabRegistry` (реестр + менеджер активной симуляции). Подключить адаптер: ядро
|
||||
лаборатории сначала смотрит в реестр, иначе — старый путь. Мигрировать 3 пилота
|
||||
(graph, quadratic, pendulum) и доказать паритет. Полностью обратимо.
|
||||
|
||||
## Tasks
|
||||
- [ ] Создать `frontend/js/labs/_registry.js` — `window.LabRegistry` (register/get/has/all + setActive/stopActive/destroyActive). Без эмоджи.
|
||||
- [ ] Подключить `_registry.js` в lab.html ПЕРВЫМ среди labs-скриптов (до graph.js).
|
||||
- [ ] Адаптер `renderSims()` (lab-glue.js): порядок из SIMS, registry-override + resolve preview (string|fn), append registry-only.
|
||||
- [ ] Адаптер `openSim()` (lab-init.js): base-id, registry-first → stopActive/open/setActive; deep-link `:arg` сохранить.
|
||||
- [ ] Адаптер `loadTheory()` (lab-init.js): registry.theory в приоритете, иначе THEORY[base].
|
||||
- [ ] Адаптер `closeSim()`/`_pauseAllSims()`: добавить `LabRegistry.stopActive()`/`destroyActive()`.
|
||||
- [ ] Зарегистрировать 3 пилота в конце lab-init.js (после _openXxx): graph, quadratic, pendulum — с preview-fn, theory-объектом, open=_openXxx, stop/destroy.
|
||||
- [ ] Удалить graph/quadratic/pendulum из legacy `THEORY` и `SIMS` (проверка, что адаптер их подхватывает из реестра).
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/js/labs/_registry.js` — новый: ядро реестра.
|
||||
- `frontend/lab.html` — добавить `<script src="/js/labs/_registry.js">` первым (в обоих местах, если дублируется список).
|
||||
- `frontend/js/labs/lab-glue.js` — renderSims адаптер; убрать 3 пилота из SIMS.
|
||||
- `frontend/js/labs/lab-init.js` — openSim/loadTheory/closeSim/_pauseAllSims адаптеры; регистрация 3 пилотов; убрать 3 пилота из THEORY.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Каталог отображает все симуляции в прежнем порядке; 3 пилота открываются и работают идентично.
|
||||
- Остальные 46 симуляций открываются по-старому (legacy путь не сломан).
|
||||
- Deep-links и обратная совместимость id работают.
|
||||
- Нет дублей карточек (пилот не показан дважды).
|
||||
- Нет эмоджи; иконки `.ic`.
|
||||
|
||||
## Notes
|
||||
- Порядок загрузки: см. CONTEXT.md. preview как функция спасает от undefined P_*.
|
||||
- `_disabledSimIds` фильтрация должна продолжать работать для registry-записей.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Адаптер не ломает legacy симуляции
|
||||
- [ ] Паритет 3 пилотов (open/stop/close/theory/preview)
|
||||
- [ ] Соблюдены конвенции проекта (no emoji, .ic)
|
||||
- [ ] Нет дублирования карточек
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- заполнить после фазы -->
|
||||
@@ -0,0 +1,40 @@
|
||||
# Phase 1: Миграция всех симуляций на манифесты
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Перевести все ~49 симуляций на сам/регистрацию через `LabRegistry`. Перенести данные
|
||||
(catalogue meta, preview, theory) и поведение (open/stop/destroy) в манифесты. Удалить
|
||||
legacy-структуры. Сохранить глобальные имена через shim.
|
||||
|
||||
## Tasks
|
||||
- [ ] Для каждой симуляции зарегистрировать манифест (метаданные из SIMS, preview из P_*, theory из THEORY, open/stop/destroy из _openXxx + _pauseAllSims/closeSim веток).
|
||||
- [ ] Удалить массив `SIMS` (lab-glue.js) и объект `THEORY` (lab-init.js).
|
||||
- [ ] Удалить if-цепочку `openSim`, `_pauseAllSims`, switch в `closeSim`, `ALL_SIM_BODIES`/`ALL_CTRL_BARS`.
|
||||
- [ ] lab-init.js усохнуть до generic-логики (openSim/closeSim через реестр).
|
||||
- [ ] Shim глобальных имён (gSim, pSim, …) — их дёргают deep-link/поиск/инлайн-обработчики.
|
||||
- [ ] Сохранить обратную совместимость id (magnetic/coulomb→emfield, thinlens/mirrors/refraction→opticsbench, stereo:fig, hydrostatics:arg, molphys:arg, chemistry:arg, dynamics:arg, emfield:mode, opticsbench:mode).
|
||||
|
||||
## Files to Modify/Create
|
||||
- Все `frontend/js/labs/*.js` симуляции — добавить `LabRegistry.register(...)`.
|
||||
- `frontend/js/labs/lab-glue.js`, `frontend/js/labs/lab-init.js` — удалить legacy.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Все симуляции открываются/работают как раньше (паритет).
|
||||
- Удалены все 6 точек дублирования из CONTEXT.md.
|
||||
- Deep-links и алиасы работают.
|
||||
|
||||
## Notes
|
||||
- Мигрировать пачками (по категориям) с проверкой паритета после каждой пачки (Big Bang допускает временную поломку между пачками).
|
||||
- Превью с зависимостями (random в P_ELECTROLYSIS) перенести как есть.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Ни одна симуляция не потеряна
|
||||
- [ ] Глобальные shim'ы на месте
|
||||
- [ ] Алиасы/deep-links работают
|
||||
- [ ] Legacy полностью удалён
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- заполнить после фазы -->
|
||||
@@ -0,0 +1,37 @@
|
||||
# Phase 2: Тела симуляций как шаблоны + ленивый mount
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Вынести inline-HTML тел симуляций (`<div id="sim-xxx">`, ~3000 строк) из lab.html в
|
||||
манифесты: `mount(host)` создаёт DOM лениво при первом открытии. lab.html худеет.
|
||||
|
||||
## Tasks
|
||||
- [ ] Добавить в манифест поле `mount(host)` (или `bodyHtml`) — строит/возвращает тело симуляции.
|
||||
- [ ] Ядро: при первом open — если тело не смонтировано, вызвать mount() в контейнер `#lab-sim`.
|
||||
- [ ] Перенести разметку каждого `sim-xxx` тела + его `ctrl-xxx` бара из lab.html в соответствующий модуль.
|
||||
- [ ] Удалить вынесенные блоки из lab.html.
|
||||
- [ ] Сохранить id элементов (canvas ids, ctrl ids) — на них завязаны Sim-классы.
|
||||
|
||||
## Files to Modify/Create
|
||||
- Все `frontend/js/labs/*.js` — добавить mount/bodyHtml.
|
||||
- `frontend/lab.html` — удалить inline тела.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Все симуляции монтируются и работают.
|
||||
- lab.html значительно меньше (~3000 строк вынесено).
|
||||
- Повторное открытие не дублирует DOM.
|
||||
|
||||
## Notes
|
||||
- Theory-panel и общий sim-topbar остаются в lab.html.
|
||||
- KaTeX/lucide ре-инициализация после mount при необходимости.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Нет дублей DOM при повторном open
|
||||
- [ ] id элементов сохранены
|
||||
- [ ] Все тела перенесены
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- заполнить после фазы -->
|
||||
@@ -0,0 +1,39 @@
|
||||
# Phase 3: Ленивая загрузка кода симуляций
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Грузить тяжёлый код симуляции и движков по клику, а не 58 скриптов + three.js сразу.
|
||||
Лёгкий манифест каталога загружается сразу.
|
||||
|
||||
## Tasks
|
||||
- [ ] Вынести лёгкие метаданные каталога (id/cat/title/desc/preview) в отдельный `sims.manifest.js`, грузимый сразу.
|
||||
- [ ] Тяжёлый код симуляции (Sim-класс + open/mount) грузить динамически при openSim (инъекция script или import()).
|
||||
- [ ] Объявить зависимости движков в манифесте (`deps: ['_fx_core','_phys_visuals',...]`); загрузчик резолвит и грузит до кода симуляции, кешируя загруженное.
|
||||
- [ ] three.js грузить только для 3D-симуляций (stereo).
|
||||
- [ ] Лоадер с дедупликацией (один и тот же файл не грузится дважды).
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/js/labs/_loader.js` — новый: динамический загрузчик + резолв зависимостей.
|
||||
- `frontend/js/labs/sims.manifest.js` — новый: лёгкий каталог.
|
||||
- `frontend/lab.html` — убрать массовые `<script>`, оставить ядро (api, registry, loader, manifest).
|
||||
|
||||
## Acceptance Criteria
|
||||
- Первый рендер каталога без загрузки кода симуляций.
|
||||
- Открытие симуляции догружает её код+движки и работает.
|
||||
- three.js грузится только для 3D.
|
||||
- Заметное падение объёма стартовой загрузки lab.html.
|
||||
|
||||
## Notes
|
||||
- Учесть defer-скрипты (solutions/organic/periodic/qualanalysis).
|
||||
- Кеш загруженных модулей в Map.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Нет двойной загрузки
|
||||
- [ ] Зависимости движков соблюдены
|
||||
- [ ] Старт лаборатории легче
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- заполнить после фазы -->
|
||||
@@ -0,0 +1,42 @@
|
||||
# Phase 4: Реестр в БД + API + админка
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Хранить оверрайды каталога в БД, мёржить с код-манифестами, управлять каталогом из
|
||||
админки (вкл/выкл, порядок, теги, рекомендуемые).
|
||||
|
||||
## Tasks
|
||||
- [ ] Миграция БД: таблица `lab_sims` (id PK, title, cat, subject, grade, desc, enabled, sort, topic_id, textbook_ref, flags JSON, updated_at). Через `node:sqlite` DatabaseSync.
|
||||
- [ ] Backend route `GET /api/lab/sims` — отдаёт мёрж: код-манифест (база) + БД-оверрайды.
|
||||
- [ ] Backend admin routes: upsert/enable/disable/reorder/tag (под RBAC admin).
|
||||
- [ ] Frontend: каталог берёт enabled/order/теги из `/api/lab/sims` (с фолбэком на код-манифест офлайн).
|
||||
- [ ] Расширить `frontend/js/admin/sections/sims.js`: список, вкл/выкл, drag-reorder, теги, «рекомендуемые».
|
||||
- [ ] Сохранить совместимость с `_disabledSimIds`.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `backend/src/db/migrations/0XX_lab_sims.sql` — новая миграция.
|
||||
- `backend/src/routes/lab.js` (или расширить существующий) — API.
|
||||
- `backend/src/server.js` — подключить роут (если новый файл).
|
||||
- `frontend/js/admin/sections/sims.js` — расширить админку.
|
||||
- `frontend/js/labs/_loader.js`/manifest — учитывать БД-данные.
|
||||
|
||||
## Acceptance Criteria
|
||||
- `npm test` зелёный; `npm run lint:routes` без ошибок (auth на роутах).
|
||||
- Админ может вкл/выкл/переупорядочить/тегировать симуляцию, изменения видны в каталоге.
|
||||
- Офлайн/без БД — фолбэк на код-манифест.
|
||||
|
||||
## Notes
|
||||
- RBAC: мутации только admin. Чтение каталога — для роли с доступом к лаборатории.
|
||||
- Не дублировать данные: код-манифест = источник базовых полей; БД = оверрайды/доп.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Миграция идемпотентна
|
||||
- [ ] Роуты под auth (lint:routes)
|
||||
- [ ] Мёрж корректен, фолбэк работает
|
||||
- [ ] Тесты проходят
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- заполнить после фазы -->
|
||||
@@ -0,0 +1,43 @@
|
||||
# Phase 5: Курикулумная привязка
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Связать симуляции с учебной программой: § учебника, узел knowledge-map, тема банка
|
||||
вопросов. Двусторонняя навигация.
|
||||
|
||||
## Tasks
|
||||
- [ ] Схема связей: использовать поля манифеста (subject/grade/topics) + таблицу связей `lab_sim_links` (sim_id, kind[textbook|topic|kmap|question], ref_id).
|
||||
- [ ] API: `GET /api/lab/sims/:id/related` — связанные § / темы / задачи.
|
||||
- [ ] Frontend учебник/теория: кнопка «Открыть в лаборатории» в § (deep-link openSim).
|
||||
- [ ] Frontend knowledge-map: узел темы → ссылка на симуляцию.
|
||||
- [ ] Страница симуляции: блок «Связанная теория и задачи».
|
||||
- [ ] Админка: редактирование связей симуляции.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `backend/src/db/migrations/0XX_lab_sim_links.sql`
|
||||
- `backend/src/routes/lab.js` — related endpoint.
|
||||
- `frontend/textbooks.html` / theory / учебник-рендер — кнопки в §.
|
||||
- `frontend/knowledge-map.html` — ссылки с узлов.
|
||||
- `frontend/lab.html` — блок связей на странице sim.
|
||||
- `frontend/js/admin/sections/sims.js` — редактор связей.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Из § учебника можно открыть нужную симуляцию.
|
||||
- На странице симуляции видны связанные теория/задачи.
|
||||
- Узлы knowledge-map ведут на симуляции.
|
||||
- `npm test` зелёный, роуты под auth.
|
||||
|
||||
## Notes
|
||||
- Привязки опциональны: отсутствие связей не ломает страницы.
|
||||
- Переиспользовать существующие topic_id банка вопросов и структуру учебников.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Навигация в обе стороны работает
|
||||
- [ ] Пустые связи не ломают UI
|
||||
- [ ] Роуты под auth, тесты проходят
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- финальная фаза -->
|
||||
Reference in New Issue
Block a user