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 (
);
},
- 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",