/ src / components / org-chart / org-chart-edge.tsx
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  }