feat: US-006 - Bidirectional hover highlighting between graph and timeline
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user