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

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

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

824 lines
38 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 { padding: 0 !important; overflow: hidden; display: flex; flex-direction: column; background: #07070f; }
.sb-sub-link { padding-left: 28px !important; font-size: 0.76rem !important; opacity: .75; }
.sb-sub-link:hover { opacity: 1; }
/* ── Page header ── */
.page-header {
padding: 22px 28px 18px;
background: linear-gradient(135deg, rgba(249,115,22,.1) 0%, rgba(155,93,229,.08) 100%);
border-bottom: 1px solid rgba(249,115,22,.15);
flex-shrink: 0;
display: flex; align-items: center; gap: 16px;
}
.page-header-icon {
width: 46px; height: 46px; border-radius: 14px;
background: rgba(249,115,22,.12); border: 1.5px solid rgba(249,115,22,.2);
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; flex-shrink: 0;
}
.page-title {
font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800;
margin-bottom: 3px;
background: linear-gradient(135deg,#fb923c,#a78bfa); -webkit-background-clip:text; -webkit-text-fill-color:transparent;
}
.page-subtitle { font-size: 0.8rem; color: #666; }
/* ── Filters ── */
.filters-row {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
padding: 12px 20px;
background: rgba(7,7,20,0.9);
border-bottom: 1px solid rgba(255,255,255,.06);
flex-shrink: 0;
}
.filter-input {
flex: 1; min-width: 200px; max-width: 280px;
padding: 8px 14px; border-radius: 10px;
background: rgba(255,255,255,.05); border: 1.5px solid rgba(255,255,255,.09);
color: #ddd; font-family: 'Manrope', sans-serif; font-size: 0.83rem;
outline: none; transition: border-color .18s;
}
.filter-input:focus { border-color: rgba(155,93,229,.5); background: rgba(155,93,229,.05); }
.filter-input::placeholder { color: #444; }
.type-chip {
padding: 5px 13px; border-radius: 999px;
border: 1.5px solid rgba(255,255,255,.08);
background: rgba(255,255,255,.03); color: #666;
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700;
cursor: pointer; transition: all .16s; white-space: nowrap;
}
.type-chip:hover { border-color: rgba(155,93,229,.35); color: #ccc; background: rgba(155,93,229,.06); }
.type-chip.active { background: rgba(155,93,229,.18); border-color: rgba(155,93,229,.6); color: #c084fc; }
.filter-count { font-size: 0.76rem; color: #444; margin-left: auto; white-space: nowrap; font-weight: 600; }
/* ── 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: 12px; margin: 5px 0; }
.bc-sk-line.sm { width: 60%; }
.bc-sk-line.md { width: 80%; }
.bc-sk-row { display: flex; gap: 12px; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,.05);
border-radius: 12px; margin-bottom: 10px; border: 1px solid rgba(255,255,255,.06); }
.bc-sk-row .bc-sk-avatar { width: 4px; height: auto; border-radius: 4px; flex-shrink: 0; }
.bc-sk-row .bc-sk-text { flex: 1; }
/* ── Scroll area ── */
.rxn-scroll { flex: 1; overflow-y: auto; padding: 18px 20px 40px; }
.rxn-scroll::-webkit-scrollbar { width: 5px; }
.rxn-scroll::-webkit-scrollbar-thumb { background: rgba(155,93,229,.3); border-radius: 4px; }
/* ── Reaction card ── */
.rxn-card {
border-radius: 16px;
border: 1.5px solid rgba(255,255,255,.07);
background: rgba(255,255,255,.025);
margin-bottom: 12px;
transition: border-color .2s, box-shadow .2s, background .2s;
overflow: hidden;
animation: cardIn .4s ease both;
animation-delay: calc(var(--i,0) * 35ms);
}
@keyframes cardIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:none; } }
.rxn-card:hover {
border-color: rgba(155,93,229,.28);
background: rgba(155,93,229,.03);
box-shadow: 0 4px 20px rgba(0,0,0,.25);
}
.rxn-card.expanded {
border-color: rgba(155,93,229,.35);
background: rgba(155,93,229,.05);
}
/* Left-side type stripe */
.rxn-card-inner { display: flex; }
.rxn-stripe {
width: 4px; flex-shrink: 0;
border-radius: 16px 0 0 16px;
}
.rxn-stripe.synthesis { background: linear-gradient(180deg,#60a5fa,#3b82f6); }
.rxn-stripe.decomposition { background: linear-gradient(180deg,#fbbf24,#f59e0b); }
.rxn-stripe.exchange { background: linear-gradient(180deg,#34d399,#10b981); }
.rxn-stripe.redox { background: linear-gradient(180deg,#f97316,#ea580c); }
.rxn-stripe.acid_base { background: linear-gradient(180deg,#a78bfa,#7c3aed); }
.rxn-stripe.combustion { background: linear-gradient(180deg,#ef4444,#dc2626); }
.rxn-stripe.hydrolysis { background: linear-gradient(180deg,#06D6E0,#0891b2); }
.rxn-stripe.other { background: rgba(148,163,184,.4); }
.rxn-stripe.neutral { background: rgba(255,255,255,.08); }
.rxn-body { flex: 1; padding: 16px 18px 14px; min-width: 0; }
/* Card header */
.rxn-header { display: flex; align-items: flex-start; gap: 12px; cursor: pointer; }
.rxn-icon {
width: 40px; height: 40px; border-radius: 11px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
}
.rxn-header-text { flex: 1; min-width: 0; }
.rxn-name {
font-family: 'Manrope', sans-serif; font-size: 0.94rem; font-weight: 700;
color: #e8e8f8; margin-bottom: 5px; line-height: 1.3;
}
.rxn-badges { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.rxn-type-badge {
font-size: 0.66rem; font-weight: 700; padding: 2px 9px; border-radius: 999px;
letter-spacing: .04em; text-transform: uppercase;
}
.rxn-type-badge.synthesis { background: rgba(96,165,250,.15); color:#93c5fd; }
.rxn-type-badge.decomposition { background: rgba(251,191,36,.15); color:#fde68a; }
.rxn-type-badge.exchange { background: rgba(52,211,153,.15); color:#6ee7b7; }
.rxn-type-badge.redox { background: rgba(249,115,22,.15); color:#fdba74; }
.rxn-type-badge.acid_base { background: rgba(167,139,250,.15); color:#c4b5fd; }
.rxn-type-badge.combustion { background: rgba(239,68,68,.15); color:#fca5a5; }
.rxn-type-badge.hydrolysis { background: rgba(6,214,224,.15); color:#67e8f9; }
.rxn-type-badge.other { background: rgba(148,163,184,.12); color:#94a3b8; }
.energy-badge {
font-size: 0.66rem; font-weight: 700; padding: 2px 9px; border-radius: 999px;
letter-spacing: .02em;
}
.energy-badge.exo { background: rgba(239,68,68,.12); color:#fca5a5; border:1px solid rgba(239,68,68,.2); }
.energy-badge.endo { background: rgba(96,165,250,.12); color:#93c5fd; border:1px solid rgba(96,165,250,.2); }
.rxn-expand-btn {
background: none; border: none; color: #555; cursor: pointer;
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
transition: color .15s, background .15s; flex-shrink: 0;
font-size: 16px; align-self: center;
}
.rxn-expand-btn:hover { color: #aaa; background: rgba(255,255,255,.07); }
.rxn-card.expanded .rxn-expand-btn { color: #a78bfa; }
/* ── Equation ── */
.rxn-equation {
margin: 12px 0 0;
padding: 11px 16px;
background: rgba(0,0,0,.35);
border-radius: 11px;
border: 1px solid rgba(255,255,255,.06);
font-family: 'Unbounded', monospace; font-size: 0.82rem;
color: #ccc; text-align: center;
overflow-x: auto; white-space: nowrap;
line-height: 1.6;
}
.rxn-equation .eq-mol {
color: #06D6E0; cursor: pointer; transition: color .15s;
display: inline-block;
}
.rxn-equation .eq-mol:hover { color: #fff; text-decoration: underline; }
.rxn-equation .eq-arrow {
color: #9B5DE5; margin: 0 10px; font-size: 1rem;
}
.rxn-equation .eq-plus { color: #555; margin: 0 6px; }
.rxn-equation.empty { color: #444; font-family: 'Manrope',sans-serif; font-size: 0.78rem; font-style: italic; }
/* ── Expanded details ── */
.rxn-details { display: none; margin-top: 14px; }
.rxn-card.expanded .rxn-details { display: block; }
.rxn-description {
font-size: 0.81rem; color: #999; line-height: 1.65;
margin-bottom: 14px; padding: 12px 14px;
background: rgba(255,255,255,.03); border-radius: 10px;
border: 1px solid rgba(255,255,255,.05);
}
.rxn-details-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
margin-bottom: 14px;
}
.rxn-detail-item { background: rgba(255,255,255,.03); border-radius: 10px; padding: 10px 12px; border: 1px solid rgba(255,255,255,.05); }
.rxn-detail-label { font-size: 0.63rem; color: #555; font-weight: 700; letter-spacing:.07em; text-transform:uppercase; margin-bottom:4px; }
.rxn-detail-value { font-size: 0.82rem; color: #ccc; font-weight: 600; }
/* Molecule row */
.rxn-mols-section { }
.rxn-mols-label { font-size: 0.65rem; color: #555; font-weight: 700; letter-spacing:.07em; text-transform:uppercase; margin-bottom: 10px; }
.rxn-mols-row {
display: flex; gap: 8px; align-items: flex-end;
flex-wrap: wrap;
}
.rxn-mol-thumb { display: flex; flex-direction: column; align-items: center; gap: 5px; }
.rxn-mol-canvas-wrap {
width: 88px; height: 78px; border-radius: 12px; overflow: hidden;
background: radial-gradient(ellipse at 40% 35%, rgba(155,93,229,.06) 0%, rgba(5,5,15,.95) 70%);
border: 1.5px solid rgba(255,255,255,.08);
position: relative; cursor: pointer;
transition: border-color .16s, transform .16s;
}
.rxn-mol-canvas-wrap:hover { border-color: rgba(6,214,224,.4); transform: translateY(-2px); }
.rxn-mol-canvas-wrap canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.rxn-mol-formula { font-family: 'Unbounded', monospace; font-size: 0.67rem; color: #06D6E0; text-align: center; font-weight: 700; }
.rxn-mol-name { font-size: 0.62rem; color: #666; text-align: center; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rxn-mol-open {
font-size: 0.64rem; color: #7c3aed; text-decoration: none;
padding: 1px 6px; border-radius: 4px;
transition: color .15s; font-weight: 600;
}
.rxn-mol-open:hover { color: #c084fc; }
.rxn-mol-arrow {
font-size: 1.4rem; color: rgba(155,93,229,.7); flex-shrink: 0;
align-self: center; padding-bottom: 22px; line-height: 1;
}
.rxn-mols-empty { font-size: 0.78rem; color: #444; padding: 10px 0; font-style: italic; }
/* Empty state */
.rxn-empty {
display: flex; flex-direction: column; align-items: center;
padding: 80px 20px; gap: 14px; color: #444;
}
.rxn-empty svg { opacity: .14; }
.rxn-empty p { font-size: 0.86rem; font-weight: 600; }
/* ── Topic chips row ── */
.topics-row {
display: flex; gap: 5px; align-items: center; flex-wrap: wrap;
padding: 7px 20px 8px;
background: rgba(5,5,14,.85);
border-bottom: 1px solid rgba(255,255,255,.04);
flex-shrink: 0;
}
.topic-lbl { font-size: 0.63rem; color: #444; font-weight: 700; letter-spacing:.06em; text-transform:uppercase; white-space:nowrap; }
.topic-chip {
padding: 2px 9px; border-radius: 999px;
border: 1px solid rgba(255,255,255,.07);
background: rgba(255,255,255,.02); color: #555;
font-size: 0.67rem; font-weight: 700;
cursor: pointer; transition: all .15s; white-space:nowrap;
}
.topic-chip:hover { border-color: rgba(6,214,224,.3); color: #aaa; }
.topic-chip.active { background: rgba(6,214,224,.1); border-color: rgba(6,214,224,.4); color: #06D6E0; }
/* ── Mini quiz ── */
.mini-quiz {
margin-top: 14px; padding: 13px 14px;
background: rgba(155,93,229,.06); border-radius: 12px;
border: 1px solid rgba(155,93,229,.15);
}
.mq-head { font-size: 0.64rem; font-weight: 700; color: #7c3aed; letter-spacing:.06em; text-transform:uppercase; margin-bottom: 7px; }
.mq-q { font-size: 0.8rem; color: #ddd; font-weight: 600; margin-bottom: 9px; line-height: 1.4; }
.mq-opts { display: flex; flex-direction: column; gap: 4px; }
.mq-btn {
padding: 6px 11px; border-radius: 7px; border: 1.5px solid rgba(255,255,255,.1);
background: rgba(255,255,255,.03); color: #ccc; font-family: 'Manrope', sans-serif;
font-size: 0.76rem; cursor: pointer; transition: all .15s; text-align: left;
}
.mq-btn:hover:not(:disabled) { border-color: rgba(155,93,229,.4); background: rgba(155,93,229,.1); }
.mq-btn.correct { border-color: #4ade80 !important; background: rgba(74,222,128,.1) !important; color: #4ade80 !important; }
.mq-btn.wrong { border-color: #ef4444 !important; background: rgba(239,68,68,.1) !important; color: #ef4444 !important; }
.mq-again { font-size: 0.68rem; color: #555; cursor: pointer; margin-top: 6px; display: inline-block; transition: color .15s; }
.mq-again:hover { color: #9B5DE5; }
@media (max-width: 768px) {
html, body { height: auto; overflow: auto; }
.app-layout { height: auto; overflow: visible; }
.sb-content { overflow: visible !important; height: auto !important; }
.rxn-scroll { padding: 12px 12px 40px; overflow: visible; }
.filters-row, .page-header, .topics-row { padding-left: 12px; padding-right: 12px; }
.filters-row { flex-wrap: wrap; }
.filter-input { min-width: 0; max-width: none; flex: 1 1 100%; }
.rxn-details-grid { grid-template-columns: 1fr; }
.page-header { padding: 14px 12px; gap: 10px; }
.page-header-icon { width: 36px; height: 36px; font-size: 1.1rem; border-radius: 10px; }
.page-title { font-size: 0.88rem; }
.type-chip { font-size: 0.72rem; padding: 4px 10px; }
.topics-row { gap: 6px; overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
.topics-row::-webkit-scrollbar { display: none; }
.topic-chip { flex-shrink: 0; }
.rxn-card { padding: 14px; border-radius: 12px; }
}
@media (max-width: 480px) {
.page-header { padding: 10px; }
.rxn-card { padding: 10px; }
}
</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">
<!-- Header -->
<div class="page-header">
<div class="page-header-icon"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
<div>
<div class="page-title">Химические реакции</div>
<div class="page-subtitle" id="subtitle">Загрузка…</div>
</div>
</div>
<!-- Filters -->
<div class="filters-row">
<input type="search" class="filter-input" id="search-q" placeholder="Поиск по названию или формуле..." oninput="applyFilters()" />
<button class="type-chip active" data-type="" onclick="setType('')">Все</button>
<button class="type-chip" data-type="synthesis" onclick="setType('synthesis')">Синтез</button>
<button class="type-chip" data-type="decomposition" onclick="setType('decomposition')">Разложение</button>
<button class="type-chip" data-type="exchange" onclick="setType('exchange')">Обмен</button>
<button class="type-chip" data-type="redox" onclick="setType('redox')">ОВР</button>
<button class="type-chip" data-type="combustion" onclick="setType('combustion')">Горение</button>
<span class="filter-count" id="filter-count"></span>
</div>
<!-- Topic filter row (populated by JS) -->
<div class="topics-row" id="topics-row" style="display:none">
<span class="topic-lbl">Тема:</span>
<span id="topics-chips"></span>
</div>
<!-- Content -->
<div class="rxn-scroll" id="rxn-scroll">
<div id="rxn-list"></div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
'use strict';
const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) location.href = '/login';
const nav = document.getElementById('nav-user');
const ava = document.getElementById('nav-avatar');
if (nav) nav.textContent = user?.name?.split(' ')[0] || 'Пользователь';
if (ava) ava.textContent = (user?.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
if (isAdmin) document.getElementById('btn-admin').style.display = '';
if (isTeacher) document.getElementById('btn-classes').style.display = '';
LS.showBoardIfAllowed();
// ── CPK colours ──
const ELEM_COLORS = {
H: { color:'#D4D4D4', text:'#222', r:9 },
C: { color:'#555555', text:'#fff', r:10 },
N: { color:'#4060FF', text:'#fff', r:10 },
O: { color:'#EE2020', text:'#fff', r:10 },
P: { color:'#FF8000', text:'#fff', r:11 },
S: { color:'#C8B400', text:'#000', r:11 },
Cl: { color:'#00A860', text:'#fff', r:11 },
Na: { color:'#8040C0', text:'#fff', r:11 },
Ca: { color:'#707070', text:'#fff', r:11 },
Mg: { color:'#1E8A1E', text:'#fff', r:11 },
Fe: { color:'#B03010', text:'#fff', r:11 },
Br: { color:'#8B4513', text:'#fff', r:11 },
I: { color:'#580DAB', text:'#fff', r:11 },
F: { color:'#B0FFB0', text:'#222', r:9 },
};
function hexToRgb(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 renderMolThumb(canvas, atoms, bonds) {
const W = canvas.width, H = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
if (!atoms || !atoms.length) {
ctx.fillStyle = '#4b5563';
ctx.beginPath();
ctx.arc(W/2, H/2, Math.min(W,H)*0.18, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#9ca3af';
ctx.font = `bold ${Math.floor(Math.min(W,H)*0.22)}px 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) {
const x=a.x??0, y=a.y??0;
minX=Math.min(minX,x); minY=Math.min(minY,y);
maxX=Math.max(maxX,x); maxY=Math.max(maxY,y);
}
const pad=Math.min(W,H)*0.15;
const rangeX=maxX-minX||1, rangeY=maxY-minY||1;
const scale=Math.min((W-pad*2)/rangeX,(H-pad*2)/rangeY);
const offX=(W-rangeX*scale)/2-minX*scale;
const offY=(H-rangeY*scale)/2-minY*scale;
const sx=ax=>ax*scale+offX, sy=ay=>ay*scale+offY;
for (const b of bonds) {
const f=b.f??b.from, t=b.t??b.to, order=b.o??b.order??1;
const af=atoms.find(a=>a.id===f), at_=atoms.find(a=>a.id===t);
if (!af||!at_) continue;
const x1=sx(af.x??0), y1=sy(af.y??0), x2=sx(at_.x??0), y2=sy(at_.y??0);
ctx.strokeStyle='rgba(180,180,200,0.6)';
ctx.lineWidth=Math.max(1,scale*0.75); ctx.lineCap='round';
if (order===1) { ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); }
else {
const dx=x2-x1, dy=y2-y1, len=Math.hypot(dx,dy)||1;
const nx=-dy/len, ny=dx/len, gap=Math.max(1.5,scale*0.85);
const offsets=order===2?[-gap/2,gap/2]:[-gap,0,gap];
for (const o of offsets) { ctx.beginPath(); ctx.moveTo(x1+nx*o,y1+ny*o); ctx.lineTo(x2+nx*o,y2+ny*o); ctx.stroke(); }
}
}
const minR=Math.max(5,Math.min(W,H)*0.065);
for (const a of atoms) {
const x=sx(a.x??0), y=sy(a.y??0);
const el=ELEM_COLORS[a.s]||{color:'#888',text:'#fff',r:10};
const r=Math.max(minR*0.6,(el.r/20)*minR);
const [hr,hg,hb]=hexToRgb(el.color);
const grd=ctx.createRadialGradient(x-r*0.3,y-r*0.35,r*0.05,x,y,r);
grd.addColorStop(0,`rgb(${Math.min(255,hr+90)},${Math.min(255,hg+90)},${Math.min(255,hb+90)})`);
grd.addColorStop(0.5,el.color);
grd.addColorStop(1,`rgb(${Math.round(hr*.3)},${Math.round(hg*.3)},${Math.round(hb*.3)})`);
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2);
ctx.fillStyle=grd; ctx.fill();
const fs=Math.max(5,r*(a.s.length>1?0.7:0.9));
ctx.fillStyle=el.text;
ctx.font=`700 ${fs}px Manrope,sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(a.s,x,y);
}
}
// ── Type metadata ──
const TYPE_META = {
synthesis: { label:'Синтез', icon:'<svg class="ic" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>', iconBg:'rgba(96,165,250,.15)' },
decomposition: { label:'Разложение', icon:'<svg class="ic" viewBox="0 0 24 24"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/></svg>', iconBg:'rgba(251,191,36,.15)' },
exchange: { label:'Обмен', icon:'<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>', iconBg:'rgba(52,211,153,.15)' },
redox: { label:'ОВР', icon:'<svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>', iconBg:'rgba(249,115,22,.15)' },
acid_base: { label:'Кислота-осн.', icon:'<svg class="ic" viewBox="0 0 24 24"><path d="M14.5 2v17.5c0 1.4-1.1 2.5-2.5 2.5s-2.5-1.1-2.5-2.5V2"/><path d="M8.5 2h7"/><path d="M14.5 16h-5"/></svg>', iconBg:'rgba(167,139,250,.15)' },
combustion: { label:'Горение', icon:'<svg class="ic" viewBox="0 0 24 24"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 3z"/></svg>', iconBg:'rgba(239,68,68,.15)' },
hydrolysis: { label:'Гидролиз', icon:'<svg class="ic" viewBox="0 0 24 24"><path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"/></svg>', iconBg:'rgba(6,214,224,.15)' },
other: { label:'Другое', 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>', iconBg:'rgba(148,163,184,.12)' },
};
// ── State ──
let allRxns = [];
let molCache = {};
let filterType = '';
let filterTopic = '';
function bcSkReactions(n = 6) {
return Array.from({length: n}, () => `
<div class="bc-sk-row">
<div class="bc-sk bc-sk-avatar" style="width:4px;border-radius:4px"></div>
<div class="bc-sk-text">
<div class="bc-sk bc-sk-line md"></div>
<div class="bc-sk bc-sk-line sm"></div>
<div class="bc-sk bc-sk-line" style="width:70%;height:10px;margin-top:8px;opacity:.6"></div>
</div>
</div>`).join('');
}
async function init() {
document.getElementById('rxn-list').innerHTML = bcSkReactions(6);
try {
allRxns = await LS.biochemGetReactions();
document.getElementById('subtitle').textContent = `${allRxns.length} реакций в базе`;
buildTopicChips();
applyFilters();
} catch(e) {
document.getElementById('rxn-list').innerHTML = `<div class="rxn-empty"><p>Ошибка загрузки: ${e.message}</p></div>`;
}
}
function buildTopicChips() {
const allTopics = new Set();
allRxns.forEach(r => (r.topic_tags||[]).forEach(t => allTopics.add(t)));
if (!allTopics.size) return;
const row = document.getElementById('topics-row');
const chips = document.getElementById('topics-chips');
row.style.display = '';
chips.innerHTML = Array.from(allTopics).map(t =>
`<button class="topic-chip" data-topic="${t}" onclick="setTopic(this,'${t.replace(/'/g,"\\'")}')">
${t}
</button>`
).join('');
}
function setTopic(btn, topic) {
if (filterTopic === topic) {
filterTopic = '';
document.querySelectorAll('.topic-chip').forEach(c => c.classList.remove('active'));
} else {
filterTopic = topic;
document.querySelectorAll('.topic-chip').forEach(c => c.classList.toggle('active', c.dataset.topic === topic));
}
applyFilters();
}
function setType(t) {
filterType = t;
document.querySelectorAll('.type-chip').forEach(c => c.classList.toggle('active', c.dataset.type === t));
applyFilters();
}
function applyFilters() {
const q = document.getElementById('search-q').value.toLowerCase().trim();
const filtered = allRxns.filter(r => {
const rxnType = r.type || r.reaction_type || '';
if (filterType && rxnType !== filterType) return false;
if (filterTopic && !(r.topic_tags||[]).includes(filterTopic)) return false;
if (q) {
const nameMatch = (r.name_ru||'').toLowerCase().includes(q);
// also search by molecule formula/name from cache
const allIds = [...new Set([...(r.reactant_ids||[]), ...(r.product_ids||[])])];
const molMatch = allIds.some(id => {
const m = molCache[id];
if (!m) return false;
return (m.formula||'').toLowerCase().includes(q) || (m.name_ru||'').toLowerCase().includes(q);
});
if (!nameMatch && !molMatch) return false;
}
return true;
});
document.getElementById('filter-count').textContent = `${filtered.length} реакций`;
renderList(filtered);
}
function buildEquation(r, molMap) {
const hasReactants = (r.reactant_ids||[]).length > 0;
const hasProducts = (r.product_ids||[]).length > 0;
if (!hasReactants && !hasProducts) {
return `<div class="rxn-equation empty">Уравнение не указано</div>`;
}
const fmt = ids => (ids||[]).map(id => {
const m = molMap[id];
return m
? `<span class="eq-mol" onclick="location.href='/biochem?mol=${id}'" title="${m.name_ru||''}">${m.formula||'?'}</span>`
: `<span style="color:#555">#${id}</span>`;
}).join('<span class="eq-plus"> + </span>');
const reactants = fmt(r.reactant_ids||[]);
const products = fmt(r.product_ids||[]);
const cond = r.conditions
? `<div style="font-size:0.7rem;color:#666;margin-top:5px;font-family:'Manrope',sans-serif">${r.conditions}</div>`
: '';
return `<div class="rxn-equation">${reactants}<span class="eq-arrow"><svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></span>${products}</div>${cond}`;
}
function renderList(rxns) {
const list = document.getElementById('rxn-list');
if (!rxns.length) {
list.innerHTML = `<div class="rxn-empty">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
<p>Реакций не найдено</p></div>`;
return;
}
list.innerHTML = '';
rxns.forEach((r, i) => {
const rxnType = r.type || r.reaction_type || 'other';
const meta = TYPE_META[rxnType] || TYPE_META.other;
const energyNum = parseFloat(r.energy_kj);
const energyBadge = !isNaN(energyNum)
? `<span class="energy-badge ${energyNum < 0 ? 'exo':'endo'}">${energyNum < 0 ? '<svg class="ic" viewBox="0 0 24 24"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 3z"/></svg> Экзо':'<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="2" x2="12" y2="22"/><path d="M17 7 12 2 7 7"/><path d="m17 17-5 5-5-5"/><line x1="2" y1="12" x2="22" y2="12"/><path d="m7 7-5 5 5 5"/><path d="m17 7 5 5-5 5"/></svg> Эндо'} ${Math.abs(energyNum)} кДж/моль</span>`
: '';
const card = document.createElement('div');
card.className = 'rxn-card';
card.style.setProperty('--i', i);
card.dataset.id = r.id;
card.innerHTML = `
<div class="rxn-card-inner">
<div class="rxn-stripe ${rxnType}"></div>
<div class="rxn-body">
<div class="rxn-header">
<div class="rxn-icon" style="background:${meta.iconBg}">${meta.icon}</div>
<div class="rxn-header-text">
<div class="rxn-name">${r.name_ru}</div>
<div class="rxn-badges">
<span class="rxn-type-badge ${rxnType}">${meta.label}</span>
${energyBadge}
</div>
</div>
<button class="rxn-expand-btn" title="Развернуть"><svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg></button>
</div>
<div class="rxn-eq-placeholder" id="rxn-eq-${r.id}">
<div class="rxn-equation empty">Загрузка…</div>
</div>
<div class="rxn-details" id="rxn-det-${r.id}">
${r.description ? `<div class="rxn-description">${r.description}</div>` : ''}
${(r.conditions || r.energy_kj != null) ? `
<div class="rxn-details-grid">
${r.conditions ? `<div class="rxn-detail-item"><div class="rxn-detail-label">Условия</div><div class="rxn-detail-value">${r.conditions}</div></div>` : ''}
${r.energy_kj != null ? `<div class="rxn-detail-item"><div class="rxn-detail-label">ΔH (энергия)</div><div class="rxn-detail-value">${r.energy_kj} кДж/моль</div></div>` : ''}
</div>` : ''}
<div class="rxn-mols-section">
<div class="rxn-mols-label">Молекулы реакции</div>
<div class="rxn-mols-row" id="rxn-mols-${r.id}">
<div class="rxn-mols-empty">Загрузка…</div>
</div>
</div>
<div id="rxn-quiz-${r.id}"></div>
</div>
</div>
</div>`;
card.querySelector('.rxn-header').addEventListener('click', () => toggleCard(card, r));
card.querySelector('.rxn-expand-btn').addEventListener('click', e => { e.stopPropagation(); toggleCard(card, r); });
list.appendChild(card);
loadEquation(r);
});
}
async function loadEquation(r) {
const allIds = [...new Set([...(r.reactant_ids||[]), ...(r.product_ids||[])])];
const molMap = await fetchMols(allIds);
const eqEl = document.getElementById(`rxn-eq-${r.id}`);
if (eqEl) eqEl.innerHTML = buildEquation(r, molMap);
}
async function fetchMols(ids) {
const missing = ids.filter(id => !molCache[id]);
await Promise.all(missing.map(async id => {
try { molCache[id] = await LS.biochemGetMolecule(id); }
catch { molCache[id] = { formula:`#${id}`, name_ru:'', atoms_json:[], bonds_json:[] }; }
}));
const map = {};
for (const id of ids) { if (molCache[id]) map[id] = molCache[id]; }
return map;
}
async function toggleCard(card, r) {
const wasExpanded = card.classList.contains('expanded');
card.classList.toggle('expanded', !wasExpanded);
card.querySelector('.rxn-expand-btn').innerHTML = card.classList.contains('expanded') ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="22 15 12 3 2 15"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg>';
if (card.classList.contains('expanded')) await loadMolThumbs(r);
}
async function loadMolThumbs(r) {
const molsRow = document.getElementById(`rxn-mols-${r.id}`);
if (!molsRow || molsRow.dataset.loaded) return;
molsRow.dataset.loaded = '1';
const reactantIds = r.reactant_ids||[];
const productIds = r.product_ids||[];
const allIds = [...new Set([...reactantIds, ...productIds])];
if (!allIds.length) {
molsRow.innerHTML = '<div class="rxn-mols-empty">Состав реакции не указан</div>';
return;
}
const molMap = await fetchMols(allIds);
molsRow.innerHTML = '';
function appendMol(id) {
const m = molMap[id];
if (!m) return;
const wrap = document.createElement('div');
wrap.className = 'rxn-mol-thumb';
wrap.innerHTML = `
<div class="rxn-mol-canvas-wrap" onclick="location.href='/biochem?mol=${id}'" title="Открыть в конструкторе">
<canvas width="88" height="78"></canvas>
</div>
<div class="rxn-mol-formula">${m.formula||'?'}</div>
<div class="rxn-mol-name">${m.name_ru||''}</div>
<a class="rxn-mol-open" href="/biochem?mol=${id}">открыть <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></a>`;
molsRow.appendChild(wrap);
requestAnimationFrame(() => {
const cvs = wrap.querySelector('canvas');
renderMolThumb(cvs, m.atoms_json||[], m.bonds_json||[]);
});
}
reactantIds.forEach(appendMol);
const arrow = document.createElement('div');
arrow.className = 'rxn-mol-arrow';
arrow.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>';
molsRow.appendChild(arrow);
productIds.forEach(appendMol);
// Generate mini-quiz
const quizEl = document.getElementById(`rxn-quiz-${r.id}`);
if (quizEl && !quizEl.dataset.loaded) {
quizEl.dataset.loaded = '1';
quizEl.dataset.rid = r.id;
const quiz = generateQuiz(r, molMap);
if (quiz) renderQuiz(quizEl, quiz);
}
}
// ── Mini-quiz ──
function _shuffle(arr) { return [...arr].sort(() => Math.random() - .5); }
function generateQuiz(r, molMap) {
const rxnType = r.type || r.reaction_type || 'other';
const reactants = (r.reactant_ids||[]).map(id=>molMap[id]).filter(Boolean);
const products = (r.product_ids||[]).map(id=>molMap[id]).filter(Boolean);
const quizTypes = [];
// Q1: reaction type
quizTypes.push(() => {
const correct = TYPE_META[rxnType]?.label || 'Другое';
const others = _shuffle(Object.values(TYPE_META).map(m=>m.label).filter(l=>l!==correct)).slice(0,3);
return { q: 'К какому типу относится эта реакция?', choices: _shuffle([correct,...others]), answer: correct };
});
// Q2: pick product from formula
if (products.length && reactants.length) {
quizTypes.push(() => {
const target = products[Math.floor(Math.random()*products.length)];
const distractors = _shuffle(
Object.values(molCache).filter(m => !(products.find(p=>p.id===m.id)) && !(reactants.find(a=>a.id===m.id)) && m.formula)
).slice(0,3);
const choices = _shuffle([target.formula, ...distractors.map(m=>m.formula)]).filter(Boolean);
if (choices.length < 2) return null;
const reactStr = reactants.map(m=>m.formula).join(' + ');
return { q: `Какое вещество является продуктом реакции?\n${reactStr} <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ?`, choices, answer: target.formula };
});
}
// Q3: pick reactant name
if (reactants.length && products.length) {
quizTypes.push(() => {
const target = reactants[Math.floor(Math.random()*reactants.length)];
const distractors = _shuffle(
Object.values(molCache).filter(m => !(reactants.find(a=>a.id===m.id)) && m.name_ru)
).slice(0,3);
const choices = _shuffle([target.name_ru||target.formula, ...distractors.map(m=>m.name_ru||m.formula)]).filter(Boolean);
if (choices.length < 2) return null;
return { q: 'Какое вещество является реагентом этой реакции?', choices, answer: target.name_ru||target.formula };
});
}
// Pick a random quiz type and generate
const shuffledTypes = _shuffle(quizTypes);
for (const gen of shuffledTypes) {
const q = gen();
if (q && q.choices.length >= 2) return q;
}
return null;
}
function renderQuiz(container, quiz) {
container.innerHTML = `
<div class="mini-quiz">
<div class="mq-head"><svg class="ic" viewBox="0 0 24 24"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 4.44-1.04z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-4.44-1.04z"/></svg> Мини-тест</div>
<div class="mq-q">${quiz.q.replace(/\n/g,'<br>')}</div>
<div class="mq-opts">
${quiz.choices.map(ch =>
`<button class="mq-btn" onclick="answerQuiz(this,'${ch.replace(/'/g,"\\'")}','${quiz.answer.replace(/'/g,"\\'")}',this.parentElement)">${ch}</button>`
).join('')}
</div>
<span class="mq-again" onclick="reloadQuiz(this,${container.dataset.rid})"><svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg> Другой вопрос</span>
</div>`;
}
function answerQuiz(btn, answer, correct, opts) {
opts.querySelectorAll('.mq-btn').forEach(b => b.disabled = true);
btn.classList.add(answer === correct ? 'correct' : 'wrong');
if (answer !== correct) {
opts.querySelectorAll('.mq-btn').forEach(b => {
if (b.textContent.trim() === correct) b.classList.add('correct');
});
}
}
function reloadQuiz(btn, rid) {
const r = allRxns.find(rx => rx.id === rid);
if (!r) return;
const allIds = [...new Set([...(r.reactant_ids||[]), ...(r.product_ids||[])])];
const molMap = {};
for (const id of allIds) if (molCache[id]) molMap[id] = molCache[id];
const quiz = generateQuiz(r, molMap);
const container = btn.closest('[data-rid]');
if (quiz && container) renderQuiz(container, quiz);
}
// ── Boot ──
if (window.lucide) lucide.createIcons();
LS.notif?.init();
LS.hideDisabledFeatures?.();
init();
</script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>