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>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+605
View File
@@ -0,0 +1,605 @@
<!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>