feat: US-012 - Add hover-highlighting between experience/skills and constellation graph

This commit is contained in:
2026-02-14 18:28:44 +00:00
parent b90706a3f6
commit 9e9962f114
6 changed files with 108 additions and 7 deletions
+62
View File
@@ -6,6 +6,7 @@ import type { ConstellationNode } from '@/types/pmr'
interface CareerConstellationProps {
onRoleClick: (id: string) => void
onSkillClick: (id: string) => void
highlightedNodeId?: string | null
}
const DESKTOP_HEIGHT = 400
@@ -70,10 +71,12 @@ function buildScreenReaderDescription(): string {
const CareerConstellation: React.FC<CareerConstellationProps> = ({
onRoleClick,
onSkillClick,
highlightedNodeId,
}) => {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
const connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT })
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
@@ -241,6 +244,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
connectedMap.get(l.source)!.add(l.target)
connectedMap.get(l.target)!.add(l.source)
})
connectedMapRef.current = connectedMap
const HOVER_TRANSITION = '150ms'
@@ -397,6 +401,64 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
}
}, [focusedNodeId])
// External highlight from hovering experience/skill entries
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
const nodeSelection = svg.selectAll<SVGGElement, SimNode>('g.node')
const linkSelection = svg.selectAll<SVGLineElement, SimLink>('g.links line')
if (!highlightedNodeId) {
// Reset all
nodeSelection.style('opacity', '1')
nodeSelection.filter(d => d.type === 'skill')
.select('.node-circle')
.attr('r', SKILL_RADIUS)
linkSelection
.attr('stroke', '#B0C4C0')
.attr('stroke-width', 1.5)
.attr('stroke-opacity', 0.45)
return
}
const connected = connectedMapRef.current.get(highlightedNodeId) ?? new Set()
// Dim non-connected nodes
nodeSelection.style('opacity', d => {
if (d.id === highlightedNodeId || connected.has(d.id)) return '1'
return '0.15'
})
// Scale up connected skill nodes
const highlightedNode = constellationNodes.find(n => n.id === highlightedNodeId)
if (highlightedNode?.type === 'role') {
nodeSelection.filter(d => d.type === 'skill' && connected.has(d.id))
.select('.node-circle')
.attr('r', SKILL_RADIUS + 4)
}
// Brighten connected links
linkSelection
.attr('stroke', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === highlightedNodeId || tgt === highlightedNodeId) return '#0D6E6E'
return '#B0C4C0'
})
.attr('stroke-opacity', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === highlightedNodeId || tgt === highlightedNodeId) return 0.7
return 0.1
})
.attr('stroke-width', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === highlightedNodeId || tgt === highlightedNodeId) return 2.5
return 1.5
})
}, [highlightedNodeId])
return (
<div
ref={containerRef}