diff --git a/EDGE_OVERLAP_UX_PROPOSAL.md b/EDGE_OVERLAP_UX_PROPOSAL.md new file mode 100644 index 0000000..e2fa1c6 --- /dev/null +++ b/EDGE_OVERLAP_UX_PROPOSAL.md @@ -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 { + const groups = new Map(); + + 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. diff --git a/EDGE_OVERLAP_VISUAL_GUIDE.md b/EDGE_OVERLAP_VISUAL_GUIDE.md new file mode 100644 index 0000000..175d109 --- /dev/null +++ b/EDGE_OVERLAP_VISUAL_GUIDE.md @@ -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` diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index 201ba42..19cd287 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -76,7 +76,10 @@ const CustomEdge = ({ }; } - const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape); + // Get offset multiplier from edge data (for parallel edges) + const offsetMultiplier = (data as { offsetMultiplier?: number })?.offsetMultiplier || 0; + + const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape, offsetMultiplier); // Create cubic bezier path using custom control points const edgePath = `M ${params.sx},${params.sy} C ${params.sourceControlX},${params.sourceControlY} ${params.targetControlX},${params.targetControlY} ${params.tx},${params.ty}`; @@ -95,7 +98,7 @@ const CustomEdge = ({ Math.pow(t, 3) * params.ty; return { edgePath, labelX, labelY }; - }, [sourceNode, targetNode, sourceShape, targetShape, sourceX, sourceY, targetX, targetY]); + }, [sourceNode, targetNode, sourceShape, targetShape, sourceX, sourceY, targetX, targetY, data]); const { edgePath, labelX, labelY } = edgeParams; diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 6a06462..9405526 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -36,6 +36,7 @@ import CustomEdge from "../Edges/CustomEdge"; import ContextMenu from "./ContextMenu"; import EmptyState from "../Common/EmptyState"; import { createNode } from "../../utils/nodeUtils"; +import { groupParallelEdges, calculateEdgeOffsetMultiplier } from "../../utils/edgeUtils"; import DeleteIcon from "@mui/icons-material/Delete"; import GroupWorkIcon from "@mui/icons-material/GroupWork"; import UngroupIcon from "@mui/icons-material/CallSplit"; @@ -266,7 +267,7 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG }); // Convert the map to an array of edges, attaching aggregation metadata - return Array.from(edgeMap.values()).map(({ edge, aggregatedRelations }) => { + const processedEdges = Array.from(edgeMap.values()).map(({ edge, aggregatedRelations }) => { if (aggregatedRelations.length > 1) { // Multiple relations aggregated - add metadata to edge data return { @@ -280,6 +281,36 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG } return edge; }); + + // Detect parallel edges and add offset information + const parallelGroups = groupParallelEdges(processedEdges as Relation[]); + + // Create a map of edge ID -> offset info + const offsetMap = new Map(); + + parallelGroups.forEach((group) => { + const totalEdges = group.edges.length; + group.edges.forEach((edge, index) => { + const multiplier = calculateEdgeOffsetMultiplier(index, totalEdges); + offsetMap.set(edge.id, { multiplier, groupSize: totalEdges }); + }); + }); + + // Apply offset information to edges + return processedEdges.map((edge) => { + const offsetInfo = offsetMap.get(edge.id); + if (offsetInfo) { + return { + ...edge, + data: { + ...edge.data, + offsetMultiplier: offsetInfo.multiplier, + parallelGroupSize: offsetInfo.groupSize, + }, + } as Edge; + } + return edge; + }); }, [storeEdges, storeGroups, storeNodes]); const [edges, setEdgesState, onEdgesChange] = useEdgesState( diff --git a/src/types/index.ts b/src/types/index.ts index a5cd9c6..e02dfb8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,6 +23,9 @@ export interface RelationData extends Record { labels?: string[]; // Array of LabelConfig IDs citations?: string[]; // Array of bibliography reference IDs metadata?: Record; + // 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; diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index b045be2..ec6d7c5 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -9,6 +9,115 @@ export const generateEdgeId = (source: string, target: string): string => { return `edge_${source}_${target}_${Date.now()}`; }; +/** + * Edge group information for parallel edges + */ +export interface EdgeGroup { + edges: Relation[]; + sourceId: string; + targetId: string; +} + +/** + * Base offset for parallel edges (in pixels) + */ +const BASE_EDGE_OFFSET = 30; + +/** + * Calculate the perpendicular offset for a parallel edge + * Returns a 2D vector that is perpendicular to the line between source and target + */ +export function calculatePerpendicularOffset( + sourceX: number, + sourceY: number, + targetX: number, + targetY: number, + offsetMagnitude: number +): { x: number; y: number } { + // Calculate direction vector from source to target + const dx = targetX - sourceX; + const dy = targetY - sourceY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Handle zero-distance case + if (distance === 0) { + return { x: offsetMagnitude, y: 0 }; + } + + // Normalize direction vector + const nx = dx / distance; + const ny = dy / distance; + + // Perpendicular vector (rotate 90 degrees counterclockwise) + const perpX = -ny; + const perpY = nx; + + // Scale by offset magnitude + return { + x: perpX * offsetMagnitude, + y: perpY * offsetMagnitude, + }; +} + +/** + * Calculate edge offset for a parallel edge within a group + * @param edgeIndex - Index of this edge within the parallel group (0-based) + * @param totalEdges - Total number of parallel edges + * @returns Offset multiplier (-1, 0, +1, etc.) + */ +export function calculateEdgeOffsetMultiplier( + edgeIndex: number, + totalEdges: number +): number { + // For single edge, no offset + if (totalEdges === 1) { + return 0; + } + + // For 2 edges: offset by ±0.5 (one above, one below center) + if (totalEdges === 2) { + return edgeIndex === 0 ? -0.5 : 0.5; + } + + // For 3+ edges: distribute evenly around center + // Center edge(s) get offset 0, others get ±1, ±2, etc. + const middle = (totalEdges - 1) / 2; + return edgeIndex - middle; +} + +/** + * Group edges by their source-target pairs (bidirectional) + * Edges between A-B and B-A are grouped together + */ +export function groupParallelEdges(edges: Relation[]): Map { + const groups = new Map(); + + 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(); + groups.forEach((group, key) => { + if (group.edges.length >= 2) { + parallelGroups.set(key, group); + } + }); + + return parallelGroups; +} + /** * Calculate intersection point with a circle * Returns both the intersection point and the normal vector (outward direction) @@ -385,12 +494,18 @@ function getNodeIntersection( /** * Calculate the parameters for a floating edge between two nodes * Returns source/target coordinates with angles for smooth bezier curves + * @param sourceNode - Source node + * @param targetNode - Target node + * @param sourceShape - Shape of source node + * @param targetShape - Shape of target node + * @param offsetMultiplier - Multiplier for perpendicular offset (0 = no offset, ±1 = BASE_EDGE_OFFSET) */ export function getFloatingEdgeParams( sourceNode: Node, targetNode: Node, sourceShape: NodeShape = 'rectangle', - targetShape: NodeShape = 'rectangle' + targetShape: NodeShape = 'rectangle', + offsetMultiplier: number = 0 ) { const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape); const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape); @@ -403,11 +518,24 @@ export function getFloatingEdgeParams( // Use 40% of distance for more pronounced curves, with reasonable limits const controlPointDistance = Math.min(Math.max(distance * 0.4, 40), 150); - // Calculate control points using the normal angles - const sourceControlX = sourceIntersection.x + Math.cos(sourceIntersection.angle) * controlPointDistance; - const sourceControlY = sourceIntersection.y + Math.sin(sourceIntersection.angle) * controlPointDistance; - const targetControlX = targetIntersection.x + Math.cos(targetIntersection.angle) * controlPointDistance; - const targetControlY = targetIntersection.y + Math.sin(targetIntersection.angle) * controlPointDistance; + // Calculate perpendicular offset if needed + let perpOffset = { x: 0, y: 0 }; + if (offsetMultiplier !== 0) { + const offsetMagnitude = offsetMultiplier * BASE_EDGE_OFFSET; + perpOffset = calculatePerpendicularOffset( + sourceIntersection.x, + sourceIntersection.y, + targetIntersection.x, + targetIntersection.y, + offsetMagnitude + ); + } + + // Calculate control points using the normal angles, with perpendicular offset applied + const sourceControlX = sourceIntersection.x + Math.cos(sourceIntersection.angle) * controlPointDistance + perpOffset.x; + const sourceControlY = sourceIntersection.y + Math.sin(sourceIntersection.angle) * controlPointDistance + perpOffset.y; + const targetControlX = targetIntersection.x + Math.cos(targetIntersection.angle) * controlPointDistance + perpOffset.x; + const targetControlY = targetIntersection.y + Math.sin(targetIntersection.angle) * controlPointDistance + perpOffset.y; return { sx: sourceIntersection.x,