Drawing a Mind Map with Three.js and React
Tags: javascript, react, threejs, mindmap
Published: 2020-12-02 07:11:05

---
title: Drawing a Mind Map with Three.js and React
published: true
description: I'm building a social media network and collaboration tool based on mind maps, documenting my work in this series of blog posts
tags: javascript,react,threejs,mindmap
cover_image: https://dev-to-uploads.s3.amazonaws.com/i/ogsh8ggec8up5wdorvu5.png
series: Nuffshell Coding Diary
---

*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.*

# Today's Goal

My goal is to create something like this:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/4wcgvvibfb7cl1mu9k3q.png)

Starting from a root node *Interests*, my mind map is branching out to sub-nodes representing my interests *Music*, *Graphic Design* and *Coding*, which in turn have other sub-nodes, and so on.

# Creating the Data

To start off, I'm creating a JSON file that contains the data for my mind map:

*data.json*
```json
[
  { "id": 1, "label": "Interests" },
  { "id": 2, "label": "Music", "parent": 1 },
  { "id": 3, "label": "Graphic Design", "parent": 1 },
  { "id": 4, "label": "Coding", "parent": 1 },
  { "id": 5, "label": "Piano", "parent": 2 },
  { "id": 6, "label": "Electronic", "parent": 2 },
  { "id": 7, "label": "Procreate", "parent": 3 },
  { "id": 8, "label": "Adobe Illustrator", "parent": 3 },
  { "id": 9, "label": "Computer Graphics", "parent": 4 },
  { "id": 10, "label": "React", "parent": 4 },
  { "id": 11, "label": "Reason", "parent": 6 },
  { "id": 12, "label": "Ableton Live", "parent": 6 },
  { "id": 13, "label": "Three.js", "parent": 9 },
  { "id": 14, "label": "Phaser", "parent": 9 }
]
```

This is an array containing objects, one object per mind map node.

To be able to reference one node to the next, I'm assigning a unique ID to each node.

The nodes are connected through the parent property, which is is the ID of the node above.

The node with ID 1 and label *Interests* doesn't have a parent, it is the root node of my mind map.

# Basic Setup with Create React App

I'm using React for my project. You may think, “Patrick, why make things complicated? You can just use Three.js and be done with it, no need for React.” Please bear with me. This experiment is part of a larger project I'm working on, *Nuffshell*, a social network and collaboration tool, which is a web app built with React, so it makes sense to use React also in my demo.

