Node transformations done from NodeContainer. Put node searching logic in selectors.

This commit is contained in:
Filip Barl
2017-02-03 16:51:25 +01:00
parent a07d01b07c
commit 2a54085c62
24 changed files with 180 additions and 316 deletions

View File

@@ -37,7 +37,7 @@ const waypointsMapToArray = (waypointsMap) => {
};
class EdgeContainer extends React.Component {
class EdgeContainer extends React.PureComponent {
constructor(props, context) {
super(props, context);
this.state = { waypointsMap: makeMap() };

View File

@@ -7,35 +7,36 @@ import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation';
import { NODE_BLUR_OPACITY } from '../constants/styles';
import Node from './node';
const transformedNode = (otherProps, { x, y, k }) => (
<Node transform={`translate(${x},${y}) scale(${k})`} {...otherProps} />
const transformedNode = (otherProps, { x, y, k, opacity }) => (
// NOTE: Controlling blurring and transform from here seems to re-render
// faster than adding a CSS class and controlling it from there.
<g transform={`translate(${x},${y}) scale(${k})`} style={{opacity}}>
<Node {...otherProps} />
</g>
);
class NodeContainer extends React.Component {
class NodeContainer extends React.PureComponent {
render() {
const { dx, dy, isAnimated, scale, blurred } = this.props;
const forwardedProps = omit(this.props, 'dx', 'dy', 'isAnimated', 'scale', 'blurred');
const opacity = blurred ? NODE_BLUR_OPACITY : 1;
// NOTE: Controlling blurring from here seems to re-render faster
// than adding a CSS class and controlling it from there.
if (!isAnimated) {
// Show static node for optimized rendering
return transformedNode(forwardedProps, { x: dx, y: dy, k: scale, opacity });
}
return (
<g className="node-container" style={{opacity}}>
{!isAnimated ?
// Show static node for optimized rendering
transformedNode(forwardedProps, { x: dx, y: dy, k: scale }) :
// Animate the node if the layout is sufficiently small
<Motion
style={{
x: spring(dx, NODES_SPRING_ANIMATION_CONFIG),
y: spring(dy, NODES_SPRING_ANIMATION_CONFIG),
k: spring(scale, NODES_SPRING_ANIMATION_CONFIG)
}}>
{interpolated => transformedNode(forwardedProps, interpolated)}
</Motion>}
</g>
// Animate the node if the layout is sufficiently small
<Motion
style={{
x: spring(dx, NODES_SPRING_ANIMATION_CONFIG),
y: spring(dy, NODES_SPRING_ANIMATION_CONFIG),
k: spring(scale, NODES_SPRING_ANIMATION_CONFIG),
opacity: spring(opacity, NODES_SPRING_ANIMATION_CONFIG),
}}>
{interpolated => transformedNode(forwardedProps, interpolated)}
</Motion>
);
}
}

View File

@@ -14,7 +14,7 @@ import {
} from '../constants/styles';
export default function NodeShapeCircle({id, highlighted, color, metric}) {
export default function NodeShapeCircle({ id, highlighted, color, metric }) {
const { height, hasMetric, formattedValue } = getMetricValue(metric);
const metricStyle = { fill: getMetricColor(metric) };

View File

@@ -14,7 +14,7 @@ const CLOUD_PATH = 'M-125 23.333Q-125 44.036-110.352 58.685-95.703 73.333-75 73.
+ '11.458-16.732 11.458-24.414 29.948-9.115-8.073-21.614-8.073-13.802 0-23.568 9.766-9.766 9.766-'
+ '9.766 23.568 0 9.766 5.339 17.968-16.797 3.906-27.735 17.513-10.938 13.607-10.937 31.185z';
export default function NodeShapeCloud({highlighted, color}) {
export default function NodeShapeCloud({ highlighted, color }) {
const pathProps = r => ({ d: CLOUD_PATH, transform: `scale(${r / NODE_BASE_SIZE})` });
return (

View File

@@ -41,12 +41,11 @@ function getNodeShape({ shape, stack }) {
}
class Node extends React.Component {
class Node extends React.PureComponent {
constructor(props, context) {
super(props, context);
this.state = {
hovered: false,
matched: false
};
this.handleMouseClick = this.handleMouseClick.bind(this);
@@ -55,15 +54,6 @@ class Node extends React.Component {
this.saveShapeRef = this.saveShapeRef.bind(this);
}
componentWillReceiveProps(nextProps) {
// marks as matched only when search query changes
if (nextProps.searchQuery !== this.props.searchQuery) {
this.setState({ matched: nextProps.matched });
} else {
this.setState({ matched: false });
}
}
renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) {
const { label, subLabel } = this.props;
return (
@@ -77,7 +67,7 @@ class Node extends React.Component {
}
renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents) {
const { label, subLabel, blurred, matches = makeMap() } = this.props;
const { label, subLabel, matches = makeMap() } = this.props;
const matchedMetadata = matches.get('metadata', makeList());
const matchedParents = matches.get('parents', makeList());
const matchedNodeDetails = matchedMetadata.concat(matchedParents);
@@ -96,16 +86,16 @@ class Node extends React.Component {
<div className={subLabelClassName}>
<MatchedText text={subLabel} match={matches.get('sublabel')} />
</div>
{!blurred && <MatchedResults matches={matchedNodeDetails} />}
<MatchedResults matches={matchedNodeDetails} />
</div>
</foreignObject>
);
}
render() {
const { blurred, focused, highlighted, networks, pseudo, rank, label,
transform, exportingGraph, showingNetworks, stack } = this.props;
const { hovered, matched } = this.state;
const { focused, highlighted, networks, pseudo, rank, label,
transform, exportingGraph, showingNetworks, stack, id, metric } = this.props;
const { hovered } = this.state;
const color = getNodeColor(rank, label, pseudo);
const truncate = !focused && !hovered;
@@ -113,9 +103,7 @@ class Node extends React.Component {
const nodeClassName = classnames('node', {
highlighted,
blurred: blurred && !focused,
hovered,
matched,
pseudo
});
@@ -123,7 +111,6 @@ class Node extends React.Component {
const subLabelClassName = classnames('node-sublabel', { truncate });
const NodeShapeType = getNodeShape(this.props);
const useSvgLabels = exportingGraph;
const mouseEvents = {
onClick: this.handleMouseClick,
onMouseEnter: this.handleMouseEnter,
@@ -132,12 +119,12 @@ class Node extends React.Component {
return (
<g className={nodeClassName} transform={transform}>
{useSvgLabels ?
{exportingGraph ?
this.renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) :
this.renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents)}
<g {...mouseEvents} ref={this.saveShapeRef}>
<NodeShapeType color={color} {...this.props} />
<NodeShapeType id={id} highlighted={highlighted} color={color} metric={metric} />
</g>
{showingNetworks && <NodeNetworksOverlay networks={networks} stack={stack} />}
@@ -167,7 +154,6 @@ class Node extends React.Component {
export default connect(
state => ({
searchQuery: state.get('searchQuery'),
exportingGraph: state.get('exportingGraph'),
showingNetworks: state.get('showingNetworks'),
}),

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { connect } from 'react-redux';
import { Map as makeMap, List as makeList } from 'immutable';
import { List as makeList } from 'immutable';
import { currentTopologySearchNodeMatchesSelector } from '../selectors/search';
import { hasSelectedNode as hasSelectedNodeFn } from '../utils/topology-utils';
import EdgeContainer from './edge-container';
@@ -9,7 +10,7 @@ class NodesChartEdges extends React.Component {
render() {
const { hasSelectedNode, highlightedEdgeIds, layoutEdges, searchQuery,
isAnimated, selectedScale, selectedNodeId, selectedNetwork, selectedNetworkNodes,
searchNodeMatches = makeMap() } = this.props;
searchNodeMatches } = this.props;
return (
<g className="nodes-chart-edges">
@@ -49,19 +50,14 @@ class NodesChartEdges extends React.Component {
}
}
function mapStateToProps(state) {
const currentTopologyId = state.get('currentTopologyId');
return {
export default connect(
state => ({
hasSelectedNode: hasSelectedNodeFn(state),
highlightedEdgeIds: state.get('highlightedEdgeIds'),
searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]),
searchNodeMatches: currentTopologySearchNodeMatchesSelector(state),
searchQuery: state.get('searchQuery'),
selectedNetwork: state.get('selectedNetwork'),
selectedNetworkNodes: state.getIn(['networkNodes', state.get('selectedNetwork')], makeList()),
selectedNodeId: state.get('selectedNodeId'),
};
}
export default connect(
mapStateToProps
})
)(NodesChartEdges);

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import { completeNodesSelector } from '../selectors/chartSelectors';
import NodesChartEdges from './nodes-chart-edges';
import NodesChartNodes from './nodes-chart-nodes';
@@ -15,7 +14,7 @@ class NodesChartElements extends React.Component {
selectedScale={props.selectedScale}
isAnimated={props.isAnimated} />
<NodesChartNodes
layoutNodes={props.completeNodes}
layoutNodes={props.layoutNodes}
selectedScale={props.selectedScale}
isAnimated={props.isAnimated} />
</g>
@@ -23,10 +22,4 @@ class NodesChartElements extends React.Component {
}
}
function mapStateToProps(state, props) {
return {
completeNodes: completeNodesSelector(state, props)
};
}
export default connect(mapStateToProps)(NodesChartElements);
export default connect()(NodesChartElements);

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { connect } from 'react-redux';
import { fromJS, Map as makeMap, List as makeList } from 'immutable';
import { fromJS, List as makeList } from 'immutable';
import { currentTopologySearchNodeMatchesSelector } from '../selectors/search';
import { getAdjacentNodes } from '../utils/topology-utils';
import NodeContainer from './node-container';
@@ -9,7 +10,7 @@ class NodesChartNodes extends React.Component {
render() {
const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId,
selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId,
topCardNode, searchNodeMatches = makeMap() } = this.props;
topCardNode, searchNodeMatches } = this.props;
// highlighter functions
const setHighlighted = node => node.set('highlighted',
@@ -56,7 +57,6 @@ class NodesChartNodes extends React.Component {
{nodesToRender.map(node => <NodeContainer
blurred={node.get('blurred')}
focused={node.get('focused')}
matched={searchNodeMatches.has(node.get('id'))}
matches={searchNodeMatches.get(node.get('id'))}
highlighted={node.get('highlighted')}
shape={node.get('shape')}
@@ -66,7 +66,6 @@ class NodesChartNodes extends React.Component {
id={node.get('id')}
label={node.get('label')}
pseudo={node.get('pseudo')}
nodeCount={node.get('nodeCount')}
subLabel={node.get('subLabel')}
metric={metric(node)}
rank={node.get('rank')}
@@ -80,21 +79,16 @@ class NodesChartNodes extends React.Component {
}
}
function mapStateToProps(state) {
const currentTopologyId = state.get('currentTopologyId');
return {
export default connect(
state => ({
adjacentNodes: getAdjacentNodes(state),
highlightedNodeIds: state.get('highlightedNodeIds'),
mouseOverNodeId: state.get('mouseOverNodeId'),
selectedMetric: state.get('selectedMetric'),
selectedNetwork: state.get('selectedNetwork'),
selectedNodeId: state.get('selectedNodeId'),
searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]),
searchNodeMatches: currentTopologySearchNodeMatchesSelector(state),
searchQuery: state.get('searchQuery'),
topCardNode: state.get('nodeDetails').last()
};
}
export default connect(
mapStateToProps
})
)(NodesChartNodes);

View File

@@ -6,7 +6,7 @@ import { Map as makeMap } from 'immutable';
import { event as d3Event, select } from 'd3-selection';
import { zoom, zoomIdentity } from 'd3-zoom';
import { nodeAdjacenciesSelector, adjacentNodesSelector } from '../selectors/chartSelectors';
import { nodeAdjacenciesSelector } from '../selectors/nodes-chart';
import { clickBackground } from '../actions/app-actions';
import Logo from '../components/logo';
import NodesChartElements from './nodes-chart-elements';
@@ -17,7 +17,7 @@ import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus';
import { graphLayout } from '../selectors/nodes-chart-layout';
const GRAPH_COMPLEXITY_NODES_TRESHOLD = 100;
const GRAPH_COMPLEXITY_NODES_TRESHOLD = 200;
const ZOOM_CACHE_FIELDS = [
'panTranslateX', 'panTranslateY',
'zoomScale', 'minZoomScale', 'maxZoomScale'
@@ -173,7 +173,6 @@ class NodesChart extends React.Component {
function mapStateToProps(state) {
return {
nodes: nodeAdjacenciesSelector(state),
adjacentNodes: adjacentNodesSelector(state),
forceRelayout: state.get('forceRelayout'),
selectedNodeId: state.get('selectedNodeId'),
topologyId: state.get('currentTopologyId'),

View File

@@ -5,8 +5,9 @@ import { connect } from 'react-redux';
import { List as makeList, Map as makeMap } from 'immutable';
import NodeDetailsTable from '../components/node-details/node-details-table';
import { clickNode, sortOrderChanged } from '../actions/app-actions';
import { nodesSelector } from '../selectors/chartSelectors';
import { nodesSelector } from '../selectors/nodes-chart';
import { currentTopologySearchNodeMatchesSelector } from '../selectors/search';
import { getNodeColor } from '../utils/color-utils';
@@ -96,7 +97,7 @@ class NodesGrid extends React.Component {
render() {
const { margins, nodes, height, gridSortedBy, gridSortedDesc,
searchNodeMatches = makeMap(), searchQuery } = this.props;
searchNodeMatches, searchQuery } = this.props;
const cmpStyle = {
height,
marginTop: margins.top,
@@ -148,7 +149,8 @@ function mapStateToProps(state) {
gridSortedDesc: state.get('gridSortedDesc'),
currentTopology: state.get('currentTopology'),
currentTopologyId: state.get('currentTopologyId'),
searchNodeMatches: state.getIn(['searchNodeMatches', state.get('currentTopologyId')]),
searchNodeMatches: currentTopologySearchNodeMatchesSelector(state),
// searchNodeMatches: state.getIn(['searchNodeMatches', state.get('currentTopologyId')]),
searchQuery: state.get('searchQuery'),
selectedNodeId: state.get('selectedNodeId')
};

View File

@@ -10,12 +10,10 @@ import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils
const log = debug('scope:nodes-layout');
const topologyCaches = {};
export const DEFAULT_WIDTH = 800;
export const DEFAULT_HEIGHT = DEFAULT_WIDTH / 2;
export const DEFAULT_MARGINS = {top: 0, left: 0};
const NODE_SIZE_FACTOR = NODE_BASE_SIZE;
const NODE_SEPARATION_FACTOR = 2 * NODE_BASE_SIZE;
const RANK_SEPARATION_FACTOR = 3 * NODE_BASE_SIZE;
const NODE_SEPARATION_FACTOR = 1.5 * NODE_BASE_SIZE;
const RANK_SEPARATION_FACTOR = 2.5 * NODE_BASE_SIZE;
let layoutRuns = 0;
let layoutRunsTrivial = 0;
@@ -89,7 +87,7 @@ function runLayoutEngine(graph, imNodes, imEdges) {
}
});
dagre.layout(graph);
dagre.layout(graph, { debugTiming: false });
const layout = graph.graph();
// apply coordinates to nodes and edges

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import { searchableFieldsSelector } from '../selectors/chartSelectors';
import { searchableFieldsSelector } from '../selectors/search';
import { CANVAS_MARGINS } from '../constants/styles';
import { hideHelp } from '../actions/app-actions';

View File

@@ -4,6 +4,7 @@ import classnames from 'classnames';
import { debounce } from 'lodash';
import { blurSearch, doSearch, focusSearch, showHelp } from '../actions/app-actions';
import { searchNodeMatchesSelector } from '../selectors/search';
import { slugify } from '../utils/string-utils';
import { isTopologyEmpty } from '../utils/topology-utils';
import SearchItem from './search-item';
@@ -156,7 +157,7 @@ export default connect(
pinnedSearches: state.get('pinnedSearches'),
searchFocused: state.get('searchFocused'),
searchQuery: state.get('searchQuery'),
searchNodeMatches: state.get('searchNodeMatches'),
searchNodeMatches: searchNodeMatchesSelector(state),
topologiesLoaded: state.get('topologiesLoaded')
}),
{ blurSearch, doSearch, focusSearch, onClickHelp: showHelp }

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import classnames from 'classnames';
import { searchNodeMatchesSelector } from '../selectors/search';
import { clickTopology } from '../actions/app-actions';
@@ -90,7 +91,7 @@ class Topologies extends React.Component {
function mapStateToProps(state) {
return {
topologies: state.get('topologies'),
searchNodeMatches: state.get('searchNodeMatches'),
searchNodeMatches: searchNodeMatchesSelector(state),
currentTopology: state.get('currentTopology')
};
}

View File

@@ -5,7 +5,7 @@ import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap,
import ActionTypes from '../constants/action-types';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils';
import { applyPinnedSearches } from '../utils/search-utils';
import { getNetworkNodes, getAvailableNetworks } from '../utils/network-view-utils';
import {
findTopologyById,
@@ -59,7 +59,6 @@ export const initialState = makeMap({
pinnedSearches: makeList(), // list of node filters
routeSet: false,
searchFocused: false,
searchNodeMatches: makeMap(),
searchQuery: null,
selectedMetric: null,
selectedNetwork: null,
@@ -386,8 +385,7 @@ export function rootReducer(state = initialState, action) {
}
case ActionTypes.DO_SEARCH: {
state = state.set('searchQuery', action.searchQuery);
return updateNodeMatches(state);
return state.set('searchQuery', action.searchQuery);
}
case ActionTypes.ENTER_EDGE: {
@@ -473,10 +471,9 @@ export function rootReducer(state = initialState, action) {
}
case ActionTypes.PIN_SEARCH: {
state = state.set('searchQuery', '');
state = updateNodeMatches(state);
const pinnedSearches = state.get('pinnedSearches');
state = state.setIn(['pinnedSearches', pinnedSearches.size], action.query);
state = state.set('searchQuery', '');
return applyPinnedSearches(state);
}
@@ -613,18 +610,12 @@ export function rootReducer(state = initialState, action) {
state = state.set('selectedMetric', state.get('pinnedMetric'));
}
// update nodes cache and search results
state = state.setIn(['nodesByTopology', state.get('currentTopologyId')], state.get('nodes'));
state = updateNodeMatches(state);
return state;
// update nodes cache
return state.setIn(['nodesByTopology', state.get('currentTopologyId')], state.get('nodes'));
}
case ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY: {
// not sure if mergeDeep() brings any benefit here
state = state.setIn(['nodesByTopology', action.topologyId], fromJS(action.nodes));
state = updateNodeMatches(state);
return state;
return state.setIn(['nodesByTopology', action.topologyId], fromJS(action.nodes));
}
case ActionTypes.RECEIVE_NOT_FOUND: {

View File

@@ -1,127 +0,0 @@
import debug from 'debug';
import { identity } from 'lodash';
import { createSelector, createSelectorCreator, defaultMemoize } from 'reselect';
import { Map as makeMap, is, Set } from 'immutable';
import { getAdjacentNodes } from '../utils/topology-utils';
import { getSearchableFields } from '../utils/search-utils';
const log = debug('scope:selectors');
//
// `mergeDeepKeyIntersection` does a deep merge on keys that exists in both maps
//
function mergeDeepKeyIntersection(mapA, mapB) {
const commonKeys = Set.fromKeys(mapA).intersect(mapB.keySeq());
return makeMap(commonKeys.map(k => [k, mapA.get(k).mergeDeep(mapB.get(k))]));
}
//
// `returnPreviousRefIfEqual` is a helper function that checks the new computed of a selector
// against the previously computed value. If they are deeply equal return the previous result. This
// is important for things like connect() which tests whether componentWillReceiveProps should be
// called by doing a '===' on the values you return from mapStateToProps.
//
// e.g.
//
// const filteredThings = createSelector(
// state => state.things,
// (things) => things.filter(t => t > 2)
// );
//
// // This will trigger componentWillReceiveProps on every store change:
// connect(s => { things: filteredThings(s) }, ThingComponent);
//
// // But if we wrap it, the result will be === if it `is()` equal and...
// const filteredThingsWrapped = returnPreviousRefIfEqual(filteredThings);
//
// // ...We're safe!
// connect(s => { things: filteredThingsWrapped(s) }, ThingComponent);
//
// Note: This is a slightly strange way to use reselect. Selectors memoize their *arguments* not
// "their results", so use the result of the wrapped selector as the argument to another selector
// here to memoize it and get what we want.
//
const createDeepEqualSelector = createSelectorCreator(defaultMemoize, is);
const returnPreviousRefIfEqual = selector => createDeepEqualSelector(selector, identity);
//
// Selectors!
//
const allNodesSelector = state => state.get('nodes');
export const nodesSelector = returnPreviousRefIfEqual(
createSelector(
allNodesSelector,
allNodes => allNodes.filter(node => !node.get('filtered'))
)
);
export const adjacentNodesSelector = returnPreviousRefIfEqual(getAdjacentNodes);
export const nodeAdjacenciesSelector = returnPreviousRefIfEqual(
createSelector(
nodesSelector,
nodes => nodes.map(n => makeMap({
id: n.get('id'),
adjacency: n.get('adjacency'),
}))
)
);
export const dataNodesSelector = createSelector(
nodesSelector,
nodes => nodes.map((node, id) => makeMap({
id,
label: node.get('label'),
pseudo: node.get('pseudo'),
subLabel: node.get('labelMinor'),
nodeCount: node.get('node_count'),
metrics: node.get('metrics'),
rank: node.get('rank'),
shape: node.get('shape'),
stack: node.get('stack'),
networks: node.get('networks'),
}))
);
export const searchableFieldsSelector = returnPreviousRefIfEqual(
createSelector(
allNodesSelector,
getSearchableFields
)
);
//
// FIXME: this is a bit of a hack...
//
export const layoutNodesSelector = (_, props) => props.layoutNodes || makeMap();
export const completeNodesSelector = createSelector(
layoutNodesSelector,
dataNodesSelector,
(layoutNodes, dataNodes) => {
//
// There are no guarantees whether this selector will be computed first (when
// node-chart-elements.mapStateToProps is called by store.subscribe before
// nodes-chart.mapStateToProps is called), and component render batching and yadada.
//
if (layoutNodes.size !== dataNodes.size) {
log('Obviously mismatched node data', layoutNodes.size, dataNodes.size);
}
return mergeDeepKeyIntersection(dataNodes, layoutNodes);
}
);

View File

@@ -1,7 +1,7 @@
import { includes, without } from 'lodash';
import { fromJS } from 'immutable';
import { createSelector } from 'reselect';
import { scaleThreshold } from 'd3-scale';
import { fromJS, Set as makeSet } from 'immutable';
import { NODE_BASE_SIZE, DETAILS_PANEL_WIDTH } from '../constants/styles';
@@ -21,8 +21,8 @@ const stateHeightSelector = state => state.height;
const stateScaleSelector = state => state.zoomScale;
const stateTranslateXSelector = state => state.panTranslateX;
const stateTranslateYSelector = state => state.panTranslateY;
const inputNodesSelector = (_, props) => props.nodes;
const propsSelectedNodeIdSelector = (_, props) => props.selectedNodeId;
const propsAdjacentNodesSelector = (_, props) => props.adjacentNodes;
const propsMarginsSelector = (_, props) => props.margins;
// The narrower dimension of the viewport, used for scaling.
@@ -60,9 +60,26 @@ const viewportCenterSelector = createSelector(
const selectedNodeNeighborsIdsSelector = createSelector(
[
propsSelectedNodeIdSelector,
propsAdjacentNodesSelector,
inputNodesSelector,
],
(selectedNodeId, adjacentNodes) => without(adjacentNodes.toArray(), selectedNodeId)
(selectedNodeId, nodes) => {
let adjacentNodes = makeSet();
if (!selectedNodeId) {
return adjacentNodes;
}
if (nodes && nodes.has(selectedNodeId)) {
adjacentNodes = makeSet(nodes.getIn([selectedNodeId, 'adjacency']));
// fill up set with reverse edges
nodes.forEach((node, id) => {
if (node.get('adjacency') && node.get('adjacency').includes(selectedNodeId)) {
adjacentNodes = adjacentNodes.add(id);
}
});
}
return without(adjacentNodes.toArray(), selectedNodeId);
}
);
const selectedNodesLayoutSettingsSelector = createSelector(

View File

@@ -25,7 +25,7 @@ function initEdgesFromNodes(nodes) {
const adjacency = node.get('adjacency');
if (adjacency) {
adjacency.forEach((adjacent) => {
const edge = [nodeId, adjacent];
const edge = nodeId < adjacent ? [nodeId, adjacent] : [adjacent, nodeId];
const edgeId = edge.join(EDGE_ID_SEPARATOR);
if (!edges.has(edgeId)) {
@@ -87,6 +87,16 @@ export const graphLayout = createSelector(
const layoutNodes = graph.nodes.map(node => makeMap({
x: node.get('x'),
y: node.get('y'),
id: node.get('id'),
label: node.get('label'),
pseudo: node.get('pseudo'),
subLabel: node.get('labelMinor'),
nodeCount: node.get('node_count'),
metrics: node.get('metrics'),
rank: node.get('rank'),
shape: node.get('shape'),
stack: node.get('stack'),
// networks: node.get('networks'),
}));
return { layoutNodes, layoutEdges };

View File

@@ -0,0 +1,32 @@
import { createSelector } from 'reselect';
import { Map as makeMap } from 'immutable';
const allNodesSelector = state => state.get('nodes');
export const nodesSelector = createSelector(
[
allNodesSelector,
],
allNodes => allNodes.filter(node => !node.get('filtered'))
);
export const nodeAdjacenciesSelector = createSelector(
[
nodesSelector,
],
nodes => nodes.map(node => makeMap({
id: node.get('id'),
adjacency: node.get('adjacency'),
label: node.get('label'),
pseudo: node.get('pseudo'),
subLabel: node.get('labelMinor'),
nodeCount: node.get('node_count'),
metrics: node.get('metrics'),
rank: node.get('rank'),
shape: node.get('shape'),
stack: node.get('stack'),
networks: node.get('networks'),
}))
);

View File

@@ -0,0 +1,41 @@
import { createSelector } from 'reselect';
import { createMapSelector } from 'reselect-map';
import { Map as makeMap } from 'immutable';
import { parseQuery, searchTopology, getSearchableFields } from '../utils/search-utils';
const allNodesSelector = state => state.get('nodes');
const nodesByTopologySelector = state => state.get('nodesByTopology');
const currentTopologyIdSelector = state => state.get('currentTopologyId');
const searchQuerySelector = state => state.get('searchQuery');
const parsedSearchQuerySelector = createSelector(
[
searchQuerySelector
],
searchQuery => parseQuery(searchQuery)
);
export const searchNodeMatchesSelector = createMapSelector(
[
nodesByTopologySelector,
parsedSearchQuerySelector,
],
(nodes, parsed) => (parsed ? searchTopology(nodes, parsed) : makeMap())
);
export const currentTopologySearchNodeMatchesSelector = createSelector(
[
searchNodeMatchesSelector,
currentTopologyIdSelector,
],
(nodesByTopology, currentTopologyId) => nodesByTopology.get(currentTopologyId) || makeMap()
);
export const searchableFieldsSelector = createSelector(
[
allNodesSelector,
],
getSearchableFields
);

View File

@@ -304,54 +304,4 @@ describe('SearchUtils', () => {
expect(matches4.get('row3_c').text).toBe('Value 3');
});
});
describe('updateNodeMatches', () => {
const fun = SearchUtils.updateNodeMatches;
it('should return no matches on an empty topology', () => {
let nextState = fromJS({
nodesByTopology: {},
searchNodeMatches: {},
searchQuery: ''
});
nextState = fun(nextState);
expect(nextState.get('searchNodeMatches').size).toEqual(0);
});
it('should return no matches when no query is present', () => {
let nextState = fromJS({
nodesByTopology: {topo1: nodeSets.someNodes},
searchNodeMatches: {},
searchQuery: ''
});
nextState = fun(nextState);
expect(nextState.get('searchNodeMatches').size).toEqual(0);
});
it('should return no matches when query matches nothing', () => {
let nextState = fromJS({
nodesByTopology: {topo1: nodeSets.someNodes},
searchNodeMatches: {},
searchQuery: 'cantmatch'
});
nextState = fun(nextState);
expect(nextState.get('searchNodeMatches').size).toEqual(0);
});
it('should return a matches when a query matches something', () => {
let nextState = fromJS({
nodesByTopology: {topo1: nodeSets.someNodes},
searchNodeMatches: {},
searchQuery: 'value 2'
});
nextState = fun(nextState);
expect(nextState.get('searchNodeMatches').size).toEqual(1);
expect(nextState.get('searchNodeMatches').get('topo1').size).toEqual(1);
// then clear up again
nextState = nextState.set('searchQuery', '');
nextState = fun(nextState);
expect(nextState.get('searchNodeMatches').size).toEqual(0);
});
});
});

View File

@@ -232,29 +232,6 @@ export function parseQuery(query) {
return null;
}
/**
* Returns {topologyId: {nodeId: matches}}
*/
export function updateNodeMatches(state) {
const parsed = parseQuery(state.get('searchQuery'));
if (parsed) {
if (state.has('nodesByTopology')) {
state.get('nodesByTopology').forEach((nodes, topologyId) => {
const nodeMatches = searchTopology(nodes, parsed);
if (nodeMatches.size > 0) {
state = state.setIn(['searchNodeMatches', topologyId], nodeMatches);
} else {
state = state.deleteIn(['searchNodeMatches', topologyId]);
}
});
}
} else if (state.has('searchNodeMatches')) {
state = state.update('searchNodeMatches', snm => snm.clear());
}
return state;
}
export function getSearchableFields(nodes) {
const get = (node, key) => node.get(key) || makeList();
@@ -269,8 +246,9 @@ export function getSearchableFields(nodes) {
labels.union(get(node, 'parents').map(p => p.get('topologyId')))
), makeSet());
// Consider only property lists (and not generic tables).
const tableRowLabels = nodes.reduce((labels, node) => (
labels.union(get(node, 'tables').flatMap(t => (t.get('rows') || makeList)
labels.union(get(node, 'tables').filter(isPropertyList).flatMap(t => (t.get('rows') || makeList)
.map(f => f.getIn(['entries', 'label']))
))
), makeSet());
@@ -325,5 +303,4 @@ export const testable = {
parseQuery,
parseValue,
searchTopology,
updateNodeMatches
};

View File

@@ -180,7 +180,7 @@ export function isNodeMatchingQuery(node, query) {
export function graphExceedsComplexityThresh(stats) {
// Check to see if complexity is high. Used to trigger table view on page load.
return (stats.get('node_count') + (2 * stats.get('edge_count'))) > 500;
return (stats.get('node_count') + (2 * stats.get('edge_count'))) > 1000;
}
export function zoomCacheKey(props) {

View File

@@ -29,6 +29,7 @@
"moment": "2.17.1",
"page": "1.7.1",
"react": "15.4.1",
"react-addons-perf": "15.4.1",
"react-dom": "15.4.1",
"react-motion": "0.4.5",
"react-redux": "4.4.6",
@@ -39,6 +40,7 @@
"redux-thunk": "2.1.0",
"reqwest": "2.0.5",
"reselect": "2.5.4",
"reselect-map": "1.0.0",
"timely": "0.1.0",
"whatwg-fetch": "2.0.1",
"react-addons-perf": "15.4.1",