feat(biochem): Фаза 3 — авто-балансировщик + энергодиаграммы реакций
BIO.balance(reactants, products) — балансировка уравнений через матрицу «элемент×вещество» и дробный метод Гаусса (RREF) + НОК/НОД, целочисленные коэффициенты. Проверено: 2H2+O2→2H2O, CH4+2O2→CO2+2H2O, 4Fe+3O2→2Fe2O3, фотосинтез 6/6/1/6, Ca(OH)2+2HCl→CaCl2+2H2O (скобки), N2+3H2→2NH3. biochem-reactions.html: в развёрнутой карточке — - энергетический профиль (реагенты → переходное состояние → продукты) на canvas из energy_kj, экзо вниз/эндо вверх, стрелка ΔH, подпись типа; - бейдж проверки баланса (BIO.balance по формулам молекул реакции). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -234,6 +234,76 @@
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Балансировка уравнений реакций ───────────────────────────────────────
|
||||
* Вход: 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) };
|
||||
}
|
||||
|
||||
/* ── 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 }
|
||||
@@ -732,6 +802,7 @@
|
||||
bF, bT, bO,
|
||||
counts, hillFormula, molarMass, parseFormula, dbe,
|
||||
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze,
|
||||
balance,
|
||||
render2D, vsepr, render3D, chargeColor,
|
||||
safe, RING_TEMPLATES,
|
||||
_hexRgb, _lighten, _darken,
|
||||
|
||||
Reference in New Issue
Block a user