mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-03-13 12:08:46 +00:00
Compare commits
No commits in common. "4322f3cf634bf89dd1fa78f253e675178c05bbc5" and "4e1f19c82b2afb86301bcbb71d4e6b87fd7c26a3" have entirely different histories.
4322f3cf63
...
4e1f19c82b
3 changed files with 8 additions and 332 deletions
|
|
@ -304,27 +304,14 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
|||
// Sort edges by direction: edges going in normalized direction first, then reverse
|
||||
const [normalizedSource, normalizedTarget] = groupKey.split('<->');
|
||||
|
||||
// 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 edgesInNormalizedDirection = group.edges.filter(
|
||||
e => e.source === normalizedSource && e.target === normalizedTarget
|
||||
);
|
||||
const edgesInReverseDirection = group.edges.filter(
|
||||
e => e.source === normalizedTarget && e.target === normalizedSource
|
||||
);
|
||||
|
||||
// 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];
|
||||
}
|
||||
const sortedEdges = [...edgesInNormalizedDirection, ...edgesInReverseDirection];
|
||||
|
||||
sortedEdges.forEach((edge, index) => {
|
||||
const multiplier = calculateEdgeOffsetMultiplier(index, totalEdges);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ import {
|
|||
calculatePerpendicularOffset,
|
||||
groupParallelEdges,
|
||||
generateEdgeId,
|
||||
getFloatingEdgeParams,
|
||||
} from './edgeUtils';
|
||||
import type { Relation } from '../types';
|
||||
import type { Node } from '@xyflow/react';
|
||||
|
||||
describe('edgeUtils', () => {
|
||||
describe('generateEdgeId', () => {
|
||||
|
|
@ -252,135 +250,5 @@ 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)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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';
|
||||
import { ROUNDED_RECTANGLE_RADIUS } from '../constants';
|
||||
|
||||
/**
|
||||
* Generates a unique ID for edges using crypto.randomUUID()
|
||||
|
|
@ -494,180 +494,6 @@ 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
|
||||
* Returns source/target coordinates with angles for smooth bezier curves
|
||||
|
|
@ -686,11 +512,6 @@ 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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue