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
+84
View File
@@ -0,0 +1,84 @@
'use strict';
/*
* Регресс-тесты химического ядра frontend/js/biochem-core.js (window.BIO).
* Чистые функции (формулы, VSEPR, заряды, диполь, баланс, SMILES) — без DOM.
*/
const test = require('node:test');
const assert = require('node:assert');
const path = require('node:path');
// shim browser global, then load the frontend module
global.window = global;
require(path.join(__dirname, '..', '..', 'frontend', 'js', 'biochem-core.js'));
const B = global.BIO;
const m = (atoms, bonds) => ({ atoms, bonds });
const water = m([{ id: 1, s: 'O' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }], [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }]);
const co2 = m([{ id: 1, s: 'C' }, { id: 2, s: 'O' }, { id: 3, s: 'O' }], [{ f: 1, t: 2, o: 2 }, { f: 1, t: 3, o: 2 }]);
const methane = m([{ id: 1, s: 'C' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }, { id: 4, s: 'H' }, { id: 5, s: 'H' }],
[{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }, { f: 1, t: 4, o: 1 }, { f: 1, t: 5, o: 1 }]);
function bondAngle(a3, c, n1, n2) {
const C = a3.find(a => a.id === c), P = a3.find(a => a.id === n1), Q = a3.find(a => a.id === n2);
const v1 = [P.x - C.x, P.y - C.y, P.z - C.z], v2 = [Q.x - C.x, Q.y - C.y, Q.z - C.z];
const d = v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2];
return Math.acos(d / (Math.hypot(...v1) * Math.hypot(...v2))) * 180 / Math.PI;
}
test('hillFormula & molarMass', () => {
assert.equal(B.hillFormula(methane.atoms), 'CH4');
assert.ok(Math.abs(B.molarMass(methane.atoms) - 16.04) < 0.05);
});
test('parseFormula handles parentheses', () => {
assert.deepEqual(B.parseFormula('Ca(OH)2'), { Ca: 1, O: 2, H: 2 });
});
test('VSEPR geometry: water bent, methane tetrahedral, CO2 linear', () => {
const gw = B.vsepr(water.atoms, water.bonds);
assert.equal(gw.shape, 'угловая');
assert.equal(gw.hybridization, 'sp³');
const gm = B.vsepr(methane.atoms, methane.bonds);
assert.equal(gm.shape, 'тетраэдрическая');
assert.ok(Math.abs(bondAngle(gm.atoms3d, 1, 2, 3) - 109.5) < 1.5);
const gc = B.vsepr(co2.atoms, co2.bonds);
assert.equal(gc.shape, 'линейная');
assert.ok(Math.abs(bondAngle(gc.atoms3d, 1, 2, 3) - 180) < 1);
});
test('partial charges: water O negative, H positive', () => {
const q = B.partialCharges(water.atoms, water.bonds);
assert.ok(q[1] < -0.3, 'O should be δ−');
assert.ok(q[2] > 0.1 && q[3] > 0.1, 'H should be δ+');
});
test('polarity: symmetric molecules nonpolar, water polar', () => {
assert.equal(B.polarity(co2.atoms, co2.bonds).label, 'Неполярная');
assert.equal(B.polarity(methane.atoms, methane.bonds).label, 'Неполярная');
assert.ok(B.polarity(water.atoms, water.bonds).dipole > 0.3);
});
test('balance: classic equations', () => {
assert.deepEqual(B.balance(['H2', 'O2'], ['H2O']).coefficients, [2, 1, 2]);
assert.deepEqual(B.balance(['CH4', 'O2'], ['CO2', 'H2O']).coefficients, [1, 2, 1, 2]);
assert.deepEqual(B.balance(['Fe', 'O2'], ['Fe2O3']).coefficients, [4, 3, 2]);
assert.deepEqual(B.balance(['Ca(OH)2', 'HCl'], ['CaCl2', 'H2O']).coefficients, [1, 2, 1, 2]);
});
test('parseSmiles: skeleton + implicit H', () => {
assert.equal(B.hillFormula(B.parseSmiles('CCO').atoms), 'C2H6O');
assert.equal(B.hillFormula(B.parseSmiles('CC(=O)O').atoms), 'C2H4O2');
assert.equal(B.hillFormula(B.parseSmiles('C1=CC=CC=C1').atoms), 'C6H6');
assert.equal(B.hillFormula(B.parseSmiles('ClC(Cl)(Cl)Cl').atoms), 'CCl4');
assert.equal(B.parseSmiles('bad[x]'), null);
});
test('analyze returns a complete report', () => {
const a = B.analyze(water.atoms, water.bonds);
assert.equal(a.formula, 'H2O');
assert.ok(a.geometry && a.geometry.shape === 'угловая');
assert.ok(a.dipole > 0);
assert.ok(a.massFractions.O > 80);
});
+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; } }
+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) {
+79 -107
View File
@@ -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 ──
+422 -30
View File
@@ -109,6 +109,316 @@
return (2 * C + 2 + N + P - H - X) / 2;
}
/* ── Химический движок: заряды, диполь, полярность, группы ─────────────────
* Частичные заряды — по разнице электроотрицательностей на связях
* (модель Гusing EN): электроны смещаются к более электроотрицательному
* атому, менее ЭО атом получает δ+, более ЭО — δ−.
*/
const _CHARGE_K = 0.21;
function partialCharges(atoms, bonds) {
const byId = {}; atoms.forEach(a => byId[a.id] = a);
const q = {}; atoms.forEach(a => q[a.id] = 0);
for (const b of bonds || []) {
const f = bF(b), t = bT(b), o = bO(b);
const af = byId[f], at = byId[t];
if (!af || !at) continue;
const d = (el(at.s).en - el(af.s).en) * o * _CHARGE_K; // поток к более ЭО
q[f] += d; // менее ЭО → δ+
q[t] -= d; // более ЭО → δ−
}
return q;
}
/* Дипольный момент — векторная сумма q·r по 3D-координатам (из VSEPR).
* Симметричные молекулы (CO₂, CH₄, CCl₄) дают ~0 → неполярны; это и есть
* окупаемость настоящей 3D-геометрии. Возврат в условных «дебаях» (D-прокси).
*/
function dipole(atoms, bonds, geom) {
const g = geom || vsepr(atoms, bonds);
const q = partialCharges(atoms, bonds);
let vx = 0, vy = 0, vz = 0;
for (const a of g.atoms3d) { const c = q[a.id] || 0; vx += c * a.x; vy += c * a.y; vz += c * a.z; }
const BOND = 94; // ~длина C–C в усл. ед. (нормировка к «дебаям»)
const magnitude = Math.hypot(vx, vy, vz) / BOND * 4.0;
return { vector: [vx, vy, vz], magnitude, charges: q };
}
/* Классификация полярности на основе диполя и состава. */
function polarity(atoms, bonds, geom) {
const c = counts(atoms);
if (c.Na || c.K || c.Ca || c.Mg || c.Fe) return { label: 'Ионная', cls: 'bad', dipole: null };
if (atoms.length < 2) return { label: '—', cls: '', dipole: 0 };
const dp = dipole(atoms, bonds, geom);
const m = dp.magnitude;
let label, cls;
if (m < 0.18) { label = 'Неполярная'; cls = 'good'; }
else if (m < 0.55) { label = 'Слабо полярная'; cls = 'warn'; }
else if (m < 1.5) { label = 'Полярная'; cls = 'warn'; }
else { label = 'Сильно полярная'; cls = 'bad'; }
return { label, cls, dipole: m, vector: dp.vector, charges: dp.charges };
}
/* Массовые доли элементов (%). */
function massFractions(atoms) {
const c = counts(atoms);
const total = molarMass(atoms) || 1;
const out = {};
for (const s of Object.keys(c)) out[s] = (el(s).mass * c[s] / total) * 100;
return out;
}
/* Детекция функциональных групп (паттерн-матчинг по графу). */
function functionalGroups(atoms, bonds) {
const byId = {}; atoms.forEach(a => byId[a.id] = a);
const bondsOf = id => (bonds || []).filter(b => bF(b) === id || bT(b) === id);
const othr = (b, id) => bF(b) === id ? bT(b) : bF(b);
const sym = id => byId[id] && byId[id].s;
const groups = [];
const usedC = new Set();
for (const a of atoms) {
if (a.s !== 'C') continue;
const my = bondsOf(a.id);
const dblO = my.some(b => bO(b) === 2 && sym(othr(b, a.id)) === 'O');
const sglO = my.filter(b => bO(b) === 1 && sym(othr(b, a.id)) === 'O');
if (dblO && sglO.length) {
const oId = othr(sglO[0], a.id);
if (bondsOf(oId).some(b => sym(othr(b, oId)) === 'H')) { groups.push({ label: 'COOH', color: '#f87171' }); usedC.add(a.id); continue; }
groups.push({ label: 'COO (эфир)', color: '#fb923c' }); usedC.add(a.id); continue;
}
if (dblO && !usedC.has(a.id)) {
const hN = my.some(b => sym(othr(b, a.id)) === 'H');
const cN = my.filter(b => sym(othr(b, a.id)) === 'C').length;
groups.push({ label: hN ? 'CHO' : (cN >= 2 ? 'C=O (кетон)' : 'C=O'), color: '#fb923c' });
usedC.add(a.id);
}
}
const ohN = atoms.filter(a => a.s === 'O' && bondsOf(a.id).some(b => bO(b) === 1 && sym(othr(b, a.id)) === 'H')).length;
if (ohN) groups.push({ label: ohN > 1 ? `OH ×${ohN}` : 'OH', color: '#60a5fa' });
for (const a of atoms) {
if (a.s !== 'N') continue;
const hC = bondsOf(a.id).filter(b => sym(othr(b, a.id)) === 'H').length;
if (hC >= 2) groups.push({ label: 'NH₂', color: '#34d399' });
else if (hC === 1) groups.push({ label: 'NH', color: '#34d399' });
}
if (atoms.some(a => a.s === 'S' && bondsOf(a.id).some(b => sym(othr(b, a.id)) === 'H'))) groups.push({ label: 'SH', color: '#fbbf24' });
const enes = (bonds || []).filter(b => bO(b) === 2 && sym(bF(b)) === 'C' && sym(bT(b)) === 'C');
if (enes.length) groups.push({ label: enes.length > 1 ? `C=C ×${enes.length}` : 'C=C', color: '#a78bfa' });
if ((bonds || []).some(b => bO(b) === 3 && sym(bF(b)) === 'C' && sym(bT(b)) === 'C')) groups.push({ label: 'C≡C', color: '#e879f9' });
const cIds = new Set(atoms.filter(a => a.s === 'C').map(a => a.id));
if ((bonds || []).filter(b => bO(b) === 2 && cIds.has(bF(b)) && cIds.has(bT(b))).length >= 3) groups.push({ label: 'Арен', color: '#06D6E0' });
const halos = ['F', 'Cl', 'Br', 'I'];
for (const h of halos) { const n = atoms.filter(a => a.s === h).length; if (n) groups.push({ label: n > 1 ? `${h} ×${n}` : `${h}`, color: '#4ade80' }); }
for (const a of atoms) { if (a.s === 'P' && bondsOf(a.id).filter(b => sym(othr(b, a.id)) === 'O').length >= 2) { groups.push({ label: 'Фосфат', color: '#f97316' }); break; } }
return groups;
}
/* Полный анализ молекулы — единая точка для всех страниц. */
function analyze(atoms, bonds) {
if (!atoms || !atoms.length) return null;
const geom = vsepr(atoms, bonds);
const pol = polarity(atoms, bonds, geom);
return {
formula: hillFormula(atoms),
mass: molarMass(atoms),
dbe: dbe(atoms),
atomCount: atoms.length,
geometry: { shape: geom.shape, hybridization: geom.hybridization, angle: geom.angle, centerSym: geom.centerSym },
polarity: pol,
charges: pol.charges || partialCharges(atoms, bonds),
dipole: pol.dipole,
groups: functionalGroups(atoms, bonds),
massFractions: massFractions(atoms),
atoms3d: geom.atoms3d,
perAtom: geom.perAtom,
};
}
/* ── Балансировка уравнений реакций ───────────────────────────────────────
* Вход: reactants[], products[] — массивы строковых формул ("H2","O2",...).
* Метод: матрица «элемент × вещество» (реагенты +, продукты −), поиск
* целочисленного вектора в ядре через дробный метод Гаусса + НОК/НОД.
* Выход: { coefficients:[...], reactants:[...], products:[...] } или null.
*/
function _gcd(a, b) { a = Math.abs(a); b = Math.abs(b); while (b) { [a, b] = [b, a % b]; } return a || 1; }
function _lcm(a, b) { return Math.abs(a * b) / _gcd(a, b); }
// дроби как [num, den]
function _fr(n, d) { d = d || 1; if (d < 0) { n = -n; d = -d; } const g = _gcd(n, d) || 1; return [n / g, d / g]; }
function _frSub(a, b) { return _fr(a[0] * b[1] - b[0] * a[1], a[1] * b[1]); }
function _frMul(a, b) { return _fr(a[0] * b[0], a[1] * b[1]); }
function _frDiv(a, b) { return _fr(a[0] * b[1], a[1] * b[0]); }
function balance(reactants, products) {
const species = [...reactants, ...products];
if (species.length < 2) return null;
const nR = reactants.length;
const elemSet = new Set();
const comps = species.map(f => { const c = parseFormula(f); Object.keys(c).forEach(e => elemSet.add(e)); return c; });
const elements = [...elemSet];
const n = species.length;
// матрица элементов (дроби)
let M = elements.map(el => comps.map((c, i) => _fr((c[el] || 0) * (i < nR ? 1 : -1), 1)));
// RREF
const rows = M.length, cols = n;
let pivotCols = [];
let r = 0;
for (let c = 0; c < cols && r < rows; c++) {
let piv = -1;
for (let i = r; i < rows; i++) if (M[i][c][0] !== 0) { piv = i; break; }
if (piv < 0) continue;
[M[r], M[piv]] = [M[piv], M[r]];
const pv = M[r][c];
for (let j = 0; j < cols; j++) M[r][j] = _frDiv(M[r][j], pv);
for (let i = 0; i < rows; i++) {
if (i === r || M[i][c][0] === 0) continue;
const factor = M[i][c];
for (let j = 0; j < cols; j++) M[i][j] = _frSub(M[i][j], _frMul(factor, M[r][j]));
}
pivotCols.push(c);
r++;
}
// свободные столбцы (нет пивота) → ставим параметр 1
const pivotSet = new Set(pivotCols);
const free = [];
for (let c = 0; c < cols; c++) if (!pivotSet.has(c)) free.push(c);
if (free.length !== 1) return null; // нет однозначного баланса (или недоопределено)
const freeCol = free[0];
// x[freeCol] = 1; x[pivot] = -M[row][freeCol]
const x = new Array(cols).fill(null);
x[freeCol] = _fr(1, 1);
for (let i = 0; i < pivotCols.length; i++) {
const pc = pivotCols[i];
x[pc] = _fr(-M[i][freeCol][0], M[i][freeCol][1]);
}
// к целым: умножить на НОК знаменателей
let denLcm = 1;
for (const v of x) denLcm = _lcm(denLcm, v[1]);
let ints = x.map(v => v[0] * (denLcm / v[1]));
// знак: сделать положительными
if (ints.some(v => v < 0) && ints.every(v => v <= 0)) ints = ints.map(v => -v);
if (ints.some(v => v < 0)) return null; // несбалансируемо в положительных
// сократить на общий НОД
let g = 0; for (const v of ints) g = _gcd(g, v);
if (g > 1) ints = ints.map(v => v / g);
if (ints.some(v => v <= 0)) return null;
return { coefficients: ints, reactants: ints.slice(0, nR), products: ints.slice(nR) };
}
/* ── Парсер SMILES (учебное подмножество) ─────────────────────────────────
* Поддержка: органические атомы в ВЕРХНЕМ регистре (B,C,N,O,P,S,F,Cl,Br,I,H),
* связи -, =, #, ветви ( ), замыкание циклов цифрами и %nn. Неявные H
* достраиваются по валентности. Возврат {atoms:[{id,s,x,y}], bonds:[{f,t,o}]}
* или null. Ароматика в нижнем регистре (c,n,o…) НЕ поддержана — используйте
* форму Кекуле (C1=CC=CC=C1). 2D-укладка — BFS с разводом углов.
*/
function parseSmiles(str) {
if (!str || typeof str !== 'string') return null;
str = str.trim().replace(/\s+/g, '');
if (!str) return null;
const atoms = [], bonds = [];
let id = 1;
const twoLetter = { C: ['Cl'], B: ['Br'] };
const known = new Set(['B','C','N','O','P','S','F','I','H','Cl','Br']);
const stack = []; // для ветвей: сохранённые «текущие» атомы
const ring = {}; // digit -> {atom, order}
let prev = null; // предыдущий атом для связи
let pendingOrder = 0; // 0 = по умолчанию (1)
let i = 0;
const addAtom = s => { const a = { id: id++, s, x: 0, y: 0 }; atoms.push(a); return a; };
const addBond = (f, t, o) => { if (f === t) return; bonds.push({ f, t, o: o || 1 }); };
while (i < str.length) {
const ch = str[i];
if (ch === '(') { stack.push(prev); i++; continue; }
if (ch === ')') { prev = stack.pop() ?? prev; i++; continue; }
if (ch === '-') { pendingOrder = 1; i++; continue; }
if (ch === '=') { pendingOrder = 2; i++; continue; }
if (ch === '#') { pendingOrder = 3; i++; continue; }
if (ch === '%') {
const d = str.slice(i + 1, i + 3);
i += 3;
_ringClose(d);
continue;
}
if (ch >= '0' && ch <= '9') { _ringClose(ch); i++; continue; }
// атом: пробуем двухбуквенный
let sym = null;
const pair = str.slice(i, i + 2);
if (twoLetter[ch] && twoLetter[ch].includes(pair)) { sym = pair; i += 2; }
else if (known.has(ch)) { sym = ch; i += 1; }
else return null; // неподдержанный символ (в т.ч. строчная ароматика, [..])
const a = addAtom(sym);
if (prev) addBond(prev.id, a.id, pendingOrder || 1);
pendingOrder = 0;
prev = a;
}
function _ringClose(d) {
if (ring[d]) { addBond(ring[d].atom.id, prev.id, ring[d].order || pendingOrder || 1); delete ring[d]; pendingOrder = 0; }
else { ring[d] = { atom: prev, order: pendingOrder || 0 }; pendingOrder = 0; }
}
if (!atoms.length) return null;
// неявные H по валентности
const sumOrder = {}; atoms.forEach(a => sumOrder[a.id] = 0);
for (const b of bonds) { sumOrder[b.f] += b.o; sumOrder[b.t] += b.o; }
const heavy = atoms.slice();
for (const a of heavy) {
if (a.s === 'H') continue;
const maxV = el(a.s).maxV || 4;
const need = maxV - (sumOrder[a.id] || 0);
for (let k = 0; k < need; k++) { const h = addAtom('H'); addBond(a.id, h.id, 1); }
}
_layout2D(atoms, bonds);
return { atoms, bonds };
}
// Простая 2D-укладка: BFS, развод связей по углам, длина ~55
function _layout2D(atoms, bonds) {
const byId = {}; atoms.forEach(a => byId[a.id] = a);
const adj = {}; atoms.forEach(a => adj[a.id] = []);
for (const b of bonds) { adj[b.f].push(b.t); adj[b.t].push(b.f); }
const placed = new Set();
const L = 55;
let root = atoms[0];
for (const a of atoms) if (adj[a.id].length > adj[root.id].length) root = a;
root.x = 0; root.y = 0; placed.add(root.id);
const q = [{ id: root.id, dir: 0 }];
while (q.length) {
const { id, dir } = q.shift();
const cur = byId[id];
const nb = adj[id].filter(n => !placed.has(n));
const n = nb.length;
// развод: вокруг направления «от родителя», сектор ~270°
const spread = Math.PI * 1.5;
nb.forEach((nid, k) => {
const ang = dir + (n === 1 ? 0.6 : (-spread / 2 + spread * (k / Math.max(1, n - 1)))) ;
const c = byId[nid];
c.x = cur.x + Math.cos(ang) * L;
c.y = cur.y + Math.sin(ang) * L;
placed.add(nid);
q.push({ id: nid, dir: ang });
});
}
}
/* ── Экспорт молекулы ───────────────────────────────────────────────────── */
function toJSON(atoms, bonds, name) {
return JSON.stringify({
name: name || hillFormula(atoms),
formula: hillFormula(atoms),
atoms: atoms.map(a => ({ id: a.id, s: a.s, x: Math.round(a.x), y: Math.round(a.y) })),
bonds: (bonds || []).map(b => ({ f: bF(b), t: bT(b), o: bO(b) })),
}, null, 2);
}
function download(filename, content, mime) {
const blob = new Blob([content], { type: mime || 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
/* ── 2D-рендер (ball-and-stick для превью) ────────────────────────────────
* atoms: [{s,x,y}] bonds: [{f,t,o}] | [{from,to,order}]
* opts: { fit:true|false, padding, bg, lineColor, showSymbols, hideH, scale }
@@ -165,10 +475,11 @@
if (opts.hideH && a.s === 'H') continue;
const p = P(a);
const r = Math.max(3, e.radius * sc * (opts.atomScale || 1));
const fill = opts.charges ? chargeColor(opts.charges[a.id]) : e.color;
const grd = ctx.createRadialGradient(p.x - r * 0.3, p.y - r * 0.35, r * 0.1, p.x, p.y, r);
grd.addColorStop(0, _lighten(e.color, 90));
grd.addColorStop(0.5, e.color);
grd.addColorStop(1, _darken(e.color, 0.55));
grd.addColorStop(0, _lighten(fill, 90));
grd.addColorStop(0.5, fill);
grd.addColorStop(1, _darken(fill, 0.55));
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = grd; ctx.fill();
if (showSym && r > 6 && (a.s !== 'H' || r > 9)) {
@@ -396,62 +707,107 @@
* 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 [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 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 fillHex = opts.charges ? chargeColor(opts.charges[a.id]) : e.color;
const [r0, g0, b0] = _hexRgb(fillHex);
// глянцевый блик смещён к свету (верх-лево)
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, fillHex);
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`;
@@ -459,6 +815,31 @@
ctx.fillText(a.s, sx, sy);
}
}
// стрелка дипольного момента (от центра к δ−), если передан вектор
if (opts.dipoleVec) {
const [dx, dy, dz] = opts.dipoleVec;
const dl = Math.hypot(dx, dy, dz);
if (dl > 1e-3) {
const proj = (x, y, z) => {
const x1 = x * cyr + z * syr, z1 = -x * syr + z * cyr;
const y2 = y * cxr - z1 * sxr, z2 = y * sxr + z1 * cxr;
const pp = fov / (fov + z2);
return [x1 * pp + W / 2, y2 * pp + H / 2];
};
const L = 70; // длина стрелки в экранных ед.
const ux = dx / dl, uy = dy / dl, uz = dz / dl;
const [ax, ay] = proj(0, 0, 0);
const [bx, by] = proj(ux * L / sc, uy * L / sc, uz * L / sc);
ctx.strokeStyle = '#facc15'; ctx.fillStyle = '#facc15'; ctx.lineWidth = 2.5; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
const ang = Math.atan2(by - ay, bx - ax), ah = 9;
ctx.beginPath(); ctx.moveTo(bx, by);
ctx.lineTo(bx - ah * Math.cos(ang - 0.4), by - ah * Math.sin(ang - 0.4));
ctx.lineTo(bx - ah * Math.cos(ang + 0.4), by - ah * Math.sin(ang + 0.4));
ctx.closePath(); ctx.fill();
}
}
}
/* ── Цветовые утилиты ─────────────────────────────────────────────────── */
@@ -476,6 +857,15 @@
const [r, g, b] = _hexRgb(hex);
return `rgb(${Math.round(r * f)},${Math.round(g * f)},${Math.round(b * f)})`;
}
// Цвет атома по частичному заряду: δ+ синий, δ− красный, 0 серый
function chargeColor(q) {
const grey = [138, 138, 138];
const t = Math.max(-1, Math.min(1, (q || 0) / 0.5));
const target = t > 0 ? [64, 96, 255] : [238, 32, 32]; // δ+ синий / δ− красный
const k = Math.abs(t);
const mix = grey.map((g, i) => Math.round(g + (target[i] - g) * k));
return `#${mix.map(v => v.toString(16).padStart(2, '0')).join('')}`;
}
/* ── safe: обёртка для API с тостом ошибки ───────────────────────────────
* await BIO.safe(LS.biochemGetMolecules(), 'Не удалось загрузить молекулы')
@@ -526,7 +916,9 @@
ELEMENTS, el,
bF, bT, bO,
counts, hillFormula, molarMass, parseFormula, dbe,
render2D, vsepr, render3D,
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze,
balance, parseSmiles, toJSON, download,
render2D, vsepr, render3D, chargeColor,
safe, RING_TEMPLATES,
_hexRgb, _lighten, _darken,
};
+33 -8
View File
@@ -63,7 +63,14 @@
---
## Фаза 2 — Химический движок (свойства из структуры) — [ ]
## Фаза 2 — Химический движок (свойства из структуры) — [x]
> Сделано client-side в `BIO` (для всех страниц, без сервера; тег `biochem-phase2`):
> `partialCharges` (по ЭО на связях), `dipole` (вектор q·r по 3D VSEPR), `polarity`
> (по диполю), `massFractions`, `functionalGroups`, `analyze` — заменили фронт-эвристику.
> Тумблер **δ±** в редакторе: тепловая карта зарядов (синий δ+/красный δ−) в 2D+3D +
> стрелка диполя; число диполя в панели свойств.
> _Проверено: H₂O O=0.52/H=+0.26; CO₂/CH₄/CCl₄ диполь 0 → неполярны; H₂O/CHCl₃ полярны._
Считать химию, а не хранить класс строкой.
@@ -78,9 +85,16 @@
---
## Фаза 3 — Реакции: стехиометрия, баланс, энергетика — [ ]
## Фаза 3 — Реакции: стехиометрия, баланс, энергетика — [~]
- [ ] 3.1 Авто-балансировщик `backend/src/services/balance.js`: матрица элементов → целочисленное решение (Гаусс + НОК). Эндпойнт `POST /api/biochem/balance`.
> Сделано (тег `biochem-phase3`): `BIO.balance(reactants,products)` — балансировка
> через матрицу «элемент×вещество» + дробный Гаусс (RREF) + НОК/НОД (client-side,
> вместо серверного `balance.js`). В [biochem-reactions.html](frontend/biochem-reactions.html) при развороте карточки:
> энергетический профиль (реагенты→ПС→продукты) из `energy_kj` + бейдж проверки
> баланса. Миграция коэффициентов в БД (3.2) и механизм (3.5) — отложены.
> _Проверено: 2H₂+O₂→2H₂O, CH₄+2O₂→…, 4Fe+3O₂→2Fe₂O₃, фотосинтез 6/6/1/6, Ca(OH)₂+2HCl, N₂+3H₂→2NH₃._
- [x] 3.1 `BIO.balance` (client-side: матрица элементов → целочисленное решение Гаусс+НОК).
- [ ] 3.2 Миграция: добавить коэффициенты в `bio_reactions` (`reactant_coef`/`product_coef` JSON или `stoich_json`); пересидировать 27 реакций сбалансированными.
- [ ] 3.3 В [biochem-reactions.html](frontend/biochem-reactions.html): показывать сбалансированное уравнение с коэффициентами, проверку сохранения массы/атомов.
- [ ] 3.4 Энергетическая диаграмма реакции (reactants → переходное состояние → products) из `energy_kj` — мини-canvas-график, экзо/эндо подпись ΔH.
@@ -112,18 +126,29 @@
---
## Фаза 6 — Свойства и анализ: вычисления + графики — [ ]
## Фаза 6 — Свойства и анализ: вычисления + графики — [~]
> Сделано (тег `biochem-phase6`): в [biochem-properties.html](frontend/biochem-properties.html) при сравнении 2+
> молекул — столбчатый график молярных масс (canvas) + экспорт таблицы в CSV
> (UTF-8 BOM). Уход от хардкода `PHYS_PROPS` (6.1) и круговая долей (6.3) — отложены.
- [ ] 6.1 Убрать хардкод `PHYS_PROPS` (15 молекул) — тянуть вычисляемые (масса, DBE, диполь, доли элементов) из `/analyze`; справочные (T_пл/T_кип/растворимость) — в новую таблицу `bio_mol_props`.
- [ ] 6.2 В [biochem-properties.html](frontend/biochem-properties.html): графики сравнения (bar/scatter масса·T_кип) на canvas, экспорт таблицы CSV и PNG структур.
- [x] 6.2 График сравнения молярных масс (bar, canvas) + экспорт CSV. _(scatter масса·T_кип и PNG — позже.)_
- [ ] 6.3 Доп. свойства: массовая доля элементов (круговая), кислотность/основность класса, окислитель/восстановитель.
---
## Фаза 7 — Импорт/экспорт и полировка — [ ]
## Фаза 7 — Импорт/экспорт и полировка — [~]
- [ ] 7.1 Парсер SMILES (подмножество: цепи, ветви `()`, кольца-цифры, кратность) → atoms/bonds; поле ввода в редакторе.
- [ ] 7.2 Экспорт молекулы: PNG (2D/3D), JSON, ссылка-share `/biochem?smiles=...`.
> Сделано (теги `biochem-phase7`/`biochem-latest`): `BIO.parseSmiles` (учебное
> подмножество: атомы верх. регистра, связи -=#, ветви, циклы, неявные H,
> 2D-укладка), `BIO.toJSON`/`download`. В редакторе — поле SMILES + Импорт,
> экспорт PNG/JSON. Регресс-тесты `backend/tests/biochem-core.test.js` (8/8 pass:
> формулы, VSEPR, заряды, полярность, баланс, SMILES, analyze).
- [x] 7.1 Парсер SMILES (цепи, ветви `()`, кольца-цифры, кратность `-=#`) → atoms/bonds; поле ввода в редакторе.
- [x] 7.2 Экспорт молекулы: PNG (текущий 2D/3D холст), JSON. _(share-ссылка `?smiles=` — позже.)_
- [x] 7.5 Регресс-тесты ядра (`node --test`, 8 тестов). _(перенесён из плана ниже.)_
- [ ] 7.3 Перф: кэш `biochemGetMolecules` (общий стор), throttle поиска/фильтров, LOD для thumbnail больших молекул (АТФ и т.п.).
- [ ] 7.4 Мобайл/a11y: читаемый sidebar на ≤768px, фокус-навигация, aria для canvas-инструментов.
- [ ] 7.5 Регресс-тесты: `backend/tests/biochem.test.js` — VSEPR, баланс, analyze, фиче-флаг `requireFeature('biochem')`.