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:
Jan-Henrik 2026-02-05 13:25:34 +01:00
parent 094fd6d957
commit 3daedbc0d8
6 changed files with 1386 additions and 9 deletions

610
EDGE_OVERLAP_UX_PROPOSAL.md Normal file
View 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.

View 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`

View file

@ -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 // 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}`;
@ -95,7 +98,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]);
const { edgePath, labelX, labelY } = edgeParams; const { edgePath, labelX, labelY } = edgeParams;

View file

@ -36,6 +36,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 } 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";
@ -266,7 +267,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG
}); });
// 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 +281,36 @@ 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 }>();
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]); }, [storeEdges, storeGroups, storeNodes]);
const [edges, setEdgesState, onEdgesChange] = useEdgesState( const [edges, setEdgesState, onEdgesChange] = useEdgesState(

View file

@ -23,6 +23,9 @@ 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
} }
export type Relation = Edge<RelationData>; export type Relation = Edge<RelationData>;

View file

@ -9,6 +9,115 @@ export const generateEdgeId = (source: string, target: string): string => {
return `edge_${source}_${target}_${Date.now()}`; 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 * 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 +494,18 @@ 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)
*/ */
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
) { ) {
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,11 +518,24 @@ 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; 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 { return {
sx: sourceIntersection.x, sx: sourceIntersection.x,