Reinventing the wheel to draw wide tree hierarchies

Lobsters Hottest News

Summary

The article explores techniques for generating random tree hierarchies and positioning nodes to fit on limited screen space, using JavaScript and D3.js for visualization.

<p><a href="https://lobste.rs/s/9ry4yi/reinventing_wheel_draw_wide_tree">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 09/26/26, 07:22 AM

# Reinventing the wheel to draw wide tree hierarchies Source: [https://lonami.dev/blog/tree-node-positioning/](https://lonami.dev/blog/tree-node-positioning/) 2026\-09\-25 Alternative title: Fitting large trees in small screens\. I recently[↪1](https://lonami.dev/blog/tree-node-positioning/#fn:1)spent a week trying to figure out how to display a very large inheritance tree on a limited amount of screen space\. As great as libraries like[D3](https://d3js.org/)are to create[hierarchies](https://d3js.org/d3-hierarchy/hierarchy),[trees](https://d3js.org/d3-hierarchy/tree)or[clusters](https://d3js.org/d3-hierarchy/cluster), they tend to like to put all nodes of a certain depth or all leaf nodes together\. This just won't cut it\! Placing all nodes of a certain depth together will lead to very wide diagrams when there is a lot of siblings\. Now, for*this*particular case, you could render the inheritance tree growing from left\-to\-right, as opposed to the usual top\-to\-bottom\. But that's no fun\. Trees were never meant to grow sideways\. ## [Generating random words](https://lonami.dev/blog/tree-node-positioning/#generating-random-words) *Feel free to[skip this detour](https://lonami.dev/blog/tree-node-positioning/#generating-random-trees)\.* Everybody knows words grow on trees\. So to generate our tree, we'll first need to generate words\. I won't go*super*overboard with this, but I also don't want completely random letters, as that doesn't make words that are easy to read\. Still, we'll want some helpers to make our life a bit easier\. We'll be grabbing the[Letter Frequencies in the English Language](https://www3.nd.edu/~busiforc/handouts/cryptography/Letter%20Frequencies.html)[↪2](https://lonami.dev/blog/tree-node-positioning/#fn:2)\. We won't worry about bigrams, trigrams, quadrigrams, or[Markov chains](https://en.wikipedia.org/wiki/Markov_chain), as this is not the main focus of the blog post\. But the letter frequencies will be nice to make a biased random function\. ``` // https://www3.nd.edu/~busiforc/handouts/cryptography/Letter%20Frequencies.html const LETTERS_FREQ = [0.08167, 0.01492, 0.02782, 0.04253, 0.12702, 0.02228, 0.02015, 0.06094, 0.06966, 0.00153, 0.00772, 0.04025, 0.02406, 0.06749, 0.07507, 0.01929, 0.00095, 0.05987, 0.06327, 0.09056, 0.02758, 0.00978, 0.02360, 0.00150, 0.01974, 0.00074] ``` With the list of frequencies in hand, we can accumulate them and normalize them to 1 \(to account for floating point errors\): ``` const cumulativeFrequencies = (letters) => { const a = 'a'.charCodeAt(0) const freq = [...letters].map((c) => LETTERS_FREQ[c.charCodeAt(0) - a]) for (let i = 1; i < freq.length; i += 1) { freq[i] += freq[i - 1] } return freq.map((value) => value / freq[freq.length - 1]) } const VOWELS = 'aeiou' const CONSONANTS = 'bcdfghjklmnpqrstvwxyz' const VOWELS_CUM_FREQ = cumulativeFrequencies(VOWELS) const CONSONANTS_CUM_FREQ = cumulativeFrequencies(CONSONANTS) ``` Additional utility functions``` const randRange = (low, high) => low + Math.floor(Math.random() * (high - low)) const binarySearchIndex = (arr, value) => { let low = 0 let high = arr.length - 1 while (low <= high) { let mid = Math.floor((low + high) * 0.5) if (arr[mid] === value) { return mid } else if (arr[mid] < value) { low = mid + 1 } else { high = mid - 1 } } return low } ``` Separating vowels from consonants will let us alternate them to make sure we don't end with consonant pairs we can't pronounce: ``` const randWord = (length) => Array(length).fill(undefined).map( (_, i) => i % 2 === 0 ? CONSONANTS[binarySearchIndex(CONSONANTS_CUM_FREQ, Math.random())] : VOWELS[binarySearchIndex(VOWELS_CUM_FREQ, Math.random())] ).join('') ``` Even Shakespeare would be amazed at the words we're able to create: Now, moving on onto something more useful… ## [Generating random trees](https://lonami.dev/blog/tree-node-positioning/#generating-random-trees) A wooden tree has a single trunk\. This will be true for our virtual tree too, by having a single root node\. We'll define a simple text format with the parent node on the left, separated by "\-\>", and then a word for each children: To toggle between text and the rendered tree, click on the "View" button on the left menu\. Of course, we haven't implemented anything yet, so the visualization is looking pretty barebones\. So barebones in fact, that the only thing you can see is the placeholder I put there\. The default text was chosen to highlight the problems we will encounter during our adventure, but feel free to change it as much as you'd like\. These controls will follow us as we iterate on the algorithm\. Let's get right into it\! ## [Rendering a single node](https://lonami.dev/blog/tree-node-positioning/#rendering-a-single-node) While this might not seem very interesting at first, it's actually quite important to get this right, or the positioning won't work at all\. Our nodes will be little boxes with the text centered inside, despite this being one of the[Hardest Problems in Computer Science](https://tonsky.me/blog/centering/)\. A node object will consist of its identifier \(under the`\.id`property\) and a position \(under the`\.pos`property\.\) ``` const renderNode = (displayText) => { const text = document.createElementNS(measureSvg.namespaceURI, 'text') text.textContent = displayText text.setAttribute('dominant-baseline', 'central') const bbox = measureBbox(text) // .getBBox() text.setAttribute('x', PADDING_X + STROKE_WIDTH / 2) text.setAttribute('y', PADDING_Y + STROKE_WIDTH / 2 + bbox.height / 2) const rect = document.createElementNS(measureSvg.namespaceURI, 'rect') rect.setAttribute('x', STROKE_WIDTH / 2) rect.setAttribute('y', STROKE_WIDTH / 2) rect.setAttribute('width', bbox.width + PADDING_X * 2) rect.setAttribute('height', bbox.height + PADDING_Y * 2) rect.setAttribute('fill', 'none') rect.setAttribute('stroke', '#000') rect.setAttribute('stroke-width', STROKE_WIDTH) const g = document.createElementNS(measureSvg.namespaceURI, 'g') g.replaceChildren(text, rect) return g } ``` The`renderNode`function will return a single[`<g\>`](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g)element that we can easily[`transform`](https://developer.mozilla.org/en-US/docs/Web/CSS/transform)to position it\. The \(not shown\) helper`measureBbox`function contains some boilerplate to[`getBBox`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBBox)of the rendered text, which we can use to create an appropriately\-sized rectangle around it: Now that we can render a single node, we can take it up a notch and… ## [Rendering a pair of nodes](https://lonami.dev/blog/tree-node-positioning/#rendering-a-pair-of-nodes) The nodes will need to be connected by lines, or we won't be able to tell that they are related at all\. To achieve this, we can use a[path](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths)with 4 points to create a "step" connection style[↪3](https://lonami.dev/blog/tree-node-positioning/#fn:3)\. We will start from the botton edge of the parent at its center, go downwards a bit, move horizontally to the center of the child, and finally move the remaining distance downwards\. We will command the line to`M`ove to its starting position, and then`V`ertically or`H`orizontally as needed\. To avoid having to recompute a node's bounding box, we will cache it in its`\.bbox`property\. ``` const SPACING_X = 16 const SPACING_Y = 16 const renderLink = (parent, child) => { const [x0, y0] = [ parent.bbox.x + parent.bbox.width / 2, parent.bbox.y + parent.bbox.height, ] const y1 = y0 + SPACING_Y / 2 const x1 = child.bbox.x + child.bbox.width / 2 const y2 = child.bbox.y const path = document.createElementNS(measureSvg.namespaceURI, 'path') path.setAttribute('d', `M${x0} ${y0} V${y1} H${x1} V${y2}`) path.setAttribute('fill', 'none') path.setAttribute('stroke', '#000') path.setAttribute('stroke-width', STROKE_WIDTH) return path } ``` This will be good enough for now\. You might need to render a few times to see it switch from turning left to right\. ## [A crude tree visualization](https://lonami.dev/blog/tree-node-positioning/#a-crude-tree-visualization) Our tree will be laid down by arranging all the nodes into columns and rows\. Within a column, all children will be centered\. Within a row, all children will be laid after each other\. We can call these our "layout elements": ``` const LEAF = Symbol('leaf') const COLUMN = Symbol('column') const ROW = Symbol('row') const defaultBbox = () => ({ x: 0, y: 0, width: 0, height: 0, right: 0, bottom: 0 }) const makeLeaf = (node) => ({ type: LEAF, id: node.id, childrenIds: node.children.map((child) => child.id), bbox: defaultBbox(), size: { width: 1, height: 1 }, }) const makeColumn = (...children) => ({ type: COLUMN, children, bbox: defaultBbox(), size: { width: Math.max(...children.map((child) => child.size.width)), height: children.reduce((acc, child) => acc + child.size.height, 0), }, }) const makeRow = (...children) => ({ type: ROW, children, bbox: defaultBbox(), size: { width: children.reduce((acc, child) => acc + child.size.width, 0), height: Math.max(...children.map((child) => child.size.height)), }, }) ``` Leaf nodes will have a size of 1x1, as they are a single node themselves\. Columns will be as wide as the widest child is, whereas rows will be as tall as the tallest child is\. Lastly, columns are as tall as the sum of all their children is, whereas rows are as wide as the sum of all their children is\. We cache these sizes inside the objects themselves\. This will help us make informed decisions later on\. We persist the link between parent and its children in the leaf nodes, since we'll need it to draw the path linking them\. This will let us go from the identifiers to the corresponding leaf node after it's been created\. Using references via identifiers saves us from having to maintain the link while creating the layout\. To actually construct the layout, we can recursively place all parent\-children pairs inside a column, with all the children being placed in a row: ``` const createLayout = (rootNode) => rootNode.children.length ? makeColumn( makeLeaf(rootNode), makeRow(...rootNode.children.map(createLayout)), ) : makeLeaf(rootNode) ``` And finally, let's adjust the bounding boxes to be correct with what we've described: Additional helpers``` const updateBbox = (layoutElement, newValues) => { Object.assign(layoutElement.bbox, newValues) layoutElement.bbox.right = layoutElement.bbox.x + layoutElement.bbox.width layoutElement.bbox.bottom = layoutElement.bbox.y + layoutElement.bbox.height } function* pairwise(arr) { for (let i = 0; i < arr.length; i += 1) { yield [arr[i - 1], arr[i]] } } const lastChild = (layoutElement) => layoutElement.children[layoutElement.children.length - 1] ``` ``` const initLayoutBbox = (layoutElement) => { switch (layoutElement.type) { case LEAF: initLeafBbox(layoutElement); break case COLUMN: initColumnBbox(layoutElement); break case ROW: initRowBbox(layoutElement); break } } const initLeafBbox = (leaf) => { const { width, height } = measureBbox(renderNode(leaf.id)) updateBbox(leaf, { width, height }) } const initColumnBbox = (column) => { column.children.forEach(initLayoutBbox) const maxWidth = Math.max(...column.children.map((child) => child.bbox.width)) for (const [prev, child] of pairwise(column.children)) { updateBbox(child, { x: (maxWidth - child.bbox.width) / 2, // center y: prev ? prev.bbox.bottom + SPACING_Y : 0, // below previous }) } updateBbox(column, { width: maxWidth, height: lastChild(column).bbox.bottom, }) } const initRowBbox = (row) => { row.children.forEach(initLayoutBbox) const maxHeight = Math.max(...row.children.map((child) => child.bbox.height)) for (const [prev, child] of pairwise(row.children)) { updateBbox(child, { x: prev ? prev.bbox.right + SPACING_X : 0, // after previous }) } updateBbox(row, { width: lastChild(row).bbox.right, height: maxHeight, }) } ``` Voila\! ## [Reducing horizontal growth](https://lonami.dev/blog/tree-node-positioning/#reducing-horizontal-growth) The problem with rows is that they place all nodes to the side, at the same vertical position\. While these are suitable in certain cases, there are times when we can do better\. We can introduce a row type that staggers its children, one below the other\. This will almost make it behave like a column, in that its width remains roughly constant\. Let's call this new group a stair: ``` const makeStair = (...children) => ({ ...makeColumn(...children), type: STAIR, }) ``` Our layout can now make use of this new type: ``` const createLayout = (rootNode) => rootNode.children.length ? makeColumn( makeLeaf(rootNode), makeStair(...rootNode.children.map(createLayout)), // ^----- for now we'll replace rows with stairs completely ) : makeLeaf(rootNode) ``` When positioning it, we'll need to stagger the nodes: ``` const initLayoutBbox = (layoutElement) => { switch (layoutElement.type) { case LEAF: initLeafBbox(layoutElement); break case COLUMN: initColumnBbox(layoutElement); break case ROW: initRowBbox(layoutElement); break case STAIR: initStairBbox(layoutElement); break } } const initStairBbox = (stair) => { stair.children.forEach(initLayoutBbox) for (const [prev, child] of pairwise(stair.children)) { updateBbox(child, { x: prev ? prev.bbox.right + SPACING_X - child.bbox.width : 0, y: prev ? prev.bbox.bottom + SPACING_Y / 2 : 0, }) } updateBbox(stair, { width: Math.max(...stair.children.map((child) => child.bbox.right)), height: lastChild(stair).bbox.bottom, }) } ``` Oh no\. That's not looking good\. Okay, let's stagger*only*if all the children are leaves: ``` const createLayout = (rootNode) => { const leaf = makeLeaf(rootNode) if (rootNode.children.length === 0) { return leaf } const children = rootNode.children.map(createLayout) if (children.every((child) => child.type === LEAF)) { return makeColumn(leaf, makeStair(...children)) } else { return makeColumn(leaf, makeRow(...children)) } } ``` That's an improvement\. There's a lot less lines crossing eachother, but… it still happens\. ## [Improving link connections](https://lonami.dev/blog/tree-node-positioning/#improving-link-connections) When rendering a stair, we can't connect to the center, because it might be occluded\. We can solve this in either of two ways: - When creating the stair, mark all children after the first as "occluded"\. This will need more housekeeping if we shuffle things around, and only works for stairs\. - When rendering a parent\-to\-child link, check if any of its children would get in the way\. This is a bit more expensive, but will work no matter what\. Let's go with the second option\. Our trees are large, but not large enough where performance should be a problem\. ``` const renderLink = (parent, child, leafMap) => { const [x0, y0] = [ parent.bbox.x + parent.bbox.width / 2, parent.bbox.y + parent.bbox.height, ] const y1 = y0 + SPACING_Y / 2 const centerX1 = child.bbox.x + child.bbox.width / 2 const isOccluded = parent.childrenIds.map((id) => leafMap[id]).some((c) => c !== child && c.bbox.y < child.bbox.y && c.bbox.x - SPACING_X / 2 < centerX1 && centerX1 < c.bbox.right + SPACING_X / 2 ) const x1 = isOccluded ? child.bbox.right - SPACING_X / 2 : centerX1 const y2 = child.bbox.y const path = document.createElementNS(measureSvg.namespaceURI, 'path') path.setAttribute('d', `M${x0} ${y0} V${y1} H${x1} V${y2}`) path.setAttribute('fill', 'none') path.setAttribute('stroke', '#000') path.setAttribute('stroke-width', STROKE_WIDTH) return path } ``` Very nice\! But there's one thing bothering me\. Some links are jagged, even though they could be a straight line\. If the bounding box of the children is within the center of the parent, we can make a straight line\. Let's fix that while we're at it: ``` const renderLink = (parent, child, leafMap) => { const [x0, y0] = [ parent.bbox.x + parent.bbox.width / 2, parent.bbox.y + parent.bbox.height, ] const y1 = y0 + SPACING_Y / 2 const withinParent = child.bbox.x + SPACING_X / 2 < x0 && x0 < child.bbox.right - SPACING_X / 2 const centerX1 = withinParent ? x0 : child.bbox.x + child.bbox.width / 2 const isOccluded = parent.childrenIds.map((id) => leafMap[id]).some((c) => c !== child && c.bbox.y < child.bbox.y && c.bbox.x - SPACING_X / 2 < centerX1 && centerX1 < c.bbox.right + SPACING_X / 2 ) const x1 = isOccluded ? child.bbox.right - SPACING_X / 2 : centerX1 const y2 = child.bbox.y const path = document.createElementNS(measureSvg.namespaceURI, 'path') path.setAttribute('d', `M${x0} ${y0} V${y1} H${x1} V${y2}`) path.setAttribute('fill', 'none') path.setAttribute('stroke', '#000') path.setAttribute('stroke-width', STROKE_WIDTH) return path } ``` Very satisfying, if I do say so myself\! ## [Making better use of the space](https://lonami.dev/blog/tree-node-positioning/#making-better-use-of-the-space) Notice however how the shorter child extends the stair to the right a tiny bit, even though it would fit perfectly under its other sibling\. We can fix this by sorting the shorter children first: ``` const initStairBbox = (stair) => { stair.children.forEach(initLayoutBbox) stair.children.sort((a, b) => a.bbox.width - b.bbox.width) // <- new for (const [prev, child] of pairwise(stair.children)) { updateBbox(child, { x: prev ? prev.bbox.right + SPACING_X - child.bbox.width : 0, y: prev ? prev.bbox.bottom + SPACING_Y / 2 : 0, }) } updateBbox(stair, { width: Math.max(...stair.children.map((child) => child.bbox.right)), height: lastChild(stair).bbox.bottom, }) } ``` Now we've managed to make even better use of the available space, as the shorter child fits comfortably within the larger one\. But we have run into a new problem\. The X position of the stair children is going into the negatives, escaping the bounding box of the stair itself\. To correct this, we'll need to find the lowest X position, and subtract it from all children to push them back to the 0 offset: ``` const initStairBbox = (stair) => { stair.children.forEach(initLayoutBbox) stair.children.sort((a, b) => a.bbox.width - b.bbox.width) for (const [prev, child] of pairwise(stair.children)) { updateBbox(child, { x: prev ? prev.bbox.right + SPACING_X - child.bbox.width : 0, y: prev ? prev.bbox.bottom + SPACING_Y / 2 : 0, }) } const minX = Math.min(...stair.children.map((child) => child.bbox.x)) // <- new stair.children.forEach((child) => updateBbox(child, { x: child.bbox.x - minX })) updateBbox(stair, { width: Math.max(...stair.children.map((child) => child.bbox.right)), height: lastChild(stair).bbox.bottom, }) } ``` ## [Nesting groups inside stairs](https://lonami.dev/blog/tree-node-positioning/#nesting-groups-inside-stairs) As a final optimization, we can place more than just leaves inside stairs\. There's no reason to not put other groups in a stair\. Still, we don't want stairs that grow too tall\. Preferably, we should aim for an overall rectangular shape that can fit comfortably on a monitor\. If your primary target is mobile users in portrait, you can of course make different tradeoffs\. To get this "square\-shape", we can create multiple buckets, depending on the amount of children we have, or the area they occupy\. Each bucket will contain a stair, and the buckets themselves will be arranged in a row\. To keep it simple, let's add 1 bucket for every 3 children\. When adding children to the buckets, we will choose the one with the smallest area so far: ``` const indexOfSmallest = ( arr, sizeFn, ) => { let smallestIndex = 0 let smallestSize = sizeFn(arr[smallestIndex]) for (let i = 1; i < arr.length; i += 1) { const currentSize = sizeFn(arr[i]) if (currentSize < smallestSize) { smallestSize = currentSize smallestIndex = i } } return smallestIndex } const createLayout = (rootNode) => { const leaf = makeLeaf(rootNode) if (rootNode.children.length === 0) { return leaf } const children = rootNode.children.map(createLayout) if (children.length === 1) { return makeColumn(leaf, children[0]) } if (children < 3 || children.every((child) => child.type === LEAF)) { return makeColumn(leaf, makeStair(...children)) } const buckets = Array(1 + Math.floor(children.length / 3)).fill(undefined).map(() => []) for (const child of children) { const i = indexOfSmallest(buckets, (arr) => arr.reduce((acc, c) => acc + c.size.width * c.size.height, 0)) buckets[i].push(child) } return makeColumn( leaf, makeRow(...buckets.map((bucket) => bucket.length === 1 ? bucket[0] : makeStair(...bucket))) ) } ``` Agh\! Nested columns default to centering the children, but just like the links, the nodes themselves need to peek from the right to be able to make a clean connection\. Because this is only a problem when a stair renders a column, we can fix it directly there: ``` const initStairBbox = (stair) => { stair.children.forEach(initLayoutBbox) stair.children.sort((a, b) => a.bbox.width - b.bbox.width) for (const [prev, child] of pairwise(stair.children)) { if (prev && child.type === COLUMN) { updateBbox(child.children[0], { x: child.bbox.width - child.children[0].bbox.width }) } updateBbox(child, { x: prev ? prev.bbox.right + SPACING_X - child.bbox.width : 0, y: prev ? prev.bbox.bottom + SPACING_Y / 2 : 0, }) } const minX = Math.min(...stair.children.map((child) => child.bbox.x)) stair.children.forEach((child) => updateBbox(child, { x: child.bbox.x - minX })) updateBbox(stair, { width: Math.max(...stair.children.map((child) => child.bbox.right)), height: lastChild(stair).bbox.bottom, }) } ``` Now, this is starting to feel a bit like a hack, as it relies on knowledge on how the columns are doing things \(and the fact the first child is always a leaf we'll want to connect to\)\. The problem is, the only other way to fix this would be, when initializing the layout of the column, to know we're "a child of a stair, but not the first"\. Even determining who's the first is tricky\! Because that relies on knowing the final width \(since we sort children by it\), which is only known after the layout is finished\. Perhaps there would be a better way to*position*elements, separate of calculating the sizes\. But because the position helps calculate the size, it's a chicken\-and\-egg problem\. In any case, we're on the home stretch\! The last improvement can be made when rendering rows\. All this rendering of stairs and tendency to shift things right means we're prone to creating large gaps on the left, with the bounding box of a stair with very wide last children only occupying the space below everything else\. This isn't an issue on the default text, but it's very apparent on larger trees\. To fix this, we can look at where the previous child "ends" its Y\-position\. Then, we can calculate the difference between the leftmost X\-position of the leaves in the current child below and above that\. This will give us that "gap" that we can use to push the current child inside the bounding box of the previous child, but without rendering on top of it, because all the nodes that could collide are actually below\! ``` function* leafPositions( layoutElement, offset, ) { const pos = { x: layoutElement.bbox.x + offset.x, y: layoutElement.bbox.y + offset.y } if (layoutElement.type === LEAF) { yield pos } else { for (const child of layoutElement.children) { yield* leafPositions(child, pos) } } } const findSafeOffsetGap = (prev, child) => { const edgeY = prev.bbox.bottom + SPACING_Y let minXAbove = Infinity let minXBelow = Infinity for (const pos of leafPositions(child, { x: 0, y: 0 })) { if (pos.y < edgeY) { minXBelow = Math.min(minXBelow, pos.x) } else { minXAbove = Math.min(minXAbove, pos.x) } } const gap = minXBelow - minXAbove return gap !== Infinity && gap > 0 ? gap : 0 } const initRowBbox = (row) => { row.children.forEach(initLayoutBbox) const maxHeight = Math.max(...row.children.map((child) => child.bbox.height)) for (const [prev, child] of pairwise(row.children)) { updateBbox(child, { x: prev ? prev.bbox.right + SPACING_X - findSafeOffsetGap(prev, child) : 0, }) } updateBbox(row, { width: lastChild(row).bbox.right, height: maxHeight, }) } ``` This solution is not perfect, as don't check every X\-position at every Y\-position\. But nonetheless, I hope you enjoyed this journey\! If you're up for the challenge of trying to improve it even further, or know of someone who has done a better job than I have, don't hesitate to contact me\! This is definitely a hard \(and fun\) problem to try to solve\. My main goal was to minimize the area used by the diagram, and I think I've gotten pretty far\![↪4](https://lonami.dev/blog/tree-node-positioning/#fn:4) ### [Footnotes](https://lonami.dev/blog/tree-node-positioning/#footnotes) 1Okay, I took forever to publish this post\. I wrote that line back in 2024\-05\-08, if not earlier\.[↩](https://lonami.dev/blog/tree-node-positioning/#fnref:1) 2Seems like that link died since I wrote it out\.[Wikipedia has an article on Letter frequency](https://en.wikipedia.org/wiki/Letter_frequency)that would also work\.[↩](https://lonami.dev/blog/tree-node-positioning/#fnref:2) 3One could connect nodes with a smooth step curve or straight line\. But I ain't got time to figure out the former and the latter will break with the layout we'll end with\.[↩](https://lonami.dev/blog/tree-node-positioning/#fnref:3) 4If you generate random nodes, you may find that some connections still intersect nodes\. Fixing this is an exercise left to the reader\.[↪5](https://lonami.dev/blog/tree-node-positioning/#fn:5)[↩](https://lonami.dev/blog/tree-node-positioning/#fnref:4) 5I might have fixed this at my job, but finding the code and updating this post with all the version mixing would be pretty painful, so, sorry\![↩](https://lonami.dev/blog/tree-node-positioning/#fnref:5)

Similar Articles

It's All Just Trees With Web Origami

Lobsters Hottest

Web Origami is a flexible JavaScript-based static site generator and data transformation tool that treats all content as interoperable trees.

Tree Appreciator

Simon Willison's Blog

Simon Willison 用 iPhone 版 Claude 应用提示 Claude Opus 5.5 Max,耗时 3 小时 7 分钟自动生成了一款名为 Tree Appreciator 的 three.js 欣赏两棵算法生成树木差异的游戏,效果出乎意料地好。