Files

578 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Путеводитель — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { overflow: hidden !important; background: #09061c !important; padding: 0 !important; }
.gx-wrap {
position: relative; width: 100%; height: 100%;
overflow: hidden; user-select: none;
}
/* ── Canvas background ── */
#gx-canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; }
/* ── Nodes layer ── */
#gx-nodes { position: absolute; inset: 0; z-index: 2; }
/* ── Node ── */
.gx-node {
position: absolute;
transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center; gap: 7px;
cursor: pointer;
animation: gxNodeIn 0.55s ease both;
}
@keyframes gxNodeIn {
from { opacity: 0; transform: translate(-50%, -45%) scale(0.55); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
.gx-orb {
border-radius: 50%;
background: radial-gradient(circle at 38% 32%, rgba(var(--nc), 0.42), rgba(var(--nc), 0.07) 72%);
border: 1.5px solid rgba(var(--nc), 0.42);
box-shadow: 0 0 14px rgba(var(--nc), 0.16), inset 0 1px 0 rgba(255,255,255,0.13);
display: flex; align-items: center; justify-content: center;
width: 50px; height: 50px;
transition: transform 0.22s cubic-bezier(.34,1.56,.64,1), box-shadow 0.22s, border-color 0.22s;
position: relative;
}
.gx-orb svg { width: 20px; height: 20px; color: rgb(var(--nc)); stroke: rgb(var(--nc)); flex-shrink: 0; }
.gx-node:hover .gx-orb {
transform: scale(1.25);
box-shadow: 0 0 32px rgba(var(--nc), 0.6), 0 0 64px rgba(var(--nc), 0.18), inset 0 1px 0 rgba(255,255,255,0.2);
border-color: rgba(var(--nc), 0.85);
}
.gx-node.gx-main .gx-orb { width: 66px; height: 66px; }
.gx-node.gx-main .gx-orb svg { width: 26px; height: 26px; }
/* Pulse ring on main node */
.gx-node.gx-main .gx-orb::after {
content: '';
position: absolute; inset: -8px; border-radius: 50%;
border: 1.5px solid rgba(var(--nc), 0.22);
animation: gxPulse 2.4s ease-in-out infinite;
}
@keyframes gxPulse {
0%,100% { transform: scale(1); opacity: 0.6; }
50% { transform: scale(1.12); opacity: 0.15; }
}
.gx-label {
font-family: 'Manrope', sans-serif;
font-size: 0.68rem; font-weight: 700;
color: rgba(255,255,255,0.68);
text-shadow: 0 1px 8px rgba(0,0,0,0.7);
white-space: nowrap;
transition: color 0.18s;
}
.gx-node:hover .gx-label { color: rgba(255,255,255,0.95); }
/* Dim non-category nodes on filter */
.gx-node.gx-dim { opacity: 0.12; transition: opacity 0.3s; }
.gx-node.gx-connected .gx-orb {
box-shadow: 0 0 20px rgba(var(--nc), 0.35), inset 0 1px 0 rgba(255,255,255,0.13);
}
/* Category colors */
.gx-node[data-cat="study"] { --nc: 155,93,229; }
.gx-node[data-cat="practice"] { --nc: 6,214,224; }
.gx-node[data-cat="games"] { --nc: 241,91,181; }
.gx-node[data-cat="personal"] { --nc: 255,179,71; }
/* ── Tooltip ── */
.gx-tt {
position: fixed; z-index: 300;
background: rgba(10,6,24,0.95);
backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid rgba(255,255,255,0.09);
border-radius: 18px;
padding: 15px 17px 17px;
width: 238px;
pointer-events: none;
opacity: 0; transform: translateY(8px) scale(0.97);
transition: opacity 0.18s, transform 0.18s;
box-shadow: 0 20px 56px rgba(0,0,0,0.55);
}
.gx-tt.vis { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.gx-tt-badge {
display: inline-flex; align-items: center;
font-size: 0.59rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase;
padding: 2px 9px; border-radius: 99px; margin-bottom: 9px;
}
.gx-tt-title {
font-family: 'Unbounded', sans-serif;
font-size: 0.83rem; font-weight: 800; color: #fff; margin-bottom: 7px; line-height: 1.2;
}
.gx-tt-desc {
font-size: 0.75rem; color: rgba(255,255,255,0.5); line-height: 1.55; margin-bottom: 13px;
}
.gx-tt-btn {
display: inline-flex; align-items: center; gap: 7px;
font-size: 0.73rem; font-weight: 700;
padding: 7px 16px; border-radius: 99px;
text-decoration: none; transition: filter 0.18s;
}
.gx-tt-btn:hover { filter: brightness(1.15); }
.gx-tt-btn svg { width: 12px; height: 12px; }
/* ── Header ── */
.gx-hdr {
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
padding: 18px 28px;
background: linear-gradient(to bottom, rgba(9,6,28,0.88) 0%, transparent 100%);
display: flex; align-items: center; gap: 20px;
pointer-events: none;
}
.gx-hdr-logo {
font-family: 'Unbounded', sans-serif;
font-size: 0.95rem; font-weight: 800; color: #fff; line-height: 1;
margin-bottom: 2px;
}
.gx-hdr-sub { font-size: 0.62rem; color: rgba(255,255,255,0.38); letter-spacing: 0.08em; text-transform: uppercase; }
/* Beginner path hint */
.gx-hint {
position: absolute; top: 18px; left: 50%; transform: translateX(-50%); z-index: 10;
background: rgba(255,255,255,0.05); backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,0.1); border-radius: 99px;
padding: 7px 18px;
display: flex; align-items: center; gap: 8px;
font-size: 0.69rem; color: rgba(255,255,255,0.5); white-space: nowrap;
pointer-events: none;
}
.gx-hint strong { color: rgba(255,255,255,0.82); font-weight: 700; }
.gx-hint-sep { color: rgba(255,255,255,0.22); margin: 0 2px; }
/* ── Category filters (bottom center) ── */
.gx-filters {
position: absolute; bottom: 22px; left: 50%; transform: translateX(-50%); z-index: 10;
display: flex; align-items: center; gap: 5px;
background: rgba(255,255,255,0.05);
backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 99px; padding: 5px 8px;
}
.gx-fil {
display: flex; align-items: center; gap: 6px;
font-family: 'Manrope', sans-serif; font-size: 0.71rem; font-weight: 700;
color: rgba(255,255,255,0.45);
background: none; border: none; cursor: pointer;
padding: 5px 13px; border-radius: 99px;
transition: all 0.18s; white-space: nowrap;
}
.gx-fil:hover { color: rgba(255,255,255,0.75); background: rgba(255,255,255,0.08); }
.gx-fil.active { color: #fff; background: rgba(255,255,255,0.12); }
.gx-fil-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
/* ── Module count (bottom right) ── */
.gx-count {
position: absolute; bottom: 26px; right: 28px; z-index: 10;
font-size: 0.61rem; color: rgba(255,255,255,0.2);
letter-spacing: 0.07em; text-transform: uppercase;
}
/* ── Mobile: simple grid fallback ── */
.gx-mobile { display: none; padding: 24px 16px 60px; }
.gx-mob-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 24px; }
.gx-mob-card {
display: flex; align-items: center; gap: 12px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px; padding: 14px 14px;
text-decoration: none; transition: background 0.18s;
}
.gx-mob-card:hover { background: rgba(255,255,255,0.1); }
.gx-mob-icon {
width: 40px; height: 40px; border-radius: 12px; flex-shrink: 0;
background: rgba(var(--nc), 0.15); border: 1px solid rgba(var(--nc), 0.3);
display: flex; align-items: center; justify-content: center;
}
.gx-mob-icon svg { width: 18px; height: 18px; color: rgb(var(--nc)); stroke: rgb(var(--nc)); }
.gx-mob-name { font-size: 0.78rem; font-weight: 700; color: #fff; }
.gx-mob-cat { font-size: 0.62rem; color: rgba(255,255,255,0.4); margin-top: 1px; }
.gx-mob-sec-title {
font-family: 'Unbounded', sans-serif;
font-size: 0.68rem; font-weight: 800; color: rgba(255,255,255,0.4);
text-transform: uppercase; letter-spacing: 0.08em;
margin: 16px 0 8px; display: flex; align-items: center; gap: 8px;
}
.gx-mob-dot { width: 7px; height: 7px; border-radius: 50%; }
@media (max-width: 768px) {
.gx-canvas-ui { display: none !important; }
#gx-canvas { display: none !important; }
#gx-nodes { display: none !important; }
.gx-mobile { display: block !important; }
.sb-content { overflow-y: auto !important; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="gx-wrap" id="gx-wrap">
<!-- Canvas background -->
<canvas id="gx-canvas"></canvas>
<!-- Nodes -->
<div id="gx-nodes"></div>
<!-- Tooltip -->
<div class="gx-tt" id="gx-tt">
<div class="gx-tt-badge" id="tt-badge"></div>
<div class="gx-tt-title" id="tt-title"></div>
<div class="gx-tt-desc" id="tt-desc"></div>
<a class="gx-tt-btn" id="tt-btn" href="#">
Открыть
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
</div>
<!-- Header (canvas UI elements) -->
<div class="gx-canvas-ui">
<div class="gx-hdr">
<div>
<div class="gx-hdr-logo">LearnSpace</div>
<div class="gx-hdr-sub">Карта модулей</div>
</div>
</div>
<!-- Beginner hint -->
<div class="gx-hint">
<strong>Новичок?</strong>
<span class="gx-hint-sep"></span>
Начни с <strong>Дашборда</strong>
<span class="gx-hint-sep"></span>
<strong>Теории</strong>
<span class="gx-hint-sep"></span>
<strong>Лаборатории</strong>
</div>
<!-- Category filters -->
<div class="gx-filters">
<button class="gx-fil active" data-cat="all">Все</button>
<button class="gx-fil" data-cat="study">
<span class="gx-fil-dot" style="background:#9B5DE5"></span>Учёба
</button>
<button class="gx-fil" data-cat="practice">
<span class="gx-fil-dot" style="background:#06D6E0"></span>Практика
</button>
<button class="gx-fil" data-cat="games">
<span class="gx-fil-dot" style="background:#F15BB5"></span>Игры
</button>
<button class="gx-fil" data-cat="personal">
<span class="gx-fil-dot" style="background:#FFB347"></span>Личное
</button>
</div>
<div class="gx-count" id="gx-count"></div>
</div>
<!-- Mobile fallback (hidden on desktop) -->
<div class="gx-mobile" id="gx-mobile"></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>
LS.initPage();
/* ══ Module data ══ */
const MODULES = [
/* ── Учёба ── */
{ id:'dashboard', href:'/dashboard', label:'Дашборд', desc:'Центр управления: задания, XP, уровни, прогресс и стрики', cat:'study', icon:'home', x:0.34, y:0.29 },
{ id:'theory', href:'/theory', label:'Теория', desc:'Уроки с текстом, формулами, медиа и встроенными мини-тестами', cat:'study', icon:'brain', x:0.17, y:0.38 },
{ id:'library', href:'/library', label:'Библиотека', desc:'Файлы и материалы от учителя: конспекты, презентации, документы', cat:'study', icon:'book-open', x:0.30, y:0.48 },
{ id:'knowledge', href:'/knowledge-map', label:'Карта знаний', desc:'Интерактивный граф связей между темами. Увидь всю картину', cat:'study', icon:'share-2', x:0.18, y:0.52 },
/* ── Практика ── */
{ id:'lab', href:'/lab', label:'Лаборатория', desc:'30+ интерактивных симуляций по физике, химии и биологии', cat:'practice', icon:'atom', x:0.64, y:0.25 },
{ id:'biochem', href:'/biochem', label:'Биохимия', desc:'Молекулы, реакции, метаболические пути и задачи-челленджи', cat:'practice', icon:'flask-conical', x:0.76, y:0.17 },
{ id:'classroom', href:'/classroom', label:'Онлайн-урок', desc:'Живой урок с учителем: интерактивная доска, чат и видеосвязь', cat:'practice', icon:'presentation', x:0.74, y:0.40 },
{ id:'board', href:'/board', label:'Доска', desc:'Свободная доска для рисования, заметок, схем и формул', cat:'practice', icon:'layout-dashboard', x:0.58, y:0.38 },
/* ── Игры ── */
{ id:'hangman', href:'/hangman', label:'Виселица', desc:'Угадывай слова по теме урока — учись играя', cat:'games', icon:'gamepad-2', x:0.20, y:0.67 },
{ id:'crossword', href:'/crossword', label:'Кроссворд', desc:'Тематические кроссворды по предметам. Проверь словарный запас', cat:'games', icon:'grid-3x3', x:0.35, y:0.74 },
{ id:'pet', href:'/pet', label:'Питомец', desc:'Виртуальный питомец растёт вместе с твоим прогрессом в учёбе', cat:'games', icon:'heart', x:0.13, y:0.77 },
{ id:'collection', href:'/collection', label:'Коллекция', desc:'Собирай карточки и предметы за достижения и активность', cat:'games', icon:'layers', x:0.46, y:0.78 },
{ id:'redbook', href:'/red-book', label:'Красная книга', desc:'Редкие виды животных и растений. Изучай биосферу Земли', cat:'games', icon:'leaf', x:0.27, y:0.83 },
/* ── Личное ── */
{ id:'profile', href:'/profile', label:'Профиль', desc:'Имя, аватар, статистика достижений, значки и история активности', cat:'personal', icon:'user', x:0.70, y:0.62 },
{ id:'history', href:'/lesson-history',label:'Архив уроков', desc:'Записи прошедших онлайн-уроков. Доски и материалы урока', cat:'personal', icon:'archive', x:0.82, y:0.54 },
];
const CONNECTIONS = [
['dashboard','theory'], ['dashboard','library'], ['dashboard','hangman'],
['theory','library'], ['theory','knowledge'], ['theory','lab'],
['lab','biochem'], ['lab','classroom'], ['classroom','board'],
['classroom','history'], ['hangman','crossword'], ['hangman','pet'],
['pet','collection'], ['collection','redbook'], ['profile','dashboard'],
['profile','history'], ['library','lab'], ['board','crossword'],
];
const CAT_INFO = {
study: { label:'Учёба', rgb:[155,93,229] },
practice: { label:'Практика', rgb:[6,214,224] },
games: { label:'Игры', rgb:[241,91,181] },
personal: { label:'Личное', rgb:[255,179,71] },
};
/* ══ Feature flag → module ID mapping ══ */
const FEAT_MAP = {
hangman: ['hangman'],
crossword: ['crossword'],
pet: ['pet'],
red_book: ['redbook'],
collection: ['collection'],
lab: ['lab'],
knowledge_map: ['knowledge'],
board: ['board'],
biochem: ['biochem'],
};
const NO_CLASS_IDS = new Set(['board','lab','hangman','crossword','pet','collection','knowledge','redbook']);
/* activeModules — updated after features load; canvas uses this for connections */
let activeModules = MODULES;
/* ══ Build HTML nodes (called after features are known) ══ */
const nodesWrap = document.getElementById('gx-nodes');
const nodeMap = {};
function buildNodes(mods) {
nodesWrap.innerHTML = '';
Object.keys(nodeMap).forEach(k => delete nodeMap[k]);
mods.forEach((m, i) => {
const [r,g,b] = CAT_INFO[m.cat].rgb;
const el = document.createElement('div');
el.className = 'gx-node' + (m.id === 'dashboard' ? ' gx-main' : '');
el.dataset.id = m.id;
el.dataset.cat = m.cat;
el.style.cssText = `left:${m.x*100}%;top:${m.y*100}%;--nc:${r},${g},${b};animation-delay:${i*0.045}s`;
el.innerHTML = `
<div class="gx-orb"><i data-lucide="${m.icon}"></i></div>
<span class="gx-label">${m.label}</span>`;
el.addEventListener('mouseenter', () => showTip(m, el));
el.addEventListener('mouseleave', queueHideTip);
el.addEventListener('click', () => location.href = m.href);
nodesWrap.appendChild(el);
nodeMap[m.id] = el;
});
if (window.lucide) lucide.createIcons();
document.getElementById('gx-count').textContent = mods.length + ' модулей';
}
/* ══ Mobile fallback (called after features are known) ══ */
function buildMobile(mods) {
const mobEl = document.getElementById('gx-mobile');
mobEl.innerHTML = '';
const byCat = {};
mods.forEach(m => { (byCat[m.cat] = byCat[m.cat]||[]).push(m); });
['study','practice','games','personal'].forEach(cat => {
if (!byCat[cat]?.length) return;
const info = CAT_INFO[cat];
const [r,g,b] = info.rgb;
let html = `<div class="gx-mob-sec-title"><span class="gx-mob-dot" style="background:rgb(${r},${g},${b})"></span>${info.label}</div><div class="gx-mob-grid">`;
byCat[cat].forEach(m => {
html += `<a href="${m.href}" class="gx-mob-card" data-cat="${cat}" style="--nc:${r},${g},${b}">
<div class="gx-mob-icon"><i data-lucide="${m.icon}"></i></div>
<div><div class="gx-mob-name">${m.label}</div><div class="gx-mob-cat">${info.label}</div></div>
</a>`;
});
html += '</div>';
mobEl.insertAdjacentHTML('beforeend', html);
});
if (window.lucide) lucide.createIcons();
}
/* ══ Async init: load features, filter modules, render ══ */
(async () => {
let disabled = new Set();
try {
const feats = await LS.loadFeatures();
for (const [key, ids] of Object.entries(FEAT_MAP)) {
if (feats[key] === false) ids.forEach(id => disabled.add(id));
}
if (feats._no_class) NO_CLASS_IDS.forEach(id => disabled.add(id));
} catch { /* если не залогинен — показываем всё */ }
activeModules = disabled.size ? MODULES.filter(m => !disabled.has(m.id)) : MODULES;
buildNodes(activeModules);
buildMobile(activeModules);
})();
/* ══ Tooltip ══ */
const tt = document.getElementById('gx-tt');
const ttBadge = document.getElementById('tt-badge');
const ttTitle = document.getElementById('tt-title');
const ttDesc = document.getElementById('tt-desc');
const ttBtn = document.getElementById('tt-btn');
let hideTimer = null;
let hoveredId = null;
function showTip(m, nodeEl) {
clearTimeout(hideTimer);
hoveredId = m.id;
const [r,g,b] = CAT_INFO[m.cat].rgb;
ttBadge.textContent = CAT_INFO[m.cat].label;
ttBadge.style.cssText = `background:rgba(${r},${g},${b},0.14);color:rgb(${r},${g},${b});border:1px solid rgba(${r},${g},${b},0.3)`;
ttTitle.textContent = m.label;
ttDesc.textContent = m.desc;
ttBtn.href = m.href;
ttBtn.style.cssText = `background:rgba(${r},${g},${b},0.14);color:rgb(${r},${g},${b});border:1px solid rgba(${r},${g},${b},0.3)`;
// Position near node
const rect = nodeEl.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top;
const W = window.innerWidth, H = window.innerHeight;
let left = cx + 24, top = cy - 16;
if (left + 250 > W - 8) left = cx - 258;
if (top + 200 > H - 8) top = H - 208;
if (top < 8) top = 8;
tt.style.left = left + 'px';
tt.style.top = top + 'px';
tt.classList.add('vis');
// Highlight connected nodes
highlightConnected(m.id);
}
function queueHideTip() {
hideTimer = setTimeout(() => { tt.classList.remove('vis'); hoveredId = null; highlightConnected(null); }, 180);
}
tt.addEventListener('mouseenter', () => clearTimeout(hideTimer));
tt.addEventListener('mouseleave', queueHideTip);
function highlightConnected(id) {
Object.values(nodeMap).forEach(el => {
el.classList.remove('gx-connected');
if (!id) return;
const nid = el.dataset.id;
const linked = CONNECTIONS.some(([a,b]) => (a===id && b===nid) || (b===id && a===nid));
if (linked) el.classList.add('gx-connected');
});
}
/* ══ Canvas ══ */
const canvas = document.getElementById('gx-canvas');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('gx-wrap');
// Stars
const STARS = Array.from({length:220}, () => ({
x: Math.random(), y: Math.random(),
r: Math.random() * 1.2 + 0.25,
a: Math.random() * 0.55 + 0.2,
sp: Math.random() * 0.012 + 0.003,
ph: Math.random() * Math.PI * 2,
}));
// Nebula zones (x,y,r in 0-1 space)
const NEBULAS = [
{ x:0.22, y:0.40, r:0.26, c:[155,93,229], a:0.10 },
{ x:0.68, y:0.30, r:0.23, c:[6,214,224], a:0.08 },
{ x:0.27, y:0.74, r:0.22, c:[241,91,181], a:0.09 },
{ x:0.74, y:0.60, r:0.20, c:[255,179,71], a:0.07 },
];
let frame = 0;
function resize() {
canvas.width = wrap.clientWidth || window.innerWidth;
canvas.height = wrap.clientHeight || window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
function drawFrame() {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Deep space background
const bg = ctx.createRadialGradient(W*0.42, H*0.38, 0, W*0.5, H*0.5, W*0.82);
bg.addColorStop(0, '#1c0e40');
bg.addColorStop(0.45,'#110832');
bg.addColorStop(1, '#08051a');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// Nebula clouds
const t = frame * 0.007;
NEBULAS.forEach((n, i) => {
const pulse = 1 + Math.sin(t + i * 1.7) * 0.08;
const g = ctx.createRadialGradient(n.x*W, n.y*H, 0, n.x*W, n.y*H, n.r * W * pulse);
g.addColorStop(0, `rgba(${n.c},${n.a})`);
g.addColorStop(0.45,`rgba(${n.c},${n.a * 0.4})`);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
});
// Twinkling stars
STARS.forEach(s => {
const tw = Math.sin(frame * s.sp + s.ph) * 0.38 + 0.62;
ctx.globalAlpha = s.a * tw;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI*2);
ctx.fill();
});
ctx.globalAlpha = 1;
// Connection lines (only between visible modules)
ctx.save();
ctx.setLineDash([3, 10]);
CONNECTIONS.forEach(([a, b]) => {
const ma = activeModules.find(m => m.id === a);
const mb = activeModules.find(m => m.id === b);
if (!ma || !mb) return;
const isHovered = hoveredId && (a === hoveredId || b === hoveredId);
ctx.strokeStyle = isHovered ? 'rgba(255,255,255,0.38)' : 'rgba(255,255,255,0.065)';
ctx.lineWidth = isHovered ? 1.4 : 0.7;
ctx.beginPath();
ctx.moveTo(ma.x * W, ma.y * H);
ctx.lineTo(mb.x * W, mb.y * H);
ctx.stroke();
});
ctx.restore();
frame++;
requestAnimationFrame(drawFrame);
}
drawFrame();
/* ══ Category filter ══ */
document.querySelectorAll('.gx-fil').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.gx-fil').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const cat = btn.dataset.cat;
Object.values(nodeMap).forEach(el => {
el.classList.toggle('gx-dim', cat !== 'all' && el.dataset.cat !== cat);
});
});
});
</script>
</body>
</html>