Files
geograsim/App/pages/mindmap.php
T
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

760 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Glossar-Mindmap — Force-directed Graph aller Begriffe + Related-Verbindungen.
*
* Datenquelle: /php/api/glossar.php?graph=1
* Modul-Metadaten: DB-Tabelle module_info (Admin-pflegbar)
*/
require_once __DIR__ . '/../php/config/app.php';
require_once __DIR__ . '/../php/config/db.php';
$bp = BASE_PATH;
$api = BASE_URL . '/php/api/glossar.php';
// Farben sind Design-Entscheidung — nicht in DB, hier pro Modul gemappt.
$moduleColors = [
'klima' => '#4a7c8a',
'fluss' => '#5a8a5e',
'heli' => '#e8833a',
'stadt' => '#c85c4a',
'regenwald' => '#3a6b3e',
'erdbeben' => '#8a6a4a',
'energiemix'=> '#c4a97a',
'lieferketten'=> '#b07aa1',
];
$defaultColor = '#c4a97a';
// Icons + Titel aus module_info (Admin-pflegbar). Legende zeigt nur Module
// mit echter Sim — analog zu glossar.php (Stadt/Klima-3D ohne Sim-Code
// sollen nicht in der Legende erscheinen).
$simsRoot = realpath(__DIR__ . '/../sims');
$modDb = getDB()->query("
SELECT module_id, title, icon, sort_order
FROM module_info
WHERE status IN ('aktiv', 'beta')
ORDER BY sort_order, title
")->fetchAll(PDO::FETCH_ASSOC);
$moduleMeta = [];
foreach ($modDb as $m) {
if (!is_dir($simsRoot . '/' . $m['module_id'])) continue;
$moduleMeta[$m['module_id']] = [
'icon' => $m['icon'] ?: '📘',
'label' => $m['title'],
'color' => $moduleColors[$m['module_id']] ?? $defaultColor,
];
}
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Glossar geograsim.at — Mindmap</title>
<meta name="description" content="Interaktive Mindmap aller Glossar-Begriffe mit ihren Verbindungen. Navigation per Klick, Drag und Zoom.">
<link rel="icon" type="image/svg+xml" href="<?= $bp ?>/favicon.svg">
<link rel="icon" type="image/png" href="<?= $bp ?>/favicon-96x96.png" sizes="96x96">
<link rel="stylesheet" href="<?= $bp ?>/assets/fonts/inter.css">
<link rel="stylesheet" href="<?= $bp ?>/assets/css/design-system.css">
<style>
html, body { height: 100%; background: var(--ggs-bg); overflow: hidden; }
body { display: flex; flex-direction: column; }
/* ===== Topbar ===== */
.mm-topbar {
background: var(--ggs-white);
padding: .55rem 1.2rem;
display: flex; align-items: center; gap: .8rem;
border-bottom: 1px solid var(--ggs-border);
flex-shrink: 0; z-index: 50;
}
.mm-topbar a { text-decoration: none; }
.mm-topbar-brand { display: flex; align-items: center; }
.mm-topbar-brand img { height: 40px; display: block; }
.mm-topbar .spacer { flex: 1; }
.mm-btn-sm {
display: inline-flex; align-items: center; gap: .3rem;
min-height: 40px; padding: .4rem .8rem;
border: 1px solid var(--ggs-border);
border-radius: var(--ggs-radius-sm); background: var(--ggs-white);
font-size: .78rem; font-weight: 600; cursor: pointer;
color: var(--ggs-text); font-family: inherit;
text-decoration: none;
}
.mm-btn-sm.primary { background: var(--ggs-fjord); color: var(--ggs-white); border-color: var(--ggs-fjord); }
@media (hover: hover) {
.mm-btn-sm:hover { background: var(--ggs-fjord-light); color: var(--ggs-fjord-dark); border-color: var(--ggs-fjord); }
.mm-btn-sm.primary:hover { background: var(--ggs-fjord-dark); }
}
/* ===== Viewport ===== */
.mm-viewport {
flex: 1; position: relative; overflow: hidden;
background:
radial-gradient(circle at 30% 20%, rgba(74,124,138,.05), transparent 60%),
radial-gradient(circle at 70% 75%, rgba(90,138,94,.06), transparent 60%),
var(--ggs-bg);
}
.mm-svg {
width: 100%; height: 100%; display: block;
cursor: grab; touch-action: none;
}
.mm-svg.dragging { cursor: grabbing; }
.mm-edge {
stroke: #9fb9c1; stroke-width: 1; fill: none;
transition: stroke-opacity .2s;
stroke-opacity: .42;
}
.mm-edge.hi { stroke: var(--ggs-fjord-dark); stroke-opacity: .95; stroke-width: 2.2; }
.mm-edge.dim { stroke-opacity: .1; }
.mm-node circle {
stroke: var(--ggs-white); stroke-width: 2.5; cursor: pointer;
transition: r .2s, stroke-width .2s;
}
.mm-node text {
font-family: var(--ggs-font);
font-size: 11px; font-weight: 700; fill: var(--ggs-fjord-dark);
text-anchor: middle; pointer-events: none;
paint-order: stroke; stroke: var(--ggs-bg); stroke-width: 3;
}
.mm-node.hi circle { stroke-width: 4; }
.mm-node.hi text { font-size: 12.5px; }
.mm-node.dim circle { opacity: .3; }
.mm-node.dim text { opacity: .3; }
.mm-node.has-img circle { stroke: var(--ggs-moss); stroke-dasharray: 3 2; }
/* ===== Overlay-Panels ===== */
.mm-panel {
position: absolute; background: var(--ggs-white);
border: 1px solid var(--ggs-border); border-radius: var(--ggs-radius-md);
box-shadow: var(--ggs-shadow-md); padding: .9rem 1rem;
font-size: .82rem; z-index: 10;
}
/* Info & Suche links oben */
.mm-info { top: 14px; left: 14px; width: 280px; }
.mm-info h2 { font-size: 1.05rem; font-weight: 900; color: var(--ggs-fjord-dark); margin-bottom: .3rem; }
.mm-info p { font-size: .75rem; color: var(--ggs-text-muted); line-height: 1.5; margin-bottom: .6rem; }
.mm-search {
width: 100%; padding: .45rem .7rem; border: 1.5px solid var(--ggs-border);
border-radius: var(--ggs-radius-sm); font-family: inherit; font-size: .82rem;
outline: none; transition: border-color .15s;
}
.mm-search:focus { border-color: var(--ggs-fjord); }
.mm-stats { font-size: .7rem; color: var(--ggs-text-muted); margin-top: .5rem; }
.mm-stats strong { color: var(--ggs-fjord-dark); font-weight: 700; }
/* Legende rechts oben */
.mm-legend { top: 14px; right: 14px; min-width: 200px; }
.mm-legend h3 { font-size: .72rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; color: var(--ggs-text-muted); margin-bottom: .5rem; }
.mm-legend-row { display: flex; align-items: center; gap: .4rem; font-size: .78rem; margin-bottom: .3rem; color: var(--ggs-text); }
.mm-legend-dot { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
.mm-legend-emoji { font-size: 1rem; line-height: 1; width: 18px; text-align: center; }
.mm-legend-hint { font-size: .68rem; color: var(--ggs-text-muted); margin-top: .6rem; border-top: 1px solid var(--ggs-border); padding-top: .5rem; line-height: 1.45; }
/* Zoom-Buttons rechts unten */
.mm-zoom { bottom: 14px; right: 14px; display: flex; flex-direction: column; gap: .3rem; padding: .3rem; }
.mm-zoom button {
width: 36px; height: 36px; border: 0; background: var(--ggs-bg);
border-radius: var(--ggs-radius-sm); font-size: 1.15rem; font-weight: 800;
color: var(--ggs-fjord-dark); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s;
}
@media (hover: hover) { .mm-zoom button:hover { background: var(--ggs-fjord-light); } }
/* Node-Popover */
.mm-pop {
position: absolute; width: 300px; z-index: 30;
background: var(--ggs-white); border: 1px solid var(--ggs-border);
border-radius: var(--ggs-radius-md); box-shadow: var(--ggs-shadow-lg);
padding: 1rem; pointer-events: auto; display: none;
}
.mm-pop.open { display: block; }
.mm-pop-close {
position: absolute; top: 6px; right: 6px; width: 24px; height: 24px;
border: 0; background: transparent; cursor: pointer;
color: var(--ggs-text-muted); font-size: 1.1rem;
border-radius: 50%;
}
@media (hover: hover) { .mm-pop-close:hover { background: var(--ggs-coral-light); color: var(--ggs-coral); } }
.mm-pop-cat {
display: inline-block; font-size: .62rem; font-weight: 800;
text-transform: uppercase; letter-spacing: .06em;
color: var(--ggs-fjord); background: var(--ggs-fjord-light);
padding: 2px 8px; border-radius: var(--ggs-radius-pill); margin-bottom: .3rem;
}
.mm-pop-title { font-size: 1.05rem; font-weight: 900; color: var(--ggs-fjord-dark); margin-bottom: .3rem; padding-right: 26px; }
.mm-pop-short { font-size: .85rem; color: var(--ggs-text); line-height: 1.5; margin-bottom: .7rem; }
.mm-pop-mods { display: flex; gap: .25rem; margin-bottom: .7rem; }
.mm-pop-mod {
font-size: .65rem; padding: 2px 8px;
background: var(--ggs-bg); border-radius: var(--ggs-radius-pill);
color: var(--ggs-text-muted); font-weight: 600;
}
.mm-pop-actions { display: flex; flex-direction: column; gap: .3rem; }
.mm-pop-btn {
display: block; padding: .55rem .8rem; border-radius: var(--ggs-radius-sm);
font-size: .82rem; font-weight: 700; text-decoration: none; text-align: left;
border: 1.5px solid var(--ggs-border); color: var(--ggs-fjord-dark);
background: var(--ggs-white); transition: all .15s;
font-family: inherit; cursor: pointer;
}
.mm-pop-btn.primary { background: var(--ggs-fjord); color: var(--ggs-white); border-color: var(--ggs-fjord); }
@media (hover: hover) {
.mm-pop-btn:hover { border-color: var(--ggs-fjord); background: var(--ggs-fjord-light); color: var(--ggs-fjord); }
.mm-pop-btn.primary:hover { background: var(--ggs-fjord-dark); border-color: var(--ggs-fjord-dark); color: var(--ggs-white); }
}
.mm-pop-btn small { display: block; font-size: .68rem; font-weight: 500; opacity: .75; margin-top: 1px; }
.mm-pop-related { margin-top: .5rem; border-top: 1px solid var(--ggs-border); padding-top: .6rem; }
.mm-pop-related-title { font-size: .65rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; color: var(--ggs-text-muted); margin-bottom: .3rem; }
.mm-pop-related-list { display: flex; flex-wrap: wrap; gap: .25rem; }
.mm-pop-related-list button {
font-size: .72rem; padding: 2px 8px; border: 1px solid var(--ggs-border);
background: var(--ggs-white); border-radius: var(--ggs-radius-pill);
color: var(--ggs-fjord-dark); cursor: pointer; font-family: inherit;
}
@media (hover: hover) { .mm-pop-related-list button:hover { background: var(--ggs-fjord-light); border-color: var(--ggs-fjord); } }
/* Loading */
.mm-loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--ggs-text-muted); font-size: .9rem; background: var(--ggs-bg);
}
@media (max-width: 700px) {
.mm-info, .mm-legend { display: none; }
.mm-pop { width: calc(100vw - 28px); }
}
</style>
</head>
<body>
<div class="mm-topbar">
<a href="<?= $bp ?>/index.html" class="mm-topbar-brand">
<img src="<?= $bp ?>/assets/img/geograsim_t.svg" alt="GeoGraSim">
</a>
<span class="spacer"></span>
<a href="<?= $bp ?>/glossar" class="mm-btn-sm">📖 Glossar-Liste</a>
<a href="<?= $bp ?>/index.html" class="mm-btn-sm">Startseite</a>
<a href="<?= $bp ?>/handbuch.html" class="mm-btn-sm">Handbuch</a>
<a href="<?= $bp ?>/login.html" class="mm-btn-sm primary">Anmelden</a>
</div>
<div class="mm-viewport" id="mm-viewport">
<div class="mm-loading" id="mm-loading">Mindmap wird aufgebaut …</div>
<svg class="mm-svg" id="mm-svg" xmlns="http://www.w3.org/2000/svg">
<g id="mm-scene">
<g id="mm-edges"></g>
<g id="mm-nodes"></g>
</g>
</svg>
<!-- Info-Panel links -->
<div class="mm-panel mm-info">
<h2>🧠 Glossar-Mindmap</h2>
<p>Alle Begriffe und ihre Zusammenhänge auf einen Blick. Linien verbinden verwandte Begriffe.</p>
<input id="mm-search" class="mm-search" type="search" placeholder="Begriff suchen …" autocomplete="off">
<div class="mm-stats" id="mm-stats"></div>
</div>
<!-- Legende rechts -->
<div class="mm-panel mm-legend">
<h3>Module</h3>
<?php foreach ($moduleMeta as $id => $m): ?>
<div class="mm-legend-row" data-module="<?= htmlspecialchars($id) ?>">
<span class="mm-legend-dot" style="background:<?= $m['color'] ?>"></span>
<span class="mm-legend-emoji"><?= $m['icon'] ?></span><?= htmlspecialchars($m['label']) ?>
</div>
<?php endforeach; ?>
<div class="mm-legend-row" data-module="__default__">
<span class="mm-legend-dot" style="background:<?= $defaultColor ?>"></span>
<span class="mm-legend-emoji">📘</span>sonstige · Einheiten
</div>
<div class="mm-legend-hint">
🖱 Ziehen, um Knoten zu bewegen<br>
🔍 Scrollen zum Zoomen<br>
🖐 Leere Fläche ziehen zum Verschieben<br>
🌿 Gestrichelter Rand = Grafik vorhanden
</div>
</div>
<!-- Zoom-Buttons -->
<div class="mm-panel mm-zoom">
<button id="mm-zoom-in" title="Hineinzoomen">+</button>
<button id="mm-zoom-out" title="Herauszoomen"></button>
<button id="mm-zoom-fit" title="Alles einpassen">⛶</button>
</div>
<!-- Node-Popover -->
<div class="mm-pop" id="mm-pop">
<button class="mm-pop-close" id="mm-pop-close" aria-label="Schließen">×</button>
<div id="mm-pop-body"></div>
</div>
</div>
<script>
const API = <?= json_encode($api) ?>;
const BP = <?= json_encode($bp) ?>;
const PAGES = <?= json_encode(BASE_URL . '/pages') ?>;
// Modul-Metadaten aus DB (module_info): icon, label, color pro Modul
const MODULE_META = <?= json_encode($moduleMeta, JSON_UNESCAPED_UNICODE) ?>;
const DEFAULT_COLOR = <?= json_encode($defaultColor) ?>;
const CATEGORY_LABEL = { klima:'Klima', einheit:'Einheit', energie:'Energie', stadt:'Stadt', wasser:'Wasser', geologie:'Geologie', wirtschaft:'Wirtschaft', biologie:'Biologie' };
function modIcon(m) { return (MODULE_META[m]?.icon) || '•'; }
function modLabel(m) { return (MODULE_META[m]?.label) || m; }
function modColor(m) { return (MODULE_META[m]?.color) || DEFAULT_COLOR; }
function esc(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function primaryColor(modules) {
if (!modules || !modules.length) return DEFAULT_COLOR;
// Priorität: klima > stadt > fluss > heli > regenwald > erster Treffer
for (const m of ['klima','stadt','fluss','heli','regenwald']) {
if (modules.includes(m) && MODULE_META[m]) return MODULE_META[m].color;
}
for (const m of modules) if (MODULE_META[m]) return MODULE_META[m].color;
return DEFAULT_COLOR;
}
// ============ STATE ============
const STATE = {
nodes: [], // {id, key_slug, title, short, category, modules, image_path, x, y, vx, vy, fixed}
edges: [], // {a, b} IDs
byId: new Map(),
edgeSet: new Map(), // nodeId → Set of connected node IDs
zoom: 1, panX: 0, panY: 0,
dragging: null, // node being dragged
panStart: null, // {x,y,panX,panY} during viewport pan
width: 0, height: 0,
highlightId: null
};
// ============ LOAD ============
async function load() {
const res = await fetch(API + '?graph=1');
const data = await res.json();
STATE.nodes = data.nodes.map(n => ({
...n,
x: 0, y: 0, vx: 0, vy: 0, fixed: false
}));
STATE.edges = data.edges;
STATE.byId = new Map(STATE.nodes.map(n => [n.id, n]));
STATE.edgeSet = new Map();
STATE.nodes.forEach(n => STATE.edgeSet.set(n.id, new Set()));
STATE.edges.forEach(e => {
if (STATE.edgeSet.has(e.a)) STATE.edgeSet.get(e.a).add(e.b);
if (STATE.edgeSet.has(e.b)) STATE.edgeSet.get(e.b).add(e.a);
});
// Viewport-Dimensionen
resize();
// Initial-Positionen: Kreis um Mitte
const cx = STATE.width / 2, cy = STATE.height / 2;
const r = Math.min(STATE.width, STATE.height) * 0.35;
STATE.nodes.forEach((n, i) => {
const a = (i / STATE.nodes.length) * Math.PI * 2;
n.x = cx + Math.cos(a) * r;
n.y = cy + Math.sin(a) * r;
});
document.getElementById('mm-loading').style.display = 'none';
document.getElementById('mm-stats').innerHTML =
`<strong>${STATE.nodes.length}</strong> Begriffe · <strong>${STATE.edges.length}</strong> Verbindungen`;
buildGraph();
// Force-Simulation: viele Ticks initial, dann kontinuierlich
for (let i = 0; i < 400; i++) simulate(0.92);
render();
// Soft-Simulation weiter laufen lassen für stabiles Einschwingen
startAnim();
}
// ============ FORCE SIMULATION ============
function simulate(damping = 0.88) {
const N = STATE.nodes.length;
const K_REPEL = 18000; // Abstoßung zwischen allen Knoten
const K_SPRING = 0.012; // Feder-Attraktion auf Kanten
const L0 = 140; // Ziellänge der Federn
const K_GRAV = 0.003; // Zentrumsanziehung
const cx = STATE.width / 2, cy = STATE.height / 2;
// Kräfte auf Null setzen
for (const n of STATE.nodes) { n.fx = 0; n.fy = 0; }
// Abstoßung (O(n²) — bei 25 Knoten kein Problem)
for (let i = 0; i < N; i++) {
const ni = STATE.nodes[i];
for (let j = i + 1; j < N; j++) {
const nj = STATE.nodes[j];
let dx = ni.x - nj.x, dy = ni.y - nj.y;
let d2 = dx*dx + dy*dy;
if (d2 < 1) { d2 = 1; dx = Math.random()-.5; dy = Math.random()-.5; }
const f = K_REPEL / d2;
const d = Math.sqrt(d2);
const fx = f * dx / d, fy = f * dy / d;
ni.fx += fx; ni.fy += fy;
nj.fx -= fx; nj.fy -= fy;
}
}
// Feder-Anziehung entlang Kanten
for (const e of STATE.edges) {
const a = STATE.byId.get(e.a), b = STATE.byId.get(e.b);
if (!a || !b) continue;
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx*dx + dy*dy) || 1;
const f = K_SPRING * (d - L0);
const fx = f * dx / d, fy = f * dy / d;
a.fx += fx; a.fy += fy;
b.fx -= fx; b.fy -= fy;
}
// Zentrumsanziehung
for (const n of STATE.nodes) {
n.fx += (cx - n.x) * K_GRAV;
n.fy += (cy - n.y) * K_GRAV;
}
// Position updaten
for (const n of STATE.nodes) {
if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
n.vx = (n.vx + n.fx) * damping;
n.vy = (n.vy + n.fy) * damping;
// Geschwindigkeit cappen (stabiler)
const v = Math.sqrt(n.vx*n.vx + n.vy*n.vy);
if (v > 20) { n.vx *= 20/v; n.vy *= 20/v; }
n.x += n.vx;
n.y += n.vy;
}
}
// ============ RENDER ============
const SVGNS = 'http://www.w3.org/2000/svg';
function buildGraph() {
const edgesG = document.getElementById('mm-edges');
const nodesG = document.getElementById('mm-nodes');
edgesG.innerHTML = '';
nodesG.innerHTML = '';
// Edges
for (const e of STATE.edges) {
const line = document.createElementNS(SVGNS, 'line');
line.setAttribute('class', 'mm-edge');
line.dataset.a = e.a;
line.dataset.b = e.b;
edgesG.appendChild(line);
}
// Nodes
for (const n of STATE.nodes) {
const g = document.createElementNS(SVGNS, 'g');
g.setAttribute('class', 'mm-node' + (n.image_path ? ' has-img' : ''));
g.dataset.id = n.id;
const circle = document.createElementNS(SVGNS, 'circle');
circle.setAttribute('r', nodeRadius(n));
circle.setAttribute('fill', primaryColor(n.modules));
const text = document.createElementNS(SVGNS, 'text');
text.setAttribute('dy', nodeRadius(n) + 15);
text.textContent = n.title.length > 22 ? n.title.slice(0, 20) + '…' : n.title;
g.appendChild(circle);
g.appendChild(text);
nodesG.appendChild(g);
// Drag
g.addEventListener('pointerdown', ev => startDrag(ev, n, g));
g.addEventListener('click', ev => { ev.stopPropagation(); openPopover(n, g); });
g.addEventListener('pointerenter', () => hoverNode(n.id));
g.addEventListener('pointerleave', () => hoverNode(null));
}
}
function nodeRadius(n) {
const deg = STATE.edgeSet.get(n.id)?.size || 0;
return 10 + Math.min(deg, 8) * 1.5;
}
function render() {
const edgesG = document.getElementById('mm-edges');
const nodesG = document.getElementById('mm-nodes');
for (const line of edgesG.children) {
const a = STATE.byId.get(+line.dataset.a);
const b = STATE.byId.get(+line.dataset.b);
if (!a || !b) continue;
line.setAttribute('x1', a.x);
line.setAttribute('y1', a.y);
line.setAttribute('x2', b.x);
line.setAttribute('y2', b.y);
}
for (const g of nodesG.children) {
const n = STATE.byId.get(+g.dataset.id);
if (!n) continue;
g.setAttribute('transform', `translate(${n.x}, ${n.y})`);
}
document.getElementById('mm-scene').setAttribute(
'transform',
`translate(${STATE.panX}, ${STATE.panY}) scale(${STATE.zoom})`
);
}
let animFrame = null;
function startAnim() {
let tick = 0;
function frame() {
if (STATE.dragging || tick < 60) {
simulate(0.85);
render();
}
tick++;
animFrame = requestAnimationFrame(frame);
}
animFrame = requestAnimationFrame(frame);
}
// ============ HIGHLIGHTING ============
function hoverNode(id) {
if (STATE.dragging) return;
STATE.highlightId = id;
const neighbours = id ? STATE.edgeSet.get(id) || new Set() : null;
document.querySelectorAll('.mm-node').forEach(g => {
const nid = +g.dataset.id;
if (id === null) { g.classList.remove('hi','dim'); return; }
if (nid === id) g.classList.add('hi');
else g.classList.remove('hi');
if (nid === id || neighbours.has(nid)) g.classList.remove('dim');
else g.classList.add('dim');
});
document.querySelectorAll('.mm-edge').forEach(line => {
const a = +line.dataset.a, b = +line.dataset.b;
if (id === null) { line.classList.remove('hi','dim'); return; }
if (a === id || b === id) { line.classList.add('hi'); line.classList.remove('dim'); }
else { line.classList.remove('hi'); line.classList.add('dim'); }
});
}
// ============ DRAG + PAN + ZOOM ============
const svg = document.getElementById('mm-svg');
function clientToScene(ev) {
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
const inv = svg.getScreenCTM().inverse();
const svgP = pt.matrixTransform(inv);
return {
x: (svgP.x - STATE.panX) / STATE.zoom,
y: (svgP.y - STATE.panY) / STATE.zoom
};
}
function startDrag(ev, node, el) {
ev.preventDefault();
ev.stopPropagation();
STATE.dragging = { node, el, startX: node.x, startY: node.y, moved: false };
el.setPointerCapture(ev.pointerId);
node.fixed = true;
svg.classList.add('dragging');
}
svg.addEventListener('pointermove', ev => {
if (STATE.dragging) {
const p = clientToScene(ev);
const n = STATE.dragging.node;
if (Math.hypot(p.x - STATE.dragging.startX, p.y - STATE.dragging.startY) > 3) {
STATE.dragging.moved = true;
}
n.x = p.x; n.y = p.y; n.vx = 0; n.vy = 0;
render();
} else if (STATE.panStart) {
STATE.panX = STATE.panStart.panX + (ev.clientX - STATE.panStart.x);
STATE.panY = STATE.panStart.panY + (ev.clientY - STATE.panStart.y);
render();
}
});
svg.addEventListener('pointerup', ev => {
if (STATE.dragging) {
const { node, el, moved } = STATE.dragging;
node.fixed = false;
svg.classList.remove('dragging');
el.releasePointerCapture?.(ev.pointerId);
STATE.dragging = null;
if (moved) ev.stopPropagation();
}
STATE.panStart = null;
});
svg.addEventListener('pointerdown', ev => {
if (ev.target.closest('.mm-node')) return;
STATE.panStart = { x: ev.clientX, y: ev.clientY, panX: STATE.panX, panY: STATE.panY };
svg.classList.add('dragging');
closePopover();
});
svg.addEventListener('pointerup', () => svg.classList.remove('dragging'));
svg.addEventListener('pointerleave', () => { STATE.panStart = null; svg.classList.remove('dragging'); });
svg.addEventListener('wheel', ev => {
ev.preventDefault();
const rect = svg.getBoundingClientRect();
const mx = ev.clientX - rect.left;
const my = ev.clientY - rect.top;
const factor = ev.deltaY < 0 ? 1.15 : 1/1.15;
const newZoom = Math.max(0.25, Math.min(3, STATE.zoom * factor));
// Zoom um Mauszeiger
STATE.panX = mx - (mx - STATE.panX) * (newZoom / STATE.zoom);
STATE.panY = my - (my - STATE.panY) * (newZoom / STATE.zoom);
STATE.zoom = newZoom;
render();
}, { passive: false });
document.getElementById('mm-zoom-in').addEventListener('click', () => zoomBy(1.25));
document.getElementById('mm-zoom-out').addEventListener('click', () => zoomBy(0.8));
document.getElementById('mm-zoom-fit').addEventListener('click', fitToView);
function zoomBy(factor) {
const cx = STATE.width/2, cy = STATE.height/2;
const newZoom = Math.max(0.25, Math.min(3, STATE.zoom * factor));
STATE.panX = cx - (cx - STATE.panX) * (newZoom / STATE.zoom);
STATE.panY = cy - (cy - STATE.panY) * (newZoom / STATE.zoom);
STATE.zoom = newZoom;
render();
}
function fitToView() {
if (!STATE.nodes.length) return;
let xs = STATE.nodes.map(n => n.x), ys = STATE.nodes.map(n => n.y);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const bx = maxX - minX + 200, by = maxY - minY + 200;
const z = Math.min(STATE.width / bx, STATE.height / by, 1.4);
STATE.zoom = z;
STATE.panX = STATE.width/2 - ((minX + maxX)/2) * z;
STATE.panY = STATE.height/2 - ((minY + maxY)/2) * z;
render();
}
// ============ POPOVER ============
const pop = document.getElementById('mm-pop');
function openPopover(n, nodeEl) {
if (STATE.dragging && STATE.dragging.moved) return;
const rect = svg.getBoundingClientRect();
const cx = rect.left + STATE.panX + n.x * STATE.zoom;
const cy = rect.top + STATE.panY + n.y * STATE.zoom;
const body = document.getElementById('mm-pop-body');
const modHtml = (n.modules || []).map(m =>
`<span class="mm-pop-mod" style="color:${modColor(m)}">${modIcon(m)} ${esc(modLabel(m))}</span>`
).join('');
const cat = n.category ? (CATEGORY_LABEL[n.category] || n.category) : '';
// Verwandte: direkte Nachbarn laut edgeSet
const neighbours = STATE.edgeSet.get(n.id) || new Set();
const relHtml = [...neighbours].map(id => {
const r = STATE.byId.get(id);
return r ? `<button data-id="${r.id}">${esc(r.title)}</button>` : '';
}).join('');
body.innerHTML = `
${cat ? `<span class="mm-pop-cat">${esc(cat)}</span>` : ''}
<div class="mm-pop-title">${esc(n.title)}</div>
${n.short ? `<div class="mm-pop-short">${esc(n.short)}</div>` : ''}
${modHtml ? `<div class="mm-pop-mods">${modHtml}</div>` : ''}
<div class="mm-pop-actions">
<a class="mm-pop-btn primary" href="${PAGES}/glossar.php?term=${encodeURIComponent(n.key_slug)}" target="_blank" rel="noopener">
📖 Im Glossar öffnen
<small>Vollständiger Eintrag: Text, Beispiele, Links, Grafik</small>
</a>
<button class="mm-pop-btn" id="mm-pop-center">
🎯 Knoten zentrieren
<small>Zoomt zu diesem Begriff und hebt Verbindungen hervor</small>
</button>
</div>
${relHtml ? `<div class="mm-pop-related">
<div class="mm-pop-related-title">Verbunden mit</div>
<div class="mm-pop-related-list">${relHtml}</div>
</div>` : ''}
`;
// Positionieren: rechts neben Node wenn Platz, sonst links
pop.classList.add('open');
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
let px = cx + 30, py = cy - ph/2;
if (px + pw > window.innerWidth - 10) px = cx - pw - 30;
if (py < rect.top + 10) py = rect.top + 10;
if (py + ph > window.innerHeight - 10) py = window.innerHeight - ph - 10;
pop.style.left = px + 'px';
pop.style.top = py + 'px';
hoverNode(n.id);
document.getElementById('mm-pop-center').addEventListener('click', () => {
STATE.zoom = Math.max(STATE.zoom, 1.2);
STATE.panX = STATE.width/2 - n.x * STATE.zoom;
STATE.panY = STATE.height/2 - n.y * STATE.zoom;
render();
closePopover();
});
pop.querySelectorAll('.mm-pop-related-list button').forEach(btn => {
btn.addEventListener('click', () => {
const next = STATE.byId.get(+btn.dataset.id);
if (next) openPopover(next, document.querySelector(`.mm-node[data-id="${next.id}"]`));
});
});
}
function closePopover() {
pop.classList.remove('open');
hoverNode(null);
}
document.getElementById('mm-pop-close').addEventListener('click', closePopover);
document.addEventListener('keydown', ev => { if (ev.key === 'Escape') closePopover(); });
// ============ SUCHE ============
document.getElementById('mm-search').addEventListener('input', ev => {
const q = ev.target.value.trim().toLowerCase();
if (!q) {
document.querySelectorAll('.mm-node').forEach(g => g.classList.remove('hi','dim'));
document.querySelectorAll('.mm-edge').forEach(l => l.classList.remove('hi','dim'));
return;
}
const matches = new Set();
STATE.nodes.forEach(n => {
const hay = (n.title + ' ' + (n.short||'') + ' ' + n.key_slug).toLowerCase();
if (hay.includes(q)) matches.add(n.id);
});
document.querySelectorAll('.mm-node').forEach(g => {
const id = +g.dataset.id;
if (matches.has(id)) { g.classList.add('hi'); g.classList.remove('dim'); }
else { g.classList.remove('hi'); g.classList.add('dim'); }
});
document.querySelectorAll('.mm-edge').forEach(l => {
const a = +l.dataset.a, b = +l.dataset.b;
if (matches.has(a) && matches.has(b)) { l.classList.add('hi'); l.classList.remove('dim'); }
else { l.classList.add('dim'); l.classList.remove('hi'); }
});
});
// ============ RESIZE ============
function resize() {
const vp = document.getElementById('mm-viewport');
STATE.width = vp.clientWidth;
STATE.height = vp.clientHeight;
svg.setAttribute('viewBox', `0 0 ${STATE.width} ${STATE.height}`);
}
window.addEventListener('resize', () => { resize(); render(); });
load();
</script>
</body>
</html>