Skip to content

Commit

Permalink
Simplified layout steps for singles and same-ranks
Browse files Browse the repository at this point in the history
  • Loading branch information
davkal committed Nov 7, 2016
1 parent 073a5d9 commit 050cfe2
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 72 deletions.
134 changes: 103 additions & 31 deletions client/app/scripts/charts/__tests__/node-layout-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,36 @@ describe('NodesLayout', () => {
'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'}
})
},
addNode15: {
rank4: {
nodes: fromJS({
n1: {id: 'n1'},
n2: {id: 'n2'},
n3: {id: 'n3'},
n4: {id: 'n4'},
n5: {id: 'n5'}
n1: {id: 'n1', rank: 'A'},
n2: {id: 'n2', rank: 'A'},
n3: {id: 'n3', rank: 'B'},
n4: {id: 'n4', rank: 'B'}
}),
edges: fromJS({
'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'},
'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'},
'n1-n5': {id: 'n1-n5', source: 'n1', target: 'n5'},
'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'}
})
},
rank6: {
nodes: fromJS({
n1: {id: 'n1', rank: 'A'},
n2: {id: 'n2', rank: 'A'},
n3: {id: 'n3', rank: 'B'},
n4: {id: 'n4', rank: 'B'},
n5: {id: 'n5', rank: 'A'},
n6: {id: 'n6', rank: 'B'},
}),
edges: fromJS({
'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'},
'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'},
'n1-n5': {id: 'n1-n5', source: 'n1', target: 'n5'},
'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'},
'n2-n6': {id: 'n2-n6', source: 'n2', target: 'n6'},
})
},
removeEdge24: {
nodes: fromJS({
n1: {id: 'n1'},
Expand Down Expand Up @@ -149,6 +164,54 @@ describe('NodesLayout', () => {
expect(hasUnseen).toBeTruthy();
});

it('shifts layouts to center', () => {
let xMin;
let xMax;
let yMin;
let yMax;
let xCenter;
let yCenter;

// make sure initial layout is centered
const original = NodesLayout.doLayout(
nodeSets.initial4.nodes,
nodeSets.initial4.edges
);
xMin = original.nodes.minBy(n => n.get('x'));
xMax = original.nodes.maxBy(n => n.get('x'));
yMin = original.nodes.minBy(n => n.get('y'));
yMax = original.nodes.maxBy(n => n.get('y'));
xCenter = (xMin.get('x') + xMax.get('x')) / 2;
yCenter = (yMin.get('y') + yMax.get('y')) / 2;
expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2);
expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2);

// make sure re-running is idempotent
const rerun = NodesLayout.shiftLayoutToCenter(original);
xMin = rerun.nodes.minBy(n => n.get('x'));
xMax = rerun.nodes.maxBy(n => n.get('x'));
yMin = rerun.nodes.minBy(n => n.get('y'));
yMax = rerun.nodes.maxBy(n => n.get('y'));
xCenter = (xMin.get('x') + xMax.get('x')) / 2;
yCenter = (yMin.get('y') + yMax.get('y')) / 2;
expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2);
expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2);

// shift after window was resized
const shifted = NodesLayout.shiftLayoutToCenter(original, {
width: 128,
height: 256
});
xMin = shifted.nodes.minBy(n => n.get('x'));
xMax = shifted.nodes.maxBy(n => n.get('x'));
yMin = shifted.nodes.minBy(n => n.get('y'));
yMax = shifted.nodes.maxBy(n => n.get('y'));
xCenter = (xMin.get('x') + xMax.get('x')) / 2;
yCenter = (yMin.get('y') + yMax.get('y')) / 2;
expect(xCenter).toEqual(128 / 2);
expect(yCenter).toEqual(256 / 2);
});

