Merge pull request #136 from weaveworks/node-highlighting

highlight connected nodes/edges on hover
This commit is contained in:
David
2015-05-29 18:38:21 +02:00
10 changed files with 168 additions and 26 deletions

View File

@@ -39,6 +39,13 @@ module.exports = {
WebapiUtils.getNodesDelta(AppStore.getCurrentTopologyUrl());
},
enterEdge: function(edgeId) {
AppDispatcher.dispatch({
type: ActionTypes.ENTER_EDGE,
edgeId: edgeId
});
},
enterNode: function(nodeId) {
AppDispatcher.dispatch({
type: ActionTypes.ENTER_NODE,
@@ -53,6 +60,13 @@ module.exports = {
RouterUtils.updateRoute();
},
leaveEdge: function(edgeId) {
AppDispatcher.dispatch({
type: ActionTypes.LEAVE_EDGE,
edgeId: edgeId
});
},
leaveNode: function(nodeId) {
AppDispatcher.dispatch({
type: ActionTypes.LEAVE_NODE,

View File

@@ -0,0 +1,34 @@
const d3 = require('d3');
const React = require('react');
const AppActions = require('../actions/app-actions');
const line = d3.svg.line()
.interpolate('basis')
.x(function(d) { return d.x; })
.y(function(d) { return d.y; });
const Edge = React.createClass({
render: function() {
const className = this.props.highlighted ? 'edge highlighted' : 'edge';
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>
);
},
handleMouseEnter: function(ev) {
AppActions.enterEdge(ev.currentTarget.id);
},
handleMouseLeave: function(ev) {
AppActions.leaveEdge(ev.currentTarget.id);
}
});
module.exports = Edge;

View File

@@ -1,6 +1,7 @@
const React = require('react');
const tweenState = require('react-tween-state');
const AppActions = require('../actions/app-actions');
const NodeColorMixin = require('../mixins/node-color-mixin');
const Node = React.createClass({
@@ -47,7 +48,9 @@ const Node = React.createClass({
const className = this.props.highlighted ? 'node highlighted' : 'node';
return (
<g className={className} transform={transform} onClick={this.props.onClick} id={this.props.id}>
<g className={className} transform={transform} id={this.props.id}
onClick={this.props.onClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
{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>
@@ -55,7 +58,16 @@ const Node = React.createClass({
<text className="node-sublabel" textAnchor="middle" x={textOffsetX} y={textOffsetY + 17}>{this.props.subLabel}</text>
</g>
);
},
handleMouseEnter: function(ev) {
AppActions.enterNode(ev.currentTarget.id);
},
handleMouseLeave: function(ev) {
AppActions.leaveNode(ev.currentTarget.id);
}
});
module.exports = Node;

View File

@@ -2,6 +2,8 @@ const _ = require('lodash');
const d3 = require('d3');
const React = require('react');
const Edge = require('./edge');
const Naming = require('../constants/naming');
const NodesLayout = require('./nodes-layout');
const Node = require('./node');
@@ -12,11 +14,6 @@ const MARGINS = {
bottom: 0
};
const line = d3.svg.line()
.interpolate('basis')
.x(function(d) { return d.x; })
.y(function(d) { return d.y; });
const NodesChart = React.createClass({
getInitialState: function() {
@@ -77,9 +74,9 @@ const NodesChart = React.createClass({
return fingerprint.join(';');
},
getGraphNodes: function(nodes, scale) {
renderGraphNodes: function(nodes, scale) {
return _.map(nodes, function(node) {
const highlighted = _.includes(this.props.highlightedNodes, node.id);
const highlighted = _.includes(this.props.highlightedNodeIds, node.id);
return (
<Node
highlighted={highlighted}
@@ -96,17 +93,18 @@ const NodesChart = React.createClass({
}, this);
},
getGraphEdges: function(edges) {
renderGraphEdges: function(edges) {
return _.map(edges, function(edge) {
const highlighted = _.includes(this.props.highlightedEdgeIds, edge.id);
return (
<path className="link" d={line(edge.points)} key={edge.id} />
<Edge key={edge.id} id={edge.id} points={edge.points} highlighted={highlighted} />
);
});
}, this);
},
render: function() {
const nodeElements = this.getGraphNodes(this.state.nodes, this.state.nodeScale);
const edgeElements = this.getGraphEdges(this.state.edges, this.state.nodeScale);
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 + ')';
@@ -131,12 +129,17 @@ const NodesChart = React.createClass({
_.each(topology, function(node, id) {
nodes[id] = prevNodes[id] || {};
// initialize position for new nodes
_.defaults(nodes[id], {
x: centerX,
y: centerY,
textAnchor: 'start'
});
// copy relevant fields to state nodes
_.assign(nodes[id], {
adjacency: node.adjacency,
id: id,
label: node.label_major,
subLabel: node.label_minor,
@@ -153,7 +156,7 @@ const NodesChart = React.createClass({
_.each(topology, function(node) {
_.each(node.adjacency, function(adjacent) {
const edge = [node.id, adjacent];
const edgeId = edge.join('-');
const edgeId = edge.join(Naming.EDGE_ID_SEPARATOR);
if (!edges[edgeId]) {
const source = nodes[edge[0]];

View File

@@ -19,6 +19,8 @@ function getStateFromStores() {
currentTopology: AppStore.getCurrentTopology(),
connectionState: AppStore.getConnectionState(),
currentGrouping: AppStore.getCurrentGrouping(),
highlightedEdgeIds: AppStore.getHighlightedEdgeIds(),
highlightedNodeIds: AppStore.getHighlightedNodeIds(),
selectedNodeId: AppStore.getSelectedNodeId(),
nodeDetails: AppStore.getNodeDetails(),
nodes: AppStore.getNodes(),
@@ -67,7 +69,8 @@ const App = React.createClass({
<Status connectionState={this.state.connectionState} />
</div>
<Nodes nodes={this.state.nodes} />
<Nodes nodes={this.state.nodes} highlightedNodeIds={this.state.highlightedNodeIds}
highlightedEdgeIds={this.state.highlightedEdgeIds} />
</div>
);
}

View File

@@ -31,8 +31,10 @@ const Nodes = React.createClass({
return (
<div id="nodes">
<NodesChart
onNodeClick={this.onNodeClick}
highlightedEdgeIds={this.props.highlightedEdgeIds}
highlightedNodeIds={this.props.highlightedNodeIds}
nodes={this.props.nodes}
onNodeClick={this.onNodeClick}
width={this.state.width}
height={this.state.height}
context="view"

View File

@@ -5,8 +5,10 @@ module.exports = keymirror({
CLICK_GROUPING: null,
CLICK_NODE: null,
CLICK_TOPOLOGY: null,
ENTER_EDGE: null,
ENTER_NODE: null,
HIT_ESC_KEY: null,
LEAVE_EDGE: null,
LEAVE_NODE: null,
RECEIVE_NODE_DETAILS: null,
RECEIVE_NODES: null,

View File

@@ -0,0 +1,4 @@
module.exports = {
EDGE_ID_SEPARATOR: '-'
};

View File

@@ -4,6 +4,7 @@ const assign = require('object-assign');
const AppDispatcher = require('../dispatcher/app-dispatcher');
const ActionTypes = require('../constants/action-types');
const Naming = require('../constants/naming');
// Helpers
@@ -16,7 +17,8 @@ function isUrlForTopologyId(url, topologyId) {
let connectionState = 'disconnected';
let currentGrouping = 'none';
let currentTopologyId = 'applications';
let mouseOverNode = null;
let mouseOverEdgeId = null;
let mouseOverNodeId = null;
let nodes = {};
let nodeDetails = null;
let selectedNodeId = null;
@@ -58,6 +60,36 @@ const AppStore = assign({}, EventEmitter.prototype, {
return currentGrouping;
},
getHighlightedEdgeIds: function() {
if (mouseOverNodeId) {
// all neighbour combinations because we dont know which direction exists
const node = nodes[mouseOverNodeId];
return _.flatten(
_.map(node.adjacency, function(nodeId) {
return [
[nodeId, mouseOverNodeId].join(Naming.EDGE_ID_SEPARATOR),
[mouseOverNodeId, nodeId].join(Naming.EDGE_ID_SEPARATOR)
];
})
);
}
if (mouseOverEdgeId) {
return mouseOverEdgeId;
}
return null;
},
getHighlightedNodeIds: function() {
if (mouseOverNodeId) {
const node = nodes[mouseOverNodeId];
return _.union(node.adjacency, [mouseOverNodeId]);
}
if (mouseOverEdgeId) {
return mouseOverEdgeId.split(Naming.EDGE_ID_SEPARATOR);
}
return null;
},
getNodeDetails: function() {
return nodeDetails;
},
@@ -110,8 +142,13 @@ AppStore.registeredCallback = function(payload) {
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.ENTER_EDGE:
mouseOverEdgeId = payload.edgeId;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.ENTER_NODE:
mouseOverNode = payload.nodeId;
mouseOverNodeId = payload.nodeId;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
@@ -121,8 +158,13 @@ AppStore.registeredCallback = function(payload) {
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.LEAVE_EDGE:
mouseOverEdgeId = null;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.LEAVE_NODE:
mouseOverNode = null;
mouseOverNodeId = null;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
@@ -142,8 +184,11 @@ AppStore.registeredCallback = function(payload) {
// nodes that no longer exist
_.each(payload.delta.remove, function(nodeId) {
// in case node disappears before mouseleave event
if (mouseOverNode === nodeId) {
mouseOverNode = null;
if (mouseOverNodeId === nodeId) {
mouseOverNodeId = null;
}
if (nodes[nodeId] && _.contains(mouseOverEdgeId, nodeId)) {
mouseOverEdgeId = null;
}
delete nodes[nodeId];
});

View File

@@ -162,13 +162,27 @@ body {
cursor: pointer;
}
.link {
stroke: @text-secondary-color;
stroke-width: 1.5px;
fill: none;
opacity: 0.5;
.edge {
.link {
stroke: @text-secondary-color;
stroke-width: 1.5px;
fill: none;
stroke-opacity: 0.5;
}
.shadow {
stroke: @weave-blue;
stroke-width: 10px;
fill: none;
stroke-opacity: 0;
}
&.highlighted {
.shadow {
stroke-opacity: 0.1;
}
}
}
circle.border {
stroke-width: 3px;
fill: none;
@@ -182,6 +196,15 @@ body {
circle.node {
fill: @text-color;
}
circle.highlighted {
fill: @weave-blue;
fill-opacity: 0.1;
stroke: @weave-blue;
stroke-width: 1px;
stroke-opacity: 0.4;
}
}
#details {