From 4584bed4b7c4fc3f056afbb8ff9c64744ff84db6 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 4 Sep 2015 14:47:15 +0200 Subject: [PATCH 01/15] WIP use react-motion to animate edges --- client/app/scripts/charts/edge.js | 47 +++++++++++++++++++++-- client/app/scripts/charts/node.js | 63 ++++++++++++------------------- client/package.json | 1 + 3 files changed, 68 insertions(+), 43 deletions(-) diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index c7bb9f4cc..da147741f 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -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,16 +10,53 @@ const line = d3.svg.line() .x(function(d) { return d.x; }) .y(function(d) { return d.y; }); +const flattenPoints = function(points) { + const flattened = {}; + points.forEach(function(point, i) { + flattened['x' + i] = point.x; + flattened['y' + i] = point.y; + }); + 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; + }); + return extracted; +}; + const Edge = React.createClass({ + getInitialState: function() { + return flattenPoints(this.props.points); + }, + render: function() { const className = this.props.highlighted ? 'edge highlighted' : 'edge'; + const points = flattenPoints(this.props.points); + const props = this.props; + const handleMouseEnter = this.handleMouseEnter; + const handleMouseLeave = this.handleMouseLeave; return ( - - - - + + {function(interpolated) { + const path = line(extractPoints(interpolated)); + return ( + + + + + ); + }} + ); }, diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index e47691470..a9249c40e 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -1,13 +1,12 @@ 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() { @@ -17,30 +16,8 @@ const Node = React.createClass({ }; }, - 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,6 +27,7 @@ const Node = React.createClass({ const onMouseEnter = this.handleMouseEnter; const onMouseLeave = this.handleMouseLeave; const classNames = ['node']; + const ellipsis = this.ellipsis; if (this.props.highlighted) { classNames.push('highlighted'); @@ -60,19 +38,26 @@ const Node = React.createClass({ } return ( - - {this.props.highlighted && } - - - - - {this.ellipsis(this.props.label, 14)} - - - {this.ellipsis(this.props.subLabel, 12)} - - + + {function(interpolated) { + const transform = 'translate(' + interpolated.x + ',' + interpolated.y + ')'; + return ( + + {props.highlighted && } + + + + + {ellipsis(props.label, 14)} + + + {ellipsis(props.subLabel, 12)} + + + ); + }} + ); }, diff --git a/client/package.json b/client/package.json index c14726dc4..0d38001c6 100644 --- a/client/package.json +++ b/client/package.json @@ -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", From c0b0e5f807bc546b92c8bf9f743a892d95d9fdf3 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 4 Sep 2015 17:09:29 +0200 Subject: [PATCH 02/15] animate edges via react-motion edges can have different point counts between updates, that throws Spring off, therefor I'm making sure the number of points is always 10. If anyone can find out how many points dagre maximally puts, please change this (my guess is max. 4). --- client/app/scripts/charts/edge.js | 31 +++++++++++++++++++++++++++---- client/app/scripts/charts/node.js | 11 ++--------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index da147741f..bb962803e 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -13,8 +13,8 @@ const line = d3.svg.line() const flattenPoints = function(points) { const flattened = {}; points.forEach(function(point, i) { - flattened['x' + i] = point.x; - flattened['y' + i] = point.y; + flattened['x' + i] = {val: point.x}; + flattened['y' + i] = {val: point.y}; }); return flattened; }; @@ -27,7 +27,7 @@ const extractPoints = function(points) { if (!extracted[index]) { extracted[index] = {}; } - extracted[index][axis] = value; + extracted[index][axis] = value.val; }); return extracted; }; @@ -35,7 +35,17 @@ const extractPoints = function(points) { const Edge = React.createClass({ getInitialState: function() { - return flattenPoints(this.props.points); + return { + points: [] + }; + }, + + componentWillMount: function() { + this.ensureSameLength(this.props.points); + }, + + componentWillReceiveProps: function(nextProps) { + this.ensureSameLength(nextProps.points); }, render: function() { @@ -60,6 +70,19 @@ const Edge = React.createClass({ ); }, + 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); }, diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index a9249c40e..4ed254cb6 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -9,13 +9,6 @@ const Node = React.createClass({ NodeColorMixin ], - getInitialState: function() { - return { - x: 0, - y: 0 - }; - }, - render: function() { const props = this.props; const scale = this.props.scale; @@ -38,9 +31,9 @@ const Node = React.createClass({ } return ( - + {function(interpolated) { - const transform = 'translate(' + interpolated.x + ',' + interpolated.y + ')'; + const transform = 'translate(' + interpolated.x.val + ',' + interpolated.y.val + ')'; return ( From 8dbb13a972e795054b557c8473a945f5c5919525 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 8 Sep 2015 14:00:52 +0200 Subject: [PATCH 03/15] slow animation down a bit --- client/app/scripts/charts/edge.js | 6 ++++-- client/app/scripts/charts/node.js | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index bb962803e..97797195f 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -10,11 +10,13 @@ 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}; - flattened['y' + i] = {val: point.y}; + flattened['x' + i] = {val: point.x, config: animConfig}; + flattened['y' + i] = {val: point.y, config: animConfig}; }); return flattened; }; diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index 4ed254cb6..eff188b02 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -21,6 +21,7 @@ const Node = React.createClass({ const onMouseLeave = this.handleMouseLeave; const classNames = ['node']; const ellipsis = this.ellipsis; + const animConfig = [80, 20]; // stiffness, bounce if (this.props.highlighted) { classNames.push('highlighted'); @@ -31,7 +32,7 @@ const Node = React.createClass({ } return ( - + {function(interpolated) { const transform = 'translate(' + interpolated.x.val + ',' + interpolated.y.val + ')'; return ( From f0a5fcc439416096c4e01d36c6b6bedaea908a5e Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 9 Sep 2015 16:36:33 +0200 Subject: [PATCH 04/15] highlight selected node and circle adjacent nodes --- client/app/scripts/charts/nodes-chart.js | 85 +++++++++++++++++++++++- client/app/scripts/components/app.js | 1 + client/app/scripts/components/nodes.js | 2 +- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 2a6d8b674..64380771a 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -50,6 +50,12 @@ const NodesChart = React.createClass({ }); this.updateGraphState(nextProps); } + if (this.props.selectedNodeId !== nextProps.selectedNodeId) { + this.restoreLayout(); + } + if (nextProps.selectedNodeId) { + this.centerSelectedNode(nextProps); + } }, componentWillUnmount: function() { @@ -77,7 +83,8 @@ const NodesChart = React.createClass({ renderGraphNodes: function(nodes, scale) { 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; return (
diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 9550f55cd..18ba2f2f3 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -33,12 +33,12 @@ const Nodes = React.createClass({
); From 2c554fe273c4204ca574126722371d378d108557 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 9 Sep 2015 18:12:02 +0200 Subject: [PATCH 05/15] blur nodes that not selected nor adjacent --- client/app/scripts/charts/edge.js | 12 ++++++++++-- client/app/scripts/charts/node.js | 7 +++++-- client/app/scripts/charts/nodes-chart.js | 18 ++++++++++++++++-- client/app/scripts/stores/app-store.js | 6 ++++++ client/app/styles/main.less | 11 +++++++++++ 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index 97797195f..5a40ea3cd 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -51,18 +51,26 @@ const Edge = React.createClass({ }, 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 ( {function(interpolated) { const path = line(extractPoints(interpolated)); return ( - + diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index eff188b02..039dd9e3e 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -26,17 +26,20 @@ const Node = React.createClass({ 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 ( {function(interpolated) { const transform = 'translate(' + interpolated.x.val + ',' + interpolated.y.val + ')'; return ( - {props.highlighted && } diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 64380771a..ffb41420c 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -82,11 +82,18 @@ const NodesChart = React.createClass({ }, renderGraphNodes: function(nodes, scale) { + const hasSelectedNode = this.props.selectedNodeId && this.props.nodes.has(this.props.selectedNodeId); + const adjacency = hasSelectedNode ? this.props.nodes.get(this.props.selectedNodeId).get('adjacency') : null; return _.map(nodes, function(node) { 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 ( + ); }, this); }, @@ -200,7 +214,7 @@ const NodesChart = React.createClass({ return; } - const adjacency = props.nodes.get(props.selectedNodeId).get('adjacency'); + const adjacency = this.props.nodes.get(props.selectedNodeId).get('adjacency'); const adjacentLayoutNodes = []; adjacency.forEach(function(adjacentId) { diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index 81f601083..7960a6388 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -93,6 +93,12 @@ const AppStore = assign({}, EventEmitter.prototype, { return activeTopologyOptions; }, + getAdjacentNodes: function() { + if (nodes.has(selectedNodeId)) { + return nodes.get(selectedNodeId).get('adjacency'); + } + }, + getCurrentTopology: function() { if (!currentTopology) { currentTopology = setTopology(currentTopologyId); diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 1eb64c63d..c5e00e0ed 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -176,6 +176,11 @@ body { g.node { cursor: pointer; + transition: opacity .5s ease-in-out; + + &.blurred { + opacity: 0.25; + } &.pseudo { opacity: 0.8; @@ -197,6 +202,12 @@ body { } .edge { + transition: opacity .5s ease-in-out; + + &.blurred { + opacity: 0.2; + } + .link { stroke: @text-secondary-color; stroke-width: 1.5px; From f764e4415ecffeebf577b98136a9a7e5dced91d7 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 9 Sep 2015 18:26:44 +0200 Subject: [PATCH 06/15] respect both ways of adjacency for selected node --- client/app/scripts/charts/nodes-chart.js | 5 +++-- client/app/scripts/stores/app-store.js | 14 +++++++++++++- client/app/styles/main.less | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index ffb41420c..73ff51e1f 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -4,6 +4,7 @@ const debug = require('debug')('scope:nodes-chart'); const React = require('react'); const timely = require('timely'); +const AppStore = require('../stores/app-store'); const Edge = require('./edge'); const Naming = require('../constants/naming'); const NodesLayout = require('./nodes-layout'); @@ -83,7 +84,7 @@ const NodesChart = React.createClass({ renderGraphNodes: function(nodes, scale) { const hasSelectedNode = this.props.selectedNodeId && this.props.nodes.has(this.props.selectedNodeId); - const adjacency = hasSelectedNode ? this.props.nodes.get(this.props.selectedNodeId).get('adjacency') : null; + const adjacency = hasSelectedNode ? AppStore.getAdjacentNodes() : null; return _.map(nodes, function(node) { const highlighted = _.includes(this.props.highlightedNodeIds, node.id) || this.props.selectedNodeId === node.id; @@ -214,7 +215,7 @@ const NodesChart = React.createClass({ return; } - const adjacency = this.props.nodes.get(props.selectedNodeId).get('adjacency'); + const adjacency = AppStore.getAdjacentNodes(); const adjacentLayoutNodes = []; adjacency.forEach(function(adjacentId) { diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index 7960a6388..bc594492d 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -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; @@ -94,9 +96,19 @@ const AppStore = assign({}, EventEmitter.prototype, { }, getAdjacentNodes: function() { + adjacentNodes = adjacentNodes.clear(); + if (nodes.has(selectedNodeId)) { - return nodes.get(selectedNodeId).get('adjacency'); + adjacentNodes = makeSet(nodes.get(selectedNodeId).get('adjacency')); + // fill up set with reverse edges + nodes.forEach(function(node, nodeId) { + if (node.get('adjacency').includes(selectedNodeId)) { + adjacentNodes = adjacentNodes.add(nodeId); + } + }); } + + return adjacentNodes; }, getCurrentTopology: function() { diff --git a/client/app/styles/main.less b/client/app/styles/main.less index c5e00e0ed..2a2b8022f 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -210,7 +210,7 @@ body { .link { stroke: @text-secondary-color; - stroke-width: 1.5px; + stroke-width: 1px; fill: none; stroke-opacity: 0.5; } From 7eb6b93ee90f4920957d3e47a060779207bc38ce Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 9 Sep 2015 18:44:54 +0200 Subject: [PATCH 07/15] added test for adjacency --- .../app/scripts/stores/__tests__/app-store-test.js | 14 ++++++++++++++ client/app/scripts/stores/app-store.js | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js index 1fae1e04d..a6011fa5c 100644 --- a/client/app/scripts/stores/__tests__/app-store-test.js +++ b/client/app/scripts/stores/__tests__/app-store-test.js @@ -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().size).toEqual(2); + expect(AppStore.getAdjacentNodes().has('n1')).toBeTruthy(); + expect(AppStore.getAdjacentNodes().has('n2')).toBeTruthy(); + + registeredCallback(HitEscAction) + expect(AppStore.getAdjacentNodes().size).toEqual(0); + }); }); \ No newline at end of file diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index bc594492d..8745de52a 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -102,7 +102,7 @@ const AppStore = assign({}, EventEmitter.prototype, { adjacentNodes = makeSet(nodes.get(selectedNodeId).get('adjacency')); // fill up set with reverse edges nodes.forEach(function(node, nodeId) { - if (node.get('adjacency').includes(selectedNodeId)) { + if (node.get('adjacency') && node.get('adjacency').includes(selectedNodeId)) { adjacentNodes = adjacentNodes.add(nodeId); } }); From b49e1f55d2a3c64cc749c347eea5e6908d8b331c Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 10 Sep 2015 15:38:03 +0200 Subject: [PATCH 08/15] Use full adjacency also for mouseover nodes --- client/app/scripts/charts/nodes-chart.js | 4 ++-- client/app/scripts/stores/app-store.js | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 73ff51e1f..e1c20318f 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -84,7 +84,7 @@ 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() : null; + const adjacency = hasSelectedNode ? AppStore.getAdjacentNodes(this.props.selectedNodeId) : null; return _.map(nodes, function(node) { const highlighted = _.includes(this.props.highlightedNodeIds, node.id) || this.props.selectedNodeId === node.id; @@ -215,7 +215,7 @@ const NodesChart = React.createClass({ return; } - const adjacency = AppStore.getAdjacentNodes(); + const adjacency = AppStore.getAdjacentNodes(props.selectedNodeId); const adjacentLayoutNodes = []; adjacency.forEach(function(adjacentId) { diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index 8745de52a..09aa0aac4 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -95,15 +95,15 @@ const AppStore = assign({}, EventEmitter.prototype, { return activeTopologyOptions; }, - getAdjacentNodes: function() { + getAdjacentNodes: function(nodeId) { adjacentNodes = adjacentNodes.clear(); - if (nodes.has(selectedNodeId)) { - adjacentNodes = makeSet(nodes.get(selectedNodeId).get('adjacency')); + if (nodes.has(nodeId)) { + adjacentNodes = makeSet(nodes.get(nodeId).get('adjacency')); // fill up set with reverse edges - nodes.forEach(function(node, nodeId) { - if (node.get('adjacency') && node.get('adjacency').includes(selectedNodeId)) { - adjacentNodes = adjacentNodes.add(nodeId); + nodes.forEach(function(node, id) { + if (node.get('adjacency') && node.get('adjacency').includes(nodeId)) { + adjacentNodes = adjacentNodes.add(id); } }); } @@ -157,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]); } } From 72c433eb860ba8f79f161e67fdbc32e41a14cc92 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 10 Sep 2015 15:55:07 +0200 Subject: [PATCH 09/15] make sure circular layouts lots of nodes spreadout --- client/app/scripts/charts/nodes-chart.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index e1c20318f..7702b4e10 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -17,6 +17,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() { @@ -226,8 +230,9 @@ const NodesChart = React.createClass({ const centerX = selectedLayoutNode.x; const centerY = selectedLayoutNode.y; - const radius = Math.min(props.width, props.height) / 3; 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; From 6c5c2c2c946e46c777036443a0b2a68660b4f735 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 11 Sep 2015 11:03:59 +0200 Subject: [PATCH 10/15] ignore previous node state/flush states on topo change --- client/app/scripts/charts/nodes-chart.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 7702b4e10..026a3eb3c 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -48,11 +48,14 @@ const NodesChart = React.createClass({ }, componentWillReceiveProps: function(nextProps) { - if (nextProps.nodes !== this.props.nodes) { + // wipe node states when showing different topology + if (nextProps.topologyId !== this.props.topologyId) { this.setState({ nodes: {}, edges: {} }); + } + if (nextProps.nodes !== this.props.nodes) { this.updateGraphState(nextProps); } if (this.props.selectedNodeId !== nextProps.selectedNodeId) { @@ -151,13 +154,13 @@ const NodesChart = React.createClass({ ); }, - 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], { From 31f7a97016c5216ede707f58ab9f3511c286e8a5 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 11 Sep 2015 14:42:10 +0200 Subject: [PATCH 11/15] setState only once in componentWillReceiveProps setstate does not set this.state immediately, so successive function calls cant rely on it --- client/app/scripts/charts/nodes-chart.js | 49 +++++++++++-------- .../stores/__tests__/app-store-test.js | 6 +-- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 026a3eb3c..740c34e7d 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -35,7 +35,8 @@ const NodesChart = React.createClass({ }, componentWillMount: function() { - this.updateGraphState(this.props); + const state = this.updateGraphState(this.props, this.state); + this.setState(state); }, componentDidMount: function() { @@ -48,22 +49,28 @@ const NodesChart = React.createClass({ }, componentWillReceiveProps: function(nextProps) { + // 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) { - this.setState({ + _.assign(state, { nodes: {}, edges: {} }); } + // FIXME add PureRenderMixin, Immutables, and move the following functions to render() if (nextProps.nodes !== this.props.nodes) { - this.updateGraphState(nextProps); + _.assign(state, this.updateGraphState(nextProps, state)); } if (this.props.selectedNodeId !== nextProps.selectedNodeId) { - this.restoreLayout(); + _.assign(state, this.restoreLayout(state)); } if (nextProps.selectedNodeId) { - this.centerSelectedNode(nextProps); + this.centerSelectedNode(nextProps, state); } + + this.setState(state); }, componentWillUnmount: function() { @@ -213,13 +220,13 @@ const NodesChart = React.createClass({ return edges; }, - centerSelectedNode: function(props) { - const layoutNodes = this.state.nodes; - const layoutEdges = this.state.edges; + centerSelectedNode: function(props, state) { + const layoutNodes = state.nodes; + const layoutEdges = state.edges; const selectedLayoutNode = layoutNodes[props.selectedNodeId]; if (!selectedLayoutNode) { - return; + return {}; } const adjacency = AppStore.getAdjacentNodes(props.selectedNodeId); @@ -257,15 +264,15 @@ const NodesChart = React.createClass({ } }); - this.setState({ + return { edges: layoutEdges, nodes: layoutNodes - }); + }; }, - restoreLayout: function() { - const edges = this.state.edges; - const nodes = this.state.nodes; + restoreLayout: function(state) { + const edges = state.edges; + const nodes = state.nodes; _.each(nodes, function(node) { node.x = node.px; @@ -278,17 +285,17 @@ const NodesChart = React.createClass({ } }); - this.setState({edges: edges, nodes: nodes}); + return {edges: edges, nodes: nodes}; }, - updateGraphState: function(props) { + 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); @@ -310,7 +317,7 @@ const NodesChart = React.createClass({ // layout was aborted if (!graph) { - return; + return {}; } // save coordinates for restore @@ -334,12 +341,12 @@ const NodesChart = React.createClass({ this.zoom.scale(zoomFactor); } - this.setState({ + return { nodes: nodes, edges: edges, nodeScale: nodeScale, scale: zoomScale - }); + }; }, zoomed: function() { diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js index a6011fa5c..81da4b9b9 100644 --- a/client/app/scripts/stores/__tests__/app-store-test.js +++ b/client/app/scripts/stores/__tests__/app-store-test.js @@ -292,9 +292,9 @@ describe('AppStore', function() { expect(AppStore.getAdjacentNodes().size).toEqual(0); registeredCallback(ClickNodeAction); - expect(AppStore.getAdjacentNodes().size).toEqual(2); - expect(AppStore.getAdjacentNodes().has('n1')).toBeTruthy(); - expect(AppStore.getAdjacentNodes().has('n2')).toBeTruthy(); + 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); From 396ebdd13eaec82ad598b3426715a6498f58d4c4 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 11 Sep 2015 14:46:07 +0200 Subject: [PATCH 12/15] pseudo nodes should be equally blurred --- client/app/styles/main.less | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 2a2b8022f..85d063a05 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -178,10 +178,6 @@ body { cursor: pointer; transition: opacity .5s ease-in-out; - &.blurred { - opacity: 0.25; - } - &.pseudo { opacity: 0.8; cursor: default; @@ -199,6 +195,10 @@ body { stroke-width: 1px; } } + + &.blurred { + opacity: 0.25; + } } .edge { From afbd1397df4d6caf483d3842ce84f0ea246c3325 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 11 Sep 2015 15:41:26 +0200 Subject: [PATCH 13/15] shift canvas if selected nodes are hidden --- client/app/scripts/charts/nodes-chart.js | 22 ++++++++++++++++++++-- client/app/scripts/components/app.js | 4 +++- client/app/scripts/components/nodes.js | 1 + 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 740c34e7d..fec3fc742 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -28,7 +28,7 @@ const NodesChart = React.createClass({ nodes: {}, edges: {}, nodeScale: 1, - translate: '0,0', + translate: [0, 0], scale: 1, hasZoomed: false }; @@ -146,6 +146,7 @@ const NodesChart = React.createClass({ const edgeElements = this.renderGraphEdges(this.state.edges, this.state.nodeScale); const transform = 'translate(' + this.state.translate + ')' + ' scale(' + this.state.scale + ')'; + debug('translate', transform); return ( @@ -264,9 +265,26 @@ const NodesChart = React.createClass({ } }); + // 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; + } + // saving translate in d3's panning cache + this.zoom.translate(translate); + return { edges: layoutEdges, - nodes: layoutNodes + nodes: layoutNodes, + translate: translate }; }, diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 6b1cd2aca..255a6732e 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -60,6 +60,8 @@ 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; return (
@@ -76,7 +78,7 @@ const App = React.createClass({
diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 18ba2f2f3..961673bd9 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -39,6 +39,7 @@ const Nodes = React.createClass({ width={this.state.width} height={this.state.height} topologyId={this.props.topologyId} + detailsWidth={this.props.detailsWidth} /> ); From bc7e8f27fcd0ee6708384023d2731c57166114b8 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 11 Sep 2015 16:26:22 +0200 Subject: [PATCH 14/15] animate shifting of canvas --- client/app/scripts/charts/nodes-chart.js | 44 +++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index fec3fc742..ded648668 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -3,6 +3,7 @@ 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'); @@ -29,6 +30,7 @@ const NodesChart = React.createClass({ edges: {}, nodeScale: 1, translate: [0, 0], + panTranslate: [0, 0], scale: 1, hasZoomed: false }; @@ -144,20 +146,37 @@ 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 + ')'; - debug('translate', transform); + 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 ( - - - {edgeElements} - - - {nodeElements} - - + + {function(interpolated) { + let interpolatedTranslate = wasShifted ? interpolated.val : panTranslate; + const transform = 'translate(' + interpolatedTranslate + ')' + + ' scale(' + scale + ')'; + return ( + + + {edgeElements} + + + {nodeElements} + + + ); + }} + ); }, @@ -370,7 +389,8 @@ const NodesChart = React.createClass({ zoomed: function() { this.setState({ hasZoomed: true, - translate: d3.event.translate, + panTranslate: d3.event.translate.slice(), + translate: d3.event.translate.slice(), scale: d3.event.scale }); } From 6191999c9ee7b477e2822b8e6899753e44d11217 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 11 Sep 2015 16:39:00 +0200 Subject: [PATCH 15/15] shift canvas up/down if node circle off canvas --- client/app/scripts/charts/nodes-chart.js | 11 +++++++++++ client/app/scripts/components/app.js | 3 ++- client/app/scripts/components/nodes.js | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index ded648668..039ac5f21 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -297,6 +297,17 @@ const NodesChart = React.createClass({ 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); diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 255a6732e..b02e79768 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -62,6 +62,7 @@ const App = React.createClass({ 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 (
@@ -79,7 +80,7 @@ const App = React.createClass({
diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 961673bd9..c2e088c19 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -40,6 +40,7 @@ const Nodes = React.createClass({ height={this.state.height} topologyId={this.props.topologyId} detailsWidth={this.props.detailsWidth} + topMargin={this.props.topMargin} />
);