diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index 1b6c1e7..8457ee5 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -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) => { 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; diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index 049010a..8b76947 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -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) => { // 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) => { ? 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) => { data.label || "", data.description || "", nodeLabel, - filters + filters, ); }, [ data.type, @@ -85,64 +81,150 @@ const CustomNode = ({ data, selected }: NodeProps) => { 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 */} + + {/* Right edge handles */} + + {/* Bottom edge handles */} + + {/* Left edge handles */} + diff --git a/src/components/Nodes/GroupNode.tsx b/src/components/Nodes/GroupNode.tsx index 9500f9d..8d4b877 100644 --- a/src/components/Nodes/GroupNode.tsx +++ b/src/components/Nodes/GroupNode.tsx @@ -220,6 +220,153 @@ const GroupNode = ({ id, data, selected }: NodeProps) => { position: 'relative', }} > + {/* Invisible handles positioned around edges - center remains free for dragging */} + {/* Bidirectional handles (source + target overlapping at each edge) */} + + {/* Top edge handles */} + + + + {/* Right edge handles */} + + + + {/* Bottom edge handles */} + + + + {/* Left edge handles */} + + + {/* Background color overlay - uses group's custom color */}
{ 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(); + }); }); }); diff --git a/src/stores/graphStore.ts b/src/stores/graphStore.ts index 2b47c1d..e6ec99a 100644 --- a/src/stores/graphStore.ts +++ b/src/stores/graphStore.ts @@ -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((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, diff --git a/src/stores/workspace/documentUtils.ts b/src/stores/workspace/documentUtils.ts index f603232..7d345e8 100644 --- a/src/stores/workspace/documentUtils.ts +++ b/src/stores/workspace/documentUtils.ts @@ -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, + }; + }); } /** diff --git a/src/stores/workspace/useActiveDocument.ts b/src/stores/workspace/useActiveDocument.ts index ddf951a..48d9b88 100644 --- a/src/stores/workspace/useActiveDocument.ts +++ b/src/stores/workspace/useActiveDocument.ts @@ -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[], diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts index b812336..1679816 100644 --- a/src/stores/workspaceStore.ts +++ b/src/stores/workspaceStore.ts @@ -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((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((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((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 = { diff --git a/src/utils/__tests__/handleMigration.test.ts b/src/utils/__tests__/handleMigration.test.ts new file mode 100644 index 0000000..699dfe7 --- /dev/null +++ b/src/utils/__tests__/handleMigration.test.ts @@ -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); + }); + }); +}); diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index 1b19ca9..17665f3 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -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, }; } diff --git a/src/utils/handleMigration.ts b/src/utils/handleMigration.ts new file mode 100644 index 0000000..f74fd33 --- /dev/null +++ b/src/utils/handleMigration.ts @@ -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); +}