Files
Learn_System/frontend/js/biochem-core.js
T
Maxim Dolgolyov 410eb8a862 fix(biochem 3D): корректная глубина + объёмные связи-цилиндры
Два дефекта, из-за которых 3D читался как плоская диаграмма:
- painter-сортировка была по возрастанию z (ближние первыми) — дальние
  атомы рисовались поверх ближних. Теперь единый список примитивов
  (атомы + половинки связей) сортируется по убыванию z (дальние первыми).
- связи были тонкими плоскими линиями. Теперь — затенённые «цилиндры»:
  толстый штрих с поперечным градиентом (центр светлее, края темнее),
  двухцветные (каждая половина под цвет своего атома) — фирменный вид
  ball-and-stick. Ширина зависит от перспективы (ближе — толще).
- усилена перспектива (fov 900→700), добавлен тёмный ободок сфер для объёма.

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

578 lines
28 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;
}
/* ── 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 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));
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 [r0, g0, b0] = _hexRgb(e.color);
// глянцевый блик смещён к свету (верх-лево)
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, e.color);
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);
}
}
}
/* ── Цветовые утилиты ─────────────────────────────────────────────────── */
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)})`;
}
/* ── 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],
],
},
};
/* ── Экспорт ──────────────────────────────────────────────────────────── */
global.BIO = {
ELEMENTS, el,
bF, bT, bO,
counts, hillFormula, molarMass, parseFormula, dbe,
render2D, vsepr, render3D,
safe, RING_TEMPLATES,
_hexRgb, _lighten, _darken,
};
})(window);