<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Amery</title>
    <description>The latest articles on DEV Community by Amery (@amery2010).</description>
    <link>https://dev.to/amery2010</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3992120%2Fd5a9c40d-b759-4dd3-a80a-1f526dad2e10.jpg</url>
      <title>DEV Community: Amery</title>
      <link>https://dev.to/amery2010</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amery2010"/>
    <language>en</language>
    <item>
      <title>How I built a mind map that's just a Markdown list (and why that makes AI streaming almost free)</title>
      <dc:creator>Amery</dc:creator>
      <pubDate>Fri, 19 Jun 2026 08:12:38 +0000</pubDate>
      <link>https://dev.to/amery2010/how-i-built-a-mind-map-thats-just-a-markdown-list-and-why-that-makes-ai-streaming-almost-free-5g30</link>
      <guid>https://dev.to/amery2010/how-i-built-a-mind-map-thats-just-a-markdown-list-and-why-that-makes-ai-streaming-almost-free-5g30</guid>
      <description>&lt;p&gt;Most mind map tools store their data in a proprietary binary blob or lock you into a WYSIWYG editor. When I started &lt;strong&gt;Open MindMap&lt;/strong&gt;, a React component, I made one decision up front that ended up shaping everything else:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The source of truth is a plain, indented Markdown-like list. Nothing else.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one constraint turned out to cascade into a bunch of properties I wanted but hadn't fully planned for — diff-friendly maps, trivial programmatic generation, and the thing people ask about most: real-time AI streaming. This post walks through how the pieces fit together, from text to tree to SVG.&lt;/p&gt;

&lt;p&gt;If you'd rather just play with it: the demo is at &lt;a href="https://mindmap.u14.app/" rel="noopener noreferrer"&gt;mindmap.u14.app&lt;/a&gt; and the code is on &lt;a href="https://github.com/u14app/mindmap" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; (&lt;code&gt;@xiangfa/mindmap&lt;/code&gt;, Apache-2.0).&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole thing in one sentence
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;text  →  tree  →  layout  →  SVG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stage is pure and one-directional, and every stage round-trips back to text. That symmetry is the trick that makes the rest easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The data model
&lt;/h2&gt;

&lt;p&gt;A mind map is a forest of trees, so the node type is about as boring as you'd expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;MindMapData&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;MindMapData&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;remark&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// multi-line note attached to a node&lt;/span&gt;
  &lt;span class="nl"&gt;taskStatus&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;todo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;doing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// plugin-populated fields:&lt;/span&gt;
  &lt;span class="nl"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;anchorId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// cross-link source&lt;/span&gt;
  &lt;span class="nl"&gt;crossLinks&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;CrossLink&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;        &lt;span class="c1"&gt;// cross-link targets&lt;/span&gt;
  &lt;span class="nl"&gt;collapsed&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;dottedLine&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;multiLineContent&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail is what &lt;em&gt;isn't&lt;/em&gt; here: no coordinates, no widths, no colors. Position and style are derived later from the tree, never stored. That keeps the data model serializable and keeps the text the single source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Parsing: indentation is the hierarchy
&lt;/h2&gt;

&lt;p&gt;The parser's only real job is converting leading whitespace into parent/child relationships. The heart of it is a stack keyed by indentation depth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;MindMapData&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;roots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MindMapData&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 class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MindMapData&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;indent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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 class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;indent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trimStart&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;-*+&lt;/span&gt;&lt;span class="se"&gt;]\s&lt;/span&gt;&lt;span class="sr"&gt;*/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// strip bullet marker&lt;/span&gt;
    &lt;span class="kd"&gt;const&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;MindMapData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="c1"&gt;// climb back up to the correct parent&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;indent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;indent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;roots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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;indent&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;roots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire core idea. A line more indented than the previous one is its child; a line at the same or lower indentation pops the stack until it finds its real parent. Multiple top-level lines (or trees separated by a blank line) simply become multiple roots — which is how you get several independent maps on one canvas.&lt;/p&gt;

&lt;p&gt;It's O(n) over lines, single pass, no backtracking. Hold that property in mind — it's what makes streaming cheap in section 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The two layers of syntax
&lt;/h2&gt;

