You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
275 lines
7.8 KiB
275 lines
7.8 KiB
import React, { Component } from 'react'
|
|
import PropTypes from 'prop-types'
|
|
import * as d3Force from 'd3-force'
|
|
import * as d3Selection from 'd3-selection'
|
|
import * as d3Zoom from 'd3-zoom'
|
|
|
|
import styles from './CanvasNetworkGraph.scss'
|
|
|
|
const d3 = Object.assign({}, d3Force, d3Selection, d3Zoom)
|
|
|
|
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 = {
|
|
simulation: {},
|
|
simulationData: {
|
|
nodes: [],
|
|
links: []
|
|
},
|
|
|
|
svgLoaded: false
|
|
}
|
|
|
|
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 be in the DOM before we start the simulation
|
|
const svgInterval = setInterval(() => {
|
|
if (document.getElementById('mapContainer')) {
|
|
d3.select('#mapContainer')
|
|
.append('svg')
|
|
.attr('id', 'map')
|
|
.attr('width', '100%')
|
|
.attr('height', '100%')
|
|
|
|
this.startSimulation()
|
|
|
|
clearInterval(svgInterval)
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
componentWillReceiveProps(nextProps) {
|
|
const { network } = nextProps
|
|
const { simulationData: { nodes, links } } = this.state
|
|
|
|
const simulationDataEmpty = !nodes.length && !links.length
|
|
const networkDataLoaded = network.nodes.length || network.edges.length
|
|
const prevNetwork = this.props.network
|
|
|
|
if (
|
|
// update the simulationData only if
|
|
// the simulationData is empty and we have network data
|
|
(simulationDataEmpty && networkDataLoaded) ||
|
|
// the nodes or edges have changed
|
|
(prevNetwork.nodes.length !== network.nodes.length || prevNetwork.edges.length !== network.edges.length)) {
|
|
this.setState({
|
|
simulationData: generateSimulationData(network.nodes, network.edges)
|
|
})
|
|
}
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
const {
|
|
selectedPeerPubkeys,
|
|
selectedChannelIds,
|
|
currentRouteChanIds
|
|
} = this.props
|
|
|
|
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')
|
|
.remove()
|
|
}
|
|
|
|
updateSelectedPeers() {
|
|
const { selectedPeerPubkeys } = this.props
|
|
|
|
// remove active class
|
|
d3.selectAll('.active-peer').classed('active-peer', false)
|
|
|
|
// add active class to all selected peers
|
|
selectedPeerPubkeys.forEach((pubkey) => {
|
|
d3.select(`#node-${pubkey}`).classed('active-peer', true)
|
|
})
|
|
}
|
|
|
|
updateSelectedChannels() {
|
|
const { selectedChannelIds } = this.props
|
|
|
|
// remove active class
|
|
d3.selectAll('.active-channel').classed('active-channel', false)
|
|
|
|
// add active class to all selected peers
|
|
selectedChannelIds.forEach((chanid) => {
|
|
d3.select(`#link-${chanid}`).classed('active-channel', true)
|
|
})
|
|
}
|
|
|
|
startSimulation() {
|
|
const { simulationData: { nodes, links } } = this.state
|
|
|
|
// grab the svg el along with the attributes
|
|
const svg = d3.select('#map')
|
|
const svgBox = svg.node().getBBox()
|
|
|
|
this.g = svg.append('g').attr('transform', `translate(${svgBox.width / 2},${svgBox.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)
|
|
.on('end', () => {
|
|
this.setState({ svgLoaded: true })
|
|
})
|
|
// zoom
|
|
const zoom_handler = d3.zoom().on('zoom', this.zoomActions)
|
|
zoom_handler(svg)
|
|
|
|
this.restart()
|
|
}
|
|
|
|
zoomActions() {
|
|
this.g.attr('transform', d3Selection.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 { 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', () => 'silver')
|
|
.attr('fill', d => (d.pub_key === identity_pubkey ? '#FFF' : '#353535'))
|
|
.attr('r', () => 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()
|
|
}
|
|
|
|
renderSelectedRoute() {
|
|
const { currentRouteChanIds } = this.props
|
|
|
|
// remove all route animations before rendering new ones
|
|
d3.selectAll('.animated-route-circle').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
|
|
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()
|
|
})
|
|
}
|
|
|
|
render() {
|
|
const { svgLoaded } = this.state
|
|
|
|
return (
|
|
<div className={styles.mapContainer} id='mapContainer'>
|
|
{
|
|
!svgLoaded &&
|
|
<div className={styles.loadingContainer}>
|
|
<div className={styles.loadingWrap}>
|
|
<div className={styles.loader} />
|
|
<div className={styles.loaderbefore} />
|
|
<div className={styles.circular} />
|
|
<div className={`${styles.circular} ${styles.another}`} />
|
|
<div className={styles.text}>loading</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
)
|
|
}
|
|
}
|
|
|
|
CanvasNetworkGraph.propTypes = {
|
|
identity_pubkey: PropTypes.string.isRequired,
|
|
|
|
network: PropTypes.object.isRequired,
|
|
|
|
selectedPeerPubkeys: PropTypes.array.isRequired,
|
|
selectedChannelIds: PropTypes.array.isRequired,
|
|
currentRouteChanIds: PropTypes.array.isRequired
|
|
}
|
|
|
|
export default CanvasNetworkGraph
|
|
|