diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 0308f2e6e..e567cd684 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -23,6 +23,8 @@ const MARGINS = { bottom: 0 }; +const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY']; + // make sure circular layouts a bit denser with 3-6 nodes const radiusDensity = d3.scale.threshold() .domain([3, 6]).range([2.5, 3.5, 3]); @@ -43,7 +45,8 @@ export default class NodesChart extends React.Component { panTranslateY: 0, scale: 1, selectedNodeScale: d3.scale.linear(), - hasZoomed: false + hasZoomed: false, + zoomCache: {} }; } @@ -58,13 +61,25 @@ export default class NodesChart extends React.Component { // wipe node states when showing different topology if (nextProps.topologyId !== this.props.topologyId) { - _.assign(state, { + // re-apply cached canvas zoom/pan to d3 behavior + const nextZoom = this.state.zoomCache[nextProps.topologyId]; + if (nextZoom) { + this.zoom.scale(nextZoom.scale); + this.zoom.translate([nextZoom.panTranslateX, nextZoom.panTranslateY]); + } + + // saving previous zoom state + const prevZoom = _.pick(this.state, ZOOM_CACHE_FIELDS); + const zoomCache = _.assign({}, this.state.zoomCache); + zoomCache[this.props.topologyId] = prevZoom; + + // clear canvas and apply zoom state + _.assign(state, nextZoom, { zoomCache }, { nodes: makeMap(), edges: makeMap() }); } - // - // FIXME add PureRenderMixin, Immutables, and move the following functions to render() + // _.assign(state, this.updateGraphState(nextProps, state)); if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) { _.assign(state, this.updateGraphState(nextProps, state)); diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index 2cd1c83ee..24503f496 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -3,7 +3,7 @@ import debug from 'debug'; import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; -import { updateNodeDegrees } from '../utils/topology-utils'; +import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils'; const log = debug('scope:nodes-layout'); @@ -25,17 +25,6 @@ function fromGraphNodeId(encodedId) { return encodedId.replace('', '.'); } -function buildCacheIdFromOptions(options) { - if (options) { - let id = options.topologyId; - if (options.topologyOptions) { - id += JSON.stringify(options.topologyOptions); - } - return id; - } - return ''; -} - /** * Layout engine runner * After the layout engine run nodes and edges have x-y-coordinates. Engine is @@ -348,7 +337,7 @@ function copyLayoutProperties(layout, nodeCache, edgeCache) { */ export function doLayout(immNodes, immEdges, opts) { const options = opts || {}; - const cacheId = buildCacheIdFromOptions(options); + const cacheId = buildTopologyCacheId(options.topologyId, options.topologyOptions); // one engine and node and edge caches per topology, to keep renderings similar if (!topologyCaches[cacheId]) { diff --git a/client/app/scripts/utils/__tests__/topology-utils-test.js b/client/app/scripts/utils/__tests__/topology-utils-test.js index 7d2e5bc14..3a1dbd697 100644 --- a/client/app/scripts/utils/__tests__/topology-utils-test.js +++ b/client/app/scripts/utils/__tests__/topology-utils-test.js @@ -107,17 +107,27 @@ describe('TopologyUtils', () => { expect(nodes.n3.degree).toEqual(0); }); + describe('buildTopologyCacheId', () => { + it('should generate a cache ID', () => { + const fun = TopologyUtils.buildTopologyCacheId; + expect(fun()).toEqual(''); + expect(fun('test')).toEqual('test'); + expect(fun(undefined, 'test')).toEqual(''); + expect(fun('test', {a: 1})).toEqual('test{"a":1}'); + }); + }); + describe('filterHiddenTopologies', () => { it('should filter out empty topos that set hide_if_empty=true', () => { const topos = [ - {id: 'a', hide_if_empty: true, stats: {node_count: 0, filtered_nodes:0}}, - {id: 'b', hide_if_empty: true, stats: {node_count: 1, filtered_nodes:0}}, - {id: 'c', hide_if_empty: true, stats: {node_count: 0, filtered_nodes:1}}, - {id: 'd', hide_if_empty: false, stats: {node_count: 0, filtered_nodes:0}} + {id: 'a', hide_if_empty: true, stats: {node_count: 0, filtered_nodes: 0}}, + {id: 'b', hide_if_empty: true, stats: {node_count: 1, filtered_nodes: 0}}, + {id: 'c', hide_if_empty: true, stats: {node_count: 0, filtered_nodes: 1}}, + {id: 'd', hide_if_empty: false, stats: {node_count: 0, filtered_nodes: 0}} ]; const res = TopologyUtils.filterHiddenTopologies(topos); expect(res.map(t => t.id)).toEqual(['b', 'c', 'd']); }); - }) + }); }); diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 0d4036feb..8c561285b 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -1,5 +1,28 @@ import _ from 'lodash'; +/** + * Returns a cache ID based on the topologyId and optionsQuery + * @param {String} topologyId + * @param {object} topologyOptions (optional) + * @return {String} + */ +export function buildTopologyCacheId(topologyId, topologyOptions) { + let id = ''; + if (topologyId) { + id = topologyId; + if (topologyOptions) { + id += JSON.stringify(topologyOptions); + } + } + return id; +} + +/** + * Returns a topology object from the topology tree + * @param {List} subTree + * @param {String} topologyId + * @return {Map} topology if found + */ export function findTopologyById(subTree, topologyId) { let foundTopology;