/**
* Renders a single connection (edge) between two workflow nodes in the AutoRA Workflow
* Editor. Contains the orthogonal auto-routing engine that chooses optimal source/target
* ports and computes an SVG path that avoids overlapping nodes and other connections.
*
* @module components/Canvas/Connection
*/
import React, { memo, useMemo, useEffect } from 'react'
import { getNodePorts, getPortType } from '../Node/Node'
// Node dimensions for collision detection
const NODE_WIDTH = 160
const NODE_HEIGHT = 80
const CONTROL_NODE_WIDTH = 100
const CONTROL_NODE_HEIGHT = 80
const DIAMOND_SIZE = 90
const PADDING = 20 // Padding around nodes for routing
/**
* Computes the padded bounding box of a node, sized according to its shape/type
* (diamond filter, control node, or standard node), for use in collision detection.
*
* @param {Object} node - The node to measure.
* @param {string} node.type - The node type (e.g. 'filter_point', 'start_point').
* @param {number} node.x - The node's x position.
* @param {number} node.y - The node's y position.
* @param {number} [extraPadding=0] - Additional padding to add beyond the default.
* @returns {{x: number, y: number, width: number, height: number}} The bounding box.
*/
// Get node bounding box with padding
function getNodeBounds(node, extraPadding = 0) {
const isDiamond = node.type === 'filter_point'
const isControl = node.type === 'start_point' || node.type === 'end_point'
const pad = PADDING + extraPadding
if (isDiamond) {
return {
x: node.x - pad,
y: node.y - pad,
width: DIAMOND_SIZE + pad * 2,
height: DIAMOND_SIZE + pad * 2
}
} else if (isControl) {
return {
x: node.x - pad,
y: node.y - pad,
width: CONTROL_NODE_WIDTH + pad * 2,
height: CONTROL_NODE_HEIGHT + pad * 2
}
} else {
return {
x: node.x - pad,
y: node.y - pad,
width: NODE_WIDTH + pad * 2,
height: NODE_HEIGHT + pad * 2
}
}
}
/**
* Determines whether two line segments (x1,y1)-(x2,y2) and (x3,y3)-(x4,y4) cross,
* using a slightly inset parametric test so endpoints that merely touch don't count.
*
* @param {number} x1 - First segment start x.
* @param {number} y1 - First segment start y.
* @param {number} x2 - First segment end x.
* @param {number} y2 - First segment end y.
* @param {number} x3 - Second segment start x.
* @param {number} y3 - Second segment start y.
* @param {number} x4 - Second segment end x.
* @param {number} y4 - Second segment end y.
* @returns {boolean} True if the segments intersect (excluding near-endpoint touches).
*/
// Check if two line segments intersect
function segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
if (Math.abs(denom) < 0.0001) return false
const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom
const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom
return ua > 0.01 && ua < 0.99 && ub > 0.01 && ub < 0.99
}
/**
* Checks whether a line segment crosses any of the four edges of a rectangle.
*
* @param {number} x1 - Segment start x.
* @param {number} y1 - Segment start y.
* @param {number} x2 - Segment end x.
* @param {number} y2 - Segment end y.
* @param {{x: number, y: number, width: number, height: number}} rect - The rectangle.
* @returns {boolean} True if the segment intersects any edge of the rectangle.
*/
// Check if a line segment intersects a rectangle
function lineIntersectsRect(x1, y1, x2, y2, rect) {
const edges = [
[rect.x, rect.y, rect.x + rect.width, rect.y],
[rect.x, rect.y + rect.height, rect.x + rect.width, rect.y + rect.height],
[rect.x, rect.y, rect.x, rect.y + rect.height],
[rect.x + rect.width, rect.y, rect.x + rect.width, rect.y + rect.height]
]
for (const [ex1, ey1, ex2, ey2] of edges) {
if (segmentsIntersect(x1, y1, x2, y2, ex1, ey1, ex2, ey2)) {
return true
}
}
return false
}
/**
* Determines whether a routing segment passes through any node's padded bounds,
* while tolerating the segment legitimately touching a node at the exact port
* positions where the connection attaches.
*
* @param {number} x1 - Segment start x.
* @param {number} y1 - Segment start y.
* @param {number} x2 - Segment end x.
* @param {number} y2 - Segment end y.
* @param {Array<Object>} nodes - All nodes to test against.
* @param {number} portX1 - Source port x (allowed touch point).
* @param {number} portY1 - Source port y (allowed touch point).
* @param {number} portX2 - Target port x (allowed touch point).
* @param {number} portY2 - Target port y (allowed touch point).
* @returns {boolean} True if the segment improperly crosses a node.
*/
// Check if segment crosses any node (except at port endpoints)
function segmentCrossesNodes(x1, y1, x2, y2, nodes, portX1, portY1, portX2, portY2) {
for (const node of nodes) {
const bounds = getNodeBounds(node)
// Allow segment to touch node only at the exact port positions
const touchesPortStart = Math.abs(x1 - portX1) < 2 && Math.abs(y1 - portY1) < 2
const touchesPortEnd = Math.abs(x2 - portX2) < 2 && Math.abs(y2 - portY2) < 2
// If segment starts at source port or ends at target port, use smaller bounds check
if (touchesPortStart || touchesPortEnd) {
// Check intersection but allow touching at ports
if (lineIntersectsRect(x1, y1, x2, y2, bounds)) {
// For segments touching ports, only fail if the OTHER end is inside the node
const otherX = touchesPortStart ? x2 : x1
const otherY = touchesPortStart ? y2 : y1
const otherInside = otherX > bounds.x && otherX < bounds.x + bounds.width &&
otherY > bounds.y && otherY < bounds.y + bounds.height
if (otherInside) return true
// Also check if the middle of the segment goes through the node
const midX = (x1 + x2) / 2
const midY = (y1 + y2) / 2
const midInside = midX > bounds.x && midX < bounds.x + bounds.width &&
midY > bounds.y && midY < bounds.y + bounds.height
if (midInside) return true
}
} else {
// For intermediate segments, strictly check intersection
if (lineIntersectsRect(x1, y1, x2, y2, bounds)) {
return true
}
}
}
return false
}
/**
* Builds a list of straight-line segment approximations for all connections except the
* one currently being routed, resolving each connection's endpoints from its stored
* points or its nodes' default ports.
*
* @param {Array<Object>} connections - All connections in the workflow.
* @param {string} currentConnectionId - Id of the connection to exclude from the result.
* @param {Array<Object>} allNodes - All nodes, used to resolve default port positions.
* @returns {Array<{x1: number, y1: number, x2: number, y2: number, connId: string}>} Segment approximations.
*/
// Parse existing connection paths to get their segments
function getExistingConnectionSegments(connections, currentConnectionId, allNodes) {
const segments = []
for (const conn of connections) {
if (conn.id === currentConnectionId) continue
const sourceNode = allNodes.find(n => n.id === conn.sourceId)
const targetNode = allNodes.find(n => n.id === conn.targetId)
if (!sourceNode || !targetNode) continue
const sourcePorts = getNodePorts(sourceNode)
const targetPorts = getNodePorts(targetNode)
const defaultSourcePort = sourcePorts.find(p => p.id === 'right') || sourcePorts[0]
const defaultTargetPort = targetPorts.find(p => p.id === 'left') || targetPorts[0]
const x1 = conn.sourcePoint?.x ?? defaultSourcePort.x
const y1 = conn.sourcePoint?.y ?? defaultSourcePort.y
const x2 = conn.targetPoint?.x ?? defaultTargetPort.x
const y2 = conn.targetPoint?.y ?? defaultTargetPort.y
// Store the connection endpoints for basic segment approximation
segments.push({ x1, y1, x2, y2, connId: conn.id })
}
return segments
}
/**
* Checks whether any segment of a candidate path crosses any existing connection segment.
*
* @param {Array<Array<number>>} path - Ordered list of [x, y] points forming the path.
* @param {Array<{x1: number, y1: number, x2: number, y2: number}>} existingSegments - Existing connection segments.
* @returns {boolean} True if the path crosses an existing connection.
*/
// Check if a path crosses existing connections
function pathCrossesConnections(path, existingSegments) {
for (let i = 0; i < path.length - 1; i++) {
const [px1, py1] = path[i]
const [px2, py2] = path[i + 1]
for (const seg of existingSegments) {
// Simple check: does our segment cross the direct line between connection endpoints?
// This is a simplification - ideally we'd check against actual routed paths
if (segmentsIntersect(px1, py1, px2, py2, seg.x1, seg.y1, seg.x2, seg.y2)) {
return true
}
}
}
return false
}
/**
* Detects whether a candidate path runs along (overlaps) an existing connection by
* comparing parallel horizontal or vertical segments that are close together and
* share a meaningful span of overlap.
*
* @param {Array<Array<number>>} path - Ordered list of [x, y] points forming the path.
* @param {Array<{x1: number, y1: number, x2: number, y2: number}>} existingSegments - Existing connection segments.
* @param {number} [threshold=15] - Max perpendicular distance to treat segments as coinciding.
* @returns {boolean} True if the path substantially overlaps an existing connection.
*/
// Check if path coincides with another connection (same route)
function pathCoincides(path, existingSegments, threshold = 15) {
for (let i = 0; i < path.length - 1; i++) {
const [px1, py1] = path[i]
const [px2, py2] = path[i + 1]
for (const seg of existingSegments) {
// Check if segments are nearly parallel and close
const isHorizontal1 = Math.abs(py2 - py1) < 2
const isHorizontal2 = Math.abs(seg.y2 - seg.y1) < 2
const isVertical1 = Math.abs(px2 - px1) < 2
const isVertical2 = Math.abs(seg.x2 - seg.x1) < 2
if (isHorizontal1 && isHorizontal2) {
// Both horizontal - check if same Y and overlapping X
if (Math.abs(py1 - seg.y1) < threshold) {
const overlap = Math.max(0,
Math.min(Math.max(px1, px2), Math.max(seg.x1, seg.x2)) -
Math.max(Math.min(px1, px2), Math.min(seg.x1, seg.x2)))
if (overlap > 20) return true
}
}
if (isVertical1 && isVertical2) {
// Both vertical - check if same X and overlapping Y
if (Math.abs(px1 - seg.x1) < threshold) {
const overlap = Math.max(0,
Math.min(Math.max(py1, py2), Math.max(seg.y1, seg.y2)) -
Math.max(Math.min(py1, py2), Math.min(seg.y1, seg.y2)))
if (overlap > 20) return true
}
}
}
}
return false
}
/**
* Returns the outward unit direction vector for a named port, used to build the initial
* stub of a routed path leaving/entering a node.
*
* @param {string} portId - The port identifier ('top', 'bottom', 'left', or 'right').
* @returns {{dx: number, dy: number}} The outward direction vector (defaults to right).
*/
// Get port direction
function getPortDirection(portId) {
switch (portId) {
case 'top': return { dx: 0, dy: -1 }
case 'bottom': return { dx: 0, dy: 1 }
case 'left': return { dx: -1, dy: 0 }
case 'right': return { dx: 1, dy: 0 }
default: return { dx: 1, dy: 0 }
}
}
/**
* Finds the id of the port nearest to a given point (by Euclidean distance).
*
* @param {Object} node - The node owning the ports (unused for the lookup itself).
* @param {{x: number, y: number}} point - The reference point.
* @param {Array<{id: string, x: number, y: number}>} ports - Candidate ports.
* @returns {string} The id of the closest port.
*/
// Find closest port
function findPortId(node, point, ports) {
let closest = ports[0]
let minDist = Infinity
for (const port of ports) {
const dist = Math.sqrt((port.x - point.x) ** 2 + (port.y - point.y) ** 2)
if (dist < minDist) {
minDist = dist
closest = port
}
}
return closest.id
}
/**
* Determines whether a given port on a node is already used (within a small distance
* threshold) as the source or target of any connection other than the current one.
*
* @param {string} nodeId - The id of the node owning the port.
* @param {{x: number, y: number}} port - The port position to test.
* @param {Array<Object>} connections - All connections in the workflow.
* @param {string} currentConnectionId - Id of the connection to ignore.
* @returns {boolean} True if the port is occupied by another connection.
*/
// Check if a port is occupied by any connection (except the current one)
function isPortOccupied(nodeId, port, connections, currentConnectionId) {
const threshold = 20
for (const conn of connections) {
if (conn.id === currentConnectionId) continue
// Check if port is used as source
if (conn.sourceId === nodeId && conn.sourcePoint) {
const dist = Math.sqrt(
(conn.sourcePoint.x - port.x) ** 2 + (conn.sourcePoint.y - port.y) ** 2
)
if (dist < threshold) return true
}
// Check if port is used as target
if (conn.targetId === nodeId && conn.targetPoint) {
const dist = Math.sqrt(
(conn.targetPoint.x - port.x) ** 2 + (conn.targetPoint.y - port.y) ** 2
)
if (dist < threshold) return true
}
}
return false
}
/**
* Returns the node's ports that are both the correct type for the requested role
* (output for source, input for target) and not already occupied by another connection.
*
* @param {Object} node - The node whose ports are considered.
* @param {Array<Object>} connections - All connections in the workflow.
* @param {string} currentConnectionId - Id of the connection being routed (ignored for occupancy).
* @param {boolean} isSource - True to find output (source) ports, false for input (target) ports.
* @returns {Array<{id: string, x: number, y: number}>} The available ports.
*/
// Get available (unoccupied neutral) ports for a node that can be used as source or target
function getAvailablePorts(node, connections, currentConnectionId, isSource) {
const ports = getNodePorts(node)
const availablePorts = []
for (const port of ports) {
// Check port type matches expected role
const portType = getPortType(node.type, port.id)
const portMatchesRole = isSource ? portType === 'output' : portType === 'input'
// For start/end nodes, all ports are output/input respectively
const isValidPortType = portMatchesRole
// Check if port is unoccupied
const occupied = isPortOccupied(node.id, port, connections, currentConnectionId)
if (isValidPortType && !occupied) {
availablePorts.push(port)
}
}
return availablePorts
}
/**
* Generates a set of candidate orthogonal routes between a source and target point,
* trying several stub lengths and routing strategies (direct, mid-split, and detours
* around the top/bottom/left/right extents of all nodes) depending on port orientation.
*
* @param {number} x1 - Source point x.
* @param {number} y1 - Source point y.
* @param {number} x2 - Target point x.
* @param {number} y2 - Target point y.
* @param {{dx: number, dy: number}} sourceDir - Outward direction of the source port.
* @param {{dx: number, dy: number}} targetDir - Outward direction of the target port.
* @param {Array<Object>} nodes - All nodes, used to compute detour extents.
* @returns {Array<Array<Array<number>>>} A list of candidate paths, each an ordered list of [x, y] points.
*/
// Generate multiple path options with different routing strategies
function generatePathOptions(x1, y1, x2, y2, sourceDir, targetDir, nodes) {
const options = []
const offsets = [15, 30, 50, 80, 120] // Different stub lengths to try
// Get bounding box of all nodes to know canvas extent
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const node of nodes) {
const bounds = getNodeBounds(node)
minX = Math.min(minX, bounds.x)
minY = Math.min(minY, bounds.y)
maxX = Math.max(maxX, bounds.x + bounds.width)
maxY = Math.max(maxY, bounds.y + bounds.height)
}
// Add margin for routing outside all nodes
const routeTop = minY - 40
const routeBottom = maxY + 40
const routeLeft = minX - 40
const routeRight = maxX + 40
const sourceHorizontal = sourceDir.dx !== 0
const targetHorizontal = targetDir.dx !== 0
for (const offset of offsets) {
const sx = x1 + sourceDir.dx * offset
const sy = y1 + sourceDir.dy * offset
const tx = x2 + targetDir.dx * offset
const ty = y2 + targetDir.dy * offset
// Direct path if aligned
if (Math.abs(y1 - y2) < 2 && sourceHorizontal && targetHorizontal && sourceDir.dx !== targetDir.dx) {
options.push([[x1, y1], [x2, y2]])
}
if (Math.abs(x1 - x2) < 2 && !sourceHorizontal && !targetHorizontal && sourceDir.dy !== targetDir.dy) {
options.push([[x1, y1], [x2, y2]])
}
if (sourceHorizontal && targetHorizontal) {
// Both horizontal
const midX = (x1 + x2) / 2
options.push([[x1, y1], [sx, sy], [midX, sy], [midX, ty], [tx, ty], [x2, y2]])
// Route via top
options.push([[x1, y1], [sx, sy], [sx, routeTop], [tx, routeTop], [tx, ty], [x2, y2]])
// Route via bottom
options.push([[x1, y1], [sx, sy], [sx, routeBottom], [tx, routeBottom], [tx, ty], [x2, y2]])
} else if (!sourceHorizontal && !targetHorizontal) {
// Both vertical
const midY = (y1 + y2) / 2
options.push([[x1, y1], [sx, sy], [sx, midY], [tx, midY], [tx, ty], [x2, y2]])
// Route via left
options.push([[x1, y1], [sx, sy], [routeLeft, sy], [routeLeft, ty], [tx, ty], [x2, y2]])
// Route via right
options.push([[x1, y1], [sx, sy], [routeRight, sy], [routeRight, ty], [tx, ty], [x2, y2]])
} else if (sourceHorizontal && !targetHorizontal) {
// Source horizontal, target vertical - L-shapes
options.push([[x1, y1], [sx, sy], [x2, sy], [x2, y2]])
options.push([[x1, y1], [sx, sy], [sx, y2], [x2, y2]])
// Z-shapes
const midY = (y1 + y2) / 2
options.push([[x1, y1], [sx, sy], [sx, midY], [x2, midY], [x2, y2]])
// Route around via top/bottom
options.push([[x1, y1], [sx, sy], [sx, routeTop], [x2, routeTop], [x2, y2]])
options.push([[x1, y1], [sx, sy], [sx, routeBottom], [x2, routeBottom], [x2, y2]])
} else {
// Source vertical, target horizontal - L-shapes
options.push([[x1, y1], [sx, sy], [sx, y2], [x2, y2]])
options.push([[x1, y1], [sx, sy], [x2, sy], [x2, y2]])
// Z-shapes
const midX = (x1 + x2) / 2
options.push([[x1, y1], [sx, sy], [midX, sy], [midX, y2], [x2, y2]])
// Route around via left/right
options.push([[x1, y1], [sx, sy], [routeLeft, sy], [routeLeft, y2], [x2, y2]])
options.push([[x1, y1], [sx, sy], [routeRight, sy], [routeRight, y2], [x2, y2]])
}
}
return options
}
/**
* Converts a list of points into an SVG path string, removing duplicate points and
* collapsing consecutive collinear (same horizontal or vertical) segments.
*
* @param {Array<Array<number>>} points - Ordered list of [x, y] points.
* @returns {string} An SVG path "d" attribute string.
*/
// Clean path - remove redundant points
function cleanPath(points) {
if (points.length < 2) return 'M 0 0'
const cleaned = [points[0]]
for (let i = 1; i < points.length; i++) {
const prev = cleaned[cleaned.length - 1]
const curr = points[i]
if (Math.abs(prev[0] - curr[0]) < 0.5 && Math.abs(prev[1] - curr[1]) < 0.5) continue
if (cleaned.length >= 2) {
const prevPrev = cleaned[cleaned.length - 2]
const sameHorizontal = Math.abs(prevPrev[1] - prev[1]) < 0.5 && Math.abs(prev[1] - curr[1]) < 0.5
const sameVertical = Math.abs(prevPrev[0] - prev[0]) < 0.5 && Math.abs(prev[0] - curr[0]) < 0.5
if (sameHorizontal || sameVertical) {
cleaned[cleaned.length - 1] = curr
continue
}
}
cleaned.push(curr)
}
return 'M ' + cleaned.map(p => `${p[0]} ${p[1]}`).join(' L ')
}
/**
* Assigns a cost to a candidate path (lower is better): heavy penalties for crossing
* nodes, medium for crossing or coinciding with other connections, plus smaller costs
* proportional to total length and number of bends.
*
* @param {Array<Array<number>>} path - Ordered list of [x, y] points forming the path.
* @param {Array<Object>} nodes - All nodes, for collision checks.
* @param {Array<Object>} existingSegments - Existing connection segments, for crossing/coincidence checks.
* @param {number} x1 - Source port x (allowed touch point).
* @param {number} y1 - Source port y (allowed touch point).
* @param {number} x2 - Target port x (allowed touch point).
* @param {number} y2 - Target port y (allowed touch point).
* @returns {number} The path score; lower values are preferred.
*/
// Score a single path
function scorePath(path, nodes, existingSegments, x1, y1, x2, y2) {
let score = 0
// Check node collisions
let crossesNode = false
for (let i = 0; i < path.length - 1; i++) {
if (segmentCrossesNodes(path[i][0], path[i][1], path[i + 1][0], path[i + 1][1], nodes, x1, y1, x2, y2)) {
crossesNode = true
break
}
}
if (crossesNode) {
score += 1000 // Heavy penalty for crossing nodes
}
// Check connection crossings
if (pathCrossesConnections(path, existingSegments)) {
score += 500 // Penalty for crossing connections
}
// Check coinciding paths
if (pathCoincides(path, existingSegments)) {
score += 200 // Penalty for coinciding
}
// Prefer shorter paths
let pathLength = 0
for (let i = 0; i < path.length - 1; i++) {
pathLength += Math.abs(path[i + 1][0] - path[i][0]) + Math.abs(path[i + 1][1] - path[i][1])
}
score += pathLength * 0.1
// Prefer fewer bends
score += (path.length - 2) * 5
return score
}
/**
* The primary routing engine. Considers the original ports plus available alternative
* source/target ports, generates candidate paths for each combination, scores them, and
* returns the lowest-cost path together with the ports that produced it.
*
* @param {{x: number, y: number}} originalSourcePort - The connection's current source point.
* @param {{x: number, y: number}} originalTargetPort - The connection's current target point.
* @param {Object} sourceNode - The source node.
* @param {Object} targetNode - The target node.
* @param {Array<Object>} nodes - All nodes in the workflow.
* @param {Array<Object>} connections - All connections in the workflow.
* @param {string} currentConnectionId - Id of the connection being routed.
* @returns {{pathD: string, sourcePort: Object, targetPort: Object}} The best SVG path string and the chosen source/target ports.
*/
// Main path builder - now returns both path and optimal ports
function buildOrthogonalPathWithPortSelection(
originalSourcePort, originalTargetPort,
sourceNode, targetNode,
nodes, connections, currentConnectionId
) {
const existingSegments = getExistingConnectionSegments(connections, currentConnectionId, nodes)
// Get available alternative ports
const availableSourcePorts = getAvailablePorts(sourceNode, connections, currentConnectionId, true)
const availableTargetPorts = getAvailablePorts(targetNode, connections, currentConnectionId, false)
// Always include the original ports in consideration
const sourcePorts = getNodePorts(sourceNode)
const targetPorts = getNodePorts(targetNode)
// Find original port objects
const origSourcePortObj = sourcePorts.find(p =>
Math.abs(p.x - originalSourcePort.x) < 20 && Math.abs(p.y - originalSourcePort.y) < 20
) || sourcePorts.find(p => p.id === 'right') || sourcePorts[0]
const origTargetPortObj = targetPorts.find(p =>
Math.abs(p.x - originalTargetPort.x) < 20 && Math.abs(p.y - originalTargetPort.y) < 20
) || targetPorts.find(p => p.id === 'left') || targetPorts[0]
// Build list of port combinations to try
// Start with original ports, then try alternatives
const portCombinations = []
// Always try original first
portCombinations.push({
source: origSourcePortObj,
target: origTargetPortObj
})
// Add combinations with available alternative ports
for (const srcPort of availableSourcePorts) {
for (const tgtPort of availableTargetPorts) {
// Skip if same as original
if (srcPort.id === origSourcePortObj.id && tgtPort.id === origTargetPortObj.id) continue
portCombinations.push({ source: srcPort, target: tgtPort })
}
}
// Also try original source with alternative targets
for (const tgtPort of availableTargetPorts) {
if (tgtPort.id !== origTargetPortObj.id) {
portCombinations.push({ source: origSourcePortObj, target: tgtPort })
}
}
// And alternative sources with original target
for (const srcPort of availableSourcePorts) {
if (srcPort.id !== origSourcePortObj.id) {
portCombinations.push({ source: srcPort, target: origTargetPortObj })
}
}
let bestPath = null
let bestScore = Infinity
let bestSourcePort = origSourcePortObj
let bestTargetPort = origTargetPortObj
for (const combo of portCombinations) {
const x1 = combo.source.x
const y1 = combo.source.y
const x2 = combo.target.x
const y2 = combo.target.y
const sourceDir = getPortDirection(combo.source.id)
const targetDir = getPortDirection(combo.target.id)
const pathOptions = generatePathOptions(x1, y1, x2, y2, sourceDir, targetDir, nodes)
for (const path of pathOptions) {
const score = scorePath(path, nodes, existingSegments, x1, y1, x2, y2)
if (score < bestScore) {
bestScore = score
bestPath = path
bestSourcePort = combo.source
bestTargetPort = combo.target
}
// If we found a perfect path (no crossings), we can stop early
if (bestScore < 50) break
}
if (bestScore < 50) break
}
return {
pathD: cleanPath(bestPath),
sourcePort: bestSourcePort,
targetPort: bestTargetPort
}
}
/**
* Legacy single-route builder kept for backward compatibility: generates candidate paths
* for fixed source/target points and directions, then returns the best-scoring one as an
* SVG path string (without alternative port selection).
*
* @param {number} x1 - Source point x.
* @param {number} y1 - Source point y.
* @param {number} x2 - Target point x.
* @param {number} y2 - Target point y.
* @param {{dx: number, dy: number}} sourceDir - Outward direction of the source port.
* @param {{dx: number, dy: number}} targetDir - Outward direction of the target port.
* @param {Array<Object>} nodes - All nodes in the workflow.
* @param {Array<Object>} connections - All connections in the workflow.
* @param {string} currentConnectionId - Id of the connection being routed.
* @returns {string} The best-scoring SVG path "d" string.
*/
// Legacy function for backward compatibility
function buildOrthogonalPath(x1, y1, x2, y2, sourceDir, targetDir, nodes, connections, currentConnectionId) {
const pathOptions = generatePathOptions(x1, y1, x2, y2, sourceDir, targetDir, nodes)
const existingSegments = getExistingConnectionSegments(connections, currentConnectionId, nodes)
let bestPath = pathOptions[0]
let bestScore = Infinity
for (const path of pathOptions) {
const score = scorePath(path, nodes, existingSegments, x1, y1, x2, y2)
if (score < bestScore) {
bestScore = score
bestPath = path
}
}
return cleanPath(bestPath)
}
/**
* Renders a single connection between two nodes as an auto-routed orthogonal SVG path,
* with an invisible wide hit-area for easier selection, endpoint dots, and an arrowhead.
* Recomputes the optimal route (memoized) and notifies the parent when the chosen ports
* differ from the connection's stored points.
*
* @param {Object} props
* @param {Object} props.connection - The connection data (id, sourceId, targetId, optional sourcePoint/targetPoint).
* @param {Object} props.sourceNode - The source node.
* @param {Object} props.targetNode - The target node.
* @param {Array<Object>} props.allNodes - All nodes in the workflow, used for routing/collision.
* @param {Array<Object>} props.allConnections - All connections, used for crossing/occupancy checks.
* @param {boolean} props.isSelected - Whether this connection is currently selected.
* @param {Function} props.onSelect - Called with the connection id when the connection is clicked.
* @param {Function} props.onPortsChanged - Called with (id, sourcePoint, targetPoint) when routing selects new ports.
* @returns {JSX.Element}
*/
function Connection({ connection, sourceNode, targetNode, allNodes, allConnections, isSelected, onSelect, onPortsChanged }) {
const sourcePorts = getNodePorts(sourceNode)
const targetPorts = getNodePorts(targetNode)
const defaultSourcePort = sourcePorts.find(p => p.id === 'right') || sourcePorts[0]
const defaultTargetPort = targetPorts.find(p => p.id === 'left') || targetPorts[0]
const originalSourcePoint = {
x: connection.sourcePoint?.x ?? defaultSourcePort.x,
y: connection.sourcePoint?.y ?? defaultSourcePort.y
}
const originalTargetPoint = {
x: connection.targetPoint?.x ?? defaultTargetPort.x,
y: connection.targetPoint?.y ?? defaultTargetPort.y
}
// Use the new port selection algorithm to find optimal routing
const { pathD, sourcePort, targetPort } = useMemo(() => {
return buildOrthogonalPathWithPortSelection(
originalSourcePoint,
originalTargetPoint,
sourceNode,
targetNode,
allNodes || [],
allConnections || [],
connection.id
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originalSourcePoint.x, originalSourcePoint.y, originalTargetPoint.x, originalTargetPoint.y,
sourceNode.x, sourceNode.y, sourceNode.type, sourceNode.id,
targetNode.x, targetNode.y, targetNode.type, targetNode.id,
allNodes, allConnections, connection.id])
// Use the optimized port positions for drawing
const x1 = sourcePort.x
const y1 = sourcePort.y
const x2 = targetPort.x
const y2 = targetPort.y
// Notify parent when optimal ports differ from stored connection points
useEffect(() => {
const sourceChanged = Math.abs(x1 - originalSourcePoint.x) > 5 || Math.abs(y1 - originalSourcePoint.y) > 5
const targetChanged = Math.abs(x2 - originalTargetPoint.x) > 5 || Math.abs(y2 - originalTargetPoint.y) > 5
if ((sourceChanged || targetChanged) && onPortsChanged) {
onPortsChanged(connection.id, { x: x1, y: y1 }, { x: x2, y: y2 })
}
}, [connection.id, x1, y1, x2, y2, originalSourcePoint.x, originalSourcePoint.y, originalTargetPoint.x, originalTargetPoint.y, onPortsChanged])
return (
<g className="connection-group">
<path
className="connection-hitarea"
d={pathD}
stroke="transparent"
strokeWidth="20"
fill="none"
onClick={(e) => {
e.stopPropagation()
onSelect(connection.id)
}}
/>
<path
className={`connection ${isSelected ? 'selected' : ''}`}
d={pathD}
stroke={isSelected ? 'var(--accent-danger)' : 'var(--accent-primary)'}
strokeWidth={isSelected ? 3 : 2}
fill="none"
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
onClick={(e) => {
e.stopPropagation()
onSelect(connection.id)
}}
/>
<circle cx={x1} cy={y1} r="4" fill={isSelected ? 'var(--accent-danger)' : 'var(--accent-primary)'} />
<circle cx={x2} cy={y2} r="4" fill={isSelected ? 'var(--accent-danger)' : 'var(--accent-primary)'} />
</g>
)
}
export default memo(Connection)