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