constellation-analyzer/src/utils/edgeUtils.ts
Jan-Henrik Bruhn ed18b11dc9 Add self-loop (self-reference) support for parallel edges
- Add getSelfLoopParams() with shape-aware geometry for all node shapes
  (rectangle, roundedRectangle, circle, ellipse, pill)
- Anchor loops at the top-right corner: innermost loop is tightest
  (label closest to node), outer parallel loops fan progressively higher
- Fix ellipse source point which was incorrectly placed at the bottom
- Fix horizontal pill source Y which was at center instead of top edge
- Apply loopOffset to ellipse/circle to differentiate parallel self-loops
- Handle GraphEditor parallel edge sorting for self-referencing edges
- Fix loopLevel overflow for 6+ parallel self-loops via Math.max(0, ...)
- Add tests for self-loop detection, geometry, and parallel self-loops

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 15:33:52 +01:00

786 lines
28 KiB
TypeScript

import type { Relation, RelationData, NodeShape } from '../types';
import type { Node } from '@xyflow/react';
import { ROUNDED_RECTANGLE_RADIUS, DEFAULT_ACTOR_WIDTH, DEFAULT_ACTOR_HEIGHT } from '../constants';
/**
* Generates a unique ID for edges using crypto.randomUUID()
* Format: edge_<source>_<target>_<uuid> for guaranteed uniqueness and readability
*/
export const generateEdgeId = (source: string, target: string): string => {
return `edge_${source}_${target}_${crypto.randomUUID()}`;
};
/**
* Edge group information for parallel edges
*/
export interface EdgeGroup {
edges: Relation[];
sourceId: string;
targetId: string;
}
/**
* Base offset for parallel edges (in pixels)
*/
const BASE_EDGE_OFFSET = 80; // Increased for better visibility with multiple edges
/**
* Calculate the perpendicular offset for a parallel edge
* Returns a 2D vector that is perpendicular to the line between source and target
*/
export function calculatePerpendicularOffset(
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
offsetMagnitude: number
): { x: number; y: number } {
// Calculate direction vector from source to target
const dx = targetX - sourceX;
const dy = targetY - sourceY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Handle zero-distance case
if (distance === 0) {
return { x: offsetMagnitude, y: 0 };
}
// Normalize direction vector
const nx = dx / distance;
const ny = dy / distance;
// Perpendicular vector (rotate 90 degrees counterclockwise)
const perpX = -ny;
const perpY = nx;
// Scale by offset magnitude
return {
x: perpX * offsetMagnitude,
y: perpY * offsetMagnitude,
};
}
/**
* Calculate edge offset for a parallel edge within a group
* @param edgeIndex - Index of this edge within the parallel group (0-based)
* @param totalEdges - Total number of parallel edges
* @returns Offset multiplier (-1, 0, +1, etc.)
*/
export function calculateEdgeOffsetMultiplier(
edgeIndex: number,
totalEdges: number
): number {
// For single edge, no offset
if (totalEdges === 1) {
return 0;
}
// For 2 edges: offset by ±0.5 (one above, one below center)
if (totalEdges === 2) {
return edgeIndex === 0 ? -0.5 : 0.5;
}
// For 3+ edges: distribute evenly around center
// Center edge(s) get offset 0, others get ±1, ±2, etc.
const middle = (totalEdges - 1) / 2;
return edgeIndex - middle;
}
/**
* Group edges by their source-target pairs (bidirectional)
* Edges between A-B and B-A are grouped together
*/
export function groupParallelEdges(edges: Relation[]): Map<string, EdgeGroup> {
const groups = new Map<string, EdgeGroup>();
edges.forEach((edge) => {
// Create normalized key (alphabetically sorted endpoints)
// Use <-> separator to avoid conflicts with underscores in node IDs
const [normalizedSource, normalizedTarget] = [edge.source, edge.target].sort();
const key = `${normalizedSource}<->${normalizedTarget}`;
if (!groups.has(key)) {
groups.set(key, {
edges: [],
sourceId: normalizedSource, // Store normalized source
targetId: normalizedTarget, // Store normalized target
});
}
groups.get(key)!.edges.push(edge);
});
// Filter to only return groups with 2+ edges (parallel edges)
const parallelGroups = new Map<string, EdgeGroup>();
groups.forEach((group, key) => {
if (group.edges.length >= 2) {
parallelGroups.set(key, group);
}
});
return parallelGroups;
}
/**
* Calculate intersection point with a circle
* Returns both the intersection point and the normal vector (outward direction)
*/
function getCircleIntersection(
centerX: number,
centerY: number,
radius: number,
targetX: number,
targetY: number,
offset: number = 3
): { x: number; y: number; angle: number } {
// Guard against zero radius
if (radius === 0) {
return { x: centerX + offset, y: centerY, angle: 0 };
}
const dx = targetX - centerX;
const dy = targetY - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) {
return { x: centerX + radius + offset, y: centerY, angle: 0 };
}
// Normalized direction vector
const nx = dx / distance;
const ny = dy / distance;
// Point on circle border in direction of target, with offset
return {
x: centerX + nx * (radius + offset),
y: centerY + ny * (radius + offset),
angle: Math.atan2(ny, nx), // Normal angle pointing outward
};
}
/**
* Calculate intersection point with an ellipse
* Returns both the intersection point and the normal vector (outward direction)
*/
function getEllipseIntersection(
centerX: number,
centerY: number,
radiusX: number,
radiusY: number,
targetX: number,
targetY: number,
offset: number = 3
): { x: number; y: number; angle: number } {
// Guard against zero radii
if (radiusX === 0 || radiusY === 0) {
return { x: centerX + offset, y: centerY, angle: 0 };
}
const dx = targetX - centerX;
const dy = targetY - centerY;
if (dx === 0 && dy === 0) {
return { x: centerX + radiusX + offset, y: centerY, angle: 0 };
}
// Angle to target point
const angle = Math.atan2(dy, dx);
// Point on ellipse border
const px = radiusX * Math.cos(angle);
const py = radiusY * Math.sin(angle);
// Normal vector at this point on the ellipse
// For ellipse, the gradient at point (px, py) is (px/radiusX^2, py/radiusY^2)
const normalX = px / (radiusX * radiusX);
const normalY = py / (radiusY * radiusY);
const normalLength = Math.sqrt(normalX * normalX + normalY * normalY);
const nx = normalX / normalLength;
const ny = normalY / normalLength;
// Normal angle
const normalAngle = Math.atan2(ny, nx);
// Offset point slightly outside ellipse border
return {
x: centerX + px + nx * offset,
y: centerY + py + ny * offset,
angle: normalAngle,
};
}
/**
* Calculate intersection point with a pill (stadium) shape
* A pill has rounded caps on the ends and straight sides
*/
function getPillIntersection(
centerX: number,
centerY: number,
width: number,
height: number,
targetX: number,
targetY: number,
offset: number = 4
): { x: number; y: number; angle: number } {
const dx = targetX - centerX;
const dy = targetY - centerY;
if (dx === 0 && dy === 0) {
return { x: centerX + width / 2 + offset, y: centerY, angle: 0 };
}
// Determine pill orientation and cap radius
const isHorizontal = width >= height;
const capRadius = isHorizontal ? height / 2 : width / 2;
if (isHorizontal) {
// Horizontal pill: semicircular caps on left and right
const leftCapX = centerX - (width / 2 - capRadius);
const rightCapX = centerX + (width / 2 - capRadius);
// Check if pointing toward left cap
if (dx < 0 && Math.abs(dx) >= Math.abs(dy)) {
return getCircleIntersection(leftCapX, centerY, capRadius, targetX, targetY, offset);
}
// Check if pointing toward right cap
else if (dx > 0 && Math.abs(dx) >= Math.abs(dy)) {
return getCircleIntersection(rightCapX, centerY, capRadius, targetX, targetY, offset);
}
// Otherwise it's pointing toward top or bottom straight edge
else {
const side = dy < 0 ? -1 : 1;
const intersectY = centerY + side * capRadius;
// Calculate x position where line from target to center intersects the horizontal edge
// Line equation: (y - centerY) / (x - centerX) = dy / dx
// Solving for x when y = intersectY: x = centerX + dx * (intersectY - centerY) / dy
let intersectX = Math.abs(dy) > 0.001
? centerX + dx * (intersectY - centerY) / dy
: centerX;
// Clamp intersection to the straight horizontal segment between the caps
intersectX = Math.min(Math.max(intersectX, leftCapX), rightCapX);
const normalAngle = side < 0 ? -Math.PI / 2 : Math.PI / 2;
return {
x: intersectX,
y: intersectY + side * offset,
angle: normalAngle,
};
}
} else {
// Vertical pill: semicircular caps on top and bottom
const topCapY = centerY - (height / 2 - capRadius);
const bottomCapY = centerY + (height / 2 - capRadius);
// Check if pointing toward top cap
if (dy < 0 && Math.abs(dy) >= Math.abs(dx)) {
return getCircleIntersection(centerX, topCapY, capRadius, targetX, targetY, offset);
}
// Check if pointing toward bottom cap
else if (dy > 0 && Math.abs(dy) >= Math.abs(dx)) {
return getCircleIntersection(centerX, bottomCapY, capRadius, targetX, targetY, offset);
}
// Otherwise it's pointing toward left or right straight edge
else {
const side = dx < 0 ? -1 : 1;
const intersectX = centerX + side * capRadius;
// Calculate y position where line from target to center intersects the vertical edge
// Line equation: (y - centerY) / (x - centerX) = dy / dx
// Solving for y when x = intersectX: y = centerY + dy * (intersectX - centerX) / dx
let intersectY = Math.abs(dx) > 0.001
? centerY + dy * (intersectX - centerX) / dx
: centerY;
// Clamp intersection to the straight vertical segment between the caps
intersectY = Math.min(Math.max(intersectY, topCapY), bottomCapY);
const normalAngle = side < 0 ? Math.PI : 0;
return {
x: intersectX + side * offset,
y: intersectY,
angle: normalAngle,
};
}
}
}
/**
* Calculate intersection point with a rounded rectangle
* Handles corners as circular arcs with specified radius
*/
function getRoundedRectangleIntersection(
centerX: number,
centerY: number,
width: number,
height: number,
targetX: number,
targetY: number,
cornerRadius: number = ROUNDED_RECTANGLE_RADIUS,
offset: number = 2
): { x: number; y: number; angle: number } {
const w = width / 2;
const h = height / 2;
// Calculate basic rectangle intersection first
const dx = targetX - centerX;
const dy = targetY - centerY;
const xx1 = dx / (2 * w) - dy / (2 * h);
const yy1 = dx / (2 * w) + dy / (2 * h);
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
const xx3 = a * xx1;
const yy3 = a * yy1;
const x = w * (xx3 + yy3) + centerX;
const y = h * (-xx3 + yy3) + centerY;
// Determine which edge the intersection is on
const leftEdge = centerX - w;
const rightEdge = centerX + w;
const topEdge = centerY - h;
const bottomEdge = centerY + h;
// Check if intersection is near a corner (within corner radius distance from corner)
const isNearTopLeft = x < leftEdge + cornerRadius && y < topEdge + cornerRadius;
const isNearTopRight = x > rightEdge - cornerRadius && y < topEdge + cornerRadius;
const isNearBottomLeft = x < leftEdge + cornerRadius && y > bottomEdge - cornerRadius;
const isNearBottomRight = x > rightEdge - cornerRadius && y > bottomEdge - cornerRadius;
if (isNearTopLeft) {
// Top-left corner - circular arc
const cornerCenterX = leftEdge + cornerRadius;
const cornerCenterY = topEdge + cornerRadius;
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
} else if (isNearTopRight) {
// Top-right corner - circular arc
const cornerCenterX = rightEdge - cornerRadius;
const cornerCenterY = topEdge + cornerRadius;
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
} else if (isNearBottomLeft) {
// Bottom-left corner - circular arc
const cornerCenterX = leftEdge + cornerRadius;
const cornerCenterY = bottomEdge - cornerRadius;
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
} else if (isNearBottomRight) {
// Bottom-right corner - circular arc
const cornerCenterX = rightEdge - cornerRadius;
const cornerCenterY = bottomEdge - cornerRadius;
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
}
// Straight edge - use rectangle calculation
const angle = Math.atan2(y - centerY, x - centerX);
const offsetX = x + Math.cos(angle) * offset;
const offsetY = y + Math.sin(angle) * offset;
return { x: offsetX, y: offsetY, angle };
}
/**
* Calculate the intersection point between a line and a node shape
* Returns the intersection point and the normal angle at that point
*/
function getNodeIntersection(
intersectionNode: Node,
targetNode: Node,
intersectionShape: NodeShape = 'rectangle',
offset: number = 2
): { x: number; y: number; angle: number } {
// Use positionAbsolute for correct positioning of nodes inside groups
// positionAbsolute accounts for parent group offset, while position is relative
// @ts-expect-error - internals.positionAbsolute exists at runtime but not in public types
const intersectionNodePosition = intersectionNode.internals?.positionAbsolute ?? intersectionNode.position;
// @ts-expect-error - internals.positionAbsolute exists at runtime but not in public types
const targetPosition = targetNode.internals?.positionAbsolute ?? targetNode.position;
// Use measured dimensions from React Flow (stored in node.measured)
// If undefined, node hasn't been measured yet - return center
const intersectionNodeWidth = intersectionNode.measured?.width ?? intersectionNode.width;
const intersectionNodeHeight = intersectionNode.measured?.height ?? intersectionNode.height;
const targetNodeWidth = targetNode.measured?.width ?? targetNode.width;
const targetNodeHeight = targetNode.measured?.height ?? targetNode.height;
if (!intersectionNodeWidth || !intersectionNodeHeight || !targetNodeWidth || !targetNodeHeight) {
const centerX = intersectionNodePosition.x + (intersectionNodeWidth ?? 0) / 2;
const centerY = intersectionNodePosition.y + (intersectionNodeHeight ?? 0) / 2;
return { x: centerX, y: centerY, angle: 0 };
}
// Calculate centers
const intersectionCenterX = intersectionNodePosition.x + intersectionNodeWidth / 2;
const intersectionCenterY = intersectionNodePosition.y + intersectionNodeHeight / 2;
const targetCenterX = targetPosition.x + targetNodeWidth / 2;
const targetCenterY = targetPosition.y + targetNodeHeight / 2;
// Handle different shapes
if (intersectionShape === 'circle') {
// Use minimum dimension as radius for perfect circle
const radius = Math.min(intersectionNodeWidth, intersectionNodeHeight) / 2;
return getCircleIntersection(
intersectionCenterX,
intersectionCenterY,
radius,
targetCenterX,
targetCenterY,
offset
);
} else if (intersectionShape === 'pill') {
// Pill shape has rounded caps and straight sides
return getPillIntersection(
intersectionCenterX,
intersectionCenterY,
intersectionNodeWidth,
intersectionNodeHeight,
targetCenterX,
targetCenterY,
offset
);
} else if (intersectionShape === 'ellipse') {
// For ellipse, use width/height as radii
const radiusX = intersectionNodeWidth / 2;
const radiusY = intersectionNodeHeight / 2;
return getEllipseIntersection(
intersectionCenterX,
intersectionCenterY,
radiusX,
radiusY,
targetCenterX,
targetCenterY,
offset
);
} else if (intersectionShape === 'roundedRectangle') {
// Rounded rectangle with circular corner arcs
return getRoundedRectangleIntersection(
intersectionCenterX,
intersectionCenterY,
intersectionNodeWidth,
intersectionNodeHeight,
targetCenterX,
targetCenterY,
ROUNDED_RECTANGLE_RADIUS,
offset
);
} else {
// Rectangle uses the original algorithm with offset
const w = intersectionNodeWidth / 2;
const h = intersectionNodeHeight / 2;
const x2 = intersectionCenterX;
const y2 = intersectionCenterY;
const x1 = targetCenterX;
const y1 = targetCenterY;
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
const xx3 = a * xx1;
const yy3 = a * yy1;
const x = w * (xx3 + yy3) + x2;
const y = h * (-xx3 + yy3) + y2;
// Calculate normal angle for rectangle edges
const dx = x - x2;
const dy = y - y2;
const angle = Math.atan2(dy, dx);
// Apply offset
const offsetX = x + Math.cos(angle) * offset;
const offsetY = y + Math.sin(angle) * offset;
return { x: offsetX, y: offsetY, angle };
}
}
/**
* Calculate the parameters for a self-loop edge (self-reference)
* Creates a curved loop that goes above the node
* Uses shape-aware intersection points for proper rendering
* @param node - The node that references itself
* @param offsetMultiplier - Offset multiplier for parallel self-loops
* @param sourceShape - Shape of the node
*/
function getSelfLoopParams(
node: Node,
offsetMultiplier: number,
sourceShape: NodeShape
): {
sx: number;
sy: number;
tx: number;
ty: number;
sourceControlX: number;
sourceControlY: number;
targetControlX: number;
targetControlY: number;
sourceAngle: number;
targetAngle: number;
} {
// Use positionAbsolute for correct positioning of nodes inside groups
// @ts-expect-error - internals.positionAbsolute exists at runtime but not in public types
const nodePosition = node.internals?.positionAbsolute ?? node.position;
const nodeWidth = node.measured?.width ?? node.width ?? DEFAULT_ACTOR_WIDTH;
const nodeHeight = node.measured?.height ?? node.height ?? DEFAULT_ACTOR_HEIGHT;
const centerX = nodePosition.x + nodeWidth / 2;
const centerY = nodePosition.y + nodeHeight / 2;
const topEdge = nodePosition.y;
const rightEdge = nodePosition.x + nodeWidth;
// Calculate loop level for progressive widening/taller loops.
// offsetMultiplier values from calculateEdgeOffsetMultiplier:
// 1 edge: 0
// 2 edges: -0.5, 0.5
// 3 edges: -1, 0, 1
// 4 edges: -1.5, -0.5, 0.5, 1.5
// 5 edges: -2, -1, 0, 1, 2
// Shift by +2 so the center/innermost loop starts at level 2 with a comfortable default
// offset and arc height. Clamp to 0 for 6+ parallel loops rather than going negative.
const roundedMultiplier = Math.round(offsetMultiplier * 2) / 2;
const loopLevel = Math.max(0, roundedMultiplier + 2);
// loopOffset controls how far the exit/entry points spread from the top-right corner.
// Lower loopLevel → smaller loopOffset → points close together near the corner → short arc.
// Higher loopLevel → larger loopOffset → points spread along edges → tall arc.
const baseOffset = 15;
const offsetIncrement = 20;
const loopOffset = baseOffset + loopLevel * offsetIncrement;
// Source exits from the top edge, measured leftward from the top-right corner.
// Target enters from the right edge, measured downward from the top-right corner.
// This anchors the innermost loop tightly at the corner (label closest to node)
// and fans outer loops out along the edges.
let sx, sy, tx, ty, sourceAngle, targetAngle;
if (sourceShape === 'circle') {
const radius = Math.min(nodeWidth, nodeHeight) / 2;
// Anchor both points near the top-right of the circle and fan outward with loopOffset.
// angleSpread controls how far source/target diverge from the top-right (angle = -PI/4).
// Capped at PI/4 so source never exceeds -PI/2 (top) and target never exceeds 0 (right).
const angleSpread = Math.min(Math.PI / 4, (loopOffset / (radius * 2)) * (Math.PI / 2));
const sourceAngleRad = -Math.PI / 4 - angleSpread; // toward top
const targetAngleRad = -Math.PI / 4 + angleSpread; // toward right
sx = centerX + Math.cos(sourceAngleRad) * (radius + 2);
sy = centerY + Math.sin(sourceAngleRad) * (radius + 2);
tx = centerX + Math.cos(targetAngleRad) * (radius + 2);
ty = centerY + Math.sin(targetAngleRad) * (radius + 2);
sourceAngle = sourceAngleRad;
targetAngle = targetAngleRad;
} else if (sourceShape === 'pill') {
const isHorizontal = nodeWidth >= nodeHeight;
const capRadius = isHorizontal ? nodeHeight / 2 : nodeWidth / 2;
if (isHorizontal) {
// Horizontal pill: source on top straight edge (left of corner), target on right cap (below corner)
sx = rightEdge - loopOffset * 0.8;
sy = topEdge;
sourceAngle = -Math.PI / 2;
const rightCapCenterX = nodePosition.x + nodeWidth - capRadius;
tx = rightCapCenterX + capRadius + 2;
ty = topEdge + loopOffset * 0.5;
targetAngle = 0;
} else {
// Vertical pill: source on top cap (near right), target on right straight edge (near top)
const topCapCenterY = nodePosition.y + capRadius;
const sourceAngleRad = -Math.PI / 2 + (loopOffset / nodeWidth) * Math.PI / 4;
sx = centerX + Math.cos(sourceAngleRad) * (capRadius + 2);
sy = topCapCenterY + Math.sin(sourceAngleRad) * (capRadius + 2);
sourceAngle = sourceAngleRad;
tx = rightEdge;
ty = topEdge + loopOffset * 0.5;
targetAngle = 0;
}
} else if (sourceShape === 'ellipse') {
const radiusX = nodeWidth / 2;
const radiusY = nodeHeight / 2;
// Source: near top of ellipse, rotated slightly CW (toward right) by loopOffset.
// In screen coords (y increases down) the top is at theta = -PI/2.
const sourceTheta = -Math.PI / 2 + (loopOffset / nodeWidth) * 0.8;
const sourcePx = centerX + radiusX * Math.cos(sourceTheta);
const sourcePy = centerY + radiusY * Math.sin(sourceTheta);
const sourceNormalX = Math.cos(sourceTheta) / radiusX;
const sourceNormalY = Math.sin(sourceTheta) / radiusY;
const sourceNormalLen = Math.sqrt(sourceNormalX * sourceNormalX + sourceNormalY * sourceNormalY);
sourceAngle = Math.atan2(sourceNormalY / sourceNormalLen, sourceNormalX / sourceNormalLen);
sx = sourcePx + Math.cos(sourceAngle) * 2;
sy = sourcePy + Math.sin(sourceAngle) * 2;
// Target: near right side of ellipse, rotated slightly CW (downward) by loopOffset.
const targetTheta = (loopOffset / nodeHeight) * 0.5;
const targetPx = centerX + radiusX * Math.cos(targetTheta);
const targetPy = centerY + radiusY * Math.sin(targetTheta);
const targetNormalX = Math.cos(targetTheta) / radiusX;
const targetNormalY = Math.sin(targetTheta) / radiusY;
const targetNormalLen = Math.sqrt(targetNormalX * targetNormalX + targetNormalY * targetNormalY);
targetAngle = Math.atan2(targetNormalY / targetNormalLen, targetNormalX / targetNormalLen);
tx = targetPx + Math.cos(targetAngle) * 2;
ty = targetPy + Math.sin(targetAngle) * 2;
} else {
// Rectangle and roundedRectangle: source on top edge (left of corner), target on right edge (below corner)
sx = rightEdge - loopOffset * 0.8;
sy = topEdge;
sourceAngle = -Math.PI / 2;
tx = rightEdge;
ty = topEdge + loopOffset * 0.5;
targetAngle = 0;
}
// Loop height grows with loopLevel so outer parallel loops arc progressively higher.
const baseLoopHeight = Math.max(nodeHeight * 1.5, 60);
const additionalHeight = loopLevel * 80;
const loopHeight = baseLoopHeight + additionalHeight;
// Control points extend upward along the normal direction
const sourceControlX = sx + Math.cos(sourceAngle) * loopHeight;
const sourceControlY = sy + Math.sin(sourceAngle) * loopHeight;
const targetControlX = tx + Math.cos(targetAngle) * loopHeight;
const targetControlY = ty + Math.sin(targetAngle) * loopHeight;
return {
sx,
sy,
tx,
ty,
sourceControlX,
sourceControlY,
targetControlX,
targetControlY,
sourceAngle,
targetAngle,
};
}
/**
* Calculate the parameters for a floating edge between two nodes
* Returns source/target coordinates with angles for smooth bezier curves
* @param sourceNode - Source node
* @param targetNode - Target node
* @param sourceShape - Shape of source node
* @param targetShape - Shape of target node
* @param offsetMultiplier - Multiplier for perpendicular offset (0 = no offset, ±1 = BASE_EDGE_OFFSET)
* @param parallelGroupKey - Normalized key for parallel group (for consistent offset direction)
*/
export function getFloatingEdgeParams(
sourceNode: Node,
targetNode: Node,
sourceShape: NodeShape = 'rectangle',
targetShape: NodeShape = 'rectangle',
offsetMultiplier: number = 0,
parallelGroupKey?: string
) {
// Handle self-referencing edges (self-loops)
if (sourceNode.id === targetNode.id) {
return getSelfLoopParams(sourceNode, offsetMultiplier, sourceShape);
}
const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape);
const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape);
// Calculate control point distance based on distance between nodes
const distance = Math.sqrt(
Math.pow(targetIntersection.x - sourceIntersection.x, 2) +
Math.pow(targetIntersection.y - sourceIntersection.y, 2)
);
// Use 40% of distance for more pronounced curves, with reasonable limits
const controlPointDistance = Math.min(Math.max(distance * 0.4, 40), 150);
// Calculate perpendicular offset if needed
let perpOffset = { x: 0, y: 0 };
if (offsetMultiplier !== 0) {
const offsetMagnitude = offsetMultiplier * BASE_EDGE_OFFSET;
// For parallel edges with a group key, calculate perpendicular based on normalized direction
// The offsetMultiplier already accounts for edge direction (assigned in GraphEditor)
let refSourceX = sourceIntersection.x;
let refSourceY = sourceIntersection.y;
let refTargetX = targetIntersection.x;
let refTargetY = targetIntersection.y;
// Always use the normalized direction for perpendicular calculation
if (parallelGroupKey) {
const [normalizedSourceId, normalizedTargetId] = parallelGroupKey.split('<->');
// Find the actual node positions for the normalized direction
if (sourceNode.id === normalizedSourceId && targetNode.id === normalizedTargetId) {
// This edge goes in normalized direction - use as-is
} else if (sourceNode.id === normalizedTargetId && targetNode.id === normalizedSourceId) {
// This edge goes in reverse direction - flip reference to use normalized direction
[refSourceX, refSourceY, refTargetX, refTargetY] = [refTargetX, refTargetY, refSourceX, refSourceY];
}
}
perpOffset = calculatePerpendicularOffset(
refSourceX,
refSourceY,
refTargetX,
refTargetY,
offsetMagnitude
);
}
// For parallel edges, use minimal endpoint offset to keep edges close to nodes
// The control points will create the visual separation
const endpointOffsetFactor = 0.1; // Minimal offset (10% of full offset)
const sourceEndpointOffset = offsetMultiplier !== 0 ? {
x: perpOffset.x * endpointOffsetFactor,
y: perpOffset.y * endpointOffsetFactor,
} : { x: 0, y: 0 };
const targetEndpointOffset = offsetMultiplier !== 0 ? {
x: perpOffset.x * endpointOffsetFactor,
y: perpOffset.y * endpointOffsetFactor,
} : { x: 0, y: 0 };
// Calculate control points using the normal angles, with perpendicular offset applied
const sourceControlX = sourceIntersection.x + Math.cos(sourceIntersection.angle) * controlPointDistance + perpOffset.x;
const sourceControlY = sourceIntersection.y + Math.sin(sourceIntersection.angle) * controlPointDistance + perpOffset.y;
const targetControlX = targetIntersection.x + Math.cos(targetIntersection.angle) * controlPointDistance + perpOffset.x;
const targetControlY = targetIntersection.y + Math.sin(targetIntersection.angle) * controlPointDistance + perpOffset.y;
return {
sx: sourceIntersection.x + sourceEndpointOffset.x,
sy: sourceIntersection.y + sourceEndpointOffset.y,
tx: targetIntersection.x + targetEndpointOffset.x,
ty: targetIntersection.y + targetEndpointOffset.y,
sourceControlX,
sourceControlY,
targetControlX,
targetControlY,
sourceAngle: sourceIntersection.angle,
targetAngle: targetIntersection.angle,
};
}
/**
* Creates a new relation/edge with default properties
*/
export const createEdge = (
source: string,
target: string,
type: string,
label?: string
): Relation => {
return {
id: generateEdgeId(source, target),
source,
target,
type: 'custom', // Using custom edge component
data: {
label,
type,
},
};
};
/**
* Validates edge data
*/
export const validateEdgeData = (data: RelationData): boolean => {
return !!data.type;
};