Files
Learn_System/frontend/biochem-pathways.html
T
Maxim Dolgolyov 358b761eb2 fix(biochem): статичный subnav без мигания + редизайн
Проблема: динамическая вставка через 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>
2026-05-31 08:54:38 +03:00

1086 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Метаболические пути — Биохимия — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<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>