it('lays out initial nodeset in a rectangle', () => {
const result = NodesLayout.doLayout(
nodeSets.initial4.nodes,
Expand Down Expand Up @@ -351,28 +414,37 @@ describe('NodesLayout', () => {
expect(nodes.n1.x).toBeLessThan(nodes.n5.x);
expect(nodes.n1.x).toBeLessThan(nodes.n6.x);
});
//
// it('adds a new node to existing layout in a line', () => {
// let result = NodesLayout.doLayout(
// nodeSets.initial4.nodes,
// nodeSets.initial4.edges);
//
// nodes = result.nodes.toJS();
//
// coords = getNodeCoordinates(result.nodes);
// options.cachedLayout = result;
// options.nodeCache = options.nodeCache.merge(result.nodes);
// options.edgeCache = options.edgeCache.merge(result.edge);
//
// result = NodesLayout.doLayout(
// nodeSets.addNode15.nodes,
// nodeSets.addNode15.edges,
// options
// );
//
// nodes = result.nodes.toJS();
//
// expect(nodes.n1.x).toBeGreaterThan(nodes.n5.x);
// expect(nodes.n1.y).toEqual(nodes.n5.y);
// });

it('adds a new node to existing layout in a line', () => {
let result = NodesLayout.doLayout(
nodeSets.rank4.nodes,
nodeSets.rank4.edges,
{ noCache: true }
);

nodes = result.nodes.toJS();

coords = getNodeCoordinates(result.nodes);
options.cachedLayout = result;
options.nodeCache = options.nodeCache.merge(result.nodes);
options.edgeCache = options.edgeCache.merge(result.edge);

expect(NodesLayout.hasNewNodesOfExistingRank(
nodeSets.rank6.nodes,
nodeSets.rank6.edges,
result.nodes)).toBeTruthy();

result = NodesLayout.doLayout(
nodeSets.rank6.nodes,
nodeSets.rank6.edges,
options
);

nodes = result.nodes.toJS();

expect(nodes.n5.x).toBeGreaterThan(nodes.n1.x);
expect(nodes.n5.y).toEqual(nodes.n1.y);
expect(nodes.n6.x).toBeGreaterThan(nodes.n3.x);
expect(nodes.n6.y).toEqual(nodes.n3.y);
});
});
143 changes: 102 additions & 41 deletions client/app/scripts/charts/nodes-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils
const log = debug('scope:nodes-layout');

const topologyCaches = {};
const DEFAULT_WIDTH = 800;
const DEFAULT_MARGINS = {top: 0, left: 0};
export const DEFAULT_WIDTH = 800;
export const DEFAULT_HEIGHT = DEFAULT_WIDTH / 2;
export const DEFAULT_MARGINS = {top: 0, left: 0};
const DEFAULT_SCALE = val => val * 2;
const NODE_SIZE_FACTOR = 1;
const NODE_SEPARATION_FACTOR = 2.0;
Expand Down Expand Up @@ -127,11 +128,63 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
};
}

