// 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. // // Damit der Graph auch bei vielen Knoten lesbar bleibt, gelten drei Regeln: // // 1. Zoomen zieht die Knoten auseinander, statt alles zu vergrößern. // Punkte und Schrift behalten ihre Bildschirmgröße — wer hineinzoomt, // schafft also tatsächlich Platz zwischen den Knoten. // 2. Beschriftet wird nur, was Platz hat. Die am stärksten verknüpften // Knoten kommen zuerst an die Reihe; eine Beschriftung, die eine schon // gesetzte oder einen fremden Punkt überdecken würde, bleibt weg (siehe // label()). Je weiter man hineinzoomt, desto mehr Namen erscheinen. // 3. Zeigt die Maus auf einen Knoten, treten er und seine direkten Nachbarn // hervor und werden bevorzugt beschriftet; alles andere wird blass. (function () { "use strict"; var SVG_NS = "http://www.w3.org/2000/svg"; // Ungefähre Höhe einer Beschriftungszeile in Bildschirmpixeln, samt // etwas Luft — nur für die Überlappungsprüfung in label(). var LINE_HEIGHT = 16; // Abstand zwischen Punkt und Beschriftung. var TEXT_GAP = 4; document.querySelectorAll("svg[data-graph]").forEach(function (svg) { loadAndDraw(svg); }); function loadAndDraw(svg) { var url = svg.getAttribute("data-graph"); var baseUrl = svg.getAttribute("data-graph-base"); var hint = document.querySelector("[data-graph-hint]"); fetch(url, { headers: { Accept: "application/json" } }) .then(function (answer) { if (!answer.ok) { throw new Error("Graph konnte nicht geladen werden"); } return answer.json(); }) .then(function (data) { if (hint && data.truncated) { hint.hidden = false; hint.textContent = "Zeigt die " + data.nodes.length + " am stärksten verknüpften von " + data.total + " Einträgen."; } draw(svg, data.nodes || [], data.edges || [], baseUrl); }) .catch(function () { svg.replaceWith(notice("Graph konnte nicht geladen werden.")); }); } function notice(text) { var div = document.createElement("div"); div.className = "empty"; var p = document.createElement("p"); p.textContent = text; div.appendChild(p); return div; } function draw(svg, nodeItems, edgeItems, baseUrl) { if (!nodeItems.length) { svg.replaceWith(notice("Noch keine Einträge mit Verweisen.")); return; } // Die viewBox entspricht der tatsächlichen Größe beim Laden — eine // viewBox-Einheit ist damit ein Bildschirmpixel, und Schriftgrößen // sowie Radien unten lassen sich direkt in Pixeln denken. var boxWidth = svg.clientWidth || 800; var boxHeight = svg.clientHeight || 560; svg.setAttribute("viewBox", "0 0 " + boxWidth + " " + boxHeight); var indexBySlug = {}; nodeItems.forEach(function (k, i) { indexBySlug[k.slug] = i; }); var edgeIdx = edgeItems .map(function (e) { return [indexBySlug[e.from], indexBySlug[e.to]]; }) .filter(function (p) { return p[0] !== undefined && p[1] !== undefined; }); // Nachbarn je Knoten — für die Hervorhebung beim Überfahren. var neighbors = nodeItems.map(function () { return []; }); edgeIdx.forEach(function (pair) { neighbors[pair[0]].push(pair[1]); neighbors[pair[1]].push(pair[0]); }); var radii = nodeItems.map(function (k) { return 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3); }); // ranking: Knotenindizes, am stärksten verknüpfte zuerst. Diese // Reihenfolge entscheidet, wer bei Platzmangel beschriftet wird. // Vorhandene Einträge gehen fehlenden Verweiszielen vor, bei // Gleichstand entscheidet der Titel — so bleibt die Auswahl bei // jedem Laden dieselbe. var ranking = nodeItems.map(function (_, i) { return i; }); ranking.sort(function (a, b) { var ka = nodeItems[a]; var kb = nodeItems[b]; if ((kb.degree || 0) !== (ka.degree || 0)) { return (kb.degree || 0) - (ka.degree || 0); } if (ka.exists !== kb.exists) { return ka.exists ? -1 : 1; } return ka.title < kb.title ? -1 : ka.title > kb.title ? 1 : 0; }); var slugs = nodeItems.map(function (k) { return k.slug; }); var positions = simulate(nodeItems.length, edgeIdx, radii, slugs); var edgeGroup = svgEl("g", { class: "graph-edges" }); var nodeGroup = svgEl("g", { class: "graph-nodes" }); svg.appendChild(edgeGroup); svg.appendChild(nodeGroup); var edgeEls = edgeIdx.map(function (pair) { var line = svgEl("line", { class: "graph-edge" }); edgeGroup.appendChild(line); return line; }); var nodeEls = nodeItems.map(function (k, i) { var group = svgEl("g", { class: "graph-node-group" + (k.exists ? "" : " graph-node-missing"), "data-index": i, }); // zeigt der Browser als Tooltip — so ist der volle Name // auch dann lesbar, wenn die Beschriftung gerade ausgeblendet ist. var titleEl = document.createElementNS(SVG_NS, "title"); titleEl.textContent = k.title; group.appendChild(titleEl); group.appendChild(svgEl("circle", { r: radii[i], class: "graph-dot" })); var text = svgEl("text", { x: radii[i] + TEXT_GAP, y: 0, class: "graph-label" }); text.textContent = shorten(k.title, 28); group.appendChild(text); if (k.exists) { group.classList.add("graph-clickable"); group.setAttribute("data-slug", k.slug); } nodeGroup.appendChild(group); return { group: group, text: text }; }); // Die Textbreite einmal messen, solange alles sichtbar ist (ein // ausgeblendetes Element hat keine messbare Breite). Scheitert die // Messung, hilft eine Schätzung über die Zeichenzahl. var textWidths = nodeEls.map(function (el) { var measured = 0; try { measured = el.text.getComputedTextLength(); } catch (e) { measured = 0; } return measured || el.text.textContent.length * 6.5; }); // view beschreibt, wie Layout-Koordinaten (aus simulate) auf den // Bildschirm kommen: screenPoint = layout * scale + verschiebung. var view = { scale: 1, x: 0, y: 0 }; var focused = -1; var screenPoint = positions.map(function () { return { x: 0, y: 0 }; }); // update setzt alle Elemente an ihre Bildschirmposition und // wählt die Beschriftungen neu aus. Wird über requestAnimationFrame // höchstens einmal je Bildschirmbild ausgeführt, auch wenn Mausrad // oder Ziehen viel häufiger Ereignisse liefern. var scheduled = false; function updateSoon() { if (scheduled) { return; } scheduled = true; window.requestAnimationFrame(function () { scheduled = false; update(); }); } function update() { positions.forEach(function (pos, i) { var bx = pos.x * view.scale + view.x; var by = pos.y * view.scale + view.y; screenPoint[i].x = bx; screenPoint[i].y = by; nodeEls[i].group.setAttribute("transform", "translate(" + bx.toFixed(1) + "," + by.toFixed(1) + ")"); }); edgeIdx.forEach(function (pair, i) { var src = screenPoint[pair[0]]; var dest = screenPoint[pair[1]]; edgeEls[i].setAttribute("x1", src.x.toFixed(1)); edgeEls[i].setAttribute("y1", src.y.toFixed(1)); edgeEls[i].setAttribute("x2", dest.x.toFixed(1)); edgeEls[i].setAttribute("y2", dest.y.toFixed(1)); }); label(); } // label entscheidet, welche Knoten gerade einen Namen tragen. // // Gierig in Rangfolge: Jede Beschriftung wird zuerst rechts, dann // links vom Punkt versucht. Überdeckt sie an beiden Stellen eine // schon gesetzte Beschriftung oder einen fremden Punkt, bleibt sie // weg. Das ist nicht die bestmögliche Verteilung, aber es garantiert // das Wichtigste: Keine zwei Namen liegen übereinander. // // Aufwand: je Knoten ein Vergleich mit allen Punkten und allen schon // gesetzten Namen — bei 150 Knoten einige zehntausend Rechtecktests, // was auch beim flüssigen Zoomen nicht ins Gewicht fällt. function label() { var pointBoxes = screenPoint.map(function (p, i) { var r = radii[i]; return { x1: p.x - r, y1: p.y - r, x2: p.x + r, y2: p.y + r }; }); var isSet = []; var order = ranking; var allowed = null; if (focused >= 0) { // Im Fokus: nur der Knoten selbst und seine Nachbarn, er // selbst zuerst. allowed = {}; allowed[focused] = true; neighbors[focused].forEach(function (n) { allowed[n] = true; }); order = [focused].concat( ranking.filter(function (i) { return i !== focused && allowed[i]; }), ); } var display = {}; order.forEach(function (i) { var p = screenPoint[i]; // Knoten außerhalb des sichtbaren Bereichs brauchen keinen // Namen und sollen auch keinen Platz belegen. if (p.x < -50 || p.y < -50 || p.x > boxWidth + 50 || p.y > boxHeight + 50) { return; } var r = radii[i]; var w = textWidths[i]; var y1 = p.y - LINE_HEIGHT / 2; var y2 = p.y + LINE_HEIGHT / 2; var right = { x1: p.x + r + TEXT_GAP, y1: y1, x2: p.x + r + TEXT_GAP + w, y2: y2 }; var links = { x1: p.x - r - TEXT_GAP - w, y1: y1, x2: p.x - r - TEXT_GAP, y2: y2 }; // Der hervorgehobene Knoten bekommt seinen Namen immer. var always = i === focused; if (always || isFree(right, i, pointBoxes, isSet)) { isSet.push(right); display[i] = "right"; } else if (isFree(links, i, pointBoxes, isSet)) { isSet.push(links); display[i] = "links"; } }); nodeEls.forEach(function (el, i) { var page = display[i]; el.text.classList.toggle("graph-label-hidden", !page); if (page === "links") { el.text.setAttribute("x", -(radii[i] + TEXT_GAP)); el.text.setAttribute("text-anchor", "end"); } else if (page === "right") { el.text.setAttribute("x", radii[i] + TEXT_GAP); el.text.removeAttribute("text-anchor"); } }); } // setFocus hebt Knoten i und seine Nachbarn hervor; -1 hebt die // Hervorhebung auf. function setFocus(i) { if (i === focused) { return; } focused = i; svg.classList.toggle("graph-focus", focused >= 0); var near = {}; if (focused >= 0) { near[focused] = true; neighbors[focused].forEach(function (n) { near[n] = true; }); } nodeEls.forEach(function (el, k) { el.group.classList.toggle("graph-neighbor", !!near[k]); }); edgeIdx.forEach(function (pair, k) { edgeEls[k].classList.toggle("graph-edge-active", pair[0] === focused || pair[1] === focused); }); // Knoten im Fokus ans Ende ihrer Gruppe hängen: In SVG liegt // oben, was zuletzt kommt — so verdeckt kein blasser Nachbar den // hervorgehobenen Namen. if (focused >= 0) { nodeGroup.appendChild(nodeEls[focused].group); } label(); } svg.addEventListener("pointerover", function (event) { // Während des Ziehens nicht umschalten — sonst flackert die // Hervorhebung über alle Knoten, die unter der Maus durchlaufen. if (svg.classList.contains("graph-dragging")) { return; } var group = event.target.closest(".graph-node-group"); setFocus(group ? Number(group.getAttribute("data-index")) : -1); }); svg.addEventListener("pointerleave", function () { setFocus(-1); }); // Einpassen: den ganzen Graphen mittig in die Fläche zoomen. var bounds = { min: 0.1, max: 4 }; function fitToView() { var minX = Infinity; var minY = Infinity; var maxX = -Infinity; var maxY = -Infinity; positions.forEach(function (p) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); // Rand in Bildschirmpixeln: Platz für die Punkte selbst und für // Beschriftungen am äußeren Rand. var margin = 48; var spanX = Math.max(maxX - minX, 1); var spanY = Math.max(maxY - minY, 1); var scale = Math.min((boxWidth - 2 * margin) / spanX, (boxHeight - 2 * margin) / spanY); // Kleine Graphen nicht künstlich aufblasen. scale = Math.min(scale, 1.5); view.scale = scale; view.x = boxWidth / 2 - ((minX + maxX) / 2) * scale; view.y = boxHeight / 2 - ((minY + maxY) / 2) * scale; // Heraus nur bis zur halben Einpass-Größe, hinein weit genug, // dass auch dichte Knäuel auseinanderfallen. bounds.min = scale * 0.5; bounds.max = Math.max(scale * 12, 3); updateSoon(); } fitToView(); enableZoomAndPan(svg, view, bounds, boxWidth, boxHeight, baseUrl, updateSoon); document.querySelectorAll("[data-graph-fit]").forEach(function (btn) { btn.addEventListener("click", fitToView); }); } // isFree prüft, ob das Rechteck box weder einen fremden Punkt (alle // außer dem eigenen, Index own) noch eine schon gesetzte // Beschriftung überdeckt. function isFree(box, own, pointBoxes, isSet) { for (var i = 0; i < pointBoxes.length; i++) { if (i !== own && overlaps(box, pointBoxes[i])) { return false; } } for (var j = 0; j < isSet.length; j++) { if (overlaps(box, isSet[j])) { return false; } } return true; } // Zwei Rechtecke überlappen, wenn sie sich auf beiden Achsen überlappen. function overlaps(a, b) { return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1; } // 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 Mitte, damit der Graph nicht davonwandert. Dazu // eine Kollisionskraft, die Punkte, die sich überlappen würden, hart // auseinanderschiebt. // // Das Ergebnis sind Layout-Koordinaten um den Nullpunkt, unabhängig von // der Größe der Zeichenfläche — auf den Bildschirm bringt sie erst // fitToView() in draw(). // // O(Knoten²) je Durchlauf — bei der serverseitigen Obergrenze von 150 // Knoten sind das rund 11.000 Paare, mit 300 Durchläufen im // einstelligen Millionenbereich an Rechenschritten. Das läuft im Browser // in Bruchteilen einer Sekunde. // // slugsForSeed 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 simulate(n, edgeIdx, radii, slugsForSeed) { var rng = mulberry32(fnv1a(slugsForSeed.join("\n"))); var REPULSION = 3000; var SPRING = 0.02; var TARGET_LENGTH = 110; var DAMPING = 0.85; var ITERATIONS = 300; // Mindestabstand zwischen zwei Punkten zusätzlich zu ihren Radien. var PADDING = 24; // Startfläche wächst mit der Wurzel der Knotenzahl — so beginnt ein // großer Graph nicht zusammengequetscht auf derselben Fläche wie ein // kleiner. var span = TARGET_LENGTH * Math.sqrt(n); var positions = []; for (var i = 0; i < n; i++) { positions.push({ x: (rng() - 0.5) * span, y: (rng() - 0.5) * span, vx: 0, vy: 0, }); } for (var iter = 0; iter < ITERATIONS; iter++) { for (var a = 0; a < n; a++) { for (var b = a + 1; b < n; b++) { var dx = positions[a].x - positions[b].x; var dy = positions[a].y - positions[b].y; var distQ = dx * dx + dy * dy || 0.01; var dist = Math.sqrt(distQ); var force = REPULSION / distQ; var minimum = radii[a] + radii[b] + PADDING; if (dist < minimum) { force += (minimum - dist) * 0.5; } var fx = (dx / dist) * force; var fy = (dy / dist) * force; positions[a].vx += fx; positions[a].vy += fy; positions[b].vx -= fx; positions[b].vy -= fy; } } edgeIdx.forEach(function (pair) { var p1 = positions[pair[0]]; var p2 = positions[pair[1]]; var dx = p2.x - p1.x; var dy = p2.y - p1.y; var dist = Math.sqrt(dx * dx + dy * dy) || 0.01; var force = (dist - TARGET_LENGTH) * SPRING; var fx = (dx / dist) * force; var fy = (dy / dist) * force; p1.vx += fx; p1.vy += fy; p2.vx -= fx; p2.vy -= fy; }); for (var k = 0; k < n; k++) { positions[k].vx -= positions[k].x * 0.002; positions[k].vy -= positions[k].y * 0.002; } for (var m = 0; m < n; m++) { positions[m].vx *= DAMPING; positions[m].vy *= DAMPING; positions[m].x += positions[m].vx; positions[m].y += positions[m].vy; } } return positions; } // Zoom (Mausrad) und Pan (Ziehen). Verändert nur das Objekt view // (scale, x, y) und ruft danach onChange auf — das Umsetzen auf // die SVG-Elemente übernimmt draw(). // // 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-Größe // kommt von svg.clientWidth/clientHeight), 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). toViewBox() rechnet deshalb bei jeder Bewegung über // getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung // zu verlassen. function enableZoomAndPan(svg, view, bounds, boxWidth, boxHeight, baseUrl, onChange) { var dragging = false; var startPoint = { x: 0, y: 0 }; var startX = 0; var startY = 0; var moveDistance = 0; var pressedElement = null; // Ab dieser Strecke (in viewBox-Einheiten) zählt eine Geste als // Ziehen statt als Klick. var DRAG_THRESHOLD = 6; function toViewBox(clientX, clientY) { var rect = svg.getBoundingClientRect(); return { x: ((clientX - rect.left) / rect.width) * boxWidth, y: ((clientY - rect.top) / rect.height) * boxHeight, }; } svg.addEventListener("pointerdown", function (event) { if (event.button !== 0) { return; } dragging = true; moveDistance = 0; pressedElement = event.target.closest(".graph-clickable"); startPoint = toViewBox(event.clientX, event.clientY); startX = view.x; startY = view.y; svg.classList.add("graph-dragging"); svg.setPointerCapture(event.pointerId); }); svg.addEventListener("pointermove", function (event) { if (!dragging) { return; } var now = toViewBox(event.clientX, event.clientY); // Die Verschiebung lebt im Bildschirm-Koordinatensystem der // viewBox — deshalb hier ohne weitere Umrechnung durch scale: // 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 gap = Math.abs(now.x - startPoint.x) + Math.abs(now.y - startPoint.y); moveDistance = Math.max(moveDistance, gap); view.x = startX + (now.x - startPoint.x); view.y = startY + (now.y - startPoint.y); onChange(); }); function endDrag(event) { if (!dragging) { return; } dragging = false; svg.classList.remove("graph-dragging"); 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 (moveDistance <= DRAG_THRESHOLD && pressedElement) { window.location.href = baseUrl + encodeURIComponent(pressedElement.getAttribute("data-slug")); } pressedElement = null; } svg.addEventListener("pointerup", endDrag); svg.addEventListener("pointercancel", function () { pressedElement = null; endDrag.apply(null, arguments); }); svg.addEventListener( "wheel", function (event) { event.preventDefault(); var factor = event.deltaY < 0 ? 1.15 : 1 / 1.15; var newScale = Math.min(bounds.max, Math.max(bounds.min, view.scale * factor)); if (newScale === view.scale) { 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 pointer = toViewBox(event.clientX, event.clientY); var ratio = newScale / view.scale; view.x = pointer.x - (pointer.x - view.x) * ratio; view.y = pointer.y - (pointer.y - view.y) * ratio; view.scale = newScale; onChange(); }, { 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 shorten(text, max) { if (!text) { return ""; } return text.length > max ? text.slice(0, max - 1) + "…" : text; } })();