diff --git a/Ralph/prd.json b/Ralph/prd.json index e2a0d1e..4fab94a 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -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." }, { diff --git a/Ralph/progress.txt b/Ralph/progress.txt index 588f479..75d04b8 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -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) +--- diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 4798e2d..604b8ce 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 + highlightedNodeId?: string | null } const DESKTOP_HEIGHT = 400 @@ -70,10 +71,12 @@ function buildScreenReaderDescription(): string { const CareerConstellation: React.FC = ({ onRoleClick, onSkillClick, + highlightedNodeId, }) => { const svgRef = useRef(null) const containerRef = useRef(null) const simulationRef = useRef | null>(null) + const connectedMapRef = useRef>>(new Map()) const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT }) const [focusedNodeId, setFocusedNodeId] = useState(null) @@ -241,6 +244,7 @@ const CareerConstellation: React.FC = ({ 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 = ({ } }, [focusedNodeId]) + // External highlight from hovering experience/skill entries + useEffect(() => { + if (!svgRef.current) return + const svg = d3.select(svgRef.current) + const nodeSelection = svg.selectAll('g.node') + const linkSelection = svg.selectAll('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 (
(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() { {/* Last Consultation subsection */} @@ -386,8 +392,8 @@ export function DashboardLayout() { {/* Two-column experience/skills grid */}
- - + +
{/* Education subsection */} diff --git a/src/components/RepeatMedicationsSubsection.tsx b/src/components/RepeatMedicationsSubsection.tsx index 6f58feb..0cb8090 100644 --- a/src/components/RepeatMedicationsSubsection.tsx +++ b/src/components/RepeatMedicationsSubsection.tsx @@ -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) }} >
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} /> ))}
@@ -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} /> ))}
diff --git a/src/components/WorkExperienceSubsection.tsx b/src/components/WorkExperienceSubsection.tsx index e743b68..a1e7b41 100644 --- a/src/components/WorkExperienceSubsection.tsx +++ b/src/components/WorkExperienceSubsection.tsx @@ -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 */}
void +} + +export function WorkExperienceSubsection({ onNodeHighlight }: WorkExperienceSubsectionProps) { const [expandedId, setExpandedId] = useState(null) const { openPanel } = useDetailPanel() @@ -280,6 +287,7 @@ export function WorkExperienceSubsection() { isExpanded={expandedId === c.id} onToggle={() => handleToggle(c.id)} onViewFull={() => handleViewFull(c)} + onHighlight={onNodeHighlight} /> ))}