Compare commits

..

2 commits

Author SHA1 Message Date
4322f3cf63 Fix pill shape self-loop geometry to follow cap curvature
Some checks failed
CI / test (push) Has been cancelled
Previously the pill branches behaved like rectangles: the target point
was pinned to the rightmost cap point with a linearly growing y, and the
vertical pill source started from the wrong end of the cap.

- Horizontal pill: target now travels along the right cap surface via
  a cap angle (-PI/2 → 0), so both tx/ty stay on the curve
- Horizontal pill: source clamped to the straight top section so it
  never crosses into the left cap area
- Vertical pill: source now starts at the corner (angle=0, rightmost
  point of top cap) and rotates toward the top as loopOffset grows,
  matching the corner-anchoring used by other shapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 15:38:14 +01:00
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
3 changed files with 332 additions and 8 deletions

View file

@ -304,14 +304,27 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
// Sort edges by direction: edges going in normalized direction first, then reverse // Sort edges by direction: edges going in normalized direction first, then reverse
const [normalizedSource, normalizedTarget] = groupKey.split('<->'); const [normalizedSource, normalizedTarget] = groupKey.split('<->');
const edgesInNormalizedDirection = group.edges.filter( // For self-references (source === target), all edges go in one group
e => e.source === normalizedSource && e.target === normalizedTarget // For regular edges, separate by direction
); let sortedEdges: Relation[];
const edgesInReverseDirection = group.edges.filter( if (normalizedSource === normalizedTarget) {
e => e.source === normalizedTarget && e.target === normalizedSource // 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) => { sortedEdges.forEach((edge, index) => {
const multiplier = calculateEdgeOffsetMultiplier(index, totalEdges); const multiplier = calculateEdgeOffsetMultiplier(index, totalEdges);

View file

@ -4,8 +4,10 @@ import {
calculatePerpendicularOffset, calculatePerpendicularOffset,
groupParallelEdges, groupParallelEdges,
generateEdgeId, generateEdgeId,
getFloatingEdgeParams,
} from './edgeUtils'; } from './edgeUtils';
import type { Relation } from '../types'; import type { Relation } from '../types';
import type { Node } from '@xyflow/react';
describe('edgeUtils', () => { describe('edgeUtils', () => {
describe('generateEdgeId', () => { describe('generateEdgeId', () => {
@ -250,5 +252,135 @@ describe('edgeUtils', () => {
expect(key1).toBe(key2); // Should produce same normalized key 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)
});
}); });
}); });

View file

@ -1,6 +1,6 @@
import type { Relation, RelationData, NodeShape } from '../types'; import type { Relation, RelationData, NodeShape } from '../types';
import type { Node } from '@xyflow/react'; 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() * Generates a unique ID for edges using crypto.randomUUID()
@ -494,6 +494,180 @@ 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) {
// The top-right corner is where the top straight edge meets the right semicircle cap.
// Source: spreads leftward along the top straight edge from the corner.
// Target: spreads downward along the right cap surface from the top of the cap.
const rightCapCenterX = nodePosition.x + nodeWidth - capRadius;
sx = Math.max(nodePosition.x + capRadius, rightCapCenterX - loopOffset * 0.6);
sy = topEdge;
sourceAngle = -Math.PI / 2;
// Angle on the right cap: -PI/2 = top of cap (corner), 0 = rightmost point.
// Innermost loop targets near the top; outer loops rotate toward the right.
const targetCapAngle = -Math.PI / 2 + Math.min(Math.PI / 2, (loopOffset / capRadius) * 0.8);
tx = rightCapCenterX + Math.cos(targetCapAngle) * (capRadius + 2);
ty = centerY + Math.sin(targetCapAngle) * (capRadius + 2);
targetAngle = targetCapAngle;
} else {
// The top-right corner is where the right straight edge meets the top semicircle cap.
// Source: spreads upward along the top cap from the corner.
// Target: spreads downward along the right straight edge from the corner.
const topCapCenterY = nodePosition.y + capRadius;
// Angle on the top cap: 0 = rightmost point of cap (corner), -PI/2 = top of cap.
// Innermost loop sources near the corner; outer loops rotate toward the top.
const sourceCapAngle = Math.max(-Math.PI / 2, -(loopOffset / capRadius) * 0.8);
sx = centerX + Math.cos(sourceCapAngle) * (capRadius + 2);
sy = topCapCenterY + Math.sin(sourceCapAngle) * (capRadius + 2);
sourceAngle = sourceCapAngle;
tx = rightEdge;
ty = topCapCenterY + 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 * Calculate the parameters for a floating edge between two nodes
* Returns source/target coordinates with angles for smooth bezier curves * Returns source/target coordinates with angles for smooth bezier curves
@ -512,6 +686,11 @@ export function getFloatingEdgeParams(
offsetMultiplier: number = 0, offsetMultiplier: number = 0,
parallelGroupKey?: string 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 sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape);
const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape); const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape);