DEV Community

loading...
Cover image for Drawing a Mind Map with Three.js and React, for Real This Time

Drawing a Mind Map with Three.js and React, for Real This Time

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've found out how to render React components on sprites in three.js. My plan is to create a mind map. So far I've got the root node of my mind map displayed, yay!

Rendering the Nodes in Radial Arrangement

OK, so now to the part I have been dreading: figuring out how to arrange all the mind map nodes defined in my data.json file so that the are fanning out from the root node in a radial layout. Calculating the positions on the nodes will involve some trigonometry. I'm terrible at math…

“Winter is Coming” meme: Math is coming!

I'll take baby steps. Let's render only the root node and the level 1 nodes for now. The level 1 nodes will be arranged in a circle around the root node.

Here's my code to find the root node and the level 1 nodes, then render them:

renderMindMap.js

import addMindMapNode from './addMindMapNode';
import initializeScene from './initializeScene';
import data from './data';

export default async function renderMindMap(div) {
  const { scene, renderer, camera } = initializeScene(div);
  const root = data.find((node) => node.parent === undefined);
  const level1 = data.filter((node) => node.parent === root.id);
  root.x = 0;
  root.y = 0;
  root.level = 0;

  await addMindMapNode(scene, root);
  const radius = 2;
  for (const level1node of level1) {
    level1node.level = 1;
    // TODO:
    //level1node.x = ?;
    //level1node.y = ?;
    await addMindMapNode(scene, level1node);
  }
  renderer.render(scene, camera);
}
Enter fullscreen mode Exit fullscreen mode

The big fat TODO here is to calculate the x and y properties for each level 1 node.

I drew me a little picture to illustrate the problem:

Calculating level 1 node positions

Where else could I find the answer that on trusty old StackOverflow?

Here's a solution using C#:

void DrawCirclePoints(int points, double radius, Point center)
{
    double slice = 2 * Math.PI / points;
    for (int i = 0; i < points; i++)
    {
        double angle = slice * i;
        int newX = (int)(center.X + radius * Math.Cos(angle));
        int newY = (int)(center.Y +

I translate the C# code from the StackOverflow post to JavaScript:

import addMindMapNode from './addMindMapNode';
import initializeScene from './initializeScene';
import data from './data';

export default async function renderMindMap(div) {
  const { scene, renderer, camera } = initializeScene(div);
  const root = data.find((node) => node.parent === undefined);
  const level1 = data.filter((node) => node.parent === root.id);
  root.x = 0;
  root.y = 0;
  root.level = 0;

  await addMindMapNode(scene, root);
  const radius = 2;
  const slice = (2 * Math.PI) / level1.length;
  for (let i = 0; i < level1.length; i++) {
    const level1node = level1[i];
    level1node.level = 1;
    const angle = slice * i;
    level1node.x = root.x + radius * Math.cos(angle);
    level1node.y = root.y + radius * Math.sin(angle);
    await addMindMapNode(scene, level1node);
  }
  renderer.render(scene, camera);
}
Enter fullscreen mode Exit fullscreen mode

This works!

Making the Child Nodes Look Evenly Spaced

Here are screenshots of mind map nodes drawn with this code, with a varying number of level 1 nodes:

Level 1 nodes

Although the child nodes are distributed evenly around the root node, in some cases it looks wonky, for example with 3, 7 or 9 child nodes. The problem is that the mind map nodes are rectangles. If they were squares or circles, it would look better (more even).

7 child nodes

The red segments of the circle I have drawn here have different lengths. For my mind map nodes to look evenly distributed along the circle, these would have to have equal lengths, i.e. I have to take the width and height of the mind map nodes into account when calculating the angle for each node.

I have to admit, I'm at a loss how to calculate this, so I've posted questions on StackOverflow and StackExchange Mathematics, let's see how it goes.

If someone is reading this who can help, please let me know!

Connecting the Dots

Meanwhile, I continued my work with the connections between the root node and the level 1 node. Drawing lines with three.js is surprisingly hard.

When I naïvely used THREE.LineBasicMaterial and THREE.Line, as explained in the three.js documentation, I discovered that the lines were always 1 pixel thin, no matter what line width I set.

The problem is that WebGL doesn't support drawing lines really well. Quoting the docs:

Due to limitations of the OpenGL Core Profile with the WebGL renderer on most platforms linewidth will always be 1 regardless of the set value.

I resorted to using the library THREE.MeshLine, which seems like using a sledgehammer to crack a nut, since it is a powerful tool in its own right that can do much more amazing things than just drawing a straight, thick line.

addConnection.js

import * as THREE from 'three';
import { MeshLine, MeshLineMaterial } from 'three.meshline';

const lineWidth = 5;

export default async function addConnection(
  scene,
  { color, parentNode, childNode }
) {
  const points = new Float32Array([
    parentNode.x,
    parentNode.y,
    0,
    childNode.x,
    childNode.y,
    0
  ]);
  const line = new MeshLine();
  line.setGeometry(points);
  const material = new MeshLineMaterial({
    useMap: false,
    color,
    opacity: 1,
    resolution: new THREE.Vector2(window.innerWidth, window.innerHeight),
    sizeAttenuation: false,
    lineWidth
  });
  const mesh = new THREE.Mesh(line.geometry, material);
  scene.add(mesh);
}
Enter fullscreen mode Exit fullscreen mode

My addConnection function is similar to addNode, it accepts as arguments a scene to add the connection (line) to and an object with additional arguments, in this case the two mind map nodes to connect.

Like the width and height of the mind map nodes in addNode, I've decided to declare the line width as constant for now.

My updated renderMindMap function that uses this now looks like this:

import addConnection from './addConnection';
import addMindMapNode from './addMindMapNode';
import colors from './colors';
import data from './data';
import initializeScene from './initializeScene';

export default async function renderMindMap(div) {
  const { scene, renderer, camera } = initializeScene(div);
  const root = data.find((node) => node.parent === undefined);
  const level1 = data.filter((node) => node.parent === root.id);
  root.x = 0;
  root.y = 0;
  root.level = 0;

  await addMindMapNode(scene, root);
  const radius = 2;
  const slice = (2 * Math.PI) / level1.length;
  for (let i = 0; i < level1.length; i++) {
    const level1node = level1[i];
    level1node.level = 1;
    const angle = slice * i;
    const x = root.x + radius * Math.cos(angle);
    const y = root.y + radius * Math.sin(angle);
    level1node.x = x;
    level1node.y = y;
    await addMindMapNode(scene, level1node);
    addConnection(scene, {
      color: colors.magenta,
      parentNode: root,
      childNode: level1node
    });
  }
  renderer.render(scene, camera);
}
Enter fullscreen mode Exit fullscreen mode

Here's the whole project so far on CodeSandbox:

To Be Continued…

Stay tuned for my ongoing quest to render my perfect mind map!

Will he figure out a way to make the level 1 nodes spaced evenly?

Will he manage to add the level 2 and level 3 nodes without having them overlap?

All these questions and more may or may not be answered in the next episode! 😅

Discussion (0)

Forem Open with the Forem app