Use the mouse (or touch) to pan & zoom or drag nodes around. Clicking a node or a link selects it. You can press del
to remove the current selection. Press n
to add new nodes (you have to click once on the SVG first).
Based on this example.
The code mixes a pan & zoom implementation based on this example by Mike Bostock with a node drag behavior (see the d3.js wiki). For unknown reasons, this implementation behaves correctly only with the provided version of d3, while it acts strangely with the latest one. It also does not work with touch input.
To implement keyboard input, suitable keyCodes values have been produced by using this jsFiddle.
The code is somewhat disorganized, and it reacts very badly when trying to change something or add new features…
(function() {
var global, graph, height, update, width,
__indexOf = Array.prototype.indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
width = 960;
height = 500;
/* SELECTION - store the selected node
*/
global = {
selection: null
};
/* create some fake data
*/
graph = {
nodes: [
{
id: 'A',
x: 469,
y: 410,
type: 'X'
}, {
id: 'B',
x: 493,
y: 364,
type: 'X'
}, {
id: 'C',
x: 442,
y: 365,
type: 'X'
}, {
id: 'D',
x: 467,
y: 314,
type: 'X'
}, {
id: 'E',
x: 477,
y: 248,
type: 'Y'
}, {
id: 'F',
x: 425,
y: 207,
type: 'Y'
}, {
id: 'G',
x: 402,
y: 155,
type: 'Y'
}, {
id: 'H',
x: 369,
y: 196,
type: 'Y'
}, {
id: 'I',
x: 350,
y: 148,
type: 'Z'
}, {
id: 'J',
x: 539,
y: 222,
type: 'Z'
}, {
id: 'K',
x: 594,
y: 235,
type: 'Z'
}, {
id: 'L',
x: 582,
y: 185,
type: 'Z'
}, {
id: 'M',
x: 633,
y: 200,
type: 'Z'
}
],
links: [
{
source: 'A',
target: 'B'
}, {
source: 'B',
target: 'C'
}, {
source: 'C',
target: 'A'
}, {
source: 'B',
target: 'D'
}, {
source: 'D',
target: 'C'
}, {
source: 'D',
target: 'E'
}, {
source: 'E',
target: 'F'
}, {
source: 'F',
target: 'G'
}, {
source: 'F',
target: 'H'
}, {
source: 'G',
target: 'H'
}, {
source: 'G',
target: 'I'
}, {
source: 'H',
target: 'I'
}, {
source: 'J',
target: 'E'
}, {
source: 'J',
target: 'L'
}, {
source: 'J',
target: 'K'
}, {
source: 'K',
target: 'L'
}, {
source: 'L',
target: 'M'
}, {
source: 'M',
target: 'K'
}
],
objectify: (function() {
/* resolve node IDs (not optimized at all!)
*/
var l, n, _i, _len, _ref, _results;
_ref = graph.links;
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
l = _ref[_i];
_results.push((function() {
var _j, _len2, _ref2, _results2;
_ref2 = graph.nodes;
_results2 = [];
for (_j = 0, _len2 = _ref2.length; _j < _len2; _j++) {
n = _ref2[_j];
if (l.source === n.id) {
l.source = n;
continue;
}
if (l.target === n.id) {
l.target = n;
continue;
} else {
_results2.push(void 0);
}
}
return _results2;
})());
}
return _results;
}),
remove: (function(condemned) {
/* remove the given node or link from the graph, also deleting dangling links if a node is removed
*/ if (__indexOf.call(graph.nodes, condemned) >= 0) {
graph.nodes = graph.nodes.filter(function(n) {
return n !== condemned;
});
return graph.links = graph.links.filter(function(l) {
return l.source.id !== condemned.id && l.target.id !== condemned.id;
});
} else if (__indexOf.call(graph.links, condemned) >= 0) {
return graph.links = graph.links.filter(function(l) {
return l !== condemned;
});
}
}),
last_index: 0,
add_node: (function() {
var n;
n = {
id: graph.last_index++,
x: width / 2,
y: height / 2,
type: 'X'
};
return graph.nodes.push(n);
})
};
graph.objectify();
window.main = (function() {
/* create the SVG
*/
var container, svg;
svg = d3.select('body').append('svg').attr('width', width).attr('height', height);
/* ZOOM and PAN
*/
/* create container elements
*/
container = svg.append('g');
container.call(d3.behavior.zoom().scaleExtent([0.5, 8]).on('zoom', (function() {
return global.vis.attr('transform', "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
})));
global.vis = container.append('g');
/* create a rectangular overlay to catch events
*/
/* WARNING rect size is huge but not infinite. this is a dirty hack
*/
global.vis.append('rect').attr('class', 'overlay').attr('x', -500000).attr('y', -500000).attr('width', 1000000).attr('height', 1000000).on('click', (function(d) {
/* SELECTION
*/ global.selection = null;
d3.selectAll('.node').classed('selected', false);
return d3.selectAll('.link').classed('selected', false);
}));
/* END ZOOM and PAN
*/
global.colorify = d3.scale.category10();
/* initialize the force layout
*/
global.force = d3.layout.force().size([width, height]).charge(-400).linkDistance(60).on('tick', (function() {
/* update nodes and links
*/ global.vis.selectAll('.node').attr('transform', function(d) {
return "translate(" + d.x + "," + d.y + ")";
});
return global.vis.selectAll('.link').attr('x1', function(d) {
return d.source.x;
}).attr('y1', function(d) {
return d.source.y;
}).attr('x2', function(d) {
return d.target.x;
}).attr('y2', function(d) {
return d.target.y;
});
}));
/* DELETION - pressing DEL deletes the selection
*/
/* CREATION - pressing N creates a new node
*/
d3.select(window).on('keydown', function() {
if (d3.event.keyCode === 46) {
if (global.selection != null) {
graph.remove(global.selection);
global.selection = null;
return update();
}
} else if (d3.event.keyCode === 78) {
graph.add_node();
return update();
}
});
return update();
});
update = function() {
/* update the layout
*/
var links, new_nodes, nodes;
global.force.nodes(graph.nodes).links(graph.links).start();
/* create nodes and links
*/
/* (links are drawn first to make them appear under the nodes)
*/
/* also, overwrite the selections with their databound version
*/
links = global.vis.selectAll('.link').data(graph.links, function(d) {
return "" + d.source.id + "->" + d.target.id;
});
links.enter().append('line').attr('class', 'link').on('click', (function(d) {
/* SELECTION
*/ global.selection = d;
d3.selectAll('.link').classed('selected', function(d2) {
return d2 === d;
});
return d3.selectAll('.node').classed('selected', false);
}));
links.exit().remove();
/* also define a drag behavior to drag nodes
*/
/* dragged nodes become fixed
*/
nodes = global.vis.selectAll('.node').data(graph.nodes, function(d) {
return d.id;
});
new_nodes = nodes.enter().append('g').attr('class', 'node').call(global.force.drag().on('dragstart', function(d) {
return d.fixed = true;
})).on('click', (function(d) {
/* SELECTION
*/ global.selection = d;
d3.selectAll('.node').classed('selected', function(d2) {
return d2 === d;
});
return d3.selectAll('.link').classed('selected', false);
}));
new_nodes.append('circle').attr('r', 18).attr('stroke', function(d) {
return global.colorify(d.type);
}).attr('fill', function(d) {
return d3.hcl(global.colorify(d.type)).brighter(3);
});
/* draw the label
*/
new_nodes.append('text').text(function(d) {
return d.id;
}).attr('dy', '0.35em').attr('fill', function(d) {
return global.colorify(d.type);
});
return nodes.exit().remove();
};
}).call(this);
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Graph editing</title>
<link type="text/css" href="index.css" rel="stylesheet"/>
<script src="d3.v3.min.js"></script>
<script src="index.js"></script>
</head>
<body onload="main()">
</body>
</html>
width = 960
height = 500
### SELECTION - store the selected node ###
global = {
selection: null
}
### create some fake data ###
graph = {
nodes: [
{id: 'A', x: 469, y: 410, type: 'X'},
{id: 'B', x: 493, y: 364, type: 'X'},
{id: 'C', x: 442, y: 365, type: 'X'},
{id: 'D', x: 467, y: 314, type: 'X'},
{id: 'E', x: 477, y: 248, type: 'Y'},
{id: 'F', x: 425, y: 207, type: 'Y'},
{id: 'G', x: 402, y: 155, type: 'Y'},
{id: 'H', x: 369, y: 196, type: 'Y'},
{id: 'I', x: 350, y: 148, type: 'Z'},
{id: 'J', x: 539, y: 222, type: 'Z'},
{id: 'K', x: 594, y: 235, type: 'Z'},
{id: 'L', x: 582, y: 185, type: 'Z'},
{id: 'M', x: 633, y: 200, type: 'Z'}
],
links: [
{source: 'A', target: 'B'},
{source: 'B', target: 'C'},
{source: 'C', target: 'A'},
{source: 'B', target: 'D'},
{source: 'D', target: 'C'},
{source: 'D', target: 'E'},
{source: 'E', target: 'F'},
{source: 'F', target: 'G'},
{source: 'F', target: 'H'},
{source: 'G', target: 'H'},
{source: 'G', target: 'I'},
{source: 'H', target: 'I'},
{source: 'J', target: 'E'},
{source: 'J', target: 'L'},
{source: 'J', target: 'K'},
{source: 'K', target: 'L'},
{source: 'L', target: 'M'},
{source: 'M', target: 'K'}
],
objectify: (() ->
### resolve node IDs (not optimized at all!) ###
for l in graph.links
for n in graph.nodes
if l.source is n.id
l.source = n
continue
if l.target is n.id
l.target = n
continue
),
remove: ((condemned) ->
### remove the given node or link from the graph, also deleting dangling links if a node is removed ###
if condemned in graph.nodes
graph.nodes = graph.nodes.filter (n) -> n isnt condemned
graph.links = graph.links.filter (l) -> l.source.id isnt condemned.id and l.target.id isnt condemned.id
else if condemned in graph.links
graph.links = graph.links.filter (l) -> l isnt condemned
),
last_index: 0,
add_node: (() ->
n = {
id: graph.last_index++,
x: width/2,
y: height/2,
type: 'X'
}
graph.nodes.push(n)
)
}
graph.objectify()
window.main = (() ->
### create the SVG ###
svg = d3.select('body').append('svg')
.attr('width', width)
.attr('height', height)
### ZOOM and PAN ###
### create container elements ###
container = svg.append('g')
container.call(d3.behavior.zoom().scaleExtent([0.5, 8]).on('zoom', (() -> global.vis.attr('transform', "translate(#{d3.event.translate})scale(#{d3.event.scale})"))))
global.vis = container.append('g')
### create a rectangular overlay to catch events ###
### WARNING rect size is huge but not infinite. this is a dirty hack ###
global.vis.append('rect')
.attr('class', 'overlay')
.attr('x', -500000)
.attr('y', -500000)
.attr('width', 1000000)
.attr('height', 1000000)
.on('click', ((d) ->
### SELECTION ###
global.selection = null
d3.selectAll('.node').classed('selected', false)
d3.selectAll('.link').classed('selected', false)
))
### END ZOOM and PAN ###
global.colorify = d3.scale.category10()
### initialize the force layout ###
global.force = d3.layout.force()
.size([width, height])
.charge(-400)
.linkDistance(60)
.on('tick', (() ->
### update nodes and links ###
global.vis.selectAll('.node')
.attr('transform', (d) -> "translate(#{d.x},#{d.y})")
global.vis.selectAll('.link')
.attr('x1', (d) -> d.source.x)
.attr('y1', (d) -> d.source.y)
.attr('x2', (d) -> d.target.x)
.attr('y2', (d) -> d.target.y)
))
### DELETION - pressing DEL deletes the selection ###
### CREATION - pressing N creates a new node ###
d3.select(window)
.on('keydown', () ->
if d3.event.keyCode is 46 # DEL
if global.selection?
graph.remove global.selection
global.selection = null
update()
else if d3.event.keyCode is 78 # N
graph.add_node()
update()
)
update()
)
update = () ->
### update the layout ###
global.force
.nodes(graph.nodes)
.links(graph.links)
.start()
### create nodes and links ###
### (links are drawn first to make them appear under the nodes) ###
### also, overwrite the selections with their databound version ###
links = global.vis.selectAll('.link')
.data(graph.links, (d) -> "#{d.source.id}->#{d.target.id}")
links
.enter().append('line')
.attr('class', 'link')
.on('click', ((d) ->
### SELECTION ###
global.selection = d
d3.selectAll('.link').classed('selected', (d2) -> d2 is d)
d3.selectAll('.node').classed('selected', false)
))
links
.exit().remove()
### also define a drag behavior to drag nodes ###
### dragged nodes become fixed ###
nodes = global.vis.selectAll('.node')
.data(graph.nodes, (d) -> d.id)
new_nodes = nodes
.enter().append('g')
.attr('class', 'node')
.call(global.force.drag().on('dragstart', (d) -> d.fixed = true)) # DRAG
.on('click', ((d) ->
### SELECTION ###
global.selection = d
d3.selectAll('.node').classed('selected', (d2) -> d2 is d)
d3.selectAll('.link').classed('selected', false)
))
new_nodes.append('circle')
.attr('r', 18)
.attr('stroke', (d) -> global.colorify(d.type))
.attr('fill', (d) -> d3.hcl(global.colorify(d.type)).brighter(3))
### draw the label ###
new_nodes.append('text')
.text((d) -> d.id)
.attr('dy', '0.35em')
.attr('fill', (d) -> global.colorify(d.type))
nodes
.exit().remove()
.node > circle {
stroke-width: 4px;
}
.node > text {
pointer-events: none;
font-family: sans-serif;
font-weight: bold;
text-anchor: middle;
}
.link {
stroke-width: 6px;
stroke: gray;
opacity: 0.6;
}
.selected > circle {
stroke-width: 8px;
}
.selected.link {
stroke-width: 14px;
}
.overlay {
fill: transparent;
}
.node > circle
stroke-width: 4px
.node > text
pointer-events: none
font-family: sans-serif
font-weight: bold
text-anchor: middle
.link
stroke-width: 6px
stroke: gray
opacity: 0.6
// selection
.selected > circle
stroke-width: 8px
.selected.link
stroke-width: 14px
// zoom and pan
.overlay
fill: transparent