diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index ff5b2c1..48f15b6 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -89,8 +89,9 @@ const CustomEdge = ({ // Get offset multiplier from edge data (for parallel edges) const offsetMultiplier = (data as { offsetMultiplier?: number })?.offsetMultiplier || 0; + const parallelGroupKey = (data as { parallelGroupKey?: string })?.parallelGroupKey; - const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape, offsetMultiplier); + const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape, offsetMultiplier, parallelGroupKey); // 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}`; diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 9405526..e38acc6 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -10,7 +10,6 @@ import { BackgroundVariant, useNodesState, useEdgesState, - addEdge, Node, Edge, NodeChange, @@ -256,13 +255,12 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG }); } } else { - // No rerouting needed, just add the edge (no aggregation for normal edges) - if (!edgeMap.has(edgeKey)) { - edgeMap.set(edgeKey, { - edge, - aggregatedRelations: [], - }); - } + // No rerouting needed - use edge ID as key to allow multiple parallel edges + // (edgeKey based on source/target would deduplicate parallel edges) + edgeMap.set(edge.id, { + edge, + aggregatedRelations: [], + }); } }); @@ -286,13 +284,26 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG const parallelGroups = groupParallelEdges(processedEdges as Relation[]); // Create a map of edge ID -> offset info - const offsetMap = new Map(); + const offsetMap = new Map(); - parallelGroups.forEach((group) => { + parallelGroups.forEach((group, groupKey) => { const totalEdges = group.edges.length; - group.edges.forEach((edge, index) => { + + // Sort edges by direction: edges going in normalized direction first, then reverse + const [normalizedSource, normalizedTarget] = groupKey.split('<->'); + + const edgesInNormalizedDirection = group.edges.filter( + e => e.source === normalizedSource && e.target === normalizedTarget + ); + const edgesInReverseDirection = group.edges.filter( + e => e.source === normalizedTarget && e.target === normalizedSource + ); + + const sortedEdges = [...edgesInNormalizedDirection, ...edgesInReverseDirection]; + + sortedEdges.forEach((edge, index) => { const multiplier = calculateEdgeOffsetMultiplier(index, totalEdges); - offsetMap.set(edge.id, { multiplier, groupSize: totalEdges }); + offsetMap.set(edge.id, { multiplier, groupSize: totalEdges, groupKey }); }); }); @@ -306,6 +317,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG ...edge.data, offsetMultiplier: offsetInfo.multiplier, parallelGroupSize: offsetInfo.groupSize, + parallelGroupKey: offsetInfo.groupKey, }, } as Edge; } @@ -746,9 +758,17 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG const edgeTypeConfig = edgeTypeConfigs.find((et) => et.id === edgeType); const defaultDirectionality = edgeTypeConfig?.defaultDirectionality || 'directed'; - // Create edge with custom data (no label - will use type default) - const edgeWithData = { - ...connection, + // Generate a unique edge ID that allows multiple edges between same nodes + // Include timestamp to ensure uniqueness + const edgeId = `edge_${connection.source}_${connection.target}_${Date.now()}`; + + // Create edge with custom data and unique ID (don't use addEdge to allow duplicates) + const newEdge: Relation = { + id: edgeId, + source: connection.source, + target: connection.target, + sourceHandle: connection.sourceHandle, + targetHandle: connection.targetHandle, type: "custom", data: { type: edgeType, @@ -757,12 +777,6 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG }, }; - // Use React Flow's addEdge helper to properly format the edge - const updatedEdges = addEdge(edgeWithData, storeEdges as Edge[]); - - // Find the newly added edge (it will be the last one) - const newEdge = updatedEdges[updatedEdges.length - 1] as Relation; - // Set pending selection - will be applied after Zustand sync pendingSelectionRef.current = { type: 'edge', id: newEdge.id }; @@ -1081,6 +1095,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose} connectOnClick={isEditable} + isValidConnection={() => true} snapToGrid={snapToGrid} snapGrid={[gridSize, gridSize]} panOnDrag={true} diff --git a/src/stores/graphStore.ts b/src/stores/graphStore.ts index e6ec99a..e0a0c67 100644 --- a/src/stores/graphStore.ts +++ b/src/stores/graphStore.ts @@ -1,5 +1,4 @@ import { create } from 'zustand'; -import { addEdge as rfAddEdge } from '@xyflow/react'; import type { Actor, Relation, @@ -121,7 +120,7 @@ export const useGraphStore = create((set) => ({ // Edge operations addEdge: (edge: Relation) => set((state) => ({ - edges: rfAddEdge(edge, state.edges) as Relation[], + edges: [...state.edges, edge], })), updateEdge: (id: string, data: Partial) => diff --git a/src/types/index.ts b/src/types/index.ts index e02dfb8..b1ee2c1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,7 @@ export interface RelationData extends Record { // Parallel edge offset information offsetMultiplier?: number; // Multiplier for perpendicular offset (0 = center, ±0.5, ±1, etc.) parallelGroupSize?: number; // Total number of edges in this parallel group + parallelGroupKey?: string; // Normalized key for parallel group (sorted node IDs) } export type Relation = Edge; diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index ec6d7c5..6d1d19b 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -21,7 +21,7 @@ export interface EdgeGroup { /** * Base offset for parallel edges (in pixels) */ -const BASE_EDGE_OFFSET = 30; +const BASE_EDGE_OFFSET = 80; // Increased for better visibility with multiple edges /** * Calculate the perpendicular offset for a parallel edge @@ -94,13 +94,15 @@ export function groupParallelEdges(edges: Relation[]): Map { edges.forEach((edge) => { // Create normalized key (alphabetically sorted endpoints) - const key = [edge.source, edge.target].sort().join('_'); + // Use <-> separator to avoid conflicts with underscores in node IDs + const [normalizedSource, normalizedTarget] = [edge.source, edge.target].sort(); + const key = `${normalizedSource}<->${normalizedTarget}`; if (!groups.has(key)) { groups.set(key, { edges: [], - sourceId: edge.source, - targetId: edge.target, + sourceId: normalizedSource, // Store normalized source + targetId: normalizedTarget, // Store normalized target }); } @@ -499,13 +501,15 @@ function getNodeIntersection( * @param sourceShape - Shape of source node * @param targetShape - Shape of target node * @param offsetMultiplier - Multiplier for perpendicular offset (0 = no offset, ±1 = BASE_EDGE_OFFSET) + * @param parallelGroupKey - Normalized key for parallel group (for consistent offset direction) */ export function getFloatingEdgeParams( sourceNode: Node, targetNode: Node, sourceShape: NodeShape = 'rectangle', targetShape: NodeShape = 'rectangle', - offsetMultiplier: number = 0 + offsetMultiplier: number = 0, + parallelGroupKey?: string ) { const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape); const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape); @@ -522,26 +526,71 @@ export function getFloatingEdgeParams( let perpOffset = { x: 0, y: 0 }; if (offsetMultiplier !== 0) { const offsetMagnitude = offsetMultiplier * BASE_EDGE_OFFSET; + + // For parallel edges with a group key, calculate perpendicular based on normalized direction + // The offsetMultiplier already accounts for edge direction (assigned in GraphEditor) + let refSourceX = sourceIntersection.x; + let refSourceY = sourceIntersection.y; + let refTargetX = targetIntersection.x; + let refTargetY = targetIntersection.y; + + // Always use the normalized direction for perpendicular calculation + if (parallelGroupKey) { + const [normalizedSourceId, normalizedTargetId] = parallelGroupKey.split('<->'); + + // Find the actual node positions for the normalized direction + if (sourceNode.id === normalizedSourceId && targetNode.id === normalizedTargetId) { + // This edge goes in normalized direction - use as-is + } else if (sourceNode.id === normalizedTargetId && targetNode.id === normalizedSourceId) { + // This edge goes in reverse direction - flip reference to use normalized direction + [refSourceX, refSourceY, refTargetX, refTargetY] = [refTargetX, refTargetY, refSourceX, refSourceY]; + } + } + perpOffset = calculatePerpendicularOffset( - sourceIntersection.x, - sourceIntersection.y, - targetIntersection.x, - targetIntersection.y, + refSourceX, + refSourceY, + refTargetX, + refTargetY, offsetMagnitude ); } + // For parallel edges, use minimal endpoint offset to keep edges close to nodes + // The control points will create the visual separation + const endpointOffsetFactor = 0.1; // Minimal offset (10% of full offset) + const sourceEndpointOffset = offsetMultiplier !== 0 ? { + x: perpOffset.x * endpointOffsetFactor, + y: perpOffset.y * endpointOffsetFactor, + } : { x: 0, y: 0 }; + const targetEndpointOffset = offsetMultiplier !== 0 ? { + x: perpOffset.x * endpointOffsetFactor, + y: perpOffset.y * endpointOffsetFactor, + } : { x: 0, y: 0 }; + // Calculate control points using the normal angles, with perpendicular offset applied const sourceControlX = sourceIntersection.x + Math.cos(sourceIntersection.angle) * controlPointDistance + perpOffset.x; const sourceControlY = sourceIntersection.y + Math.sin(sourceIntersection.angle) * controlPointDistance + perpOffset.y; const targetControlX = targetIntersection.x + Math.cos(targetIntersection.angle) * controlPointDistance + perpOffset.x; const targetControlY = targetIntersection.y + Math.sin(targetIntersection.angle) * controlPointDistance + perpOffset.y; + // Debug: Log control point offsets + if (offsetMultiplier !== 0) { + console.log('🎯 Edge path with offset:', { + offsetMultiplier, + perpOffset, + controlPointDistance, + endpointOffset: sourceEndpointOffset, + sourceControl: { x: sourceControlX, y: sourceControlY }, + targetControl: { x: targetControlX, y: targetControlY }, + }); + } + return { - sx: sourceIntersection.x, - sy: sourceIntersection.y, - tx: targetIntersection.x, - ty: targetIntersection.y, + sx: sourceIntersection.x + sourceEndpointOffset.x, + sy: sourceIntersection.y + sourceEndpointOffset.y, + tx: targetIntersection.x + targetEndpointOffset.x, + ty: targetIntersection.y + targetEndpointOffset.y, sourceControlX, sourceControlY, targetControlX,