diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index c540072..2231a1e 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -304,14 +304,27 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG // Sort edges by direction: edges going in normalized direction first, then reverse const [normalizedSource, normalizedTarget] = groupKey.split('<->'); - const edgesInNormalizedDirection = group.edges.filter( - e => e.source === normalizedSource && e.target === normalizedTarget - ); - const edgesInReverseDirection = group.edges.filter( - e => e.source === normalizedTarget && e.target === normalizedSource - ); + // For self-references (source === target), all edges go in one group + // For regular edges, separate by direction + let sortedEdges: Relation[]; + if (normalizedSource === normalizedTarget) { + // Self-reference: just sort by ID for deterministic ordering + sortedEdges = [...group.edges].sort((a, b) => a.id.localeCompare(b.id)); + } else { + // Regular edges: separate by direction + const edgesInNormalizedDirection = group.edges.filter( + e => e.source === normalizedSource && e.target === normalizedTarget + ); + const edgesInReverseDirection = group.edges.filter( + e => e.source === normalizedTarget && e.target === normalizedSource + ); - const sortedEdges = [...edgesInNormalizedDirection, ...edgesInReverseDirection]; + // Sort each group by edge ID for deterministic ordering + edgesInNormalizedDirection.sort((a, b) => a.id.localeCompare(b.id)); + edgesInReverseDirection.sort((a, b) => a.id.localeCompare(b.id)); + + sortedEdges = [...edgesInNormalizedDirection, ...edgesInReverseDirection]; + } sortedEdges.forEach((edge, index) => { const multiplier = calculateEdgeOffsetMultiplier(index, totalEdges); diff --git a/src/utils/edgeUtils.test.ts b/src/utils/edgeUtils.test.ts index d1a1321..03dbb3f 100644 --- a/src/utils/edgeUtils.test.ts +++ b/src/utils/edgeUtils.test.ts @@ -4,8 +4,10 @@ import { calculatePerpendicularOffset, groupParallelEdges, generateEdgeId, + getFloatingEdgeParams, } from './edgeUtils'; import type { Relation } from '../types'; +import type { Node } from '@xyflow/react'; describe('edgeUtils', () => { describe('generateEdgeId', () => { @@ -250,5 +252,135 @@ describe('edgeUtils', () => { expect(key1).toBe(key2); // Should produce same normalized key }); + + it('should group self-referencing edges together', () => { + const edges = [ + createMockEdge('e1', 'n1', 'n1'), + createMockEdge('e2', 'n1', 'n1'), + createMockEdge('e3', 'n1', 'n1'), + ]; + + const result = groupParallelEdges(edges); + + expect(result.size).toBe(1); // All self-references should be in one group + const group = Array.from(result.values())[0]; + expect(group.edges).toHaveLength(3); + const key = Array.from(result.keys())[0]; + expect(key).toBe('n1<->n1'); // Self-reference key + }); + }); + + describe('getFloatingEdgeParams - self-references', () => { + const createMockNode = (id: string, x: number, y: number, width: number = 150, height: number = 80): Node => ({ + id, + position: { x, y }, + width, + height, + measured: { width, height }, + selected: false, + dragging: false, + type: 'custom', + data: {}, + internals: { + positionAbsolute: { x, y }, + handleBounds: { source: [], target: [] }, + }, + } as Node); + + it('should detect self-reference and create loop', () => { + const node = createMockNode('node-1', 100, 100); + + const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0); + + // Source should be at top of node, target at right side + expect(result.sy).toBe(100); // Source at top edge + expect(result.tx).toBe(250); // Target at right edge (100 + 150) + // Control points should extend outward + expect(result.sourceControlY).toBeLessThan(result.sy); // Control point above + expect(result.targetControlX).toBeGreaterThan(result.tx); // Control point to the right + }); + + it('should create loop with correct height based on node dimensions', () => { + const node = createMockNode('node-1', 100, 100, 200, 100); + + const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0); + + const loopHeight = result.sy - result.sourceControlY; + // Should be at least 1.5x node height = 150px (may be more due to shape calculations) + expect(loopHeight).toBeGreaterThanOrEqual(150); + }); + + it('should apply horizontal offset for parallel self-loops', () => { + const node = createMockNode('node-1', 100, 100, 150, 80); + + const result1 = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0); + const result2 = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 1); + const result3 = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', -1); + + // Source should be at top, target at right side + const rightEdge = 250; // 100 + 150 + + expect(result1.sy).toBe(100); // Source at top + expect(result1.tx).toBe(rightEdge); // Target at right + + // Outer loops (±1) should arc higher than the center loop (0) + const height1 = Math.abs(result1.sourceControlY - result1.sy); + const height2 = Math.abs(result2.sourceControlY - result2.sy); + const height3 = Math.abs(result3.sourceControlY - result3.sy); + + // offsetMultiplier=1 → loopLevel=3, offsetMultiplier=0 → loopLevel=2, offsetMultiplier=-1 → loopLevel=1 + expect(height2).toBeGreaterThan(height1); // Outer loop (level 3) taller than center (level 2) + expect(height1).toBeGreaterThan(height3); // Center (level 2) taller than inner (level 1) + + // Spreads should be reasonable + expect(height1).toBeLessThan(500); + expect(height2).toBeLessThan(500); + }); + + it('should use shape-aware angles for self-loop', () => { + const node = createMockNode('node-1', 100, 100); + + const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0); + + // Source angle should point upward (from top of node) + // Target angle should point rightward (from right side of node) + expect(result.sourceAngle).toBeCloseTo(-Math.PI / 2, 0); // Upward + expect(result.targetAngle).toBeCloseTo(0, 0); // Rightward + }); + + it('should create loop with source at top and target at right', () => { + const node = createMockNode('node-1', 100, 100, 150, 80); + + const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0); + + // Source should be near top of node + expect(result.sy).toBe(100); + // Target should be at right edge of node + expect(result.tx).toBe(250); // 100 + 150 + // offsetMultiplier=0 → loopLevel=2, loopOffset=55 → sx = rightEdge - 55*0.8 = 250 - 44 = 206 + expect(result.sx).toBeCloseTo(206, 0); + }); + + it('should handle nodes with minimum loop height', () => { + const node = createMockNode('node-1', 100, 100, 150, 20); + + const result = getFloatingEdgeParams(node, node, 'rectangle', 'rectangle', 0); + + const loopHeight = result.sy - result.sourceControlY; + // Node height is 20, 1.5x = 30, but minimum is 60 + expect(loopHeight).toBeGreaterThanOrEqual(60); + }); + + it('should not affect regular edges between different nodes', () => { + const sourceNode = createMockNode('node-1', 100, 100); + const targetNode = createMockNode('node-2', 300, 100); + + const result = getFloatingEdgeParams(sourceNode, targetNode, 'rectangle', 'rectangle', 0); + + // Should use normal edge calculation + expect(result.sx).not.toBe(result.tx); + expect(result.sy).toBe(result.ty); // Same Y for horizontal edge + // Control points create a slight curve (not necessarily above or below) + }); }); }); diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index a828aee..c6f4535 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -1,6 +1,6 @@ import type { Relation, RelationData, NodeShape } from '../types'; import type { Node } from '@xyflow/react'; -import { ROUNDED_RECTANGLE_RADIUS } from '../constants'; +import { ROUNDED_RECTANGLE_RADIUS, DEFAULT_ACTOR_WIDTH, DEFAULT_ACTOR_HEIGHT } from '../constants'; /** * Generates a unique ID for edges using crypto.randomUUID() @@ -494,6 +494,169 @@ function getNodeIntersection( } } +/** + * 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 @@ -512,6 +675,11 @@ export function getFloatingEdgeParams( 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);