From 8d71da76b22d58888f2a743864b97bb8d90431b7 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 24 Jan 2026 16:03:34 +0100 Subject: [PATCH] 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 --- src/components/Edges/CustomEdge.tsx | 80 ++++--- src/components/Nodes/CustomNode.tsx | 71 ++++-- src/components/Nodes/GroupNode.tsx | 50 +++- src/utils/edgeUtils.ts | 342 ++++++++++++++++++++++------ 4 files changed, 424 insertions(+), 119 deletions(-) diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index 3f5362c..8457ee5 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -1,7 +1,6 @@ import { memo, useMemo } from 'react'; import { EdgeProps, - getBezierPath, EdgeLabelRenderer, BaseEdge, useInternalNode, @@ -33,13 +32,12 @@ const CustomEdge = ({ sourceY, targetX, targetY, - sourcePosition, - targetPosition, data, selected, }: EdgeProps) => { const edgeTypes = useGraphStore((state) => state.edgeTypes); const labels = useGraphStore((state) => state.labels); + const nodeTypes = useGraphStore((state) => state.nodeTypes); // Get active filters based on mode (editing vs presentation) const filters = useActiveFilters(); @@ -48,34 +46,58 @@ const CustomEdge = ({ const sourceNode = useInternalNode(source); const targetNode = useInternalNode(target); - // Always use floating edges for easy-connect (dynamic border point calculation) - let finalSourceX = sourceX; - let finalSourceY = sourceY; - let finalTargetX = targetX; - let finalTargetY = targetY; - let finalSourcePosition = sourcePosition; - let finalTargetPosition = targetPosition; + // Determine node shapes from node type configuration + const sourceShape = useMemo(() => { + if (!sourceNode) return 'rectangle'; + // Groups always use rectangle shape + if (sourceNode.type === 'group') return 'rectangle'; + const nodeData = sourceNode.data as { type?: string }; + 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 - if (sourceNode && targetNode) { - const floatingParams = getFloatingEdgeParams(sourceNode, targetNode); - finalSourceX = floatingParams.sx; - finalSourceY = floatingParams.sy; - finalSourcePosition = floatingParams.sourcePos; - finalTargetX = floatingParams.tx; - finalTargetY = floatingParams.ty; - finalTargetPosition = floatingParams.targetPos; - } + const targetShape = useMemo(() => { + if (!targetNode) return 'rectangle'; + // Groups always use rectangle shape + if (targetNode.type === 'group') return 'rectangle'; + const nodeData = targetNode.data as { type?: string }; + const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeData?.type); + return nodeTypeConfig?.shape || 'rectangle'; + }, [targetNode, nodeTypes]); - // Calculate the bezier path - const [edgePath, labelX, labelY] = getBezierPath({ - sourceX: finalSourceX, - sourceY: finalSourceY, - sourcePosition: finalSourcePosition, - targetX: finalTargetX, - targetY: finalTargetY, - targetPosition: finalTargetPosition, - }); + // 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, + }; + } + + const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape); + + // Create cubic bezier path using custom control points + const edgePath = `M ${params.sx},${params.sy} C ${params.sourceControlX},${params.sourceControlY} ${params.targetControlX},${params.targetControlY} ${params.tx},${params.ty}`; + + // Calculate label position at midpoint of the bezier curve (t=0.5) + const t = 0.5; + 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 const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount; diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index 005f21b..820302d 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -9,7 +9,10 @@ import { getIconComponent } from "../../utils/iconUtils"; import type { Actor } from "../../types"; import NodeShapeRenderer from "./Shapes/NodeShapeRenderer"; 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 @@ -50,7 +53,7 @@ const CustomNode = ({ data, selected }: NodeProps) => { data.label || "", data.description || "", nodeLabel, - filters + filters, ); }, [ data.type, @@ -78,39 +81,73 @@ const CustomNode = ({ data, selected }: NodeProps) => { opacity: nodeOpacity, }} > - {/* Invisible handles for easy-connect - floating edges calculate actual connection points */} - {/* Target handle - full node coverage for incoming connections */} + {/* Invisible handles positioned around edges - center remains free for dragging */} + {/* Top edge handle */} - {/* Source handle - full node coverage for outgoing connections */} + {/* Right edge handle */} + + {/* Bottom edge handle */} + {/* Left edge handle */} + diff --git a/src/components/Nodes/GroupNode.tsx b/src/components/Nodes/GroupNode.tsx index 9982915..0650ce2 100644 --- a/src/components/Nodes/GroupNode.tsx +++ b/src/components/Nodes/GroupNode.tsx @@ -220,39 +220,73 @@ const GroupNode = ({ id, data, selected }: NodeProps) => { position: 'relative', }} > - {/* Invisible handles for easy-connect - floating edges calculate actual connection points */} - {/* Target handle - full node coverage for incoming connections */} + {/* Invisible handles positioned around edges - center remains free for dragging */} + {/* Top edge handle */} - {/* Source handle - full node coverage for outgoing connections */} + {/* Right edge handle */} + + {/* Bottom edge handle */} + {/* Left edge handle */} + diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index ccc12be..e9b7053 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -1,6 +1,5 @@ -import type { Relation, RelationData } from '../types'; +import type { Relation, RelationData, NodeShape } from '../types'; import type { Node } from '@xyflow/react'; -import { Position } from '@xyflow/react'; /** * 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 - * Used for floating edges to connect at the closest point on the node + * Calculate intersection point with a circle + * 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 // positionAbsolute accounts for parent group offset, while position is relative // @ts-ignore - internals.positionAbsolute exists at runtime but not in public types @@ -31,83 +206,120 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) { if (!intersectionNodeWidth || !intersectionNodeHeight || !targetNodeWidth || !targetNodeHeight) { const centerX = intersectionNodePosition.x + (intersectionNodeWidth ?? 0) / 2; const centerY = intersectionNodePosition.y + (intersectionNodeHeight ?? 0) / 2; - return { x: centerX, y: centerY }; + return { x: centerX, y: centerY, angle: 0 }; } - const w = intersectionNodeWidth / 2; - const h = intersectionNodeHeight / 2; + // 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; - const x2 = intersectionNodePosition.x + w; - const y2 = intersectionNodePosition.y + h; - const x1 = targetPosition.x + targetNodeWidth / 2; - const y1 = 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 h = intersectionNodeHeight / 2; - 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; + const x2 = intersectionCenterX; + const y2 = intersectionCenterY; + const x1 = targetCenterX; + const y1 = targetCenterY; - return { x, y }; -} + 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; -/** - * Get the position (top, right, bottom, left) of the handle based on the intersection point - */ -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); + // Calculate normal angle for rectangle edges + const dx = x - x2; + const dy = y - y2; + const angle = Math.atan2(dy, dx); - // Use measured dimensions from React Flow (stored in node.measured) - // If not available, default to Top - const width = node.measured?.width ?? node.width; - const height = node.measured?.height ?? node.height; + // Apply offset + const offsetX = x + Math.cos(angle) * offset; + const offsetY = y + Math.sin(angle) * offset; - if (!width || !height) { - return Position.Top; + return { x: offsetX, y: offsetY, angle }; } - - 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 - * 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) { - const sourceIntersectionPoint = getNodeIntersection(sourceNode, targetNode); - const targetIntersectionPoint = getNodeIntersection(targetNode, sourceNode); +export function getFloatingEdgeParams( + sourceNode: Node, + 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); - const targetPos = getEdgePosition(targetNode, targetIntersectionPoint); + // 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 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 { - sx: sourceIntersectionPoint.x, - sy: sourceIntersectionPoint.y, - tx: targetIntersectionPoint.x, - ty: targetIntersectionPoint.y, - sourcePos, - targetPos, + sx: sourceIntersection.x, + sy: sourceIntersection.y, + tx: targetIntersection.x, + ty: targetIntersection.y, + sourceControlX, + sourceControlY, + targetControlX, + targetControlY, + sourceAngle: sourceIntersection.angle, + targetAngle: targetIntersection.angle, }; }