mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 15:53:42 +00:00
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:
parent
c9c888d0ac
commit
8d71da76b2
4 changed files with 424 additions and 119 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue