672 lines
27 KiB
JavaScript
672 lines
27 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.
|
|
//
|
|
// 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,
|
|
});
|
|
|
|
// <title> 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;
|
|
}
|
|
})();
|