DEV Community

Cover image for Rendering graph nodes as React components in d3.js+React graph.
Anton Zhuravlev
Anton Zhuravlev

Posted on

Rendering graph nodes as React components in d3.js+React graph.

Today, let's create a graph component that uses React and d3.js.
Usually when someone speaks about graph in d3.js, they mean something like this. The entities are shown as a circles and relationship between them — as line (they call them "edge" or "link") connecting them.
For many cases that representation is more than enough. But recently I've met the necessity to render a react component instead of just simple circle. Doing that would give us great freedom in ways to display neatly the information in the node, because the main pain point in SVG is inability to comfortably deal with text, flexbox etc.

So first things first, we would need a Vite+React+Typescript project (I will use bun as package manager)

bun create vite
Enter fullscreen mode Exit fullscreen mode

After installation finishes, we need to add some packages to render the graph:

cd <folder of the project>
bun add d3-force d3-selection
Enter fullscreen mode Exit fullscreen mode

Also, we would need a type definitions for d3 packages, which comes separately:

bun add -D @types/d3-force @types/d3-selection
Enter fullscreen mode Exit fullscreen mode

Now we are good to go.

First we will need to define the type of data:

// Graph.tsx
interface GraphNode = {
  id: string;
  name: string; // some text to show on the node
  url: string; // some additional info
};

interface GraphLink = {
  source: string; // id of the source node
  target: string; // id of the target node
  strength: number; // strength of the link
};
Enter fullscreen mode Exit fullscreen mode

The d3-force package has Typescript types for generic nodes and links, so we would want to use them for type safety.
We will extend our interfaces from the generics from d3-force:

// Graph.tsx

import { SimulationLinkDatum, SimulationNodeDatum } from "d3-force";

interface GraphNode extends SimulationNodeDatum {
  id: string;
  name: string;
  url: string;
};

interface GraphLink extends SimulationLinkDatum<GraphNode> {
  strength: number;
}; 

Enter fullscreen mode Exit fullscreen mode

(SimulationLinkDatum already has source and target fields)

So now let's define our Graph component:

// Graph.tsx

// ... type definitions 

function Graph({
  nodes,
  links,
  width = 300,
  height = 400,
}: {
  nodes: GraphNode[];
  links: GraphLink[];
  width: number;
  height: number;
}) {
  return <svg width={width} height={height}></svg>;
}
Enter fullscreen mode Exit fullscreen mode

Data we want to visualize would look something like this:

const nodes: GraphNode[] = [
  {
    id: "1",
    name: "Node 1",
    url: "https://example.com",
  },
  {
    id: "2",
    name: "Node 2",
    url: "https://google.com",
  },
  {
    id: "3",
    name: "Node 3",
    url: "https://yahoo.com",
  },
  {
    id: "4",
    name: "Node 4",
    url: "https://x.com",
  }
]

const links: GraphLink[] = [
  {
    source: "1",
    target: "2",
    strength: 1,
  },
  {
    source: "2",
    target: "3",
    strength: 2,
  },
  {
    source: "3",
    target: "4",
    strength: 3,
  },
  {
    source: "4",
    target: "1",
    strength: 4,
  }
]
Enter fullscreen mode Exit fullscreen mode

Cool. Now we need to construct new links, so the source and target would contain the node objects themselves:

// inside the Graph component

  const filledLinks = useMemo(() => {
    const nodesMap = new Map(nodes.map((node) => [node.id, node]));

    return links.map((link) => ({
      source: nodesMap.get(link.source as string)!,
      target: nodesMap.get(link.target as string)!,
      strength: link.strength,
      label: link.label,
    }));
  }, [nodes, links]);
Enter fullscreen mode Exit fullscreen mode

Now we need to get to rendering stuff with d3.js and React.
Let's create our force simulation object.

// ... outside Graph component
const LINK_DISTANCE = 90;
const FORCE_RADIUS_FACTOR = 2;
const NODE_STRENGTH = -100;

const simulation = forceSimulation<GraphNode, GraphLink>()
      .force("charge", forceManyBody().strength(NODE_STRENGTH))
      .force("collision", forceCollide(RADIUS * FORCE_RADIUS_FACTOR))
Enter fullscreen mode Exit fullscreen mode