export function doLayoutNewNodesOfExistingRank(layout, immNodes, immEdges, opts) {
console.log(opts);
/**
* Adds `points` array to edge based on location of source and target
* @param {Map} edge new edge
* @param {Map} nodeCache all nodes
* @returns {Map} modified edge
*/
function setSimpleEdgePoints(edge, nodeCache) {
const source = nodeCache.get(edge.get('source'));
const target = nodeCache.get(edge.get('target'));
return edge.set('points', fromJS([
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
]));
}

/**
* Layout nodes that have rank that already exists.
* Relies on only nodes being added that have a connection to an existing node
* while having a rank of an existing node. They will be laid out in the same
* line as the latter, with a direct connection between the existing and the new node.
* @param {object} layout Layout with nodes and edges
* @param {Map} nodeCache previous nodes
* @param {object} opts Options
* @return {object} new layout object
*/
export function doLayoutNewNodesOfExistingRank(layout, nodeCache, opts) {
const result = Object.assign({}, layout);
const options = opts || {};
const scale = options.scale || DEFAULT_SCALE;
const nodesep = scale(NODE_SEPARATION_FACTOR);
const nodeWidth = scale(NODE_SIZE_FACTOR);

// determine new nodes
// layout new nodes
// return layout
const oldNodes = ImmSet.fromKeys(nodeCache);
const newNodes = ImmSet.fromKeys(layout.nodes.filter(n => n.get('degree') > 0))
.subtract(oldNodes);
result.nodes = layout.nodes.map(n => {
if (newNodes.contains(n.get('id'))) {
const nodesSameRank = nodeCache.filter(nn => nn.get('rank') === n.get('rank'));
if (nodesSameRank.size > 0) {
const y = nodesSameRank.first().get('y');
const x = nodesSameRank.maxBy(nn => nn.get('x')).get('x') + nodesep + nodeWidth;
return n.merge({ x, y });
}
return n;
}
return n;
});

result.edges = layout.edges.map(edge => {
if (!edge.has('points')) {
return setSimpleEdgePoints(edge, layout.nodes);
}
return edge;
});

return result;
}

/**
Expand Down Expand Up @@ -223,53 +276,44 @@ function layoutSingleNodes(layout, opts) {
* @param {Object} opts Options with width and margins
* @return {Object} modified layout
*/
function shiftLayoutToCenter(layout, opts) {
export function shiftLayoutToCenter(layout, opts) {
const result = Object.assign({}, layout);
const options = opts || {};
const margins = options.margins || DEFAULT_MARGINS;
const width = options.width || DEFAULT_WIDTH;
const height = options.height || width / 2;
const height = options.height || DEFAULT_HEIGHT;

let offsetX = 0 + margins.left;
let offsetY = 0 + margins.top;

if (layout.width < width) {
offsetX = (width - layout.width) / 2 + margins.left;
const xMin = layout.nodes.minBy(n => n.get('x'));
const xMax = layout.nodes.maxBy(n => n.get('x'));
offsetX = (width - (xMin.get('x') + xMax.get('x'))) / 2 + margins.left;
}
if (layout.height < height) {
offsetY = (height - layout.height) / 2 + margins.top;
const yMin = layout.nodes.minBy(n => n.get('y'));
const yMax = layout.nodes.maxBy(n => n.get('y'));
offsetY = (height - (yMin.get('y') + yMax.get('y'))) / 2 + margins.top;
}

result.nodes = layout.nodes.map(node => node.merge({
x: node.get('x') + offsetX,
y: node.get('y') + offsetY
}));

result.edges = layout.edges.map(edge => edge.update('points',
points => points.map(point => point.merge({
x: point.get('x') + offsetX,
y: point.get('y') + offsetY
}))
));
if (offsetX || offsetY) {
result.nodes = layout.nodes.map(node => node.merge({
x: node.get('x') + offsetX,
y: node.get('y') + offsetY
}));

result.edges = layout.edges.map(edge => edge.update('points',
points => points.map(point => point.merge({
x: point.get('x') + offsetX,
y: point.get('y') + offsetY
}))
));
}

return result;
}

/**
* Adds `points` array to edge based on location of source and target
* @param {Map} edge new edge
* @param {Map} nodeCache all nodes
* @returns {Map} modified edge
*/
function setSimpleEdgePoints(edge, nodeCache) {
const source = nodeCache.get(edge.get('source'));
const target = nodeCache.get(edge.get('target'));
return edge.set('points', fromJS([
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
]));
}

/**
* Determine if nodes were added between node sets
* @param {Map} nodes new Map of nodes
Expand Down Expand Up @@ -303,11 +347,25 @@ function hasNewSingleNode(nodes, cache) {
* Determine if all new nodes are of existing ranks
* Requires cached nodes (implies a previous layout run).
* @param {Map} nodes new Map of nodes
* @param {Map} edges new Map of edges
* @param {Map} cache old Map of nodes
* @return {Boolean} True if all new nodes have a rank that already exists
*/
function hasNewNodesOfExistingRank(nodes, cache) {
return false && nodes && cache;
export function hasNewNodesOfExistingRank(nodes, edges, cache) {
const oldNodes = ImmSet.fromKeys(cache);
const newNodes = ImmSet.fromKeys(nodes).subtract(oldNodes);

// if new there are edges that connect 2 new nodes, need a full layout
const bothNodesNew = edges.find(edge => newNodes.contains(edge.get('source'))
&& newNodes.contains(edge.get('target')));
if (bothNodesNew) {
return false;
}

const oldRanks = cache.filter(n => n.get('rank')).map(n => n.get('rank')).toSet();
const hasNewNodesOfExistingRankOrSingle = newNodes.every(key => nodes.getIn([key, 'degree']) === 0
|| oldRanks.contains(nodes.getIn([key, 'rank'])));
return oldNodes.size > 0 && hasNewNodesOfExistingRankOrSingle;
}

/**
Expand Down Expand Up @@ -357,8 +415,10 @@ function copyLayoutProperties(layout, nodeCache, edgeCache) {
if (edgeCache.has(edge.get('id'))
&& hasSameEndpoints(edgeCache.get(edge.get('id')), result.nodes)) {
return edge.merge(edgeCache.get(edge.get('id')));
} else if (nodeCache.get(edge.get('source')) && nodeCache.get(edge.get('target'))) {
return setSimpleEdgePoints(edge, nodeCache);
}
return setSimpleEdgePoints(edge, nodeCache);
return edge;
});
return result;
}
Expand Down Expand Up @@ -406,20 +466,21 @@ export function doLayout(immNodes, immEdges, opts) {
log('skip layout, only 0-degree node(s) added');
layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
} else if (useCache && hasNewNodesOfExistingRank(nodesWithDegrees, nodeCache)) {
} else if (useCache && hasNewNodesOfExistingRank(nodesWithDegrees, immEdges, nodeCache)) {
// special case: few new nodes were added, no need for layout run,
// they will inserted according to ranks
log('skip layout, used rank-based insertion');
layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
layout = doLayoutNewNodesOfExistingRank(layout, nodesWithDegrees, immEdges);
layout = doLayoutNewNodesOfExistingRank(layout, nodeCache, opts);
} else {
const graph = cache.graph;
layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts);
if (!layout) {
return layout;
}
}

layout = layoutSingleNodes(layout, opts);
layout = shiftLayoutToCenter(layout, opts);
}
Expand Down

0 comments on commit 050cfe2

Please sign in to comment.