mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-03-13 20:18:47 +00:00
Compare commits
12 commits
094fd6d957
...
4e1f19c82b
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e1f19c82b | |||
| 8feccb6a48 | |||
| de87be5f66 | |||
| de8fd67fb7 | |||
| 32f5b3d532 | |||
| 676f1a61da | |||
| 516d9fb444 | |||
| f797da9835 | |||
| ce7f6c2eed | |||
| fdef63e8bd | |||
| 833c130690 | |||
| 3daedbc0d8 |
15 changed files with 1810 additions and 100 deletions
610
docs/EDGE_OVERLAP_UX_PROPOSAL.md
Normal file
610
docs/EDGE_OVERLAP_UX_PROPOSAL.md
Normal file
|
|
@ -0,0 +1,610 @@
|
||||||
|
# Edge Overlap UX Design Proposal
|
||||||
|
## Constellation Analyzer - Handling Overlapping Edges
|
||||||
|
|
||||||
|
**Date:** 2026-02-05
|
||||||
|
**Status:** Proposal
|
||||||
|
**Problem:** When multiple relations exist between the same two nodes, or when edges cross each other, they overlap and become difficult to distinguish, select, or understand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Implementation Analysis
|
||||||
|
|
||||||
|
### What We Have
|
||||||
|
The codebase uses **@xyflow/react** (React Flow v12) with custom edge rendering:
|
||||||
|
|
||||||
|
1. **CustomEdge component** (`src/components/Edges/CustomEdge.tsx`)
|
||||||
|
- Renders edges as cubic Bezier curves
|
||||||
|
- Uses `getFloatingEdgeParams()` for calculating intersection points with various node shapes
|
||||||
|
- Supports directional arrows (directed, bidirectional, undirected)
|
||||||
|
- Shows edge labels with type indicators and custom text
|
||||||
|
- Implements visual filtering with opacity changes
|
||||||
|
|
||||||
|
2. **Edge Routing** (`src/utils/edgeUtils.ts`)
|
||||||
|
- Smart shape-aware intersection calculation (circle, ellipse, pill, rounded rectangle)
|
||||||
|
- Bezier curves with control points at 40% of inter-node distance
|
||||||
|
- Single path between any two nodes - no multi-edge handling
|
||||||
|
|
||||||
|
3. **Edge Aggregation for Groups**
|
||||||
|
- When groups are minimized, multiple internal edges are aggregated into a single edge
|
||||||
|
- Shows count badge (e.g., "5 relations")
|
||||||
|
- Uses neutral gray color for aggregated edges
|
||||||
|
|
||||||
|
### Current Gaps
|
||||||
|
- **No offset for parallel edges**: Multiple edges between same nodes overlap completely
|
||||||
|
- **No edge crossing detection**: Edges can cross over each other with no visual differentiation
|
||||||
|
- **Selection challenges**: Overlapping edges are hard to click/select
|
||||||
|
- **Visual clutter**: Dense graphs become illegible with many crossing edges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Research: Best Practices for Edge Overlap
|
||||||
|
|
||||||
|
### Industry Standards
|
||||||
|
|
||||||
|
1. **Dagre/Graphviz Approach**
|
||||||
|
- Hierarchical edge routing with splines
|
||||||
|
- Edge bundling for crossing edges
|
||||||
|
- Used by: Mermaid, PlantUML
|
||||||
|
|
||||||
|
2. **Force-Directed Graphs (D3, Cytoscape)**
|
||||||
|
- Physics-based layout to minimize crossings
|
||||||
|
- Edge bundling for hierarchical structures
|
||||||
|
- Used by: Neo4j Browser, Gephi
|
||||||
|
|
||||||
|
3. **Network Diagram Tools (Draw.io, Lucidchart)**
|
||||||
|
- Manual routing with waypoints
|
||||||
|
- Orthogonal connectors (90-degree angles)
|
||||||
|
- Automatic routing around obstacles
|
||||||
|
|
||||||
|
4. **React Flow Patterns**
|
||||||
|
- Edge offset for parallel edges
|
||||||
|
- Custom edge components with hover states
|
||||||
|
- Edge label positioning to avoid overlap
|
||||||
|
|
||||||
|
### User Expectations from Similar Tools
|
||||||
|
|
||||||
|
**Graph Databases (Neo4j, ArangoDB)**
|
||||||
|
- Clear visual separation between parallel edges
|
||||||
|
- Interactive hover to highlight connection paths
|
||||||
|
- Edge bundling for many-to-many relationships
|
||||||
|
|
||||||
|
**Diagramming Tools (Miro, FigJam)**
|
||||||
|
- Smooth bezier curves with collision avoidance
|
||||||
|
- Hover states that bring edges to front
|
||||||
|
- Smart label positioning
|
||||||
|
|
||||||
|
**Network Analysis (Gephi, Cytoscape)**
|
||||||
|
- Edge bundling for dense areas
|
||||||
|
- Opacity/width to show edge importance
|
||||||
|
- Interactive filtering to reduce visual noise
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Solution 1: Parallel Edge Offset (Recommended)
|
||||||
|
|
||||||
|
### Visual Approach
|
||||||
|
**Offset parallel edges** with curved paths that arc away from the center line between nodes.
|
||||||
|
|
||||||
|
#### Implementation Details
|
||||||
|
- When multiple edges exist between same source/target, calculate offset curves
|
||||||
|
- Use consistent offset distance (e.g., 30px) that scales with zoom
|
||||||
|
- Maximum of 3 parallel edges visible; beyond that, show aggregation badge
|
||||||
|
- Bidirectional edges use center position (no offset)
|
||||||
|
|
||||||
|
#### Visual Example (ASCII)
|
||||||
|
```
|
||||||
|
A ─────────────→ B (Single edge: straight bezier)
|
||||||
|
|
||||||
|
A ═══════════⟩ B (Multiple edges: curved offsets)
|
||||||
|
⟨─────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Interaction Patterns
|
||||||
|
|
||||||
|
**Selection**
|
||||||
|
- Click tolerance increases for offset edges (larger hit area)
|
||||||
|
- Selected edge highlighted with thicker stroke (3px -> 4px)
|
||||||
|
- Non-selected parallel edges dim to 50% opacity
|
||||||
|
- Hover shows all parallel edges with tooltip
|
||||||
|
|
||||||
|
**Hover States**
|
||||||
|
- Individual edge highlight on hover
|
||||||
|
- Show edge type and label in tooltip
|
||||||
|
- Dim other edges to 30% opacity
|
||||||
|
- Bring hovered edge to top layer (z-index manipulation)
|
||||||
|
|
||||||
|
**Multi-Edge Badge**
|
||||||
|
- Show count when 4+ edges between same nodes: "4 relations"
|
||||||
|
- Click badge to expand into offset view
|
||||||
|
- Badge position: midpoint of straight line between nodes
|
||||||
|
- Color: use neutral gray (matches aggregation pattern)
|
||||||
|
|
||||||
|
#### Accessibility Considerations
|
||||||
|
|
||||||
|
**Keyboard Navigation**
|
||||||
|
- Tab through edges in document order (source node ID order)
|
||||||
|
- Arrow keys to navigate between parallel edges
|
||||||
|
- Space/Enter to select edge
|
||||||
|
- Screen reader announces: "Relation 2 of 4 from Person A to Organization B, Collaborates type"
|
||||||
|
|
||||||
|
**Screen Readers**
|
||||||
|
- Edge count announced: "4 relations between nodes"
|
||||||
|
- Each edge individually focusable and describable
|
||||||
|
- Alternative text includes source, target, type, and label
|
||||||
|
|
||||||
|
**Color Contrast**
|
||||||
|
- Maintain WCAG AA standards (4.5:1 for text, 3:1 for UI components)
|
||||||
|
- Don't rely solely on color - use patterns (dashed/dotted) and labels
|
||||||
|
- High contrast mode: increase stroke width and use distinct patterns
|
||||||
|
|
||||||
|
**Motor Impairments**
|
||||||
|
- Larger click targets (minimum 44x44px according to WCAG 2.1 AAA)
|
||||||
|
- Increase hover tolerance for parallel edges
|
||||||
|
- Longer hover delay before tooltip appears (300ms)
|
||||||
|
|
||||||
|
#### Implementation Complexity
|
||||||
|
|
||||||
|
**Effort: Medium (3-5 days)**
|
||||||
|
|
||||||
|
**Changes Required:**
|
||||||
|
1. Modify `getFloatingEdgeParams()` to accept edge index parameter
|
||||||
|
2. Add offset calculation to bezier control points
|
||||||
|
3. Update `GraphEditor` to detect parallel edges and pass index
|
||||||
|
4. Create edge grouping utility function
|
||||||
|
5. Update CustomEdge to handle offset rendering
|
||||||
|
6. Add hover state management for edge groups
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Clear visual distinction between parallel edges
|
||||||
|
- Minimal performance impact (mathematical calculation only)
|
||||||
|
- Consistent with user expectations from diagramming tools
|
||||||
|
- Works at all zoom levels
|
||||||
|
- Preserves existing edge aggregation for groups
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Visual complexity increases with many parallel edges
|
||||||
|
- Requires recalculation when edges added/removed
|
||||||
|
- May need edge limit policy (e.g., max 5 visible, then aggregate)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Solution 2: Edge Bundling with Interaction
|
||||||
|
|
||||||
|
### Visual Approach
|
||||||
|
**Bundle overlapping edges** into a single visual path that expands on interaction.
|
||||||
|
|
||||||
|
#### Implementation Details
|
||||||
|
- Detect edge clusters (edges that cross within 20px threshold)
|
||||||
|
- Render as single thick edge with width indicating count
|
||||||
|
- On hover, "explode" bundle into individual edges with labels
|
||||||
|
- Use color gradient to show multiple edge types in bundle
|
||||||
|
|
||||||
|
#### Visual Example (ASCII)
|
||||||
|
```
|
||||||
|
Default State:
|
||||||
|
A ══════════════⟩ B (Thick bundle: 5 edges)
|
||||||
|
|
||||||
|
Hover/Expanded State:
|
||||||
|
A ═══════════⟩ B
|
||||||
|
───────────→
|
||||||
|
─ ─ ─ ─ ─→
|
||||||
|
· · · · · →
|
||||||
|
⟨─────────
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Interaction Patterns
|
||||||
|
|
||||||
|
**Default State**
|
||||||
|
- Show edge count badge on bundle
|
||||||
|
- Use widest stroke width to indicate bundle (4-8px)
|
||||||
|
- Color: blend of constituent edge types (or neutral gray)
|
||||||
|
|
||||||
|
**Hover State**
|
||||||
|
- Animate expansion into individual offset edges (300ms transition)
|
||||||
|
- Show all edge labels
|
||||||
|
- Individual edges selectable in expanded state
|
||||||
|
- Background blur/dim for focus
|
||||||
|
|
||||||
|
**Selection**
|
||||||
|
- Click bundle to expand permanently until deselected
|
||||||
|
- Click individual edge when expanded to select it
|
||||||
|
- Selected edge shows properties panel
|
||||||
|
- Double-click bundle to "pin" expansion
|
||||||
|
|
||||||
|
**Touch Devices**
|
||||||
|
- Tap bundle to expand
|
||||||
|
- Tap again to collapse
|
||||||
|
- Long-press for context menu
|
||||||
|
- Pinch to zoom focuses on bundle
|
||||||
|
|
||||||
|
#### Accessibility Considerations
|
||||||
|
|
||||||
|
**Keyboard Navigation**
|
||||||
|
- Tab to bundle
|
||||||
|
- Enter/Space to expand bundle
|
||||||
|
- Arrow keys to navigate within expanded bundle
|
||||||
|
- Escape to collapse bundle
|
||||||
|
- Screen reader: "Edge bundle containing 5 relations. Press Enter to expand."
|
||||||
|
|
||||||
|
**Visual Indicators**
|
||||||
|
- Animated expansion provides visual feedback
|
||||||
|
- Count badge always visible
|
||||||
|
- Edge labels appear only when expanded
|
||||||
|
- High contrast mode: use distinct patterns for bundle indicator
|
||||||
|
|
||||||
|
**Cognitive Load**
|
||||||
|
- Progressive disclosure reduces initial complexity
|
||||||
|
- Expansion animation helps user track state change
|
||||||
|
- Consistent interaction pattern across all bundles
|
||||||
|
- Visual hierarchy: bundles stand out in dense graphs
|
||||||
|
|
||||||
|
#### Implementation Complexity
|
||||||
|
|
||||||
|
**Effort: High (7-10 days)**
|
||||||
|
|
||||||
|
**Changes Required:**
|
||||||
|
1. Create edge clustering algorithm (detect overlapping edges)
|
||||||
|
2. Build BundledEdge component with expansion animation
|
||||||
|
3. Add state management for expanded/collapsed bundles
|
||||||
|
4. Implement hover detection and animation system
|
||||||
|
5. Create bundle label component with count badge
|
||||||
|
6. Update selection logic to handle bundle vs individual edge
|
||||||
|
7. Performance optimization for large graphs (edge clustering can be expensive)
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Dramatically reduces visual clutter in dense graphs
|
||||||
|
- Progressive disclosure matches user intent
|
||||||
|
- Scalable to very large graphs
|
||||||
|
- Innovative UX that stands out
|
||||||
|
- Reduces cognitive load in default state
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Higher implementation complexity
|
||||||
|
- Requires state management for bundle expansion
|
||||||
|
- Animation performance concerns on large graphs
|
||||||
|
- May confuse users unfamiliar with the pattern
|
||||||
|
- Edge clustering algorithm is computationally expensive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Solution 3: Smart Edge Routing with Collision Avoidance
|
||||||
|
|
||||||
|
### Visual Approach
|
||||||
|
**Automatically route edges** around nodes and other edges to minimize crossings.
|
||||||
|
|
||||||
|
#### Implementation Details
|
||||||
|
- Use A* pathfinding or similar algorithm to route edges
|
||||||
|
- Create orthogonal or curved paths that avoid node boundaries
|
||||||
|
- Calculate collision-free paths on layout change
|
||||||
|
- Option to manually add waypoints for fine-tuning
|
||||||
|
|
||||||
|
#### Visual Example (ASCII)
|
||||||
|
```
|
||||||
|
Default:
|
||||||
|
A → B → C
|
||||||
|
↓ ↓
|
||||||
|
D → E → F
|
||||||
|
|
||||||
|
With Smart Routing:
|
||||||
|
A → B → C
|
||||||
|
↓ ↓ ↓
|
||||||
|
D ←─╯ ↓
|
||||||
|
↓ ↓
|
||||||
|
└─→ E → F
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Interaction Patterns
|
||||||
|
|
||||||
|
**Automatic Routing**
|
||||||
|
- Edges automatically reroute when nodes move
|
||||||
|
- Smooth animation (300ms) when path changes
|
||||||
|
- Maintain edge label positions at path midpoints
|
||||||
|
- Option to disable auto-routing (manual mode)
|
||||||
|
|
||||||
|
**Manual Waypoints**
|
||||||
|
- Double-click edge to add waypoint
|
||||||
|
- Drag waypoint to adjust path
|
||||||
|
- Right-click waypoint to remove
|
||||||
|
- Waypoints persist across sessions
|
||||||
|
|
||||||
|
**Path Highlighting**
|
||||||
|
- Hover edge to highlight entire path
|
||||||
|
- Show direction arrows along path
|
||||||
|
- Dim other edges when hovering
|
||||||
|
- Show path length in tooltip (optional)
|
||||||
|
|
||||||
|
**Configuration**
|
||||||
|
- Toggle between "curved" and "orthogonal" routing
|
||||||
|
- Adjust routing algorithm sensitivity
|
||||||
|
- Set collision avoidance distance
|
||||||
|
- Enable/disable automatic rerouting
|
||||||
|
|
||||||
|
#### Accessibility Considerations
|
||||||
|
|
||||||
|
**Keyboard Navigation**
|
||||||
|
- Tab to select edge
|
||||||
|
- Arrow keys to navigate waypoints
|
||||||
|
- Delete key to remove waypoint
|
||||||
|
- Screen reader: "Edge with 3 waypoints. From Person A to Organization B."
|
||||||
|
|
||||||
|
**Visual Clarity**
|
||||||
|
- Orthogonal paths easier to follow than curves
|
||||||
|
- Clear directional indicators
|
||||||
|
- Path highlighting on focus
|
||||||
|
- High contrast mode: thicker paths with distinct patterns
|
||||||
|
|
||||||
|
**Cognitive Load**
|
||||||
|
- Auto-routing reduces manual work
|
||||||
|
- Can be confusing when paths change automatically
|
||||||
|
- Manual waypoints give user control
|
||||||
|
- Learning curve for waypoint editing
|
||||||
|
|
||||||
|
#### Implementation Complexity
|
||||||
|
|
||||||
|
**Effort: Very High (15-20 days)**
|
||||||
|
|
||||||
|
**Changes Required:**
|
||||||
|
1. Implement edge routing algorithm (A*, Dijkstra, or orthogonal routing)
|
||||||
|
2. Create waypoint system for manual editing
|
||||||
|
3. Add collision detection for nodes and edges
|
||||||
|
4. Implement path smoothing and bezier curve generation
|
||||||
|
5. Add animation system for path updates
|
||||||
|
6. Create configuration panel for routing options
|
||||||
|
7. Performance optimization (routing is CPU-intensive)
|
||||||
|
8. Handle edge cases (loops, self-edges, complex layouts)
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Eliminates edge crossings in most cases
|
||||||
|
- Professional appearance (like diagramming tools)
|
||||||
|
- User control through manual waypoints
|
||||||
|
- Scales to complex graphs
|
||||||
|
- Industry-standard approach
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Very high implementation complexity
|
||||||
|
- Significant performance impact on large graphs
|
||||||
|
- May produce unexpected routing in complex scenarios
|
||||||
|
- Requires substantial testing and edge case handling
|
||||||
|
- Can feel "overengineered" for simple graphs
|
||||||
|
- Breaking change from existing bezier curve UX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation: Solution 1 (Parallel Edge Offset)
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
After analyzing the three solutions against project requirements, I recommend **Solution 1: Parallel Edge Offset** for the following reasons:
|
||||||
|
|
||||||
|
#### 1. **Best Effort/Value Ratio**
|
||||||
|
- **Medium complexity** (3-5 days) vs High (7-10 days) or Very High (15-20 days)
|
||||||
|
- Solves the primary problem: parallel edges between same nodes
|
||||||
|
- Incremental improvement that doesn't require architectural changes
|
||||||
|
|
||||||
|
#### 2. **Preserves Existing UX Patterns**
|
||||||
|
- Maintains current bezier curve style
|
||||||
|
- Consistent with existing group aggregation behavior
|
||||||
|
- No breaking changes to user workflows
|
||||||
|
- Works within React Flow's existing architecture
|
||||||
|
|
||||||
|
#### 3. **User-Centered Design**
|
||||||
|
- Immediate visual clarity for parallel edges
|
||||||
|
- Familiar pattern from other diagramming tools (Draw.io, Miro)
|
||||||
|
- Low learning curve
|
||||||
|
- Accessible with keyboard and screen readers
|
||||||
|
|
||||||
|
#### 4. **Performance**
|
||||||
|
- Pure mathematical calculation (no expensive algorithms)
|
||||||
|
- Scales well to large graphs (O(n) complexity)
|
||||||
|
- No animation/state management overhead
|
||||||
|
- Works at all zoom levels without recalculation
|
||||||
|
|
||||||
|
#### 5. **Accessibility**
|
||||||
|
- WCAG AA compliant
|
||||||
|
- Screen reader support straightforward
|
||||||
|
- Keyboard navigation well-defined
|
||||||
|
- High contrast mode compatible
|
||||||
|
|
||||||
|
#### 6. **Extensibility**
|
||||||
|
- Foundation for future enhancements
|
||||||
|
- Can add edge bundling later if needed
|
||||||
|
- Compatible with future smart routing
|
||||||
|
- Doesn't preclude other solutions
|
||||||
|
|
||||||
|
### Implementation Plan
|
||||||
|
|
||||||
|
#### Phase 1: Core Offset Logic (Day 1-2)
|
||||||
|
1. Create `calculateEdgeOffset()` utility function
|
||||||
|
2. Detect parallel edges in `GraphEditor`
|
||||||
|
3. Pass offset index to `CustomEdge` component
|
||||||
|
4. Modify `getFloatingEdgeParams()` to accept offset parameter
|
||||||
|
5. Calculate perpendicular offset for bezier control points
|
||||||
|
|
||||||
|
#### Phase 2: Visual Refinement (Day 2-3)
|
||||||
|
1. Implement hover states for parallel edge groups
|
||||||
|
2. Add selection highlighting
|
||||||
|
3. Create edge count badge component (for 4+ edges)
|
||||||
|
4. Test at different zoom levels
|
||||||
|
5. Ensure labels don't overlap
|
||||||
|
|
||||||
|
#### Phase 3: Interaction & Accessibility (Day 3-4)
|
||||||
|
1. Keyboard navigation for parallel edges
|
||||||
|
2. Screen reader announcements
|
||||||
|
3. Click tolerance adjustment
|
||||||
|
4. Tooltip improvements
|
||||||
|
5. High contrast mode testing
|
||||||
|
|
||||||
|
#### Phase 4: Testing & Documentation (Day 4-5)
|
||||||
|
1. Unit tests for offset calculation
|
||||||
|
2. Integration tests for edge groups
|
||||||
|
3. Visual regression tests
|
||||||
|
4. User documentation
|
||||||
|
5. Code review and refinement
|
||||||
|
|
||||||
|
### Success Metrics
|
||||||
|
|
||||||
|
**Visual Clarity**
|
||||||
|
- Parallel edges clearly distinguishable at all zoom levels
|
||||||
|
- No overlap between offset curves
|
||||||
|
- Labels remain readable
|
||||||
|
|
||||||
|
**Interaction Quality**
|
||||||
|
- Click target accuracy > 95% for offset edges
|
||||||
|
- Hover states respond within 100ms
|
||||||
|
- Keyboard navigation covers all edges
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- No frame rate impact on graphs up to 500 edges
|
||||||
|
- Edge offset calculation < 5ms per edge
|
||||||
|
- Zoom/pan remains smooth
|
||||||
|
|
||||||
|
**Accessibility**
|
||||||
|
- WCAG AA compliance verified
|
||||||
|
- Screen reader testing with NVDA/JAWS
|
||||||
|
- Keyboard-only navigation successful
|
||||||
|
- High contrast mode functional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
After implementing Solution 1, consider these incremental improvements:
|
||||||
|
|
||||||
|
### Short-term (1-3 months)
|
||||||
|
1. **Edge hover tooltips**: Show full edge information on hover
|
||||||
|
2. **Edge filtering**: Hide edges by type/label to reduce clutter
|
||||||
|
3. **Edge path highlighting**: Show full path on selection
|
||||||
|
4. **Curved edge labels**: Orient labels along curve path
|
||||||
|
|
||||||
|
### Medium-term (3-6 months)
|
||||||
|
1. **Edge bundling** (Solution 2): For dense graphs with many crossings
|
||||||
|
2. **Edge strength visualization**: Vary width/opacity by strength property
|
||||||
|
3. **Edge animation**: Flowing particles to show direction/activity
|
||||||
|
4. **Edge grouping controls**: Manual grouping/ungrouping
|
||||||
|
|
||||||
|
### Long-term (6-12 months)
|
||||||
|
1. **Smart routing** (Solution 3): Optional for complex layouts
|
||||||
|
2. **Layout algorithms**: Auto-arrange to minimize crossings
|
||||||
|
3. **Edge styles library**: More edge type options (elbow, stepped, etc.)
|
||||||
|
4. **3D graph view**: For very complex networks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Technical Specifications
|
||||||
|
|
||||||
|
### Edge Offset Calculation Algorithm
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Calculate perpendicular offset for parallel edges
|
||||||
|
* @param sourcePos Source node position
|
||||||
|
* @param targetPos Target node position
|
||||||
|
* @param edgeIndex Index in parallel edge group (0 = center, 1 = first offset, 2 = second offset)
|
||||||
|
* @param offsetDistance Base offset distance in pixels (default 30)
|
||||||
|
* @returns Offset vector { x, y }
|
||||||
|
*/
|
||||||
|
function calculateEdgeOffset(
|
||||||
|
sourcePos: { x: number; y: number },
|
||||||
|
targetPos: { x: number; y: number },
|
||||||
|
edgeIndex: number,
|
||||||
|
offsetDistance: number = 30
|
||||||
|
): { x: number; y: number } {
|
||||||
|
// Calculate edge direction vector
|
||||||
|
const dx = targetPos.x - sourcePos.x;
|
||||||
|
const dy = targetPos.y - sourcePos.y;
|
||||||
|
const length = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (length === 0) return { x: 0, y: 0 };
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
const nx = dx / length;
|
||||||
|
const ny = dy / length;
|
||||||
|
|
||||||
|
// Perpendicular vector (rotate 90 degrees)
|
||||||
|
const perpX = -ny;
|
||||||
|
const perpY = nx;
|
||||||
|
|
||||||
|
// Alternate sides for even/odd indices
|
||||||
|
// 0: center (no offset)
|
||||||
|
// 1: +offset (top/right side)
|
||||||
|
// 2: -offset (bottom/left side)
|
||||||
|
// 3: +offset * 2 (further top/right)
|
||||||
|
// 4: -offset * 2 (further bottom/left)
|
||||||
|
const side = edgeIndex % 2 === 0 ? -1 : 1;
|
||||||
|
const magnitude = Math.ceil(edgeIndex / 2);
|
||||||
|
const offset = side * magnitude * offsetDistance;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: perpX * offset,
|
||||||
|
y: perpY * offset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge Grouping Detection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Group edges by source-target pair
|
||||||
|
* @param edges Array of all edges
|
||||||
|
* @returns Map of edge groups, keyed by "sourceId_targetId"
|
||||||
|
*/
|
||||||
|
function groupParallelEdges(edges: Relation[]): Map<string, Relation[]> {
|
||||||
|
const groups = new Map<string, Relation[]>();
|
||||||
|
|
||||||
|
edges.forEach(edge => {
|
||||||
|
// Normalize key: always alphabetically sorted for bidirectional grouping
|
||||||
|
const key = [edge.source, edge.target].sort().join('_');
|
||||||
|
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, []);
|
||||||
|
}
|
||||||
|
groups.get(key)!.push(edge);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
|
||||||
|
**Memory:**
|
||||||
|
- Edge offset calculation: O(1) per edge
|
||||||
|
- Edge grouping: O(n) space for n edges
|
||||||
|
- Total memory impact: ~50 bytes per edge
|
||||||
|
|
||||||
|
**CPU:**
|
||||||
|
- Offset calculation: ~0.1ms per edge (pure math)
|
||||||
|
- Grouping detection: O(n) time for n edges
|
||||||
|
- Total impact: < 100ms for 1000 edges
|
||||||
|
|
||||||
|
**Rendering:**
|
||||||
|
- No additional DOM nodes
|
||||||
|
- Same SVG path rendering as current implementation
|
||||||
|
- Z-index manipulation on hover (no re-render)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Implementing **Parallel Edge Offset** (Solution 1) provides the best balance of:
|
||||||
|
- User experience improvement
|
||||||
|
- Implementation complexity
|
||||||
|
- Performance impact
|
||||||
|
- Accessibility compliance
|
||||||
|
- Future extensibility
|
||||||
|
|
||||||
|
This solution directly addresses the stated problem of overlapping edges between the same two nodes, while maintaining compatibility with the existing codebase architecture and user expectations.
|
||||||
|
|
||||||
|
The implementation can be completed in 3-5 days and provides a solid foundation for future enhancements like edge bundling or smart routing if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next Steps:**
|
||||||
|
1. Review this proposal with the team
|
||||||
|
2. Approve implementation plan and timeline
|
||||||
|
3. Create implementation tickets
|
||||||
|
4. Begin Phase 1 development
|
||||||
|
5. Iterate based on user feedback
|
||||||
|
|
||||||
|
**Questions or feedback?** Open an issue or discussion on the repository.
|
||||||
602
docs/EDGE_OVERLAP_VISUAL_GUIDE.md
Normal file
602
docs/EDGE_OVERLAP_VISUAL_GUIDE.md
Normal file
|
|
@ -0,0 +1,602 @@
|
||||||
|
# Edge Overlap Visual Design Guide
|
||||||
|
## Constellation Analyzer - Visual Specifications for Parallel Edge Offset
|
||||||
|
|
||||||
|
**Companion Document to:** EDGE_OVERLAP_UX_PROPOSAL.md
|
||||||
|
**Date:** 2026-02-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Design Patterns
|
||||||
|
|
||||||
|
### 1. Single Edge (Current State)
|
||||||
|
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ │ │
|
||||||
|
│ A │ ─────────────→ │ B │
|
||||||
|
│ │ │ │
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
Stroke: 2px
|
||||||
|
Color: Based on edge type
|
||||||
|
Curve: Cubic Bezier with 40% control point distance
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current Behavior:**
|
||||||
|
- Clean, simple bezier curve
|
||||||
|
- Works well for single connections
|
||||||
|
- Professional appearance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Parallel Edges (Proposed Design)
|
||||||
|
|
||||||
|
#### Two Edges Between Same Nodes
|
||||||
|
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭───────────→ │ │
|
||||||
|
│ A │ │ B │
|
||||||
|
│ │ ╰───────────→ │ │
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
Upper edge: +30px offset
|
||||||
|
Lower edge: -30px offset
|
||||||
|
Both: 2px stroke
|
||||||
|
Curves: Smooth, symmetrical arcs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visual Properties:**
|
||||||
|
- Offset: 30px perpendicular to center line
|
||||||
|
- Arc depth: Proportional to edge length
|
||||||
|
- Spacing: Consistent at all zoom levels
|
||||||
|
- Labels: Positioned at curve midpoint
|
||||||
|
|
||||||
|
#### Three Edges Between Same Nodes
|
||||||
|
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭──────────────→│ │
|
||||||
|
│ A │ ────────────────│ B │ (center, no offset)
|
||||||
|
│ │ ╰──────────────→│ │
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
Top edge: +30px offset
|
||||||
|
Center edge: 0px offset (straight bezier)
|
||||||
|
Bottom edge: -30px offset
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visual Hierarchy:**
|
||||||
|
- Center edge is most prominent (straight)
|
||||||
|
- Offset edges curve away from center
|
||||||
|
- Equal visual weight for all edges
|
||||||
|
|
||||||
|
#### Four+ Edges (Aggregation Badge)
|
||||||
|
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭──────────────→│ │
|
||||||
|
│ A │ ─────┌───┐─────│ B │
|
||||||
|
│ │ │ 4 │ │ │ │
|
||||||
|
│ │ ╰────└───┘─────→│ │
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
Top 3 edges: Visible with offsets
|
||||||
|
Badge: "4 relations" at center
|
||||||
|
Badge style: Gray pill, white text
|
||||||
|
Hover: Expand to show all 4 edges
|
||||||
|
```
|
||||||
|
|
||||||
|
**Badge Design:**
|
||||||
|
- Background: #6b7280 (gray-500)
|
||||||
|
- Text: White, 12px, medium weight
|
||||||
|
- Border radius: 12px (pill shape)
|
||||||
|
- Padding: 4px 8px
|
||||||
|
- Shadow: 0 2px 4px rgba(0,0,0,0.1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Hover States
|
||||||
|
|
||||||
|
#### Default State
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭───────────→ │ │
|
||||||
|
│ A │ ────────────→ │ B │
|
||||||
|
│ │ ╰───────────→ │ │
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
All edges: 100% opacity
|
||||||
|
No highlights
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Hover Single Edge
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭───────────→ │ │ (30% opacity, dimmed)
|
||||||
|
│ A │ ━━━━━━━━━━━━→ │ B │ (100% opacity, 3px stroke, highlighted)
|
||||||
|
│ │ ╰───────────→ │ │ (30% opacity, dimmed)
|
||||||
|
└──────┘ └──────┘
|
||||||
|
┌─────────────┐
|
||||||
|
│ Collaborates│ (Tooltip)
|
||||||
|
│ Type: Work │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hover Behavior:**
|
||||||
|
- Hovered edge: 3px stroke (from 2px)
|
||||||
|
- Hovered edge: 100% opacity
|
||||||
|
- Other parallel edges: 30% opacity
|
||||||
|
- Tooltip appears after 200ms
|
||||||
|
- Z-index: Bring hovered edge to top layer
|
||||||
|
|
||||||
|
#### Selection State
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭───────────→ │ │ (50% opacity, dimmed)
|
||||||
|
│ A │ ━━━━━━━━━━━━→ │ B │ (4px stroke, blue outline, selected)
|
||||||
|
│ │ ╰───────────→ │ │ (50% opacity, dimmed)
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
Selected edge: 4px stroke
|
||||||
|
Selected edge: Blue glow (#3b82f6)
|
||||||
|
Other parallel edges: 50% opacity
|
||||||
|
Selection persists until deselected
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Bidirectional Edges
|
||||||
|
|
||||||
|
#### Bidirectional vs Two Directed Edges
|
||||||
|
|
||||||
|
**Bidirectional (Single Edge):**
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ │ │
|
||||||
|
│ A │ ⟨────────────⟩ │ B │
|
||||||
|
│ │ │ │
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
Single edge with arrows at both ends
|
||||||
|
No offset (uses center line)
|
||||||
|
Marker-start and marker-end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Two Directed Edges:**
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭───────────→ │ │
|
||||||
|
│ A │ │ B │
|
||||||
|
│ │ ╰←──────────╯ │ │
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
Two separate edges with offsets
|
||||||
|
Offset: ±30px
|
||||||
|
Each has single arrow
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design Decision:**
|
||||||
|
- Bidirectional edges: No offset, use center line
|
||||||
|
- Two separate directed edges: Apply offset
|
||||||
|
- Visual distinction clear to users
|
||||||
|
- Preserves semantic meaning
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Edge Label Positioning
|
||||||
|
|
||||||
|
#### Label on Curved Edge
|
||||||
|
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭──┌─────────┐→ │ │
|
||||||
|
│ A │ │Collabora.│ │ B │
|
||||||
|
│ │ └─────────┘ │ │
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
Label positioned at bezier t=0.5 (midpoint)
|
||||||
|
Background: White with border
|
||||||
|
Padding: 8px 12px
|
||||||
|
Font: 12px, medium weight
|
||||||
|
Max-width: 200px (wrap text)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Label Collision Avoidance:**
|
||||||
|
- Labels offset 5px above curve for top edge
|
||||||
|
- Labels offset 5px below curve for bottom edge
|
||||||
|
- Center edge: label on curve (existing behavior)
|
||||||
|
- Labels never overlap edge paths
|
||||||
|
|
||||||
|
#### Multiple Labels on Parallel Edges
|
||||||
|
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭─┌────────┐──→ │ │
|
||||||
|
│ A │ │Reports To│ │ B │
|
||||||
|
│ │ ─┌──────────┐──→│ │
|
||||||
|
│ │ │Collabora.│ │ │
|
||||||
|
│ │ ╰┌─────────┐───→│ │
|
||||||
|
│ │ │Depends On│ │ │
|
||||||
|
└──────┘ └─────────┘ └──────┘
|
||||||
|
|
||||||
|
Labels staggered to prevent overlap
|
||||||
|
Each label aligned with its edge curve
|
||||||
|
Smart positioning algorithm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Zoom Level Behavior
|
||||||
|
|
||||||
|
#### Zoom Out (0.5x)
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌─┐ ┌─┐
|
||||||
|
│A│ ╭─────→ │B│
|
||||||
|
│ │ ───────→ │ │
|
||||||
|
│ │ ╰─────→ │ │
|
||||||
|
└─┘ └─┘
|
||||||
|
|
||||||
|
Offset: 30px (constant, not scaled)
|
||||||
|
Stroke: 1px (minimum)
|
||||||
|
Labels: Hidden or summarized
|
||||||
|
Badge: Visible
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design Note:** Offset distance remains constant in screen pixels, creating proportionally larger curves when zoomed out. This maintains visual separation.
|
||||||
|
|
||||||
|
#### Zoom In (2.0x)
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────────┐ ┌──────────┐
|
||||||
|
│ │ ╭────────────────────→ │ │
|
||||||
|
│ A │ │ B │
|
||||||
|
│ │ ──────────────────────→ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ╰────────────────────→ │ │
|
||||||
|
└──────────┘ └──────────┘
|
||||||
|
|
||||||
|
Offset: 30px (constant)
|
||||||
|
Stroke: 3px (scaled up)
|
||||||
|
Labels: Fully visible with more detail
|
||||||
|
Curves: More pronounced
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design Note:** At higher zoom, offset appears smaller relative to nodes, but remains visually distinct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Color and Styling
|
||||||
|
|
||||||
|
#### Edge Type Colors (Existing)
|
||||||
|
```
|
||||||
|
Collaborates: #3b82f6 (blue)
|
||||||
|
Reports To: #10b981 (green)
|
||||||
|
Depends On: #f59e0b (orange)
|
||||||
|
Influences: #8b5cf6 (purple)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Edge Styles (Existing)
|
||||||
|
```
|
||||||
|
Solid: ────────────
|
||||||
|
Dashed: ─ ─ ─ ─ ─ ─
|
||||||
|
Dotted: · · · · · ·
|
||||||
|
```
|
||||||
|
|
||||||
|
#### New States
|
||||||
|
```
|
||||||
|
Default: stroke-width: 2px, opacity: 1.0
|
||||||
|
Hover: stroke-width: 3px, opacity: 1.0
|
||||||
|
Dimmed: stroke-width: 2px, opacity: 0.3
|
||||||
|
Selected: stroke-width: 4px, opacity: 1.0, glow: #3b82f6
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Aggregation Badge
|
||||||
|
```
|
||||||
|
Background: #6b7280
|
||||||
|
Text: #ffffff
|
||||||
|
Border: None
|
||||||
|
Shadow: 0 2px 4px rgba(0,0,0,0.1)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Accessibility Visual Indicators
|
||||||
|
|
||||||
|
#### High Contrast Mode
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╔═══════════⟩ │ │ (4px stroke, solid)
|
||||||
|
│ A │ ═════════════⟩ │ B │ (4px stroke, dashed)
|
||||||
|
│ │ ╚═══════════⟩ │ │ (4px stroke, dotted)
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
All strokes: 4px (increased from 2px)
|
||||||
|
Distinct patterns for each edge type
|
||||||
|
Colors: High contrast (black/white basis)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Focus Indicator (Keyboard Navigation)
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌──────┐ ┌──────┐
|
||||||
|
│ │ ╭───────────→ │ │
|
||||||
|
│ A │ ┏━━━━━━━━━━━┓→ │ B │ (Focus ring: 2px offset)
|
||||||
|
│ │ ╰───────────→ │ │
|
||||||
|
└──────┘ └──────┘
|
||||||
|
|
||||||
|
Focus ring: 2px blue outline (#3b82f6)
|
||||||
|
Offset: 4px from edge path
|
||||||
|
Visible only when focused via keyboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Animation Specifications
|
||||||
|
|
||||||
|
#### Edge Creation Animation
|
||||||
|
```
|
||||||
|
Frame 0: Node A · Node B
|
||||||
|
·
|
||||||
|
·
|
||||||
|
Frame 1: Node A ·········· Node B
|
||||||
|
|
||||||
|
Frame 2: Node A ─────────────→ Node B
|
||||||
|
|
||||||
|
Duration: 300ms
|
||||||
|
Easing: ease-out
|
||||||
|
Effect: Draw from source to target
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Hover Transition
|
||||||
|
```
|
||||||
|
Frame 0: Normal state (2px, 100% opacity)
|
||||||
|
Frame 1: Transitioning (2.5px, 100% opacity)
|
||||||
|
Frame 2: Hover state (3px, 100% opacity)
|
||||||
|
|
||||||
|
Duration: 150ms
|
||||||
|
Easing: ease-in-out
|
||||||
|
Effect: Smooth width increase
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Selection Transition
|
||||||
|
```
|
||||||
|
Frame 0: Normal state
|
||||||
|
Frame 1: Glow appears (opacity: 0 → 0.5)
|
||||||
|
Frame 2: Width increases (2px → 4px)
|
||||||
|
Frame 3: Full selection state
|
||||||
|
|
||||||
|
Duration: 200ms
|
||||||
|
Easing: ease-out
|
||||||
|
Effect: Blue glow + width increase
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Responsive Behavior
|
||||||
|
|
||||||
|
#### Mobile View (< 768px)
|
||||||
|
```
|
||||||
|
- Offset distance: 40px (increased for touch targets)
|
||||||
|
- Stroke width: 3px (increased for visibility)
|
||||||
|
- Minimum click target: 44x44px
|
||||||
|
- Labels: Hidden by default, show on tap
|
||||||
|
- Badge: Always visible
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tablet View (768px - 1024px)
|
||||||
|
```
|
||||||
|
- Offset distance: 35px
|
||||||
|
- Stroke width: 2px
|
||||||
|
- Click target: 44x44px
|
||||||
|
- Labels: Show on hover
|
||||||
|
- Badge: Visible when 4+ edges
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Desktop View (> 1024px)
|
||||||
|
```
|
||||||
|
- Offset distance: 30px (default)
|
||||||
|
- Stroke width: 2px
|
||||||
|
- Click target: natural edge width
|
||||||
|
- Labels: Always visible
|
||||||
|
- Badge: Visible when 4+ edges
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Edge Case Visual Handling
|
||||||
|
|
||||||
|
#### Self-Loop Edge
|
||||||
|
```
|
||||||
|
Node A
|
||||||
|
┌──────┐
|
||||||
|
│ │ ╭─╮
|
||||||
|
│ A │ │ │ (Loop extends 80px from node)
|
||||||
|
│ │ ╰─╯
|
||||||
|
└──────┘
|
||||||
|
|
||||||
|
Rendered as circular arc
|
||||||
|
Extends 80px from node edge
|
||||||
|
Arrow points back to source
|
||||||
|
Label positioned outside loop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Very Short Distance Between Nodes
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌───┐ ┌───┐
|
||||||
|
│ A │╭→ │ B │
|
||||||
|
│ │╰→ │ │
|
||||||
|
└───┘ └───┘
|
||||||
|
|
||||||
|
Offset: Reduced to 15px (50% of default)
|
||||||
|
Curves: Sharper to fit space
|
||||||
|
Labels: Hidden to prevent overlap
|
||||||
|
Badge: Positioned above nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Long Distance Between Nodes
|
||||||
|
```
|
||||||
|
Node A Node B
|
||||||
|
┌───┐ ┌───┐
|
||||||
|
│ A │ ╭───────────────────────────────────────→ │ B │
|
||||||
|
│ │ ─────────────────────────────────────────→ │ │
|
||||||
|
│ │ ╰───────────────────────────────────────→ │ │
|
||||||
|
└───┘ └───┘
|
||||||
|
|
||||||
|
Offset: 30px (constant)
|
||||||
|
Curves: Gentle (control point distance capped at 150px)
|
||||||
|
Labels: Positioned at midpoint
|
||||||
|
Visual: Offset less noticeable but still distinct
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Tokens
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
```typescript
|
||||||
|
const EDGE_OFFSET_BASE = 30; // Base offset in pixels
|
||||||
|
const EDGE_OFFSET_MOBILE = 40; // Increased for touch
|
||||||
|
const EDGE_OFFSET_MIN = 15; // Minimum for close nodes
|
||||||
|
const LABEL_OFFSET = 5; // Label offset from curve
|
||||||
|
const BADGE_PADDING = '4px 8px'; // Badge internal padding
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strokes
|
||||||
|
```typescript
|
||||||
|
const STROKE_DEFAULT = 2; // Default edge width
|
||||||
|
const STROKE_HOVER = 3; // Hovered edge width
|
||||||
|
const STROKE_SELECTED = 4; // Selected edge width
|
||||||
|
const STROKE_DIMMED = 2; // Width when dimmed (opacity changes)
|
||||||
|
const STROKE_HIGH_CONTRAST = 4; // Width in high contrast mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opacity
|
||||||
|
```typescript
|
||||||
|
const OPACITY_DEFAULT = 1.0; // Normal edge visibility
|
||||||
|
const OPACITY_DIMMED = 0.3; // Non-hovered parallel edges
|
||||||
|
const OPACITY_SEMI_DIMMED = 0.5; // Non-selected parallel edges
|
||||||
|
const OPACITY_FILTERED = 0.2; // Edges filtered out by search
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
```typescript
|
||||||
|
const COLOR_SELECTION_GLOW = '#3b82f6'; // Blue focus/selection
|
||||||
|
const COLOR_BADGE_BG = '#6b7280'; // Gray badge background
|
||||||
|
const COLOR_BADGE_TEXT = '#ffffff'; // White badge text
|
||||||
|
const COLOR_LABEL_BG = '#ffffff'; // White label background
|
||||||
|
const COLOR_LABEL_BORDER = '#d1d5db'; // Gray label border
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timing
|
||||||
|
```typescript
|
||||||
|
const DURATION_HOVER = 150; // Hover transition duration (ms)
|
||||||
|
const DURATION_SELECTION = 200; // Selection animation duration (ms)
|
||||||
|
const DURATION_CREATION = 300; // Edge creation animation (ms)
|
||||||
|
const DURATION_TOOLTIP_DELAY = 200; // Delay before tooltip appears (ms)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bezier Curves
|
||||||
|
```typescript
|
||||||
|
const CONTROL_POINT_RATIO = 0.4; // 40% of distance between nodes
|
||||||
|
const CONTROL_POINT_MIN = 40; // Minimum control point distance (px)
|
||||||
|
const CONTROL_POINT_MAX = 150; // Maximum control point distance (px)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Reference
|
||||||
|
|
||||||
|
### CSS Classes (for styled edges)
|
||||||
|
```css
|
||||||
|
.edge-default {
|
||||||
|
stroke-width: 2px;
|
||||||
|
opacity: 1;
|
||||||
|
transition: stroke-width 150ms ease-in-out, opacity 150ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-hover {
|
||||||
|
stroke-width: 3px;
|
||||||
|
opacity: 1;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-selected {
|
||||||
|
stroke-width: 4px;
|
||||||
|
opacity: 1;
|
||||||
|
filter: drop-shadow(0 0 4px #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-dimmed {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-badge {
|
||||||
|
background: #6b7280;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-label {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
max-width: 200px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High Contrast Mode */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
.edge-default {
|
||||||
|
stroke-width: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus indicator for keyboard navigation */
|
||||||
|
.edge-focused {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This visual guide provides detailed specifications for implementing parallel edge offset in the Constellation Analyzer. All measurements, colors, and animations are designed to:
|
||||||
|
|
||||||
|
1. **Maintain visual consistency** with existing design patterns
|
||||||
|
2. **Ensure accessibility** across different modes and devices
|
||||||
|
3. **Scale gracefully** from mobile to desktop
|
||||||
|
4. **Provide clear interaction feedback** through hover, selection, and focus states
|
||||||
|
5. **Handle edge cases** without breaking the visual hierarchy
|
||||||
|
|
||||||
|
Use this guide alongside the main UX proposal document for implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Related Files:**
|
||||||
|
- Main proposal: `/home/jbruhn/dev/constellation-analyzer/EDGE_OVERLAP_UX_PROPOSAL.md`
|
||||||
|
- Current edge implementation: `/home/jbruhn/dev/constellation-analyzer/src/components/Edges/CustomEdge.tsx`
|
||||||
|
- Edge utilities: `/home/jbruhn/dev/constellation-analyzer/src/utils/edgeUtils.ts`
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
EdgeProps,
|
EdgeProps,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
useInternalNode,
|
useInternalNode,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import { useGraphStore } from '../../stores/graphStore';
|
import { useGraphStore } from '../../stores/graphStore';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
import type { Relation } from '../../types';
|
import type { Relation } from '../../types';
|
||||||
import LabelBadge from '../Common/LabelBadge';
|
import LabelBadge from '../Common/LabelBadge';
|
||||||
import { getFloatingEdgeParams } from '../../utils/edgeUtils';
|
import { getFloatingEdgeParams } from '../../utils/edgeUtils';
|
||||||
|
|
@ -35,13 +36,24 @@ const CustomEdge = ({
|
||||||
data,
|
data,
|
||||||
selected,
|
selected,
|
||||||
}: EdgeProps<Relation>) => {
|
}: EdgeProps<Relation>) => {
|
||||||
const edgeTypes = useGraphStore((state) => state.edgeTypes);
|
const edgeTypes = useGraphStore((state) => state.edgeTypes, shallow);
|
||||||
const labels = useGraphStore((state) => state.labels);
|
const labels = useGraphStore((state) => state.labels, shallow);
|
||||||
const nodeTypes = useGraphStore((state) => state.nodeTypes);
|
const nodeTypes = useGraphStore((state) => state.nodeTypes, shallow);
|
||||||
|
|
||||||
// Get active filters based on mode (editing vs presentation)
|
// Get active filters based on mode (editing vs presentation)
|
||||||
const filters = useActiveFilters();
|
const filters = useActiveFilters();
|
||||||
|
|
||||||
|
// Hover state for parallel edge highlighting
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
setIsHovered(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Get internal nodes for floating edge calculations with correct absolute positioning
|
// Get internal nodes for floating edge calculations with correct absolute positioning
|
||||||
const sourceNode = useInternalNode(source);
|
const sourceNode = useInternalNode(source);
|
||||||
const targetNode = useInternalNode(target);
|
const targetNode = useInternalNode(target);
|
||||||
|
|
@ -76,13 +88,33 @@ const CustomEdge = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape);
|
// 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, parallelGroupKey);
|
||||||
|
|
||||||
// Create cubic bezier path using custom control points
|
// 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}`;
|
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)
|
// Calculate label position along the curve with staggering for parallel edges
|
||||||
const t = 0.5;
|
// Use t parameter variation (position along curve from 0 to 1)
|
||||||
|
// Center edge at t=0.5, others staggered symmetrically
|
||||||
|
// Reverse stagger direction for edges going opposite to normalized direction
|
||||||
|
let tStagger = offsetMultiplier * 0.12; // 12% of curve per offset unit
|
||||||
|
|
||||||
|
// Check if this edge goes in the reverse direction relative to normalized group
|
||||||
|
if (parallelGroupKey) {
|
||||||
|
const [normalizedSource, normalizedTarget] = parallelGroupKey.split('<->');
|
||||||
|
const isReversed = source === normalizedTarget && target === normalizedSource;
|
||||||
|
if (isReversed) {
|
||||||
|
tStagger = -tStagger; // Reverse the stagger direction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = 0.5 + tStagger;
|
||||||
|
|
||||||
|
// Calculate position on Bezier curve at parameter t
|
||||||
const labelX =
|
const labelX =
|
||||||
Math.pow(1 - t, 3) * params.sx +
|
Math.pow(1 - t, 3) * params.sx +
|
||||||
3 * Math.pow(1 - t, 2) * t * params.sourceControlX +
|
3 * Math.pow(1 - t, 2) * t * params.sourceControlX +
|
||||||
|
|
@ -95,7 +127,7 @@ const CustomEdge = ({
|
||||||
Math.pow(t, 3) * params.ty;
|
Math.pow(t, 3) * params.ty;
|
||||||
|
|
||||||
return { edgePath, labelX, labelY };
|
return { edgePath, labelX, labelY };
|
||||||
}, [sourceNode, targetNode, sourceShape, targetShape, sourceX, sourceY, targetX, targetY]);
|
}, [sourceNode, targetNode, sourceShape, targetShape, sourceX, sourceY, targetX, targetY, data, source, target]);
|
||||||
|
|
||||||
const { edgePath, labelX, labelY } = edgeParams;
|
const { edgePath, labelX, labelY } = edgeParams;
|
||||||
|
|
||||||
|
|
@ -148,6 +180,9 @@ const CustomEdge = ({
|
||||||
// Calculate opacity based on visibility
|
// Calculate opacity based on visibility
|
||||||
const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
|
const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
|
||||||
|
|
||||||
|
// Calculate stroke width based on state (selected, hovered, or default)
|
||||||
|
const strokeWidth = selected ? 4 : isHovered ? 3 : 2;
|
||||||
|
|
||||||
// Create unique marker IDs based on color (for reusability)
|
// Create unique marker IDs based on color (for reusability)
|
||||||
const safeColor = edgeColor.replace('#', '');
|
const safeColor = edgeColor.replace('#', '');
|
||||||
const markerEndId = `arrow-end-${safeColor}`;
|
const markerEndId = `arrow-end-${safeColor}`;
|
||||||
|
|
@ -199,12 +234,14 @@ const CustomEdge = ({
|
||||||
path={edgePath}
|
path={edgePath}
|
||||||
style={{
|
style={{
|
||||||
stroke: edgeColor,
|
stroke: edgeColor,
|
||||||
strokeWidth: selected ? 3 : 2,
|
strokeWidth,
|
||||||
strokeDasharray,
|
strokeDasharray,
|
||||||
opacity: edgeOpacity,
|
opacity: edgeOpacity,
|
||||||
}}
|
}}
|
||||||
markerEnd={markerEnd}
|
markerEnd={markerEnd}
|
||||||
markerStart={markerStart}
|
markerStart={markerStart}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Edge label - show custom or type default, plus labels, plus aggregation count */}
|
{/* Edge label - show custom or type default, plus labels, plus aggregation count */}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
addEdge,
|
|
||||||
Node,
|
Node,
|
||||||
Edge,
|
Edge,
|
||||||
NodeChange,
|
NodeChange,
|
||||||
|
|
@ -36,6 +35,7 @@ import CustomEdge from "../Edges/CustomEdge";
|
||||||
import ContextMenu from "./ContextMenu";
|
import ContextMenu from "./ContextMenu";
|
||||||
import EmptyState from "../Common/EmptyState";
|
import EmptyState from "../Common/EmptyState";
|
||||||
import { createNode } from "../../utils/nodeUtils";
|
import { createNode } from "../../utils/nodeUtils";
|
||||||
|
import { groupParallelEdges, calculateEdgeOffsetMultiplier, generateEdgeId } from "../../utils/edgeUtils";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import GroupWorkIcon from "@mui/icons-material/GroupWork";
|
import GroupWorkIcon from "@mui/icons-material/GroupWork";
|
||||||
import UngroupIcon from "@mui/icons-material/CallSplit";
|
import UngroupIcon from "@mui/icons-material/CallSplit";
|
||||||
|
|
@ -116,6 +116,18 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
selectedRelationType,
|
selectedRelationType,
|
||||||
} = useEditorStore();
|
} = useEditorStore();
|
||||||
|
|
||||||
|
// Optimize MiniMap nodeColor lookup with Map for O(1) performance
|
||||||
|
const nodeTypeColorMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
nodeTypeConfigs.forEach(nt => map.set(nt.id, nt.color));
|
||||||
|
return map;
|
||||||
|
}, [nodeTypeConfigs]);
|
||||||
|
|
||||||
|
const miniMapNodeColor = useCallback((node: Node) => {
|
||||||
|
const actor = node as Actor;
|
||||||
|
return nodeTypeColorMap.get(actor.data?.type) || "#6b7280";
|
||||||
|
}, [nodeTypeColorMap]);
|
||||||
|
|
||||||
// React Flow instance for screen-to-flow coordinates and viewport control
|
// React Flow instance for screen-to-flow coordinates and viewport control
|
||||||
const {
|
const {
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
|
|
@ -255,18 +267,17 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No rerouting needed, just add the edge (no aggregation for normal edges)
|
// No rerouting needed - use edge ID as key to allow multiple parallel edges
|
||||||
if (!edgeMap.has(edgeKey)) {
|
// (edgeKey based on source/target would deduplicate parallel edges)
|
||||||
edgeMap.set(edgeKey, {
|
edgeMap.set(edge.id, {
|
||||||
edge,
|
edge,
|
||||||
aggregatedRelations: [],
|
aggregatedRelations: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert the map to an array of edges, attaching aggregation metadata
|
// Convert the map to an array of edges, attaching aggregation metadata
|
||||||
return Array.from(edgeMap.values()).map(({ edge, aggregatedRelations }) => {
|
const processedEdges = Array.from(edgeMap.values()).map(({ edge, aggregatedRelations }) => {
|
||||||
if (aggregatedRelations.length > 1) {
|
if (aggregatedRelations.length > 1) {
|
||||||
// Multiple relations aggregated - add metadata to edge data
|
// Multiple relations aggregated - add metadata to edge data
|
||||||
return {
|
return {
|
||||||
|
|
@ -280,6 +291,50 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
}
|
}
|
||||||
return edge;
|
return edge;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detect parallel edges and add offset information
|
||||||
|
const parallelGroups = groupParallelEdges(processedEdges as Relation[]);
|
||||||
|
|
||||||
|
// Create a map of edge ID -> offset info
|
||||||
|
const offsetMap = new Map<string, { multiplier: number; groupSize: number; groupKey: string }>();
|
||||||
|
|
||||||
|
parallelGroups.forEach((group, groupKey) => {
|
||||||
|
const totalEdges = group.edges.length;
|
||||||
|
|
||||||
|
// 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, groupKey });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply offset information to edges
|
||||||
|
return processedEdges.map((edge) => {
|
||||||
|
const offsetInfo = offsetMap.get(edge.id);
|
||||||
|
if (offsetInfo) {
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
data: {
|
||||||
|
...edge.data,
|
||||||
|
offsetMultiplier: offsetInfo.multiplier,
|
||||||
|
parallelGroupSize: offsetInfo.groupSize,
|
||||||
|
parallelGroupKey: offsetInfo.groupKey,
|
||||||
|
},
|
||||||
|
} as Edge;
|
||||||
|
}
|
||||||
|
return edge;
|
||||||
|
});
|
||||||
}, [storeEdges, storeGroups, storeNodes]);
|
}, [storeEdges, storeGroups, storeNodes]);
|
||||||
|
|
||||||
const [edges, setEdgesState, onEdgesChange] = useEdgesState(
|
const [edges, setEdgesState, onEdgesChange] = useEdgesState(
|
||||||
|
|
@ -389,19 +444,11 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
) {
|
) {
|
||||||
const currentViewport = getCurrentViewport();
|
const currentViewport = getCurrentViewport();
|
||||||
saveViewport(prevDocumentIdRef.current, currentViewport);
|
saveViewport(prevDocumentIdRef.current, currentViewport);
|
||||||
console.log(
|
|
||||||
`Saved viewport for document: ${prevDocumentIdRef.current}`,
|
|
||||||
currentViewport,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore viewport for the new document
|
// Restore viewport for the new document
|
||||||
const savedViewport = getViewport(activeDocumentId);
|
const savedViewport = getViewport(activeDocumentId);
|
||||||
if (savedViewport) {
|
if (savedViewport) {
|
||||||
console.log(
|
|
||||||
`Restoring viewport for document: ${activeDocumentId}`,
|
|
||||||
savedViewport,
|
|
||||||
);
|
|
||||||
setViewport(savedViewport, { duration: 0 });
|
setViewport(savedViewport, { duration: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -715,9 +762,17 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
const edgeTypeConfig = edgeTypeConfigs.find((et) => et.id === edgeType);
|
const edgeTypeConfig = edgeTypeConfigs.find((et) => et.id === edgeType);
|
||||||
const defaultDirectionality = edgeTypeConfig?.defaultDirectionality || 'directed';
|
const defaultDirectionality = edgeTypeConfig?.defaultDirectionality || 'directed';
|
||||||
|
|
||||||
// Create edge with custom data (no label - will use type default)
|
// Generate a unique edge ID that allows multiple edges between same nodes
|
||||||
const edgeWithData = {
|
// Use UUID to guarantee uniqueness without collision risk
|
||||||
...connection,
|
const edgeId = generateEdgeId(connection.source, connection.target);
|
||||||
|
|
||||||
|
// 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",
|
type: "custom",
|
||||||
data: {
|
data: {
|
||||||
type: edgeType,
|
type: edgeType,
|
||||||
|
|
@ -726,12 +781,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
|
// Set pending selection - will be applied after Zustand sync
|
||||||
pendingSelectionRef.current = { type: 'edge', id: newEdge.id };
|
pendingSelectionRef.current = { type: 'edge', id: newEdge.id };
|
||||||
|
|
||||||
|
|
@ -739,7 +788,6 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
addEdgeWithHistory(newEdge);
|
addEdgeWithHistory(newEdge);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
storeEdges,
|
|
||||||
edgeTypeConfigs,
|
edgeTypeConfigs,
|
||||||
addEdgeWithHistory,
|
addEdgeWithHistory,
|
||||||
selectedRelationType,
|
selectedRelationType,
|
||||||
|
|
@ -1050,6 +1098,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
connectOnClick={isEditable}
|
connectOnClick={isEditable}
|
||||||
|
isValidConnection={() => true}
|
||||||
snapToGrid={snapToGrid}
|
snapToGrid={snapToGrid}
|
||||||
snapGrid={[gridSize, gridSize]}
|
snapGrid={[gridSize, gridSize]}
|
||||||
panOnDrag={true}
|
panOnDrag={true}
|
||||||
|
|
@ -1063,6 +1112,9 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
connectionRadius={0}
|
connectionRadius={0}
|
||||||
fitView
|
fitView
|
||||||
attributionPosition="bottom-left"
|
attributionPosition="bottom-left"
|
||||||
|
onlyRenderVisibleElements={true}
|
||||||
|
elevateEdgesOnSelect={true}
|
||||||
|
selectNodesOnDrag={false}
|
||||||
>
|
>
|
||||||
{/* Background grid - Hide in presentation mode */}
|
{/* Background grid - Hide in presentation mode */}
|
||||||
{!presentationMode && showGrid && (
|
{!presentationMode && showGrid && (
|
||||||
|
|
@ -1079,13 +1131,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
||||||
|
|
||||||
{/* MiniMap for navigation - Read-only in presentation mode */}
|
{/* MiniMap for navigation - Read-only in presentation mode */}
|
||||||
<MiniMap
|
<MiniMap
|
||||||
nodeColor={(node) => {
|
nodeColor={miniMapNodeColor}
|
||||||
const actor = node as Actor;
|
|
||||||
const nodeType = nodeTypeConfigs.find(
|
|
||||||
(nt) => nt.id === actor.data?.type,
|
|
||||||
);
|
|
||||||
return nodeType?.color || "#6b7280";
|
|
||||||
}}
|
|
||||||
pannable={isEditable}
|
pannable={isEditable}
|
||||||
zoomable={isEditable}
|
zoomable={isEditable}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "@xyflow/react";
|
import { Handle, Position, NodeProps } from "@xyflow/react";
|
||||||
import { useGraphStore } from "../../stores/graphStore";
|
import { useGraphStore } from "../../stores/graphStore";
|
||||||
|
import { shallow } from "zustand/shallow";
|
||||||
import {
|
import {
|
||||||
getContrastColor,
|
getContrastColor,
|
||||||
adjustColorBrightness,
|
adjustColorBrightness,
|
||||||
|
|
@ -26,8 +27,8 @@ import {
|
||||||
* Usage: Automatically rendered by React Flow for nodes with type='custom'
|
* Usage: Automatically rendered by React Flow for nodes with type='custom'
|
||||||
*/
|
*/
|
||||||
const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
||||||
const nodeTypes = useGraphStore((state) => state.nodeTypes);
|
const nodeTypes = useGraphStore((state) => state.nodeTypes, shallow);
|
||||||
const labels = useGraphStore((state) => state.labels);
|
const labels = useGraphStore((state) => state.labels, shallow);
|
||||||
|
|
||||||
// Get active filters based on mode (editing vs presentation)
|
// Get active filters based on mode (editing vs presentation)
|
||||||
const filters = useActiveFilters();
|
const filters = useActiveFilters();
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,18 @@ const CircleShape = ({
|
||||||
color,
|
color,
|
||||||
borderColor,
|
borderColor,
|
||||||
textColor,
|
textColor,
|
||||||
selected = false,
|
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
}: CircleShapeProps) => {
|
}: CircleShapeProps) => {
|
||||||
const shadowStyle = selected
|
// Simplified shadow for performance - single shadow instead of multiple layers
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40`
|
const shadowStyle = isHighlighted
|
||||||
: isHighlighted
|
? `0 0 0 2px ${color}80` // Simple outline for highlight
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60`
|
: '0 2px 4px rgb(0 0 0 / 0.1)'; // Single lightweight shadow
|
||||||
: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-4 py-3 rounded-full min-w-[120px] flex items-center justify-center transition-shadow duration-200 ${className}`}
|
className={`px-4 py-3 rounded-full min-w-[120px] flex items-center justify-center ${className}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
borderWidth: '3px',
|
borderWidth: '3px',
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,18 @@ const EllipseShape = ({
|
||||||
color,
|
color,
|
||||||
borderColor,
|
borderColor,
|
||||||
textColor,
|
textColor,
|
||||||
selected = false,
|
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
}: EllipseShapeProps) => {
|
}: EllipseShapeProps) => {
|
||||||
const shadowStyle = selected
|
// Simplified shadow for performance - single shadow instead of multiple layers
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40`
|
const shadowStyle = isHighlighted
|
||||||
: isHighlighted
|
? `0 0 0 2px ${color}80` // Simple outline for highlight
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60`
|
: '0 2px 4px rgb(0 0 0 / 0.1)'; // Single lightweight shadow
|
||||||
: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-6 py-3 min-w-[140px] min-h-[80px] flex items-center justify-center transition-shadow duration-200 ${className}`}
|
className={`px-6 py-3 min-w-[140px] min-h-[80px] flex items-center justify-center ${className}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
borderWidth: '3px',
|
borderWidth: '3px',
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,18 @@ const PillShape = ({
|
||||||
color,
|
color,
|
||||||
borderColor,
|
borderColor,
|
||||||
textColor,
|
textColor,
|
||||||
selected = false,
|
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
}: PillShapeProps) => {
|
}: PillShapeProps) => {
|
||||||
const shadowStyle = selected
|
// Simplified shadow for performance - single shadow instead of multiple layers
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40`
|
const shadowStyle = isHighlighted
|
||||||
: isHighlighted
|
? `0 0 0 2px ${color}80` // Simple outline for highlight
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60`
|
: '0 2px 4px rgb(0 0 0 / 0.1)'; // Single lightweight shadow
|
||||||
: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-6 py-3 min-w-[120px] flex items-center justify-center transition-shadow duration-200 ${className}`}
|
className={`px-6 py-3 min-w-[120px] flex items-center justify-center ${className}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
borderWidth: '3px',
|
borderWidth: '3px',
|
||||||
|
|
|
||||||
|
|
@ -20,21 +20,18 @@ const RectangleShape = ({
|
||||||
color,
|
color,
|
||||||
borderColor,
|
borderColor,
|
||||||
textColor,
|
textColor,
|
||||||
selected = false,
|
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
}: RectangleShapeProps) => {
|
}: RectangleShapeProps) => {
|
||||||
// Build shadow style
|
// Simplified shadow for performance - single shadow instead of multiple layers
|
||||||
const shadowStyle = selected
|
const shadowStyle = isHighlighted
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40`
|
? `0 0 0 2px ${color}80` // Simple outline for highlight
|
||||||
: isHighlighted
|
: '0 2px 4px rgb(0 0 0 / 0.1)'; // Single lightweight shadow
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60`
|
|
||||||
: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-4 py-3 rounded-lg min-w-[120px] transition-shadow duration-200 ${className}`}
|
className={`px-4 py-3 rounded-lg min-w-[120px] ${className}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
borderWidth: '3px',
|
borderWidth: '3px',
|
||||||
|
|
|
||||||
|
|
@ -22,20 +22,18 @@ const RoundedRectangleShape = ({
|
||||||
color,
|
color,
|
||||||
borderColor,
|
borderColor,
|
||||||
textColor,
|
textColor,
|
||||||
selected = false,
|
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
}: RoundedRectangleShapeProps) => {
|
}: RoundedRectangleShapeProps) => {
|
||||||
const shadowStyle = selected
|
// Simplified shadow for performance - single shadow instead of multiple layers
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}40`
|
const shadowStyle = isHighlighted
|
||||||
: isHighlighted
|
? `0 0 0 2px ${color}80` // Simple outline for highlight
|
||||||
? `0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1), 0 0 0 3px ${color}80, 0 0 12px ${color}60`
|
: '0 2px 4px rgb(0 0 0 / 0.1)'; // Single lightweight shadow
|
||||||
: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-4 py-3 min-w-[120px] transition-shadow duration-200 ${className}`}
|
className={`px-4 py-3 min-w-[120px] ${className}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
borderWidth: '3px',
|
borderWidth: '3px',
|
||||||
|
|
|
||||||
|
|
@ -324,16 +324,18 @@ describe('graphStore', () => {
|
||||||
expect(state.edges).toHaveLength(2);
|
expect(state.edges).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use React Flow addEdge for duplicate prevention', () => {
|
it('should allow parallel edges (multiple edges between same nodes)', () => {
|
||||||
const { addEdge } = useGraphStore.getState();
|
const { addEdge } = useGraphStore.getState();
|
||||||
|
|
||||||
// Add same edge twice
|
// Add two edges between same nodes with different IDs
|
||||||
addEdge(createMockEdge('edge-1', 'node-1', 'node-2'));
|
|
||||||
addEdge(createMockEdge('edge-1', 'node-1', 'node-2'));
|
addEdge(createMockEdge('edge-1', 'node-1', 'node-2'));
|
||||||
|
addEdge(createMockEdge('edge-2', 'node-1', 'node-2'));
|
||||||
|
|
||||||
const state = useGraphStore.getState();
|
const state = useGraphStore.getState();
|
||||||
// React Flow's addEdge should prevent duplicates
|
// Should allow parallel edges (no deduplication)
|
||||||
expect(state.edges.length).toBeGreaterThan(0);
|
expect(state.edges).toHaveLength(2);
|
||||||
|
expect(state.edges[0].id).toBe('edge-1');
|
||||||
|
expect(state.edges[1].id).toBe('edge-2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { addEdge as rfAddEdge } from '@xyflow/react';
|
|
||||||
import type {
|
import type {
|
||||||
Actor,
|
Actor,
|
||||||
Relation,
|
Relation,
|
||||||
|
|
@ -121,7 +120,7 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
||||||
// Edge operations
|
// Edge operations
|
||||||
addEdge: (edge: Relation) =>
|
addEdge: (edge: Relation) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
edges: rfAddEdge(edge, state.edges) as Relation[],
|
edges: [...state.edges, edge],
|
||||||
})),
|
})),
|
||||||
|
|
||||||
updateEdge: (id: string, data: Partial<RelationData>) =>
|
updateEdge: (id: string, data: Partial<RelationData>) =>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ export interface RelationData extends Record<string, unknown> {
|
||||||
labels?: string[]; // Array of LabelConfig IDs
|
labels?: string[]; // Array of LabelConfig IDs
|
||||||
citations?: string[]; // Array of bibliography reference IDs
|
citations?: string[]; // Array of bibliography reference IDs
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
// 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<RelationData>;
|
export type Relation = Edge<RelationData>;
|
||||||
|
|
|
||||||
254
src/utils/edgeUtils.test.ts
Normal file
254
src/utils/edgeUtils.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
calculateEdgeOffsetMultiplier,
|
||||||
|
calculatePerpendicularOffset,
|
||||||
|
groupParallelEdges,
|
||||||
|
generateEdgeId,
|
||||||
|
} from './edgeUtils';
|
||||||
|
import type { Relation } from '../types';
|
||||||
|
|
||||||
|
describe('edgeUtils', () => {
|
||||||
|
describe('generateEdgeId', () => {
|
||||||
|
it('should generate unique IDs for same source/target', () => {
|
||||||
|
const id1 = generateEdgeId('node-1', 'node-2');
|
||||||
|
const id2 = generateEdgeId('node-1', 'node-2');
|
||||||
|
|
||||||
|
expect(id1).not.toBe(id2);
|
||||||
|
expect(id1).toMatch(/^edge_node-1_node-2_[0-9a-f-]{36}$/);
|
||||||
|
expect(id2).toMatch(/^edge_node-1_node-2_[0-9a-f-]{36}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include source and target in ID for readability', () => {
|
||||||
|
const id = generateEdgeId('actor-123', 'actor-456');
|
||||||
|
|
||||||
|
expect(id).toContain('actor-123');
|
||||||
|
expect(id).toContain('actor-456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateEdgeOffsetMultiplier', () => {
|
||||||
|
it('should return 0 for single edge', () => {
|
||||||
|
expect(calculateEdgeOffsetMultiplier(0, 1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ±0.5 for 2 edges', () => {
|
||||||
|
expect(calculateEdgeOffsetMultiplier(0, 2)).toBe(-0.5);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(1, 2)).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return -1, 0, 1 for 3 edges', () => {
|
||||||
|
expect(calculateEdgeOffsetMultiplier(0, 3)).toBe(-1);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(1, 3)).toBe(0);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(2, 3)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return -1.5, -0.5, 0.5, 1.5 for 4 edges', () => {
|
||||||
|
expect(calculateEdgeOffsetMultiplier(0, 4)).toBe(-1.5);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(1, 4)).toBe(-0.5);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(2, 4)).toBe(0.5);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(3, 4)).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return -2, -1, 0, 1, 2 for 5 edges', () => {
|
||||||
|
expect(calculateEdgeOffsetMultiplier(0, 5)).toBe(-2);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(1, 5)).toBe(-1);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(2, 5)).toBe(0);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(3, 5)).toBe(1);
|
||||||
|
expect(calculateEdgeOffsetMultiplier(4, 5)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should distribute symmetrically around center for any count', () => {
|
||||||
|
// Test with 6 edges
|
||||||
|
const offsets = [0, 1, 2, 3, 4, 5].map(i => calculateEdgeOffsetMultiplier(i, 6));
|
||||||
|
const sum = offsets.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// Sum should be 0 (symmetric distribution)
|
||||||
|
expect(sum).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculatePerpendicularOffset', () => {
|
||||||
|
it('should calculate perpendicular for horizontal line (should be vertical)', () => {
|
||||||
|
const result = calculatePerpendicularOffset(0, 0, 100, 0, 50);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(0, 10);
|
||||||
|
expect(result.y).toBeCloseTo(50, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate perpendicular for vertical line (should be horizontal)', () => {
|
||||||
|
const result = calculatePerpendicularOffset(0, 0, 0, 100, 50);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(-50, 10);
|
||||||
|
expect(result.y).toBeCloseTo(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate perpendicular for diagonal line (45 degrees)', () => {
|
||||||
|
const result = calculatePerpendicularOffset(0, 0, 100, 100, 50);
|
||||||
|
|
||||||
|
// For 45° line, perpendicular should be at -45°
|
||||||
|
// Components should be equal in magnitude, opposite signs
|
||||||
|
expect(Math.abs(result.x)).toBeCloseTo(Math.abs(result.y), 5);
|
||||||
|
expect(result.x).toBeLessThan(0);
|
||||||
|
expect(result.y).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero-distance case', () => {
|
||||||
|
const result = calculatePerpendicularOffset(50, 50, 50, 50, 30);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(30, 10);
|
||||||
|
expect(result.y).toBeCloseTo(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative offset magnitude', () => {
|
||||||
|
const result = calculatePerpendicularOffset(0, 0, 100, 0, -50);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(0, 10);
|
||||||
|
expect(result.y).toBeCloseTo(-50, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scale proportionally with magnitude', () => {
|
||||||
|
const result1 = calculatePerpendicularOffset(0, 0, 100, 0, 25);
|
||||||
|
const result2 = calculatePerpendicularOffset(0, 0, 100, 0, 50);
|
||||||
|
|
||||||
|
expect(result2.y).toBe(result1.y * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce unit vector when magnitude is line length', () => {
|
||||||
|
const distance = Math.sqrt(100 * 100 + 100 * 100); // ~141.42
|
||||||
|
const result = calculatePerpendicularOffset(0, 0, 100, 100, distance);
|
||||||
|
|
||||||
|
// Perpendicular should have same length as the line
|
||||||
|
const resultLength = Math.sqrt(result.x * result.x + result.y * result.y);
|
||||||
|
expect(resultLength).toBeCloseTo(distance, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupParallelEdges', () => {
|
||||||
|
const createMockEdge = (id: string, source: string, target: string): Relation => ({
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
type: 'custom',
|
||||||
|
data: { type: 'default' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty map when no parallel edges exist', () => {
|
||||||
|
const edges = [
|
||||||
|
createMockEdge('e1', 'n1', 'n2'),
|
||||||
|
createMockEdge('e2', 'n2', 'n3'),
|
||||||
|
createMockEdge('e3', 'n3', 'n4'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupParallelEdges(edges);
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should group two edges in same direction', () => {
|
||||||
|
const edges = [
|
||||||
|
createMockEdge('e1', 'n1', 'n2'),
|
||||||
|
createMockEdge('e2', 'n1', 'n2'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupParallelEdges(edges);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
const group = Array.from(result.values())[0];
|
||||||
|
expect(group.edges).toHaveLength(2);
|
||||||
|
expect(group.sourceId).toBe('n1');
|
||||||
|
expect(group.targetId).toBe('n2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should group bidirectional edges (A→B and B→A)', () => {
|
||||||
|
const edges = [
|
||||||
|
createMockEdge('e1', 'n1', 'n2'),
|
||||||
|
createMockEdge('e2', 'n2', 'n1'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupParallelEdges(edges);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
const group = Array.from(result.values())[0];
|
||||||
|
expect(group.edges).toHaveLength(2);
|
||||||
|
// Should use normalized (sorted) IDs
|
||||||
|
expect([group.sourceId, group.targetId].sort()).toEqual(['n1', 'n2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple parallel groups', () => {
|
||||||
|
const edges = [
|
||||||
|
createMockEdge('e1', 'n1', 'n2'),
|
||||||
|
createMockEdge('e2', 'n1', 'n2'),
|
||||||
|
createMockEdge('e3', 'n3', 'n4'),
|
||||||
|
createMockEdge('e4', 'n4', 'n3'),
|
||||||
|
createMockEdge('e5', 'n5', 'n6'), // Not parallel
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupParallelEdges(edges);
|
||||||
|
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use <-> separator to handle node IDs with underscores', () => {
|
||||||
|
const edges = [
|
||||||
|
createMockEdge('e1', 'node_1_abc', 'node_2_def'),
|
||||||
|
createMockEdge('e2', 'node_1_abc', 'node_2_def'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupParallelEdges(edges);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
const key = Array.from(result.keys())[0];
|
||||||
|
expect(key).toContain('<->');
|
||||||
|
expect(key.split('<->')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle three parallel edges in same direction', () => {
|
||||||
|
const edges = [
|
||||||
|
createMockEdge('e1', 'n1', 'n2'),
|
||||||
|
createMockEdge('e2', 'n1', 'n2'),
|
||||||
|
createMockEdge('e3', 'n1', 'n2'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupParallelEdges(edges);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
const group = Array.from(result.values())[0];
|
||||||
|
expect(group.edges).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed bidirectional parallel edges', () => {
|
||||||
|
const edges = [
|
||||||
|
createMockEdge('e1', 'n1', 'n2'),
|
||||||
|
createMockEdge('e2', 'n1', 'n2'),
|
||||||
|
createMockEdge('e3', 'n2', 'n1'),
|
||||||
|
createMockEdge('e4', 'n2', 'n1'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupParallelEdges(edges);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
const group = Array.from(result.values())[0];
|
||||||
|
expect(group.edges).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize group key regardless of edge direction', () => {
|
||||||
|
const edges1 = [
|
||||||
|
createMockEdge('e1', 'n1', 'n2'),
|
||||||
|
createMockEdge('e2', 'n1', 'n2'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const edges2 = [
|
||||||
|
createMockEdge('e1', 'n2', 'n1'),
|
||||||
|
createMockEdge('e2', 'n2', 'n1'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result1 = groupParallelEdges(edges1);
|
||||||
|
const result2 = groupParallelEdges(edges2);
|
||||||
|
|
||||||
|
const key1 = Array.from(result1.keys())[0];
|
||||||
|
const key2 = Array.from(result2.keys())[0];
|
||||||
|
|
||||||
|
expect(key1).toBe(key2); // Should produce same normalized key
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,12 +3,124 @@ import type { Node } from '@xyflow/react';
|
||||||
import { ROUNDED_RECTANGLE_RADIUS } from '../constants';
|
import { ROUNDED_RECTANGLE_RADIUS } from '../constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a unique ID for edges
|
* Generates a unique ID for edges using crypto.randomUUID()
|
||||||
|
* Format: edge_<source>_<target>_<uuid> for guaranteed uniqueness and readability
|
||||||
*/
|
*/
|
||||||
export const generateEdgeId = (source: string, target: string): string => {
|
export const generateEdgeId = (source: string, target: string): string => {
|
||||||
return `edge_${source}_${target}_${Date.now()}`;
|
return `edge_${source}_${target}_${crypto.randomUUID()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge group information for parallel edges
|
||||||
|
*/
|
||||||
|
export interface EdgeGroup {
|
||||||
|
edges: Relation[];
|
||||||
|
sourceId: string;
|
||||||
|
targetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base offset for parallel edges (in pixels)
|
||||||
|
*/
|
||||||
|
const BASE_EDGE_OFFSET = 80; // Increased for better visibility with multiple edges
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the perpendicular offset for a parallel edge
|
||||||
|
* Returns a 2D vector that is perpendicular to the line between source and target
|
||||||
|
*/
|
||||||
|
export function calculatePerpendicularOffset(
|
||||||
|
sourceX: number,
|
||||||
|
sourceY: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
offsetMagnitude: number
|
||||||
|
): { x: number; y: number } {
|
||||||
|
// Calculate direction vector from source to target
|
||||||
|
const dx = targetX - sourceX;
|
||||||
|
const dy = targetY - sourceY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Handle zero-distance case
|
||||||
|
if (distance === 0) {
|
||||||
|
return { x: offsetMagnitude, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize direction vector
|
||||||
|
const nx = dx / distance;
|
||||||
|
const ny = dy / distance;
|
||||||
|
|
||||||
|
// Perpendicular vector (rotate 90 degrees counterclockwise)
|
||||||
|
const perpX = -ny;
|
||||||
|
const perpY = nx;
|
||||||
|
|
||||||
|
// Scale by offset magnitude
|
||||||
|
return {
|
||||||
|
x: perpX * offsetMagnitude,
|
||||||
|
y: perpY * offsetMagnitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate edge offset for a parallel edge within a group
|
||||||
|
* @param edgeIndex - Index of this edge within the parallel group (0-based)
|
||||||
|
* @param totalEdges - Total number of parallel edges
|
||||||
|
* @returns Offset multiplier (-1, 0, +1, etc.)
|
||||||
|
*/
|
||||||
|
export function calculateEdgeOffsetMultiplier(
|
||||||
|
edgeIndex: number,
|
||||||
|
totalEdges: number
|
||||||
|
): number {
|
||||||
|
// For single edge, no offset
|
||||||
|
if (totalEdges === 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For 2 edges: offset by ±0.5 (one above, one below center)
|
||||||
|
if (totalEdges === 2) {
|
||||||
|
return edgeIndex === 0 ? -0.5 : 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For 3+ edges: distribute evenly around center
|
||||||
|
// Center edge(s) get offset 0, others get ±1, ±2, etc.
|
||||||
|
const middle = (totalEdges - 1) / 2;
|
||||||
|
return edgeIndex - middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group edges by their source-target pairs (bidirectional)
|
||||||
|
* Edges between A-B and B-A are grouped together
|
||||||
|
*/
|
||||||
|
export function groupParallelEdges(edges: Relation[]): Map<string, EdgeGroup> {
|
||||||
|
const groups = new Map<string, EdgeGroup>();
|
||||||
|
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
// Create normalized key (alphabetically sorted endpoints)
|
||||||
|
// 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: normalizedSource, // Store normalized source
|
||||||
|
targetId: normalizedTarget, // Store normalized target
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.get(key)!.edges.push(edge);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter to only return groups with 2+ edges (parallel edges)
|
||||||
|
const parallelGroups = new Map<string, EdgeGroup>();
|
||||||
|
groups.forEach((group, key) => {
|
||||||
|
if (group.edges.length >= 2) {
|
||||||
|
parallelGroups.set(key, group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parallelGroups;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate intersection point with a circle
|
* Calculate intersection point with a circle
|
||||||
* Returns both the intersection point and the normal vector (outward direction)
|
* Returns both the intersection point and the normal vector (outward direction)
|
||||||
|
|
@ -385,12 +497,20 @@ function getNodeIntersection(
|
||||||
/**
|
/**
|
||||||
* Calculate the parameters for a floating edge between two nodes
|
* Calculate the parameters for a floating edge between two nodes
|
||||||
* Returns source/target coordinates with angles for smooth bezier curves
|
* Returns source/target coordinates with angles for smooth bezier curves
|
||||||
|
* @param sourceNode - Source node
|
||||||
|
* @param targetNode - Target node
|
||||||
|
* @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(
|
export function getFloatingEdgeParams(
|
||||||
sourceNode: Node,
|
sourceNode: Node,
|
||||||
targetNode: Node,
|
targetNode: Node,
|
||||||
sourceShape: NodeShape = 'rectangle',
|
sourceShape: NodeShape = 'rectangle',
|
||||||
targetShape: NodeShape = 'rectangle'
|
targetShape: NodeShape = 'rectangle',
|
||||||
|
offsetMultiplier: number = 0,
|
||||||
|
parallelGroupKey?: string
|
||||||
) {
|
) {
|
||||||
const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape);
|
const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape);
|
||||||
const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape);
|
const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape);
|
||||||
|
|
@ -403,17 +523,63 @@ export function getFloatingEdgeParams(
|
||||||
// Use 40% of distance for more pronounced curves, with reasonable limits
|
// Use 40% of distance for more pronounced curves, with reasonable limits
|
||||||
const controlPointDistance = Math.min(Math.max(distance * 0.4, 40), 150);
|
const controlPointDistance = Math.min(Math.max(distance * 0.4, 40), 150);
|
||||||
|
|
||||||
// Calculate control points using the normal angles
|
// Calculate perpendicular offset if needed
|
||||||
const sourceControlX = sourceIntersection.x + Math.cos(sourceIntersection.angle) * controlPointDistance;
|
let perpOffset = { x: 0, y: 0 };
|
||||||
const sourceControlY = sourceIntersection.y + Math.sin(sourceIntersection.angle) * controlPointDistance;
|
if (offsetMultiplier !== 0) {
|
||||||
const targetControlX = targetIntersection.x + Math.cos(targetIntersection.angle) * controlPointDistance;
|
const offsetMagnitude = offsetMultiplier * BASE_EDGE_OFFSET;
|
||||||
const targetControlY = targetIntersection.y + Math.sin(targetIntersection.angle) * controlPointDistance;
|
|
||||||
|
// 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(
|
||||||
|
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;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sx: sourceIntersection.x,
|
sx: sourceIntersection.x + sourceEndpointOffset.x,
|
||||||
sy: sourceIntersection.y,
|
sy: sourceIntersection.y + sourceEndpointOffset.y,
|
||||||
tx: targetIntersection.x,
|
tx: targetIntersection.x + targetEndpointOffset.x,
|
||||||
ty: targetIntersection.y,
|
ty: targetIntersection.y + targetEndpointOffset.y,
|
||||||
sourceControlX,
|
sourceControlX,
|
||||||
sourceControlY,
|
sourceControlY,
|
||||||
targetControlX,
|
targetControlX,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue