Files
portfolio/src/components/CareerConstellation.tsx
T

1057 lines
37 KiB
TypeScript

import React, { useRef, useEffect, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import * as d3 from 'd3'
import { constellationNodes, constellationLinks, roleSkillMappings } from '@/data/constellation'
import { consultations } from '@/data/consultations'
import type { ConstellationNode } from '@/types/pmr'
interface CareerConstellationProps {
onRoleClick: (id: string) => void
onSkillClick: (id: string) => void
onNodeHover?: (id: string | null) => void
highlightedNodeId?: string | null
containerHeight?: number | null
}
const MIN_HEIGHT = 400
const MOBILE_FALLBACK_HEIGHT = 520
// Desktop defaults — mobile overrides computed in the D3 effect
const ROLE_WIDTH = 104
const ROLE_HEIGHT = 32
const ROLE_RX = 16
const SKILL_RADIUS_DEFAULT = 7
const SKILL_RADIUS_ACTIVE = 11
const MOBILE_ROLE_WIDTH = 80
const MOBILE_SKILL_RADIUS_DEFAULT = 6
const MOBILE_SKILL_RADIUS_ACTIVE = 9
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
const domainColorMap: Record<string, string> = {
clinical: '#059669',
technical: '#0D6E6E',
leadership: '#D97706',
}
const roleNodes = constellationNodes.filter(n => n.type === 'role')
const srDescription = buildScreenReaderDescription()
function getHeight(width: number, containerHeight?: number | null): number {
// Mobile/tablet: use fallback since columns stack vertically
if (width < 1024) return MOBILE_FALLBACK_HEIGHT
// Desktop: use measured container height if available, with minimum
if (containerHeight && containerHeight > 0) return Math.max(MIN_HEIGHT, containerHeight)
return MIN_HEIGHT
}
interface SimNode extends ConstellationNode {
x: number
y: number
vx: number
vy: number
fx?: number | null
fy?: number | null
homeX: number
homeY: number
}
interface SimLink {
source: SimNode | string
target: SimNode | string
strength: number
}
function hashString(input: string): number {
let hash = 0
for (let i = 0; i < input.length; i++) {
hash = (hash << 5) - hash + input.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
function buildScreenReaderDescription(): string {
const roleNodes = constellationNodes.filter(n => n.type === 'role')
const skillNodes = constellationNodes.filter(n => n.type === 'skill')
const roleDescriptions = roleNodes.map(role => {
const mapping = roleSkillMappings.find(m => m.roleId === role.id)
const skillNames = mapping
? mapping.skillIds
.map(sid => skillNodes.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}`
})
return `Career constellation graph showing ${roleNodes.length} roles and ${skillNodes.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` +
roleDescriptions.join('. ') + '.'
}
const CareerConstellation: React.FC<CareerConstellationProps> = ({
onRoleClick,
onSkillClick,
onNodeHover,
highlightedNodeId,
containerHeight,
}) => {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover })
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null)
const [accordionShowMore, setAccordionShowMore] = useState(false)
const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setPinnedNodeId(nodeId)
if (nodeType === 'role') {
onRoleClick(nodeId)
} else {
onSkillClick(nodeId)
}
}
}, [onRoleClick, onSkillClick])
useEffect(() => {
const container = containerRef.current
if (!container) return
const updateDimensions = () => {
const width = container.clientWidth
// Use viewport width for breakpoint check since container may overflow on mobile
const viewportWidth = window.innerWidth
const height = getHeight(viewportWidth, containerHeight)
// Viewport-proportional scaling: 1.0x at 1440px, up to 1.6x at 2560px+
// Only applies on desktop (>=1024px); mobile/tablet stays at 1.0
const scaleFactor = viewportWidth >= 1024
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
: 1
setDimensions({ width, height, scaleFactor })
}
updateDimensions()
const observer = new ResizeObserver(updateDimensions)
observer.observe(container)
return () => observer.disconnect()
}, [containerHeight])
useEffect(() => {
const svg = d3.select(svgRef.current)
if (!svgRef.current) return
const { width, height, scaleFactor } = dimensions
// Use viewport width for responsive breakpoint — container.clientWidth overflows on mobile
const isMobile = window.innerWidth < 640
const sf = isMobile ? 1 : scaleFactor
if (simulationRef.current) {
simulationRef.current.stop()
}
svg.selectAll('*').remove()
const years = roleNodes.map(n => n.startYear ?? 2016)
const minYear = Math.min(...years)
const maxYear = Math.max(...years)
// Responsive layout parameters — desktop values scale proportionally with viewport
const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf)
const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf)
const rrx = isMobile ? ROLE_RX : Math.round(ROLE_RX * sf)
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf)
const topPadding = isMobile ? 36 : Math.round(46 * sf)
const bottomPadding = isMobile ? 40 : Math.round(46 * sf)
const sidePadding = isMobile ? 20 : Math.round(36 * sf)
const timelineX = isMobile
? Math.max(60, width * 0.16)
: Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18))
const yScale = d3.scaleLinear()
.domain([maxYear, minYear])
.range([topPadding, height - bottomPadding])
svg.append('rect')
.attr('class', 'bg-rect')
.attr('width', width)
.attr('height', height)
.attr('fill', 'var(--surface)')
.attr('rx', 6)
// SVG filter defs for role node shadows
const defs = svg.append('defs')
const shadowSm = defs.append('filter')
.attr('id', 'shadow-sm-filter')
.attr('x', '-20%').attr('y', '-20%')
.attr('width', '140%').attr('height', '140%')
shadowSm.append('feDropShadow')
.attr('dx', 0).attr('dy', 1)
.attr('stdDeviation', 1.5)
.attr('flood-color', 'rgba(26,43,42,0.08)')
const shadowMd = defs.append('filter')
.attr('id', 'shadow-md-filter')
.attr('x', '-30%').attr('y', '-30%')
.attr('width', '160%').attr('height', '160%')
shadowMd.append('feDropShadow')
.attr('dx', 0).attr('dy', 2)
.attr('stdDeviation', 3)
.attr('flood-color', 'rgba(26,43,42,0.12)')
// Timeline guides and subtle era lanes
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
const tickYears = d3.range(minYear, maxYear + 1)
timelineGroup.selectAll('line.year-guide')
.data(tickYears)
.join('line')
.attr('class', 'year-guide')
.attr('x1', sidePadding)
.attr('x2', width - sidePadding)
.attr('y1', d => yScale(d))
.attr('y2', d => yScale(d))
.attr('stroke', 'var(--border-light)')
.attr('stroke-opacity', 0.25)
.attr('stroke-width', 1)
.attr('stroke-dasharray', '3 4')
timelineGroup.append('line')
.attr('x1', timelineX)
.attr('x2', timelineX)
.attr('y1', topPadding - 12)
.attr('y2', height - bottomPadding + 12)
.attr('stroke', 'var(--border)')
.attr('stroke-width', 1)
timelineGroup.selectAll('line.year-tick')
.data(tickYears)
.join('line')
.attr('class', 'year-tick')
.attr('x1', timelineX)
.attr('x2', d => timelineX + (roleNodes.some(r => r.startYear === d) ? 8 : 6))
.attr('y1', d => yScale(d))
.attr('y2', d => yScale(d))
.attr('stroke', 'var(--border)')
.attr('stroke-width', 1)
.attr('stroke-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.8 : 0.4)
timelineGroup.selectAll('text.year-label')
.data(tickYears)
.join('text')
.attr('class', 'year-label')
.attr('x', timelineX - (isMobile ? 8 : Math.round(12 * sf)))
.attr('y', d => yScale(d) + Math.round(4 * sf))
.attr('text-anchor', 'end')
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
.attr('font-family', 'var(--font-geist-mono)')
.attr('fill', 'var(--text-tertiary)')
.text(d => d)
// Prepare data with deterministic initial positions
const links: SimLink[] = constellationLinks.map(l => ({
source: l.source,
target: l.target,
strength: l.strength,
}))
const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0))
const roleInitialMap = new Map<string, { x: number; y: number }>()
// Consistent horizontal offset for all role nodes — anchored right of timeline
const roleGap = isMobile ? 40 : Math.round(56 * sf)
const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2)
roleOrder.forEach((role) => {
roleInitialMap.set(role.id, {
x: roleX,
y: yScale(role.startYear ?? minYear),
})
})
const nodes: SimNode[] = constellationNodes.map(n => {
if (n.type === 'role') {
const pos = roleInitialMap.get(n.id)!
return {
...n,
x: pos.x,
y: pos.y,
vx: 0,
vy: 0,
homeX: pos.x,
homeY: pos.y,
}
}
const roleIds = constellationLinks
.filter(l => l.target === n.id)
.map(l => l.source)
const linkedRolePositions = roleIds
.map(roleId => roleInitialMap.get(roleId))
.filter(Boolean) as Array<{ x: number; y: number }>
// Skill centroid: offset right of roles into the available distribution space
const skillGap = isMobile ? 20 : Math.round(28 * sf)
const skillSpaceStart = roleX + rw / 2 + skillGap
const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2
const centroid = linkedRolePositions.length > 0
? {
x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(40 * sf))),
y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length,
}
: { x: skillSpaceMid, y: height * 0.5 }
const hash = hashString(n.id)
const domainBaseAngle = n.domain === 'clinical'
? Math.PI * 0.5
: n.domain === 'leadership'
? Math.PI * 1.35
: Math.PI * 0.05
const angle = domainBaseAngle + ((hash % 360) * Math.PI / 180) * 0.18
const radius = (isMobile ? 25 : Math.round(35 * sf)) + (hash % (isMobile ? 25 : Math.round(35 * sf)))
const seededX = centroid.x + Math.cos(angle) * radius
const seededY = centroid.y + Math.sin(angle) * radius
return {
...n,
x: seededX,
y: seededY,
vx: 0,
vy: 0,
homeX: seededX,
homeY: seededY,
}
})
const linkGroup = svg.append('g').attr('class', 'links')
const connectorGroup = svg.append('g').attr('class', 'connectors')
const nodeGroup = svg.append('g').attr('class', 'nodes')
const linkSelection = linkGroup.selectAll('path')
.data(links)
.join('path')
.attr('fill', 'none')
.attr('stroke', 'var(--border-light)')
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.15)
.style('transition', prefersReducedMotion
? 'none'
: 'stroke 150ms ease, stroke-opacity 150ms ease, stroke-width 150ms ease'
)
const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
.data(nodes)
.join('g')
.attr('class', d => `node node-${d.type}`)
.style('cursor', 'pointer')
.attr('data-node-id', d => d.id)
nodeSelection.filter(d => d.type === 'role')
.append('rect')
.attr('class', 'focus-ring')
.attr('x', -rw / 2 - 3)
.attr('y', -rh / 2 - 3)
.attr('width', rw + 6)
.attr('height', rh + 6)
.attr('rx', rrx + 2)
.attr('fill', 'none')
.attr('stroke', 'transparent')
.attr('stroke-width', 2)
nodeSelection.filter(d => d.type === 'role')
.append('rect')
.attr('class', 'node-circle')
.attr('x', -rw / 2)
.attr('y', -rh / 2)
.attr('width', rw)
.attr('height', rh)
.attr('rx', rrx)
.attr('fill', d => d.orgColor ?? 'var(--accent)')
.attr('fill-opacity', 0.12)
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
.attr('stroke-opacity', 0.4)
.attr('stroke-width', 1)
const mobileLabelMaxLen = 10
nodeSelection.filter(d => d.type === 'role')
.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', d => d.orgColor ?? 'var(--accent)')
.attr('font-size', isMobile ? '10' : `${Math.round(12 * sf)}`)
.attr('font-weight', '600')
.attr('font-family', 'var(--font-ui)')
.attr('pointer-events', 'none')
.text(d => {
const label = d.shortLabel ?? d.label.slice(0, 12)
return isMobile && label.length > mobileLabelMaxLen ? `${label.slice(0, mobileLabelMaxLen - 1)}` : label
})
nodeSelection.filter(d => d.type === 'skill')
.append('circle')
.attr('class', 'focus-ring')
.attr('r', srActive + 3)
.attr('fill', 'none')
.attr('stroke', 'transparent')
.attr('stroke-width', 2)
nodeSelection.filter(d => d.type === 'skill')
.append('circle')
.attr('class', 'node-circle')
.attr('r', srDefault)
.attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E')
.attr('stroke', 'none')
.attr('fill-opacity', 0.35)
nodeSelection.filter(d => d.type === 'skill')
.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'middle')
.attr('dy', srActive + Math.round(14 * sf))
.attr('fill', 'var(--text-secondary)')
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
.attr('font-family', 'var(--font-geist-mono)')
.attr('pointer-events', 'none')
.attr('opacity', 0.5)
.text(d => {
const label = d.shortLabel ?? d.label
const maxLen = isMobile ? 12 : width < 500 ? 12 : 16
return label.length > maxLen ? `${label.slice(0, maxLen - 1)}` : label
})
const roleConnectors = connectorGroup.selectAll('line.role-connector')
.data(nodes.filter(n => n.type === 'role'))
.join('line')
.attr('class', 'role-connector')
.attr('stroke', 'var(--border)')
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.3)
const connectedMap = new Map<string, Set<string>>()
constellationLinks.forEach(l => {
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set())
connectedMap.get(l.source)!.add(l.target)
connectedMap.get(l.target)!.add(l.source)
})
const applyGraphHighlight = (activeNodeId: string | null) => {
const dur = prefersReducedMotion ? 0 : 180
if (!activeNodeId) {
nodeSelection.style('opacity', '1')
nodeSelection.filter(d => d.type === 'role')
.attr('filter', null)
.select('.node-circle')
.attr('stroke-opacity', 0.4)
.attr('stroke-width', 1)
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
if (dur > 0) {
skillNodes.select('.node-circle')
.transition().duration(dur)
.attr('r', srDefault)
.attr('fill-opacity', 0.35)
skillNodes.select('.node-label')
.transition().duration(dur)
.attr('opacity', 0.5)
} else {
skillNodes.select('.node-circle')
.attr('r', srDefault)
.attr('fill-opacity', 0.35)
skillNodes.select('.node-label')
.attr('opacity', 0.5)
}
linkSelection
.attr('stroke', 'var(--border-light)')
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.15)
return
}
const connected = connectedMap.get(activeNodeId) ?? new Set()
const isInGroup = (id: string) => id === activeNodeId || connected.has(id)
nodeSelection.style('opacity', d => isInGroup(d.id) ? '1' : '0.15')
nodeSelection.filter(d => d.type === 'role')
.attr('filter', d => {
if (d.id === activeNodeId) return 'url(#shadow-md-filter)'
if (connected.has(d.id)) return 'url(#shadow-sm-filter)'
return null
})
.select('.node-circle')
.attr('stroke-opacity', d => {
if (d.id === activeNodeId) return 1
if (connected.has(d.id)) return 0.7
return 0.4
})
.attr('stroke-width', d => d.id === activeNodeId ? 1.5 : 1)
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
if (dur > 0) {
skillNodes.select('.node-circle')
.transition().duration(dur)
.attr('r', d => isInGroup(d.id) ? srActive : srDefault)
.attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
skillNodes.select('.node-label')
.transition().duration(dur)
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
} else {
skillNodes.select('.node-circle')
.attr('r', d => isInGroup(d.id) ? srActive : srDefault)
.attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
skillNodes.select('.node-label')
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
}
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 === activeNodeId || tgt === activeNodeId) {
const skillId = src === activeNodeId ? tgt : src
const skillNode = nodes.find(n => n.id === skillId)
return domainColorMap[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
}
return 'var(--border-light)'
})
.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 === activeNodeId || tgt === activeNodeId) {
return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2))
}
return 0.15
})
.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 === activeNodeId || tgt === activeNodeId) return 1.5
return 1
})
}
highlightGraphRef.current = applyGraphHighlight
// Touch: tap on background to clear pinned highlight and close accordion
svg.select('.bg-rect').on('click', () => {
if (supportsCoarsePointer) {
setPinnedNodeId(null)
applyGraphHighlight(null)
callbacksRef.current.onNodeHover?.(null)
}
})
nodeSelection.on('mouseenter', function(_event, d) {
if (supportsCoarsePointer) return
applyGraphHighlight(d.id)
if (d.type === 'role') {
callbacksRef.current.onNodeHover?.(d.id)
}
})
nodeSelection.on('mouseleave', function() {
if (supportsCoarsePointer) return
applyGraphHighlight(highlightedNodeId ?? null)
callbacksRef.current.onNodeHover?.(null)
})
nodeSelection.on('click', function(_event, d) {
if (supportsCoarsePointer) {
// Touch: tap-to-pin toggle
if (pinnedNodeId === d.id) {
setPinnedNodeId(null)
applyGraphHighlight(null)
callbacksRef.current.onNodeHover?.(null)
} else {
setPinnedNodeId(d.id)
applyGraphHighlight(d.id)
callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : null)
}
}
// Fire detail callbacks for both desktop and touch
if (d.type === 'role') {
callbacksRef.current.onRoleClick(d.id)
} else {
callbacksRef.current.onSkillClick(d.id)
}
})
const simulation = d3.forceSimulation<SimNode>(nodes)
.alpha(0.65)
.alphaDecay(prefersReducedMotion ? 0.28 : 0.08)
.force('charge', d3.forceManyBody<SimNode>().strength(d =>
d.type === 'role' ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf))
))
.force('link', d3.forceLink<SimNode, SimLink>(links)
.id(d => d.id)
.distance(isMobile ? 56 : Math.round(72 * sf))
.strength(d => (d as SimLink).strength * 0.5))
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25))
.force('y', d3.forceY<SimNode>(d => {
if (d.type === 'role') {
return yScale(d.startYear ?? minYear)
}
return d.homeY
}).strength(d => d.type === 'role' ? 0.98 : 0.18))
.force('collide', d3.forceCollide<SimNode>(d =>
d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
).iterations(3))
simulationRef.current = simulation
// Padding for skill label text below the node (radius + gap + line height)
const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
const rightMargin = isMobile ? 16 : Math.round(32 * sf)
const renderTick = () => {
nodes.forEach(d => {
if (d.type === 'role') {
d.x = Math.max(rw / 2 + 6, Math.min(width - rw / 2 - 6, d.x))
d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y))
} else {
d.x = Math.max(srActive + 6, Math.min(width - srActive - rightMargin, d.x))
d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y))
}
})
linkSelection
.attr('d', d => {
const sx = (d.source as SimNode).x
const sy = (d.source as SimNode).y
const tx = (d.target as SimNode).x
const ty = (d.target as SimNode).y
const cx = (sx + tx) / 2
return `M${sx},${sy} Q${cx},${sy} ${tx},${ty}`
})
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
roleConnectors
.attr('x1', timelineX)
.attr('y1', d => d.y)
.attr('x2', d => d.x - rw / 2)
.attr('y2', d => d.y)
const nextNodePositions: Record<string, { x: number; y: number }> = {}
nodes.forEach(node => {
nextNodePositions[node.id] = {
x: Math.round(node.x),
y: Math.round(node.y),
}
})
setNodeButtonPositions(prev => {
const prevKeys = Object.keys(prev)
const nextKeys = Object.keys(nextNodePositions)
if (prevKeys.length !== nextKeys.length) return nextNodePositions
for (const key of nextKeys) {
const prevPos = prev[key]
const nextPos = nextNodePositions[key]
if (!prevPos || prevPos.x !== nextPos.x || prevPos.y !== nextPos.y) {
return nextNodePositions
}
}
return prev
})
applyGraphHighlight(highlightedNodeId ?? pinnedNodeId)
}
if (prefersReducedMotion) {
simulation.stop()
for (let i = 0; i < 150; i++) {
simulation.tick()
}
renderTick()
} else {
simulation.on('tick', renderTick)
}
return () => {
simulation.stop()
}
}, [dimensions, highlightedNodeId, pinnedNodeId])
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
svg.selectAll('.focus-ring')
.attr('stroke', 'transparent')
if (focusedNodeId) {
svg.selectAll<SVGGElement, SimNode>('g.node')
.filter(d => d.id === focusedNodeId)
.select('.focus-ring')
.attr('stroke', 'var(--accent)')
.attr('stroke-width', 2)
}
}, [focusedNodeId])
useEffect(() => {
if (!highlightGraphRef.current) return
highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId)
}, [highlightedNodeId, pinnedNodeId])
// Reset "show more" when switching between pinned roles
useEffect(() => {
setAccordionShowMore(false)
}, [pinnedNodeId])
// Find consultation for pinned role (accordion on mobile)
const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && n.type === 'role') : null
const pinnedConsultation = pinnedRoleNode ? consultations.find(c => c.id === pinnedRoleNode.id) : null
const showAccordion = supportsCoarsePointer && pinnedConsultation !== null && pinnedConsultation !== undefined
return (
<div
ref={containerRef}
style={{
width: '100%',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
overflow: 'hidden',
position: 'relative',
}}
>
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
role="img"
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
style={{ display: 'block' }}
/>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '12px',
padding: '6px 12px',
fontFamily: 'var(--font-geist-mono)',
fontSize: '10px',
color: 'var(--text-tertiary)',
lineHeight: '24px',
}}
>
{[
{ label: 'Technical', color: 'var(--accent)' },
{ label: 'Clinical', color: 'var(--success)' },
{ label: 'Leadership', color: 'var(--amber)' },
].map((item, i) => (
<React.Fragment key={item.label}>
{i > 0 && (
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
)}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
<span
style={{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: item.color,
flexShrink: 0,
}}
/>
{item.label}
</span>
</React.Fragment>
))}
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
<span style={{ opacity: 0.7 }}>{supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}</span>
</div>
{/* Mobile accordion: role details on tap */}
<AnimatePresence>
{showAccordion && pinnedConsultation && (
<motion.div
key={pinnedConsultation.id}
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
<div
style={{
padding: '12px 16px',
borderTop: `1px solid ${pinnedConsultation.orgColor ?? 'var(--border-light)'}`,
fontFamily: 'var(--font-ui)',
}}
>
<div style={{ marginBottom: '8px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '2px',
}}
>
<span
style={{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: pinnedConsultation.orgColor ?? 'var(--accent)',
flexShrink: 0,
}}
/>
<span
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
{pinnedConsultation.role}
</span>
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-secondary)',
fontFamily: 'var(--font-geist-mono)',
paddingLeft: '14px',
}}
>
{pinnedConsultation.organization} · {pinnedConsultation.duration}
</div>
</div>
<ul
style={{
margin: 0,
paddingLeft: '14px',
listStyle: 'none',
}}
>
{(accordionShowMore ? pinnedConsultation.examination : pinnedConsultation.examination.slice(0, 3)).map((item, i) => (
<li
key={i}
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
lineHeight: '1.5',
marginBottom: '4px',
display: 'flex',
gap: '8px',
}}
>
<span
style={{
display: 'inline-block',
width: '4px',
height: '4px',
borderRadius: '50%',
backgroundColor: pinnedConsultation.orgColor ?? 'var(--accent)',
opacity: 0.5,
flexShrink: 0,
marginTop: '7px',
}}
/>
{item}
</li>
))}
</ul>
{accordionShowMore && pinnedConsultation.plan.length > 0 && (
<ul
style={{
margin: '8px 0 0',
paddingLeft: '14px',
listStyle: 'none',
}}
>
{pinnedConsultation.plan.map((item, i) => (
<li
key={i}
style={{
fontSize: '12px',
color: 'var(--text-tertiary)',
lineHeight: '1.5',
marginBottom: '4px',
display: 'flex',
gap: '8px',
}}
>
<span
style={{
display: 'inline-block',
width: '4px',
height: '4px',
borderRadius: '50%',
backgroundColor: 'var(--text-tertiary)',
opacity: 0.4,
flexShrink: 0,
marginTop: '7px',
}}
/>
{item}
</li>
))}
</ul>
)}
{pinnedConsultation.examination.length > 3 && (
<button
type="button"
onClick={() => setAccordionShowMore(prev => !prev)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 14px',
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
color: pinnedConsultation.orgColor ?? 'var(--accent)',
fontWeight: 500,
marginTop: '4px',
}}
>
{accordionShowMore ? 'Show less' : 'Show more'}
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
<p
style={{
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
border: 0,
}}
>
{srDescription}
</p>
<div
role="group"
aria-label="Career nodes - use Tab to navigate and Enter to open details"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
>
{(() => {
const domainOrder: Record<string, number> = { technical: 0, clinical: 1, leadership: 2 }
const sorted = [...constellationNodes].sort((a, b) => {
if (a.type === 'role' && b.type !== 'role') return -1
if (a.type !== 'role' && b.type === 'role') return 1
if (a.type === 'role' && b.type === 'role') {
return (b.startYear ?? 0) - (a.startYear ?? 0)
}
const da = domainOrder[a.domain ?? 'technical'] ?? 0
const db = domainOrder[b.domain ?? 'technical'] ?? 0
if (da !== db) return da - db
return (a.label ?? '').localeCompare(b.label ?? '')
})
return sorted
})().map(node => {
const yearRange = node.endYear
? `${node.startYear}-${node.endYear}`
: `${node.startYear}-present`
const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
const mobileBtn = window.innerWidth < 640
const btnSf = mobileBtn ? 1 : dimensions.scaleFactor
const buttonWidth = node.type === 'role' ? (mobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf)
const buttonHeight = node.type === 'role' ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf)
return (
<button
key={node.id}
type="button"
aria-label={
node.type === 'role'
? `${node.label} at ${node.organization}, ${yearRange}. Press Enter to view details.`
: `${node.label} skill node. Press Enter to view details.`
}
style={{
position: 'absolute',
width: buttonWidth,
height: buttonHeight,
top: `${position.y}px`,
left: `${position.x}px`,
transform: 'translate(-50%, -50%)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
pointerEvents: 'auto',
padding: 0,
opacity: 0,
}}
onFocus={() => {
setFocusedNodeId(node.id)
highlightGraphRef.current?.(node.id)
if (node.type === 'role') {
onNodeHover?.(node.id)
}
}}
onBlur={() => {
setFocusedNodeId(null)
highlightGraphRef.current?.(pinnedNodeId)
onNodeHover?.(pinnedNodeId)
}}
onClick={() => {
setPinnedNodeId(node.id)
if (node.type === 'role') {
onRoleClick(node.id)
} else {
onSkillClick(node.id)
}
}}
onKeyDown={e => handleNodeKeyDown(e, node.id, node.type)}
/>
)
})}
</div>
</div>
)
}
export default CareerConstellation