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