Glossar: Komplettpaket (28 Eintraege, 19 SVGs, 5 DALL-E-Bilder, iPad, Experimente)

- Mindmap mit Force-directed Layout (pages/mindmap.php) + Cross-Links
- 28 Eintraege im Fachartikel-Stil mit 113 Quellen + Inline-Referenzen [^n]
- Leichte-Sprache-Version fuer alle 28 Eintraege (short_easy, text_easy, examples.text_easy)
- 19 Infografiken (12 neu): co2-Keeling, klimawandel-Temp, kohlenstoff-Kreislauf, fotosynthese, energiemix-AT, methan-GWP, photovoltaik, wasserkraft, kipppunkt, biodiversitaet-LPI, permafrost, treibhausgas-GWP + bestehende (treibhauseffekt, ppm, windrad, albedo, fossile, kwh, co2-fussabdruck)
- 5 DALL-E-Reprasentationsbilder im Querformat (nachhaltigkeit, klimaneutralitaet, erneuerbare-energie, emissionen, pariser-abkommen)
- Alphabetische Sprungnavigation A-Z mit Buchstabenbannern
- Lightbox-Vollbild auf allen Infografiken mit expliziten SVG-Dimensionen (Safari/iPad-Fix)
- Easy-Sprache-Toggle pro Card mit localStorage-Persistenz
- 4 interaktive Experimente: kWh-Rennen, ppm-Finder, albedo-Slider, CO2-Fussabdruck-Rechner
- iPad-Optimierung: Touch-Ziele >=38px, Hover in @media (hover), touch-action
- Modul-Metadaten aus DB-Tabelle module_info (keine Hardcodes)
- Cache-Buster auf JS-Asset
- Schema-Erweiterung: glossar_sources, examples.text_easy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 00:48:29 +02:00
parent 003eeeb9f7
commit a425ce31e6
32 changed files with 6785 additions and 0 deletions
+755
View File
@@ -0,0 +1,755 @@
<?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)
$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) {
$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">
<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 ?>/pages/glossar.php" 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>