From 743fb625d548a4683b021f33c8e2a95424357c16 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Mon, 16 Feb 2026 02:49:43 +0000 Subject: [PATCH] feat: US-006 - Bidirectional hover highlighting between graph and timeline --- src/components/CareerConstellation.tsx | 17 +++++++++--- src/components/DashboardLayout.tsx | 30 ++++++++++++++++++--- src/components/WorkExperienceSubsection.tsx | 13 +++++---- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 4c4f3e1..6092998 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -6,6 +6,7 @@ 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 } @@ -89,6 +90,7 @@ function buildScreenReaderDescription(): string { const CareerConstellation: React.FC = ({ onRoleClick, onSkillClick, + onNodeHover, highlightedNodeId, containerHeight, }) => { @@ -96,13 +98,13 @@ const CareerConstellation: React.FC = ({ const containerRef = useRef(null) const simulationRef = useRef | null>(null) const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null) - const callbacksRef = useRef({ onRoleClick, onSkillClick }) + const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover }) const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT }) const [focusedNodeId, setFocusedNodeId] = useState(null) const [pinnedNodeId, setPinnedNodeId] = useState(null) const [nodeButtonPositions, setNodeButtonPositions] = useState>({}) - callbacksRef.current = { onRoleClick, onSkillClick } + callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover } const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => { if (e.key === 'Enter' || e.key === ' ') { @@ -506,21 +508,30 @@ const CareerConstellation: React.FC = ({ 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(highlightedNodeId ?? pinnedNodeId) + callbacksRef.current.onNodeHover?.(pinnedNodeId) }) nodeSelection.on('click', function(_event, d) { if (supportsCoarsePointer && pinnedNodeId !== d.id) { setPinnedNodeId(d.id) applyGraphHighlight(d.id) + if (d.type === 'role') { + callbacksRef.current.onNodeHover?.(d.id) + } return } - setPinnedNodeId(prev => prev === d.id ? null : d.id) + const newPinned = pinnedNodeId === d.id ? null : d.id + setPinnedNodeId(newPinned) + callbacksRef.current.onNodeHover?.(d.type === 'role' ? newPinned : null) if (d.type === 'role') { callbacksRef.current.onRoleClick(d.id) diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index 8c20788..ca82ddf 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -55,9 +55,14 @@ const contentVariants = { }, } -function LastConsultationSubsection() { +interface LastConsultationSubsectionProps { + highlightedRoleId?: string | null +} + +function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubsectionProps) { const { openPanel } = useDetailPanel() const consultation = consultations[0] + const isHighlighted = highlightedRoleId === consultation.id const handleOpenPanel = () => { openPanel({ type: 'consultation', consultation }) @@ -104,7 +109,18 @@ function LastConsultationSubsection() { } return ( -
+
(null) + const [highlightedRoleId, setHighlightedRoleId] = useState(null) const [chronologyHeight, setChronologyHeight] = useState(null) const chronologyRef = useRef(null) const activeSection = useActiveSection() @@ -293,6 +310,10 @@ export function DashboardLayout() { setHighlightedNodeId(id) }, []) + const handleNodeHover = useCallback((id: string | null) => { + setHighlightedRoleId(id) + }, []) + // Global Ctrl+K listener to open command palette useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -428,12 +449,12 @@ export function DashboardLayout() {
Role - +
Role - +
@@ -445,6 +466,7 @@ export function DashboardLayout() { diff --git a/src/components/WorkExperienceSubsection.tsx b/src/components/WorkExperienceSubsection.tsx index b348827..5a55574 100644 --- a/src/components/WorkExperienceSubsection.tsx +++ b/src/components/WorkExperienceSubsection.tsx @@ -10,12 +10,13 @@ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce) interface RoleItemProps { consultation: typeof consultations[0] isExpanded: boolean + isHighlightedFromGraph: boolean onToggle: () => void onViewFull: () => void onHighlight?: (id: string | null) => void } -function RoleItem({ consultation, isExpanded, onToggle, onViewFull, onHighlight }: RoleItemProps) { +function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle, onViewFull, onHighlight }: RoleItemProps) { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { @@ -33,10 +34,10 @@ function RoleItem({ consultation, isExpanded, onToggle, onViewFull, onHighlight return (
onHighlight?.(consultation.id)} @@ -259,9 +260,10 @@ function RoleItem({ consultation, isExpanded, onToggle, onViewFull, onHighlight interface WorkExperienceSubsectionProps { onNodeHighlight?: (id: string | null) => void + highlightedRoleId?: string | null } -export function WorkExperienceSubsection({ onNodeHighlight }: WorkExperienceSubsectionProps) { +export function WorkExperienceSubsection({ onNodeHighlight, highlightedRoleId }: WorkExperienceSubsectionProps) { const [expandedId, setExpandedId] = useState(null) const { openPanel } = useDetailPanel() @@ -285,6 +287,7 @@ export function WorkExperienceSubsection({ onNodeHighlight }: WorkExperienceSubs key={c.id} consultation={c} isExpanded={expandedId === c.id} + isHighlightedFromGraph={highlightedRoleId === c.id} onToggle={() => handleToggle(c.id)} onViewFull={() => handleViewFull(c)} onHighlight={onNodeHighlight}