org-chart-edge.tsx
1 'use client' 2 3 import { useState } from 'react' 4 5 type EdgeColor = 'indigo' | 'emerald' | 'red' 6 type EdgeDirection = 'down' | 'up' | null 7 8 interface Props { 9 x1: number 10 y1: number 11 x2: number 12 y2: number 13 active?: boolean 14 direction?: EdgeDirection 15 color?: EdgeColor 16 onClick?: (e: React.MouseEvent) => void 17 } 18 19 const COLOR_MAP: Record<EdgeColor, { stroke: string; glow: string; dot: string; text: string; border: string }> = { 20 indigo: { 21 stroke: 'rgba(99,102,241,0.4)', 22 glow: 'rgba(99,102,241,0.15)', 23 dot: 'rgba(99,102,241,0.8)', 24 text: 'rgba(165,180,252,0.9)', 25 border: 'rgba(99,102,241,0.2)', 26 }, 27 emerald: { 28 stroke: 'rgba(52,211,153,0.4)', 29 glow: 'rgba(52,211,153,0.15)', 30 dot: 'rgba(52,211,153,0.8)', 31 text: 'rgba(110,231,183,0.9)', 32 border: 'rgba(52,211,153,0.2)', 33 }, 34 red: { 35 stroke: 'rgba(244,63,94,0.4)', 36 glow: 'rgba(244,63,94,0.15)', 37 dot: 'rgba(244,63,94,0.8)', 38 text: 'rgba(251,113,133,0.9)', 39 border: 'rgba(244,63,94,0.2)', 40 }, 41 } 42 43 export function OrgChartEdge({ x1, y1, x2, y2, active, direction, color = 'indigo', onClick }: Props) { 44 const [hovered, setHovered] = useState(false) 45 46 // Cubic bezier from parent bottom-center to child top-center 47 const midY = (y1 + y2) / 2 48 const d = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}` 49 50 // Midpoint for label / hover button 51 const midX = (x1 + x2) / 2 52 const midPtY = midY 53 54 const colors = COLOR_MAP[color] 55 const isUp = direction === 'up' 56 57 return ( 58 <g 59 onMouseEnter={() => setHovered(true)} 60 onMouseLeave={() => setHovered(false)} 61 > 62 {/* Invisible wide hit area for hover detection */} 63 <path 64 d={d} 65 fill="none" 66 stroke="transparent" 67 strokeWidth={20} 68 strokeLinecap="round" 69 pointerEvents="stroke" 70 /> 71 {/* Visible edge */} 72 <path 73 d={d} 74 fill="none" 75 stroke={hovered && !active ? 'rgba(255,255,255,0.2)' : active ? colors.stroke : 'rgba(255,255,255,0.08)'} 76 strokeWidth={hovered && !active ? 2 : active ? 2 : 1.5} 77 strokeLinecap="round" 78 style={{ pointerEvents: 'none', transition: 'stroke 0.15s, stroke-width 0.15s' }} 79 /> 80 {/* Hover glow */} 81 {hovered && !active && ( 82 <path 83 d={d} 84 fill="none" 85 stroke="rgba(255,255,255,0.06)" 86 strokeWidth={8} 87 strokeLinecap="round" 88 style={{ pointerEvents: 'none' }} 89 /> 90 )} 91 {active && ( 92 <> 93 {/* Active glow */} 94 <path 95 d={d} 96 fill="none" 97 stroke={colors.glow} 98 strokeWidth={6} 99 strokeLinecap="round" 100 style={{ pointerEvents: 'none' }} 101 /> 102 {/* Traveling dot */} 103 <circle r="3" fill={colors.dot} style={{ pointerEvents: 'none' }}> 104 <animateMotion 105 dur="1.5s" 106 repeatCount="indefinite" 107 path={d} 108 {...(isUp ? { keyPoints: '1;0', keyTimes: '0;1' } : {})} 109 /> 110 </circle> 111 </> 112 )} 113 {/* Midpoint hover button — HTML so clicks bypass SVG pointer capture */} 114 {hovered && onClick && ( 115 <foreignObject 116 x={midX - 48} 117 y={midPtY - 12} 118 width={96} 119 height={24} 120 style={{ overflow: 'visible' }} 121 > 122 <button 123 onClick={(e) => { 124 e.stopPropagation() 125 onClick(e as unknown as React.MouseEvent) 126 }} 127 onPointerDown={(e) => e.stopPropagation()} 128 style={{ 129 fontSize: 9, 130 fontWeight: 500, 131 color: 'rgba(200,200,220,0.9)', 132 background: 'rgba(18,18,30,0.95)', 133 border: '1px solid rgba(255,255,255,0.12)', 134 borderRadius: 10, 135 padding: '2px 10px', 136 cursor: 'pointer', 137 whiteSpace: 'nowrap', 138 width: 'fit-content', 139 margin: '0 auto', 140 display: 'block', 141 transition: 'background 0.15s, border-color 0.15s', 142 }} 143 onMouseEnter={(e) => { 144 e.currentTarget.style.background = 'rgba(99,102,241,0.2)' 145 e.currentTarget.style.borderColor = 'rgba(99,102,241,0.3)' 146 }} 147 onMouseLeave={(e) => { 148 e.currentTarget.style.background = 'rgba(18,18,30,0.95)' 149 e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' 150 }} 151 > 152 View activity 153 </button> 154 </foreignObject> 155 )} 156 </g> 157 ) 158 }