feat: US-011 - Improve constellation graph visual clarity
This commit is contained in:
+1
-1
@@ -193,7 +193,7 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 11,
|
"priority": 11,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "The key issue is readability — the current graph is too sparse/faint. Larger nodes, thicker links, and stronger colours will help. The background should provide subtle contrast so the white card surface feels like the graph 'lives somewhere'."
|
"notes": "The key issue is readability — the current graph is too sparse/faint. Larger nodes, thicker links, and stronger colours will help. The background should provide subtle contrast so the white card surface feels like the graph 'lives somewhere'."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -983,3 +983,21 @@
|
|||||||
- The `.activity-grid` CSS was orphaned when CareerActivityTile was removed — always check for CSS classes that were only used by deleted components
|
- The `.activity-grid` CSS was orphaned when CareerActivityTile was removed — always check for CSS classes that were only used by deleted components
|
||||||
- Only 2 tile files remain in src/components/tiles/: PatientSummaryTile.tsx and ProjectsTile.tsx
|
- Only 2 tile files remain in src/components/tiles/: PatientSummaryTile.tsx and ProjectsTile.tsx
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-02-14 - US-011
|
||||||
|
- Improved constellation graph visual clarity with larger nodes, thicker links, and better contrast
|
||||||
|
- Role node radius: 24→30, skill node radius: 10→14, collide radius: 30→36
|
||||||
|
- Role label font: 8→10px, skill label font: 9→11px, skill label color darkened (#5B7A78→#4A6B69)
|
||||||
|
- Link stroke: 1→1.5px, opacity: 0.3→0.45, color: #D4E0DE→#B0C4C0 (darker, more visible)
|
||||||
|
- Background gradient: center #F0F5F4→#EDF2F1 (slightly darker), edge #FFFFFF→#F7FAF9 (warmer)
|
||||||
|
- Force simulation: charge -200→-120, link distance 80→65, link strength 0.5→0.6, Y strength 0.3→0.4, skill X pull 0.05→0.08, padding 80→60
|
||||||
|
- Hover highlight link width: 2→2.5px, skill scale-up: +3→+4px
|
||||||
|
- All existing interactions preserved (hover dim/highlight, click, keyboard nav)
|
||||||
|
- Responsive height tiers unchanged (400/300/250px)
|
||||||
|
- Files changed: src/components/CareerConstellation.tsx
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Reducing charge repulsion (-200→-120) combined with shorter link distance (80→65) makes nodes cluster more tightly, filling the container better
|
||||||
|
- The `#B0C4C0` link color at 0.45 opacity provides much better visibility than `#D4E0DE` at 0.3 while still being subtle
|
||||||
|
- 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)
|
||||||
|
---
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ const DESKTOP_HEIGHT = 400
|
|||||||
const TABLET_HEIGHT = 300
|
const TABLET_HEIGHT = 300
|
||||||
const MOBILE_HEIGHT = 250
|
const MOBILE_HEIGHT = 250
|
||||||
|
|
||||||
const ROLE_RADIUS = 24
|
const ROLE_RADIUS = 30
|
||||||
const SKILL_RADIUS = 10
|
const SKILL_RADIUS = 14
|
||||||
const COLLIDE_RADIUS = 30
|
const COLLIDE_RADIUS = 36
|
||||||
|
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
@@ -131,8 +131,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
.attr('cx', '50%')
|
.attr('cx', '50%')
|
||||||
.attr('cy', '50%')
|
.attr('cy', '50%')
|
||||||
.attr('r', '60%')
|
.attr('r', '60%')
|
||||||
gradient.append('stop').attr('offset', '0%').attr('stop-color', '#F0F5F4')
|
gradient.append('stop').attr('offset', '0%').attr('stop-color', '#EDF2F1')
|
||||||
gradient.append('stop').attr('offset', '100%').attr('stop-color', '#FFFFFF')
|
gradient.append('stop').attr('offset', '100%').attr('stop-color', '#F7FAF9')
|
||||||
|
|
||||||
// Background rect
|
// Background rect
|
||||||
svg.append('rect')
|
svg.append('rect')
|
||||||
@@ -160,7 +160,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
const years = simRoleNodes.map(n => n.startYear ?? 2016)
|
const years = simRoleNodes.map(n => n.startYear ?? 2016)
|
||||||
const minYear = Math.min(...years)
|
const minYear = Math.min(...years)
|
||||||
const maxYear = Math.max(...years)
|
const maxYear = Math.max(...years)
|
||||||
const padding = 80
|
const padding = 60
|
||||||
|
|
||||||
const xScale = d3.scaleLinear()
|
const xScale = d3.scaleLinear()
|
||||||
.domain([minYear, maxYear])
|
.domain([minYear, maxYear])
|
||||||
@@ -172,9 +172,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', '#D4E0DE')
|
.attr('stroke', '#B0C4C0')
|
||||||
.attr('stroke-width', 1)
|
.attr('stroke-width', 1.5)
|
||||||
.attr('stroke-opacity', 0.3)
|
.attr('stroke-opacity', 0.45)
|
||||||
|
|
||||||
const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
|
const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
|
||||||
.data(nodes)
|
.data(nodes)
|
||||||
@@ -206,7 +206,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'middle')
|
.attr('dominant-baseline', 'middle')
|
||||||
.attr('fill', '#FFFFFF')
|
.attr('fill', '#FFFFFF')
|
||||||
.attr('font-size', '8')
|
.attr('font-size', '10')
|
||||||
.attr('font-weight', '600')
|
.attr('font-weight', '600')
|
||||||
.attr('font-family', 'var(--font-ui)')
|
.attr('font-family', 'var(--font-ui)')
|
||||||
.attr('pointer-events', 'none')
|
.attr('pointer-events', 'none')
|
||||||
@@ -226,9 +226,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
.append('text')
|
.append('text')
|
||||||
.attr('class', 'node-label')
|
.attr('class', 'node-label')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dy', SKILL_RADIUS + 12)
|
.attr('dy', SKILL_RADIUS + 14)
|
||||||
.attr('fill', '#5B7A78')
|
.attr('fill', '#4A6B69')
|
||||||
.attr('font-size', '9')
|
.attr('font-size', '11')
|
||||||
.attr('font-family', 'var(--font-geist-mono)')
|
.attr('font-family', 'var(--font-geist-mono)')
|
||||||
.attr('pointer-events', 'none')
|
.attr('pointer-events', 'none')
|
||||||
.text(d => d.shortLabel ?? d.label)
|
.text(d => d.shortLabel ?? d.label)
|
||||||
@@ -262,7 +262,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
nodeSelection.filter(n => n.type === 'skill' && connected.has(n.id))
|
nodeSelection.filter(n => n.type === 'skill' && connected.has(n.id))
|
||||||
.select('.node-circle')
|
.select('.node-circle')
|
||||||
.transition().duration(150)
|
.transition().duration(150)
|
||||||
.attr('r', SKILL_RADIUS + 3)
|
.attr('r', SKILL_RADIUS + 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brighten connected links, dim others
|
// Brighten connected links, dim others
|
||||||
@@ -272,7 +272,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
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 === d.id || tgt === d.id) return '#0D6E6E'
|
if (src === d.id || tgt === d.id) return '#0D6E6E'
|
||||||
return '#D4E0DE'
|
return '#B0C4C0'
|
||||||
})
|
})
|
||||||
.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
|
||||||
@@ -283,8 +283,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
.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 === d.id || tgt === d.id) return 2
|
if (src === d.id || tgt === d.id) return 2.5
|
||||||
return 1
|
return 1.5
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -301,9 +301,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
|
|
||||||
// Reset all links
|
// Reset all links
|
||||||
linkSelection
|
linkSelection
|
||||||
.attr('stroke', '#D4E0DE')
|
.attr('stroke', '#B0C4C0')
|
||||||
.attr('stroke-width', 1)
|
.attr('stroke-width', 1.5)
|
||||||
.attr('stroke-opacity', 0.3)
|
.attr('stroke-opacity', 0.45)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Click interactions
|
// Click interactions
|
||||||
@@ -317,20 +317,20 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
|
|
||||||
// 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(-120))
|
||||||
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
||||||
.id(d => d.id)
|
.id(d => d.id)
|
||||||
.distance(80)
|
.distance(65)
|
||||||
.strength(d => (d as SimLink).strength * 0.5))
|
.strength(d => (d as SimLink).strength * 0.6))
|
||||||
.force('x', d3.forceX<SimNode>(d => {
|
.force('x', d3.forceX<SimNode>(d => {
|
||||||
if (d.type === 'role' && d.startYear != null) {
|
if (d.type === 'role' && d.startYear != null) {
|
||||||
return xScale(d.startYear)
|
return xScale(d.startYear)
|
||||||
}
|
}
|
||||||
return width / 2
|
return width / 2
|
||||||
}).strength(d => d.type === 'role' ? 0.8 : 0.05))
|
}).strength(d => d.type === 'role' ? 0.8 : 0.08))
|
||||||
.force('y', d3.forceY<SimNode>(height / 2).strength(0.3))
|
.force('y', d3.forceY<SimNode>(height / 2).strength(0.4))
|
||||||
.force('collide', d3.forceCollide<SimNode>(d =>
|
.force('collide', d3.forceCollide<SimNode>(d =>
|
||||||
d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 4
|
d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 6
|
||||||
))
|
))
|
||||||
|
|
||||||
simulationRef.current = simulation
|
simulationRef.current = simulation
|
||||||
|
|||||||
Reference in New Issue
Block a user