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