feat(biochem): единый рендер BIO.render2D + 3D-превью молекул в библиотеке и свойствах

Фаза 0.2 (DRY) + Фаза 1.5 (3D-превью) плана BIOCHEM_UPGRADE:
- library/properties/reactions подключают biochem-core.js; локальные
  дубль-рендереры молекул заменены вызовами BIO.render2D; удалены
  дублирующиеся таблицы ELEM_COLORS/CPK и hexToRgb/cpkColor (~250 строк).
- Библиотека: в детальной панели тумблер 2D/3D — вращающаяся VSEPR-модель
  с подписью формы/гибридизации/угла.
- Свойства: на каждой карточке сравнения тумблер 2D/3D с вращением и
  геометрией; thumbnail-и тоже через общий рендер.
- Fallback-и сохранены (колба в библиотеке, «?» в реакциях, «Нет
  структуры» в свойствах).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 12:48:39 +03:00
parent 76df3b4594
commit 3b6481b1df
3 changed files with 135 additions and 267 deletions
+70 -102
View File
@@ -324,8 +324,13 @@
<div class="detail-name" id="det-name"></div>
<div class="detail-lat" id="det-lat"></div>
</div>
<div class="detail-canvas-wrap">
<div class="detail-canvas-wrap" style="position:relative">
<canvas id="det-canvas"></canvas>
<button id="det-3d-toggle" onclick="toggleDet3D()" title="2D / 3D"
style="position:absolute;top:8px;right:8px;z-index:2;height:26px;padding:0 10px;border-radius:7px;
border:1.5px solid rgba(255,255,255,.15);background:rgba(15,15,30,.85);color:#aaa;
font:700 .72rem Manrope,sans-serif;cursor:pointer">3D</button>
<div id="det-geom" style="position:absolute;bottom:6px;left:8px;right:8px;font:600 .66rem Manrope,sans-serif;color:#8aa;display:none;text-align:center"></div>
</div>
<div class="detail-section">
<div class="detail-label">Категория</div>
@@ -352,6 +357,7 @@
</div>
<script src="/js/api.js"></script>
<script src="/js/biochem-core.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
@@ -367,111 +373,30 @@ if (isAdmin) document.getElementById('btn-admin').style.display = '';
if (isTeacher) document.getElementById('btn-classes').style.display = '';
LS.showBoardIfAllowed();
// ── CPK element colours ──
const ELEM_COLORS = {
H: { color:'#D4D4D4', text:'#222', r:9 },
C: { color:'#555555', text:'#fff', r:10 },
N: { color:'#4060FF', text:'#fff', r:10 },
O: { color:'#EE2020', text:'#fff', r:10 },
P: { color:'#FF8000', text:'#fff', r:11 },
S: { color:'#C8B400', text:'#000', r:11 },
Cl: { color:'#00A860', text:'#fff', r:11 },
Na: { color:'#8040C0', text:'#fff', r:11 },
Ca: { color:'#707070', text:'#fff', r:11 },
Mg: { color:'#1E8A1E', text:'#fff', r:11 },
Fe: { color:'#B03010', text:'#fff', r:11 },
Br: { color:'#8B4513', text:'#fff', r:11 },
I: { color:'#580DAB', text:'#fff', r:11 },
F: { color:'#B0FFB0', text:'#222', r:9 },
};
// ── Molecule thumbnail renderer ──
// ── Molecule thumbnail renderer (delegates to shared BIO.render2D) ──
function renderMolThumb(canvas, atoms, bonds) {
const W = canvas.width, H = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
if (!atoms || !atoms.length) {
// flask outline fallback
const fw = Math.min(W, H) * 0.32, fx = W/2, fy = H/2 + fw*0.05;
ctx.strokeStyle = '#555';
ctx.lineWidth = Math.max(1.5, fw * 0.07);
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(fx - fw*0.22, fy - fw*0.52);
ctx.lineTo(fx - fw*0.22, fy - fw*0.08);
ctx.lineTo(fx - fw*0.55, fy + fw*0.42);
ctx.quadraticCurveTo(fx, fy + fw*0.62, fx + fw*0.55, fy + fw*0.42);
ctx.lineTo(fx + fw*0.22, fy - fw*0.08);
ctx.lineTo(fx + fw*0.22, fy - fw*0.52);
ctx.moveTo(fx - fw*0.32, fy - fw*0.52);
ctx.lineTo(fx + fw*0.32, fy - fw*0.52);
ctx.stroke();
if (atoms && atoms.length) {
BIO.render2D(ctx, atoms, bonds || [], { fit: true, padding: Math.min(W, H) * 0.16 });
return;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const a of atoms) {
const x = a.x ?? 0, y = a.y ?? 0;
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
}
const pad = Math.min(W, H) * 0.16;
const rangeX = maxX - minX || 1, rangeY = maxY - minY || 1;
const scale = Math.min((W - pad*2) / rangeX, (H - pad*2) / rangeY);
const offX = (W - rangeX * scale) / 2 - minX * scale;
const offY = (H - rangeY * scale) / 2 - minY * scale;
const sx = ax => ax * scale + offX;
const sy = ay => ay * scale + offY;
// bonds
for (const b of bonds) {
const f = b.f ?? b.from, t = b.t ?? b.to, order = b.o ?? b.order ?? 1;
const af = atoms.find(a => a.id === f), at_ = atoms.find(a => a.id === t);
if (!af || !at_) continue;
const x1 = sx(af.x??0), y1 = sy(af.y??0);
const x2 = sx(at_.x??0), y2 = sy(at_.y??0);
ctx.strokeStyle = 'rgba(180,180,200,0.65)';
ctx.lineWidth = Math.max(1, scale * 0.75); ctx.lineCap = 'round';
if (order === 1) {
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
} else {
const dx = x2-x1, dy = y2-y1, len = Math.hypot(dx,dy)||1;
const nx = -dy/len, ny = dx/len, gap = Math.max(1.5, scale * 0.85);
const offsets = order === 2 ? [-gap/2, gap/2] : [-gap, 0, gap];
for (const o of offsets) {
ctx.beginPath(); ctx.moveTo(x1+nx*o, y1+ny*o); ctx.lineTo(x2+nx*o, y2+ny*o); ctx.stroke();
}
}
}
// atoms
const minR = Math.max(6, Math.min(W, H) * 0.065);
for (const a of atoms) {
const x = sx(a.x??0), y = sy(a.y??0);
const el = ELEM_COLORS[a.s] || { color:'#888', text:'#fff', r:10 };
const r = Math.max(minR * 0.65, (el.r / 20) * minR);
// sphere gradient
const grd = ctx.createRadialGradient(x-r*0.3,y-r*0.35,r*0.05,x,y,r);
const [hr,hg,hb] = hexToRgb(el.color);
grd.addColorStop(0, `rgb(${Math.min(255,hr+90)},${Math.min(255,hg+90)},${Math.min(255,hb+90)})`);
grd.addColorStop(0.5, el.color);
grd.addColorStop(1, `rgb(${Math.round(hr*.3)},${Math.round(hg*.3)},${Math.round(hb*.3)})`);
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2);
ctx.fillStyle = grd; ctx.fill();
const fs = Math.max(6, r * (a.s.length > 1 ? 0.72 : 0.92));
ctx.fillStyle = el.text;
ctx.font = `700 ${fs}px Manrope,sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(a.s, x, y);
}
}
function hexToRgb(hex) {
hex = 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];
// flask outline fallback
ctx.clearRect(0, 0, W, H);
const fw = Math.min(W, H) * 0.32, fx = W/2, fy = H/2 + fw*0.05;
ctx.strokeStyle = '#555';
ctx.lineWidth = Math.max(1.5, fw * 0.07);
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(fx - fw*0.22, fy - fw*0.52);
ctx.lineTo(fx - fw*0.22, fy - fw*0.08);
ctx.lineTo(fx - fw*0.55, fy + fw*0.42);
ctx.quadraticCurveTo(fx, fy + fw*0.62, fx + fw*0.55, fy + fw*0.42);
ctx.lineTo(fx + fw*0.22, fy - fw*0.08);
ctx.lineTo(fx + fw*0.22, fy - fw*0.52);
ctx.moveTo(fx - fw*0.32, fy - fw*0.52);
ctx.lineTo(fx + fw*0.32, fy - fw*0.52);
ctx.stroke();
}
// ── Data ──
@@ -620,15 +545,58 @@ async function selectMol(id) {
document.getElementById('det-open-btn').dataset.id = id;
_detMol = m;
const cvs = document.getElementById('det-canvas');
cvs.width = cvs.offsetWidth || 252;
cvs.height = cvs.offsetHeight || 252;
renderMolThumb(cvs, m.atoms_json || [], m.bonds_json || []);
_renderDetail();
} catch(e) {
document.getElementById('det-name').textContent = 'Ошибка загрузки';
}
}
// ── Detail 2D/3D view ──
let _detMol = null, _det3D = false, _det3dRotY = 0.4, _det3dRotX = 0.35, _detAnim = null;
function _stopDetAnim() { if (_detAnim) { cancelAnimationFrame(_detAnim); _detAnim = null; } }
function _renderDetail() {
_stopDetAnim();
const cvs = document.getElementById('det-canvas');
if (!cvs || !_detMol) return;
const ctx = cvs.getContext('2d');
const atoms = _detMol.atoms_json || [], bonds = _detMol.bonds_json || [];
const geomEl = document.getElementById('det-geom');
const tgl = document.getElementById('det-3d-toggle');
if (!_det3D || !atoms.length) {
geomEl.style.display = 'none';
if (tgl) { tgl.textContent = '3D'; tgl.style.color = '#aaa'; }
renderMolThumb(cvs, atoms, bonds);
return;
}
// 3D: build VSEPR geometry once, spin it
const g = BIO.vsepr(atoms, bonds);
if (g.shape) {
geomEl.style.display = 'block';
geomEl.textContent = [g.shape, g.hybridization, g.angle != null ? g.angle + '°' : '']
.filter(Boolean).join(' · ');
}
if (tgl) { tgl.textContent = '2D'; tgl.style.color = '#06D6E0'; }
let ext = 1;
for (const a of g.atoms3d) ext = Math.max(ext, Math.hypot(a.x, a.y, a.z));
const sc = Math.max(0.4, Math.min(3, (Math.min(cvs.width, cvs.height) * 0.40) / (ext * 1.6 + 30)));
const frame = () => {
if (!_det3D) return;
_det3dRotY += 0.012;
BIO.render3D(ctx, g.atoms3d, bonds, { rotX: _det3dRotX, rotY: _det3dRotY, scale: sc, W: cvs.width, H: cvs.height }, { vdw: false, bg: '#0a0a16' });
_detAnim = requestAnimationFrame(frame);
};
frame();
}
function toggleDet3D() { _det3D = !_det3D; _renderDetail(); }
function openInEditor() {
const id = document.getElementById('det-open-btn').dataset.id;
if (id) openInEditorId(id);
+52 -84
View File
@@ -238,6 +238,7 @@
</div>
<script src="/js/api.js"></script>
<script src="/js/biochem-core.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
@@ -261,15 +262,6 @@ const ATOMIC_MASS = {
Ag:107.868, Ba:137.327, Mn:54.938,
};
// ── CPK colors (for canvas thumbnails) ──
const CPK = {
H:'#D4D4D4', C:'#555555', N:'#4060FF', O:'#EE2020', P:'#FF8000',
S:'#C8B400', Cl:'#00A860', Na:'#8040C0', Ca:'#707070', K:'#8040C0',
Mg:'#1E8A1E', Fe:'#B03010', Br:'#8B2222', F:'#90E050', Al:'#BFA6A6',
};
function cpkColor(sym) { return CPK[sym] || '#888'; }
function molarMass(formula) {
let mass = 0;
const re = /([A-Z][a-z]*)(\d*)/g;
@@ -377,45 +369,9 @@ function renderMolList() {
function drawThumb(mol) {
const canvas = document.getElementById('thumb-'+mol.id);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = 34, H = 34;
ctx.clearRect(0,0,W,H);
ctx.fillStyle = '#08080f';
ctx.fillRect(0,0,W,H);
const atoms = mol.atoms_json || [];
const bonds = mol.bonds_json || [];
if (!atoms.length) return;
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
for (const a of atoms) { minX=Math.min(minX,a.x??a.id); minY=Math.min(minY,a.y??0); maxX=Math.max(maxX,a.x??a.id); maxY=Math.max(maxY,a.y??0); }
const pad=6, molW=Math.max(maxX-minX,1), molH=Math.max(maxY-minY,1);
const sc=Math.min((W-pad*2)/molW,(H-pad*2)/molH);
const ox=pad+(W-pad*2-molW*sc)/2, oy=pad+(H-pad*2-molH*sc)/2;
const sx=a=>ox+(a.x-minX)*sc, sy=a=>oy+(a.y-minY)*sc;
// bonds
ctx.strokeStyle='rgba(180,180,200,.5)'; ctx.lineWidth=1;
for (const b of bonds) {
const a1=atoms.find(a=>a.id===b.from||(b.f&&a.id===b.f)), a2=atoms.find(a=>a.id===b.to||(b.t&&a.id===b.t));
if (!a1||!a2) continue;
ctx.beginPath(); ctx.moveTo(sx(a1),sy(a1)); ctx.lineTo(sx(a2),sy(a2)); ctx.stroke();
}
// atoms
for (const a of atoms) {
const r = Math.max(2, 4*sc);
const col = cpkColor(a.s);
ctx.beginPath(); ctx.arc(sx(a),sy(a),r,0,Math.PI*2);
const [r0,g0,b0] = hexRgb(col);
const grd = ctx.createRadialGradient(sx(a)-r*.3,sy(a)-r*.35,r*.05,sx(a),sy(a),r);
grd.addColorStop(0,`rgb(${Math.min(255,r0+80)},${Math.min(255,g0+80)},${Math.min(255,b0+80)})`);
grd.addColorStop(1,`rgb(${Math.round(r0*.3)},${Math.round(g0*.3)},${Math.round(b0*.3)})`);
ctx.fillStyle=grd; ctx.fill();
}
}
function hexRgb(hex) {
hex=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];
if (!atoms.length) { const c = canvas.getContext('2d'); c.clearRect(0,0,34,34); c.fillStyle='#08080f'; c.fillRect(0,0,34,34); return; }
BIO.render2D(canvas.getContext('2d'), atoms, mol.bonds_json || [], { fit:true, padding:6, bg:'#08080f', showSymbols:false });
}
function toggleCompare(id) {
@@ -454,9 +410,14 @@ function renderCompare() {
const phys = getPhysProps(mol.formula);
html += `
<div class="compare-card">
<div class="cc-canvas-wrap">
<div class="cc-canvas-wrap" style="position:relative">
<canvas id="ccc-${mol.id}" width="200" height="160"></canvas>
<button class="cc-remove" onclick="removeFromCompare(${mol.id})" title="Убрать"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button id="cc3d-${mol.id}" onclick="toggleCompareCard3D(${mol.id})" title="2D / 3D"
style="position:absolute;top:6px;left:6px;z-index:2;height:22px;padding:0 8px;border-radius:6px;
border:1.5px solid rgba(255,255,255,.15);background:rgba(15,15,30,.85);color:#aaa;
font:700 .66rem Manrope,sans-serif;cursor:pointer">3D</button>
<div id="ccgeom-${mol.id}" style="position:absolute;bottom:4px;left:4px;right:4px;font:600 .6rem Manrope,sans-serif;color:#8aa;display:none;text-align:center;pointer-events:none"></div>
</div>
<div class="cc-body">
<div class="cc-formula">${mol.formula}</div>
@@ -528,55 +489,62 @@ function renderCompare() {
}, 0);
}
// Per-card 3D view state: id -> { on, rotY, anim }
const _cc3d = {};
function _stopCC(id) { const s = _cc3d[id]; if (s && s.anim) { cancelAnimationFrame(s.anim); s.anim = null; } }
function drawCompareCanvas(mol) {
const canvas = document.getElementById('ccc-'+mol.id);
if (!canvas) return;
const W = canvas.offsetWidth || 200, H = canvas.offsetHeight || 160;
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#08080f';
ctx.fillRect(0,0,W,H);
const atoms = mol.atoms_json || [];
const bonds = mol.bonds_json || [];
const atoms = mol.atoms_json || [], bonds = mol.bonds_json || [];
const tgl = document.getElementById('cc3d-'+mol.id);
const geomEl = document.getElementById('ccgeom-'+mol.id);
_stopCC(mol.id);
if (!atoms.length) {
ctx.fillStyle = '#444';
ctx.font = '0.8rem Manrope,sans-serif';
ctx.fillStyle = '#08080f'; ctx.fillRect(0,0,W,H);
ctx.fillStyle = '#444'; ctx.font = '0.8rem Manrope,sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('Нет структуры', W/2, H/2);
if (tgl) tgl.style.display = 'none';
return;
}
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
for (const a of atoms) { minX=Math.min(minX,a.x); minY=Math.min(minY,a.y); maxX=Math.max(maxX,a.x); maxY=Math.max(maxY,a.y); }
const pad=20, molW=Math.max(maxX-minX,1), molH=Math.max(maxY-minY,1);
const sc=Math.min((W-pad*2)/molW,(H-pad*2)/molH, 2.5);
const ox=W/2-(minX+maxX)*sc/2, oy=H/2-(minY+maxY)*sc/2;
const sx=a=>ox+a.x*sc, sy=a=>oy+a.y*sc;
// bonds
for (const b of bonds) {
const a1=atoms.find(a=>a.id===(b.from??b.f)), a2=atoms.find(a=>a.id===(b.to??b.t));
if (!a1||!a2) continue;
ctx.strokeStyle='rgba(180,180,200,.55)'; ctx.lineWidth=Math.max(1.5,2*sc);
ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(sx(a1),sy(a1)); ctx.lineTo(sx(a2),sy(a2)); ctx.stroke();
if (tgl) tgl.style.display = '';
const state = _cc3d[mol.id] || (_cc3d[mol.id] = { on:false, rotY:0.4 });
if (!state.on) {
if (tgl) { tgl.textContent = '3D'; tgl.style.color = '#aaa'; }
if (geomEl) geomEl.style.display = 'none';
BIO.render2D(ctx, atoms, bonds, { fit:true, padding:20, bg:'#08080f', maxScale:2.5 });
return;
}
// atoms
for (const a of atoms) {
const r = Math.max(6, 9*sc);
const col = cpkColor(a.s);
const [r0,g0,b0] = hexRgb(col);
const grd = ctx.createRadialGradient(sx(a)-r*.32,sy(a)-r*.38,r*.05,sx(a),sy(a),r);
grd.addColorStop(0,`rgb(${Math.min(255,r0+100)},${Math.min(255,g0+100)},${Math.min(255,b0+100)})`);
grd.addColorStop(.4, col);
grd.addColorStop(1,`rgb(${Math.round(r0*.2)},${Math.round(g0*.2)},${Math.round(b0*.2)})`);
ctx.beginPath(); ctx.arc(sx(a),sy(a),r,0,Math.PI*2);
ctx.fillStyle=grd; ctx.fill();
if (atoms.length <= 15) {
ctx.fillStyle = a.s==='C'||a.s==='N'||a.s==='Fe'||a.s==='Mg' ? '#fff' : '#111';
ctx.font=`bold ${Math.max(8,Math.round(r*.7))}px Manrope,sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(a.s,sx(a),sy(a));
}
// 3D spinning
if (tgl) { tgl.textContent = '2D'; tgl.style.color = '#06D6E0'; }
const g = BIO.vsepr(atoms, bonds);
if (geomEl && g.shape) {
geomEl.style.display = 'block';
geomEl.textContent = [g.shape, g.hybridization, g.angle != null ? g.angle + '°' : ''].filter(Boolean).join(' · ');
}
let ext = 1; for (const a of g.atoms3d) ext = Math.max(ext, Math.hypot(a.x, a.y, a.z));
const sc = Math.max(0.4, Math.min(2.6, (Math.min(W, H) * 0.40) / (ext * 1.6 + 30)));
const frame = () => {
if (!state.on) return;
state.rotY += 0.012;
BIO.render3D(ctx, g.atoms3d, bonds, { rotX:0.35, rotY:state.rotY, scale:sc, W, H }, { vdw:false, bg:'#0a0a16' });
state.anim = requestAnimationFrame(frame);
};
frame();
}
function toggleCompareCard3D(id) {
const mol = _compare.find(c => c.id === id);
if (!mol) return;
const s = _cc3d[id] || (_cc3d[id] = { on:false, rotY:0.4 });
s.on = !s.on;
drawCompareCanvas(mol);
}
init();
+13 -81
View File
@@ -358,6 +358,7 @@
</div>
<script src="/js/api.js"></script>
<script src="/js/biochem-core.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
@@ -373,92 +374,23 @@ if (isAdmin) document.getElementById('btn-admin').style.display = '';
if (isTeacher) document.getElementById('btn-classes').style.display = '';
LS.showBoardIfAllowed();
// ── CPK colours ──
const ELEM_COLORS = {
H: { color:'#D4D4D4', text:'#222', r:9 },
C: { color:'#555555', text:'#fff', r:10 },
N: { color:'#4060FF', text:'#fff', r:10 },
O: { color:'#EE2020', text:'#fff', r:10 },
P: { color:'#FF8000', text:'#fff', r:11 },
S: { color:'#C8B400', text:'#000', r:11 },
Cl: { color:'#00A860', text:'#fff', r:11 },
Na: { color:'#8040C0', text:'#fff', r:11 },
Ca: { color:'#707070', text:'#fff', r:11 },
Mg: { color:'#1E8A1E', text:'#fff', r:11 },
Fe: { color:'#B03010', text:'#fff', r:11 },
Br: { color:'#8B4513', text:'#fff', r:11 },
I: { color:'#580DAB', text:'#fff', r:11 },
F: { color:'#B0FFB0', text:'#222', r:9 },
};
function hexToRgb(hex) {
hex = 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 renderMolThumb(canvas, atoms, bonds) {
const W = canvas.width, H = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
if (!atoms || !atoms.length) {
ctx.fillStyle = '#4b5563';
ctx.beginPath();
ctx.arc(W/2, H/2, Math.min(W,H)*0.18, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#9ca3af';
ctx.font = `bold ${Math.floor(Math.min(W,H)*0.22)}px sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('?', W/2, H/2);
if (atoms && atoms.length) {
BIO.render2D(ctx, atoms, bonds || [], { fit:true, padding:Math.min(W,H)*0.15 });
return;
}
let minX=Infinity, minY=Infinity, maxX=-Infinity, maxY=-Infinity;
for (const a of atoms) {
const x=a.x??0, y=a.y??0;
minX=Math.min(minX,x); minY=Math.min(minY,y);
maxX=Math.max(maxX,x); maxY=Math.max(maxY,y);
}
const pad=Math.min(W,H)*0.15;
const rangeX=maxX-minX||1, rangeY=maxY-minY||1;
const scale=Math.min((W-pad*2)/rangeX,(H-pad*2)/rangeY);
const offX=(W-rangeX*scale)/2-minX*scale;
const offY=(H-rangeY*scale)/2-minY*scale;
const sx=ax=>ax*scale+offX, sy=ay=>ay*scale+offY;
for (const b of bonds) {
const f=b.f??b.from, t=b.t??b.to, order=b.o??b.order??1;
const af=atoms.find(a=>a.id===f), at_=atoms.find(a=>a.id===t);
if (!af||!at_) continue;
const x1=sx(af.x??0), y1=sy(af.y??0), x2=sx(at_.x??0), y2=sy(at_.y??0);
ctx.strokeStyle='rgba(180,180,200,0.6)';
ctx.lineWidth=Math.max(1,scale*0.75); ctx.lineCap='round';
if (order===1) { ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); }
else {
const dx=x2-x1, dy=y2-y1, len=Math.hypot(dx,dy)||1;
const nx=-dy/len, ny=dx/len, gap=Math.max(1.5,scale*0.85);
const offsets=order===2?[-gap/2,gap/2]:[-gap,0,gap];
for (const o of offsets) { ctx.beginPath(); ctx.moveTo(x1+nx*o,y1+ny*o); ctx.lineTo(x2+nx*o,y2+ny*o); ctx.stroke(); }
}
}
const minR=Math.max(5,Math.min(W,H)*0.065);
for (const a of atoms) {
const x=sx(a.x??0), y=sy(a.y??0);
const el=ELEM_COLORS[a.s]||{color:'#888',text:'#fff',r:10};
const r=Math.max(minR*0.6,(el.r/20)*minR);
const [hr,hg,hb]=hexToRgb(el.color);
const grd=ctx.createRadialGradient(x-r*0.3,y-r*0.35,r*0.05,x,y,r);
grd.addColorStop(0,`rgb(${Math.min(255,hr+90)},${Math.min(255,hg+90)},${Math.min(255,hb+90)})`);
grd.addColorStop(0.5,el.color);
grd.addColorStop(1,`rgb(${Math.round(hr*.3)},${Math.round(hg*.3)},${Math.round(hb*.3)})`);
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2);
ctx.fillStyle=grd; ctx.fill();
const fs=Math.max(5,r*(a.s.length>1?0.7:0.9));
ctx.fillStyle=el.text;
ctx.font=`700 ${fs}px Manrope,sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(a.s,x,y);
}
// unknown-structure fallback: "?"
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#4b5563';
ctx.beginPath();
ctx.arc(W/2, H/2, Math.min(W,H)*0.18, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#9ca3af';
ctx.font = `bold ${Math.floor(Math.min(W,H)*0.22)}px sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('?', W/2, H/2);
}
// ── Type metadata ──