feat: phase 2 visual improvements for CareerConstellation

- Links: domain-colored with strength-weighted width/opacity, improved bezier curves
- Skill nodes: domain-colored stroke, size encoding by connected role count, glow filter on highlight
- Role nodes: gradient fill (orgColor 0.08→0.18), enhanced highlight with fill-opacity and stroke-width
- Entry animation: staggered reveal (guides→roles→skills→links with stroke-dashoffset), skipped under prefers-reduced-motion
- Legend: domain node counts displayed
This commit is contained in:
2026-02-16 14:16:36 +00:00
parent 65b265733e
commit 7d7628c8a7
5 changed files with 232 additions and 46 deletions
@@ -116,6 +116,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, import('./types').SimLink, SVGGElement, unknown> | null>(null)
const connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
const skillRestRadiiRef = useRef<Map<string, number>>(new Map())
const { applyGraphHighlight } = useConstellationHighlight({
nodeSelectionRef,
linkSelectionRef,
@@ -123,6 +125,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
srDefault,
srActive,
nodesRef,
skillRestRadii: skillRestRadiiRef.current,
})
highlightGraphRef.current = applyGraphHighlight
@@ -149,6 +152,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
if (sim.connectedMap.size > 0) {
connectedMapRef.current = sim.connectedMap
}
if (sim.skillRestRadii.size > 0) {
skillRestRadiiRef.current = sim.skillRestRadii
}
})
// Interaction hook
@@ -185,27 +191,27 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
}, [focusedNodeId])
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId)
if (nodeType === 'role') {
onNodeHover?.(nodeId)
} else {
onNodeHover?.(resolveRoleFallback())
}
if (nodeType === 'role') {
onRoleClick(nodeId)
} else {
onSkillClick(nodeId)
}
}
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId)
onNodeHover?.(nodeType === 'role' ? nodeId : resolveRoleFallback())
;(nodeType === 'role' ? onRoleClick : onSkillClick)(nodeId)
}, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback, setPinnedNodeId, pinnedNodeIdRef])
// Pinned career entity for mobile accordion
const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && n.type === 'role') : null
const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) ?? null : null
const domainCounts = useMemo(() => {
const counts: Record<string, number> = {}
constellationNodes.filter(n => n.type === 'skill').forEach(n => {
const d = n.domain ?? 'technical'
counts[d] = (counts[d] ?? 0) + 1
})
return counts
}, [])
const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
return (
@@ -229,7 +235,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
style={{ display: 'block' }}
/>
<ConstellationLegend isTouch={supportsCoarsePointer} />
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
<MobileAccordion
pinnedCareerEntity={pinnedCareerEntity}