DEV Community

loading...
Cover image for Drawing a Mind Map with Force Directed Graphs

Drawing a Mind Map with Force Directed Graphs

Patrick Hund
Software engineer, cartoonist, electronic music producer. He/him.
Updated on ・5 min read

I'm building a social media network and collaboration tool based on mind maps, documenting my work in this series of blog posts. Follow me if you're interested in what I've learned along the way about building web apps with React, Tailwind CSS, Firebase, Apollo/GraphQL, three.js and TypeScript.

In the previous part I, part II and part III, I've found out how to render React components on sprites in three.js and connect them with lines to make up a mind map that's nested two levels deep.

There's Got to Be a Better Way

I've been thinking about the solution I have this far. It is renders mind maps with a depth of two levels, and already it becomes clear that drawing a graph like this is not easy (it is mathematically speaking a graph).

I ultimately want my mind map to be potentially hundreds of levels deep and have thousands of nodes. To calculate the position of the nodes so that the don't overlap is a nontrivial problem.

A Facebook friend pointed me to the wikipedia article about graph drawing (thanks, Stefan!). It discusses different layout methods. This one seems to be the most appropriate for drawing a mind map:

In force-based layout systems, the graph drawing software modifies an initial vertex placement by continuously moving the vertices according to a system of forces based on physical metaphors related to systems of springs or molecular mechanics. Typically, these systems combine attractive forces between adjacent vertices with repulsive forces between all pairs of vertices, in order to seek a layout in which edge lengths are small while vertices are well-separated.

This is what it looks like (with tons and tons of nodes):

Alt Text

Martin Grandjean • CC BY-SA 3.0

Note quite what I'm aiming at, but I think I can make this work for me.

Using three-forcegraph

Dev.to user crimsonmed pointed me to an implementation of force-directed graphs for three.js: three-forcegraph – thanks Médéric!

To use three-forcegraph, I have to reformat the JSON file with my mind map data:

data.json

