Add shape-aware edge connections and edge-only handles

Improvements:
- Edges now follow actual node shape contours (circle, ellipse, pill, rectangle)
- Smooth arrow rotation using normal vectors at intersection points
- Custom bezier curves with control points aligned to shape normals
- Edge-only handles (30px strips) leaving center free for node dragging
- Proper offset calculations to prevent edge-shape overlap

Technical changes:
- Add getCircleIntersection() for perfect circle geometry
- Add getEllipseIntersection() with gradient-based normals
- Add getPillIntersection() for stadium shape (rounded caps + straight sides)
- Update getFloatingEdgeParams() to accept and use node shapes
- CustomEdge determines shapes from nodeType config and creates custom bezier paths
- Replace full-node handles with 4 edge-positioned handles (top/right/bottom/left)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2026-01-24 16:03:34 +01:00
parent c9c888d0ac
commit 8d71da76b2
4 changed files with 424 additions and 119 deletions

View file

@ -1,7 +1,6 @@
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { import {
EdgeProps, EdgeProps,
getBezierPath,
EdgeLabelRenderer, EdgeLabelRenderer,
BaseEdge, BaseEdge,
useInternalNode, useInternalNode,
@ -33,13 +32,12 @@ const CustomEdge = ({
sourceY, sourceY,
targetX, targetX,
targetY, targetY,
sourcePosition,
targetPosition,
data, data,
selected, selected,
}: EdgeProps<Relation>) => { }: EdgeProps<Relation>) => {
const edgeTypes = useGraphStore((state) => state.edgeTypes); const edgeTypes = useGraphStore((state) => state.edgeTypes);
const labels = useGraphStore((state) => state.labels); const labels = useGraphStore((state) => state.labels);
const nodeTypes = useGraphStore((state) => state.nodeTypes);
// Get active filters based on mode (editing vs presentation) // Get active filters based on mode (editing vs presentation)
const filters = useActiveFilters(); const filters = useActiveFilters();
@ -48,34 +46,58 @@ const CustomEdge = ({
const sourceNode = useInternalNode(source); const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target); const targetNode = useInternalNode(target);
// Always use floating edges for easy-connect (dynamic border point calculation) // Determine node shapes from node type configuration
let finalSourceX = sourceX; const sourceShape = useMemo(() => {
let finalSourceY = sourceY; if (!sourceNode) return 'rectangle';
let finalTargetX = targetX; // Groups always use rectangle shape
let finalTargetY = targetY; if (sourceNode.type === 'group') return 'rectangle';
let finalSourcePosition = sourcePosition; const nodeData = sourceNode.data as { type?: string };
let finalTargetPosition = targetPosition; const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeData?.type);
return nodeTypeConfig?.shape || 'rectangle';
}, [sourceNode, nodeTypes]);
// Use floating edge calculations for ALL edges to get smart border connection const targetShape = useMemo(() => {
if (sourceNode && targetNode) { if (!targetNode) return 'rectangle';
const floatingParams = getFloatingEdgeParams(sourceNode, targetNode); // Groups always use rectangle shape
finalSourceX = floatingParams.sx; if (targetNode.type === 'group') return 'rectangle';
finalSourceY = floatingParams.sy; const nodeData = targetNode.data as { type?: string };
finalSourcePosition = floatingParams.sourcePos; const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeData?.type);
finalTargetX = floatingParams.tx; return nodeTypeConfig?.shape || 'rectangle';
finalTargetY = floatingParams.ty; }, [targetNode, nodeTypes]);
finalTargetPosition = floatingParams.targetPos;
// Calculate floating edge parameters with custom bezier control points
const edgeParams = useMemo(() => {
if (!sourceNode || !targetNode) {
// Fallback to default React Flow positioning
return {
edgePath: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
labelX: (sourceX + targetX) / 2,
labelY: (sourceY + targetY) / 2,
};
} }
// Calculate the bezier path const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape);
const [edgePath, labelX, labelY] = getBezierPath({
sourceX: finalSourceX, // Create cubic bezier path using custom control points
sourceY: finalSourceY, const edgePath = `M ${params.sx},${params.sy} C ${params.sourceControlX},${params.sourceControlY} ${params.targetControlX},${params.targetControlY} ${params.tx},${params.ty}`;
sourcePosition: finalSourcePosition,
targetX: finalTargetX, // Calculate label position at midpoint of the bezier curve (t=0.5)
targetY: finalTargetY, const t = 0.5;
targetPosition: finalTargetPosition, const labelX =
}); Math.pow(1 - t, 3) * params.sx +
3 * Math.pow(1 - t, 2) * t * params.sourceControlX +
3 * (1 - t) * Math.pow(t, 2) * params.targetControlX +
Math.pow(t, 3) * params.tx;
const labelY =
Math.pow(1 - t, 3) * params.sy +
3 * Math.pow(1 - t, 2) * t * params.sourceControlY +
3 * (1 - t) * Math.pow(t, 2) * params.targetControlY +
Math.pow(t, 3) * params.ty;
return { edgePath, labelX, labelY };
}, [sourceNode, targetNode, sourceShape, targetShape, sourceX, sourceY, targetX, targetY]);
const { edgePath, labelX, labelY } = edgeParams;
// Check if this is an aggregated edge // Check if this is an aggregated edge
const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount; const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount;

