From 65b265733eef47778ed5a6af352169d4a3c6af80 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Mon, 16 Feb 2026 14:06:41 +0000 Subject: [PATCH] refactor: decompose CareerConstellation monolith into focused modules Break 1102-line CareerConstellation.tsx into: - constellation/constants.ts: sizing, opacity, domain color tokens - constellation/types.ts: SimNode, SimLink, LayoutParams interfaces - hooks/useForceSimulation.ts: D3 simulation lifecycle - hooks/useConstellationHighlight.ts: highlight/dim logic - hooks/useConstellationInteraction.ts: mouse/touch/pin handlers - constellation/MobileAccordion.tsx: tap-to-expand role details - constellation/ConstellationLegend.tsx: domain legend - constellation/AccessibleNodeOverlay.tsx: keyboard navigation buttons - constellation/CareerConstellation.tsx: 288-line orchestrator All existing behaviour preserved. Quality gates pass. --- src/components/CareerConstellation.tsx | 1101 ----------------- src/components/DashboardLayout.tsx | 2 +- .../constellation/AccessibleNodeOverlay.tsx | 94 ++ .../constellation/CareerConstellation.tsx | 288 +++++ .../constellation/ConstellationLegend.tsx | 53 + .../constellation/MobileAccordion.tsx | 150 +++ src/components/constellation/constants.ts | 30 + src/components/constellation/types.ts | 41 + src/hooks/useConstellationHighlight.ts | 128 ++ src/hooks/useConstellationInteraction.ts | 85 ++ src/hooks/useForceSimulation.ts | 454 +++++++ 11 files changed, 1324 insertions(+), 1102 deletions(-) delete mode 100644 src/components/CareerConstellation.tsx create mode 100644 src/components/constellation/AccessibleNodeOverlay.tsx create mode 100644 src/components/constellation/CareerConstellation.tsx create mode 100644 src/components/constellation/ConstellationLegend.tsx create mode 100644 src/components/constellation/MobileAccordion.tsx create mode 100644 src/components/constellation/constants.ts create mode 100644 src/components/constellation/types.ts create mode 100644 src/hooks/useConstellationHighlight.ts create mode 100644 src/hooks/useConstellationInteraction.ts create mode 100644 src/hooks/useForceSimulation.ts diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx deleted file mode 100644 index 91e078f..0000000 --- a/src/components/CareerConstellation.tsx +++ /dev/null @@ -1,1101 +0,0 @@ -import React, { useRef, useEffect, useState, useCallback } from 'react' -import { motion, AnimatePresence } from 'framer-motion' -import * as d3 from 'd3' -import { constellationNodes, constellationLinks, roleSkillMappings } from '@/data/constellation' -import { timelineCareerEntities } from '@/data/timeline' -import type { ConstellationNode } from '@/types/pmr' - -interface CareerConstellationProps { - onRoleClick: (id: string) => void - onSkillClick: (id: string) => void - onNodeHover?: (id: string | null) => void - highlightedNodeId?: string | null - containerHeight?: number | null -} - -const MIN_HEIGHT = 400 -const MOBILE_FALLBACK_HEIGHT = 520 - -// Desktop defaults — mobile overrides computed in the D3 effect -const ROLE_WIDTH = 104 -const ROLE_HEIGHT = 32 -const ROLE_RX = 16 -const SKILL_RADIUS_DEFAULT = 7 -const SKILL_RADIUS_ACTIVE = 11 - -const MOBILE_ROLE_WIDTH = 80 -const MOBILE_SKILL_RADIUS_DEFAULT = 6 -const MOBILE_SKILL_RADIUS_ACTIVE = 9 - -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 nodeById = new Map(constellationNodes.map(node => [node.id, node])) -const careerEntityById = new Map(timelineCareerEntities.map(entity => [entity.id, entity])) -const srDescription = buildScreenReaderDescription() - -function getHeight(width: number, containerHeight?: number | null): number { - // Mobile/tablet: use fallback since columns stack vertically - if (width < 1024) return MOBILE_FALLBACK_HEIGHT - // Desktop: use measured container height if available, with minimum - if (containerHeight && containerHeight > 0) return Math.max(MIN_HEIGHT, containerHeight) - return MIN_HEIGHT -} - -interface SimNode extends ConstellationNode { - x: number - y: number - vx: number - vy: number - fx?: number | null - fy?: number | null - homeX: number - homeY: number -} - -interface SimLink { - source: SimNode | string - target: SimNode | string - 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') - - 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 showing ${roleNodes.length} roles and ${skillNodes.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` + - roleDescriptions.join('. ') + '.' -} - -const CareerConstellation: React.FC = ({ - onRoleClick, - onSkillClick, - onNodeHover, - highlightedNodeId, - containerHeight, -}) => { - const svgRef = useRef(null) - const containerRef = useRef(null) - const simulationRef = useRef | null>(null) - const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null) - const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover }) - const highlightedNodeIdRef = useRef(highlightedNodeId ?? null) - const pinnedNodeIdRef = useRef(null) - const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 }) - const [focusedNodeId, setFocusedNodeId] = useState(null) - const [pinnedNodeId, setPinnedNodeId] = useState(null) - const [accordionShowMore, setAccordionShowMore] = useState(false) - const [nodeButtonPositions, setNodeButtonPositions] = useState>({}) - - callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover } - - const resolveGraphFallback = useCallback( - () => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current, - [], - ) - - const resolveRoleFallback = useCallback(() => { - const highlightedId = highlightedNodeIdRef.current - if (highlightedId && nodeById.get(highlightedId)?.type === 'role') { - return highlightedId - } - - const pinnedId = pinnedNodeIdRef.current - if (pinnedId && nodeById.get(pinnedId)?.type === 'role') { - return pinnedId - } - - return null - }, []) - - const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setPinnedNodeId(nodeId) - pinnedNodeIdRef.current = nodeId - highlightGraphRef.current?.(nodeId) - if (nodeType === 'role') { - onNodeHover?.(nodeId) - } else { - onNodeHover?.(resolveRoleFallback()) - } - if (nodeType === 'role') { - onRoleClick(nodeId) - } else { - onSkillClick(nodeId) - } - } - }, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback]) - - useEffect(() => { - const container = containerRef.current - if (!container) return - - const updateDimensions = () => { - const width = container.clientWidth - // Use viewport width for breakpoint check since container may overflow on mobile - const viewportWidth = window.innerWidth - const height = getHeight(viewportWidth, containerHeight) - // Viewport-proportional scaling: 1.0x at 1440px, up to 1.6x at 2560px+ - // Only applies on desktop (>=1024px); mobile/tablet stays at 1.0 - const scaleFactor = viewportWidth >= 1024 - ? Math.max(1, Math.min(1.6, viewportWidth / 1440)) - : 1 - setDimensions({ width, height, scaleFactor }) - } - - updateDimensions() - - const observer = new ResizeObserver(updateDimensions) - observer.observe(container) - - return () => observer.disconnect() - }, [containerHeight]) - - useEffect(() => { - highlightedNodeIdRef.current = highlightedNodeId ?? null - }, [highlightedNodeId]) - - useEffect(() => { - pinnedNodeIdRef.current = pinnedNodeId - }, [pinnedNodeId]) - - useEffect(() => { - const svg = d3.select(svgRef.current) - if (!svgRef.current) return - - const { width, height, scaleFactor } = dimensions - // Use viewport width for responsive breakpoint — container.clientWidth overflows on mobile - const isMobile = window.innerWidth < 640 - const sf = isMobile ? 1 : scaleFactor - - if (simulationRef.current) { - simulationRef.current.stop() - } - - svg.selectAll('*').remove() - - const years = roleNodes.map(n => n.startYear ?? 2016) - const minYear = Math.min(...years) - const maxYear = Math.max(...years) - - // Responsive layout parameters — desktop values scale proportionally with viewport - const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf) - const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf) - const rrx = isMobile ? ROLE_RX : Math.round(ROLE_RX * sf) - const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf) - const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf) - - const topPadding = isMobile ? 36 : Math.round(46 * sf) - const bottomPadding = isMobile ? 40 : Math.round(46 * sf) - const sidePadding = isMobile ? 20 : Math.round(36 * sf) - const timelineX = isMobile - ? Math.max(60, width * 0.16) - : Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18)) - - const yScale = d3.scaleLinear() - .domain([maxYear, minYear]) - .range([topPadding, height - bottomPadding]) - - svg.append('rect') - .attr('class', 'bg-rect') - .attr('width', width) - .attr('height', height) - .attr('fill', 'var(--surface)') - .attr('rx', 6) - - // SVG filter defs for role node shadows - const defs = svg.append('defs') - - const shadowSm = defs.append('filter') - .attr('id', 'shadow-sm-filter') - .attr('x', '-20%').attr('y', '-20%') - .attr('width', '140%').attr('height', '140%') - shadowSm.append('feDropShadow') - .attr('dx', 0).attr('dy', 1) - .attr('stdDeviation', 1.5) - .attr('flood-color', 'rgba(26,43,42,0.08)') - - const shadowMd = defs.append('filter') - .attr('id', 'shadow-md-filter') - .attr('x', '-30%').attr('y', '-30%') - .attr('width', '160%').attr('height', '160%') - shadowMd.append('feDropShadow') - .attr('dx', 0).attr('dy', 2) - .attr('stdDeviation', 3) - .attr('flood-color', 'rgba(26,43,42,0.12)') - - // 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', 'var(--border-light)') - .attr('stroke-opacity', 0.25) - .attr('stroke-width', 1) - .attr('stroke-dasharray', '3 4') - - timelineGroup.append('line') - .attr('x1', timelineX) - .attr('x2', timelineX) - .attr('y1', topPadding - 12) - .attr('y2', height - bottomPadding + 12) - .attr('stroke', 'var(--border)') - .attr('stroke-width', 1) - - timelineGroup.selectAll('line.year-tick') - .data(tickYears) - .join('line') - .attr('class', 'year-tick') - .attr('x1', timelineX) - .attr('x2', d => timelineX + (roleNodes.some(r => r.startYear === d) ? 8 : 6)) - .attr('y1', d => yScale(d)) - .attr('y2', d => yScale(d)) - .attr('stroke', 'var(--border)') - .attr('stroke-width', 1) - .attr('stroke-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.8 : 0.4) - - timelineGroup.selectAll('text.year-label') - .data(tickYears) - .join('text') - .attr('class', 'year-label') - .attr('x', timelineX - (isMobile ? 8 : Math.round(12 * sf))) - .attr('y', d => yScale(d) + Math.round(4 * sf)) - .attr('text-anchor', 'end') - .attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`) - .attr('font-family', 'var(--font-geist-mono)') - .attr('fill', 'var(--text-tertiary)') - .text(d => d) - - // Prepare data with deterministic initial positions - const links: SimLink[] = constellationLinks.map(l => ({ - source: l.source, - target: l.target, - strength: l.strength, - })) - - const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0)) - const roleInitialMap = new Map() - // Consistent horizontal offset for all role nodes — anchored right of timeline - const roleGap = isMobile ? 40 : Math.round(56 * sf) - const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2) - - roleOrder.forEach((role) => { - roleInitialMap.set(role.id, { - x: roleX, - 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 }> - - // Skill centroid: offset right of roles into the available distribution space - const skillGap = isMobile ? 20 : Math.round(28 * sf) - const skillSpaceStart = roleX + rw / 2 + skillGap - const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2 - const centroid = linkedRolePositions.length > 0 - ? { - x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(40 * sf))), - y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length, - } - : { x: skillSpaceMid, 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 = (isMobile ? 25 : Math.round(35 * sf)) + (hash % (isMobile ? 25 : Math.round(35 * sf))) - - 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 connectorGroup = svg.append('g').attr('class', 'connectors') - const nodeGroup = svg.append('g').attr('class', 'nodes') - - const linkSelection = linkGroup.selectAll('path') - .data(links) - .join('path') - .attr('fill', 'none') - .attr('stroke', 'var(--border-light)') - .attr('stroke-width', 1) - .attr('stroke-opacity', 0.15) - .style('transition', prefersReducedMotion - ? 'none' - : 'stroke 150ms ease, stroke-opacity 150ms ease, stroke-width 150ms ease' - ) - - const nodeSelection = nodeGroup.selectAll('g') - .data(nodes) - .join('g') - .attr('class', d => `node node-${d.type}`) - .style('cursor', 'pointer') - .attr('data-node-id', d => d.id) - - nodeSelection.filter(d => d.type === 'role') - .append('rect') - .attr('class', 'focus-ring') - .attr('x', -rw / 2 - 3) - .attr('y', -rh / 2 - 3) - .attr('width', rw + 6) - .attr('height', rh + 6) - .attr('rx', rrx + 2) - .attr('fill', 'none') - .attr('stroke', 'transparent') - .attr('stroke-width', 2) - - nodeSelection.filter(d => d.type === 'role') - .append('rect') - .attr('class', 'node-circle') - .attr('x', -rw / 2) - .attr('y', -rh / 2) - .attr('width', rw) - .attr('height', rh) - .attr('rx', rrx) - .attr('fill', d => d.orgColor ?? 'var(--accent)') - .attr('fill-opacity', 0.12) - .attr('stroke', d => d.orgColor ?? 'var(--accent)') - .attr('stroke-opacity', 0.4) - .attr('stroke-width', 1) - - const mobileLabelMaxLen = 10 - nodeSelection.filter(d => d.type === 'role') - .append('text') - .attr('class', 'node-label') - .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'central') - .attr('fill', d => d.orgColor ?? 'var(--accent)') - .attr('font-size', isMobile ? '10' : `${Math.round(12 * sf)}`) - .attr('font-weight', '600') - .attr('font-family', 'var(--font-ui)') - .attr('pointer-events', 'none') - .text(d => { - const label = d.shortLabel ?? d.label.slice(0, 12) - return isMobile && label.length > mobileLabelMaxLen ? `${label.slice(0, mobileLabelMaxLen - 1)}…` : label - }) - - nodeSelection.filter(d => d.type === 'skill') - .append('circle') - .attr('class', 'focus-ring') - .attr('r', srActive + 3) - .attr('fill', 'none') - .attr('stroke', 'transparent') - .attr('stroke-width', 2) - - nodeSelection.filter(d => d.type === 'skill') - .append('circle') - .attr('class', 'node-circle') - .attr('r', srDefault) - .attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E') - .attr('stroke', 'none') - .attr('fill-opacity', 0.35) - - nodeSelection.filter(d => d.type === 'skill') - .append('text') - .attr('class', 'node-label') - .attr('text-anchor', 'middle') - .attr('dy', srActive + Math.round(14 * sf)) - .attr('fill', 'var(--text-secondary)') - .attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`) - .attr('font-family', 'var(--font-geist-mono)') - .attr('pointer-events', 'none') - .attr('opacity', 0.5) - .text(d => { - const label = d.shortLabel ?? d.label - const maxLen = isMobile ? 12 : width < 500 ? 12 : 16 - return label.length > maxLen ? `${label.slice(0, maxLen - 1)}…` : label - }) - - const roleConnectors = connectorGroup.selectAll('line.role-connector') - .data(nodes.filter(n => n.type === 'role')) - .join('line') - .attr('class', 'role-connector') - .attr('stroke', 'var(--border)') - .attr('stroke-width', 1) - .attr('stroke-opacity', 0.3) - - const connectedMap = new Map>() - constellationLinks.forEach(l => { - if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set()) - if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set()) - connectedMap.get(l.source)!.add(l.target) - connectedMap.get(l.target)!.add(l.source) - }) - const applyGraphHighlight = (activeNodeId: string | null) => { - const dur = prefersReducedMotion ? 0 : 180 - - if (!activeNodeId) { - nodeSelection.style('opacity', '1') - - nodeSelection.filter(d => d.type === 'role') - .attr('filter', null) - .select('.node-circle') - .attr('stroke-opacity', 0.4) - .attr('stroke-width', 1) - - const skillNodes = nodeSelection.filter(d => d.type === 'skill') - if (dur > 0) { - skillNodes.select('.node-circle') - .transition().duration(dur) - .attr('r', srDefault) - .attr('fill-opacity', 0.35) - skillNodes.select('.node-label') - .transition().duration(dur) - .attr('opacity', 0.5) - } else { - skillNodes.select('.node-circle') - .attr('r', srDefault) - .attr('fill-opacity', 0.35) - skillNodes.select('.node-label') - .attr('opacity', 0.5) - } - - linkSelection - .attr('stroke', 'var(--border-light)') - .attr('stroke-width', 1) - .attr('stroke-opacity', 0.15) - - return - } - - const connected = connectedMap.get(activeNodeId) ?? new Set() - const isInGroup = (id: string) => id === activeNodeId || connected.has(id) - - nodeSelection.style('opacity', d => isInGroup(d.id) ? '1' : '0.15') - - nodeSelection.filter(d => d.type === 'role') - .attr('filter', d => { - if (d.id === activeNodeId) return 'url(#shadow-md-filter)' - if (connected.has(d.id)) return 'url(#shadow-sm-filter)' - return null - }) - .select('.node-circle') - .attr('stroke-opacity', d => { - if (d.id === activeNodeId) return 1 - if (connected.has(d.id)) return 0.7 - return 0.4 - }) - .attr('stroke-width', d => d.id === activeNodeId ? 1.5 : 1) - - const skillNodes = nodeSelection.filter(d => d.type === 'skill') - if (dur > 0) { - skillNodes.select('.node-circle') - .transition().duration(dur) - .attr('r', d => isInGroup(d.id) ? srActive : srDefault) - .attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35) - skillNodes.select('.node-label') - .transition().duration(dur) - .attr('opacity', d => isInGroup(d.id) ? 1 : 0.5) - } else { - skillNodes.select('.node-circle') - .attr('r', d => isInGroup(d.id) ? srActive : srDefault) - .attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35) - skillNodes.select('.node-label') - .attr('opacity', d => isInGroup(d.id) ? 1 : 0.5) - } - - 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 === activeNodeId || tgt === activeNodeId) { - const skillId = src === activeNodeId ? tgt : src - const skillNode = nodes.find(n => n.id === skillId) - return domainColorMap[skillNode?.domain ?? 'technical'] ?? '#0D6E6E' - } - return 'var(--border-light)' - }) - .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 === activeNodeId || tgt === activeNodeId) { - return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2)) - } - return 0.15 - }) - .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 === activeNodeId || tgt === activeNodeId) return 1.5 - return 1 - }) - } - - highlightGraphRef.current = applyGraphHighlight - - // Touch: tap on background to clear pinned highlight and close accordion - svg.select('.bg-rect').on('click', () => { - if (supportsCoarsePointer) { - setPinnedNodeId(null) - pinnedNodeIdRef.current = null - applyGraphHighlight(null) - callbacksRef.current.onNodeHover?.(null) - } - }) - - nodeSelection.on('mouseenter', function(_event, d) { - if (supportsCoarsePointer) return - applyGraphHighlight(d.id) - if (d.type === 'role') { - callbacksRef.current.onNodeHover?.(d.id) - } - }) - - nodeSelection.on('mouseleave', function() { - if (supportsCoarsePointer) return - applyGraphHighlight(resolveGraphFallback()) - callbacksRef.current.onNodeHover?.(resolveRoleFallback()) - }) - - nodeSelection.on('click', function(_event, d) { - if (supportsCoarsePointer) { - // Touch: tap-to-pin toggle - if (pinnedNodeIdRef.current === d.id) { - setPinnedNodeId(null) - pinnedNodeIdRef.current = null - applyGraphHighlight(null) - callbacksRef.current.onNodeHover?.(null) - } else { - setPinnedNodeId(d.id) - pinnedNodeIdRef.current = d.id - applyGraphHighlight(d.id) - callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : resolveRoleFallback()) - } - } - - // Fire detail callbacks for both desktop and touch - if (d.type === 'role') { - callbacksRef.current.onRoleClick(d.id) - } else { - callbacksRef.current.onSkillClick(d.id) - } - }) - - const simulation = d3.forceSimulation(nodes) - .alpha(0.65) - .alphaDecay(prefersReducedMotion ? 0.28 : 0.08) - .force('charge', d3.forceManyBody().strength(d => - d.type === 'role' ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf)) - )) - .force('link', d3.forceLink(links) - .id(d => d.id) - .distance(isMobile ? 56 : Math.round(72 * sf)) - .strength(d => (d as SimLink).strength * 0.5)) - .force('x', d3.forceX(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25)) - .force('y', d3.forceY(d => { - if (d.type === 'role') { - return yScale(d.startYear ?? minYear) - } - return d.homeY - }).strength(d => d.type === 'role' ? 0.98 : 0.18)) - .force('collide', d3.forceCollide(d => - d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf)) - ).iterations(3)) - - simulationRef.current = simulation - - // Padding for skill label text below the node (radius + gap + line height) - const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf) - const rightMargin = isMobile ? 16 : Math.round(32 * sf) - - const renderTick = () => { - nodes.forEach(d => { - if (d.type === 'role') { - d.x = Math.max(rw / 2 + 6, Math.min(width - rw / 2 - 6, d.x)) - d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y)) - } else { - d.x = Math.max(srActive + 6, Math.min(width - srActive - rightMargin, d.x)) - d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y)) - } - }) - - linkSelection - .attr('d', d => { - const sx = (d.source as SimNode).x - const sy = (d.source as SimNode).y - const tx = (d.target as SimNode).x - const ty = (d.target as SimNode).y - const cx = (sx + tx) / 2 - return `M${sx},${sy} Q${cx},${sy} ${tx},${ty}` - }) - - nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`) - - roleConnectors - .attr('x1', timelineX) - .attr('y1', d => d.y) - .attr('x2', d => d.x - rw / 2) - .attr('y2', d => 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(resolveGraphFallback()) - } - - if (prefersReducedMotion) { - simulation.stop() - for (let i = 0; i < 150; i++) { - simulation.tick() - } - renderTick() - } else { - simulation.on('tick', renderTick) - } - - return () => { - simulation.stop() - } - }, [dimensions, resolveGraphFallback, resolveRoleFallback]) - - useEffect(() => { - if (!svgRef.current) return - const svg = d3.select(svgRef.current) - - svg.selectAll('.focus-ring') - .attr('stroke', 'transparent') - - if (focusedNodeId) { - svg.selectAll('g.node') - .filter(d => d.id === focusedNodeId) - .select('.focus-ring') - .attr('stroke', 'var(--accent)') - .attr('stroke-width', 2) - } - }, [focusedNodeId]) - - useEffect(() => { - if (!highlightGraphRef.current) return - highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId) - }, [highlightedNodeId, pinnedNodeId]) - - // Reset "show more" when switching between pinned roles - useEffect(() => { - setAccordionShowMore(false) - }, [pinnedNodeId]) - - // Find canonical career entity for pinned role (accordion on mobile) - const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && n.type === 'role') : null - const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) : null - const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null && pinnedCareerEntity !== undefined - - return ( -
- - -
- {[ - { label: 'Technical', color: 'var(--accent)' }, - { label: 'Clinical', color: 'var(--success)' }, - { label: 'Leadership', color: 'var(--amber)' }, - ].map((item, i) => ( - - {i > 0 && ( - - )} - - - {item.label} - - - ))} - - {supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'} -
- - {/* Mobile accordion: role details on tap */} - - {showAccordion && pinnedCareerEntity && ( - -
-
-
- - - {pinnedCareerEntity.title} - -
-
- {pinnedCareerEntity.organization} · {pinnedCareerEntity.dateRange.display} -
-
- -
    - {(accordionShowMore ? pinnedCareerEntity.details : pinnedCareerEntity.details.slice(0, 3)).map((item, i) => ( -
  • - - {item} -
  • - ))} -
- - {accordionShowMore && (pinnedCareerEntity.outcomes ?? []).length > 0 && ( -
    - {(pinnedCareerEntity.outcomes ?? []).map((item, i) => ( -
  • - - {item} -
  • - ))} -
- )} - - {pinnedCareerEntity.details.length > 3 && ( - - )} -
-
- )} -
- -

- {srDescription} -

- -
- {(() => { - const domainOrder: Record = { technical: 0, clinical: 1, leadership: 2 } - const sorted = [...constellationNodes].sort((a, b) => { - if (a.type === 'role' && b.type !== 'role') return -1 - if (a.type !== 'role' && b.type === 'role') return 1 - if (a.type === 'role' && b.type === 'role') { - return (b.startYear ?? 0) - (a.startYear ?? 0) - } - const da = domainOrder[a.domain ?? 'technical'] ?? 0 - const db = domainOrder[b.domain ?? 'technical'] ?? 0 - if (da !== db) return da - db - return (a.label ?? '').localeCompare(b.label ?? '') - }) - return sorted - })().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 mobileBtn = window.innerWidth < 640 - const btnSf = mobileBtn ? 1 : dimensions.scaleFactor - const buttonWidth = node.type === 'role' ? (mobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf) - const buttonHeight = node.type === 'role' ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf) - - return ( -
-
- ) -} - -export default CareerConstellation diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index c3ab208..559e923 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -8,7 +8,7 @@ import { CardHeader } from './Card' import { PatientSummaryTile } from './tiles/PatientSummaryTile' import { ProjectsTile } from './tiles/ProjectsTile' import { ParentSection } from './ParentSection' -import CareerConstellation from './CareerConstellation' +import CareerConstellation from './constellation/CareerConstellation' import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsection' import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection' import { ChatWidget } from './ChatWidget' diff --git a/src/components/constellation/AccessibleNodeOverlay.tsx b/src/components/constellation/AccessibleNodeOverlay.tsx new file mode 100644 index 0000000..e44a68f --- /dev/null +++ b/src/components/constellation/AccessibleNodeOverlay.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import type { ConstellationNode } from '@/types/pmr' +import { ROLE_WIDTH, ROLE_HEIGHT, MOBILE_ROLE_WIDTH } from './constants' + +interface AccessibleNodeOverlayProps { + nodes: ConstellationNode[] + nodeButtonPositions: Record + dimensions: { width: number; height: number; scaleFactor: number } + onFocus: (nodeId: string) => void + onBlur: () => void + onClick: (nodeId: string, nodeType: 'role' | 'skill') => void + onKeyDown: (e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => void +} + +export const AccessibleNodeOverlay: React.FC = ({ + nodes, + nodeButtonPositions, + dimensions, + onFocus, + onBlur, + onClick, + onKeyDown, +}) => { + const domainOrder: Record = { technical: 0, clinical: 1, leadership: 2 } + const sorted = [...nodes].sort((a, b) => { + if (a.type === 'role' && b.type !== 'role') return -1 + if (a.type !== 'role' && b.type === 'role') return 1 + if (a.type === 'role' && b.type === 'role') { + return (b.startYear ?? 0) - (a.startYear ?? 0) + } + const da = domainOrder[a.domain ?? 'technical'] ?? 0 + const db = domainOrder[b.domain ?? 'technical'] ?? 0 + if (da !== db) return da - db + return (a.label ?? '').localeCompare(b.label ?? '') + }) + + const isMobileBtn = typeof window !== 'undefined' && window.innerWidth < 640 + const btnSf = isMobileBtn ? 1 : dimensions.scaleFactor + + return ( +
+ {sorted.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 buttonWidth = node.type === 'role' ? (isMobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf) + const buttonHeight = node.type === 'role' ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf) + + return ( +
+ ) +} diff --git a/src/components/constellation/CareerConstellation.tsx b/src/components/constellation/CareerConstellation.tsx new file mode 100644 index 0000000..92eb4c7 --- /dev/null +++ b/src/components/constellation/CareerConstellation.tsx @@ -0,0 +1,288 @@ +import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react' +import * as d3 from 'd3' +import { constellationNodes, roleSkillMappings } from '@/data/constellation' +import { timelineCareerEntities } from '@/data/timeline' +import { useForceSimulation, getHeight } from '@/hooks/useForceSimulation' +import { useConstellationHighlight } from '@/hooks/useConstellationHighlight' +import { useConstellationInteraction } from '@/hooks/useConstellationInteraction' +import { MobileAccordion } from './MobileAccordion' +import { ConstellationLegend } from './ConstellationLegend' +import { AccessibleNodeOverlay } from './AccessibleNodeOverlay' +import { + MIN_HEIGHT, + SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE, + MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE, + supportsCoarsePointer, +} from './constants' + +interface CareerConstellationProps { + onRoleClick: (id: string) => void + onSkillClick: (id: string) => void + onNodeHover?: (id: string | null) => void + highlightedNodeId?: string | null + containerHeight?: number | null +} + +const nodeById = new Map(constellationNodes.map(node => [node.id, node])) +const careerEntityById = new Map(timelineCareerEntities.map(entity => [entity.id, entity])) +const srDescription = buildScreenReaderDescription() + +function buildScreenReaderDescription(): string { + const roles = constellationNodes.filter(n => n.type === 'role') + const skills = constellationNodes.filter(n => n.type === 'skill') + + const roleDescriptions = roles.map(role => { + const mapping = roleSkillMappings.find(m => m.roleId === role.id) + const skillNames = mapping + ? mapping.skillIds + .map(sid => skills.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 showing ${roles.length} roles and ${skills.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` + + roleDescriptions.join('. ') + '.' +} + +const CareerConstellation: React.FC = ({ + onRoleClick, + onSkillClick, + onNodeHover, + highlightedNodeId, + containerHeight, +}) => { + const svgRef = useRef(null) + const containerRef = useRef(null) + const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover }) + const highlightedNodeIdRef = useRef(highlightedNodeId ?? null) + const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 }) + const [focusedNodeId, setFocusedNodeId] = useState(null) + + callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover } + + useEffect(() => { + highlightedNodeIdRef.current = highlightedNodeId ?? null + }, [highlightedNodeId]) + + // ResizeObserver for container dimensions + useEffect(() => { + const container = containerRef.current + if (!container) return + + const updateDimensions = () => { + const width = container.clientWidth + const viewportWidth = window.innerWidth + const height = getHeight(viewportWidth, containerHeight) + const scaleFactor = viewportWidth >= 1024 + ? Math.max(1, Math.min(1.6, viewportWidth / 1440)) + : 1 + setDimensions({ width, height, scaleFactor }) + } + + updateDimensions() + const observer = new ResizeObserver(updateDimensions) + observer.observe(container) + return () => observer.disconnect() + }, [containerHeight]) + + // Compute layout-dependent skill radii for highlight hook + const isMobile = typeof window !== 'undefined' && window.innerWidth < 640 + const sf = isMobile ? 1 : dimensions.scaleFactor + const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf) + const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf) + + const resolveGraphFallback = useCallback( + () => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current, + [], + ) + + const resolveRoleFallback = useCallback(() => { + const hId = highlightedNodeIdRef.current + if (hId && nodeById.get(hId)?.type === 'role') return hId + const pId = pinnedNodeIdRef.current + if (pId && nodeById.get(pId)?.type === 'role') return pId + return null + }, []) + + // Highlight hook (needs to be created before simulation so we can pass applyHighlight) + const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null) + const nodesRef = useRef([]) + const nodeSelectionRef = useRef | null>(null) + const linkSelectionRef = useRef | null>(null) + const connectedMapRef = useRef>>(new Map()) + + const { applyGraphHighlight } = useConstellationHighlight({ + nodeSelectionRef, + linkSelectionRef, + connectedMap: connectedMapRef.current, + srDefault, + srActive, + nodesRef, + }) + + highlightGraphRef.current = applyGraphHighlight + + // Stable options ref for simulation to avoid re-creating on every render + const simOptionsRef = useRef({ + resolveGraphFallback, + applyHighlight: applyGraphHighlight, + }) + simOptionsRef.current = { resolveGraphFallback, applyHighlight: applyGraphHighlight } + + const stableSimOptions = useMemo(() => ({ + resolveGraphFallback: () => simOptionsRef.current.resolveGraphFallback(), + applyHighlight: (id: string | null) => simOptionsRef.current.applyHighlight(id), + }), []) + + const sim = useForceSimulation(svgRef, dimensions, stableSimOptions) + + // Sync simulation refs to our local refs for highlight/interaction hooks + useEffect(() => { + nodesRef.current = sim.nodesRef.current + nodeSelectionRef.current = sim.nodeSelectionRef.current + linkSelectionRef.current = sim.linkSelectionRef.current + if (sim.connectedMap.size > 0) { + connectedMapRef.current = sim.connectedMap + } + }) + + // Interaction hook + const { pinnedNodeId, setPinnedNodeId, pinnedNodeIdRef } = useConstellationInteraction({ + highlightGraphRef, + nodeSelectionRef, + svgRef, + callbacksRef, + resolveGraphFallback, + resolveRoleFallback, + dimensionsTrigger: dimensions.width + dimensions.height, + }) + + // External highlight sync + useEffect(() => { + if (!highlightGraphRef.current) return + highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId) + }, [highlightedNodeId, pinnedNodeId]) + + // Focus ring management + useEffect(() => { + if (!svgRef.current) return + const svg = d3.select(svgRef.current) + + svg.selectAll('.focus-ring').attr('stroke', 'transparent') + + if (focusedNodeId) { + svg.selectAll('g.node') + .filter(d => d.id === focusedNodeId) + .select('.focus-ring') + .attr('stroke', 'var(--accent)') + .attr('stroke-width', 2) + } + }, [focusedNodeId]) + + const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setPinnedNodeId(nodeId) + pinnedNodeIdRef.current = nodeId + highlightGraphRef.current?.(nodeId) + if (nodeType === 'role') { + onNodeHover?.(nodeId) + } else { + onNodeHover?.(resolveRoleFallback()) + } + if (nodeType === 'role') { + onRoleClick(nodeId) + } else { + onSkillClick(nodeId) + } + } + }, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback, setPinnedNodeId, pinnedNodeIdRef]) + + // Pinned career entity for mobile accordion + const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && n.type === 'role') : null + const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) ?? null : null + const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null + + return ( +
+ + + + + + +

+ {srDescription} +

+ + { + setFocusedNodeId(nodeId) + highlightGraphRef.current?.(nodeId) + const node = nodeById.get(nodeId) + if (node?.type === 'role') onNodeHover?.(nodeId) + }} + onBlur={() => { + setFocusedNodeId(null) + highlightGraphRef.current?.(resolveGraphFallback()) + onNodeHover?.(resolveRoleFallback()) + }} + onClick={(nodeId, nodeType) => { + setPinnedNodeId(nodeId) + pinnedNodeIdRef.current = nodeId + highlightGraphRef.current?.(nodeId) + if (nodeType === 'role') { + onNodeHover?.(nodeId) + onRoleClick(nodeId) + } else { + onNodeHover?.(resolveRoleFallback()) + onSkillClick(nodeId) + } + }} + onKeyDown={handleNodeKeyDown} + /> +
+ ) +} + +export default CareerConstellation diff --git a/src/components/constellation/ConstellationLegend.tsx b/src/components/constellation/ConstellationLegend.tsx new file mode 100644 index 0000000..3b23626 --- /dev/null +++ b/src/components/constellation/ConstellationLegend.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { supportsCoarsePointer } from './constants' + +interface ConstellationLegendProps { + isTouch: boolean +} + +export const ConstellationLegend: React.FC = ({ isTouch }) => { + const items = [ + { label: 'Technical', color: 'var(--accent)' }, + { label: 'Clinical', color: 'var(--success)' }, + { label: 'Leadership', color: 'var(--amber)' }, + ] + + return ( +
+ {items.map((item, i) => ( + + {i > 0 && ( + + )} + + + {item.label} + + + ))} + + {isTouch || supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'} +
+ ) +} diff --git a/src/components/constellation/MobileAccordion.tsx b/src/components/constellation/MobileAccordion.tsx new file mode 100644 index 0000000..d59743c --- /dev/null +++ b/src/components/constellation/MobileAccordion.tsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import type { TimelineEntity } from '@/types/pmr' +import { prefersReducedMotion } from './constants' + +interface MobileAccordionProps { + pinnedCareerEntity: TimelineEntity | null + show: boolean +} + +export const MobileAccordion: React.FC = ({ pinnedCareerEntity, show }) => { + const [accordionShowMore, setAccordionShowMore] = useState(false) + + useEffect(() => { + setAccordionShowMore(false) + }, [pinnedCareerEntity?.id]) + + return ( + + {show && pinnedCareerEntity && ( + +
+
+
+ + + {pinnedCareerEntity.title} + +
+
+ {pinnedCareerEntity.organization} · {pinnedCareerEntity.dateRange.display} +
+
+ +
    + {(accordionShowMore ? pinnedCareerEntity.details : pinnedCareerEntity.details.slice(0, 3)).map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ + {accordionShowMore && (pinnedCareerEntity.outcomes ?? []).length > 0 && ( +
    + {(pinnedCareerEntity.outcomes ?? []).map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ )} + + {pinnedCareerEntity.details.length > 3 && ( + + )} +
+
+ )} +
+ ) +} diff --git a/src/components/constellation/constants.ts b/src/components/constellation/constants.ts new file mode 100644 index 0000000..b5e079f --- /dev/null +++ b/src/components/constellation/constants.ts @@ -0,0 +1,30 @@ +// Sizing +export const MIN_HEIGHT = 400 +export const MOBILE_FALLBACK_HEIGHT = 520 +export const ROLE_WIDTH = 104 +export const ROLE_HEIGHT = 32 +export const ROLE_RX = 16 +export const SKILL_RADIUS_DEFAULT = 7 +export const SKILL_RADIUS_ACTIVE = 11 +export const MOBILE_ROLE_WIDTH = 80 +export const MOBILE_SKILL_RADIUS_DEFAULT = 6 +export const MOBILE_SKILL_RADIUS_ACTIVE = 9 +export const MOBILE_LABEL_MAX_LEN = 10 + +// Animation / opacity +export const HIGHLIGHT_DIM_OPACITY = 0.15 +export const SKILL_REST_OPACITY = 0.35 +export const SKILL_ACTIVE_OPACITY = 0.9 +export const LINK_REST_OPACITY = 0.15 +export const LABEL_REST_OPACITY = 0.5 + +// Domain color map +export const DOMAIN_COLOR_MAP: Record = { + clinical: '#059669', + technical: '#0D6E6E', + leadership: '#D97706', +} + +// Media queries (evaluated once at module level) +export const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches +export const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches diff --git a/src/components/constellation/types.ts b/src/components/constellation/types.ts new file mode 100644 index 0000000..ff2c820 --- /dev/null +++ b/src/components/constellation/types.ts @@ -0,0 +1,41 @@ +import type { ConstellationNode } from '@/types/pmr' + +export interface SimNode extends ConstellationNode { + x: number + y: number + vx: number + vy: number + fx?: number | null + fy?: number | null + homeX: number + homeY: number +} + +export interface SimLink { + source: SimNode | string + target: SimNode | string + strength: number +} + +export interface LayoutParams { + width: number + height: number + scaleFactor: number + isMobile: boolean + rw: number + rh: number + rrx: number + srDefault: number + srActive: number + topPadding: number + bottomPadding: number + sidePadding: number + timelineX: number + sf: number +} + +export interface ConstellationCallbacks { + onRoleClick: (id: string) => void + onSkillClick: (id: string) => void + onNodeHover?: (id: string | null) => void +} diff --git a/src/hooks/useConstellationHighlight.ts b/src/hooks/useConstellationHighlight.ts new file mode 100644 index 0000000..8869df9 --- /dev/null +++ b/src/hooks/useConstellationHighlight.ts @@ -0,0 +1,128 @@ +import { useRef, useCallback } from 'react' +import type * as d3 from 'd3' +import { DOMAIN_COLOR_MAP, prefersReducedMotion } from '@/components/constellation/constants' +import type { SimNode, SimLink } from '@/components/constellation/types' + +export function useConstellationHighlight(deps: { + nodeSelectionRef: React.MutableRefObject | null> + linkSelectionRef: React.MutableRefObject | null> + connectedMap: Map> + srDefault: number + srActive: number + nodesRef: React.MutableRefObject +}) { + const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null) + + const applyGraphHighlight = useCallback((activeNodeId: string | null) => { + const nodeSelection = deps.nodeSelectionRef.current + const linkSelection = deps.linkSelectionRef.current + if (!nodeSelection || !linkSelection) return + + const { srDefault, srActive, connectedMap } = deps + const nodes = deps.nodesRef.current + const dur = prefersReducedMotion ? 0 : 180 + + if (!activeNodeId) { + nodeSelection.style('opacity', '1') + + nodeSelection.filter(d => d.type === 'role') + .attr('filter', null) + .select('.node-circle') + .attr('stroke-opacity', 0.4) + .attr('stroke-width', 1) + + const skillNodes = nodeSelection.filter(d => d.type === 'skill') + if (dur > 0) { + skillNodes.select('.node-circle') + .transition().duration(dur) + .attr('r', srDefault) + .attr('fill-opacity', 0.35) + skillNodes.select('.node-label') + .transition().duration(dur) + .attr('opacity', 0.5) + } else { + skillNodes.select('.node-circle') + .attr('r', srDefault) + .attr('fill-opacity', 0.35) + skillNodes.select('.node-label') + .attr('opacity', 0.5) + } + + linkSelection + .attr('stroke', 'var(--border-light)') + .attr('stroke-width', 1) + .attr('stroke-opacity', 0.15) + + return + } + + const connected = connectedMap.get(activeNodeId) ?? new Set() + const isInGroup = (id: string) => id === activeNodeId || connected.has(id) + + nodeSelection.style('opacity', d => isInGroup(d.id) ? '1' : '0.15') + + nodeSelection.filter(d => d.type === 'role') + .attr('filter', d => { + if (d.id === activeNodeId) return 'url(#shadow-md-filter)' + if (connected.has(d.id)) return 'url(#shadow-sm-filter)' + return null + }) + .select('.node-circle') + .attr('stroke-opacity', d => { + if (d.id === activeNodeId) return 1 + if (connected.has(d.id)) return 0.7 + return 0.4 + }) + .attr('stroke-width', d => d.id === activeNodeId ? 1.5 : 1) + + const skillNodes = nodeSelection.filter(d => d.type === 'skill') + if (dur > 0) { + skillNodes.select('.node-circle') + .transition().duration(dur) + .attr('r', d => isInGroup(d.id) ? srActive : srDefault) + .attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35) + skillNodes.select('.node-label') + .transition().duration(dur) + .attr('opacity', d => isInGroup(d.id) ? 1 : 0.5) + } else { + skillNodes.select('.node-circle') + .attr('r', d => isInGroup(d.id) ? srActive : srDefault) + .attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35) + skillNodes.select('.node-label') + .attr('opacity', d => isInGroup(d.id) ? 1 : 0.5) + } + + 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 === activeNodeId || tgt === activeNodeId) { + const skillId = src === activeNodeId ? tgt : src + const skillNode = nodes.find(n => n.id === skillId) + return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E' + } + return 'var(--border-light)' + }) + .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 === activeNodeId || tgt === activeNodeId) { + return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2)) + } + return 0.15 + }) + .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 === activeNodeId || tgt === activeNodeId) return 1.5 + return 1 + }) + }, [deps]) + + highlightGraphRef.current = applyGraphHighlight + + return { + highlightGraphRef, + applyGraphHighlight, + } +} diff --git a/src/hooks/useConstellationInteraction.ts b/src/hooks/useConstellationInteraction.ts new file mode 100644 index 0000000..4ea4dbc --- /dev/null +++ b/src/hooks/useConstellationInteraction.ts @@ -0,0 +1,85 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import * as d3 from 'd3' +import { supportsCoarsePointer } from '@/components/constellation/constants' +import type { SimNode, ConstellationCallbacks } from '@/components/constellation/types' + +export function useConstellationInteraction(deps: { + highlightGraphRef: React.MutableRefObject<((id: string | null) => void) | null> + nodeSelectionRef: React.MutableRefObject | null> + svgRef: React.RefObject + callbacksRef: React.MutableRefObject + resolveGraphFallback: () => string | null + resolveRoleFallback: () => string | null + dimensionsTrigger: number +}) { + const [pinnedNodeId, setPinnedNodeId] = useState(null) + const pinnedNodeIdRef = useRef(null) + + useEffect(() => { + pinnedNodeIdRef.current = pinnedNodeId + }, [pinnedNodeId]) + + const bindEvents = useCallback(() => { + const nodeSelection = deps.nodeSelectionRef.current + const svgEl = deps.svgRef.current + if (!nodeSelection || !svgEl) return + + const svg = d3.select(svgEl) + + svg.select('.bg-rect').on('click.interaction', () => { + if (supportsCoarsePointer) { + setPinnedNodeId(null) + pinnedNodeIdRef.current = null + deps.highlightGraphRef.current?.(null) + deps.callbacksRef.current.onNodeHover?.(null) + } + }) + + nodeSelection.on('mouseenter.interaction', function(_event: MouseEvent, d: SimNode) { + if (supportsCoarsePointer) return + deps.highlightGraphRef.current?.(d.id) + if (d.type === 'role') { + deps.callbacksRef.current.onNodeHover?.(d.id) + } + }) + + nodeSelection.on('mouseleave.interaction', function() { + if (supportsCoarsePointer) return + deps.highlightGraphRef.current?.(deps.resolveGraphFallback()) + deps.callbacksRef.current.onNodeHover?.(deps.resolveRoleFallback()) + }) + + nodeSelection.on('click.interaction', function(_event: MouseEvent, d: SimNode) { + if (supportsCoarsePointer) { + if (pinnedNodeIdRef.current === d.id) { + setPinnedNodeId(null) + pinnedNodeIdRef.current = null + deps.highlightGraphRef.current?.(null) + deps.callbacksRef.current.onNodeHover?.(null) + } else { + setPinnedNodeId(d.id) + pinnedNodeIdRef.current = d.id + deps.highlightGraphRef.current?.(d.id) + deps.callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : deps.resolveRoleFallback()) + } + } + + if (d.type === 'role') { + deps.callbacksRef.current.onRoleClick(d.id) + } else { + deps.callbacksRef.current.onSkillClick(d.id) + } + }) + }, [deps]) + + // Re-bind events whenever selections change (triggered by simulation re-creation) + useEffect(() => { + bindEvents() + }, [deps.dimensionsTrigger, bindEvents]) + + return { + pinnedNodeId, + setPinnedNodeId, + pinnedNodeIdRef, + } +} diff --git a/src/hooks/useForceSimulation.ts b/src/hooks/useForceSimulation.ts new file mode 100644 index 0000000..39692b9 --- /dev/null +++ b/src/hooks/useForceSimulation.ts @@ -0,0 +1,454 @@ +import { useEffect, useRef, useState } from 'react' +import * as d3 from 'd3' +import { constellationNodes, constellationLinks } from '@/data/constellation' +import { + ROLE_WIDTH, ROLE_HEIGHT, ROLE_RX, + SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE, + MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN, + MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE, + DOMAIN_COLOR_MAP, prefersReducedMotion, +} from '@/components/constellation/constants' +import type { SimNode, SimLink, LayoutParams } from '@/components/constellation/types' + +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 getHeight(width: number, containerHeight?: number | null): number { + if (width < 1024) return 520 + if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight) + return 400 +} + +const roleNodes = constellationNodes.filter(n => n.type === 'role') + +export function useForceSimulation( + svgRef: React.RefObject, + dimensions: { width: number; height: number; scaleFactor: number }, + options: { + resolveGraphFallback: () => string | null + applyHighlight: (activeNodeId: string | null) => void + } +) { + const simulationRef = useRef | null>(null) + const nodesRef = useRef([]) + const nodeSelectionRef = useRef | null>(null) + const linkSelectionRef = useRef | null>(null) + const connectedMapRef = useRef>>(new Map()) + const layoutParamsRef = useRef(null) + const [nodeButtonPositions, setNodeButtonPositions] = useState>({}) + + useEffect(() => { + const svg = d3.select(svgRef.current) + if (!svgRef.current) return + + const { width, height, scaleFactor } = dimensions + const isMobile = window.innerWidth < 640 + const sf = isMobile ? 1 : scaleFactor + + if (simulationRef.current) { + simulationRef.current.stop() + } + + svg.selectAll('*').remove() + + const years = roleNodes.map(n => n.startYear ?? 2016) + const minYear = Math.min(...years) + const maxYear = Math.max(...years) + + const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf) + const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf) + const rrx = isMobile ? ROLE_RX : Math.round(ROLE_RX * sf) + const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf) + const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf) + + const topPadding = isMobile ? 36 : Math.round(46 * sf) + const bottomPadding = isMobile ? 40 : Math.round(46 * sf) + const sidePadding = isMobile ? 20 : Math.round(36 * sf) + const timelineX = isMobile + ? Math.max(60, width * 0.16) + : Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18)) + + const layoutParams: LayoutParams = { + width, height, scaleFactor, isMobile, + rw, rh, rrx, srDefault, srActive, + topPadding, bottomPadding, sidePadding, timelineX, sf, + } + layoutParamsRef.current = layoutParams + + const yScale = d3.scaleLinear() + .domain([maxYear, minYear]) + .range([topPadding, height - bottomPadding]) + + // Background rect + svg.append('rect') + .attr('class', 'bg-rect') + .attr('width', width) + .attr('height', height) + .attr('fill', 'var(--surface)') + .attr('rx', 6) + + // SVG filter defs + const defs = svg.append('defs') + + const shadowSm = defs.append('filter') + .attr('id', 'shadow-sm-filter') + .attr('x', '-20%').attr('y', '-20%') + .attr('width', '140%').attr('height', '140%') + shadowSm.append('feDropShadow') + .attr('dx', 0).attr('dy', 1) + .attr('stdDeviation', 1.5) + .attr('flood-color', 'rgba(26,43,42,0.08)') + + const shadowMd = defs.append('filter') + .attr('id', 'shadow-md-filter') + .attr('x', '-30%').attr('y', '-30%') + .attr('width', '160%').attr('height', '160%') + shadowMd.append('feDropShadow') + .attr('dx', 0).attr('dy', 2) + .attr('stdDeviation', 3) + .attr('flood-color', 'rgba(26,43,42,0.12)') + + // Timeline guides + 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', 'var(--border-light)') + .attr('stroke-opacity', 0.25) + .attr('stroke-width', 1) + .attr('stroke-dasharray', '3 4') + + timelineGroup.append('line') + .attr('x1', timelineX) + .attr('x2', timelineX) + .attr('y1', topPadding - 12) + .attr('y2', height - bottomPadding + 12) + .attr('stroke', 'var(--border)') + .attr('stroke-width', 1) + + timelineGroup.selectAll('line.year-tick') + .data(tickYears) + .join('line') + .attr('class', 'year-tick') + .attr('x1', timelineX) + .attr('x2', d => timelineX + (roleNodes.some(r => r.startYear === d) ? 8 : 6)) + .attr('y1', d => yScale(d)) + .attr('y2', d => yScale(d)) + .attr('stroke', 'var(--border)') + .attr('stroke-width', 1) + .attr('stroke-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.8 : 0.4) + + timelineGroup.selectAll('text.year-label') + .data(tickYears) + .join('text') + .attr('class', 'year-label') + .attr('x', timelineX - (isMobile ? 8 : Math.round(12 * sf))) + .attr('y', d => yScale(d) + Math.round(4 * sf)) + .attr('text-anchor', 'end') + .attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`) + .attr('font-family', 'var(--font-geist-mono)') + .attr('fill', 'var(--text-tertiary)') + .text(d => d) + + // Prepare data + const links: SimLink[] = constellationLinks.map(l => ({ + source: l.source, + target: l.target, + strength: l.strength, + })) + + const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0)) + const roleInitialMap = new Map() + const roleGap = isMobile ? 40 : Math.round(56 * sf) + const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2) + + roleOrder.forEach((role) => { + roleInitialMap.set(role.id, { + x: roleX, + 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 skillGap = isMobile ? 20 : Math.round(28 * sf) + const skillSpaceStart = roleX + rw / 2 + skillGap + const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2 + const centroid = linkedRolePositions.length > 0 + ? { + x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(40 * sf))), + y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length, + } + : { x: skillSpaceMid, 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 = (isMobile ? 25 : Math.round(35 * sf)) + (hash % (isMobile ? 25 : Math.round(35 * sf))) + + 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 } + }) + + nodesRef.current = nodes + + // Build connected map + const connectedMap = new Map>() + constellationLinks.forEach(l => { + if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set()) + if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set()) + connectedMap.get(l.source)!.add(l.target) + connectedMap.get(l.target)!.add(l.source) + }) + connectedMapRef.current = connectedMap + + // Create SVG groups + const linkGroup = svg.append('g').attr('class', 'links') + const connectorGroup = svg.append('g').attr('class', 'connectors') + const nodeGroup = svg.append('g').attr('class', 'nodes') + + const linkSelection = linkGroup.selectAll('path') + .data(links) + .join('path') + .attr('fill', 'none') + .attr('stroke', 'var(--border-light)') + .attr('stroke-width', 1) + .attr('stroke-opacity', 0.15) + .style('transition', prefersReducedMotion + ? 'none' + : 'stroke 150ms ease, stroke-opacity 150ms ease, stroke-width 150ms ease' + ) + + linkSelectionRef.current = linkSelection as unknown as d3.Selection + + const nodeSelection = nodeGroup.selectAll('g') + .data(nodes) + .join('g') + .attr('class', d => `node node-${d.type}`) + .style('cursor', 'pointer') + .attr('data-node-id', d => d.id) + + nodeSelectionRef.current = nodeSelection + + // Role nodes + nodeSelection.filter(d => d.type === 'role') + .append('rect') + .attr('class', 'focus-ring') + .attr('x', -rw / 2 - 3) + .attr('y', -rh / 2 - 3) + .attr('width', rw + 6) + .attr('height', rh + 6) + .attr('rx', rrx + 2) + .attr('fill', 'none') + .attr('stroke', 'transparent') + .attr('stroke-width', 2) + + nodeSelection.filter(d => d.type === 'role') + .append('rect') + .attr('class', 'node-circle') + .attr('x', -rw / 2) + .attr('y', -rh / 2) + .attr('width', rw) + .attr('height', rh) + .attr('rx', rrx) + .attr('fill', d => d.orgColor ?? 'var(--accent)') + .attr('fill-opacity', 0.12) + .attr('stroke', d => d.orgColor ?? 'var(--accent)') + .attr('stroke-opacity', 0.4) + .attr('stroke-width', 1) + + nodeSelection.filter(d => d.type === 'role') + .append('text') + .attr('class', 'node-label') + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .attr('fill', d => d.orgColor ?? 'var(--accent)') + .attr('font-size', isMobile ? '10' : `${Math.round(12 * sf)}`) + .attr('font-weight', '600') + .attr('font-family', 'var(--font-ui)') + .attr('pointer-events', 'none') + .text(d => { + const label = d.shortLabel ?? d.label.slice(0, 12) + return isMobile && label.length > MOBILE_LABEL_MAX_LEN ? `${label.slice(0, MOBILE_LABEL_MAX_LEN - 1)}…` : label + }) + + // Skill nodes + nodeSelection.filter(d => d.type === 'skill') + .append('circle') + .attr('class', 'focus-ring') + .attr('r', srActive + 3) + .attr('fill', 'none') + .attr('stroke', 'transparent') + .attr('stroke-width', 2) + + nodeSelection.filter(d => d.type === 'skill') + .append('circle') + .attr('class', 'node-circle') + .attr('r', srDefault) + .attr('fill', d => DOMAIN_COLOR_MAP[d.domain ?? 'technical'] ?? '#0D6E6E') + .attr('stroke', 'none') + .attr('fill-opacity', 0.35) + + nodeSelection.filter(d => d.type === 'skill') + .append('text') + .attr('class', 'node-label') + .attr('text-anchor', 'middle') + .attr('dy', srActive + Math.round(14 * sf)) + .attr('fill', 'var(--text-secondary)') + .attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`) + .attr('font-family', 'var(--font-geist-mono)') + .attr('pointer-events', 'none') + .attr('opacity', 0.5) + .text(d => { + const label = d.shortLabel ?? d.label + const maxLen = isMobile ? 12 : width < 500 ? 12 : 16 + return label.length > maxLen ? `${label.slice(0, maxLen - 1)}…` : label + }) + + // Role connectors to timeline + const roleConnectors = connectorGroup.selectAll('line.role-connector') + .data(nodes.filter(n => n.type === 'role')) + .join('line') + .attr('class', 'role-connector') + .attr('stroke', 'var(--border)') + .attr('stroke-width', 1) + .attr('stroke-opacity', 0.3) + + // Simulation + const simulation = d3.forceSimulation(nodes) + .alpha(0.65) + .alphaDecay(prefersReducedMotion ? 0.28 : 0.08) + .force('charge', d3.forceManyBody().strength(d => + d.type === 'role' ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf)) + )) + .force('link', d3.forceLink(links) + .id(d => d.id) + .distance(isMobile ? 56 : Math.round(72 * sf)) + .strength(d => (d as SimLink).strength * 0.5)) + .force('x', d3.forceX(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25)) + .force('y', d3.forceY(d => { + if (d.type === 'role') { + return yScale(d.startYear ?? minYear) + } + return d.homeY + }).strength(d => d.type === 'role' ? 0.98 : 0.18)) + .force('collide', d3.forceCollide(d => + d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf)) + ).iterations(3)) + + simulationRef.current = simulation + + const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf) + const rightMargin = isMobile ? 16 : Math.round(32 * sf) + + const renderTick = () => { + nodes.forEach(d => { + if (d.type === 'role') { + d.x = Math.max(rw / 2 + 6, Math.min(width - rw / 2 - 6, d.x)) + d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y)) + } else { + d.x = Math.max(srActive + 6, Math.min(width - srActive - rightMargin, d.x)) + d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y)) + } + }) + + linkSelection + .attr('d', d => { + const sx = (d.source as SimNode).x + const sy = (d.source as SimNode).y + const tx = (d.target as SimNode).x + const ty = (d.target as SimNode).y + const cx = (sx + tx) / 2 + return `M${sx},${sy} Q${cx},${sy} ${tx},${ty}` + }) + + nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`) + + roleConnectors + .attr('x1', timelineX) + .attr('y1', d => d.y) + .attr('x2', d => d.x - rw / 2) + .attr('y2', d => 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 + }) + + options.applyHighlight(options.resolveGraphFallback()) + } + + if (prefersReducedMotion) { + simulation.stop() + for (let i = 0; i < 150; i++) { + simulation.tick() + } + renderTick() + } else { + simulation.on('tick', renderTick) + } + + return () => { + simulation.stop() + } + }, [dimensions, options]) + + return { + simulationRef, + nodesRef, + nodeSelectionRef, + linkSelectionRef, + nodeButtonPositions, + layoutParams: layoutParamsRef.current, + connectedMap: connectedMapRef.current, + } +} + +export { getHeight }