diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 2778df7..e55e4c6 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -1,6 +1,6 @@ -import React, { useRef, useEffect, useState } from 'react' +import React, { useRef, useEffect, useState, useCallback } from 'react' import * as d3 from 'd3' -import { constellationNodes, constellationLinks } from '@/data/constellation' +import { constellationNodes, constellationLinks, roleSkillMappings } from '@/data/constellation' import type { ConstellationNode } from '@/types/pmr' interface CareerConstellationProps { @@ -16,11 +16,12 @@ const ROLE_RADIUS = 24 const SKILL_RADIUS = 10 const COLLIDE_RADIUS = 30 -// Domain color mapping for skill nodes +const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const domainColorMap: Record = { - clinical: '#059669', // var(--success) - technical: '#0D6E6E', // var(--accent) - leadership: '#D97706', // var(--amber) + clinical: '#059669', + technical: '#0D6E6E', + leadership: '#D97706', } function getHeight(width: number): number { @@ -29,7 +30,6 @@ function getHeight(width: number): number { return DESKTOP_HEIGHT } -// D3 simulation node extends ConstellationNode with x/y interface SimNode extends ConstellationNode { x: number y: number @@ -45,6 +45,28 @@ interface SimLink { strength: number } +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 with ${roleNodes.length} roles and ${skillNodes.length} skills. ` + + roleDescriptions.join('. ') + '.' +} + const CareerConstellation: React.FC = ({ onRoleClick, onSkillClick, @@ -53,11 +75,25 @@ const CareerConstellation: React.FC = ({ const containerRef = useRef(null) const simulationRef = useRef | null>(null) const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT }) + const [focusedNodeId, setFocusedNodeId] = useState(null) - // Store callbacks in refs so D3 event handlers can access latest versions const callbacksRef = useRef({ onRoleClick, onSkillClick }) callbacksRef.current = { onRoleClick, onSkillClick } + const roleNodes = constellationNodes.filter(n => n.type === 'role') + const srDescription = buildScreenReaderDescription() + + const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + if (nodeType === 'role') { + onRoleClick(nodeId) + } else { + onSkillClick(nodeId) + } + } + }, [onRoleClick, onSkillClick]) + useEffect(() => { const container = containerRef.current if (!container) return @@ -82,12 +118,10 @@ const CareerConstellation: React.FC = ({ const { width, height } = dimensions - // Stop previous simulation if (simulationRef.current) { simulationRef.current.stop() } - // Clear previous content svg.selectAll('*').remove() // Defs with radial gradient @@ -107,7 +141,7 @@ const CareerConstellation: React.FC = ({ .attr('fill', 'url(#constellation-bg)') .attr('rx', 6) - // Prepare node and link data (deep copy to avoid mutation) + // Prepare data const nodes: SimNode[] = constellationNodes.map(n => ({ ...n, x: 0, @@ -122,23 +156,19 @@ const CareerConstellation: React.FC = ({ strength: l.strength, })) - // Compute chronological x positions for role nodes - const roleNodes = nodes.filter(n => n.type === 'role') - const years = roleNodes.map(n => n.startYear ?? 2016) + const simRoleNodes = nodes.filter(n => n.type === 'role') + const years = simRoleNodes.map(n => n.startYear ?? 2016) const minYear = Math.min(...years) const maxYear = Math.max(...years) const padding = 80 - // Scale: startYear → x position (left-to-right chronologically) const xScale = d3.scaleLinear() .domain([minYear, maxYear]) .range([padding, width - padding]) - // Create container groups for layering: links below, nodes above const linkGroup = svg.append('g').attr('class', 'links') const nodeGroup = svg.append('g').attr('class', 'nodes') - // Draw links const linkSelection = linkGroup.selectAll('line') .data(links) .join('line') @@ -146,14 +176,22 @@ const CareerConstellation: React.FC = ({ .attr('stroke-width', 1) .attr('stroke-opacity', 0.3) - // Draw nodes 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) + + // Role nodes: large circles with focus ring support + nodeSelection.filter(d => d.type === 'role') + .append('circle') + .attr('class', 'focus-ring') + .attr('r', ROLE_RADIUS + 4) + .attr('fill', 'none') + .attr('stroke', 'transparent') + .attr('stroke-width', 2) - // Role nodes: large circles with org color + white text nodeSelection.filter(d => d.type === 'role') .append('circle') .attr('r', ROLE_RADIUS) @@ -172,7 +210,7 @@ const CareerConstellation: React.FC = ({ .attr('pointer-events', 'none') .text(d => d.shortLabel ?? d.label.slice(0, 8)) - // Skill nodes: smaller circles, color-coded by domain + // Skill nodes nodeSelection.filter(d => d.type === 'skill') .append('circle') .attr('r', SKILL_RADIUS) @@ -181,7 +219,6 @@ const CareerConstellation: React.FC = ({ .attr('stroke-width', 1.5) .attr('fill-opacity', 0.85) - // Skill labels (short labels for readability) nodeSelection.filter(d => d.type === 'skill') .append('text') .attr('text-anchor', 'middle') @@ -212,9 +249,14 @@ const CareerConstellation: React.FC = ({ simulationRef.current = simulation - // Update positions on each tick - simulation.on('tick', () => { - // Constrain nodes within bounds + if (prefersReducedMotion) { + // Run simulation to completion synchronously — no animation + simulation.stop() + for (let i = 0; i < 300; i++) { + simulation.tick() + } + + // Constrain and render final positions nodes.forEach(d => { const r = d.type === 'role' ? ROLE_RADIUS : SKILL_RADIUS d.x = Math.max(r, Math.min(width - r, d.x)) @@ -228,14 +270,47 @@ const CareerConstellation: React.FC = ({ .attr('y2', d => (d.target as SimNode).y) nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`) - }) + } else { + simulation.on('tick', () => { + nodes.forEach(d => { + const r = d.type === 'role' ? ROLE_RADIUS : SKILL_RADIUS + d.x = Math.max(r, Math.min(width - r, d.x)) + d.y = Math.max(r, Math.min(height - r, d.y)) + }) + + linkSelection + .attr('x1', d => (d.source as SimNode).x) + .attr('y1', d => (d.source as SimNode).y) + .attr('x2', d => (d.target as SimNode).x) + .attr('y2', d => (d.target as SimNode).y) + + nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`) + }) + } - // Cleanup return () => { simulation.stop() } }, [dimensions]) + // Update focus ring when focusedNodeId changes + useEffect(() => { + if (!svgRef.current) return + const svg = d3.select(svgRef.current) + + // Reset all focus rings + svg.selectAll('.focus-ring') + .attr('stroke', 'transparent') + + // Highlight focused node + if (focusedNodeId) { + svg.selectAll('g.node') + .filter(d => d.id === focusedNodeId) + .select('.focus-ring') + .attr('stroke', '#0D6E6E') + } + }, [focusedNodeId]) + return (
= ({ width: '100%', borderRadius: 'var(--radius-sm)', overflow: 'hidden', + position: 'relative', }} > = ({ aria-label="Career constellation showing roles and skills across career timeline" style={{ display: 'block' }} /> + {/* Screen-reader-only description */} +

+ {srDescription} +

+ {/* Keyboard-navigable role buttons (visually hidden, positioned over SVG) */} +
+ {roleNodes.map(role => { + const yearRange = role.endYear + ? `${role.startYear}–${role.endYear}` + : `${role.startYear}–present` + return ( +
) }