|
|
|
@@ -1,10 +1,11 @@
|
|
|
|
|
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'
|
|
|
|
|
import * as d3 from 'd3'
|
|
|
|
|
import { constellationNodes, roleSkillMappings } from '@/data/constellation'
|
|
|
|
|
import { timelineCareerEntities } from '@/data/timeline'
|
|
|
|
|
import { timelineEntities } from '@/data/timeline'
|
|
|
|
|
import { useForceSimulation, getHeight } from '@/hooks/useForceSimulation'
|
|
|
|
|
import { useConstellationHighlight } from '@/hooks/useConstellationHighlight'
|
|
|
|
|
import { useConstellationInteraction } from '@/hooks/useConstellationInteraction'
|
|
|
|
|
import { useTimelineAnimation } from '@/hooks/useTimelineAnimation'
|
|
|
|
|
import { MobileAccordion } from './MobileAccordion'
|
|
|
|
|
import { ConstellationLegend } from './ConstellationLegend'
|
|
|
|
|
import { AccessibleNodeOverlay } from './AccessibleNodeOverlay'
|
|
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
|
|
MIN_HEIGHT,
|
|
|
|
|
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
|
|
|
|
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
|
|
|
|
supportsCoarsePointer,
|
|
|
|
|
supportsCoarsePointer, prefersReducedMotion,
|
|
|
|
|
} from './constants'
|
|
|
|
|
|
|
|
|
|
interface CareerConstellationProps {
|
|
|
|
@@ -24,29 +25,29 @@ interface CareerConstellationProps {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
|
|
|
|
|
const careerEntityById = new Map(timelineCareerEntities.map(entity => [entity.id, entity]))
|
|
|
|
|
const careerEntityById = new Map(timelineEntities.map(entity => [entity.id, entity]))
|
|
|
|
|
const srDescription = buildScreenReaderDescription()
|
|
|
|
|
|
|
|
|
|
function buildScreenReaderDescription(): string {
|
|
|
|
|
const roles = constellationNodes.filter(n => n.type === 'role')
|
|
|
|
|
const entities = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')
|
|
|
|
|
const skills = constellationNodes.filter(n => n.type === 'skill')
|
|
|
|
|
|
|
|
|
|
const roleDescriptions = roles.map(role => {
|
|
|
|
|
const mapping = roleSkillMappings.find(m => m.roleId === role.id)
|
|
|
|
|
const entityDescriptions = entities.map(entity => {
|
|
|
|
|
const mapping = roleSkillMappings.find(m => m.roleId === entity.id)
|
|
|
|
|
const skillNames = mapping
|
|
|
|
|
? mapping.skillIds
|
|
|
|
|
.map(sid => skills.find(s => s.id === sid)?.label)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(', ')
|
|
|
|
|
: ''
|
|
|
|
|
const yearRange = role.endYear
|
|
|
|
|
? `${role.startYear}-${role.endYear}`
|
|
|
|
|
: `${role.startYear}-present`
|
|
|
|
|
return `${role.label} at ${role.organization} (${yearRange}): ${skillNames}`
|
|
|
|
|
const yearRange = entity.endYear
|
|
|
|
|
? `${entity.startYear}-${entity.endYear}`
|
|
|
|
|
: `${entity.startYear}-present`
|
|
|
|
|
return `${entity.label} at ${entity.organization} (${yearRange}): ${skillNames}`
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return `Career constellation graph showing ${roles.length} roles and ${skills.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` +
|
|
|
|
|
roleDescriptions.join('. ') + '.'
|
|
|
|
|
return `Career constellation graph showing ${entities.length} roles and ${skills.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` +
|
|
|
|
|
entityDescriptions.join('. ') + '.'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
@@ -69,7 +70,6 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
highlightedNodeIdRef.current = highlightedNodeId ?? null
|
|
|
|
|
}, [highlightedNodeId])
|
|
|
|
|
|
|
|
|
|
// ResizeObserver for container dimensions
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const container = containerRef.current
|
|
|
|
|
if (!container) return
|
|
|
|
@@ -90,7 +90,6 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
return () => observer.disconnect()
|
|
|
|
|
}, [containerHeight])
|
|
|
|
|
|
|
|
|
|
// Compute layout-dependent skill radii for highlight hook
|
|
|
|
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
|
|
|
|
const sf = isMobile ? 1 : dimensions.scaleFactor
|
|
|
|
|
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
|
|
|
|
@@ -103,20 +102,22 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
|
|
|
|
|
const resolveRoleFallback = useCallback(() => {
|
|
|
|
|
const hId = highlightedNodeIdRef.current
|
|
|
|
|
if (hId && nodeById.get(hId)?.type === 'role') return hId
|
|
|
|
|
const hType = hId ? nodeById.get(hId)?.type : null
|
|
|
|
|
if (hId && hType && hType !== 'skill') return hId
|
|
|
|
|
const pId = pinnedNodeIdRef.current
|
|
|
|
|
if (pId && nodeById.get(pId)?.type === 'role') return pId
|
|
|
|
|
const pType = pId ? nodeById.get(pId)?.type : null
|
|
|
|
|
if (pId && pType && pType !== 'skill') return pId
|
|
|
|
|
return null
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// Highlight hook (needs to be created before simulation so we can pass applyHighlight)
|
|
|
|
|
// Shared refs for hooks
|
|
|
|
|
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
|
|
|
|
|
const nodesRef = useRef<import('./types').SimNode[]>([])
|
|
|
|
|
const nodeSelectionRef = useRef<d3.Selection<SVGGElement, import('./types').SimNode, SVGGElement, unknown> | null>(null)
|
|
|
|
|
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 visibleNodeIdsRef = useRef<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
const { applyGraphHighlight } = useConstellationHighlight({
|
|
|
|
|
nodeSelectionRef,
|
|
|
|
@@ -126,11 +127,11 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
srActive,
|
|
|
|
|
nodesRef,
|
|
|
|
|
skillRestRadii: skillRestRadiiRef.current,
|
|
|
|
|
visibleNodeIdsRef,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
highlightGraphRef.current = applyGraphHighlight
|
|
|
|
|
|
|
|
|
|
// Stable options ref for simulation to avoid re-creating on every render
|
|
|
|
|
const simOptionsRef = useRef({
|
|
|
|
|
resolveGraphFallback,
|
|
|
|
|
applyHighlight: applyGraphHighlight,
|
|
|
|
@@ -144,19 +145,31 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
|
|
|
|
|
const sim = useForceSimulation(svgRef, dimensions, stableSimOptions)
|
|
|
|
|
|
|
|
|
|
// Sync simulation refs to our local refs for highlight/interaction hooks
|
|
|
|
|
// Sync simulation refs
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
nodesRef.current = sim.nodesRef.current
|
|
|
|
|
nodeSelectionRef.current = sim.nodeSelectionRef.current
|
|
|
|
|
linkSelectionRef.current = sim.linkSelectionRef.current
|
|
|
|
|
if (sim.connectedMap.size > 0) {
|
|
|
|
|
connectedMapRef.current = sim.connectedMap
|
|
|
|
|
}
|
|
|
|
|
if (sim.skillRestRadii.size > 0) {
|
|
|
|
|
skillRestRadiiRef.current = sim.skillRestRadii
|
|
|
|
|
}
|
|
|
|
|
if (sim.connectedMap.size > 0) connectedMapRef.current = sim.connectedMap
|
|
|
|
|
if (sim.skillRestRadii.size > 0) skillRestRadiiRef.current = sim.skillRestRadii
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Animation hook
|
|
|
|
|
const animation = useTimelineAnimation({
|
|
|
|
|
nodeSelectionRef,
|
|
|
|
|
linkSelectionRef,
|
|
|
|
|
simulationRef: sim.simulationRef,
|
|
|
|
|
yearIndicatorRef: sim.yearIndicatorRef,
|
|
|
|
|
connectorSelectionRef: sim.connectorSelectionRef,
|
|
|
|
|
timelineGroupRef: sim.timelineGroupRef,
|
|
|
|
|
skillRestRadiiRef,
|
|
|
|
|
srDefault,
|
|
|
|
|
dimensionsTrigger: dimensions.width + dimensions.height,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Sync visibleNodeIdsRef from animation hook
|
|
|
|
|
visibleNodeIdsRef.current = animation.visibleNodeIdsRef.current
|
|
|
|
|
|
|
|
|
|
// Interaction hook
|
|
|
|
|
const { pinnedNodeId, setPinnedNodeId, pinnedNodeIdRef } = useConstellationInteraction({
|
|
|
|
|
highlightGraphRef,
|
|
|
|
@@ -166,6 +179,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
resolveGraphFallback,
|
|
|
|
|
resolveRoleFallback,
|
|
|
|
|
dimensionsTrigger: dimensions.width + dimensions.height,
|
|
|
|
|
pauseForInteraction: animation.pauseForInteraction,
|
|
|
|
|
resumeAfterInteraction: animation.resumeAfterInteraction,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// External highlight sync
|
|
|
|
@@ -178,9 +193,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!svgRef.current) return
|
|
|
|
|
const svg = d3.select(svgRef.current)
|
|
|
|
|
|
|
|
|
|
svg.selectAll('.focus-ring').attr('stroke', 'transparent')
|
|
|
|
|
|
|
|
|
|
if (focusedNodeId) {
|
|
|
|
|
svg.selectAll<SVGGElement, { id: string }>('g.node')
|
|
|
|
|
.filter(d => d.id === focusedNodeId)
|
|
|
|
@@ -190,18 +203,17 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
}
|
|
|
|
|
}, [focusedNodeId])
|
|
|
|
|
|
|
|
|
|
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
|
|
|
|
|
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill' | 'education') => {
|
|
|
|
|
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)
|
|
|
|
|
onNodeHover?.(nodeType !== 'skill' ? nodeId : resolveRoleFallback())
|
|
|
|
|
;(nodeType !== 'skill' ? 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 pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && (n.type === 'role' || n.type === 'education')) : null
|
|
|
|
|
const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) ?? null : null
|
|
|
|
|
const domainCounts = useMemo(() => {
|
|
|
|
|
const counts: Record<string, number> = {}
|
|
|
|
@@ -237,22 +249,50 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
|
|
|
|
|
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
|
|
|
|
|
|
|
|
|
<MobileAccordion
|
|
|
|
|
pinnedCareerEntity={pinnedCareerEntity}
|
|
|
|
|
show={showAccordion}
|
|
|
|
|
/>
|
|
|
|
|
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
|
|
|
|
|
|
|
|
|
|
{!prefersReducedMotion && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={animation.togglePlayPause}
|
|
|
|
|
aria-label={animation.isPlaying ? 'Pause animation' : 'Play animation'}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
bottom: isMobile ? 8 : 12,
|
|
|
|
|
right: isMobile ? 8 : 12,
|
|
|
|
|
width: isMobile ? 44 : 36,
|
|
|
|
|
height: isMobile ? 44 : 36,
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
border: '1px solid var(--border-light)',
|
|
|
|
|
background: 'var(--surface)',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
opacity: 0.6,
|
|
|
|
|
transition: 'opacity 150ms ease',
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
|
|
|
|
|
onMouseLeave={e => (e.currentTarget.style.opacity = '0.6')}
|
|
|
|
|
>
|
|
|
|
|
{animation.isPlaying ? (
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="var(--text-secondary)">
|
|
|
|
|
<rect x="2" y="1" width="4" height="12" rx="1" />
|
|
|
|
|
<rect x="8" y="1" width="4" height="12" rx="1" />
|
|
|
|
|
</svg>
|
|
|
|
|
) : (
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="var(--text-secondary)">
|
|
|
|
|
<polygon points="3,1 13,7 3,13" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<p
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
width: 1,
|
|
|
|
|
height: 1,
|
|
|
|
|
padding: 0,
|
|
|
|
|
margin: -1,
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
clip: 'rect(0,0,0,0)',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
border: 0,
|
|
|
|
|
width: 1, height: 1, padding: 0, margin: -1,
|
|
|
|
|
overflow: 'hidden', clip: 'rect(0,0,0,0)',
|
|
|
|
|
whiteSpace: 'nowrap', border: 0,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{srDescription}
|
|
|
|
@@ -266,7 +306,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
setFocusedNodeId(nodeId)
|
|
|
|
|
highlightGraphRef.current?.(nodeId)
|
|
|
|
|
const node = nodeById.get(nodeId)
|
|
|
|
|
if (node?.type === 'role') onNodeHover?.(nodeId)
|
|
|
|
|
if (node?.type !== 'skill') onNodeHover?.(nodeId)
|
|
|
|
|
}}
|
|
|
|
|
onBlur={() => {
|
|
|
|
|
setFocusedNodeId(null)
|
|
|
|
@@ -277,7 +317,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|
|
|
|
setPinnedNodeId(nodeId)
|
|
|
|
|
pinnedNodeIdRef.current = nodeId
|
|
|
|
|
highlightGraphRef.current?.(nodeId)
|
|
|
|
|
if (nodeType === 'role') {
|
|
|
|
|
if (nodeType !== 'skill') {
|
|
|
|
|
onNodeHover?.(nodeId)
|
|
|
|
|
onRoleClick(nodeId)
|
|
|
|
|
} else {
|
|
|
|
|