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:
Maxim Dolgolyov
2026-05-30 13:53:40 +03:00
parent eb5593333c
commit 177a5b94d7
6 changed files with 768 additions and 146 deletions
+86 -1
View File
@@ -560,6 +560,11 @@ function renderList(rxns) {
<div class="rxn-details-grid">
${r.conditions ? `<div class="rxn-detail-item"><div class="rxn-detail-label">Условия</div><div class="rxn-detail-value">${r.conditions}</div></div>` : ''}
${r.energy_kj != null ? `<div class="rxn-detail-item"><div class="rxn-detail-label">ΔH (энергия)</div><div class="rxn-detail-value">${r.energy_kj} кДж/моль</div></div>` : ''}
<div class="rxn-detail-item" id="rxn-balance-${r.id}" style="display:none"><div class="rxn-detail-label">Баланс</div><div class="rxn-detail-value" id="rxn-balance-val-${r.id}"></div></div>
</div>` : ''}
${r.energy_kj != null ? `<div class="rxn-energy-section" style="margin-top:10px">
<div class="rxn-mols-label">Энергетический профиль</div>
<canvas id="rxn-energy-${r.id}" width="300" height="150" style="width:100%;max-width:340px;height:auto;display:block;margin-top:6px;border-radius:10px;background:#08080f"></canvas>
</div>` : ''}
<div class="rxn-mols-section">
<div class="rxn-mols-label">Молекулы реакции</div>
@@ -601,7 +606,87 @@ async function toggleCard(card, r) {
const wasExpanded = card.classList.contains('expanded');
card.classList.toggle('expanded', !wasExpanded);
card.querySelector('.rxn-expand-btn').innerHTML = card.classList.contains('expanded') ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="22 15 12 3 2 15"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg>';
if (card.classList.contains('expanded')) await loadMolThumbs(r);
if (card.classList.contains('expanded')) {
await loadMolThumbs(r);
drawEnergyDiagram(r);
checkBalance(r);
}
}
// ── Энергетический профиль реакции (реагенты → переходное состояние → продукты) ──
function drawEnergyDiagram(r) {
const dH = parseFloat(r.energy_kj);
if (isNaN(dH)) return;
const cvs = document.getElementById(`rxn-energy-${r.id}`);
if (!cvs || cvs.dataset.drawn) return;
cvs.dataset.drawn = '1';
const ctx = cvs.getContext('2d');
const W = cvs.width, H = cvs.height, padX = 36, padY = 24;
ctx.clearRect(0, 0, W, H);
// нормировка энергии в пиксели
const span = Math.max(Math.abs(dH), 100);
const exo = dH < 0;
// уровни: реагенты y0, продукты yP = y0 + dH (экзо вниз)
const baseY = exo ? padY + 14 : H - padY - 14; // реагенты сверху если экзо
const prodY = baseY + (exo ? 1 : -1) * (Math.abs(dH) / span) * (H - padY * 2 - 28);
const xR = padX, xM = W / 2, xP = W - padX;
// энергия активации — горб над более высоким уровнем
const topY = Math.min(baseY, prodY);
const peakY = topY - 26;
// ось/сетка
ctx.strokeStyle = 'rgba(255,255,255,.08)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(padX, padY - 6); ctx.lineTo(padX, H - padY + 6); ctx.lineTo(W - 8, H - padY + 6); ctx.stroke();
// кривая профиля
ctx.strokeStyle = exo ? '#4ade80' : '#fb923c'; ctx.lineWidth = 2.4; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(xR, baseY);
ctx.lineTo(xR + 22, baseY);
ctx.bezierCurveTo(xM - 30, baseY, xM - 18, peakY, xM, peakY);
ctx.bezierCurveTo(xM + 18, peakY, xP - 52, prodY, xP - 22, prodY);
ctx.lineTo(xP, prodY);
ctx.stroke();
// подписи уровней
ctx.fillStyle = '#9aa'; ctx.font = "600 9px Manrope,sans-serif"; ctx.textAlign = 'center';
ctx.fillText('Реагенты', xR + 16, baseY + (exo ? -8 : 14));
ctx.fillText('Продукты', xP - 14, prodY + (exo ? 14 : -8));
ctx.fillStyle = '#facc15'; ctx.fillText('ПС', xM, peakY - 7);
// стрелка ΔH
ctx.strokeStyle = 'rgba(255,255,255,.35)'; ctx.setLineDash([3, 3]); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(xP - 4, baseY); ctx.lineTo(xP - 4, prodY); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = exo ? '#4ade80' : '#fb923c'; ctx.font = "700 10px Manrope,sans-serif"; ctx.textAlign = 'right';
ctx.fillText(`ΔH ${dH > 0 ? '+' : ''}${dH}`, xP - 8, (baseY + prodY) / 2 + 3);
ctx.fillStyle = exo ? '#4ade80' : '#fb923c'; ctx.textAlign = 'left'; ctx.font = "600 9px Manrope,sans-serif";
ctx.fillText(exo ? 'экзотермическая' : 'эндотермическая', padX + 4, padY - 2);
}
// ── Проверка баланса уравнения авто-балансировщиком ──
async function checkBalance(r) {
if (!window.BIO) return;
const wrap = document.getElementById(`rxn-balance-${r.id}`);
const val = document.getElementById(`rxn-balance-val-${r.id}`);
if (!wrap || !val || wrap.dataset.done) return;
const rIds = r.reactant_ids || [], pIds = r.product_ids || [];
if (!rIds.length || !pIds.length) return;
wrap.dataset.done = '1';
const map = await fetchMols([...new Set([...rIds, ...pIds])]);
const rf = rIds.map(id => map[id] && map[id].formula).filter(Boolean);
const pf = pIds.map(id => map[id] && map[id].formula).filter(Boolean);
if (rf.length !== rIds.length || pf.length !== pIds.length) return;
const res = BIO.balance(rf, pf);
wrap.style.display = '';
if (res) {
const coef = c => c > 1 ? c : '';
const lhs = rf.map((f, i) => coef(res.reactants[i]) + f).join(' + ');
const rhs = pf.map((f, i) => coef(res.products[i]) + f).join(' + ');
val.innerHTML = `<span style="color:#4ade80">✓ сбалансировано</span> <span style="color:#888;font-size:.92em">${lhs}${rhs}</span>`;
} else {
val.innerHTML = `<span style="color:#facc15">требует коэффициентов</span>`;
}
}
async function loadMolThumbs(r) {