diff --git a/frontend/biochem-reactions.html b/frontend/biochem-reactions.html index b456456..3bd6de8 100644 --- a/frontend/biochem-reactions.html +++ b/frontend/biochem-reactions.html @@ -560,6 +560,11 @@ function renderList(rxns) {
${r.conditions ? `
Условия
${r.conditions}
` : ''} ${r.energy_kj != null ? `
ΔH (энергия)
${r.energy_kj} кДж/моль
` : ''} + +
` : ''} + ${r.energy_kj != null ? `
+
Энергетический профиль
+
` : ''}
Молекулы реакции
@@ -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') ? '' : ''; - 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 = `✓ сбалансировано ${lhs} → ${rhs}`; + } else { + val.innerHTML = `требует коэффициентов`; + } } async function loadMolThumbs(r) { diff --git a/frontend/js/biochem-core.js b/frontend/js/biochem-core.js index 515144f..6a7e5c8 100644 --- a/frontend/js/biochem-core.js +++ b/frontend/js/biochem-core.js @@ -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,