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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user