ml-visjs-graph
Version:
Graph visualization for triples stored in MarkLogic, based on the VisJS Network library
1,032 lines (898 loc) • 29.9 kB
JavaScript
if(typeof mlvisjsTpls === 'undefined') {var mlvisjsTpls = {};}
mlvisjsTpls['ml-visjs-graph.js/mlvisjs-graph.html'] = '<div class="row mlvisjs-graph default-style">\n' +
' <div class="col-md-12 graph-controls">\n' +
' <form class="form-inline">\n' +
' <div class="checkbox physics-enabled">\n' +
' <label>\n' +
' <input type="checkbox" name="physicsEnabled" checked="checked">\n' +
' Enable Physics\n' +
' </label>\n' +
' </div>\n' +
' <div class="form-group layout">\n' +
' <label class="sr-only" class="form-control" for="layout"> Layout: </label>\n' +
' <select name="layout">\n' +
' <option value="standard">Standard</option>\n' +
' <option value="hierarchyTop">Hierarchy - Top</option>\n' +
' <option value="hierarchyBottom">Hierarchy - Bottom</option>\n' +
' <option value="hierarchyLeft">Hierarchy - Left</option>\n' +
' <option value="hierarchyRight">Hierarchy - Right</option>\n' +
' </select>\n' +
' </div>\n' +
' </form>\n' +
' </div>\n' +
' <vis-network class="col-md-12"></vis-network>\n' +
'</div>\n' +
'';
/* globals mlvisjsTpls, require */
/* jshint unused:false */
var vis = vis || require('vis');
var mlvisjs = (function () {
'use strict';
// globals
var allEvents = [
'afterDrawing',
'animationFinished',
'beforeDrawing',
'blurEdge',
'blurNode',
'click',
'configChange',
'deselectEdge',
'deselectNode',
'doubleClick',
'dragEnd',
'dragging',
'dragStart',
'hidePopup',
'hold',
'hoverEdge',
'hoverNode',
'initRedraw',
'oncontext',
'release',
'resize',
'select',
'selectEdge',
'selectNode',
'showPopup',
'stabilizationIterationsDone',
'stabilizationProgress',
'stabilized',
'startStabilizing',
'zoom'
];
var allTimelineEvents = [
'currentTimeTick',
'click',
'contextmenu',
'doubleClick',
'drop',
'mouseOver',
'mouseDown',
'mouseUp',
'mouseMove',
'groupDragged',
'changed',
'rangechange',
'rangechanged',
'select',
'itemover',
'itemout',
'timechange',
'timechanged'
];
// static defaults
var initialPhysics = true;
var initialSolver = 'forceAtlas2Based';
var initialLayout = 'standard';
var initialOptions = {
layout: {
hierarchical: false,
randomSeed: 2
},
manipulation: {
enabled: false// ,
// addNode: false,
// addEdge: false,
// editEdge: false
},
interaction: {
navigationButtons: true
},
height: '500px',
nodes: {
size: 30,
borderWidth: 2,
shadow: true,
borderWidthSelected: 6,
color: {
background: 'white'
},
font: {
size: 12
},
},
physics: {
enabled: initialPhysics,
solver: initialSolver,
// built-in default
// forceAtlas2Based: {
// gravitationalConstant: -50,
// centralGravity: 0.01,
// springLength: 100,
// springConstant: 0.08,
// damping: 0.4,
// avoidOverlap: 0
// },
// GJo tweaks
forceAtlas2Based: {
gravitationalConstant: -200,
centralGravity: 0.01,
springLength: 100,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 0
},
maxVelocity: 150, // default 50
minVelocity: 6, // default 0.1
stabilization: {
enabled: true,
iterations: 1000,
updateInterval: 100,
onlyDynamicEdges: false,
fit: false
},
timestep: 0.5,
adaptiveTimestep: true
},
edges: {
width: 2,
shadow: true,
arrows: {
to: {
enabled: true,
scaleFactor: 0.75
}
},
font: {
size: 10,
align: 'top'
},
smooth: {
type: 'curvedCW',
roundness: 0.1
}
}
};
var initialTimelineOptions = {};
var initialOrbColors = {
// light colors for the odd positions
NW: '#848484', // light grey
NE: '#428484', // light blue(ish)
SW: '#844284', // purple
SE: '#848442', // mustard
// dark colors for the straight positions
N: '#424284', // dark blue(ish)
E: '#428442', // green
S: '#844242', // brown
W: '#424242' // dark grey
};
// applied Module and Prototype pattern as described in
// https://scotch.io/bar-talk/4-javascript-design-patterns-you-should-know
var NetworkManager = function(container) {
var self = this;
// assert visjs is loaded and available
if (!vis || !vis.DataSet || !vis.Network) {
throw 'Error: vis.DataSet and vis.Network not found, required by mlvisjs';
}
// store arguments as is
self.container = container;
// dynamic defaults
var nodes = new vis.DataSet();
var edges = new vis.DataSet();
var initialData = {
nodes: nodes,
edges: edges
};
self.nodes = nodes;
self.edges = edges;
self.options = clone(initialOptions);
self.orbColors = clone(initialOrbColors);
self.physics = initialPhysics;
self.solver = initialSolver;
// initialize visjs Network
self.network = new vis.Network(self.container, initialData, clone(self.options));
// apply defaults
self.setEvents();
self.setLayout();
self.setPhysics();
self.setSolver();
};
NetworkManager.prototype = (function() {
var setData = function(nodes, nodeOptions, edges, edgeOptions) {
var self = this;
var existingIds, newIds, missingIds;
if (arguments.length > 0) {
if (self.nodes && nodes) {
// flush what has disappeared
existingIds = self.nodes.getIds();
newIds = nodes.map(function(node) {
return node.id;
});
missingIds = existingIds.filter(function(id) {
return !newIds.includes(id);
});
self.nodes.remove(missingIds);
// add new, and update existing
self.nodes.update(nodes);
if (nodeOptions) {
self.nodes.setOptions(clone(nodeOptions));
}
} else if (nodes) {
throw 'Error: nodes DataSet not initialized';
}
if (self.edges && edges) {
// flush what has disappeared
existingIds = self.edges.getIds();
newIds = edges.map(function(edge) {
return edge.id;
});
missingIds = existingIds.filter(function(id) {
return !newIds.includes(id);
});
self.edges.remove(missingIds);
// add new, and update existing
self.edges.update(edges);
if (edgeOptions) {
self.edges.setOptions(clone(edgeOptions));
}
} else if (edges) {
throw 'Error: edges DataSet not initialized';
}
} else {
nodes = new vis.DataSet();
edges = new vis.DataSet();
var initialData = {
nodes: nodes,
edges: edges
};
self.nodes = nodes;
self.edges = edges;
if (nodeOptions) {
self.nodes.setOptions(clone(nodeOptions));
}
if (edgeOptions) {
self.edges.setOptions(clone(edgeOptions));
}
if (self.network) {
self.network.setData(initialData);
self.network.fit();
}
}
};
var setEvents = function(events) {
var self = this;
var doubleclick;
if (self.network) {
if (arguments.length === 0) {
allEvents.forEach(function(event) {
self.network.off(event);
});
}
events = events || {};
// provide default for stabilized
events.stabilized = events.stabilized || function() {
//self.network.fit();
};
// afterDrawing is used to paint orbs and coronas
events.afterDrawing = events.afterDrawing || function() {};
forEach(events, function(callback, event) {
if (isFunction(callback)) {
// decorate provided event callbacks with built-in extras
switch (String(event)) {
case 'click':
self.network.on(event, function(arg) {
doubleclick = false;
window.setTimeout(function() {
if (! doubleclick) {
callback.call(self, arg);
}
}, 300);
});
break;
case 'doubleClick':
self.network.on(event, function(arg) {
doubleclick = true;
callback.call(self, arg);
});
break;
case 'afterDrawing':
self.network.on(event, function(arg) {
var ctx = arg;
var nodePositions = self.network.getPositions();
self.nodes.forEach(function(node) {
var nodePosition = nodePositions[node.id];
if (nodePosition) {
node.orbs = node.orbs || {};
// Backwards compatibility
var edgeCount = node.edgeCount || node.linkCount;
if (edgeCount && (edgeCount > 0) && !node.orbs.NW) {
node.orbs.NW = {
label: ''+edgeCount
};
}
forEach(node.orbs, function(orb, key) {
var x = nodePosition.x;
var y = nodePosition.y;
if (key.match(/W/)) {
x = x - (key.match(/[NS]/) ? 22 : 30);
} else if (key.match(/E/)) {
x = x + (key.match(/[NS]/) ? 22 : 30);
}
if (key.match(/N/)) {
y = y - (key.match(/[WE]/) ? 22 : 30);
} else if (key.match(/S/)) {
y = y + (key.match(/[WE]/) ? 22 : 30);
}
var label = orb.label;
var font = orb.font || '10px Arial';
var measure = (label && getMeasureOfText(label, font)) ||
{
width: orb.width || 10,
height: orb.height || 10
};
var radius = Math.round(
(orb.width ||
Math.max(measure.width, measure.height) ||
10
) / 2
) + 3;
var lineStyle = orb.lineStyle || 'white';
var background = orb.background || self.orbColors[key];
var lineWidth = orb.lineWidth || 1;
var textColor = orb.textColor || 'white';
// Circle
ctx.strokeStyle = lineStyle;
ctx.fillStyle = background;
ctx.lineWidth = lineWidth;
ctx.circle(x, y, Math.max(radius, 10));
ctx.fill();
ctx.stroke();
// Text info
if (label) {
ctx.font = font;
ctx.fillStyle = textColor;
ctx.fillText(
label,
x - (measure.width / 2),
y + 1 // compensating for the padding
);
} else if (orb.image) {
var img = new Image();
img.src = orb.image;
ctx.drawImage(
img,
x - (measure.width / 2),
y - (measure.height / 2),
measure.width,
measure.height
);
}
});
}
});
callback.call(self, arg);
});
break; // case 'afterDrawing'
case 'onload':
callback(self);
break;
default:
self.network.on(event, callback);
} // switch
}
}); // forEach(events)
}
}; // setEvents
var setOptions = function(networkOptions) {
var self = this;
if (networkOptions !== undefined) {
self.options = self.options || clone(initialOptions);
// merge options (crude method)
Object.keys(networkOptions).forEach(function(key) {
var option = networkOptions[key];
if ((typeof option === 'object') && (option !== null) && !Array.isArray(option) && !(Object.prototype.toString.call(option) === '[object Date]')) {
self.options[key] = self.options[key] || {};
Object.keys(option).forEach(function(subkey) {
var suboption = option[subkey];
self.options[key][subkey] = clone(suboption);
});
} else {
self.options[key] = option;
}
});
} else {
self.options = clone(initialOptions);
}
if (self.network) {
self.network.setOptions(clone(self.options));
}
};
var setOrbColors = function(colors) {
var self = this;
if (colors !== undefined) {
forEach(colors, function(color, key) {
self.orbColors[key] = color;
});
} else {
self.orbColors = clone(initialOrbColors);
}
if (self.network) {
self.network.redraw();
}
};
var setPhysics = function(physics) {
var self = this;
if (physics !== undefined) {
self.physics = physics;
} else {
self.physics = initialPhysics;
}
if (self.network) {
// keep a shadow
self.options.physics = self.options.physics || {};
self.options.physics.enabled = self.physics;
// only set diff
self.network.setOptions({
physics: {
enabled: self.physics
}
});
if (self.physics) {
self.network.stabilize();
}
}
};
var setSolver = function(solver) {
var self = this;
if (solver !== undefined) {
self.solver = solver;
} else {
self.solver = initialSolver;
}
// keep shadow
self.options.physics = self.options.physics || {};
self.options.physics.solver = self.solver;
self.options.layout = self.options.layout || {};
self.options.layout.hierarchical = self.options.layout.hierarchical || {};
self.options.layout.hierarchical.enabled = (self.solver === 'hierarchicalRepulsion');
if (self.network) {
// only set diff
self.network.setOptions({
physics: {
solver: self.solver
},
layout: {
hierarchical: {
enabled: (self.solver === 'hierarchicalRepulsion')
}
}
});
self.network.stabilize();
}
};
var setLayout = function(layout) {
var self = this;
if (layout !== undefined) {
self.layout = layout;
} else {
self.layout = initialLayout;
}
// keep shadow
self.options.layout = self.options.layout || {};
self.options.layout.hierarchical = self.options.layout.hierarchical || {};
if (self.layout === 'standard') {
self.options.layout.hierarchical.enabled = false;
}
else if (self.layout === 'hierarchyTop') {
self.solver = 'hierarchicalRepulsion';
self.options.layout.hierarchical = {
enabled: true,
direction: 'UD',
sortMethod: 'directed'
};
if (self.options.edges && self.options.edges.smooth && self.options.edges.smooth.type === 'vertical') {
self.options.edges.smooth.type = 'horizontal';
}
}
else if (self.layout === 'hierarchyBottom') {
self.solver = 'hierarchicalRepulsion';
self.options.layout.hierarchical = {
enabled: true,
direction: 'DU',
sortMethod: 'directed'
};
if (self.options.edges && self.options.edges.smooth && self.options.edges.smooth.type === 'vertical') {
self.options.edges.smooth.type = 'horizontal';
}
}
else if (self.layout === 'hierarchyLeft') {
self.solver = 'hierarchicalRepulsion';
self.options.layout.hierarchical = {
enabled: true,
direction: 'LR',
sortMethod: 'directed'
};
if (self.options.edges && self.options.edges.smooth && self.options.edges.smooth.type === 'horizontal') {
self.options.edges.smooth.type = 'vertical';
}
}
else if (self.layout === 'hierarchyRight') {
self.solver = 'hierarchicalRepulsion';
self.options.layout.hierarchical = {
enabled: true,
direction: 'RL',
sortMethod: 'directed'
};
if (self.options.edges && self.options.edges.smooth && self.options.edges.smooth.type === 'horizontal') {
self.options.edges.smooth.type = 'vertical';
}
}
self.options.physics = self.options.physics || {};
self.options.physics.solver = self.solver;
if (self.network) {
// only set diff
self.network.setOptions({
physics: {
solver: self.solver
},
layout: {
hierarchical: clone(self.options.layout.hierarchical)
},
edges: {
smooth: clone(self.options.edges.smooth)
}
});
self.network.stabilize();
}
};
return {
setData: setData,
setEvents: setEvents,
setOptions: setOptions,
setOrbColors: setOrbColors,
setPhysics: setPhysics,
setSolver: setSolver,
setLayout: setLayout
};
})();
var GraphManager = function(container, templateUri, templateCache, done, fail) {
var self = this;
self.container = container;
self.templateUri = templateUri || 'ml-visjs-graph.js/mlvisjs-graph.html';
self.templateCache = templateCache;
var initContainer = function(container, template) {
// insert the template
container.innerHTML = template;
// hook up visjs network
var network = container.getElementsByTagName('vis-network');
if (network.length === 1) {
self.network = new NetworkManager(network[0]);
// hook up user interaction
var physics = container.querySelectorAll('input[name="physicsEnabled"]');
if (physics.length === 1) {
physics[0].checked = self.network.physics;
physics[0].onchange = function(event) {
event.preventDefault();
self.network.setPhysics(physics[0].checked);
};
} else if (physics.length > 1) {
fail('Only one physicsEnabled input supported in the graph template');
} else {
fail('No physicsEnabled input found in the graph template');
}
var layout = container.querySelectorAll('select[name="layout"]');
if (layout.length === 1) {
layout[0].value = self.network.layout;
layout[0].onchange = function(event) {
event.preventDefault();
self.network.setLayout(layout[0].value);
};
} else if (layout.length > 1) {
fail('Only one layout selector supported in the graph template');
} else {
fail('No layout selector found in the graph template');
}
done(self);
} else if (network.length > 1) {
fail('Only one vis-network supported in the graph template');
} else {
fail('No vis-network found in the graph template');
}
};
self.template = templateCache && templateCache.get(self.templateUri);
if (!self.template) {
if (self.templateUri === 'ml-visjs-graph.js/mlvisjs-graph.html') {
self.template = mlvisjsTpls['ml-visjs-graph.js/mlvisjs-graph.html'];
if (self.templateCache) {
self.templateCache.put(self.templateUri, self.template);
}
initContainer(self.container, self.template);
} else {
httpGetAsync(self.templateUri, function(response) {
self.template = response;
if (self.templateCache) {
self.templateCache.put(self.templateUri, self.template);
}
initContainer(self.container, self.template);
}, function(failure) {
fail(failure);
});
}
} else {
initContainer(self.container, self.template);
}
};
GraphManager.prototype = (function() {
var setPhysics = function(physics) {
var self = this;
var elem = self.container.querySelectorAll('input[name="physicsEnabled"]');
if (elem.length === 1) {
elem[0].checked = physics;
if (self.network) {
self.network.setPhysics(physics);
}
}
};
var setLayout = function(layout) {
var self = this;
var elem = self.container.querySelectorAll('select[name="layout"]');
if (elem.length === 1) {
elem[0].value = layout;
if (self.network) {
self.network.setLayout(layout);
}
}
};
var setStyling = function(styling) {
var self = this;
self.styling = styling;
var root = self.container.firstElementChild;
root.className = root.className.split(' ').filter(function(c) {
return c.indexOf('-style') < 0;
}).join(' ') + ' ' + styling + '-style';
};
return {
setPhysics: setPhysics,
setLayout: setLayout,
setStyling: setStyling
};
})();
var TimelineManager = function(container) {
var self = this;
// assert visjs is loaded and available
if (!vis || !vis.DataSet || !vis.Timeline) {
throw 'Error: vis.DataSet and vis.Timeline not found, required by mlvisjs';
}
// store arguments as is
self.container = container;
// dynamic defaults
var items = new vis.DataSet();
var groups = new vis.DataSet();
self.items = items;
self.groups = groups;
self.options = clone(initialTimelineOptions);
// initialize visjs Timeline
self.timeline = new vis.Timeline(self.container, self.items, self.groups, clone(self.options));
// apply defaults
self.setEvents();
};
TimelineManager.prototype = (function() {
var setData = function(items, itemOptions, groups, groupOptions) {
var self = this;
var existingIds, newIds, missingIds;
if (arguments.length > 0) {
if (self.items && items) {
// flush what has disappeared
existingIds = self.items.getIds();
newIds = items.map(function(item) {
return item.id;
});
missingIds = existingIds.filter(function(id) {
return !newIds.includes(id);
});
self.items.remove(missingIds);
// add new, and update existing
self.items.update(items);
if (itemOptions) {
self.items.setOptions(clone(itemOptions));
}
} else if (items) {
throw 'Error: items DataSet not initialized';
}
if (self.groups && groups) {
// flush what has disappeared
existingIds = self.groups.getIds();
newIds = groups.map(function(group) {
return group.id;
});
missingIds = existingIds.filter(function(id) {
return !newIds.includes(id);
});
self.groups.remove(missingIds);
// add new, and update existing
self.groups.update(groups);
if (groupOptions) {
self.groups.setOptions(clone(groupOptions));
}
} else if (groups) {
throw 'Error: groups DataSet not initialized';
}
} else {
items = new vis.DataSet();
groups = new vis.DataSet();
var initialData = {
items: items,
groups: groups
};
self.items = items;
self.groups = groups;
if (itemOptions) {
self.items.setOptions(clone(itemOptions));
}
if (groupOptions) {
self.groups.setOptions(clone(groupOptions));
}
if (self.timeline) {
self.timeline.setData(initialData);
self.timeline.fit();
}
}
};
var setEvents = function(events) {
var self = this;
var doubleclick;
if (self.timeline) {
if (arguments.length === 0) {
allTimelineEvents.forEach(function(event) {
self.timeline.off(event);
});
}
events = events || {};
forEach(events, function(callback, event) {
if (isFunction(callback)) {
// decorate provided event callbacks with built-in extras
switch (String(event)) {
case 'click':
self.timeline.on(event, function(arg) {
doubleclick = false;
window.setTimeout(function() {
if (! doubleclick) {
callback.call(self, arg);
}
}, 300);
});
break;
case 'doubleClick':
self.timeline.on(event, function(arg) {
doubleclick = true;
callback.call(self, arg);
});
break;
case 'onload':
callback(self);
break;
default:
self.timeline.on(event, callback);
} // switch
}
}); // forEach(events)
}
}; // setEvents
var setOptions = function(timelineOptions) {
var self = this;
if (timelineOptions !== undefined) {
self.options = self.options || clone(initialTimelineOptions);
// merge options (crude method)
Object.keys(timelineOptions).forEach(function(key) {
var option = timelineOptions[key];
if ((typeof option === 'object') && (option !== null) && !Array.isArray(option) && !(Object.prototype.toString.call(option) === '[object Date]')) {
self.options[key] = self.options[key] || {};
Object.keys(option).forEach(function(subkey) {
var suboption = option[subkey];
self.options[key][subkey] = clone(suboption);
});
} else {
self.options[key] = option;
}
});
} else {
self.options = clone(initialTimelineOptions);
}
if (self.timeline) {
self.timeline.setOptions(clone(self.options));
}
};
return {
setData: setData,
setEvents: setEvents,
setOptions: setOptions
};
})();
return {
Network: NetworkManager,
Graph: GraphManager,
Timeline: TimelineManager
};
/* HELPER FUNCTIONS */
// derived from angular
function forEach(obj, iterator, context) {
var key;
if (obj) {
for (key in obj) {
if (obj.hasOwnProperty(key)) {
iterator.call(context, obj[key], key, obj);
}
}
}
return obj;
}
// https://stackoverflow.com/questions/2057682/determine-pixel-length-of-string-in-javascript-jquery
function getMeasureOfText(txt, font) {
if (getMeasureOfText.e === undefined) {
getMeasureOfText.e = document.createElement('span');
//getMeasureOfText.e.style.display = 'hidden';
}
getMeasureOfText.e.style.font = font;
getMeasureOfText.e.innerText = txt;
document.body.appendChild(getMeasureOfText.e);
var measure = {
width: getMeasureOfText.e.offsetWidth,
height: getMeasureOfText.e.offsetHeight
};
document.body.removeChild(getMeasureOfText.e);
return measure;
}
// https://stackoverflow.com/questions/247483/http-get-request-in-javascript
function httpGetAsync(url, callback, fail) {
var xmlHttp = new XMLHttpRequest();
xmlHttp.open('GET', url, true); // true for asynchronous
xmlHttp.onreadystatechange = function() {
if (xmlHttp.readyState === 4) {
if (xmlHttp.status >= 200 && xmlHttp.status < 400) {
callback(xmlHttp.responseText);
} else {
fail(xmlHttp.responseText);
}
}
};
xmlHttp.send(null);
}
// copied from angular
function isFunction(value) {
return typeof value === 'function';
}
function clone(obj) {
if (Array.isArray(obj)) {
return obj.map(function(item) {
return clone(item);
});
} else if (Object.prototype.toString.call(obj) === '[object Date]') {
return new Date(obj.valueOf());
} else if ( (typeof obj === 'object') && (obj !== null) ) {
return Object.keys(obj).reduce(
function(res, e) {
res[e] = clone(obj[e]);
return res;
},
{}
);
} else {
return obj;
}
}
})();
/* globals mlvisjs, module */
// export this module
// (https://stackoverflow.com/questions/12571737/module-exports-client-side)
(typeof module !== 'undefined' && module !== null ? module : {}).exports = this.mlvisjs = mlvisjs;