- 1 :
/* global d3 */
- 2 :
- 3 :
/**
- 4 :
* A force directed layout network graph with labeled nodes.
- 5 :
* Uses the [d3]{@link https://d3js.org/d3-force} library for rendering.
- 6 :
*
- 7 :
* This graph should be created via the [Spyral.Chart.networkgraph]{@link Spyral.Chart.networkgraph} method.
- 8 :
* @class
- 9 :
*
- 10 :
* @example
- 11 :
*
- 12 :
* var nodes = [{
- 13 :
* term: 'foo', value: 15
- 14 :
* },{
- 15 :
* term: 'bar', value: 3
- 16 :
* },{
- 17 :
* term: 'baz', value: 4
- 18 :
* }]
- 19 :
* var links = [{
- 20 :
* source: 'foo', target: 'bar'
- 21 :
* },{
- 22 :
* source: 'foo', target: 'baz'
- 23 :
* }]
- 24 :
* Spyral.Chart.networkgraph({
- 25 :
* nodes: nodes, nodeIdField: 'term',
- 26 :
* links: links
- 27 :
* })
- 28 :
*/
- 29 :
class NetworkGraph {
- 30 :
- 31 :
physics = {
- 32 :
damping: 0.4, // 0 = no damping, 1 = full damping
- 33 :
centralGravity: 0.1, // 0 = no grav, 1 = high grav
- 34 :
nodeGravity: -50, // negative = repel, positive = attract
- 35 :
springLength: 100,
- 36 :
springStrength: 0.25, // 0 = not strong, >1 = probably too strong
- 37 :
collisionScale: 1.25 // 1 = default, 0 = no collision
- 38 :
}
- 39 :
- 40 :
/**
- 41 :
* The NetworkGraph config
- 42 :
* @typedef {Object} NetworkGraph~Config
- 43 :
* @property {Array} config.nodes An array of node objects
- 44 :
* @property {Array} config.links An array of link objects
- 45 :
* @property {String|Function} [config.nodeIdField=id] The name of the ID field in the node object, or a function for accessing that field. Default is "id".
- 46 :
* @property {String|Function} [config.nodeLabelField] The name of the label field in the node object, or a function for accessing that field. If not specified, nodeIdField will be used.
- 47 :
* @property {String|Function} [config.nodeValueField=value] The name of the value field in the node object, or a function for accessing that field. Default is "value".
- 48 :
* @property {String|Function} [config.nodeCategoryField=category] The name of the category field in the node object, or a function for accessing that field. Default is "category". This applies a category attribute to the node in the graph which can then be used for targeting or styling purposes.
- 49 :
* @property {String|Function} [config.linkSourceField=source] The name of the source field in the link object, or a function for accessing that field. Default is "source".
- 50 :
* @property {String|Function} [config.linkTargetField=target] The name of the target field in the link object, or a function for accessing that field. Default is "target".
- 51 :
* @property {String|Function} [config.linkValueField=value] The name of the value field in the link object, or a function for accessing that field. Default is "value".
- 52 :
*/
- 53 :
- 54 :
/**
- 55 :
* Construct a new NetworkGraph class
- 56 :
* @constructor
- 57 :
* @param {HTMLElement} target The element to render the graph to
- 58 :
* @param {NetworkGraph~Config} config The NetworkGraph config
- 59 :
*
- 60 :
* @return {NetworkGraph}
- 61 :
*/
- 62 :
constructor(target, config) {
- 63 :
this.target = target;
- 64 :
- 65 :
if (config.nodes === undefined) throw new Error('Missing nodes!');
- 66 :
if (config.links === undefined) throw new Error('Missing links!');
- 67 :
- 68 :
const nodeIdField = config.nodeIdField === undefined ? 'id' : config.nodeIdField;
- 69 :
const nodeLabelField = config.nodeLabelField === undefined ? nodeIdField : config.nodeLabelField;
- 70 :
const nodeValueField = config.nodeValueField === undefined ? 'value' : config.nodeValueField;
- 71 :
const nodeCategoryField = config.nodeCategoryField === undefined ? 'category' : config.nodeCategoryField;
- 72 :
- 73 :
this.nodeData = config.nodes.map(node => {
- 74 :
return {
- 75 :
id: this._idGet(typeof nodeIdField === 'string' ? node[nodeIdField] : nodeIdField(node)),
- 76 :
label: typeof nodeLabelField === 'string' ? node[nodeLabelField] : nodeLabelField(node),
- 77 :
value: typeof nodeValueField === 'string' ? node[nodeValueField] : nodeValueField(node),
- 78 :
category: typeof nodeCategoryField === 'string' ? node[nodeCategoryField] : nodeCategoryField(node)
- 79 :
};
- 80 :
});
- 81 :
- 82 :
const linkSourceField = config.linkSourceField === undefined ? 'source' : config.linkSourceField;
- 83 :
const linkTargetField = config.linkTargetField === undefined ? 'target' : config.linkTargetField;
- 84 :
const linkValueField = config.linkValueField === undefined ? 'value' : config.linkValueField;
- 85 :
- 86 :
this.linkData = config.links.map(link => {
- 87 :
const sourceId = this._idGet(typeof linkSourceField === 'string' ? link[linkSourceField] : linkSourceField(link));
- 88 :
const targetId = this._idGet(typeof linkTargetField === 'string' ? link[linkTargetField] : linkTargetField(link));
- 89 :
const linkId = sourceId+'-'+targetId;
- 90 :
return {
- 91 :
id: linkId,
- 92 :
source: sourceId,
- 93 :
target: targetId,
- 94 :
value: link[linkValueField]
- 95 :
};
- 96 :
});
- 97 :
- 98 :
this.simulation;
- 99 :
this.zoom;
- 100 :
this.parentEl;
- 101 :
this.links;
- 102 :
this.nodes;
- 103 :
- 104 :
this._insertStyles();
- 105 :
this.initGraph();
- 106 :
- 107 :
return this;
- 108 :
}
- 109 :
- 110 :
initGraph() {
- 111 :
const width = this.target.offsetWidth;
- 112 :
const height = this.target.offsetHeight;
- 113 :
- 114 :
const svg = d3.select(this.target).append('svg')
- 115 :
.attr('viewBox', [0, 0, width, height]);
- 116 :
- 117 :
this.parentEl = svg.append('g');
- 118 :
- 119 :
this.links = this.parentEl.append('g').attr('class', 'spyral-ng-links').selectAll('.spyral-ng-link');
- 120 :
this.nodes = this.parentEl.append('g').attr('class', 'spyral-ng-nodes').selectAll('.spyral-ng-node');
- 121 :
- 122 :
this.simulation = d3.forceSimulation()
- 123 :
.force('center', d3.forceCenter(width*.5, height*.5)
- 124 :
// .strength(this.physics.centralGravity)
- 125 :
)
- 126 :
.force('link', d3.forceLink().id(d => d.id).distance(this.physics.springLength).strength(this.physics.springStrength))
- 127 :
.force('charge', d3.forceManyBody().strength(this.physics.nodeGravity))
- 128 :
.force('collide', d3.forceCollide(d => Math.sqrt(d.bbox.width * d.bbox.height) * this.physics.collisionScale))
- 129 :
.on('tick', this._ticked.bind(this))
- 130 :
// TODO need to update sandbox cached output when simulation is done running
- 131 :
.on('end', this._zoomToFit.bind(this));
- 132 :
- 133 :
const link = this.links.data(this.linkData);
- 134 :
link.exit().remove();
- 135 :
const linkEnter = link.enter().append('line')
- 136 :
.attr('class', 'spyral-ng-link')
- 137 :
.attr('id', d => d.id)
- 138 :
.on('mouseover', this._linkMouseOver.bind(this))
- 139 :
.on('mouseout', this._linkMouseOut.bind(this));
- 140 :
- 141 :
this.links = linkEnter.merge(link);
- 142 :
- 143 :
const node = this.nodes.data(this.nodeData);
- 144 :
node.exit().remove();
- 145 :
const nodeEnter = node.enter().append('g')
- 146 :
.attr('class', 'spyral-ng-node')
- 147 :
.attr('id', d => d.id)
- 148 :
.attr('category', d => d.category)
- 149 :
.on('mouseover', this._nodeMouseOver.bind(this))
- 150 :
.on('mouseout', this._nodeMouseOut.bind(this))
- 151 :
.on('click', function(data) {
- 152 :
d3.event.stopImmediatePropagation();
- 153 :
d3.event.preventDefault();
- 154 :
this._nodeClick(data);
- 155 :
}.bind(this))
- 156 :
.on('contextmenu', (d) => {
- 157 :
d3.event.preventDefault();
- 158 :
d.fixed = false;
- 159 :
d.fx = null;
- 160 :
d.fy = null;
- 161 :
})
- 162 :
.call(d3.drag()
- 163 :
.on('start', function(d) {
- 164 :
if (!d3.event.active) this.simulation.alpha(0.3).restart();
- 165 :
d.fx = d.x;
- 166 :
d.fy = d.y;
- 167 :
d.fixed = true;
- 168 :
}.bind(this))
- 169 :
.on('drag', function(d) {
- 170 :
this.simulation.alpha(0.3); // don't let simulation end while the user is dragging
- 171 :
d.fx = d3.event.x;
- 172 :
d.fy = d3.event.y;
- 173 :
}.bind(this))
- 174 :
.on('end', function(d) {
- 175 :
// if (!d3.event.active) me.getVisLayout().alpha(0);
- 176 :
if (d.fixed !== true) {
- 177 :
d.fx = null;
- 178 :
d.fy = null;
- 179 :
}
- 180 :
})
- 181 :
);
- 182 :
- 183 :
nodeEnter.append('rect');
- 184 :
- 185 :
nodeEnter.append('text')
- 186 :
.text(d => d.label)
- 187 :
.attr('font-size', d => d.value ? Math.max(10, Math.sqrt(d.value)*8) : 10)
- 188 :
.each(function(d) { d.bbox = this.getBBox(); }) // set bounding box for later use
- 189 :
.attr('dominant-baseline', 'central');
- 190 :
- 191 :
this.nodes = nodeEnter.merge(node);
- 192 :
- 193 :
this.parentEl.selectAll('rect')
- 194 :
.attr('width', d => d.bbox.width+16)
- 195 :
.attr('height', d => d.bbox.height+8)
- 196 :
.attr('rx', d => Math.max(2, d.bbox.height * 0.2))
- 197 :
.attr('ry', d => Math.max(2, d.bbox.height * 0.2));
- 198 :
this.parentEl.selectAll('text')
- 199 :
.attr('dx', 8)
- 200 :
.attr('dy', d => d.bbox.height*0.5+4);
- 201 :
- 202 :
this.zoom = d3.zoom()
- 203 :
.scaleExtent([1/4, 4])
- 204 :
.on('zoom', function() {
- 205 :
this.parentEl.attr('transform', d3.event.transform);
- 206 :
}.bind(this));
- 207 :
svg.call(this.zoom);
- 208 :
- 209 :
this.simulation.nodes(this.nodeData);
- 210 :
this.simulation.force('link').links(this.linkData);
- 211 :
}
- 212 :
- 213 :
_nodeMouseOver(node) {
- 214 :
this.parentEl.selectAll('.spyral-ng-node').each((d, i, nodes) => nodes[i].classList.remove('hover'));
- 215 :
- 216 :
this.links.each(link => {
- 217 :
let id;
- 218 :
if (link.source.id === node.id) {
- 219 :
id = link.target.id;
- 220 :
} else if (link.target.id === node.id) {
- 221 :
id = link.source.id;
- 222 :
}
- 223 :
if (id) {
- 224 :
this.parentEl.select('#'+id).each((d, i, nodes) => nodes[i].classList.add('hover'));
- 225 :
this.parentEl.select('#'+link.id).each((d, i, links) => links[i].classList.add('hover'));
- 226 :
}
- 227 :
});
- 228 :
- 229 :
this.parentEl.select('#'+node.id).each((d, i, nodes) => nodes[i].classList.add('hover'));
- 230 :
}
- 231 :
- 232 :
_nodeMouseOut() {
- 233 :
this.parentEl.selectAll('.spyral-ng-node, .spyral-ng-link').each((d, i, nodes) => nodes[i].classList.remove('hover'));
- 234 :
}
- 235 :
- 236 :
_nodeClick(node) {
- 237 :
console.log('click', node);
- 238 :
}
- 239 :
- 240 :
_linkMouseOver(link) {
- 241 :
this.parentEl.selectAll('.spyral-ng-link').each((d, i, links) => links[i].classList.remove('hover'));
- 242 :
this.parentEl.select('#'+link.id).each((d, i, links) => links[i].classList.add('hover'));
- 243 :
}
- 244 :
- 245 :
_linkMouseOut() {
- 246 :
this.parentEl.selectAll('.spyral-ng-link').each((d, i, links) => links[i].classList.remove('hover'));
- 247 :
}
- 248 :
- 249 :
_ticked() {
- 250 :
this.links
- 251 :
.attr('x1', d => d.source.x)
- 252 :
.attr('y1', d => d.source.y)
- 253 :
.attr('x2', d => d.target.x)
- 254 :
.attr('y2', d => d.target.y);
- 255 :
- 256 :
this.nodes
- 257 :
.attr('transform', d => {
- 258 :
let x = d.x;
- 259 :
let y = d.y;
- 260 :
x -= d.bbox.width*.5;
- 261 :
y -= d.bbox.height*.5;
- 262 :
return 'translate('+x+','+y+')';
- 263 :
});
- 264 :
}
- 265 :
- 266 :
_idGet(term) {
- 267 :
if (term.search(/^\d+$/) === 0) {
- 268 :
return 'spyral_'+term;
- 269 :
}
- 270 :
return term.replace(/\W/g, '_');
- 271 :
}
- 272 :
- 273 :
_zoomToFit(paddingPercent, transitionDuration) {
- 274 :
var bounds = this.parentEl.node().getBBox();
- 275 :
var width = bounds.width;
- 276 :
var height = bounds.height;
- 277 :
var midX = bounds.x + width/2;
- 278 :
var midY = bounds.y + height/2;
- 279 :
var svg = this.parentEl.node().parentElement;
- 280 :
var svgRect = svg.getBoundingClientRect();
- 281 :
var fullWidth = svgRect.width;
- 282 :
var fullHeight = svgRect.height;
- 283 :
var scale = (paddingPercent || 0.8) / Math.max(width/fullWidth, height/fullHeight);
- 284 :
var translate = [fullWidth/2 - scale*midX, fullHeight/2 - scale*midY];
- 285 :
if (width<1) {return;} // FIXME: something strange with spyral
- 286 :
- 287 :
d3.select(svg)
- 288 :
.transition()
- 289 :
.duration(transitionDuration || 500)
- 290 :
.call(this.zoom.transform, d3.zoomIdentity.translate(translate[0],translate[1]).scale(scale));
- 291 :
}
- 292 :
- 293 :
_resize() {
- 294 :
- 295 :
}
- 296 :
- 297 :
_insertStyles() {
- 298 :
const styleElement = document.createElement('style');
- 299 :
styleElement.append(`
- 300 :
.spyral-ng-nodes {
- 301 :
}
- 302 :
.spyral-ng-links {
- 303 :
}
- 304 :
- 305 :
.spyral-ng-node {
- 306 :
cursor: pointer;
- 307 :
}
- 308 :
.spyral-ng-node rect {
- 309 :
fill: hsl(200, 73%, 90%);
- 310 :
stroke: #333;
- 311 :
stroke-width: 1px;
- 312 :
}
- 313 :
.spyral-ng-node.hover rect {
- 314 :
fill: hsl(354, 73%, 90%);
- 315 :
}
- 316 :
.spyral-ng-node text {
- 317 :
user-select: none;
- 318 :
}
- 319 :
- 320 :
.spyral-ng-link {
- 321 :
stroke-width: 1px;
- 322 :
stroke: #555;
- 323 :
}
- 324 :
.spyral-ng-link.hover {
- 325 :
stroke-width: 2px;
- 326 :
stroke: #333;
- 327 :
}
- 328 :
`);
- 329 :
- 330 :
this.target.parentElement.prepend(styleElement);
- 331 :
}
- 332 :
}
- 333 :
- 334 :
export default NetworkGraph;