&lt;p&gt;Plain indentation gets you a tree, but a tree of bare strings is boring. The richer syntax lives in two layers that run &lt;em&gt;after&lt;/em&gt; the structural parse:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Line-level markers&lt;/strong&gt; decide what kind of node a line is, before the text is stored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; React #framework #frontend      → tags: ["framework", "frontend"]
&lt;span class="p"&gt;-&lt;/span&gt; [x] Ship the parser             → taskStatus: "done"
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Write the layout engine     → taskStatus: "todo"
&lt;span class="gt"&gt;  &amp;gt; remember to handle RTL        → remark on the parent node&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Launch {#launch}                → anchorId: "launch"
  -&amp;gt; {#launch} "depends on"       → crossLink with a label
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Inline formatting&lt;/strong&gt; is parsed lazily, only when a node renders, into a small token stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// "**bold** and `code`" → [{type:"bold",...}, {type:"text"," and "}, {type:"code",...}]&lt;/span&gt;
&lt;span class="nf"&gt;parseInlineMarkdown&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;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping inline parsing lazy matters: you don't pay to tokenize &lt;code&gt;**bold**&lt;/code&gt; for a node that's currently scrolled off-screen or collapsed.&lt;/p&gt;

&lt;p&gt;Each of these extras is a &lt;strong&gt;plugin&lt;/strong&gt;. There are seven built in (tags, folding, cross-links, LaTeX, dotted lines, multi-line, frontmatter), they're all on by default, and each one is tree-shakeable — a plugin you don't import isn't in your bundle. A plugin is essentially a pair of hooks: one that claims a line during parsing and writes a field onto the node, and one that contributes to rendering. The core parser stays oblivious to all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Why the round-trip matters
&lt;/h2&gt;

&lt;p&gt;Because position and style are derived, every transform is reversible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;parseMarkdownList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// text  → MindMapData&lt;/span&gt;
&lt;span class="nf"&gt;toMarkdownList&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="c1"&gt;// MindMapData → text&lt;/span&gt;
&lt;span class="nf"&gt;parseMarkdownMultiRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// text  → MindMapData[]&lt;/span&gt;
&lt;span class="nf"&gt;toMarkdownMultiRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;forest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// MindMapData[] → text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what makes the maps &lt;em&gt;diffable&lt;/em&gt; and &lt;em&gt;version-controllable&lt;/em&gt; — a one-node change is a one-line diff in git, not an opaque binary delta. It's also why the built-in text editor can flip between a visual map and raw Markdown with no lossy conversion in either direction: they're two views of the same string.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Streaming: the part that was almost free
&lt;/h2&gt;

&lt;p&gt;Here's the payoff. An LLM emits a mind map as a Markdown list, token by token. At any instant, the buffer you've received so far is &lt;em&gt;already valid Markdown&lt;/em&gt; — a partial list is just a smaller list. So the streaming consumer is almost insultingly simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;llmStream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;mindMapRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;setMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// re-parse the partial text every tick&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tick re-runs the O(n) parser over the whole buffer and hands React a fresh tree. Two things keep this smooth instead of janky:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The parser is cheap and total.&lt;/strong&gt; It never throws on incomplete input — a half-written line is just a node with short text that'll get longer next tick. There's no "wait for a complete element" state machine to stall on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The diff between ticks is tiny.&lt;/strong&gt; Re-parsing produces a tree that's almost identical to the last one, so React's reconciliation and the SVG re-layout only touch what changed. The map visibly &lt;em&gt;grows&lt;/em&gt; rather than flashing and redrawing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The optional built-in AI input bar wraps this and talks to any OpenAI-compatible endpoint, but the streaming behavior isn't special-cased for it — anything that can call &lt;code&gt;setMarkdown&lt;/code&gt; with a growing string gets the same effect. The component never had to know an LLM existed. That's the whole point of making text the interface.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One honest caveat: the AI input bar sends the API key straight from the browser. That's fine for local prototyping, but in production you should put a proxy in front of it so the key stays server-side.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. Layout: from tree to coordinates
&lt;/h2&gt;

&lt;p&gt;This is where the real work hides, because SVG gives you zero automatic layout — you place every pixel yourself.&lt;/p&gt;

&lt;p&gt;The layout pass is a recursive walk that assigns each node an &lt;code&gt;(x, y)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;x&lt;/strong&gt; comes from depth. Each level steps a fixed distance away from the root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;y&lt;/strong&gt; comes from subtree size. A node is centered against the vertical span of its children, so a parent sits opposite the middle of its branch.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;measureSubtree&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;MindMapData&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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="o"&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;collapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NODE_HEIGHT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&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="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;measureSubtree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the default &lt;strong&gt;balanced ("both") layout&lt;/strong&gt;, the root's top-level branches are split between the left and right sides to keep the two halves roughly even in height, then each side lays out independently — left side mirrored. &lt;code&gt;left&lt;/code&gt; and &lt;code&gt;right&lt;/code&gt; modes just send every branch one way.&lt;/p&gt;

&lt;p&gt;Each top-level branch also gets a stable &lt;strong&gt;branch index&lt;/strong&gt;, which is how coloring works: the index maps to a CSS variable (&lt;code&gt;--mindmap-branch-0&lt;/code&gt; … &lt;code&gt;-9&lt;/code&gt;) and lands on the SVG element as &lt;code&gt;data-branch-index&lt;/code&gt;, so you can theme an entire branch with one selector.&lt;/p&gt;

&lt;p&gt;Text width is the gnarly bit. SVG won't tell you how wide a string renders until it's in the DOM, so node sizing has to either measure rendered text or approximate it — and getting that wrong means edges that don't quite meet their boxes. It's the least glamorous and most fiddly part of the whole component.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Rendering: why pure SVG
&lt;/h2&gt;

&lt;p&gt;Everything draws as SVG — no canvas, no external graph/layout library. That's a deliberate tradeoff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Crisp at any zoom.&lt;/strong&gt; Vectors don't pixelate, so deep zoom stays sharp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Themeable with plain CSS.&lt;/strong&gt; Nodes, edges, and tags are real elements with semantic classes, so you style them with ~30 CSS variables instead of re-rendering a bitmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exportable for free&lt;/strong&gt; (next section).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Edges are SVG paths — a curve from a parent's anchor point to each child's. Selection, the add-child button, fold toggles, and tags are all just more SVG nodes layered on. The cost you pay for this is that very large trees mean a lot of DOM nodes, so there's a ceiling where canvas would win on raw throughput. For the document-sized maps this is built for, vectors are the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Export falls out of the rendering model
&lt;/h2&gt;

&lt;p&gt;Because the live view &lt;em&gt;is&lt;/em&gt; SVG, exporting it is mostly serialization:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SVG export&lt;/strong&gt; walks the rendered tree and inlines a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block with the &lt;em&gt;resolved&lt;/em&gt; CSS values, plus the same semantic classes and &lt;code&gt;data-branch-index&lt;/code&gt; attributes. The result renders correctly as a standalone file with no external stylesheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG export&lt;/strong&gt; draws that SVG onto a canvas at high DPI and reads back a blob.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markdown export&lt;/strong&gt; is just &lt;code&gt;toMarkdownList&lt;/code&gt; again — the round-trip from section 4.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;svg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportToSVG&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// string&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportToPNG&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Promise&amp;lt;Blob&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;md&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportToOutline&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// markdown list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No separate "export renderer" to keep in sync with the on-screen one, because there's only ever one renderer.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What's still hard
&lt;/h2&gt;

&lt;p&gt;In the spirit of being honest rather than selling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Text measurement&lt;/strong&gt; is the perennial source of layout bugs, as mentioned. Web fonts loading late can shift sizing after first paint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large trees&lt;/strong&gt; push the DOM-node-count ceiling. Folding helps; virtualization of an SVG tree is a project of its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-links&lt;/strong&gt; break the clean tree model on purpose — they're a second edge layer drawn between arbitrary anchors, and routing them through a dense map without crossings is genuinely unsolved here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser-side API keys&lt;/strong&gt; for the AI bar need a proxy in real deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are unique to this project; they're the standard taxes you pay for "render a graph in the browser, from text, live."&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If there's one transferable idea here, it's this: &lt;strong&gt;pick a plain-text representation as your source of truth, and a surprising number of features stop being features and start being consequences.&lt;/strong&gt; Diffability, version control, programmatic generation, lossless editing, and incremental/streaming rendering all fell out of "it's just a Markdown list" — I didn't build them one by one.&lt;/p&gt;

&lt;p&gt;The code is open source (&lt;a href="https://github.com/u14app/mindmap" rel="noopener noreferrer"&gt;github.com/u14app/mindmap&lt;/a&gt;, Apache-2.0) and on npm as &lt;code&gt;@xiangfa/mindmap&lt;/code&gt;. I'd genuinely like feedback on the syntax design and the parsing/streaming approach — and if you spot a smarter way to handle the text-measurement problem, please tell me.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
