feat: US-012 - Add hover-highlighting between experience/skills and constellation graph
This commit is contained in:
+1
-1
@@ -213,7 +213,7 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 12,
|
"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."
|
"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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1001,3 +1001,17 @@
|
|||||||
- Hover reset values must exactly match the initial link styling — always update both together
|
- 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)
|
- 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)
|
||||||
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
highlightedNodeId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const DESKTOP_HEIGHT = 400
|
const DESKTOP_HEIGHT = 400
|
||||||
@@ -70,10 +71,12 @@ function buildScreenReaderDescription(): string {
|
|||||||
const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||||
onRoleClick,
|
onRoleClick,
|
||||||
onSkillClick,
|
onSkillClick,
|
||||||
|
highlightedNodeId,
|
||||||
}) => {
|
}) => {
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
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 connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
|
||||||
const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT })
|
const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT })
|
||||||
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
|
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.source)!.add(l.target)
|
||||||
connectedMap.get(l.target)!.add(l.source)
|
connectedMap.get(l.target)!.add(l.source)
|
||||||
})
|
})
|
||||||
|
connectedMapRef.current = connectedMap
|
||||||
|
|
||||||
const HOVER_TRANSITION = '150ms'
|
const HOVER_TRANSITION = '150ms'
|
||||||
|
|
||||||
@@ -397,6 +401,64 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
}
|
}
|
||||||
}, [focusedNodeId])
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|||||||
@@ -234,6 +234,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 activeSection = useActiveSection()
|
const activeSection = useActiveSection()
|
||||||
const { openPanel } = useDetailPanel()
|
const { openPanel } = useDetailPanel()
|
||||||
|
|
||||||
@@ -271,6 +272,10 @@ export function DashboardLayout() {
|
|||||||
[openPanel],
|
[openPanel],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleNodeHighlight = useCallback((id: string | null) => {
|
||||||
|
setHighlightedNodeId(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) {
|
||||||
@@ -379,6 +384,7 @@ export function DashboardLayout() {
|
|||||||
<CareerConstellation
|
<CareerConstellation
|
||||||
onRoleClick={handleRoleClick}
|
onRoleClick={handleRoleClick}
|
||||||
onSkillClick={handleSkillClick}
|
onSkillClick={handleSkillClick}
|
||||||
|
highlightedNodeId={highlightedNodeId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Last Consultation subsection */}
|
{/* Last Consultation subsection */}
|
||||||
@@ -386,8 +392,8 @@ export function DashboardLayout() {
|
|||||||
|
|
||||||
{/* Two-column experience/skills grid */}
|
{/* Two-column experience/skills grid */}
|
||||||
<div className="pathway-columns" style={{ marginTop: '24px' }}>
|
<div className="pathway-columns" style={{ marginTop: '24px' }}>
|
||||||
<WorkExperienceSubsection />
|
<WorkExperienceSubsection onNodeHighlight={handleNodeHighlight} />
|
||||||
<RepeatMedicationsSubsection />
|
<RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Education subsection */}
|
{/* Education subsection */}
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ const categoryConfig: { id: SkillCategory; label: string }[] = [
|
|||||||
interface SkillRowProps {
|
interface SkillRowProps {
|
||||||
skill: SkillMedication
|
skill: SkillMedication
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
onHighlight?: (id: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkillRow({ skill, onClick }: SkillRowProps) {
|
function SkillRow({ skill, onClick, onHighlight }: SkillRowProps) {
|
||||||
const IconComponent = iconMap[skill.icon]
|
const IconComponent = iconMap[skill.icon]
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
@@ -64,10 +65,12 @@ function SkillRow({ skill, onClick }: SkillRowProps) {
|
|||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
||||||
|
onHighlight?.(skill.id)
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
e.currentTarget.style.boxShadow = 'none'
|
e.currentTarget.style.boxShadow = 'none'
|
||||||
|
onHighlight?.(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -135,6 +138,7 @@ interface CategorySectionProps {
|
|||||||
onSkillClick: (skill: SkillMedication) => void
|
onSkillClick: (skill: SkillMedication) => void
|
||||||
onViewAll: (category: SkillCategory) => void
|
onViewAll: (category: SkillCategory) => void
|
||||||
isFirst: boolean
|
isFirst: boolean
|
||||||
|
onNodeHighlight?: (id: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategorySection({
|
function CategorySection({
|
||||||
@@ -144,6 +148,7 @@ function CategorySection({
|
|||||||
onSkillClick,
|
onSkillClick,
|
||||||
onViewAll,
|
onViewAll,
|
||||||
isFirst,
|
isFirst,
|
||||||
|
onNodeHighlight,
|
||||||
}: CategorySectionProps) {
|
}: CategorySectionProps) {
|
||||||
const visibleSkills = categorySkills.slice(0, SKILLS_PER_CATEGORY)
|
const visibleSkills = categorySkills.slice(0, SKILLS_PER_CATEGORY)
|
||||||
const remainingCount = categorySkills.length - SKILLS_PER_CATEGORY
|
const remainingCount = categorySkills.length - SKILLS_PER_CATEGORY
|
||||||
@@ -194,6 +199,7 @@ function CategorySection({
|
|||||||
key={skill.id}
|
key={skill.id}
|
||||||
skill={skill}
|
skill={skill}
|
||||||
onClick={() => onSkillClick(skill)}
|
onClick={() => onSkillClick(skill)}
|
||||||
|
onHighlight={onNodeHighlight}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 { openPanel } = useDetailPanel()
|
||||||
|
|
||||||
const groupedSkills = categoryConfig.map(({ id, label }) => ({
|
const groupedSkills = categoryConfig.map(({ id, label }) => ({
|
||||||
@@ -267,6 +277,7 @@ export function RepeatMedicationsSubsection() {
|
|||||||
onSkillClick={handleSkillClick}
|
onSkillClick={handleSkillClick}
|
||||||
onViewAll={handleViewAll}
|
onViewAll={handleViewAll}
|
||||||
isFirst={index === 0}
|
isFirst={index === 0}
|
||||||
|
onNodeHighlight={onNodeHighlight}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ interface RoleItemProps {
|
|||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
onViewFull: () => 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(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
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',
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => onHighlight?.(consultation.id)}
|
||||||
|
onMouseLeave={() => onHighlight?.(null)}
|
||||||
>
|
>
|
||||||
{/* Clickable header */}
|
{/* Clickable header */}
|
||||||
<div
|
<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 [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
const { openPanel } = useDetailPanel()
|
const { openPanel } = useDetailPanel()
|
||||||
|
|
||||||
@@ -280,6 +287,7 @@ export function WorkExperienceSubsection() {
|
|||||||
isExpanded={expandedId === c.id}
|
isExpanded={expandedId === c.id}
|
||||||
onToggle={() => handleToggle(c.id)}
|
onToggle={() => handleToggle(c.id)}
|
||||||
onViewFull={() => handleViewFull(c)}
|
onViewFull={() => handleViewFull(c)}
|
||||||
|
onHighlight={onNodeHighlight}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user