Files
Learn_System/frontend/biochem-properties.html
T
Maxim Dolgolyov 29ef974e35 feat(biochem): skeleton loaders for async fetches
Replace plain "Загрузка..." placeholders with shimmer-animated skeletons
matching the actual layout shape:
- library: 12 placeholder cards (canvas + 2 lines)
- reactions: 6 row skeletons (stripe + title + 2 text lines)
- properties: 10 sidebar row shimmers (thumb + 2 lines)
- biochem editor: 4-5 row skeletons for saved-molecules and challenges lists

No existing skeleton classes in ls.css; added local .bc-sk-* helpers per page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:49:54 +03:00

591 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Свойства молекул — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
html, body { height: 100%; overflow: hidden; }
.app-layout { height: 100vh; overflow: hidden; }
.sb-content { background: #0d0d1a; overflow: hidden; display: flex; flex-direction: column; }
.sb-sub-link { padding-left: 28px !important; font-size: 0.76rem !important; opacity: .75; }
.sb-sub-link:hover { opacity: 1; }
/* ── Layout ── */
.props-layout {
display: flex; flex: 1; min-height: 0; overflow: hidden; background: #0d0d1a;
}
/* ── Left: molecule search list ── */
.props-sidebar {
width: 260px; flex-shrink: 0;
background: rgba(8,8,20,.95);
border-right: 1px solid rgba(155,93,229,.12);
display: flex; flex-direction: column; overflow: hidden;
}
.ps-head {
padding: 14px 14px 10px;
border-bottom: 1px solid rgba(255,255,255,.05);
flex-shrink: 0;
}
.ps-title {
font-family: 'Unbounded', sans-serif; font-size: 0.68rem; font-weight: 700;
color: #9B5DE5; letter-spacing: .05em; margin-bottom: 8px;
}
.ps-search {
width: 100%; padding: 7px 10px; border-radius: 8px;
background: rgba(255,255,255,.06); border: 1.5px solid rgba(255,255,255,.1);
color: #ddd; font-family: 'Manrope', sans-serif; font-size: 0.8rem;
margin-bottom: 8px;
}
.ps-search:focus { outline: none; border-color: rgba(155,93,229,.5); }
.ps-cats { display: flex; gap: 4px; flex-wrap: wrap; }
.ps-cat {
font-size: 0.64rem; font-weight: 700; padding: 2px 7px; border-radius: 999px;
border: 1px solid rgba(255,255,255,.1); cursor: pointer; transition: all .15s;
background: rgba(255,255,255,.04); color: #666;
}
.ps-cat.active { background: rgba(155,93,229,.2); border-color: var(--violet); color: #c084fc; }
.ps-list { flex: 1; overflow-y: auto; padding: 6px 8px; }
.mol-row {
display: flex; align-items: center; gap: 8px; padding: 7px 8px;
border-radius: 8px; border: 1px solid transparent;
cursor: pointer; transition: all .15s; margin-bottom: 2px;
}
.mol-row:hover { background: rgba(155,93,229,.1); border-color: rgba(155,93,229,.15); }
.mol-row.in-compare { background: rgba(155,93,229,.18); border-color: var(--violet); }
.mol-thumb { width: 34px; height: 34px; border-radius: 8px; flex-shrink: 0; background: rgba(255,255,255,.04); }
.mol-info { flex: 1; min-width: 0; }
.mol-name { font-size: 0.74rem; font-weight: 700; color: #ddd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mol-formula { font-size: 0.68rem; color: #06D6E0; font-family: monospace; }
/* ── Right: comparison area ── */
.props-main {
flex: 1; overflow-y: auto; padding: 20px 24px;
display: flex; flex-direction: column; gap: 20px;
}
.props-hero {
display: flex; align-items: center; gap: 14px;
}
.props-hero-title {
font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800;
color: #fff;
}
.props-hero-sub { font-size: 0.78rem; color: #666; margin-top: 2px; }
.compare-hint {
flex: 1; display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 10px; color: #444;
padding: 60px 24px; text-align: center; border: 1.5px dashed rgba(255,255,255,.06);
border-radius: 16px;
}
.compare-hint-icon { font-size: 2.5rem; }
.compare-hint-text { font-size: 0.82rem; }
/* ── Comparison cards ── */
.compare-grid {
display: grid; gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.compare-card {
background: rgba(15,15,35,.9); border: 1.5px solid rgba(155,93,229,.15);
border-radius: 16px; overflow: hidden; position: relative;
}
.compare-card .cc-canvas-wrap {
height: 160px; background: #08080f; position: relative;
}
.compare-card canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.cc-remove {
position: absolute; top: 8px; right: 8px; width: 24px; height: 24px;
border-radius: 6px; background: rgba(239,68,68,.2); border: 1px solid rgba(239,68,68,.3);
color: #f87171; font-size: 12px; cursor: pointer; display: flex; align-items: center;
justify-content: center; transition: all .15s; z-index: 5;
}
.cc-remove:hover { background: rgba(239,68,68,.4); }
.cc-body { padding: 12px 14px 14px; }
.cc-formula {
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
background: linear-gradient(135deg,#a78bfa,#06D6E0);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text; margin-bottom: 2px;
}
.cc-name { font-size: 0.78rem; font-weight: 700; color: #ddd; margin-bottom: 10px; }
.cc-props { display: flex; flex-direction: column; gap: 5px; }
.cc-prop { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; }
.cc-prop-label { font-size: 0.66rem; font-weight: 700; color: #555; text-transform: uppercase; letter-spacing: .04em; }
.cc-prop-val { font-size: 0.78rem; font-weight: 700; color: #ccc; text-align: right; }
.cc-prop-val.highlight { color: #c084fc; }
.cc-cat {
display: inline-block; font-size: 0.62rem; font-weight: 700; padding: 2px 7px;
border-radius: 999px; margin-top: 6px;
}
.cc-cat.inorganic { background: rgba(59,130,246,.15); color: #60a5fa; border: 1px solid rgba(59,130,246,.2); }
.cc-cat.organic { background: rgba(74,222,128,.12); color: #4ade80; border: 1px solid rgba(74,222,128,.2); }
.cc-cat.biomolecule{ background: rgba(251,191,36,.12); color: #fbbf24; border: 1px solid rgba(251,191,36,.2); }
.cc-cat.amino_acid { background: rgba(244,114,182,.12); color: #f472b6; border: 1px solid rgba(244,114,182,.2); }
/* ── Comparison table (when 2+ molecules) ── */
.compare-table-wrap { overflow-x: auto; }
.compare-table {
width: 100%; border-collapse: collapse; font-size: 0.78rem;
}
.compare-table th, .compare-table td {
padding: 9px 14px; text-align: left;
border-bottom: 1px solid rgba(255,255,255,.05);
}
.compare-table th {
background: rgba(155,93,229,.1); color: #c084fc;
font-weight: 700; font-size: 0.7rem; text-transform: uppercase; letter-spacing: .05em;
white-space: nowrap;
}
.compare-table th.mol-col { color: #06D6E0; background: rgba(6,214,224,.06); }
.compare-table td.row-label { color: #666; font-weight: 700; font-size: 0.69rem; white-space: nowrap; }
.compare-table td.mol-val { color: #ddd; font-weight: 600; }
.compare-table tr:hover td { background: rgba(255,255,255,.02); }
/* ── Add placeholder card ── */
.add-card {
border: 1.5px dashed rgba(155,93,229,.2); border-radius: 16px;
display: flex; align-items: center; justify-content: center; flex-direction: column;
gap: 8px; color: #444; cursor: pointer; transition: all .2s;
min-height: 240px; font-size: 0.78rem; padding: 20px;
}
.add-card:hover { border-color: rgba(155,93,229,.5); color: #9B5DE5; background: rgba(155,93,229,.05); }
.add-card-icon { font-size: 2rem; }
/* ── Shimmer skeleton ── */
@keyframes bc-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.bc-sk {
background: linear-gradient(90deg,
rgba(255,255,255,0.04) 0%,
rgba(255,255,255,0.10) 50%,
rgba(255,255,255,0.04) 100%);
background-size: 200% 100%;
animation: bc-shimmer 1.6s infinite;
border-radius: 8px;
}
.bc-sk-line { height: 10px; margin: 5px 0; }
.bc-sk-line.sm { width: 55%; }
.bc-sk-line.md { width: 80%; }
.bc-sk-molrow { display: flex; align-items: center; gap: 8px; padding: 7px 8px; margin-bottom: 2px; }
.bc-sk-molrow .bc-sk-thumb { width: 34px; height: 34px; border-radius: 8px; flex-shrink: 0; }
.bc-sk-molrow .bc-sk-info { flex: 1; }
/* ── Mobile ── */
@media (max-width: 768px) {
html, body { overflow: auto; }
.app-layout { height: auto; overflow: visible; }
.sb-content { overflow: auto; }
.props-layout { flex-direction: column; overflow: visible; }
.props-sidebar { width: 100%; height: auto; max-height: 45vh; border-right: none; border-bottom: 1px solid rgba(155,93,229,.12); flex-shrink: 0; }
.ps-list { max-height: 30vh; }
.props-main { overflow-y: auto; }
.compare-table { font-size: 0.72rem; }
.compare-table th, .compare-table td { padding: 6px 10px; }
}
@media (max-width: 480px) {
.props-sidebar { max-height: 40vh; }
.mol-card-grid { grid-template-columns: 1fr !important; }
}
</style>
</head>
<body>
<div class="app-layout" id="app">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content" style="overflow:hidden;display:flex;flex-direction:column">
<div class="props-layout">
<!-- Left: molecule list -->
<div class="props-sidebar">
<div class="ps-head">
<div class="ps-title">Молекулы</div>
<input type="text" class="ps-search" placeholder="Поиск…" oninput="filterMols(this.value)" id="mol-search" />
<div class="ps-cats">
<button class="ps-cat active" data-cat="" onclick="setCat(this,'')">Все</button>
<button class="ps-cat" data-cat="inorganic" onclick="setCat(this,'inorganic')">Неорг.</button>
<button class="ps-cat" data-cat="organic" onclick="setCat(this,'organic')">Орган.</button>
<button class="ps-cat" data-cat="biomolecule" onclick="setCat(this,'biomolecule')">Биомол.</button>
<button class="ps-cat" data-cat="amino_acid" onclick="setCat(this,'amino_acid')">АК</button>
</div>
</div>
<div class="ps-list" id="mol-list"></div>
</div>
<!-- Right: comparison -->
<div class="props-main" id="props-main">
<div class="props-hero">
<div>
<div class="props-hero-title">Сравнение молекул</div>
<div class="props-hero-sub">Добавь до 4 молекул из списка слева</div>
</div>
</div>
<div id="compare-area">
<div class="compare-hint" id="compare-empty">
<div class="compare-hint-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg></div>
<div class="compare-hint-text">Выбери молекулы из списка, чтобы сравнить их свойства</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.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>
'use strict';
const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) location.href = '/login';
const nav = document.getElementById('nav-user');
const ava = document.getElementById('nav-avatar');
if (nav) nav.textContent = user?.name?.split(' ')[0] || 'Пользователь';
if (ava) ava.textContent = (user?.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
if (isAdmin) document.getElementById('btn-admin').style.display = '';
if (isTeacher) document.getElementById('btn-classes').style.display = '';
LS.showBoardIfAllowed();
// ── Atomic masses (g/mol) ──
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, K:39.098, Mg:24.305, Fe:55.845,
Br:79.904, I:126.904, F:18.998, Al:26.982, Cu:63.546, Zn:65.38,
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;
let m;
while ((m = re.exec(formula)) !== null) {
const el = m[1], n = parseInt(m[2])||1;
mass += (ATOMIC_MASS[el]||0) * n;
}
return mass;
}
function catLabel(cat) {
return {inorganic:'Неорганика', organic:'Органика', biomolecule:'Биомолекула', amino_acid:'Аминокислота'}[cat] || cat || '—';
}
// ── Known physical properties ──
const PHYS_PROPS = {
'H2O': { state:'жидкость', solubility:'растворитель', bp:'100°C', mp:'0°C' },
'CO2': { state:'газ', solubility:'растворим', bp:'-78.5°C(субл.)', mp:'—' },
'O2': { state:'газ', solubility:'мало растворим', bp:'-183°C', mp:'-218.4°C' },
'H2': { state:'газ', solubility:'мало растворим', bp:'-252.9°C', mp:'-259.2°C' },
'N2': { state:'газ', solubility:'мало растворим', bp:'-195.8°C', mp:'-210°C' },
'NH3': { state:'газ', solubility:'хорошо растворим', bp:'-33.4°C', mp:'-77.7°C' },
'HCl': { state:'газ', solubility:'хорошо растворим', bp:'-85.1°C', mp:'-114.2°C' },
'H2SO4': { state:'жидкость', solubility:'растворитель', bp:'337°C', mp:'10°C' },
'HNO3': { state:'жидкость', solubility:'смешивается', bp:'83°C', mp:'-42°C' },
'NaOH': { state:'твёрдое', solubility:'хорошо растворим', bp:'1388°C', mp:'318°C' },
'NaCl': { state:'твёрдое', solubility:'растворим', bp:'1413°C', mp:'801°C' },
'CH4': { state:'газ', solubility:'мало растворим', bp:'-161.5°C', mp:'-182.5°C' },
'C2H5OH':{ state:'жидкость', solubility:'смешивается', bp:'78.4°C', mp:'-114.1°C' },
'C6H6': { state:'жидкость', solubility:'не растворим', bp:'80.1°C', mp:'5.5°C' },
'C6H12O6':{ state:'твёрдое', solubility:'растворим', bp:'разлагается', mp:'146°C' },
};
function getPhysProps(formula) {
return PHYS_PROPS[formula] || { state:'—', solubility:'—', bp:'—', mp:'—' };
}
// ── State ──
let _allMols = [];
let _filtered = [];
let _catFilter = '';
let _searchQ = '';
let _compare = []; // array of mol objects, max 4
function bcSkMolList(n = 10) {
return Array.from({length: n}, () => `
<div class="bc-sk-molrow">
<div class="bc-sk bc-sk-thumb"></div>
<div class="bc-sk-info">
<div class="bc-sk bc-sk-line md"></div>
<div class="bc-sk bc-sk-line sm"></div>
</div>
</div>`).join('');
}
async function init() {
document.getElementById('mol-list').innerHTML = bcSkMolList(10);
try {
_allMols = await LS.biochemGetMolecules();
applyFilter();
} catch(e) {
document.getElementById('mol-list').innerHTML = '<div style="padding:20px;color:#666;font-size:.8rem">Ошибка загрузки</div>';
}
}
function applyFilter() {
_filtered = _allMols.filter(m => {
const q = _searchQ.toLowerCase();
const matchQ = !q || m.name_ru.toLowerCase().includes(q) || m.formula.toLowerCase().includes(q);
const matchCat = !_catFilter || m.category === _catFilter;
return matchQ && matchCat;
});
renderMolList();
}
function filterMols(q) { _searchQ = q; applyFilter(); }
function setCat(btn, cat) {
_catFilter = cat;
document.querySelectorAll('.ps-cat').forEach(b => b.classList.toggle('active', b.dataset.cat===cat));
applyFilter();
}
function renderMolList() {
const list = document.getElementById('mol-list');
if (!_filtered.length) {
list.innerHTML = '<div style="padding:20px 8px;color:#555;font-size:.78rem">Ничего не найдено</div>';
return;
}
list.innerHTML = _filtered.map(m => {
const inCompare = _compare.some(c => c.id === m.id);
return `<div class="mol-row ${inCompare?'in-compare':''}" onclick="toggleCompare(${m.id})">
<canvas class="mol-thumb" id="thumb-${m.id}" width="34" height="34"></canvas>
<div class="mol-info">
<div class="mol-name">${m.name_ru}</div>
<div class="mol-formula">${m.formula}</div>
</div>
${inCompare ? '<span style="color:#c084fc;font-size:11px"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>' : ''}
</div>`;
}).join('');
// draw thumbnails
setTimeout(() => { _filtered.forEach(m => drawThumb(m)); }, 0);
}
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];
}
function toggleCompare(id) {
const mol = _allMols.find(m => m.id===id);
if (!mol) return;
const idx = _compare.findIndex(c => c.id===id);
if (idx >= 0) {
_compare.splice(idx, 1);
} else {
if (_compare.length >= 4) { LS.toast('Максимум 4 молекулы', 'info'); return; }
_compare.push(mol);
}
renderMolList();
renderCompare();
}
function removeFromCompare(id) {
_compare = _compare.filter(c => c.id !== id);
renderMolList();
renderCompare();
}
function renderCompare() {
const area = document.getElementById('compare-area');
if (!_compare.length) {
area.innerHTML = `<div class="compare-hint" id="compare-empty">
<div class="compare-hint-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg></div>
<div class="compare-hint-text">Выбери молекулы из списка, чтобы сравнить их свойства</div>
</div>`;
return;
}
let html = '<div class="compare-grid">';
for (const mol of _compare) {
const mm = molarMass(mol.formula);
const phys = getPhysProps(mol.formula);
html += `
<div class="compare-card">
<div class="cc-canvas-wrap">
<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>
</div>
<div class="cc-body">
<div class="cc-formula">${mol.formula}</div>
<div class="cc-name">${mol.name_ru}</div>
<div class="cc-props">
<div class="cc-prop">
<span class="cc-prop-label">М. масса</span>
<span class="cc-prop-val highlight">${mm > 0 ? mm.toFixed(2) + ' г/моль' : '—'}</span>
</div>
<div class="cc-prop">
<span class="cc-prop-label">Состояние</span>
<span class="cc-prop-val">${phys.state}</span>
</div>
<div class="cc-prop">
<span class="cc-prop-label">Растворимость</span>
<span class="cc-prop-val">${phys.solubility}</span>
</div>
<div class="cc-prop">
<span class="cc-prop-label">Ткип</span>
<span class="cc-prop-val">${phys.bp}</span>
</div>
<div class="cc-prop">
<span class="cc-prop-label">Тпл</span>
<span class="cc-prop-val">${phys.mp}</span>
</div>
<div class="cc-prop">
<span class="cc-prop-label">Сложность</span>
<span class="cc-prop-val">${['','<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>','<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>','<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'][mol.difficulty]||'—'}</span>
</div>
</div>
<span class="cc-cat ${mol.category||''}">${catLabel(mol.category)}</span>
</div>
</div>`;
}
// Add placeholder if < 4
if (_compare.length < 4) {
html += `<div class="add-card" onclick="document.getElementById('mol-search').focus()">
<div class="add-card-icon">+</div>
<div>Добавить молекулу</div>
</div>`;
}
html += '</div>';
// Comparison table (when 2+)
if (_compare.length >= 2) {
html += `<div class="compare-table-wrap"><table class="compare-table">
<thead><tr>
<th>Свойство</th>
${_compare.map(m=>`<th class="mol-col">${m.formula}</th>`).join('')}
</tr></thead>
<tbody>
<tr><td class="row-label">Название</td>${_compare.map(m=>`<td class="mol-val">${m.name_ru}</td>`).join('')}</tr>
<tr><td class="row-label">Молярная масса</td>${_compare.map(m=>{const mm=molarMass(m.formula);return`<td class="mol-val">${mm>0?mm.toFixed(2)+' г/моль':'—'}</td>`}).join('')}</tr>
<tr><td class="row-label">Агр. состояние</td>${_compare.map(m=>`<td class="mol-val">${getPhysProps(m.formula).state}</td>`).join('')}</tr>
<tr><td class="row-label">Растворимость</td>${_compare.map(m=>`<td class="mol-val">${getPhysProps(m.formula).solubility}</td>`).join('')}</tr>
<tr><td class="row-label">Температура кипения</td>${_compare.map(m=>`<td class="mol-val">${getPhysProps(m.formula).bp}</td>`).join('')}</tr>
<tr><td class="row-label">Температура плавления</td>${_compare.map(m=>`<td class="mol-val">${getPhysProps(m.formula).mp}</td>`).join('')}</tr>
<tr><td class="row-label">Категория</td>${_compare.map(m=>`<td class="mol-val">${catLabel(m.category)}</td>`).join('')}</tr>
<tr><td class="row-label">Сложность</td>${_compare.map(m=>`<td class="mol-val">${['','<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>','<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>','<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'][m.difficulty]||'—'}</td>`).join('')}</tr>
<tr><td class="row-label">Описание</td>${_compare.map(m=>`<td class="mol-val" style="font-size:.7rem;color:#888">${m.description||'—'}</td>`).join('')}</tr>
</tbody>
</table></div>`;
}
area.innerHTML = html;
// Draw compare canvases
setTimeout(() => {
for (const mol of _compare) drawCompareCanvas(mol);
}, 0);
}
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 || [];
if (!atoms.length) {
ctx.fillStyle = '#444';
ctx.font = '0.8rem Manrope,sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('Нет структуры', W/2, H/2);
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();
}
// 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));
}
}
}
init();
if (window.lucide) lucide.createIcons();
LS.notif?.init();
LS.hideDisabledFeatures?.();
</script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>