diff --git a/frontend/js/biochem-core.js b/frontend/js/biochem-core.js index e450918..d580b1e 100644 --- a/frontend/js/biochem-core.js +++ b/frontend/js/biochem-core.js @@ -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`; diff --git a/frontend/js/labs/_registry.js b/frontend/js/labs/_registry.js new file mode 100644 index 0000000..386cfdf --- /dev/null +++ b/frontend/js/labs/_registry.js @@ -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 + }; +})(); diff --git a/frontend/js/labs/lab-glue.js b/frontend/js/labs/lab-glue.js index 221d586..b46b90f 100644 --- a/frontend/js/labs/lab-glue.js +++ b/frontend/js/labs/lab-glue.js @@ -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 => `
- ${s.preview} + ${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}
diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js index 9a46798..597aaa0 100644 --- a/frontend/js/labs/lab-init.js +++ b/frontend/js/labs/lab-init.js @@ -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(); diff --git a/plans/lab-content-engine/CONTEXT.md b/plans/lab-content-engine/CONTEXT.md new file mode 100644 index 0000000..a576140 --- /dev/null +++ b/plans/lab-content-engine/CONTEXT.md @@ -0,0 +1,61 @@ +# Feature Context: Контент-движок лаборатории + +## Current State +- Лаборатория работает на захардкоженной регистрации (см. PLAN.md Summary). +- Ветка `feature/lab-content-engine` создана от `master`. + +## Architecture map (как было ДО рефактора) +- `frontend/lab.html` — sim-тела `
` (inline HTML, ~3000 строк) + 58 `