diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index c7bb9f4cc..5a40ea3cd 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -1,5 +1,7 @@ +const _ = require('lodash'); const d3 = require('d3'); const React = require('react'); +const Spring = require('react-motion').Spring; const AppActions = require('../actions/app-actions'); @@ -8,19 +10,89 @@ const line = d3.svg.line() .x(function(d) { return d.x; }) .y(function(d) { return d.y; }); +const animConfig = [80, 20]; // stiffness, bounce + +const flattenPoints = function(points) { + const flattened = {}; + points.forEach(function(point, i) { + flattened['x' + i] = {val: point.x, config: animConfig}; + flattened['y' + i] = {val: point.y, config: animConfig}; + }); + return flattened; +}; + +const extractPoints = function(points) { + const extracted = []; + _.each(points, function(value, key) { + const axis = key[0]; + const index = key.slice(1); + if (!extracted[index]) { + extracted[index] = {}; + } + extracted[index][axis] = value.val; + }); + return extracted; +}; + const Edge = React.createClass({ + getInitialState: function() { + return { + points: [] + }; + }, + + componentWillMount: function() { + this.ensureSameLength(this.props.points); + }, + + componentWillReceiveProps: function(nextProps) { + this.ensureSameLength(nextProps.points); + }, + render: function() { - const className = this.props.highlighted ? 'edge highlighted' : 'edge'; + const classNames = ['edge']; + const points = flattenPoints(this.props.points); + const props = this.props; + const handleMouseEnter = this.handleMouseEnter; + const handleMouseLeave = this.handleMouseLeave; + + if (this.props.highlighted) { + classNames.push('highlighted'); + } + if (this.props.blurred) { + classNames.push('blurred'); + } + const classes = classNames.join(' '); return ( - - - - + + {function(interpolated) { + const path = line(extractPoints(interpolated)); + return ( + + + + + ); + }} + ); }, + ensureSameLength: function(points) { + // Spring needs constant list length, hoping that dagre will insert never more than 10 + const length = 10; + let missing = length - points.length; + + while (missing) { + points.unshift(points[0]); + missing = length - points.length; + } + + return points; + }, + handleMouseEnter: function(ev) { AppActions.enterEdge(ev.currentTarget.id); }, diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index e47691470..039dd9e3e 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -1,46 +1,16 @@ const React = require('react'); -const tweenState = require('react-tween-state'); +const Spring = require('react-motion').Spring; const AppActions = require('../actions/app-actions'); const NodeColorMixin = require('../mixins/node-color-mixin'); const Node = React.createClass({ mixins: [ - NodeColorMixin, - tweenState.Mixin + NodeColorMixin ], - getInitialState: function() { - return { - x: 0, - y: 0 - }; - }, - - componentWillMount: function() { - // initial node position when rendered the first time - this.setState({ - x: this.props.dx, - y: this.props.dy - }); - }, - - componentWillReceiveProps: function(nextProps) { - // animate node transition to next position - this.tweenState('x', { - easing: tweenState.easingTypes.easeInOutQuad, - duration: 500, - endValue: nextProps.dx - }); - this.tweenState('y', { - easing: tweenState.easingTypes.easeInOutQuad, - duration: 500, - endValue: nextProps.dy - }); - }, - render: function() { - const transform = 'translate(' + this.getTweeningValue('x') + ',' + this.getTweeningValue('y') + ')'; + const props = this.props; const scale = this.props.scale; const textOffsetX = 0; const textOffsetY = scale(0.5) + 18; @@ -50,29 +20,41 @@ const Node = React.createClass({ const onMouseEnter = this.handleMouseEnter; const onMouseLeave = this.handleMouseLeave; const classNames = ['node']; + const ellipsis = this.ellipsis; + const animConfig = [80, 20]; // stiffness, bounce if (this.props.highlighted) { classNames.push('highlighted'); } - + if (this.props.blurred) { + classNames.push('blurred'); + } if (this.props.pseudo) { classNames.push('pseudo'); } + const classes = classNames.join(' '); return ( - - {this.props.highlighted && } - - - - - {this.ellipsis(this.props.label, 14)} - - - {this.ellipsis(this.props.subLabel, 12)} - - + + {function(interpolated) { + const transform = 'translate(' + interpolated.x.val + ',' + interpolated.y.val + ')'; + return ( + + {props.highlighted && } + + + + + {ellipsis(props.label, 14)} + + + {ellipsis(props.subLabel, 12)} + + + ); + }} + ); }, diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 2a6d8b674..039ac5f21 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -3,7 +3,9 @@ const d3 = require('d3'); const debug = require('debug')('scope:nodes-chart'); const React = require('react'); const timely = require('timely'); +const Spring = require('react-motion').Spring; +const AppStore = require('../stores/app-store'); const Edge = require('./edge'); const Naming = require('../constants/naming'); const NodesLayout = require('./nodes-layout'); @@ -16,6 +18,10 @@ const MARGINS = { bottom: 0 }; +// make sure circular layouts lots of nodes spread out +const radiusDensity = d3.scale.sqrt() + .domain([12, 2]).range([2.5, 5]).clamp(true); + const NodesChart = React.createClass({ getInitialState: function() { @@ -23,14 +29,16 @@ const NodesChart = React.createClass({ nodes: {}, edges: {}, nodeScale: 1, - translate: '0,0', + translate: [0, 0], + panTranslate: [0, 0], scale: 1, hasZoomed: false }; }, componentWillMount: function() { - this.updateGraphState(this.props); + const state = this.updateGraphState(this.props, this.state); + this.setState(state); }, componentDidMount: function() { @@ -43,13 +51,28 @@ const NodesChart = React.createClass({ }, componentWillReceiveProps: function(nextProps) { - if (nextProps.nodes !== this.props.nodes) { - this.setState({ + // gather state, setState should be called only once here + const state = _.assign({}, this.state); + + // wipe node states when showing different topology + if (nextProps.topologyId !== this.props.topologyId) { + _.assign(state, { nodes: {}, edges: {} }); - this.updateGraphState(nextProps); } + // FIXME add PureRenderMixin, Immutables, and move the following functions to render() + if (nextProps.nodes !== this.props.nodes) { + _.assign(state, this.updateGraphState(nextProps, state)); + } + if (this.props.selectedNodeId !== nextProps.selectedNodeId) { + _.assign(state, this.restoreLayout(state)); + } + if (nextProps.selectedNodeId) { + this.centerSelectedNode(nextProps, state); + } + + this.setState(state); }, componentWillUnmount: function() { @@ -76,10 +99,18 @@ const NodesChart = React.createClass({ }, renderGraphNodes: function(nodes, scale) { + const hasSelectedNode = this.props.selectedNodeId && this.props.nodes.has(this.props.selectedNodeId); + const adjacency = hasSelectedNode ? AppStore.getAdjacentNodes(this.props.selectedNodeId) : null; return _.map(nodes, function(node) { - const highlighted = _.includes(this.props.highlightedNodeIds, node.id); + const highlighted = _.includes(this.props.highlightedNodeIds, node.id) + || this.props.selectedNodeId === node.id; + const blurred = hasSelectedNode + && this.props.selectedNodeId !== node.id + && !adjacency.includes(node.id); + return ( + ); }, this); }, @@ -108,30 +146,48 @@ const NodesChart = React.createClass({ render: function() { const nodeElements = this.renderGraphNodes(this.state.nodes, this.state.nodeScale); const edgeElements = this.renderGraphEdges(this.state.edges, this.state.nodeScale); - const transform = 'translate(' + this.state.translate + ')' + - ' scale(' + this.state.scale + ')'; + const scale = this.state.scale; + + // only animate shift behavior, not panning + const panTranslate = this.state.panTranslate; + const shiftTranslate = this.state.translate; + let translate = panTranslate; + let wasShifted = false; + if (shiftTranslate[0] !== panTranslate[0] || shiftTranslate[1] !== panTranslate[1]) { + translate = shiftTranslate; + wasShifted = true; + } return ( - - - {edgeElements} - - - {nodeElements} - - + + {function(interpolated) { + let interpolatedTranslate = wasShifted ? interpolated.val : panTranslate; + const transform = 'translate(' + interpolatedTranslate + ')' + + ' scale(' + scale + ')'; + return ( + + + {edgeElements} + + + {nodeElements} + + + ); + }} + ); }, - initNodes: function(topology, prevNodes) { + initNodes: function(topology) { const centerX = this.props.width / 2; const centerY = this.props.height / 2; const nodes = {}; topology.forEach(function(node, id) { - nodes[id] = prevNodes[id] || {}; + nodes[id] = {}; // use cached positions if available _.defaults(nodes[id], { @@ -184,14 +240,110 @@ const NodesChart = React.createClass({ return edges; }, - updateGraphState: function(props) { + centerSelectedNode: function(props, state) { + const layoutNodes = state.nodes; + const layoutEdges = state.edges; + const selectedLayoutNode = layoutNodes[props.selectedNodeId]; + + if (!selectedLayoutNode) { + return {}; + } + + const adjacency = AppStore.getAdjacentNodes(props.selectedNodeId); + const adjacentLayoutNodes = []; + + adjacency.forEach(function(adjacentId) { + adjacentLayoutNodes.push(layoutNodes[adjacentId]); + }); + + // circle layout for adjacent nodes + + const centerX = selectedLayoutNode.x; + const centerY = selectedLayoutNode.y; + const adjacentCount = adjacentLayoutNodes.length; + const density = radiusDensity(adjacentCount); + const radius = Math.min(props.width, props.height) / density; + + _.each(adjacentLayoutNodes, function(node, i) { + const angle = Math.PI * 2 * i / adjacentCount; + node.x = centerX + radius * Math.sin(angle); + node.y = centerY + radius * Math.cos(angle); + }); + + // fix all edges for circular nodes + + _.each(layoutEdges, function(edge) { + if (edge.source === selectedLayoutNode + || edge.target === selectedLayoutNode + || _.includes(adjacentLayoutNodes, edge.source) + || _.includes(adjacentLayoutNodes, edge.target)) { + edge.points = [ + {x: edge.source.x, y: edge.source.y}, + {x: edge.target.x, y: edge.target.y} + ]; + } + }); + + // shift canvas selected node out of view + const visibleWidth = Math.max(props.width - props.detailsWidth, 0); + const translate = state.translate; + const offsetX = translate[0]; + if (offsetX + centerX + radius > visibleWidth) { + // shift left if blocked by details + const shift = centerX + radius - visibleWidth; + translate[0] = -shift; + } else if (offsetX + centerX - radius < 0) { + // shift right if off canvas + const shift = offsetX - offsetX + centerX - radius; + translate[0] = -shift; + } + const offsetY = translate[1]; + if (offsetY + centerY + radius > props.height) { + // shift up if past bottom + const shift = centerY + radius - props.height; + translate[1] = -shift; + } else if (offsetY + centerY - radius - props.topMargin < 0) { + // shift down if off canvas + const shift = offsetY - offsetY + centerY - radius - props.topMargin; + translate[1] = -shift; + } + + // saving translate in d3's panning cache + this.zoom.translate(translate); + + return { + edges: layoutEdges, + nodes: layoutNodes, + translate: translate + }; + }, + + restoreLayout: function(state) { + const edges = state.edges; + const nodes = state.nodes; + + _.each(nodes, function(node) { + node.x = node.px; + node.y = node.py; + }); + + _.each(edges, function(edge) { + if (edge.ppoints) { + edge.points = edge.ppoints; + } + }); + + return {edges: edges, nodes: nodes}; + }, + + updateGraphState: function(props, state) { const n = props.nodes.size; if (n === 0) { - return; + return {}; } - const nodes = this.initNodes(props.nodes, this.state.nodes); + const nodes = this.initNodes(props.nodes, state.nodes); const edges = this.initEdges(props.nodes, nodes); const expanse = Math.min(props.height, props.width); @@ -213,9 +365,18 @@ const NodesChart = React.createClass({ // layout was aborted if (!graph) { - return; + return {}; } + // save coordinates for restore + _.each(nodes, function(node) { + node.px = node.x; + node.py = node.y; + }); + _.each(edges, function(edge) { + edge.ppoints = edge.points; + }); + // adjust layout based on viewport const xFactor = (props.width - MARGINS.left - MARGINS.right) / graph.width; const yFactor = props.height / graph.height; @@ -228,18 +389,19 @@ const NodesChart = React.createClass({ this.zoom.scale(zoomFactor); } - this.setState({ + return { nodes: nodes, edges: edges, nodeScale: nodeScale, scale: zoomScale - }); + }; }, zoomed: function() { this.setState({ hasZoomed: true, - translate: d3.event.translate, + panTranslate: d3.event.translate.slice(), + translate: d3.event.translate.slice(), scale: d3.event.scale }); } diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 39d5120f1..b02e79768 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -60,6 +60,9 @@ const App = React.createClass({ render: function() { const showingDetails = this.state.selectedNodeId; const versionString = this.state.version ? 'Version ' + this.state.version : ''; + // width of details panel blocking a view + const detailsWidth = showingDetails ? 420 : 0; + const topMargin = 100; return (
@@ -76,7 +79,8 @@ const App = React.createClass({
diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 9550f55cd..c2e088c19 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -33,12 +33,14 @@ const Nodes = React.createClass({
); diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js index 1fae1e04d..81da4b9b9 100644 --- a/client/app/scripts/stores/__tests__/app-store-test.js +++ b/client/app/scripts/stores/__tests__/app-store-test.js @@ -285,5 +285,19 @@ describe('AppStore', function() { expect(AppStore.getNodes().toJS()).toEqual({}); }); + // adjacency test + + it('returns the correct adjacency set for a node', function() { + registeredCallback(ReceiveNodesDeltaAction); + expect(AppStore.getAdjacentNodes().size).toEqual(0); + + registeredCallback(ClickNodeAction); + expect(AppStore.getAdjacentNodes('n1').size).toEqual(2); + expect(AppStore.getAdjacentNodes('n1').has('n1')).toBeTruthy(); + expect(AppStore.getAdjacentNodes('n1').has('n2')).toBeTruthy(); + + registeredCallback(HitEscAction) + expect(AppStore.getAdjacentNodes().size).toEqual(0); + }); }); \ No newline at end of file diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index 81f601083..09aa0aac4 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -9,6 +9,7 @@ const ActionTypes = require('../constants/action-types'); const Naming = require('../constants/naming'); const makeOrderedMap = Immutable.OrderedMap; +const makeSet = Immutable.Set; // Helpers @@ -44,6 +45,7 @@ function makeNode(node) { // Initial values let activeTopologyOptions = null; +let adjacentNodes = makeSet(); let currentTopology = null; let currentTopologyId = 'containers'; let errorUrl = null; @@ -93,6 +95,22 @@ const AppStore = assign({}, EventEmitter.prototype, { return activeTopologyOptions; }, + getAdjacentNodes: function(nodeId) { + adjacentNodes = adjacentNodes.clear(); + + if (nodes.has(nodeId)) { + adjacentNodes = makeSet(nodes.get(nodeId).get('adjacency')); + // fill up set with reverse edges + nodes.forEach(function(node, id) { + if (node.get('adjacency') && node.get('adjacency').includes(nodeId)) { + adjacentNodes = adjacentNodes.add(id); + } + }); + } + + return adjacentNodes; + }, + getCurrentTopology: function() { if (!currentTopology) { currentTopology = setTopology(currentTopologyId); @@ -139,8 +157,8 @@ const AppStore = assign({}, EventEmitter.prototype, { getHighlightedNodeIds: function() { if (mouseOverNodeId) { - const adjacency = nodes.get(mouseOverNodeId).get('adjacency'); - if (adjacency) { + const adjacency = this.getAdjacentNodes(mouseOverNodeId); + if (adjacency.size) { return _.union(adjacency.toJS(), [mouseOverNodeId]); } } diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 1eb64c63d..85d063a05 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -176,6 +176,7 @@ body { g.node { cursor: pointer; + transition: opacity .5s ease-in-out; &.pseudo { opacity: 0.8; @@ -194,12 +195,22 @@ body { stroke-width: 1px; } } + + &.blurred { + opacity: 0.25; + } } .edge { + transition: opacity .5s ease-in-out; + + &.blurred { + opacity: 0.2; + } + .link { stroke: @text-secondary-color; - stroke-width: 1.5px; + stroke-width: 1px; fill: none; stroke-opacity: 0.5; } diff --git a/client/package.json b/client/package.json index c14726dc4..0d38001c6 100644 --- a/client/package.json +++ b/client/package.json @@ -20,6 +20,7 @@ "object-assign": "^2.0.0", "page": "^1.6.3", "react": "^0.13.3", + "react-motion": "^0.2.7", "react-tap-event-plugin": "^0.1.7", "react-tween-state": "0.0.5", "reqwest": "~1.1.5",