Force Graph Navigation in Next.js

Written by Adrian Tejada

30 Minutes15/2/2024

Source codelive Demo

Hi, and welcome to my first ever blog post. As the title suggests, we’ll be building a force graph to navigate through a website built with Next.js. By using a force graph for navigation, we create a distinctive and visually appealing method for users to interact with a website.

Furthermore, it can aide users in comprehending the relationships between different pages. Each node represents the pages of a website, allowing users to interact with the nodes corresponding to distinct pages. As you’ve probably noticed, this is the same UI used on this website.

Technologies

Next.js

For this tutorial we’re using the App Router. Adapting the code for Pages Router should be straightforward since we're only working with Client Components. Familiarity with the useEffect, and usePathname hooks are essential.

D3.js

A basic understanding of D3.js for selecting React-rendered elements and data binding is required. If you're new, check out the D3.js documentation and React and D3 by Amelia Wattenberger. Also, here's a D3-force example.

Finally here’s a repository to code alongside with, along with a live demo.. Happy coding!

Step 1: Project Setup

Clone the repository and checkout to the tutorial-start branch. After installing dependencies with npm i, run npm run dev to run a development server locally. Open /src/app/_components/ForceGraph.js. You'll see that we have a starting point for our component.

ForceGraph.js
"use client";
import { useRouter } from "next/navigation";

export default function ForceGraph({ width = 250, height = 250 }) {
  const router = useRouter();

  const handleRoute = (node) => {
    router.push(node.route);
  };

  return (
    <svg
      className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
      viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
    ></svg>
  );
}

Writing the Data

In order for D3-force to create the force graph, we need to give it the right shape of data. D3-force requires an array of nodes. It also requires an array of links, which describe the relationship between the nodes.

Nodes

In our case, the nodes will represent the pages on our application. Each node is an object with an id and route properties. You’ll notice that we already have the nodes data hard coded in /src/data/nodes.json, as, our navbar makes use of that data.

const nodes = [
  {
    id: "home",
    route: "/",
  },
  {
    id: "work",
    route: "/work",
  },
  {
    id: "blog",
    route: "/blog",
  },
  // additional nodes...
];

Links

Links describe relationships between nodes, which are defined by source and target properties. It's important that the values inside our source and target properties coincide with existing id's in the nodes. This is necessary for D3-force to create the force graph.

const links = [
  {
    // node with id of home links to node with id of work
    source: "home",
    target: "work",
  },
  {
    // node with id of home links to node with id of blog
    source: "home",
    target: "blog",
  },
  // additional links...
];

Now that we understand the data, let’s hard code the links. Go to /src/data and create links.json. Next, copy and past the following the data into the file. You’ll notice that we’ve added a property called id. This will be passed into the key prop when we render the links with React.

links.json
[
  {
    "source": "home",
    "target": "work",
    "id": 0
  },
  {
    "source": "home",
    "target": "blog",
    "id": 1
  },
  {
    "source": "home",
    "target": "info",
    "id": 2
  },
  {
    "source": "home",
    "target": "contact",
    "id": 3
  }
]

As mentioned earlier, we already have the nodes data as it’s being used in our navbar. This structured data is crucial for D3-force to generate the force graph accurately. Now that we have the data, we can leverage React to render it.

Step 2: Render the Data with React

For our purposes, we'll leverage React to render the nodes, links, and labels. After they're rendered, we'll use D3.js to style them. Create Client Components for rendering the nodes (Circles.js), links (Lines.js), and labels (Labels.js).

Circles.js
"use client";

export default function Circles({ nodes, handleRoute }) {
  return nodes.map((node) => (
    <circle key={node.id} onClick={() => handleRoute(node)} />
  ));
}

The handleRoute prop that allows us to route to the page represented by the node when it is clicked on.

Lines.js
"use client";

export default function Lines({ links }) {
  return links.map((link) => <line key={link.id} />);
}
Labels.js
"use client";

export default function Labels({ nodes }) {
  return nodes.map((node) => (
    <text alignmentBaseline="middle" key={node.id}>
      {node.id}
    </text>
  ));
}

