From b41a422cf0a52e2f046b0d20ecb27faf1c12de32 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Mon, 16 Feb 2026 01:35:24 +0000 Subject: [PATCH] Rearranged graph vs timeline --- src/components/CareerConstellation.tsx | 554 +++++++++++++------- src/components/DashboardLayout.tsx | 69 ++- src/components/tiles/PatientSummaryTile.tsx | 112 +++- src/index.css | 101 +++- 4 files changed, 598 insertions(+), 238 deletions(-) diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 604b8ce..a9b3794 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -9,21 +9,24 @@ interface CareerConstellationProps { highlightedNodeId?: string | null } -const DESKTOP_HEIGHT = 400 -const TABLET_HEIGHT = 300 -const MOBILE_HEIGHT = 250 +const DESKTOP_HEIGHT = 480 +const TABLET_HEIGHT = 380 +const MOBILE_HEIGHT = 310 const ROLE_RADIUS = 30 const SKILL_RADIUS = 14 const COLLIDE_RADIUS = 36 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 srDescription = buildScreenReaderDescription() function getHeight(width: number): number { if (width < 768) return MOBILE_HEIGHT @@ -38,6 +41,8 @@ interface SimNode extends ConstellationNode { vy: number fx?: number | null fy?: number | null + homeX: number + homeY: number } interface SimLink { @@ -46,6 +51,15 @@ interface SimLink { 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') @@ -59,12 +73,12 @@ function buildScreenReaderDescription(): string { .join(', ') : '' const yearRange = role.endYear - ? `${role.startYear}–${role.endYear}` - : `${role.startYear}–present` + ? `${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. ` + + return `Career constellation graph with ${roleNodes.length} roles and ${skillNodes.length} skills aligned against a vertical timeline. ` + roleDescriptions.join('. ') + '.' } @@ -76,19 +90,19 @@ const CareerConstellation: React.FC = ({ const svgRef = useRef(null) const containerRef = useRef(null) const simulationRef = useRef | null>(null) - const connectedMapRef = useRef>>(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 [focusedNodeId, setFocusedNodeId] = useState(null) + const [pinnedNodeId, setPinnedNodeId] = useState(null) + const [nodeButtonPositions, setNodeButtonPositions] = useState>({}) - 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() + setPinnedNodeId(nodeId) if (nodeType === 'role') { onRoleClick(nodeId) } else { @@ -127,47 +141,198 @@ const CareerConstellation: React.FC = ({ 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 gradient = defs.append('radialGradient') .attr('id', 'constellation-bg') - .attr('cx', '50%') - .attr('cy', '50%') - .attr('r', '60%') - gradient.append('stop').attr('offset', '0%').attr('stop-color', '#EDF2F1') - gradient.append('stop').attr('offset', '100%').attr('stop-color', '#F7FAF9') + .attr('cx', '45%') + .attr('cy', '40%') + .attr('r', '75%') + gradient.append('stop').attr('offset', '0%').attr('stop-color', '#F2F7F6') + gradient.append('stop').attr('offset', '100%').attr('stop-color', '#FAFCFB') - // Background rect svg.append('rect') .attr('width', width) .attr('height', height) .attr('fill', 'url(#constellation-bg)') .attr('rx', 6) - // Prepare data - const nodes: SimNode[] = constellationNodes.map(n => ({ - ...n, - x: 0, - y: 0, - vx: 0, - vy: 0, - })) + // 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', '#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 => ({ source: l.source, target: l.target, strength: l.strength, })) - 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 = 60 + const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0)) + const roleInitialMap = new Map() - const xScale = d3.scaleLinear() - .domain([minYear, maxYear]) - .range([padding, width - padding]) + roleOrder.forEach((role, index) => { + const jitter = (index % 2 === 0 ? -1 : 1) * 32 + 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 nodeGroup = svg.append('g').attr('class', 'nodes') @@ -186,7 +351,6 @@ const CareerConstellation: React.FC = ({ .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') @@ -213,9 +377,8 @@ const CareerConstellation: React.FC = ({ .attr('font-weight', '600') .attr('font-family', 'var(--font-ui)') .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') .append('circle') .attr('class', 'node-circle') @@ -223,20 +386,22 @@ const CareerConstellation: React.FC = ({ .attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E') .attr('stroke', '#FFFFFF') .attr('stroke-width', 1.5) - .attr('fill-opacity', 0.85) + .attr('fill-opacity', 0.86) nodeSelection.filter(d => d.type === 'skill') .append('text') .attr('class', 'node-label') .attr('text-anchor', 'middle') .attr('dy', SKILL_RADIUS + 14) - .attr('fill', '#4A6B69') + .attr('fill', '#436964') .attr('font-size', '11') .attr('font-family', 'var(--font-geist-mono)') .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>() constellationLinks.forEach(l => { if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set()) @@ -244,74 +409,102 @@ const CareerConstellation: React.FC = ({ connectedMap.get(l.source)!.add(l.target) connectedMap.get(l.target)!.add(l.source) }) - connectedMapRef.current = connectedMap - - const HOVER_TRANSITION = '150ms' - - // Hover interactions - nodeSelection.on('mouseenter', function(_event, d) { - const connected = connectedMap.get(d.id) ?? new Set() - - // Dim non-connected nodes + const updateSkillLabelVisibility = (activeNodeId: string | null) => { + const shownPositions: Array<{ x: number; y: number }> = [] nodeSelection - .style('transition', `opacity ${HOVER_TRANSITION}`) - .style('opacity', n => { - if (n.id === d.id) return '1' - if (connected.has(n.id)) return '1' - return '0.15' - }) + .filter(n => n.type === 'skill') + .each(function(n) { + const textSel = d3.select(this).select('text.node-label') + const connected = activeNodeId ? connectedMap.get(activeNodeId) : null + const shouldForceShow = Boolean(activeNodeId && (n.id === activeNodeId || connected?.has(n.id))) - // Scale up connected skill nodes when hovering a role - if (d.type === 'role') { - nodeSelection.filter(n => n.type === 'skill' && connected.has(n.id)) + if (shouldForceShow) { + textSel.attr('opacity', 1) + 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') - .transition().duration(150) - .attr('r', SKILL_RADIUS + 4) + .attr('r', SKILL_RADIUS) + 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 - .style('transition', `stroke-opacity ${HOVER_TRANSITION}, stroke ${HOVER_TRANSITION}`) .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 === d.id || tgt === d.id) return '#0D6E6E' + if (src === activeNodeId || tgt === activeNodeId) 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 === d.id || tgt === d.id) return 0.7 + if (src === activeNodeId || tgt === activeNodeId) return 0.76 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 === d.id || tgt === d.id) return 2.5 + if (src === activeNodeId || tgt === activeNodeId) return 2.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() { - // Reset all nodes - nodeSelection - .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) + if (supportsCoarsePointer) return + applyGraphHighlight(highlightedNodeId ?? pinnedNodeId) }) - // Click interactions 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') { callbacksRef.current.onRoleClick(d.id) } else { @@ -319,38 +512,32 @@ const CareerConstellation: React.FC = ({ } }) - // Force simulation const simulation = d3.forceSimulation(nodes) - .force('charge', d3.forceManyBody().strength(-120)) + .alpha(0.65) + .alphaDecay(prefersReducedMotion ? 0.26 : 0.06) + .force('charge', d3.forceManyBody().strength(-85)) .force('link', d3.forceLink(links) .id(d => d.id) - .distance(65) - .strength(d => (d as SimLink).strength * 0.6)) - .force('x', d3.forceX(d => { - if (d.type === 'role' && d.startYear != null) { - return xScale(d.startYear) + .distance(56) + .strength(d => (d as SimLink).strength * 0.7)) + .force('x', d3.forceX(d => d.homeX).strength(d => d.type === 'role' ? 1 : 0.2)) + .force('y', d3.forceY(d => { + if (d.type === 'role') { + return yScale(d.startYear ?? minYear) } - return width / 2 - }).strength(d => d.type === 'role' ? 0.8 : 0.08)) - .force('y', d3.forceY(height / 2).strength(0.4)) + return d.homeY + }).strength(d => d.type === 'role' ? 1 : 0.2)) .force('collide', d3.forceCollide(d => - d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 6 + d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 8 )) simulationRef.current = simulation - 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 + const renderTick = () => { 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)) + d.x = Math.max(r + 6, Math.min(width - r - 6, d.x)) + d.y = Math.max(r + 6, Math.min(height - r - 6, d.y)) }) linkSelection @@ -360,39 +547,56 @@ 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})`) + 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(highlightedNodeId ?? pinnedNodeId) + } + + if (prefersReducedMotion) { + simulation.stop() + for (let i = 0; i < 220; i++) { + simulation.tick() + } + renderTick() + } else { + simulation.on('tick', renderTick) } return () => { simulation.stop() } - }, [dimensions]) + }, [dimensions, highlightedNodeId, pinnedNodeId]) - // 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) @@ -401,63 +605,10 @@ const CareerConstellation: React.FC = ({ } }, [focusedNodeId]) - // External highlight from hovering experience/skill entries useEffect(() => { - if (!svgRef.current) return - const svg = d3.select(svgRef.current) - const nodeSelection = svg.selectAll('g.node') - const linkSelection = svg.selectAll('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]) + if (!highlightGraphRef.current) return + highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId) + }, [highlightedNodeId, pinnedNodeId]) return (
= ({ 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) */} +
= ({ pointerEvents: 'none', }} > - {roleNodes.map(role => { - const yearRange = role.endYear - ? `${role.startYear}–${role.endYear}` - : `${role.startYear}–present` + {constellationNodes.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 buttonSize = node.type === 'role' ? 54 : 34 + return ( +
+ {showCoachmark && ( +
+ Open any metric to see evidence +
+ )} + +
) } 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 = { fontSize: '15px', lineHeight: '1.65', @@ -111,10 +165,20 @@ export function PatientSummaryTile() { {/* Latest Results subsection */}
- + +

+ Select a metric to inspect methodology, impact, and outcomes. +

- {kpis.map((kpi) => ( - + {kpis.map((kpi, index) => ( + ))}
diff --git a/src/index.css b/src/index.css index 7937843..5218cf7 100644 --- a/src/index.css +++ b/src/index.css @@ -204,6 +204,26 @@ body { 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 { -ms-overflow-style: none; scrollbar-width: none; @@ -278,6 +298,47 @@ html { 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-grid { display: grid; @@ -308,10 +369,46 @@ html { 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 */ -@media (min-width: 768px) { +@media (min-width: 1024px) { .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; } }