mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-03-13 12:08:46 +00:00
- 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>
786 lines
28 KiB
TypeScript
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;
|
|
};
|