diff --git a/client/app/scripts/charts/__tests__/node-layout-test.js b/client/app/scripts/charts/__tests__/node-layout-test.js index a8799bd6f..04a308bb3 100644 --- a/client/app/scripts/charts/__tests__/node-layout-test.js +++ b/client/app/scripts/charts/__tests__/node-layout-test.js @@ -1,6 +1,8 @@ jest.dontMock('../nodes-layout'); jest.dontMock('../../constants/naming'); // edge naming: 'source-target' +import { fromJS } from 'immutable'; + describe('NodesLayout', () => { const NodesLayout = require('../nodes-layout'); @@ -14,6 +16,8 @@ describe('NodesLayout', () => { left: 0, top: 0 }; + let history; + let nodes; const nodeSets = { initial4: { @@ -24,27 +28,57 @@ describe('NodesLayout', () => { n4: {id: 'n4'} }, edges: { - 'n1-n3': {id: 'n1-n3', source: {id: 'n1'}, target: {id: 'n3'}}, - 'n1-n4': {id: 'n1-n4', source: {id: 'n1'}, target: {id: 'n4'}}, - 'n2-n4': {id: 'n2-n4', source: {id: 'n2'}, target: {id: 'n4'}} + 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, + 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}, + 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'} + } + }, + removeEdge24: { + nodes: { + n1: {id: 'n1'}, + n2: {id: 'n2'}, + n3: {id: 'n3'}, + n4: {id: 'n4'} + }, + edges: { + 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, + 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} } } }; - it('lays out initial nodeset', () => { - const nodes = nodeSets.initial4.nodes; - const edges = nodeSets.initial4.edges; - NodesLayout.doLayout(nodes, edges); + it('lays out initial nodeset in a rectangle', () => { + const result = NodesLayout.doLayout( + fromJS(nodeSets.initial4.nodes), + fromJS(nodeSets.initial4.edges)); + // console.log('initial', result.get('nodes')); + nodes = result.nodes.toJS(); + expect(nodes.n1.x).toBeLessThan(nodes.n2.x); expect(nodes.n1.y).toEqual(nodes.n2.y); - expect(nodes.n1.x).toEqual(nodes.n3.x); expect(nodes.n1.y).toBeLessThan(nodes.n3.y); - expect(nodes.n3.x).toBeLessThan(nodes.n4.x); expect(nodes.n3.y).toEqual(nodes.n4.y); - - console.log(nodes, nodeSets.initial4.nodes); }); + // it('keeps nodes in rectangle after removing one edge', () => { + // history = [{ + // nodes: nodeSets.initial4.nodes, + // edges: nodeSets.initial4.edges + // }]; + // nodes = nodeSets.removeEdge24.nodes; + // edges = nodeSets.removeEdge24.edges; + // NodesLayout.doLayout(nodes, edges, {history}); + // console.log('remove 1 edge', nodes); + // + // expect(nodes.n1.x).toBeLessThan(nodes.n2.x); + // expect(nodes.n1.y).toEqual(nodes.n2.y); + // expect(nodes.n1.x).toEqual(nodes.n3.x); + // expect(nodes.n1.y).toBeLessThan(nodes.n3.y); + // expect(nodes.n3.x).toBeLessThan(nodes.n4.x); + // expect(nodes.n3.y).toEqual(nodes.n4.y); + // + // }); + }); diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 945554a94..a0c98336f 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -2,6 +2,7 @@ const _ = require('lodash'); const d3 = require('d3'); const debug = require('debug')('scope:nodes-chart'); const React = require('react'); +const makeMap = require('immutable').Map; const timely = require('timely'); const Spring = require('react-motion').Spring; @@ -28,8 +29,8 @@ const NodesChart = React.createClass({ getInitialState: function() { return { - nodes: {}, - edges: {}, + nodes: makeMap(), + edges: makeMap(), nodeScale: d3.scale.linear(), shiftTranslate: [0, 0], panTranslate: [0, 0], @@ -62,8 +63,8 @@ const NodesChart = React.createClass({ if (nextProps.topologyId !== this.props.topologyId) { _.assign(state, { autoShifted: false, - nodes: {}, - edges: {} + nodes: makeMap(), + edges: makeMap() }); } // FIXME add PureRenderMixin, Immutables, and move the following functions to render() @@ -96,60 +97,81 @@ const NodesChart = React.createClass({ const adjacency = hasSelectedNode ? AppStore.getAdjacentNodes(this.props.selectedNodeId) : null; const onNodeClick = this.props.onNodeClick; - _.each(nodes, function(node) { - node.highlighted = _.includes(this.props.highlightedNodeIds, node.id) - || this.props.selectedNodeId === node.id; - node.focused = hasSelectedNode - && (this.props.selectedNodeId === node.id || adjacency.includes(node.id)); - node.blurred = hasSelectedNode && !node.focused; - }, this); + // highlighter functions + const setHighlighted = node => { + const highlighted = _.includes(this.props.highlightedNodeIds, node.get('id')) + || this.props.selectedNodeId === node.get('id'); + return node.set('highlighted', highlighted); + }; + const setFocused = node => { + const focused = hasSelectedNode + && (this.props.selectedNodeId === node.get('id') || adjacency.includes(node.get('id'))); + return node.set('focused', focused); + }; + const setBlurred = node => { + return node.set('blurred', hasSelectedNode && !node.get('focused')); + }; - return _.chain(nodes) - .sortBy(function(node) { - if (node.blurred) { - return 0; - } - if (node.highlighted) { - return 2; - } - return 1; - }) - .map(function(node) { - return ( - { + if (node.get('blurred')) { + return 0; + } + if (node.get('highlighted')) { + return 2; + } + return 1; + }; + + return nodes + .toIndexedSeq() + .map(setHighlighted) + .map(setFocused) + .map(setBlurred) + .sortBy(sortNodes) + .map(node => { + return ( ); - }) - .value(); + }); }, renderGraphEdges: function(edges) { const selectedNodeId = this.props.selectedNodeId; const hasSelectedNode = selectedNodeId && this.props.nodes.has(selectedNodeId); - return _.map(edges, function(edge) { - const highlighted = _.includes(this.props.highlightedEdgeIds, edge.id); - const blurred = hasSelectedNode - && edge.source.id !== selectedNodeId - && edge.target.id !== selectedNodeId; - return ( - - ); - }, this); + const setHighlighted = edge => { + return edge.set('highlighted', _.includes(this.props.highlightedEdgeIds, edge.get('id'))); + }; + const setBlurred = edge => { + return (edge.set('blurred', hasSelectedNode + && edge.get('source') !== selectedNodeId + && edge.get('target') !== selectedNodeId)); + }; + + return edges + .toIndexedSeq() + .map(setHighlighted) + .map(setBlurred) + .map(edge => { + return ( + + ); + }); }, renderMaxNodesError: function(show) { @@ -187,7 +209,7 @@ const NodesChart = React.createClass({ translate = shiftTranslate; wasShifted = true; } - const svgClassNames = this.state.maxNodesExceeded || _.size(nodeElements) === 0 ? 'hide' : ''; + const svgClassNames = this.state.maxNodesExceeded || nodeElements.size === 0 ? 'hide' : ''; const errorEmpty = this.renderEmptyTopologyError(AppStore.isTopologyEmpty()); const errorMaxNodesExceeded = this.renderMaxNodesError(this.state.maxNodesExceeded); @@ -219,34 +241,22 @@ const NodesChart = React.createClass({ }, initNodes: function(topology) { - const centerX = this.props.width / 2; - const centerY = this.props.height / 2; - const nodes = {}; - - topology.forEach(function(node, id) { - nodes[id] = {}; - - // use cached positions if available - _.defaults(nodes[id], { - x: centerX, - y: centerY - }); - + return topology.map((node, id) => { // copy relevant fields to state nodes - _.assign(nodes[id], { + return makeMap({ id: id, label: node.get('label_major'), pseudo: node.get('pseudo'), subLabel: node.get('label_minor'), - rank: node.get('rank') + rank: node.get('rank'), + x: 0, + y: 0 }); }); - - return nodes; }, - initEdges: function(topology, nodes) { - const edges = {}; + initEdges: function(topology, stateNodes) { + let edges = makeMap(); topology.forEach(function(node, nodeId) { const adjacency = node.get('adjacency'); @@ -255,20 +265,20 @@ const NodesChart = React.createClass({ const edge = [nodeId, adjacent]; const edgeId = edge.join(Naming.EDGE_ID_SEPARATOR); - if (!edges[edgeId]) { - const source = nodes[edge[0]]; - const target = nodes[edge[1]]; + if (!edges.has(edgeId)) { + const source = edge[0]; + const target = edge[1]; - if (!source || !target) { - debug('Missing edge node', edge[0], source, edge[1], target); + if (!stateNodes.has(source) || !stateNodes.has(target)) { + debug('Missing edge node', edge[0], edge[1]); } - edges[edgeId] = { + edges = edges.set(edgeId, makeMap({ id: edgeId, value: 1, source: source, target: target - }; + })); } }); } @@ -278,55 +288,65 @@ const NodesChart = React.createClass({ }, centerSelectedNode: function(props, state) { - const layoutNodes = state.nodes; - const layoutEdges = state.edges; - const selectedLayoutNode = layoutNodes[props.selectedNodeId]; + let stateNodes = state.nodes; + let stateEdges = state.edges; + let selectedLayoutNode = stateNodes.get(props.selectedNodeId); if (!selectedLayoutNode) { return {}; } const adjacency = AppStore.getAdjacentNodes(props.selectedNodeId); - const adjacentLayoutNodes = []; + let adjacentLayoutNodeIds = []; adjacency.forEach(function(adjacentId) { // filter loopback if (adjacentId !== props.selectedNodeId) { - adjacentLayoutNodes.push(layoutNodes[adjacentId]); + adjacentLayoutNodeIds.push(adjacentId); } }); // shift center node a bit const nodeScale = state.nodeScale; - selectedLayoutNode.x = selectedLayoutNode.px + nodeScale(1); - selectedLayoutNode.y = selectedLayoutNode.py + nodeScale(1); + const centerX = selectedLayoutNode.get('px') + nodeScale(1); + const centerY = selectedLayoutNode.get('py') + nodeScale(1); + stateNodes = stateNodes.mergeIn([props.selectedNodeId], { + x: centerX, + y: centerY + }); // circle layout for adjacent nodes - const centerX = selectedLayoutNode.x; - const centerY = selectedLayoutNode.y; - const adjacentCount = adjacentLayoutNodes.length; + const adjacentCount = adjacentLayoutNodeIds.length; const density = radiusDensity(adjacentCount); const radius = Math.min(props.width, props.height) / density; const offsetAngle = Math.PI / 4; - _.each(adjacentLayoutNodes, function(node, i) { - const angle = offsetAngle + Math.PI * 2 * i / adjacentCount; - node.x = centerX + radius * Math.sin(angle); - node.y = centerY + radius * Math.cos(angle); + stateNodes = stateNodes.map((node) => { + const index = adjacentLayoutNodeIds.indexOf(node.get('id')); + if (index > -1) { + const angle = offsetAngle + Math.PI * 2 * index / adjacentCount; + return node.merge({ + x: centerX + radius * Math.sin(angle), + y: centerY + radius * Math.cos(angle) + }); + } + return node; }); // fix all edges for circular nodes - - _.each(layoutEdges, function(edge) { - if (edge.source === selectedLayoutNode - || edge.target === selectedLayoutNode - || _.includes(adjacentLayoutNodes, edge.source) - || _.includes(adjacentLayoutNodes, edge.target)) { - edge.points = [ - {x: edge.source.x, y: edge.source.y}, - {x: edge.target.x, y: edge.target.y} - ]; + stateEdges = stateEdges.map(edge => { + if (edge.get('source') === selectedLayoutNode.get('id') + || edge.get('target') === selectedLayoutNode.get('id') + || _.includes(adjacentLayoutNodeIds, edge.get('source')) + || _.includes(adjacentLayoutNodeIds, edge.get('target'))) { + const source = stateNodes.get(edge.get('source')); + const target = stateNodes.get(edge.get('target')); + return edge.set('points', [ + {x: source.get('x'), y: source.get('y')}, + {x: target.get('x'), y: target.get('y')} + ]); } + return edge; }); // shift canvas selected node out of view if it has not been shifted already @@ -373,8 +393,8 @@ const NodesChart = React.createClass({ return { autoShifted: autoShifted, - edges: layoutEdges, - nodes: layoutNodes, + edges: stateEdges, + nodes: stateNodes, shiftTranslate: shiftTranslate }; }, @@ -394,21 +414,21 @@ const NodesChart = React.createClass({ }, restoreLayout: function(state) { - const edges = state.edges; - const nodes = state.nodes; - - _.each(nodes, function(node) { - node.x = node.px; - node.y = node.py; + const nodes = state.nodes.map(node => { + return node.merge({ + x: node.get('px'), + y: node.get('py') + }); }); - _.each(edges, function(edge) { - if (edge.ppoints) { - edge.points = edge.ppoints; + const edges = state.edges.map(edge => { + if (edge.has('ppoints')) { + return edge.set('points', edge.get('ppoints')); } + return edge; }); - return {edges: edges, nodes: nodes}; + return {edges, nodes}; }, updateGraphState: function(props, state) { @@ -416,13 +436,13 @@ const NodesChart = React.createClass({ if (n === 0) { return { - nodes: {}, - edges: {} + nodes: makeMap(), + edges: makeMap() }; } - const nodes = this.initNodes(props.nodes, state.nodes); - const edges = this.initEdges(props.nodes, nodes); + 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 @@ -437,7 +457,7 @@ const NodesChart = React.createClass({ }; const timedLayouter = timely(NodesLayout.doLayout); - const graph = timedLayouter(nodes, edges, options); + const graph = timedLayouter(stateNodes, stateEdges, options); debug('graph layout took ' + timedLayouter.time + 'ms'); @@ -445,14 +465,18 @@ const NodesChart = React.createClass({ if (!graph) { return {maxNodesExceeded: true}; } + stateNodes = graph.nodes; + stateEdges = graph.edges; // save coordinates for restore - _.each(nodes, function(node) { - node.px = node.x; - node.py = node.y; + stateNodes = stateNodes.map(node => { + return node.merge({ + px: node.get('x'), + py: node.get('y') + }); }); - _.each(edges, function(edge) { - edge.ppoints = edge.points; + stateEdges = stateEdges.map(edge => { + return edge.set('ppoints', edge.get('points')); }); // adjust layout based on viewport @@ -468,8 +492,8 @@ const NodesChart = React.createClass({ } return { - nodes: nodes, - edges: edges, + nodes: stateNodes, + edges: stateEdges, nodeScale: nodeScale, scale: zoomScale, maxNodesExceeded: false diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index a86247991..91ee6061c 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -1,12 +1,19 @@ const dagre = require('dagre'); const debug = require('debug')('scope:nodes-layout'); const Naming = require('../constants/naming'); -const _ = require('lodash'); const MAX_NODES = 100; const topologyGraphs = {}; -export function doLayout(nodes, edges, opts) { +function runLayoutEngine(imNodes, imEdges, opts) { + let nodes = imNodes; + let edges = imEdges; + + if (nodes.size > MAX_NODES) { + debug('Too many nodes for graph layout engine. Limit: ' + MAX_NODES); + return null; + } + const options = opts || {}; const margins = options.margins || {top: 0, left: 0}; const width = options.width || 800; @@ -14,20 +21,11 @@ export function doLayout(nodes, edges, opts) { const scale = options.scale || (val => val * 2); const topologyId = options.topologyId || 'noId'; - let offsetX = 0 + margins.left; - let offsetY = 0 + margins.top; - let graph; - - if (_.size(nodes) > MAX_NODES) { - debug('Too many nodes for graph layout engine. Limit: ' + MAX_NODES); - return null; - } - // one engine per topology, to keep renderings similar if (!topologyGraphs[topologyId]) { topologyGraphs[topologyId] = new dagre.graphlib.Graph({}); } - graph = topologyGraphs[topologyId]; + const graph = topologyGraphs[topologyId]; // configure node margins graph.setGraph({ @@ -36,10 +34,10 @@ export function doLayout(nodes, edges, opts) { }); // add nodes to the graph if not already there - _.each(nodes, function(node) { - if (!graph.hasNode(node.id)) { - graph.setNode(node.id, { - id: node.id, + nodes.forEach(node => { + if (!graph.hasNode(node.get('id'))) { + graph.setNode(node.get('id'), { + id: node.get('id'), width: scale(1), height: scale(1) }); @@ -47,35 +45,41 @@ export function doLayout(nodes, edges, opts) { }); // remove nodes that are no longer there - _.each(graph.nodes(), function(nodeid) { - if (!_.has(nodes, nodeid)) { + graph.nodes().forEach(nodeid => { + if (!nodes.has(nodeid)) { graph.removeNode(nodeid); } }); // add edges to the graph if not already there - _.each(edges, function(edge) { - if (!graph.hasEdge(edge.source.id, edge.target.id)) { - const virtualNodes = edge.source.id === edge.target.id ? 1 : 0; - graph.setEdge(edge.source.id, edge.target.id, {id: edge.id, minlen: virtualNodes}); + edges.forEach(edge => { + if (!graph.hasEdge(edge.get('source'), edge.get('target'))) { + const virtualNodes = edge.get('source') === edge.get('target') ? 1 : 0; + graph.setEdge( + edge.get('source'), + edge.get('target'), + {id: edge.get('id'), minlen: virtualNodes} + ); } }); - // remoed egdes that are no longer there - _.each(graph.edges(), function(edgeObj) { + // remove edges that are no longer there + graph.edges().forEach(edgeObj => { const edge = [edgeObj.v, edgeObj.w]; const edgeId = edge.join(Naming.EDGE_ID_SEPARATOR); - if (!_.has(edges, edgeId)) { + if (!edges.has(edgeId)) { graph.removeEdge(edgeObj.v, edgeObj.w); } }); dagre.layout(graph); - const layout = graph.graph(); // shifting graph coordinates to center + let offsetX = 0 + margins.left; + let offsetY = 0 + margins.top; + if (layout.width < width) { offsetX = (width - layout.width) / 2 + margins.left; } @@ -85,27 +89,45 @@ export function doLayout(nodes, edges, opts) { // apply coordinates to nodes and edges - graph.nodes().forEach(function(id) { - const node = nodes[id]; + graph.nodes().forEach(id => { const graphNode = graph.node(id); - node.x = graphNode.x + offsetX; - node.y = graphNode.y + offsetY; + nodes = nodes.setIn([id, 'x'], graphNode.x + offsetX); + nodes = nodes.setIn([id, 'y'], graphNode.y + offsetY); }); - graph.edges().forEach(function(id) { + graph.edges().forEach(id => { const graphEdge = graph.edge(id); - const edge = edges[graphEdge.id]; - _.each(graphEdge.points, function(point) { - point.x += offsetX; - point.y += offsetY; - }); - edge.points = graphEdge.points; + const edge = edges.get(graphEdge.id); + const points = graphEdge.points.map(point => ({ + x: point.x + offsetX, + y: point.y + offsetY + })); + // set beginning and end points to node coordinates to ignore node bounding box - edge.points[0] = {x: edge.source.x, y: edge.source.y}; - edge.points[edge.points.length - 1] = {x: edge.target.x, y: edge.target.y}; + const source = nodes.get(edge.get('source')); + const target = nodes.get(edge.get('target')); + points[0] = {x: source.get('x'), y: source.get('y')}; + points[points.length - 1] = {x: target.get('x'), y: target.get('y')}; + + edges = edges.setIn([graphEdge.id, 'points'], points); }); // return object with the width and height of layout - + layout.nodes = nodes; + layout.edges = edges; return layout; } + +/** + * Layout of nodes and edges + * @param {Map} nodes All nodes + * @param {Map} edges All edges + * @param {object} opts width, height, margins, etc... + * @return {object} graph object with nodes, edges, dimensions + */ +export function doLayout(nodes, edges, opts) { + // const options = opts || {}; + // const history = options.history || []; + + return runLayoutEngine(nodes, edges, opts); +}