mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-03-13 20:18:47 +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
|
// 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue