Implement force-directed graph in next js

1.5k views Asked by At

I'm trying to create a force-directed graph for mapping the interactions between courses in an institution. Using Next JS + TypeScript for my frontend.

Have tried several attempts at charting this out using react-flow, dagre, vis-network but am getting either a window : undefined error or just the damn alignment of nodes not being force-directed inside the box I have defined.

Before I move on with implementing d3-force right out of the box, can someone please recommend any alternative solution to this ?

Here's what my nodes & edges look like :

Here's my attempt with reactflow & dagre :

import React, { useCallback, useEffect, useState } from 'react';
import ReactFlow, {
  addEdge,
  useNodesState,
  useEdgesState,
  Edge,
  Node,
  Position,
  ConnectionLineType,
  ReactFlowProvider,
  MiniMap,
  Controls,
  Background,
} from 'react-flow-renderer';
import dagre from 'dagre';
import { NodeData, useCourseNodes } from 'src/hooks/useCourseNodes';
import { useDepartment } from '@contexts/ActiveDepartmentContext';
import {
  useUpdateActiveCourse,
} from '@contexts/ActiveCourseContext';
import { useDrawerOpen, useUpdateDrawerOpen } from '@contexts/DrawerContext';

const dagreGraph = new dagre.graphlib.Graph({directed:true});
dagreGraph.setDefaultEdgeLabel(() => ({}));

const nodeWidth = 10.2;
const nodeHeight = 6.6;

const getLayoutedElements = (
  nodes: Node[],
  edges:Edge[],
) => {
    // const isHorizontal = direction === 'LR';
    dagreGraph.setGraph( {width:900, height:900, nodesep:20, ranker:'longest-path' });

    nodes.forEach((node: Node) => {
      dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight  });
    });

    edges.forEach((edge: Edge) => {
      dagreGraph.setEdge(edge.source, edge.target);
    });

    dagre.layout(dagreGraph);

    nodes.forEach((node) => {
      const nodeWithPosition = dagreGraph.node(node.id);
      // node.targetPosition = isHorizontal ? Position.Left : Position.Top;
      // node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
      node.targetPosition = Position.Top;
      node.sourcePosition = Position.Bottom;

      // We are shifting the dagre node position (anchor=center center) to the top left
      // so it matches the React Flow node anchor point (top left).
      node.position = {
        x: nodeWithPosition.x - nodeWidth / 2,
        y: nodeWithPosition.y - nodeHeight / 2,
      };
      console.log(nodeWithPosition)

      return node;
    })
  return { layoutedNodes:nodes, layoutedEdges:edges };
};

const LayoutFlow = () => {
  const activeDept = useDepartment();
  const setActiveCourse = useUpdateActiveCourse();
  const setDrawerOpen = useUpdateDrawerOpen()
  const drawerOpen = useDrawerOpen();
  const {courseList, edgeList} = useCourseNodes()
  const { layoutedNodes, layoutedEdges } = getLayoutedElements(courseList, edgeList)
  const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
  const [edges, setEdges,onEdgesChange] = useEdgesState(layoutedEdges);
  console.log(layoutedNodes)

  const onConnect = useCallback(
    (params) =>
      setEdges((eds) =>
        addEdge({ ...params, type: ConnectionLineType.SimpleBezier, animated: true }, eds),
      ),
    [],
  );

  // ? For switching between layouts (horizontal & vertical) for phone & desktop
  // const onLayout = useCallback(
  //   (direction) => {
  //     const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
  //       nodes,
  //       edges,
  //       direction
  //     );

  //     setNodes([...layoutedNodes]);
  //     setEdges([...layoutedEdges]);
  //   },
  //   [nodes, edges]
  // );

  // ? M1 - for force re-rendering react flow graph on state change - https://github.com/wbkd/react-flow/issues/1168
  // ? M2 - (Applied currently in useEffect block below)for force re-rendering react flow graph on state change - https://github.com/wbkd/react-flow/issues/1168
  useEffect(() => {
    const {layoutedNodes, layoutedEdges} = getLayoutedElements(courseList, edgeList)
    setNodes([...layoutedNodes]);
    setEdges([...layoutedEdges]);
  }, [activeDept, drawerOpen]);
  return (
    <div style={{ width: '100%', height: '100%' }} className="layoutflow">
      <ReactFlowProvider>
        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          onNodeClick={(e: React.MouseEvent, node: Node<NodeData>) => {
            e.preventDefault();
            // created a copy of the node since we're only deleting the "label" property from the node object to conveniently map the rest of the data to the "data" property of the active course
            const nodeCopy = JSON.parse(JSON.stringify(node))
            const { data } = nodeCopy;
            const { label } = data
            delete data.label
            setActiveCourse({
              courseId: label,
              data
            });
            setDrawerOpen(true);
          }}
          connectionLineType={ConnectionLineType.SimpleBezier}
          fitView
        >
          <MiniMap />
          <Controls />
          {/* <Background /> */}
        </ReactFlow>
      </ReactFlowProvider>
      <div className="controls">
        {/* <button onClick={() => onLayout('TB')}>vertical layout</button>
        <button onClick={() => onLayout('LR')}>horizontal layout</button> */}
      </div>
    </div>
  );
};

export default LayoutFlow;

Here's my attempt with vis-network : (note : I did slightly modify edges to have from-to instead of source-target when working with this)

import { useCourseNodes } from "@hooks/useCourseNodes";
import React, { useEffect, useRef } from "react";
import { Network } from "vis-network";

