diff --git a/package.json b/package.json index 5883aa9594..f3899a366f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "postbuild": "rm -rf build/api", "start": "REACT_APP_DATA_SOURCE=$DATA NODE_OPTIONS=\"--dns-result-order=ipv4first\" npm-run-all -p start:app start:lib", "start:dev": "rm -rf node_modules/.cache && npm start", - "start:app": "PORT=4141 react-scripts start", + "start:app": "PORT=4143 react-scripts start", "start:lib": "rm -rf lib && babel src --out-dir lib --copy-files --watch", "lib": "npm-run-all -s lib:clean lib:copy lib:webpack lib:babel lib:prune", "lib:clean": "rm -rf lib", diff --git a/src/actions/graph.js b/src/actions/graph.js index 4e39794e8d..a59946b4ba 100644 --- a/src/actions/graph.js +++ b/src/actions/graph.js @@ -33,7 +33,10 @@ export function updateGraph(graph) { * @param {Object} state A subset of main state * @return {Function} Promise function */ -const layout = async (instance, state) => instance.graphNew(state); + +const layout = async (instance, state) => { + return instance.graphNew(state); +} // Prepare new layout worker const layoutWorker = preventWorkerQueues(worker, layout); diff --git a/src/components/flowchart/draw.js b/src/components/flowchart/draw.js index f2f300bf43..3ae0dff579 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; + + // Calculate the layer name position based on orientation + const layerNamePosition = orientation + ? 100 || 0 // Vertical: position based on height + : chartSize.sidebarWidth || 0; // Horizontal: position based on sidebar width + + // Apply the correct translation based on orientation + const transformValue = orientation + ? `translateY(${layerNamePosition}px)` // Vertical: use translateY + : `translateX(${layerNamePosition}px)`; // Horizontal: use translateX 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,12 @@ 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) => orientation ? -node.width/2 + 10: (node.width + 20)/-2) + .attr('y', (node) => orientation ? -node.height+12 : -6); /** * Render node icons and name labels @@ -150,6 +157,7 @@ export const drawNodes = function (changed) { focusMode, hoveredFocusMode, isSlicingPipelineApplied, + orientation } = this.props; const { from: slicedPipelineFromId, @@ -223,7 +231,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 +352,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 1afc5c4b93..e98e1b6bc9 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); } @@ -358,8 +358,13 @@ 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 orientation + const updateX = x + (d.x + d.width / 4) * scale; + return `translateX(${updateX}px)`; // Use translateX for horizontal layout + } else { // Horizontal orientation + const updateY = y + (d.y + d.height / 2) * scale; + return `translateY(${updateY}px)`; // Use translateY for vertical layout + } }); } @@ -974,6 +979,7 @@ const emptyGraphSize = {}; export const mapStateToProps = (state, ownProps) => ({ clickedNode: state.node.clicked, chartSize: getChartSize(state), + orientation: state.textLabels, chartZoom: getChartZoom(state), displayGlobalNavigation: state.display.globalNavigation, displaySidebar: state.display.sidebar, diff --git a/src/components/flowchart/styles/_layers.scss b/src/components/flowchart/styles/_layers.scss index 5f4086923b..90073f0c84 100644 --- a/src/components/flowchart/styles/_layers.scss +++ b/src/components/flowchart/styles/_layers.scss @@ -56,17 +56,28 @@ } .pipeline-layer-name { - align-items: center; - color: var(--layer-text); - display: flex; + position: absolute; font-size: 1.6em; font-weight: bold; - height: 20px; - padding-left: 18px; - position: absolute; - top: -10px; - transition: opacity ease 0.5s; - white-space: nowrap; + color: var(--layer-text); // Ensure this matches your theme + white-space: nowrap; // Prevent text wrapping + overflow: hidden; // Hide text overflow + text-overflow: ellipsis; // Show ellipsis when text overflows + transition: opacity ease 0.5s, transform ease 0.5s; // Smooth transitions + pointer-events: none; // Avoid interfering with user interactions +} + +.pipeline-layer-name--horizontal { + text-align: left; // Align text to the left + text-anchor: start; // Align text anchor for SVG-like elements + padding-left: 12px; // Add some padding for spacing +} + +.pipeline-layer-name--vertical { + transform: translateY(-50%); // Center the text along the vertical axis + text-align: center; // Align text in the middle + text-anchor: middle; // Align text anchor for SVG-like elements + left: 0; // Position at the start of the layer } .pipeline-layer-name--active { diff --git a/src/selectors/layers.js b/src/selectors/layers.js index f11c3f996a..59299e51c2 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 getOrientation = (state) => state.textLabels; /** * Get layer positions */ export const getLayers = createSelector( - [getGraph, getVisibleLayerIDs, getLayerName], - ({ nodes, size }, layerIDs, layerName) => { + [getGraph, getVisibleLayerIDs, getLayerName, getOrientation], + ({ nodes, size }, layerIDs, layerName, orientation) => { if (!nodes || !size || !nodes.length || !layerIDs.length) { return []; } @@ -17,22 +18,35 @@ export const getLayers = createSelector( const bounds = {}; + // Calculate the bounds for each layer based on node positions for (const node of nodes) { const layer = node.nearestLayer || node.layer; 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 orientation (when true) + 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 { // Horizontal orientation (when false) + 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; + } } } } + // Calculate the layer positions based on the orientation return layerIDs.map((id, i) => { const currentBound = bounds[id] || [0, 0]; const prevBound = bounds[layerIDs[i - 1]] || [ @@ -45,16 +59,28 @@ 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; // Adjust size calculation + + // Return positions based on boolean orientation + if (orientation) { // 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..dc72a80af9 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 getOrient = (state) => state.textLabels; /** * Show the large graph warning only if there are sufficient nodes + edges, @@ -51,13 +52,14 @@ export const getGraphInput = createSelector( getVisibleEdges, getVisibleLayerIDs, getTriggerLargeGraphWarning, + getOrient, ], - (nodes, edges, layers, triggerLargeGraphWarning) => { + (nodes, edges, layers, triggerLargeGraphWarning, orient) => { if (triggerLargeGraphWarning) { return null; } - return { nodes, edges, layers }; + return { nodes, edges, layers, orient }; } ); diff --git a/src/selectors/nodes.js b/src/selectors/nodes.js index adaaaedf73..f44da46c43 100644 --- a/src/selectors/nodes.js +++ b/src/selectors/nodes.js @@ -24,7 +24,7 @@ const getHoveredNode = (state) => state.node.hovered; const getIsPrettyName = (state) => state.isPrettyName; const getTagActive = (state) => state.tag.active; const getModularPipelineActive = (state) => state.modularPipeline.active; -const getTextLabels = (state) => state.textLabels; +const getTextLabels = (state) => true; const getNodeTypeDisabled = (state) => state.nodeType.disabled; const getClickedNode = (state) => state.node.clicked; const getEdgeIDs = (state) => state.edge.ids; diff --git a/src/utils/graph/common.js b/src/utils/graph/common.js index ef1e0b7ba7..6835736df6 100644 --- a/src/utils/graph/common.js +++ b/src/utils/graph/common.js @@ -33,12 +33,24 @@ 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 ('top-to-bottom' or 'left-to-right') * @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 === 'top-to-bottom') { + // Top-to-bottom orientation + return Math.atan2(a.y - b.y, a.x - b.x); + } else if (orientation === 'left-to-right') { + // Left-to-right orientation + return Math.atan2(a.x - b.x, a.y - b.y); + } else { + throw new Error(`Unsupported orientation: ${orientation}`); + } +}; + /** * Returns the left edge x-position of the node @@ -75,24 +87,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 = 'top-to-bottom') => { const rows = {}; - // Create rows using node Y values + // Define the coordinate keys based on the orientation + const primaryCoord = orientation === 'left-to-right' ? 'x' : 'y'; + const secondaryCoord = orientation === 'left-to-right' ? 'y' : 'x'; + + // Create rows using the primary coordinate (Y for top-to-bottom, X for left-to-right) 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 + // 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)); + + // Assign row index to each node in the row for (const node of sortedRows[i]) { node.row = i; } @@ -101,6 +119,7 @@ export const groupByRow = (nodes) => { return sortedRows; }; + /** * Generalised comparator function for sorting * If values are strings then `localeCompare` is used, otherwise values are subtracted diff --git a/src/utils/graph/constraints.js b/src/utils/graph/constraints.js index cc47d6824b..e94420d4e7 100644 --- a/src/utils/graph/constraints.js +++ b/src/utils/graph/constraints.js @@ -12,23 +12,19 @@ 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 ), }; /** - * Layout constraint in Y for separating layers + * Layout constraint 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,24 @@ 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,7 +84,6 @@ export const crossingConstraint = { * Layout constraint in X for minimum node separation */ export const separationConstraint = { - property: 'x', strict: (constraint, constants, variableA, variableB) => new Constraint( diff --git a/src/utils/graph/graph.js b/src/utils/graph/graph.js index 989a5b77d9..5a34c8d69c 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,12 @@ 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..dee58aa403 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, orient =false }) => { for (const node of nodes) { node.iconSize = node.iconSize || 24; node.icon = node.icon || 'node'; @@ -24,8 +24,11 @@ export const graphNew = ({ nodes, edges, layers }) => { node.textOffset = node.textOffset || (innerWidth - textWidth) / 2; node.iconOffset = node.iconOffset || -innerWidth / 2; } - - const result = graph(nodes, edges, layers); + let orientation = "top-to-bottom"; + if (orient) { + orientation = "left-to-right" + } + 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..86a680f748 100644 --- a/src/utils/graph/layout.js +++ b/src/utils/graph/layout.js @@ -32,30 +32,44 @@ export const layout = ({ spreadX, layerSpaceY, iterations, + orientation, }) => { - // Set initial positions for nodes + + let coordPrimary = 'x'; + let coordSecondary = 'y'; + + if (orientation === 'left-to-right') { + 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); @@ -74,7 +88,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,12 +96,16 @@ export const layout = ({ * @param {Array} edges The input edges * @returns {Array} The constraints */ -const createRowConstraints = (edges) => - edges.map((edge) => ({ - base: rowConstraint, - a: edge.targetNode, - b: edge.sourceNode, - })); +const createRowConstraints = (edges, constants) => + edges.map((edge) => { + return { + base: rowConstraint, + property: constants.coordSecondary, + a: edge.targetNode, + b: edge.sourceNode, + separation: constants.spaceY, + }; + }); /** * Creates layer constraints for the given nodes and layers. @@ -95,7 +113,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 @@ -122,6 +140,7 @@ const createLayerConstraints = (nodes, layers) => { base: layerConstraint, a: intermediary, b: node, + property: constants.coordSecondary }); } @@ -131,6 +150,7 @@ const createLayerConstraints = (nodes, layers) => { base: layerConstraint, a: node, b: intermediary, + property: constants.coordSecondary }); } } @@ -146,7 +166,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 +199,7 @@ const createCrossingConstraints = (edges, constants) => { crossingConstraints.push({ base: crossingConstraint, + property : coordPrimary, edgeA: edgeA, edgeB: edgeB, // The required horizontal spacing between connected nodes @@ -201,9 +222,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,44 +240,44 @@ 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 for (let i = 0; i < rows.length; i += 1) { 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)); - - // Update constraints given updated row node order - for (let j = 0; j < rowNodes.length - 1; j += 1) { + rowNodes.sort((a, b) => compare(a[coordPrimary], b[coordPrimary], a.id, b.id)); + + // Update constraints for the sorted row node order + for (let j = 0; j < rowNodes.length-1; j += 1) { const nodeA = rowNodes[j]; const nodeB = rowNodes[j + 1]; - + + // Count the connected edges - const degreeA = Math.max( - 1, - nodeA.targets.length + nodeA.sources.length - 2 - ); - const degreeB = Math.max( - 1, - nodeB.targets.length + nodeB.sources.length - 2 - ); - - // Allow more spacing for nodes with more edges - const spread = Math.min(10, degreeA * degreeB * constants.spreadX); + const degreeA = Math.max(1, nodeA.targets.length + nodeA.sources.length - 2); + const degreeB = Math.max(1, nodeB.targets.length + nodeB.sources.length - 2); + + // Calculate spacing and separation for primary orientation + 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; + + // Adjust separation if the orientation is 'left-to-right' (using height instead of width) + if (orientation === 'left-to-right') { + 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, }); } } - return separationConstraints; }; @@ -268,22 +290,34 @@ 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; // Use generic offset instead of currentOffsetY // 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; + // Round offset to a common unit amount to improve rhythm + const offset = snap(density * scale * spaceY, spaceYUnit); + + // Only calculate width-based offset if orientation is 'left-to-right' + if (orientation === 'left-to-right') { + // Calculate the maximum width between the two rows + const maxWidthInCurrentRow = Math.max(...rows[i].map(node => node.width)); + const maxWidthInNextRow = Math.max(...rows[i + 1].map(node => node.width)); + + // Adjust current offset by the max width between rows + currentOffset += offset + maxWidthInCurrentRow*0.5 + maxWidthInNextRow*0.5; + } else { + // If not left-to-right, only add the calculated offset + currentOffset += offset; + } - // Apply offset to all nodes following the current node + // Apply offset to all nodes following the current node in the appropriate coordinate (X or Y) for (const node of rows[i + 1]) { - node.y += currentOffsetY; + node[coordSecondary] += currentOffset; // This now handles both 'x' and 'y' } } }; @@ -297,13 +331,13 @@ 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..04ffbb5964 100644 --- a/src/utils/graph/routing.js +++ b/src/utils/graph/routing.js @@ -40,21 +40,22 @@ 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) ) ); } - + // For each edge for (const edge of edges) { const source = edge.sourceNode; @@ -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 @@ -104,17 +105,36 @@ export const routing = ({ continue; } + const offsetX = Math.min(spaceX, nodeGap * 0.5); - // 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 - ); + // Define variables for source and target positions based on orientation + let sourceX, sourceY, targetX, targetY; + + if (orientation === 'top-to-bottom') { + // Top-to-bottom orientation + sourceX = nodeRight(node) + offsetX; // Right side of the current node + sourceY = nodeTop(node) - spaceY; // Above the current node + targetX = nodeLeft(nextNode) - offsetX; // Left side of the next node + targetY = nodeTop(nextNode) - spaceY; // Above the next node + } + // else if (orientation === 'left-to-right') { + // sourceX = nodeTop(node); // Right side of the current node + // sourceY = nodeLeft(node) ; // Above the current node + // targetX = nodeBottom(nextNode); // Left side of the next node + // targetY = nodeLeft(nextNode); // Above the next node + // } + + // Calculate the nearest point using the computed source and target positions + const candidatePoint = nearestOnLine( + currentPoint.x, + currentPoint.y, + sourceX, + sourceY, + targetX, + targetY + ); + const distance = distance1d(currentPoint.x, candidatePoint.x); @@ -153,15 +173,15 @@ 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,24 +191,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 = @@ -201,53 +208,91 @@ export const routing = ({ target.sources.length * (1 - Math.abs(targetEdgeDistance) / target.sources.length); - // 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), - }, - ]; + let sourceStem, targetStem; + + if (orientation === 'top-to-bottom') { + // Build the source stem for the edge (top-to-bottom) + 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), + }, + ]; + + // Build the target stem for the edge (top-to-bottom) + 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 if (orientation === 'left-to-right') { + // Build the source stem for the edge (left-to-right) + 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), + }, + ]; + + // Build the target stem for the edge (left-to-right) + 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), + }, + ]; + } else { + throw new Error(`Unsupported orientation: ${orientation}`); + } // 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; - } - } + // // Fix any invalid points caused by invalid layouts + // let pointXMax = points[0].y; + + // for (const point of points) { + // // Ensure increasing Y values for each point + // if (point.x < pointXMax) { + // point.x = pointXMax; + // } else { + // pointXMax = point.x; + // } + // } // Assign finished points to edge edge.points = points; diff --git a/src/utils/graph/solver.js b/src/utils/graph/solver.js index 769bb2b816..877d906d98 100644 --- a/src/utils/graph/solver.js +++ b/src/utils/graph/solver.js @@ -55,22 +55,23 @@ export const solveStrict = (constraints, constants) => { variable.obj = obj; } }; - 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) { diff --git a/src/utils/graph/test.js b/src/utils/graph/test.js new file mode 100644 index 0000000000..c33d778c7f --- /dev/null +++ b/src/utils/graph/test.js @@ -0,0 +1,59 @@ +const createSeparationConstraints = (rows, constants) => { + const { spaceX, spaceY } = constants; + const separationConstraints = []; + + // For each row of nodes + for (let i = 0; i < rows.length - 1; i += 1) { + const rowNodes = rows[i]; + const nodeB = rows[i + 1][0]; + + // Stable sort row nodes horizontally, breaks ties with ids + rowNodes.sort((a, b) => compare(a.y, b.y, a.id, b.id)); + + // Update constraints given updated row node order + for (let j = 0; j < rowNodes.length; j += 1) { + const nodeA = rowNodes[j]; + const nodeC = j + 1 < rowNodes.length ? rowNodes[j + 1] : null; + + // Count the connected edges + const degreeA = Math.max( + 1, + nodeA.targets.length + nodeA.sources.length - 2 + ); + const degreeB = Math.max( + 1, + nodeB.targets.length + nodeB.sources.length - 2 + ); + if (nodeC) { + const degreeC = Math.max( + 1, + nodeC.targets.length + nodeC.sources.length - 2 + ); + // Allow more spacing for nodes with more edges + const spreadInX = Math.min(10, degreeA * degreeC * constants.spreadX); + const spaceInX = snap(spreadInX * spaceX, spaceX); + + separationConstraints.push({ + base: { property: 'x', ...separationConstraint }, + a: nodeA, + b: nodeC, + separation: nodeA.height + spaceInX + nodeC.height, + }); + } + + // Allow more spacing for nodes with more edges + const spreadInY = Math.min(10, degreeA * degreeB * constants.spreadX); + const spaceInY = snap(spreadInY * spaceY, spaceY); + + separationConstraints.push({ + base: { property: 'y', ...separationConstraint }, + a: nodeA, + b: nodeB, + separation: nodeA.width * 0.5 + spaceInY + nodeB.width * 0.5, + }); + } + } + + return separationConstraints; + }; + \ No newline at end of file