From 4e597ddccf4702a1ce5a4691d31c45a9e5c185aa Mon Sep 17 00:00:00 2001 From: Richard Westenra Date: Tue, 7 Apr 2020 14:49:17 +0100 Subject: [PATCH] Add data layers (#129) - Separate graph nodes vertically (by layer) - Add layer bands, and highlight them on hover --- package-lock.json | 14 +- package.json | 3 +- src/actions/actions.test.js | 22 + src/actions/index.js | 13 + src/components/app/index.js | 6 +- src/components/flowchart/draw.js | 69 ++ src/components/flowchart/flowchart.test.js | 2 + src/components/flowchart/index.js | 26 +- src/components/flowchart/styles/_edges.scss | 1 + src/components/flowchart/styles/_layers.scss | 48 ++ .../flowchart/styles/flowchart.scss | 1 + src/components/icon-toolbar/icon-button.js | 6 + src/components/icon-toolbar/icon-toolbar.scss | 5 + .../icon-toolbar/icon-toolbar.test.js | 13 +- src/components/icon-toolbar/index.js | 16 +- src/components/icons/layers.js | 10 + src/config.js | 9 +- src/reducers/index.js | 1 + src/reducers/reducers.test.js | 22 + src/reducers/visible.js | 9 +- src/selectors/disabled.js | 89 +++ src/selectors/disabled.test.js | 186 +++++ src/selectors/edges.js | 16 +- src/selectors/edges.test.js | 85 +-- src/selectors/layers.js | 51 ++ src/selectors/layers.test.js | 53 ++ src/selectors/layout.js | 50 +- src/selectors/layout.test.js | 19 +- src/selectors/nodes.js | 77 +- src/selectors/nodes.test.js | 74 -- src/selectors/ranks.js | 76 ++ src/selectors/ranks.test.js | 51 ++ src/store/index.js | 4 +- src/store/initial-state.js | 18 +- src/store/initial-state.test.js | 7 +- src/store/normalize-data.js | 20 +- src/utils/data/animals.mock.js | 34 +- src/utils/data/layers.mock.js | 691 ++++++++++++++++++ src/utils/index.js | 2 +- src/utils/random-data.js | 26 +- src/utils/state.mock.js | 1 + 41 files changed, 1627 insertions(+), 299 deletions(-) create mode 100644 src/components/flowchart/styles/_layers.scss create mode 100644 src/components/icons/layers.js create mode 100644 src/selectors/disabled.js create mode 100644 src/selectors/disabled.test.js create mode 100644 src/selectors/layers.js create mode 100644 src/selectors/layers.test.js create mode 100644 src/selectors/ranks.js create mode 100644 src/selectors/ranks.test.js create mode 100644 src/utils/data/layers.mock.js diff --git a/package-lock.json b/package-lock.json index f93ee2b6d1..875e2d1f68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4276,6 +4276,11 @@ "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", "integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=" }, + "batching-toposort": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/batching-toposort/-/batching-toposort-1.2.0.tgz", + "integrity": "sha512-HDf0OOv00dqYGm+M5tJ121RTzX0sK9fxzBMKXYsuQrY0pKSOJjc5qa0DUtzvCGkgIVf1YON2G1e/MHEdHXVaRQ==" + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -6504,12 +6509,11 @@ } }, "dagre": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", - "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "version": "git+https://github.com/richardwestenra/dagre.git#c866cda90cb98768d03ae928ad75ae0d74f37ca2", + "from": "git+https://github.com/richardwestenra/dagre.git#manual-ranking", "requires": { - "graphlib": "^2.1.8", - "lodash": "^4.17.15" + "graphlib": "^2.1.7", + "lodash": "^4.17.11" } }, "damerau-levenshtein": { diff --git a/package.json b/package.json index 28c88301c1..d0aa540936 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@quantumblack/kedro-ui": "^1.1.2", + "batching-toposort": "^1.2.0", "classnames": "^2.2.6", "d3-fetch": "^1.1.2", "d3-interpolate-path": "^2.1.0", @@ -38,7 +39,7 @@ "d3-shape": "^1.3.5", "d3-transition": "^1.2.0", "d3-zoom": "^1.7.3", - "dagre": "^0.8.5", + "dagre": "git+https://github.com/richardwestenra/dagre.git#manual-ranking", "konami-code": "^0.2.1", "react-custom-scrollbars": "^4.2.1", "react-flip-toolkit": "^7.0.7", diff --git a/src/actions/actions.test.js b/src/actions/actions.test.js index 2c4c0dee54..96e6709fe6 100644 --- a/src/actions/actions.test.js +++ b/src/actions/actions.test.js @@ -1,11 +1,15 @@ import animals from '../utils/data/animals.mock'; import { RESET_DATA, + TOGGLE_LAYERS, + TOGGLE_SIDEBAR, TOGGLE_TEXT_LABELS, TOGGLE_THEME, UPDATE_CHART_SIZE, UPDATE_FONT_LOADED, resetData, + toggleLayers, + toggleSidebar, toggleTextLabels, toggleTheme, updateChartSize, @@ -36,6 +40,24 @@ describe('actions', () => { expect(resetData(animals)).toEqual(expectedAction); }); + it('should create an action to toggle whether to show layers', () => { + const visible = false; + const expectedAction = { + type: TOGGLE_LAYERS, + visible + }; + expect(toggleLayers(visible)).toEqual(expectedAction); + }); + + it('should create an action to toggle whether the sidebar is open', () => { + const visible = false; + const expectedAction = { + type: TOGGLE_SIDEBAR, + visible + }; + expect(toggleSidebar(visible)).toEqual(expectedAction); + }); + it('should create an action to toggle whether a node has been clicked', () => { const nodeClicked = '12367890'; const expectedAction = { diff --git a/src/actions/index.js b/src/actions/index.js index 846ba656af..459dc3404a 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -11,6 +11,19 @@ export function resetData(data) { }; } +export const TOGGLE_LAYERS = 'TOGGLE_LAYERS'; + +/** + * Toggle whether to show layers on/off + * @param {Boolean} layers True if text labels are to be shown + */ +export function toggleLayers(visible) { + return { + type: TOGGLE_LAYERS, + visible + }; +} + export const TOGGLE_TEXT_LABELS = 'TOGGLE_TEXT_LABELS'; /** diff --git a/src/components/app/index.js b/src/components/app/index.js index e6920ebf0b..56cf4ea400 100644 --- a/src/components/app/index.js +++ b/src/components/app/index.js @@ -73,7 +73,7 @@ class App extends React.Component { App.propTypes = { data: PropTypes.oneOfType([ - PropTypes.oneOf(['random', 'lorem', 'animals', 'demo', 'json']), + PropTypes.oneOf(['random', 'lorem', 'animals', 'demo', 'json', 'layers']), PropTypes.shape({ schema_id: PropTypes.string, edges: PropTypes.array.isRequired, @@ -84,6 +84,10 @@ App.propTypes = { theme: PropTypes.oneOf(['dark', 'light']), visible: PropTypes.shape({ labelBtn: PropTypes.bool, + layerBtn: PropTypes.bool, + layers: PropTypes.bool, + exportBtn: PropTypes.bool, + sidebar: PropTypes.bool, themeBtn: PropTypes.bool }) }; diff --git a/src/components/flowchart/draw.js b/src/components/flowchart/draw.js index 55bd64f456..86c92c2865 100644 --- a/src/components/flowchart/draw.js +++ b/src/components/flowchart/draw.js @@ -4,6 +4,73 @@ import { select } from 'd3-selection'; import { curveBasis, line } from 'd3-shape'; import icon from './icon'; +/** + * Render layer bands + */ +const drawLayers = function() { + const { layers, visibleLayers } = this.props; + + this.el.layers = this.el.layerGroup + .selectAll('.layer') + .data(visibleLayers ? layers : [], layer => layer.id); + + const enterLayers = this.el.layers + .enter() + .append('rect') + .attr('class', 'layer'); + + this.el.layers.exit().remove(); + + this.el.layers = this.el.layers.merge(enterLayers); + + this.el.layers + .attr('x', d => d.x) + .attr('y', d => d.y) + .attr('height', d => d.height) + .attr('width', d => d.width); +}; + +/** + * Render layer name labels + */ +const drawLayerNames = function() { + const { + chartSize: { sidebarWidth = 0 }, + layers, + visibleLayers + } = this.props; + + this.el.layerNameGroup + .transition('layer-names-sidebar-width') + .duration(this.DURATION) + .style('transform', `translateX(${sidebarWidth}px)`); + + this.el.layerNames = this.el.layerNameGroup + .selectAll('.layer-name') + .data(visibleLayers ? layers : [], layer => layer.id); + + const enterLayerNames = this.el.layerNames + .enter() + .append('li') + .attr('class', 'layer-name') + .style('opacity', 0) + .transition('enter-layer-names') + .duration(this.DURATION) + .style('opacity', 1); + + this.el.layerNames + .exit() + .style('opacity', 1) + .transition('exit-layer-names') + .duration(this.DURATION) + .style('opacity', 0) + .remove(); + + this.el.layerNames = this.el.layerNames.merge(enterLayerNames); + + this.el.layerNames.text(d => d.name).attr('dy', 5); +}; + /** * Render node icons and name labels */ @@ -150,6 +217,8 @@ const drawEdges = function() { * Render chart to the DOM with D3 */ const draw = function() { + drawLayers.call(this); + drawLayerNames.call(this); drawEdges.call(this); drawNodes.call(this); }; diff --git a/src/components/flowchart/flowchart.test.js b/src/components/flowchart/flowchart.test.js index 5cccc945f4..c1fa6386f0 100644 --- a/src/components/flowchart/flowchart.test.js +++ b/src/components/flowchart/flowchart.test.js @@ -65,9 +65,11 @@ describe('FlowChart', () => { chartSize: expect.any(Object), edges: expect.any(Array), graphSize: expect.any(Object), + layers: expect.any(Array), linkedNodes: expect.any(Object), nodes: expect.any(Array), textLabels: expect.any(Boolean), + visibleLayers: expect.any(Boolean), visibleSidebar: expect.any(Boolean), zoom: expect.any(Object) }; diff --git a/src/components/flowchart/index.js b/src/components/flowchart/index.js index 7a418bd93c..2f413a876f 100644 --- a/src/components/flowchart/index.js +++ b/src/components/flowchart/index.js @@ -13,6 +13,7 @@ import { getLayoutEdges, getZoomPosition } from '../../selectors/layout'; +import { getLayers } from '../../selectors/layers'; import { getCentralNode, getLinkedNodes } from '../../selectors/linked-nodes'; import draw from './draw'; import './styles/flowchart.css'; @@ -39,6 +40,8 @@ export class FlowChart extends Component { this.wrapperRef = React.createRef(); this.edgesRef = React.createRef(); this.nodesRef = React.createRef(); + this.layersRef = React.createRef(); + this.layerNamesRef = React.createRef(); } componentDidMount() { @@ -72,7 +75,9 @@ export class FlowChart extends Component { svg: select(this.svgRef.current), wrapper: select(this.wrapperRef.current), edgeGroup: select(this.edgesRef.current), - nodeGroup: select(this.nodesRef.current) + nodeGroup: select(this.nodesRef.current), + layerGroup: select(this.layersRef.current), + layerNameGroup: select(this.layerNamesRef.current) }; } @@ -124,7 +129,7 @@ export class FlowChart extends Component { */ initZoomBehaviour() { this.zoomBehaviour = zoom().on('zoom', () => { - const { k: scale } = event.transform; + const { k: scale, y } = event.transform; const { sidebarWidth } = this.props.chartSize; const { width, height } = this.props.graphSize; @@ -140,6 +145,14 @@ export class FlowChart extends Component { // Transform the that wraps the chart this.el.wrapper.attr('transform', event.transform); + // Update layer label y positions + this.el.layerNames + .style('height', d => `${d.height * scale}px`) + .style('transform', d => { + const ty = y + d.y * scale; + return `translateY(${ty}px)`; + }); + // Hide the tooltip so it doesn't get misaligned to its node this.hideTooltip(); }); @@ -255,7 +268,7 @@ export class FlowChart extends Component { * Render React elements */ render() { - const { outerWidth, outerHeight } = this.props.chartSize; + const { outerWidth = 0, outerHeight = 0 } = this.props.chartSize; const { tooltipVisible, tooltipIsRight, @@ -290,6 +303,7 @@ export class FlowChart extends Component { + +
    ({ chartSize: getChartSize(state), edges: getLayoutEdges(state), graphSize: getGraphSize(state), + layers: getLayers(state), linkedNodes: getLinkedNodes(state), nodes: getLayoutNodes(state), textLabels: state.textLabels, + visibleLayers: state.visible.layers, visibleSidebar: state.visible.sidebar, zoom: getZoomPosition(state) }); diff --git a/src/components/flowchart/styles/_edges.scss b/src/components/flowchart/styles/_edges.scss index 8bc715fdf6..7afc76d7f9 100644 --- a/src/components/flowchart/styles/_edges.scss +++ b/src/components/flowchart/styles/_edges.scss @@ -1,6 +1,7 @@ @import './variables'; .edge path { + pointer-events: none; fill: none; stroke-width: 1.5px; diff --git a/src/components/flowchart/styles/_layers.scss b/src/components/flowchart/styles/_layers.scss new file mode 100644 index 0000000000..514cba5bd5 --- /dev/null +++ b/src/components/flowchart/styles/_layers.scss @@ -0,0 +1,48 @@ +@import './variables'; + +.layer { + opacity: 0; + transition: opacity ease 0.5s; + + .kui-theme--dark & { + fill: rgba(white, 0.05); + } + + .kui-theme--light & { + fill: rgba(black, 0.05); + } + + &:hover { + opacity: 1; + } +} + +.pipeline-flowchart__layer-names { + position: absolute; + top: 0; + left: 0; + margin: 0; + padding: 0; + list-style: none; + pointer-events: none; +} + +.layer-name { + position: absolute; + top: 0; + display: flex; + align-items: center; + width: 130px; + padding-left: 15px; + font-weight: bold; + font-size: 1.6em; + white-space: nowrap; + + .kui-theme--dark & { + background: linear-gradient(to right, $color-bg-dark, transparent); + } + + .kui-theme--light & { + background: linear-gradient(to right, $color-bg-light, transparent); + } +} diff --git a/src/components/flowchart/styles/flowchart.scss b/src/components/flowchart/styles/flowchart.scss index a3136f836f..97d643d4d6 100644 --- a/src/components/flowchart/styles/flowchart.scss +++ b/src/components/flowchart/styles/flowchart.scss @@ -1,5 +1,6 @@ @import './node'; @import './edges'; +@import './layers'; @import './tooltip'; .pipeline-flowchart { diff --git a/src/components/icon-toolbar/icon-button.js b/src/components/icon-toolbar/icon-button.js index 74a8b5f548..c325986857 100644 --- a/src/components/icon-toolbar/icon-button.js +++ b/src/components/icon-toolbar/icon-button.js @@ -1,11 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import LabelIcon from '../icons/label'; +import LayersIcon from '../icons/layers'; import ThemeIcon from '../icons/theme'; import ExportIcon from '../icons/export'; const icons = { label: LabelIcon, + layers: LayersIcon, theme: ThemeIcon, export: ExportIcon }; @@ -18,6 +20,7 @@ const icons = { const IconButton = ({ ariaLabel, ariaLive, + disabled, icon, labelText, onClick, @@ -31,6 +34,7 @@ const IconButton = ({ aria-label={ariaLabel} aria-live={ariaLive} className="pipeline-icon-button" + disabled={disabled} onClick={onClick}> {labelText} @@ -42,6 +46,7 @@ const IconButton = ({ IconButton.propTypes = { ariaLabel: PropTypes.string, ariaLive: PropTypes.string, + disabled: PropTypes.bool, icon: PropTypes.string, labelText: PropTypes.string, onClick: PropTypes.func, @@ -51,6 +56,7 @@ IconButton.propTypes = { IconButton.defaultProps = { ariaLabel: null, ariaLive: null, + disabled: false, icon: 'label', labelText: null, onClick: null, diff --git a/src/components/icon-toolbar/icon-toolbar.scss b/src/components/icon-toolbar/icon-toolbar.scss index ae87bca5d3..af15cfdcec 100644 --- a/src/components/icon-toolbar/icon-toolbar.scss +++ b/src/components/icon-toolbar/icon-toolbar.scss @@ -59,6 +59,11 @@ opacity: 1; } + &:disabled { + cursor: not-allowed; + opacity: 0.2; + } + svg { position: relative; display: block; diff --git a/src/components/icon-toolbar/icon-toolbar.test.js b/src/components/icon-toolbar/icon-toolbar.test.js index 415b89ed86..152697c994 100644 --- a/src/components/icon-toolbar/icon-toolbar.test.js +++ b/src/components/icon-toolbar/icon-toolbar.test.js @@ -5,13 +5,14 @@ import { mockState, setup } from '../../utils/state.mock'; describe('IconToolbar', () => { it('renders without crashing', () => { const wrapper = setup.mount(); - expect(wrapper.find('.pipeline-icon-button').length).toBe(3); + expect(wrapper.find('.pipeline-icon-button').length).toBe(4); }); it('hides both buttons when visible prop is false for each of them', () => { const visible = { themeBtn: false, labelBtn: false, + layerBtn: false, exportBtn: false }; const wrapper = setup.mount(, { visible }); @@ -23,7 +24,7 @@ describe('IconToolbar', () => { labelBtn: false }; const wrapper = setup.mount(, { visible }); - expect(wrapper.find('.pipeline-icon-button').length).toBe(2); + expect(wrapper.find('.pipeline-icon-button').length).toBe(3); }); it('shows the export modal on export button click', () => { @@ -38,12 +39,16 @@ describe('IconToolbar', () => { it('maps state to props', () => { const expectedResult = { + disableLayerBtn: expect.any(Boolean), textLabels: expect.any(Boolean), theme: expect.stringMatching(/light|dark/), visible: expect.objectContaining({ - themeBtn: expect.any(Boolean), + exportBtn: expect.any(Boolean), labelBtn: expect.any(Boolean), - exportBtn: expect.any(Boolean) + layerBtn: expect.any(Boolean), + layers: expect.any(Boolean), + themeBtn: expect.any(Boolean), + sidebar: expect.any(Boolean) }) }; expect(mapStateToProps(mockState.lorem)).toEqual(expectedResult); diff --git a/src/components/icon-toolbar/index.js b/src/components/icon-toolbar/index.js index aea0b5e855..65dea031b8 100644 --- a/src/components/icon-toolbar/index.js +++ b/src/components/icon-toolbar/index.js @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; -import { toggleTextLabels, toggleTheme } from '../../actions'; +import { toggleLayers, toggleTextLabels, toggleTheme } from '../../actions'; import IconButton from './icon-button'; import ExportModal from './export-modal'; import './icon-toolbar.css'; @@ -13,6 +13,8 @@ import './icon-toolbar.css'; * @param {string} theme Kedro UI light/dark theme */ export const IconToolbar = ({ + disableLayerBtn, + onToggleLayers, onToggleTextLabels, onToggleTheme, textLabels, @@ -46,6 +48,14 @@ export const IconToolbar = ({ labelText="Export visualisation" visible={visible.exportBtn} /> + onToggleLayers(!visible.layers)} + icon="layers" + labelText="Toggle layers" + disabled={disableLayerBtn} + visible={visible.layerBtn} + />
{visible.exportBtn && ( @@ -55,12 +65,16 @@ export const IconToolbar = ({ }; export const mapStateToProps = state => ({ + disableLayerBtn: !state.layer.ids.length, textLabels: state.textLabels, theme: state.theme, visible: state.visible }); export const mapDispatchToProps = dispatch => ({ + onToggleLayers: value => { + dispatch(toggleLayers(Boolean(value))); + }, onToggleTextLabels: value => { dispatch(toggleTextLabels(Boolean(value))); }, diff --git a/src/components/icons/layers.js b/src/components/icons/layers.js new file mode 100644 index 0000000000..4ea9cc7d10 --- /dev/null +++ b/src/components/icons/layers.js @@ -0,0 +1,10 @@ +import React from 'react'; + +export default ({ className }) => ( + + + +); diff --git a/src/config.js b/src/config.js index 8d1cbf35ec..f859f533ca 100644 --- a/src/config.js +++ b/src/config.js @@ -28,7 +28,14 @@ const getDataSource = () => { * @return {string} Data source type key */ const validateDataSource = source => { - const expectedInput = ['lorem', 'animals', 'demo', 'json', 'random']; + const expectedInput = [ + 'lorem', + 'animals', + 'demo', + 'layers', + 'json', + 'random' + ]; if (expectedInput.includes(source)) { return source; } diff --git a/src/reducers/index.js b/src/reducers/index.js index ed50853f70..3834d2485d 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -48,6 +48,7 @@ const combinedReducer = combineReducers({ visible, edge: (state = {}) => state, id: (state = null) => state, + layer: (state = {}) => state, chartSize: createReducer(UPDATE_CHART_SIZE, 'chartSize', {}), fontLoaded: createReducer(UPDATE_FONT_LOADED, 'fontLoaded', false), textLabels: createReducer(TOGGLE_TEXT_LABELS, 'textLabels', true), diff --git a/src/reducers/reducers.test.js b/src/reducers/reducers.test.js index d1dbaf6ea0..6976be19c8 100644 --- a/src/reducers/reducers.test.js +++ b/src/reducers/reducers.test.js @@ -5,6 +5,8 @@ import reducer from './index'; import normalizeData from '../store/normalize-data'; import { RESET_DATA, + TOGGLE_LAYERS, + TOGGLE_SIDEBAR, TOGGLE_TEXT_LABELS, TOGGLE_THEME, UPDATE_CHART_SIZE, @@ -129,6 +131,26 @@ describe('Reducer', () => { }); }); + describe('TOGGLE_LAYERS', () => { + it('should toggle whether layers are shown', () => { + const newState = reducer(mockState.layers, { + type: TOGGLE_LAYERS, + visible: false + }); + expect(newState.visible.layers).toEqual(false); + }); + }); + + describe('TOGGLE_SIDEBAR', () => { + it('should toggle whether the sidebar is open', () => { + const newState = reducer(mockState.lorem, { + type: TOGGLE_SIDEBAR, + visible: false + }); + expect(newState.visible.sidebar).toEqual(false); + }); + }); + describe('UPDATE_CHART_SIZE', () => { it("should update the chart's dimensions", () => { const newState = reducer(mockState.lorem, { diff --git a/src/reducers/visible.js b/src/reducers/visible.js index f3659656d5..d0692135f1 100644 --- a/src/reducers/visible.js +++ b/src/reducers/visible.js @@ -1,12 +1,19 @@ -import { TOGGLE_SIDEBAR } from '../actions'; +import { TOGGLE_LAYERS, TOGGLE_SIDEBAR } from '../actions'; function visibleReducer(visibleState = {}, action) { switch (action.type) { + case TOGGLE_LAYERS: { + return Object.assign({}, visibleState, { + layers: action.visible + }); + } + case TOGGLE_SIDEBAR: { return Object.assign({}, visibleState, { sidebar: action.visible }); } + default: return visibleState; } diff --git a/src/selectors/disabled.js b/src/selectors/disabled.js new file mode 100644 index 0000000000..01de20ddc8 --- /dev/null +++ b/src/selectors/disabled.js @@ -0,0 +1,89 @@ +import { createSelector } from 'reselect'; +import { arrayToObject } from '../utils'; +import { getTagCount } from './tags'; + +const getNodeIDs = state => state.node.ids; +const getNodeDisabledNode = state => state.node.disabled; +const getNodeTags = state => state.node.tags; +const getNodeType = state => state.node.type; +const getTagEnabled = state => state.tag.enabled; +const getNodeTypeDisabled = state => state.nodeType.disabled; +const getEdgeIDs = state => state.edge.ids; +const getEdgeSources = state => state.edge.sources; +const getEdgeTargets = state => state.edge.targets; +const getLayerIDs = state => state.layer.ids; +const getNodeLayer = state => state.node.layer; + +/** + * Calculate whether nodes should be disabled based on their tags + */ +export const getNodeDisabledTag = createSelector( + [getNodeIDs, getTagEnabled, getTagCount, getNodeTags], + (nodeIDs, tagEnabled, tagCount, nodeTags) => + arrayToObject(nodeIDs, nodeID => { + if (tagCount.enabled === 0) { + return false; + } + if (nodeTags[nodeID].length) { + // Hide task nodes that don't have at least one tag filter enabled + return !nodeTags[nodeID].some(tag => tagEnabled[tag]); + } + return true; + }) +); + +/** + * Set disabled status if the node is specifically hidden, and/or via a tag/view/type + */ +export const getNodeDisabled = createSelector( + [ + getNodeIDs, + getNodeDisabledNode, + getNodeDisabledTag, + getNodeType, + getNodeTypeDisabled + ], + (nodeIDs, nodeDisabledNode, nodeDisabledTag, nodeType, typeDisabled) => + arrayToObject(nodeIDs, id => + Boolean( + nodeDisabledNode[id] || + nodeDisabledTag[id] || + typeDisabled[nodeType[id]] + ) + ) +); + +/** + * Get a list of just the IDs for the remaining visible nodes + */ +export const getVisibleNodeIDs = createSelector( + [getNodeIDs, getNodeDisabled], + (nodeIDs, nodeDisabled) => nodeIDs.filter(id => !nodeDisabled[id]) +); + +/** + * Get a list of just the IDs for the remaining visible layers + */ +export const getVisibleLayerIDs = createSelector( + [getVisibleNodeIDs, getNodeLayer, getLayerIDs], + (nodeIDs, nodeLayer, layerIDs) => { + const visibleLayerIDs = {}; + for (const nodeID of nodeIDs) { + visibleLayerIDs[nodeLayer[nodeID]] = true; + } + return layerIDs.filter(layerID => visibleLayerIDs[layerID]); + } +); + +/** + * Determine whether an edge should be disabled based on their source/target nodes + */ +export const getEdgeDisabled = createSelector( + [getEdgeIDs, getNodeDisabled, getEdgeSources, getEdgeTargets], + (edgeIDs, nodeDisabled, edgeSources, edgeTargets) => + arrayToObject(edgeIDs, edgeID => { + const source = edgeSources[edgeID]; + const target = edgeTargets[edgeID]; + return Boolean(nodeDisabled[source] || nodeDisabled[target]); + }) +); diff --git a/src/selectors/disabled.test.js b/src/selectors/disabled.test.js new file mode 100644 index 0000000000..9d1ef06999 --- /dev/null +++ b/src/selectors/disabled.test.js @@ -0,0 +1,186 @@ +import { mockState } from '../utils/state.mock'; +import { + getNodeDisabledTag, + getNodeDisabled, + getEdgeDisabled +} from './disabled'; +import { toggleNodesDisabled } from '../actions/nodes'; +import { toggleTagFilter } from '../actions/tags'; +import reducer from '../reducers'; + +const getNodeIDs = state => state.node.ids; +const getEdgeIDs = state => state.edge.ids; +const getEdgeSources = state => state.edge.sources; +const getEdgeTargets = state => state.edge.targets; +const getNodeTags = state => state.node.tags; +const getNodeType = state => state.node.type; + +describe('Selectors', () => { + describe('getNodeDisabledTag', () => { + it('returns an object', () => { + expect(getNodeDisabledTag(mockState.lorem)).toEqual(expect.any(Object)); + }); + + it("returns an object whose keys match the current pipeline's nodes", () => { + expect(Object.keys(getNodeDisabledTag(mockState.lorem))).toEqual( + getNodeIDs(mockState.lorem) + ); + }); + + it('returns an object whose values are all Booleans', () => { + expect( + Object.values(getNodeDisabledTag(mockState.lorem)).every( + value => typeof value === 'boolean' + ) + ).toBe(true); + }); + + it('does not disable any nodes if all tags filters are inactive', () => { + const nodeDisabled = getNodeDisabledTag(mockState.lorem); + expect(Object.values(nodeDisabled)).toEqual( + Object.values(nodeDisabled).map(() => false) + ); + }); + + it('disables a node with no tags if a tag filter is active', () => { + const tag = mockState.animals.tag.ids[0]; + const nodeTags = getNodeTags(mockState.animals); + // Choose a node that has no tags (and which should be disabled) + const hasNoTags = id => !Boolean(nodeTags[id].length); + const disabledNodeID = getNodeIDs(mockState.animals).find(hasNoTags); + // Update the state to enable one of the tags for that node + const newMockState = reducer( + mockState.animals, + toggleTagFilter(tag, true) + ); + expect(getNodeDisabledTag(newMockState)[disabledNodeID]).toEqual(true); + }); + + it('does not disable a node if only one of its several tag filters are active', () => { + const nodeTags = getNodeTags(mockState.animals); + // Choose a node that has > 1 tag + const enabledNodeID = getNodeIDs(mockState.animals).find( + id => nodeTags[id].length > 1 + ); + // Update the state to enable one of the tags for that node + const enabledNodeTags = nodeTags[enabledNodeID]; + const newMockState = reducer( + mockState.animals, + toggleTagFilter(enabledNodeTags[0], true) + ); + expect(getNodeDisabledTag(newMockState)[enabledNodeID]).toEqual(false); + }); + + it('does not disable a node if all of its several tag filters are active', () => { + const nodeTags = getNodeTags(mockState.animals); + // Choose a node that has > 1 tag + const enabledNodeID = getNodeIDs(mockState.animals).find( + id => nodeTags[id].length > 1 + ); + // Update the state to activate all of the tag filters for that node + const enabledNodeTags = nodeTags[enabledNodeID]; + const newMockState = enabledNodeTags.reduce( + (state, tag) => reducer(state, toggleTagFilter(tag, true)), + mockState.animals + ); + expect(getNodeDisabledTag(newMockState)[enabledNodeID]).toEqual(false); + }); + }); + + describe('getNodeDisabled', () => { + it('returns an object', () => { + expect(getNodeDisabled(mockState.lorem)).toEqual(expect.any(Object)); + }); + + it("returns an object whose keys match the current pipeline's nodes", () => { + expect(Object.keys(getNodeDisabled(mockState.lorem))).toEqual( + getNodeIDs(mockState.lorem) + ); + }); + + it('returns an object whose values are all Booleans', () => { + expect( + Object.values(getNodeDisabled(mockState.lorem)).every( + value => typeof value === 'boolean' + ) + ).toBe(true); + }); + }); + + describe('getEdgeDisabled', () => { + const nodeID = getNodeIDs(mockState.lorem)[0]; + const newMockState = reducer( + mockState.lorem, + toggleNodesDisabled([nodeID], true) + ); + const edgeDisabled = getEdgeDisabled(newMockState); + const edges = getEdgeIDs(newMockState); + + it('returns an object', () => { + expect(getEdgeDisabled(mockState.lorem)).toEqual(expect.any(Object)); + }); + + it("returns an object whose keys match the current pipeline's edges", () => { + expect(Object.keys(getEdgeDisabled(mockState.lorem))).toEqual( + getEdgeIDs(mockState.lorem) + ); + }); + + it('returns an object whose values are all Booleans', () => { + expect( + Object.values(getEdgeDisabled(mockState.lorem)).every( + value => typeof value === 'boolean' + ) + ).toBe(true); + }); + + it('does not disable an edge if no nodes are disabled', () => { + const edgeDisabledValues = Object.values( + getEdgeDisabled(mockState.lorem) + ); + expect(edgeDisabledValues).toEqual(edgeDisabledValues.map(() => false)); + }); + + it('disables an edge if one of its nodes is disabled', () => { + const disabledEdges = Object.keys(edgeDisabled).filter( + id => edgeDisabled[id] + ); + const disabledEdgesMock = edges.filter( + id => + getEdgeSources(newMockState)[id] === nodeID || + getEdgeTargets(newMockState)[id] === nodeID + ); + expect(disabledEdges).toEqual(disabledEdgesMock); + }); + + it('does not disable an edge if none of its nodes are disabled', () => { + const enabledEdges = Object.keys(edgeDisabled).filter( + id => !edgeDisabled[id] + ); + const enabledEdgesMock = edges.filter( + id => + getEdgeSources(newMockState)[id] !== nodeID && + getEdgeTargets(newMockState)[id] !== nodeID + ); + expect(enabledEdges).toEqual(enabledEdgesMock); + }); + + it('returns an object', () => { + expect(getEdgeDisabled(mockState.lorem)).toEqual(expect.any(Object)); + }); + + it("returns an object whose keys match the current pipeline's edges", () => { + expect(Object.keys(getEdgeDisabled(mockState.lorem))).toEqual( + getEdgeIDs(mockState.lorem) + ); + }); + + it('returns an object whose values are all Booleans', () => { + expect( + Object.values(getEdgeDisabled(mockState.lorem)).every( + value => typeof value === 'boolean' + ) + ).toBe(true); + }); + }); +}); diff --git a/src/selectors/edges.js b/src/selectors/edges.js index 8e22e4eb62..0770c61245 100644 --- a/src/selectors/edges.js +++ b/src/selectors/edges.js @@ -1,25 +1,11 @@ import { createSelector } from 'reselect'; -import { arrayToObject } from '../utils'; -import { getNodeDisabled } from './nodes'; +import { getNodeDisabled, getEdgeDisabled } from './disabled'; const getNodeIDs = state => state.node.ids; const getEdgeIDs = state => state.edge.ids; const getEdgeSources = state => state.edge.sources; const getEdgeTargets = state => state.edge.targets; -/** - * Determine whether an edge should be disabled based on their source/target nodes - */ -export const getEdgeDisabled = createSelector( - [getEdgeIDs, getNodeDisabled, getEdgeSources, getEdgeTargets], - (edgeIDs, nodeDisabled, edgeSources, edgeTargets) => - arrayToObject(edgeIDs, edgeID => { - const source = edgeSources[edgeID]; - const target = edgeTargets[edgeID]; - return Boolean(nodeDisabled[source] || nodeDisabled[target]); - }) -); - /** * Create a new transitive edge from the first and last edge in the path * @param {string} target Node ID for the new edge diff --git a/src/selectors/edges.test.js b/src/selectors/edges.test.js index 30aaba194b..ef7d1f2657 100644 --- a/src/selectors/edges.test.js +++ b/src/selectors/edges.test.js @@ -1,10 +1,6 @@ import { mockState } from '../utils/state.mock'; -import { - getEdgeDisabled, - addNewEdge, - getTransitiveEdges, - getVisibleEdges -} from './edges'; +import { getEdgeDisabled } from './disabled'; +import { addNewEdge, getTransitiveEdges, getVisibleEdges } from './edges'; import { toggleNodesDisabled } from '../actions/nodes'; import reducer from '../reducers'; @@ -14,83 +10,6 @@ const getEdgeSources = state => state.edge.sources; const getEdgeTargets = state => state.edge.targets; describe('Selectors', () => { - describe('getEdgeDisabled', () => { - const nodeID = getNodeIDs(mockState.lorem)[0]; - const newMockState = reducer( - mockState.lorem, - toggleNodesDisabled([nodeID], true) - ); - const edgeDisabled = getEdgeDisabled(newMockState); - const edges = getEdgeIDs(newMockState); - - it('returns an object', () => { - expect(getEdgeDisabled(mockState.lorem)).toEqual(expect.any(Object)); - }); - - it("returns an object whose keys match the current pipeline's edges", () => { - expect(Object.keys(getEdgeDisabled(mockState.lorem))).toEqual( - getEdgeIDs(mockState.lorem) - ); - }); - - it('returns an object whose values are all Booleans', () => { - expect( - Object.values(getEdgeDisabled(mockState.lorem)).every( - value => typeof value === 'boolean' - ) - ).toBe(true); - }); - - it('does not disable an edge if no nodes are disabled', () => { - const edgeDisabledValues = Object.values( - getEdgeDisabled(mockState.lorem) - ); - expect(edgeDisabledValues).toEqual(edgeDisabledValues.map(() => false)); - }); - - it('disables an edge if one of its nodes is disabled', () => { - const disabledEdges = Object.keys(edgeDisabled).filter( - id => edgeDisabled[id] - ); - const disabledEdgesMock = edges.filter( - id => - getEdgeSources(newMockState)[id] === nodeID || - getEdgeTargets(newMockState)[id] === nodeID - ); - expect(disabledEdges).toEqual(disabledEdgesMock); - }); - - it('does not disable an edge if none of its nodes are disabled', () => { - const enabledEdges = Object.keys(edgeDisabled).filter( - id => !edgeDisabled[id] - ); - const enabledEdgesMock = edges.filter( - id => - getEdgeSources(newMockState)[id] !== nodeID && - getEdgeTargets(newMockState)[id] !== nodeID - ); - expect(enabledEdges).toEqual(enabledEdgesMock); - }); - - it('returns an object', () => { - expect(getEdgeDisabled(mockState.lorem)).toEqual(expect.any(Object)); - }); - - it("returns an object whose keys match the current pipeline's edges", () => { - expect(Object.keys(getEdgeDisabled(mockState.lorem))).toEqual( - getEdgeIDs(mockState.lorem) - ); - }); - - it('returns an object whose values are all Booleans', () => { - expect( - Object.values(getEdgeDisabled(mockState.lorem)).every( - value => typeof value === 'boolean' - ) - ).toBe(true); - }); - }); - describe('addNewEdge', () => { const transitiveEdges = {}; beforeEach(() => { diff --git a/src/selectors/layers.js b/src/selectors/layers.js new file mode 100644 index 0000000000..8cae02f5c8 --- /dev/null +++ b/src/selectors/layers.js @@ -0,0 +1,51 @@ +import { createSelector } from 'reselect'; +import { getLayoutNodes, getGraphSize } from './layout'; +import { getVisibleLayerIDs } from './disabled'; + +const getLayerName = state => state.layer.name; + +/** + * Get layer positions + */ +export const getLayers = createSelector( + [getLayoutNodes, getVisibleLayerIDs, getLayerName, getGraphSize], + (nodes, layerIDs, layerName, { width }) => { + // Get list of layer Y positions from nodes + const layerY = nodes.reduce((layerY, node) => { + if (!layerY[node.layer]) { + layerY[node.layer] = []; + } + layerY[node.layer].push(node.y); + return layerY; + }, {}); + + /** + * Determine the y position and height of a layer band + * @param {number} id + */ + const calculateYPos = (layerID, prevID, nextID) => { + const yMin = Math.min(...layerY[layerID]); + const yMax = Math.max(...layerY[layerID]); + const prev = layerY[prevID]; + const next = layerY[nextID]; + const topYGap = prev && yMin - Math.max(...prev); + const bottomYGap = next && Math.min(...next) - yMax; + const yGap = (topYGap || bottomYGap) / 2; + const y = yMin - yGap; + const height = yMax + yGap - y; + return { y, height }; + }; + + return layerIDs.map((id, i) => { + const prevID = layerIDs[i - 1]; + const nextID = layerIDs[i + 1]; + return { + id, + name: layerName[id], + x: -width / 2, + width: width * 2, + ...calculateYPos(id, prevID, nextID) + }; + }); + } +); diff --git a/src/selectors/layers.test.js b/src/selectors/layers.test.js new file mode 100644 index 0000000000..9d9a36826f --- /dev/null +++ b/src/selectors/layers.test.js @@ -0,0 +1,53 @@ +import { mockState } from '../utils/state.mock'; +import { getLayers } from './layers'; +import { getLayoutNodes } from './layout'; + +describe('Selectors', () => { + describe('getLayers', () => { + it('returns an array', () => { + expect(getLayers(mockState.layers)).toEqual(expect.any(Array)); + }); + + it("returns an array whose IDs match the current pipeline's layer IDs, in the same order", () => { + expect(getLayers(mockState.layers).map(d => d.id)).toEqual( + mockState.layers.layer.ids + ); + }); + + it('returns numeric y/height properties for each layer object', () => { + expect(getLayers(mockState.layers)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + y: expect.any(Number), + height: expect.any(Number) + }) + ]) + ); + }); + + it("calculates appropriate y/height positions for each layer corresponding to each layer's nodes", () => { + const nodes = getLayoutNodes(mockState.layers); + const layers = getLayers(mockState.layers); + const layerIDs = layers.map(layer => layer.id); + const layersObj = layers.reduce((layers, layer) => { + layers[layer.id] = layer; + return layers; + }, {}); + + expect( + nodes.every(node => { + const i = layerIDs.indexOf(node.layer); + const prevLayer = layersObj[layerIDs[i - 1]]; + const thisLayer = layersObj[node.layer]; + const nextLayer = layersObj[layerIDs[i + 1]]; + return ( + (!prevLayer || node.y > prevLayer.y + prevLayer.height) && + node.y > thisLayer.y && + node.y + node.height < thisLayer.y + thisLayer.height && + (!nextLayer || node.y + node.height < nextLayer.y) + ); + }) + ).toBe(true); + }); + }); +}); diff --git a/src/selectors/layout.js b/src/selectors/layout.js index 22bdc9b574..aa942d5cb8 100644 --- a/src/selectors/layout.js +++ b/src/selectors/layout.js @@ -3,7 +3,10 @@ import dagre from 'dagre'; import { getNodeActive, getVisibleNodes } from './nodes'; import { getVisibleEdges } from './edges'; +const getHasVisibleLayers = state => + state.visible.layers && Boolean(state.layer.ids.length); const getNodeType = state => state.node.type; +const getNodeLayer = state => state.node.layer; const getVisibleSidebar = state => state.visible.sidebar; /** @@ -13,9 +16,16 @@ const getVisibleSidebar = state => state.visible.sidebar; * which don't affect layout. */ export const getGraph = createSelector( - [getVisibleNodes, getVisibleEdges], - (nodes, edges) => { + [getVisibleNodes, getVisibleEdges, getHasVisibleLayers], + (nodes, edges, hasVisibleLayers) => { + if (!nodes.length || !edges.length) { + return; + } + + const ranker = hasVisibleLayers ? 'none' : null; const graph = new dagre.graphlib.Graph().setGraph({ + ranker: hasVisibleLayers ? ranker : null, + ranksep: hasVisibleLayers ? 200 : 70, marginx: 40, marginy: 40 }); @@ -40,16 +50,19 @@ export const getGraph = createSelector( * and recombine with other data that doesn't affect layout */ export const getLayoutNodes = createSelector( - [getGraph, getNodeType, getNodeActive], - (graph, nodeType, nodeActive) => - graph.nodes().map(nodeID => { - const node = graph.node(nodeID); - return Object.assign({}, node, { - type: nodeType[nodeID], - order: node.x + node.y * 9999, - active: nodeActive[nodeID] - }); - }) + [getGraph, getNodeType, getNodeLayer, getNodeActive], + (graph, nodeType, nodeLayer, nodeActive) => + graph + ? graph.nodes().map(nodeID => { + const node = graph.node(nodeID); + return Object.assign({}, node, { + layer: nodeLayer[nodeID], + type: nodeType[nodeID], + order: node.x + node.y * 9999, + active: nodeActive[nodeID] + }); + }) + : [] ); /** @@ -57,7 +70,8 @@ export const getLayoutNodes = createSelector( */ export const getLayoutEdges = createSelector( [getGraph], - graph => graph.edges().map(edge => Object.assign({}, graph.edge(edge))) + graph => + graph ? graph.edges().map(edge => Object.assign({}, graph.edge(edge))) : [] ); /** @@ -65,7 +79,7 @@ export const getLayoutEdges = createSelector( */ export const getGraphSize = createSelector( [getGraph], - graph => graph.graph() + graph => (graph ? graph.graph() : {}) ); /** @@ -111,8 +125,12 @@ export const getChartSize = createSelector( export const getZoomPosition = createSelector( [getGraphSize, getChartSize], (graph, chart) => { - if (!Object.keys(chart).length) { - return {}; + if (!chart.width || !graph.width) { + return { + scale: 1, + translateX: 0, + translateY: 0 + }; } const scale = Math.min( diff --git a/src/selectors/layout.test.js b/src/selectors/layout.test.js index f99301f99e..86460c7a6e 100644 --- a/src/selectors/layout.test.js +++ b/src/selectors/layout.test.js @@ -11,6 +11,7 @@ import { import { getVisibleNodes } from './nodes'; import { getVisibleEdges } from './edges'; import { updateChartSize } from '../actions'; +import getInitialState from '../store/initial-state'; import reducer from '../reducers'; describe('Selectors', () => { @@ -150,8 +151,22 @@ describe('Selectors', () => { }); describe('getZoomPosition', () => { - it('returns an empty object if chartSize is unset', () => { - expect(getZoomPosition(mockState.lorem)).toEqual({}); + const defaultZoom = { + scale: 1, + translateX: 0, + translateY: 0 + }; + + it('returns default values if chartSize is unset', () => { + expect(getZoomPosition(mockState.lorem)).toEqual(defaultZoom); + }); + + it('returns default values when no nodes are visible', () => { + const newMockState = reducer( + getInitialState({ data: [] }), + updateChartSize({ width: 100, height: 100 }) + ); + expect(getZoomPosition(newMockState)).toEqual(defaultZoom); }); it('returns the updated chart zoom translation/scale if chartSize is set', () => { diff --git a/src/selectors/nodes.js b/src/selectors/nodes.js index adebeab63e..2627427d15 100644 --- a/src/selectors/nodes.js +++ b/src/selectors/nodes.js @@ -1,8 +1,13 @@ import { createSelector } from 'reselect'; import { select } from 'd3-selection'; import { arrayToObject } from '../utils'; -import { getTagCount } from './tags'; +import { + getNodeDisabled, + getNodeDisabledTag, + getVisibleNodeIDs +} from './disabled'; import { getCentralNode } from './linked-nodes'; +import { getNodeRank } from './ranks'; const getNodeIDs = state => state.node.ids; const getNodeName = state => state.node.name; @@ -10,51 +15,12 @@ const getNodeFullName = state => state.node.fullName; const getNodeDisabledNode = state => state.node.disabled; const getNodeTags = state => state.node.tags; const getNodeType = state => state.node.type; +const getNodeLayer = state => state.node.layer; const getTagActive = state => state.tag.active; -const getTagEnabled = state => state.tag.enabled; const getTextLabels = state => state.textLabels; const getFontLoaded = state => state.fontLoaded; const getNodeTypeDisabled = state => state.nodeType.disabled; -/** - * Calculate whether nodes should be disabled based on their tags - */ -export const getNodeDisabledTag = createSelector( - [getNodeIDs, getTagEnabled, getTagCount, getNodeTags], - (nodeIDs, tagEnabled, tagCount, nodeTags) => - arrayToObject(nodeIDs, nodeID => { - if (tagCount.enabled === 0) { - return false; - } - if (nodeTags[nodeID].length) { - // Hide task nodes that don't have at least one tag filter enabled - return !nodeTags[nodeID].some(tag => tagEnabled[tag]); - } - return true; - }) -); - -/** - * Set disabled status if the node is specifically hidden, and/or via a tag/view/type - */ -export const getNodeDisabled = createSelector( - [ - getNodeIDs, - getNodeDisabledNode, - getNodeDisabledTag, - getNodeType, - getNodeTypeDisabled - ], - (nodeIDs, nodeDisabledNode, nodeDisabledTag, nodeType, typeDisabled) => - arrayToObject(nodeIDs, id => - Boolean( - nodeDisabledNode[id] || - nodeDisabledTag[id] || - typeDisabled[nodeType[id]] - ) - ) -); - /** * Set active status if the node is specifically highlighted, and/or via an associated tag * @return {Boolean} True if active @@ -196,22 +162,23 @@ export const getNodeSize = createSelector( */ export const getVisibleNodes = createSelector( [ - getNodeIDs, + getVisibleNodeIDs, getNodeName, getNodeType, - getNodeDisabled, getNodeFullName, - getNodeSize + getNodeSize, + getNodeLayer, + getNodeRank ], - (nodeIDs, nodeName, nodeType, nodeDisabled, nodeFullName, nodeSize) => - nodeIDs - .filter(id => !nodeDisabled[id]) - .map(id => ({ - id, - name: nodeName[id], - label: nodeName[id], - fullName: nodeFullName[id], - type: nodeType[id], - ...nodeSize[id] - })) + (nodeIDs, nodeName, nodeType, nodeFullName, nodeSize, nodeLayer, nodeRank) => + nodeIDs.map(id => ({ + id, + name: nodeName[id], + label: nodeName[id], + fullName: nodeFullName[id], + type: nodeType[id], + layer: nodeLayer[id], + rank: nodeRank[id], + ...nodeSize[id] + })) ); diff --git a/src/selectors/nodes.test.js b/src/selectors/nodes.test.js index 93c6e8e3a7..4fd6078ca7 100644 --- a/src/selectors/nodes.test.js +++ b/src/selectors/nodes.test.js @@ -1,7 +1,5 @@ import { mockState } from '../utils/state.mock'; import { - getNodeDisabledTag, - getNodeDisabled, getNodeActive, getNodeData, getNodeTextWidth, @@ -15,84 +13,12 @@ import { toggleNodeHovered, toggleNodesDisabled } from '../actions/nodes'; -import { toggleTagFilter } from '../actions/tags'; import reducer from '../reducers'; const getNodeIDs = state => state.node.ids; const getNodeName = state => state.node.name; -const getNodeTags = state => state.node.tags; -const getNodeType = state => state.node.type; describe('Selectors', () => { - describe('getNodeDisabledTag', () => { - it('returns an object', () => { - expect(getNodeDisabledTag(mockState.lorem)).toEqual(expect.any(Object)); - }); - - it("returns an object whose keys match the current pipeline's nodes", () => { - expect(Object.keys(getNodeDisabledTag(mockState.lorem))).toEqual( - getNodeIDs(mockState.lorem) - ); - }); - - it('returns an object whose values are all Booleans', () => { - expect( - Object.values(getNodeDisabledTag(mockState.lorem)).every( - value => typeof value === 'boolean' - ) - ).toBe(true); - }); - - it('does not disable a node if all tags are disabled', () => { - const nodeDisabled = getNodeDisabledTag(mockState.lorem); - expect(Object.values(nodeDisabled)).toEqual( - Object.values(nodeDisabled).map(() => false) - ); - }); - - it('disables a node only if all of its tags are disabled', () => { - const nodeTags = getNodeTags(mockState.animals); - // Get list of task nodes from the current pipeline - const taskNodes = getNodeIDs(mockState.animals).filter( - id => getNodeType(mockState.animals)[id] === 'task' - ); - // Choose a node that has some tags (and which should be enabled) - const hasTags = id => Boolean(nodeTags[id].length); - const enabledNodeID = taskNodes.find(hasTags); - // Choose a node that has no tags (and which should be disabled) - const hasNoTags = id => !Boolean(nodeTags[id].length); - const disabledNodeID = taskNodes.find(hasNoTags); - // Update the state to enable one of the tags for that node - const enabledNodeTags = nodeTags[enabledNodeID]; - const newMockState = reducer( - mockState.animals, - toggleTagFilter(enabledNodeTags[0], true) - ); - expect(getNodeDisabledTag(newMockState)[enabledNodeID]).toEqual(false); - expect(getNodeDisabledTag(newMockState)[disabledNodeID]).toEqual(true); - }); - }); - - describe('getNodeDisabled', () => { - it('returns an object', () => { - expect(getNodeDisabled(mockState.lorem)).toEqual(expect.any(Object)); - }); - - it("returns an object whose keys match the current pipeline's nodes", () => { - expect(Object.keys(getNodeDisabled(mockState.lorem))).toEqual( - getNodeIDs(mockState.lorem) - ); - }); - - it('returns an object whose values are all Booleans', () => { - expect( - Object.values(getNodeDisabled(mockState.lorem)).every( - value => typeof value === 'boolean' - ) - ).toBe(true); - }); - }); - describe('getNodeActive', () => { it('returns an object', () => { expect(getNodeActive(mockState.lorem)).toEqual(expect.any(Object)); diff --git a/src/selectors/ranks.js b/src/selectors/ranks.js new file mode 100644 index 0000000000..013da821d4 --- /dev/null +++ b/src/selectors/ranks.js @@ -0,0 +1,76 @@ +import { createSelector } from 'reselect'; +import batchingToposort from 'batching-toposort'; +import { getVisibleNodeIDs, getVisibleLayerIDs } from './disabled'; +import { getVisibleEdges } from './edges'; + +const getNodeLayer = state => state.node.layer; +const getLayersVisible = state => state.visible.layers; + +/** + * Get list of visible nodes for each visible layer + */ +export const getLayerNodes = createSelector( + [getVisibleNodeIDs, getVisibleLayerIDs, getNodeLayer], + (nodeIDs, layerIDs, nodeLayer) => { + // Create object containing a list of every node for each layer + const layerNodes = {}; + for (const nodeID of nodeIDs) { + const layer = nodeLayer[nodeID]; + if (!layerNodes[layer]) { + layerNodes[layer] = []; + } + layerNodes[layer].push(nodeID); + } + + // Convert to a nested array of layers of nodes + return layerIDs.map(layerID => layerNodes[layerID]); + } +); + +/** + * Calculate ranks (vertical placement) for each node, + * by toposorting while taking layers into account + */ +export const getNodeRank = createSelector( + [getVisibleNodeIDs, getVisibleEdges, getLayerNodes, getLayersVisible], + (nodeIDs, edges, layerNodes, layersVisible) => { + if (!layersVisible) { + return {}; + } + + // For each node, create a list of nodes that depend on that node + const nodeDeps = {}; + + // Initialise empty dependency arrays for each node + for (const nodeID of nodeIDs) { + nodeDeps[nodeID] = []; + } + + // Add dependencies for visible edges + for (const edge of edges) { + nodeDeps[edge.source].push(edge.target); + } + + // Add "false edge" dependencies for layered nodes to prevent layer overlaps + for (let i = 1; i < layerNodes.length; i++) { + for (const sourceID of layerNodes[i - 1]) { + for (const targetID of layerNodes[i]) { + nodeDeps[sourceID].push(targetID); + } + } + } + + // Run toposort algorithm to rank nodes by dependency + const toposortedNodes = batchingToposort(nodeDeps); + + // Convert toposort order into rank numbering + const nodeRanks = {}; + for (let rank = 0; rank < toposortedNodes.length; rank++) { + for (const nodeID of toposortedNodes[rank]) { + nodeRanks[nodeID] = rank; + } + } + + return nodeRanks; + } +); diff --git a/src/selectors/ranks.test.js b/src/selectors/ranks.test.js new file mode 100644 index 0000000000..d4c2f4e0e8 --- /dev/null +++ b/src/selectors/ranks.test.js @@ -0,0 +1,51 @@ +import { mockState } from '../utils/state.mock'; +import { getLayerNodes, getNodeRank } from './ranks'; +import { getVisibleNodeIDs, getVisibleLayerIDs } from './disabled'; +import { getVisibleEdges } from './edges'; + +const getNodeLayer = state => state.node.layer; + +describe('Selectors', () => { + describe('getLayerNodes', () => { + it('returns an array containing an array of node IDs', () => { + expect(getLayerNodes(mockState.layers)).toEqual( + expect.arrayContaining([expect.arrayContaining([expect.any(String)])]) + ); + }); + + test('all node IDs are in the correct layer', () => { + const layerIDs = getVisibleLayerIDs(mockState.layers); + const nodeLayer = getNodeLayer(mockState.layers); + expect( + getLayerNodes(mockState.layers).every((layerNodeIDs, i) => + layerNodeIDs.every(nodeID => nodeLayer[nodeID] === layerIDs[i]) + ) + ).toBe(true); + }); + }); + + describe('getNodeRank', () => { + const nodeRank = getNodeRank(mockState.layers); + const nodeIDs = getVisibleNodeIDs(mockState.layers); + const edges = getVisibleEdges(mockState.layers); + + it('returns an object', () => { + expect(nodeRank).toEqual(expect.any(Object)); + }); + + it('returns an object containing ranks for each node ID', () => { + expect(nodeRank).toEqual( + nodeIDs.reduce((ranks, nodeID) => { + ranks[nodeID] = expect.any(Number); + return ranks; + }, {}) + ); + }); + + test('for every edge, the source rank is less than the target rank', () => { + expect( + edges.every(edge => nodeRank[edge.source] < nodeRank[edge.target]) + ).toBe(true); + }); + }); +}); diff --git a/src/store/index.js b/src/store/index.js index 91bc0f50b7..252fb776f0 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -11,12 +11,12 @@ export default function configureStore(initialState) { const store = createStore(reducer, initialState); store.subscribe(() => { - const { textLabels, theme, nodeType, visibleSidebar } = store.getState(); + const { textLabels, theme, nodeType, visible } = store.getState(); saveState({ textLabels, theme, nodeTypeDisabled: nodeType.disabled, - visibleSidebar + visible }); }); diff --git a/src/store/initial-state.js b/src/store/initial-state.js index 0c4aa05927..580660957c 100644 --- a/src/store/initial-state.js +++ b/src/store/initial-state.js @@ -4,6 +4,7 @@ import normalizeData from './normalize-data'; import loremIpsum from '../utils/data/lorem-ipsum.mock'; import animals from '../utils/data/animals.mock'; import demo from '../utils/data/demo.mock'; +import layers from '../utils/data/layers.mock'; /** * Determine where data should be loaded from (i.e. async from JSON, @@ -25,6 +26,9 @@ export const getPipelineData = data => { case 'demo': // Use data from the 'demo' test dataset return demo; + case 'layers': + // Use data from the 'layers' test dataset + return layers; case 'json': // Return empty state, as data will be loaded asynchronously later return null; @@ -50,6 +54,7 @@ export const getInitialPipelineState = () => ({ type: {}, isParam: {}, tags: {}, + layer: {}, disabled: {}, clicked: null, hovered: null @@ -68,6 +73,10 @@ export const getInitialPipelineState = () => ({ sources: {}, targets: {} }, + layer: { + ids: [], + name: {} + }, tag: { ids: [], name: {}, @@ -91,11 +100,14 @@ const getInitialState = (props = {}) => { const visible = Object.assign( { - exportBtn: true, labelBtn: true, - themeBtn: true, - sidebar: true + layerBtn: true, + layers: Boolean(pipelineData.layer.ids.length), + exportBtn: true, + sidebar: true, + themeBtn: true }, + localStorageState.visible, props.visible ); diff --git a/src/store/initial-state.test.js b/src/store/initial-state.test.js index a4313368e1..8709dad9ae 100644 --- a/src/store/initial-state.test.js +++ b/src/store/initial-state.test.js @@ -32,7 +32,12 @@ describe('getInitialState', () => { chartSize: {}, textLabels: true, theme: 'dark', - visible: { exportBtn: true, labelBtn: true, themeBtn: true } + visible: { + exportBtn: true, + labelBtn: true, + layerBtn: true, + themeBtn: true + } }); }); diff --git a/src/store/normalize-data.js b/src/store/normalize-data.js index 909ea26d50..c86a56b409 100644 --- a/src/store/normalize-data.js +++ b/src/store/normalize-data.js @@ -33,6 +33,7 @@ const addNode = state => node => { state.node.name[id] = node.name; state.node.fullName[id] = node.full_name || node.name; state.node.type[id] = node.type; + state.node.layer[id] = node.layer; state.node.isParam[id] = node.type === 'parameters'; state.node.tags[id] = node.tags || []; }; @@ -54,7 +55,7 @@ const addEdge = state => ({ source, target }) => { /** * Add a new Tag if it doesn't already exist - * @param {string} name - Default node name + * @param {Object} tag - Tag object */ const addTag = state => tag => { const { id } = tag; @@ -62,6 +63,16 @@ const addTag = state => tag => { state.tag.name[id] = tag.name; }; +/** + * Add a new Layer if it doesn't already exist + * @param {Object} layer - Layer object + */ +const addLayer = state => layer => { + const { id, name } = layer; + state.layer.ids.push(id); + state.layer.name[id] = name; +}; + /** * Convert the pipeline data into a normalised state object * @param {Object} data Raw unformatted data input @@ -76,7 +87,12 @@ const formatData = data => { } data.nodes.forEach(addNode(state)); data.edges.forEach(addEdge(state)); - data.tags.forEach(addTag(state)); + if (data.tags) { + data.tags.forEach(addTag(state)); + } + if (data.layers) { + data.layers.forEach(addLayer(state)); + } } return state; diff --git a/src/utils/data/animals.mock.js b/src/utils/data/animals.mock.js index 0d3cbb43fd..68804a560e 100644 --- a/src/utils/data/animals.mock.js +++ b/src/utils/data/animals.mock.js @@ -5,6 +5,10 @@ export default { id: 'small', name: 'small' }, + { + id: 'medium', + name: 'medium' + }, { id: 'huge', name: 'huge' @@ -15,105 +19,105 @@ export default { id: 'task/salmon', name: 'salmon', full_name: 'salmon', - tags: ['huge', 'small'], + tags: ['small'], type: 'task' }, { id: 'task/shark', name: 'shark', full_name: 'shark', - tags: [], + tags: ['medium', 'huge'], type: 'task' }, { id: 'task/trout', name: 'trout', full_name: 'trout', - tags: [], + tags: ['small'], type: 'task' }, { id: 'data/whale', name: 'whale', full_name: 'whale', - tags: [], + tags: ['huge'], type: 'data' }, { id: 'data/dog', name: 'dog', full_name: 'dog', - tags: ['small', 'huge'], + tags: ['small', 'medium'], type: 'data' }, { id: 'data/cat', name: 'cat', full_name: 'cat', - tags: ['small', 'huge'], + tags: ['small', 'medium', 'huge'], type: 'data' }, { id: 'data/parameters_rabbit', name: 'parameters_rabbit', full_name: 'parameters_rabbit', - tags: ['small', 'huge'], + tags: ['small'], type: 'parameters' }, { id: 'data/parameters', name: 'parameters', full_name: 'parameters', - tags: ['small', 'huge'], + tags: [], type: 'parameters' }, { id: 'data/sheep', name: 'sheep', full_name: 'sheep', - tags: ['small', 'huge'], + tags: ['medium'], type: 'data' }, { id: 'data/horse', name: 'horse', full_name: 'horse', - tags: ['small', 'huge'], + tags: ['huge'], type: 'data' }, { id: 'data/weasel', name: 'weasel', full_name: 'weasel', - tags: [], + tags: ['small'], type: 'data' }, { id: 'data/elephant', name: 'elephant', full_name: 'elephant', - tags: [], + tags: ['huge'], type: 'data' }, { id: 'data/bear', name: 'bear', full_name: 'bear', - tags: [], + tags: ['huge'], type: 'data' }, { id: 'data/giraffe', name: 'giraffe', full_name: 'giraffe', - tags: [], + tags: ['huge'], type: 'data' }, { id: 'data/pig', name: 'pig', full_name: 'pig', - tags: ['small', 'huge'], + tags: ['medium'], type: 'data' } ], diff --git a/src/utils/data/layers.mock.js b/src/utils/data/layers.mock.js new file mode 100644 index 0000000000..ab66c4fc71 --- /dev/null +++ b/src/utils/data/layers.mock.js @@ -0,0 +1,691 @@ +export default { + layers: [ + { + id: 0, + name: 'Raw' + }, + { + id: 1, + name: 'Intermediate' + }, + { + id: 2, + name: 'Primary' + }, + { + id: 3, + name: 'Feature' + }, + { + id: 4, + name: 'Model Input' + }, + { + id: 5, + name: 'Model Output' + } + ], + nodes: [ + { + id: 'task/sed_viverra', + name: 'sed viverra', + full_name: 'sed viverra', + type: 'task', + layer: 1, + tags: ['intermediate', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'task/neque_sit_ac_elit_neque', + name: 'neque sit ac elit neque', + full_name: 'neque sit ac elit neque', + type: 'task', + layer: 4, + tags: [ + 'model-input', + 'adipiscing_dolor', + 'pellentesque_ipsum_dolor_fermentum_pellentesque' + ] + }, + { + id: 'task/finibus_amet_rhoncus_consectetur_vitae_libero_nulla', + name: 'finibus amet rhoncus consectetur vitae libero nulla', + full_name: 'finibus amet rhoncus consectetur vitae libero nulla', + type: 'task', + layer: 0, + tags: ['raw', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'task/vestibulum_consectetur_id', + name: 'vestibulum consectetur id', + full_name: 'vestibulum consectetur id', + type: 'task', + layer: 3, + tags: ['feature', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'task/vitae', + name: 'vitae', + full_name: 'vitae', + type: 'task', + layer: 2, + tags: ['primary', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'task/nulla_consequat_dignissim_elit_adipiscing_ac', + name: 'nulla consequat dignissim elit adipiscing ac', + full_name: 'nulla consequat dignissim elit adipiscing ac', + type: 'task', + layer: 2, + tags: ['primary', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'task/consequat', + name: 'consequat', + full_name: 'consequat', + type: 'task', + layer: 2, + tags: [ + 'primary', + 'pellentesque_ipsum_dolor_fermentum_pellentesque', + 'adipiscing_dolor' + ] + }, + { + id: + 'task/finibus_neque_sit_fermentum_adipiscing_dignissim_viverra_pellentesque_quisque_ipsum', + name: + 'finibus neque sit fermentum adipiscing dignissim viverra pellentesque quisque ipsum', + full_name: + 'finibus neque sit fermentum adipiscing dignissim viverra pellentesque quisque ipsum', + type: 'task', + layer: 1, + tags: [ + 'intermediate', + 'pellentesque_ipsum_dolor_fermentum_pellentesque', + 'adipiscing_dolor' + ] + }, + { + id: 'task/pellentesque_amet_adipiscing_ac_libero_id_consectetur', + name: 'pellentesque amet adipiscing ac libero id consectetur', + full_name: 'pellentesque amet adipiscing ac libero id consectetur', + type: 'task', + layer: 2, + tags: ['primary', 'adipiscing_dolor'] + }, + { + id: 'task/sit_pellentesque_amet_lorem', + name: 'sit pellentesque amet lorem', + full_name: 'sit pellentesque amet lorem', + type: 'task', + layer: 1, + tags: [ + 'intermediate', + 'pellentesque_ipsum_dolor_fermentum_pellentesque', + 'adipiscing_dolor' + ] + }, + { + id: 'data/diam_nulla_finibus_dignissim_viverra_viverra', + name: 'diam nulla finibus dignissim viverra viverra', + full_name: 'diam nulla finibus dignissim viverra viverra', + type: 'data', + layer: 0, + tags: ['raw', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/neque_amet_turpis_rhoncus_dolor_nunc_sit', + name: 'neque amet turpis rhoncus dolor nunc sit', + full_name: 'neque amet turpis rhoncus dolor nunc sit', + type: 'data', + layer: 4, + tags: ['model-input', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/elit_adipiscing_fermentum_nunc_amet_consectetur_adipiscing', + name: 'elit adipiscing fermentum nunc amet consectetur adipiscing', + full_name: 'elit adipiscing fermentum nunc amet consectetur adipiscing', + type: 'data', + layer: 0, + tags: [ + 'raw', + 'adipiscing_dolor', + 'pellentesque_ipsum_dolor_fermentum_pellentesque' + ] + }, + { + id: 'data/parameters_convallis_amet_fermentum_sit_nulla_id_ac_diam', + name: 'parameters convallis amet fermentum sit nulla id ac diam', + full_name: 'parameters convallis amet fermentum sit nulla id ac diam', + type: 'parameters', + layer: 1, + tags: ['intermediate', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/condimentum_viverra_rhoncus_sit_amet_neque_diam_consequat', + name: 'condimentum viverra rhoncus sit amet neque diam consequat', + full_name: 'condimentum viverra rhoncus sit amet neque diam consequat', + type: 'data', + layer: 1, + tags: ['intermediate', 'adipiscing_dolor'] + }, + { + id: 'data/parameters_libero', + name: 'parameters libero', + full_name: 'parameters libero', + type: 'parameters', + layer: 4, + tags: ['model-input', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/amet_fermentum_fermentum_amet_sed', + name: 'amet fermentum fermentum amet sed', + full_name: 'amet fermentum fermentum amet sed', + type: 'data', + layer: 0, + tags: [ + 'raw', + 'adipiscing_dolor', + 'pellentesque_ipsum_dolor_fermentum_pellentesque' + ] + }, + { + id: 'data/parameters_nulla_rhoncus', + name: 'parameters nulla rhoncus', + full_name: 'parameters nulla rhoncus', + type: 'parameters', + layer: 0, + tags: [ + 'raw', + 'adipiscing_dolor', + 'pellentesque_ipsum_dolor_fermentum_pellentesque' + ] + }, + { + id: 'data/diam', + name: 'diam', + full_name: 'diam', + type: 'data', + layer: 3, + tags: [ + 'feature', + 'adipiscing_dolor', + 'pellentesque_ipsum_dolor_fermentum_pellentesque' + ] + }, + { + id: 'data/consectetur_libero_sit_diam_vestibulum_vitae', + name: 'consectetur libero sit diam vestibulum vitae', + full_name: 'consectetur libero sit diam vestibulum vitae', + type: 'data', + layer: 2, + tags: ['primary', 'adipiscing_dolor'] + }, + { + id: + 'data/amet_rhoncus_convallis_libero_fermentum_dignissim_amet_elit_rhoncus', + name: + 'amet rhoncus convallis libero fermentum dignissim amet elit rhoncus', + full_name: + 'amet rhoncus convallis libero fermentum dignissim amet elit rhoncus', + type: 'data', + layer: 3, + tags: ['feature', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/neque_diam_convallis_amet_consequat', + name: 'neque diam convallis amet consequat', + full_name: 'neque diam convallis amet consequat', + type: 'data', + layer: 0, + tags: [ + 'raw', + 'adipiscing_dolor', + 'pellentesque_ipsum_dolor_fermentum_pellentesque' + ] + }, + { + id: 'data/vestibulum_diam_nunc', + name: 'vestibulum diam nunc', + full_name: 'vestibulum diam nunc', + type: 'data', + layer: 1, + tags: ['intermediate', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/nulla_adipiscing_ac_elit_lorem_finibus', + name: 'nulla adipiscing ac elit lorem finibus', + full_name: 'nulla adipiscing ac elit lorem finibus', + type: 'data', + layer: 0, + tags: ['raw', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: + 'data/parameters_viverra_rhoncus_rhoncus_condimentum_elit_fermentum_turpis_amet_quisque_sit', + name: + 'parameters viverra rhoncus rhoncus condimentum elit fermentum turpis amet quisque sit', + full_name: + 'parameters viverra rhoncus rhoncus condimentum elit fermentum turpis amet quisque sit', + type: 'parameters', + layer: 0, + tags: [ + 'raw', + 'adipiscing_dolor', + 'pellentesque_ipsum_dolor_fermentum_pellentesque' + ] + }, + { + id: 'data/amet', + name: 'amet', + full_name: 'amet', + type: 'data', + layer: 1, + tags: [ + 'intermediate', + 'adipiscing_dolor', + 'pellentesque_ipsum_dolor_fermentum_pellentesque' + ] + }, + { + id: 'data/sed_condimentum_diam_diam', + name: 'sed condimentum diam diam', + full_name: 'sed condimentum diam diam', + type: 'data', + layer: 4, + tags: ['model-input', 'adipiscing_dolor'] + }, + { + id: + 'data/amet_pellentesque_dolor_consequat_elit_convallis_fermentum_vitae_diam', + name: + 'amet pellentesque dolor consequat elit convallis fermentum vitae diam', + full_name: + 'amet pellentesque dolor consequat elit convallis fermentum vitae diam', + type: 'data', + layer: 0, + tags: ['raw', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/quisque_fermentum_fermentum_diam_libero_nulla', + name: 'quisque fermentum fermentum diam libero nulla', + full_name: 'quisque fermentum fermentum diam libero nulla', + type: 'data', + layer: 2, + tags: [ + 'primary', + 'pellentesque_ipsum_dolor_fermentum_pellentesque', + 'adipiscing_dolor' + ] + }, + { + id: 'data/libero_sit_libero_dignissim_consequat_vestibulum_neque', + name: 'libero sit libero dignissim consequat vestibulum neque', + full_name: 'libero sit libero dignissim consequat vestibulum neque', + type: 'data', + layer: 3, + tags: ['feature', 'adipiscing_dolor'] + }, + { + id: 'data/nunc_turpis', + name: 'nunc turpis', + full_name: 'nunc turpis', + type: 'data', + layer: 0, + tags: ['raw', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/amet_nunc_libero_nulla_sit', + name: 'amet nunc libero nulla sit', + full_name: 'amet nunc libero nulla sit', + type: 'data', + layer: 5, + tags: ['model-output', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: + 'data/pellentesque_elit_neque_sed_pellentesque_condimentum_condimentum', + name: 'pellentesque elit neque sed pellentesque condimentum condimentum', + full_name: + 'pellentesque elit neque sed pellentesque condimentum condimentum', + type: 'data', + layer: 1, + tags: ['intermediate', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/consequat_rhoncus_ac_ipsum_lorem_neque_vestibulum', + name: 'consequat rhoncus ac ipsum lorem neque vestibulum', + full_name: 'consequat rhoncus ac ipsum lorem neque vestibulum', + type: 'data', + layer: 1, + tags: ['intermediate', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: + 'data/vitae_libero_libero_lorem_elit_adipiscing_fermentum_fermentum_vestibulum_id', + name: + 'vitae libero libero lorem elit adipiscing fermentum fermentum vestibulum id', + full_name: + 'vitae libero libero lorem elit adipiscing fermentum fermentum vestibulum id', + type: 'data', + layer: 2, + tags: ['primary', 'pellentesque_ipsum_dolor_fermentum_pellentesque'] + }, + { + id: 'data/convallis_convallis', + name: 'convallis convallis', + full_name: 'convallis convallis', + type: 'data', + layer: 2, + tags: [ + 'primary', + 'adipiscing_dolor', + 'pellentesque_ipsum_dolor_fermentum_pellentesque' + ] + }, + { + id: 'data/neque', + name: 'neque', + full_name: 'neque', + type: 'data', + layer: 1, + tags: [ + 'intermediate', + 'pellentesque_ipsum_dolor_fermentum_pellentesque', + 'adipiscing_dolor' + ] + }, + { + id: 'data/convallis', + name: 'convallis', + full_name: 'convallis', + type: 'data', + layer: 1, + tags: [ + 'intermediate', + 'pellentesque_ipsum_dolor_fermentum_pellentesque', + 'adipiscing_dolor' + ] + } + ], + edges: [ + { source: 'task/sed_viverra', target: 'data/parameters_libero' }, + { + source: 'task/sed_viverra', + target: 'data/libero_sit_libero_dignissim_consequat_vestibulum_neque' + }, + { source: 'task/sed_viverra', target: 'data/amet_nunc_libero_nulla_sit' }, + { source: 'task/sed_viverra', target: 'data/convallis_convallis' }, + { + source: + 'data/amet_pellentesque_dolor_consequat_elit_convallis_fermentum_vitae_diam', + target: 'task/sed_viverra' + }, + { + source: 'data/diam_nulla_finibus_dignissim_viverra_viverra', + target: 'task/sed_viverra' + }, + { source: 'data/nunc_turpis', target: 'task/sed_viverra' }, + { source: 'data/parameters_nulla_rhoncus', target: 'task/sed_viverra' }, + { + source: 'data/consequat_rhoncus_ac_ipsum_lorem_neque_vestibulum', + target: 'task/neque_sit_ac_elit_neque' + }, + { source: 'data/convallis', target: 'task/neque_sit_ac_elit_neque' }, + { + source: 'data/amet_fermentum_fermentum_amet_sed', + target: 'task/neque_sit_ac_elit_neque' + }, + { + source: 'data/parameters_nulla_rhoncus', + target: 'task/neque_sit_ac_elit_neque' + }, + { + source: 'task/finibus_amet_rhoncus_consectetur_vitae_libero_nulla', + target: 'data/sed_condimentum_diam_diam' + }, + { + source: 'task/finibus_amet_rhoncus_consectetur_vitae_libero_nulla', + target: 'data/consectetur_libero_sit_diam_vestibulum_vitae' + }, + { + source: 'task/finibus_amet_rhoncus_consectetur_vitae_libero_nulla', + target: 'data/amet_nunc_libero_nulla_sit' + }, + { + source: 'task/finibus_amet_rhoncus_consectetur_vitae_libero_nulla', + target: 'data/quisque_fermentum_fermentum_diam_libero_nulla' + }, + { + source: 'data/parameters_nulla_rhoncus', + target: 'task/finibus_amet_rhoncus_consectetur_vitae_libero_nulla' + }, + { + source: 'data/elit_adipiscing_fermentum_nunc_amet_consectetur_adipiscing', + target: 'task/finibus_amet_rhoncus_consectetur_vitae_libero_nulla' + }, + { + source: 'data/nulla_adipiscing_ac_elit_lorem_finibus', + target: 'task/finibus_amet_rhoncus_consectetur_vitae_libero_nulla' + }, + { + source: 'task/vestibulum_consectetur_id', + target: 'data/amet_nunc_libero_nulla_sit' + }, + { + source: 'task/vestibulum_consectetur_id', + target: 'data/parameters_libero' + }, + { source: 'data/neque', target: 'task/vestibulum_consectetur_id' }, + { + source: 'data/nulla_adipiscing_ac_elit_lorem_finibus', + target: 'task/vestibulum_consectetur_id' + }, + { + source: 'data/amet_fermentum_fermentum_amet_sed', + target: 'task/vestibulum_consectetur_id' + }, + { + source: 'data/vestibulum_diam_nunc', + target: 'task/vestibulum_consectetur_id' + }, + { + source: 'task/vitae', + target: + 'data/amet_rhoncus_convallis_libero_fermentum_dignissim_amet_elit_rhoncus' + }, + { + source: 'task/vitae', + target: 'data/consectetur_libero_sit_diam_vestibulum_vitae' + }, + { + source: 'task/vitae', + target: 'data/libero_sit_libero_dignissim_consequat_vestibulum_neque' + }, + { + source: 'task/vitae', + target: + 'data/vitae_libero_libero_lorem_elit_adipiscing_fermentum_fermentum_vestibulum_id' + }, + { + source: 'data/neque_diam_convallis_amet_consequat', + target: 'task/vitae' + }, + { + source: 'data/nulla_adipiscing_ac_elit_lorem_finibus', + target: 'task/vitae' + }, + { + source: 'data/parameters_convallis_amet_fermentum_sit_nulla_id_ac_diam', + target: 'task/vitae' + }, + { + source: 'task/nulla_consequat_dignissim_elit_adipiscing_ac', + target: 'data/libero_sit_libero_dignissim_consequat_vestibulum_neque' + }, + { + source: 'task/nulla_consequat_dignissim_elit_adipiscing_ac', + target: + 'data/amet_rhoncus_convallis_libero_fermentum_dignissim_amet_elit_rhoncus' + }, + { + source: 'task/nulla_consequat_dignissim_elit_adipiscing_ac', + target: 'data/neque_amet_turpis_rhoncus_dolor_nunc_sit' + }, + { + source: 'task/nulla_consequat_dignissim_elit_adipiscing_ac', + target: 'data/amet_nunc_libero_nulla_sit' + }, + { + source: 'data/neque_diam_convallis_amet_consequat', + target: 'task/nulla_consequat_dignissim_elit_adipiscing_ac' + }, + { + source: + 'data/parameters_viverra_rhoncus_rhoncus_condimentum_elit_fermentum_turpis_amet_quisque_sit', + target: 'task/nulla_consequat_dignissim_elit_adipiscing_ac' + }, + { + source: 'data/vestibulum_diam_nunc', + target: 'task/nulla_consequat_dignissim_elit_adipiscing_ac' + }, + { + source: 'data/diam_nulla_finibus_dignissim_viverra_viverra', + target: 'task/nulla_consequat_dignissim_elit_adipiscing_ac' + }, + { source: 'task/consequat', target: 'data/diam' }, + { source: 'task/consequat', target: 'data/convallis_convallis' }, + { + source: 'task/consequat', + target: 'data/libero_sit_libero_dignissim_consequat_vestibulum_neque' + }, + { + source: 'task/consequat', + target: + 'data/amet_rhoncus_convallis_libero_fermentum_dignissim_amet_elit_rhoncus' + }, + { + source: 'data/elit_adipiscing_fermentum_nunc_amet_consectetur_adipiscing', + target: 'task/consequat' + }, + { source: 'data/convallis', target: 'task/consequat' }, + { source: 'data/neque', target: 'task/consequat' }, + { + source: 'data/amet_fermentum_fermentum_amet_sed', + target: 'task/consequat' + }, + { + source: + 'task/finibus_neque_sit_fermentum_adipiscing_dignissim_viverra_pellentesque_quisque_ipsum', + target: 'data/quisque_fermentum_fermentum_diam_libero_nulla' + }, + { + source: + 'task/finibus_neque_sit_fermentum_adipiscing_dignissim_viverra_pellentesque_quisque_ipsum', + target: 'data/sed_condimentum_diam_diam' + }, + { + source: + 'task/finibus_neque_sit_fermentum_adipiscing_dignissim_viverra_pellentesque_quisque_ipsum', + target: 'data/neque_amet_turpis_rhoncus_dolor_nunc_sit' + }, + { + source: + 'task/finibus_neque_sit_fermentum_adipiscing_dignissim_viverra_pellentesque_quisque_ipsum', + target: 'data/amet' + }, + { + source: 'data/elit_adipiscing_fermentum_nunc_amet_consectetur_adipiscing', + target: + 'task/finibus_neque_sit_fermentum_adipiscing_dignissim_viverra_pellentesque_quisque_ipsum' + }, + { + source: + 'data/amet_pellentesque_dolor_consequat_elit_convallis_fermentum_vitae_diam', + target: + 'task/finibus_neque_sit_fermentum_adipiscing_dignissim_viverra_pellentesque_quisque_ipsum' + }, + { + source: + 'data/parameters_viverra_rhoncus_rhoncus_condimentum_elit_fermentum_turpis_amet_quisque_sit', + target: + 'task/finibus_neque_sit_fermentum_adipiscing_dignissim_viverra_pellentesque_quisque_ipsum' + }, + { + source: 'data/parameters_nulla_rhoncus', + target: + 'task/finibus_neque_sit_fermentum_adipiscing_dignissim_viverra_pellentesque_quisque_ipsum' + }, + { + source: 'task/pellentesque_amet_adipiscing_ac_libero_id_consectetur', + target: 'data/libero_sit_libero_dignissim_consequat_vestibulum_neque' + }, + { + source: 'task/pellentesque_amet_adipiscing_ac_libero_id_consectetur', + target: + 'data/amet_rhoncus_convallis_libero_fermentum_dignissim_amet_elit_rhoncus' + }, + { + source: 'data/condimentum_viverra_rhoncus_sit_amet_neque_diam_consequat', + target: 'task/pellentesque_amet_adipiscing_ac_libero_id_consectetur' + }, + { + source: + 'data/pellentesque_elit_neque_sed_pellentesque_condimentum_condimentum', + target: 'task/pellentesque_amet_adipiscing_ac_libero_id_consectetur' + }, + { + source: 'data/quisque_fermentum_fermentum_diam_libero_nulla', + target: 'task/pellentesque_amet_adipiscing_ac_libero_id_consectetur' + }, + { + source: 'data/consequat_rhoncus_ac_ipsum_lorem_neque_vestibulum', + target: 'task/pellentesque_amet_adipiscing_ac_libero_id_consectetur' + }, + { + source: 'task/sit_pellentesque_amet_lorem', + target: + 'data/amet_rhoncus_convallis_libero_fermentum_dignissim_amet_elit_rhoncus' + }, + { + source: 'task/sit_pellentesque_amet_lorem', + target: + 'data/vitae_libero_libero_lorem_elit_adipiscing_fermentum_fermentum_vestibulum_id' + }, + { + source: 'task/sit_pellentesque_amet_lorem', + target: 'data/convallis_convallis' + }, + { + source: 'task/sit_pellentesque_amet_lorem', + target: 'data/neque_amet_turpis_rhoncus_dolor_nunc_sit' + }, + { + source: + 'data/parameters_viverra_rhoncus_rhoncus_condimentum_elit_fermentum_turpis_amet_quisque_sit', + target: 'task/sit_pellentesque_amet_lorem' + }, + { + source: 'data/elit_adipiscing_fermentum_nunc_amet_consectetur_adipiscing', + target: 'task/sit_pellentesque_amet_lorem' + }, + { + source: + 'data/pellentesque_elit_neque_sed_pellentesque_condimentum_condimentum', + target: 'task/sit_pellentesque_amet_lorem' + } + ], + tags: [ + { + name: 'pellentesque_ipsum_dolor_fermentum_pellentesque', + id: 'pellentesque_ipsum_dolor_fermentum_pellentesque' + }, + { name: 'adipiscing_dolor', id: 'adipiscing_dolor' }, + { name: 'Raw', id: 'raw' }, + { name: 'Intermediate', id: 'intermediate' }, + { name: 'Primary', id: 'primary' }, + { name: 'Feature', id: 'feature' }, + { name: 'Model Input', id: 'model-input' }, + { name: 'Model Output', id: 'model-output' } + ] +}; diff --git a/src/utils/index.js b/src/utils/index.js index 3bc138bca0..02259b60cb 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -47,7 +47,7 @@ export const randomNumber = n => Math.ceil(Math.random() * n); */ export const getRandom = range => range[randomIndex(range.length)]; -const LOREM_IPSUM = 'lorem ipsum dolor sit amet consectetur adipiscing elit vestibulum id turpis nunc nulla vitae diam dignissim fermentum elit sit amet viverra libero quisque condimentum pellentesque convallis sed consequat neque ac rhoncus finibus'.split( +export const LOREM_IPSUM = 'lorem ipsum dolor sit amet consectetur adipiscing elit vestibulum id turpis nunc nulla vitae diam dignissim fermentum elit sit amet viverra libero quisque condimentum pellentesque convallis sed consequat neque ac rhoncus finibus'.split( ' ' ); diff --git a/src/utils/random-data.js b/src/utils/random-data.js index d13db0d005..3fc930ae06 100644 --- a/src/utils/random-data.js +++ b/src/utils/random-data.js @@ -12,6 +12,7 @@ import { const DATA_NODE_COUNT = 30; const MAX_CONNECTED_NODES = 4; const MAX_LAYER_COUNT = 20; +const MIN_LAYER_COUNT = 5; const MAX_NODE_TAG_COUNT = 5; const MAX_TAG_COUNT = 20; const PARAMETERS_FREQUENCY = 0.05; @@ -23,7 +24,8 @@ const TASK_NODE_COUNT = 10; class Pipeline { constructor() { this.CONNECTION_COUNT = randomNumber(MAX_CONNECTED_NODES); - this.LAYER_COUNT = randomNumber(MAX_LAYER_COUNT); + this.LAYER_COUNT = + randomNumber(MAX_LAYER_COUNT - MIN_LAYER_COUNT) + MIN_LAYER_COUNT; this.TAG_COUNT = randomNumber(MAX_TAG_COUNT); this.nodes = this.getNodes(); this.tags = this.generateTags(); @@ -43,11 +45,10 @@ class Pipeline { /** * Generate a list of nodes * @param {number} count The number of nodes to generate - * @param {Function} getLayer A callback to create a random layer number * @param {number} paramFreq How often nodes should include 'parameters' in their name * @param {string} type */ - generateNodeList(count, getLayer, paramFreq, type) { + generateNodeList(count, paramFreq, type) { return getNumberArray(count) .map(() => this.getRandomNodeName(paramFreq)) .filter(unique) @@ -58,7 +59,7 @@ class Pipeline { name, full_name: `${name} (${name})`, type: id.includes('param') ? 'parameters' : type, - layer: getLayer() + layer: this.getLayer(type) }; }); } @@ -70,19 +71,18 @@ class Pipeline { return { data: this.generateNodeList( DATA_NODE_COUNT, - () => randomIndex(this.LAYER_COUNT + 1), PARAMETERS_FREQUENCY, 'data' ), - task: this.generateNodeList( - TASK_NODE_COUNT, - () => randomIndex(this.LAYER_COUNT) + 0.5, - 0, - 'task' - ) + task: this.generateNodeList(TASK_NODE_COUNT, 0, 'task') }; } + getLayer(type) { + const increment = { data: 1, task: 0.5 }; + return this.LAYER_COUNT - randomIndex(this.LAYER_COUNT + increment[type]); + } + /** * Generate a random list of tags */ @@ -123,13 +123,13 @@ class Pipeline { const edges = []; this.nodes.task.forEach(node => { - this.getConnectedNodes(d => d.layer < node.layer).forEach(target => { + this.getConnectedNodes(d => d.layer > node.layer).forEach(target => { edges.push({ source: node.id, target }); }); - this.getConnectedNodes(d => d.layer > node.layer).forEach(source => { + this.getConnectedNodes(d => d.layer < node.layer).forEach(source => { edges.push({ source, target: node.id diff --git a/src/utils/state.mock.js b/src/utils/state.mock.js index 1d11e76980..26fbc3d464 100644 --- a/src/utils/state.mock.js +++ b/src/utils/state.mock.js @@ -8,6 +8,7 @@ import getInitialState from '../store/initial-state'; * Example state objects for use in tests of redux-enabled components */ export const mockState = { + layers: getInitialState({ data: 'layers' }), lorem: getInitialState({ data: 'lorem' }), animals: getInitialState({ data: 'animals' }) };