feat: US-012 - Add hover-highlighting between experience/skills and constellation graph

This commit is contained in:
2026-02-14 18:28:44 +00:00
parent b90706a3f6
commit 9e9962f114
6 changed files with 108 additions and 7 deletions
+1 -1
View File
@@ -213,7 +213,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 12,
"passes": false,
"passes": true,
"notes": "CareerConstellation already has hover logic that dims non-connected nodes. The new prop should trigger the same visual effect but from an external source. Use the existing adjacency map and opacity/stroke manipulation."
},
{
+14
View File
@@ -1001,3 +1001,17 @@
- Hover reset values must exactly match the initial link styling — always update both together
- Skill label offset (dy) should increase proportionally with radius: 14+14=28 (was 10+12=22)
---
## 2026-02-14 - US-012
- Added cross-component hover-highlighting between work experience/skills entries and the constellation graph
- DashboardLayout: added `highlightedNodeId` state + `handleNodeHighlight` callback, wired to CareerConstellation, WorkExperienceSubsection, and RepeatMedicationsSubsection
- CareerConstellation: new `highlightedNodeId` prop + `connectedMapRef` to store adjacency map + useEffect that applies same dim/highlight logic as internal hover when external ID changes
- WorkExperienceSubsection: added `onNodeHighlight` prop, triggers on mouseenter/mouseleave of each RoleItem container (passes consultation.id)
- RepeatMedicationsSubsection: added `onNodeHighlight` prop chain through CategorySection → SkillRow, integrated with existing hover handlers (passes skill.id)
- Browser verified: hovering "Interim Head" work entry highlights corresponding role node + connected skills in graph; hovering "Python" skill entry highlights Python node + connected role nodes
- Files changed: src/components/CareerConstellation.tsx, src/components/DashboardLayout.tsx, src/components/WorkExperienceSubsection.tsx, src/components/RepeatMedicationsSubsection.tsx
- **Learnings for future iterations:**
- The `connectedMapRef` pattern stores the D3 adjacency map in a React ref so a separate useEffect (responding to prop changes) can read it without depending on the main D3 initialization useEffect
- External highlight useEffect must reset styles when highlightedNodeId becomes null — use the same baseline values as the hover mouseleave handler
- The onNodeHighlight callbacks integrate cleanly with existing mouseenter/mouseleave handlers in SkillRow (just append the highlight call)
---
+62
View File
@@ -6,6 +6,7 @@ import type { ConstellationNode } from '@/types/pmr'
interface CareerConstellationProps {
onRoleClick: (id: string) => void
onSkillClick: (id: string) => void
highlightedNodeId?: string | null
}
const DESKTOP_HEIGHT = 400
@@ -70,10 +71,12 @@ function buildScreenReaderDescription(): string {
const CareerConstellation: React.FC<CareerConstellationProps> = ({
onRoleClick,
onSkillClick,
highlightedNodeId,
}) => {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
const connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT })
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
@@ -241,6 +244,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
connectedMap.get(l.source)!.add(l.target)
connectedMap.get(l.target)!.add(l.source)
})
connectedMapRef.current = connectedMap
const HOVER_TRANSITION = '150ms'
@@ -397,6 +401,64 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
}
}, [focusedNodeId])
// External highlight from hovering experience/skill entries
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
const nodeSelection = svg.selectAll<SVGGElement, SimNode>('g.node')
const linkSelection = svg.selectAll<SVGLineElement, SimLink>('g.links line')
if (!highlightedNodeId) {
// Reset all
nodeSelection.style('opacity', '1')
nodeSelection.filter(d => d.type === 'skill')
.select('.node-circle')
.attr('r', SKILL_RADIUS)
linkSelection
.attr('stroke', '#B0C4C0')
.attr('stroke-width', 1.5)
.attr('stroke-opacity', 0.45)
return
}
const connected = connectedMapRef.current.get(highlightedNodeId) ?? new Set()
// Dim non-connected nodes
nodeSelection.style('opacity', d => {
if (d.id === highlightedNodeId || connected.has(d.id)) return '1'
return '0.15'
})
// Scale up connected skill nodes
const highlightedNode = constellationNodes.find(n => n.id === highlightedNodeId)
if (highlightedNode?.type === 'role') {
nodeSelection.filter(d => d.type === 'skill' && connected.has(d.id))
.select('.node-circle')
.attr('r', SKILL_RADIUS + 4)
}
// Brighten connected links
linkSelection
.attr('stroke', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === highlightedNodeId || tgt === highlightedNodeId) return '#0D6E6E'
return '#B0C4C0'
})
.attr('stroke-opacity', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === highlightedNodeId || tgt === highlightedNodeId) return 0.7
return 0.1
})
.attr('stroke-width', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === highlightedNodeId || tgt === highlightedNodeId) return 2.5
return 1.5
})
}, [highlightedNodeId])
return (
<div
ref={containerRef}
+8 -2
View File
@@ -234,6 +234,7 @@ function LastConsultationSubsection() {
export function DashboardLayout() {
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null)
const activeSection = useActiveSection()
const { openPanel } = useDetailPanel()
@@ -271,6 +272,10 @@ export function DashboardLayout() {
[openPanel],
)
const handleNodeHighlight = useCallback((id: string | null) => {
setHighlightedNodeId(id)
}, [])
// Global Ctrl+K listener to open command palette
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
@@ -379,6 +384,7 @@ export function DashboardLayout() {
<CareerConstellation
onRoleClick={handleRoleClick}
onSkillClick={handleSkillClick}
highlightedNodeId={highlightedNodeId}
/>
{/* Last Consultation subsection */}
@@ -386,8 +392,8 @@ export function DashboardLayout() {
{/* Two-column experience/skills grid */}
<div className="pathway-columns" style={{ marginTop: '24px' }}>
<WorkExperienceSubsection />
<RepeatMedicationsSubsection />
<WorkExperienceSubsection onNodeHighlight={handleNodeHighlight} />
<RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} />
</div>
{/* Education subsection */}
+13 -2
View File
@@ -30,9 +30,10 @@ const categoryConfig: { id: SkillCategory; label: string }[] = [
interface SkillRowProps {
skill: SkillMedication
onClick: () => void
onHighlight?: (id: string | null) => void
}
function SkillRow({ skill, onClick }: SkillRowProps) {
function SkillRow({ skill, onClick, onHighlight }: SkillRowProps) {
const IconComponent = iconMap[skill.icon]
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -64,10 +65,12 @@ function SkillRow({ skill, onClick }: SkillRowProps) {
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
onHighlight?.(skill.id)
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-light)'
e.currentTarget.style.boxShadow = 'none'
onHighlight?.(null)
}}
>
<div
@@ -135,6 +138,7 @@ interface CategorySectionProps {
onSkillClick: (skill: SkillMedication) => void
onViewAll: (category: SkillCategory) => void
isFirst: boolean
onNodeHighlight?: (id: string | null) => void
}
function CategorySection({
@@ -144,6 +148,7 @@ function CategorySection({
onSkillClick,
onViewAll,
isFirst,
onNodeHighlight,
}: CategorySectionProps) {
const visibleSkills = categorySkills.slice(0, SKILLS_PER_CATEGORY)
const remainingCount = categorySkills.length - SKILLS_PER_CATEGORY
@@ -194,6 +199,7 @@ function CategorySection({
key={skill.id}
skill={skill}
onClick={() => onSkillClick(skill)}
onHighlight={onNodeHighlight}
/>
))}
</div>
@@ -232,7 +238,11 @@ function CategorySection({
)
}
export function RepeatMedicationsSubsection() {
interface RepeatMedicationsSubsectionProps {
onNodeHighlight?: (id: string | null) => void
}
export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicationsSubsectionProps) {
const { openPanel } = useDetailPanel()
const groupedSkills = categoryConfig.map(({ id, label }) => ({
@@ -267,6 +277,7 @@ export function RepeatMedicationsSubsection() {
onSkillClick={handleSkillClick}
onViewAll={handleViewAll}
isFirst={index === 0}
onNodeHighlight={onNodeHighlight}
/>
))}
</div>
+10 -2
View File
@@ -12,9 +12,10 @@ interface RoleItemProps {
isExpanded: boolean
onToggle: () => void
onViewFull: () => void
onHighlight?: (id: string | null) => void
}
function RoleItem({ consultation, isExpanded, onToggle, onViewFull }: RoleItemProps) {
function RoleItem({ consultation, isExpanded, onToggle, onViewFull, onHighlight }: RoleItemProps) {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -38,6 +39,8 @@ function RoleItem({ consultation, isExpanded, onToggle, onViewFull }: RoleItemPr
transition: 'border-color 0.15s, box-shadow 0.15s',
overflow: 'hidden',
}}
onMouseEnter={() => onHighlight?.(consultation.id)}
onMouseLeave={() => onHighlight?.(null)}
>
{/* Clickable header */}
<div
@@ -254,7 +257,11 @@ function RoleItem({ consultation, isExpanded, onToggle, onViewFull }: RoleItemPr
)
}
export function WorkExperienceSubsection() {
interface WorkExperienceSubsectionProps {
onNodeHighlight?: (id: string | null) => void
}
export function WorkExperienceSubsection({ onNodeHighlight }: WorkExperienceSubsectionProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const { openPanel } = useDetailPanel()
@@ -280,6 +287,7 @@ export function WorkExperienceSubsection() {
isExpanded={expandedId === c.id}
onToggle={() => handleToggle(c.id)}
onViewFull={() => handleViewFull(c)}
onHighlight={onNodeHighlight}
/>
))}
</div>