A simple method for comparing small, similar graphs. Two linked views are presented, showing differences as "phantom" nodes and links. The views share the same layout, ensuring comparability.
forked from nitaku's block: Graph comparison
license: mit |
A simple method for comparing small, similar graphs. Two linked views are presented, showing differences as "phantom" nodes and links. The views share the same layout, ensuring comparability.
forked from nitaku's block: Graph comparison
graph = { | |
nodes: [ | |
{id: 'A', graphs:['I','II']}, | |
{id: 'B', graphs:['II']}, | |
{id: 'C', graphs:['I','II']}, | |
{id: 'D', graphs:['I']}, | |
{id: 'E', graphs:['II']}, | |
{id: 'F', graphs:['I','II']}, | |
{id: 'G', graphs:['I','II']}, | |
{id: 'H', graphs:['I','II']}, | |
{id: 'I', graphs:['I','II']}, | |
{id: 'J', graphs:['I']} | |
], | |
links: [ | |
{id: 1, source: 'A', target: 'B', graphs:['II']}, | |
{id: 2, source: 'A', target: 'C', graphs:['I','II']}, | |
{id: 3, source: 'A', target: 'D', graphs:['I']}, | |
{id: 4, source: 'B', target: 'E', graphs:['II']}, | |
{id: 5, source: 'B', target: 'F', graphs:['II']}, | |
{id: 6, source: 'C', target: 'G', graphs:['I','II']}, | |
{id: 7, source: 'C', target: 'F', graphs:['I','II']}, | |
{id: 8, source: 'F', target: 'G', graphs:['I','II']}, | |
{id: 9, source: 'G', target: 'H', graphs:['I','II']}, | |
{id: 10, source: 'G', target: 'I', graphs:['I','II']}, | |
{id: 11, source: 'H', target: 'I', graphs:['I','II']}, | |
{id: 12, source: 'I', target: 'J', graphs:['I']} | |
]} | |
### objectify the graph ### | |
### 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 | |
if l.target is n.id | |
l.target = n | |
R = 18 | |
svg = d3.select('svg') | |
width = svg.node().getBoundingClientRect().width | |
height = svg.node().getBoundingClientRect().height | |
defs = svg.append('defs') | |
### define arrow markers for graph links ### | |
defs.append('marker') | |
.attr | |
id: 'end-arrow' | |
viewBox: '0 0 10 10' | |
refX: 4+R | |
refY: 5 | |
orient: 'auto' | |
.append('path') | |
.attr | |
d: 'M0,0 L0,10 L10,5 z' | |
defs.append('marker') | |
.attr | |
id: 'phantom-end-arrow' | |
viewBox: '0 0 10 10' | |
refX: 4+R | |
refY: 5 | |
orient: 'auto' | |
.append('path') | |
.attr | |
d: 'M0,0 L0,10 L10,5 z' | |
### create views ### | |
defs.append('clipPath') | |
.attr | |
id: 'square_window' | |
.append('rect') | |
.attr | |
x: 0 | |
y: 0 | |
width: width/2 | |
height: height | |
views_data = ['I','II'] | |
views = svg.selectAll('.view') | |
.data(views_data) | |
enter_views = views.enter().append('g') | |
.attr | |
class: 'view' | |
'clip-path': 'url(#square_window)' | |
transform: (d) -> if d is 'II' then "translate(#{width/2},0)" else 'translate(0,0)' | |
svg.append('line') | |
.attr | |
class: 'separator' | |
x1: width/2 | |
y1: 0 | |
x2: width/2 | |
y2: height | |
### create phantom nodes and links ### | |
phantom_links_layer = enter_views.append('g') | |
phantom_links = phantom_links_layer.selectAll('.link') | |
.data(((v) -> graph.links.filter((l) -> v not in l.graphs)), (d) -> d.id) | |
phantom_links | |
.enter().append('line') | |
.attr('class', 'phantom link') | |
phantom_nodes_layer = enter_views.append('g') | |
phantom_nodes = phantom_nodes_layer.selectAll('.node') | |
.data(((v) -> graph.nodes.filter((n) -> v not in n.graphs)), (d) -> d.id) | |
enter_phantom_nodes = phantom_nodes.enter().append('g') | |
.attr('class', 'phantom node') | |
enter_phantom_nodes.append('circle') | |
.attr('r', R) | |
### create nodes and links ### | |
links_layer = enter_views.append('g') | |
links = links_layer.selectAll('.link') | |
.data(((v) -> graph.links.filter((l) -> v in l.graphs)), (d) -> d.id) | |
links | |
.enter().append('line') | |
.attr('class', 'link') | |
nodes_layer = enter_views.append('g') | |
nodes = nodes_layer.selectAll('.node') | |
.data(((v) -> graph.nodes.filter((n) -> v in n.graphs)), (d) -> d.id) | |
enter_nodes = nodes.enter().append('g') | |
.attr('class', 'node') | |
enter_nodes.append('circle') | |
.attr('r', R) | |
### draw the label ### | |
enter_nodes.append('text') | |
.text((d) -> d.id) | |
.attr('dy', '0.35em') | |
### cola layout ### | |
graph.nodes.forEach (v) -> | |
v.width = 2.5*R | |
v.height = 2.5*R | |
d3cola = cola.d3adaptor() | |
.size([width/2, height]) | |
.linkDistance(70) | |
.avoidOverlaps(true) | |
.nodes(graph.nodes) | |
.links(graph.links) | |
.on 'tick', () -> | |
### update nodes and links ### | |
views.selectAll('.node') | |
.attr('transform', (d) -> "translate(#{d.x},#{d.y})") | |
views.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) | |
enter_nodes | |
.call(d3cola.drag) | |
enter_phantom_nodes | |
.call(d3cola.drag) | |
d3cola.start(30,30,30) |
.node > circle { | |
fill: #DDD; | |
stroke: #777; | |
stroke-width: 2px; | |
} | |
.node > text { | |
font-family: sans-serif; | |
text-anchor: middle; | |
pointer-events: none; | |
user-select: none; | |
-webkit-user-select: none; | |
} | |
.link { | |
stroke: #88A; | |
stroke-width: 4px; | |
marker-end: url(#end-arrow); | |
} | |
#end-arrow { | |
fill: #88A; | |
} | |
.separator { | |
stroke: #dfd7c4; | |
shape-rendering: crispEdges; | |
} | |
.phantom.node > circle { | |
fill: #EEE; | |
stroke: #EEE; | |
} | |
.phantom.link { | |
stroke: #EEE; | |
marker-end: url(#phantom-end-arrow); | |
} | |
#phantom-end-arrow { | |
fill: #EEE; | |
} |
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8"> | |
<title>Graph comparison</title> | |
<link rel="stylesheet" href="index.css"> | |
<script src="http://d3js.org/d3.v3.min.js"></script> | |
<script src="http://marvl.infotech.monash.edu/webcola/cola.v3.min.js"></script> | |
</head> | |
<body> | |
<svg width="960px" height="500px"></svg> | |
<script src="index.js"></script> | |
</body> | |
</html> |
// Generated by CoffeeScript 1.4.0 | |
(function() { | |
var R, d3cola, defs, enter_nodes, enter_phantom_nodes, enter_views, graph, height, l, links, links_layer, n, nodes, nodes_layer, phantom_links, phantom_links_layer, phantom_nodes, phantom_nodes_layer, svg, views, views_data, width, _i, _j, _len, _len1, _ref, _ref1, | |
__indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; | |
graph = { | |
nodes: [ | |
{ | |
id: 'A', | |
graphs: ['I', 'II'] | |
}, { | |
id: 'B', | |
graphs: ['II'] | |
}, { | |
id: 'C', | |
graphs: ['I', 'II'] | |
}, { | |
id: 'D', | |
graphs: ['I'] | |
}, { | |
id: 'E', | |
graphs: ['II'] | |
}, { | |
id: 'F', | |
graphs: ['I', 'II'] | |
}, { | |
id: 'G', | |
graphs: ['I', 'II'] | |
}, { | |
id: 'H', | |
graphs: ['I', 'II'] | |
}, { | |
id: 'I', | |
graphs: ['I', 'II'] | |
}, { | |
id: 'J', | |
graphs: ['I'] | |
} | |
], | |
links: [ | |
{ | |
id: 1, | |
source: 'A', | |
target: 'B', | |
graphs: ['II'] | |
}, { | |
id: 2, | |
source: 'A', | |
target: 'C', | |
graphs: ['I', 'II'] | |
}, { | |
id: 3, | |
source: 'A', | |
target: 'D', | |
graphs: ['I'] | |
}, { | |
id: 4, | |
source: 'B', | |
target: 'E', | |
graphs: ['II'] | |
}, { | |
id: 5, | |
source: 'B', | |
target: 'F', | |
graphs: ['II'] | |
}, { | |
id: 6, | |
source: 'C', | |
target: 'G', | |
graphs: ['I', 'II'] | |
}, { | |
id: 7, | |
source: 'C', | |
target: 'F', | |
graphs: ['I', 'II'] | |
}, { | |
id: 8, | |
source: 'F', | |
target: 'G', | |
graphs: ['I', 'II'] | |
}, { | |
id: 9, | |
source: 'G', | |
target: 'H', | |
graphs: ['I', 'II'] | |
}, { | |
id: 10, | |
source: 'G', | |
target: 'I', | |
graphs: ['I', 'II'] | |
}, { | |
id: 11, | |
source: 'H', | |
target: 'I', | |
graphs: ['I', 'II'] | |
}, { | |
id: 12, | |
source: 'I', | |
target: 'J', | |
graphs: ['I'] | |
} | |
] | |
}; | |
/* objectify the graph | |
*/ | |
/* resolve node IDs (not optimized at all!) | |
*/ | |
_ref = graph.links; | |
for (_i = 0, _len = _ref.length; _i < _len; _i++) { | |
l = _ref[_i]; | |
_ref1 = graph.nodes; | |
for (_j = 0, _len1 = _ref1.length; _j < _len1; _j++) { | |
n = _ref1[_j]; | |
if (l.source === n.id) { | |
l.source = n; | |
} | |
if (l.target === n.id) { | |
l.target = n; | |
} | |
} | |
} | |
R = 18; | |
svg = d3.select('svg'); | |
width = svg.node().getBoundingClientRect().width; | |
height = svg.node().getBoundingClientRect().height; | |
defs = svg.append('defs'); | |
/* define arrow markers for graph links | |
*/ | |
defs.append('marker').attr({ | |
id: 'end-arrow', | |
viewBox: '0 0 10 10', | |
refX: 4 + R, | |
refY: 5, | |
orient: 'auto' | |
}).append('path').attr({ | |
d: 'M0,0 L0,10 L10,5 z' | |
}); | |
defs.append('marker').attr({ | |
id: 'phantom-end-arrow', | |
viewBox: '0 0 10 10', | |
refX: 4 + R, | |
refY: 5, | |
orient: 'auto' | |
}).append('path').attr({ | |
d: 'M0,0 L0,10 L10,5 z' | |
}); | |
/* create views | |
*/ | |
defs.append('clipPath').attr({ | |
id: 'square_window' | |
}).append('rect').attr({ | |
x: 0, | |
y: 0, | |
width: width / 2, | |
height: height | |
}); | |
views_data = ['I', 'II']; | |
views = svg.selectAll('.view').data(views_data); | |
enter_views = views.enter().append('g').attr({ | |
"class": 'view', | |
'clip-path': 'url(#square_window)', | |
transform: function(d) { | |
if (d === 'II') { | |
return "translate(" + (width / 2) + ",0)"; | |
} else { | |
return 'translate(0,0)'; | |
} | |
} | |
}); | |
svg.append('line').attr({ | |
"class": 'separator', | |
x1: width / 2, | |
y1: 0, | |
x2: width / 2, | |
y2: height | |
}); | |
/* create phantom nodes and links | |
*/ | |
phantom_links_layer = enter_views.append('g'); | |
phantom_links = phantom_links_layer.selectAll('.link').data((function(v) { | |
return graph.links.filter(function(l) { | |
return __indexOf.call(l.graphs, v) < 0; | |
}); | |
}), function(d) { | |
return d.id; | |
}); | |
phantom_links.enter().append('line').attr('class', 'phantom link'); | |
phantom_nodes_layer = enter_views.append('g'); | |
phantom_nodes = phantom_nodes_layer.selectAll('.node').data((function(v) { | |
return graph.nodes.filter(function(n) { | |
return __indexOf.call(n.graphs, v) < 0; | |
}); | |
}), function(d) { | |
return d.id; | |
}); | |
enter_phantom_nodes = phantom_nodes.enter().append('g').attr('class', 'phantom node'); | |
enter_phantom_nodes.append('circle').attr('r', R); | |
/* create nodes and links | |
*/ | |
links_layer = enter_views.append('g'); | |
links = links_layer.selectAll('.link').data((function(v) { | |
return graph.links.filter(function(l) { | |
return __indexOf.call(l.graphs, v) >= 0; | |
}); | |
}), function(d) { | |
return d.id; | |
}); | |
links.enter().append('line').attr('class', 'link'); | |
nodes_layer = enter_views.append('g'); | |
nodes = nodes_layer.selectAll('.node').data((function(v) { | |
return graph.nodes.filter(function(n) { | |
return __indexOf.call(n.graphs, v) >= 0; | |
}); | |
}), function(d) { | |
return d.id; | |
}); | |
enter_nodes = nodes.enter().append('g').attr('class', 'node'); | |
enter_nodes.append('circle').attr('r', R); | |
/* draw the label | |
*/ | |
enter_nodes.append('text').text(function(d) { | |
return d.id; | |
}).attr('dy', '0.35em'); | |
/* cola layout | |
*/ | |
graph.nodes.forEach(function(v) { | |
v.width = 2.5 * R; | |
return v.height = 2.5 * R; | |
}); | |
d3cola = cola.d3adaptor().size([width / 2, height]).linkDistance(70).avoidOverlaps(true).nodes(graph.nodes).links(graph.links).on('tick', function() { | |
/* update nodes and links | |
*/ | |
views.selectAll('.node').attr('transform', function(d) { | |
return "translate(" + d.x + "," + d.y + ")"; | |
}); | |
return views.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; | |
}); | |
}); | |
enter_nodes.call(d3cola.drag); | |
enter_phantom_nodes.call(d3cola.drag); | |
d3cola.start(30, 30, 30); | |
}).call(this); |