Skip to content

Instantly share code, notes, and snippets.

@cjrd
Last active September 28, 2023 18:17
Show Gist options
  • Star 25 You must be signed in to star a gist
  • Fork 16 You must be signed in to fork a gist
  • Save cjrd/6863459 to your computer and use it in GitHub Desktop.
Save cjrd/6863459 to your computer and use it in GitHub Desktop.
Interactive tool for creating directed graphs using d3.js.

directed-graph-creator

Interactive tool for creating directed graphs, created using d3.js.

Demo: http://bl.ocks.org/cjrd/6863459

Operation:

  • drag/scroll to translate/zoom the graph
  • shift-click on graph to create a node
  • shift-click on a node and then drag to another node to connect them with a directed edge
  • shift-click on a node to change its title
  • click on node or edge and press backspace/delete to delete

Run:

Github repo is at https://github.com/metacademy/directed-graph-creator

License: MIT/X

body{
margin: 0;
padding: 0;
overflow:hidden;
}
p{
text-align: center;
overflow: overlay;
position: relative;
}
body{
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-color: rgb(248, 248, 248)
}
#toolbox{
position: absolute;
bottom: 0;
left: 0;
margin-bottom: 0.5em;
margin-left: 1em;
border: 2px solid #EEEEEE;
border-radius: 5px;
padding: 1em;
z-index: 5;
}
#toolbox input{
width: 30px;
opacity: 0.4;
}
#toolbox input:hover{
opacity: 1;
cursor: pointer;
}
#hidden-file-upload{
display: none;
}
#download-input{
margin: 0 0.5em;
}
.conceptG text{
pointer-events: none;
}
marker{
fill: #333;
}
g.conceptG circle{
fill: #F6FBFF;
stroke: #333;
stroke-width: 2px;
}
g.conceptG:hover circle{
fill: rgb(200, 238, 241);
}
g.selected circle{
fill: rgb(250, 232, 255);
}
g.selected:hover circle{
fill: rgb(250, 232, 255);
}
path.link {
fill: none;
stroke: #333;
stroke-width: 6px;
cursor: default;
}
path.link:hover{
stroke: rgb(94, 196, 204);
}
g.connect-node circle{
fill: #BEFFFF;
}
path.link.hidden{
stroke-width: 0;
}
path.link.selected {
stroke: rgb(229, 172, 247);
}
document.onload = (function(d3, saveAs, Blob, undefined){
"use strict";
// define graphcreator object
var GraphCreator = function(svg, nodes, edges){
var thisGraph = this;
thisGraph.idct = 0;
thisGraph.nodes = nodes || [];
thisGraph.edges = edges || [];
thisGraph.state = {
selectedNode: null,
selectedEdge: null,
mouseDownNode: null,
mouseDownLink: null,
justDragged: false,
justScaleTransGraph: false,
lastKeyDown: -1,
shiftNodeDrag: false,
selectedText: null
};
// define arrow markers for graph links
var defs = svg.append('svg:defs');
defs.append('svg:marker')
.attr('id', 'end-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', "32")
.attr('markerWidth', 3.5)
.attr('markerHeight', 3.5)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5');
// define arrow markers for leading arrow
defs.append('svg:marker')
.attr('id', 'mark-end-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 7)
.attr('markerWidth', 3.5)
.attr('markerHeight', 3.5)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5');
thisGraph.svg = svg;
thisGraph.svgG = svg.append("g")
.classed(thisGraph.consts.graphClass, true);
var svgG = thisGraph.svgG;
// displayed when dragging between nodes
thisGraph.dragLine = svgG.append('svg:path')
.attr('class', 'link dragline hidden')
.attr('d', 'M0,0L0,0')
.style('marker-end', 'url(#mark-end-arrow)');
// svg nodes and edges
thisGraph.paths = svgG.append("g").selectAll("g");
thisGraph.circles = svgG.append("g").selectAll("g");
thisGraph.drag = d3.behavior.drag()
.origin(function(d){
return {x: d.x, y: d.y};
})
.on("drag", function(args){
thisGraph.state.justDragged = true;
thisGraph.dragmove.call(thisGraph, args);
})
.on("dragend", function() {
// todo check if edge-mode is selected
});
// listen for key events
d3.select(window).on("keydown", function(){
thisGraph.svgKeyDown.call(thisGraph);
})
.on("keyup", function(){
thisGraph.svgKeyUp.call(thisGraph);
});
svg.on("mousedown", function(d){thisGraph.svgMouseDown.call(thisGraph, d);});
svg.on("mouseup", function(d){thisGraph.svgMouseUp.call(thisGraph, d);});
// listen for dragging
var dragSvg = d3.behavior.zoom()
.on("zoom", function(){
if (d3.event.sourceEvent.shiftKey){
// TODO the internal d3 state is still changing
return false;
} else{
thisGraph.zoomed.call(thisGraph);
}
return true;
})
.on("zoomstart", function(){
var ael = d3.select("#" + thisGraph.consts.activeEditId).node();
if (ael){
ael.blur();
}
if (!d3.event.sourceEvent.shiftKey) d3.select('body').style("cursor", "move");
})
.on("zoomend", function(){
d3.select('body').style("cursor", "auto");
});
svg.call(dragSvg).on("dblclick.zoom", null);
// listen for resize
window.onresize = function(){thisGraph.updateWindow(svg);};
// handle download data
d3.select("#download-input").on("click", function(){
var saveEdges = [];
thisGraph.edges.forEach(function(val, i){
saveEdges.push({source: val.source.id, target: val.target.id});
});
var blob = new Blob([window.JSON.stringify({"nodes": thisGraph.nodes, "edges": saveEdges})], {type: "text/plain;charset=utf-8"});
saveAs(blob, "mydag.json");
});
// handle uploaded data
d3.select("#upload-input").on("click", function(){
document.getElementById("hidden-file-upload").click();
});
d3.select("#hidden-file-upload").on("change", function(){
if (window.File && window.FileReader && window.FileList && window.Blob) {
var uploadFile = this.files[0];
var filereader = new window.FileReader();
filereader.onload = function(){
var txtRes = filereader.result;
// TODO better error handling
try{
var jsonObj = JSON.parse(txtRes);
thisGraph.deleteGraph(true);
thisGraph.nodes = jsonObj.nodes;
thisGraph.setIdCt(jsonObj.nodes.length + 1);
var newEdges = jsonObj.edges;
newEdges.forEach(function(e, i){
newEdges[i] = {source: thisGraph.nodes.filter(function(n){return n.id == e.source;})[0],
target: thisGraph.nodes.filter(function(n){return n.id == e.target;})[0]};
});
thisGraph.edges = newEdges;
thisGraph.updateGraph();
}catch(err){
window.alert("Error parsing uploaded file\nerror message: " + err.message);
return;
}
};
filereader.readAsText(uploadFile);
} else {
alert("Your browser won't let you save this graph -- try upgrading your browser to IE 10+ or Chrome or Firefox.");
}
});
// handle delete graph
d3.select("#delete-graph").on("click", function(){
thisGraph.deleteGraph(false);
});
};
GraphCreator.prototype.setIdCt = function(idct){
this.idct = idct;
};
GraphCreator.prototype.consts = {
selectedClass: "selected",
connectClass: "connect-node",
circleGClass: "conceptG",
graphClass: "graph",
activeEditId: "active-editing",
BACKSPACE_KEY: 8,
DELETE_KEY: 46,
ENTER_KEY: 13,
nodeRadius: 50
};
/* PROTOTYPE FUNCTIONS */
GraphCreator.prototype.dragmove = function(d) {
var thisGraph = this;
if (thisGraph.state.shiftNodeDrag){
thisGraph.dragLine.attr('d', 'M' + d.x + ',' + d.y + 'L' + d3.mouse(thisGraph.svgG.node())[0] + ',' + d3.mouse(this.svgG.node())[1]);
} else{
d.x += d3.event.dx;
d.y += d3.event.dy;
thisGraph.updateGraph();
}
};
GraphCreator.prototype.deleteGraph = function(skipPrompt){
var thisGraph = this,
doDelete = true;
if (!skipPrompt){
doDelete = window.confirm("Press OK to delete this graph");
}
if(doDelete){
thisGraph.nodes = [];
thisGraph.edges = [];
thisGraph.updateGraph();
}
};
/* select all text in element: taken from http://stackoverflow.com/questions/6139107/programatically-select-text-in-a-contenteditable-html-element */
GraphCreator.prototype.selectElementContents = function(el) {
var range = document.createRange();
range.selectNodeContents(el);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
};
/* insert svg line breaks: taken from http://stackoverflow.com/questions/13241475/how-do-i-include-newlines-in-labels-in-d3-charts */
GraphCreator.prototype.insertTitleLinebreaks = function (gEl, title) {
var words = title.split(/\s+/g),
nwords = words.length;
var el = gEl.append("text")
.attr("text-anchor","middle")
.attr("dy", "-" + (nwords-1)*7.5);
for (var i = 0; i < words.length; i++) {
var tspan = el.append('tspan').text(words[i]);
if (i > 0)
tspan.attr('x', 0).attr('dy', '15');
}
};
// remove edges associated with a node
GraphCreator.prototype.spliceLinksForNode = function(node) {
var thisGraph = this,
toSplice = thisGraph.edges.filter(function(l) {
return (l.source === node || l.target === node);
});
toSplice.map(function(l) {
thisGraph.edges.splice(thisGraph.edges.indexOf(l), 1);
});
};
GraphCreator.prototype.replaceSelectEdge = function(d3Path, edgeData){
var thisGraph = this;
d3Path.classed(thisGraph.consts.selectedClass, true);
if (thisGraph.state.selectedEdge){
thisGraph.removeSelectFromEdge();
}
thisGraph.state.selectedEdge = edgeData;
};
GraphCreator.prototype.replaceSelectNode = function(d3Node, nodeData){
var thisGraph = this;
d3Node.classed(this.consts.selectedClass, true);
if (thisGraph.state.selectedNode){
thisGraph.removeSelectFromNode();
}
thisGraph.state.selectedNode = nodeData;
};
GraphCreator.prototype.removeSelectFromNode = function(){
var thisGraph = this;
thisGraph.circles.filter(function(cd){
return cd.id === thisGraph.state.selectedNode.id;
}).classed(thisGraph.consts.selectedClass, false);
thisGraph.state.selectedNode = null;
};
GraphCreator.prototype.removeSelectFromEdge = function(){
var thisGraph = this;
thisGraph.paths.filter(function(cd){
return cd === thisGraph.state.selectedEdge;
}).classed(thisGraph.consts.selectedClass, false);
thisGraph.state.selectedEdge = null;
};
GraphCreator.prototype.pathMouseDown = function(d3path, d){
var thisGraph = this,
state = thisGraph.state;
d3.event.stopPropagation();
state.mouseDownLink = d;
if (state.selectedNode){
thisGraph.removeSelectFromNode();
}
var prevEdge = state.selectedEdge;
if (!prevEdge || prevEdge !== d){
thisGraph.replaceSelectEdge(d3path, d);
} else{
thisGraph.removeSelectFromEdge();
}
};
// mousedown on node
GraphCreator.prototype.circleMouseDown = function(d3node, d){
var thisGraph = this,
state = thisGraph.state;
d3.event.stopPropagation();
state.mouseDownNode = d;
if (d3.event.shiftKey){
state.shiftNodeDrag = d3.event.shiftKey;
// reposition dragged directed edge
thisGraph.dragLine.classed('hidden', false)
.attr('d', 'M' + d.x + ',' + d.y + 'L' + d.x + ',' + d.y);
return;
}
};
/* place editable text on node in place of svg text */
GraphCreator.prototype.changeTextOfNode = function(d3node, d){
var thisGraph= this,
consts = thisGraph.consts,
htmlEl = d3node.node();
d3node.selectAll("text").remove();
var nodeBCR = htmlEl.getBoundingClientRect(),
curScale = nodeBCR.width/consts.nodeRadius,
placePad = 5*curScale,
useHW = curScale > 1 ? nodeBCR.width*0.71 : consts.nodeRadius*1.42;
// replace with editableconent text
var d3txt = thisGraph.svg.selectAll("foreignObject")
.data([d])
.enter()
.append("foreignObject")
.attr("x", nodeBCR.left + placePad )
.attr("y", nodeBCR.top + placePad)
.attr("height", 2*useHW)
.attr("width", useHW)
.append("xhtml:p")
.attr("id", consts.activeEditId)
.attr("contentEditable", "true")
.text(d.title)
.on("mousedown", function(d){
d3.event.stopPropagation();
})
.on("keydown", function(d){
d3.event.stopPropagation();
if (d3.event.keyCode == consts.ENTER_KEY && !d3.event.shiftKey){
this.blur();
}
})
.on("blur", function(d){
d.title = this.textContent;
thisGraph.insertTitleLinebreaks(d3node, d.title);
d3.select(this.parentElement).remove();
});
return d3txt;
};
// mouseup on nodes
GraphCreator.prototype.circleMouseUp = function(d3node, d){
var thisGraph = this,
state = thisGraph.state,
consts = thisGraph.consts;
// reset the states
state.shiftNodeDrag = false;
d3node.classed(consts.connectClass, false);
var mouseDownNode = state.mouseDownNode;
if (!mouseDownNode) return;
thisGraph.dragLine.classed("hidden", true);
if (mouseDownNode !== d){
// we're in a different node: create new edge for mousedown edge and add to graph
var newEdge = {source: mouseDownNode, target: d};
var filtRes = thisGraph.paths.filter(function(d){
if (d.source === newEdge.target && d.target === newEdge.source){
thisGraph.edges.splice(thisGraph.edges.indexOf(d), 1);
}
return d.source === newEdge.source && d.target === newEdge.target;
});
if (!filtRes[0].length){
thisGraph.edges.push(newEdge);
thisGraph.updateGraph();
}
} else{
// we're in the same node
if (state.justDragged) {
// dragged, not clicked
state.justDragged = false;
} else{
// clicked, not dragged
if (d3.event.shiftKey){
// shift-clicked node: edit text content
var d3txt = thisGraph.changeTextOfNode(d3node, d);
var txtNode = d3txt.node();
thisGraph.selectElementContents(txtNode);
txtNode.focus();
} else{
if (state.selectedEdge){
thisGraph.removeSelectFromEdge();
}
var prevNode = state.selectedNode;
if (!prevNode || prevNode.id !== d.id){
thisGraph.replaceSelectNode(d3node, d);
} else{
thisGraph.removeSelectFromNode();
}
}
}
}
state.mouseDownNode = null;
return;
}; // end of circles mouseup
// mousedown on main svg
GraphCreator.prototype.svgMouseDown = function(){
this.state.graphMouseDown = true;
};
// mouseup on main svg
GraphCreator.prototype.svgMouseUp = function(){
var thisGraph = this,
state = thisGraph.state;
if (state.justScaleTransGraph) {
// dragged not clicked
state.justScaleTransGraph = false;
} else if (state.graphMouseDown && d3.event.shiftKey){
// clicked not dragged from svg
var xycoords = d3.mouse(thisGraph.svgG.node()),
d = {id: thisGraph.idct++, title: "new concept", x: xycoords[0], y: xycoords[1]};
thisGraph.nodes.push(d);
thisGraph.updateGraph();
// make title of text immediently editable
var d3txt = thisGraph.changeTextOfNode(thisGraph.circles.filter(function(dval){
return dval.id === d.id;
}), d),
txtNode = d3txt.node();
thisGraph.selectElementContents(txtNode);
txtNode.focus();
} else if (state.shiftNodeDrag){
// dragged from node
state.shiftNodeDrag = false;
thisGraph.dragLine.classed("hidden", true);
}
state.graphMouseDown = false;
};
// keydown on main svg
GraphCreator.prototype.svgKeyDown = function() {
var thisGraph = this,
state = thisGraph.state,
consts = thisGraph.consts;
// make sure repeated key presses don't register for each keydown
if(state.lastKeyDown !== -1) return;
state.lastKeyDown = d3.event.keyCode;
var selectedNode = state.selectedNode,
selectedEdge = state.selectedEdge;
switch(d3.event.keyCode) {
case consts.BACKSPACE_KEY:
case consts.DELETE_KEY:
d3.event.preventDefault();
if (selectedNode){
thisGraph.nodes.splice(thisGraph.nodes.indexOf(selectedNode), 1);
thisGraph.spliceLinksForNode(selectedNode);
state.selectedNode = null;
thisGraph.updateGraph();
} else if (selectedEdge){
thisGraph.edges.splice(thisGraph.edges.indexOf(selectedEdge), 1);
state.selectedEdge = null;
thisGraph.updateGraph();
}
break;
}
};
GraphCreator.prototype.svgKeyUp = function() {
this.state.lastKeyDown = -1;
};
// call to propagate changes to graph
GraphCreator.prototype.updateGraph = function(){
var thisGraph = this,
consts = thisGraph.consts,
state = thisGraph.state;
thisGraph.paths = thisGraph.paths.data(thisGraph.edges, function(d){
return String(d.source.id) + "+" + String(d.target.id);
});
var paths = thisGraph.paths;
// update existing paths
paths.style('marker-end', 'url(#end-arrow)')
.classed(consts.selectedClass, function(d){
return d === state.selectedEdge;
})
.attr("d", function(d){
return "M" + d.source.x + "," + d.source.y + "L" + d.target.x + "," + d.target.y;
});
// add new paths
paths.enter()
.append("path")
.style('marker-end','url(#end-arrow)')
.classed("link", true)
.attr("d", function(d){
return "M" + d.source.x + "," + d.source.y + "L" + d.target.x + "," + d.target.y;
})
.on("mousedown", function(d){
thisGraph.pathMouseDown.call(thisGraph, d3.select(this), d);
}
)
.on("mouseup", function(d){
state.mouseDownLink = null;
});
// remove old links
paths.exit().remove();
// update existing nodes
thisGraph.circles = thisGraph.circles.data(thisGraph.nodes, function(d){ return d.id;});
thisGraph.circles.attr("transform", function(d){return "translate(" + d.x + "," + d.y + ")";});
// add new nodes
var newGs= thisGraph.circles.enter()
.append("g");
newGs.classed(consts.circleGClass, true)
.attr("transform", function(d){return "translate(" + d.x + "," + d.y + ")";})
.on("mouseover", function(d){
if (state.shiftNodeDrag){
d3.select(this).classed(consts.connectClass, true);
}
})
.on("mouseout", function(d){
d3.select(this).classed(consts.connectClass, false);
})
.on("mousedown", function(d){
thisGraph.circleMouseDown.call(thisGraph, d3.select(this), d);
})
.on("mouseup", function(d){
thisGraph.circleMouseUp.call(thisGraph, d3.select(this), d);
})
.call(thisGraph.drag);
newGs.append("circle")
.attr("r", String(consts.nodeRadius));
newGs.each(function(d){
thisGraph.insertTitleLinebreaks(d3.select(this), d.title);
});
// remove old nodes
thisGraph.circles.exit().remove();
};
GraphCreator.prototype.zoomed = function(){
this.state.justScaleTransGraph = true;
d3.select("." + this.consts.graphClass)
.attr("transform", "translate(" + d3.event.translate + ") scale(" + d3.event.scale + ")");
};
GraphCreator.prototype.updateWindow = function(svg){
var docEl = document.documentElement,
bodyEl = document.getElementsByTagName('body')[0];
var x = window.innerWidth || docEl.clientWidth || bodyEl.clientWidth;
var y = window.innerHeight|| docEl.clientHeight|| bodyEl.clientHeight;
svg.attr("width", x).attr("height", y);
};
/**** MAIN ****/
// warn the user when leaving
window.onbeforeunload = function(){
return "Make sure to save your graph locally before leaving :-)";
};
var docEl = document.documentElement,
bodyEl = document.getElementsByTagName('body')[0];
var width = window.innerWidth || docEl.clientWidth || bodyEl.clientWidth,
height = window.innerHeight|| docEl.clientHeight|| bodyEl.clientHeight;
var xLoc = width/2 - 25,
yLoc = 100;
// initial node data
var nodes = [{title: "new concept", id: 0, x: xLoc, y: yLoc},
{title: "new concept", id: 1, x: xLoc, y: yLoc + 200}];
var edges = [{source: nodes[1], target: nodes[0]}];
/** MAIN SVG **/
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
var graph = new GraphCreator(svg, nodes, edges);
graph.setIdCt(2);
graph.updateGraph();
})(window.d3, window.saveAs, window.Blob);
<!DOCTYPE HTML>
<html>
<head>
<link rel="stylesheet" href="graph-creator.css" />
</head>
<body>
<div id="toolbox">
<input type="file" id="hidden-file-upload"><input id="upload-input" type="image" title="upload graph" src="upload-icon.png" alt="upload graph"> <input type="image" id="download-input" title="download graph" src="download-icon.png" alt="download graph"> <input type="image" id="delete-graph" title="delete graph" src="trash-icon.png" alt="delete graph">
</div>
<script src="//d3js.org/d3.v3.js" charset="utf-8"></script>
<script src="//cdn.jsdelivr.net/filesaver.js/0.1/FileSaver.min.js"></script>
<script src="graph-creator.js"></script>
</body>
</html>
@irshad001
Copy link

Thank you for really useful work. Can we add different hyperlinks at the nodes?

@irshad001
Copy link

Sometimes the nodes are getting deleted and sometimes they are not . Plus there when I am trying to delete the map it is throwing this error : "Ignored call to 'confirm()'. The document is sandboxed, and the 'allow-modals' keyword is not set"

@drossoa
Copy link

drossoa commented Jan 20, 2016

Very nice.

@gokulnath95
Copy link

i need to add parallel edges..the code doesn't support to add parallel edge. If it is possible, please send the piece of code to be added. Pls provide help ASAP

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment