// 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; } })();