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
Comments