Merge pull request #4 from OFFIS-ESC/whole-node-handle

Implement shape-aware edge connections and improved handle interaction
This commit is contained in:
Jan-Henrik Bruhn 2026-01-24 16:34:47 +01:00 committed by GitHub
commit 2c91320bb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1083 additions and 170 deletions

View file

@ -1,14 +1,12 @@
import { memo, useMemo } from 'react';
import {
EdgeProps,
getBezierPath,
EdgeLabelRenderer,
BaseEdge,
useNodes,
useInternalNode,
} from '@xyflow/react';
import { useGraphStore } from '../../stores/graphStore';
import type { Relation } from '../../types';
import type { Group } from '../../types';
import LabelBadge from '../Common/LabelBadge';
import { getFloatingEdgeParams } from '../../utils/edgeUtils';
import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters';
@ -34,66 +32,72 @@ const CustomEdge = ({
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}: EdgeProps<Relation>) => {
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();
// Get all nodes to check if source/target are minimized groups
const nodes = useNodes();
const sourceNode = nodes.find((n) => n.id === source);
const targetNode = nodes.find((n) => n.id === target);
// Get internal nodes for floating edge calculations with correct absolute positioning
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
// Check if either endpoint is a minimized group
const sourceIsMinimizedGroup = sourceNode?.type === 'group' && (sourceNode.data as Group['data']).minimized;
const targetIsMinimizedGroup = targetNode?.type === 'group' && (targetNode.data as Group['data']).minimized;
// 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]);
// Calculate floating edge parameters if needed
// When connecting to groups (especially minimized ones), we need to use floating edges
// because groups don't have specific handles
let finalSourceX = sourceX;
let finalSourceY = sourceY;
let finalTargetX = targetX;
let finalTargetY = targetY;
let finalSourcePosition = sourcePosition;
let finalTargetPosition = targetPosition;
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]);
// Check if we need to use floating edge calculations
const needsFloatingEdge = (sourceIsMinimizedGroup || targetIsMinimizedGroup) && sourceNode && targetNode;
if (needsFloatingEdge) {
const floatingParams = getFloatingEdgeParams(sourceNode, targetNode);
// When either endpoint is a minimized group, use floating positions for that side
// IMPORTANT: When BOTH are groups, we must use floating for BOTH sides
if (sourceIsMinimizedGroup) {
finalSourceX = floatingParams.sx;
finalSourceY = floatingParams.sy;
finalSourcePosition = floatingParams.sourcePos;
// 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,
};
}
if (targetIsMinimizedGroup) {
finalTargetX = floatingParams.tx;
finalTargetY = floatingParams.ty;
finalTargetPosition = floatingParams.targetPos;
}
}
const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape);
// Calculate the bezier path
const [edgePath, labelX, labelY] = getBezierPath({
sourceX: finalSourceX,
sourceY: finalSourceY,
sourcePosition: finalSourcePosition,
targetX: finalTargetX,
targetY: finalTargetY,
targetPosition: finalTargetPosition,
});
// 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;

View file

