Add visual enhancements for parallel edges

Add hover states and badge indicators to improve parallel edge visibility:

Features:
- Hover effect: Edge stroke increases from 2px to 3px on hover
- Selected edges: Stroke increases to 4px for clear selection feedback
- Parallel badge: Show "X relations" badge when 4+ parallel edges exist
- Badge positioning: Display only on center edge to avoid clutter
- Smooth transitions: 150ms ease-in-out for stroke width changes

Visual design:
- Default stroke: 2px
- Hover stroke: 3px
- Selected stroke: 4px
- Badge color: neutral gray (#6b7280)
- Badge appears at edge label position

All 517 tests pass.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik 2026-02-05 13:27:09 +01:00
parent 3daedbc0d8
commit 833c130690

View file

@ -1,4 +1,4 @@
import { memo, useMemo } from 'react'; import { memo, useMemo, useState, useCallback } from 'react';
import { import {
EdgeProps, EdgeProps,
EdgeLabelRenderer, EdgeLabelRenderer,
@ -42,6 +42,17 @@ const CustomEdge = ({
// Get active filters based on mode (editing vs presentation) // Get active filters based on mode (editing vs presentation)
const filters = useActiveFilters(); 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 // Get internal nodes for floating edge calculations with correct absolute positioning
const sourceNode = useInternalNode(source); const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target); const targetNode = useInternalNode(target);
@ -105,6 +116,10 @@ const CustomEdge = ({
// Check if this is an aggregated edge // Check if this is an aggregated edge
const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount; const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount;
// Check if this edge is part of a large parallel group (4+ edges)
const parallelGroupSize = (data as { parallelGroupSize?: number })?.parallelGroupSize || 0;
const showParallelBadge = parallelGroupSize >= 4;
// Find the edge type configuration // Find the edge type configuration
const edgeTypeConfig = edgeTypes.find((et) => et.id === data?.type); const edgeTypeConfig = edgeTypes.find((et) => et.id === data?.type);
@ -151,6 +166,9 @@ const CustomEdge = ({
// Calculate opacity based on visibility // Calculate opacity based on visibility
const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; 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) // Create unique marker IDs based on color (for reusability)
const safeColor = edgeColor.replace('#', ''); const safeColor = edgeColor.replace('#', '');
const markerEndId = `arrow-end-${safeColor}`; const markerEndId = `arrow-end-${safeColor}`;
@ -202,16 +220,19 @@ const CustomEdge = ({
path={edgePath} path={edgePath}
style={{ style={{
stroke: edgeColor, stroke: edgeColor,
strokeWidth: selected ? 3 : 2, strokeWidth,
strokeDasharray, strokeDasharray,
opacity: edgeOpacity, opacity: edgeOpacity,
transition: 'stroke-width 150ms ease-in-out',
}} }}
markerEnd={markerEnd} markerEnd={markerEnd}
markerStart={markerStart} markerStart={markerStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/> />
{/* Edge label - show custom or type default, plus labels, plus aggregation count */} {/* Edge label - show custom or type default, plus labels, plus aggregation count, plus parallel badge */}
{(displayLabel || (data?.labels && data.labels.length > 0) || (data as { aggregatedCount?: number })?.aggregatedCount) && ( {(displayLabel || (data?.labels && data.labels.length > 0) || (data as { aggregatedCount?: number })?.aggregatedCount || showParallelBadge) && (
<EdgeLabelRenderer> <EdgeLabelRenderer>
<div <div
style={{ style={{
@ -253,6 +274,16 @@ const CustomEdge = ({
{(data as { aggregatedCount?: number }).aggregatedCount} relations {(data as { aggregatedCount?: number }).aggregatedCount} relations
</div> </div>
)} )}
{/* Parallel edge badge for 4+ parallel edges (show only on center edge) */}
{showParallelBadge && Math.abs((data as { offsetMultiplier?: number })?.offsetMultiplier || 0) < 0.1 && (
<div
className="mt-1 px-2 py-0.5 rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: '#6b7280' }}
title={`${parallelGroupSize} parallel relations between these nodes`}
>
{parallelGroupSize} relations
</div>
)}
</div> </div>
</EdgeLabelRenderer> </EdgeLabelRenderer>
)} )}