<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Visualization |</title><link>https://aretascodes.dev/de/tags/visualization/</link><atom:link href="https://aretascodes.dev/de/tags/visualization/index.xml" rel="self" type="application/rss+xml"/><description>Visualization</description><generator>HugoBlox Kit (https://hugoblox.com)</generator><language>de-DE</language><lastBuildDate>Fri, 15 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://aretascodes.dev/media/icon_hu_2ab4f4763b27c75b.png</url><title>Visualization</title><link>https://aretascodes.dev/de/tags/visualization/</link></image><item><title>Teil 6 · Der Mindmap-Renderer: Was mich das händische Bauen von SVG gelehrt hat (und warum v2 React Flow nutzt)</title><link>https://aretascodes.dev/de/blog/svg-mindmap-from-scratch/</link><pubDate>Fri, 15 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/svg-mindmap-from-scratch/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Teil einer Serie über die Entwicklung von
. Zuvor:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang unten auf der Seite vollständig erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Der Study Hub von CogniVault hat vier Modi. Drei davon – Quiz, Workshop, Flashcards – haben eine Listenform. Der vierte, &lt;strong&gt;Mindmap&lt;/strong&gt;, nicht. Es ist ein Baum von Konzepten, die von einem zentralen Thema ausgehen, und ich wollte, dass er:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Optisch so sauber ist, dass der Nutzer ihn sich tatsächlich gerne ansieht.&lt;/li&gt;
&lt;li&gt;Interaktiv ist: Verschieben, Zoomen, Erkunden.&lt;/li&gt;
&lt;li&gt;Hochauflösend als PNG und PDF exportiert werden kann.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Dieser Beitrag ist die ehrliche Version davon, wie dieser Renderer gebaut wurde – &lt;strong&gt;und zwar zweimal&lt;/strong&gt;. Version eins war reines, händisch gebautes SVG ohne Graphen-Bibliothek, und sie wurde veröffentlicht. Version zwei, die heute in der Codebasis steht, baut auf &lt;code&gt;@xyflow/react&lt;/code&gt; (React Flow) und einem dagre Auto-Layout auf. Ich glaube, beide Entscheidungen waren &lt;em&gt;zu dem Zeitpunkt, als sie getroffen wurden&lt;/em&gt;, richtig. Und der Weg dazwischen hat mir mehr über &amp;ldquo;Build vs. Buy&amp;rdquo; (Selber bauen oder einkaufen) beigebracht als jede der beiden Versionen für sich genommen.&lt;/p&gt;
&lt;h2 id="runde-eins-händisch-bauen"&gt;Runde eins: Händisch bauen&lt;/h2&gt;
&lt;p&gt;Mein erster Instinkt war, wie bei jedem anderen auch, sofort an Tag eins zu einer Bibliothek zu greifen. Ich habe widerstanden, und zwar aus guten Gründen: Das Standard-Styling hätte sowieso komplett angepasst werden müssen, das gewünschte Layout war simpel, für den Export hätte man ohnehin externe Abhängigkeiten gebraucht, und die Bundle-Größe ist auch nicht zu verachten. Für die kleinen Bäume, die Gemma generiert, schien SVG völlig ausreichend – es lässt sich mit dem &lt;code&gt;viewBox&lt;/code&gt;-Attribut verschieben und zoomen, zeichnet beliebige Formen, lässt sich als String serialisieren und sauber rastern.&lt;/p&gt;
&lt;p&gt;Also war v1 reines SVG. Und der Kern davon war wirklich klein.&lt;/p&gt;
&lt;h3 id="radial-layout-in-40-zeilen"&gt;Radial-Layout in 40 Zeilen&lt;/h3&gt;
&lt;p&gt;Ein radiales Layout platziert die Wurzel in der Mitte und ordnet die Kinder in konzentrischen Ringen an:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;: &lt;span class="kt"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Placed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;: &lt;span class="kt"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;radiusStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Placed&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;: &lt;span class="kt"&gt;Placed&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;node&lt;/span&gt;: &lt;span class="kt"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;toAngle&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fromAngle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;toAngle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;x&lt;/span&gt;: &lt;span class="kt"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;radiusStep&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;y&lt;/span&gt;: &lt;span class="kt"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;radiusStep&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toAngle&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Jede Ebene erbt ein Winkelsegment von ihrem Elternteil und unterteilt es unter ihren Kindern. Verschieben (Pan) und Zoomen waren reine &lt;code&gt;viewBox&lt;/code&gt;-Arithmetik – keine Transformationsmatrizen, keine Event-Bibliothek, nur Zahlen. Kanten waren quadratische Bézier-Kurven, die in Richtung Zentrum gezogen wurden. Es sah gut aus, war schnell, und der gesamte Renderer passte bequem in eine einzige Komponente.&lt;/p&gt;
&lt;h3 id="der-export-trick-den-man-immer-noch-kennen-sollte"&gt;Der Export-Trick, den man immer noch kennen sollte&lt;/h3&gt;
&lt;p&gt;Um ein SVG ohne Abhängigkeiten als PNG zu exportieren, lässt man den Browser die ganze Arbeit machen:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SVG DOM ─► XMLSerializer ─► string ─► &amp;lt;img&amp;gt; ─► &amp;lt;canvas&amp;gt; ─► PNG blob
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Serialisiere das SVG zu einem String, lade es in ein &lt;code&gt;Image&lt;/code&gt;, zeichne dieses Bild auf ein skaliertes &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; und frag das Canvas nach einem PNG. Schriftarten, Anti-Aliasing, &lt;code&gt;currentColor&lt;/code&gt; – der Browser löst das alles nativ auf. Wenn deine Grafik &lt;em&gt;ein&lt;/em&gt; SVG-Element ist, ist das immer noch die sauberste Export-Pipeline, die es gibt, und ich würde sie ohne Zögern wieder verwenden.&lt;/p&gt;
&lt;p&gt;Ein Detail von v1 hat bis heute komplett unverändert überlebt: der Speichern-Flow. Anstatt des klassischen &amp;ldquo;Direkt in den Downloads-Ordner&amp;rdquo;-Erlebnisses laufen Exporte über die &lt;strong&gt;File System Access API&lt;/strong&gt; (&lt;code&gt;showSaveFilePicker&lt;/code&gt;), wo der Browser das unterstützt, mit einem Anker-Tag-Download-Fallback für Firefox und Safari. Ein echter &amp;ldquo;Speichern unter&amp;hellip;&amp;quot;-Dialog, ganz ohne Electron. Dieser Helfer (&lt;code&gt;lib/saveBlob.ts&lt;/code&gt;) bedient jetzt auch die Quiz- und Workshop-Exporte.&lt;/p&gt;
&lt;h2 id="die-anforderungen-die-v1-gebrochen-haben"&gt;Die Anforderungen, die v1 gebrochen haben&lt;/h2&gt;
&lt;p&gt;Dann traf das Feature auf seine Nutzer (naja – auf mich, als ich es beim Lernen ernsthaft benutzte), und es tauchten drei Anforderungen auf, mit denen die elegante, handgemachte Version nicht gut zurechtkam:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Lass mich diesen Knoten bewegen.&amp;rdquo;&lt;/strong&gt; Ein generiertes Layout ist ein Startpunkt; eine &lt;em&gt;nützliche&lt;/em&gt; Mindmap ist eine, die du so anordnest, dass sie deiner Denkweise entspricht. Die Knoten von v1 waren an ihren berechneten Positionen fixiert. Drag-and-Drop hinzuzufügen hätte bedeutet, Hit-Testing, Drag-State und Positions-Persistenz von Grund auf neu zu bauen – genau die unglamouröse Interaktions-Maschinerie, für die Graphen-Bibliotheken überhaupt existieren.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Text wollte HTML sein.&lt;/strong&gt; SVG &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; bricht nicht um. Lange Konzept-Labels erforderten manuelles Umbrechen, Ausmessen und Abschneiden von Zeilen – ein ständiger Kampf. HTML-Knoten (echte &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s mit CSS) übernehmen Zeilenumbrüche, Ellipsen und Theming umsonst.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Radial war doch nicht das beste Lese-Layout.&lt;/strong&gt; Für die breiten und flachen Bäume, die Gemma tatsächlich generiert, liest sich ein Links-nach-Rechts- oder Von-Oben-nach-Unten-Baum (die Art, die eine Layout-Engine wie &lt;strong&gt;dagre&lt;/strong&gt; berechnet) besser als Ringe. Und als Layouts umschaltbar wurden, wurde &amp;ldquo;Auto-Layout plus gemerkte manuelle Anpassungen&amp;rdquo; das natürliche Modell.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Ich hätte all das auf der SVG-Basis bauen können. Aber schau dir die Liste an: Viewport-Management, Drag-and-Drop für Knoten, HTML-Knoten in einem Graphen-Canvas, austauschbare Layouts. Das ist &lt;em&gt;exakt&lt;/em&gt; das Feature-Set von React Flow. In v1 wäre die Bibliothek nur ein Wrapper um Dinge gewesen, die ich nicht brauchte. Für v2 waren meine Anforderungen genau zu den Dingen herangewachsen, die sie gut kann.&lt;/p&gt;
&lt;p&gt;Also habe ich meine Meinung geändert.&lt;/p&gt;
&lt;h2 id="runde-zwei-react-flow--dagre"&gt;Runde zwei: React Flow + dagre&lt;/h2&gt;
&lt;p&gt;Der heutige Renderer (&lt;code&gt;frontend/src/components/study/mindmaps/&lt;/code&gt;):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;@xyflow/react&lt;/code&gt; (React Flow)&lt;/strong&gt; liefert das Canvas: natives Panning/Zoomen, ziehbare Knoten, einen Steuerungsblock im Minimap-Stil und Dark-Mode-Unterstützung via &lt;code&gt;colorMode&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dagre&lt;/strong&gt; berechnet das automatische Layout, mit einem sichtbaren Schalter zwischen Links-nach-Rechts und Von-Oben-nach-Unten. Die Baum-zu-Graph-Konvertierung ist eine winzige Pure Function.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Benutzerdefinierte HTML-Knoten&lt;/strong&gt; tragen das Design-System: Die Wurzel bekommt einen Verlauf, Themen eine Tönung, Blätter bleiben dezent – und Text bricht um, wie Text umbrechen sollte.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gezogene Positionen bleiben erhalten.&lt;/strong&gt; Das Bewegen eines Knotens löst einen Speicher-Vorgang aus; öffnest du die Map neu, wird deine Anordnung wiederhergestellt. Ein &amp;ldquo;Reset layout&amp;rdquo;-Button löscht die gespeicherten Positionen und kehrt zum dagre-Auto-Layout zurück. Die Layout-Wahl und die Positionen leben zusammen mit der Mindmap in SQLite.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Der Export wurde an die neue Realität angepasst.&lt;/strong&gt; Die Knoten sind jetzt HTML, also funktioniert der SVG-Serialisierungs-Trick aus v1 nicht mehr. Der PNG-Export nutzt &lt;code&gt;html-to-image&lt;/code&gt; über den React-Flow-Viewport, passgenau auf die Knotengrenzen zugeschnitten, unabhängig vom aktuellen Zoom. Das PDF bettet dieses PNG über ein lazy-geladenes &lt;code&gt;jsPDF&lt;/code&gt; ein. Der Markdown-Export ist ein rekursiver Durchlauf des Baums ohne externe Abhängigkeiten. Ja – v2 nutzt genau die Bibliothek (&lt;code&gt;html-to-image&lt;/code&gt;), auf deren Vermeidung ich in v1 noch stolz war. Die Anforderungen haben sich geändert; und damit auch der Trade-off.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="was-mich-die-reise-wirklich-gelehrt-hat"&gt;Was mich die Reise wirklich gelehrt hat&lt;/h2&gt;
&lt;p&gt;Ich habe hin- und herüberlegt, wie ich diesen Beitrag schreiben soll, denn die Geschichte von v1 (&amp;ldquo;Schau mal, wie wenig Code man braucht!&amp;rdquo;) ist schmeichelhafter. Aber die Wahrheit aus beiden Versionen ist die viel nützlichere Lektion:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Zuerst selber bauen war trotzdem richtig.&lt;/strong&gt; v1 war an einem Wochenende fertig, hat mir die wahre Form des Problems gezeigt (Layout, Viewport und Export sind getrennte Baustellen) und kostete mich nichts, es wegzuwerfen, weil es so klein war. Hätte ich mit React Flow angefangen, hätte ich eine Bibliothek konfiguriert, bevor ich das Problem überhaupt verstanden hatte.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Bibliotheken verdienen sich ihren Platz erst, wenn deine Anforderungen auf ihr Feature-Set zulaufen – nicht vorher.&lt;/strong&gt; In dem Moment, als &amp;ldquo;Zieh Knoten und merk dir, wo ich sie hingelegt habe&amp;rdquo; zur Anforderung wurde, drehte sich die Build-vs-Buy-Rechnung komplett um.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Einige Teile überleben das Rewrite.&lt;/strong&gt; Der Speicher-Dialog-Helfer, der Markdown-Durchlauf, der Instinkt, Exporte auf die Inhaltsgrenzen zuzuschneiden – all das wurde übernommen. Rewrites sind selten total.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Die nativen Pipelines des Browsers sollte man kennen, auch wenn man sie am Ende nicht nutzt.&lt;/strong&gt; SVG → Canvas → PNG ist immer noch der beste Export-Trick ohne externe Abhängigkeiten in der Frontend-Entwicklung. Er funktioniert nur nicht mehr an dem Tag, an dem deine Knoten HTML werden.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;&amp;ldquo;Build or buy&amp;rdquo; (Selber bauen oder kaufen) ist eine Funktion von Anforderungen – und Anforderungen bewegen sich. Bau selbst, solange das Problem klein ist und du noch lernst, wie es aussieht. Kauf ein, wenn deine Feature-Liste anfängt, sich wie die README der Bibliothek zu lesen. Und wenn du wechselst, schreib auf, warum, damit die nächste Person (oder dein zukünftiges Ich) weiß, dass es keine Unentschlossenheit war. Es war einfach der Plan, der erwachsen geworden ist.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-beitrag"&gt;Anhang: Abkürzungen in diesem Beitrag&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Vollform&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SVG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scalable Vector Graphics&lt;/td&gt;
&lt;td&gt;Das eingebaute Vektor-Zeichenformat des Browsers – das komplette Fundament von v1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PNG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Network Graphics&lt;/td&gt;
&lt;td&gt;Das Rasterbildformat, das bei Exporten herauskommt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format&lt;/td&gt;
&lt;td&gt;Der druckfertige Export, gebaut durch Einbetten des PNGs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DOM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Document Object Model&lt;/td&gt;
&lt;td&gt;Die Live-Repräsentation der Seite im Browser – das, was &lt;code&gt;html-to-image&lt;/code&gt; in v2 rastert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTML / CSS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Markup Language / Cascading Style Sheets&lt;/td&gt;
&lt;td&gt;Woraus die Knoten in v2 bestehen – und warum ihr Text automatisch umbricht&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Wie in File System Access API, die echte &amp;ldquo;Speichern unter&amp;hellip;&amp;quot;-Dialoge bereitstellt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI / UX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Interface / User Experience&lt;/td&gt;
&lt;td&gt;Die Anforderung, einen Knoten ziehen zu können, die das Rewrite ausgelöst hat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Die Baumstruktur, die Gemma für jede Mindmap generiert (siehe vorheriger Beitrag)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;Die Single-File-Datenbank, in der Layout-Entscheidungen und Knotenpositionen persistieren&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
.&lt;/p&gt;</description></item></channel></rss>