Files
Learn_System/frontend/knowledge-map.html
Maxim Dolgolyov 26ba289019 a11y: WCAG AA contrast + ARIA roles + focus management across all pages
- css/ls.css: --text-3 #8898AA → #56687A (5.1:1 contrast), min-height 44px on .btn-primary/.btn-ghost/.sb-link, new .icon-btn utility (44×44px)
- js/api.js: lsConfirm — role=dialog, aria-modal, aria-labelledby, Tab focus trap, restore focus on close; lsToast — aria-live=polite on container, role=alert on errors; live quiz — role=dialog, role=radiogroup, role=radio, aria-checked, keyboard support
- test-run.html: q-opt divs — role=radio/checkbox, aria-checked, tabindex, keyboard enter/space; confirm modal — role=dialog, aria-modal; btn-flag — aria-pressed; dots — aria-label, aria-current; touch targets 44px
- board.html: btn-del-ann — aria-label; reaction buttons — aria-label, aria-pressed
- All 18 HTML files: replace hardcoded color:#8898AA with color:var(--text-3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:42:38 +03:00

1861 lines
77 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous" />
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" crossorigin="anonymous"
onload="window._katexReady = true; if(window._katexReadyCb) window._katexReadyCb()"></script>
<style>
.app-layout { height: 100vh; overflow: hidden; }
.sb-content { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
/* ── Topbar ── */
.km-topbar {
flex-shrink: 0; display: flex; align-items: center; gap: 12px;
padding: 14px 22px 0;
}
.km-icon {
width: 42px; height: 42px; border-radius: 13px; flex-shrink: 0;
background: linear-gradient(135deg, rgba(155,93,229,.35), rgba(6,214,224,.2));
border: 1.5px solid rgba(255,255,255,.1);
display: flex; align-items: center; justify-content: center;
}
.km-icon svg { width: 19px; height: 19px; stroke: #9B5DE5; stroke-width: 1.8; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.km-title-wrap { flex: 1; min-width: 0; }
.km-title { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; letter-spacing: -.02em; }
.km-sub { font-size: 0.76rem; color: var(--text-2); font-weight: 500; margin-top: 2px; }
/* ── Stats chips ── */
.km-stats {
flex-shrink: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 10px 22px 4px;
}
.km-stat {
display: flex; align-items: center; gap: 6px;
padding: 4px 12px 4px 8px; border-radius: 99px;
border: 1.5px solid var(--border-h); background: var(--surface);
font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 700;
}
.km-stat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.km-stat-val { color: var(--text); }
.km-stat-lbl { color: var(--text-3); font-weight: 600; }
/* ── Overall progress bar ── */
.km-progress-wrap {
flex-shrink: 0; padding: 0 22px 4px; display: flex; align-items: center; gap: 10px;
}
.km-progress-bar {
flex: 1; height: 5px; border-radius: 3px; background: rgba(255,255,255,.07);
overflow: hidden;
}
.km-progress-fill { height: 100%; border-radius: 3px; transition: width .5s ease; }
.km-progress-pct { font-size: 0.72rem; font-weight: 700; color: var(--text-2); min-width: 36px; text-align: right; }
/* ── Controls ── */
.km-controls {
flex-shrink: 0; display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
padding: 6px 22px 8px;
border-bottom: 1px solid var(--border);
}
.km-filter {
padding: 4px 13px; border-radius: 99px;
border: 1.5px solid var(--border-h);
background: var(--surface); color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.77rem; font-weight: 700;
cursor: pointer; transition: all .14s;
}
.km-filter:hover { border-color: rgba(155,93,229,.4); color: var(--violet); }
.km-filter.active { background: var(--violet); color: #fff; border-color: var(--violet); }
.km-sep { width: 1px; height: 20px; background: var(--border-h); margin: 0 2px; }
.km-btn {
padding: 4px 12px; border-radius: 9px;
border: 1.5px solid var(--border-h); background: var(--surface); color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.77rem; font-weight: 700;
cursor: pointer; transition: all .14s; display: flex; align-items: center; gap: 5px;
}
.km-btn:hover { border-color: rgba(155,93,229,.4); color: var(--violet); }
.km-btn svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 2; fill: none; }
/* Legend */
.km-legend {
margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
.km-legend-item { display: flex; align-items: center; gap: 4px; font-size: 0.7rem; color: var(--text-3); font-weight: 600; }
.km-legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
/* ── Canvas ── */
.km-canvas-wrap { flex: 1; min-height: 0; position: relative; }
#km-canvas { display: block; width: 100%; height: 100%; }
/* ── Hint ── */
.km-hint {
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
font-size: 0.7rem; color: rgba(255,255,255,.2); font-family: 'Manrope', sans-serif;
pointer-events: none; white-space: nowrap;
}
/* ── Zoom controls ── */
.km-zoom-btns {
position: absolute; right: 14px; bottom: 14px;
display: flex; flex-direction: column; gap: 4px;
}
.km-zoom-btn {
width: 32px; height: 32px; border-radius: 9px;
border: 1.5px solid rgba(255,255,255,.1); background: rgba(10,10,20,.7);
color: rgba(255,255,255,.6); cursor: pointer; font-size: 1rem;
display: flex; align-items: center; justify-content: center;
transition: all .14s; backdrop-filter: blur(6px);
}
.km-zoom-btn:hover { border-color: rgba(155,93,229,.5); color: #9B5DE5; }
/* ── Tooltip ── */
.km-tooltip {
position: fixed; pointer-events: auto;
background: rgba(8,8,18,.96); border: 1px solid rgba(255,255,255,.13);
border-radius: 12px; padding: 12px 15px; color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.82rem;
box-shadow: 0 12px 40px rgba(0,0,0,.6);
min-width: 190px; max-width: 250px; z-index: 9999;
display: none;
}
.tt-subject { font-size: 0.68rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 700; margin-bottom: 5px; }
.tt-name { font-weight: 800; color: #fff; font-size: 0.9rem; margin-bottom: 8px; line-height: 1.3; }
.tt-bar-wrap { height: 5px; border-radius: 3px; background: rgba(255,255,255,.1); margin-bottom: 5px; overflow: hidden; }
.tt-bar-fill { height: 100%; border-radius: 3px; transition: width .3s; }
.tt-mastery { font-size: 0.75rem; color: rgba(255,255,255,.6); display: flex; justify-content: space-between; }
.tt-action { margin-top: 10px; padding: 8px 12px; border-radius: 10px; background: rgba(155,93,229,.2); border: 1px solid rgba(155,93,229,.35); color: #c39af5; font-size: 0.76rem; font-weight: 700; text-align: center; cursor: pointer; transition: all .15s; display: flex; align-items: center; justify-content: center; gap: 6px; }
.tt-action:hover { background: rgba(155,93,229,.35); color: #fff; }
/* ── Loading ── */
.km-loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-size: 0.88rem; gap: 8px;
}
.km-spinner { width: 20px; height: 20px; border-radius: 50%; border: 2px solid rgba(155,93,229,.2); border-top-color: var(--violet); animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Empty state ── */
.km-empty {
position: absolute; inset: 0; display: none; flex-direction: column;
align-items: center; justify-content: center; gap: 10px; color: var(--text-3);
}
.km-empty.show { display: flex; }
.km-empty-icon { font-size: 2.5rem; opacity: .4; }
.km-empty-text { font-size: 0.88rem; font-weight: 600; }
/* ── Context menu ── */
.km-ctx {
position: fixed; z-index: 300; display: none;
background: rgba(8,8,18,.96); border: 1px solid rgba(255,255,255,.12);
border-radius: 14px; padding: 6px; min-width: 190px;
box-shadow: 0 16px 48px rgba(0,0,0,.6); backdrop-filter: blur(16px);
font-family: 'Manrope', sans-serif;
}
.km-ctx.open { display: block; }
.km-ctx-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 14px; border-radius: 9px; cursor: pointer;
font-size: 0.82rem; font-weight: 600; color: rgba(255,255,255,.8);
transition: all .12s; border: none; background: none; width: 100%; text-align: left;
}
.km-ctx-item:hover { background: rgba(155,93,229,.12); color: #fff; }
.km-ctx-item svg { width: 15px; height: 15px; stroke: currentColor; stroke-width: 2; fill: none; flex-shrink: 0; }
.km-ctx-sep { height: 1px; background: rgba(255,255,255,.06); margin: 4px 8px; }
.km-ctx-head {
padding: 8px 14px 4px; font-size: 0.68rem; font-weight: 700;
color: rgba(255,255,255,.3); text-transform: uppercase; letter-spacing: .06em;
}
/* ── Quick test modal ── */
.km-qtest {
position: fixed; inset: 0; z-index: 400;
background: rgba(8,4,20,.88); backdrop-filter: blur(12px);
display: none; align-items: center; justify-content: center; padding: 20px;
}
.km-qtest.open { display: flex; }
.km-qtest-box {
background: #fff; border-radius: 22px; width: 100%; max-width: 540px;
max-height: 90vh; overflow-y: auto; position: relative;
box-shadow: 0 40px 120px rgba(0,0,0,.5);
}
.km-qtest-head {
padding: 22px 24px 14px; border-bottom: 1px solid #E8EBF0;
display: flex; align-items: center; gap: 12px;
}
.km-qtest-head-ic {
width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, rgba(155,93,229,.12), rgba(6,214,224,.08));
color: #9B5DE5;
}
.km-qtest-head-ic svg { width: 17px; height: 17px; stroke: currentColor; stroke-width: 2; fill: none; }
.km-qtest-title {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: #0F172A; flex: 1;
}
.km-qtest-close {
width: 32px; height: 32px; border-radius: 9px; border: 1.5px solid #E8EBF0;
background: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center;
color: var(--text-3); transition: all .15s;
}
.km-qtest-close:hover { border-color: #ef4444; color: #ef4444; }
.km-qtest-close svg { width: 16px; height: 16px; stroke: currentColor; stroke-width: 2; fill: none; }
.km-qtest-body { padding: 20px 24px; }
.km-qtest-progress {
display: flex; align-items: center; gap: 8px; margin-bottom: 18px;
}
.km-qtest-pbar { flex: 1; height: 4px; border-radius: 99px; background: #E8EBF0; overflow: hidden; }
.km-qtest-pfill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, #9B5DE5, #06D6E0); transition: width .3s; }
.km-qtest-ptext { font-size: 0.72rem; font-weight: 700; color: var(--text-3); min-width: 36px; text-align: right; }
.km-qtest-q {
font-size: 0.95rem; font-weight: 600; color: #0F172A; line-height: 1.6;
margin-bottom: 16px;
}
.km-qtest-opts { display: flex; flex-direction: column; gap: 8px; }
.km-qtest-opt {
display: flex; align-items: center; gap: 12px;
padding: 13px 16px; border: 1.5px solid #E8EBF0; border-radius: 14px;
cursor: pointer; transition: all .15s; font-size: 0.88rem; color: #0F172A;
background: #fff;
}
.km-qtest-opt:hover { border-color: rgba(155,93,229,.4); background: rgba(155,93,229,.03); }
.km-qtest-opt.correct { border-color: #22c55e; background: rgba(34,197,94,.06); color: #166534; }
.km-qtest-opt.wrong { border-color: #ef4444; background: rgba(239,68,68,.06); color: #991b1b; }
.km-qtest-opt.dimmed { opacity: 0.5; pointer-events: none; }
.km-qtest-opt-marker {
width: 22px; height: 22px; border-radius: 7px; border: 2px solid #E8EBF0;
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
font-size: 0.7rem; font-weight: 800; color: transparent; transition: all .15s;
}
.km-qtest-opt.correct .km-qtest-opt-marker { border-color: #22c55e; color: #22c55e; background: rgba(34,197,94,.1); }
.km-qtest-opt.wrong .km-qtest-opt-marker { border-color: #ef4444; color: #ef4444; background: rgba(239,68,68,.1); }
.km-qtest-explain {
margin-top: 14px; padding: 14px 16px; border-radius: 12px;
background: rgba(155,93,229,.05); border: 1px solid rgba(155,93,229,.1);
font-size: 0.82rem; color: #475569; line-height: 1.6; display: none;
}
.km-qtest-result {
text-align: center; padding: 32px 20px;
}
.km-qtest-result-score {
font-family: 'Unbounded', sans-serif; font-size: 2rem; font-weight: 800;
margin-bottom: 6px;
}
.km-qtest-result-label { font-size: 0.88rem; color: var(--text-3); margin-bottom: 20px; }
.km-qtest-result-btn {
padding: 12px 28px; border-radius: 99px; border: none;
background: linear-gradient(135deg, #9B5DE5, #7C3AED);
color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700;
cursor: pointer; transition: all .15s;
}
.km-qtest-result-btn:hover { opacity: .88; transform: translateY(-1px); }
/* ── Minimap ── */
.km-minimap {
position: absolute; left: 14px; bottom: 14px;
width: 140px; height: 90px; border-radius: 10px;
background: rgba(8,8,18,.75); border: 1px solid rgba(255,255,255,.1);
backdrop-filter: blur(6px); overflow: hidden; cursor: pointer;
}
.km-minimap canvas { width: 100%; height: 100%; display: block; }
/* ── Search input ── */
.km-search {
padding: 4px 10px 4px 28px; border-radius: 99px; width: 140px;
border: 1.5px solid var(--border-h); background: var(--surface);
color: var(--text); font-family: 'Manrope', sans-serif; font-size: 0.77rem;
font-weight: 600; outline: none; transition: all .2s;
}
.km-search:focus { border-color: rgba(155,93,229,.5); width: 200px; }
.km-search::placeholder { color: var(--text-3); }
.km-search-wrap {
position: relative; display: flex; align-items: center;
}
.km-search-ic {
position: absolute; left: 9px; color: var(--text-3); pointer-events: none;
}
/* ── Recommendation panel ── */
.km-reco {
position: absolute; top: 0; right: 0; bottom: 0; width: 300px;
background: rgba(8,8,18,.95); backdrop-filter: blur(16px);
border-left: 1px solid rgba(255,255,255,.08);
transform: translateX(100%); transition: transform .3s cubic-bezier(.4,0,.2,1);
z-index: 50; display: flex; flex-direction: column; overflow: hidden;
}
.km-reco.open { transform: translateX(0); }
.km-reco-head {
padding: 16px 18px; border-bottom: 1px solid rgba(255,255,255,.06);
display: flex; align-items: center; gap: 10px;
}
.km-reco-title {
font-family: 'Unbounded', sans-serif; font-size: 0.75rem; font-weight: 800;
color: #fff; flex: 1;
}
.km-reco-close {
width: 28px; height: 28px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1);
background: transparent; color: rgba(255,255,255,.5); cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all .15s;
}
.km-reco-close:hover { border-color: rgba(255,255,255,.3); color: #fff; }
.km-reco-list { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
.km-reco-item {
padding: 14px 16px; border-radius: 14px;
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06);
cursor: pointer; transition: all .15s;
}
.km-reco-item:hover { background: rgba(155,93,229,.08); border-color: rgba(155,93,229,.2); }
.km-reco-item-top { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.km-reco-item-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.km-reco-item-name { font-size: 0.82rem; font-weight: 700; color: #fff; flex: 1; }
.km-reco-item-pct {
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
}
.km-reco-item-bar { height: 4px; border-radius: 99px; background: rgba(255,255,255,.06); overflow: hidden; margin-bottom: 6px; }
.km-reco-item-fill { height: 100%; border-radius: 99px; }
.km-reco-item-sub { font-size: 0.7rem; color: rgba(255,255,255,.4); }
.km-reco-item-reason { font-size: 0.7rem; color: rgba(255,209,102,.7); margin-top: 4px; font-weight: 600; }
.km-reco-btn {
margin: 6px 14px 14px; padding: 10px; border-radius: 12px; border: none;
background: linear-gradient(135deg, rgba(155,93,229,.2), rgba(6,214,224,.1));
color: #c39af5; font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
cursor: pointer; text-align: center; transition: all .15s;
border: 1px solid rgba(155,93,229,.2);
}
.km-reco-btn:hover { background: rgba(155,93,229,.3); }
.km-reco-empty { text-align: center; padding: 40px 16px; color: rgba(255,255,255,.3); font-size: 0.85rem; }
/* ── Mobile ── */
@media (max-width: 768px) {
.app-layout { height: auto; }
.sb-content { height: auto; overflow: auto; flex-direction: column; }
.km-topbar { padding: 12px 14px 0; flex-wrap: wrap; gap: 8px; }
.km-title { font-size: 0.9rem; }
.km-stats { padding: 8px 14px 4px; }
.km-progress-wrap { padding: 0 14px 4px; }
.km-canvas-wrap { height: 70vw; min-height: 300px; flex: none; }
.km-minimap { display: none; }
.km-reco { width: 260px; }
.km-search { width: 110px; }
.km-search:focus { width: 150px; }
.km-legend { display: none; }
}
@media (max-width: 480px) {
.km-canvas-wrap { height: 280px; }
.km-stat { font-size: 0.68rem; padding: 3px 8px 3px 6px; }
.km-title { font-size: 0.82rem; }
.km-reco { width: 100%; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<!-- Topbar -->
<div class="km-topbar">
<div class="km-icon">
<svg viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</div>
<div class="km-title-wrap">
<div class="km-title">Карта знаний</div>
<div class="km-sub" id="km-sub-text">Визуализация освоенности тем</div>
</div>
</div>
<!-- Stats -->
<div class="km-stats" id="km-stats" style="display:none">
<div class="km-stat" id="stat-mastered">
<div class="km-stat-dot" style="background:#06D6A0"></div>
<span class="km-stat-val" id="stat-mastered-val">0</span>
<span class="km-stat-lbl">освоено</span>
</div>
<div class="km-stat" id="stat-progress">
<div class="km-stat-dot" style="background:#FFD166"></div>
<span class="km-stat-val" id="stat-progress-val">0</span>
<span class="km-stat-lbl">в процессе</span>
</div>
<div class="km-stat" id="stat-new">
<div class="km-stat-dot" style="background:rgba(255,255,255,.25)"></div>
<span class="km-stat-val" id="stat-new-val">0</span>
<span class="km-stat-lbl">не начато</span>
</div>
</div>
<div class="km-progress-wrap" id="km-prog-wrap" style="display:none">
<div class="km-progress-bar"><div class="km-progress-fill" id="km-prog-fill"></div></div>
<div class="km-progress-pct" id="km-prog-pct">0%</div>
</div>
<!-- Controls -->
<div class="km-controls" id="km-controls">
<button class="km-filter active" data-slug="">Все</button>
<button class="km-filter" data-slug="bio"><svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/><path d="M9 22c1.798-2 2.518-4 2.807-6"/><path d="M15 2c-1.798 2-2.518 4-2.807 6"/><path d="m17 6-2.5-2.5M14 8 13 7M7 18l2.5 2.5M3.5 14.5l.5.5M20 9l.5.5M6.5 12.5l1 1M16.5 10.5l1 1M10 16l1.5 1.5"/></svg> Биология</button>
<button class="km-filter" data-slug="chem"><svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Химия</button>
<button class="km-filter" data-slug="math"><svg class="ic" viewBox="0 0 24 24"><path d="m15 5 6.3 6.3a2.4 2.4 0 0 1 0 3.4L17 19"/><path d="M9.586 5.586A2 2 0 0 0 8.172 5H3a1 1 0 0 0-1 1v5.172a2 2 0 0 0 .586 1.414L8.29 18.29a2.426 2.426 0 0 0 3.42 0l3.58-3.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="6.5" cy="9.5" r=".5" fill="currentColor"/></svg> Математика</button>
<button class="km-filter" data-slug="phys"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> Физика</button>
<div class="km-search-wrap">
<svg class="km-search-ic ic" viewBox="0 0 24 24" style="width:13px;height:13px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input class="km-search" id="km-search" type="text" placeholder="Найти тему..." oninput="searchTopic(this.value)" />
</div>
<div class="km-sep"></div>
<button class="km-btn" id="btn-recenter" title="Сбросить вид">
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 18 0A9 9 0 0 0 3 12z"/><path d="M12 8v4l3 3"/></svg>
Центр
</button>
<button class="km-btn" id="btn-resim" title="Перегруппировать">
<svg viewBox="0 0 24 24"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Перегруппировать
</button>
<button class="km-btn" id="btn-reco" title="Рекомендации" onclick="toggleReco()">
<svg viewBox="0 0 24 24"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Рекомендации
</button>
<div class="km-legend">
<div class="km-legend-item"><div class="km-legend-dot" style="background:#06D6A0"></div>≥70%</div>
<div class="km-legend-item"><div class="km-legend-dot" style="background:#FFD166"></div>3069%</div>
<div class="km-legend-item"><div class="km-legend-dot" style="background:#F94144"></div>&lt;30%</div>
<div class="km-legend-item"><div class="km-legend-dot" style="background:rgba(255,255,255,.2)"></div>Новое</div>
</div>
</div>
<!-- Canvas -->
<div class="km-canvas-wrap" id="km-canvas-wrap">
<canvas id="km-canvas"></canvas>
<div class="km-loading" id="km-loading"><div class="km-spinner"></div> Загрузка…</div>
<div class="km-empty" id="km-empty">
<div class="km-empty-icon"><svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg></div>
<div class="km-empty-text">Нет тем для этого предмета</div>
</div>
<!-- Recommendation panel -->
<div class="km-reco" id="km-reco">
<div class="km-reco-head">
<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;color:#9B5DE5;stroke-width:2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
<div class="km-reco-title">Что учить дальше</div>
<button class="km-reco-close" onclick="toggleReco()"><svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="km-reco-list" id="km-reco-list"></div>
</div>
<div class="km-hint">Колёсико — zoom · Перетащите узел · Клик — фокус</div>
<div class="km-minimap" id="km-minimap"><canvas id="km-minimap-canvas"></canvas></div>
<div class="km-zoom-btns">
<button class="km-zoom-btn" id="btn-zoom-in" title="Приблизить">+</button>
<button class="km-zoom-btn" id="btn-zoom-out" title="Отдалить"></button>
</div>
</div>
<!-- Context menu -->
<div class="km-ctx" id="km-ctx">
<div class="km-ctx-head" id="km-ctx-head">Тема</div>
<button class="km-ctx-item" id="ctx-test">
<svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Быстрый тест (5 вопросов)
</button>
<button class="km-ctx-item" id="ctx-theory">
<svg viewBox="0 0 24 24"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Открыть теорию
</button>
<div class="km-ctx-sep"></div>
<button class="km-ctx-item" id="ctx-focus">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Фокус на тему
</button>
</div>
<!-- Quick test modal -->
<div class="km-qtest" id="km-qtest">
<div class="km-qtest-box">
<div class="km-qtest-head">
<div class="km-qtest-head-ic"><svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg></div>
<div class="km-qtest-title" id="qtest-title">Быстрый тест</div>
<button class="km-qtest-close" onclick="closeQuickTest()"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="km-qtest-body" id="qtest-body"></div>
</div>
</div>
<!-- Tooltip -->
<div class="km-tooltip" id="km-tooltip">
<div class="tt-subject" id="tt-subject"></div>
<div class="tt-name" id="tt-name"></div>
<div class="tt-bar-wrap"><div class="tt-bar-fill" id="tt-fill"></div></div>
<div class="tt-mastery">
<span id="tt-mastery-pct"></span>
<span id="tt-mastery-detail"></span>
</div>
<div class="tt-action" id="tt-action" style="display:none">Пройти тест по теме <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></div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script>
/* ═══════════════════════════════════════════════════════
CONSTANTS
═══════════════════════════════════════════════════════ */
const SUBJ_COLORS = { bio: '#9B5DE5', chem: '#06D6A0', math: '#FFD166', phys: '#4CC9F0' };
const R_SUBJ = 34, R_TOPIC_MIN = 16, R_TOPIC_MAX = 26;
const ENTRANCE_MS = 800; // stagger entrance duration per node
const ENTRANCE_STAGGER = 40; // ms between each node's entrance
/* roundRect polyfill for older browsers */
if (!CanvasRenderingContext2D.prototype.roundRect) {
CanvasRenderingContext2D.prototype.roundRect = function(x,y,w,h,r) {
r = Math.min(r, w/2, h/2);
this.moveTo(x+r,y); this.arcTo(x+w,y,x+w,y+h,r); this.arcTo(x+w,y+h,x,y+h,r);
this.arcTo(x,y+h,x,y,r); this.arcTo(x,y,x+w,y,r); this.closePath();
};
}
function nodeRadius(n) {
if (n.type === 'subject') return R_SUBJ;
const q = n.totalQ || 0;
// Scale: 0 qs → R_TOPIC_MIN, 30+ qs → R_TOPIC_MAX
return R_TOPIC_MIN + Math.min(1, q / 30) * (R_TOPIC_MAX - R_TOPIC_MIN);
}
function masteryColor(m) {
if (m === null || m === undefined) return 'rgba(255,255,255,.18)';
if (m >= 70) return '#06D6A0';
if (m >= 30) return '#FFD166';
return '#F94144';
}
function masteryGlow(m) {
if (m === null || m === undefined) return null;
if (m >= 70) return 'rgba(6,214,160,.45)';
if (m >= 30) return 'rgba(255,209,102,.45)';
return 'rgba(249,65,68,.45)';
}
/* ═══════════════════════════════════════════════════════
STATE
═══════════════════════════════════════════════════════ */
let _nodes = [], _links = [], _positions = {};
let _subjLookup = {}; // topic_id → subject node
let _dragging = null, _dragOff = { x: 0, y: 0 };
let _panning = false, _panStart = { mx: 0, my: 0, vx: 0, vy: 0 };
let _hoverId = null, _selectedId = null;
let _canvas = null, _ctx = null;
let _raf = null;
let _alpha = 0; // simulation heat
let _curSlug = '';
let _view = { x: 0, y: 0, scale: 1 }; // current view
let _tview = { x: 0, y: 0, scale: 1 }; // target view (for smooth zoom)
/* ── Animation state ── */
let _animStart = 0; // timestamp when data loaded
const RING_ANIM_MS = 1200; // mastery rings fill over 1.2s
let _prevMastery = {}; // topic_id → previous mastery % (from localStorage)
let _deltas = {}; // topic_id → { delta, opacity } for "+N%" badges
let _pulsePhase = 0; // global pulse phase for mastered nodes
let _particles = []; // floating particles near mastered nodes
let _mmCanvas = null, _mmCtx = null, _mmFrame = 0; // minimap state
let _stars = []; // background stars [{x,y,size,alpha}]
let _ttHovered = false; // is mouse over the tooltip?
let _ttNode = null; // current tooltip node reference
let _ttHideTimer = null; // delayed hide timer
let _nodeOrder = []; // ordered node ids for stagger entrance
let _dashOffset = 0; // animated dash offset for link flow
/* ═══════════════════════════════════════════════════════
INIT
═══════════════════════════════════════════════════════ */
(async () => {
if (!LS.requireAuth()) return;
const user = LS.getUser();
LS.initPage();
LS.showBoardIfAllowed();
LS.notif.init();
lucide.createIcons();
const feats = await LS.loadFeatures();
if (feats.knowledge_map === false) { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.();
document.querySelector('.sb-toggle')?.addEventListener('click', () => {
setTimeout(() => { resizeCanvas(); recenter(); }, 310);
});
initCanvas();
initControls();
initMinimap();
await loadMap();
startRender();
})();
/* ═══════════════════════════════════════════════════════
CONTROLS
═══════════════════════════════════════════════════════ */
function initControls() {
// Filters
document.getElementById('km-controls').addEventListener('click', e => {
const btn = e.target.closest('.km-filter');
if (!btn) return;
document.querySelectorAll('.km-filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_curSlug = btn.dataset.slug || '';
loadMap();
});
// Recenter
document.getElementById('btn-recenter').addEventListener('click', recenter);
// Re-simulate
document.getElementById('btn-resim').addEventListener('click', () => {
initPositions();
kickSim(1);
});
// Zoom buttons
document.getElementById('btn-zoom-in').addEventListener('click', () => zoomBy(1.25));
document.getElementById('btn-zoom-out').addEventListener('click', () => zoomBy(0.8));
}
/* ═══════════════════════════════════════════════════════
LOAD DATA
═══════════════════════════════════════════════════════ */
async function loadMap() {
document.getElementById('km-loading').style.display = 'flex';
document.getElementById('km-empty').classList.remove('show');
document.getElementById('km-stats').style.display = 'none';
document.getElementById('km-prog-wrap').style.display = 'none';
const qs = _curSlug ? `?subject_slug=${_curSlug}` : '';
const data = await LS.api(`/api/knowledge-map${qs}`).catch(() => null);
document.getElementById('km-loading').style.display = 'none';
if (!data || !data.nodes.length) {
document.getElementById('km-empty').classList.add('show');
_nodes = []; _links = []; _positions = {};
return;
}
_nodes = data.nodes;
_links = data.links;
// Build subject lookup for topics
_subjLookup = {};
const subjMap = {};
for (const n of _nodes) {
if (n.type === 'subject') subjMap[n.id] = n;
}
for (const lk of _links) {
if (subjMap[lk.source]) _subjLookup[lk.target] = subjMap[lk.source];
}
// ── Mastery deltas (compare with previous visit) ──
try { _prevMastery = JSON.parse(localStorage.getItem('ls_km_mastery') || '{}'); } catch { _prevMastery = {}; }
_deltas = {};
const newMastery = {};
for (const n of _nodes) {
if (n.type !== 'topic' || n.mastery === null) continue;
newMastery[n.id] = n.mastery;
const prev = _prevMastery[n.id];
if (prev !== undefined && n.mastery > prev) {
_deltas[n.id] = { delta: n.mastery - prev, opacity: 1 };
}
}
try { localStorage.setItem('ls_km_mastery', JSON.stringify(newMastery)); } catch {}
// Spawn particles for mastered nodes
_particles = [];
for (const n of _nodes) {
if (n.type === 'topic' && n.mastery !== null && n.mastery >= 70) {
for (let i = 0; i < 3; i++) {
_particles.push({
nodeId: n.id,
angle: Math.random() * Math.PI * 2,
dist: nodeRadius(n) + 10 + Math.random() * 14,
speed: 0.3 + Math.random() * 0.4,
size: 1.5 + Math.random() * 1.5,
alpha: 0.3 + Math.random() * 0.4,
});
}
}
}
// Generate background stars (once, or on reload)
_stars = [];
const _starW = _canvas.width / devicePixelRatio;
const _starH = _canvas.height / devicePixelRatio;
for (let i = 0; i < 120; i++) {
_stars.push({
x: Math.random() * _starW,
y: Math.random() * _starH,
size: 0.5 + Math.random() * 1.5,
alpha: 0.1 + Math.random() * 0.3,
twinkle: Math.random() * Math.PI * 2,
});
}
// Node ordering for stagger entrance (subjects first, then topics)
_nodeOrder = [];
for (const n of _nodes) if (n.type === 'subject') _nodeOrder.push(n.id);
for (const n of _nodes) if (n.type === 'topic') _nodeOrder.push(n.id);
// Start ring animation
_animStart = performance.now();
initPositions();
kickSim(1);
updateStats();
}
/* ═══════════════════════════════════════════════════════
STATS BAR
═══════════════════════════════════════════════════════ */
function updateStats() {
const topics = _nodes.filter(n => n.type === 'topic');
if (!topics.length) return;
const mastered = topics.filter(n => n.mastery !== null && n.mastery >= 70).length;
const inProg = topics.filter(n => n.mastery !== null && n.mastery < 70).length;
const fresh = topics.filter(n => n.mastery === null).length;
const studied = mastered + inProg;
const pct = Math.round((mastered / topics.length) * 100);
const gradColor = pct >= 70 ? '#06D6A0' : pct >= 30 ? '#FFD166' : '#F94144';
document.getElementById('stat-mastered-val').textContent = mastered;
document.getElementById('stat-progress-val').textContent = inProg;
document.getElementById('stat-new-val').textContent = fresh;
document.getElementById('km-prog-fill').style.width = pct + '%';
document.getElementById('km-prog-fill').style.background = gradColor;
document.getElementById('km-prog-pct').textContent = pct + '%';
document.getElementById('km-prog-pct').style.color = gradColor;
document.getElementById('km-sub-text').textContent =
`${studied} из ${topics.length} тем изучается · ${mastered} освоено`;
document.getElementById('km-stats').style.display = '';
document.getElementById('km-prog-wrap').style.display = '';
}
/* ═══════════════════════════════════════════════════════
POSITIONS — cluster topics near their subject
═══════════════════════════════════════════════════════ */
function initPositions() {
const W = _canvas.width / devicePixelRatio;
const H = _canvas.height / devicePixelRatio;
const subjs = _nodes.filter(n => n.type === 'subject');
const topics = _nodes.filter(n => n.type === 'topic');
_positions = {};
const cx = W / 2, cy = H / 2;
const sr = Math.min(W, H) * 0.28;
// Place subjects in a ring
subjs.forEach((s, i) => {
const angle = (i / subjs.length) * Math.PI * 2 - Math.PI / 2;
_positions[s.id] = { x: cx + sr * Math.cos(angle), y: cy + sr * Math.sin(angle), vx: 0, vy: 0, pinned: false };
});
// Place topics near their subject
topics.forEach((t, i) => {
const subj = _subjLookup[t.id];
const sp = subj ? _positions[subj.id] : { x: cx, y: cy };
const angle = Math.random() * Math.PI * 2;
const r = 60 + Math.random() * 40;
_positions[t.id] = { x: sp.x + r * Math.cos(angle), y: sp.y + r * Math.sin(angle), vx: 0, vy: 0, pinned: false };
});
}
/* ═══════════════════════════════════════════════════════
SIMULATION
═══════════════════════════════════════════════════════ */
function kickSim(heat) { _alpha = heat; }
function simulate() {
if (_alpha < 0.001) return;
const W = _canvas.width / devicePixelRatio;
const H = _canvas.height / devicePixelRatio;
// Repulsion
for (let i = 0; i < _nodes.length; i++) {
for (let j = i + 1; j < _nodes.length; j++) {
const a = _positions[_nodes[i].id];
const b = _positions[_nodes[j].id];
if (!a || !b) continue;
const dx = b.x - a.x, dy = b.y - a.y;
const dist2 = dx * dx + dy * dy || 0.01;
const dist = Math.sqrt(dist2);
const ri = nodeRadius(_nodes[i]), rj = nodeRadius(_nodes[j]);
const minDist = ri + rj + ((_nodes[i].type === 'subject' || _nodes[j].type === 'subject') ? 70 : 30);
if (dist < minDist) {
const f = (minDist - dist) / dist * 0.35 * _alpha;
a.vx -= dx * f; a.vy -= dy * f;
b.vx += dx * f; b.vy += dy * f;
}
}
}
// Spring on links
for (const lk of _links) {
const a = _positions[lk.source];
const b = _positions[lk.target];
if (!a || !b) continue;
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.1;
const rest = lk.source.startsWith('subj') ? 110 : 90;
const f = (dist - rest) / dist * 0.06 * _alpha;
a.vx += dx * f; a.vy += dy * f;
b.vx -= dx * f; b.vy -= dy * f;
}
// Gravity to center
for (const n of _nodes) {
const p = _positions[n.id];
if (!p || p.pinned) continue;
p.vx += (W / 2 - p.x) * 0.004 * _alpha;
p.vy += (H / 2 - p.y) * 0.004 * _alpha;
}
// Integrate
for (const n of _nodes) {
if (_dragging === n.id) continue;
const p = _positions[n.id];
if (!p || p.pinned) continue;
p.vx *= 0.65; p.vy *= 0.65;
p.x += p.vx; p.y += p.vy;
const pad = 44;
p.x = Math.max(pad, Math.min(W - pad, p.x));
p.y = Math.max(pad, Math.min(H - pad, p.y));
}
_alpha *= 0.96;
}
/* ═══════════════════════════════════════════════════════
RENDER LOOP
═══════════════════════════════════════════════════════ */
function startRender() {
if (_raf) cancelAnimationFrame(_raf);
function loop() {
simulate();
// Smooth view interpolation
_view.x += (_tview.x - _view.x) * 0.12;
_view.y += (_tview.y - _view.y) * 0.12;
_view.scale += (_tview.scale - _view.scale) * 0.12;
draw();
drawMinimap();
_raf = requestAnimationFrame(loop);
}
_raf = requestAnimationFrame(loop);
}
/* ═══════════════════════════════════════════════════════
DRAW
═══════════════════════════════════════════════════════ */
function draw() {
if (!_ctx || !_canvas) return;
const DPR = devicePixelRatio;
const W = _canvas.width / DPR, H = _canvas.height / DPR;
const now = performance.now();
const animT = Math.min(1, (now - _animStart) / RING_ANIM_MS);
const ease = 1 - Math.pow(1 - animT, 3);
_pulsePhase = (now % 2400) / 2400;
_dashOffset = (now % 3000) / 3000 * 20; // dash flow cycle
_ctx.clearRect(0, 0, _canvas.width, _canvas.height);
// ══ 1. COSMIC BACKGROUND (screen-space, before world transform) ══
_ctx.save();
_ctx.scale(DPR, DPR);
// Dark base fill
_ctx.fillStyle = '#08080f';
_ctx.fillRect(0, 0, W, H);
// Nebula glow — fixed to screen
const ncx = W / 2, ncy = H / 2;
const nebR = Math.max(W, H) * 0.7;
const neb = _ctx.createRadialGradient(ncx, ncy, 0, ncx, ncy, nebR);
neb.addColorStop(0, 'rgba(155,93,229,0.08)');
neb.addColorStop(0.35, 'rgba(6,214,224,0.03)');
neb.addColorStop(0.7, 'rgba(155,93,229,0.015)');
neb.addColorStop(1, 'transparent');
_ctx.fillStyle = neb;
_ctx.fillRect(0, 0, W, H);
// Secondary glow offset
const neb2 = _ctx.createRadialGradient(W * 0.75, H * 0.3, 0, W * 0.75, H * 0.3, nebR * 0.5);
neb2.addColorStop(0, 'rgba(6,214,224,0.05)');
neb2.addColorStop(1, 'transparent');
_ctx.fillStyle = neb2;
_ctx.fillRect(0, 0, W, H);
// Stars (screen-space, parallax-shifted slightly by view)
const px = _view.x * 0.05, py = _view.y * 0.05; // subtle parallax
for (const st of _stars) {
const twinkle = Math.sin(now * 0.001 + st.twinkle) * 0.5 + 0.5;
_ctx.globalAlpha = st.alpha * (0.4 + twinkle * 0.6);
const sx = ((st.x + px) % W + W) % W;
const sy = ((st.y + py) % H + H) % H;
_ctx.beginPath();
_ctx.arc(sx, sy, st.size, 0, Math.PI * 2);
_ctx.fillStyle = '#fff';
_ctx.fill();
}
_ctx.globalAlpha = 1;
_ctx.restore();
// World-space transform
_ctx.save();
_ctx.scale(DPR, DPR);
_ctx.translate(_view.x, _view.y);
_ctx.scale(_view.scale, _view.scale);
const sel = _selectedId;
// ══ 2. CLUSTER HALOS — soft glow behind each subject's topics ══
const subjNodes = _nodes.filter(n => n.type === 'subject');
for (const sn of subjNodes) {
const sp = _positions[sn.id];
if (!sp) continue;
const color = SUBJ_COLORS[sn.slug] || '#9B5DE5';
// Find bounding radius of cluster
const childIds = _links.filter(l => l.source === sn.id).map(l => l.target);
let maxDist = 60;
for (const cid of childIds) {
const cp = _positions[cid];
if (!cp) continue;
const d = Math.sqrt((cp.x - sp.x) ** 2 + (cp.y - sp.y) ** 2);
if (d > maxDist) maxDist = d;
}
const haloR = maxDist + 40;
const halo = _ctx.createRadialGradient(sp.x, sp.y, 0, sp.x, sp.y, haloR);
halo.addColorStop(0, color + '0A'); // very subtle center
halo.addColorStop(0.6, color + '05');
halo.addColorStop(1, 'transparent');
const isDimmed = sel && sel !== sn.id && !childIds.some(id => id === sel);
_ctx.globalAlpha = isDimmed ? 0.05 : 0.7;
_ctx.beginPath();
_ctx.arc(sp.x, sp.y, haloR, 0, Math.PI * 2);
_ctx.fillStyle = halo;
_ctx.fill();
}
_ctx.globalAlpha = 1;
// ══ 3. LINKS — gradient + dash flow ══
for (const lk of _links) {
const a = _positions[lk.source], b = _positions[lk.target];
if (!a || !b) continue;
const isConnected = !sel || sel === lk.source || sel === lk.target;
const subj = _nodes.find(n => n.id === lk.source);
const subjColor = subj ? (SUBJ_COLORS[subj.slug] || '#9B5DE5') : '#9B5DE5';
const topicNode = _nodes.find(n => n.id === lk.target);
const topicColor = topicNode ? masteryColor(topicNode.mastery) : 'rgba(255,255,255,.15)';
if (sel && isConnected) {
// Gradient link when selected
const lg = _ctx.createLinearGradient(a.x, a.y, b.x, b.y);
lg.addColorStop(0, subjColor + 'CC');
lg.addColorStop(1, topicColor + '88');
_ctx.strokeStyle = lg;
_ctx.lineWidth = 1.8;
// Animated dash flow
_ctx.setLineDash([4, 6]);
_ctx.lineDashOffset = -_dashOffset;
} else if (!sel) {
_ctx.strokeStyle = 'rgba(255,255,255,.07)';
_ctx.lineWidth = 1;
_ctx.setLineDash([]);
} else {
_ctx.strokeStyle = 'rgba(255,255,255,.02)';
_ctx.lineWidth = 0.5;
_ctx.setLineDash([]);
}
_ctx.beginPath();
_ctx.moveTo(a.x, a.y);
_ctx.lineTo(b.x, b.y);
_ctx.stroke();
_ctx.setLineDash([]);
}
// ══ 4. NODES ══
for (const n of _nodes) {
const p = _positions[n.id];
if (!p) continue;
const isSubj = n.type === 'subject';
const baseR = nodeRadius(n);
const isHover = _hoverId === n.id;
const isSel = _selectedId === n.id;
const isDimmed = sel && sel !== n.id && !isConnectedTo(n.id, sel);
const isMastered = !isSubj && n.mastery !== null && n.mastery >= 70;
const isNew = !isSubj && n.mastery === null;
// ── Stagger entrance scale ──
const idx = _nodeOrder.indexOf(n.id);
const entranceT = Math.min(1, Math.max(0, (now - _animStart - idx * ENTRANCE_STAGGER) / ENTRANCE_MS));
const entranceScale = 1 - Math.pow(1 - entranceT, 3); // easeOutCubic
if (entranceScale <= 0) continue; // not visible yet
const r = baseR * entranceScale;
const alpha = (isDimmed ? 0.22 : 1) * entranceScale;
_ctx.globalAlpha = alpha;
// Pulse glow for mastered nodes
if (isMastered && !isDimmed && !isHover && !isSel) {
const pv = Math.sin(_pulsePhase * Math.PI * 2) * 0.5 + 0.5;
_ctx.shadowColor = 'rgba(6,214,160,' + (0.15 + pv * 0.2) + ')';
_ctx.shadowBlur = 6 + pv * 8;
}
// Glow on hover or selected
if ((isHover || isSel) && !isDimmed) {
_ctx.shadowColor = isSubj ? (SUBJ_COLORS[n.slug] || '#9B5DE5') : masteryGlow(n.mastery) || '#9B5DE5';
_ctx.shadowBlur = isSel ? 24 : 16;
}
// Fill
const grad = _ctx.createRadialGradient(p.x - r * 0.3, p.y - r * 0.3, 0, p.x, p.y, r);
if (isSubj) {
const c = SUBJ_COLORS[n.slug] || '#9B5DE5';
grad.addColorStop(0, c + 'EE');
grad.addColorStop(1, c + '88');
} else {
const c = masteryColor(n.mastery);
grad.addColorStop(0, c + 'CC');
grad.addColorStop(1, c + '66');
}
_ctx.beginPath();
_ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
_ctx.fillStyle = grad;
_ctx.fill();
// Border — pulsing for untouched nodes
if (isNew && !isDimmed) {
const pv = Math.sin(_pulsePhase * Math.PI * 2) * 0.5 + 0.5;
_ctx.strokeStyle = `rgba(255,255,255,${0.12 + pv * 0.25})`;
_ctx.lineWidth = 1 + pv;
} else {
_ctx.strokeStyle = (isHover || isSel)
? 'rgba(255,255,255,.7)'
: isSubj ? 'rgba(255,255,255,.25)' : 'rgba(255,255,255,.15)';
_ctx.lineWidth = (isHover || isSel) ? 2 : 1;
}
_ctx.stroke();
_ctx.shadowBlur = 0;
// Mastery ring (animated)
if (!isSubj && n.mastery !== null && entranceScale > 0.5) {
const animM = n.mastery * ease;
const arc = (animM / 100) * Math.PI * 2;
_ctx.beginPath();
_ctx.arc(p.x, p.y, r + 5, -Math.PI / 2, -Math.PI / 2 + arc);
_ctx.strokeStyle = masteryColor(n.mastery);
_ctx.lineWidth = 3;
_ctx.lineCap = 'round';
_ctx.stroke();
_ctx.lineCap = 'butt';
// Track
_ctx.beginPath();
_ctx.arc(p.x, p.y, r + 5, -Math.PI / 2 + arc, -Math.PI / 2 + Math.PI * 2);
_ctx.strokeStyle = 'rgba(255,255,255,.06)';
_ctx.lineWidth = 3;
_ctx.stroke();
}
// Label
_ctx.globalAlpha = (isDimmed ? 0.18 : (isSubj ? 1 : 0.9)) * entranceScale;
_ctx.textAlign = 'center';
_ctx.textBaseline = 'middle';
if (isSubj) {
_ctx.font = '700 11px Manrope, sans-serif';
_ctx.fillStyle = 'rgba(255,255,255,.95)';
_ctx.fillText(n.label.toUpperCase(), p.x, p.y);
} else {
const maxChars = Math.floor((r * 2 - 4) / 6);
const label = n.label.length > maxChars ? n.label.slice(0, maxChars - 1) + '…' : n.label;
_ctx.font = `600 ${Math.min(9.5, 34 / Math.max(label.length, 1) + 5)}px Manrope, sans-serif`;
_ctx.fillStyle = '#fff';
_ctx.fillText(label, p.x, p.y);
}
// Mastery % on hover
if (!isSubj && isHover && n.mastery !== null) {
_ctx.globalAlpha = 1;
_ctx.font = '700 9px Manrope, sans-serif';
_ctx.fillStyle = masteryColor(n.mastery);
_ctx.fillText(Math.round(n.mastery * ease) + '%', p.x, p.y + r + 13);
}
// Delta badge
const delta = _deltas[n.id];
if (delta && delta.opacity > 0 && !isDimmed) {
_ctx.globalAlpha = delta.opacity * entranceScale;
const bt = '+' + delta.delta + '%';
_ctx.font = '800 10px Unbounded, sans-serif';
const bw = _ctx.measureText(bt).width + 10;
const bx = p.x - bw / 2, by = p.y - r - 16;
_ctx.fillStyle = 'rgba(6,214,160,.9)';
_ctx.beginPath(); _ctx.roundRect(bx, by, bw, 16, 6); _ctx.fill();
_ctx.fillStyle = '#fff'; _ctx.textAlign = 'center'; _ctx.textBaseline = 'middle';
_ctx.fillText(bt, p.x, by + 8);
delta.opacity -= 0.003;
}
_ctx.globalAlpha = 1;
}
// ══ 5. PARTICLES ══
for (const pt of _particles) {
const p = _positions[pt.nodeId];
if (!p) continue;
const isDimmed = sel && sel !== pt.nodeId && !isConnectedTo(pt.nodeId, sel);
if (isDimmed) continue;
pt.angle += pt.speed * 0.02;
const px = p.x + Math.cos(pt.angle) * pt.dist;
const py = p.y + Math.sin(pt.angle) * pt.dist;
_ctx.globalAlpha = pt.alpha * (0.5 + Math.sin(pt.angle * 3) * 0.5);
_ctx.beginPath();
_ctx.arc(px, py, pt.size, 0, Math.PI * 2);
_ctx.fillStyle = '#06D6A0';
_ctx.fill();
}
_ctx.globalAlpha = 1;
_ctx.restore();
}
function isConnectedTo(nodeId, selId) {
return _links.some(lk =>
(lk.source === selId && lk.target === nodeId) ||
(lk.target === selId && lk.source === nodeId)
);
}
/* ═══════════════════════════════════════════════════════
CANVAS SETUP
═══════════════════════════════════════════════════════ */
function initCanvas() {
_canvas = document.getElementById('km-canvas');
_ctx = _canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', () => { resizeCanvas(); recenter(); });
_canvas.addEventListener('mousemove', onMouseMove);
_canvas.addEventListener('mousedown', onMouseDown);
_canvas.addEventListener('mouseup', onMouseUp);
_canvas.addEventListener('dblclick', onDblClick);
_canvas.addEventListener('mouseleave', () => {
_dragging = null; _panning = false;
_hoverId = null;
if (!_ttHovered) { clearTimeout(_ttHideTimer); _ttHideTimer = setTimeout(hideTooltip, 200); }
});
_canvas.addEventListener('wheel', onWheel, { passive: false });
_canvas.addEventListener('contextmenu', onContextMenu);
// Keep tooltip alive when mouse enters it
const ttEl = document.getElementById('km-tooltip');
ttEl.addEventListener('mouseenter', () => { _ttHovered = true; clearTimeout(_ttHideTimer); });
ttEl.addEventListener('mouseleave', () => { _ttHovered = false; _ttHideTimer = setTimeout(hideTooltip, 150); });
// Click "Пройти тест" in tooltip
document.getElementById('tt-action').addEventListener('click', () => {
if (_ttNode && _ttNode.type === 'topic' && _ttNode.totalQ > 0) {
hideTooltip();
startQuickTest(_ttNode);
}
});
_canvas.addEventListener('touchstart', onTouchStart, { passive: false });
_canvas.addEventListener('touchmove', onTouchMove, { passive: false });
_canvas.addEventListener('touchend', onTouchEnd);
// Close context menu on click anywhere
document.addEventListener('click', () => closeCtx());
document.addEventListener('contextmenu', e => {
if (!e.target.closest('#km-canvas') && !e.target.closest('.km-ctx')) closeCtx();
});
}
function resizeCanvas() {
if (!_canvas) return;
const wrap = document.getElementById('km-canvas-wrap');
_canvas.width = wrap.clientWidth * devicePixelRatio;
_canvas.height = wrap.clientHeight * devicePixelRatio;
_canvas.style.width = wrap.clientWidth + 'px';
_canvas.style.height = wrap.clientHeight + 'px';
}
/* ═══════════════════════════════════════════════════════
VIEW HELPERS
═══════════════════════════════════════════════════════ */
function recenter() {
const W = _canvas.width / devicePixelRatio;
const H = _canvas.height / devicePixelRatio;
_tview = { x: 0, y: 0, scale: 1 };
}
function zoomBy(factor, cx, cy) {
const W = _canvas.width / devicePixelRatio;
const H = _canvas.height / devicePixelRatio;
if (cx === undefined) { cx = W / 2; cy = H / 2; }
const ns = Math.max(0.2, Math.min(4, _tview.scale * factor));
_tview.x = cx - (cx - _tview.x) * (ns / _tview.scale);
_tview.y = cy - (cy - _tview.y) * (ns / _tview.scale);
_tview.scale = ns;
}
function toWorld(cx, cy) {
return { x: (cx - _view.x) / _view.scale, y: (cy - _view.y) / _view.scale };
}
/* ═══════════════════════════════════════════════════════
HIT TEST
═══════════════════════════════════════════════════════ */
function hitNode(cx, cy) {
const { x, y } = toWorld(cx, cy);
for (let i = _nodes.length - 1; i >= 0; i--) {
const n = _nodes[i];
const p = _positions[n.id];
if (!p) continue;
const r = nodeRadius(n);
const dx = p.x - x, dy = p.y - y;
if (dx * dx + dy * dy <= r * r) return n;
}
return null;
}
/* ═══════════════════════════════════════════════════════
MOUSE EVENTS
═══════════════════════════════════════════════════════ */
function canvasXY(e) {
const rect = _canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
let _mouseDownPos = null;
let _mouseDownTime = 0;
function onMouseMove(e) {
const { x, y } = canvasXY(e);
if (_dragging) {
const w = toWorld(x - _dragOff.x, y - _dragOff.y);
const p = _positions[_dragging];
if (p) { p.x = w.x + _dragOff.x; p.y = w.y + _dragOff.y; p.vx = 0; p.vy = 0; }
kickSim(0.1);
hideTooltip();
return;
}
if (_panning) {
_tview.x = _panStart.vx + (x - _panStart.mx);
_tview.y = _panStart.vy + (y - _panStart.my);
hideTooltip();
return;
}
const hit = hitNode(x, y);
_hoverId = hit ? hit.id : null;
_canvas.style.cursor = hit ? 'pointer' : 'grab';
if (hit) {
clearTimeout(_ttHideTimer);
showTooltip(hit, e.clientX, e.clientY);
} else if (!_ttHovered) {
// Delay hide so user can move mouse to tooltip
clearTimeout(_ttHideTimer);
_ttHideTimer = setTimeout(hideTooltip, 200);
}
}
function onMouseDown(e) {
const { x, y } = canvasXY(e);
_mouseDownPos = { x, y };
_mouseDownTime = Date.now();
const hit = hitNode(x, y);
if (hit) {
_dragging = hit.id;
const p = _positions[hit.id];
const w = toWorld(x, y);
_dragOff = { x: w.x - p.x, y: w.y - p.y };
kickSim(0.15);
} else {
_panning = true;
_panStart = { mx: x, my: y, vx: _tview.x, vy: _tview.y };
_canvas.style.cursor = 'grabbing';
}
}
function onMouseUp(e) {
const { x, y } = canvasXY(e);
const moved = _mouseDownPos && (Math.abs(x - _mouseDownPos.x) + Math.abs(y - _mouseDownPos.y)) > 4;
const fast = (Date.now() - _mouseDownTime) < 300;
if (!moved && fast) {
const hit = hitNode(x, y);
if (hit) {
_selectedId = (_selectedId === hit.id) ? null : hit.id;
} else {
_selectedId = null;
}
}
_dragging = null;
_panning = false;
_canvas.style.cursor = 'grab';
}
function onDblClick(e) {
const { x, y } = canvasXY(e);
const hit = hitNode(x, y);
if (hit && hit.type === 'subject') {
drillInto(hit);
} else if (hit && hit.type === 'topic') {
// Focus on topic with smooth zoom
focusNode(hit.id);
} else {
recenter();
_selectedId = null;
}
}
function onWheel(e) {
e.preventDefault();
const { x, y } = canvasXY(e);
zoomBy(e.deltaY > 0 ? 0.88 : 1.14, x, y);
}
/* ── Touch ── */
function onTouchStart(e) {
e.preventDefault();
const t = e.touches[0];
const rect = _canvas.getBoundingClientRect();
const x = t.clientX - rect.left, y = t.clientY - rect.top;
const hit = hitNode(x, y);
if (hit) {
_dragging = hit.id;
const p = _positions[hit.id];
const w = toWorld(x, y);
_dragOff = { x: w.x - p.x, y: w.y - p.y };
kickSim(0.15);
showTooltip(hit, t.clientX, t.clientY);
} else {
_panning = true;
_panStart = { mx: x, my: y, vx: _tview.x, vy: _tview.y };
}
}
function onTouchMove(e) {
e.preventDefault();
const t = e.touches[0];
const rect = _canvas.getBoundingClientRect();
const x = t.clientX - rect.left, y = t.clientY - rect.top;
if (_dragging) {
const w = toWorld(x, y);
const p = _positions[_dragging];
if (p) { p.x = w.x - _dragOff.x + _positions[_dragging].x; p.y = w.y - _dragOff.y + _positions[_dragging].y; }
kickSim(0.1);
} else if (_panning) {
_tview.x = _panStart.vx + (x - _panStart.mx);
_tview.y = _panStart.vy + (y - _panStart.my);
}
}
function onTouchEnd() { _dragging = null; _panning = false; }
/* ═══════════════════════════════════════════════════════
TOOLTIP
═══════════════════════════════════════════════════════ */
function showTooltip(node, cx, cy) {
_ttNode = node;
const tt = document.getElementById('km-tooltip');
const subj = node.type === 'subject' ? null : (_subjLookup[node.id] || null);
const subjEl = document.getElementById('tt-subject');
if (subj) {
subjEl.textContent = subj.label;
subjEl.style.color = SUBJ_COLORS[subj.slug] || '#9B5DE5';
subjEl.style.display = '';
} else {
subjEl.style.display = 'none';
}
document.getElementById('tt-name').textContent = node.label;
const fill = document.getElementById('tt-fill');
const pctEl = document.getElementById('tt-mastery-pct');
const detEl = document.getElementById('tt-mastery-detail');
const actEl = document.getElementById('tt-action');
if (node.type === 'subject') {
fill.style.width = '100%';
fill.style.background = SUBJ_COLORS[node.slug] || '#9B5DE5';
pctEl.textContent = 'Предмет';
detEl.textContent = '';
actEl.style.display = 'none';
} else if (node.mastery === null) {
fill.style.width = '2%';
fill.style.background = 'rgba(255,255,255,.2)';
pctEl.textContent = 'Не начато';
detEl.textContent = node.totalQ > 0 ? `${node.totalQ} вопросов` : 'Вопросов нет';
actEl.style.display = node.totalQ > 0 ? '' : 'none';
} else {
const color = masteryColor(node.mastery);
fill.style.width = node.mastery + '%';
fill.style.background = color;
pctEl.textContent = node.mastery + '%';
pctEl.style.color = color;
detEl.textContent = `${node.correctQ} / ${node.totalQ} верных`;
actEl.style.display = '';
}
// Position
const ttW = 250, ttH = 110;
let tx = cx + 16, ty = cy - 14;
if (tx + ttW > window.innerWidth) tx = cx - ttW - 12;
if (ty + ttH > window.innerHeight) ty = cy - ttH - 12;
if (ty < 4) ty = 4;
tt.style.left = tx + 'px';
tt.style.top = ty + 'px';
tt.style.display = 'block';
}
function hideTooltip() {
if (_ttHovered) return;
document.getElementById('km-tooltip').style.display = 'none';
_ttNode = null;
}
/* ═══════════════════════════════════════════════════════
RECOMMENDATION PANEL
═══════════════════════════════════════════════════════ */
function toggleReco() {
const panel = document.getElementById('km-reco');
const isOpen = panel.classList.toggle('open');
if (isOpen) buildRecoList();
}
function buildRecoList() {
const list = document.getElementById('km-reco-list');
const topics = _nodes.filter(n => n.type === 'topic' && n.totalQ > 0);
// Sort: worst mastery first (null = not started = priority), then by mastery ascending
const sorted = topics.slice().sort((a, b) => {
const am = a.mastery ?? -1;
const bm = b.mastery ?? -1;
return am - bm;
}).slice(0, 8);
if (!sorted.length) {
list.innerHTML = '<div class="km-reco-empty">Нет данных для рекомендаций</div>';
return;
}
list.innerHTML = sorted.map(n => {
const subj = _subjLookup[n.id];
const col = subj ? (SUBJ_COLORS[subj.slug] || '#9B5DE5') : '#9B5DE5';
const m = n.mastery;
const mCol = masteryColor(m);
const pct = m !== null ? m + '%' : '—';
const barW = m !== null ? m : 0;
const reason = m === null
? 'Ещё не начато — попробуйте!'
: m < 30 ? 'Низкий результат — стоит повторить'
: m < 70 ? 'Можно улучшить до уверенного уровня'
: 'Почти освоено — закрепите!';
return `<div class="km-reco-item" onclick="focusNode('${n.id}')">
<div class="km-reco-item-top">
<div class="km-reco-item-dot" style="background:${col}"></div>
<div class="km-reco-item-name">${LS.esc(n.label)}</div>
<div class="km-reco-item-pct" style="color:${mCol}">${pct}</div>
</div>
<div class="km-reco-item-bar"><div class="km-reco-item-fill" style="width:${barW}%;background:${mCol}"></div></div>
<div class="km-reco-item-sub">${subj ? esc(subj.label) : ''} · ${n.totalQ} вопросов</div>
<div class="km-reco-item-reason">${reason}</div>
</div>`;
}).join('');
}
function focusNode(nodeId) {
const p = _positions[nodeId];
if (!p) return;
_selectedId = nodeId;
const W = _canvas.width / devicePixelRatio;
const H = _canvas.height / devicePixelRatio;
_tview.x = W / 2 - p.x * 1.5;
_tview.y = H / 2 - p.y * 1.5;
_tview.scale = 1.5;
}
/* esc is already defined in api.js as window.LS.escapeHtml */
/* ═══════════════════════════════════════════════════════
DRILL-DOWN — double-click subject to zoom into it
═══════════════════════════════════════════════════════ */
function drillInto(subjNode) {
// Find all topic positions connected to this subject
const topicIds = _links.filter(l => l.source === subjNode.id).map(l => l.target);
const allIds = [subjNode.id, ...topicIds];
const poses = allIds.map(id => _positions[id]).filter(Boolean);
if (!poses.length) return;
// Compute bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of poses) {
minX = Math.min(minX, p.x - 40);
minY = Math.min(minY, p.y - 40);
maxX = Math.max(maxX, p.x + 40);
maxY = Math.max(maxY, p.y + 40);
}
const W = _canvas.width / devicePixelRatio;
const H = _canvas.height / devicePixelRatio;
const bw = maxX - minX || 100;
const bh = maxY - minY || 100;
const scale = Math.min(W / bw, H / bh) * 0.8;
const clampedScale = Math.max(0.5, Math.min(3, scale));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
_tview.scale = clampedScale;
_tview.x = W / 2 - cx * clampedScale;
_tview.y = H / 2 - cy * clampedScale;
_selectedId = subjNode.id;
}
/* ═══════════════════════════════════════════════════════
SEARCH
═══════════════════════════════════════════════════════ */
function searchTopic(query) {
const q = query.trim().toLowerCase();
if (!q) { _selectedId = null; return; }
const found = _nodes.find(n => n.type === 'topic' && n.label.toLowerCase().includes(q));
if (found) focusNode(found.id);
}
/* ═══════════════════════════════════════════════════════
MINIMAP — rendered every ~10 frames
═══════════════════════════════════════════════════════ */
function initMinimap() {
_mmCanvas = document.getElementById('km-minimap-canvas');
if (!_mmCanvas) return;
_mmCtx = _mmCanvas.getContext('2d');
const wrap = document.getElementById('km-minimap');
_mmCanvas.width = wrap.clientWidth * devicePixelRatio;
_mmCanvas.height = wrap.clientHeight * devicePixelRatio;
}
function drawMinimap() {
if (!_mmCtx || !_nodes.length) return;
_mmFrame++;
if (_mmFrame % 10 !== 0) return; // Draw every 10th frame for perf
const DPR = devicePixelRatio;
const mw = _mmCanvas.width / DPR, mh = _mmCanvas.height / DPR;
_mmCtx.clearRect(0, 0, _mmCanvas.width, _mmCanvas.height);
_mmCtx.save();
_mmCtx.scale(DPR, DPR);
// Compute world bounds
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of _nodes) {
const p = _positions[n.id];
if (!p) continue;
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
}
const pad = 30;
minX -= pad; minY -= pad; maxX += pad; maxY += pad;
const ww = maxX - minX || 100, wh = maxY - minY || 100;
const scale = Math.min(mw / ww, mh / wh);
const ox = (mw - ww * scale) / 2 - minX * scale;
const oy = (mh - wh * scale) / 2 - minY * scale;
// Draw nodes as dots
for (const n of _nodes) {
const p = _positions[n.id];
if (!p) continue;
const x = p.x * scale + ox, y = p.y * scale + oy;
const r = n.type === 'subject' ? 3 : 2;
_mmCtx.beginPath();
_mmCtx.arc(x, y, r, 0, Math.PI * 2);
_mmCtx.fillStyle = n.type === 'subject'
? (SUBJ_COLORS[n.slug] || '#9B5DE5')
: masteryColor(n.mastery);
_mmCtx.globalAlpha = 0.8;
_mmCtx.fill();
}
// Draw viewport rectangle
const cw = _canvas.width / DPR, ch = _canvas.height / DPR;
const vx1 = (-_view.x / _view.scale) * scale + ox;
const vy1 = (-_view.y / _view.scale) * scale + oy;
const vw = (cw / _view.scale) * scale;
const vh = (ch / _view.scale) * scale;
_mmCtx.globalAlpha = 1;
_mmCtx.strokeStyle = 'rgba(155,93,229,.6)';
_mmCtx.lineWidth = 1;
_mmCtx.strokeRect(vx1, vy1, vw, vh);
_mmCtx.restore();
}
/* ═══════════════════════════════════════════════════════
CONTEXT MENU
═══════════════════════════════════════════════════════ */
let _ctxNode = null;
function onContextMenu(e) {
e.preventDefault();
hideTooltip();
_ttHovered = false;
clearTimeout(_ttHideTimer);
const { x, y } = canvasXY(e);
const hit = hitNode(x, y);
if (!hit || hit.type === 'subject') { closeCtx(); return; }
_ctxNode = hit;
const ctx = document.getElementById('km-ctx');
document.getElementById('km-ctx-head').textContent = hit.label;
// Show/hide test option based on question count
document.getElementById('ctx-test').style.display = hit.totalQ > 0 ? '' : 'none';
// Position
let tx = e.clientX, ty = e.clientY;
if (tx + 200 > window.innerWidth) tx = e.clientX - 200;
if (ty + 160 > window.innerHeight) ty = e.clientY - 160;
ctx.style.left = tx + 'px';
ctx.style.top = ty + 'px';
ctx.classList.add('open');
hideTooltip();
}
function closeCtx() {
document.getElementById('km-ctx').classList.remove('open');
}
// Wire context menu actions
document.getElementById('ctx-test').addEventListener('click', () => {
closeCtx();
if (_ctxNode) startQuickTest(_ctxNode);
});
document.getElementById('ctx-theory').addEventListener('click', () => {
closeCtx();
if (_ctxNode) {
const subj = _subjLookup[_ctxNode.id];
if (subj) window.location.href = `/theory?subject=${subj.slug}`;
else window.location.href = '/theory';
}
});
document.getElementById('ctx-focus').addEventListener('click', () => {
closeCtx();
if (_ctxNode) focusNode(_ctxNode.id);
});
/* ═══════════════════════════════════════════════════════
QUICK TEST — inline 5-question test from topic
═══════════════════════════════════════════════════════ */
let _qt = {
node: null,
sessionId: null,
questions: [],
current: 0,
score: 0,
answered: false,
};
async function startQuickTest(node) {
const subj = _subjLookup[node.id];
if (!subj) return;
const topicId = node.id.replace('topic_', '');
document.getElementById('qtest-title').textContent = 'Тест: ' + node.label;
document.getElementById('qtest-body').innerHTML =
'<div style="text-align:center;padding:40px;color:var(--text-3)"><div class="km-spinner" style="margin:0 auto 12px;width:24px;height:24px;border-width:3px"></div>Загрузка вопросов...</div>';
document.getElementById('km-qtest').classList.add('open');
try {
const data = await LS.startSession(subj.slug, 'practice', 5, topicId, null);
_qt = {
node,
sessionId: data.session_id,
questions: data.questions || [],
current: 0,
score: 0,
answered: false,
};
if (!_qt.questions.length) {
document.getElementById('qtest-body').innerHTML =
'<div style="text-align:center;padding:40px;color:var(--text-3)">Нет вопросов по этой теме</div>';
return;
}
renderQuestion();
} catch (e) {
document.getElementById('qtest-body').innerHTML =
`<div style="text-align:center;padding:40px;color:#ef4444">${LS.esc(e.message || 'Ошибка запуска теста')}</div>`;
}
}
function renderQuestion() {
const q = _qt.questions[_qt.current];
if (!q) return;
_qt.answered = false;
const total = _qt.questions.length;
const pct = Math.round((_qt.current / total) * 100);
const LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
let html = `
<div class="km-qtest-progress">
<div class="km-qtest-pbar"><div class="km-qtest-pfill" style="width:${pct}%"></div></div>
<div class="km-qtest-ptext">${_qt.current + 1}/${total}</div>
</div>
<div class="km-qtest-q">${q.text}</div>
<div class="km-qtest-opts" id="qtest-opts">`;
if (q.options && q.options.length) {
q.options.forEach((opt, i) => {
html += `<div class="km-qtest-opt" data-oid="${opt.id}" onclick="answerQuick(${opt.id}, this)">
<div class="km-qtest-opt-marker">${LABELS[i] || ''}</div>
<div>${LS.esc(opt.text)}</div>
</div>`;
});
} else {
// Short answer
html += `<div style="display:flex;gap:10px">
<input type="text" id="qtest-input" class="form-input" style="flex:1;margin:0" placeholder="Введите ответ..." onkeydown="if(event.key==='Enter')answerQuickText()" />
<button onclick="answerQuickText()" style="padding:10px 20px;border:none;border-radius:12px;background:#9B5DE5;color:#fff;font-weight:700;cursor:pointer">OK</button>
</div>`;
}
html += '</div><div class="km-qtest-explain" id="qtest-explain"></div>';
document.getElementById('qtest-body').innerHTML = html;
renderMath(document.getElementById('qtest-body'));
}
async function answerQuick(optionId, el) {
if (_qt.answered) return;
_qt.answered = true;
const q = _qt.questions[_qt.current];
// Disable all options
document.querySelectorAll('.km-qtest-opt').forEach(o => o.classList.add('dimmed'));
el.classList.remove('dimmed');
try {
const res = await LS.sendAnswer(_qt.sessionId, q.id, optionId, null);
if (res.is_correct) {
el.classList.add('correct');
_qt.score++;
} else {
el.classList.add('wrong');
// Highlight correct option(s) in practice mode
if (res.correct_options?.length) {
for (const co of res.correct_options) {
const cel = document.querySelector(`[data-oid="${co.id}"]`);
if (cel) { cel.classList.remove('dimmed'); cel.classList.add('correct'); }
}
}
}
} catch {}
setTimeout(nextQuestion, 1500);
}
async function answerQuickText() {
if (_qt.answered) return;
_qt.answered = true;
const q = _qt.questions[_qt.current];
const inp = document.getElementById('qtest-input');
const text = inp?.value?.trim() || '';
if (!text) { _qt.answered = false; return; }
inp.disabled = true;
try {
const res = await LS.sendAnswer(_qt.sessionId, q.id, null, text);
if (res.is_correct) {
inp.style.borderColor = '#22c55e';
inp.style.background = 'rgba(34,197,94,.06)';
_qt.score++;
} else {
inp.style.borderColor = '#ef4444';
inp.style.background = 'rgba(239,68,68,.06)';
// Show correct answer in practice mode
if (res.correct_text) {
const expEl = document.getElementById('qtest-explain');
expEl.textContent = 'Правильный ответ: ' + res.correct_text;
expEl.style.display = '';
}
}
} catch {}
setTimeout(nextQuestion, 1500);
}
async function nextQuestion() {
_qt.current++;
if (_qt.current < _qt.questions.length) {
renderQuestion();
} else {
// Finish session
try { await LS.finishSession(_qt.sessionId); } catch {}
showQuickResult();
}
}
function showQuickResult() {
const total = _qt.questions.length;
const pct = Math.round((_qt.score / total) * 100);
const col = pct >= 70 ? '#22c55e' : pct >= 40 ? '#FFD166' : '#ef4444';
document.getElementById('qtest-body').innerHTML = `
<div class="km-qtest-result">
<div class="km-qtest-result-score" style="color:${col}">${_qt.score}/${total}</div>
<div class="km-qtest-result-label">${pct}% правильных ответов</div>
<button class="km-qtest-result-btn" onclick="closeQuickTest(); loadMap();">Вернуться к карте</button>
</div>
`;
// Update the node's mastery visually (will be correct after reload)
if (_qt.node) {
const prev = _qt.node.mastery;
// Optimistic: blend old mastery with new result
_qt.node.mastery = prev !== null
? Math.round((prev + pct) / 2)
: pct;
}
}
function closeQuickTest() {
document.getElementById('km-qtest').classList.remove('open');
}
/* ── KaTeX formula rendering ── */
function renderMath(el) {
if (!el) return;
const run = () => {
if (window.renderMathInElement) {
renderMathInElement(el, {
delimiters: [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
throwOnError: false,
});
}
};
if (window._katexReady) run(); else window._katexReadyCb = run;
}
</script>
</body>
</html>