Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6, 5.1–5.4 (Details in AUSBAUSTUFEN.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
15 KiB
JavaScript
380 lines
15 KiB
JavaScript
// Zeichnet den Verweisgraph eines Projekts: Knoten = Einträge, Kanten =
|
|
// [[…]]-Verweise.
|
|
//
|
|
// Das Layout ist eine einfache Kräftesimulation (Abstoßung zwischen allen
|
|
// Knotenpaaren, Anziehung entlang der Kanten, eine schwache Zentrierung) —
|
|
// kein externes Diagramm-Werkzeug, das an der Content-Security-Policy
|
|
// (script-src 'self') vorbeigemusst hätte, und ohne Abhängigkeit, die
|
|
// gepflegt werden müsste. Läuft einmal beim Laden komplett durch, nicht
|
|
// fortlaufend animiert: Bei der serverseitig gedeckelten Knotenzahl (siehe
|
|
// graphNodeLimit in internal/web/handlers_entries.go) ist das in Bruchteilen
|
|
// einer Sekunde fertig.
|
|
(function () {
|
|
"use strict";
|
|
|
|
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
document.querySelectorAll("svg[data-graph]").forEach(function (svg) {
|
|
ladeUndZeichne(svg);
|
|
});
|
|
|
|
function ladeUndZeichne(svg) {
|
|
var url = svg.getAttribute("data-graph");
|
|
var basisUrl = svg.getAttribute("data-graph-base");
|
|
var hinweis = document.querySelector("[data-graph-hinweis]");
|
|
|
|
fetch(url, { headers: { Accept: "application/json" } })
|
|
.then(function (antwort) {
|
|
if (!antwort.ok) {
|
|
throw new Error("Graph konnte nicht geladen werden");
|
|
}
|
|
return antwort.json();
|
|
})
|
|
.then(function (daten) {
|
|
if (hinweis && daten.truncated) {
|
|
hinweis.hidden = false;
|
|
hinweis.textContent =
|
|
"Zeigt die " + daten.nodes.length + " am stärksten verknüpften von " +
|
|
daten.total + " Einträgen.";
|
|
}
|
|
zeichne(svg, daten.nodes || [], daten.edges || [], basisUrl);
|
|
})
|
|
.catch(function () {
|
|
svg.replaceWith(meldung("Graph konnte nicht geladen werden."));
|
|
});
|
|
}
|
|
|
|
function meldung(text) {
|
|
var div = document.createElement("div");
|
|
div.className = "leer";
|
|
var p = document.createElement("p");
|
|
p.textContent = text;
|
|
div.appendChild(p);
|
|
return div;
|
|
}
|
|
|
|
function zeichne(svg, knoten, kanten, basisUrl) {
|
|
if (!knoten.length) {
|
|
svg.replaceWith(meldung("Noch keine Einträge mit Verweisen."));
|
|
return;
|
|
}
|
|
|
|
var breite = svg.clientWidth || 800;
|
|
var hoehe = 560;
|
|
svg.setAttribute("viewBox", "0 0 " + breite + " " + hoehe);
|
|
|
|
var indexBySlug = {};
|
|
knoten.forEach(function (k, i) {
|
|
indexBySlug[k.slug] = i;
|
|
});
|
|
var kantenIdx = kanten
|
|
.map(function (e) {
|
|
return [indexBySlug[e.from], indexBySlug[e.to]];
|
|
})
|
|
.filter(function (p) {
|
|
return p[0] !== undefined && p[1] !== undefined;
|
|
});
|
|
|
|
var slugs = knoten.map(function (k) {
|
|
return k.slug;
|
|
});
|
|
var positionen = simuliere(knoten.length, kantenIdx, breite, hoehe, slugs);
|
|
|
|
var viewport = svgEl("g", {});
|
|
svg.appendChild(viewport);
|
|
|
|
var kantenGruppe = svgEl("g", { class: "graph-kanten" });
|
|
var knotenGruppe = svgEl("g", { class: "graph-knoten" });
|
|
viewport.appendChild(kantenGruppe);
|
|
viewport.appendChild(knotenGruppe);
|
|
|
|
kantenIdx.forEach(function (paar) {
|
|
var von = positionen[paar[0]];
|
|
var nach = positionen[paar[1]];
|
|
kantenGruppe.appendChild(
|
|
svgEl("line", { x1: von.x, y1: von.y, x2: nach.x, y2: nach.y, class: "graph-kante" }),
|
|
);
|
|
});
|
|
|
|
knoten.forEach(function (k, i) {
|
|
var pos = positionen[i];
|
|
var radius = 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3);
|
|
|
|
var gruppe = svgEl("g", {
|
|
class: "graph-knoten-gruppe" + (k.exists ? "" : " graph-knoten-fehlend"),
|
|
transform: "translate(" + pos.x.toFixed(1) + "," + pos.y.toFixed(1) + ")",
|
|
});
|
|
|
|
var titelEl = document.createElementNS(SVG_NS, "title");
|
|
titelEl.textContent = k.title;
|
|
gruppe.appendChild(titelEl);
|
|
|
|
gruppe.appendChild(svgEl("circle", { r: radius, class: "graph-punkt" }));
|
|
|
|
var text = svgEl("text", { x: radius + 4, y: 4, class: "graph-beschriftung" });
|
|
text.textContent = kuerze(k.title, 28);
|
|
gruppe.appendChild(text);
|
|
|
|
if (k.exists) {
|
|
gruppe.classList.add("graph-klickbar");
|
|
gruppe.setAttribute("data-slug", k.slug);
|
|
}
|
|
|
|
knotenGruppe.appendChild(gruppe);
|
|
});
|
|
|
|
aktiviereZoomUndPan(svg, viewport, breite, hoehe, basisUrl);
|
|
}
|
|
|
|
// fnv1a bildet aus einer Zeichenkette einen 32-Bit-Hash — hier verwendet,
|
|
// um aus den Bezeichnern der Knoten einen reproduzierbaren Startwert für
|
|
// die Zufallsfolge unten zu gewinnen. Kein kryptografischer Anspruch,
|
|
// nur Verteilung.
|
|
function fnv1a(text) {
|
|
var hash = 0x811c9dc5;
|
|
for (var i = 0; i < text.length; i++) {
|
|
hash ^= text.charCodeAt(i);
|
|
hash = Math.imul(hash, 0x01000193);
|
|
}
|
|
return hash >>> 0;
|
|
}
|
|
|
|
// mulberry32: ein kleiner, schneller, seed-basierter Zufallsgenerator.
|
|
// Math.random() lässt sich nicht mit einem Startwert versehen — jeder
|
|
// Aufruf wäre unvermeidbar anders. Mit demselben Seed liefert diese
|
|
// Funktion dagegen bei jedem Laden dieselbe Folge, und damit die
|
|
// Kräftesimulation unten dasselbe Ergebnis: Wer denselben Eintrag sucht,
|
|
// findet ihn beim nächsten Besuch wieder an derselben Stelle im Graphen,
|
|
// statt dass sich das Bild bei jedem Laden neu würfelt.
|
|
function mulberry32(seed) {
|
|
return function () {
|
|
seed |= 0;
|
|
seed = (seed + 0x6d2b79f5) | 0;
|
|
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
// Klassische Kräftesimulation: Abstoßung zwischen allen Knotenpaaren
|
|
// (Coulomb-artig), Anziehung entlang der Kanten (Federgesetz) und eine
|
|
// schwache Kraft zur Bildmitte, damit der Graph nicht davonwandert.
|
|
//
|
|
// O(Knoten²) je Durchlauf — bei der serverseitigen Obergrenze von 150
|
|
// Knoten sind das rund 22.000 Paare, mit 250 Durchläufen im
|
|
// einstelligen Millionenbereich an Rechenschritten. Das läuft im Browser
|
|
// in Bruchteilen einer Sekunde.
|
|
//
|
|
// slugsFuerSeed bestimmt nur den Startwert der Zufallsfolge (siehe
|
|
// mulberry32) — Zwei Aufrufe mit derselben Knotenmenge ergeben dieselben
|
|
// Startpositionen und damit, weil die Kräfte selbst keine Zufallszahlen
|
|
// mehr ziehen, dasselbe Endlayout. Ändert sich die Knotenmenge, ändert
|
|
// sich auch der Seed, und das Layout darf sich neu einpendeln.
|
|
function simuliere(n, kantenIdx, breite, hoehe, slugsFuerSeed) {
|
|
var zufall = mulberry32(fnv1a(slugsFuerSeed.join("\n")));
|
|
|
|
var positionen = [];
|
|
for (var i = 0; i < n; i++) {
|
|
positionen.push({
|
|
x: breite / 2 + (zufall() - 0.5) * breite * 0.6,
|
|
y: hoehe / 2 + (zufall() - 0.5) * hoehe * 0.6,
|
|
vx: 0,
|
|
vy: 0,
|
|
});
|
|
}
|
|
|
|
var ABSTOSSUNG = 1800;
|
|
var FEDER = 0.02;
|
|
var ZIELLAENGE = 90;
|
|
var DAEMPFUNG = 0.85;
|
|
var ITERATIONEN = 250;
|
|
|
|
for (var iter = 0; iter < ITERATIONEN; iter++) {
|
|
for (var a = 0; a < n; a++) {
|
|
for (var b = a + 1; b < n; b++) {
|
|
var dx = positionen[a].x - positionen[b].x;
|
|
var dy = positionen[a].y - positionen[b].y;
|
|
var distQ = dx * dx + dy * dy || 0.01;
|
|
var dist = Math.sqrt(distQ);
|
|
var kraft = ABSTOSSUNG / distQ;
|
|
var fx = (dx / dist) * kraft;
|
|
var fy = (dy / dist) * kraft;
|
|
positionen[a].vx += fx;
|
|
positionen[a].vy += fy;
|
|
positionen[b].vx -= fx;
|
|
positionen[b].vy -= fy;
|
|
}
|
|
}
|
|
|
|
kantenIdx.forEach(function (paar) {
|
|
var p1 = positionen[paar[0]];
|
|
var p2 = positionen[paar[1]];
|
|
var dx = p2.x - p1.x;
|
|
var dy = p2.y - p1.y;
|
|
var dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
var kraft = (dist - ZIELLAENGE) * FEDER;
|
|
var fx = (dx / dist) * kraft;
|
|
var fy = (dy / dist) * kraft;
|
|
p1.vx += fx;
|
|
p1.vy += fy;
|
|
p2.vx -= fx;
|
|
p2.vy -= fy;
|
|
});
|
|
|
|
for (var k = 0; k < n; k++) {
|
|
positionen[k].vx += (breite / 2 - positionen[k].x) * 0.002;
|
|
positionen[k].vy += (hoehe / 2 - positionen[k].y) * 0.002;
|
|
}
|
|
for (var m = 0; m < n; m++) {
|
|
positionen[m].vx *= DAEMPFUNG;
|
|
positionen[m].vy *= DAEMPFUNG;
|
|
positionen[m].x += positionen[m].vx;
|
|
positionen[m].y += positionen[m].vy;
|
|
}
|
|
}
|
|
|
|
return positionen;
|
|
}
|
|
|
|
// Zoom (Mausrad) und Pan (Ziehen) auf der Transform-Gruppe "viewport".
|
|
//
|
|
// Der naheliegende Ansatz — Mausbewegung in Bildschirm-Pixeln direkt als
|
|
// Verschiebung im viewBox-Koordinatensystem übernehmen — geht nur auf,
|
|
// wenn die dargestellte Größe der SVG-Fläche exakt ihrer viewBox
|
|
// entspricht. Das stimmt zufällig beim ersten Zeichnen (viewBox-Breite
|
|
// kommt von svg.clientWidth), aber nicht mehr nach einer
|
|
// Fenstergröße-Änderung (die SVG-Breite ist "100%" und wächst/schrumpft
|
|
// mit), und auch sonst nicht zuverlässig (abweichende rem-Basis,
|
|
// Browser-Zoom). zuViewBox() rechnet deshalb bei jeder Bewegung über
|
|
// getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung
|
|
// zu verlassen.
|
|
function aktiviereZoomUndPan(svg, viewport, breite, hoehe, basisUrl) {
|
|
var massstab = 1;
|
|
var x = 0;
|
|
var y = 0;
|
|
var ziehtGerade = false;
|
|
var startPunkt = { x: 0, y: 0 };
|
|
var startX = 0;
|
|
var startY = 0;
|
|
var bewegungsstrecke = 0;
|
|
var gedruecktesElement = null;
|
|
// Ab dieser Strecke (in viewBox-Einheiten) zählt eine Geste als
|
|
// Ziehen statt als Klick.
|
|
var ZIEHSCHWELLE = 6;
|
|
|
|
function anwenden() {
|
|
viewport.setAttribute("transform", "translate(" + x + "," + y + ") scale(" + massstab + ")");
|
|
}
|
|
|
|
function zuViewBox(clientX, clientY) {
|
|
var rect = svg.getBoundingClientRect();
|
|
return {
|
|
x: ((clientX - rect.left) / rect.width) * breite,
|
|
y: ((clientY - rect.top) / rect.height) * hoehe,
|
|
};
|
|
}
|
|
|
|
svg.addEventListener("pointerdown", function (event) {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
ziehtGerade = true;
|
|
bewegungsstrecke = 0;
|
|
gedruecktesElement = event.target.closest(".graph-klickbar");
|
|
startPunkt = zuViewBox(event.clientX, event.clientY);
|
|
startX = x;
|
|
startY = y;
|
|
svg.classList.add("graph-zieht");
|
|
svg.setPointerCapture(event.pointerId);
|
|
});
|
|
|
|
svg.addEventListener("pointermove", function (event) {
|
|
if (!ziehtGerade) {
|
|
return;
|
|
}
|
|
var jetzt = zuViewBox(event.clientX, event.clientY);
|
|
// Die Verschiebung (translate) der Gruppe lebt im selben
|
|
// Koordinatensystem wie die viewBox der äußeren SVG — deshalb
|
|
// hier ohne weitere Umrechnung durch massstab: Eine Bewegung um
|
|
// 10 viewBox-Einheiten verschiebt den Inhalt um genau 10
|
|
// Einheiten, unabhängig vom aktuellen Zoomstand.
|
|
//
|
|
// Der größte je erreichte Abstand zum Startpunkt zählt, nicht
|
|
// die Summe aller Zwischenschritte — sonst würde allein die
|
|
// Zahl der pointermove-Ereignisse (auch minimales Zittern der
|
|
// Maus feuert mehrere) die Schwelle überschreiten und selbst
|
|
// einen stillstehenden Klick als Ziehen werten.
|
|
var abstand = Math.abs(jetzt.x - startPunkt.x) + Math.abs(jetzt.y - startPunkt.y);
|
|
bewegungsstrecke = Math.max(bewegungsstrecke, abstand);
|
|
x = startX + (jetzt.x - startPunkt.x);
|
|
y = startY + (jetzt.y - startPunkt.y);
|
|
anwenden();
|
|
});
|
|
|
|
function beendeZiehen(event) {
|
|
if (!ziehtGerade) {
|
|
return;
|
|
}
|
|
ziehtGerade = false;
|
|
svg.classList.remove("graph-zieht");
|
|
if (event && svg.hasPointerCapture(event.pointerId)) {
|
|
svg.releasePointerCapture(event.pointerId);
|
|
}
|
|
|
|
// Navigation läuft hier statt über einen "click"-Listener auf
|
|
// dem Knoten: setPointerCapture (oben) verschiebt das Ziel
|
|
// NACHFOLGENDER Zeiger- UND Klick-Ereignisse auf die SVG-Wurzel,
|
|
// ganz gleich, worauf ursprünglich gedrückt wurde. Ein
|
|
// click-Listener am Knoten selbst würde dadurch nie mehr feuern.
|
|
// Stattdessen wird beim Loslassen nachgesehen, ob (a) kaum
|
|
// Bewegung stattfand und (b) das Drücken auf einem anklickbaren
|
|
// Knoten begann.
|
|
if (bewegungsstrecke <= ZIEHSCHWELLE && gedruecktesElement) {
|
|
window.location.href = basisUrl + encodeURIComponent(gedruecktesElement.getAttribute("data-slug"));
|
|
}
|
|
gedruecktesElement = null;
|
|
}
|
|
svg.addEventListener("pointerup", beendeZiehen);
|
|
svg.addEventListener("pointercancel", function () {
|
|
gedruecktesElement = null;
|
|
beendeZiehen.apply(null, arguments);
|
|
});
|
|
|
|
svg.addEventListener(
|
|
"wheel",
|
|
function (event) {
|
|
event.preventDefault();
|
|
var faktor = event.deltaY < 0 ? 1.1 : 0.9;
|
|
var neuerMassstab = Math.min(4, Math.max(0.25, massstab * faktor));
|
|
if (neuerMassstab === massstab) {
|
|
return;
|
|
}
|
|
// Zoomt zur Cursor-Position statt zur Bildecke: Der Punkt
|
|
// unter dem Mauszeiger bleibt an derselben Stelle stehen,
|
|
// statt dass der Inhalt beim Zoomen "wegläuft".
|
|
var zeiger = zuViewBox(event.clientX, event.clientY);
|
|
var verhaeltnis = neuerMassstab / massstab;
|
|
x = zeiger.x - (zeiger.x - x) * verhaeltnis;
|
|
y = zeiger.y - (zeiger.y - y) * verhaeltnis;
|
|
massstab = neuerMassstab;
|
|
anwenden();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
}
|
|
|
|
function svgEl(tag, attrs) {
|
|
var el = document.createElementNS(SVG_NS, tag);
|
|
Object.keys(attrs).forEach(function (name) {
|
|
el.setAttribute(name, attrs[name]);
|
|
});
|
|
return el;
|
|
}
|
|
|
|
function kuerze(text, max) {
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
}
|
|
})();
|