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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user