top of page
Writer's pictureThe Tech Platform

Building D3 interactive network graph D3 Force-Simulation + React + TypeScript

We can build very powerful network graphs using D3 Force Simulation library.


In this blog, I have done same exercise but with React.js and TypeScript. Before we start, checkout the running application to see what we are trying to build. Interaction graph



Let’s go!

1. Create new react application with TypeScript

npx create-react-app d3-network-graph-editor --template typescript

2. Add D3 package in application

yarn add d3

3. Add D3 types for IDE

yarn add d3 @types/d3

4. Run application to test everything is good so far.

yarn build

5. Let’s add code for our D3 network graph. First, we will create a SVG element reference and pass it to D3 for rendering graph and other class members for graph manipulation.

class App extends Component{
    svgRef: React.RefObject<any>;
    dataset = new Array<number>();
    width=960;
    height=500;
    colors=d3.scaleOrdinal(d3.schemeCategory10);
    
    svg: any;
    
    nodes = new Array<any>();
    lastNodeId: number=0;
    links = new Array<any>();
    force: any;
    drag: any;
    dragLine: any;
    path: any;
    circle: any;
    
    // mouse event vars
    selectedNode: any=null;
    selectedLink: any=null;
    mousedownLink: any=null;
    mousedownNode: any=null;
    mouseupNode: any=null;
    
    // only respond once per keydown


6. In constructor function we will initialise SVG reference.

constructor(props: any){
    super(props);
    this.svgRef = React.createRef();
}


7. The render() function would have a simple div with svgRef binding.


render(){
    return(
        <divref={this.svgRef}></div>
    )
}


8. In componentDidMount() function, we will setup for our graph. We shall set size for SVG, setup a default nodes and links, force-simulation setup, drag feature setup, mouse pointers and mouse and keyboard event handlers etc.

componentDidMount(){
    let size=500;
    this.svg=d3.select(this.svgRef.current)
        .append("svg").attr("width",this.width)
        .attr("height",this.height)
        .on('contextmenu',(event,d)=>{event.preventDefault();})
    
    // set up initial nodes and links
    //  - nodes are known by 'id', not by index in array.
    //  - reflexive edges are indicated on the node (as a bold black 
          circle).
    //  - links are always source < target; edge directions are set by 
          'left' and 'right'.
    this.nodes=[
        {id: 0,reflexive: false},
        {id: 1,reflexive: true},
        {id: 2,reflexive: false}
    ];
    
    this.lastNodeId=2;
    
    this.links=[
        {source: this.nodes[0],target: this.nodes[1],left: false,right: 
                                                                true},
        {source: this.nodes[1],target: this.nodes[2],left: false,right: 
                                                                true}];
        
    // init D3 force layout
    this.force=d3.forceSimulation()
        .force('link',d3.forceLink().id((d: any)=>d.id).distance(150))
        .force('charge',d3.forceManyBody().strength(-500))
        .force('x',d3.forceX(this.width/2))
        .force('y',d3.forceY(this.height/2))
        .on('tick',()=>this.tick());
        
    // init D3 drag support
    this.drag=d3.drag()
        // Mac Firefox doesn't distinguish between left/right click when Ctrl is held... 
        .filter((event, d)=>event.button===0||event.button===2)
        .on('start',(event, d: any)=>{
            if(!event.active)this.force.alphaTarget(0.3).restart();
            
            d.fx=d.x;
            d.fy=d.y;
        })
        .on('drag',(event,d: any)=>{
            d.fx=event.x;
            d.fy=event.y;
        })
        .on('end',(event,d: any)=>{
            if(!event.active)this.force.alphaTarget(0);
            
            d.fx=null;
            d.fy=null;
        });
        
     // define arrow markers for graph links
     this.svg.append('svg:defs').append('svg:marker')
         .attr('id','end-arrow')
         .attr('viewBox','0 -5 10 10')
         .attr('refX',6)
         .attr('markerWidth',3)
         .attr('markerHeight',3)
         .attr('orient','auto')
         .append('svg:path')
         .attr('d','M0,-5L10,0L0,5')
         .attr('fill','#000');
         
