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:
+422
-30
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user