Files
Learn_System/frontend/js/biochem-core.js
T
Maxim Dolgolyov b67fac6407 feat(biochem): Фаза 2.1/2.2/2.4 — серверный chem.js + /analyze + подсказки валентности
- biochem-core.js dual-export (browser window.BIO + Node module.exports), без дублей
- BIO.valency: подробные подсказки валентности (2.4), общие для редактора и сервера
- services/chem.js: серверный анализ поверх того же ядра (analyze/validate)
- POST /api/biochem/analyze (2.2); /validate переведён на ядро (+фикс формата связей)
- api.js: LS.biochemAnalyze

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:37:59 +03:00

964 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* biochem-core.js — общее ядро модуля «Биохимия» (window.BIO)
*
* Единый источник правды для всех 5 страниц биохимии:
* - ELEMENTS: реестр элементов (цвет CPK, масса, валентность, электроотрицательность,
* ковалентный и ван-дер-ваальсов радиусы, число валентных электронов)
* - формулы/масса: hillFormula, molarMass, parseFormula
* - нормализация связей: bF/bT/bO (чинит расхождение полей f/from, t/to, o/order)
* - 2D-рендер: render2D (ball-and-stick для превью)
* - 3D-геометрия: vsepr (генератор настоящих 3D-координат по теории ОЭПВО/VSEPR)
* - 3D-рендер: render3D (ball-and-stick с глубиной и затенением)
* - safe: обёртка для API-вызовов с тостом ошибки
* - RING_TEMPLATES: шаблоны колец
*
* Зависимостей нет; LS.toast используется опционально (если доступен).
*/
(function (global) {
'use strict';
/* ── Реестр элементов ─────────────────────────────────────────────────
* color — CPK-цвет заливки
* text — цвет символа поверх заливки
* radius — радиус кружка в 2D-редакторе (усл. ед.)
* mass — атомная масса (г/моль)
* maxV — типичная максимальная валентность (для проверки и оценки геометрии)
* en — электроотрицательность по Полингу (для полярности связей)
* ve — число валентных электронов (для оценки неподелённых пар → VSEPR)
* cov — ковалентный радиус, пм (длины связей в 3D)
* vdw — ван-дер-ваальсов радиус, пм (space-fill режим)
* metal — ионный/металлический центр (нет ковалентной геометрии)
*/
const ELEMENTS = {
H: { name:'Водород', color:'#D4D4D4', text:'#222', radius:18, mass:1.008, maxV:1, en:2.20, ve:1, cov:31, vdw:120 },
C: { name:'Углерод', color:'#555555', text:'#fff', radius:20, mass:12.011, maxV:4, en:2.55, ve:4, cov:76, vdw:170 },
N: { name:'Азот', color:'#4060FF', text:'#fff', radius:20, mass:14.007, maxV:3, en:3.04, ve:5, cov:71, vdw:155 },
O: { name:'Кислород', color:'#EE2020', text:'#fff', radius:20, mass:15.999, maxV:2, en:3.44, ve:6, cov:66, vdw:152 },
P: { name:'Фосфор', color:'#FF8000', text:'#fff', radius:22, mass:30.974, maxV:5, en:2.19, ve:5, cov:107, vdw:180 },
S: { name:'Сера', color:'#C8B400', text:'#000', radius:22, mass:32.06, maxV:6, en:2.58, ve:6, cov:105, vdw:180 },
F: { name:'Фтор', color:'#33CC33', text:'#fff', radius:18, mass:18.998, maxV:1, en:3.98, ve:7, cov:57, vdw:147 },
Cl: { name:'Хлор', color:'#00A860', text:'#fff', radius:22, mass:35.45, maxV:1, en:3.16, ve:7, cov:102, vdw:175 },
Br: { name:'Бром', color:'#A52A2A', text:'#fff', radius:24, mass:79.904, maxV:1, en:2.96, ve:7, cov:120, vdw:185 },
I: { name:'Иод', color:'#940094', text:'#fff', radius:26, mass:126.90, maxV:1, en:2.66, ve:7, cov:139, vdw:198 },
Na: { name:'Натрий', color:'#8040C0', text:'#fff', radius:22, mass:22.990, maxV:1, en:0.93, ve:1, cov:166, vdw:227, metal:true },
K: { name:'Калий', color:'#8F40D4', text:'#fff', radius:24, mass:39.098, maxV:1, en:0.82, ve:1, cov:203, vdw:275, metal:true },
Ca: { name:'Кальций', color:'#707070', text:'#fff', radius:22, mass:40.078, maxV:2, en:1.00, ve:2, cov:176, vdw:231, metal:true },
Mg: { name:'Магний', color:'#1E8A1E', text:'#fff', radius:22, mass:24.305, maxV:2, en:1.31, ve:2, cov:141, vdw:173, metal:true },
Fe: { name:'Железо', color:'#B03010', text:'#fff', radius:22, mass:55.845, maxV:3, en:1.83, ve:8, cov:132, vdw:194, metal:true },
};
function el(sym) { return ELEMENTS[sym] || { name:sym, color:'#888', text:'#fff', radius:20, mass:0, maxV:4, en:2.5, ve:4, cov:75, vdw:170 }; }
/* ── Нормализация связей ──────────────────────────────────────────────
* В БД связи хранятся как {f,t,o}; в редакторе — как {from,to,order}.
* Эти хелперы устраняют расхождение (бывший баг `b.o || b.order`).
*/
function bF(b) { return b.from != null ? b.from : b.f; }
function bT(b) { return b.to != null ? b.to : b.t; }
function bO(b) { return (b.order != null ? b.order : b.o) || 1; }
/* ── Формулы и масса ──────────────────────────────────────────────────── */
function counts(atoms) {
const c = {};
for (const a of atoms) c[a.s] = (c[a.s] || 0) + 1;
return c;
}
function hillFormula(atoms) {
if (!atoms || !atoms.length) return '';
const cnt = counts(atoms);
const parts = [];
if (cnt.C) { parts.push('C' + (cnt.C > 1 ? cnt.C : '')); delete cnt.C; }
if (cnt.H) { parts.push('H' + (cnt.H > 1 ? cnt.H : '')); delete cnt.H; }
for (const e of Object.keys(cnt).sort()) parts.push(e + (cnt[e] > 1 ? cnt[e] : ''));
return parts.join('');
}
function molarMass(atoms) {
let m = 0;
for (const a of atoms) m += el(a.s).mass;
return m;
}
// Разбор строковой формулы (с поддержкой скобок и цифр): "Ca(OH)2" → {Ca:1,O:2,H:2}
function parseFormula(str) {
const out = {};
const re = /([A-Z][a-z]?)(\d*)|(\()|(\))(\d*)/g;
const stack = [out];
let m;
while ((m = re.exec(str)) !== null) {
if (m[1]) {
const n = m[2] ? parseInt(m[2], 10) : 1;
const top = stack[stack.length - 1];
top[m[1]] = (top[m[1]] || 0) + n;
} else if (m[3]) {
stack.push({});
} else if (m[4] !== undefined) {
const grp = stack.pop();
const mult = m[5] ? parseInt(m[5], 10) : 1;
const top = stack[stack.length - 1];
for (const k in grp) top[k] = (top[k] || 0) + grp[k] * mult;
}
}
return out;
}
/* ── Степень ненасыщенности (DBE) ─────────────────────────────────────── */
function dbe(atoms) {
const c = counts(atoms);
const C = c.C || 0, H = c.H || 0, N = c.N || 0, P = c.P || 0;
const X = (c.Cl || 0) + (c.F || 0) + (c.Br || 0) + (c.I || 0);
if (!C && !H) return null;
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 }
* Если fit=true — масштабирует молекулу под размер canvas (для thumbnail).
*/
function render2D(ctx, atoms, bonds, opts) {
opts = opts || {};
const W = ctx.canvas.width, H = ctx.canvas.height;
if (opts.bg) { ctx.fillStyle = opts.bg; ctx.fillRect(0, 0, W, H); }
else ctx.clearRect(0, 0, W, H);
if (!atoms || !atoms.length) return;
let sc = opts.scale || 1, ox = W / 2, oy = H / 2;
let cx = 0, cy = 0;
for (const a of atoms) { cx += a.x; cy += a.y; }
cx /= atoms.length; cy /= atoms.length;
if (opts.fit !== false) {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const a of atoms) {
const r = el(a.s).radius;
minX = Math.min(minX, a.x - r); maxX = Math.max(maxX, a.x + r);
minY = Math.min(minY, a.y - r); maxY = Math.max(maxY, a.y + r);
}
const pad = opts.padding != null ? opts.padding : 14;
const bw = maxX - minX || 1, bh = maxY - minY || 1;
sc = Math.min((W - pad * 2) / bw, (H - pad * 2) / bh);
if (opts.maxScale) sc = Math.min(sc, opts.maxScale);
}
const P = (a) => ({ x: (a.x - cx) * sc + ox, y: (a.y - cy) * sc + oy });
const byId = {}; for (const a of atoms) byId[a.id] = a;
// Bonds
ctx.lineCap = 'round';
for (const b of bonds || []) {
const a1 = byId[bF(b)], a2 = byId[bT(b)];
if (!a1 || !a2) continue;
const p1 = P(a1), p2 = P(a2);
const dx = p2.x - p1.x, dy = p2.y - p1.y, len = Math.hypot(dx, dy) || 1;
const px = -dy / len, py = dx / len;
const o = bO(b);
ctx.strokeStyle = opts.lineColor || '#7a8290';
ctx.lineWidth = Math.max(1, 2.2 * sc);
const off = 3 * sc;
const seg = (k) => { ctx.beginPath(); ctx.moveTo(p1.x + px * k, p1.y + py * k); ctx.lineTo(p2.x + px * k, p2.y + py * k); ctx.stroke(); };
if (o === 1) seg(0);
else if (o === 2) { seg(-off); seg(off); }
else { seg(0); seg(-off * 1.4); seg(off * 1.4); }
}
// Atoms
const showSym = opts.showSymbols !== false;
for (const a of atoms) {
const e = el(a.s);
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(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)) {
ctx.fillStyle = e.text || '#fff';
ctx.font = `bold ${Math.max(7, Math.round(r * 0.8))}px Manrope, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(a.s, p.x, p.y);
}
}
}
/* ── VSEPR: генерация настоящей 3D-геометрии ──────────────────────────────
* Вход: atoms [{id,s,x,y}], bonds [{f,t,o}|{from,to,order}]
* Выход: {
* atoms3d: [{id,s,x,y,z}] — 3D-координаты (усл. ед., ~как 2D-масштаб),
* perAtom: { id: {domains, shape, hybridization, lonePairs} },
* shape: строка-описание формы для малых молекул (напр. «угловая»),
* angle: характерный валентный угол центральной молекулы (°) | null
* }
*
* Алгоритм: для каждого атома считаем число электронных доменов
* (соседи + неподелённые пары) → идеальная геометрия → BFS-укладка в 3D,
* ориентируя набор идеальных направлений так, чтобы связь к «родителю»
* совпала с одним из направлений.
*/
const TET = (function () {
const k = 1 / Math.sqrt(3);
return [[k, k, k], [k, -k, -k], [-k, k, -k], [-k, -k, k]];
})();
const TRIG = [[1, 0, 0], [-0.5, 0.8660254, 0], [-0.5, -0.8660254, 0]];
const LIN = [[1, 0, 0], [-1, 0, 0]];
const OCT = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];
const TRIGBIPY = [[0, 0, 1], [0, 0, -1], [1, 0, 0], [-0.5, 0.8660254, 0], [-0.5, -0.8660254, 0]];
function _idealDirs(domains) {
if (domains <= 1) return [[1, 0, 0]];
if (domains === 2) return LIN;
if (domains === 3) return TRIG;
if (domains === 4) return TET;
if (domains === 5) return TRIGBIPY;
return OCT;
}
function _shapeName(centerSym, neighbors, lonePairs) {
const d = neighbors + lonePairs;
if (neighbors === 1) return 'двухатомная';
if (d === 2) return 'линейная';
if (d === 3) return lonePairs === 0 ? 'тригональная' : 'угловая';
if (d === 4) {
if (lonePairs === 0) return 'тетраэдрическая';
if (lonePairs === 1) return 'пирамидальная';
return 'угловая';
}
if (d === 5) return 'тригонально-бипирамидальная';
return 'октаэдрическая';
}
function _hyb(domains) {
return domains <= 2 ? 'sp' : domains === 3 ? 'sp²' : domains === 4 ? 'sp³' : domains === 5 ? 'sp³d' : 'sp³d²';
}
function _idealAngle(domains, lonePairs) {
if (domains === 2) return 180;
if (domains === 3) return lonePairs ? 117 : 120;
if (domains === 4) return lonePairs === 0 ? 109.5 : lonePairs === 1 ? 107 : 104.5;
if (domains === 5) return 90;
return 90;
}
// Вращение, переводящее единичный вектор a в единичный вектор b (Родригес)
function _rotBetween(a, b) {
const v = _cross(a, b);
const c = _dot(a, b);
const s = _len(v);
if (s < 1e-9) {
if (c > 0) return [[1,0,0],[0,1,0],[0,0,1]]; // совпадают
// противоположны — поворот на 180° вокруг любой перпендикулярной оси
const ax = Math.abs(a[0]) < 0.9 ? [1,0,0] : [0,1,0];
const k = _norm(_cross(a, ax));
return _rotAxis(k, Math.PI);
}
const k = [v[0]/s, v[1]/s, v[2]/s];
return _rotAxis(k, Math.atan2(s, c));
}
function _rotAxis(k, ang) {
const c = Math.cos(ang), s = Math.sin(ang), t = 1 - c;
const [x, y, z] = k;
return [
[t*x*x + c, t*x*y - s*z, t*x*z + s*y],
[t*x*y + s*z, t*y*y + c, t*y*z - s*x],
[t*x*z - s*y, t*y*z + s*x, t*z*z + c],
];
}
function _apply(M, v) {
return [
M[0][0]*v[0] + M[0][1]*v[1] + M[0][2]*v[2],
M[1][0]*v[0] + M[1][1]*v[1] + M[1][2]*v[2],
M[2][0]*v[0] + M[2][1]*v[1] + M[2][2]*v[2],
];
}
function _cross(a, b) { return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]; }
function _dot(a, b) { return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]; }
function _len(v) { return Math.hypot(v[0], v[1], v[2]); }
function _norm(v) { const l = _len(v) || 1; return [v[0]/l, v[1]/l, v[2]/l]; }
function vsepr(atoms, bonds) {
const idList = atoms.map(a => a.id);
const byId = {}; atoms.forEach(a => byId[a.id] = a);
// adjacency: id -> [{id, order}]
const adj = {}; idList.forEach(i => adj[i] = []);
let bondSum = {}; idList.forEach(i => bondSum[i] = 0);
for (const b of bonds || []) {
const f = bF(b), t = bT(b), o = bO(b);
if (adj[f] && adj[t]) {
adj[f].push({ id: t, order: o });
adj[t].push({ id: f, order: o });
bondSum[f] += o; bondSum[t] += o;
}
}
// per-atom geometry descriptor
const perAtom = {};
for (const a of atoms) {
const e = el(a.s);
const deg = adj[a.id].length;
let lp = 0;
if (!e.metal && a.s !== 'H') {
lp = Math.max(0, Math.round((e.ve - bondSum[a.id]) / 2));
// у H, галогенов-терминалов и т.п. домены = соседи (терминальные)
}
const domains = Math.max(deg, deg + lp, 1);
perAtom[a.id] = {
domains,
lonePairs: lp,
neighbors: deg,
shape: _shapeName(a.s, deg, lp),
hybridization: _hyb(domains),
angle: _idealAngle(domains, lp),
};
}
// bond length in canvas units between two atoms
const blen = (s1, s2) => (el(s1).cov + el(s2).cov) * 0.62; // C-C ≈ 94
// BFS embedding
const pos = {}; // id -> [x,y,z]
const placedDirs = {}; // id -> array of used unit directions (from this atom)
const visited = new Set();
// root = atom with max degree (центральный)
let root = idList[0];
for (const i of idList) if (adj[i].length > adj[root].length) root = i;
const queue = [];
pos[root] = [0, 0, 0];
placedDirs[root] = [];
visited.add(root);
queue.push(root);
while (queue.length) {
const cur = queue.shift();
const e = byId[cur].s;
const info = perAtom[cur];
const ideal = _idealDirs(info.domains).map(_norm);
// Сколько направлений уже занято связью к родителю
const usedDirs = placedDirs[cur].slice();
// Ориентируем идеальный набор так, чтобы ideal[0] совпал с первым занятым
let dirs = ideal;
if (usedDirs.length) {
const R = _rotBetween(ideal[0], usedDirs[0]);
dirs = ideal.map(d => _norm(_apply(R, d)));
}
// помечаем направления, ближайшие к уже занятым, как использованные
const taken = new Array(dirs.length).fill(false);
for (const u of usedDirs) {
let best = -1, bestDot = -2;
for (let i = 0; i < dirs.length; i++) {
if (taken[i]) continue;
const dd = _dot(dirs[i], u);
if (dd > bestDot) { bestDot = dd; best = i; }
}
if (best >= 0) taken[best] = true;
}
// распределяем оставшиеся направления по непосещённым соседям
const freeIdx = [];
for (let i = 0; i < dirs.length; i++) if (!taken[i]) freeIdx.push(i);
let fi = 0;
for (const nb of adj[cur]) {
if (visited.has(nb.id)) continue;
const dir = dirs[freeIdx[fi++]] || _norm([Math.random()-0.5, Math.random()-0.5, Math.random()-0.5]);
const L = blen(e, byId[nb.id].s);
pos[nb.id] = [pos[cur][0] + dir[0]*L, pos[cur][1] + dir[1]*L, pos[cur][2] + dir[2]*L];
placedDirs[nb.id] = [[-dir[0], -dir[1], -dir[2]]]; // обратное направление к родителю
(placedDirs[cur] = placedDirs[cur] || []).push(dir);
visited.add(nb.id);
queue.push(nb.id);
}
}
// атомы вне основного компонента (несвязанные) — раскидываем по сетке
let stray = 0;
for (const a of atoms) {
if (!pos[a.id]) { pos[a.id] = [(stray % 4) * 90 - 135, Math.floor(stray / 4) * 90, 0]; stray++; }
}
// центрируем
let cx = 0, cy = 0, cz = 0, n = atoms.length || 1;
for (const a of atoms) { cx += pos[a.id][0]; cy += pos[a.id][1]; cz += pos[a.id][2]; }
cx /= n; cy /= n; cz /= n;
const atoms3d = atoms.map(a => ({
id: a.id, s: a.s,
x: pos[a.id][0] - cx, y: pos[a.id][1] - cy, z: pos[a.id][2] - cz,
}));
// характеристика молекулы в целом (по центральному тяжёлому атому)
let centerId = root;
for (const a of atoms) if (a.s !== 'H' && perAtom[a.id].neighbors > perAtom[centerId].neighbors) centerId = a.id;
const ci = perAtom[centerId];
return {
atoms3d, perAtom,
shape: ci ? ci.shape : null,
angle: ci && ci.neighbors >= 2 ? ci.angle : null,
hybridization: ci ? ci.hybridization : null,
centerSym: byId[centerId] ? byId[centerId].s : null,
};
}
/* ── 3D-рендер (ball-and-stick с глубиной) ────────────────────────────────
* atoms3d: [{id,s,x,y,z}] bonds: нормализуются
* 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 = 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;
// проекция: 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);
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) {
for (const b of bonds || []) {
const p1 = pm[bF(b)], p2 = pm[bT(b)];
if (!p1 || !p2) continue;
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; // перпендикуляр для кратных связей
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 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) * 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`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
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();
}
}
}
/* ── Цветовые утилиты ─────────────────────────────────────────────────── */
function _hexRgb(hex) {
hex = String(hex).replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
const n = parseInt(hex, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function _lighten(hex, amt) {
const [r, g, b] = _hexRgb(hex);
return `rgb(${Math.min(255, r + amt)},${Math.min(255, g + amt)},${Math.min(255, b + amt)})`;
}
function _darken(hex, f) {
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(), 'Не удалось загрузить молекулы')
*/
async function safe(promiseOrFn, errMsg) {
try {
return await (typeof promiseOrFn === 'function' ? promiseOrFn() : promiseOrFn);
} catch (e) {
const msg = errMsg || ('Ошибка: ' + (e && e.message ? e.message : e));
if (global.LS && typeof global.LS.toast === 'function') global.LS.toast(msg, 'error');
else console.error(msg, e);
return null;
}
}
/* ── Шаблоны колец (2D-координаты) ───────────────────────────────────── */
const RING_TEMPLATES = {
benzene: {
atoms: [{s:'C',x:0,y:-55},{s:'C',x:47.6,y:-27.5},{s:'C',x:47.6,y:27.5},
{s:'C',x:0,y:55},{s:'C',x:-47.6,y:27.5},{s:'C',x:-47.6,y:-27.5}],
bonds: [[0,1,2],[1,2,1],[2,3,2],[3,4,1],[4,5,2],[5,0,1]],
},
cyclohexane: {
atoms: [{s:'C',x:0,y:-55},{s:'C',x:47.6,y:-27.5},{s:'C',x:47.6,y:27.5},
{s:'C',x:0,y:55},{s:'C',x:-47.6,y:27.5},{s:'C',x:-47.6,y:-27.5}],
bonds: [[0,1,1],[1,2,1],[2,3,1],[3,4,1],[4,5,1],[5,0,1]],
},
cyclopentane: {
atoms: [{s:'C',x:0,y:-50},{s:'C',x:47.6,y:-15.5},{s:'C',x:29.4,y:40.5},
{s:'C',x:-29.4,y:40.5},{s:'C',x:-47.6,y:-15.5}],
bonds: [[0,1,1],[1,2,1],[2,3,1],[3,4,1],[4,0,1]],
},
naphthalene: {
atoms: [
{s:'C',x:0,y:27.5},{s:'C',x:0,y:-27.5},
{s:'C',x:-47.6,y:-55},{s:'C',x:-95.2,y:-27.5},{s:'C',x:-95.2,y:27.5},{s:'C',x:-47.6,y:55},
{s:'C',x:47.6,y:-55},{s:'C',x:95.2,y:-27.5},{s:'C',x:95.2,y:27.5},{s:'C',x:47.6,y:55},
],
bonds: [
[0,1,1],[1,2,2],[2,3,1],[3,4,2],[4,5,1],[5,0,2],
[1,6,2],[6,7,1],[7,8,2],[8,9,1],[9,0,2],
],
},
};
/* ── Валентность: подробная проверка с подсказками (Фаза 2.4) ──────────
* Возвращает массив проблем-«перевалентностей» с готовым человекочитаемым
* текстом: [{ id, symbol, name, used, max, over, kind:'error', msg }].
* Работает с обоими форматами связей (from/to/order и f/t/o) через bF/bT/bO.
*/
function _bondWord(n) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return 'связь';
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'связи';
return 'связей';
}
function valency(atoms, bonds) {
if (!atoms || !atoms.length) return [];
const sum = {};
for (const b of (bonds || [])) {
const f = bF(b), t = bT(b), o = bO(b);
sum[f] = (sum[f] || 0) + o;
sum[t] = (sum[t] || 0) + o;
}
const out = [];
for (const a of atoms) {
const e = el(a.s);
const used = sum[a.id] || 0;
const max = e.maxV != null ? e.maxV : 4;
if (used > max) {
const over = used - max;
out.push({
id: a.id, symbol: a.s, name: e.name, used, max, over, kind: 'error',
msg: e.name + ' (' + a.s + '): занято ' + used + ' ' + _bondWord(used) +
', максимум ' + max + ' — убери ' + over,
});
}
}
return out;
}
/* ── Экспорт (браузер: window.BIO; Node: module.exports) ──────────────── */
var _api = {
ELEMENTS, el,
bF, bT, bO,
counts, hillFormula, molarMass, parseFormula, dbe,
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze, valency,
balance, parseSmiles, toJSON, download,
render2D, vsepr, render3D, chargeColor,
safe, RING_TEMPLATES,
_hexRgb, _lighten, _darken,
};
global.BIO = _api;
if (typeof module !== 'undefined' && module.exports) module.exports = _api;
})(typeof window !== 'undefined' ? window : globalThis);