Compare commits

...

10 commits

Author SHA1 Message Date
Jan-Henrik Bruhn
094fd6d957 Extract rounded rectangle corner radius as a constant
Some checks failed
CI / test (push) Has been cancelled
Changes:
- Add ROUNDED_RECTANGLE_RADIUS = 24 to src/constants.ts
- Update RoundedRectangleShape to import and use the constant
- Update edgeUtils.ts to import and use the constant
- Ensures consistency between component rendering and edge calculations

Improves maintainability by having a single source of truth for the
rounded rectangle corner radius value.
2026-01-24 16:54:50 +01:00
Jan-Henrik Bruhn
603c767403 Add rounded rectangle intersection handling for proper edge routing
Rounded rectangles now have shape-aware edge intersections that follow
the curved corners instead of treating them as sharp corners.

Implementation:
- Add getRoundedRectangleIntersection() function
- Detects when intersection point is near a corner
- Uses circular arc intersection for corners (24px radius)
- Falls back to straight edge calculation for non-corner intersections
- Ensures arrows smoothly follow the rounded contours

Fixes issue where edge arrows didn't correctly follow rounded rectangle
outer contours.
2026-01-24 16:53:00 +01:00
Jan-Henrik Bruhn
66d47fb022 Fix duplicate label selection and add label wrapping
Label selection fix:
- Prevent duplicate labels when creating a label that already exists
- Check if label is already selected before adding to selection

Label wrapping improvements:
- Labels now wrap within a 200px container to prevent nodes growing too large
- LabelBadge updated to only truncate when maxWidth is explicitly provided
- Labels display full text without individual truncation
- Applies to both CustomNode and CustomEdge components

Note: Some overlap may occur with circular shapes - accepted for now.
2026-01-24 16:50:22 +01:00
Jan-Henrik Bruhn
d17702452d Fix duplicate label selection bug in AutocompleteLabelSelector
When attempting to create a label that already exists, the component
would add it to the selected labels without checking if it was already
selected, causing duplicate entries.

Now checks if the label is already selected before adding it.

Fixes issue where creating labels via label select allows duplicate
labels if the label is already assigned.
2026-01-24 16:41:44 +01:00
Jan-Henrik Bruhn
2c91320bb7
Merge pull request #4 from OFFIS-ESC/whole-node-handle
Implement shape-aware edge connections and improved handle interaction
2026-01-24 16:34:47 +01:00
Jan-Henrik Bruhn
93a5f38112 Omit handle fields from serialization entirely
Since edges use floating calculations that ignore handle positions,
the handle IDs (like 'top-source', 'right-target') should never be
persisted. They're only used to define clickable areas for connections.

This ensures consistency: both migrated old edges and newly created
edges will have no handle fields in saved JSON files.

Addresses PR review comment about serialization inconsistency.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-24 16:33:17 +01:00
Jan-Henrik Bruhn
4b865762a1 Address PR review comments
Edge calculation improvements:
- Add zero radius/radii guards in circle and ellipse intersection functions
- Add clamping for pill straight edge intersections to prevent overflow
- Ensure intersection points stay within valid pill boundaries

Handle improvements:
- Add bidirectional connection support with overlapping source/target handles
- Each edge now has both source and target handles (8 total per node)
- Allows edges to connect in any direction from any side
- Fixes handle type restrictions that prevented flexible connections

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-24 16:17:23 +01:00
Jan-Henrik Bruhn
318cdee15c Fix lint errors: change @ts-ignore to @ts-expect-error and fix type assertions 2026-01-24 16:05:50 +01:00
Jan-Henrik Bruhn
8d71da76b2 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>
2026-01-24 16:03:34 +01:00
Jan-Henrik Bruhn
c9c888d0ac Implement whole-node easy-connect handle system with floating edges
Migrated from 4-position handle system (top/right/bottom/left) to React Flow's
easy-connect pattern where the entire node surface is connectable and edges
dynamically route to the nearest point on the node border.

