├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── demo.gif ├── example └── index.html ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | example/p2p-graph.js 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .travis.yml 2 | demo.gif 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - lts/* 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Feross Aboukhadijeh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # p2p-graph [![travis][travis-image]][travis-url] [![npm][npm-image]][npm-url] [![downloads][downloads-image]][downloads-url] [![javascript style guide][standard-image]][standard-url] 2 | 3 | [travis-image]: https://img.shields.io/travis/feross/p2p-graph/master.svg 4 | [travis-url]: https://travis-ci.org/feross/p2p-graph 5 | [npm-image]: https://img.shields.io/npm/v/p2p-graph.svg 6 | [npm-url]: https://npmjs.org/package/p2p-graph 7 | [downloads-image]: https://img.shields.io/npm/dm/p2p-graph.svg 8 | [downloads-url]: https://npmjs.org/package/p2p-graph 9 | [standard-image]: https://img.shields.io/badge/code_style-standard-brightgreen.svg 10 | [standard-url]: https://standardjs.com 11 | 12 | ### Real-time P2P network visualization with D3 13 | 14 | ![demo](demo.gif) 15 | 16 | This package is used by [WebTorrent](https://webtorrent.io). You can see this 17 | package in action on the [webtorrent.io](https://webtorrent.io) homepage or 18 | play with it on the 19 | [esnextb.in demo](https://esnextb.in/?gist=6d2ede2438db14c108d30343f352ad8c). 20 | 21 | ## Install 22 | 23 | ``` 24 | npm install p2p-graph 25 | ``` 26 | 27 | This package works in the browser with [browserify](https://browserify.org). If you do not use a bundler, you can use the [standalone script](https://bundle.run/p2p-graph) directly in a ` 17 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = P2PGraph 2 | 3 | var d3 = require('d3') 4 | var debug = require('debug')('p2p-graph') 5 | var EventEmitter = require('events') 6 | var inherits = require('inherits') 7 | var throttle = require('throttleit') 8 | 9 | var STYLE = { 10 | links: { 11 | width: 0.7, // default link thickness 12 | maxWidth: 5.0, // max thickness 13 | maxBytes: 2097152 // link max thickness at 2MB 14 | } 15 | } 16 | 17 | var COLORS = { 18 | links: { 19 | color: '#C8C8C8' 20 | }, 21 | text: { 22 | subtitle: '#C8C8C8' 23 | }, 24 | nodes: { 25 | method: function (d, i) { 26 | return d.me 27 | ? d3.hsl(210, 0.7, 0.725) // blue 28 | : d.seeder 29 | ? d3.hsl(120, 0.7, 0.725) // green 30 | : d3.hsl(55, 0.7, 0.725) // yellow 31 | }, 32 | hover: '#A9A9A9', 33 | dep: '#252929' 34 | } 35 | } 36 | 37 | inherits(P2PGraph, EventEmitter) 38 | 39 | function P2PGraph (root) { 40 | var self = this 41 | if (!(self instanceof P2PGraph)) return new P2PGraph(root) 42 | 43 | EventEmitter.call(self) 44 | 45 | if (typeof root === 'string') root = document.querySelector(root) 46 | self._root = root 47 | 48 | self._model = { 49 | nodes: [], 50 | links: [], 51 | focused: null 52 | } 53 | 54 | self._model.links.forEach(function (link) { 55 | var source = self._model.nodes[link.source] 56 | var target = self._model.nodes[link.target] 57 | 58 | source.children = source.children || [] 59 | source.children.push(link.target) 60 | 61 | target.parents = target.parents || [] 62 | target.parents.push(link.source) 63 | }) 64 | 65 | self._svg = d3.select(self._root).append('svg') 66 | 67 | self._resize() 68 | 69 | self._force = d3.layout.force() 70 | .size([self._width, self._height]) 71 | .nodes(self._model.nodes) 72 | .links(self._model.links) 73 | .on('tick', function () { 74 | self._link 75 | .attr('x1', function (d) { 76 | return d.source.x 77 | }) 78 | .attr('y1', function (d) { 79 | return d.source.y 80 | }) 81 | .attr('x2', function (d) { 82 | return d.target.x 83 | }) 84 | .attr('y2', function (d) { 85 | return d.target.y 86 | }) 87 | 88 | self._node 89 | .attr('cx', function (d) { 90 | return d.x 91 | }) 92 | .attr('cy', function (d) { 93 | return d.y 94 | }) 95 | 96 | self._node.attr('transform', function (d) { 97 | return 'translate(' + d.x + ',' + d.y + ')' 98 | }) 99 | }) 100 | 101 | self._node = self._svg.selectAll('.node') 102 | self._link = self._svg.selectAll('.link') 103 | 104 | self._update() 105 | 106 | self._resizeThrottled = throttle(function () { 107 | self._resize() 108 | }, 500) 109 | window.addEventListener('resize', self._resizeThrottled) 110 | } 111 | 112 | P2PGraph.prototype.list = function () { 113 | var self = this 114 | debug('list') 115 | return self._model.nodes 116 | } 117 | 118 | P2PGraph.prototype.add = function (node) { 119 | var self = this 120 | debug('add %s %o', node.id, node) 121 | if (self._getNode(node.id)) throw new Error('add: cannot add duplicate node') 122 | self._model.nodes.push(node) 123 | self._update() 124 | } 125 | 126 | P2PGraph.prototype.remove = function (id) { 127 | var self = this 128 | debug('remove %s', id) 129 | var index = self._getNodeIndex(id) 130 | if (index === -1) throw new Error('remove: node does not exist') 131 | 132 | if (self._model.focused && self._model.focused.id === id) { 133 | self._model.focused = null 134 | self.emit('select', false) 135 | } 136 | 137 | self._model.nodes.splice(index, 1) 138 | self._update() 139 | } 140 | 141 | P2PGraph.prototype.connect = function (sourceId, targetId) { 142 | var self = this 143 | debug('connect %s %s', sourceId, targetId) 144 | 145 | var sourceNode = self._getNode(sourceId) 146 | if (!sourceNode) throw new Error('connect: invalid source id') 147 | var targetNode = self._getNode(targetId) 148 | if (!targetNode) throw new Error('connect: invalid target id') 149 | 150 | if (self.getLink(sourceNode.index, targetNode.index)) { 151 | throw new Error('connect: cannot make duplicate connection') 152 | } 153 | 154 | self._model.links.push({ 155 | source: sourceNode.index, 156 | target: targetNode.index 157 | }) 158 | self._update() 159 | } 160 | 161 | P2PGraph.prototype.disconnect = function (sourceId, targetId) { 162 | var self = this 163 | debug('disconnect %s %s', sourceId, targetId) 164 | 165 | var sourceNode = self._getNode(sourceId) 166 | if (!sourceNode) throw new Error('disconnect: invalid source id') 167 | var targetNode = self._getNode(targetId) 168 | if (!targetNode) throw new Error('disconnect: invalid target id') 169 | 170 | var index = self.getLinkIndex(sourceNode.index, targetNode.index) 171 | if (index === -1) throw new Error('disconnect: connection does not exist') 172 | 173 | self._model.links.splice(index, 1) 174 | self._update() 175 | } 176 | 177 | P2PGraph.prototype.hasPeer = function () { 178 | var self = this 179 | var args = Array.prototype.slice.call(arguments, 0) 180 | debug('Checking for peers:', args) 181 | return args.every(function (nodeId) { 182 | return self._getNode(nodeId) 183 | }) 184 | } 185 | 186 | P2PGraph.prototype.hasLink = function (sourceId, targetId) { 187 | var self = this 188 | var sourceNode = self._getNode(sourceId) 189 | if (!sourceNode) throw new Error('hasLink: invalid source id') 190 | var targetNode = self._getNode(targetId) 191 | if (!targetNode) throw new Error('hasLink: invalid target id') 192 | return !!self.getLink(sourceNode.index, targetNode.index) 193 | } 194 | 195 | P2PGraph.prototype.areConnected = function (sourceId, targetId) { 196 | var self = this 197 | var sourceNode = self._getNode(sourceId) 198 | if (!sourceNode) throw new Error('areConnected: invalid source id') 199 | var targetNode = self._getNode(targetId) 200 | if (!targetNode) throw new Error('areConnected: invalid target id') 201 | return self.getLink(sourceNode.index, targetNode.index) || 202 | self.getLink(targetNode.index, sourceNode.index) 203 | } 204 | 205 | P2PGraph.prototype.unchoke = function (sourceId, targetId) { 206 | debug('unchoke %s %s', sourceId, targetId) 207 | // TODO: resume opacity 208 | } 209 | 210 | P2PGraph.prototype.choke = function (sourceId, targetId) { 211 | debug('choke %s %s', sourceId, targetId) 212 | // TODO: lower opacity 213 | } 214 | 215 | P2PGraph.prototype.seed = function (id, isSeeding) { 216 | var self = this 217 | debug(id, 'isSeeding:', isSeeding) 218 | if (typeof isSeeding !== 'boolean') throw new Error('seed: 2nd param must be a boolean') 219 | var index = self._getNodeIndex(id) 220 | if (index === -1) throw new Error('seed: node does not exist') 221 | self._model.nodes[index].seeder = isSeeding 222 | self._update() 223 | } 224 | 225 | P2PGraph.prototype.rate = function (sourceId, targetId, bytesRate) { 226 | var self = this 227 | debug('rate update:', sourceId + '<->' + targetId, 'at', bytesRate) 228 | if (typeof bytesRate !== 'number' || bytesRate < 0) throw new Error('rate: 3th param must be a positive number') 229 | var sourceNode = self._getNode(sourceId) 230 | if (!sourceNode) throw new Error('rate: invalid source id') 231 | var targetNode = self._getNode(targetId) 232 | if (!targetNode) throw new Error('rate: invalid target id') 233 | var index = self.getLinkIndex(sourceNode.index, targetNode.index) 234 | if (index === -1) throw new Error('rate: connection does not exist') 235 | self._model.links[index].rate = speedRange(bytesRate) 236 | debug('rate:', self._model.links[index].rate) 237 | self._update() 238 | 239 | function speedRange (bytes) { 240 | return Math.min(bytes, STYLE.links.maxBytes) * 241 | STYLE.links.maxWidth / STYLE.links.maxBytes 242 | } 243 | } 244 | 245 | P2PGraph.prototype.getLink = function (source, target) { 246 | var self = this 247 | for (var i = 0, len = self._model.links.length; i < len; i += 1) { 248 | var link = self._model.links[i] 249 | if (link.source === self._model.nodes[source] && 250 | link.target === self._model.nodes[target]) { 251 | return link 252 | } 253 | } 254 | return null 255 | } 256 | 257 | P2PGraph.prototype.destroy = function () { 258 | var self = this 259 | debug('destroy') 260 | 261 | self._root.remove() 262 | window.removeEventListener('resize', self._resizeThrottled) 263 | 264 | self._root = null 265 | self._resizeThrottled = null 266 | } 267 | 268 | P2PGraph.prototype._update = function () { 269 | var self = this 270 | 271 | self._link = self._link.data(self._model.links) 272 | self._node = self._node.data(self._model.nodes, function (d) { 273 | return d.id 274 | }) 275 | 276 | self._link.enter() 277 | .insert('line', '.node') 278 | .attr('class', 'link') 279 | .style('stroke', COLORS.links.color) 280 | .style('opacity', 0.5) 281 | 282 | self._link 283 | .exit() 284 | .remove() 285 | 286 | self._link.style('stroke-width', function (d) { 287 | // setting thickness 288 | return d.rate 289 | ? d.rate < STYLE.links.width ? STYLE.links.width : d.rate 290 | : STYLE.links.width 291 | }) 292 | 293 | var g = self._node.enter() 294 | .append('g') 295 | .attr('class', 'node') 296 | 297 | g.call(self._force.drag) 298 | 299 | g.append('circle') 300 | .on('mouseover', function (d) { 301 | d3.select(this) 302 | .style('fill', COLORS.nodes.hover) 303 | 304 | d3.selectAll(self._childNodes(d)) 305 | .style('fill', COLORS.nodes.hover) 306 | .style('stroke', COLORS.nodes.method) 307 | .style('stroke-width', 2) 308 | 309 | d3.selectAll(self._parentNodes(d)) 310 | .style('fill', COLORS.nodes.dep) 311 | .style('stroke', COLORS.nodes.method) 312 | .style('stroke-width', 2) 313 | }) 314 | .on('mouseout', function (d) { 315 | d3.select(this) 316 | .style('fill', COLORS.nodes.method) 317 | 318 | d3.selectAll(self._childNodes(d)) 319 | .style('fill', COLORS.nodes.method) 320 | .style('stroke', null) 321 | 322 | d3.selectAll(self._parentNodes(d)) 323 | .style('fill', COLORS.nodes.method) 324 | .style('stroke', null) 325 | }) 326 | .on('click', function (d) { 327 | if (self._model.focused === d) { 328 | self._force 329 | .charge(-200 * self._scale()) 330 | .linkDistance(100 * self._scale()) 331 | .linkStrength(1) 332 | .start() 333 | 334 | self._node.style('opacity', 1) 335 | self._link.style('opacity', 0.3) 336 | 337 | self._model.focused = null 338 | self.emit('select', false) 339 | return 340 | } 341 | 342 | self._model.focused = d 343 | self.emit('select', d.id) 344 | 345 | self._node.style('opacity', function (o) { 346 | o.active = self._connected(d, o) 347 | return o.active ? 1 : 0.2 348 | }) 349 | 350 | self._force.charge(function (o) { 351 | return (o.active ? -100 : -5) * self._scale() 352 | }).linkDistance(function (l) { 353 | return (l.source.active && l.target.active ? 100 : 60) * self._scale() 354 | }).linkStrength(function (l) { 355 | return (l.source === d || l.target === d ? 1 : 0) * self._scale() 356 | }).start() 357 | 358 | self._link.style('opacity', function (l, i) { 359 | return l.source.active && l.target.active ? 1 : 0.02 360 | }) 361 | }) 362 | 363 | self._node 364 | .select('circle') 365 | .attr('r', function (d) { 366 | return self._scale() * (d.me ? 15 : 10) 367 | }) 368 | .style('fill', COLORS.nodes.method) 369 | 370 | g.append('text') 371 | .attr('class', 'text') 372 | .text(function (d) { 373 | return d.name 374 | }) 375 | 376 | self._node 377 | .select('text') 378 | .attr('font-size', function (d) { 379 | return d.me ? 16 * self._scale() : 12 * self._scale() 380 | }) 381 | .attr('dx', 0) 382 | .attr('dy', function (d) { 383 | return d.me ? -22 * self._scale() : -15 * self._scale() 384 | }) 385 | 386 | self._node 387 | .exit() 388 | .remove() 389 | 390 | self._force 391 | .linkDistance(100 * self._scale()) 392 | .charge(-200 * self._scale()) 393 | .start() 394 | } 395 | 396 | P2PGraph.prototype._childNodes = function (d) { 397 | var self = this 398 | if (!d.children) return [] 399 | 400 | return d.children 401 | .map(function (child) { 402 | return self._node[0][child] 403 | }).filter(function (child) { 404 | return child 405 | }) 406 | } 407 | 408 | P2PGraph.prototype._parentNodes = function (d) { 409 | var self = this 410 | if (!d.parents) return [] 411 | 412 | return d.parents 413 | .map(function (parent) { 414 | return self._node[0][parent] 415 | }).filter(function (parent) { 416 | return parent 417 | }) 418 | } 419 | 420 | P2PGraph.prototype._connected = function (d, o) { 421 | return o.id === d.id || 422 | (d.children && d.children.indexOf(o.id) !== -1) || 423 | (o.children && o.children.indexOf(d.id) !== -1) || 424 | (o.parents && o.parents.indexOf(d.id) !== -1) || 425 | (d.parents && d.parents.indexOf(o.id) !== -1) 426 | } 427 | 428 | P2PGraph.prototype._getNode = function (id) { 429 | var self = this 430 | for (var i = 0, len = self._model.nodes.length; i < len; i += 1) { 431 | var node = self._model.nodes[i] 432 | if (node.id === id) return node 433 | } 434 | return null 435 | } 436 | 437 | P2PGraph.prototype._scale = function () { 438 | var self = this 439 | var len = self._model.nodes.length 440 | return len < 10 441 | ? 1 442 | : Math.max(0.2, 1 - ((len - 10) / 100)) 443 | } 444 | 445 | P2PGraph.prototype._resize = function (e) { 446 | var self = this 447 | self._width = self._root.offsetWidth 448 | self._height = window.innerWidth >= 900 ? 400 : 250 449 | 450 | self._svg 451 | .attr('width', self._width) 452 | .attr('height', self._height) 453 | 454 | if (self._force) { 455 | self._force 456 | .size([self._width, self._height]) 457 | .resume() 458 | } 459 | } 460 | 461 | P2PGraph.prototype._getNodeIndex = function (id) { 462 | var self = this 463 | for (var i = 0, len = self._model.nodes.length; i < len; i += 1) { 464 | var node = self._model.nodes[i] 465 | if (node.id === id) return i 466 | } 467 | return -1 468 | } 469 | 470 | P2PGraph.prototype.getLinkIndex = function (source, target) { 471 | var self = this 472 | for (var i = 0, len = self._model.links.length; i < len; i += 1) { 473 | var link = self._model.links[i] 474 | if (link.source === self._model.nodes[source] && 475 | link.target === self._model.nodes[target]) { 476 | return i 477 | } 478 | } 479 | return -1 480 | } 481 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "p2p-graph", 3 | "description": "Real-time P2P network visualization with D3", 4 | "version": "2.0.0", 5 | "author": { 6 | "name": "Feross Aboukhadijeh", 7 | "email": "feross@feross.org", 8 | "url": "https://feross.org" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/feross/p2p-graph/issues" 12 | }, 13 | "dependencies": { 14 | "d3": "^3.5.6", 15 | "debug": "^4.1.1", 16 | "inherits": "^2.0.3", 17 | "throttleit": "^1.0.0" 18 | }, 19 | "devDependencies": { 20 | "babel-minify": "^0.5.1", 21 | "browserify": "^16.1.0", 22 | "ecstatic": "^4.1.2", 23 | "standard": "*" 24 | }, 25 | "homepage": "https://github.com/feross/p2p-graph", 26 | "keywords": [ 27 | "d3", 28 | "graph", 29 | "network", 30 | "p2p", 31 | "peer-to-peer", 32 | "real-time", 33 | "visualization" 34 | ], 35 | "license": "MIT", 36 | "main": "index.js", 37 | "repository": { 38 | "type": "git", 39 | "url": "git://github.com/feross/p2p-graph.git" 40 | }, 41 | "scripts": { 42 | "example": "browserify -s P2PGraph . > example/p2p-graph.js && ecstatic example --port 4000", 43 | "size": "browserify -s P2PGraph . | minify | gzip | wc -c", 44 | "test": "standard" 45 | } 46 | } 47 | --------------------------------------------------------------------------------