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}
@@ -3,13 +3,14 @@ import { supportsCoarsePointer } from './constants'
interface ConstellationLegendProps {
isTouch: boolean
domainCounts?: Record<string, number>
}
export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch }) => {
export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch, domainCounts }) => {
const items = [
{ label: 'Technical', color: 'var(--accent)' },
{ label: 'Clinical', color: 'var(--success)' },
{ label: 'Leadership', color: 'var(--amber)' },
{ label: 'Technical', domain: 'technical', color: 'var(--accent)' },
{ label: 'Clinical', domain: 'clinical', color: 'var(--success)' },
{ label: 'Leadership', domain: 'leadership', color: 'var(--amber)' },
]
return (
@@ -42,7 +43,7 @@ export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouc
flexShrink: 0,
}}
/>
{item.label}
{item.label}{domainCounts?.[item.domain] != null ? ` (${domainCounts[item.domain]})` : ''}
</span>
</React.Fragment>
))}
+22 -1
View File
@@ -15,9 +15,30 @@ export const MOBILE_LABEL_MAX_LEN = 10
export const HIGHLIGHT_DIM_OPACITY = 0.15
export const SKILL_REST_OPACITY = 0.35
export const SKILL_ACTIVE_OPACITY = 0.9
export const LINK_REST_OPACITY = 0.15
export const LABEL_REST_OPACITY = 0.5
// Link visual params
export const LINK_BASE_WIDTH = 0.5
export const LINK_STRENGTH_WIDTH_FACTOR = 1.5
export const LINK_BASE_OPACITY = 0.08
export const LINK_STRENGTH_OPACITY_FACTOR = 0.12
export const LINK_HIGHLIGHT_BASE_WIDTH = 1
export const LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR = 2
export const LINK_BEZIER_VERTICAL_OFFSET = 0.15
// Skill node visual params
export const SKILL_STROKE_WIDTH = 1
export const SKILL_STROKE_OPACITY = 0.4
export const SKILL_SIZE_ROLE_FACTOR = 0.8
export const SKILL_GLOW_STD_DEVIATION = 2.5
// Entry animation
export const ENTRY_GUIDE_FADE_MS = 200
export const ENTRY_ROLE_STAGGER_MS = 80
export const ENTRY_ROLE_DURATION_MS = 300
export const ENTRY_SKILL_STAGGER_MS = 30
export const ENTRY_SKILL_DURATION_MS = 250
// Domain color map
export const DOMAIN_COLOR_MAP: Record<string, string> = {
clinical: '#059669',