feat: US-012 - Add hover-highlighting between experience/skills and constellation graph
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user