From 3ca7415d47833c0e3b14f5c71754ba11ccc9b5e8 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 10 Nov 2015 19:23:55 +0100 Subject: [PATCH 1/4] Always center selected node * dont allow panning while node is selected * Shift nodes, not canvas on circling selection * scale nodes on window resize * Scale selected node to viewable size --- client/app/scripts/charts/node.js | 29 +++-- client/app/scripts/charts/nodes-chart.js | 139 ++++++----------------- client/app/scripts/components/nodes.js | 17 ++- client/app/styles/main.less | 2 - 4 files changed, 69 insertions(+), 118 deletions(-) diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index b2c2d3ab7..b06c96dd0 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -12,15 +12,16 @@ const Node = React.createClass({ render: function() { const props = this.props; - const scale = this.props.scale; + const scale = this.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; @@ -30,7 +31,15 @@ const Node = React.createClass({ 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)); + 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,7 +55,11 @@ const Node = React.createClass({ {function(interpolated) { const transform = `translate(${interpolated.x},${interpolated.y})`; @@ -57,10 +70,12 @@ const Node = React.createClass({ - + {label} - + {subLabel} diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index d8647cd77..da979a293 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -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,9 @@ const NodesChart = React.createClass({ return { nodes: makeMap(), edges: makeMap(), - nodeScale: d3.scale.linear(), - shiftTranslate: [0, 0], panTranslate: [0, 0], scale: 1, hasZoomed: false, - autoShifted: false, maxNodesExceeded: false }; }, @@ -63,7 +58,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 +87,11 @@ 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; // highlighter functions const setHighlighted = node => { @@ -142,7 +137,8 @@ const NodesChart = React.createClass({ pseudo={node.get('pseudo')} subLabel={node.get('subLabel')} rank={node.get('rank')} - scale={scale} + nodeScale={nodeScale} + zoomScale={zoomScale} dx={node.get('x')} dy={node.get('y')} /> @@ -198,46 +194,29 @@ 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 nodeElements = this.renderGraphNodes(this.state.nodes, this.props.nodeScale); + const edgeElements = this.renderGraphEdges(this.state.edges, this.props.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 (
{errorEmpty} {errorMaxNodesExceeded} - - {function(interpolated) { - const interpolatedTranslate = wasShifted ? [interpolated.x, interpolated.y] : panTranslate; - const transform = 'translate(' + interpolatedTranslate + ')' + - ' scale(' + scale + ')'; - return ( - - - {edgeElements} - - - {nodeElements} - - - ); - }} - + + + {edgeElements} + + + {nodeElements} + +
); @@ -309,10 +288,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 +302,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 +333,9 @@ 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; - } - return { - autoShifted: autoShifted, edges: stateEdges, - nodes: stateNodes, - shiftTranslate: shiftTranslate + nodes: stateNodes }; }, @@ -407,10 +344,6 @@ const NodesChart = React.createClass({ handleMouseClick: function() { if (!this.isZooming) { AppActions.clickCloseDetails(); - // allow shifts again - this.setState({ - autoShifted: false - }); } else { this.isZooming = false; } @@ -431,7 +364,7 @@ const NodesChart = React.createClass({ return edge; }); - return {edges, nodes}; + return { edges, nodes}; }, updateGraphState: function(props, state) { @@ -447,14 +380,10 @@ const NodesChart = React.createClass({ let stateNodes = this.initNodes(props.nodes, state.nodes); let stateEdges = this.initEdges(props.nodes, stateNodes); - 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, - scale: nodeScale, + scale: props.nodeScale, margins: MARGINS, topologyId: this.props.topologyId }; @@ -497,7 +426,6 @@ const NodesChart = React.createClass({ return { nodes: stateNodes, edges: stateEdges, - nodeScale: nodeScale, scale: zoomScale, maxNodesExceeded: false }; @@ -506,13 +434,14 @@ const NodesChart = React.createClass({ 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 + }); + } } }); diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 3e913aae7..0d70ea226 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -1,3 +1,4 @@ +const d3 = require('d3'); const React = require('react'); const NodesChart = require('../charts/nodes-chart'); @@ -9,12 +10,14 @@ const Nodes = React.createClass({ getInitialState: function() { return { + nodeScale: d3.scale.linear(), width: window.innerWidth, height: window.innerHeight - navbarHeight - marginTop }; }, componentDidMount: function() { + this.setDimensions(); window.addEventListener('resize', this.handleResize); }, @@ -31,6 +34,7 @@ const Nodes = React.createClass({ nodes={this.props.nodes} width={this.state.width} height={this.state.height} + nodeScale={this.state.nodeScale} topologyId={this.props.topologyId} detailsWidth={this.props.detailsWidth} topMargin={this.props.topMargin} @@ -43,10 +47,15 @@ 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; + const expanse = Math.min(height, 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(this.props.nodes.size), maxNodeSize); + const nodeScale = this.state.nodeScale.range([0, normalizedNodeSize]); + + this.setState({height, width, nodeScale}); } }); diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 8c41679c8..d174c4f89 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -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; } } From b6905d136d81e555f1fffef0ed81be616e6c61b5 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 12 Nov 2015 15:00:06 +0100 Subject: [PATCH 2/4] Undo pan/zoom after unselecting a node --- client/app/scripts/charts/nodes-chart.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index da979a293..96d3d223f 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -350,6 +350,10 @@ const NodesChart = React.createClass({ }, 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'), From 117f8b84443207b2cd72db2f87249c340f2851b5 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 12 Nov 2015 16:18:19 +0100 Subject: [PATCH 3/4] Fix sublabel sizing --- client/app/scripts/charts/node.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index b06c96dd0..f51154b5b 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -12,7 +12,7 @@ const Node = React.createClass({ render: function() { const props = this.props; - const scale = this.props.nodeScale; + const nodeScale = this.props.nodeScale; const zoomScale = this.props.zoomScale; let scaleFactor = 1; if (props.focused) { @@ -29,8 +29,8 @@ 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; @@ -66,16 +66,16 @@ const Node = React.createClass({ return ( - {props.highlighted && } - - - + {props.highlighted && } + + + + x="0" y={interpolated.labelOffsetY + nodeScale(0.5 * interpolated.f)}> {label} - + {subLabel} From 9fb3099a893124913a43b49ed95ff299f466d476 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 12 Nov 2015 17:22:33 +0100 Subject: [PATCH 4/4] Dont scale graph on resize, only selected nodes --- client/app/scripts/charts/node.js | 2 +- client/app/scripts/charts/nodes-chart.js | 24 +++++++++++++++++++++--- client/app/scripts/components/nodes.js | 11 +---------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index f51154b5b..59023a745 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -12,7 +12,7 @@ const Node = React.createClass({ render: function() { const props = this.props; - const nodeScale = this.props.nodeScale; + const nodeScale = props.focused ? props.selectedNodeScale : props.nodeScale; const zoomScale = this.props.zoomScale; let scaleFactor = 1; if (props.focused) { diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 96d3d223f..4c5507854 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -32,6 +32,8 @@ const NodesChart = React.createClass({ edges: makeMap(), panTranslate: [0, 0], scale: 1, + nodeScale: d3.scale.linear(), + selectedNodeScale: d3.scale.linear(), hasZoomed: false, maxNodesExceeded: false }; @@ -92,6 +94,7 @@ const NodesChart = React.createClass({ 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 => { @@ -137,6 +140,7 @@ const NodesChart = React.createClass({ pseudo={node.get('pseudo')} subLabel={node.get('subLabel')} rank={node.get('rank')} + selectedNodeScale={selectedNodeScale} nodeScale={nodeScale} zoomScale={zoomScale} dx={node.get('x')} @@ -194,8 +198,8 @@ const NodesChart = React.createClass({ }, render: function() { - const nodeElements = this.renderGraphNodes(this.state.nodes, this.props.nodeScale); - const edgeElements = this.renderGraphEdges(this.state.edges, this.props.nodeScale); + const nodeElements = this.renderGraphNodes(this.state.nodes, this.state.nodeScale); + const edgeElements = this.renderGraphEdges(this.state.edges, this.state.nodeScale); const scale = this.state.scale; const translate = this.state.panTranslate; @@ -333,7 +337,11 @@ const NodesChart = React.createClass({ return edge; }); + // auto-scale node size for selected nodes + const selectedNodeScale = this.getNodeScale(props); + return { + selectedNodeScale, edges: stateEdges, nodes: stateNodes }; @@ -383,11 +391,12 @@ 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 options = { width: props.width, height: props.height, - scale: props.nodeScale, + scale: nodeScale, margins: MARGINS, topologyId: this.props.topologyId }; @@ -431,10 +440,19 @@ const NodesChart = React.createClass({ nodes: stateNodes, edges: stateEdges, 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; diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 0d70ea226..60cb2f932 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -1,4 +1,3 @@ -const d3 = require('d3'); const React = require('react'); const NodesChart = require('../charts/nodes-chart'); @@ -10,14 +9,12 @@ const Nodes = React.createClass({ getInitialState: function() { return { - nodeScale: d3.scale.linear(), width: window.innerWidth, height: window.innerHeight - navbarHeight - marginTop }; }, componentDidMount: function() { - this.setDimensions(); window.addEventListener('resize', this.handleResize); }, @@ -34,7 +31,6 @@ const Nodes = React.createClass({ nodes={this.props.nodes} width={this.state.width} height={this.state.height} - nodeScale={this.state.nodeScale} topologyId={this.props.topologyId} detailsWidth={this.props.detailsWidth} topMargin={this.props.topMargin} @@ -49,13 +45,8 @@ const Nodes = React.createClass({ setDimensions: function() { const width = window.innerWidth; const height = window.innerHeight - navbarHeight - marginTop; - const expanse = Math.min(height, 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(this.props.nodes.size), maxNodeSize); - const nodeScale = this.state.nodeScale.range([0, normalizedNodeSize]); - this.setState({height, width, nodeScale}); + this.setState({height, width}); } });