    this.svg.append('svg:defs').append('svg:marker')
        .attr('id','start-arrow')
        .attr('viewBox','0 -5 10 10')
        .attr('refX',4)
        .attr('markerWidth',3)
        .attr('markerHeight',3)
        .attr('orient','auto')
        .append('svg:path')
        .attr('d','M10,-5L0,0L10,5')
        .attr('fill','#000');
    
    // line displayed when dragging new nodes
    this.dragLine=this.svg.append('svg:path')
        .attr('class','link dragline hidden')
        .attr('d','M0,0L0,0');
        
    // handles to link and node element groups
    this.path=this.svg.append('svg:g').selectAll('path');
    this.circle=this.svg.append('svg:g').selectAll('g');
    
    // app starts here
    this.svg.on('mousedown',(event: any,d: 
                                    any)=>this.mousedown(event,d))
        .on('mousemove',(event: any,d: any)=>this.mousemove(event,d))
        .on('mouseup',(event: any,d: any)=>this.mouseup(event,d));
    
    d3.select(window)
        .on('keydown',(event: any,d: any)=>this.keydown(event,d))
        .on('keyup',(event: any,d: any)=>this.keyup(event,d));
    
    this.restart();
}


9. Once setup is done, restart() method gets called which would be our starting point. This method redraws the graph by removing previous edges and nodes, adding new edges, showing drag lines, showing text for each node, setting an arrow direction etc.

