US-026: Add hover and click interactions to CareerConstellation
This commit is contained in:
@@ -194,6 +194,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
|
|
||||||
nodeSelection.filter(d => d.type === 'role')
|
nodeSelection.filter(d => d.type === 'role')
|
||||||
.append('circle')
|
.append('circle')
|
||||||
|
.attr('class', 'node-circle')
|
||||||
.attr('r', ROLE_RADIUS)
|
.attr('r', ROLE_RADIUS)
|
||||||
.attr('fill', d => d.orgColor ?? '#0D6E6E')
|
.attr('fill', d => d.orgColor ?? '#0D6E6E')
|
||||||
.attr('stroke', '#FFFFFF')
|
.attr('stroke', '#FFFFFF')
|
||||||
@@ -201,6 +202,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
|
|
||||||
nodeSelection.filter(d => d.type === 'role')
|
nodeSelection.filter(d => d.type === 'role')
|
||||||
.append('text')
|
.append('text')
|
||||||
|
.attr('class', 'node-label')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.attr('fill', '#FFFFFF')
|
.attr('fill', '#FFFFFF')
|
||||||
@@ -213,6 +215,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
// Skill nodes
|
// Skill nodes
|
||||||
nodeSelection.filter(d => d.type === 'skill')
|
nodeSelection.filter(d => d.type === 'skill')
|
||||||
.append('circle')
|
.append('circle')
|
||||||
|
.attr('class', 'node-circle')
|
||||||
.attr('r', SKILL_RADIUS)
|
.attr('r', SKILL_RADIUS)
|
||||||
.attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E')
|
.attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E')
|
||||||
.attr('stroke', '#FFFFFF')
|
.attr('stroke', '#FFFFFF')
|
||||||
@@ -221,6 +224,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
|
|
||||||
nodeSelection.filter(d => d.type === 'skill')
|
nodeSelection.filter(d => d.type === 'skill')
|
||||||
.append('text')
|
.append('text')
|
||||||
|
.attr('class', 'node-label')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dy', SKILL_RADIUS + 12)
|
.attr('dy', SKILL_RADIUS + 12)
|
||||||
.attr('fill', '#5B7A78')
|
.attr('fill', '#5B7A78')
|
||||||
@@ -229,6 +233,88 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
.attr('pointer-events', 'none')
|
.attr('pointer-events', 'none')
|
||||||
.text(d => d.shortLabel ?? d.label)
|
.text(d => d.shortLabel ?? d.label)
|
||||||
|
|
||||||
|
// Build adjacency lookup for hover interactions
|
||||||
|
const connectedMap = new Map<string, Set<string>>()
|
||||||
|
constellationLinks.forEach(l => {
|
||||||
|
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
|
||||||
|
if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set())
|
||||||
|
connectedMap.get(l.source)!.add(l.target)
|
||||||
|
connectedMap.get(l.target)!.add(l.source)
|
||||||
|
})
|
||||||
|
|
||||||
|
const HOVER_TRANSITION = '150ms'
|
||||||
|
|
||||||
|
// Hover interactions
|
||||||
|
nodeSelection.on('mouseenter', function(_event, d) {
|
||||||
|
const connected = connectedMap.get(d.id) ?? new Set()
|
||||||
|
|
||||||
|
// Dim non-connected nodes
|
||||||
|
nodeSelection
|
||||||
|
.style('transition', `opacity ${HOVER_TRANSITION}`)
|
||||||
|
.style('opacity', n => {
|
||||||
|
if (n.id === d.id) return '1'
|
||||||
|
if (connected.has(n.id)) return '1'
|
||||||
|
return '0.15'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scale up connected skill nodes when hovering a role
|
||||||
|
if (d.type === 'role') {
|
||||||
|
nodeSelection.filter(n => n.type === 'skill' && connected.has(n.id))
|
||||||
|
.select('.node-circle')
|
||||||
|
.transition().duration(150)
|
||||||
|
.attr('r', SKILL_RADIUS + 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brighten connected links, dim others
|
||||||
|
linkSelection
|
||||||
|
.style('transition', `stroke-opacity ${HOVER_TRANSITION}, stroke ${HOVER_TRANSITION}`)
|
||||||
|
.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 === d.id || tgt === d.id) return '#0D6E6E'
|
||||||
|
return '#D4E0DE'
|
||||||
|
})
|
||||||
|
.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 === d.id || tgt === d.id) 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 === d.id || tgt === d.id) return 2
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
nodeSelection.on('mouseleave', function() {
|
||||||
|
// Reset all nodes
|
||||||
|
nodeSelection
|
||||||
|
.style('opacity', '1')
|
||||||
|
|
||||||
|
// Reset skill node sizes
|
||||||
|
nodeSelection.filter(n => n.type === 'skill')
|
||||||
|
.select('.node-circle')
|
||||||
|
.transition().duration(150)
|
||||||
|
.attr('r', SKILL_RADIUS)
|
||||||
|
|
||||||
|
// Reset all links
|
||||||
|
linkSelection
|
||||||
|
.attr('stroke', '#D4E0DE')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
.attr('stroke-opacity', 0.3)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click interactions
|
||||||
|
nodeSelection.on('click', function(_event, d) {
|
||||||
|
if (d.type === 'role') {
|
||||||
|
callbacksRef.current.onRoleClick(d.id)
|
||||||
|
} else {
|
||||||
|
callbacksRef.current.onSkillClick(d.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Force simulation
|
// Force simulation
|
||||||
const simulation = d3.forceSimulation<SimNode>(nodes)
|
const simulation = d3.forceSimulation<SimNode>(nodes)
|
||||||
.force('charge', d3.forceManyBody<SimNode>().strength(-200))
|
.force('charge', d3.forceManyBody<SimNode>().strength(-200))
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import React, { useState, useCallback } from 'react'
|
|||||||
import { Card, CardHeader } from '../Card'
|
import { Card, CardHeader } from '../Card'
|
||||||
import { documents } from '@/data/documents'
|
import { documents } from '@/data/documents'
|
||||||
import { consultations } from '@/data/consultations'
|
import { consultations } from '@/data/consultations'
|
||||||
|
import { skills } from '@/data/skills'
|
||||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
|
import CareerConstellation from '../CareerConstellation'
|
||||||
|
|
||||||
type ActivityType = 'role' | 'project' | 'cert' | 'edu'
|
type ActivityType = 'role' | 'project' | 'cert' | 'edu'
|
||||||
|
|
||||||
@@ -265,39 +267,44 @@ export const CareerActivityTile: React.FC = () => {
|
|||||||
const timeline = buildTimeline()
|
const timeline = buildTimeline()
|
||||||
const { openPanel } = useDetailPanel()
|
const { openPanel } = useDetailPanel()
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
const handleRoleClick = useCallback(
|
||||||
(entry: ActivityEntry) => {
|
(roleId: string) => {
|
||||||
if (entry.type === 'role' && entry.consultationId) {
|
const consultation = consultations.find((c) => c.id === roleId)
|
||||||
const consultation = consultations.find((c) => c.id === entry.consultationId)
|
|
||||||
if (consultation) {
|
if (consultation) {
|
||||||
openPanel({ type: 'career-role', consultation })
|
openPanel({ type: 'career-role', consultation })
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[openPanel],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSkillClick = useCallback(
|
||||||
|
(skillId: string) => {
|
||||||
|
const skill = skills.find((s) => s.id === skillId)
|
||||||
|
if (skill) {
|
||||||
|
openPanel({ type: 'skill', skill })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[openPanel],
|
[openPanel],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleItemClick = useCallback(
|
||||||
|
(entry: ActivityEntry) => {
|
||||||
|
if (entry.type === 'role' && entry.consultationId) {
|
||||||
|
handleRoleClick(entry.consultationId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleRoleClick],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card full tileId="career-activity">
|
<Card full tileId="career-activity">
|
||||||
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
|
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
|
||||||
|
|
||||||
{/* Placeholder for CareerConstellation component (to be added later) */}
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<div
|
<CareerConstellation
|
||||||
style={{
|
onRoleClick={handleRoleClick}
|
||||||
minHeight: '200px',
|
onSkillClick={handleSkillClick}
|
||||||
display: 'flex',
|
/>
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'var(--bg-dashboard)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
border: '1px dashed var(--border-light)',
|
|
||||||
marginBottom: '20px',
|
|
||||||
color: 'var(--text-tertiary)',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Career Constellation visualization (to be implemented)
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="activity-grid">
|
<div className="activity-grid">
|
||||||
|
|||||||
Reference in New Issue
Block a user