@ -1,5 +1,5 @@
import { memo, useMemo } from "react";
import { Handle, Position, NodeProps, useConnection } from "@xyflow/react";
import { Handle, Position, NodeProps } from "@xyflow/react";
import { useGraphStore } from "../../stores/graphStore";
import {
getContrastColor,
@ -9,14 +9,17 @@ 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
*
* Features:
* - Visual representation with type-based coloring
* - Connection handles (top, right, bottom, left)
* - Easy-connect: whole node is connectable, edges auto-route to nearest border point
* - Label display
* - Type badge
*
@ -29,10 +32,6 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
// Get active filters based on mode (editing vs presentation)
const filters = useActiveFilters();
// Check if any connection is being made (to show handles)
const connection = useConnection();
const isConnecting = !!connection.inProgress;
// Find the node type configuration
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type);
const nodeColor = nodeTypeConfig?.color || "#6b7280";
@ -46,9 +45,6 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
? adjustColorBrightness(nodeColor, -20)
: nodeColor;
// Show handles when selected or when connecting
const showHandles = selected || isConnecting;
// Check if this node matches the filter criteria
const isMatch = useMemo(() => {
return nodeMatchesFilters(
@ -57,7 +53,7 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
data.label || "",
data.description || "",
nodeLabel,
filters
filters,
);
}, [
data.type,
@ -85,64 +81,150 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
opacity: nodeOpacity,
}}
>
{/* Connection handles - shown only when selected or connecting */}
{/* Invisible handles positioned around edges - center remains free for dragging */}
{/* Bidirectional handles (source + target overlapping at each edge) */}
{/* Top edge handles */}
<Handle
type="target"
position={Position.Top}
id="top-target"
isConnectable={true}
style={{
width: "100%",
height: "30px",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
<Handle
type="source"
position={Position.Top}
id="top"
id="top-source"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
width: "100%",
height: "30px",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Right edge handles */}
<Handle
type="target"
position={Position.Right}
id="right-target"
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0,
right: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
<Handle
type="source"
position={Position.Right}
id="right"
id="right-source"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
width: "30px",
height: "100%",
top: 0,
right: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Bottom edge handles */}
<Handle
type="target"
position={Position.Bottom}
id="bottom-target"
isConnectable={true}
style={{
width: "100%",
height: "30px",
bottom: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
id="bottom-source"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
width: "100%",
height: "30px",
bottom: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Left edge handles */}
<Handle
type="target"
position={Position.Left}
id="left-target"
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
<Handle
type="source"
position={Position.Left}
id="left"
id="left-source"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
width: "30px",
height: "100%",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>

View file

@ -220,6 +220,153 @@ const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
position: 'relative',
}}
>
{/* Invisible handles positioned around edges - center remains free for dragging */}
{/* Bidirectional handles (source + target overlapping at each edge) */}
{/* Top edge handles */}
<Handle
type="target"
position={Position.Top}
id="top-target"
isConnectable={true}
style={{
width: '100%',
height: '30px',
top: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
<Handle
type="source"
position={Position.Top}
id="top-source"
isConnectable={true}
style={{
width: '100%',
height: '30px',
top: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
{/* Right edge handles */}
<Handle
type="target"
position={Position.Right}
id="right-target"
isConnectable={true}
style={{
width: '30px',
height: '100%',
top: 0,
right: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
<Handle
type="source"
position={Position.Right}
id="right-source"
isConnectable={true}
style={{
width: '30px',
height: '100%',
top: 0,
right: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
{/* Bottom edge handles */}
<Handle
type="target"
position={Position.Bottom}
id="bottom-target"
isConnectable={true}
style={{
width: '100%',
height: '30px',
bottom: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom-source"
isConnectable={true}
style={{
width: '100%',
height: '30px',
bottom: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
{/* Left edge handles */}
<Handle
type="target"
position={Position.Left}
id="left-target"
isConnectable={true}
style={{
width: '30px',
height: '100%',
top: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
<Handle
type="source"
position={Position.Left}
id="left-source"
isConnectable={true}
style={{
width: '30px',
height: '100%',
top: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
{/* Background color overlay - uses group's custom color */}
<div
style={{

View file

@ -1256,6 +1256,69 @@ describe('graphStore', () => {
const loadedNode = state.nodes[0];
expect(loadedNode.parentId).toBe('group-1');
});
it('should migrate old 4-position handle references by removing handles', () => {
const { loadGraphState } = useGraphStore.getState();
// Create edges with old handle format
const edgeWithOldHandles: Relation = {
...createMockEdge('edge-1', 'node-1', 'node-2'),
sourceHandle: 'right',
targetHandle: 'left',
};
const edgeWithTopBottom: Relation = {
...createMockEdge('edge-2', 'node-1', 'node-2'),
sourceHandle: 'top',
targetHandle: 'bottom',
};
loadGraphState({
nodes: [createMockNode('node-1'), createMockNode('node-2')],
edges: [edgeWithOldHandles, edgeWithTopBottom],
groups: [],
nodeTypes: [],
edgeTypes: [],
labels: [],
});
const state = useGraphStore.getState();
// Both edges should have handles removed (undefined) for floating edge pattern
expect(state.edges[0].sourceHandle).toBeUndefined();
expect(state.edges[0].targetHandle).toBeUndefined();
expect(state.edges[1].sourceHandle).toBeUndefined();
expect(state.edges[1].targetHandle).toBeUndefined();
// Other fields should be preserved
expect(state.edges[0].id).toBe('edge-1');
expect(state.edges[0].source).toBe('node-1');
expect(state.edges[0].target).toBe('node-2');
});
it('should preserve undefined/null handles', () => {
const { loadGraphState } = useGraphStore.getState();
// Create edge without handles (new format)
const edgeWithoutHandles: Relation = {
...createMockEdge('edge-1', 'node-1', 'node-2'),
};
loadGraphState({
nodes: [createMockNode('node-1'), createMockNode('node-2')],
edges: [edgeWithoutHandles],
groups: [],
nodeTypes: [],
edgeTypes: [],
labels: [],
});
const state = useGraphStore.getState();
// Handles should remain undefined
expect(state.edges[0].sourceHandle).toBeUndefined();
expect(state.edges[0].targetHandle).toBeUndefined();
});
});
});

View file

@ -14,6 +14,7 @@ import type {
} from '../types';
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
import { migrateRelationHandlesArray } from '../utils/handleMigration';
/**
* IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS
@ -641,10 +642,13 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
? migrateTangibleConfigs(data.tangibles)
: [];
// Apply handle migration for backward compatibility (remove old 4-position handles)
const migratedEdges = migrateRelationHandlesArray(data.edges);
// Atomic update: all state changes happen in a single set() call
set({
nodes: sanitizedNodes,
edges: data.edges,
edges: migratedEdges,
groups: data.groups || [],
nodeTypes: data.nodeTypes,
edgeTypes: data.edgeTypes,

View file

@ -225,15 +225,18 @@ export function serializeActors(actors: Actor[]): SerializedActor[] {
* Serialize relations for storage (strip React Flow internals)
*/
export function serializeRelations(relations: Relation[]): SerializedRelation[] {
return relations.map(relation => ({
id: relation.id,
source: relation.source,
target: relation.target,
type: relation.type,
data: relation.data,
sourceHandle: relation.sourceHandle,
targetHandle: relation.targetHandle,
}));
return relations.map(relation => {
// Omit handle fields entirely - edges use floating calculations
// The handle IDs (like "top-source", "right-target") are only for defining
// clickable areas and should not be persisted
return {
id: relation.id,
source: relation.source,
target: relation.target,
type: relation.type,
data: relation.data,
};
});
}
/**

View file

@ -4,6 +4,7 @@ import { useGraphStore } from '../graphStore';
import { useTimelineStore } from '../timelineStore';
import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig, TangibleConfig } from '../../types';
import { getCurrentGraphFromDocument } from './documentUtils';
import { migrateRelationHandlesArray } from '../../utils/handleMigration';
/**
* useActiveDocument Hook
@ -97,8 +98,11 @@ export function useActiveDocument() {
isLoadingRef.current = true;
lastLoadedDocIdRef.current = activeDocumentId;
// Apply handle migration for backward compatibility (remove old 4-position handles)
const migratedEdges = migrateRelationHandlesArray(currentGraph.edges);
setNodes(currentGraph.nodes as never[]);
setEdges(currentGraph.edges as never[]);
setEdges(migratedEdges as never[]);
setGroups(currentGraph.groups as never[]);
setNodeTypes(currentGraph.nodeTypes as never[]);
setEdgeTypes(currentGraph.edgeTypes as never[]);
@ -109,7 +113,7 @@ export function useActiveDocument() {
lastSyncedStateRef.current = {
documentId: activeDocumentId,
nodes: currentGraph.nodes as Actor[],
edges: currentGraph.edges as Relation[],
edges: migratedEdges as Relation[],
groups: currentGraph.groups as Group[],
nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[],
edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[],

View file

@ -33,6 +33,7 @@ import { Cite } from '@citation-js/core';
import type { CSLReference } from '../types/bibliography';
import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage';
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
import { migrateRelationHandlesArray } from '../utils/handleMigration';
/**
* Workspace Store
@ -318,6 +319,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
}
// Apply handle migration to all timeline states for backward compatibility
if (doc.timeline && doc.timeline.states) {
Object.keys(doc.timeline.states).forEach((stateId) => {
const state = doc.timeline.states[stateId];
if (state && state.graph && state.graph.edges) {
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
}
});
}
// Load timeline if it exists
if (doc.timeline) {
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
@ -626,6 +637,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
importedDoc.tangibles = migrateTangibleConfigs(importedDoc.tangibles);
}
// Apply handle migration to all timeline states for backward compatibility
if (importedDoc.timeline && importedDoc.timeline.states) {
Object.keys(importedDoc.timeline.states).forEach((stateId) => {
const state = importedDoc.timeline.states[stateId];
if (state && state.graph && state.graph.edges) {
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
}
});
}
const metadata: DocumentMetadata = {
id: documentId,
title: importedDoc.metadata.title || 'Imported Analysis',
@ -938,6 +959,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
}
// Apply handle migration to all timeline states for backward compatibility
if (doc.timeline && doc.timeline.states) {
Object.keys(doc.timeline.states).forEach((stateId) => {
const state = doc.timeline.states[stateId];
if (state && state.graph && state.graph.edges) {
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
}
});
}
saveDocumentToStorage(docId, doc);
const metadata = {

View file

@ -0,0 +1,291 @@
import { describe, it, expect } from 'vitest';
import { migrateRelationHandles, migrateRelationHandlesArray } from '../handleMigration';
import type { SerializedRelation } from '../../stores/persistence/types';
describe('handleMigration', () => {
describe('migrateRelationHandles', () => {
it('should migrate old "top" source handle by removing handles', () => {
const oldFormat: SerializedRelation = {
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
targetHandle: 'bottom',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
expect(result.id).toBe('edge-1');
expect(result.source).toBe('node-1');
expect(result.target).toBe('node-2');
});
it('should migrate old "right" source handle', () => {
const oldFormat: SerializedRelation = {
id: 'edge-2',
source: 'node-1',
target: 'node-2',
sourceHandle: 'right',
targetHandle: 'left',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate old "bottom" source handle', () => {
const oldFormat: SerializedRelation = {
id: 'edge-3',
source: 'node-1',
target: 'node-2',
sourceHandle: 'bottom',
targetHandle: 'top',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate old "left" source handle', () => {
const oldFormat: SerializedRelation = {
id: 'edge-4',
source: 'node-1',
target: 'node-2',
sourceHandle: 'left',
targetHandle: 'right',
};
const result = migrateRelationHandles(oldFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate when only source handle is old format', () => {
const mixed: SerializedRelation = {
id: 'edge-5',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
};
const result = migrateRelationHandles(mixed);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should migrate when only target handle is old format', () => {
const mixed: SerializedRelation = {
id: 'edge-6',
source: 'node-1',
target: 'node-2',
targetHandle: 'bottom',
};
const result = migrateRelationHandles(mixed);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should leave relations with undefined handles unchanged', () => {
const newFormat: SerializedRelation = {
id: 'edge-7',
source: 'node-1',
target: 'node-2',
};
const result = migrateRelationHandles(newFormat);
expect(result).toEqual(newFormat);
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should leave relations with null handles unchanged', () => {
const newFormat: SerializedRelation = {
id: 'edge-8',
source: 'node-1',
target: 'node-2',
sourceHandle: null,
targetHandle: null,
};
const result = migrateRelationHandles(newFormat);
expect(result).toEqual(newFormat);
expect(result.sourceHandle).toBeNull();
expect(result.targetHandle).toBeNull();
});
it('should leave relations with custom handle IDs unchanged', () => {
const customHandles: SerializedRelation = {
id: 'edge-9',
source: 'node-1',
target: 'node-2',
sourceHandle: 'custom-source-1',
targetHandle: 'custom-target-1',
};
const result = migrateRelationHandles(customHandles);
expect(result).toEqual(customHandles);
expect(result.sourceHandle).toBe('custom-source-1');
expect(result.targetHandle).toBe('custom-target-1');
});
it('should preserve type and data fields', () => {
const withData: SerializedRelation = {
id: 'edge-10',
source: 'node-1',
target: 'node-2',
sourceHandle: 'right',
targetHandle: 'left',
type: 'custom-edge',
data: {
label: 'Test Edge',
description: 'Test description',
},
};
const result = migrateRelationHandles(withData);
expect(result.type).toBe('custom-edge');
expect(result.data).toEqual({
label: 'Test Edge',
description: 'Test description',
});
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
it('should be idempotent (running twice produces same result)', () => {
const oldFormat: SerializedRelation = {
id: 'edge-11',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
targetHandle: 'bottom',
};
const firstMigration = migrateRelationHandles(oldFormat);
const secondMigration = migrateRelationHandles(firstMigration);
expect(firstMigration).toEqual(secondMigration);
expect(secondMigration.sourceHandle).toBeUndefined();
expect(secondMigration.targetHandle).toBeUndefined();
});
it('should handle mixed old and custom handles', () => {
const mixed: SerializedRelation = {
id: 'edge-12',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top', // Old format
targetHandle: 'custom-target', // Custom handle
};
const result = migrateRelationHandles(mixed);
// Should migrate because sourceHandle is old format (removes both handles)
expect(result.sourceHandle).toBeUndefined();
expect(result.targetHandle).toBeUndefined();
});
});
describe('migrateRelationHandlesArray', () => {
it('should migrate an array of relations', () => {
const relations: SerializedRelation[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: 'right',
targetHandle: 'left',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
// Already new format (undefined)
},
{
id: 'edge-3',
source: 'node-3',
target: 'node-4',
sourceHandle: 'custom-handle',
targetHandle: 'custom-target',
},
];
const result = migrateRelationHandlesArray(relations);
expect(result).toHaveLength(3);
// First should be migrated (old format removed)
expect(result[0].sourceHandle).toBeUndefined();
expect(result[0].targetHandle).toBeUndefined();
// Second should remain unchanged
expect(result[1]).toEqual(relations[1]);
// Third should remain unchanged (custom handles)
expect(result[2]).toEqual(relations[2]);
});
it('should handle empty array', () => {
const result = migrateRelationHandlesArray([]);
expect(result).toEqual([]);
});
it('should handle array with all old format relations', () => {
const relations: SerializedRelation[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceHandle: 'top',
targetHandle: 'bottom',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
sourceHandle: 'right',
targetHandle: 'left',
},
];
const result = migrateRelationHandlesArray(relations);
expect(result).toHaveLength(2);
result.forEach((relation) => {
expect(relation.sourceHandle).toBeUndefined();
expect(relation.targetHandle).toBeUndefined();
});
});
it('should handle array with all new format relations', () => {
const relations: SerializedRelation[] = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
},
{
id: 'edge-2',
source: 'node-2',
target: 'node-3',
},
];
const result = migrateRelationHandlesArray(relations);
expect(result).toEqual(relations);
});
});
});

View file

@ -1,7 +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';
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
/**
* Generates a unique ID for edges
@ -11,91 +9,333 @@ 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) {
const {
width: intersectionNodeWidth,
height: intersectionNodeHeight,
position: intersectionNodePosition,
} = intersectionNode;
const targetPosition = targetNode.position;
// Use fallback dimensions if width/height are not set (e.g., for groups without measured dimensions)
const w = (intersectionNodeWidth ?? MINIMIZED_GROUP_WIDTH) / 2;
const h = (intersectionNodeHeight ?? MINIMIZED_GROUP_HEIGHT) / 2;
const x2 = intersectionNodePosition.x + w;
const y2 = intersectionNodePosition.y + h;
const x1 = targetPosition.x + (targetNode.width ?? MINIMIZED_GROUP_WIDTH) / 2;
const y1 = targetPosition.y + (targetNode.height ?? MINIMIZED_GROUP_HEIGHT) / 2;
// Guard against division by zero
if (w === 0 || h === 0) {
// If node has no dimensions, return center point
return { x: x2, y: y2 };
function getCircleIntersection(
centerX: number,
centerY: number,
radius: number,
targetX: number,
targetY: number,
offset: number = 3
): { x: number; y: number; angle: number } {
// Guard against zero radius
if (radius === 0) {
return { x: centerX + offset, y: centerY, angle: 0 };
}
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 dx = targetX - centerX;
const dy = targetY - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
return { x, y };
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
};
}
/**
* Get the position (top, right, bottom, left) of the handle based on the intersection point
* Calculate intersection point with an ellipse
* Returns both the intersection point and the normal vector (outward direction)
*/
function getEdgePosition(node: Node, intersectionPoint: { x: number; y: number }) {
const n = { ...node.position, ...node };
const nx = Math.round(n.x);
const ny = Math.round(n.y);
const px = Math.round(intersectionPoint.x);
const py = Math.round(intersectionPoint.y);
// Use fallback dimensions if not set (same as getNodeIntersection)
const width = node.width ?? MINIMIZED_GROUP_WIDTH;
const height = node.height ?? MINIMIZED_GROUP_HEIGHT;
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;
function getEllipseIntersection(
centerX: number,
centerY: number,
radiusX: number,
radiusY: number,
targetX: number,
targetY: number,
offset: number = 3
): { x: number; y: number; angle: number } {
// Guard against zero radii
if (radiusX === 0 || radiusY === 0) {
return { x: centerX + offset, y: centerY, angle: 0 };
}
return Position.Top;
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
let intersectX = Math.abs(dy) > 0.001
? centerX + dx * (intersectY - centerY) / dy
: centerX;
// Clamp intersection to the straight horizontal segment between the caps
intersectX = Math.min(Math.max(intersectX, leftCapX), rightCapX);
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
let intersectY = Math.abs(dx) > 0.001
? centerY + dy * (intersectX - centerX) / dx
: centerY;
// Clamp intersection to the straight vertical segment between the caps
intersectY = Math.min(Math.max(intersectY, topCapY), bottomCapY);
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-expect-error - internals.positionAbsolute exists at runtime but not in public types
const intersectionNodePosition = intersectionNode.internals?.positionAbsolute ?? intersectionNode.position;
// @ts-expect-error - internals.positionAbsolute exists at runtime but not in public types
const targetPosition = targetNode.internals?.positionAbsolute ?? targetNode.position;
// Use measured dimensions from React Flow (stored in node.measured)
// If undefined, node hasn't been measured yet - return center
const intersectionNodeWidth = intersectionNode.measured?.width ?? intersectionNode.width;
const intersectionNodeHeight = intersectionNode.measured?.height ?? intersectionNode.height;
const targetNodeWidth = targetNode.measured?.width ?? targetNode.width;
const targetNodeHeight = targetNode.measured?.height ?? targetNode.height;
if (!intersectionNodeWidth || !intersectionNodeHeight || !targetNodeWidth || !targetNodeHeight) {
const centerX = intersectionNodePosition.x + (intersectionNodeWidth ?? 0) / 2;
const centerY = intersectionNodePosition.y + (intersectionNodeHeight ?? 0) / 2;
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 h = intersectionNodeHeight / 2;
const x2 = intersectionCenterX;
const y2 = intersectionCenterY;
const x1 = targetCenterX;
const y1 = targetCenterY;
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;
// Calculate normal angle for rectangle edges
const dx = x - x2;
const dy = y - y2;
const angle = Math.atan2(dy, dx);
// Apply offset
const offsetX = x + Math.cos(angle) * offset;
const offsetY = y + Math.sin(angle) * offset;
return { x: offsetX, y: offsetY, angle };
}
}
/**
* 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,
};
}

View file

@ -0,0 +1,44 @@
import type { SerializedRelation } from '../stores/persistence/types';
/**
* List of old 4-position handle identifiers that should be migrated
*/
const OLD_HANDLE_POSITIONS = ['top', 'right', 'bottom', 'left'] as const;
/**
* Migrates a relation from the old 4-position handle system to the new easy-connect handle system.
* This function ensures backward compatibility with existing constellation files.
*
* Old format uses specific position handles: "top", "right", "bottom", "left"
* New format omits handle fields entirely, allowing floating edges to calculate connection points dynamically
*
* @param relation - The relation to migrate
* @returns The migrated relation
*/
export function migrateRelationHandles(relation: SerializedRelation): SerializedRelation {
// Check if either handle uses old format
const hasOldSourceHandle =
relation.sourceHandle != null && OLD_HANDLE_POSITIONS.includes(relation.sourceHandle as typeof OLD_HANDLE_POSITIONS[number]);
const hasOldTargetHandle =
relation.targetHandle != null && OLD_HANDLE_POSITIONS.includes(relation.targetHandle as typeof OLD_HANDLE_POSITIONS[number]);
// If old format detected, remove handle fields entirely for floating edge pattern
if (hasOldSourceHandle || hasOldTargetHandle) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { sourceHandle, targetHandle, ...relationWithoutHandles } = relation;
return relationWithoutHandles;
}
// Otherwise return unchanged
return relation;
}
/**
* Migrates an array of relations.
*
* @param relations - Array of relations to migrate
* @returns Array of migrated relations
*/
export function migrateRelationHandlesArray(relations: SerializedRelation[]): SerializedRelation[] {
return relations.map(migrateRelationHandles);
}