feat(biochem): Фазы 2-7 — химдвижок, баланс, энергодиаграммы, графики, SMILES
Перенос изолированной работы по модулю «Биохимия» на master (разработка велась параллельно с другой сессией; здесь только biochem-файлы). Ядро biochem-core.js: - Фаза 2 (химдвижок): partialCharges (по ЭО), dipole (вектор q·r по 3D VSEPR), polarity, massFractions, functionalGroups, analyze; chargeColor + δ± в рендерах. - Фаза 3: balance() — балансировка уравнений (матрица элементов + дробный Гаусс). - Фаза 7: parseSmiles (учебное подмножество) + toJSON/download. - Фикс 3D-рендера: глубинная сортировка + объёмные связи-цилиндры. Страницы: - biochem.html: δ±-тепловая карта зарядов + стрелка диполя; импорт SMILES; экспорт PNG/JSON; замена крудных эвристик на BIO.analyze (−95 строк). - biochem-reactions.html: энергопрофиль реакции + проверка баланса. - biochem-properties.html: график молярных масс + экспорт CSV. Тесты: backend/tests/biochem-core.test.js (8/8 pass: формулы, VSEPR, заряды, полярность, баланс, SMILES, analyze). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+79
-107
@@ -368,6 +368,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>3D
|
||||
</button>
|
||||
<button class="tool-btn" id="btn-vdw" onclick="toggleVDW()" title="Space-fill (VDW радиусы)" style="display:none"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg> VDW</button>
|
||||
<button class="tool-btn" id="btn-charge" onclick="toggleCharges()" title="Частичные заряды δ+/δ− и диполь"><svg class="ic" viewBox="0 0 24 24"><path d="M12 2v20M2 12h20"/></svg> δ±</button>
|
||||
<div class="tool-sep"></div>
|
||||
<button class="tool-btn icon-only" id="btn-undo" onclick="undo()" disabled title="Отменить (Ctrl+Z)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="14" height="14"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"/></svg>
|
||||
@@ -434,6 +435,18 @@
|
||||
<button class="bp-btn bp-btn-secondary" onclick="loadFromLibrary()">
|
||||
Загрузить из библиотеки
|
||||
</button>
|
||||
<!-- SMILES import -->
|
||||
<div style="display:flex;gap:6px;margin-top:8px">
|
||||
<input type="text" id="smiles-in" placeholder="SMILES, напр. CCO" spellcheck="false"
|
||||
onkeydown="if(event.key==='Enter')importSmiles()"
|
||||
style="flex:1;min-width:0;padding:7px 10px;border-radius:8px;background:rgba(255,255,255,.06);border:1.5px solid rgba(255,255,255,.12);color:#ddd;font:600 .78rem monospace">
|
||||
<button class="bp-btn bp-btn-secondary" style="width:auto;margin:0;padding:0 14px" onclick="importSmiles()">Импорт</button>
|
||||
</div>
|
||||
<!-- Export -->
|
||||
<div style="display:flex;gap:6px;margin-top:6px">
|
||||
<button class="bp-btn bp-btn-secondary" style="margin:0;flex:1" onclick="exportPNG()">PNG</button>
|
||||
<button class="bp-btn bp-btn-secondary" style="margin:0;flex:1" onclick="exportJSON()">JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-section" id="bp-active-challenge" style="display:none">
|
||||
<div class="bp-label" id="bp-chal-type-label">Текущее задание</div>
|
||||
@@ -680,11 +693,6 @@ function getIssues() {
|
||||
}
|
||||
|
||||
// ── Live molecular stats ──
|
||||
const ATOMIC_MASS = {
|
||||
H:1.008, C:12.011, N:14.007, O:15.999, P:30.974, S:32.06,
|
||||
Cl:35.45, Na:22.990, Ca:40.078, Mg:24.305, Fe:55.845,
|
||||
};
|
||||
|
||||
function calcMolStats() {
|
||||
const wrap = document.getElementById('bp-mol-stats');
|
||||
if (!atoms.length) { wrap.style.display = 'none'; return; }
|
||||
@@ -693,33 +701,30 @@ function calcMolStats() {
|
||||
const cnt = {};
|
||||
for (const a of atoms) cnt[a.s] = (cnt[a.s]||0) + 1;
|
||||
|
||||
// Molar weight
|
||||
let mw = 0;
|
||||
for (const [el, n] of Object.entries(cnt)) mw += (ATOMIC_MASS[el]||0) * n;
|
||||
|
||||
// DBE: (2C + 2 + N + P − H − Cl) / 2
|
||||
const C = cnt.C||0, H = cnt.H||0, Nv = cnt.N||0, Pv = cnt.P||0, Clv = cnt.Cl||0;
|
||||
const dbe = (C || H) ? (2*C + 2 + Nv + Pv - H - Clv) / 2 : null;
|
||||
|
||||
const fg = _detectFG();
|
||||
// Полный химический анализ из общего ядра (масса, DBE, геометрия, диполь, группы)
|
||||
const an = BIO.analyze(atoms, bonds);
|
||||
_chargeMap = an.charges || null;
|
||||
_dipoleVec = (an.polarity && an.polarity.vector) || null;
|
||||
const dbe = an.dbe;
|
||||
const fg = an.groups;
|
||||
const molClass = _molClass(cnt, dbe, fg);
|
||||
const polarity = _polarity(cnt, fg);
|
||||
|
||||
const chips = [];
|
||||
chips.push(_chip('М.М. г/моль', mw.toFixed(2), 'cyan'));
|
||||
chips.push(_chip('М.М. г/моль', an.mass.toFixed(2), 'cyan'));
|
||||
if (dbe !== null && Number.isFinite(dbe)) {
|
||||
const cls = dbe < 0 ? 'bad' : dbe === 0 ? 'good' : dbe >= 4 ? 'violet' : 'warn';
|
||||
chips.push(_chip('DBE', Number.isInteger(dbe*2) && dbe === Math.round(dbe) ? dbe : dbe.toFixed(1), cls));
|
||||
}
|
||||
chips.push(_chip('Полярность', polarity.label, polarity.cls));
|
||||
chips.push(_chip('Полярность', an.polarity.label, an.polarity.cls));
|
||||
if (an.dipole != null) chips.push(_chip('Дипольный момент', an.dipole.toFixed(2) + ' D', an.dipole < 0.18 ? 'good' : an.dipole >= 1.5 ? 'bad' : 'warn'));
|
||||
chips.push(_chip('Атомов', atoms.length, ''));
|
||||
|
||||
// Geometry (VSEPR) — for the central atom
|
||||
if (window.BIO && bonds.length) {
|
||||
const geom = BIO.vsepr(atoms, bonds);
|
||||
if (geom.shape) chips.push(_chip('Геометрия', geom.shape, 'violet'));
|
||||
if (geom.hybridization) chips.push(_chip('Гибридизация', geom.hybridization, 'cyan'));
|
||||
if (geom.angle != null) chips.push(_chip('Угол связи', geom.angle + '°', ''));
|
||||
if (bonds.length) {
|
||||
const g = an.geometry;
|
||||
if (g.shape) chips.push(_chip('Геометрия', g.shape, 'violet'));
|
||||
if (g.hybridization) chips.push(_chip('Гибридизация', g.hybridization, 'cyan'));
|
||||
if (g.angle != null) chips.push(_chip('Угол связи', g.angle + '°', ''));
|
||||
}
|
||||
|
||||
if (molClass) chips.push(
|
||||
@@ -744,80 +749,6 @@ function _chip(label, val, cls) {
|
||||
`<span class="bp-stat-val ${cls}">${val}</span></div>`;
|
||||
}
|
||||
|
||||
function _detectFG() {
|
||||
const groups = [];
|
||||
const bondsOf = id => bonds.filter(b => b.from===id || b.to===id);
|
||||
const othr = (b, id) => b.from===id ? b.to : b.from;
|
||||
const sym = id => atoms.find(a=>a.id===id)?.s;
|
||||
const used = new Set();
|
||||
|
||||
// −COOH: C with C=O and C-O-H
|
||||
for (const a of atoms) {
|
||||
if (a.s !== 'C') continue;
|
||||
const myB = bondsOf(a.id);
|
||||
const hasDblO = myB.some(b => b.order===2 && sym(othr(b,a.id))==='O');
|
||||
const sglOs = myB.filter(b => b.order===1 && sym(othr(b,a.id))==='O');
|
||||
if (hasDblO && sglOs.length) {
|
||||
const oId = othr(sglOs[0], a.id);
|
||||
if (bondsOf(oId).some(b => sym(othr(b,oId))==='H')) {
|
||||
groups.push({ label:'−COOH', color:'#f87171' }); used.add(a.id); continue;
|
||||
}
|
||||
}
|
||||
// C=O (aldehyde or ketone)
|
||||
if (hasDblO && !used.has(a.id)) {
|
||||
const hN = myB.some(b => sym(othr(b,a.id))==='H');
|
||||
const cN = myB.filter(b => sym(othr(b,a.id))==='C').length;
|
||||
if (hN) groups.push({ label:'−CHO', color:'#fb923c' });
|
||||
else if(cN>=2)groups.push({ label:'C=O (кетон)', color:'#fb923c' });
|
||||
else groups.push({ label:'C=O', color:'#fb923c' });
|
||||
used.add(a.id);
|
||||
}
|
||||
}
|
||||
|
||||
// −OH
|
||||
const ohCount = atoms.filter(a => a.s==='O' &&
|
||||
bondsOf(a.id).some(b => b.order===1 && sym(othr(b,a.id))==='H')).length;
|
||||
if (ohCount) groups.push({ label: ohCount>1 ? `−OH ×${ohCount}` : '−OH', color:'#60a5fa' });
|
||||
|
||||
// −NH₂ / −NH
|
||||
for (const a of atoms) {
|
||||
if (a.s !== 'N') continue;
|
||||
const hCnt = bondsOf(a.id).filter(b => sym(othr(b,a.id))==='H').length;
|
||||
if (hCnt >= 2) groups.push({ label:'−NH₂', color:'#34d399' });
|
||||
else if (hCnt === 1) groups.push({ label:'−NH', color:'#34d399' });
|
||||
}
|
||||
|
||||
// −SH
|
||||
if (atoms.some(a => a.s==='S' && bondsOf(a.id).some(b => sym(othr(b,a.id))==='H')))
|
||||
groups.push({ label:'−SH', color:'#fbbf24' });
|
||||
|
||||
// C=C
|
||||
const enes = bonds.filter(b => b.order===2 && sym(b.from)==='C' && sym(b.to)==='C');
|
||||
if (enes.length) groups.push({ label: enes.length>1 ? `C=C ×${enes.length}` : 'C=C', color:'#a78bfa' });
|
||||
|
||||
// C≡C
|
||||
if (bonds.some(b => b.order===3 && sym(b.from)==='C' && sym(b.to)==='C'))
|
||||
groups.push({ label:'C≡C', color:'#e879f9' });
|
||||
|
||||
// Aromatic (≥3 C=C bonds in C skeleton)
|
||||
const cIds = new Set(atoms.filter(a=>a.s==='C').map(a=>a.id));
|
||||
if (bonds.filter(b => b.order===2 && cIds.has(b.from) && cIds.has(b.to)).length >= 3)
|
||||
groups.push({ label:'Арен', color:'#06D6E0' });
|
||||
|
||||
// −Cl
|
||||
const clCnt = atoms.filter(a=>a.s==='Cl').length;
|
||||
if (clCnt) groups.push({ label: clCnt>1 ? `−Cl ×${clCnt}` : '−Cl', color:'#4ade80' });
|
||||
|
||||
// Phosphate
|
||||
for (const a of atoms) {
|
||||
if (a.s!=='P') continue;
|
||||
if (bondsOf(a.id).filter(b=>sym(othr(b,a.id))==='O').length >= 2) {
|
||||
groups.push({ label:'Фосфат', color:'#f97316' }); break;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function _molClass(cnt, dbe, fg) {
|
||||
const has = label => fg.some(g => g.label.startsWith(label));
|
||||
const onlyCH = Object.keys(cnt).every(el => el==='C'||el==='H');
|
||||
@@ -836,15 +767,6 @@ function _molClass(cnt, dbe, fg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function _polarity(cnt, fg) {
|
||||
if (cnt.Na||cnt.Ca||cnt.Mg||cnt.Fe) return { label:'Ионная', cls:'bad' };
|
||||
if (fg.some(g=>g.label.startsWith('−COOH')) || (cnt.O&&cnt.N))
|
||||
return { label:'Сильно полярная', cls:'bad' };
|
||||
if (cnt.O||cnt.N) return { label:'Полярная', cls:'warn' };
|
||||
if (cnt.Cl||cnt.S) return { label:'Слабо полярная', cls:'warn' };
|
||||
return { label:'Неполярная', cls:'good' };
|
||||
}
|
||||
|
||||
// ── Rendering ──
|
||||
function render() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -936,7 +858,7 @@ function renderAtom(a, hovered) {
|
||||
// Circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(a.x, a.y, r, 0, Math.PI*2);
|
||||
ctx.fillStyle = el.color;
|
||||
ctx.fillStyle = (_showCharges && _chargeMap) ? BIO.chargeColor(_chargeMap[a.id]) : el.color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = overloaded ? '#ef4444' : (hovered ? '#c084fc' : lighten(el.color));
|
||||
ctx.lineWidth = hovered ? 2.5 : 1.8;
|
||||
@@ -1350,6 +1272,42 @@ async function saveCurrentMolecule() {
|
||||
} catch(e) { LS.toast('Ошибка: '+e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── SMILES import ──
|
||||
function importSmiles() {
|
||||
const inp = document.getElementById('smiles-in');
|
||||
const smi = (inp.value || '').trim();
|
||||
if (!smi) return;
|
||||
const parsed = BIO.parseSmiles(smi);
|
||||
if (!parsed || !parsed.atoms.length) {
|
||||
LS.toast('Не удалось разобрать SMILES (поддержан верхний регистр: CCO, C1=CC=CC=C1)', 'error');
|
||||
return;
|
||||
}
|
||||
pushHistory();
|
||||
// переносим в редактор (bonds в формате {from,to,order})
|
||||
const idMap = {};
|
||||
atoms = parsed.atoms.map(a => { const nid = nextId++; idMap[a.id] = nid; return { id: nid, s: a.s, x: a.x, y: a.y }; });
|
||||
bonds = parsed.bonds.map(b => ({ id: nextId++, from: idMap[b.f], to: idMap[b.t], order: b.o }));
|
||||
inp.value = '';
|
||||
if (_is3D) _build3D();
|
||||
centerView(); updateInfo();
|
||||
LS.toast(`Импортировано: ${BIO.hillFormula(atoms)}`, 'success');
|
||||
}
|
||||
|
||||
// ── Export ──
|
||||
function exportPNG() {
|
||||
if (!atoms.length) { LS.toast('Пустой холст', 'info'); return; }
|
||||
const a = document.createElement('a');
|
||||
a.href = canvas.toDataURL('image/png');
|
||||
a.download = (hillFormula() || 'molecule') + (_is3D ? '-3d' : '') + '.png';
|
||||
a.click();
|
||||
}
|
||||
function exportJSON() {
|
||||
if (!atoms.length) { LS.toast('Пустой холст', 'info'); return; }
|
||||
BIO.download((hillFormula() || 'molecule') + '.json',
|
||||
BIO.toJSON(atoms, bonds.map(b => ({ f: b.from, t: b.to, o: b.order })), hillFormula()),
|
||||
'application/json');
|
||||
}
|
||||
|
||||
// ── Library ──
|
||||
async function loadFromLibrary() {
|
||||
if (!_libAll.length) _libAll = await LS.biochemGetMolecules();
|
||||
@@ -1716,6 +1674,16 @@ function toggleVDW() {
|
||||
document.getElementById('btn-vdw').classList.toggle('mode-3d-active', _isVDW);
|
||||
}
|
||||
|
||||
// ── Partial-charge heatmap (δ+/δ−) + dipole arrow ──
|
||||
let _showCharges = false;
|
||||
let _chargeMap = null; // { atomId: partialCharge }
|
||||
let _dipoleVec = null; // [x,y,z]
|
||||
function toggleCharges() {
|
||||
_showCharges = !_showCharges;
|
||||
document.getElementById('btn-charge').classList.toggle('mode-3d-active', _showCharges);
|
||||
if (_is3D) render3D(); else render();
|
||||
}
|
||||
|
||||
function _start3D() {
|
||||
_stop3D();
|
||||
function frame() {
|
||||
@@ -1748,7 +1716,11 @@ function render3D() {
|
||||
// 3D coords are in canvas units (~real geometry); scale to fit the view
|
||||
BIO.render3D(ctx, _atoms3d, bonds, {
|
||||
rotX: _3dRotX, rotY: _3dRotY, scale: scale * 1.6, W, H,
|
||||
}, { vdw: _isVDW, bg: '#07070f' });
|
||||
}, {
|
||||
vdw: _isVDW, bg: '#07070f',
|
||||
charges: _showCharges ? _chargeMap : null,
|
||||
dipoleVec: _showCharges ? _dipoleVec : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
|
||||
Reference in New Issue
Block a user