feat(biochem): Фаза 2 — химический движок (заряды, диполь, полярность)
В biochem-core.js добавлен расчёт химии из структуры (client-side, для всех страниц): partialCharges (по разнице электроотрицательностей на связях), dipole (векторная сумма q·r по 3D-координатам VSEPR), polarity (классификация по дипольному моменту), massFractions, functionalGroups, analyze (единая точка). chargeColor + поддержка opts.charges в render2D/render3D + стрелка диполя. biochem.html: крудные эвристики _detectFG/_polarity/ATOMIC_MASS заменены на BIO.analyze (−95 строк дублей); в панель свойств добавлен дипольный момент; тумблер δ± — тепловая карта частичных зарядов (синий δ+/красный δ−) в 2D и 3D плюс стрелка диполя. Проверено: H2O O=−0.52/H=+0.26; CO2/CH4/CCl4 диполь 0 (неполярны); H2O/CHCl3 полярны — симметрия гасит вектора за счёт настоящей 3D-геометрии. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+31
-107
@@ -368,6 +368,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>3D
|
||||
</button>
|
||||
<button class="tool-btn" id="btn-vdw" onclick="toggleVDW()" title="Space-fill (VDW радиусы)" style="display:none"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg> VDW</button>
|
||||
<button class="tool-btn" id="btn-charge" onclick="toggleCharges()" title="Частичные заряды δ+/δ− и диполь"><svg class="ic" viewBox="0 0 24 24"><path d="M12 2v20M2 12h20"/></svg> δ±</button>
|
||||
<div class="tool-sep"></div>
|
||||
<button class="tool-btn icon-only" id="btn-undo" onclick="undo()" disabled title="Отменить (Ctrl+Z)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="14" height="14"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"/></svg>
|
||||
@@ -680,11 +681,6 @@ function getIssues() {
|
||||
}
|
||||
|
||||
// ── Live molecular stats ──
|
||||
const ATOMIC_MASS = {
|
||||
H:1.008, C:12.011, N:14.007, O:15.999, P:30.974, S:32.06,
|
||||
Cl:35.45, Na:22.990, Ca:40.078, Mg:24.305, Fe:55.845,
|
||||
};
|
||||
|
||||
function calcMolStats() {
|
||||
const wrap = document.getElementById('bp-mol-stats');
|
||||
if (!atoms.length) { wrap.style.display = 'none'; return; }
|
||||
@@ -693,33 +689,30 @@ function calcMolStats() {
|
||||
const cnt = {};
|
||||
for (const a of atoms) cnt[a.s] = (cnt[a.s]||0) + 1;
|
||||
|
||||
// Molar weight
|
||||
let mw = 0;
|
||||
for (const [el, n] of Object.entries(cnt)) mw += (ATOMIC_MASS[el]||0) * n;
|
||||
|
||||
// DBE: (2C + 2 + N + P − H − Cl) / 2
|
||||
const C = cnt.C||0, H = cnt.H||0, Nv = cnt.N||0, Pv = cnt.P||0, Clv = cnt.Cl||0;
|
||||
const dbe = (C || H) ? (2*C + 2 + Nv + Pv - H - Clv) / 2 : null;
|
||||
|
||||
const fg = _detectFG();
|
||||
// Полный химический анализ из общего ядра (масса, DBE, геометрия, диполь, группы)
|
||||
const an = BIO.analyze(atoms, bonds);
|
||||
_chargeMap = an.charges || null;
|
||||
_dipoleVec = (an.polarity && an.polarity.vector) || null;
|
||||
const dbe = an.dbe;
|
||||
const fg = an.groups;
|
||||
const molClass = _molClass(cnt, dbe, fg);
|
||||
const polarity = _polarity(cnt, fg);
|
||||
|
||||
const chips = [];
|
||||
chips.push(_chip('М.М. г/моль', mw.toFixed(2), 'cyan'));
|
||||
chips.push(_chip('М.М. г/моль', an.mass.toFixed(2), 'cyan'));
|
||||
if (dbe !== null && Number.isFinite(dbe)) {
|
||||
const cls = dbe < 0 ? 'bad' : dbe === 0 ? 'good' : dbe >= 4 ? 'violet' : 'warn';
|
||||
chips.push(_chip('DBE', Number.isInteger(dbe*2) && dbe === Math.round(dbe) ? dbe : dbe.toFixed(1), cls));
|
||||
}
|
||||
chips.push(_chip('Полярность', polarity.label, polarity.cls));
|
||||
chips.push(_chip('Полярность', an.polarity.label, an.polarity.cls));
|
||||
if (an.dipole != null) chips.push(_chip('Дипольный момент', an.dipole.toFixed(2) + ' D', an.dipole < 0.18 ? 'good' : an.dipole >= 1.5 ? 'bad' : 'warn'));
|
||||
chips.push(_chip('Атомов', atoms.length, ''));
|
||||
|
||||
// Geometry (VSEPR) — for the central atom
|
||||
if (window.BIO && bonds.length) {
|
||||
const geom = BIO.vsepr(atoms, bonds);
|
||||
if (geom.shape) chips.push(_chip('Геометрия', geom.shape, 'violet'));
|
||||
if (geom.hybridization) chips.push(_chip('Гибридизация', geom.hybridization, 'cyan'));
|
||||
if (geom.angle != null) chips.push(_chip('Угол связи', geom.angle + '°', ''));
|
||||
if (bonds.length) {
|
||||
const g = an.geometry;
|
||||
if (g.shape) chips.push(_chip('Геометрия', g.shape, 'violet'));
|
||||
if (g.hybridization) chips.push(_chip('Гибридизация', g.hybridization, 'cyan'));
|
||||
if (g.angle != null) chips.push(_chip('Угол связи', g.angle + '°', ''));
|
||||
}
|
||||
|
||||
if (molClass) chips.push(
|
||||
@@ -744,80 +737,6 @@ function _chip(label, val, cls) {
|
||||
`<span class="bp-stat-val ${cls}">${val}</span></div>`;
|
||||
}
|
||||
|
||||
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 +755,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 +846,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;
|
||||
@@ -1716,6 +1626,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 +1668,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 ──
|
||||
|
||||
+168
-6
@@ -109,6 +109,131 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 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 +290,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)) {
|
||||
@@ -486,11 +612,12 @@
|
||||
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 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, e.color);
|
||||
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);
|
||||
@@ -503,6 +630,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Цветовые утилиты ─────────────────────────────────────────────────── */
|
||||
@@ -520,6 +672,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(), 'Не удалось загрузить молекулы')
|
||||
@@ -570,7 +731,8 @@
|
||||
ELEMENTS, el,
|
||||
bF, bT, bO,
|
||||
counts, hillFormula, molarMass, parseFormula, dbe,
|
||||
render2D, vsepr, render3D,
|
||||
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze,
|
||||
render2D, vsepr, render3D, chargeColor,
|
||||
safe, RING_TEMPLATES,
|
||||
_hexRgb, _lighten, _darken,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user