app/assets/javascripts/viz/network-graph.js
// We define $j instead of $ to avoid conflict with annotorious.js
// (see https://learn.jquery.com/using-jquery-core/avoid-conflicts-other-libraries/)
$j = jQuery.noConflict();
function NetworkGraph(selector, infobox, undoBtn, redoBtn, historyParams) {
var _this = this;
// Basic D3.js SVG container setup
var $cont = $j(selector),
width = $cont.width(),
height = $cont.height(),
centerx = width / 2,
centery = height / 2,
linkDistance = height * 0.2,
isFullscreen = false;
var infobox = d3.select(infobox);
// History to store actions over graph
var history = new NetworkHistory(undoBtn, redoBtn);
var historyArr = [];
// I keep track of panning+zooming and use the 'drag' behaviour plus two buttons.
// The mousewheel behaviour of the 'zoom' behaviour was annoying, and couldn't
// find a way of disabling it.
var scale = 1;
var viewport_origin_x = 0;
var viewport_origin_y = 0;
var viewport_x = 0;
var viewport_y = 0;
var viewport_dx = 0;
var viewport_dy = 0;
var svg = d3.select(selector).append("svg")
.attr("width", width)
.attr("height", height)
.call(d3.behavior.drag().on("drag", onDrag).on("dragstart", onDragStart).on("dragend", onDragEnd))
.append("g"); // Removing this breaks zooming/panning
rescale(); // translate svg
// Force layout configuration
var force = d3.layout.force()
.on("tick", tick)
.charge(-linkDistance * 5)
.gravity(0.02)
.linkDistance(linkDistance)
.linkStrength(0.6)
.size([width, height]);
// Node drag behaviour
var drag = force.drag()
.on("dragstart", onNodeDragStart)
.on("dragend", onNodeDragEnd);
var drag_x = 0;
var drag_y = 0;
// Visualization data
var nodes = {};
var links = {};
var connectedNodes = {}; // Convenience data, to highlight neighbours
// D3.js selectors, available for tick() method
var nodeRootUrl,
node,
path,
text;
// Arrow marker
svg.append("svg:defs")
.append("svg:marker")
.attr("id", 'relation')
.attr("viewBox", "0 -5 15 10")
.attr("refX", 20)
.attr("refY", -0.5)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("svg:path")
.attr("d", "M0,-5L10,0L0,5");
// Create two separate containers for links and nodes, to make sure
// nodes come always after nodes, even when loading data dynamically
svg.append("g")
.attr("id", "linksContainer");
svg.append("g")
.attr("id", "nodesContainer");
/*** PUBLIC mehods ***/
this.getCont = function(){
return $cont;
};
// Load the root node
this.loadRootNode = function(url) {
nodeRootUrl = url;
loadNode(url);
};
// Zoom controls
this.zoomIn = function() {
scale *= 1.2;
rescale();
};
this.zoomOut = function() {
scale /= 1.2;
rescale();
};
this.zoomReset = function() {
scale = 1;
viewport_x = 0;
viewport_y = 0;
rescale();
};
// History Undo/redo
this.historyUndo = function() {
var item = history.undo();
historyCall( item.action, item.args, true );
};
this.historyRedo = function() {
var item = history.redo();
historyCall( item.action, item.args, false );
};
this.getHistoryParams = function(){
var str = '',
done = history.getDone();
// convert history.done array in a string like that:
// "action1|argKey,argValue|argKey,argValue|argKey,argValue!action2|argKey,argValue!"
done.forEach(function(d){
str += d.action;
for (var key in d.args) {
if (d.args.hasOwnProperty(key)) {
str += '¡'+key+','+d.args[key];
}
}
str += '!';
});
// add scale if is not one
if (scale !== 1) {
str += 'scale¡s,'+scale+'!';
}
return encodeURIComponent(str);
};
function historyCall( action, args, undo ) {
switch (action) {
case 'nodeMove':
nodeMove(args, undo);
break;
case 'nodeExpand':
nodeExpand(args, undo);
break;
case 'nodeContract':
nodeExpand(args, !undo);
break;
case 'viewportMove':
viewportMove(args, undo);
break;
case 'scale':
scale = +args.s;
rescale();
break;
}
}
function setHistoryArray( params ) {
var args;
params = params.split('!');
params.forEach(function(d){
args = {};
d = d.split('¡');
d.forEach(function(e,i){
if (i>0) {
e = e.split(',');
args[e[0]] = e[1];
}
});
if(d[0]){
historyArr.push({action:d[0],args:args});
}
});
}
function historySetup() {
var d, url;
while (historyArr.length > 0) {
url = historyArr[0].args.url;
if (url !== undefined && nodes[url] === undefined) {
return; // We try to apply an action to an element that is not yet loaded
} else {
d = historyArr.shift();
historyCall(d.action,d.args,false);
}
}
}
// Resize visualization
this.resize = function() {
width = $cont.width();
height = $cont.height();
viewport_origin_x = (width * 0.5) - centerx;
viewport_origin_y = (height * 0.5) - centery;
linkDistance = height * 0.2;
// update svg size
d3.select(selector).select('svg')
.attr('width', width)
.attr('height', height);
rescale();
};
/*** PRIVATE mehods ***/
// Update canvas after nodes are added
function display() {
// Create force layout
force
.nodes(d3.values(nodes))
.links(d3.values(links))
.start();
// Relations
path = svg.select("#linksContainer").selectAll(".link")
.data(force.links(), function(d) { return d.id; });
path.enter().append("svg:path")
.attr("id", function(d){ return 'link-'+d.id; })
.attr("class", "link")
.on("mouseover", onLinkMouseOver)
.on("mouseout", onLinkMouseOut)
.attr("marker-end", function(d) { return "url(#relation)"; });
// Nodes
node = svg
.select("#nodesContainer")
.selectAll(".node")
.data(force.nodes(), function(d) { return d.url; });
node.enter().append("g")
.call(drag)
.call(displayNode)
.attr("id", function(d){ return 'node'+d.url.replace(/\//g,'-'); })
.attr("class", getNodeClass)
.on('click', onNodeClick)
.append("text")
.attr("dx", 11)
.attr("dy", ".35em")
.text(function(d) { return d.name; });
}
// NODE METHODS
// Load a node .json & add its related nodes & links
function loadNode(url, posx, posy) {
// If a position is given, preposition child nodes around that.
// Otherwise just put them in the middle of the screen
if(typeof(posx)==='undefined') posx = 0;
if(typeof(posy)==='undefined') posy = 0;
var spinner = showSpinner();
// Adding '.json' is not strictly necessary, since Rails and jQuery will understand
// the Content Type headers of the request. But the Rails cache is mixing up the
// HTML and JSON responses, so the quickest way of fixing that is making sure the
// request URLs are different.
$j.getJSON(url+'.json', function(data) {
// Add the retrieved nodes to the network graph
$j.each(data.nodes, function(key, node) {
addNode(node, url, posx, posy);
});
// Add the retrieved links to the network graph
$j.each(data.links, function(key, link) {
addLink(link);
});
// Go through the relations of child nodes, looking for relations among them
$j.each(data.child_links, function(key, link) {
if ( nodes[link.source] == null ) {
nodes[link.target]['expandable'] = true;
} else if ( nodes[link.target] == null ) {
nodes[link.source]['expandable'] = true;
} else {
addLink(link);
}
});
spinner.stop();
display();
if (url === nodeRootUrl && historyParams !== undefined && historyParams !== '') {
setHistoryArray( decodeURIComponent(historyParams) );
historySetup();
} else if(historyArr.length > 0) { // Check if we have actions waiting to be called
historySetup();
}
});
}
// Remove related nodes & its links
function unloadNode(url) {
$j.each(nodes, function(key,node) {
// filter all nodes to get only nodes loadedBy url
if (node.url !== url && node.url !== nodeRootUrl && node.loadedBy && node.loadedBy.indexOf(url) > -1) {
// check if node has been loadedBy other nodes
if (node.loadedBy.length < 2) {
// Remove links from this node
$j.each(links, function(key,link) {
if (link.source.url == node.url || link.target.url == node.url){
removeLink(link);
}
});
removeNode(node);
}
// update node.loadedBy
node.loadedBy.splice(node.loadedBy.indexOf(url), 1);
}
});
// Update force after remove nodes & links
force
.nodes(d3.values(nodes))
.links(d3.values(links))
.start();
}
// Add a node
function addNode(node, url, posx, posy) {
presetNode(node, posx, posy);
nodes[node.url] = nodes[node.url] || node;
// update loadedBy array
if (node.url !== url && node.url !== nodeRootUrl) {
(nodes[node.url].loadedBy = (nodes[node.url].loadedBy || [])).push( url );
}
}
// Remove a node
function removeNode(node) {
var nodeToDelete = getNodeByUrl(node.url);
// Check if node to delete has child to remove recursively
if( nodeToDelete.classed('expanded') ){
unloadNode( node.url );
}
// Delete node
nodeToDelete.remove(); // from DOM
delete nodes[node.url]; // from data
}
// Display a node in canvas
function displayNode(node) {
node.append("circle")
.on("mouseover", onNodeMouseOver)
.on("mouseout", onNodeMouseOut)
.attr("r", 9);
// We add an image with a expand sign; will be visible only when applicable
node.append("image")
.attr("xlink:href", "/img/plus-sign.png")
.attr("x", -8)
.attr("y", -8)
.attr("class", "expand-icon")
.attr("width", 16)
.attr("height", 16);
}
// Get the node class (root|expandable|expanded)
function getNodeClass(node) {
var nodeClass = node.root ?
"node root" :
(node['expandable'] ? "node expandable" :
(node['expanded'] ? "node expanded" : "node") );
nodeClass += ' cat-'+node.group;
return nodeClass;
}
function expandNode(node) {
node['expandable'] = false; // Not anymore expandable
node['expanded'] = true; // Expanded
getNodeByUrl(node.url) // Update class & change image icon (less)
.attr('class', getNodeClass)
.select('image')
.attr("xlink:href", "/img/less-sign.png");
node.fixed = true; // Fix after 'exploding', feels better
loadNode(node.url, node.x, node.y); // add linked nodes
}
function contractNode(node) {
node['expandable'] = true;
node['expanded'] = false;
getNodeByUrl(node.url) // Update class & change image icon (plus)
.attr('class', getNodeClass)
.select('image')
.attr("xlink:href", "/img/plus-sign.png");
node.fixed = false;
unloadNode(node.url); // remove linked nodes
}
// When load a node, put the related nodes randomly in a circle around
// the existing one to avoid the dizzying start where all the nodes fly around.
function presetNode(node, posx, posy) {
if (node['root']) {
node['fixed'] = true;
node['x'] = posx;
node['y'] = posy;
} else {
var angle = 2 * Math.PI * Math.random();
node['x'] = posx + linkDistance * Math.sin(angle);
node['y'] = posy + linkDistance * Math.cos(angle);
}
}
// LINK METHODS
// Add a link
function addLink(link) {
// Keep track of neighboring nodes
connectedNodes[link.source + "," + link.target] = 1;
link.source = nodes[link.source];
link.target = nodes[link.target];
links[link.id] = links[link.id] || link;
}
// Remove a link
function removeLink(link) {
d3.select('#link-'+link.id).remove(); // from DOM
delete links[link.id]; // from data
}
// EVENT HANDLERS
// Canvas drag handler
function onDrag() {
viewport_x += d3.event.dx;
viewport_y += d3.event.dy;
viewport_dx += d3.event.dx;
viewport_dy += d3.event.dy;
rescale();
d3.event.sourceEvent.stopPropagation(); // silence other listeners
}
function onDragStart() {
d3.select(svg.node().parentNode).style('cursor','move');
}
function onDragEnd() {
if (viewport_dx === 0 && viewport_dy === 0){ return; } // Skip if viewport has no translation
// add viewportMove action to history
history.add({
action: 'viewportMove',
args: { x: viewport_dx, y:viewport_dy }
});
viewport_dx = viewport_dy = 0;
d3.select(svg.node().parentNode).style('cursor','default');
}
// Node drag handlers
function onNodeDragStart(d) {
d3.event.sourceEvent.stopPropagation(); // silence other listeners
drag_x = d.x;
drag_y = d.y;
}
function onNodeDragEnd(d) {
if (drag_x === d.x && drag_y === d.y){ return; } // Skip if has no translation
// add nodeMove action to history
history.add({
action: 'nodeMove',
args: { url:d.url, x:drag_x, y:drag_y, dx:drag_x-d.x, dy:drag_y-d.y, fixed:d.fixed }
});
// fix the node position when the node is dragged
d.fixed = true;
}
// Node click handler
function onNodeClick(d) {
if (d3.event.defaultPrevented) return; // click suppressed
// Check if expand or contract
if ( d['expandable'] ) {
expandNode(d);
// add nodeExpand action to history
history.add({
action: 'nodeExpand',
args: { url:d.url }
});
} else if ( d['expanded'] ) {
contractNode(d);
// add nodeContract action to history
history.add({
action: 'nodeContract',
args: { url:d.url }
});
}
}
// Node over/out handlers
function onNodeMouseOver(d) {
if (d3.select( d3.event.target.parentNode ).classed('root')) { return; }
highlightRelatedNodes(d, 0.2); // Fade-out non-neighbouring nodes
// Display node information
infobox.html('');
renderNodeName(infobox, d);
if ( d.description !== null ) {
infobox.append('span')
.text(' '+d.description+' ');
}
}
function onNodeMouseOut(d) {
if (d3.select( d3.event.target.parentNode ).classed('root')) { return; }
highlightRelatedNodes(d, 1); // Fade-in the whole graph
}
// Link over/out handlers
function onLinkMouseOver(link) {
highlightRelatedNodes(link.source, 0.2); // Fade-out non-neighbouring nodes
// Display basic relation information
infobox.html('');
renderNodeName(infobox, link.source);
infobox.append('span')
.attr('class', 'separator')
.text(link.type);
renderNodeName(infobox, link.target);
// Display date information
if ( link.at !== null ) {
infobox.append('span')
.attr('class', 'separator')
.text('('+link.at+')');
} else if ( link.from !== null || link.to !== null ) {
infobox.append('span')
.attr('class', 'separator')
.text('('+(link.from||'')+' - '+(link.to||'')+')');
}
// Display sources
if ( link.via.length > 0 ) {
sources = infobox.append('span').attr('class', 'sources').text('Fuente: ');
sources.selectAll('.via').data(link.via)
.enter().append('a')
.attr('target', '_blank')
.attr('href', function(d) { return d; })
.text(function(d, i) { return (i+1); });
}
}
function onLinkMouseOut(link) {
highlightRelatedNodes(link.source, 1); // Fade-in the whole graph
}
// HISTORY METHODS
function nodeMove(args, undo) {
var node = nodes[args.url];
node.x = node.px = (undo === true) ? +args.x : (+args.x) - (+args.dx);
node.y = node.py = (undo === true) ? +args.y : (+args.y) - (+args.dy);
node.fixed = (undo === true) ? args.fixed : true;
force.start(); // Restart force layout to update position
}
function nodeExpand(args, undo) {
if (undo === true) {
contractNode( nodes[args.url] );
} else {
expandNode( nodes[args.url] );
}
}
function viewportMove(args, undo) {
args.x = +args.x;
args.y = +args.y;
if (undo === true) {
viewport_x -= args.x;
viewport_y -= args.y;
} else {
viewport_x += args.x;
viewport_y += args.y;
}
rescale();
}
// AUXILIAR METHODS
// Load spinner, using spin.js
function showSpinner() {
var opts = {
lines: 12, // The number of lines to draw
length: 15, // The length of each line
width: 8, // The line thickness
radius: 22, // The radius of the inner circle
corners: 0.5, // Corner roundness (0..1)
rotate: 59, // The rotation offset
direction: 1, // 1: clockwise, -1: counterclockwise
color: '#414141', // #rgb or #rrggbb or array of colors
speed: 1, // Rounds per second
trail: 63, // Afterglow percentage
shadow: false, // Whether to render a shadow
hwaccel: false, // Whether to use hardware acceleration
className: 'spinner', // The CSS class to assign to the spinner
zIndex: 2e9, // The z-index (defaults to 2000000000)
top: 'auto', // Top position relative to parent in px
left: 'auto' // Left position relative to parent in px
};
return new Spinner(opts).spin($cont[0]);
}
// Force layout iteration
function tick() {
path.attr("d", function(d) {
var dx = d.target.x - d.source.x,
dy = d.target.y - d.source.y,
dr = Math.sqrt(dx * dx + dy * dy) * 1.5;
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
});
node.attr("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")";
});
}
// Update translate/scale of canvas
function rescale() {
svg.attr("transform", "translate(" + (centerx + viewport_origin_x + viewport_x) + ","+ (centery + viewport_origin_y + viewport_y) +")scale("+scale+")");
}
// Add node label to infobox on hover
function renderNodeName(parent, node) {
parent.append('a')
.attr('href', node.url)
.attr('target', '_blank')
.append('strong')
.text(node.name)
.append('i')
.attr('class', 'icon-external-link');
}
// Highlighting related nodes on hover
function highlightRelatedNodes(d, opacity) {
node.style("stroke-opacity", function(o) {
thisOpacity = isConnected(d, o) ? 1 : opacity;
this.setAttribute('fill-opacity', thisOpacity);
return thisOpacity;
});
d3.selectAll('.expand-icon')
.style('opacity', opacity);
path.style("stroke-opacity", function(o) {
return o.source === d || o.target === d ? 1 : opacity;
});
if (d3.event.type == "mouseover")
path.attr("marker-end", function(o) { return o.source === d || o.target === d ? "url(#relation)" : null; });
else
path.attr("marker-end", "url(#relation)");
}
// Check if two nodes are connected
function isConnected(a, b) {
return connectedNodes[a.url + "," + b.url] ||
connectedNodes[b.url + "," + a.url] ||
a.url == b.url;
}
function getNodeByUrl(url) {
return d3.select('#node'+url.replace(/\//g,'-'));
}
}