Search code examples
typescriptd3.jsgraphsolid-js

D3 Force Graph in SolidJS with TypeScript


D3 seems so complicated to make work with basically any framework... Specially when it comes to TypeScript, since they apparently keep changing their APIs, rendering so many past examples from gists and other posts close to useless.

At the moment, I would like to make work with TypeScript and SolidJS the canonical force graph example.

I feel like trying to solve a jigsaw puzzle since a lot of the types just won't match. The one I've had the most trouble with was the drag() arguments, I believe. I've also tried simply ignoring types, of course, but I couldn't make it work either.

Also, does anyone know if it is possible to append any arbitrary component as a graph node?


References

  1. D3 - Force Directed Graph
  2. CM-Tech's D3 Graph for a SolidJS Debugger (in TypeScript)
  3. Easily show relationships — Draw Simple Force Graph with React & d3 utilizing TypeScript
  4. Building D3 interactive network graph D3 Force-Simulation + React + TypeScript
  5. mbostock's Gist
  6. steveharoz's Gist with Full Controls
  7. YouTube - React Tutorials & Tips For ReactJs, d3 Developers - Integrating d3.js with React - Force Chart - Force network graph - Intro and setting up
  8. YouTube - React Tutorials & Tips For ReactJs, d3 Developers - Integrating d3.js with React - Force Chart - Force network graph - Network graph graphics

Solution

  • To create a FDG (Force Directed Graph) like the one in the docs example using SolidJS, there's very little that's SolidJS specific, it's mostly about knowing how d3 works and what function invocation does what and through that one knows where to do what (whether to use a side effect, or do something on mounting etc., which is what I'm referring to as "where").

    Issues with Typescript

    Most of the methods make use of typescript generics and infer the types from the arguments, however, at times it's better to just add the type parameters. To not to have issues with types, here's a little tip -

    Always check the return, parameter types of the function calls, go to library source if you need to and then add the typings accordingly

    You had issues with the d3.drag because by default, the generics take an unknown type (unless they're assigned a default type), if you're explicit in the call with the type parameters, ts doesn't complain.

    FDG Docs Example with SolidJS

    Here's how you can create a graph like the one in example -

    import * as d3 from "d3";
    import { onMount } from "solid-js";
    
    interface FDGNode extends d3.SimulationNodeDatum {
      id: string;
      group: number;
    }
    
    interface FDGLink extends d3.SimulationLinkDatum<FDGNode> {
      value: number;
    }
    
    export type FDGData = {
      nodes: Array<FDGNode>;
      links: Array<FDGLink>;
    };
    
    let svgRef: SVGSVGElement | undefined;
    
    export default function ForceDirectedGraph(props: {
      children?: never;
      data: FDGData;
    }) {
      const width = 928;
      const height = 600;
      const color = d3.scaleOrdinal(d3.schemeCategory10);
    
      const links = props.data.links.map((d) => ({ ...d }));
      const nodes = props.data.nodes.map((d) => ({ ...d }));
    
      onMount(() => {
        if (svgRef) {
          const simulation = d3
            .forceSimulation(nodes)
            .force(
              "link",
              d3.forceLink<FDGNode, FDGLink>(links).id((d) => d.id)
            )
            .force("charge", d3.forceManyBody())
            .force("center", d3.forceCenter(width / 2, height / 2));
    
          const link = d3
            .select<SVGSVGElement, FDGNode>(svgRef)
            .selectAll<SVGLineElement, FDGLink>("line")
            .data(links);
    
          const node = d3
            .select<SVGSVGElement, FDGNode>(svgRef)
            .selectAll<SVGCircleElement, FDGNode>("circle")
            .data(nodes);
    
          function ticked() {
            link
    // The source property comes from the SimulationLinkDatum interface, and by default,
    // has a type of TypeOfNode | string | number, where TypeOfNode is FDGNode in this example
    // so we need to make an as assertion. The x,y properties added by the SimulationNodeDatum
    // interface are of type number | undefined, so a non-null assertion operator is used
              .attr("x1", (d) => (d.source as FDGNode).x!)  
              .attr("y1", (d) => (d.source as FDGNode).y!)
              .attr("x2", (d) => (d.target as FDGNode).x!)
              .attr("y2", (d) => (d.target as FDGNode).y!);
    
            node.attr("cx", (d) => d.x!).attr("cy", (d) => d.y!);
          }
          simulation.on("tick", ticked);
    
          function dragstarted(
            event: d3.D3DragEvent<SVGCircleElement, FDGNode, FDGNode>
          ) {
            if (!event.active) simulation.alphaTarget(0.3).restart();
            event.subject.fx = event.subject.x;
            event.subject.fy = event.subject.y;
          }
    
          function dragged(
            event: d3.D3DragEvent<SVGCircleElement, FDGNode, FDGNode>
          ) {
            event.subject.fx = event.x;
            event.subject.fy = event.y;
          }
    
          function dragended(
            event: d3.D3DragEvent<SVGCircleElement, FDGNode, FDGNode>
          ) {
            if (!event.active) simulation.alphaTarget(0);
            event.subject.fx = null;
            event.subject.fy = null;
          }
    
          node.call(
            d3
              .drag<SVGCircleElement, FDGNode>()
              .on("start", dragstarted)
              .on("drag", dragged)
              .on("end", dragended)
          );
        }
      });
    
      return (
        <svg
          ref={svgRef}
          width={width}
          height={height}
          viewBox={`0 0 ${width} ${height}`}
          style={{
            "max-width": "100%",
            height: "auto"
          }}
        >
          <g stroke="#999" stroke-opacity={0.6}>
            {links.map((link) => {
              return <line stroke-width={Math.sqrt(link.value)} />;
            })}
          </g>
          <g stroke={"#fff"} stroke-width={1.5}>
            {nodes.map((node) => (
              <circle r={5} fill={color(`${node.group}`)}>
                <title>{node.id}</title>
              </circle>
            ))}
          </g>
        </svg>
      );
    }
    
    

    Graph Nodes

    Once a simulation is rendered (in the particular example from the docs, using an svg container) the interaction inside of the FDG is handled by d3 only, so not much is required in terms of re-rendering from SolidJS. Since it's an svg container, it can only have svg elements as children.

    However, it's entirely possible to be able to use any arbitrary component as a graph node, for that, one will have to not use an svg container (unless you're willing to use path elements to draw paths, then use an svg container), so maybe a div container? With that, the configuration for all the interactions has to be done by adding all the different handlers, a tad bit like the CM-Tech's D3 Graph for a SolidJS Debugger (in TypeScript) example that you've linked in your question. Check this question for more details.