cc7332c7ce
biochem-properties.html: при сравнении 2+ молекул — столбчатый график молярных масс (canvas, градиентные столбцы с подписями) и кнопка «Экспорт CSV» (UTF-8 BOM, экранирование, скачивание таблицы свойств). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
623 lines
30 KiB
HTML
623 lines
30 KiB
HTML
<!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/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] || 'Пользователь';
|
||
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,
|
||
};
|
||
|
||
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>
|
||
</body>
|
||
</html>
|