diff --git a/src/actions/index.js b/src/actions/index.js index c5698fad02..52817fc6ed 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -24,6 +24,19 @@ export function toggleLayers(visible) { }; } +export const TOGGLE_ORIENTATION = 'TOGGLE_ORIENTATION'; + +/** + * Toggle whether to show horizontal or vertical orientation + * @param {string} orientation The orientation to set to vertical by default + */ +export function toggleOrientation(orientation) { + return { + type: TOGGLE_ORIENTATION, + orientation, + }; +} + export const TOGGLE_EXPAND_ALL_PIPELINES = 'TOGGLE_EXPAND_ALL_PIPELINES'; /** diff --git a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js index e456e03933..8d787a9808 100644 --- a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js +++ b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js @@ -6,6 +6,7 @@ import { toggleSidebar, toggleTextLabels, toggleExpandAllPipelines, + toggleOrientation, } from '../../actions'; import { toggleModularPipelinesVisibilityState } from '../../actions/modular-pipelines'; import IconButton from '../ui/icon-button'; @@ -35,6 +36,8 @@ export const FlowchartPrimaryToolbar = ({ visibleLayers, expandedPipelines, onToggleExpandAllPipelines, + orientation, + onToggleOrientation, }) => { const { toSetQueryParam } = useGeneratePathname(); @@ -97,6 +100,19 @@ export const FlowchartPrimaryToolbar = ({ onClick={() => onToggleExportModal(true)} visible={display.exportBtn} /> + + onToggleOrientation( + orientation === 'vertical' ? 'horizontal' : 'vertical' + ) + } + visible={display.orientationBtn} + /> ); @@ -108,6 +124,7 @@ export const mapStateToProps = (state) => ({ visible: state.visible, display: state.display, visibleLayers: Boolean(getVisibleLayerIDs(state).length), + orientation: state.orientation, expandedPipelines: state.expandAllPipelines, }); @@ -128,6 +145,9 @@ export const mapDispatchToProps = (dispatch) => ({ dispatch(toggleExpandAllPipelines(isExpanded)); dispatch(toggleModularPipelinesVisibilityState(isExpanded)); }, + onToggleOrientation: (value) => { + dispatch(toggleOrientation(value)); + }, }); export default connect( diff --git a/src/reducers/index.js b/src/reducers/index.js index 79af193f24..33f176a8da 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -21,6 +21,7 @@ import { TOGGLE_IS_PRETTY_NAME, TOGGLE_TEXT_LABELS, TOGGLE_THEME, + TOGGLE_ORIENTATION, UPDATE_CHART_SIZE, UPDATE_ZOOM, TOGGLE_EXPAND_ALL_PIPELINES, @@ -96,6 +97,7 @@ const combinedReducer = combineReducers({ zoom: createReducer({}, UPDATE_ZOOM, 'zoom'), textLabels: createReducer(true, TOGGLE_TEXT_LABELS, 'textLabels'), theme: createReducer('dark', TOGGLE_THEME, 'theme'), + orientation: createReducer('vertical', TOGGLE_ORIENTATION, 'orientation'), isPrettyName: createReducer(false, TOGGLE_IS_PRETTY_NAME, 'isPrettyName'), showFeatureHints: createReducer( true, diff --git a/src/selectors/layers.js b/src/selectors/layers.js index f11c3f996a..b56d7322bf 100644 --- a/src/selectors/layers.js +++ b/src/selectors/layers.js @@ -3,13 +3,14 @@ import { getVisibleLayerIDs } from './disabled'; const getGraph = (state) => state.graph; const getLayerName = (state) => state.layer.name; +const getFlowChartOrientation = (state) => state.orientation; /** * Get layer positions */ export const getLayers = createSelector( - [getGraph, getVisibleLayerIDs, getLayerName], - ({ nodes, size }, layerIDs, layerName) => { + [getGraph, getVisibleLayerIDs, getLayerName, getFlowChartOrientation], + ({ nodes, size }, layerIDs, layerName, orientation) => { if (!nodes || !size || !nodes.length || !layerIDs.length) { return []; } @@ -23,12 +24,22 @@ export const getLayers = createSelector( if (layer) { const bound = bounds[layer] || (bounds[layer] = [Infinity, -Infinity]); - if (node.y - node.height < bound[0]) { - bound[0] = node.y - node.height; - } + if (orientation === 'vertical') { + if (node.x - node.width < bound[0]) { + bound[0] = node.x - node.width; + } + + if (node.x + node.width > bound[1]) { + bound[1] = node.x + node.width; + } + } else { + if (node.y - node.height < bound[0]) { + bound[0] = node.y - node.height; + } - if (node.y + node.height > bound[1]) { - bound[1] = node.y + node.height; + if (node.y + node.height > bound[1]) { + bound[1] = node.y + node.height; + } } } } @@ -45,16 +56,29 @@ export const getLayers = createSelector( ]; const start = (prevBound[1] + currentBound[0]) / 2; const end = (currentBound[1] + nextBound[0]) / 2; - const rectWidth = Math.max(width, height) * 5; - - return { - id, - name: layerName[id], - x: (rectWidth - width) / -2, - y: start, - width: rectWidth, - height: Math.max(end - start, 0), - }; + const rectSize = Math.max(width, height) * 5; + + if (orientation === 'vertical') { + // Vertical layout when orientation is true + return { + id, + name: layerName[id], + x: start, // Horizontal layout moves along the x-axis + y: (rectSize - height) / -2, // Centered along y-axis + width: Math.max(end - start, 0), + height: rectSize, + }; + } else { + // Horizontal layout when orientation is false + return { + id, + name: layerName[id], + y: start, // Vertical layout moves along the y-axis + x: (rectSize - width) / -2, // Centered along x-axis + height: Math.max(end - start, 0), + width: rectSize, + }; + } }); } ); diff --git a/src/selectors/layout.js b/src/selectors/layout.js index d275ac990e..67f7b9987e 100644 --- a/src/selectors/layout.js +++ b/src/selectors/layout.js @@ -19,6 +19,7 @@ const getVisibleCode = (state) => state.visible.code; const getIgnoreLargeWarning = (state) => state.ignoreLargeWarning; const getGraphHasNodes = (state) => Boolean(state.graph?.nodes?.length); const getChartSizeState = (state) => state.chartSize; +const getFlowChartOrientation = (state) => state.orientation; /** * Show the large graph warning only if there are sufficient nodes + edges, @@ -50,14 +51,15 @@ export const getGraphInput = createSelector( getVisibleNodes, getVisibleEdges, getVisibleLayerIDs, + getFlowChartOrientation, getTriggerLargeGraphWarning, ], - (nodes, edges, layers, triggerLargeGraphWarning) => { + (nodes, edges, layers, orientation, triggerLargeGraphWarning) => { if (triggerLargeGraphWarning) { return null; } - return { nodes, edges, layers }; + return { nodes, edges, layers, orientation }; } ); diff --git a/src/store/initial-state.js b/src/store/initial-state.js index 60f2423310..e46b1231db 100644 --- a/src/store/initial-state.js +++ b/src/store/initial-state.js @@ -23,6 +23,7 @@ export const createInitialState = () => ({ textLabels: true, theme: 'dark', expandAllPipelines: false, + orientation: 'vertical', isPrettyName: settings.isPrettyName.default, showFeatureHints: settings.showFeatureHints.default, showDatasetPreviews: settings.showDatasetPreviews.default, @@ -55,6 +56,7 @@ export const createInitialState = () => ({ exportBtn: true, labelBtn: true, layerBtn: true, + orientationBtn: true, zoomToolbar: true, metadataPanel: true, }, diff --git a/src/utils/graph/common.js b/src/utils/graph/common.js index ef1e0b7ba7..0573208de8 100644 --- a/src/utils/graph/common.js +++ b/src/utils/graph/common.js @@ -33,12 +33,19 @@ export const snap = (value, unit) => Math.round(value / unit) * unit; export const distance1d = (a, b) => Math.abs(a - b); /** - * Returns the angle in radians between the points a and b relative to the X-axis about the origin + * Returns the angle in radians between the points a and b based on the given orientation * @param {Object} a The first point * @param {Object} b The second point + * @param {String} orientation The layout orientation * @returns {Number} The angle */ -export const angle = (a, b) => Math.atan2(a.y - b.y, a.x - b.x); +export const angle = (a, b, orientation) => { + if (orientation === 'vertical') { + return Math.atan2(a.y - b.y, a.x - b.x); + } else { + return Math.atan2(a.x - b.x, a.y - b.y); + } +}; /** * Returns the left edge x-position of the node @@ -75,23 +82,29 @@ export const nodeBottom = (node) => node.y + node.height * 0.5; * @param {Array} nodes The input nodes * @returns {Array} The sorted rows of nodes */ -export const groupByRow = (nodes) => { +export const groupByRow = (nodes, orientation) => { const rows = {}; - // Create rows using node Y values + const primaryCoord = orientation === 'vertical' ? 'x' : 'y'; + const secondaryCoord = orientation === 'horizontal' ? 'y' : 'x'; + + // Create rows using primaryCoord values for (const node of nodes) { - rows[node.y] = rows[node.y] || []; - rows[node.y].push(node); + const key = snap(node[primaryCoord], 10); + rows[key] = rows[key] || []; + rows[key].push(node); } // Sort the set of rows accounting for keys being strings const rowNumbers = Object.keys(rows).map((row) => parseFloat(row)); rowNumbers.sort((a, b) => a - b); - // Sort rows in order of X position if set. Break ties with ids for stability + // Sort rows in order of secondaryCoord position if set. Break ties with ids for stability const sortedRows = rowNumbers.map((row) => rows[row]); for (let i = 0; i < sortedRows.length; i += 1) { - sortedRows[i].sort((a, b) => compare(a.x, b.x, a.id, b.id)); + sortedRows[i].sort((a, b) => + compare(a[secondaryCoord], b[secondaryCoord], a.id, b.id) + ); for (const node of sortedRows[i]) { node.row = i; diff --git a/src/utils/graph/constraints.js b/src/utils/graph/constraints.js index cc47d6824b..ff7422bf3c 100644 --- a/src/utils/graph/constraints.js +++ b/src/utils/graph/constraints.js @@ -12,13 +12,11 @@ import { Constraint, Operator, Strength } from 'kiwi.js'; * Layout constraint in Y for separating rows */ export const rowConstraint = { - property: 'y', - strict: (constraint, constants, variableA, variableB) => new Constraint( variableA.minus(variableB), Operator.Ge, - constants.spaceY, + constraint.separation, Strength.required ), }; @@ -27,8 +25,6 @@ export const rowConstraint = { * Layout constraint in Y for separating layers */ export const layerConstraint = { - property: 'y', - strict: (constraint, constants, variableA, variableB) => new Constraint( variableA.minus(variableB), @@ -42,13 +38,12 @@ export const layerConstraint = { * Layout constraint in X for minimising distance from source to target for straight edges */ export const parallelConstraint = { - property: 'x', - - solve: (constraint) => { + solve: (constraint, constants) => { const { a, b, strength } = constraint; - const resolve = strength * (a.x - b.x); - a.x -= resolve; - b.x += resolve; + const resolve = + strength * (a[constants.coordPrimary] - b[constants.coordPrimary]); + a[constants.coordPrimary] -= resolve; + b[constants.coordPrimary] += resolve; }, strict: (constraint, constants, variableA, variableB) => @@ -64,25 +59,29 @@ export const parallelConstraint = { * Crossing constraint in X for minimising edge crossings */ export const crossingConstraint = { - property: 'x', - - solve: (constraint) => { + solve: (constraint, constants) => { const { edgeA, edgeB, separationA, separationB, strength } = constraint; // Amount to move each node towards required separation const resolveSource = strength * - ((edgeA.sourceNode.x - edgeB.sourceNode.x - separationA) / separationA); + ((edgeA.sourceNode[constants.coordPrimary] - + edgeB.sourceNode[constants.coordPrimary] - + separationA) / + separationA); const resolveTarget = strength * - ((edgeA.targetNode.x - edgeB.targetNode.x - separationB) / separationB); + ((edgeA.targetNode[constants.coordPrimary] - + edgeB.targetNode[constants.coordPrimary] - + separationB) / + separationB); // Apply the resolve each node - edgeA.sourceNode.x -= resolveSource; - edgeB.sourceNode.x += resolveSource; - edgeA.targetNode.x -= resolveTarget; - edgeB.targetNode.x += resolveTarget; + edgeA.sourceNode[constants.coordPrimary] -= resolveSource; + edgeB.sourceNode[constants.coordPrimary] += resolveSource; + edgeA.targetNode[constants.coordPrimary] -= resolveTarget; + edgeB.targetNode[constants.coordPrimary] += resolveTarget; }, }; @@ -90,8 +89,6 @@ export const crossingConstraint = { * Layout constraint in X for minimum node separation */ export const separationConstraint = { - property: 'x', - strict: (constraint, constants, variableA, variableB) => new Constraint( variableB.minus(variableA), diff --git a/src/utils/graph/graph.js b/src/utils/graph/graph.js index 989a5b77d9..6bef0b1aa8 100644 --- a/src/utils/graph/graph.js +++ b/src/utils/graph/graph.js @@ -12,7 +12,7 @@ const defaultOptions = { layout: { spaceX: 14, spaceY: 110, - layerSpaceY: 55, + layerSpaceY: 100, spreadX: 2.2, padding: 100, iterations: 25, @@ -22,9 +22,9 @@ const defaultOptions = { spaceY: 28, minPassageGap: 40, stemUnit: 8, - stemMinSource: 5, - stemMinTarget: 5, - stemMax: 20, + stemMinSource: 0, + stemMinTarget: 15, + stemMax: 10, stemSpaceSource: 6, stemSpaceTarget: 10, }, @@ -41,12 +41,18 @@ const defaultOptions = { * @param {Object=} options The graph options * @returns {Object} The generated graph */ -export const graph = (nodes, edges, layers, options = defaultOptions) => { +export const graph = ( + nodes, + edges, + layers, + orientation, + options = defaultOptions +) => { addEdgeLinks(nodes, edges); addNearestLayers(nodes, layers); - layout({ nodes, edges, layers, ...options.layout }); - routing({ nodes, edges, layers, ...options.routing }); + layout({ nodes, edges, layers, orientation, ...options.layout }); + routing({ nodes, edges, layers, orientation, ...options.routing }); const size = bounds(nodes, options.layout.padding); nodes.forEach((node) => offsetNode(node, size.min)); diff --git a/src/utils/graph/index.js b/src/utils/graph/index.js index a76602ee70..7da9475df0 100644 --- a/src/utils/graph/index.js +++ b/src/utils/graph/index.js @@ -6,7 +6,7 @@ import { graph } from './graph'; * as possible, and keep it separate from other properties (like node.active) * which don't affect layout. */ -export const graphNew = ({ nodes, edges, layers }) => { +export const graphNew = ({ nodes, edges, layers, orientation }) => { for (const node of nodes) { node.iconSize = node.iconSize || 24; node.icon = node.icon || 'node'; @@ -25,7 +25,7 @@ export const graphNew = ({ nodes, edges, layers }) => { node.iconOffset = node.iconOffset || -innerWidth / 2; } - const result = graph(nodes, edges, layers); + const result = graph(nodes, edges, layers, orientation); return { ...result, diff --git a/src/utils/graph/layout.js b/src/utils/graph/layout.js index e962110523..fde9500cba 100644 --- a/src/utils/graph/layout.js +++ b/src/utils/graph/layout.js @@ -32,34 +32,47 @@ export const layout = ({ spreadX, layerSpaceY, iterations, + orientation, }) => { + console.log(orientation); + + let coordPrimary = 'x'; + let coordSecondary = 'y'; + + if (orientation === 'horizontal') { + coordPrimary = 'y'; + coordSecondary = 'x'; + } // Set initial positions for nodes for (const node of nodes) { - node.x = 0; - node.y = 0; + node[coordPrimary] = 0; + node[coordSecondary] = 0; } // Constants used by constraints const constants = { + orientation, spaceX, spaceY, spreadX, - layerSpace: (spaceY + layerSpaceY) * 0.5, + layerSpace: (spaceX + layerSpaceY) * 0.5, + coordPrimary, + coordSecondary, }; // Constraints to separate nodes into rows and layers - const rowConstraints = createRowConstraints(edges); - const layerConstraints = createLayerConstraints(nodes, layers); + const rowConstraints = createRowConstraints(edges, constants); + const layerConstraints = createLayerConstraints(nodes, layers, constants); // Find the node positions given these constraints solveStrict([...rowConstraints, ...layerConstraints], constants, 1); // Find the solved rows using the node positions after solving - const rows = groupByRow(nodes); + const rows = groupByRow(nodes, orientation); // Constraints to avoid edges crossing and maintain parallel vertical edges const crossingConstraints = createCrossingConstraints(edges, constants); - const parallelConstraints = createParallelConstraints(edges, constants); + const parallelConstraints = createParallelConstraints(edges, coordPrimary); // Solve these constraints iteratively for (let i = 0; i < iterations; i += 1) { @@ -74,7 +87,7 @@ export const layout = ({ solveStrict([...separationConstraints, ...parallelConstraints], constants, 1); // Adjust vertical spacing between rows for legibility - expandDenseRows(edges, rows, spaceY); + expandDenseRows(edges, rows, coordSecondary, spaceY, orientation); }; /** @@ -82,11 +95,13 @@ export const layout = ({ * @param {Array} edges The input edges * @returns {Array} The constraints */ -const createRowConstraints = (edges) => +const createRowConstraints = (edges, constants) => edges.map((edge) => ({ base: rowConstraint, + property: constants.coordSecondary, a: edge.targetNode, b: edge.sourceNode, + separation: constants.spaceY, })); /** @@ -95,7 +110,7 @@ const createRowConstraints = (edges) => * @param {Array=} layers The input layers if any * @returns {Array} The constraints */ -const createLayerConstraints = (nodes, layers) => { +const createLayerConstraints = (nodes, layers, constants) => { const layerConstraints = []; // Early out if no layers defined @@ -120,6 +135,7 @@ const createLayerConstraints = (nodes, layers) => { for (const node of layerNodes) { layerConstraints.push({ base: layerConstraint, + property: constants.coordSecondary, a: intermediary, b: node, }); @@ -129,6 +145,7 @@ const createLayerConstraints = (nodes, layers) => { for (const node of nextLayerNodes) { layerConstraints.push({ base: layerConstraint, + property: constants.coordSecondary, a: node, b: intermediary, }); @@ -146,7 +163,7 @@ const createLayerConstraints = (nodes, layers) => { * @returns {Array} The constraints */ const createCrossingConstraints = (edges, constants) => { - const { spaceX } = constants; + const { spaceX, coordPrimary } = constants; const crossingConstraints = []; // For every pair of edges @@ -179,6 +196,7 @@ const createCrossingConstraints = (edges, constants) => { crossingConstraints.push({ base: crossingConstraint, + property: coordPrimary, edgeA: edgeA, edgeB: edgeB, // The required horizontal spacing between connected nodes @@ -201,9 +219,10 @@ const createCrossingConstraints = (edges, constants) => { * @param {Array} edges The input edges * @returns {Object} An object containing the constraints */ -const createParallelConstraints = (edges) => +const createParallelConstraints = (edges, constants) => edges.map(({ sourceNode, targetNode }) => ({ base: parallelConstraint, + property: constants.coordPrimary, a: sourceNode, b: targetNode, // Evenly distribute the constraint @@ -218,7 +237,7 @@ const createParallelConstraints = (edges) => * @returns {Array} The constraints */ const createSeparationConstraints = (rows, constants) => { - const { spaceX } = constants; + const { spaceX, coordPrimary, spreadX, orientation } = constants; const separationConstraints = []; // For each row of nodes @@ -226,9 +245,11 @@ const createSeparationConstraints = (rows, constants) => { const rowNodes = rows[i]; // Stable sort row nodes horizontally, breaks ties with ids - rowNodes.sort((a, b) => compare(a.x, b.x, a.id, b.id)); + rowNodes.sort((a, b) => + compare(a[coordPrimary], b[coordPrimary], a.id, b.id) + ); - // Update constraints given updated row node order + // Update constraints given sorted row node order for (let j = 0; j < rowNodes.length - 1; j += 1) { const nodeA = rowNodes[j]; const nodeB = rowNodes[j + 1]; @@ -244,14 +265,21 @@ const createSeparationConstraints = (rows, constants) => { ); // Allow more spacing for nodes with more edges - const spread = Math.min(10, degreeA * degreeB * constants.spreadX); + const spread = Math.min(10, degreeA * degreeB * spreadX); const space = snap(spread * spaceX, spaceX); + let separation = nodeA.width * 0.5 + space + nodeB.width * 0.5; + + if (orientation === 'horizontal') { + separation = nodeA.height + nodeB.height; + } + separationConstraints.push({ base: separationConstraint, + property: coordPrimary, a: nodeA, b: nodeB, - separation: nodeA.width * 0.5 + space + nodeB.width * 0.5, + separation, }); } } @@ -268,22 +296,42 @@ const createSeparationConstraints = (rows, constants) => { * @param {Number} [scale=1.25] The amount of expansion to apply relative to row density * @param {Number} [unit=0.25] The unit size for rounding expansion relative to spaceY */ -const expandDenseRows = (edges, rows, spaceY, scale = 1.25, unit = 0.25) => { - const densities = rowDensity(edges); +const expandDenseRows = ( + edges, + rows, + spaceY, + orientation, + coordSecondary, + scale = 1.25, + unit = 0.25 +) => { + const densities = rowDensity(edges, orientation); const spaceYUnit = Math.round(spaceY * unit); - let currentOffsetY = 0; + let currentOffset = 0; // Add spacing based relative to row density for (let i = 0; i < rows.length - 1; i += 1) { const density = densities[i] || 0; // Round offset to a common unit amount to improve vertical rhythm - const offsetY = snap(density * scale * spaceY, spaceYUnit); - currentOffsetY += offsetY; + const offset = snap(density * scale * spaceY, spaceYUnit); + + if (orientation === 'horizontal') { + const maxWidthInCurrentRow = Math.max( + ...rows[i].map((node) => node.width) + ); + const maxWidthInNextRow = Math.max( + ...rows[i + 1].map((node) => node.width) + ); + currentOffset += + offset + maxWidthInCurrentRow * 0.5 + maxWidthInNextRow * 0.5; + } else { + currentOffset += offset; + } // Apply offset to all nodes following the current node for (const node of rows[i + 1]) { - node.y += currentOffsetY; + node[coordSecondary] += currentOffset; } } }; @@ -297,13 +345,14 @@ const expandDenseRows = (edges, rows, spaceY, scale = 1.25, unit = 0.25) => { * @param {Array} edges The input edges * @returns {Array} The density of each row */ -const rowDensity = (edges) => { +const rowDensity = (edges, orientation) => { const rows = {}; for (const edge of edges) { // Find the normalized angle of the edge source and target nodes, relative to the X axis const edgeAngle = - Math.abs(angle(edge.targetNode, edge.sourceNode) - HALF_PI) / HALF_PI; + Math.abs(angle(edge.targetNode, edge.sourceNode, orientation) - HALF_PI) / + HALF_PI; const sourceRow = edge.sourceNode.row; const targetRow = edge.targetNode.row - 1; diff --git a/src/utils/graph/routing.js b/src/utils/graph/routing.js index 466a357af6..96b25a92c1 100644 --- a/src/utils/graph/routing.js +++ b/src/utils/graph/routing.js @@ -40,17 +40,18 @@ export const routing = ({ stemMax, stemSpaceSource, stemSpaceTarget, + orientation, }) => { // Find the rows formed by nodes - const rows = groupByRow(nodes); + const rows = groupByRow(nodes, orientation); // For each node for (const node of nodes) { // Sort the node's target edges by the angle between source and target nodes node.targets.sort((a, b) => compare( - angle(b.sourceNode, b.targetNode), - angle(a.sourceNode, a.targetNode) + angle(b.sourceNode, b.targetNode, orientation), + angle(a.sourceNode, a.targetNode, orientation) ) ); } @@ -75,7 +76,7 @@ export const routing = ({ const sourceOffsetX = sourceSeparation * sourceEdgeDistance; // Start at source node offset - const startPoint = { x: source.x + sourceOffsetX, y: source.y }; + const startPoint = { x: source.x, y: source.y }; let currentPoint = startPoint; // For each row between the source and target rows exclusive @@ -153,15 +154,23 @@ export const routing = ({ // Sort the node's outgoing edges by the starting angle of the edge path node.targets.sort((a, b) => compare( - angle(b.sourceNode, b.points[0] || b.targetNode), - angle(a.sourceNode, a.points[0] || a.targetNode) + angle(b.sourceNode, b.points[0] || b.targetNode, orientation), + angle(a.sourceNode, a.points[0] || a.targetNode, orientation) ) ); // Sort the node's incoming edges by the ending angle of the edge path node.sources.sort((a, b) => compare( - angle(a.points[a.points.length - 1] || a.sourceNode, a.targetNode), - angle(b.points[b.points.length - 1] || b.sourceNode, b.targetNode) + angle( + a.points[a.points.length - 1] || a.sourceNode, + a.targetNode, + orientation + ), + angle( + b.points[b.points.length - 1] || b.sourceNode, + b.targetNode, + orientation + ) ) ); } @@ -171,25 +180,11 @@ export const routing = ({ const source = edge.sourceNode; const target = edge.targetNode; - // Find the ideal gap between edge source and target anchors - const sourceSeparation = Math.min( - (source.width - stemSpaceSource) / source.targets.length, - stemSpaceSource - ); - - const targetSeparation = Math.min( - (target.width - stemSpaceTarget) / target.sources.length, - stemSpaceTarget - ); - const sourceEdgeDistance = source.targets.indexOf(edge) - (source.targets.length - 1) * 0.5; const targetEdgeDistance = target.sources.indexOf(edge) - (target.sources.length - 1) * 0.5; - const sourceOffsetX = sourceSeparation * sourceEdgeDistance; - const targetOffsetX = targetSeparation * targetEdgeDistance; - // Decrease stem length outwards from the middle stem const sourceOffsetY = stemUnit * @@ -201,54 +196,79 @@ export const routing = ({ target.sources.length * (1 - Math.abs(targetEdgeDistance) / target.sources.length); + let sourceStem, targetStem; + // Build the source stem for the edge - const sourceStem = [ - { - x: source.x + sourceOffsetX, - y: nodeBottom(source), - }, - { - x: source.x + sourceOffsetX, - y: nodeBottom(source) + stemMinSource, - }, - { - x: source.x + sourceOffsetX, - y: - nodeBottom(source) + stemMinSource + Math.min(sourceOffsetY, stemMax), - }, - ]; - - // Build the target stem for the edge - const targetStem = [ - { - x: target.x + targetOffsetX, - y: nodeTop(target) - stemMinTarget - Math.min(targetOffsetY, stemMax), - }, - { - x: target.x + targetOffsetX, - y: nodeTop(target) - stemMinTarget, - }, - { - x: target.x + targetOffsetX, - y: nodeTop(target), - }, - ]; + if (orientation === 'vertical') { + sourceStem = [ + { + x: source.x, + y: nodeBottom(source), + }, + { + x: source.x, + y: nodeBottom(source) + stemMinSource, + }, + { + x: source.x, + y: + nodeBottom(source) + + stemMinSource + + Math.min(sourceOffsetY, stemMax), + }, + ]; + targetStem = [ + { + x: target.x, + y: nodeTop(target) - stemMinTarget - Math.min(targetOffsetY, stemMax), + }, + { + x: target.x, + y: nodeTop(target) - stemMinTarget, + }, + { + x: target.x, + y: nodeTop(target), + }, + ]; + } else { + sourceStem = [ + { + x: nodeRight(source), + y: source.y, + }, + { + y: source.y, + x: nodeRight(source) + stemMinSource, + }, + { + y: source.y, + x: + nodeRight(source) + + stemMinSource + + Math.min(sourceOffsetY, stemMax), + }, + ]; + targetStem = [ + { + y: target.y, + x: + nodeLeft(target) - stemMinTarget - Math.min(targetOffsetY, stemMax), + }, + { + y: target.y, + x: nodeLeft(target) - stemMinTarget, + }, + { + y: target.y, + x: nodeLeft(target), + }, + ]; + } // Combine all points const points = [...sourceStem, ...edge.points, ...targetStem]; - // Fix any invalid points caused by invalid layouts - let pointYMax = points[0].y; - - for (const point of points) { - // Ensure increasing Y values for each point - if (point.y < pointYMax) { - point.y = pointYMax; - } else { - pointYMax = point.y; - } - } - // Assign finished points to edge edge.points = points; } diff --git a/src/utils/graph/solver.js b/src/utils/graph/solver.js index 769bb2b816..1d1527ec17 100644 --- a/src/utils/graph/solver.js +++ b/src/utils/graph/solver.js @@ -57,20 +57,22 @@ export const solveStrict = (constraints, constants) => { }; for (const constraint of constraints) { - addVariable(constraint.a, constraint.base.property); - addVariable(constraint.b, constraint.base.property); + const property = constraint.property || constraint.base.property; + addVariable(constraint.a, property); + addVariable(constraint.b, property); } let unsolvableCount = 0; for (const constraint of constraints) { + const property = constraint.property || constraint.base.property; try { solver.addConstraint( constraint.base.strict( constraint, constants, - variables[variableId(constraint.a, constraint.base.property)], - variables[variableId(constraint.b, constraint.base.property)] + variables[variableId(constraint.a, property)], + variables[variableId(constraint.b, property)] ) ); } catch (err) {