Merge pull request #646 from weaveworks/615-shifting

Always center selected node
This commit is contained in:
David
2015-11-13 11:17:02 +01:00
4 changed files with 84 additions and 120 deletions

View File

@@ -12,15 +12,16 @@ const Node = React.createClass({
render: function() {
const props = this.props;
const scale = this.props.scale;
const nodeScale = props.focused ? props.selectedNodeScale : props.nodeScale;
const zoomScale = this.props.zoomScale;
let scaleFactor = 1;
if (props.focused) {
scaleFactor = 1.25;
scaleFactor = 1.25 / zoomScale;
} else if (props.blurred) {
scaleFactor = 0.75;
}
const labelOffsetY = 18;
const subLabelOffsetY = labelOffsetY + 17;
let labelOffsetY = 18;
let subLabelOffsetY = 35;
const isPseudo = !!this.props.pseudo;
const color = isPseudo ? '' : this.getNodeColor(this.props.rank);
const onMouseEnter = this.handleMouseEnter;
@@ -28,9 +29,17 @@ const Node = React.createClass({
const onMouseClick = this.handleMouseClick;
const classNames = ['node'];
const animConfig = [80, 20]; // stiffness, bounce
const label = this.ellipsis(props.label, 14, scale(4 * scaleFactor));
const subLabel = this.ellipsis(props.subLabel, 12, scale(4 * scaleFactor));
const label = this.ellipsis(props.label, 14, nodeScale(4 * scaleFactor));
const subLabel = this.ellipsis(props.subLabel, 12, nodeScale(4 * scaleFactor));
let labelFontSize = 14;
let subLabelFontSize = 12;
if (props.focused) {
labelFontSize /= zoomScale;
subLabelFontSize /= zoomScale;
labelOffsetY /= zoomScale;
subLabelOffsetY /= zoomScale;
}
if (this.props.highlighted) {
classNames.push('highlighted');
}
@@ -46,21 +55,27 @@ const Node = React.createClass({
<Motion style={{
x: spring(this.props.dx, animConfig),
y: spring(this.props.dy, animConfig),
f: spring(scaleFactor, animConfig)
f: spring(scaleFactor, animConfig),
labelFontSize: spring(labelFontSize, animConfig),
subLabelFontSize: spring(subLabelFontSize, animConfig),
labelOffsetY: spring(labelOffsetY, animConfig),
subLabelOffsetY: spring(subLabelOffsetY, animConfig)
}}>
{function(interpolated) {
const transform = `translate(${interpolated.x},${interpolated.y})`;
return (
<g className={classes} transform={transform} id={props.id}
onClick={onMouseClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{props.highlighted && <circle r={scale(0.7 * interpolated.f)} className="highlighted"></circle>}
<circle r={scale(0.5 * interpolated.f)} className="border" stroke={color}></circle>
<circle r={scale(0.45 * interpolated.f)} className="shadow"></circle>
<circle r={Math.max(2, scale(0.125 * interpolated.f))} className="node"></circle>
<text className="node-label" textAnchor="middle" x="0" y={labelOffsetY + scale(0.5 * interpolated.f)}>
{props.highlighted && <circle r={nodeScale(0.7 * interpolated.f)} className="highlighted"></circle>}
<circle r={nodeScale(0.5 * interpolated.f)} className="border" stroke={color}></circle>
<circle r={nodeScale(0.45 * interpolated.f)} className="shadow"></circle>
<circle r={Math.max(2, nodeScale(0.125 * interpolated.f))} className="node"></circle>
<text className="node-label" textAnchor="middle" style={{fontSize: interpolated.labelFontSize}}
x="0" y={interpolated.labelOffsetY + nodeScale(0.5 * interpolated.f)}>
{label}
</text>
<text className="node-sublabel" textAnchor="middle" x="0" y={subLabelOffsetY + scale(0.5 * interpolated.f)}>
<text className="node-sublabel" textAnchor="middle" style={{fontSize: interpolated.subLabelFontSize}}
x="0" y={interpolated.subLabelOffsetY + nodeScale(0.5 * interpolated.f)}>
{subLabel}
</text>
</g>

View File

@@ -4,8 +4,6 @@ const debug = require('debug')('scope:nodes-chart');
const React = require('react');
const makeMap = require('immutable').Map;
const timely = require('timely');
const Motion = require('react-motion').Motion;
const spring = require('react-motion').spring;
const AppActions = require('../actions/app-actions');
const AppStore = require('../stores/app-store');
@@ -32,12 +30,11 @@ const NodesChart = React.createClass({
return {
nodes: makeMap(),
edges: makeMap(),
nodeScale: d3.scale.linear(),
shiftTranslate: [0, 0],
panTranslate: [0, 0],
scale: 1,
nodeScale: d3.scale.linear(),
selectedNodeScale: d3.scale.linear(),
hasZoomed: false,
autoShifted: false,
maxNodesExceeded: false
};
},
@@ -63,7 +60,6 @@ const NodesChart = React.createClass({
// wipe node states when showing different topology
if (nextProps.topologyId !== this.props.topologyId) {
_.assign(state, {
autoShifted: false,
nodes: makeMap(),
edges: makeMap()
});
@@ -93,10 +89,12 @@ const NodesChart = React.createClass({
.on('touchstart.zoom', null);
},
renderGraphNodes: function(nodes, scale) {
renderGraphNodes: function(nodes, nodeScale) {
const hasSelectedNode = this.props.selectedNodeId && this.props.nodes.has(this.props.selectedNodeId);
const adjacency = hasSelectedNode ? AppStore.getAdjacentNodes(this.props.selectedNodeId) : null;
const onNodeClick = this.props.onNodeClick;
const zoomScale = this.state.scale;
const selectedNodeScale = this.state.selectedNodeScale;
// highlighter functions
const setHighlighted = node => {
@@ -142,7 +140,9 @@ const NodesChart = React.createClass({
pseudo={node.get('pseudo')}
subLabel={node.get('subLabel')}
rank={node.get('rank')}
scale={scale}
selectedNodeScale={selectedNodeScale}
nodeScale={nodeScale}
zoomScale={zoomScale}
dx={node.get('x')}
dy={node.get('y')}
/>
@@ -202,42 +202,25 @@ const NodesChart = React.createClass({
const edgeElements = this.renderGraphEdges(this.state.edges, this.state.nodeScale);
const scale = this.state.scale;
// only animate shift behavior, not panning
const panTranslate = this.state.panTranslate;
const shiftTranslate = this.state.shiftTranslate;
let translate = panTranslate;
let wasShifted = false;
if (shiftTranslate[0] !== panTranslate[0] || shiftTranslate[1] !== panTranslate[1]) {
translate = shiftTranslate;
wasShifted = true;
}
const translate = this.state.panTranslate;
const transform = 'translate(' + translate + ') scale(' + scale + ')';
const svgClassNames = this.state.maxNodesExceeded || nodeElements.size === 0 ? 'hide' : '';
const errorEmpty = this.renderEmptyTopologyError(AppStore.isTopologyEmpty());
const errorMaxNodesExceeded = this.renderMaxNodesError(this.state.maxNodesExceeded);
const motionConfig = [80, 20];
return (
<div className="nodes-chart">
{errorEmpty}
{errorMaxNodesExceeded}
<svg width="100%" height="100%" className={svgClassNames} onClick={this.handleMouseClick}>
<Motion style={{x: spring(translate[0], motionConfig), y: spring(translate[1], motionConfig)}}>
{function(interpolated) {
const interpolatedTranslate = wasShifted ? [interpolated.x, interpolated.y] : panTranslate;
const transform = 'translate(' + interpolatedTranslate + ')' +
' scale(' + scale + ')';
return (
<g className="canvas" transform={transform}>
<g className="edges">
{edgeElements}
</g>
<g className="nodes">
{nodeElements}
</g>
</g>
);
}}
</Motion>
<g className="canvas" transform={transform}>
<g className="edges">
{edgeElements}
</g>
<g className="nodes">
{nodeElements}
</g>
</g>
</svg>
</div>
);
@@ -309,10 +292,12 @@ const NodesChart = React.createClass({
}
});
// shift center node a bit
const nodeScale = state.nodeScale;
const centerX = selectedLayoutNode.get('px') + nodeScale(1);
const centerY = selectedLayoutNode.get('py') + nodeScale(1);
// move origin node to center of viewport
const zoomScale = state.scale;
const detailsWidth = 420;
const translate = state.panTranslate;
const centerX = (-translate[0] + (props.width + MARGINS.left - detailsWidth) / 2) / zoomScale;
const centerY = (-translate[1] + (props.height + MARGINS.top) / 2) / zoomScale;
stateNodes = stateNodes.mergeIn([props.selectedNodeId], {
x: centerX,
y: centerY
@@ -321,7 +306,7 @@ const NodesChart = React.createClass({
// circle layout for adjacent nodes
const adjacentCount = adjacentLayoutNodeIds.length;
const density = radiusDensity(adjacentCount);
const radius = Math.min(props.width, props.height) / density;
const radius = Math.min(props.width, props.height) / density / zoomScale;
const offsetAngle = Math.PI / 4;
stateNodes = stateNodes.map((node) => {
@@ -352,53 +337,13 @@ const NodesChart = React.createClass({
return edge;
});
// shift canvas selected node out of view if it has not been shifted already
let autoShifted = this.state.autoShifted;
const shiftTranslate = state.shiftTranslate;
if (!autoShifted) {
const visibleWidth = Math.max(props.width - props.detailsWidth, 0);
const offsetX = shiftTranslate[0];
// normalize graph coordinates by zoomScale
const zoomScale = state.scale;
const outerRadius = radius + this.state.nodeScale(1.5);
if (2 * outerRadius * zoomScale > props.width) {
// radius too big, centering center node on canvas
shiftTranslate[0] = -(centerX * zoomScale - (props.width + MARGINS.left) / 2);
} else if (offsetX + (centerX + outerRadius) * zoomScale > visibleWidth) {
// shift left if blocked by details
const shift = (centerX + outerRadius) * zoomScale - visibleWidth;
shiftTranslate[0] = -shift;
} else if (offsetX + (centerX - outerRadius) * zoomScale < 0) {
// shift right if off canvas
const shift = offsetX - offsetX + (centerX - outerRadius) * zoomScale;
shiftTranslate[0] = -shift;
}
const offsetY = shiftTranslate[1];
if (2 * outerRadius * zoomScale > props.height) {
// radius too big, centering center node on canvas
shiftTranslate[1] = -(centerY * zoomScale - (props.height + MARGINS.top) / 2);
} else if (offsetY + (centerY + outerRadius) * zoomScale > props.height) {
// shift up if past bottom
const shift = (centerY + outerRadius) * zoomScale - props.height;
shiftTranslate[1] = -shift;
} else if (offsetY + (centerY - outerRadius) * zoomScale - props.topMargin < 0) {
// shift down if off canvas
const shift = offsetY - offsetY + (centerY - outerRadius) * zoomScale - props.topMargin;
shiftTranslate[1] = -shift;
}
// debug('shift', centerX, centerY, outerRadius, shiftTranslate);
// saving translate in d3's panning cache
this.zoom.translate(shiftTranslate);
autoShifted = true;
}
// auto-scale node size for selected nodes
const selectedNodeScale = this.getNodeScale(props);
return {
autoShifted: autoShifted,
selectedNodeScale,
edges: stateEdges,
nodes: stateNodes,
shiftTranslate: shiftTranslate
nodes: stateNodes
};
},
@@ -407,16 +352,16 @@ const NodesChart = React.createClass({
handleMouseClick: function() {
if (!this.isZooming) {
AppActions.clickCloseDetails();
// allow shifts again
this.setState({
autoShifted: false
});
} else {
this.isZooming = false;
}
},
restoreLayout: function(state) {
// undo any pan/zooming that might have happened
this.zoom.scale(state.scale);
this.zoom.translate(state.panTranslate);
const nodes = state.nodes.map(node => {
return node.merge({
x: node.get('px'),
@@ -431,7 +376,7 @@ const NodesChart = React.createClass({
return edge;
});
return {edges, nodes};
return { edges, nodes};
},
updateGraphState: function(props, state) {
@@ -446,11 +391,8 @@ const NodesChart = React.createClass({
let stateNodes = this.initNodes(props.nodes, state.nodes);
let stateEdges = this.initEdges(props.nodes, stateNodes);
const nodeScale = this.getNodeScale(props);
const expanse = Math.min(props.height, props.width);
const nodeSize = expanse / 3; // single node should fill a third of the screen
const normalizedNodeSize = nodeSize / Math.sqrt(n); // assuming rectangular layout
const nodeScale = this.state.nodeScale.range([0, normalizedNodeSize]);
const options = {
width: props.width,
height: props.height,
@@ -497,22 +439,31 @@ const NodesChart = React.createClass({
return {
nodes: stateNodes,
edges: stateEdges,
nodeScale: nodeScale,
scale: zoomScale,
nodeScale: nodeScale,
maxNodesExceeded: false
};
},
getNodeScale: function(props) {
const expanse = Math.min(props.height, props.width);
const nodeSize = expanse / 3; // single node should fill a third of the screen
const maxNodeSize = expanse / 10;
const normalizedNodeSize = Math.min(nodeSize / Math.sqrt(props.nodes.size), maxNodeSize);
return this.state.nodeScale.copy().range([0, normalizedNodeSize]);
},
zoomed: function() {
// debug('zoomed', d3.event.scale, d3.event.translate);
this.isZooming = true;
this.setState({
autoShifted: false,
hasZoomed: true,
panTranslate: d3.event.translate.slice(),
shiftTranslate: d3.event.translate.slice(),
scale: d3.event.scale
});
// dont pan while node is selected
if (!this.props.selectedNodeId) {
this.setState({
hasZoomed: true,
panTranslate: d3.event.translate.slice(),
scale: d3.event.scale
});
}
}
});

View File

@@ -43,10 +43,10 @@ const Nodes = React.createClass({
},
setDimensions: function() {
this.setState({
height: window.innerHeight - navbarHeight - marginTop,
width: window.innerWidth
});
const width = window.innerWidth;
const height = window.innerHeight - navbarHeight - marginTop;
this.setState({height, width});
}
});

View File

@@ -217,7 +217,6 @@ h2 {
}
text {
font-size: 14px;
font-family: Roboto;
fill: @text-secondary-color;
text-shadow: 0 2px 0 @white, 2px 0 0 @white, 0 -2px 0 @white, -2px 0 0 @white;
@@ -227,7 +226,6 @@ h2 {
}
&.node-sublabel {
font-size: 12px;
fill: @text-secondary-color;
}
}