be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1911 lines
81 KiB
HTML
1911 lines
81 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Карта знаний — LearnSpace</title>
|
||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="/css/ls.css" />
|
||
<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: #8898AA; 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: #8898AA; 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: #8898AA; 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">
|
||
<div class="sb-brand">
|
||
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
|
||
<button class="sb-toggle" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
|
||
</div>
|
||
<nav class="sb-nav">
|
||
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
|
||
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
|
||
<a href="/board" class="sb-link" id="sbl-board" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
|
||
<a href="/classes" class="sb-link" id="sbl-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
|
||
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
|
||
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
|
||
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
|
||
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
|
||
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
|
||
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
|
||
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
|
||
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
|
||
<span class="sb-link active"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></span>
|
||
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
|
||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||
<div class="sb-divider"></div>
|
||
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
|
||
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
|
||
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
|
||
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
|
||
<a href="/admin" class="sb-link" id="sbl-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
|
||
</nav>
|
||
<div style="padding: 4px 2px">
|
||
<div id="notif-wrap">
|
||
<button class="sb-link" id="notif-btn" onclick="LS.notif.toggle()">
|
||
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
|
||
<span class="sb-badge" id="notif-badge" style="display:none"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="sb-foot">
|
||
<a href="/profile" class="sb-user-row" style="text-decoration:none">
|
||
<div class="sb-avatar" id="nav-avatar">?</div>
|
||
<div class="sb-user-info">
|
||
<div class="sb-user-name" id="nav-user">—</div>
|
||
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
</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>30–69%</div>
|
||
<div class="km-legend-item"><div class="km-legend-dot" style="background:#F94144"></div><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/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();
|
||
|
||
// sbl-classes — только для учителей/админов
|
||
if (user.role !== 'student') {
|
||
const el = document.getElementById('sbl-classes');
|
||
if (el) el.style.display = '';
|
||
}
|
||
if (user.role === 'admin') {
|
||
const a = document.getElementById('sbl-admin');
|
||
if (a) a.style.display = '';
|
||
}
|
||
LS.showBoardIfAllowed();
|
||
|
||
LS.notif.init();
|
||
lucide.createIcons();
|
||
|
||
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:#8898AA"><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:#8898AA">Нет вопросов по этой теме</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>
|