1e51ef7def
- 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>
760 lines
29 KiB
PHP
760 lines
29 KiB
PHP
<?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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
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>
|