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