diff --git a/app/app.global.scss b/app/app.global.scss index 1af35784..93dcdb57 100644 --- a/app/app.global.scss +++ b/app/app.global.scss @@ -163,3 +163,33 @@ body { padding: 10px 5px; font-size: 15px; } + +// network + +@keyframes dash { + to { + stroke-dashoffset: 1000; + } +} + +// each node in the map +.network-node { + +} + +// each channel in the map +.network-link { + opacity: 0.6; +} + +.active-peer { + fill: #5589F3; +} + +.active-channel { + opacity: 1; + stroke: #88D4A2; + stroke-width: 15; + stroke-dasharray: 100; + animation: dash 2.5s infinite linear; +} diff --git a/app/components/Network/CanvasNetworkGraph.css b/app/components/Network/CanvasNetworkGraph.css new file mode 100644 index 00000000..fe0f334b --- /dev/null +++ b/app/components/Network/CanvasNetworkGraph.css @@ -0,0 +1,22 @@ +.network { + width: 100%; + height: 100vh; + animation: fadein 0.5s; + animation-timing-function:linear; + animation-fill-mode: forwards; + animation-iteration-count: 1; +} + +.links line { + stroke: #999; + stroke-opacity: 0.6; +} + +.nodes circle { + stroke: black ; + stroke-width: 0px; +} + +.active-peer { + fill: pink; +} \ No newline at end of file diff --git a/app/components/Network/CanvasNetworkGraph.js b/app/components/Network/CanvasNetworkGraph.js new file mode 100644 index 00000000..d37dd249 --- /dev/null +++ b/app/components/Network/CanvasNetworkGraph.js @@ -0,0 +1,259 @@ +import { findDOMNode } from 'react-dom' +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import * as d3 from 'd3' +// import './CanvasNetworkGraph.css' + +function generateSimulationData(nodes, edges) { + const resNodes = nodes.map(node => Object.assign(node, { id: node.pub_key })) + const resEdges = edges.map(node => Object.assign(node, { source: node.node1_pub, target: node.node2_pub })) + + return { + nodes: resNodes, + links: resEdges + } +} + +class CanvasNetworkGraph extends Component { + constructor(props) { + super(props) + + this.state = { + width: 800, + height: 800, + + simulation: {}, + simulationData: { + nodes: [], + links: [] + } + } + + this._startSimulation = this._startSimulation.bind(this) + this._zoomActions = this._zoomActions.bind(this) + this._ticked = this._ticked.bind(this) + this._restart = this._restart.bind(this) + } + + componentDidMount() { + // wait for the svg to me in the DOM before we start the simulation + const svgInterval = setInterval(() => { + if (document.getElementById('map')) { + this._startSimulation() + + clearInterval(svgInterval) + } + }, 1000) + } + + componentDidUpdate(prevProps, prevState) { + const { + network: { nodes, edges }, + selectedPeerPubkeys, + selectedChannelIds, + currentRouteChanIds + } = this.props + + const prevNodes = prevProps.network.nodes + const prevEdges = prevProps.network.edges + + // update the simulationData only if the nodes or edges have changed + if (prevNodes.length !== nodes.length || prevEdges.length !== edges.length) { + this.setState({ + simulationData: generateSimulationData(nodes, edges) + }) + } + + if (prevProps.selectedPeerPubkeys.length !== selectedPeerPubkeys.length) { + this._updateSelectedPeers() + } + + if (prevProps.selectedChannelIds.length !== selectedChannelIds.length) { + this._updateSelectedChannels() + } + + if (prevProps.currentRouteChanIds.length !== currentRouteChanIds.length) { + this._renderSelectedRoute() + } + } + + componentWillUnmount() { + d3.select('#map') + .selectAll('*') + .remove() + } + + _updateSelectedPeers() { + const { selectedPeerPubkeys } = this.props + + // remove active class + d3.selectAll('.active-peer') + .each(function(d) { + d3.select(this).classed('active-peer', false) + }) + + // add active class to all selected peers + selectedPeerPubkeys.forEach(pubkey => { + const node = d3.select(`#node-${pubkey}`).classed('active-peer', true) + }) + } + + _updateSelectedChannels() { + const { selectedChannelIds } = this.props + + // remove active class + d3.selectAll('.active-channel') + .each(function(d) { + d3.select(this).classed('active-channel', false) + }) + + // add active class to all selected peers + selectedChannelIds.forEach(chanid => { + const node = d3.select(`#link-${chanid}`).classed('active-channel', true) + }) + } + + _renderSelectedRoute() { + const { currentRouteChanIds } = this.props + + // remove all route animations before rendering new ones + d3.selectAll('.animated-route-circle') + .each(function(d) { + d3.select(this).remove() + }) + + currentRouteChanIds.forEach(chanId => { + const link = document.getElementById(`link-${chanId}`) + + if (!link) { return } + const x1 = link.x1.baseVal.value + const x2 = link.x2.baseVal.value + const y1 = link.y1.baseVal.value + const y2 = link.y2.baseVal.value + + // create the circle that represent btc traveling through a channel + const circle = this.g + .append('circle') + .attr('id', `circle-${chanId}`) + .attr('class', 'animated-route-circle') + .attr('r', 50) + .attr('cx', x1) + .attr('cy', y1) + .attr('fill', '#FFDC53') + + // we want the animation to repeat back and forth, this function executes that visually + const repeat = () => { + d3.select(`#circle-${chanId}`) + .transition() + .attr('cx', x2 ) + .attr('cy', y2 ) + .duration(1000) + .transition() + .duration(1000) + .attr('cx', x1) + .attr('cy', y1) + .on('end', repeat) + } + + // call repeat to animate the circle + repeat() + }) + } + + _startSimulation() { + const { simulationData: { nodes, links } } = this.state + + // grab the svg el along with the attributes + const svg = d3.select('#map'), + width = +svg.attr('width'), + height = +svg.attr('height') + + this.g = svg.append('g').attr('transform', `translate(${width / 2},${height / 2})`) + this.link = this.g.append('g').attr('stroke', 'white').attr('stroke-width', 1.5).selectAll('.link') + this.node = this.g.append('g').attr('stroke', 'silver').attr('stroke-width', 1.5).selectAll('.node') + + this.simulation = d3.forceSimulation(nodes) + .force('charge', d3.forceManyBody().strength(-750)) + .force('link', d3.forceLink(links).id(d => d.pub_key).distance(500)) + .force('collide', d3.forceCollide(300)) + .on('tick', this._ticked) + + // zoom + const zoom_handler = d3.zoom().on('zoom', this._zoomActions) + zoom_handler(svg) + + this._restart() + } + + _zoomActions() { + this.g.attr('transform', d3.event.transform) + } + + _ticked() { + this.node.attr('cx', d => d.x) + .attr('cy', d => d.y) + + this.link.attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y) + } + + _restart() { + const { identity_pubkey } = this.props + const { + simulation, + simulationData: { nodes, links } + } = this.state + + // Apply the general update pattern to the nodes. + this.node = this.node.data(nodes, d => d.pub_key) + this.node.exit().remove() + this.node = this.node.enter() + .append('circle') + .attr('stroke', d => 'silver') + .attr('fill', d => d.pub_key === identity_pubkey ? '#FFF' : '#353535') + .attr('r', d => 100) + .attr('id', d => `node-${d.pub_key}`) + .attr('class', 'network-node') + .merge(this.node) + + // Apply the general update pattern to the links. + this.link = this.link.data(links, d => `${d.source.id}-${d.target.id}`) + this.link.exit().remove() + this.link = + this.link.enter() + .append('line') + .attr('id', d => `link-${d.channel_id}`) + .attr('class','network-link') + .merge(this.link) + + // Update and restart the simulation. + this.simulation.nodes(nodes) + this.simulation.force('link').links(links) + this.simulation.restart() + } + + render() { + const { simulationData } = this.state + const { + network: { nodes, edges, selectedChannel, networkLoading }, + selectedPeerPubkeys, + selectedChannelIds, + currentRouteChanIds, + identity_pubkey + } = this.props + + return ( +