Code Sandbox has a nice template that lets you kick-start a new React app. It uses [create-react-app](https://create-react-app.dev) under the hood.

I'm adding the npm dependency *three* to my Code Sandbox and changing the *App.js* component to create a basic three.js scene, to see if it works:

```javascript
import React, { createRef, useEffect } from 'react';
import * as THREE from 'three';

export default function App() {
  const divRef = createRef();
  useEffect(() => {
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    );
    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    divRef.current.appendChild(renderer.domElement);
    const geometry = new THREE.BoxGeometry();
    const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
    const cube = new THREE.Mesh(geometry, material);
    scene.add(cube);
    camera.position.z = 5;
    function animate() {
      requestAnimationFrame(animate);
      cube.rotation.x += 0.01;
      cube.rotation.y += 0.01;
      renderer.render(scene, camera);
    }
    animate();
  }, [divRef]);
  return <div className="App" ref={divRef} />;
}
```

This is the most basic three.js example, taken from the [intro tutorial](https://threejs.org/docs/index.html#manual/en/introduction/Creating-a-scene) of the three.js documentation.

Only difference to the tutorial is that I'm rendering the three.js scene into a React component.

It renders a rotating green cube, like this:

{% codesandbox 9xl2h %}

# Styling

For my demo, I'm going to use one inline style definition for all my CSS. It will become evident why later in this post. I'm adding some basic style definitions to the *&lt;head&gt;* tag of my *index.html* file:

```html
<style id="styles" type="text/css">
  html {
    box-sizing: border-box;
  }

  *,
  *:before,
  *:after {
    box-sizing: inherit;
  }

  body {
    font-family: sans-serif;
    margin: 0;
    padding: 0;
  }
</style>
```

The *margin* and *padding* properties on the *body* style make sure my three.js scene fits snugly into the browser's viewport.

The *box-sizing* make sure that margins and paddings are calculated in a more sensible way than the CSS default.

Note that I'd adding an ID *styles* to the style element – I will need this later.

# Rendering the Mind Map's Text Labels

I could now go ahead and build my mind map with three.js by creating a box mesh object for each node.

But how do I add the text labels to the nodes?

The page [Creating Text](https://threejs.org/docs/index.html#api/en/geometries/TextGeometry) of the three.js documentation discusses some possibilities.

I could use [TextGeometry](https://threejs.org/docs/index.html#api/en/geometries/TextGeometry) for this.

But this would be complicated and tedious – I would have to calculate things like text wrapping myself, for one.

A better way is to create a canvas, write my text on the canvas, then render the canvas into a texture, to be used for a three.js object. I could use the [fillText](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeText) method of HTML canvas for this. But this is still pretty tedious.

An even better way: create a texture with a canvas and render HTML/CSS into that canvas – this way, I will be able to utilize the power of all of CSS. To do this, we can load an SVG image into the canvas. SVG supports the [foreignObject](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject) element to include HTML/CSS code in the image.

An even better better way (peak galaxy brain!): Render a React component, which creates HTML/CSS, which is rendered into a canvas through an SVG image, which is used as a texture for a three.js object!

![Galaxy brain meme](https://dev-to-uploads.s3.amazonaws.com/i/xdlff3yaeqdee0c9rjo1.png)

Naturally, I'll go with that option. This way, I can use my familiar React techniques to control the layout and style of the nodes in my mind map.

# The Mind Map Node React Component

Here's my mind map node React component:

*MindMapNode.js*
```javascript
import React from 'react';
import cx from 'classnames';

export default function MindMapNode({ level, label }) {
  return (
    <div
      xmlns="http://www.w3.org/1999/xhtml"
      className={cx(
        'mind-map-node',
        level === 0 && 'magenta',
        level === 1 && 'violet',
        level === 2 && 'blue',
        level >= 3 && 'turquoise'
      )}
    >
      <div>{label}</div>
    </div>
  );
}
```

The [classnames](https://www.npmjs.com/package/classnames) library I'm importing in line #2 is a simple utility for conditionally joining class names together. It makes it easier to add different CSS classes depending on the level prop, which determines how deeply nested the mind map node is. This is how I achieve the different colors of the mind map nodes.

Also note the `xmlns` attribute – this is necessary for rendering the React component into an SVG image.

The CSS styles to accompany this I put in my *index.html* file under the other style definitions:

```css
foreignObject {
  box-sizing: border-box;
  font-family: sans-serif;
}

.mind-map-node {
  padding: 10px;
  width: 120px;
  height: 60px;
  display: flex;
  justify-content: center;
  align-items: center;
  border-width: 3px;
  border-style: solid;
  border-radius: 12px;
  text-align: center;
  font-weight: bold;
}

.magenta {
  color: rgb(241, 3, 200);
  border-color: rgb(241, 3, 200);
  background-color: rgb(251, 175, 238);
}

.violet {
  color: rgb(134, 3, 241);
  border-color: rgb(134, 3, 241);
  background-color: rgb(215, 166, 254);
}

.blue {
  color: rgb(0, 100, 210);
  border-color: rgb(0, 100, 210);
  background-color: rgb(176, 213, 254);
}

.turquoise {
  color: rgb(3, 211, 207);
  border-color: rgb(3, 211, 207);
  background-color: rgb(190, 254, 253);
}
```

**Note:** 

* Styling the `foreignObject` is necessary here – the rendered React component doesn't pick up the styling of the body or html elements
* I'm defining my colors using `rgb` here, instead of the more common hex codes; I discovered during a frustrating one hour “why the hell does it not render” trial and error session that hex color codes break the rendering into SVG images, I have no idea why

When I render these components, they look like this:

{% codesandbox tbtqd %}

For now, I'm just test-rendering directly to the DOM, the components are not yet rendered to my three.js scene.

# Rendering React Components to an HTML Canvas

How do I get my *MindMapNode* components into my three.js scene? As I said earlier, the way to go is to render them into an HTML canvas element first. Then we can use them to create a texture, which three.js can then put on any 3D mesh.

Here is the function I've written to do this:

*renderToCanvas.js*

```javascript
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

function loadImage(url) {
  const image = new window.Image();
  return new Promise((resolve) => {
    image.onload = () => resolve(image);
    image.src = url;
  });
}

export default async function renderToCanvas({
  canvas,
  width,
  height,
  Component
}) {
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  const url = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
      <style type="text/css">
        <![CDATA[
          ${document.getElementById('styles').innerHTML}
        ]]>
      </style>
      <foreignObject width=${width} height=${height}>
      ${renderToStaticMarkup(<Component />)}
      </foreignObject>
      </svg>`;
  const image = await loadImage(url);
  ctx.drawImage(image, 0, 0);
}
```

Since we can't render the HTML code from our React component directly into the canvas, we have to create an SVG image first, using [new window.Image()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image). Loading data into that image is done asynchronously, so we have to use a promise for this, making our whole *renderToCanvas* function `async`.

To create the SVG image, we create a data URL string (variable `url`). 

The SVG code in this string includes a style element, which I'm using to load all the styles from the style element in my *index.html* into the SVG image. This is necessary because the CSS classes referenced from my React component otherwise will have no effect – they need to be defined in the same SVG image. This is why I had added the ID `styles` to the style element in *index.html* earlier.

Next, the [foreignObject](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject) tags wrap the actual HTML code I want to render inside the SVG image.

This HTML code is generated using [renderToStaticMarkup](https://reactjs.org/docs/react-dom-server.html#rendertostaticmarkup) from the ReactDOM library, part of React.

# Texturing a Mesh with a React Component

Now that I have the power to render my React components to a canvas, I can use this power to render the components in 3D by adding the canvases as textures to 3D meshes. Let's do this!

Here's my *renderMindMap.js* module, which contains the three.js code I had added to *App.js* earlier, modified so that it uses my nifty React canvas as texture:

```javascript
import React from 'react';
import * as THREE from 'three';
import renderToCanvas from './renderToCanvas';
import MindMapNode from './MindMapNode';

export default async function renderMindMap(div) {
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  div.appendChild(renderer.domElement);
  const geometry = new THREE.BoxGeometry();
  const canvas = document.createElement('canvas');
    await renderToCanvas({
      canvas,
      width: 120,
      height: 60,
      Component: () => <MindMapNode level={0} label="Interests" />
    });
  const texture = new THREE.CanvasTexture(canvas);
  const material = new THREE.MeshBasicMaterial({ map: texture });
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
  camera.position.z = 5;
  function animate() {
    requestAnimationFrame(animate);
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;
    renderer.render(scene, camera);
  }
  animate();
}
```

Then I can modify my *App.js* module to use this function, like so:

```javascript
import React, { createRef, useEffect } from 'react';
import renderMindMap from './renderMindMap';

export default function App() {
  const divRef = createRef();
  useEffect(() => renderMindMap(divRef.current), [divRef]);
  return (
      <div ref={divRef} />
  );
}
```

As a result, the spinning cube that before was just plain green now has my pink root node, labeled “Interests”, painted on it:

{% codesandbox i4kr4 %}

# Using Sprites

So far, my mind map node is a cube, but that's not really what I want. I actually want the nodes of my mind map to be flat objects, they don't need to have depth. Using [sprites](https://threejs.org/docs/index.html#api/en/objects/Sprite) is ideal for that. 

I'm doing a bit of refactoring as I change my React-to-SVG-to-Canvas-to-Texture thingy to use sprites:

*renderToSprite.js*

```javascript
import * as THREE from 'three';
import renderToCanvas from './renderToCanvas';

export default async function renderToSprite(content, { width, height }) {
  const canvas = await renderToCanvas(content, {
    width,
    height
  });
  const map = new THREE.CanvasTexture(canvas);
  const material = new THREE.SpriteMaterial({ map });
  const sprite = new THREE.Sprite(material);
  sprite.scale.set(width / 100, height / 100, 0.1);
  return sprite;
}
```

Instead of passing a canvas element to `renderToCanvas`, I let the `renderToCanvas` function create a canvas element for me. This makes it less flexible, since I can now only use it for three.js materials, not for canvas elements mounted on the DOM, but I won't be needing that.

I also don't pass a React component to `renderToCanvas`, but the already rendered component (argument `content`).

My `renderMindMap.js` is now tidied up to just include the actual rendering of mind map nodes:

```javascript
import React from 'react';
import initializeScene from './initializeScene';
import MindMapNode from './MindMapNode';
import renderToSprite from './renderToSprite';

export default async function renderMindMap(div) {
  const { scene, renderer, camera } = initializeScene(div);
  const mindMapNode = await renderToSprite(
    <MindMapNode level={0} label="Interests" />,
    {
      width: 120,
      height: 60
    }
  );
  scene.add(mindMapNode);
  renderer.render(scene, camera);
}
```

I've moved all the initialization logic of scene, renderer and camera to `initializeScene`:

```javascript
import * as THREE from 'three';

export default function initializeScene(div) {
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  div.appendChild(renderer.domElement);
  camera.position.z = 5;
  return { scene, renderer, camera };
}
```

This is what it looks like after these refactoring steps:

{% codesandbox grhot %}

# To Be Continued…

OK, to be honest, it's not much of a mind map at this point, only the root node. Stay tuned for the next part, where I'll figure out how to actually turn this into a proper mind map.