We place that simulation definition with all the settings that are not dependent on the concrete data outside our React component, in order to escape creating new simulation every time our component rerenders.

There is one peculiarity in the way d3-force is handling it's data: it will mutate the passed nodes and links data "in-place". It will add some information about the node's coordinate x, y, it's velocity vector vx,vy and some other info — right into the corresponding element of our nodes array, without reassigning them.
So React won't "notice" that the node's x and y has changed. And that is quite beneficial for us, because that won't cause unnecessary rerenders on every simulation "tick".

Now, when we have our simulation object almost ready, let's get to linking it with the real data:


// Graph.tsx, inside the component:
// ...

  useEffect(() => {
    simulation
      .nodes(nodes)
      .force(
        "link",
        forceLink<GraphNode, GraphLink>(filledLinks)
          .id((d) => d.id)
          .distance(LINK_DISTANCE),
      )
      .force("center", forceCenter(width / 2, height / 2).strength(0.05));

  }, [height, width, nodes, filledLinks]);

Enter fullscreen mode Exit fullscreen mode

We need to bind the d3 to the svg, that is rendered by React, so we will add a ref:


// Graph.tsx, inside component

function Graph(
// ...
 ) {

const svgRef = useRef<SVGSVGElement>()

// ...

  return <svg width={width} height={height} ref={svgRef}> </svg>
}
Enter fullscreen mode Exit fullscreen mode

Now we will render our links and nodes.


//Graph.tsx inside component, inside the useEffect that we set up earlier

    const linksSelection = select(svgRef.current)
      .selectAll("line.link")
      .data(filledLinks)
      .join("line")
      .classed("link", true)
      .attr("stroke-width", d => d.strength)
      .attr("stroke", "black");

Enter fullscreen mode Exit fullscreen mode

For nodes o four graph to be React components, and not just some svg <circle>, we will use a SVG element <foreignObject>:

//Graph.tsx inside component, inside the useEffect that we set up earlier

const linksSelection = // ...

const nodesSelection = select(svgRef.current)
      .selectAll("foreignObject.node")
      .data(nodes)
      .join("foreignObject")
      .classed("node", true)
      .attr("width", 1)
      .attr("height", 1)
      .attr("overflow", "visible"); 
Enter fullscreen mode Exit fullscreen mode

That will render an empty <foreignObject> node as our nodes. Notice that we setting width and height of it, as well as setting overflow: visible. That will help us to render react component of arbitrary size for our nodes.

Now we need to render a react component inside the foreignObject. We will do that using createRoot function from react-dom/client, as it is done for the root component to bind React to DOM.
So we will iterate over all created foreignObjects:

//Graph.tsx inside component, inside the useEffect that we set up earlier

const linksSelection = // ...
const nodesSelection = // ...

nodesSelection?.each(function (node) {
      const root = createRoot(this as SVGForeignObjectElement);
      root.render(
        <div className="z-20 w-max -translate-x-1/2 -translate-y-1/2">
          <Node name={node.name} />
        </div>,
      );
    });
Enter fullscreen mode Exit fullscreen mode

this in the callback function is a DOM node.

The z-20 w-max -translate-x-1/2 -translate-y-1/2 classes adjusts position of our node for it to be centered around the node's coordinates.

The Node component is arbitrary React component. In my case, it's like this:

// Node.tsx

