Merge pull request #473 from weaveworks/324-highlight-selected

Highlight selected node
This commit is contained in:
David
2015-09-14 14:49:13 +02:00
9 changed files with 349 additions and 83 deletions

View File

@@ -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 (
<g className={className} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} id={this.props.id}>
<path d={line(this.props.points)} className="shadow" />
<path d={line(this.props.points)} className="link" />
</g>
<Spring endValue={points}>
{function(interpolated) {
const path = line(extractPoints(interpolated));
return (
<g className={classes} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} id={props.id}>
<path d={path} className="shadow" />
<path d={path} className="link" />
</g>
);
}}
</Spring>
);
},
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);
},

View File

@@ -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 (
<g className={classNames.join(' ')} transform={transform} id={this.props.id}
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{this.props.highlighted && <circle r={scale(0.7)} className="highlighted"></circle>}
<circle r={scale(0.5)} className="border" stroke={color}></circle>
<circle r={scale(0.45)} className="shadow"></circle>
<circle r={Math.max(2, scale(0.125))} className="node"></circle>
<text className="node-label" textAnchor="middle" x={textOffsetX} y={textOffsetY}>
{this.ellipsis(this.props.label, 14)}
</text>
<text className="node-sublabel" textAnchor="middle" x={textOffsetX} y={textOffsetY + 17}>
{this.ellipsis(this.props.subLabel, 12)}
</text>
</g>
<Spring endValue={{x: {val: this.props.dx, config: animConfig}, y: {val: this.props.dy, config: animConfig}}}>
{function(interpolated) {
const transform = 'translate(' + interpolated.x.val + ',' + interpolated.y.val + ')';
return (
<g className={classes} transform={transform} id={props.id}
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{props.highlighted && <circle r={scale(0.7)} className="highlighted"></circle>}
<circle r={scale(0.5)} className="border" stroke={color}></circle>
<circle r={scale(0.45)} className="shadow"></circle>
<circle r={Math.max(2, scale(0.125))} className="node"></circle>
<text className="node-label" textAnchor="middle" x={textOffsetX} y={textOffsetY}>
{ellipsis(props.label, 14)}
</text>
<text className="node-sublabel" textAnchor="middle" x={textOffsetX} y={textOffsetY + 17}>
{ellipsis(props.subLabel, 12)}
</text>
</g>
);
}}
</Spring>
);
},

View File

@@ -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 (
<Node
blurred={blurred}
highlighted={highlighted}
onClick={this.props.onNodeClick}
key={node.id}
@@ -97,10 +128,17 @@ const NodesChart = React.createClass({
},
renderGraphEdges: function(edges) {
const selectedNodeId = this.props.selectedNodeId;
const hasSelectedNode = selectedNodeId && this.props.nodes.has(selectedNodeId);
return _.map(edges, function(edge) {
const highlighted = _.includes(this.props.highlightedEdgeIds, edge.id);
const blurred = hasSelectedNode
&& edge.source.id !== selectedNodeId
&& edge.target.id !== selectedNodeId;
return (
<Edge key={edge.id} id={edge.id} points={edge.points} highlighted={highlighted} />
<Edge key={edge.id} id={edge.id} points={edge.points} blurred={blurred}
highlighted={highlighted} />
);
}, 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 (
<svg width="100%" height="100%" className="nodes-chart">
<g className="canvas" transform={transform}>
<g className="edges">
{edgeElements}
</g>
<g className="nodes">
{nodeElements}
</g>
</g>
<Spring endValue={{val: translate, config: [80, 20]}}>
{function(interpolated) {
let interpolatedTranslate = wasShifted ? interpolated.val : panTranslate;
const transform = 'translate(' + interpolatedTranslate + ')' +
' scale(' + scale + ')';
return (
<g className="canvas" transform={transform}>
<g className="edges">
{edgeElements}
</g>
<g className="nodes">
{nodeElements}
</g>
</g>
);
}}
</Spring>
</svg>
);
},
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
});
}

View File

@@ -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 (
<div>
@@ -76,7 +79,8 @@ const App = React.createClass({
</div>
<Nodes nodes={this.state.nodes} highlightedNodeIds={this.state.highlightedNodeIds}
highlightedEdgeIds={this.state.highlightedEdgeIds}
highlightedEdgeIds={this.state.highlightedEdgeIds} detailsWidth={detailsWidth}
selectedNodeId={this.state.selectedNodeId} topMargin={topMargin}
topologyId={this.state.currentTopologyId} />
<div className="footer">

View File

@@ -33,12 +33,14 @@ const Nodes = React.createClass({
<NodesChart
highlightedEdgeIds={this.props.highlightedEdgeIds}
highlightedNodeIds={this.props.highlightedNodeIds}
selectedNodeId={this.props.selectedNodeId}
nodes={this.props.nodes}
onNodeClick={this.onNodeClick}
width={this.state.width}
height={this.state.height}
topologyId={this.props.topologyId}
context="view"
detailsWidth={this.props.detailsWidth}
topMargin={this.props.topMargin}
/>
</div>
);

View File

@@ -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);
});
});

View File

@@ -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]);
}
}

View File

@@ -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;
}

View File

@@ -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",