View file

@ -9,7 +9,10 @@ import { getIconComponent } from "../../utils/iconUtils";
import type { Actor } from "../../types"; import type { Actor } from "../../types";
import NodeShapeRenderer from "./Shapes/NodeShapeRenderer"; import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
import LabelBadge from "../Common/LabelBadge"; import LabelBadge from "../Common/LabelBadge";
import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilters"; import {
useActiveFilters,
nodeMatchesFilters,
} from "../../hooks/useActiveFilters";
/** /**
* CustomNode - Represents an actor in the constellation graph * CustomNode - Represents an actor in the constellation graph
@ -50,7 +53,7 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
data.label || "", data.label || "",
data.description || "", data.description || "",
nodeLabel, nodeLabel,
filters filters,
); );
}, [ }, [
data.type, data.type,
@ -78,39 +81,73 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
opacity: nodeOpacity, opacity: nodeOpacity,
}} }}
> >
{/* Invisible handles for easy-connect - floating edges calculate actual connection points */} {/* Invisible handles positioned around edges - center remains free for dragging */}
{/* Target handle - full node coverage for incoming connections */} {/* Top edge handle */}
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
isConnectable={true} isConnectable={true}
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "30px",
top: 0, top: 0,
left: 0, left: 0,
borderRadius: 0,
opacity: 0, opacity: 0,
border: 'none', border: "none",
background: 'transparent', background: "transparent",
transform: 'none', transform: "none",
cursor: "crosshair",
}} }}
/> />
{/* Source handle - full node coverage for outgoing connections */} {/* Right edge handle */}
<Handle
type="source"
position={Position.Right}
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0,
right: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Bottom edge handle */}
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
isConnectable={true} isConnectable={true}
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "30px",
bottom: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Left edge handle */}
<Handle
type="target"
position={Position.Left}
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0, top: 0,
left: 0, left: 0,
borderRadius: 0,
opacity: 0, opacity: 0,
border: 'none', border: "none",
background: 'transparent', background: "transparent",
transform: 'none', transform: "none",
cursor: "crosshair",
}} }}
/> />

View file