Render these components in the <svg> tags inside ForceGraph.js, import the nodes and links, and pass the data and handleRoute function into the corresponding props.

ForceGraph.js
"use client";
import { useRouter } from "next/navigation";

import nodes from "@/data/nodes";
import links from "@/data/links";

import Circles from "./Circles";
import Lines from "./Lines";
import Labels from "./Labels";

export default function ForceGraph({ width = 250, height = 250 }) {
  const router = useRouter();

  const handleRoute = (node) => {
    router.push(node.route);
  };

  return (
    <svg
      className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
      viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
    >
      <Lines links={links} />
      <Circles nodes={nodes} handleRoute={handleRoute} />
      <Labels nodes={nodes} />
    </svg>
  );
}

We're now using React to render the nodes, links, and labels. We can now leverage D3.js to simulate the force graph.

Step 3: Initialize Simulation

With D3-force, we’re essentially using a physics engine that positions our SVG elements depending on the forces acting on the simulation. In order to do this, we first need to initialize the simulation.

Go to /src/data/ and create a file called graph.config.json. We'll declare a set of constants for simulation configuration. This allows us to configure the forces and appearance of our components in one place.

graph.config.json
{
  "LINK_DISTANCE": 70,
  "LINK_STRENGTH": 1.5,
  "MANY_BODY_FORCE": -100,
  "X_FORCE": 0.05,
  "Y_FORCE": 0.05,
  "DEFAULT_NODE_RADIUS": 8,
  "CURRENT_NODE_RADIUS": 9,
  "HOVER_NODE_RADIUS": 10,
  "LINK_STROKE_WIDTH": 2,
  "LABEL_X": 10,
  "TRANSITION_LENGTH": 100
}

Next, navigate to src/utils/ and create a file called simulationHelpers.js. In this file, declare a function named initializeGraph. This function initializes the forces that will act on the nodes and links. It will accept a parameter called simulation. This parameter will be a variable provided to us by a useRef hook. Here are the reasons why it's best to use a useRef hook to store the simulation:

  1. The simulation will need to be mutated as we use the force graph and update it's values. This is the nature of D3.js.
  2. useRef doesn't trigger our component to rerender whenever the value changes.
  3. The value of simulation will persist across rerenders and need to be accessed by other functions inside our ForceGraph.js component.
simulationHelpers.js
import GRAPH_CONFIG from '@/data/graph.config';
import * as d3 from "d3";

function initializeGraph(simulation, d3) {
  const { 
    LINK_DISTANCE, 
    LINK_STRENGTH, 
    MANY_BODY_FORCE, 
    X_FORCE, 
    Y_FORCE 
  } = GRAPH_CONFIG;

  // simulation is provided to us by a useRef hook, cence the .current property
  simulation.current = d3
    .forceSimulation()
    .force("link", d3
      .forceLink()
      .id((link) => link.id)
      .distance(LINK_DISTANCE)
      .strength(LINK_STRENGTH)
    )
    .force("charge", d3
      .forceManyBody()
      .strength(MANY_BODY_FORCE)
    )
    .force("x", d3
      .forceX()
      .strength(X_FORCE)
    )
    .force("y", d3
      .forceY()
      .strength(Y_FORCE)
    )
    .alphaTarget(0);
}

export { initializeGraph };

Call initializeGraph in a useEffect inside ForceGraph.js. Don’t forget to create a simulation variable with useRef and pass it into initialzeGraph. We only need to initialize the simulation when the component mounts, so we’ll have an empty dependency array.

ForceGraph.js
"use client";
import { useRouter } from "next/navigation";
import { useRef, useEffect } from "react";

import nodes from "@/data/nodes";
import links from "@/data/links";

import { initializeGraph } from "@/utils/simulationHelpers";

import Circles from "./Circles";
import Lines from "./Lines";
import Labels from "./Labels";

export default function ForceGraph({ width = 250, height = 250 }) {
  const router = useRouter();
  const simulation = useRef();

  const handleRoute = (route) => {
    router.push(route);
  };

  useEffect(() => {
    initializeGraph(simulation);
  }, []);

  return (
    <svg
      className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
      viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
    >
      <Lines links={links} />
      <Circles nodes={nodes} handleRoute={handleRoute} />
      <Labels nodes={nodes} />
    </svg>
  );
}

