Compare commits

...

2 Commits

Author SHA1 Message Date
admin c9c69d2417 Merge branch 'master' of http://192.168.8.143:3000/admin/portfolio
Hope this works...
2026-02-16 01:36:23 +00:00
admin b41a422cf0 Rearranged graph vs timeline 2026-02-16 01:35:24 +00:00
4 changed files with 598 additions and 238 deletions
+360 -194
View File
@@ -9,21 +9,24 @@ interface CareerConstellationProps {
highlightedNodeId?: string | null highlightedNodeId?: string | null
} }
const DESKTOP_HEIGHT = 400 const DESKTOP_HEIGHT = 480
const TABLET_HEIGHT = 300 const TABLET_HEIGHT = 380
const MOBILE_HEIGHT = 250 const MOBILE_HEIGHT = 310
const ROLE_RADIUS = 30 const ROLE_RADIUS = 30
const SKILL_RADIUS = 14 const SKILL_RADIUS = 14
const COLLIDE_RADIUS = 36 const COLLIDE_RADIUS = 36
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
const domainColorMap: Record<string, string> = { const domainColorMap: Record<string, string> = {
clinical: '#059669', clinical: '#059669',
technical: '#0D6E6E', technical: '#0D6E6E',
leadership: '#D97706', leadership: '#D97706',
} }
const roleNodes = constellationNodes.filter(n => n.type === 'role')
const srDescription = buildScreenReaderDescription()
function getHeight(width: number): number { function getHeight(width: number): number {
if (width < 768) return MOBILE_HEIGHT if (width < 768) return MOBILE_HEIGHT
@@ -38,6 +41,8 @@ interface SimNode extends ConstellationNode {
vy: number vy: number
fx?: number | null fx?: number | null
fy?: number | null fy?: number | null
homeX: number
homeY: number
} }
interface SimLink { interface SimLink {
@@ -46,6 +51,15 @@ interface SimLink {
strength: number 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 { function buildScreenReaderDescription(): string {
const roleNodes = constellationNodes.filter(n => n.type === 'role') const roleNodes = constellationNodes.filter(n => n.type === 'role')
const skillNodes = constellationNodes.filter(n => n.type === 'skill') const skillNodes = constellationNodes.filter(n => n.type === 'skill')
@@ -59,12 +73,12 @@ function buildScreenReaderDescription(): string {
.join(', ') .join(', ')
: '' : ''
const yearRange = role.endYear const yearRange = role.endYear
? `${role.startYear}${role.endYear}` ? `${role.startYear}-${role.endYear}`
: `${role.startYear}present` : `${role.startYear}-present`
return `${role.label} at ${role.organization} (${yearRange}): ${skillNames}` return `${role.label} at ${role.organization} (${yearRange}): ${skillNames}`
}) })
return `Career constellation graph with ${roleNodes.length} roles and ${skillNodes.length} skills. ` + return `Career constellation graph with ${roleNodes.length} roles and ${skillNodes.length} skills aligned against a vertical timeline. ` +
roleDescriptions.join('. ') + '.' roleDescriptions.join('. ') + '.'
} }
@@ -76,19 +90,19 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
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 connectedMapRef = useRef<Map<string, Set<string>>>(new Map()) const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
const callbacksRef = useRef({ onRoleClick, onSkillClick })
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) const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null)
const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})
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') => { const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
setPinnedNodeId(nodeId)
if (nodeType === 'role') { if (nodeType === 'role') {
onRoleClick(nodeId) onRoleClick(nodeId)
} else { } else {
@@ -127,47 +141,198 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
svg.selectAll('*').remove() svg.selectAll('*').remove()
// Defs with radial gradient const years = roleNodes.map(n => n.startYear ?? 2016)
const minYear = Math.min(...years)
const maxYear = Math.max(...years)
const topPadding = 46
const bottomPadding = 46
const sidePadding = 56
const timelineX = Math.max(100, Math.min(160, width * 0.18))
const yScale = d3.scaleLinear()
.domain([minYear, maxYear])
.range([topPadding, height - bottomPadding])
// Defs with subtle radial gradient
const defs = svg.append('defs') const defs = svg.append('defs')
const gradient = defs.append('radialGradient') const gradient = defs.append('radialGradient')
.attr('id', 'constellation-bg') .attr('id', 'constellation-bg')
.attr('cx', '50%') .attr('cx', '45%')
.attr('cy', '50%') .attr('cy', '40%')
.attr('r', '60%') .attr('r', '75%')
gradient.append('stop').attr('offset', '0%').attr('stop-color', '#EDF2F1') gradient.append('stop').attr('offset', '0%').attr('stop-color', '#F2F7F6')
gradient.append('stop').attr('offset', '100%').attr('stop-color', '#F7FAF9') gradient.append('stop').attr('offset', '100%').attr('stop-color', '#FAFCFB')
// Background rect
svg.append('rect') svg.append('rect')
.attr('width', width) .attr('width', width)
.attr('height', height) .attr('height', height)
.attr('fill', 'url(#constellation-bg)') .attr('fill', 'url(#constellation-bg)')
.attr('rx', 6) .attr('rx', 6)
// Prepare data // Timeline guides and subtle era lanes
const nodes: SimNode[] = constellationNodes.map(n => ({ const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
...n,
x: 0,
y: 0,
vx: 0,
vy: 0,
}))
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', '#D5E3E0')
.attr('stroke-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.9 : 0.38)
.attr('stroke-width', d => roleNodes.some(r => r.startYear === d) ? 1.2 : 1)
timelineGroup.append('line')
.attr('x1', timelineX)
.attr('x2', timelineX)
.attr('y1', topPadding - 12)
.attr('y2', height - bottomPadding + 12)
.attr('stroke', '#A8C4BF')
.attr('stroke-width', 2)
.attr('stroke-opacity', 0.8)
timelineGroup.selectAll('circle.year-dot')
.data(tickYears)
.join('circle')
.attr('class', 'year-dot')
.attr('cx', timelineX)
.attr('cy', d => yScale(d))
.attr('r', d => roleNodes.some(r => r.startYear === d) ? 3.2 : 2)
.attr('fill', '#6A8E88')
.attr('fill-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.8 : 0.35)
timelineGroup.selectAll('text.year-label')
.data(tickYears)
.join('text')
.attr('class', 'year-label')
.attr('x', timelineX - 12)
.attr('y', d => yScale(d) + 4)
.attr('text-anchor', 'end')
.attr('font-size', '10')
.attr('font-family', 'var(--font-geist-mono)')
.attr('fill', '#6F8F8A')
.text(d => d)
// Compact legend
const legendX = width - sidePadding - 190
const legendY = 16
const legendGroup = svg.append('g').attr('class', 'constellation-legend')
.attr('transform', `translate(${Math.max(12, legendX)}, ${legendY})`)
legendGroup.append('rect')
.attr('width', 182)
.attr('height', 64)
.attr('rx', 6)
.attr('fill', 'rgba(255,255,255,0.72)')
.attr('stroke', '#D8E6E3')
legendGroup.append('circle')
.attr('cx', 12)
.attr('cy', 16)
.attr('r', 5)
.attr('fill', '#0D6E6E')
legendGroup.append('text')
.attr('x', 24)
.attr('y', 20)
.attr('font-size', '11')
.attr('fill', '#3A5F5A')
.attr('font-family', 'var(--font-geist-mono)')
.text('Roles (timeline anchored)')
legendGroup.append('circle')
.attr('cx', 12)
.attr('cy', 34)
.attr('r', 4)
.attr('fill', '#D97706')
legendGroup.append('text')
.attr('x', 24)
.attr('y', 38)
.attr('font-size', '11')
.attr('fill', '#3A5F5A')
.attr('font-family', 'var(--font-geist-mono)')
.text('Skills (linked clusters)')
legendGroup.append('text')
.attr('x', 12)
.attr('y', 56)
.attr('font-size', '10')
.attr('fill', '#5E7F7B')
.attr('font-family', 'var(--font-geist-mono)')
.text('Tap/click a node to pin links')
// Prepare data with deterministic initial positions
const links: SimLink[] = constellationLinks.map(l => ({ const links: SimLink[] = constellationLinks.map(l => ({
source: l.source, source: l.source,
target: l.target, target: l.target,
strength: l.strength, strength: l.strength,
})) }))
const simRoleNodes = nodes.filter(n => n.type === 'role') const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0))
const years = simRoleNodes.map(n => n.startYear ?? 2016) const roleInitialMap = new Map<string, { x: number; y: number }>()
const minYear = Math.min(...years)
const maxYear = Math.max(...years)
const padding = 60
const xScale = d3.scaleLinear() roleOrder.forEach((role, index) => {
.domain([minYear, maxYear]) const jitter = (index % 2 === 0 ? -1 : 1) * 32
.range([padding, width - padding]) roleInitialMap.set(role.id, {
x: Math.min(width - sidePadding, Math.max(timelineX + 64, timelineX + 124 + jitter)),
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 }>
const centroid = linkedRolePositions.length > 0
? {
x: linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length,
y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length,
}
: { x: width * 0.55, 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 = 54 + (hash % 46)
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 linkGroup = svg.append('g').attr('class', 'links')
const nodeGroup = svg.append('g').attr('class', 'nodes') const nodeGroup = svg.append('g').attr('class', 'nodes')
@@ -186,7 +351,6 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.style('cursor', 'pointer') .style('cursor', 'pointer')
.attr('data-node-id', d => d.id) .attr('data-node-id', d => d.id)
// Role nodes: large circles with focus ring support
nodeSelection.filter(d => d.type === 'role') nodeSelection.filter(d => d.type === 'role')
.append('circle') .append('circle')
.attr('class', 'focus-ring') .attr('class', 'focus-ring')
@@ -213,9 +377,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('font-weight', '600') .attr('font-weight', '600')
.attr('font-family', 'var(--font-ui)') .attr('font-family', 'var(--font-ui)')
.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, 9))
// Skill nodes
nodeSelection.filter(d => d.type === 'skill') nodeSelection.filter(d => d.type === 'skill')
.append('circle') .append('circle')
.attr('class', 'node-circle') .attr('class', 'node-circle')
@@ -223,20 +386,22 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E') .attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E')
.attr('stroke', '#FFFFFF') .attr('stroke', '#FFFFFF')
.attr('stroke-width', 1.5) .attr('stroke-width', 1.5)
.attr('fill-opacity', 0.85) .attr('fill-opacity', 0.86)
nodeSelection.filter(d => d.type === 'skill') nodeSelection.filter(d => d.type === 'skill')
.append('text') .append('text')
.attr('class', 'node-label') .attr('class', 'node-label')
.attr('text-anchor', 'middle') .attr('text-anchor', 'middle')
.attr('dy', SKILL_RADIUS + 14) .attr('dy', SKILL_RADIUS + 14)
.attr('fill', '#4A6B69') .attr('fill', '#436964')
.attr('font-size', '11') .attr('font-size', '11')
.attr('font-family', 'var(--font-geist-mono)') .attr('font-family', 'var(--font-geist-mono)')
.attr('pointer-events', 'none') .attr('pointer-events', 'none')
.text(d => d.shortLabel ?? d.label) .text(d => {
const label = d.shortLabel ?? d.label
return label.length > 16 ? `${label.slice(0, 15)}` : label
})
// Build adjacency lookup for hover interactions
const connectedMap = new Map<string, Set<string>>() const connectedMap = new Map<string, Set<string>>()
constellationLinks.forEach(l => { constellationLinks.forEach(l => {
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set()) if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
@@ -244,74 +409,102 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
connectedMap.get(l.source)!.add(l.target) connectedMap.get(l.source)!.add(l.target)
connectedMap.get(l.target)!.add(l.source) connectedMap.get(l.target)!.add(l.source)
}) })
connectedMapRef.current = connectedMap const updateSkillLabelVisibility = (activeNodeId: string | null) => {
const shownPositions: Array<{ x: number; y: number }> = []
const HOVER_TRANSITION = '150ms'
// Hover interactions
nodeSelection.on('mouseenter', function(_event, d) {
const connected = connectedMap.get(d.id) ?? new Set()
// Dim non-connected nodes
nodeSelection nodeSelection
.style('transition', `opacity ${HOVER_TRANSITION}`) .filter(n => n.type === 'skill')
.style('opacity', n => { .each(function(n) {
if (n.id === d.id) return '1' const textSel = d3.select(this).select<SVGTextElement>('text.node-label')
if (connected.has(n.id)) return '1' const connected = activeNodeId ? connectedMap.get(activeNodeId) : null
return '0.15' const shouldForceShow = Boolean(activeNodeId && (n.id === activeNodeId || connected?.has(n.id)))
})
// Scale up connected skill nodes when hovering a role if (shouldForceShow) {
if (d.type === 'role') { textSel.attr('opacity', 1)
nodeSelection.filter(n => n.type === 'skill' && connected.has(n.id)) shownPositions.push({ x: n.x, y: n.y + SKILL_RADIUS + 14 })
return
}
const x = n.x
const y = n.y + SKILL_RADIUS + 14
const collides = shownPositions.some(p => Math.abs(p.x - x) < 28 && Math.abs(p.y - y) < 14)
textSel.attr('opacity', collides ? 0 : 1)
if (!collides) {
shownPositions.push({ x, y })
}
})
}
const applyGraphHighlight = (activeNodeId: string | null) => {
if (!activeNodeId) {
nodeSelection.style('opacity', '1')
nodeSelection.filter(d => d.type === 'skill')
.select('.node-circle') .select('.node-circle')
.transition().duration(150) .attr('r', SKILL_RADIUS)
.attr('r', SKILL_RADIUS + 4) linkSelection
.attr('stroke', '#B0C4C0')
.attr('stroke-width', 1.5)
.attr('stroke-opacity', 0.45)
updateSkillLabelVisibility(null)
return
} }
// Brighten connected links, dim others const connected = connectedMap.get(activeNodeId) ?? new Set()
nodeSelection.style('opacity', d => {
if (d.id === activeNodeId || connected.has(d.id)) return '1'
return '0.16'
})
nodeSelection.filter(d => d.type === 'skill')
.select('.node-circle')
.attr('r', d => (d.id === activeNodeId || connected.has(d.id)) ? SKILL_RADIUS + 3 : SKILL_RADIUS)
linkSelection linkSelection
.style('transition', `stroke-opacity ${HOVER_TRANSITION}, stroke ${HOVER_TRANSITION}`)
.attr('stroke', l => { .attr('stroke', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id 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 const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === d.id || tgt === d.id) return '#0D6E6E' if (src === activeNodeId || tgt === activeNodeId) return '#0D6E6E'
return '#B0C4C0' return '#B0C4C0'
}) })
.attr('stroke-opacity', l => { .attr('stroke-opacity', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id 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 const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === d.id || tgt === d.id) return 0.7 if (src === activeNodeId || tgt === activeNodeId) return 0.76
return 0.1 return 0.1
}) })
.attr('stroke-width', l => { .attr('stroke-width', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id 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 const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === d.id || tgt === d.id) return 2.5 if (src === activeNodeId || tgt === activeNodeId) return 2.5
return 1.5 return 1.5
}) })
updateSkillLabelVisibility(activeNodeId)
}
highlightGraphRef.current = applyGraphHighlight
nodeSelection.on('mouseenter', function(_event, d) {
if (supportsCoarsePointer) return
applyGraphHighlight(d.id)
}) })
nodeSelection.on('mouseleave', function() { nodeSelection.on('mouseleave', function() {
// Reset all nodes if (supportsCoarsePointer) return
nodeSelection applyGraphHighlight(highlightedNodeId ?? pinnedNodeId)
.style('opacity', '1')
// Reset skill node sizes
nodeSelection.filter(n => n.type === 'skill')
.select('.node-circle')
.transition().duration(150)
.attr('r', SKILL_RADIUS)
// Reset all links
linkSelection
.attr('stroke', '#B0C4C0')
.attr('stroke-width', 1.5)
.attr('stroke-opacity', 0.45)
}) })
// Click interactions
nodeSelection.on('click', function(_event, d) { nodeSelection.on('click', function(_event, d) {
if (supportsCoarsePointer && pinnedNodeId !== d.id) {
setPinnedNodeId(d.id)
applyGraphHighlight(d.id)
return
}
setPinnedNodeId(prev => prev === d.id ? null : d.id)
if (d.type === 'role') { if (d.type === 'role') {
callbacksRef.current.onRoleClick(d.id) callbacksRef.current.onRoleClick(d.id)
} else { } else {
@@ -319,38 +512,32 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
} }
}) })
// Force simulation
const simulation = d3.forceSimulation<SimNode>(nodes) const simulation = d3.forceSimulation<SimNode>(nodes)
.force('charge', d3.forceManyBody<SimNode>().strength(-120)) .alpha(0.65)
.alphaDecay(prefersReducedMotion ? 0.26 : 0.06)
.force('charge', d3.forceManyBody<SimNode>().strength(-85))
.force('link', d3.forceLink<SimNode, SimLink>(links) .force('link', d3.forceLink<SimNode, SimLink>(links)
.id(d => d.id) .id(d => d.id)
.distance(65) .distance(56)
.strength(d => (d as SimLink).strength * 0.6)) .strength(d => (d as SimLink).strength * 0.7))
.force('x', d3.forceX<SimNode>(d => { .force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => d.type === 'role' ? 1 : 0.2))
if (d.type === 'role' && d.startYear != null) { .force('y', d3.forceY<SimNode>(d => {
return xScale(d.startYear) if (d.type === 'role') {
return yScale(d.startYear ?? minYear)
} }
return width / 2 return d.homeY
}).strength(d => d.type === 'role' ? 0.8 : 0.08)) }).strength(d => d.type === 'role' ? 1 : 0.2))
.force('y', d3.forceY<SimNode>(height / 2).strength(0.4))
.force('collide', d3.forceCollide<SimNode>(d => .force('collide', d3.forceCollide<SimNode>(d =>
d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 6 d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 8
)) ))
simulationRef.current = simulation simulationRef.current = simulation
if (prefersReducedMotion) { const renderTick = () => {
// 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 => { 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 + 6, Math.min(width - r - 6, d.x))
d.y = Math.max(r, Math.min(height - r, d.y)) d.y = Math.max(r + 6, Math.min(height - r - 6, d.y))
}) })
linkSelection linkSelection
@@ -360,39 +547,56 @@ 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 const nextNodePositions: Record<string, { x: number; y: number }> = {}
.attr('x1', d => (d.source as SimNode).x) nodes.forEach(node => {
.attr('y1', d => (d.source as SimNode).y) nextNodePositions[node.id] = {
.attr('x2', d => (d.target as SimNode).x) x: Math.round(node.x),
.attr('y2', d => (d.target as SimNode).y) y: Math.round(node.y),
}
nodeSelection.attr('transform', d => `translate(${d.x},${d.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 < 220; i++) {
simulation.tick()
}
renderTick()
} else {
simulation.on('tick', renderTick)
} }
return () => { return () => {
simulation.stop() simulation.stop()
} }
}, [dimensions]) }, [dimensions, highlightedNodeId, pinnedNodeId])
// Update focus ring when focusedNodeId changes
useEffect(() => { useEffect(() => {
if (!svgRef.current) return if (!svgRef.current) return
const svg = d3.select(svgRef.current) const svg = d3.select(svgRef.current)
// Reset all focus rings
svg.selectAll('.focus-ring') svg.selectAll('.focus-ring')
.attr('stroke', 'transparent') .attr('stroke', 'transparent')
// Highlight focused node
if (focusedNodeId) { if (focusedNodeId) {
svg.selectAll<SVGGElement, SimNode>('g.node') svg.selectAll<SVGGElement, SimNode>('g.node')
.filter(d => d.id === focusedNodeId) .filter(d => d.id === focusedNodeId)
@@ -401,63 +605,10 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
} }
}, [focusedNodeId]) }, [focusedNodeId])
// External highlight from hovering experience/skill entries
useEffect(() => { useEffect(() => {
if (!svgRef.current) return if (!highlightGraphRef.current) return
const svg = d3.select(svgRef.current) highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId)
const nodeSelection = svg.selectAll<SVGGElement, SimNode>('g.node') }, [highlightedNodeId, pinnedNodeId])
const linkSelection = svg.selectAll<SVGLineElement, SimLink>('g.links line')
if (!highlightedNodeId) {
// Reset all
nodeSelection.style('opacity', '1')
nodeSelection.filter(d => d.type === 'skill')
.select('.node-circle')
.attr('r', SKILL_RADIUS)
linkSelection
.attr('stroke', '#B0C4C0')
.attr('stroke-width', 1.5)
.attr('stroke-opacity', 0.45)
return
}
const connected = connectedMapRef.current.get(highlightedNodeId) ?? new Set()
// Dim non-connected nodes
nodeSelection.style('opacity', d => {
if (d.id === highlightedNodeId || connected.has(d.id)) return '1'
return '0.15'
})
// Scale up connected skill nodes
const highlightedNode = constellationNodes.find(n => n.id === highlightedNodeId)
if (highlightedNode?.type === 'role') {
nodeSelection.filter(d => d.type === 'skill' && connected.has(d.id))
.select('.node-circle')
.attr('r', SKILL_RADIUS + 4)
}
// Brighten connected links
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 === highlightedNodeId || tgt === highlightedNodeId) return '#0D6E6E'
return '#B0C4C0'
})
.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 === highlightedNodeId || tgt === highlightedNodeId) return 0.7
return 0.1
})
.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 === highlightedNodeId || tgt === highlightedNodeId) return 2.5
return 1.5
})
}, [highlightedNodeId])
return ( return (
<div <div
@@ -478,7 +629,7 @@ 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 <p
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -494,10 +645,10 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
> >
{srDescription} {srDescription}
</p> </p>
{/* Keyboard-navigable role buttons (visually hidden, positioned over SVG) */}
<div <div
role="group" role="group"
aria-label="Career roles use Tab to navigate, Enter to view details" aria-label="Career nodes - use Tab to navigate and Enter to open details"
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@@ -507,33 +658,48 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
pointerEvents: 'none', pointerEvents: 'none',
}} }}
> >
{roleNodes.map(role => { {constellationNodes.map(node => {
const yearRange = role.endYear const yearRange = node.endYear
? `${role.startYear}${role.endYear}` ? `${node.startYear}-${node.endYear}`
: `${role.startYear}present` : `${node.startYear}-present`
const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
const buttonSize = node.type === 'role' ? 54 : 34
return ( return (
<button <button
key={role.id} key={node.id}
type="button" type="button"
aria-label={`${role.label} at ${role.organization}, ${yearRange}. Press Enter to view details.`} 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={{ style={{
position: 'absolute', position: 'absolute',
width: 48, width: buttonSize,
height: 48, height: buttonSize,
top: '50%', top: `${position.y}px`,
left: '50%', left: `${position.x}px`,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
pointerEvents: 'auto', pointerEvents: 'none',
padding: 0, padding: 0,
opacity: 0, opacity: 0,
}} }}
onFocus={() => setFocusedNodeId(role.id)} onFocus={() => setFocusedNodeId(node.id)}
onBlur={() => setFocusedNodeId(null)} onBlur={() => setFocusedNodeId(null)}
onClick={() => onRoleClick(role.id)} onClick={() => {
onKeyDown={e => handleNodeKeyDown(e, role.id, 'role')} setPinnedNodeId(node.id)
if (node.type === 'role') {
onRoleClick(node.id)
} else {
onSkillClick(node.id)
}
}}
onKeyDown={e => handleNodeKeyDown(e, node.id, node.type)}
/> />
) )
})} })}
+51 -18
View File
@@ -382,28 +382,61 @@ export function DashboardLayout() {
{/* Patient Pathway — parent section with constellation graph + subsections */} {/* Patient Pathway — parent section with constellation graph + subsections */}
<ParentSection title="Patient Pathway" tileId="patient-pathway"> <ParentSection title="Patient Pathway" tileId="patient-pathway">
<CareerConstellation <div className="pathway-columns">
onRoleClick={handleRoleClick} <div className="pathway-graph-sticky">
onSkillClick={handleSkillClick} <CareerConstellation
highlightedNodeId={highlightedNodeId} onRoleClick={handleRoleClick}
/> onSkillClick={handleSkillClick}
highlightedNodeId={highlightedNodeId}
{/* Last Consultation subsection */} />
<LastConsultationSubsection />
{/* Two-column experience/skills grid */}
<div className="pathway-columns" style={{ marginTop: '24px' }}>
<div data-tile-id="section-experience">
<WorkExperienceSubsection onNodeHighlight={handleNodeHighlight} />
</div> </div>
<div data-tile-id="section-skills">
<RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} /> <div className="chronology-stream" data-tile-id="section-experience">
<div
style={{
marginBottom: '14px',
padding: '10px 12px',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
background: 'var(--bg-dashboard)',
}}
>
<div
style={{
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
marginBottom: '4px',
fontFamily: 'var(--font-geist-mono)',
}}
>
Clinical Record Stream
</div>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
Chronological role and education entries. Select items to inspect full records.
</div>
</div>
<div className="chronology-item">
<span className="chronology-badge">Role</span>
<LastConsultationSubsection />
</div>
<div className="chronology-item">
<span className="chronology-badge">Role</span>
<WorkExperienceSubsection onNodeHighlight={handleNodeHighlight} />
</div>
<div className="chronology-item" data-tile-id="section-education">
<span className="chronology-badge">Education</span>
<EducationSubsection />
</div>
</div> </div>
</div> </div>
{/* Education subsection */} <div data-tile-id="section-skills" style={{ marginTop: '22px' }}>
<div data-tile-id="section-education"> <RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} />
<EducationSubsection />
</div> </div>
</ParentSection> </ParentSection>
</div> </div>
+88 -24
View File
@@ -1,4 +1,5 @@
import React from 'react' import React, { useEffect, useState } from 'react'
import { FileText, ChevronRight } from 'lucide-react'
import { CardHeader } from '../Card' import { CardHeader } from '../Card'
import { ParentSection } from '../ParentSection' import { ParentSection } from '../ParentSection'
import { kpis } from '@/data/kpis' import { kpis } from '@/data/kpis'
@@ -11,20 +12,26 @@ const colorMap: Record<KPI['colorVariant'], string> = {
teal: '#0D6E6E', teal: '#0D6E6E',
} }
const KPI_COACHMARK_KEY = 'kpi-evidence-coachmark-dismissed-v1'
interface MetricCardProps { interface MetricCardProps {
kpi: KPI kpi: KPI
showCoachmark?: boolean
onOpen: () => void
} }
function MetricCard({ kpi }: MetricCardProps) { function MetricCard({ kpi, showCoachmark = false, onOpen }: MetricCardProps) {
const { openPanel } = useDetailPanel() const { openPanel } = useDetailPanel()
const handleClick = () => { const handleClick = () => {
onOpen()
openPanel({ type: 'kpi', kpi }) openPanel({ type: 'kpi', kpi })
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
onOpen()
openPanel({ type: 'kpi', kpi }) openPanel({ type: 'kpi', kpi })
} }
} }
@@ -37,7 +44,11 @@ function MetricCard({ kpi }: MetricCardProps) {
border: '1px solid var(--border-light)', border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
cursor: 'pointer', cursor: 'pointer',
transition: 'border-color 150ms ease-out, box-shadow 150ms ease-out', transition: 'border-color 160ms ease-out, box-shadow 160ms ease-out, transform 120ms ease-out',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '2px',
} }
const valueStyles: React.CSSProperties = { const valueStyles: React.CSSProperties = {
@@ -63,28 +74,71 @@ function MetricCard({ kpi }: MetricCardProps) {
} }
return ( return (
<button <div className={showCoachmark ? 'kpi-card-coachmark-target' : undefined} style={{ position: 'relative' }}>
onClick={handleClick} {showCoachmark && (
onKeyDown={handleKeyDown} <div className="kpi-coachmark" role="status" aria-live="polite">
style={buttonStyles} Open any metric to see evidence
aria-label={`${kpi.label}: ${kpi.value}. Click to view details.`} </div>
onMouseEnter={(e) => { )}
e.currentTarget.style.borderColor = 'var(--accent-border)' <button
e.currentTarget.style.boxShadow = 'var(--shadow-md)' onClick={handleClick}
}} onKeyDown={handleKeyDown}
onMouseLeave={(e) => { style={buttonStyles}
e.currentTarget.style.borderColor = 'var(--border-light)' className={`metric-card ${showCoachmark ? 'metric-card-pulse' : ''}`}
e.currentTarget.style.boxShadow = 'none' aria-label={`${kpi.label}: ${kpi.value}. Click to view details.`}
}} >
> <div
<div style={valueStyles}>{kpi.value}</div> style={{
<div style={labelStyles}>{kpi.label}</div> position: 'absolute',
<div style={subStyles}>{kpi.sub}</div> top: '14px',
</button> right: '14px',
color: 'var(--accent)',
opacity: 0.85,
}}
aria-hidden="true"
>
<FileText size={14} />
</div>
<div style={valueStyles}>{kpi.value}</div>
<div style={labelStyles}>{kpi.label}</div>
<div style={subStyles}>{kpi.sub}</div>
<div
style={{
marginTop: '10px',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
fontWeight: 600,
color: 'var(--accent)',
fontFamily: 'var(--font-geist-mono)',
}}
>
Click to view evidence
<ChevronRight size={13} />
</div>
</button>
</div>
) )
} }
export function PatientSummaryTile() { export function PatientSummaryTile() {
const [showCoachmark, setShowCoachmark] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
const hasDismissed = window.localStorage.getItem(KPI_COACHMARK_KEY) === '1'
if (!hasDismissed) {
setShowCoachmark(true)
}
}, [])
const handleMetricOpen = () => {
if (!showCoachmark) return
setShowCoachmark(false)
window.localStorage.setItem(KPI_COACHMARK_KEY, '1')
}
const profileTextStyles: React.CSSProperties = { const profileTextStyles: React.CSSProperties = {
fontSize: '15px', fontSize: '15px',
lineHeight: '1.65', lineHeight: '1.65',
@@ -111,10 +165,20 @@ export function PatientSummaryTile() {
{/* Latest Results subsection */} {/* Latest Results subsection */}
<div style={{ marginTop: '28px' }}> <div style={{ marginTop: '28px' }}>
<CardHeader dotColor="teal" title="LATEST RESULTS" rightText="Updated May 2025" /> <CardHeader dotColor="teal" title="LATEST RESULTS (CLICK TO VIEW FULL REFERENCE RANGE)" rightText="Updated May 2025" />
<p
style={{
margin: '0 0 12px 0',
fontSize: '12px',
color: 'var(--text-secondary)',
fontFamily: 'var(--font-geist-mono)',
}}
>
Select a metric to inspect methodology, impact, and outcomes.
</p>
<div className="grid-cols-1 xs:grid-cols-2" style={kpiGridStyles}> <div className="grid-cols-1 xs:grid-cols-2" style={kpiGridStyles}>
{kpis.map((kpi) => ( {kpis.map((kpi, index) => (
<MetricCard key={kpi.id} kpi={kpi} /> <MetricCard key={kpi.id} kpi={kpi} onOpen={handleMetricOpen} showCoachmark={showCoachmark && index === 0} />
))} ))}
</div> </div>
</div> </div>
+99 -2
View File
@@ -204,6 +204,26 @@ body {
animation: fadeIn 200ms ease-out forwards; animation: fadeIn 200ms ease-out forwards;
} }
@keyframes kpiPulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(10, 128, 128, 0.12);
}
50% {
box-shadow: 0 0 0 8px rgba(10, 128, 128, 0);
}
}
@keyframes coachmarkIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
@@ -278,6 +298,47 @@ html {
display: none; display: none;
} }
.metric-card:hover {
border-color: var(--accent-border) !important;
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.metric-card:focus-visible {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px rgba(10, 128, 128, 0.22);
outline: none;
}
.metric-card:active {
transform: translateY(0) scale(0.992);
}
.metric-card-pulse {
animation: kpiPulse 1.8s ease-out infinite;
}
.kpi-card-coachmark-target {
margin-top: 24px;
}
.kpi-coachmark {
position: absolute;
top: -28px;
left: 0;
z-index: 2;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
font-family: var(--font-geist-mono);
color: var(--accent);
background: rgba(10, 128, 128, 0.1);
border: 1px solid var(--accent-border);
animation: coachmarkIn 180ms ease-out;
}
/* Dashboard card grid responsive — mobile-first */ /* Dashboard card grid responsive — mobile-first */
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
@@ -308,10 +369,46 @@ html {
gap: 16px; gap: 16px;
} }
.chronology-stream {
display: flex;
flex-direction: column;
gap: 14px;
}
.chronology-item {
padding: 10px 12px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-light);
background: var(--surface);
}
.chronology-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-family: var(--font-geist-mono);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-light);
border: 1px solid var(--accent-border);
}
/* Desktop: 2 columns */ /* Desktop: 2 columns */
@media (min-width: 768px) { @media (min-width: 1024px) {
.pathway-columns { .pathway-columns {
grid-template-columns: 1fr 1fr; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
align-items: start;
gap: 22px;
}
.pathway-graph-sticky {
position: sticky;
top: 12px;
} }
} }