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 { timelineCareerEntities } from '@/data/timeline' 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 = { clinical: '#059669', technical: '#0D6E6E', leadership: '#D97706', } const roleNodes = constellationNodes.filter(n => n.type === 'role') const nodeById = new Map(constellationNodes.map(node => [node.id, node])) const careerEntityById = new Map(timelineCareerEntities.map(entity => [entity.id, entity])) 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 = ({ onRoleClick, onSkillClick, onNodeHover, highlightedNodeId, containerHeight, }) => { const svgRef = useRef(null) const containerRef = useRef(null) const simulationRef = useRef | null>(null) const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null) const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover }) const highlightedNodeIdRef = useRef(highlightedNodeId ?? null) const pinnedNodeIdRef = useRef(null) const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 }) const [focusedNodeId, setFocusedNodeId] = useState(null) const [pinnedNodeId, setPinnedNodeId] = useState(null) const [accordionShowMore, setAccordionShowMore] = useState(false) const [nodeButtonPositions, setNodeButtonPositions] = useState>({}) callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover } const resolveGraphFallback = useCallback( () => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current, [], ) const resolveRoleFallback = useCallback(() => { const highlightedId = highlightedNodeIdRef.current if (highlightedId && nodeById.get(highlightedId)?.type === 'role') { return highlightedId } const pinnedId = pinnedNodeIdRef.current if (pinnedId && nodeById.get(pinnedId)?.type === 'role') { return pinnedId } return null }, []) 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) } } }, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback]) 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(() => { highlightedNodeIdRef.current = highlightedNodeId ?? null }, [highlightedNodeId]) useEffect(() => { pinnedNodeIdRef.current = pinnedNodeId }, [pinnedNodeId]) 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() // 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('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>() 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) pinnedNodeIdRef.current = 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(resolveGraphFallback()) callbacksRef.current.onNodeHover?.(resolveRoleFallback()) }) nodeSelection.on('click', function(_event, d) { if (supportsCoarsePointer) { // Touch: tap-to-pin toggle if (pinnedNodeIdRef.current === d.id) { setPinnedNodeId(null) pinnedNodeIdRef.current = null applyGraphHighlight(null) callbacksRef.current.onNodeHover?.(null) } else { setPinnedNodeId(d.id) pinnedNodeIdRef.current = d.id applyGraphHighlight(d.id) callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : resolveRoleFallback()) } } // 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(nodes) .alpha(0.65) .alphaDecay(prefersReducedMotion ? 0.28 : 0.08) .force('charge', d3.forceManyBody().strength(d => d.type === 'role' ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf)) )) .force('link', d3.forceLink(links) .id(d => d.id) .distance(isMobile ? 56 : Math.round(72 * sf)) .strength(d => (d as SimLink).strength * 0.5)) .force('x', d3.forceX(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25)) .force('y', d3.forceY(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(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 = {} 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(resolveGraphFallback()) } if (prefersReducedMotion) { simulation.stop() for (let i = 0; i < 150; i++) { simulation.tick() } renderTick() } else { simulation.on('tick', renderTick) } return () => { simulation.stop() } }, [dimensions, resolveGraphFallback, resolveRoleFallback]) useEffect(() => { if (!svgRef.current) return const svg = d3.select(svgRef.current) svg.selectAll('.focus-ring') .attr('stroke', 'transparent') if (focusedNodeId) { svg.selectAll('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 canonical career entity for pinned role (accordion on mobile) const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && n.type === 'role') : null const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) : null const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null && pinnedCareerEntity !== undefined return (
{[ { label: 'Technical', color: 'var(--accent)' }, { label: 'Clinical', color: 'var(--success)' }, { label: 'Leadership', color: 'var(--amber)' }, ].map((item, i) => ( {i > 0 && ( )} {item.label} ))} {supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}
{/* Mobile accordion: role details on tap */} {showAccordion && pinnedCareerEntity && (
{pinnedCareerEntity.title}
{pinnedCareerEntity.organization} · {pinnedCareerEntity.dateRange.display}
    {(accordionShowMore ? pinnedCareerEntity.details : pinnedCareerEntity.details.slice(0, 3)).map((item, i) => (
  • {item}
  • ))}
{accordionShowMore && (pinnedCareerEntity.outcomes ?? []).length > 0 && (
    {(pinnedCareerEntity.outcomes ?? []).map((item, i) => (
  • {item}
  • ))}
)} {pinnedCareerEntity.details.length > 3 && ( )}
)}

{srDescription}

{(() => { const domainOrder: Record = { 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 (
) } export default CareerConstellation