feat: US-006 - Bidirectional hover highlighting between graph and timeline

This commit is contained in:
2026-02-16 02:49:43 +00:00
parent 52238c5662
commit 743fb625d5
3 changed files with 48 additions and 12 deletions
+14 -3
View File
@@ -6,6 +6,7 @@ import type { ConstellationNode } from '@/types/pmr'
interface CareerConstellationProps { interface CareerConstellationProps {
onRoleClick: (id: string) => void onRoleClick: (id: string) => void
onSkillClick: (id: string) => void onSkillClick: (id: string) => void
onNodeHover?: (id: string | null) => void
highlightedNodeId?: string | null highlightedNodeId?: string | null
containerHeight?: number | null containerHeight?: number | null
} }
@@ -89,6 +90,7 @@ function buildScreenReaderDescription(): string {
const CareerConstellation: React.FC<CareerConstellationProps> = ({ const CareerConstellation: React.FC<CareerConstellationProps> = ({
onRoleClick, onRoleClick,
onSkillClick, onSkillClick,
onNodeHover,
highlightedNodeId, highlightedNodeId,
containerHeight, containerHeight,
}) => { }) => {
@@ -96,13 +98,13 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null) const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | 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 [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT })
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null) const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null) const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null)
const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({}) const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})
callbacksRef.current = { onRoleClick, onSkillClick } callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => { const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -506,21 +508,30 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
nodeSelection.on('mouseenter', function(_event, d) { nodeSelection.on('mouseenter', function(_event, d) {
if (supportsCoarsePointer) return if (supportsCoarsePointer) return
applyGraphHighlight(d.id) applyGraphHighlight(d.id)
if (d.type === 'role') {
callbacksRef.current.onNodeHover?.(d.id)
}
}) })
nodeSelection.on('mouseleave', function() { nodeSelection.on('mouseleave', function() {
if (supportsCoarsePointer) return if (supportsCoarsePointer) return
applyGraphHighlight(highlightedNodeId ?? pinnedNodeId) applyGraphHighlight(highlightedNodeId ?? pinnedNodeId)
callbacksRef.current.onNodeHover?.(pinnedNodeId)
}) })
nodeSelection.on('click', function(_event, d) { nodeSelection.on('click', function(_event, d) {
if (supportsCoarsePointer && pinnedNodeId !== d.id) { if (supportsCoarsePointer && pinnedNodeId !== d.id) {
setPinnedNodeId(d.id) setPinnedNodeId(d.id)
applyGraphHighlight(d.id) applyGraphHighlight(d.id)
if (d.type === 'role') {
callbacksRef.current.onNodeHover?.(d.id)
}
return 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') { if (d.type === 'role') {
callbacksRef.current.onRoleClick(d.id) callbacksRef.current.onRoleClick(d.id)
+26 -4
View File
@@ -55,9 +55,14 @@ const contentVariants = {
}, },
} }
function LastConsultationSubsection() { interface LastConsultationSubsectionProps {
highlightedRoleId?: string | null
}
function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubsectionProps) {
const { openPanel } = useDetailPanel() const { openPanel } = useDetailPanel()
const consultation = consultations[0] const consultation = consultations[0]
const isHighlighted = highlightedRoleId === consultation.id
const handleOpenPanel = () => { const handleOpenPanel = () => {
openPanel({ type: 'consultation', consultation }) openPanel({ type: 'consultation', consultation })
@@ -104,7 +109,18 @@ function LastConsultationSubsection() {
} }
return ( return (
<div style={{ marginTop: '24px' }}> <div
style={{
marginTop: '24px',
borderRadius: 'var(--radius-sm)',
border: '1px solid',
borderColor: isHighlighted ? 'var(--accent-border)' : 'transparent',
background: isHighlighted ? 'rgba(10,128,128,0.03)' : 'transparent',
transition: 'border-color 150ms ease-out, background-color 150ms ease-out',
padding: '8px',
margin: '-8px',
}}
>
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" /> <CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
<div <div
@@ -236,6 +252,7 @@ function LastConsultationSubsection() {
export function DashboardLayout() { export function DashboardLayout() {
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false) const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null) const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null)
const [highlightedRoleId, setHighlightedRoleId] = useState<string | null>(null)
const [chronologyHeight, setChronologyHeight] = useState<number | null>(null) const [chronologyHeight, setChronologyHeight] = useState<number | null>(null)
const chronologyRef = useRef<HTMLDivElement>(null) const chronologyRef = useRef<HTMLDivElement>(null)
const activeSection = useActiveSection() const activeSection = useActiveSection()
@@ -293,6 +310,10 @@ export function DashboardLayout() {
setHighlightedNodeId(id) setHighlightedNodeId(id)
}, []) }, [])
const handleNodeHover = useCallback((id: string | null) => {
setHighlightedRoleId(id)
}, [])
// Global Ctrl+K listener to open command palette // Global Ctrl+K listener to open command palette
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
@@ -428,12 +449,12 @@ export function DashboardLayout() {
<div className="chronology-item"> <div className="chronology-item">
<span className="chronology-badge">Role</span> <span className="chronology-badge">Role</span>
<LastConsultationSubsection /> <LastConsultationSubsection highlightedRoleId={highlightedRoleId} />
</div> </div>
<div className="chronology-item"> <div className="chronology-item">
<span className="chronology-badge">Role</span> <span className="chronology-badge">Role</span>
<WorkExperienceSubsection onNodeHighlight={handleNodeHighlight} /> <WorkExperienceSubsection onNodeHighlight={handleNodeHighlight} highlightedRoleId={highlightedRoleId} />
</div> </div>
<div className="chronology-item" data-tile-id="section-education"> <div className="chronology-item" data-tile-id="section-education">
@@ -445,6 +466,7 @@ export function DashboardLayout() {
<CareerConstellation <CareerConstellation
onRoleClick={handleRoleClick} onRoleClick={handleRoleClick}
onSkillClick={handleSkillClick} onSkillClick={handleSkillClick}
onNodeHover={handleNodeHover}
highlightedNodeId={highlightedNodeId} highlightedNodeId={highlightedNodeId}
containerHeight={chronologyHeight} containerHeight={chronologyHeight}
/> />
+8 -5
View File
@@ -10,12 +10,13 @@ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)
interface RoleItemProps { interface RoleItemProps {
consultation: typeof consultations[0] consultation: typeof consultations[0]
isExpanded: boolean isExpanded: boolean
isHighlightedFromGraph: boolean
onToggle: () => void onToggle: () => void
onViewFull: () => void onViewFull: () => void
onHighlight?: (id: string | null) => 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( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -33,10 +34,10 @@ function RoleItem({ consultation, isExpanded, onToggle, onViewFull, onHighlight
return ( return (
<div <div
style={{ style={{
background: 'var(--bg-dashboard)', background: isHighlightedFromGraph ? 'rgba(10,128,128,0.03)' : 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
border: `1px solid ${isExpanded ? 'var(--accent-border)' : 'var(--border-light)'}`, border: `1px solid ${isExpanded || isHighlightedFromGraph ? 'var(--accent-border)' : 'var(--border-light)'}`,
transition: 'border-color 0.15s, box-shadow 0.15s', transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.15s',
overflow: 'hidden', overflow: 'hidden',
}} }}
onMouseEnter={() => onHighlight?.(consultation.id)} onMouseEnter={() => onHighlight?.(consultation.id)}
@@ -259,9 +260,10 @@ function RoleItem({ consultation, isExpanded, onToggle, onViewFull, onHighlight
interface WorkExperienceSubsectionProps { interface WorkExperienceSubsectionProps {
onNodeHighlight?: (id: string | null) => void onNodeHighlight?: (id: string | null) => void
highlightedRoleId?: string | null
} }
export function WorkExperienceSubsection({ onNodeHighlight }: WorkExperienceSubsectionProps) { export function WorkExperienceSubsection({ onNodeHighlight, highlightedRoleId }: WorkExperienceSubsectionProps) {
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const { openPanel } = useDetailPanel() const { openPanel } = useDetailPanel()
@@ -285,6 +287,7 @@ export function WorkExperienceSubsection({ onNodeHighlight }: WorkExperienceSubs
key={c.id} key={c.id}
consultation={c} consultation={c}
isExpanded={expandedId === c.id} isExpanded={expandedId === c.id}
isHighlightedFromGraph={highlightedRoleId === c.id}
onToggle={() => handleToggle(c.id)} onToggle={() => handleToggle(c.id)}
onViewFull={() => handleViewFull(c)} onViewFull={() => handleViewFull(c)}
onHighlight={onNodeHighlight} onHighlight={onNodeHighlight}