DEV Community

loading...
Cover image for Drawing a Mind Map with Three.js and React

Drawing a Mind Map with Three.js and React

Patrick Hund
Software engineer, cartoonist, electronic music producer. He/him.
Updated on ・9 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.

Today's Goal

My goal is to create something like this:

Alt Text

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

[
  { "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 }
]
Enter fullscreen mode Exit fullscreen mode

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 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:

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} />;
}
Enter fullscreen mode Exit fullscreen mode

This is the most basic three.js example, taken from the intro tutorial 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:

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 <head> tag of my index.html file:

<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>
Enter fullscreen mode Exit fullscreen mode

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 of the three.js documentation discusses some possibilities.

I could use 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 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 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

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

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

The 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:

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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);
}
Enter fullscreen mode Exit fullscreen mode

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(). 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 tags wrap the actual HTML code I want to render inside the SVG image.

This HTML code is generated using 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:

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();
}
Enter fullscreen mode Exit fullscreen mode

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

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} />
  );
}
Enter fullscreen mode Exit fullscreen mode

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

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

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

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);
}
Enter fullscreen mode Exit fullscreen mode

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

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 };
}
Enter fullscreen mode Exit fullscreen mode

This is what it looks like after these refactoring steps:

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.

Discussion (8)

Collapse
crimsonmed profile image
Médéric Burlet • Edited

why not use a force graph? That would be the best way to do it no?

GitHub logo vasturiano / three-forcegraph

Force-directed graph as a ThreeJS 3d object

ThreeJS Force-Directed Graph

NPM package Build Size Dependencies

A ThreeJS WebGL class to represent a graph data structure in a 3-dimensional space using a force-directed iterative layout Uses either d3-force-3d or ngraph for the underlying physics engine.

Quick start

import ThreeForceGraph from 'three-forcegraph'

or

var ThreeForceGraph = require('three-forcegraph')

or even

<script src="//unpkg.com/three-forcegraph"></script>

then

var myGraph = new ThreeForceGraph()
    .graphData(<myData>);

var myScene = new THREE.Scene();
myScene.add(myGraph);

...

// on animation frame
myGraph.tickFrame();

API reference

Method Description Default
graphData([data]) Getter/setter for graph data structure (see below for syntax details). Can also be used to apply incremental updates. { nodes: [], links: [] }
jsonUrl([url]) URL of JSON file to load graph data directly from, as an alternative to specifying graphData directly.
numDimensions([int]) Getter/setter for number of dimensions to run the force simulation on (1, 2 or 3). 3
dagMode([str]) Apply layout constraints based on

For 2D you could use this:

GitHub logo vasturiano / force-graph

Force-directed graph rendered on HTML5 canvas

force-graph

NPM package Build Size Dependencies

Force-directed graph rendered on HTML5 canvas.

A web component to represent a graph data structure in a 2-dimensional canvas using a force-directed iterative layout Uses HTML5 canvas for rendering and d3-force for the underlying physics engine Supports canvas zooming/panning, node dragging and node/link hover/click interactions.

Check out the examples:

Collapse
pahund profile image
Patrick Hund Author

Thanks for the reply! I'll take a look at it. I don't think it can render React components as nodes of the graph, or can it? Ultimately, what I'm planning is that each node of the mind map/graph will be a social media post (text, picture, video, comment, etc.). Do you think I could pull that off with this library?

Collapse
crimsonmed profile image
Médéric Burlet

Hey I don't think each node can be a react component as that will be pretty heavy. There is a react wrapper for the library mentioned. We use it to visualize a whole computer folder / file system.
If you update the datalist the graph will update dynamically with animations if wanted.

I think a mind map based off post would have much less posts than a whole filesysten.

For the react one here is the one we used.

GitHub logo vasturiano / react-force-graph

React component for 2D, 3D, VR and AR force directed graphs

react-force-graph

NPM package Dependencies

React bindings for the force-graph suite of components: force-graph (2D HTML Canvas), 3d-force-graph (ThreeJS/WebGL), 3d-force-graph-vr (A-Frame) and 3d-force-graph-ar (AR.js).

This module exports 4 React components with identical interfaces: ForceGraph2D, ForceGraph3D, ForceGraphVR and ForceGraphAR. Each can be used to represent a graph data structure in a 2 or 3-dimensional space using a force-directed iterative layout.

For dependency convenience, all of the components are also available as stand-alone packages: react-force-graph-2d, react-force-graph-3d, react-force-graph-vr and react-force-graph-ar.

Uses canvas/WebGL for rendering and d3-force-3d for the underlying physics engine Supports zooming/panning, node dragging and node/link hover/click interactions.

Check out the examples:





Thread Thread
pahund profile image
Patrick Hund Author

I'm definitely curious how quickly I will run into performance issues with my mind map with React components as sprite textures. We'll see…

Thread Thread
crimsonmed profile image
Thread Thread
pahund profile image
Patrick Hund Author

The tip to use the force-graph library was gold, I'm now using it in the latest version of my project. Check it out: dev.to/pahund/drawing-a-mind-map-w...

Thread Thread
crimsonmed profile image
Médéric Burlet

Glad it helped! Looking forward to seeing more of the projects and if I have any tips I'll make sure to comment! That's what I love about IT all sharing ideas and tips to make projects better!

Thread Thread
pahund profile image
Patrick Hund Author

Should have listened to your advice right away 😂

Forem Open with the Forem app