feat: US-005 - Skill node redesign with muted default and reveal on interaction
This commit is contained in:
+1
-1
@@ -98,7 +98,7 @@
|
|||||||
"Verify in browser — graph looks clean and quiet at rest, informative on hover"
|
"Verify in browser — graph looks clean and quiet at rest, informative on hover"
|
||||||
],
|
],
|
||||||
"priority": 5,
|
"priority": 5,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "This modifies the applyGraphHighlight() function (line ~439) and the initial skill node rendering (lines ~382-403). The resting state setup happens when nodes are first created and in the 'no activeNodeId' branch of applyGraphHighlight. The highlighted state logic is in the activeNodeId branch. Key change: skill labels default to opacity 0 (not the current collision-based visibility), and only become visible via applyGraphHighlight when connected. The updateSkillLabelVisibility() function can be simplified or merged into applyGraphHighlight. The SKILL_RADIUS constant should be split into SKILL_RADIUS_DEFAULT (7) and SKILL_RADIUS_ACTIVE (11). Link line styling in the resting branch should use much lower opacity than current 0.45. Use the d3-viz skill for implementation."
|
"notes": "This modifies the applyGraphHighlight() function (line ~439) and the initial skill node rendering (lines ~382-403). The resting state setup happens when nodes are first created and in the 'no activeNodeId' branch of applyGraphHighlight. The highlighted state logic is in the activeNodeId branch. Key change: skill labels default to opacity 0 (not the current collision-based visibility), and only become visible via applyGraphHighlight when connected. The updateSkillLabelVisibility() function can be simplified or merged into applyGraphHighlight. The SKILL_RADIUS constant should be split into SKILL_RADIUS_DEFAULT (7) and SKILL_RADIUS_ACTIVE (11). Link line styling in the resting branch should use much lower opacity than current 0.45. Use the d3-viz skill for implementation."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,4 +69,24 @@
|
|||||||
- Filter bounds need generous overflow (x/y -20%, width/height 140%+) to avoid clipping the shadow
|
- Filter bounds need generous overflow (x/y -20%, width/height 140%+) to avoid clipping the shadow
|
||||||
- When clearing a filter, use `.attr('filter', null)` — not empty string
|
- When clearing a filter, use `.attr('filter', null)` — not empty string
|
||||||
- The role node pill rendering (rect with rx/ry, orgColor fill at 0.12, border at 0.4) was built incrementally across US-003 and US-004 — check existing code before implementing to avoid duplication
|
- The role node pill rendering (rect with rx/ry, orgColor fill at 0.12, border at 0.4) was built incrementally across US-003 and US-004 — check existing code before implementing to avoid duplication
|
||||||
|
- Skill nodes use SKILL_RADIUS_DEFAULT (7) for resting state and SKILL_RADIUS_ACTIVE (11) for highlighted state — controlled via applyGraphHighlight, not CSS transitions (SVG `r` doesn't transition via CSS)
|
||||||
|
- Skill labels default to opacity 0 and are shown/hidden via D3 transitions in applyGraphHighlight — the old updateSkillLabelVisibility collision-based approach was removed
|
||||||
|
- Link lines use var(--border-light) at opacity 0.08 for resting state — highlighted links use the skill's domain colour from domainColorMap with strength-proportional opacity
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-16 - US-005
|
||||||
|
- Replaced SKILL_RADIUS (14) with SKILL_RADIUS_DEFAULT (7) and SKILL_RADIUS_ACTIVE (11)
|
||||||
|
- Skill nodes now default to small (r=7), low opacity (0.2), no stroke, hidden labels (opacity 0)
|
||||||
|
- On hover/pin: connected skills grow to r=11, fill-opacity 0.85, labels fade in; unconnected nodes dim to opacity 0.06
|
||||||
|
- Link lines default to var(--border-light) at opacity 0.08; highlighted links use domain colour with strength-proportional opacity (0.35-0.65)
|
||||||
|
- Removed updateSkillLabelVisibility function — label visibility now fully controlled by applyGraphHighlight
|
||||||
|
- D3 transitions (180ms) used for skill radius and opacity changes, respecting prefers-reduced-motion
|
||||||
|
- Updated collision force and boundary clamping to use SKILL_RADIUS_ACTIVE
|
||||||
|
- Skill labels styled: font-geist-mono, 10px, var(--text-secondary)
|
||||||
|
- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- SVG `r` attribute cannot be animated via CSS transitions — must use D3 `.transition().duration()` for radius changes
|
||||||
|
- The applyGraphHighlight function is the single source of truth for all visual states (resting, highlighted, dimmed) — keep all styling logic there, not split between initial rendering and highlight
|
||||||
|
- D3 transition on a selection that already has a pending transition interrupts it — this is fine for hover interactions where the latest state wins
|
||||||
|
- domainColorMap hex values are needed for D3 attrs (can't use CSS custom properties for computed color values in stroke/fill of highlighted links)
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const MOBILE_FALLBACK_HEIGHT = 360
|
|||||||
const ROLE_WIDTH = 104
|
const ROLE_WIDTH = 104
|
||||||
const ROLE_HEIGHT = 32
|
const ROLE_HEIGHT = 32
|
||||||
const ROLE_RX = 16
|
const ROLE_RX = 16
|
||||||
const SKILL_RADIUS = 14
|
const SKILL_RADIUS_DEFAULT = 7
|
||||||
|
const SKILL_RADIUS_ACTIVE = 11
|
||||||
|
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
|
const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
|
||||||
@@ -313,9 +314,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
const linkSelection = linkGroup.selectAll('line')
|
const linkSelection = linkGroup.selectAll('line')
|
||||||
.data(links)
|
.data(links)
|
||||||
.join('line')
|
.join('line')
|
||||||
.attr('stroke', '#B0C4C0')
|
.attr('stroke', 'var(--border-light)')
|
||||||
.attr('stroke-width', 1.5)
|
.attr('stroke-width', 1)
|
||||||
.attr('stroke-opacity', 0.45)
|
.attr('stroke-opacity', 0.08)
|
||||||
|
|
||||||
const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
|
const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
|
||||||
.data(nodes)
|
.data(nodes)
|
||||||
@@ -365,21 +366,21 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
nodeSelection.filter(d => d.type === 'skill')
|
nodeSelection.filter(d => d.type === 'skill')
|
||||||
.append('circle')
|
.append('circle')
|
||||||
.attr('class', 'node-circle')
|
.attr('class', 'node-circle')
|
||||||
.attr('r', SKILL_RADIUS)
|
.attr('r', SKILL_RADIUS_DEFAULT)
|
||||||
.attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E')
|
.attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E')
|
||||||
.attr('stroke', '#FFFFFF')
|
.attr('stroke', 'none')
|
||||||
.attr('stroke-width', 1.5)
|
.attr('fill-opacity', 0.2)
|
||||||
.attr('fill-opacity', 0.86)
|
|
||||||
|
|
||||||
nodeSelection.filter(d => d.type === 'skill')
|
nodeSelection.filter(d => d.type === 'skill')
|
||||||
.append('text')
|
.append('text')
|
||||||
.attr('class', 'node-label')
|
.attr('class', 'node-label')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dy', SKILL_RADIUS + 14)
|
.attr('dy', SKILL_RADIUS_ACTIVE + 14)
|
||||||
.attr('fill', '#436964')
|
.attr('fill', 'var(--text-secondary)')
|
||||||
.attr('font-size', '11')
|
.attr('font-size', '10')
|
||||||
.attr('font-family', 'var(--font-geist-mono)')
|
.attr('font-family', 'var(--font-geist-mono)')
|
||||||
.attr('pointer-events', 'none')
|
.attr('pointer-events', 'none')
|
||||||
|
.attr('opacity', 0)
|
||||||
.text(d => {
|
.text(d => {
|
||||||
const label = d.shortLabel ?? d.label
|
const label = d.shortLabel ?? d.label
|
||||||
return label.length > 16 ? `${label.slice(0, 15)}…` : label
|
return label.length > 16 ? `${label.slice(0, 15)}…` : label
|
||||||
@@ -400,58 +401,47 @@ 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)
|
||||||
})
|
})
|
||||||
const updateSkillLabelVisibility = (activeNodeId: string | null) => {
|
|
||||||
const shownPositions: Array<{ x: number; y: number }> = []
|
|
||||||
nodeSelection
|
|
||||||
.filter(n => n.type === 'skill')
|
|
||||||
.each(function(n) {
|
|
||||||
const textSel = d3.select(this).select<SVGTextElement>('text.node-label')
|
|
||||||
const connected = activeNodeId ? connectedMap.get(activeNodeId) : null
|
|
||||||
const shouldForceShow = Boolean(activeNodeId && (n.id === activeNodeId || connected?.has(n.id)))
|
|
||||||
|
|
||||||
if (shouldForceShow) {
|
|
||||||
textSel.attr('opacity', 1)
|
|
||||||
shownPositions.push({ x: n.x, y: n.y + SKILL_RADIUS + 14 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const x = n.x
|
|
||||||
const y = n.y + SKILL_RADIUS + 14
|
|
||||||
const collides = shownPositions.some(p => Math.abs(p.x - x) < 28 && Math.abs(p.y - y) < 14)
|
|
||||||
|
|
||||||
textSel.attr('opacity', collides ? 0 : 1)
|
|
||||||
|
|
||||||
if (!collides) {
|
|
||||||
shownPositions.push({ x, y })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyGraphHighlight = (activeNodeId: string | null) => {
|
const applyGraphHighlight = (activeNodeId: string | null) => {
|
||||||
|
const dur = prefersReducedMotion ? 0 : 180
|
||||||
|
|
||||||
if (!activeNodeId) {
|
if (!activeNodeId) {
|
||||||
nodeSelection.style('opacity', '1')
|
nodeSelection.style('opacity', '1')
|
||||||
|
|
||||||
nodeSelection.filter(d => d.type === 'role')
|
nodeSelection.filter(d => d.type === 'role')
|
||||||
.attr('filter', null)
|
.attr('filter', null)
|
||||||
.select('.node-circle')
|
.select('.node-circle')
|
||||||
.attr('stroke-opacity', 0.4)
|
.attr('stroke-opacity', 0.4)
|
||||||
.attr('stroke-width', 1)
|
.attr('stroke-width', 1)
|
||||||
nodeSelection.filter(d => d.type === 'skill')
|
|
||||||
.select('.node-circle')
|
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||||
.attr('r', SKILL_RADIUS)
|
if (dur > 0) {
|
||||||
|
skillNodes.select('.node-circle')
|
||||||
|
.transition().duration(dur)
|
||||||
|
.attr('r', SKILL_RADIUS_DEFAULT)
|
||||||
|
.attr('fill-opacity', 0.2)
|
||||||
|
skillNodes.select('.node-label')
|
||||||
|
.transition().duration(dur)
|
||||||
|
.attr('opacity', 0)
|
||||||
|
} else {
|
||||||
|
skillNodes.select('.node-circle')
|
||||||
|
.attr('r', SKILL_RADIUS_DEFAULT)
|
||||||
|
.attr('fill-opacity', 0.2)
|
||||||
|
skillNodes.select('.node-label')
|
||||||
|
.attr('opacity', 0)
|
||||||
|
}
|
||||||
|
|
||||||
linkSelection
|
linkSelection
|
||||||
.attr('stroke', '#B0C4C0')
|
.attr('stroke', 'var(--border-light)')
|
||||||
.attr('stroke-width', 1.5)
|
.attr('stroke-width', 1)
|
||||||
.attr('stroke-opacity', 0.45)
|
.attr('stroke-opacity', 0.08)
|
||||||
updateSkillLabelVisibility(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = connectedMap.get(activeNodeId) ?? new Set()
|
const connected = connectedMap.get(activeNodeId) ?? new Set()
|
||||||
|
const isInGroup = (id: string) => id === activeNodeId || connected.has(id)
|
||||||
|
|
||||||
nodeSelection.style('opacity', d => {
|
nodeSelection.style('opacity', d => isInGroup(d.id) ? '1' : '0.06')
|
||||||
if (d.id === activeNodeId || connected.has(d.id)) return '1'
|
|
||||||
return '0.16'
|
|
||||||
})
|
|
||||||
|
|
||||||
nodeSelection.filter(d => d.type === 'role')
|
nodeSelection.filter(d => d.type === 'role')
|
||||||
.attr('filter', d => {
|
.attr('filter', d => {
|
||||||
@@ -467,31 +457,48 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
})
|
})
|
||||||
.attr('stroke-width', d => d.id === activeNodeId ? 1.5 : 1)
|
.attr('stroke-width', d => d.id === activeNodeId ? 1.5 : 1)
|
||||||
|
|
||||||
nodeSelection.filter(d => d.type === 'skill')
|
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||||
.select('.node-circle')
|
if (dur > 0) {
|
||||||
.attr('r', d => (d.id === activeNodeId || connected.has(d.id)) ? SKILL_RADIUS + 3 : SKILL_RADIUS)
|
skillNodes.select('.node-circle')
|
||||||
|
.transition().duration(dur)
|
||||||
|
.attr('r', d => isInGroup(d.id) ? SKILL_RADIUS_ACTIVE : SKILL_RADIUS_DEFAULT)
|
||||||
|
.attr('fill-opacity', d => isInGroup(d.id) ? 0.85 : 0.2)
|
||||||
|
skillNodes.select('.node-label')
|
||||||
|
.transition().duration(dur)
|
||||||
|
.attr('opacity', d => isInGroup(d.id) ? 1 : 0)
|
||||||
|
} else {
|
||||||
|
skillNodes.select('.node-circle')
|
||||||
|
.attr('r', d => isInGroup(d.id) ? SKILL_RADIUS_ACTIVE : SKILL_RADIUS_DEFAULT)
|
||||||
|
.attr('fill-opacity', d => isInGroup(d.id) ? 0.85 : 0.2)
|
||||||
|
skillNodes.select('.node-label')
|
||||||
|
.attr('opacity', d => isInGroup(d.id) ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
linkSelection
|
linkSelection
|
||||||
.attr('stroke', l => {
|
.attr('stroke', l => {
|
||||||
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
|
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
|
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
|
||||||
if (src === activeNodeId || tgt === activeNodeId) return '#0D6E6E'
|
if (src === activeNodeId || tgt === activeNodeId) {
|
||||||
return '#B0C4C0'
|
const skillId = src === activeNodeId ? tgt : src
|
||||||
|
const skillNode = nodes.find(n => n.id === skillId)
|
||||||
|
return domainColorMap[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
|
||||||
|
}
|
||||||
|
return 'var(--border-light)'
|
||||||
})
|
})
|
||||||
.attr('stroke-opacity', l => {
|
.attr('stroke-opacity', l => {
|
||||||
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
|
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
|
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
|
||||||
if (src === activeNodeId || tgt === activeNodeId) return 0.76
|
if (src === activeNodeId || tgt === activeNodeId) {
|
||||||
return 0.1
|
return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2))
|
||||||
|
}
|
||||||
|
return 0.08
|
||||||
})
|
})
|
||||||
.attr('stroke-width', l => {
|
.attr('stroke-width', l => {
|
||||||
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
|
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
|
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
|
||||||
if (src === activeNodeId || tgt === activeNodeId) return 2.5
|
if (src === activeNodeId || tgt === activeNodeId) return 1.5
|
||||||
return 1.5
|
return 1
|
||||||
})
|
})
|
||||||
|
|
||||||
updateSkillLabelVisibility(activeNodeId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
highlightGraphRef.current = applyGraphHighlight
|
highlightGraphRef.current = applyGraphHighlight
|
||||||
@@ -538,7 +545,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
return d.homeY
|
return d.homeY
|
||||||
}).strength(d => d.type === 'role' ? 1 : 0.2))
|
}).strength(d => d.type === 'role' ? 1 : 0.2))
|
||||||
.force('collide', d3.forceCollide<SimNode>(d =>
|
.force('collide', d3.forceCollide<SimNode>(d =>
|
||||||
d.type === 'role' ? Math.max(ROLE_WIDTH, ROLE_HEIGHT) / 2 + 8 : SKILL_RADIUS + 8
|
d.type === 'role' ? Math.max(ROLE_WIDTH, ROLE_HEIGHT) / 2 + 8 : SKILL_RADIUS_ACTIVE + 10
|
||||||
))
|
))
|
||||||
|
|
||||||
simulationRef.current = simulation
|
simulationRef.current = simulation
|
||||||
@@ -549,8 +556,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
d.x = Math.max(ROLE_WIDTH / 2 + 6, Math.min(width - ROLE_WIDTH / 2 - 6, d.x))
|
d.x = Math.max(ROLE_WIDTH / 2 + 6, Math.min(width - ROLE_WIDTH / 2 - 6, d.x))
|
||||||
d.y = Math.max(ROLE_HEIGHT / 2 + 6, Math.min(height - ROLE_HEIGHT / 2 - 6, d.y))
|
d.y = Math.max(ROLE_HEIGHT / 2 + 6, Math.min(height - ROLE_HEIGHT / 2 - 6, d.y))
|
||||||
} else {
|
} else {
|
||||||
d.x = Math.max(SKILL_RADIUS + 6, Math.min(width - SKILL_RADIUS - 6, d.x))
|
d.x = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(width - SKILL_RADIUS_ACTIVE - 6, d.x))
|
||||||
d.y = Math.max(SKILL_RADIUS + 6, Math.min(height - SKILL_RADIUS - 6, d.y))
|
d.y = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(height - SKILL_RADIUS_ACTIVE - 6, d.y))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user