US-025: Add accessibility to CareerConstellation

This commit is contained in:
2026-02-14 02:49:14 +00:00
parent 6956ad001b
commit 24e0f8963f
+160 -24
View File
@@ -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 * as d3 from 'd3'
import { constellationNodes, constellationLinks } from '@/data/constellation' import { constellationNodes, constellationLinks, roleSkillMappings } from '@/data/constellation'
import type { ConstellationNode } from '@/types/pmr' import type { ConstellationNode } from '@/types/pmr'
interface CareerConstellationProps { interface CareerConstellationProps {
@@ -16,11 +16,12 @@ const ROLE_RADIUS = 24
const SKILL_RADIUS = 10 const SKILL_RADIUS = 10
const COLLIDE_RADIUS = 30 const COLLIDE_RADIUS = 30
// Domain color mapping for skill nodes const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const domainColorMap: Record<string, string> = { const domainColorMap: Record<string, string> = {
clinical: '#059669', // var(--success) clinical: '#059669',
technical: '#0D6E6E', // var(--accent) technical: '#0D6E6E',
leadership: '#D97706', // var(--amber) leadership: '#D97706',
} }
function getHeight(width: number): number { function getHeight(width: number): number {
@@ -29,7 +30,6 @@ function getHeight(width: number): number {
return DESKTOP_HEIGHT return DESKTOP_HEIGHT
} }
// D3 simulation node extends ConstellationNode with x/y
interface SimNode extends ConstellationNode { interface SimNode extends ConstellationNode {
x: number x: number
y: number y: number
@@ -45,6 +45,28 @@ interface SimLink {
strength: number 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<CareerConstellationProps> = ({ const CareerConstellation: React.FC<CareerConstellationProps> = ({
onRoleClick, onRoleClick,
onSkillClick, onSkillClick,
@@ -53,11 +75,25 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null) const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT }) const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT })
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
// Store callbacks in refs so D3 event handlers can access latest versions
const callbacksRef = useRef({ onRoleClick, onSkillClick }) const callbacksRef = useRef({ onRoleClick, onSkillClick })
callbacksRef.current = { 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(() => { useEffect(() => {
const container = containerRef.current const container = containerRef.current
if (!container) return if (!container) return
@@ -82,12 +118,10 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const { width, height } = dimensions const { width, height } = dimensions
// Stop previous simulation
if (simulationRef.current) { if (simulationRef.current) {
simulationRef.current.stop() simulationRef.current.stop()
} }
// Clear previous content
svg.selectAll('*').remove() svg.selectAll('*').remove()
// Defs with radial gradient // Defs with radial gradient
@@ -107,7 +141,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('fill', 'url(#constellation-bg)') .attr('fill', 'url(#constellation-bg)')
.attr('rx', 6) .attr('rx', 6)
// Prepare node and link data (deep copy to avoid mutation) // Prepare data
const nodes: SimNode[] = constellationNodes.map(n => ({ const nodes: SimNode[] = constellationNodes.map(n => ({
...n, ...n,
x: 0, x: 0,
@@ -122,23 +156,19 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
strength: l.strength, strength: l.strength,
})) }))
// Compute chronological x positions for role nodes const simRoleNodes = nodes.filter(n => n.type === 'role')
const roleNodes = nodes.filter(n => n.type === 'role') const years = simRoleNodes.map(n => n.startYear ?? 2016)
const years = roleNodes.map(n => n.startYear ?? 2016)
const minYear = Math.min(...years) const minYear = Math.min(...years)
const maxYear = Math.max(...years) const maxYear = Math.max(...years)
const padding = 80 const padding = 80
// Scale: startYear → x position (left-to-right chronologically)
const xScale = d3.scaleLinear() const xScale = d3.scaleLinear()
.domain([minYear, maxYear]) .domain([minYear, maxYear])
.range([padding, width - padding]) .range([padding, width - padding])
// Create container groups for layering: links below, nodes above
const linkGroup = svg.append('g').attr('class', 'links') const linkGroup = svg.append('g').attr('class', 'links')
const nodeGroup = svg.append('g').attr('class', 'nodes') const nodeGroup = svg.append('g').attr('class', 'nodes')
// Draw links
const linkSelection = linkGroup.selectAll('line') const linkSelection = linkGroup.selectAll('line')
.data(links) .data(links)
.join('line') .join('line')
@@ -146,14 +176,22 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('stroke-width', 1) .attr('stroke-width', 1)
.attr('stroke-opacity', 0.3) .attr('stroke-opacity', 0.3)
// Draw nodes
const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g') const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
.data(nodes) .data(nodes)
.join('g') .join('g')
.attr('class', d => `node node-${d.type}`) .attr('class', d => `node node-${d.type}`)
.style('cursor', 'pointer') .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') nodeSelection.filter(d => d.type === 'role')
.append('circle') .append('circle')
.attr('r', ROLE_RADIUS) .attr('r', ROLE_RADIUS)
@@ -172,7 +210,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('pointer-events', 'none') .attr('pointer-events', 'none')
.text(d => d.shortLabel ?? d.label.slice(0, 8)) .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') nodeSelection.filter(d => d.type === 'skill')
.append('circle') .append('circle')
.attr('r', SKILL_RADIUS) .attr('r', SKILL_RADIUS)
@@ -181,7 +219,6 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('stroke-width', 1.5) .attr('stroke-width', 1.5)
.attr('fill-opacity', 0.85) .attr('fill-opacity', 0.85)
// Skill labels (short labels for readability)
nodeSelection.filter(d => d.type === 'skill') nodeSelection.filter(d => d.type === 'skill')
.append('text') .append('text')
.attr('text-anchor', 'middle') .attr('text-anchor', 'middle')
@@ -212,9 +249,29 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
simulationRef.current = simulation simulationRef.current = simulation
// Update positions on each tick 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))
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})`)
} else {
simulation.on('tick', () => { simulation.on('tick', () => {
// Constrain nodes within bounds
nodes.forEach(d => { nodes.forEach(d => {
const r = d.type === 'role' ? ROLE_RADIUS : SKILL_RADIUS const r = d.type === 'role' ? ROLE_RADIUS : SKILL_RADIUS
d.x = Math.max(r, Math.min(width - r, d.x)) d.x = Math.max(r, Math.min(width - r, d.x))
@@ -229,13 +286,31 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`) nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
}) })
}
// Cleanup
return () => { return () => {
simulation.stop() simulation.stop()
} }
}, [dimensions]) }, [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<SVGGElement, SimNode>('g.node')
.filter(d => d.id === focusedNodeId)
.select('.focus-ring')
.attr('stroke', '#0D6E6E')
}
}, [focusedNodeId])
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@@ -243,6 +318,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
width: '100%', width: '100%',
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
overflow: 'hidden', overflow: 'hidden',
position: 'relative',
}} }}
> >
<svg <svg
@@ -254,6 +330,66 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
aria-label="Career constellation showing roles and skills across career timeline" aria-label="Career constellation showing roles and skills across career timeline"
style={{ display: 'block' }} style={{ display: 'block' }}
/> />
{/* Screen-reader-only description */}
<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>
{/* Keyboard-navigable role buttons (visually hidden, positioned over SVG) */}
<div
role="group"
aria-label="Career roles — use Tab to navigate, Enter to view details"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
>
{roleNodes.map(role => {
const yearRange = role.endYear
? `${role.startYear}${role.endYear}`
: `${role.startYear}present`
return (
<button
key={role.id}
type="button"
aria-label={`${role.label} at ${role.organization}, ${yearRange}. Press Enter to view details.`}
style={{
position: 'absolute',
width: 48,
height: 48,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
pointerEvents: 'auto',
padding: 0,
opacity: 0,
}}
onFocus={() => setFocusedNodeId(role.id)}
onBlur={() => setFocusedNodeId(null)}
onClick={() => onRoleClick(role.id)}
onKeyDown={e => handleNodeKeyDown(e, role.id, 'role')}
/>
)
})}
</div>
</div> </div>
) )
} }