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
+64
View File
@@ -480,15 +480,79 @@ function renderCompare() {
<tr><td class="row-label">Описание</td>${_compare.map(m=>`<td class="mol-val" style="font-size:.7rem;color:#888">${m.description||'—'}</td>`).join('')}</tr>
</tbody>
</table></div>`;
// график молярных масс + экспорт
html += `<div class="compare-chart-wrap" style="margin-top:14px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:14px;padding:14px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<div class="detail-label" style="margin:0">Молярная масса, г/моль</div>
<button onclick="exportCompareCSV()" style="height:28px;padding:0 12px;border-radius:8px;border:1.5px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06);color:#aaa;font:700 .72rem Manrope,sans-serif;cursor:pointer">Экспорт CSV</button>
</div>
<canvas id="cmp-chart" width="600" height="200" style="width:100%;height:auto;display:block"></canvas>
</div>`;
}
area.innerHTML = html;
// Draw compare canvases
setTimeout(() => {
for (const mol of _compare) drawCompareCanvas(mol);
if (_compare.length >= 2) drawMassChart();
}, 0);
}
// ── Столбчатый график молярных масс ──
function drawMassChart() {
const cvs = document.getElementById('cmp-chart');
if (!cvs) return;
const W = cvs.width, H = cvs.height, ctx = cvs.getContext('2d');
ctx.clearRect(0, 0, W, H);
const data = _compare.map(m => ({ label: m.formula, v: molarMass(m.formula), name: m.name_ru }));
const maxV = Math.max(...data.map(d => d.v), 1);
const padB = 34, padT = 18, padL = 8, padR = 8;
const n = data.length, gap = 18;
const bw = Math.min(90, (W - padL - padR - gap * (n - 1)) / n);
const colors = ['#9B5DE5', '#06D6E0', '#facc15', '#4ade80'];
// ось
ctx.strokeStyle = 'rgba(255,255,255,.1)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(padL, H - padB); ctx.lineTo(W - padR, H - padB); ctx.stroke();
const totalW = bw * n + gap * (n - 1);
let x = (W - totalW) / 2;
data.forEach((d, i) => {
const bh = (d.v / maxV) * (H - padB - padT);
const y = H - padB - bh;
const col = colors[i % colors.length];
const grd = ctx.createLinearGradient(0, y, 0, H - padB);
grd.addColorStop(0, col); grd.addColorStop(1, col + '55');
ctx.fillStyle = grd;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, bw, bh, 6); else ctx.rect(x, y, bw, bh);
ctx.fill();
ctx.fillStyle = '#ddd'; ctx.font = '700 11px Manrope,sans-serif'; ctx.textAlign = 'center';
ctx.fillText(d.v.toFixed(1), x + bw / 2, y - 5);
ctx.fillStyle = '#888'; ctx.font = '600 10px Manrope,sans-serif';
ctx.fillText(d.label.length > 10 ? d.label.slice(0, 9) + '…' : d.label, x + bw / 2, H - padB + 14);
x += bw + gap;
});
}
// ── Экспорт сравнения в CSV ──
function exportCompareCSV() {
const rows = [['Свойство', ..._compare.map(m => m.name_ru)]];
const cell = v => `"${String(v == null ? '—' : v).replace(/"/g, '""')}"`;
rows.push(['Формула', ..._compare.map(m => m.formula)]);
rows.push(['Молярная масса, г/моль', ..._compare.map(m => { const v = molarMass(m.formula); return v > 0 ? v.toFixed(2) : '—'; })]);
rows.push(['Агр. состояние', ..._compare.map(m => getPhysProps(m.formula).state)]);
rows.push(['Растворимость', ..._compare.map(m => getPhysProps(m.formula).solubility)]);
rows.push(['T кипения', ..._compare.map(m => getPhysProps(m.formula).bp)]);
rows.push(['T плавления', ..._compare.map(m => getPhysProps(m.formula).mp)]);
rows.push(['Категория', ..._compare.map(m => catLabel(m.category))]);
const csv = '' + rows.map(r => r.map(cell).join(',')).join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'сравнение-молекул.csv';
a.click();
URL.revokeObjectURL(a.href);
}
// Per-card 3D view state: id -> { on, rotY, anim }
const _cc3d = {};
function _stopCC(id) { const s = _cc3d[id]; if (s && s.anim) { cancelAnimationFrame(s.anim); s.anim = null; } }