diff --git a/backend/tests/biochem-core.test.js b/backend/tests/biochem-core.test.js new file mode 100644 index 0000000..1cb8e66 --- /dev/null +++ b/backend/tests/biochem-core.test.js @@ -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); +}); diff --git a/frontend/biochem-properties.html b/frontend/biochem-properties.html index 93516c1..f5e6bdf 100644 --- a/frontend/biochem-properties.html +++ b/frontend/biochem-properties.html @@ -480,15 +480,79 @@ function renderCompare() { Описание${_compare.map(m=>`${m.description||'—'}`).join('')} `; + // график молярных масс + экспорт + html += `
+
+
Молярная масса, г/моль
+ +
+ +
`; } 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; } } 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/biochem.html b/frontend/biochem.html index 4324f5e..a6dec9b 100644 --- a/frontend/biochem.html +++ b/frontend/biochem.html @@ -368,6 +368,7 @@ 3D +
+ +
+ + +
+ +
+ + +
`; } -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 ── diff --git a/frontend/js/biochem-core.js b/frontend/js/biochem-core.js index e450918..a622e0b 100644 --- a/frontend/js/biochem-core.js +++ b/frontend/js/biochem-core.js @@ -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, }; diff --git a/plans/BIOCHEM_UPGRADE.md b/plans/BIOCHEM_UPGRADE.md index 17f47a7..3a05601 100644 --- a/plans/BIOCHEM_UPGRADE.md +++ b/plans/BIOCHEM_UPGRADE.md @@ -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')`.