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