Step 4: Bind Simulation Data to SVG Elements

Now that we have the simulation initialized, we can bind the data created by the simulation to the SVG elements rendered by React. We’ll be able to update the graph whenever the user routes to a new page.

Declare an updateGraph function in simulationHelpers.js. It will take four parameters: simulation, nodes, links, and currentPath(provided by the [usePathname](https://nextjs.org/docs/app/api-reference/functions/use-pathname) hook).

simulationHelpers.js
function updateGraph(simulation, nodes, links, currentPath) {
  const {
    CURRENT_NODE_RADIUS,
    DEFAULT_NODE_RADIUS,
    LINK_STROKE_WIDTH,
    LABEL_X,
  } = GRAPH_CONFIG;

  // passing in nodes and links into the simulation instance
  simulation.current.nodes(nodes);
  simulation.current.force("link").links(links);
  simulation.current.alpha(1).restart();

  // selecting SVG elements and binding data with .data()
  const nodeSelection = d3.selectAll("circle").data(nodes);
  const linkSelection = d3.selectAll("line").data(links);
  const labelSelection = d3.selectAll("text").data(nodes);

  // styling our selections
  nodeSelection
    .attr("fill", (node) => (node.route === currentPath ? "#333" : "#e1e1e1"))
    .attr("r", (node) =>
      node.route === currentPath ? CURRENT_NODE_RADIUS : DEFAULT_NODE_RADIUS
    );

  linkSelection
    .attr("stroke", "#e1e1e1")
    .attr("stroke-width", LINK_STROKE_WIDTH);

  labelSelection.attr("fill", "#000").style("font-size", ".75em");

  // "tick" event that updates positions of nodes and links as the simulation iterates
  simulation.current.on("tick", (d) => {
    nodeSelection.attr("cx", (node) => node.x).attr("cy", (node) => node.y);

    linkSelection
      .attr("x1", (d) => d.source.x)
      .attr("y1", (d) => d.source.y)
      .attr("x2", (d) => d.target.x)
      .attr("y2", (d) => d.target.y);

    labelSelection
      .attr("x", (node) => node.x + LABEL_X)
      .attr("y", (node) => node.y);
  });
}

export { 
  initializeGraph, 
  updateGraph 
};

The code in updateGraph is basic DOM selection and data binding using D3, as well as handling a “tick” event in the simulation. This allows us update the position of the nodes, links and labels when the simulation runs.

Call updateGraph in a useEffect inside ForceGraph.js. The useEffect will have currentPath as a dependency, since we want to update our graph whenever we navigate to a different page.

ForceGraph.js
"use client";
import { useRouter, usePathname } from "next/navigation";
import { useRef, useEffect } from "react";

import nodes from "@/data/nodes";
import links from "@/data/links";

import { initializeGraph, updateGraph } from "@/utils/simulationHelpers";

import Circles from "./Circles";
import Lines from "./Lines";
import Labels from "./Labels";

export default function ForceGraph({ width = 250, height = 250 }) {
  const router = useRouter();
  const simulation = useRef();
  const currentPath = usePathname();

  const handleRoute = (route) => {
    router.push(route);
  };

  useEffect(() => {
    initializeGraph(simulation);
  }, []);

  useEffect(() => {
    updateGraph(simulation, nodes, links, currentPath);
  }, [currentPath]);

  return (
    <svg
      className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
      viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
    >
      <Lines links={links} />
      <Circles nodes={nodes} handleRoute={handleRoute} />
      <Labels nodes={nodes} />
    </svg>
  );
}

At this point, you'll be able see and click on the graph to route to pages.

Step 5: Add D3 Event Handlers

Finally, we'll create drag and hover handlers.

In simulationHelpers.js Declare a a function called addD3EventHandlers. It will take two parameters: simulation, and currentPath.

simulationHelpers.js
function addD3EventHandlers(simulation, currentPath) {
  const {
    CURRENT_NODE_RADIUS,
    DEFAULT_NODE_RADIUS,
    HOVER_NODE_RADIUS,
    TRANSITION_LENGTH,
  } = GRAPH_CONFIG;

  // selecting SVG elements
  const nodeSelection = d3.selectAll("circle");
  const labelSelection = d3.selectAll("text");

  // drag event
  nodeSelection.call(
    d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended)
  );

  function dragstarted(event) {
    if (!event.active) simulation.current.alphaTarget(0.3).restart();
    event.subject.fx = event.subject.x;
    event.subject.fy = event.subject.y;
  }

  function dragged(event) {
    event.subject.fx = event.x;
    event.subject.fy = event.y;
  }

  function dragended(event) {
    if (!event.active) simulation.current.alphaTarget(0);
    event.subject.fx = null;
    event.subject.fy = null;
  }

  // mouse over event and styling logic
  nodeSelection.on("mouseover", (event, currentNode) => {
    d3.select(event.target).style("cursor", "pointer");

    nodeSelection
      .transition(TRANSITION_LENGTH)
      .attr("fill", (node) => (node.id === currentNode.id ? "#333" : "#e1e1e1"))
      .attr("r", (node) =>
        node.id === currentNode.id ? HOVER_NODE_RADIUS : DEFAULT_NODE_RADIUS
      );

    labelSelection
      .transition(TRANSITION_LENGTH)
      .style("font-size", (node) =>
        currentNode.id === node.id ? ".9em" : ".75em"
      );
  });

  // mouse out event and styling logic
  nodeSelection.on("mouseout", (event) => {
    d3.select(event.target).style("cursor", "pointer");

    nodeSelection
      .transition()
      .attr("fill", (node) => (node.route === currentPath ? "#333" : "#e1e1e1"))
      .attr("r", (node) =>
        node.route === currentPath ? CURRENT_NODE_RADIUS : DEFAULT_NODE_RADIUS
      );

    labelSelection
      .transition()
      .attr("fill", "#000")
      .style("font-size", ".75em");
  });
}

export { 
  initializeGraph,
  updateGraph,
  addD3EventHandlers
};

Call the addD3EventHandlers in a useEffect inside ForceGraph.js. The useEffect will have currentPath as a dependency, since we want to update our event handlers whenever we navigate to a different page.

ForceGraph.js
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useRef, useEffect } from "react";

