mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-03-13 12:08:46 +00:00
Implement parallel edge offset for overlapping relations
Add core logic to detect and offset parallel edges (multiple edges between the same two nodes) to make them visually distinguishable. Features: - Detect parallel edges using groupParallelEdges() utility - Calculate perpendicular offset for each edge in a parallel group - Distribute edges evenly around the center line (±0.5, ±1, ±1.5, etc.) - Apply offset to Bezier control points for smooth curved paths - Base offset of 30px provides clear visual separation Technical implementation: - Added calculatePerpendicularOffset() to compute offset vectors - Added calculateEdgeOffsetMultiplier() for even distribution - Extended getFloatingEdgeParams() to accept offsetMultiplier parameter - Added offsetMultiplier and parallelGroupSize to RelationData type - Updated GraphEditor to detect parallel edges and assign offsets - Updated CustomEdge to apply offsets when rendering Design documents included: - EDGE_OVERLAP_UX_PROPOSAL.md: Complete UX design and implementation plan - EDGE_OVERLAP_VISUAL_GUIDE.md: Visual specifications and design tokens All 517 tests pass. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
094fd6d957
commit
3daedbc0d8
6 changed files with 1386 additions and 9 deletions
610
EDGE_OVERLAP_UX_PROPOSAL.md
Normal file
610
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
EDGE_OVERLAP_VISUAL_GUIDE.md
Normal file
602
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`
|
||||
|
|
@ -76,7 +76,10 @@ 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 params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape, offsetMultiplier);
|
||||
|
||||
// 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}`;
|
||||
|
|
@ -95,7 +98,7 @@ const CustomEdge = ({
|
|||
Math.pow(t, 3) * params.ty;
|
||||
|
||||
return { edgePath, labelX, labelY };
|
||||
}, [sourceNode, targetNode, sourceShape, targetShape, sourceX, sourceY, targetX, targetY]);
|
||||
}, [sourceNode, targetNode, sourceShape, targetShape, sourceX, sourceY, targetX, targetY, data]);
|
||||
|
||||
const { edgePath, labelX, labelY } = edgeParams;
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import CustomEdge from "../Edges/CustomEdge";
|
|||
import ContextMenu from "./ContextMenu";
|
||||
import EmptyState from "../Common/EmptyState";
|
||||
import { createNode } from "../../utils/nodeUtils";
|
||||
import { groupParallelEdges, calculateEdgeOffsetMultiplier } from "../../utils/edgeUtils";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import GroupWorkIcon from "@mui/icons-material/GroupWork";
|
||||
import UngroupIcon from "@mui/icons-material/CallSplit";
|
||||
|
|
@ -266,7 +267,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
|||
});
|
||||
|
||||
// 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) {
|
||||
// Multiple relations aggregated - add metadata to edge data
|
||||
return {
|
||||
|
|
@ -280,6 +281,36 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
|
|||
}
|
||||
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 }>();
|
||||
|
||||
parallelGroups.forEach((group) => {
|
||||
const totalEdges = group.edges.length;
|
||||
group.edges.forEach((edge, index) => {
|
||||
const multiplier = calculateEdgeOffsetMultiplier(index, totalEdges);
|
||||
offsetMap.set(edge.id, { multiplier, groupSize: totalEdges });
|
||||
});
|
||||
});
|
||||
|
||||
// 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,
|
||||
},
|
||||
} as Edge;
|
||||
}
|
||||
return edge;
|
||||
});
|
||||
}, [storeEdges, storeGroups, storeNodes]);
|
||||
|
||||
const [edges, setEdgesState, onEdgesChange] = useEdgesState(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ export interface RelationData extends Record<string, unknown> {
|
|||
labels?: string[]; // Array of LabelConfig IDs
|
||||
citations?: string[]; // Array of bibliography reference IDs
|
||||
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
|
||||
}
|
||||
|
||||
export type Relation = Edge<RelationData>;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,115 @@ export const generateEdgeId = (source: string, target: string): string => {
|
|||
return `edge_${source}_${target}_${Date.now()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = 30;
|
||||
|
||||
/**
|
||||
* 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)
|
||||
const key = [edge.source, edge.target].sort().join('_');
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
edges: [],
|
||||
sourceId: edge.source,
|
||||
targetId: edge.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
|
||||
* Returns both the intersection point and the normal vector (outward direction)
|
||||
|
|
@ -385,12 +494,18 @@ function getNodeIntersection(
|
|||
/**
|
||||
* Calculate the parameters for a floating edge between two nodes
|
||||
* 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)
|
||||
*/
|
||||
export function getFloatingEdgeParams(
|
||||
sourceNode: Node,
|
||||
targetNode: Node,
|
||||
sourceShape: NodeShape = 'rectangle',
|
||||
targetShape: NodeShape = 'rectangle'
|
||||
targetShape: NodeShape = 'rectangle',
|
||||
offsetMultiplier: number = 0
|
||||
) {
|
||||
const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape);
|
||||
const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape);
|
||||
|
|
@ -403,11 +518,24 @@ export function getFloatingEdgeParams(
|
|||
// Use 40% of distance for more pronounced curves, with reasonable limits
|
||||
const controlPointDistance = Math.min(Math.max(distance * 0.4, 40), 150);
|
||||
|
||||
// Calculate control points using the normal angles
|
||||
const sourceControlX = sourceIntersection.x + Math.cos(sourceIntersection.angle) * controlPointDistance;
|
||||
const sourceControlY = sourceIntersection.y + Math.sin(sourceIntersection.angle) * controlPointDistance;
|
||||
const targetControlX = targetIntersection.x + Math.cos(targetIntersection.angle) * controlPointDistance;
|
||||
const targetControlY = targetIntersection.y + Math.sin(targetIntersection.angle) * controlPointDistance;
|
||||
// Calculate perpendicular offset if needed
|
||||
let perpOffset = { x: 0, y: 0 };
|
||||
if (offsetMultiplier !== 0) {
|
||||
const offsetMagnitude = offsetMultiplier * BASE_EDGE_OFFSET;
|
||||
perpOffset = calculatePerpendicularOffset(
|
||||
sourceIntersection.x,
|
||||
sourceIntersection.y,
|
||||
targetIntersection.x,
|
||||
targetIntersection.y,
|
||||
offsetMagnitude
|
||||
);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
sx: sourceIntersection.x,
|
||||
|
|
|
|||
Loading…
Reference in a new issue