restart(){
    // path (link) group
    this.path=this.path.data(this.links);
    
    // update existing links
    this.path.classed('selected',(d: any)=>d===this.selectedLink)
     .style('marker-start',(d: any)=>d.left ? 'url(#start-arrow)' : '')
     .style('marker-end',(d: any)=>d.right ? 'url(#end-arrow)' : '');
     
     // remove old links
     this.path.exit().remove();
     
     // add new links
     this.path=this.path.enter().append('svg:path')
      .attr('class','link')
      .classed('selected',(d: any)=>d===this.selectedLink)
      .style('marker-start',(d: any)=>d.left ? 'url(#start-arrow)': '')
      .style('marker-end',(d: any)=>d.right ? 'url(#end-arrow)' : '')
      .on('mousedown',(event: any,d: any)=>{
        if(event.ctrlKey) return;
     
       // select link
       this.mousedownLink=d;
       this.selectedLink=(this.mousedownLink===this.selectedLink) ? 
                                   null : this.mousedownLink;
       this.selectedNode=null;
       this.restart();
    })
    .merge(this.path);
    
    // circle (node) group
    // NB: the function arg is crucial here! nodes are known by id, not 
    by index!
    this.circle=this.circle.data(this.nodes,(d: any)=>d.id);
    
    // update existing nodes (reflexive & selected visual states)
    this.circle.selectAll('circle')
     .style('fill',(d: any)=>(d===this.selectedNode) ? d3.rgb(this.colors(d.id)).brighter().toString() : this.colors(d.id))
     .classed('reflexive',(d: any)=>d.reflexive);
     
     // remove old nodes
     this.circle.exit().remove();
     
     // add new nodes
     const g=this.circle.enter().append('svg:g');
     
     g.append('svg:circle')
      .attr('class','node')
      .attr('r',12)
      .style('fill',(d: any)=>(d===this.selectedNode) ? d3.rgb(this.colors(d.id)).brighter().toString() : this.colors(d.id))
      .style('stroke',(d: any)=>d3.rgb(this.colors(d.id)).darker().toString())
      .classed('reflexive',(d: any)=>d.reflexive)
      .on('mouseover',(event: any,d: any)=>{
       if(!this.mousedownNode||d===this.mousedownNode) return;
       // enlarge target node
       d3.select(event.currentTarget).attr('transform','scale(1.1)');
     })
     .on('mouseout',(event: any,d: any)=>{
      if(!this.mousedownNode||d===this.mousedownNode)return;
      // unenlarge target node
      d3.select(event?.currentTarget).attr('transform','');
    })
    .on('mousedown',(event: any,d: any)=>{
      if(event.ctrlKey) return;
      
      // select node
      this.mousedownNode=d;
      this.selectedNode=(this.mousedownNode===this.selectedNode) ? null : this.mousedownNode;
      this.selectedLink=null;
      
      // reposition drag line
      this.dragLine
       .style('marker-end','url(#end-arrow)')
       .classed('hidden',false)
       .attr('d',`M${this.mousedownNode.x},${this.mousedownNode.y}
               L${this.mousedownNode.x},${this.mousedownNode.y}`);
       
      this.restart();
   })
   .on('mouseup',(event: any,d: any)=>{
    if(!this.mousedownNode) return;
    
    // needed by FF
    this.dragLine
     .classed('hidden',true)
     .style('marker-end','');
     
    // check for drag-to-self
    this.mouseupNode=d;
    if(this.mouseupNode===this.mousedownNode){
      this.resetMouseVars();
      return;
    }
    
    // unenlarge target node
    d3.select(event.currentTarget).attr('transform','');
    
    // add link to graph (update if exists)
    // NB: links are strictly source < target; arrows separately specified by booleans
    const isRight=this.mousedownNode.id<this.mouseupNode.id;
    const source=isRight ? this.mousedownNode : this.mouseupNode;
    const target=isRight ? this.mouseupNode : this.mousedownNode;
    
    let link=this.links.filter((l: 
              any)=>l.source===source&&l.target===target)[0];
    if(link){
       link[isRight ? 'right' : 'left']=true;}
    else{
       this.links.push({ source, target,left: !isRight,right: 
                                 isRight});
    }
       
    // select new link
    this.selectedLink=link;
    this.selectedNode=null;
    this.restart();
   });
   
   // show node IDs
   g.append('svg:text')
      .attr('x',0)
      .attr('y',4)
      .attr('class','id')
      .text((d: any)=>d.id);


10. These are the important functions in our code base. If you go through rest of the functions then you would find they are doing specific operations like tick() for calculating new line being drawn on drag, mousedown(), mouseup(), mousemove() for handling mouse events for adding new node, selection, drag etc and keydown() for handling keyboard shortcuts for changing arrow directions, deleting selected node etc.


11. Finally CSS, we would like to see a nice looking network graph, won’t we? In App.css, we would add these CSS classes.

svg {
    background-color:#FFF;
    cursor: default;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    -o-user-select: none;
    user-select: none;
}

svg:not(.active):not(.ctrl) {
    cursor: crosshair;
}

path.link {
    fill: none;
    stroke:#000;
    stroke-width:4px;
    cursor: default;
}

svg:not(.active):not(.ctrl) path.link {
    cursor: pointer;
}

path.link.selected {
    stroke-dasharray:10,2;
}

path.link.dragline {
    pointer-events: none;
}

path.link.hidden {
    stroke-width:0;
}

circle.node {
    stroke-width:1.5px;
    cursor: pointer;
}

circle.node.reflexive {
    stroke:#000 !important;
    stroke-width:2.5px;
}

text {
    font:12px sans-serif;
    pointer-events: none;
}


12. Let’s test final output by saving all changes made so far and run yarn start if not running already. We should see a graph in action with default nodes and edges. We can add new node by mouse click, connect nodes by dragging them to target, move position of node by Ctrl + drag, change arrow direction of an edge by selecting it and pressing L or R keyboard button, delete node by pressing Del keyboard button etc.


13. You might have noticed that code is not very TypeScript friendly and lots of variables are of type any. In next iteration, I would refactor and try to use right D3 types rather but feel free to submit PR if you are faster than me :) !



Source: Medium - Balram Chavan


The Tech Platform

0 comments

Comments


bottom of page