358b761eb2
Проблема: динамическая вставка через JS вызывала мигание (nav появлялся через ~100ms после первого пейнта). Решение: nav — статичный HTML в каждой странице, CSS — в <head>. Активная вкладка проставлена в HTML (class bsn-active) — нет JS, нет мигания, работает с первого байта. Редизайн .biochem-subnav: - frosted glass (backdrop-filter blur 14px, rgba 0.92) - активная вкладка: фиолетовый фон-пилюля + нижняя линия 2.5px - hover: мягкий фиолетовый фон - mobile <560px: только иконки (bsn-label display:none) - overflow-x auto + scrollbar-width:none — горизонтальная прокрутка без полосы - biochem-nav.js сведён к no-op комментарию Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1086 lines
49 KiB
HTML
1086 lines
49 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" />
|
||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||
<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: 18px 28px 14px;
|
||
background: linear-gradient(135deg, rgba(16,185,129,.1) 0%, rgba(155,93,229,.08) 100%);
|
||
border-bottom: 1px solid rgba(16,185,129,.15);
|
||
flex-shrink: 0;
|
||
display: flex; align-items: center; gap: 16px;
|
||
}
|
||
.page-header-icon {
|
||
width: 44px; height: 44px; border-radius: 14px;
|
||
background: rgba(16,185,129,.12); border: 1.5px solid rgba(16,185,129,.25);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 1.35rem; flex-shrink: 0;
|
||
}
|
||
.page-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
|
||
background: linear-gradient(135deg,#34d399,#a78bfa); -webkit-background-clip:text; -webkit-text-fill-color:transparent;
|
||
margin-bottom: 2px;
|
||
}
|
||
.page-subtitle { font-size: 0.78rem; color: #555; }
|
||
.header-right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
||
|
||
/* ── Pathway selector ── */
|
||
.path-selector {
|
||
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
||
padding: 10px 20px;
|
||
background: rgba(7,7,20,.95);
|
||
border-bottom: 1px solid rgba(255,255,255,.06);
|
||
flex-shrink: 0;
|
||
}
|
||
.path-chip {
|
||
padding: 5px 14px; border-radius: 999px;
|
||
border: 1.5px solid rgba(255,255,255,.09);
|
||
background: rgba(255,255,255,.04); color: #666;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
|
||
cursor: pointer; transition: all .18s; white-space: nowrap;
|
||
display: flex; align-items: center; gap: 5px;
|
||
}
|
||
.path-chip:hover { border-color: rgba(52,211,153,.35); color: #ccc; background: rgba(52,211,153,.07); }
|
||
.path-chip.active { background: rgba(52,211,153,.16); border-color: rgba(52,211,153,.55); color: #34d399; }
|
||
.path-chip .chip-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
||
.path-chip[data-path="glycolysis"].active { color: #f59e0b; border-color: rgba(245,158,11,.5); background: rgba(245,158,11,.12); }
|
||
.path-chip[data-path="glycolysis"] .chip-dot { background: #f59e0b; }
|
||
.path-chip[data-path="krebs"].active { color: #06b6d4; border-color: rgba(6,182,212,.5); background: rgba(6,182,212,.12); }
|
||
.path-chip[data-path="krebs"] .chip-dot { background: #06b6d4; }
|
||
.path-chip[data-path="oxidation"].active { color: #fb923c; border-color: rgba(251,146,60,.5); background: rgba(251,146,60,.12); }
|
||
.path-chip[data-path="oxidation"] .chip-dot { background: #fb923c; }
|
||
.path-chip[data-path="synthesis"].active { color: #a78bfa; border-color: rgba(167,139,250,.5); background: rgba(167,139,250,.12); }
|
||
.path-chip[data-path="synthesis"] .chip-dot { background: #a78bfa; }
|
||
.sel-divider { width: 1px; height: 20px; background: rgba(255,255,255,.07); margin: 0 4px; }
|
||
.learn-btn {
|
||
margin-left: auto; padding: 5px 16px; border-radius: 8px;
|
||
background: linear-gradient(135deg,#34d399,#059669); border: none;
|
||
color: #fff; font-family:'Manrope',sans-serif; font-size:.8rem; font-weight:700;
|
||
cursor: pointer; transition: opacity .18s; display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.learn-btn:hover { opacity: .85; }
|
||
.anim-btn {
|
||
padding: 5px 14px; border-radius: 8px;
|
||
background: rgba(255,255,255,.05); border: 1.5px solid rgba(255,255,255,.1);
|
||
color: #aaa; font-family:'Manrope',sans-serif; font-size:.78rem; font-weight:600;
|
||
cursor: pointer; transition: all .18s; display: flex; align-items: center; gap: 5px;
|
||
}
|
||
.anim-btn.on { background: rgba(52,211,153,.12); border-color: rgba(52,211,153,.4); color: #34d399; }
|
||
.anim-btn:hover { border-color: rgba(255,255,255,.2); color: #ccc; }
|
||
|
||
/* ── Main layout ── */
|
||
.paths-main {
|
||
flex: 1; display: flex; overflow: hidden; min-height: 0;
|
||
}
|
||
|
||
/* ── SVG Canvas area ── */
|
||
.svg-area {
|
||
flex: 1; overflow: hidden; position: relative;
|
||
background: radial-gradient(ellipse at 40% 40%, rgba(16,185,129,.04) 0%, transparent 60%),
|
||
radial-gradient(ellipse at 70% 70%, rgba(155,93,229,.04) 0%, transparent 60%),
|
||
#07070f;
|
||
cursor: grab;
|
||
}
|
||
.svg-area:active { cursor: grabbing; }
|
||
#pathway-svg {
|
||
width: 100%; height: 100%;
|
||
overflow: visible;
|
||
}
|
||
|
||
/* SVG node styles */
|
||
.mol-node { cursor: pointer; transition: filter .18s; }
|
||
.mol-node:hover .node-circle { filter: brightness(1.4); }
|
||
.mol-node .node-circle { transition: r .15s; }
|
||
.mol-node.active .node-circle { filter: drop-shadow(0 0 8px currentColor); }
|
||
.node-label {
|
||
font-family: 'Manrope', sans-serif; font-size: 11px; font-weight: 700;
|
||
fill: #e0e0f0; pointer-events: none; dominant-baseline: central; text-anchor: middle;
|
||
}
|
||
.node-formula {
|
||
font-family: 'Manrope', sans-serif; font-size: 9.5px;
|
||
fill: #777; pointer-events: none; dominant-baseline: central; text-anchor: middle;
|
||
}
|
||
.edge-arrow { marker-end: url(#arrowhead); }
|
||
.edge-label {
|
||
font-family: 'Manrope', sans-serif; font-size: 9px;
|
||
fill: #555; dominant-baseline: central; text-anchor: middle;
|
||
pointer-events: none;
|
||
}
|
||
.edge-enzyme {
|
||
font-family: 'Manrope', sans-serif; font-size: 8.5px; font-style: italic;
|
||
fill: #494; pointer-events: none; dominant-baseline: central; text-anchor: middle;
|
||
}
|
||
/* Particle animation */
|
||
.flow-particle { pointer-events: none; }
|
||
@keyframes particleAppear { from { opacity:0; transform:scale(0); } to { opacity:1; transform:scale(1); } }
|
||
|
||
/* ── Side panel ── */
|
||
.side-panel {
|
||
width: 320px; flex-shrink: 0;
|
||
border-left: 1px solid rgba(255,255,255,.07);
|
||
background: rgba(8,8,20,.9);
|
||
display: flex; flex-direction: column; overflow: hidden;
|
||
transition: width .25s;
|
||
}
|
||
.side-panel.hidden { width: 0; overflow: hidden; }
|
||
|
||
.panel-tabs {
|
||
display: flex; border-bottom: 1px solid rgba(255,255,255,.07); flex-shrink: 0;
|
||
}
|
||
.ptab {
|
||
flex: 1; padding: 10px 8px; text-align: center;
|
||
font-family: 'Manrope', sans-serif; font-size: .75rem; font-weight: 700;
|
||
color: #555; cursor: pointer; border-bottom: 2px solid transparent;
|
||
transition: all .18s; white-space: nowrap;
|
||
}
|
||
.ptab.active { color: #34d399; border-bottom-color: #34d399; }
|
||
.ptab:hover { color: #aaa; }
|
||
|
||
.panel-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||
.panel-body::-webkit-scrollbar { width: 4px; }
|
||
.panel-body::-webkit-scrollbar-thumb { background: rgba(52,211,153,.25); border-radius: 4px; }
|
||
|
||
/* Mol popup card */
|
||
.mol-card { display: none; }
|
||
.mol-card.show { display: block; }
|
||
.mol-card-name {
|
||
font-family: 'Unbounded', sans-serif; font-size: .9rem; font-weight: 800;
|
||
color: #e0e0f0; margin-bottom: 3px;
|
||
}
|
||
.mol-card-formula {
|
||
font-size: .8rem; color: #888; margin-bottom: 12px; font-family: 'Manrope', sans-serif;
|
||
}
|
||
.mol-card-desc {
|
||
font-size: .8rem; color: #aaa; line-height: 1.55; font-family: 'Manrope', sans-serif;
|
||
margin-bottom: 12px;
|
||
}
|
||
.mol-card-props { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||
.mol-prop { padding: 3px 10px; border-radius: 6px; font-size: .72rem; font-weight: 600;
|
||
font-family: 'Manrope',sans-serif; }
|
||
.mol-prop.atp { background: rgba(245,158,11,.15); color: #f59e0b; border: 1px solid rgba(245,158,11,.25); }
|
||
.mol-prop.nadh { background: rgba(6,182,212,.15); color: #06b6d4; border: 1px solid rgba(6,182,212,.25); }
|
||
.mol-prop.co2 { background: rgba(239,68,68,.12); color: #f87171; border: 1px solid rgba(239,68,68,.2); }
|
||
.mol-prop.key { background: rgba(52,211,153,.12); color: #34d399; border: 1px solid rgba(52,211,153,.2); }
|
||
.mol-card-btn {
|
||
display: block; width: 100%; padding: 7px; border-radius: 9px; text-align: center;
|
||
background: rgba(52,211,153,.1); border: 1.5px solid rgba(52,211,153,.25);
|
||
color: #34d399; font-family:'Manrope',sans-serif; font-size:.78rem; font-weight:700;
|
||
cursor: pointer; text-decoration: none; transition: all .18s; margin-top: 4px;
|
||
}
|
||
.mol-card-btn:hover { background: rgba(52,211,153,.2); }
|
||
.mol-empty {
|
||
text-align: center; padding: 40px 20px;
|
||
color: #444; font-family:'Manrope',sans-serif; font-size:.83rem;
|
||
}
|
||
.mol-empty .hint-icon { font-size: 2.2rem; margin-bottom: 8px; }
|
||
|
||
/* ── Learn mode ── */
|
||
.learn-panel { display: none; }
|
||
.learn-panel.active { display: block; }
|
||
.learn-progress-bar {
|
||
height: 4px; border-radius: 2px; background: rgba(255,255,255,.07); margin-bottom: 14px;
|
||
}
|
||
.learn-progress-fill {
|
||
height: 100%; border-radius: 2px;
|
||
background: linear-gradient(90deg,#34d399,#a78bfa);
|
||
transition: width .4s;
|
||
}
|
||
.step-counter {
|
||
font-family:'Manrope',sans-serif; font-size:.72rem; color:#555; margin-bottom:10px;
|
||
}
|
||
.step-num {
|
||
display: inline-flex; align-items:center; justify-content:center;
|
||
width:28px; height:28px; border-radius:50%;
|
||
background: linear-gradient(135deg,#34d399,#059669);
|
||
color:#fff; font-family:'Unbounded',sans-serif; font-size:.7rem; font-weight:800;
|
||
margin-right: 8px; flex-shrink:0;
|
||
}
|
||
.step-title-row { display:flex; align-items:center; margin-bottom:10px; }
|
||
.step-title {
|
||
font-family:'Unbounded',sans-serif; font-size:.82rem; font-weight:800; color:#e0e0f0;
|
||
}
|
||
.step-desc {
|
||
font-family:'Manrope',sans-serif; font-size:.8rem; color:#aaa; line-height:1.6;
|
||
margin-bottom: 12px;
|
||
}
|
||
.step-energy {
|
||
display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;
|
||
}
|
||
.energy-badge {
|
||
padding: 3px 10px; border-radius:6px; font-family:'Manrope',sans-serif;
|
||
font-size:.72rem; font-weight:700;
|
||
}
|
||
.energy-badge.atp-prod { background:rgba(245,158,11,.15); color:#f59e0b; }
|
||
.energy-badge.atp-used { background:rgba(239,68,68,.12); color:#f87171; }
|
||
.energy-badge.nadh { background:rgba(6,182,212,.12); color:#06b6d4; }
|
||
.energy-badge.fadh2 { background:rgba(167,139,250,.12);color:#a78bfa; }
|
||
.energy-badge.co2 { background:rgba(74,222,128,.1); color:#4ade80; }
|
||
|
||
/* Mini-quiz */
|
||
.mini-quiz { margin-top:12px; }
|
||
.quiz-q {
|
||
font-family:'Manrope',sans-serif; font-size:.8rem; font-weight:700; color:#ddd;
|
||
margin-bottom:8px;
|
||
}
|
||
.quiz-opts { display:flex; flex-direction:column; gap:5px; }
|
||
.quiz-opt {
|
||
padding:7px 12px; border-radius:8px;
|
||
border:1.5px solid rgba(255,255,255,.08); background:rgba(255,255,255,.03);
|
||
color:#aaa; font-family:'Manrope',sans-serif; font-size:.78rem;
|
||
cursor:pointer; transition:all .16s; text-align:left;
|
||
}
|
||
.quiz-opt:hover { border-color:rgba(52,211,153,.35); color:#ccc; background:rgba(52,211,153,.06); }
|
||
.quiz-opt.correct { border-color:rgba(52,211,153,.6) !important; background:rgba(52,211,153,.15) !important; color:#34d399 !important; }
|
||
.quiz-opt.wrong { border-color:rgba(239,68,68,.5) !important; background:rgba(239,68,68,.1) !important; color:#f87171 !important; }
|
||
.quiz-feedback {
|
||
margin-top:8px; font-family:'Manrope',sans-serif; font-size:.78rem;
|
||
padding:7px 12px; border-radius:8px;
|
||
}
|
||
.quiz-feedback.ok { background:rgba(52,211,153,.1); color:#34d399; }
|
||
.quiz-feedback.err { background:rgba(239,68,68,.08); color:#f87171; }
|
||
.step-nav { display:flex; gap:8px; margin-top:14px; }
|
||
.step-btn {
|
||
flex:1; padding:8px; border-radius:9px;
|
||
font-family:'Manrope',sans-serif; font-size:.78rem; font-weight:700;
|
||
cursor:pointer; transition:all .18s; border:none;
|
||
}
|
||
.step-btn.prev { background:rgba(255,255,255,.06); color:#888; }
|
||
.step-btn.prev:hover { background:rgba(255,255,255,.1); color:#ccc; }
|
||
.step-btn.next { background:linear-gradient(135deg,#34d399,#059669); color:#fff; }
|
||
.step-btn.next:hover { opacity:.88; }
|
||
.step-btn:disabled { opacity:.3; cursor:default; }
|
||
.learn-complete {
|
||
text-align:center; padding:20px 10px;
|
||
}
|
||
.complete-icon { font-size:2.5rem; margin-bottom:10px; }
|
||
.complete-title {
|
||
font-family:'Unbounded',sans-serif; font-size:.9rem; font-weight:800; color:#34d399;
|
||
margin-bottom:6px;
|
||
}
|
||
.complete-text { font-family:'Manrope',sans-serif; font-size:.78rem; color:#888; }
|
||
|
||
/* ── Path legend ── */
|
||
.path-legend {
|
||
padding: 12px 16px;
|
||
border-top: 1px solid rgba(255,255,255,.06);
|
||
flex-shrink: 0;
|
||
}
|
||
.legend-title {
|
||
font-family:'Manrope',sans-serif; font-size:.7rem; font-weight:700; color:#444;
|
||
text-transform:uppercase; letter-spacing:.06em; margin-bottom:6px;
|
||
}
|
||
.legend-items { display:flex; flex-wrap:wrap; gap:6px; }
|
||
.legend-item {
|
||
display:flex; align-items:center; gap:4px;
|
||
font-family:'Manrope',sans-serif; font-size:.72rem; color:#666;
|
||
}
|
||
.legend-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||
.legend-line { width:18px; height:2px; flex-shrink:0; border-radius:1px; }
|
||
|
||
/* zoom controls */
|
||
.zoom-controls {
|
||
position: absolute; bottom: 16px; right: 16px;
|
||
display: flex; gap: 6px;
|
||
}
|
||
.zoom-btn {
|
||
width: 34px; height: 34px; border-radius: 9px;
|
||
background: rgba(15,15,30,.9); border: 1.5px solid rgba(255,255,255,.09);
|
||
color: #aaa; font-size: 1.1rem; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: all .16s;
|
||
}
|
||
.zoom-btn:hover { background: rgba(52,211,153,.12); border-color: rgba(52,211,153,.3); color: #34d399; }
|
||
|
||
/* path stats bar */
|
||
.path-stats {
|
||
position: absolute; top: 12px; left: 16px;
|
||
display: flex; gap: 8px; pointer-events: none;
|
||
}
|
||
.pstat {
|
||
padding: 4px 12px; border-radius: 8px;
|
||
background: rgba(8,8,20,.85); border: 1px solid rgba(255,255,255,.08);
|
||
font-family:'Manrope',sans-serif; font-size:.73rem; font-weight:700;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.pstat.atp { color:#f59e0b; }
|
||
.pstat.nadh { color:#06b6d4; }
|
||
.pstat.co2 { color:#f87171; }
|
||
|
||
/* ── Mobile ── */
|
||
@media (max-width: 768px) {
|
||
.sb-content { flex-direction: column; overflow: auto; }
|
||
.page-header { padding: 10px 14px 0; gap: 8px; }
|
||
.page-title { font-size: 0.85rem !important; }
|
||
.page-subtitle { display: none; }
|
||
.header-right { gap: 4px; }
|
||
.path-selector { padding: 8px 12px; gap: 5px; flex-wrap: wrap; }
|
||
.paths-main { flex-direction: column-reverse; }
|
||
.svg-area { min-height: 55vw; flex: none; height: 55vw; }
|
||
.side-panel { width: 100% !important; max-height: 40vh; border-left: none !important; border-top: 1px solid rgba(255,255,255,.07); overflow-y: auto; }
|
||
.path-stats { top: 6px; left: 8px; gap: 4px; }
|
||
.zoom-controls { bottom: 8px; right: 8px; }
|
||
}
|
||
@media (max-width: 480px) {
|
||
.svg-area { min-height: 260px; height: 260px; }
|
||
.path-chip { font-size: 0.68rem; padding: 4px 10px; }
|
||
.side-panel { max-height: 45vh; }
|
||
}
|
||
|
||
/* ── Biochem subnav ─────────────────────────────────────────────── */
|
||
.biochem-subnav {
|
||
display: flex; align-items: center; gap: 2px;
|
||
padding: 0 16px;
|
||
background: rgba(255,255,255,0.92);
|
||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||
border-bottom: 1.5px solid rgba(15,23,42,0.07);
|
||
flex-shrink: 0; overflow-x: auto;
|
||
scrollbar-width: none; position: relative;
|
||
}
|
||
.biochem-subnav::-webkit-scrollbar { display: none; }
|
||
.biochem-subnav::after {
|
||
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
|
||
height: 1.5px; background: rgba(15,23,42,0.07);
|
||
}
|
||
.bsn-tab {
|
||
display: inline-flex; align-items: center; gap: 7px;
|
||
padding: 11px 14px; border-radius: 9px; margin: 5px 1px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
|
||
color: var(--text-3, #56687A); text-decoration: none; white-space: nowrap;
|
||
transition: background .15s, color .15s; position: relative;
|
||
}
|
||
.bsn-tab svg { stroke: currentColor; width: 15px; height: 15px; flex-shrink: 0; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||
.bsn-tab:hover { background: rgba(155,93,229,0.08); color: #9B5DE5; }
|
||
.bsn-active {
|
||
background: rgba(155,93,229,0.10); color: #7c3aed; font-weight: 700;
|
||
}
|
||
.bsn-active::after {
|
||
content: ''; position: absolute; bottom: -5px; left: 14px; right: 14px;
|
||
height: 2.5px; border-radius: 99px; background: #9B5DE5;
|
||
}
|
||
@media (max-width: 560px) {
|
||
.biochem-subnav { padding: 0 6px; }
|
||
.bsn-tab { padding: 10px 10px; gap: 0; }
|
||
.bsn-tab .bsn-label { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-layout" id="app">
|
||
<aside class="sidebar" id="app-sidebar"></aside>
|
||
<div class="notif-drop" id="notif-drop"></div>
|
||
|
||
<div class="sb-content">
|
||
<nav class="biochem-subnav" aria-label="Разделы биохимии">
|
||
<a class="bsn-tab" href="/biochem"><svg viewBox="0 0 24 24"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3M7.5 15h9"/></svg><span class="bsn-label">Редактор</span></a><a class="bsn-tab" href="/biochem-library"><svg viewBox="0 0 24 24"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg><span class="bsn-label">Библиотека</span></a><a class="bsn-tab" href="/biochem-reactions"><svg viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg><span class="bsn-label">Реакции</span></a><a class="bsn-tab" href="/biochem-properties"><svg viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 10h2a5 5 0 0 0 0-10h-2m-6 5h6"/></svg><span class="bsn-label">Свойства</span></a><a class="bsn-tab bsn-active" href="/biochem-pathways" aria-current="page"><svg viewBox="0 0 24 24"><path d="M6 18h8M3 22h18M8 22V12l4-10 4 10v10M10 9h4"/></svg><span class="bsn-label">Пути</span></a>
|
||
</nav>
|
||
<!-- Header -->
|
||
<div class="page-header">
|
||
<div class="page-header-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14l2-7"/><path d="M12 14l2-7"/></svg></div>
|
||
<div>
|
||
<div class="page-title">Метаболические пути</div>
|
||
<div class="page-subtitle" id="path-subtitle">Интерактивные схемы обмена веществ</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<div class="nav-user-chip">
|
||
<div class="nav-avatar" id="nav-avatar2"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pathway selector -->
|
||
<div class="path-selector">
|
||
<span style="font-family:'Manrope',sans-serif;font-size:.75rem;color:#444;font-weight:700;">Путь:</span>
|
||
<button class="path-chip active" data-path="glycolysis" onclick="selectPath('glycolysis')">
|
||
<span class="chip-dot"></span>Гликолиз
|
||
</button>
|
||
<button class="path-chip" data-path="krebs" onclick="selectPath('krebs')">
|
||
<span class="chip-dot"></span>Цикл Кребса
|
||
</button>
|
||
<button class="path-chip" data-path="oxidation" onclick="selectPath('oxidation')">
|
||
<span class="chip-dot"></span>β-Окисление
|
||
</button>
|
||
<button class="path-chip" data-path="synthesis" onclick="selectPath('synthesis')">
|
||
<span class="chip-dot"></span>Синтез белка
|
||
</button>
|
||
<div class="sel-divider"></div>
|
||
<button class="anim-btn on" id="anim-toggle" onclick="toggleAnimation()">
|
||
<i data-lucide="play-circle" style="width:14px;height:14px"></i> Анимация
|
||
</button>
|
||
<button class="learn-btn" onclick="startLearn()">
|
||
<i data-lucide="graduation-cap" style="width:14px;height:14px"></i> Пройти путь
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Main content -->
|
||
<div class="paths-main">
|
||
<!-- SVG area -->
|
||
<div class="svg-area" id="svg-area">
|
||
<svg id="pathway-svg" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||
<polygon points="0 0, 8 3, 0 6" fill="rgba(120,120,160,0.7)" />
|
||
</marker>
|
||
<marker id="arrowhead-active" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||
<polygon points="0 0, 8 3, 0 6" fill="#34d399" />
|
||
</marker>
|
||
<marker id="arrowhead-gly" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||
<polygon points="0 0, 8 3, 0 6" fill="#f59e0b" />
|
||
</marker>
|
||
<marker id="arrowhead-krebs" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||
<polygon points="0 0, 8 3, 0 6" fill="#06b6d4" />
|
||
</marker>
|
||
<filter id="glow-green">
|
||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||
</filter>
|
||
<filter id="glow-amber">
|
||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||
</filter>
|
||
</defs>
|
||
<g id="svg-root" transform="translate(0,0) scale(1)">
|
||
<g id="edges-layer"></g>
|
||
<g id="nodes-layer"></g>
|
||
<g id="particles-layer"></g>
|
||
</g>
|
||
</svg>
|
||
<!-- Path stats overlay -->
|
||
<div class="path-stats" id="path-stats"></div>
|
||
<!-- Zoom controls -->
|
||
<div class="zoom-controls">
|
||
<button class="zoom-btn" onclick="zoom(1.2)" title="Увеличить">+</button>
|
||
<button class="zoom-btn" onclick="zoom(0.83)" title="Уменьшить">−</button>
|
||
<button class="zoom-btn" onclick="resetView()" title="Сбросить">⊙</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Side panel -->
|
||
<div class="side-panel" id="side-panel">
|
||
<div class="panel-tabs">
|
||
<div class="ptab active" id="tab-mol" onclick="switchTab('mol')">Молекула</div>
|
||
<div class="ptab" id="tab-learn" onclick="switchTab('learn')">Обучение</div>
|
||
<div class="ptab" id="tab-info" onclick="switchTab('info')">Путь</div>
|
||
</div>
|
||
<div class="panel-body" id="panel-mol">
|
||
<div class="mol-empty" id="mol-empty">
|
||
<div class="hint-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"/></svg></div>
|
||
<div>Кликни на молекулу<br/>на схеме</div>
|
||
</div>
|
||
<div class="mol-card" id="mol-card">
|
||
<div class="mol-card-name" id="mc-name"></div>
|
||
<div class="mol-card-formula" id="mc-formula"></div>
|
||
<div class="mol-card-desc" id="mc-desc"></div>
|
||
<div class="mol-card-props" id="mc-props"></div>
|
||
<a class="mol-card-btn" id="mc-link">Открыть в редакторе <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>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body" id="panel-learn" style="display:none">
|
||
<div class="learn-panel" id="learn-inactive" style="display:block">
|
||
<div class="mol-empty">
|
||
<div class="hint-icon"><svg class="ic" viewBox="0 0 24 24"><polygon points="22 10 12 5 2 10 12 15 22 10"/><polyline points="6 12 6 17"/><path d="M18 13.5V17c-3 1.5-9 1.5-12 0v-3.5"/></svg></div>
|
||
<div>Нажми «Пройти путь»<br/>чтобы начать обучение</div>
|
||
</div>
|
||
</div>
|
||
<div class="learn-panel" id="learn-active"></div>
|
||
</div>
|
||
<div class="panel-body" id="panel-info" style="display:none">
|
||
<div id="path-info-content"></div>
|
||
</div>
|
||
|
||
<div class="path-legend" id="path-legend">
|
||
<div class="legend-title">Обозначения</div>
|
||
<div class="legend-items" id="legend-items"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/api.js"></script>
|
||
<script src="/js/sidebar.js"></script>
|
||
<script>
|
||
// ═══════════════════════════════════════════════════════
|
||
// PATHWAY DATA
|
||
// ═══════════════════════════════════════════════════════
|
||
let PATHWAYS = {}; // данные путей грузятся из БД через API в init() (loadPathways)
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// STATE
|
||
// ═══════════════════════════════════════════════════════
|
||
let currentPath = 'glycolysis';
|
||
let animOn = true;
|
||
let activeNode = null;
|
||
let learnStep = 0;
|
||
let learnMode = false;
|
||
let particles = [];
|
||
let animFrame = null;
|
||
let svgTranslate = { x: 0, y: 0 };
|
||
let svgScale = 1;
|
||
let dragState = null;
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// PATH SELECTION & RENDER
|
||
// ═══════════════════════════════════════════════════════
|
||
function selectPath(id) {
|
||
currentPath = id;
|
||
document.querySelectorAll('.path-chip').forEach(c => c.classList.toggle('active', c.dataset.path === id));
|
||
stopLearnMode();
|
||
renderPath();
|
||
renderPathInfo();
|
||
}
|
||
|
||
function renderPath() {
|
||
const pd = PATHWAYS[currentPath];
|
||
const area = document.getElementById('svg-area');
|
||
const W = area.clientWidth || 800;
|
||
const H = area.clientHeight || 600;
|
||
|
||
// auto-fit: find bounding box of nodes
|
||
const xs = pd.nodes.map(n => n.x), ys = pd.nodes.map(n => n.y);
|
||
const minX = Math.min(...xs) - 60, minY = Math.min(...ys) - 60;
|
||
const maxX = Math.max(...xs) + 60, maxY = Math.max(...ys) + 60;
|
||
const scaleX = W / (maxX - minX), scaleY = H / (maxY - minY);
|
||
svgScale = Math.min(scaleX, scaleY, 1.2) * 0.9;
|
||
svgTranslate.x = (W - (maxX - minX) * svgScale) / 2 - minX * svgScale;
|
||
svgTranslate.y = (H - (maxY - minY) * svgScale) / 2 - minY * svgScale;
|
||
applyTransform();
|
||
|
||
renderEdges(pd);
|
||
renderNodes(pd);
|
||
renderStats(pd);
|
||
renderLegend(pd);
|
||
|
||
stopParticles();
|
||
if (animOn) startParticles(pd);
|
||
}
|
||
|
||
function applyTransform() {
|
||
const g = document.getElementById('svg-root');
|
||
g.setAttribute('transform', `translate(${svgTranslate.x},${svgTranslate.y}) scale(${svgScale})`);
|
||
}
|
||
|
||
function renderEdges(pd) {
|
||
const layer = document.getElementById('edges-layer');
|
||
layer.innerHTML = '';
|
||
const color = pd.color;
|
||
const marker = `url(#arrowhead)`;
|
||
|
||
for (const e of pd.edges) {
|
||
const fn = pd.nodes.find(n => n.id === e.from);
|
||
const tn = pd.nodes.find(n => n.id === e.to);
|
||
if (!fn || !tn) continue;
|
||
|
||
const x1 = fn.x, y1 = fn.y, x2 = tn.x, y2 = tn.y;
|
||
const dx = x2 - x1, dy = y2 - y1;
|
||
const len = Math.hypot(dx, dy) || 1;
|
||
// shorten to edge of node circle (r=28)
|
||
const R = 28;
|
||
const sx = x1 + dx/len * R, sy = y1 + dy/len * R;
|
||
const ex = x2 - dx/len * R, ey = y2 - dy/len * R;
|
||
|
||
let pathD;
|
||
if (e.curveX && e.curveX !== 0) {
|
||
// quadratic curve
|
||
const mx = (x1+x2)/2 + e.curveX, my = (y1+y2)/2;
|
||
pathD = `M${sx},${sy} Q${mx},${my} ${ex},${ey}`;
|
||
} else {
|
||
pathD = `M${sx},${sy} L${ex},${ey}`;
|
||
}
|
||
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
path.setAttribute('d', pathD);
|
||
path.setAttribute('stroke', `rgba(${pd.colorRgb},0.45)`);
|
||
path.setAttribute('stroke-width', '1.8');
|
||
path.setAttribute('fill', 'none');
|
||
path.setAttribute('marker-end', marker);
|
||
path.setAttribute('data-edge-id', `${e.from}-${e.to}`);
|
||
layer.appendChild(path);
|
||
|
||
// enzyme label
|
||
if (e.enzyme) {
|
||
const midT = 0.5;
|
||
let lx, ly;
|
||
if (e.curveX) {
|
||
const mx = (x1+x2)/2 + e.curveX, my = (y1+y2)/2;
|
||
lx = (1-midT)*(1-midT)*sx + 2*(1-midT)*midT*mx + midT*midT*ex;
|
||
ly = (1-midT)*(1-midT)*sy + 2*(1-midT)*midT*my + midT*midT*ey - 12;
|
||
} else {
|
||
lx = (sx+ex)/2 + (-dy/len)*12;
|
||
ly = (sy+ey)/2 + (dx/len)*12;
|
||
}
|
||
const txt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
txt.setAttribute('x', lx);
|
||
txt.setAttribute('y', ly);
|
||
txt.setAttribute('class', 'edge-enzyme');
|
||
txt.textContent = e.enzyme;
|
||
layer.appendChild(txt);
|
||
}
|
||
|
||
// co-factor label
|
||
if (e.co) {
|
||
const nx = -dy/len, ny = dx/len;
|
||
const lx = (sx+ex)/2 - nx*13, ly = (sy+ey)/2 - ny*13;
|
||
const txt2 = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
txt2.setAttribute('x', lx);
|
||
txt2.setAttribute('y', ly);
|
||
txt2.setAttribute('class', 'edge-label');
|
||
txt2.setAttribute('fill', `rgba(${pd.colorRgb},0.7)`);
|
||
txt2.textContent = e.co;
|
||
layer.appendChild(txt2);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderNodes(pd) {
|
||
const layer = document.getElementById('nodes-layer');
|
||
layer.innerHTML = '';
|
||
|
||
const roleColor = {
|
||
substrate: { fill: '#1a2e20', stroke: '#34d399' },
|
||
key: { fill: '#1e1a2e', stroke: '#a78bfa' },
|
||
inter: { fill: '#111128', stroke: `rgba(${pd.colorRgb},0.7)` },
|
||
product: { fill: '#1a2a20', stroke: '#4ade80' },
|
||
};
|
||
|
||
for (const n of pd.nodes) {
|
||
const rc = roleColor[n.role] || roleColor.inter;
|
||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||
g.setAttribute('class', 'mol-node' + (activeNode === n.id ? ' active' : ''));
|
||
g.setAttribute('data-node-id', n.id);
|
||
g.setAttribute('transform', `translate(${n.x},${n.y})`);
|
||
g.style.cursor = 'pointer';
|
||
g.addEventListener('click', () => clickNode(n.id));
|
||
|
||
// Outer glow circle
|
||
const glow = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||
glow.setAttribute('r', '34');
|
||
glow.setAttribute('fill', 'none');
|
||
glow.setAttribute('stroke', rc.stroke);
|
||
glow.setAttribute('stroke-width', '0.5');
|
||
glow.setAttribute('opacity', '0.2');
|
||
g.appendChild(glow);
|
||
|
||
// Main circle
|
||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||
circle.setAttribute('class', 'node-circle');
|
||
circle.setAttribute('r', n.role === 'key' ? '30' : '27');
|
||
circle.setAttribute('fill', rc.fill);
|
||
circle.setAttribute('stroke', rc.stroke);
|
||
circle.setAttribute('stroke-width', activeNode === n.id ? '2.5' : '1.8');
|
||
g.appendChild(circle);
|
||
|
||
// Label
|
||
const lines = n.label.split(' ');
|
||
if (lines.length === 1) {
|
||
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
t.setAttribute('class', 'node-label');
|
||
t.setAttribute('y', '0');
|
||
t.textContent = n.label;
|
||
g.appendChild(t);
|
||
// formula
|
||
const f = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
f.setAttribute('class', 'node-formula');
|
||
f.setAttribute('y', '13');
|
||
f.textContent = n.formula;
|
||
g.appendChild(f);
|
||
} else {
|
||
lines.forEach((ln, i) => {
|
||
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
t.setAttribute('class', 'node-label');
|
||
t.setAttribute('y', (i - (lines.length-1)/2) * 13 + '');
|
||
t.setAttribute('font-size', '9.5');
|
||
t.textContent = ln;
|
||
g.appendChild(t);
|
||
});
|
||
}
|
||
|
||
layer.appendChild(g);
|
||
}
|
||
}
|
||
|
||
function renderStats(pd) {
|
||
const el = document.getElementById('path-stats');
|
||
el.innerHTML = pd.stats.map(s => `<div class="pstat ${s.cls}">${s.label}</div>`).join('');
|
||
}
|
||
|
||
function renderLegend(pd) {
|
||
const el = document.getElementById('legend-items');
|
||
el.innerHTML = pd.legend.map(l => {
|
||
if (l.type === 'circle') return `<div class="legend-item"><div class="legend-dot" style="background:${l.color}"></div>${l.label}</div>`;
|
||
if (l.type === 'circle-sm') return `<div class="legend-item"><div class="legend-dot" style="background:${l.color};width:5px;height:5px;opacity:.6"></div>${l.label}</div>`;
|
||
return `<div class="legend-item"><div class="legend-line" style="background:${l.color}"></div>${l.label}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderPathInfo() {
|
||
const pd = PATHWAYS[currentPath];
|
||
document.getElementById('path-info-content').innerHTML = `
|
||
<div style="font-family:'Unbounded',sans-serif;font-size:.85rem;font-weight:800;color:#e0e0f0;margin-bottom:8px">${pd.name}</div>
|
||
<div style="font-family:'Manrope',sans-serif;font-size:.8rem;color:#aaa;line-height:1.6;margin-bottom:14px">${pd.desc}</div>
|
||
<div style="font-family:'Manrope',sans-serif;font-size:.72rem;font-weight:700;color:#444;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px">Выход энергии</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
|
||
${pd.stats.map(s=>`<span class="pstat ${s.cls}" style="position:static;font-size:.75rem">${s.label}</span>`).join('')}
|
||
</div>
|
||
<div style="font-family:'Manrope',sans-serif;font-size:.72rem;font-weight:700;color:#444;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px">Ключевые молекулы (${pd.nodes.length})</div>
|
||
${pd.nodes.map(n=>`
|
||
<div style="display:flex;gap:8px;align-items:center;padding:5px 0;border-bottom:1px solid rgba(255,255,255,.04);cursor:pointer" onclick="clickNode('${n.id}');switchTab('mol')">
|
||
<div style="width:8px;height:8px;border-radius:50%;background:${pd.color};flex-shrink:0"></div>
|
||
<div>
|
||
<div style="font-family:'Manrope',sans-serif;font-size:.78rem;font-weight:700;color:#ccc">${n.label}</div>
|
||
<div style="font-family:'Manrope',sans-serif;font-size:.72rem;color:#555">${n.formula}</div>
|
||
</div>
|
||
</div>`).join('')}
|
||
`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// PARTICLE ANIMATION
|
||
// ═══════════════════════════════════════════════════════
|
||
function startParticles(pd) {
|
||
const layer = document.getElementById('particles-layer');
|
||
layer.innerHTML = '';
|
||
particles = [];
|
||
|
||
for (const e of pd.edges) {
|
||
const fn = pd.nodes.find(n => n.id === e.from);
|
||
const tn = pd.nodes.find(n => n.id === e.to);
|
||
if (!fn || !tn) continue;
|
||
// stagger start
|
||
const delay = Math.random() * 2000;
|
||
setTimeout(() => spawnParticle(pd, e, fn, tn), delay);
|
||
}
|
||
}
|
||
|
||
function spawnParticle(pd, edge, fn, tn) {
|
||
if (!animOn) return;
|
||
const layer = document.getElementById('particles-layer');
|
||
|
||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||
circle.setAttribute('r', '4');
|
||
circle.setAttribute('fill', pd.color);
|
||
circle.setAttribute('opacity', '0.9');
|
||
circle.setAttribute('class', 'flow-particle');
|
||
layer.appendChild(circle);
|
||
|
||
const duration = 1400 + Math.random() * 600;
|
||
const start = performance.now();
|
||
|
||
const R = 28;
|
||
const dx0 = tn.x - fn.x, dy0 = tn.y - fn.y;
|
||
const len0 = Math.hypot(dx0, dy0) || 1;
|
||
const sx = fn.x + dx0/len0 * R, sy = fn.y + dy0/len0 * R;
|
||
const ex = tn.x - dx0/len0 * R, ey = tn.y - dy0/len0 * R;
|
||
|
||
function frame(now) {
|
||
if (!animOn) { circle.remove(); return; }
|
||
const t = Math.min((now - start) / duration, 1);
|
||
const ease = t < 0.5 ? 2*t*t : -1+(4-2*t)*t;
|
||
|
||
let px, py;
|
||
if (edge.curveX) {
|
||
const mx = (fn.x+tn.x)/2 + edge.curveX, my = (fn.y+tn.y)/2;
|
||
px = (1-ease)*(1-ease)*sx + 2*(1-ease)*ease*mx + ease*ease*ex;
|
||
py = (1-ease)*(1-ease)*sy + 2*(1-ease)*ease*my + ease*ease*ey;
|
||
} else {
|
||
px = sx + (ex-sx)*ease;
|
||
py = sy + (ey-sy)*ease;
|
||
}
|
||
|
||
circle.setAttribute('cx', px);
|
||
circle.setAttribute('cy', py);
|
||
circle.setAttribute('opacity', t < 0.1 ? t*9 : t > 0.85 ? (1-t)/0.15 : 0.9);
|
||
|
||
if (t < 1) {
|
||
requestAnimationFrame(frame);
|
||
} else {
|
||
circle.remove();
|
||
// respawn after pause
|
||
if (animOn) setTimeout(() => spawnParticle(pd, edge, fn, tn), 800 + Math.random() * 1200);
|
||
}
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
function stopParticles() {
|
||
animOn = false;
|
||
document.getElementById('particles-layer').innerHTML = '';
|
||
}
|
||
|
||
function toggleAnimation() {
|
||
animOn = !animOn;
|
||
const btn = document.getElementById('anim-toggle');
|
||
btn.classList.toggle('on', animOn);
|
||
btn.innerHTML = `<i data-lucide="${animOn ? 'play-circle' : 'pause-circle'}" style="width:14px;height:14px"></i> Анимація`;
|
||
btn.innerHTML = `<i data-lucide="${animOn ? 'play-circle' : 'pause-circle'}" style="width:14px;height:14px"></i> Анимация`;
|
||
if (window.lucide) lucide.createIcons();
|
||
if (animOn) startParticles(PATHWAYS[currentPath]);
|
||
else document.getElementById('particles-layer').innerHTML = '';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// NODE CLICK <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> SIDE PANEL
|
||
// ═══════════════════════════════════════════════════════
|
||
function clickNode(id) {
|
||
activeNode = id;
|
||
// re-render nodes to update active state
|
||
renderNodes(PATHWAYS[currentPath]);
|
||
|
||
const pd = PATHWAYS[currentPath];
|
||
const n = pd.nodes.find(x => x.id === id);
|
||
if (!n) return;
|
||
|
||
document.getElementById('mol-empty').style.display = 'none';
|
||
document.getElementById('mol-card').classList.add('show');
|
||
document.getElementById('mc-name').textContent = n.label;
|
||
document.getElementById('mc-formula').textContent = n.formula;
|
||
document.getElementById('mc-desc').textContent = n.desc;
|
||
document.getElementById('mc-props').innerHTML = (n.props||[]).map(p => {
|
||
let cls = 'key';
|
||
if (p.includes('АТФ') || p.includes('ГТФ')) cls = p.startsWith('+') ? 'atp' : 'atp';
|
||
if (p.includes('НАДН') || p.includes('ФАДН')) cls = 'nadh';
|
||
if (p.includes('CO₂')) cls = 'co2';
|
||
return `<span class="mol-prop ${cls}">${p}</span>`;
|
||
}).join('');
|
||
document.getElementById('mc-link').href = '/biochem-library';
|
||
document.getElementById('mc-link').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>';
|
||
|
||
switchTab('mol');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// LEARN MODE
|
||
// ═══════════════════════════════════════════════════════
|
||
// ── Загрузка данных путей из БД (API) ──
|
||
async function loadPathways() {
|
||
try {
|
||
const data = await LS.biochemGetPathways();
|
||
if (data && Object.keys(data).length) PATHWAYS = data;
|
||
} catch (e) {
|
||
LS.toast?.('Не удалось загрузить пути', 'error');
|
||
}
|
||
if (!PATHWAYS[currentPath]) currentPath = Object.keys(PATHWAYS)[0] || currentPath;
|
||
}
|
||
|
||
// ── Прогресс прохождения путей (персистентность Learn-режима) ──
|
||
let _pathProgress = {};
|
||
async function loadPathProgress() {
|
||
try { _pathProgress = (await LS.biochemGetPathwayProgress()) || {}; }
|
||
catch { _pathProgress = {}; }
|
||
markCompletedChips();
|
||
}
|
||
function markCompletedChips() {
|
||
document.querySelectorAll('.path-chip').forEach(chip => {
|
||
const key = chip.dataset.path;
|
||
const done = _pathProgress[key] && _pathProgress[key].completed;
|
||
let badge = chip.querySelector('.path-done');
|
||
if (done && !badge) {
|
||
badge = document.createElement('span');
|
||
badge.className = 'path-done';
|
||
badge.style.cssText = 'display:inline-flex;margin-left:5px;color:#4ade80';
|
||
badge.innerHTML = '<svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px"><polyline points="20 6 9 17 4 12"/></svg>';
|
||
chip.appendChild(badge);
|
||
} else if (!done && badge) { badge.remove(); }
|
||
});
|
||
}
|
||
function savePathCompletion() {
|
||
LS.biochemSavePathwayProgress(currentPath, learnStep, true).then(r => {
|
||
_pathProgress[currentPath] = { step: learnStep, completed: true };
|
||
markCompletedChips();
|
||
if (r && r.xp) LS.toast(`Путь пройден! +${r.xp} XP`, 'success');
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function startLearn() {
|
||
learnMode = true;
|
||
learnStep = 0;
|
||
switchTab('learn');
|
||
document.getElementById('learn-inactive').style.display = 'none';
|
||
renderLearnStep();
|
||
}
|
||
|
||
function stopLearnMode() {
|
||
learnMode = false;
|
||
document.getElementById('learn-inactive').style.display = 'block';
|
||
document.getElementById('learn-active').innerHTML = '';
|
||
}
|
||
|
||
let quizAnswered = false;
|
||
|
||
function renderLearnStep() {
|
||
const pd = PATHWAYS[currentPath];
|
||
const steps = pd.steps;
|
||
if (learnStep >= steps.length) { renderLearnComplete(); return; }
|
||
|
||
const s = steps[learnStep];
|
||
const pct = Math.round((learnStep / steps.length) * 100);
|
||
quizAnswered = false;
|
||
|
||
// highlight node
|
||
activeNode = s.mol;
|
||
renderNodes(pd);
|
||
// scroll to node (pan)
|
||
const n = pd.nodes.find(x => x.id === s.mol);
|
||
if (n) {
|
||
const area = document.getElementById('svg-area');
|
||
const W = area.clientWidth, H = area.clientHeight;
|
||
svgTranslate.x = W/2 - n.x * svgScale;
|
||
svgTranslate.y = H/2 - n.y * svgScale;
|
||
applyTransform();
|
||
}
|
||
|
||
const html = `
|
||
<div class="learn-progress-bar"><div class="learn-progress-fill" style="width:${pct}%"></div></div>
|
||
<div class="step-counter">Шаг ${learnStep+1} из ${steps.length}</div>
|
||
<div class="step-title-row">
|
||
<span class="step-num">${learnStep+1}</span>
|
||
<span class="step-title">${s.title}</span>
|
||
</div>
|
||
<div class="step-desc">${s.desc}</div>
|
||
${s.energy.length ? `<div class="step-energy">${s.energy.map(e=>`<span class="energy-badge ${e.cls}">${e.label}</span>`).join('')}</div>` : ''}
|
||
<div class="mini-quiz">
|
||
<div class="quiz-q">${s.quiz.q}</div>
|
||
<div class="quiz-opts">
|
||
${s.quiz.opts.map((o,i)=>`<button class="quiz-opt" id="qopt-${i}" onclick="answerQuiz(${i},${s.quiz.ans})">${o}</button>`).join('')}
|
||
</div>
|
||
<div id="quiz-fb" style="display:none" class="quiz-feedback"></div>
|
||
</div>
|
||
<div class="step-nav">
|
||
<button class="step-btn prev" onclick="stepNav(-1)" ${learnStep===0?'disabled':''}><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Назад</button>
|
||
<button class="step-btn next" id="step-next-btn" onclick="stepNav(1)" disabled>
|
||
${learnStep===steps.length-1 ? 'Завершить <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : 'Далее <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>'}
|
||
</button>
|
||
</div>
|
||
`;
|
||
document.getElementById('learn-active').innerHTML = html;
|
||
}
|
||
|
||
function answerQuiz(chosen, correct) {
|
||
if (quizAnswered) return;
|
||
quizAnswered = true;
|
||
const ok = chosen === correct;
|
||
const fb = document.getElementById('quiz-fb');
|
||
fb.style.display = '';
|
||
fb.className = 'quiz-feedback ' + (ok ? 'ok' : 'err');
|
||
fb.innerHTML = ok ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Верно!' : `<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> Неверно. Правильный ответ: "${PATHWAYS[currentPath].steps[learnStep].quiz.opts[correct]}"`;
|
||
|
||
document.querySelectorAll('.quiz-opt').forEach((el, i) => {
|
||
el.disabled = true;
|
||
if (i === correct) el.classList.add('correct');
|
||
else if (i === chosen && !ok) el.classList.add('wrong');
|
||
});
|
||
document.getElementById('step-next-btn').disabled = false;
|
||
}
|
||
|
||
function stepNav(dir) {
|
||
learnStep += dir;
|
||
if (learnStep < 0) learnStep = 0;
|
||
renderLearnStep();
|
||
}
|
||
|
||
function renderLearnComplete() {
|
||
activeNode = null;
|
||
savePathCompletion(); // сохранить прохождение + начислить XP (один раз)
|
||
renderNodes(PATHWAYS[currentPath]);
|
||
document.getElementById('learn-active').innerHTML = `
|
||
<div class="learn-complete">
|
||
<div class="complete-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></div>
|
||
<div class="complete-title">${PATHWAYS[currentPath].name} пройден!</div>
|
||
<div class="complete-text" style="margin-bottom:16px">Вы изучили все ${PATHWAYS[currentPath].steps.length} шагов пути.</div>
|
||
<button class="learn-btn" style="display:inline-flex;margin:0 auto" onclick="startLearn()">Повторить</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// TABS
|
||
// ═══════════════════════════════════════════════════════
|
||
function switchTab(tab) {
|
||
['mol','learn','info'].forEach(t => {
|
||
document.getElementById('tab-'+t).classList.toggle('active', t===tab);
|
||
document.getElementById('panel-'+t).style.display = t===tab ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// PAN / ZOOM
|
||
// ═══════════════════════════════════════════════════════
|
||
function zoom(factor) {
|
||
svgScale *= factor;
|
||
applyTransform();
|
||
}
|
||
function resetView() {
|
||
renderPath();
|
||
}
|
||
|
||
const svgArea = document.getElementById('svg-area');
|
||
svgArea.addEventListener('mousedown', e => {
|
||
if (e.target.closest('.mol-node')) return;
|
||
dragState = { sx: e.clientX, sy: e.clientY, tx: svgTranslate.x, ty: svgTranslate.y };
|
||
});
|
||
window.addEventListener('mousemove', e => {
|
||
if (!dragState) return;
|
||
svgTranslate.x = dragState.tx + (e.clientX - dragState.sx);
|
||
svgTranslate.y = dragState.ty + (e.clientY - dragState.sy);
|
||
applyTransform();
|
||
});
|
||
window.addEventListener('mouseup', () => { dragState = null; });
|
||
svgArea.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? 0.88 : 1.14;
|
||
const rect = svgArea.getBoundingClientRect();
|
||
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||
svgTranslate.x = mx - (mx - svgTranslate.x) * delta;
|
||
svgTranslate.y = my - (my - svgTranslate.y) * delta;
|
||
svgScale *= delta;
|
||
applyTransform();
|
||
}, { passive: false });
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// BOOT
|
||
// ═══════════════════════════════════════════════════════
|
||
async function init() {
|
||
try {
|
||
const user = await LS.getMe();
|
||
LS.renderNavAvatar(document.getElementById('nav-avatar'), user);
|
||
LS.renderNavAvatar(document.getElementById('nav-avatar2'), user);
|
||
if (user?.role === 'admin') document.getElementById('btn-admin').style.display = '';
|
||
LS.applyRoleSidebar(user);
|
||
LS.showBoardIfAllowed();
|
||
if (user?.role !== 'student') {
|
||
document.getElementById('btn-classes').style.display = '';
|
||
}
|
||
document.getElementById('nav-user').textContent = user?.name || '—';
|
||
} catch(e) {}
|
||
|
||
// wait for layout
|
||
await new Promise(r => setTimeout(r, 60));
|
||
await loadPathways(); // данные путей из БД
|
||
renderPath();
|
||
renderPathInfo();
|
||
loadPathProgress(); // отметить пройденные пути галочкой
|
||
if (window.lucide) lucide.createIcons();
|
||
LS.notif?.init();
|
||
LS.hideDisabledFeatures?.();
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
<script src="/js/notifications.js"></script>
|
||
<script src="/js/mobile.js"></script>
|
||
<script src="/js/biochem-nav.js"></script>
|
||
</body>
|
||
</html>
|