Files
Learn_System/frontend/biochem-properties.html
T
Maxim Dolgolyov 358b761eb2 fix(biochem): статичный subnav без мигания + редизайн
Проблема: динамическая вставка через JS вызывала мигание (nav
появлялся через ~100ms после первого пейнта).

Решение: nav — статичный HTML в каждой странице, CSS — в <head>.
Активная вкладка проставлена в HTML (class bsn-active) — нет JS,
нет мигания, работает с первого байта.

Редизайн .biochem-subnav:
- frosted glass (backdrop-filter blur 14px, rgba 0.92)
- активная вкладка: фиолетовый фон-пилюля + нижняя линия 2.5px
- hover: мягкий фиолетовый фон
- mobile <560px: только иконки (bsn-label display:none)
- overflow-x auto + scrollbar-width:none — горизонтальная прокрутка без полосы
- biochem-nav.js сведён к no-op комментарию

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 08:54:38 +03:00

664 lines
33 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; }
}
/* ── Biochem subnav ─────────────────────────────────────────────── */
.biochem-subnav {
display: flex; align-items: center; gap: 2px;
padding: 0 16px;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border-bottom: 1.5px solid rgba(15,23,42,0.07);
flex-shrink: 0; overflow-x: auto;
scrollbar-width: none; position: relative;
}
.biochem-subnav::-webkit-scrollbar { display: none; }
.biochem-subnav::after {
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
height: 1.5px; background: rgba(15,23,42,0.07);
}
.bsn-tab {
display: inline-flex; align-items: center; gap: 7px;
padding: 11px 14px; border-radius: 9px; margin: 5px 1px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
color: var(--text-3, #56687A); text-decoration: none; white-space: nowrap;
transition: background .15s, color .15s; position: relative;
}
.bsn-tab svg { stroke: currentColor; width: 15px; height: 15px; flex-shrink: 0; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.bsn-tab:hover { background: rgba(155,93,229,0.08); color: #9B5DE5; }
.bsn-active {
background: rgba(155,93,229,0.10); color: #7c3aed; font-weight: 700;
}
.bsn-active::after {
content: ''; position: absolute; bottom: -5px; left: 14px; right: 14px;
height: 2.5px; border-radius: 99px; background: #9B5DE5;
}
@media (max-width: 560px) {
.biochem-subnav { padding: 0 6px; }
.bsn-tab { padding: 10px 10px; gap: 0; }
.bsn-tab .bsn-label { display: none; }
}
</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">
<nav class="biochem-subnav" aria-label="Разделы биохимии">
<a class="bsn-tab" href="/biochem"><svg viewBox="0 0 24 24"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3M7.5 15h9"/></svg><span class="bsn-label">Редактор</span></a><a class="bsn-tab" href="/biochem-library"><svg viewBox="0 0 24 24"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg><span class="bsn-label">Библиотека</span></a><a class="bsn-tab" href="/biochem-reactions"><svg viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg><span class="bsn-label">Реакции</span></a><a class="bsn-tab bsn-active" href="/biochem-properties" aria-current="page"><svg viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 10h2a5 5 0 0 0 0-10h-2m-6 5h6"/></svg><span class="bsn-label">Свойства</span></a><a class="bsn-tab" href="/biochem-pathways"><svg viewBox="0 0 24 24"><path d="M6 18h8M3 22h18M8 22V12l4-10 4 10v10M10 9h4"/></svg><span class="bsn-label">Пути</span></a>
</nav>
<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/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>
'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] || 'Пользователь';
LS.renderNavAvatar(ava, user);
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,
};
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 atoms = mol.atoms_json || [];
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) {
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" 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>
<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>`;
// график молярных масс + экспорт
html += `<div class="compare-chart-wrap" style="margin-top:14px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:14px;padding:14px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<div class="detail-label" style="margin:0">Молярная масса, г/моль</div>
<button onclick="exportCompareCSV()" style="height:28px;padding:0 12px;border-radius:8px;border:1.5px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06);color:#aaa;font:700 .72rem Manrope,sans-serif;cursor:pointer">Экспорт CSV</button>
</div>
<canvas id="cmp-chart" width="600" height="200" style="width:100%;height:auto;display:block"></canvas>
</div>`;
}
area.innerHTML = html;
// Draw compare canvases
setTimeout(() => {
for (const mol of _compare) drawCompareCanvas(mol);
if (_compare.length >= 2) drawMassChart();
}, 0);
}
// ── Столбчатый график молярных масс ──
function drawMassChart() {
const cvs = document.getElementById('cmp-chart');
if (!cvs) return;
const W = cvs.width, H = cvs.height, ctx = cvs.getContext('2d');
ctx.clearRect(0, 0, W, H);
const data = _compare.map(m => ({ label: m.formula, v: molarMass(m.formula), name: m.name_ru }));
const maxV = Math.max(...data.map(d => d.v), 1);
const padB = 34, padT = 18, padL = 8, padR = 8;
const n = data.length, gap = 18;
const bw = Math.min(90, (W - padL - padR - gap * (n - 1)) / n);
const colors = ['#9B5DE5', '#06D6E0', '#facc15', '#4ade80'];
// ось
ctx.strokeStyle = 'rgba(255,255,255,.1)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(padL, H - padB); ctx.lineTo(W - padR, H - padB); ctx.stroke();
const totalW = bw * n + gap * (n - 1);
let x = (W - totalW) / 2;
data.forEach((d, i) => {
const bh = (d.v / maxV) * (H - padB - padT);
const y = H - padB - bh;
const col = colors[i % colors.length];
const grd = ctx.createLinearGradient(0, y, 0, H - padB);
grd.addColorStop(0, col); grd.addColorStop(1, col + '55');
ctx.fillStyle = grd;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, bw, bh, 6); else ctx.rect(x, y, bw, bh);
ctx.fill();
ctx.fillStyle = '#ddd'; ctx.font = '700 11px Manrope,sans-serif'; ctx.textAlign = 'center';
ctx.fillText(d.v.toFixed(1), x + bw / 2, y - 5);
ctx.fillStyle = '#888'; ctx.font = '600 10px Manrope,sans-serif';
ctx.fillText(d.label.length > 10 ? d.label.slice(0, 9) + '…' : d.label, x + bw / 2, H - padB + 14);
x += bw + gap;
});
}
// ── Экспорт сравнения в CSV ──
function exportCompareCSV() {
const rows = [['Свойство', ..._compare.map(m => m.name_ru)]];
const cell = v => `"${String(v == null ? '—' : v).replace(/"/g, '""')}"`;
rows.push(['Формула', ..._compare.map(m => m.formula)]);
rows.push(['Молярная масса, г/моль', ..._compare.map(m => { const v = molarMass(m.formula); return v > 0 ? v.toFixed(2) : '—'; })]);
rows.push(['Агр. состояние', ..._compare.map(m => getPhysProps(m.formula).state)]);
rows.push(['Растворимость', ..._compare.map(m => getPhysProps(m.formula).solubility)]);
rows.push(['T кипения', ..._compare.map(m => getPhysProps(m.formula).bp)]);
rows.push(['T плавления', ..._compare.map(m => getPhysProps(m.formula).mp)]);
rows.push(['Категория', ..._compare.map(m => catLabel(m.category))]);
const csv = '' + rows.map(r => r.map(cell).join(',')).join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'сравнение-молекул.csv';
a.click();
URL.revokeObjectURL(a.href);
}
// 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');
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 = '#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;
}
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;
}
// 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();
if (window.lucide) lucide.createIcons();
LS.notif?.init();
LS.hideDisabledFeatures?.();
</script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/biochem-nav.js"></script>
</body>
</html>