const GraphLayoutFour: React.FC = () => {
  const {courseList:nodes, edgeList:edges} = useCourseNodes()
    // Create a ref to provide DOM access
    const visJsRef = useRef<HTMLDivElement>(null);
    useEffect(() => {
        const network =
            visJsRef.current &&
            new Network(visJsRef.current, { nodes, edges } );
        // Use `network` here to configure events, etc
    }, [visJsRef, nodes, edges]);
    return typeof window !== "undefined" ? <div ref={visJsRef} /> : <p>NOT AVAILABLE</p>;
};

export default GraphLayoutFour;

Here's my attempt with react-sigma

import React, { ReactNode, useEffect, useState } from "react";
import ReactDOM from "react-dom";
import { UndirectedGraph } from "graphology";
import erdosRenyi from "graphology-generators/random/erdos-renyi";
import randomLayout from "graphology-layout/random";
import chroma from "chroma-js";

import { Attributes } from "graphology-types";
import { ControlsContainer, ForceAtlasControl, SearchControl, SigmaContainer, useLoadGraph, useRegisterEvents, useSetSettings, useSigma, ZoomControl } from "react-sigma-v2/lib/esm";

interface MyCustomGraphProps {
  children?: ReactNode;
}

export const MyCustomGraph: React.FC<MyCustomGraphProps> = ({ children }) => {
  const sigma = useSigma();
  const registerEvents = useRegisterEvents();
  const loadGraph = useLoadGraph();
  const setSettings = useSetSettings();
  const [hoveredNode, setHoveredNode] = useState<any>(null);

  useEffect(() => {
    // Create the graph
    const graph = erdosRenyi(UndirectedGraph, { order: 100, probability: 0.2 });
    randomLayout.assign(graph);
    graph.nodes().forEach(node => {
    graph.mergeNodeAttributes(node, {
        label: "label",
        size: Math.max(4, Math.random() * 10),
        color: chroma.random().hex(),
      });
    });
    loadGraph(graph);

    // Register the events
    registerEvents({
      enterNode: event => setHoveredNode(event.node),
      leaveNode: () => setHoveredNode(null),
    });
  }, []);

  useEffect(() => {
    setSettings({
      nodeReducer: (node, data) => {
        const graph = sigma.getGraph();
        const newData: Attributes = { ...data, highlighted: data.highlighted || false };

        if (hoveredNode) {
          //TODO : add type safety
          if (node === hoveredNode || (graph as any).neighbors(hoveredNode).includes(node)) {
            newData.highlighted = true;
          } else {
            newData.color = "#E2E2E2";
            newData.highlighted = false;
          }
        }
        return newData;
      },
      edgeReducer: (edge, data) => {
        const graph = sigma.getGraph();
        const newData = { ...data, hidden: false };

        //TODO : add type safety
        if (hoveredNode && !(graph as any).extremities(edge).includes(hoveredNode)) {
          newData.hidden = true;
        }
        return newData;
      },
    });
  }, [hoveredNode]);

  return <>{children}</>;
};

ReactDOM.render(
  <React.StrictMode>
    <SigmaContainer>
      <MyCustomGraph />
      <ControlsContainer position={"bottom-right"}>
        <ZoomControl />
        <ForceAtlasControl autoRunFor={2000} />
      </ControlsContainer>
      <ControlsContainer position={"top-right"}>
        <SearchControl />
      </ControlsContainer>
    </SigmaContainer>
  </React.StrictMode>,
  document.getElementById("root"),
);
import { useCourseNodes } from '@hooks/useCourseNodes'
import dynamic from 'next/dynamic';
import React from 'react'
import { useSigma } from 'react-sigma-v2/lib/esm';

const GraphLayoutThree = () => {
  const isBrowser = () => typeof window !== "undefined"
  const { courseList, edgeList } = useCourseNodes()
  const sigma = useSigma();
    if(isBrowser) {
    const SigmaContainer = dynamic(import("react-sigma-v2").then(mod => mod.SigmaContainer), {ssr: false});
    const MyGraph = dynamic(import("./CustomGraph").then(mod => mod.MyCustomGraph), {ssr: false});
    return (
      <SigmaContainer style={{ height: "500px", width: "500px" }} >
        <MyGraph/>
      </SigmaContainer>
    )
  }
  else return (<p>NOT AVAILABLE</p>)
}

export default GraphLayoutThree

Here's my attempt with react-force-graph (note : I did slightly modify edges to have from-to instead of source-target when working with this)

import dynamic from "next/dynamic";

const GraphLayoutTwo = () => {
  const isBrowser = () => typeof window !== "undefined"
    if(isBrowser) {
    const MyGraph = dynamic(import("./CustomGraphTwo").then(mod => mod.default), {ssr: false});
    return (
        <MyGraph/>
    )
  }
  else return (<p>NOT AVAILABLE</p>)
}

export default GraphLayoutTwo

import dynamic from "next/dynamic";

const GraphLayoutTwo = () => {
  const isBrowser = () => typeof window !== "undefined"
    if(isBrowser) {
    const MyGraph = dynamic(import("./CustomGraphTwo").then(mod => mod.default), {ssr: false});
    return (
        <MyGraph/>
    )
  }
  else return (<p>NOT AVAILABLE</p>)
}

export default GraphLayoutTwo

1

There are 1 answers

3
dna On

To implement something similar we use react-graph-vis inside a nextjs application.

If you have the window is not defined error, just wrap the component and import it with dynamic

// components/graph.tsx

export const Graph = ({data, options, events, ...props}) => {

      return (
              <GraphVis
                graph={transformData(data)}
                options={options}
                events={events}
              />

      )
}

then in your page

// pages/index.ts

const Graph = dynamic(() => (import("../components/graph").then(cmp => cmp.Graph)), { ssr: false })

const Index = () => {

    return (
          <>
             <Graph data={...} .... />
          </>
    )

}

export default Index;