export function Node({ name }: { name: string }) {
  return (
    <div className=" bg-blue-300 rounded-full border border-blue-800 px-2">
      {name}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, we will tell d3 to adjust positions of the nodes and links on every iteration ("tick") of the force simulation:

//Graph.tsx inside component, inside the useEffect that we set up earlier
const linksSelection = // ...
const nodesSelection = // ...

nodesSelection?.each.( 
  //...
);

simulation.on("tick", () => {
      linksSelection
        .attr("x1", (d) => d.source.x!)
        .attr("y1", (d) => d.source.y!)
        .attr("x2", (d) => d.target.x!)
        .attr("y2", (d) => d.target.y!);

      nodesSelection.attr("transform", (d) => `translate(${d.x}, ${d.y})`);
    });
Enter fullscreen mode Exit fullscreen mode

Voila! We have a d3 graph with force simulation and our nodes are rendered as a React components!

End result - Graph with d3 force simulation and nodes as React components

The full code for the Graph component (I've adjusted the Node.tsx a little bit to show the othe data that belongs to the node):

//Graph.tsx
import {
  forceCenter,
  forceCollide,
  forceLink,
  forceManyBody,
  forceSimulation,
  SimulationLinkDatum,
  SimulationNodeDatum,
} from "d3-force";
import { select } from "d3-selection";
import { useEffect, useMemo, useRef } from "react";
import { createRoot } from "react-dom/client";
import { Node } from "./Node";

const RADIUS = 10;
const LINK_DISTANCE = 150;
const FORCE_RADIUS_FACTOR = 10;
const NODE_STRENGTH = -100;

export interface GraphNode extends SimulationNodeDatum {
  id: string;
  name: string;
  url: string;
}

export interface GraphLink extends SimulationLinkDatum<GraphNode> {
  strength: number;
  label: string;
}

const nodes: GraphNode[] = [
  {
    id: "1",
    name: "Node 1",
    url: "https://example.com",
  },
  {
    id: "2",
    name: "Node 2",
    url: "https://google.com",
  },
  {
    id: "3",
    name: "Node 3",
    url: "https://yahoo.com",
  },
  {
    id: "4",
    name: "Node 4",
    url: "https://x.com",
  },
];

const links: GraphLink[] = [
  {
    source: "1",
    target: "2",
    strength: 1,
  },
  {
    source: "2",
    target: "3",
    strength: 2,
  },
  {
    source: "3",
    target: "4",
    strength: 3,
  },
  {
    source: "4",
    target: "1",
    strength: 4,
  },
];

const simulation = forceSimulation<GraphNode, GraphLink>()
  .force("charge", forceManyBody().strength(NODE_STRENGTH))
  .force("collision", forceCollide(RADIUS * FORCE_RADIUS_FACTOR));

function Graph({
  nodes,
  links,
  width = 600,
  height = 400,
}: {
  nodes: GraphNode[];
  links: GraphLink[];
  width?: number;
  height?: number;
}) {
  const svgRef = useRef<SVGSVGElement>(null);

  const filledLinks = useMemo(() => {
    const nodesMap = new Map(nodes.map((node) => [node.id, node]));
    return links.map((link) => ({
      source: nodesMap.get(link.source as string)!,
      target: nodesMap.get(link.target as string)!,
      strength: link.strength,
      label: link.label,
    }));
  }, [nodes, links]);

  useEffect(() => {
    simulation
      .nodes(nodes)
      .force(
        "link",
        forceLink<GraphNode, GraphLink>(filledLinks)
          .id((d) => d.id)
          .distance(LINK_DISTANCE),
      )
      .force("center", forceCenter(width / 2, height / 2).strength(0.05));

    const linksSelection = select(svgRef.current)
      .selectAll("line.link")
      .data(filledLinks)
      .join("line")
      .classed("link", true)
      .attr("stroke-width", (d) => d.strength)
      .attr("stroke", "black");

    const nodesSelection = select(svgRef.current)
      .selectAll("foreignObject.node")
      .data(nodes)
      .join("foreignObject")
      .classed("node", true)
      .attr("width", 1)
      .attr("height", 1)
      .attr("overflow", "visible");

    nodesSelection?.each(function (node) {
      const root = createRoot(this as SVGForeignObjectElement);
      root.render(
        <div className="z-20 w-max -translate-x-1/2 -translate-y-1/2">
          <Node node={node} />
        </div>,
      );
    });

    simulation.on("tick", () => {
      linksSelection
        .attr("x1", (d) => d.source.x!)
        .attr("y1", (d) => d.source.y!)
        .attr("x2", (d) => d.target.x!)
        .attr("y2", (d) => d.target.y!);

      nodesSelection.attr("transform", (d) => `translate(${d.x}, ${d.y})`);
    });
  }, [height, width, nodes, filledLinks]);

  return <svg width={width} height={height} ref={svgRef}></svg>;
}

export { Graph, links, nodes };
Enter fullscreen mode Exit fullscreen mode
// Node.tsx
import { GraphNode } from "./Graph";

export function Node({ node }: { node: GraphNode }) {
  return (
    <div className=" bg-blue-300 rounded-full border border-blue-800 px-5 py-1">
      <h2>{node.name} </h2>
      <a className=" inline-block text-sm underline" href={node.url}>
        {node.url}
      </a>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)