Key changes:
- Migration utility removes old 4-position handle references for backwards compatibility
- Full-coverage invisible handles on CustomNode and GroupNode (maximized state)
- Floating edges use node.measured dimensions and node.internals.positionAbsolute
- useInternalNode hook for correct absolute positioning of nodes in groups
- All edges now omit handle fields, allowing dynamic border calculations

This improves UX by making nodes easier to connect (whole surface vs tiny handles)
and edges intelligently route to optimal connection points.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-24 13:01:04 +01:00
15 changed files with 1184 additions and 181 deletions

View file

@ -99,8 +99,10 @@ const AutocompleteLabelSelector = ({ value, onChange, scope }: Props) => {
// Check if ID already exists
if (labels.some((l) => l.id === id)) {
// If label already exists, just select it
// If label already exists, just select it (but only if not already selected)
if (!value.includes(id)) {
onChange([...value, id]);
}
setInputValue('');
setIsOpen(false);
setHighlightedIndex(0);

View file

@ -6,7 +6,7 @@ import { getContrastColor } from '../../utils/colorUtils';
* Features:
* - Pill-shaped design
* - Auto-contrast text color
* - Truncation with ellipsis
* - Optional truncation with ellipsis (if maxWidth is provided)
* - Tooltip on hover (via title attribute)
*/
@ -17,7 +17,7 @@ interface Props {
size?: 'sm' | 'md';
}
const LabelBadge = ({ name, color, maxWidth = '120px', size = 'sm' }: Props) => {
const LabelBadge = ({ name, color, maxWidth, size = 'sm' }: Props) => {
const textColor = getContrastColor(color);
const sizeClasses = size === 'sm'
@ -26,11 +26,11 @@ const LabelBadge = ({ name, color, maxWidth = '120px', size = 'sm' }: Props) =>
return (
<span
className={`inline-block rounded-full font-medium whitespace-nowrap overflow-hidden text-ellipsis ${sizeClasses}`}
className={`inline-block rounded-full font-medium whitespace-nowrap ${maxWidth ? 'overflow-hidden text-ellipsis' : ''} ${sizeClasses}`}
style={{
backgroundColor: color,
color: textColor,
maxWidth,
...(maxWidth && { maxWidth }),
}}
title={name}
>

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;
@ -222,7 +226,7 @@ const CustomEdge = ({
</div>
)}
{data?.labels && data.labels.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center">
<div className="flex flex-wrap gap-1 justify-center" style={{ maxWidth: '200px' }}>
{data.labels.map((labelId) => {
const labelConfig = labels.find((l) => l.id === labelId);
if (!labelConfig) return null;
@ -231,7 +235,6 @@ const CustomEdge = ({
key={labelId}
name={labelConfig.name}
color={labelConfig.color}
maxWidth="80px"
size="sm"
/>
);

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",
}}
/>
@ -184,7 +266,7 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
{/* Labels */}
{data.labels && data.labels.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center mt-2">
<div className="flex flex-wrap gap-1 justify-center mt-2" style={{ maxWidth: '200px' }}>
{data.labels.map((labelId) => {
const labelConfig = labels.find((l) => l.id === labelId);
if (!labelConfig) return null;
@ -193,7 +275,6 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
key={labelId}
name={labelConfig.name}
color={labelConfig.color}
maxWidth="80px"
size="sm"
/>
);

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

@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { ROUNDED_RECTANGLE_RADIUS } from '../../../constants';
interface RoundedRectangleShapeProps {
color: string;
@ -41,7 +42,7 @@ const RoundedRectangleShape = ({
borderStyle: 'solid',
borderColor: borderColor,
color: textColor,
borderRadius: '24px', // More rounded than regular rectangle
borderRadius: `${ROUNDED_RECTANGLE_RADIUS}px`,
boxShadow: shadowStyle,
}}
>

View file

@ -13,3 +13,8 @@ export const MINIMIZED_GROUP_HEIGHT = 80;
*/
export const DEFAULT_ACTOR_WIDTH = 150;
export const DEFAULT_ACTOR_HEIGHT = 80;
/**
* Rounded rectangle corner radius (in pixels)
*/
export const ROUNDED_RECTANGLE_RADIUS = 24;

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 => ({
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,
sourceHandle: relation.sourceHandle,
targetHandle: relation.targetHandle,
}));
};
});
}
/**

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,6 @@
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';
import { ROUNDED_RECTANGLE_RADIUS } from '../constants';
/**
* Generates a unique ID for edges
@ -11,32 +10,357 @@ 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 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 } {
// Guard against zero radii
if (radiusX === 0 || radiusY === 0) {
return { x: centerX + offset, y: centerY, angle: 0 };
}
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 intersection point with a rounded rectangle
* Handles corners as circular arcs with specified radius
*/
function getRoundedRectangleIntersection(
centerX: number,
centerY: number,
width: number,
height: number,
targetX: number,
targetY: number,
cornerRadius: number = ROUNDED_RECTANGLE_RADIUS,
offset: number = 2
): { x: number; y: number; angle: number } {
const w = width / 2;
const h = height / 2;
// Calculate basic rectangle intersection first
const dx = targetX - centerX;
const dy = targetY - centerY;
const xx1 = dx / (2 * w) - dy / (2 * h);
const yy1 = dx / (2 * w) + dy / (2 * h);
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
const xx3 = a * xx1;
const yy3 = a * yy1;
const x = w * (xx3 + yy3) + centerX;
const y = h * (-xx3 + yy3) + centerY;
// Determine which edge the intersection is on
const leftEdge = centerX - w;
const rightEdge = centerX + w;
const topEdge = centerY - h;
const bottomEdge = centerY + h;
// Check if intersection is near a corner (within corner radius distance from corner)
const isNearTopLeft = x < leftEdge + cornerRadius && y < topEdge + cornerRadius;
const isNearTopRight = x > rightEdge - cornerRadius && y < topEdge + cornerRadius;
const isNearBottomLeft = x < leftEdge + cornerRadius && y > bottomEdge - cornerRadius;
const isNearBottomRight = x > rightEdge - cornerRadius && y > bottomEdge - cornerRadius;
if (isNearTopLeft) {
// Top-left corner - circular arc
const cornerCenterX = leftEdge + cornerRadius;
const cornerCenterY = topEdge + cornerRadius;
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
} else if (isNearTopRight) {
// Top-right corner - circular arc
const cornerCenterX = rightEdge - cornerRadius;
const cornerCenterY = topEdge + cornerRadius;
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
} else if (isNearBottomLeft) {
// Bottom-left corner - circular arc
const cornerCenterX = leftEdge + cornerRadius;
const cornerCenterY = bottomEdge - cornerRadius;
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
} else if (isNearBottomRight) {
// Bottom-right corner - circular arc
const cornerCenterX = rightEdge - cornerRadius;
const cornerCenterY = bottomEdge - cornerRadius;
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
}
// Straight edge - use rectangle calculation
const angle = Math.atan2(y - centerY, x - centerX);
const offsetX = x + Math.cos(angle) * offset;
const offsetY = y + Math.sin(angle) * offset;
return { x: offsetX, y: offsetY, angle };
}
/**
* 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 if (intersectionShape === 'roundedRectangle') {
// Rounded rectangle with circular corner arcs
return getRoundedRectangleIntersection(
intersectionCenterX,
intersectionCenterY,
intersectionNodeWidth,
intersectionNodeHeight,
targetCenterX,
targetCenterY,
ROUNDED_RECTANGLE_RADIUS,
offset
);
} else {
// Rectangle uses 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));
@ -45,57 +369,57 @@ function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
const x = w * (xx3 + yy3) + x2;
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);
/**
* Get the position (top, right, bottom, left) of the handle based on the intersection point
*/
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);
// Apply offset
const offsetX = x + Math.cos(angle) * offset;
const offsetY = y + Math.sin(angle) * offset;
// 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;
return { x: offsetX, y: offsetY, angle };
}
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,
};
}

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);
}