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/app/app.scss b/src/components/app/app.scss index bd53527605..ea9ae92349 100644 --- a/src/components/app/app.scss +++ b/src/components/app/app.scss @@ -25,6 +25,8 @@ --color-base-20: #{colors.$grey-300}; --color-black-10: #{colors.$white-200}; --color-border-line: #{colors.$white-500}; + --color-arrow-primary: #{colors.$black-900}; + --color-arrow-secondary: #{colors.$black-0}; // Experiment tracking colors below --color-exp-tracking-bg: #{colors.$white-0}; @@ -67,6 +69,8 @@ --color-run-list-hover: #{colors.$slate-0}; --color-base-20: #{colors.$grey-800}; --color-black-10: #{colors.$slate-700}; + --color-arrow-primary: #{colors.$white-0}; + --color-arrow-secondary: #{colors.$black-300}; // Experiment tracking colors below --color-exp-tracking-bg: #{colors.$slate-900}; diff --git a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js index e456e03933..c37fcef8e1 100644 --- a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js +++ b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js @@ -6,12 +6,15 @@ import { toggleSidebar, toggleTextLabels, toggleExpandAllPipelines, + toggleOrientation, } from '../../actions'; import { toggleModularPipelinesVisibilityState } from '../../actions/modular-pipelines'; import IconButton from '../ui/icon-button'; import LabelIcon from '../icons/label'; import ExportIcon from '../icons/export'; import LayersIcon from '../icons/layers'; +import LeftRightIcon from '../icons/left-right'; +import TopBottomIcon from '../icons/top-bottom'; import PrimaryToolbar from '../primary-toolbar'; import { getVisibleLayerIDs } from '../../selectors/disabled'; import ExpandPipelinesIcon from '../icons/expand-pipelines'; @@ -35,6 +38,8 @@ export const FlowchartPrimaryToolbar = ({ visibleLayers, expandedPipelines, onToggleExpandAllPipelines, + orientation, + onToggleOrientation, }) => { const { toSetQueryParam } = useGeneratePathname(); @@ -97,6 +102,19 @@ export const FlowchartPrimaryToolbar = ({ onClick={() => onToggleExportModal(true)} visible={display.exportBtn} /> + + onToggleOrientation( + orientation === 'vertical' ? 'horizontal' : 'vertical' + ) + } + visible={display.orientationBtn} + /> ); @@ -108,6 +126,7 @@ export const mapStateToProps = (state) => ({ visible: state.visible, display: state.display, visibleLayers: Boolean(getVisibleLayerIDs(state).length), + orientation: state.orientation, expandedPipelines: state.expandAllPipelines, }); @@ -128,6 +147,9 @@ export const mapDispatchToProps = (dispatch) => ({ dispatch(toggleExpandAllPipelines(isExpanded)); dispatch(toggleModularPipelinesVisibilityState(isExpanded)); }, + onToggleOrientation: (value) => { + dispatch(toggleOrientation(value)); + }, }); export default connect( diff --git a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.test.js b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.test.js index c59a4953ba..deb9534b28 100644 --- a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.test.js +++ b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.test.js @@ -15,7 +15,7 @@ jest.mock('../../utils/hooks/use-generate-pathname', () => ({ describe('PrimaryToolbar', () => { it('renders without crashing', () => { const wrapper = setup.mount(); - expect(wrapper.find('.pipeline-icon-toolbar__button').length).toBe(5); + expect(wrapper.find('.pipeline-icon-toolbar__button').length).toBe(6); }); it('hides all buttons (except menu button) when display prop is false for each of them', () => { @@ -24,6 +24,7 @@ describe('PrimaryToolbar', () => { layerBtn: false, exportBtn: false, expandPipelinesBtn: false, + orientationBtn: false, }; const wrapper = setup.mount(, { options: { display }, @@ -38,7 +39,7 @@ describe('PrimaryToolbar', () => { const wrapper = setup.mount(, { options: { display }, }); - expect(wrapper.find('.pipeline-icon-toolbar__button').length).toBe(4); + expect(wrapper.find('.pipeline-icon-toolbar__button').length).toBe(5); }); const functionCalls = [ @@ -74,6 +75,7 @@ describe('PrimaryToolbar', () => { disableLayerBtn: expect.any(Boolean), textLabels: expect.any(Boolean), expandedPipelines: expect.any(Boolean), + orientation: expect.any(String), visible: expect.objectContaining({ exportModal: expect.any(Boolean), metadataModal: expect.any(Boolean), @@ -84,6 +86,7 @@ describe('PrimaryToolbar', () => { exportBtn: expect.any(Boolean), labelBtn: expect.any(Boolean), layerBtn: expect.any(Boolean), + orientationBtn: expect.any(Boolean), expandPipelinesBtn: expect.any(Boolean), }), visibleLayers: expect.any(Boolean), diff --git a/src/components/flowchart/draw.js b/src/components/flowchart/draw.js index f2f300bf43..5e78b5c70d 100644 --- a/src/components/flowchart/draw.js +++ b/src/components/flowchart/draw.js @@ -70,15 +70,22 @@ export const drawLayers = function () { * Render layer name labels */ export const drawLayerNames = function () { - const { - chartSize: { sidebarWidth = 0 }, - layers, - } = this.props; + const { chartSize, layers, orientation } = this.props; + + const layerNamePosition = + orientation === 'vertical' ? chartSize.sidebarWidth || 0 : 100 || 0; + + const transformValue = + orientation === 'vertical' + ? // In vertical mode, layer names are positioned along the X-axis at sidebarWidth + `translateX(${layerNamePosition}px)` + : // In horizontal mode, layer names are positioned at a fixed Y = 100px + `translateY(${layerNamePosition}px)`; this.el.layerNameGroup .transition('layer-names-sidebar-width') .duration(this.DURATION) - .style('transform', `translateX(${sidebarWidth}px)`); + .style('transform', transformValue); this.el.layerNames = this.el.layerNameGroup .selectAll('.pipeline-layer-name') @@ -126,12 +133,20 @@ const updateNodeRects = (nodeRects) => return node.height / 2; }); -const updateParameterRect = (nodeRects) => +const updateParameterRect = (nodeRects, orientation) => nodeRects .attr('width', 12) .attr('height', 12) - .attr('x', (node) => (node.width + 20) / -2) - .attr('y', -6); + .attr('x', (node) => + // Position parameter icon on the left side of the node in vertical mode + // Position it slightly inside the node in horizontal mode + orientation === 'vertical' + ? (node.width + 20) / -2 + : -(node.width / 2) + 10 + ) + // Center parameter icon vertically on the left side of the node (12px parameter icon height, so -6 for centering) + // Place parameter icon on top of the node (12px parameter icon height) + .attr('y', (node) => (orientation === 'vertical' ? -6 : -node.height + 12)); /** * Render node icons and name labels @@ -150,6 +165,7 @@ export const drawNodes = function (changed) { focusMode, hoveredFocusMode, isSlicingPipelineApplied, + orientation, } = this.props; const { from: slicedPipelineFromId, @@ -223,7 +239,7 @@ export const drawNodes = function (changed) { .append('rect') .attr('class', 'pipeline-node__parameter-indicator') .on('mouseover', this.handleParamsIndicatorMouseOver) - .call(updateParameterRect); + .call(updateParameterRect, orientation); // Performance: use a single path per icon enterNodes @@ -344,7 +360,7 @@ export const drawNodes = function (changed) { ) .transition('node-rect') .duration((node) => (node.showText ? 200 : 600)) - .call(updateParameterRect); + .call(updateParameterRect, orientation); // Performance: icon transitions with CSS on GPU allNodes diff --git a/src/components/flowchart/flowchart.js b/src/components/flowchart/flowchart.js index 58d300c30d..b70fdef71b 100644 --- a/src/components/flowchart/flowchart.js +++ b/src/components/flowchart/flowchart.js @@ -183,7 +183,7 @@ export class FlowChart extends Component { this.updateChartSize(); } - if (changed('layers', 'chartSize')) { + if (changed('layers', 'chartSize', 'orientation')) { drawLayers.call(this); drawLayerNames.call(this); } @@ -364,8 +364,14 @@ export class FlowChart extends Component { // Update layer label y positions if (this.el.layerNames) { this.el.layerNames.style('transform', (d) => { - const updateY = y + (d.y + d.height / 2) * scale; - return `translateY(${updateY}px)`; + if (this.props.orientation === 'vertical') { + const updateY = y + (d.y + d.height / 2) * scale; + return `translateY(${updateY}px)`; // Use translateY for vertical layout + } else { + // Horizontal orientation + const updateX = x + (d.x + d.width / 4) * scale; + return `translateX(${updateX}px)`; // Use translateX for horizontal layout + } }); } @@ -473,7 +479,8 @@ export class FlowChart extends Component { * Zoom and scale to fit graph and any selected node in view */ resetView(preventZoom) { - const { chartSize, graphSize, clickedNode, nodes } = this.props; + const { chartSize, graphSize, clickedNode, nodes, orientation } = + this.props; const { width: chartWidth, height: chartHeight } = chartSize; const { width: graphWidth, height: graphHeight } = graphSize; @@ -491,6 +498,7 @@ export class FlowChart extends Component { : null; // Find a transform that fits everything in view + const transform = viewTransformToFit({ offset, focus, @@ -498,12 +506,14 @@ export class FlowChart extends Component { viewHeight: chartHeight, objectWidth: graphWidth, objectHeight: graphHeight, - minScaleX: 0.2, + sidebarWidth: chartSize.sidebarWidth, + minScaleX: 0.05, minScaleFocus: this.props.visibleMetaSidebar ? this.props.chartZoom.scale : 0.1, focusOffset: 0, preventZoom, + orientation, }); // Detect first transform @@ -997,6 +1007,7 @@ export const mapStateToProps = (state, ownProps) => ({ nodeSelected: getNodeSelected(state), nodesWithInputParams: getNodesWithInputParams(state), modularPipelineIds: state.modularPipeline.ids, + orientation: state.orientation, inputOutputDataNodes: getInputOutputNodesForFocusedModularPipeline(state), inputOutputDataEdges: getInputOutputDataEdges(state), visibleGraph: state.visible.graph, diff --git a/src/components/flowchart/flowchart.test.js b/src/components/flowchart/flowchart.test.js index d3d8719fd3..1204df2438 100644 --- a/src/components/flowchart/flowchart.test.js +++ b/src/components/flowchart/flowchart.test.js @@ -190,6 +190,93 @@ describe('FlowChart', () => { expect(spy).toHaveBeenCalled(); }); + it('applies transform correctly for different orientations', () => { + const chartSize = mockChartSize({ + sidebarWidth: 0, + metaSidebarWidth: 0, + codeSidebarWidth: 0, + }); + + const wrapperVertical = setup.mount( + + ); + const wrapperHorizontal = setup.mount( + + ); + + const instanceVertical = wrapperVertical.find('FlowChart').instance(); + const instanceHorizontal = wrapperHorizontal.find('FlowChart').instance(); + + const viewTransformVertical = getViewTransform(instanceVertical.view); + const viewTransformHorizontal = getViewTransform(instanceHorizontal.view); + + expect(viewTransformVertical.y).toBe(0); + expect(viewTransformHorizontal.x).toBe(0); + }); + + it('applies expected view extents for different orientations', () => { + const chartSize = mockChartSize({ + sidebarWidth: 0, + metaSidebarWidth: 0, + codeSidebarWidth: 0, + }); + + const wrapperVertical = setup.mount( + + ); + const wrapperHorizontal = setup.mount( + + ); + + const instanceVertical = wrapperVertical.find('FlowChart').instance(); + const instanceHorizontal = wrapperHorizontal.find('FlowChart').instance(); + + const viewExtentsVertical = getViewExtents(instanceVertical.view); + const viewExtentsHorizontal = getViewExtents(instanceHorizontal.view); + + // Verify vertical orientation behavior + expect(viewExtentsVertical.translate.minX).toBe(-instanceVertical.MARGIN); + expect(viewExtentsVertical.translate.maxX).toEqual( + instanceVertical.props.graphSize.width + instanceVertical.MARGIN + ); + expect(viewExtentsVertical.translate.minY).toEqual( + -instanceVertical.MARGIN + ); + expect(viewExtentsVertical.translate.maxY).toEqual( + instanceVertical.props.graphSize.height + instanceVertical.MARGIN + ); + + // Verify horizontal orientation behavior + expect(viewExtentsHorizontal.translate.minX).toEqual( + -instanceHorizontal.MARGIN + ); + expect(viewExtentsHorizontal.translate.maxX).toEqual( + instanceHorizontal.props.graphSize.width + instanceHorizontal.MARGIN + ); + expect(viewExtentsHorizontal.translate.minY).toEqual( + -instanceHorizontal.MARGIN + ); + expect(viewExtentsHorizontal.translate.maxY).toEqual( + instanceHorizontal.props.graphSize.height + instanceHorizontal.MARGIN + ); + }); + it('removes the resize event listener on unmount', () => { const map = {}; window.addEventListener = jest.fn((event, callback) => { @@ -269,6 +356,53 @@ describe('FlowChart', () => { ); }); + it('correctly positions parameter icons in vertical orientation', () => { + const wrapper = setup.mount( + + ); + + const nodeRects = wrapper + .render() + .find('.pipeline-node__parameter-indicator--visible'); + nodeRects.each((i, el) => { + const y = parseFloat(select(el).attr('y')); + expect(y).toEqual(-6); + }); + }); + + it('correctly positions parameter icons in horizontal orientation', () => { + const wrapper = setup.mount( + + ); + + // Find the corresponding pipeline node for `params1` + const nodeElement = wrapper.render().find(`.pipeline-node__bg`); + const nodeY = parseFloat(select(nodeElement).attr('y')); + + const nodeRects = wrapper + .render() + .find('.pipeline-node__parameter-indicator--visible'); + nodeRects.each((i, el) => { + const y = parseFloat(select(el).attr('y')); + expect(y).toBeLessThan(nodeY); + }); + }); + it('applies parameter-indicator--visible class to nodes with input parameters when nodeDisabled prop set', () => { const wrapper = setup.mount( { nodeSelected: expect.any(Object), nodeTypeDisabled: expect.any(Object), nodesWithInputParams: expect.any(Object), + orientation: expect.any(String), nodes: expect.any(Array), visibleGraph: expect.any(Boolean), visibleSidebar: expect.any(Boolean), diff --git a/src/components/icons/left-right.js b/src/components/icons/left-right.js new file mode 100644 index 0000000000..2c84ab536b --- /dev/null +++ b/src/components/icons/left-right.js @@ -0,0 +1,17 @@ +import React from 'react'; +import './orientation.scss'; + +const LeftRightIcon = ({ className }) => ( + + + + +); + +export default LeftRightIcon; diff --git a/src/components/icons/orientation.scss b/src/components/icons/orientation.scss new file mode 100644 index 0000000000..23770f7494 --- /dev/null +++ b/src/components/icons/orientation.scss @@ -0,0 +1,40 @@ +@use '../../styles/variables' as variables; + +.icon-orientation { + transition: fill 300ms ease, opacity 300ms ease; + opacity: 0.7; /* Default opacity */ +} + +/* Vertical orientation */ +.icon-orientation.vertical .icon-orientation-arrow__vertical { + fill: var(--color-arrow-primary); +} + +.icon-orientation.vertical .icon-orientation-arrow__horizontal { + fill: var(--color-arrow-secondary); +} + +/* Horizontal orientation */ +.icon-orientation.horizontal .icon-orientation-arrow__vertical { + fill: var(--color-arrow-secondary); +} + +.icon-orientation.horizontal .icon-orientation-arrow__horizontal { + fill: var(--color-arrow-primary); +} + +/* Hover state */ +.icon-orientation:hover { + opacity: 1; /* Full visibility */ +} + +/* Click (active) state */ +.icon-orientation:active { + opacity: 0.85; /* Slight dimming to show click feedback */ +} + +/* Disabled state */ +.icon-orientation[disabled] { + opacity: 0.2; + pointer-events: none; /* Prevent interaction */ +} diff --git a/src/components/icons/top-bottom.js b/src/components/icons/top-bottom.js new file mode 100644 index 0000000000..bc857757d9 --- /dev/null +++ b/src/components/icons/top-bottom.js @@ -0,0 +1,17 @@ +import React from 'react'; +import './orientation.scss'; + +const TopBottomIcon = ({ className }) => ( + + + + +); + +export default TopBottomIcon; 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..68ea2e2c6b 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.y - node.height < bound[0]) { + bound[0] = node.y - node.height; + } + + if (node.y + node.height > bound[1]) { + bound[1] = node.y + node.height; + } + } else { + if (node.x - node.width < bound[0]) { + bound[0] = node.x - node.width; + } - if (node.y + node.height > bound[1]) { - bound[1] = node.y + node.height; + if (node.x + node.width > bound[1]) { + bound[1] = node.x + node.width; + } } } } @@ -45,15 +56,26 @@ 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; + const rectSize = Math.max(width, height) * 5; + if (orientation === 'vertical') { + // Vertical layout when orientation is vertical + 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, + }; + } return { id, name: layerName[id], - x: (rectWidth - width) / -2, - y: start, - width: rectWidth, - height: Math.max(end - start, 0), + 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, }; }); } 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..51285db0ba 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,30 @@ 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 + // Define the coordinate keys based on the orientation + const primaryCoord = orientation === 'vertical' ? 'y' : 'x'; + const secondaryCoord = orientation === 'vertical' ? 'x' : 'y'; + + // Create rows using the primary coordinate for (const node of nodes) { - rows[node.y] = rows[node.y] || []; - rows[node.y].push(node); + const key = node[primaryCoord]; + rows[key] = rows[key] || []; + rows[key].push(node); } - // Sort the set of rows accounting for keys being strings + // Sort the set of rows by the primary coordinate 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 the secondary coordinate, then by 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..e20862d5f1 100644 --- a/src/utils/graph/constraints.js +++ b/src/utils/graph/constraints.js @@ -9,26 +9,22 @@ import { Constraint, Operator, Strength } from 'kiwi.js'; /** - * Layout constraint in Y for separating rows + * Layout constraint in 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 ), }; /** - * Layout constraint in Y for separating layers + * Layout constraint in for separating layers */ export const layerConstraint = { - property: 'y', - strict: (constraint, constants, variableA, variableB) => new Constraint( variableA.minus(variableB), @@ -39,16 +35,15 @@ export const layerConstraint = { }; /** - * Layout constraint in X for minimising distance from source to target for straight edges + * Layout constraint in 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[constraint.property] - b[constraint.property]); + a[constraint.property] -= resolve; + b[constraint.property] += resolve; }, strict: (constraint, constants, variableA, variableB) => @@ -61,37 +56,39 @@ export const parallelConstraint = { }; /** - * Crossing constraint in X for minimising edge crossings + * Crossing constraint in 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[constraint.property] - + edgeB.sourceNode[constraint.property] - + separationA) / + separationA); const resolveTarget = strength * - ((edgeA.targetNode.x - edgeB.targetNode.x - separationB) / separationB); + ((edgeA.targetNode[constraint.property] - + edgeB.targetNode[constraint.property] - + 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[constraint.property] -= resolveSource; + edgeB.sourceNode[constraint.property] += resolveSource; + edgeA.targetNode[constraint.property] -= resolveTarget; + edgeB.targetNode[constraint.property] += resolveTarget; }, }; /** - * Layout constraint in X for minimum node separation + * Layout constraint in 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/graph.test.js b/src/utils/graph/graph.test.js index 8a5115c997..5ca1ddec40 100644 --- a/src/utils/graph/graph.test.js +++ b/src/utils/graph/graph.test.js @@ -126,24 +126,39 @@ describe('commmon', () => { it('angle returns the angle between two points relative to x-axis', () => { // Degenerate case (coincident) - expect(angle({ x: 0, y: 0 }, { x: 0, y: 0 })).toEqual(0); + + const normalizeAngle = (angle) => + ((angle + Math.PI) % (2 * Math.PI)) - Math.PI; + + expect(angle({ x: 0, y: 0 }, { x: 0, y: 0 }, 'horizontal')).toEqual(0); + expect(angle({ x: 0, y: 0 }, { x: 0, y: 0 }, 'vertical')).toEqual(0); // Same quadrants for (let a = -Math.PI; a <= Math.PI; a += Math.PI / 3) { const pointA = { x: 2 * Math.cos(a), y: 2 * Math.sin(a) }; expect( - angle(pointA, { x: 0.5 * pointA.x, y: 0.5 * pointA.y }) + angle(pointA, { x: 0.5 * pointA.x, y: 0.5 * pointA.y }, 'vertical') ).toBeCloseTo(a); } // Different quadrants for (let a = -Math.PI; a <= Math.PI; a += Math.PI / 2) { const pointA = { x: Math.cos(a), y: Math.sin(a) }; - expect(angle(pointA, { x: -pointA.x, y: -pointA.y })).toBeCloseTo(a); + expect( + angle(pointA, { x: -pointA.x, y: -pointA.y }, 'vertical') + ).toBeCloseTo(a); + } + + // Same quadrants + for (let a = -Math.PI; a <= Math.PI; a += Math.PI / 3) { + const pointA = { x: 2 * Math.cos(a), y: 2 * Math.sin(a) }; + expect( + angle(pointA, { x: 0.5 * pointA.x, y: 0.5 * pointA.y }, 'horizontal') + ).toBeCloseTo(normalizeAngle(Math.PI / 2 - a)); } }); - it('groupByRow finds the rows formed by nodes given the their positions in Y sorted in X and Y.', () => { + describe('groupByRow function', () => { const nodes = [ { x: 1, y: 0 }, { x: 0, y: 1 }, @@ -155,20 +170,39 @@ describe('commmon', () => { { x: 3, y: 2 }, ]; - expect(groupByRow(nodes)).toEqual([ - [ - { x: 0, y: 0, row: 0 }, - { x: 1, y: 0, row: 0 }, - ], - [{ x: 0, y: 1, row: 1 }], - [ - { x: 1, y: 2, row: 2 }, - { x: 2, y: 2, row: 2 }, - { x: 3, y: 2, row: 2 }, - ], - [{ x: 0, y: 3, row: 3 }], - [{ x: 0, y: 4, row: 4 }], - ]); + it('groups nodes by rows in vertical orientation', () => { + expect(groupByRow(nodes, 'vertical')).toEqual([ + [ + { x: 0, y: 0, row: 0 }, + { x: 1, y: 0, row: 0 }, + ], + [{ x: 0, y: 1, row: 1 }], + [ + { x: 1, y: 2, row: 2 }, + { x: 2, y: 2, row: 2 }, + { x: 3, y: 2, row: 2 }, + ], + [{ x: 0, y: 3, row: 3 }], + [{ x: 0, y: 4, row: 4 }], + ]); + }); + + it('groups nodes by rows in horizontal orientation', () => { + expect(groupByRow(nodes, 'horizontal')).toEqual([ + [ + { x: 0, y: 0, row: 0 }, + { x: 0, y: 1, row: 0 }, + { x: 0, y: 3, row: 0 }, + { x: 0, y: 4, row: 0 }, + ], + [ + { x: 1, y: 0, row: 1 }, + { x: 1, y: 2, row: 1 }, + ], + [{ x: 2, y: 2, row: 2 }], + [{ x: 3, y: 2, row: 3 }], + ]); + }); }); it('nodeLeft returns the left edge x-position of the node', () => { @@ -317,12 +351,16 @@ describe('constraints', () => { base: rowConstraint, a: testB, b: testA, + property: 'y', + separation: spaceY, }; const rowConstraintBC = { base: rowConstraint, a: testC, b: testB, + property: 'y', + separation: spaceY, }; // Expect initial y values with no separation @@ -330,7 +368,7 @@ describe('constraints', () => { expect(testC.y - testB.y).toBe(0); // Solve test constraints - solveStrict([rowConstraintAB, rowConstraintBC], { spaceY }); + solveStrict([rowConstraintAB, rowConstraintBC]); // Expect order in y is A -> B -> C expect(testA.y).toBeLessThan(testB.y); @@ -354,12 +392,14 @@ describe('constraints', () => { base: layerConstraint, a: testB, b: testA, + property: 'y', }; const layerConstraintBC = { base: layerConstraint, a: testC, b: testB, + property: 'y', }; // Expect initial y values have no separation @@ -392,6 +432,7 @@ describe('constraints', () => { strength: 0.5, a: testA, b: testB, + property: 'x', }; const parallelConstraintBC = { @@ -399,6 +440,7 @@ describe('constraints', () => { strength: 0.5, a: testB, b: testC, + property: 'x', }; // Expect initial x values have some separation @@ -427,6 +469,7 @@ describe('constraints', () => { strength: 0.5, a: testA, b: testB, + property: 'x', }; const parallelConstraintBC = { @@ -434,6 +477,7 @@ describe('constraints', () => { strength: 0.5, a: testB, b: testC, + property: 'x', }; // Expect initial x values have some separation @@ -462,6 +506,7 @@ describe('constraints', () => { a: testA, b: testB, separation, + property: 'x', }; const separationConstraintBC = { @@ -469,6 +514,7 @@ describe('constraints', () => { a: testB, b: testC, separation, + property: 'x', }; // Expect initial x values have no separation @@ -509,6 +555,7 @@ describe('constraints', () => { strength: 0.9, separationA: separation, separationB: separation, + property: 'x', }; // Use the dot product to determine if edges cross in X 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..a737315496 100644 --- a/src/utils/graph/layout.js +++ b/src/utils/graph/layout.js @@ -32,30 +32,41 @@ export const layout = ({ spreadX, layerSpaceY, iterations, + 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, + 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); @@ -74,7 +85,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 +93,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 +108,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 +133,7 @@ const createLayerConstraints = (nodes, layers) => { for (const node of layerNodes) { layerConstraints.push({ base: layerConstraint, + property: constants.coordSecondary, a: intermediary, b: node, }); @@ -129,6 +143,7 @@ const createLayerConstraints = (nodes, layers) => { for (const node of nextLayerNodes) { layerConstraints.push({ base: layerConstraint, + property: constants.coordSecondary, a: node, b: intermediary, }); @@ -146,7 +161,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 +194,7 @@ const createCrossingConstraints = (edges, constants) => { crossingConstraints.push({ base: crossingConstraint, + property: coordPrimary, edgeA: edgeA, edgeB: edgeB, // The required horizontal spacing between connected nodes @@ -201,9 +217,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 +235,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 +243,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 +263,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 +294,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, + coordSecondary, + spaceY, + orientation, + 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 +343,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..db6e9cb883 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 @@ -106,14 +107,24 @@ export const routing = ({ const offsetX = Math.min(spaceX, nodeGap * 0.5); + let sourceX, sourceY, targetX, targetY; + + //TODO: Need to do this for horizontal orientation as well. + if (orientation === 'vertical') { + sourceX = nodeRight(node) + offsetX; + sourceY = nodeTop(node) - spaceY; + targetX = nodeLeft(nextNode) - offsetX; + targetY = nodeTop(nextNode) - spaceY; + } + // Find the next potential point. Include offset to reduce overlapping edges const candidatePoint = nearestOnLine( currentPoint.x, currentPoint.y, - nodeRight(node) + offsetX, - nodeTop(node) - spaceY, - nodeLeft(nextNode) - offsetX, - nodeTop(nextNode) - spaceY + sourceX, + sourceY, + targetX, + targetY ); const distance = distance1d(currentPoint.x, candidatePoint.x); @@ -153,15 +164,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 +190,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,51 +206,91 @@ 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; + const coordPrimary = orientation === 'vertical' ? 'y' : 'x'; + + // Initialize the maximum value for the primary coordinate + let pointMax = points[0][coordPrimary]; for (const point of points) { - // Ensure increasing Y values for each point - if (point.y < pointYMax) { - point.y = pointYMax; + // Ensure increasing values for the primary coordinate + if (point[coordPrimary] < pointMax) { + point[coordPrimary] = pointMax; } else { - pointYMax = point.y; + pointMax = point[coordPrimary]; } } 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) {