import nodes from "@/data/nodes";
import links from "@/data/links";

import { initializeGraph, updateGraph, addD3EventHandlers } from "@/utils/simulationHelpers";

import Circles from "./Circles";
import Lines from "./Lines";
import Labels from "./Labels";

export default function ForceGraph({ width = 250, height = 250 }) {
  const currentPath = usePathname();
  const simulation = useRef();
  const router = useRouter();

  const handleRoute = (route) => {
    router.push(route);
  }

  useEffect(() => {
    initializeGraph(simulation)
  }, []);

  useEffect(() => {
    updateGraph(simulation, nodes, links, currentPath)
  }, [currentPath]);

  useEffect(() => {
    addD3EventHandlers(simulation, currentPath)
  }, [currentPath]);

  return (
    <svg
      className={`w-[${width}px] h-[${height}px] bg-white drop-shadow-lg rounded-md`}
      viewBox={`${-width / 2}, ${-height / 2}, ${width}, ${height}`}
    >
      <Lines links={links}/>
      <Circles nodes={nodes} handleRoute={handleRoute}/>
      <Labels nodes={nodes}/>
    </svg>
  );
}

Congratulations! You now have a functioning force graph to route through your website.

Conclusion and Final Notes

In conclusion, employing a force graph for website navigation not only establishes a unique and visually captivating interaction method for users, but also assists them in grasping the interconnected relationships among various pages. This tutorial serves a solid foundation for any applications you may have.

As cool as this UI is, I wouldn’t recommend this as the primary form of navigation due to SEO considerations. Next.js Link components are crucial for SEO, and they automatically prefetch routes for your website's client side routing.

Feel free to check my GitHub, Twitter, and LinkedIn. Any improvements? Open a pull request on my repository. Thank you for reading through my first ever blog post!