constellation-analyzer/src/components/Edges/CustomEdge.tsx
Jan-Henrik Bruhn 3a64d37f02 feat: implement directional relationships for edges
Adds full support for directed, bidirectional, and undirected relationships
between actors with visual arrow markers and intuitive controls.

**Type System:**
- Add EdgeDirectionality type (directed | bidirectional | undirected)
- Add directionality field to RelationData
- Add defaultDirectionality field to EdgeTypeConfig

**Visual Representation:**
- Directed edges: single arrow marker at target (→)
- Bidirectional edges: arrow markers at both ends (↔)
- Undirected edges: no arrow markers (—)
- Separate marker definitions for start/end to ensure correct orientation

**Property Panel Controls:**
- MUI ToggleButtonGroup for selecting directionality
- Visual connection indicator with directional symbols
- Reverse direction button (swaps source/target, only for directed edges)
- Live updates with 500ms debounce

**Edge Type Configuration:**
- Default directionality selector in edge type form
- Dropdown with helpful descriptions (→, ↔, —)
- Applied to both create and edit workflows

**Edge Creation:**
- New edges inherit defaultDirectionality from edge type config
- Falls back to 'directed' for backwards compatibility

**Reverse Direction:**
- Swaps source/target and sourceHandle/targetHandle
- Maintains edge ID and selection state
- Tracked in undo/redo history

Includes comprehensive UX specification document with wireframes,
interaction patterns, accessibility guidelines, and implementation phases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 11:54:16 +02:00

141 lines
3.7 KiB
TypeScript

import { memo } from 'react';
import {
EdgeProps,
getBezierPath,
EdgeLabelRenderer,
BaseEdge,
} from 'reactflow';
import { useGraphStore } from '../../stores/graphStore';
import type { RelationData } from '../../types';
/**
* CustomEdge - Represents a relation between actors in the constellation graph
*
* Features:
* - Bezier curve path
* - Type-based coloring and styling
* - Optional label display
* - Edge type badge
* - Directional arrow markers (directed, bidirectional, undirected)
*
* Usage: Automatically rendered by React Flow for edges with type='custom'
*/
const CustomEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}: EdgeProps<RelationData>) => {
const edgeTypes = useGraphStore((state) => state.edgeTypes);
// Calculate the bezier path
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
// Find the edge type configuration
const edgeTypeConfig = edgeTypes.find((et) => et.id === data?.type);
const edgeColor = edgeTypeConfig?.color || '#6b7280';
const edgeStyle = edgeTypeConfig?.style || 'solid';
// Use custom label if provided, otherwise use type's default label
const displayLabel = data?.label || edgeTypeConfig?.label;
// Convert style to stroke-dasharray
const strokeDasharray = {
solid: '0',
dashed: '5,5',
dotted: '1,5',
}[edgeStyle];
// Get directionality (default to 'directed' for backwards compatibility)
const directionality = data?.directionality || edgeTypeConfig?.defaultDirectionality || 'directed';
// Create unique marker IDs based on color (for reusability)
const safeColor = edgeColor.replace('#', '');
const markerEndId = `arrow-end-${safeColor}`;
const markerStartId = `arrow-start-${safeColor}`;
// Determine marker start/end based on directionality
const markerEnd = (directionality === 'directed' || directionality === 'bidirectional')
? `url(#${markerEndId})`
: undefined;
const markerStart = directionality === 'bidirectional'
? `url(#${markerStartId})`
: undefined;
return (
<>
{/* Arrow marker definitions */}
<defs>
{/* Arrow pointing right (for marker-end) */}
<marker
id={markerEndId}
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="8"
markerHeight="8"
orient="auto"
fill={edgeColor}
>
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
{/* Arrow pointing left (for marker-start) */}
<marker
id={markerStartId}
viewBox="0 0 10 10"
refX="2"
refY="5"
markerWidth="8"
markerHeight="8"
orient="auto"
fill={edgeColor}
>
<path d="M 10 0 L 0 5 L 10 10 z" />
</marker>
</defs>
<BaseEdge
id={id}
path={edgePath}
style={{
stroke: edgeColor,
strokeWidth: selected ? 3 : 2,
strokeDasharray,
}}
markerEnd={markerEnd}
markerStart={markerStart}
/>
{/* Edge label - show custom or type default */}
{displayLabel && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
className="bg-white px-2 py-1 rounded border border-gray-300 text-xs font-medium shadow-sm"
>
<div style={{ color: edgeColor }}>{displayLabel}</div>
</div>
</EdgeLabelRenderer>
)}
</>
);
};
export default memo(CustomEdge);