mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-04 02:30:45 +00:00
Merge pull request #1993 from weaveworks/dance-tuning
Add anti-dance heuristics (and feature flags)
This commit is contained in:
@@ -35,6 +35,36 @@ describe('NodesLayout', () => {
|
||||
'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'}
|
||||
})
|
||||
},
|
||||
rank4: {
|
||||
nodes: fromJS({
|
||||
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'},
|
||||
'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'},
|
||||
@@ -86,10 +116,26 @@ describe('NodesLayout', () => {
|
||||
edges: fromJS({
|
||||
'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}
|
||||
})
|
||||
},
|
||||
singlePortrait6: {
|
||||
nodes: fromJS({
|
||||
n1: {id: 'n1'},
|
||||
n2: {id: 'n2'},
|
||||
n3: {id: 'n3'},
|
||||
n4: {id: 'n4'},
|
||||
n5: {id: 'n5'},
|
||||
n6: {id: 'n6'}
|
||||
}),
|
||||
edges: fromJS({
|
||||
'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// clear feature flags
|
||||
window.localStorage.clear();
|
||||
|
||||
options = {
|
||||
nodeCache: makeMap(),
|
||||
edgeCache: makeMap()
|
||||
@@ -121,6 +167,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,
|
||||
@@ -266,7 +360,9 @@ describe('NodesLayout', () => {
|
||||
it('renders single nodes next to portrait graph', () => {
|
||||
const result = NodesLayout.doLayout(
|
||||
nodeSets.singlePortrait.nodes,
|
||||
nodeSets.singlePortrait.edges);
|
||||
nodeSets.singlePortrait.edges,
|
||||
{ noCache: true }
|
||||
);
|
||||
|
||||
nodes = result.nodes.toJS();
|
||||
|
||||
@@ -282,4 +378,79 @@ describe('NodesLayout', () => {
|
||||
expect(nodes.n1.x).toBeLessThan(nodes.n5.x);
|
||||
expect(nodes.n2.x).toEqual(nodes.n5.x);
|
||||
});
|
||||
|
||||
it('renders an additional single node in single nodes group', () => {
|
||||
let result = NodesLayout.doLayout(
|
||||
nodeSets.singlePortrait.nodes,
|
||||
nodeSets.singlePortrait.edges,
|
||||
{ noCache: true }
|
||||
);
|
||||
|
||||
nodes = result.nodes.toJS();
|
||||
|
||||
// first square row on same level as top-most other node
|
||||
expect(nodes.n1.y).toEqual(nodes.n2.y);
|
||||
expect(nodes.n1.y).toEqual(nodes.n3.y);
|
||||
expect(nodes.n4.y).toEqual(nodes.n5.y);
|
||||
|
||||
// all singles right to other nodes
|
||||
expect(nodes.n1.x).toEqual(nodes.n4.x);
|
||||
expect(nodes.n1.x).toBeLessThan(nodes.n2.x);
|
||||
expect(nodes.n1.x).toBeLessThan(nodes.n3.x);
|
||||
expect(nodes.n1.x).toBeLessThan(nodes.n5.x);
|
||||
expect(nodes.n2.x).toEqual(nodes.n5.x);
|
||||
|
||||
options.cachedLayout = result;
|
||||
options.nodeCache = options.nodeCache.merge(result.nodes);
|
||||
options.edgeCache = options.edgeCache.merge(result.edge);
|
||||
|
||||
result = NodesLayout.doLayout(
|
||||
nodeSets.singlePortrait6.nodes,
|
||||
nodeSets.singlePortrait6.edges,
|
||||
options
|
||||
);
|
||||
|
||||
nodes = result.nodes.toJS();
|
||||
|
||||
expect(nodes.n1.x).toBeLessThan(nodes.n2.x);
|
||||
expect(nodes.n1.x).toBeLessThan(nodes.n3.x);
|
||||
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', () => {
|
||||
// feature flag
|
||||
window.localStorage.setItem('scope-experimental:layout-dance', true);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,15 @@ import debug from 'debug';
|
||||
import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
|
||||
|
||||
import { EDGE_ID_SEPARATOR } from '../constants/naming';
|
||||
import { featureIsEnabledAny } from '../utils/feature-utils';
|
||||
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;
|
||||
@@ -118,6 +120,8 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
|
||||
|
||||
// return object with the width and height of layout
|
||||
return {
|
||||
graphWidth: layout.width,
|
||||
graphHeight: layout.height,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
nodes,
|
||||
@@ -125,6 +129,65 @@ function runLayoutEngine(graph, imNodes, imEdges, 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
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add coordinates to 0-degree nodes using a square layout
|
||||
* Depending on the previous layout run's graph aspect ratio, the square will be
|
||||
@@ -142,7 +205,9 @@ function layoutSingleNodes(layout, opts) {
|
||||
const nodesep = scale(NODE_SEPARATION_FACTOR);
|
||||
const nodeWidth = scale(NODE_SIZE_FACTOR);
|
||||
const nodeHeight = scale(NODE_SIZE_FACTOR);
|
||||
const aspectRatio = layout.height ? layout.width / layout.height : 1;
|
||||
const graphHeight = layout.graphHeight || layout.height;
|
||||
const graphWidth = layout.graphWidth || layout.width;
|
||||
const aspectRatio = graphHeight ? graphWidth / graphHeight : 1;
|
||||
|
||||
let nodes = layout.nodes;
|
||||
|
||||
@@ -212,53 +277,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
|
||||
}));
|
||||
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
|
||||
}))
|
||||
));
|
||||
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
|
||||
@@ -274,6 +330,45 @@ export function hasUnseenNodes(nodes, cache) {
|
||||
return hasUnseen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if all new nodes are 0-degree nodes
|
||||
* Requires cached nodes (implies a previous layout run).
|
||||
* @param {Map} nodes new Map of nodes
|
||||
* @param {Map} cache old Map of nodes
|
||||
* @return {Boolean} True if all new nodes are 0-nodes
|
||||
*/
|
||||
function hasNewSingleNode(nodes, cache) {
|
||||
const oldNodes = ImmSet.fromKeys(cache);
|
||||
const newNodes = ImmSet.fromKeys(nodes).subtract(oldNodes);
|
||||
const hasNewSingleNodes = newNodes.every(key => nodes.getIn([key, 'degree']) === 0);
|
||||
return oldNodes.size > 0 && hasNewSingleNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if edge has same endpoints in new nodes as well as in the nodeCache
|
||||
* @param {Map} edge Edge with source and target
|
||||
@@ -315,13 +410,16 @@ function cloneLayout(layout, nodes, edges) {
|
||||
*/
|
||||
function copyLayoutProperties(layout, nodeCache, edgeCache) {
|
||||
const result = Object.assign({}, layout);
|
||||
result.nodes = layout.nodes.map(node => node.merge(nodeCache.get(node.get('id'))));
|
||||
result.nodes = layout.nodes.map(node => (nodeCache.has(node.get('id'))
|
||||
? node.merge(nodeCache.get(node.get('id'))) : node));
|
||||
result.edges = layout.edges.map(edge => {
|
||||
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;
|
||||
}
|
||||
@@ -340,7 +438,7 @@ export function doLayout(immNodes, immEdges, opts) {
|
||||
const cacheId = buildTopologyCacheId(options.topologyId, options.topologyOptions);
|
||||
|
||||
// one engine and node and edge caches per topology, to keep renderings similar
|
||||
if (!topologyCaches[cacheId]) {
|
||||
if (options.noCache || !topologyCaches[cacheId]) {
|
||||
topologyCaches[cacheId] = {
|
||||
nodeCache: makeMap(),
|
||||
edgeCache: makeMap(),
|
||||
@@ -352,22 +450,42 @@ export function doLayout(immNodes, immEdges, opts) {
|
||||
const cachedLayout = options.cachedLayout || cache.cachedLayout;
|
||||
const nodeCache = options.nodeCache || cache.nodeCache;
|
||||
const edgeCache = options.edgeCache || cache.edgeCache;
|
||||
const useCache = !options.forceRelayout && cachedLayout && nodeCache && edgeCache;
|
||||
let layout;
|
||||
|
||||
++layoutRuns;
|
||||
if (!options.forceRelayout && cachedLayout && nodeCache && edgeCache
|
||||
&& !hasUnseenNodes(immNodes, nodeCache)) {
|
||||
if (useCache && !hasUnseenNodes(immNodes, nodeCache)) {
|
||||
log('skip layout, trivial adjustment', ++layoutRunsTrivial, layoutRuns);
|
||||
layout = cloneLayout(cachedLayout, immNodes, immEdges);
|
||||
// copy old properties, works also if nodes get re-added
|
||||
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
|
||||
} else {
|
||||
const graph = cache.graph;
|
||||
const nodesWithDegrees = updateNodeDegrees(immNodes, immEdges);
|
||||
layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts);
|
||||
if (!layout) {
|
||||
return layout;
|
||||
if (useCache
|
||||
&& featureIsEnabledAny('layout-dance', 'layout-dance-single')
|
||||
&& hasNewSingleNode(nodesWithDegrees, nodeCache)) {
|
||||
// special case: new nodes are 0-degree nodes, no need for layout run,
|
||||
// they will be laid out further below
|
||||
log('skip layout, only 0-degree node(s) added');
|
||||
layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
|
||||
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
|
||||
} else if (useCache
|
||||
&& featureIsEnabledAny('layout-dance', 'layout-dance-rank')
|
||||
&& 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, 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);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import d3 from 'd3';
|
||||
import _ from 'lodash';
|
||||
import Perf from 'react-addons-perf';
|
||||
import { connect } from 'react-redux';
|
||||
import { fromJS } from 'immutable';
|
||||
import { fromJS, Set as makeSet } from 'immutable';
|
||||
|
||||
import debug from 'debug';
|
||||
const log = debug('scope:debug-panel');
|
||||
@@ -160,6 +160,10 @@ class DebugToolbar extends React.Component {
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.toggleColors = this.toggleColors.bind(this);
|
||||
this.addNodes = this.addNodes.bind(this);
|
||||
this.intermittentTimer = null;
|
||||
this.intermittentNodes = makeSet();
|
||||
this.shortLivedTimer = null;
|
||||
this.shortLivedNodes = makeSet();
|
||||
this.state = {
|
||||
nodesToAdd: 30,
|
||||
showColors: false
|
||||
@@ -184,6 +188,53 @@ class DebugToolbar extends React.Component {
|
||||
this.asyncDispatch(setAppState(state => state.set('topologiesLoaded', !loading)));
|
||||
}
|
||||
|
||||
setIntermittent() {
|
||||
// simulate epheremal nodes
|
||||
if (this.intermittentTimer) {
|
||||
clearInterval(this.intermittentTimer);
|
||||
this.intermittentTimer = null;
|
||||
} else {
|
||||
this.intermittentTimer = setInterval(() => {
|
||||
// add new node
|
||||
this.addNodes(1);
|
||||
|
||||
// remove random node
|
||||
const ns = this.props.nodes;
|
||||
const nodeNames = ns.keySeq().toJS();
|
||||
const randomNode = _.sample(nodeNames);
|
||||
this.asyncDispatch(receiveNodesDelta({
|
||||
remove: [randomNode]
|
||||
}));
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
setShortLived() {
|
||||
// simulate nodes with same ID popping in and out
|
||||
if (this.shortLivedTimer) {
|
||||
clearInterval(this.shortLivedTimer);
|
||||
this.shortLivedTimer = null;
|
||||
} else {
|
||||
this.shortLivedTimer = setInterval(() => {
|
||||
// filter random node
|
||||
const ns = this.props.nodes;
|
||||
const nodeNames = ns.keySeq().toJS();
|
||||
const randomNode = _.sample(nodeNames);
|
||||
if (randomNode) {
|
||||
let nextNodes = ns.setIn([randomNode, 'filtered'], true);
|
||||
this.shortLivedNodes = this.shortLivedNodes.add(randomNode);
|
||||
// bring nodes back after a bit
|
||||
if (this.shortLivedNodes.size > 5) {
|
||||
const returningNode = this.shortLivedNodes.first();
|
||||
this.shortLivedNodes = this.shortLivedNodes.rest();
|
||||
nextNodes = nextNodes.setIn([returningNode, 'filtered'], false);
|
||||
}
|
||||
this.asyncDispatch(setAppState(state => state.set('nodes', nextNodes)));
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
updateAdjacencies() {
|
||||
const ns = this.props.nodes;
|
||||
const nodeNames = ns.keySeq().toJS();
|
||||
@@ -303,6 +354,12 @@ class DebugToolbar extends React.Component {
|
||||
<button onClick={() => this.setLoading(false)}>Stop</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Short-lived nodes</label>
|
||||
<button onClick={() => this.setShortLived()}>Toggle short-lived nodes</button>
|
||||
<button onClick={() => this.setIntermittent()}>Toggle intermittent nodes</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Measure React perf for </label>
|
||||
<button onClick={() => startPerf(2)}>2s</button>
|
||||
|
||||
43
client/app/scripts/utils/__tests__/feature-utils-test.js
Normal file
43
client/app/scripts/utils/__tests__/feature-utils-test.js
Normal file
@@ -0,0 +1,43 @@
|
||||
|
||||
const FU = require('../feature-utils');
|
||||
|
||||
describe('FeatureUtils', () => {
|
||||
const FEATURE_X_KEY = 'my feature 1';
|
||||
const FEATURE_Y_KEY = 'my feature 2';
|
||||
|
||||
beforeEach(() => {
|
||||
FU.setFeature(FEATURE_X_KEY, false);
|
||||
FU.setFeature(FEATURE_Y_KEY, false);
|
||||
});
|
||||
|
||||
describe('Setting of features', () => {
|
||||
it('should not have any features by default', () => {
|
||||
expect(FU.featureIsEnabled(FEATURE_X_KEY)).toBeFalsy();
|
||||
expect(FU.featureIsEnabled(FEATURE_Y_KEY)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should work with enabling one feature', () => {
|
||||
let success;
|
||||
expect(FU.featureIsEnabled(FEATURE_X_KEY)).toBeFalsy();
|
||||
success = FU.setFeature(FEATURE_X_KEY, true);
|
||||
expect(success).toBeTruthy();
|
||||
expect(FU.featureIsEnabled(FEATURE_X_KEY)).toBeTruthy();
|
||||
expect(FU.featureIsEnabled(FEATURE_Y_KEY)).toBeFalsy();
|
||||
success = FU.setFeature(FEATURE_X_KEY, false);
|
||||
expect(success).toBeTruthy();
|
||||
expect(FU.featureIsEnabled(FEATURE_X_KEY)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should allow for either feature', () => {
|
||||
let success;
|
||||
expect(FU.featureIsEnabledAny(FEATURE_X_KEY, FEATURE_Y_KEY)).toBeFalsy();
|
||||
success = FU.setFeature(FEATURE_X_KEY, true);
|
||||
expect(success).toBeTruthy();
|
||||
expect(FU.featureIsEnabledAny(FEATURE_X_KEY, FEATURE_Y_KEY)).toBeTruthy();
|
||||
success = FU.setFeature(FEATURE_X_KEY, false);
|
||||
success = FU.setFeature(FEATURE_Y_KEY, true);
|
||||
expect(success).toBeTruthy();
|
||||
expect(FU.featureIsEnabledAny(FEATURE_X_KEY, FEATURE_Y_KEY)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
39
client/app/scripts/utils/feature-utils.js
Normal file
39
client/app/scripts/utils/feature-utils.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { storageGet, storageSet } from './storage-utils';
|
||||
|
||||
// prefix for all feature flags
|
||||
const STORAGE_KEY_PREFIX = 'scope-experimental:';
|
||||
|
||||
const getKey = key => `${STORAGE_KEY_PREFIX}${key}`;
|
||||
|
||||
/**
|
||||
* Returns true if `feature` is enabled
|
||||
*
|
||||
* Features can be enabled either via calling `setFeature()` or by setting
|
||||
* `localStorage.scope-experimental:featureName = true` in the console.
|
||||
* @param {String} feature Feature name, ideally one word or hyphenated
|
||||
* @return {Boolean} True if feature is enabled
|
||||
*/
|
||||
export function featureIsEnabled(feature) {
|
||||
return storageGet(getKey(feature));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any of the features given as arguments are enabled.
|
||||
*
|
||||
* Useful if features are hierarchical, e.g.:
|
||||
* `featureIsEnabledAny('superFeature', 'subFeature')`
|
||||
* @param {String} args Feature names
|
||||
* @return {Boolean} True if any of the features are enabled
|
||||
*/
|
||||
export function featureIsEnabledAny(...args) {
|
||||
return Array.prototype.some.call(args, feature => featureIsEnabled(feature));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set true/false if a feature is enabled.
|
||||
* @param {String} feature Feature name
|
||||
* @param {Boolean} isEnabled true/false
|
||||
*/
|
||||
export function setFeature(feature, isEnabled) {
|
||||
return storageSet(getKey(feature), isEnabled);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const log = debug('scope:storage-utils');
|
||||
const storage = typeof(Storage) !== 'undefined' ? window.localStorage : null;
|
||||
|
||||
export function storageGet(key, defaultValue) {
|
||||
if (storage && storage[key] !== undefined) {
|
||||
if (storage && storage.getItem(key) !== undefined) {
|
||||
return storage.getItem(key);
|
||||
}
|
||||
return defaultValue;
|
||||
@@ -16,8 +16,31 @@ export function storageSet(key, value) {
|
||||
if (storage) {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
log('Error storing value in storage. Maybe full? Could not store key.', key);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function storageGetObject(key, defaultValue) {
|
||||
const value = storageGet(key);
|
||||
if (value) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
log('Error getting object for key.', key);
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function storageSetObject(key, obj) {
|
||||
try {
|
||||
return storageSet(key, JSON.stringify(obj));
|
||||
} catch (e) {
|
||||
log('Error encoding object for key', key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
},
|
||||
"jest": {
|
||||
"transform": {".*": "<rootDir>/node_modules/babel-jest"},
|
||||
"setupFiles": [
|
||||
"<rootDir>/test/support/localStorage.js"
|
||||
],
|
||||
"testPathDirs": [
|
||||
"<rootDir>/app/scripts"
|
||||
],
|
||||
|
||||
17
client/test/support/localStorage.js
Normal file
17
client/test/support/localStorage.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const localStorageMock = (function() {
|
||||
let store = {};
|
||||
return {
|
||||
store,
|
||||
getItem: function(key) {
|
||||
return store[key];
|
||||
},
|
||||
setItem: function(key, value) {
|
||||
store[key] = value;
|
||||
},
|
||||
clear: function() {
|
||||
store = {};
|
||||
}
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, 'Storage', { value: localStorageMock });
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
Reference in New Issue
Block a user