import React from 'react'; import { Svg } from 'normalized-styled-components'; import PropTypes from 'prop-types'; import difference from 'lodash.difference'; import differenceBy from 'lodash.differenceby'; import Baseline from '../baseline'; import Constants from './constants'; import { createSimulation } from './simulation'; import TopologyNode from './node'; import TopologyLink from './link'; import TopologyLinkArrow from './link/arrow'; import { getNodeRect, calculateLineLayout } from './functions'; const StyledSvg = Svg.extend` width: 100%; height: 1000px; `; /** * @example ./usage.md */ class Topology extends React.Component { componentWillMount() { this.create(this.props); } componentDidMount() { this.boundResize = this.handleResize.bind(this); window.addEventListener('resize', this.boundResize); } componentWillUnmount() { window.removeEventListener('resize', this.boundResize); } shouldComponentUpdate() { return false; } getChangedConnections(services, nextServices) { return nextServices.reduce((changed, nextService) => { if (changed.added || changed.removed) { return changed; } const service = services .filter(service => service.id === nextService.id) .shift(); const connectionsAdded = difference( nextService.connections, service.connections ).length; // there's a new connection, we need to redraw if (connectionsAdded) { return { added: true }; } const connectionsRemoved = difference( service.connections, nextService.connections ).length; // we'll need to remove the offending connections from links if (connectionsRemoved) { return { removed: true }; } return changed; }, {}); } getNextLinks(nextServices) { const links = this.state.links; return links.reduce((nextLinks, link) => { const sourceExists = nextServices.filter( nextService => nextService.id === link.source.id ); if (sourceExists.length) { const source = sourceExists.shift(); const targetExists = nextServices.filter( nextService => nextService.id === link.target.id ).length; const connectionExists = source.connections.filter( connection => connection === link.target.id ).length; if (targetExists && connectionExists) { nextLinks.push(link); } } return nextLinks; }, []); } getNextNodes(nextServices) { const nodes = this.state.nodes; // let notConnectedX = 0; return nodes.reduce((nextNodes, node) => { const keep = nextServices.filter( nextService => nextService.id === node.id ).length; if (keep) { nextNodes.push(node); } return nextNodes; }, []); } componentWillReceiveProps(nextProps) { // if we remove a node, it should just be removed from the simulation nodes and links // if we add a node, then we should recreate the damn thing // on other updates, we should update the services on the state and that's it // we should forceUpdate once the state has been updated const nextServices = nextProps.services.sort(); const connectedNextServices = nextServices.filter( service => service.connected ); const notConnectedNextServices = nextServices.filter( service => !service.connected ); const { services } = this.state; if (nextServices.length > services.length) { // new service added, we need to redraw this.create(nextProps); } else if (nextServices.length <= services.length) { const servicesRemoved = differenceBy(services, nextServices, 'id'); const servicesChanged = differenceBy(nextServices, services, 'id'); if ( servicesChanged.length || servicesRemoved.length !== services.length - nextServices.length ) { this.create(nextProps); } else { // check whether there are new connections. if so, we need to redraw // if we just dropped one, we need to remove it from links // comparison to yield 3 possible outcomes; no change, added, dropped const changedConnections = this.getChangedConnections( services, nextServices ); // if connections are added, we'll need to redraw if (changedConnections.added) { this.create(nextProps); } else if (servicesRemoved.length || changedConnections.removed) { const nextNodes = this.getNextNodes(connectedNextServices); const notConnectedNodes = this.getNotConnectedNodes( notConnectedNextServices ); const nextLinks = this.getNextLinks(nextServices); this.setState( { services: nextServices, links: nextLinks, nodes: nextNodes, notConnectedNodes }, () => this.forceUpdate() ); } else { // we've got the same services, no links changed, so we just need to set them to the state this.setState({ services: nextServices }, () => this.forceUpdate()); } } } } getNotConnectedNodes(notConnectedServices) { return notConnectedServices.map((notConnectedService, index) => { const svgSize = this.getSvgSize(); const x = notConnectedService.isConsul ? svgSize.width - Constants.nodeSize.width : (Constants.nodeSize.width + 10) * index; return { id: notConnectedService.id, x, y: 0 }; }); } handleResize(evt) { this.create(this.props); // resize should just rejig the positions } create(props) { // other updates should also just update the services rather than recreate the simulation const services = props.services.sort(); const connectedServices = services.filter(service => service.connected); const notConnectedServices = services.filter(service => !service.connected); const svgSize = this.getSvgSize(); const { nodes, links, simulation } = createSimulation( connectedServices, svgSize ); const notConnectedNodes = this.getNotConnectedNodes(notConnectedServices); this.setState( { notConnectedNodes, nodes, links, simulation, services }, () => { this.forceUpdate(); } ); } getSvgSize() { if (document.getElementById('topology-svg')) { return document.getElementById('topology-svg').getBoundingClientRect(); } const windowWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; return { width: windowWidth - 2 * 24, height: 1000 }; } constrainNodePosition(x, y, nodeRect, children = false) { const svgSize = this.getSvgSize(); /* const nodeRect = children ? Constants.nodeRectWithChildren : Constants.nodeRect; */ if (x < nodeRect.right + 2) { x = nodeRect.right + 2; } else if (x > svgSize.width + nodeRect.left - 2) { x = svgSize.width + nodeRect.left - 2; } if (y < -nodeRect.top + 2) { y = -nodeRect.top + 2; } else if (y > svgSize.height - nodeRect.bottom - 2) { y = svgSize.height - nodeRect.bottom - 2; } return { x, y }; } findNode(nodeId) { return this.state.nodes.reduce( (acc, simNode, index) => (simNode.id === nodeId ? simNode : acc), {} ); } getConstrainedNodePosition(nodeId, nodeRect, children = false) { const node = this.findNode(nodeId); return this.constrainNodePosition(node.x, node.y, nodeRect, children); } getNotConnectedNodePosition(nodeId) { return this.state.notConnectedNodes .filter(ncn => ncn.id === nodeId) .shift(); } findNodeData(nodesData, nodeId) { return nodesData.filter(nodeData => nodeData.id === nodeId).shift(); } setDragInfo(dragging, nodeId = null, position = {}) { this.dragInfo = { dragging, nodeId, position }; } render() { const { onQuickActionsClick, onNodeTitleClick } = this.props; const { nodes, links, services } = this.state; const nodesData = services.map((service, index) => { const nodeRect = getNodeRect(service); const nodePosition = service.connected ? this.getConstrainedNodePosition( service.id, nodeRect, service.children ) : this.getNotConnectedNodePosition(service.id); return { ...service, ...nodePosition, nodeRect }; }); // TODO links will need to know whether a service has children // if it does, the height of it will be different const linksData = links .map((link, index) => ({ source: this.findNodeData(nodesData, link.source.id), target: this.findNodeData(nodesData, link.target.id) })) .map((linkData, index) => { return calculateLineLayout(linkData, index); }); const onDragStart = (evt, nodeId) => { // It's this node's position that we'll need to update const x = evt.changedTouches ? evt.changedTouches[0].pageX : evt.clientX; const y = evt.changedTouches ? evt.changedTouches[0].pageY : evt.clientY; this.setDragInfo(true, nodeId, { x, y }); }; const onDragMove = evt => { if (this.dragInfo && this.dragInfo.dragging) { const x = evt.changedTouches ? evt.changedTouches[0].pageX : evt.clientX; const y = evt.changedTouches ? evt.changedTouches[0].pageY : evt.clientY; const offset = { x: x - this.dragInfo.position.x, y: y - this.dragInfo.position.y }; const dragNodes = nodes.map((simNode, index) => { if (simNode.id === this.dragInfo.nodeId) { return { ...simNode, x: simNode.x + offset.x, y: simNode.y + offset.y }; } return { ...simNode }; }); this.setState( { nodes: dragNodes }, () => this.forceUpdate() ); this.setDragInfo(true, this.dragInfo.nodeId, { x, y }); } }; const onDragEnd = evt => { this.setDragInfo(false); }; const renderedNode = (n, index) => ; const renderedLink = (l, index) => ; const renderedLinkArrow = (l, index) => ; const renderedNodes = this.dragInfo && this.dragInfo.dragging ? nodesData .filter((n, index) => n.id !== this.dragInfo.nodeId) .map((n, index) => renderedNode(n, index)) : nodesData.map((n, index) => renderedNode(n, index)); const renderedLinks = linksData.map((l, index) => renderedLink(l, index)); const renderedLinkArrows = this.dragInfo && this.dragInfo.dragging ? linksData .filter((l, index) => l.target.id !== this.dragInfo.nodeId) .map((l, index) => renderedLinkArrow(l, index)) : linksData.map((l, index) => renderedLinkArrow(l, index)); const dragNode = !this.dragInfo || !this.dragInfo.dragging ? null : renderedNode( nodesData.reduce((dragNode, n, index) => { if (n.id === this.dragInfo.nodeId) { return n; } return dragNode; }, {}) ); const dragLinkArrow = !this.dragInfo || !this.dragInfo.dragging || renderedLinkArrows.length === renderedLinks.length ? null : renderedLinkArrow( linksData.reduce((dragLinkArrow, l, index) => { if (l.target.id === this.dragInfo.nodeId) { return l; } return dragLinkArrow; }, {}) ); return ( {renderedNodes} {renderedLinks} {renderedLinkArrows} {dragNode} {dragLinkArrow} ); } } Topology.propTypes = { onQuickActionsClick: PropTypes.func, onNodeTitleClick: PropTypes.func, services: PropTypes.array }; export default Baseline(Topology); export { default as TopologyNode } from './node'; export { default as TopologyLink } from './link';