Files
Learn_System/frontend/biochem-properties.html
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

606 lines
32 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; }
/* ── 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">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link" id="btn-board" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="btn-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/biochem-library" class="sb-link sb-sub-link"><i data-lucide="library" class="sb-icon"></i><span class="sb-lbl">↳ Библиотека</span></a>
<a href="/biochem-reactions" class="sb-link sb-sub-link"><i data-lucide="arrow-right-left" class="sb-icon"></i><span class="sb-lbl">↳ Реакции</span></a>
<a href="/biochem-properties" class="sb-link sb-sub-link active"><i data-lucide="table-properties" class="sb-icon"></i><span class="sb-lbl">↳ Свойства</span></a>
<a href="/biochem-pathways" class="sb-link sb-sub-link"><i data-lucide="route" class="sb-icon"></i><span class="sb-lbl">↳ Пути</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding:4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="LS.notif.toggle()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</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 style="padding:20px;color:#555;font-size:.8rem">Загрузка…</div></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="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
async function init() {
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>