@ -220,39 +220,73 @@ const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
position: 'relative', position: 'relative',
}} }}
> >
{/* Invisible handles for easy-connect - floating edges calculate actual connection points */} {/* Invisible handles positioned around edges - center remains free for dragging */}
{/* Target handle - full node coverage for incoming connections */} {/* Top edge handle */}
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
isConnectable={true} isConnectable={true}
style={{ style={{
width: '100%', width: '100%',
height: '100%', height: '30px',
top: 0, top: 0,
left: 0, left: 0,
borderRadius: 0,
opacity: 0, opacity: 0,
border: 'none', border: 'none',
background: 'transparent', background: 'transparent',
transform: 'none', transform: 'none',
cursor: 'crosshair',
}} }}
/> />
{/* Source handle - full node coverage for outgoing connections */} {/* Right edge handle */}
<Handle
type="source"
position={Position.Right}
isConnectable={true}
style={{
width: '30px',
height: '100%',
top: 0,
right: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
{/* Bottom edge handle */}
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
isConnectable={true} isConnectable={true}
style={{ style={{
width: '100%', width: '100%',
height: '100%', height: '30px',
top: 0, bottom: 0,
left: 0, left: 0,
borderRadius: 0,
opacity: 0, opacity: 0,
border: 'none', border: 'none',
background: 'transparent', background: 'transparent',
transform: 'none', transform: 'none',
cursor: 'crosshair',
}}
/>
{/* Left edge handle */}
<Handle
type="target"
position={Position.Left}
isConnectable={true}
style={{
width: '30px',
height: '100%',
top: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}} }}
/> />

View file

@ -1,6 +1,5 @@
import type { Relation, RelationData } from '../types'; import type { Relation, RelationData, NodeShape } from '../types';
import type { Node } from '@xyflow/react'; import type { Node } from '@xyflow/react';
import { Position } from '@xyflow/react';
/** /**
* Generates a unique ID for edges * Generates a unique ID for edges
@ -10,10 +9,186 @@ export const generateEdgeId = (source: string, target: string): string => {
}; };
/** /**
* Calculate the intersection point between a line and a rectangle * Calculate intersection point with a circle
* Used for floating edges to connect at the closest point on the node * Returns both the intersection point and the normal vector (outward direction)
*/ */
function getNodeIntersection(intersectionNode: Node, targetNode: Node) { function getCircleIntersection(
centerX: number,
centerY: number,
radius: number,
targetX: number,
targetY: number,
offset: number = 3
): { x: number; y: number; angle: number } {
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 } {
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
const intersectX = Math.abs(dy) > 0.001
? centerX + dx * (intersectY - centerY) / dy
: centerX;
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
const intersectY = Math.abs(dx) > 0.001
? centerY + dy * (intersectX - centerX) / dx
: centerY;
const normalAngle = side < 0 ? Math.PI : 0;
return {
x: intersectX + side * offset,
y: intersectY,
angle: normalAngle,
};
}
}
}
/**
* 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 // Use positionAbsolute for correct positioning of nodes inside groups
// positionAbsolute accounts for parent group offset, while position is relative // positionAbsolute accounts for parent group offset, while position is relative
// @ts-ignore - internals.positionAbsolute exists at runtime but not in public types // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types
@ -31,16 +206,60 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
if (!intersectionNodeWidth || !intersectionNodeHeight || !targetNodeWidth || !targetNodeHeight) { if (!intersectionNodeWidth || !intersectionNodeHeight || !targetNodeWidth || !targetNodeHeight) {
const centerX = intersectionNodePosition.x + (intersectionNodeWidth ?? 0) / 2; const centerX = intersectionNodePosition.x + (intersectionNodeWidth ?? 0) / 2;
const centerY = intersectionNodePosition.y + (intersectionNodeHeight ?? 0) / 2; const centerY = intersectionNodePosition.y + (intersectionNodeHeight ?? 0) / 2;
return { x: centerX, y: centerY }; 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 {
// Rectangle and roundedRectangle use the original algorithm with offset
const w = intersectionNodeWidth / 2; const w = intersectionNodeWidth / 2;
const h = intersectionNodeHeight / 2; const h = intersectionNodeHeight / 2;
const x2 = intersectionNodePosition.x + w; const x2 = intersectionCenterX;
const y2 = intersectionNodePosition.y + h; const y2 = intersectionCenterY;
const x1 = targetPosition.x + targetNodeWidth / 2; const x1 = targetCenterX;
const y1 = targetPosition.y + targetNodeHeight / 2; const y1 = targetCenterY;
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
@ -50,64 +269,57 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
const x = w * (xx3 + yy3) + x2; const x = w * (xx3 + yy3) + x2;
const y = h * (-xx3 + yy3) + y2; const y = h * (-xx3 + yy3) + y2;
return { x, y }; // Calculate normal angle for rectangle edges
} const dx = x - x2;
const dy = y - y2;
const angle = Math.atan2(dy, dx);
/** // Apply offset
* Get the position (top, right, bottom, left) of the handle based on the intersection point const offsetX = x + Math.cos(angle) * offset;
*/ const offsetY = y + Math.sin(angle) * offset;
function getEdgePosition(node: Node, intersectionPoint: { x: number; y: number }) {
// Use positionAbsolute for correct positioning of nodes inside groups
// @ts-ignore - internals.positionAbsolute exists at runtime but not in public types
const nodePosition = node.internals?.positionAbsolute ?? node.position;
const nx = Math.round(nodePosition.x);
const ny = Math.round(nodePosition.y);
const px = Math.round(intersectionPoint.x);
const py = Math.round(intersectionPoint.y);
// Use measured dimensions from React Flow (stored in node.measured) return { x: offsetX, y: offsetY, angle };
// If not available, default to Top
const width = node.measured?.width ?? node.width;
const height = node.measured?.height ?? node.height;
if (!width || !height) {
return Position.Top;
} }
if (px <= nx + 1) {
return Position.Left;
}
if (px >= nx + width - 1) {
return Position.Right;
}
if (py <= ny + 1) {
return Position.Top;
}
if (py >= ny + height - 1) {
return Position.Bottom;
}
return Position.Top;
} }
/** /**
* Calculate the parameters for a floating edge between two nodes * Calculate the parameters for a floating edge between two nodes
* Returns source/target coordinates and positions for dynamic edge routing * Returns source/target coordinates with angles for smooth bezier curves
*/ */
export function getFloatingEdgeParams(sourceNode: Node, targetNode: Node) { export function getFloatingEdgeParams(
const sourceIntersectionPoint = getNodeIntersection(sourceNode, targetNode); sourceNode: Node,
const targetIntersectionPoint = getNodeIntersection(targetNode, sourceNode); targetNode: Node,
sourceShape: NodeShape = 'rectangle',
targetShape: NodeShape = 'rectangle'
) {
const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape);
const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape);
const sourcePos = getEdgePosition(sourceNode, sourceIntersectionPoint); // Calculate control point distance based on distance between nodes
const targetPos = getEdgePosition(targetNode, targetIntersectionPoint); 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 control points using the normal angles
const sourceControlX = sourceIntersection.x + Math.cos(sourceIntersection.angle) * controlPointDistance;
const sourceControlY = sourceIntersection.y + Math.sin(sourceIntersection.angle) * controlPointDistance;
const targetControlX = targetIntersection.x + Math.cos(targetIntersection.angle) * controlPointDistance;
const targetControlY = targetIntersection.y + Math.sin(targetIntersection.angle) * controlPointDistance;
return { return {
sx: sourceIntersectionPoint.x, sx: sourceIntersection.x,
sy: sourceIntersectionPoint.y, sy: sourceIntersection.y,
tx: targetIntersectionPoint.x, tx: targetIntersection.x,
ty: targetIntersectionPoint.y, ty: targetIntersection.y,
sourcePos, sourceControlX,
targetPos, sourceControlY,
targetControlX,
targetControlY,
sourceAngle: sourceIntersection.angle,
targetAngle: targetIntersection.angle,
}; };
} }