munin/static/graph.js

672 lines
28 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
// beschrifte()). 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 beschrifte().
var ZEILENHOEHE = 16;
// Abstand zwischen Punkt und Beschriftung.
var TEXTABSTAND = 4;
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;
}
// 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 breite = svg.clientWidth || 800;
var hoehe = svg.clientHeight || 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;
});
// Nachbarn je Knoten — für die Hervorhebung beim Überfahren.
var nachbarn = knoten.map(function () {
return [];
});
kantenIdx.forEach(function (paar) {
nachbarn[paar[0]].push(paar[1]);
nachbarn[paar[1]].push(paar[0]);
});
var radien = knoten.map(function (k) {
return 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3);
});
// rangfolge: 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 rangfolge = knoten.map(function (_, i) {
return i;
});
rangfolge.sort(function (a, b) {
var ka = knoten[a];
var kb = knoten[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 = knoten.map(function (k) {
return k.slug;
});
var positionen = simuliere(knoten.length, kantenIdx, radien, slugs);
var kantenGruppe = svgEl("g", { class: "graph-kanten" });
var knotenGruppe = svgEl("g", { class: "graph-knoten" });
svg.appendChild(kantenGruppe);
svg.appendChild(knotenGruppe);
var kantenEls = kantenIdx.map(function (paar) {
var linie = svgEl("line", { class: "graph-kante" });
kantenGruppe.appendChild(linie);
return linie;
});
var knotenEls = knoten.map(function (k, i) {
var gruppe = svgEl("g", {
class: "graph-knoten-gruppe" + (k.exists ? "" : " graph-knoten-fehlend"),
"data-index": i,
});
// <title> zeigt der Browser als Tooltip — so ist der volle Name
// auch dann lesbar, wenn die Beschriftung gerade ausgeblendet ist.
var titelEl = document.createElementNS(SVG_NS, "title");
titelEl.textContent = k.title;
gruppe.appendChild(titelEl);
gruppe.appendChild(svgEl("circle", { r: radien[i], class: "graph-punkt" }));
var text = svgEl("text", { x: radien[i] + TEXTABSTAND, y: 0, 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);
return { gruppe: gruppe, 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 textBreiten = knotenEls.map(function (el) {
var gemessen = 0;
try {
gemessen = el.text.getComputedTextLength();
} catch (e) {
gemessen = 0;
}
return gemessen || el.text.textContent.length * 6.5;
});
// ansicht beschreibt, wie Layout-Koordinaten (aus simuliere) auf den
// Bildschirm kommen: bildschirm = layout * massstab + verschiebung.
var ansicht = { massstab: 1, x: 0, y: 0 };
var fokus = -1;
var bildschirm = positionen.map(function () {
return { x: 0, y: 0 };
});
// aktualisiere 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 geplant = false;
function aktualisiereBald() {
if (geplant) {
return;
}
geplant = true;
window.requestAnimationFrame(function () {
geplant = false;
aktualisiere();
});
}
function aktualisiere() {
positionen.forEach(function (pos, i) {
var bx = pos.x * ansicht.massstab + ansicht.x;
var by = pos.y * ansicht.massstab + ansicht.y;
bildschirm[i].x = bx;
bildschirm[i].y = by;
knotenEls[i].gruppe.setAttribute("transform", "translate(" + bx.toFixed(1) + "," + by.toFixed(1) + ")");
});
kantenIdx.forEach(function (paar, i) {
var von = bildschirm[paar[0]];
var nach = bildschirm[paar[1]];
kantenEls[i].setAttribute("x1", von.x.toFixed(1));
kantenEls[i].setAttribute("y1", von.y.toFixed(1));
kantenEls[i].setAttribute("x2", nach.x.toFixed(1));
kantenEls[i].setAttribute("y2", nach.y.toFixed(1));
});
beschrifte();
}
// beschrifte 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 beschrifte() {
var punktKaesten = bildschirm.map(function (p, i) {
var r = radien[i];
return { x1: p.x - r, y1: p.y - r, x2: p.x + r, y2: p.y + r };
});
var gesetzt = [];
var reihenfolge = rangfolge;
var erlaubt = null;
if (fokus >= 0) {
// Im Fokus: nur der Knoten selbst und seine Nachbarn, er
// selbst zuerst.
erlaubt = {};
erlaubt[fokus] = true;
nachbarn[fokus].forEach(function (n) {
erlaubt[n] = true;
});
reihenfolge = [fokus].concat(
rangfolge.filter(function (i) {
return i !== fokus && erlaubt[i];
}),
);
}
var zeigen = {};
reihenfolge.forEach(function (i) {
var p = bildschirm[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 > breite + 50 || p.y > hoehe + 50) {
return;
}
var r = radien[i];
var w = textBreiten[i];
var y1 = p.y - ZEILENHOEHE / 2;
var y2 = p.y + ZEILENHOEHE / 2;
var rechts = { x1: p.x + r + TEXTABSTAND, y1: y1, x2: p.x + r + TEXTABSTAND + w, y2: y2 };
var links = { x1: p.x - r - TEXTABSTAND - w, y1: y1, x2: p.x - r - TEXTABSTAND, y2: y2 };
// Der hervorgehobene Knoten bekommt seinen Namen immer.
var immer = i === fokus;
if (immer || istFrei(rechts, i, punktKaesten, gesetzt)) {
gesetzt.push(rechts);
zeigen[i] = "rechts";
} else if (istFrei(links, i, punktKaesten, gesetzt)) {
gesetzt.push(links);
zeigen[i] = "links";
}
});
knotenEls.forEach(function (el, i) {
var seite = zeigen[i];
el.text.classList.toggle("graph-beschriftung-aus", !seite);
if (seite === "links") {
el.text.setAttribute("x", -(radien[i] + TEXTABSTAND));
el.text.setAttribute("text-anchor", "end");
} else if (seite === "rechts") {
el.text.setAttribute("x", radien[i] + TEXTABSTAND);
el.text.removeAttribute("text-anchor");
}
});
}
// setzeFokus hebt Knoten i und seine Nachbarn hervor; -1 hebt die
// Hervorhebung auf.
function setzeFokus(i) {
if (i === fokus) {
return;
}
fokus = i;
svg.classList.toggle("graph-fokus", fokus >= 0);
var nah = {};
if (fokus >= 0) {
nah[fokus] = true;
nachbarn[fokus].forEach(function (n) {
nah[n] = true;
});
}
knotenEls.forEach(function (el, k) {
el.gruppe.classList.toggle("graph-nachbar", !!nah[k]);
});
kantenIdx.forEach(function (paar, k) {
kantenEls[k].classList.toggle("graph-kante-aktiv", paar[0] === fokus || paar[1] === fokus);
});
// 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 (fokus >= 0) {
knotenGruppe.appendChild(knotenEls[fokus].gruppe);
}
beschrifte();
}
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-zieht")) {
return;
}
var gruppe = event.target.closest(".graph-knoten-gruppe");
setzeFokus(gruppe ? Number(gruppe.getAttribute("data-index")) : -1);
});
svg.addEventListener("pointerleave", function () {
setzeFokus(-1);
});
// Einpassen: den ganzen Graphen mittig in die Fläche zoomen.
var grenzen = { min: 0.1, max: 4 };
function einpassen() {
var minX = Infinity;
var minY = Infinity;
var maxX = -Infinity;
var maxY = -Infinity;
positionen.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 rand = 48;
var spanneX = Math.max(maxX - minX, 1);
var spanneY = Math.max(maxY - minY, 1);
var massstab = Math.min((breite - 2 * rand) / spanneX, (hoehe - 2 * rand) / spanneY);
// Kleine Graphen nicht künstlich aufblasen.
massstab = Math.min(massstab, 1.5);
ansicht.massstab = massstab;
ansicht.x = breite / 2 - ((minX + maxX) / 2) * massstab;
ansicht.y = hoehe / 2 - ((minY + maxY) / 2) * massstab;
// Heraus nur bis zur halben Einpass-Größe, hinein weit genug,
// dass auch dichte Knäuel auseinanderfallen.
grenzen.min = massstab * 0.5;
grenzen.max = Math.max(massstab * 12, 3);
aktualisiereBald();
}
einpassen();
aktiviereZoomUndPan(svg, ansicht, grenzen, breite, hoehe, basisUrl, aktualisiereBald);
document.querySelectorAll("[data-graph-einpassen]").forEach(function (knopf) {
knopf.addEventListener("click", einpassen);
});
}
// istFrei prüft, ob das Rechteck kasten weder einen fremden Punkt (alle
// außer dem eigenen, Index eigener) noch eine schon gesetzte
// Beschriftung überdeckt.
function istFrei(kasten, eigener, punktKaesten, gesetzt) {
for (var i = 0; i < punktKaesten.length; i++) {
if (i !== eigener && ueberlappt(kasten, punktKaesten[i])) {
return false;
}
}
for (var j = 0; j < gesetzt.length; j++) {
if (ueberlappt(kasten, gesetzt[j])) {
return false;
}
}
return true;
}
// Zwei Rechtecke überlappen, wenn sie sich auf beiden Achsen überlappen.
function ueberlappt(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
// einpassen() in zeichne().
//
// 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.
//
// 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, radien, slugsFuerSeed) {
var zufall = mulberry32(fnv1a(slugsFuerSeed.join("\n")));
var ABSTOSSUNG = 3000;
var FEDER = 0.02;
var ZIELLAENGE = 110;
var DAEMPFUNG = 0.85;
var ITERATIONEN = 300;
// Mindestabstand zwischen zwei Punkten zusätzlich zu ihren Radien.
var LUFT = 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 spanne = ZIELLAENGE * Math.sqrt(n);
var positionen = [];
for (var i = 0; i < n; i++) {
positionen.push({
x: (zufall() - 0.5) * spanne,
y: (zufall() - 0.5) * spanne,
vx: 0,
vy: 0,
});
}
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 mindest = radien[a] + radien[b] + LUFT;
if (dist < mindest) {
kraft += (mindest - dist) * 0.5;
}
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 -= positionen[k].x * 0.002;
positionen[k].vy -= 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). Verändert nur das Objekt ansicht
// (massstab, x, y) und ruft danach beiAenderung auf — das Umsetzen auf
// die SVG-Elemente übernimmt zeichne().
//
// 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). zuViewBox() rechnet deshalb bei jeder Bewegung über
// getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung
// zu verlassen.
function aktiviereZoomUndPan(svg, ansicht, grenzen, breite, hoehe, basisUrl, beiAenderung) {
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 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 = ansicht.x;
startY = ansicht.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 lebt im Bildschirm-Koordinatensystem der
// viewBox — 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);
ansicht.x = startX + (jetzt.x - startPunkt.x);
ansicht.y = startY + (jetzt.y - startPunkt.y);
beiAenderung();
});
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.15 : 1 / 1.15;
var neuerMassstab = Math.min(grenzen.max, Math.max(grenzen.min, ansicht.massstab * faktor));
if (neuerMassstab === ansicht.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 / ansicht.massstab;
ansicht.x = zeiger.x - (zeiger.x - ansicht.x) * verhaeltnis;
ansicht.y = zeiger.y - (zeiger.y - ansicht.y) * verhaeltnis;
ansicht.massstab = neuerMassstab;
beiAenderung();
},
{ 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;
}
})();