{
  "nodes": [
    { "id": "1", "name": "Interests", "val": 64 },
    { "id": "2", "name": "Music", "val": 32 },
    { "id": "3", "name": "Graphic Design", "val": 32 },
    { "id": "4", "name": "Coding", "val": 32 },
    { "id": "5", "name": "Piano", "val": 16 },
    { "id": "6", "name": "Guitar", "val": 16 },
    { "id": "7", "name": "Electronic", "val": 16 },
    { "id": "8", "name": "Procreate", "val": 16 },
    { "id": "9", "name": "Photoshop", "val": 16 },
    { "id": "10", "name": "Illustrator", "val": 16 },
    { "id": "11", "name": "Sketch", "val": 16 },
    { "id": "12", "name": "React", "val": 16 },
    { "id": "13", "name": "TypeScript", "val": 16 },
    { "id": "14", "name": "GraphQL", "val": 16 },
    { "id": "15", "name": "Firebase", "val": 16 },
    { "id": "16", "name": "Tailwind CSS", "val": 16 },
    { "id": "17", "name": "Computer Graphics", "val": 16 },
    { "id": "18", "name": "Ableton Live", "val": 8 },
    { "id": "19", "name": "Reason", "val": 8 },
    { "id": "20", "name": "Phaser", "val": 8 },
    { "id": "21", "name": "Three.js", "val": 8 }
  ],
  "links": [
    { "source": "1", "target": "2" },
    { "source": "1", "target": "3" },
    { "source": "1", "target": "4" },
    { "source": "2", "target": "5" },
    { "source": "2", "target": "6" },
    { "source": "2", "target": "7" },
    { "source": "3", "target": "8" },
    { "source": "3", "target": "9" },
    { "source": "3", "target": "10" },
    { "source": "3", "target": "11" },
    { "source": "4", "target": "12" },
    { "source": "4", "target": "13" },
    { "source": "4", "target": "14" },
    { "source": "4", "target": "15" },
    { "source": "4", "target": "16" },
    { "source": "4", "target": "17" },
    { "source": "7", "target": "18" },
    { "source": "7", "target": "19" },
    { "source": "17", "target": "20" },
    { "source": "17", "target": "21" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

While previously, the nodes were linked through a parent property, now the nodes and the links between them are defined as separate arrays.

My renderMindMap for now simply throws the data in a ThreeForceGraph object and renders it:

function renderMindMap(div) {
  const { scene, renderer, camera } = initializeScene(div);
  const graph = new ThreeForceGraph().graphData(data);
  graph.numDimensions(2);
  scene.add(graph);
  camera.lookAt(graph.position);

  (function animate() {
    graph.tickFrame();
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
  })();
}
Enter fullscreen mode Exit fullscreen mode

Note the line graph.numDimensions(2) – it's important, because by default, the graph will be three-dimensional, which is not suitable for a mind map and would lead to nodes that appear to overlap.

I need to render the graph in an animation loop, because that's how the library works, it starts out drawing all the nodes in the same spot, then the force of each node drives them apart from each other.

The result looks promising already – none of the nodes are overlapping:

Rendering the Mind Map Using the Force Directed Graph

Now, instead of those bubbles, I want the force directed graph to display the MindMapNode React components I had created in my earlier attempts (see part I of this series).

It took quite a bit of fiddling and trial and error, this is what I came up with:

renderMindMap.js

async function renderMindMap(div) {
  const { scene, renderer, camera } = initializeScene(div, data);
  data.nodes = await Promise.all(
    data.nodes.map((node) =>
      renderToSprite(<MindMapNode label={node.name} level={node.level} />, {
        width: 120,
        height: 60
      }).then((sprite) => ({ ...node, sprite }))
    )
  );
  const graph = new ThreeForceGraph().graphData(data);
  graph.nodeThreeObject(({ sprite }) => sprite);
  graph.linkMaterial(
    ({ level }) => new THREE.MeshBasicMaterial({ color: colorsByLevel[level] })
  );
  graph.linkPositionUpdate(updateLinkPosition);
  graph.numDimensions(2);
  graph.linkWidth(1);
  graph.scale.set(0.005, 0.005, 0.005);
  scene.add(graph);
  camera.lookAt(graph.position);

  let counter = 0;

  (function animate() {
    graph.tickFrame();
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
  })();
}
Enter fullscreen mode Exit fullscreen mode

Comments:

  • I have to render the mind map nodes, that are created through my MindMapNode React component asynchronously, hence the Promise.all call that renders all of them in one go and stores them in the data, to be pulled out again in graph.nodeThreeObject
  • I had to set the scale of the graph to tiny (0.005), otherwise my mind map nodes would have been very small because the whole graph is so big
  • To fix these scale issues, I also had to adjust my renderToSprite function so that it scales down the sprites to ⅙ size
  • I had to overwrite the function that calculates the position of the links with graph.linkPositionUpdate, just to set the z position to make the links appear behind the nodes; by default, they were rendered in front

So here you have it, my mind map rendered as a force directed graph:

To Be Continued…

I think next time, I'll see if I can add interactivity: it would be cool if I could add new mind map nodes and remove existing ones.

Discussion (5)

Collapse
dcsan profile image
dc

I'm glad to see this, I'm doing some similar research right now.

It's pretty cool you've got all these things to work together, but wonder if these are the right choices for a stack. three.js isn't really very good with text nodes afair so if you plan to add more content it will be hard to render.

The normal candidate would be D3, which has great SVG support and a lot of ways to do force directed graphs. But you may not like the API, I always find it takes ages to work with - although it gives you fine grained control.

Another library I like a lot is cytoscape which has a JS version. It's a very sophisticated library for all kinds of graph exploration, but has imho a much easier to use API than threeJS. It allows direct DOM elements to be laid out. I've been using it for some conversation graph visualization experiments here (also a sankey using D3).
This was about combining react components with a network graph. Happy to share a repo link if you'd like
dc.rik.ai/projects/convoai
convo-graph

For a network graph the force DAG view is good, but if you really mean "mind-map" maybe some other layout algo would be better? Did you look into any of the existing react libraries for mindmaps? There's a whole thing called mindmark, which is mindmaps in markdown. Kind of like mermaid format for mind maps. I am still looking for something quick to work with here myself.

Oh also I recommend looking into supabase!

Let us know your progress!

Collapse
pahund profile image
Patrick Hund Author

Thanks for the suggestions, I'll take a look!

Collapse
pahund profile image
Patrick Hund Author • Edited

Btw. I'm aware that three.js is not good at rendering text, that's why I'm creating the mind map nodes as sprites with canvas textures. The canvas textures contents are created using React components, so I can harness the full power of HTML/CSS to put any content I like in my graph.

Here's an early experiment I did with this technique:

The pink cube displays content rendered to a DOM node with React, including a bitmap image.

Collapse
xedii profile image
Michał Mrotek

If you need some "network graph" I've got something really cool. I created a library for vis js. It's called vis-network-hook Feel free to try It and leave feedback 😉

Collapse
pahund profile image
Patrick Hund Author

Thanks, I'll take a look!