mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 02:00:43 +00:00
Merge pull request #646 from weaveworks/615-shifting
Always center selected node
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user