US-025: Add accessibility to CareerConstellation
This commit is contained in:
@@ -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,14 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
|
|
||||||
simulationRef.current = simulation
|
simulationRef.current = simulation
|
||||||
|
|
||||||
// Update positions on each tick
|
if (prefersReducedMotion) {
|
||||||
simulation.on('tick', () => {
|
// Run simulation to completion synchronously — no animation
|
||||||
// Constrain nodes within bounds
|
simulation.stop()
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
simulation.tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constrain and render final positions
|
||||||
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))
|
||||||
@@ -228,14 +270,47 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
.attr('y2', d => (d.target as SimNode).y)
|
.attr('y2', d => (d.target as SimNode).y)
|
||||||
|
|
||||||
nodeSelection.attr('transform', d => `translate(${d.x},${d.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 () => {
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user