feat: US-012 - Responsive behaviour for mobile and tablet constellation

- Add mobile-specific layout constants (MOBILE_ROLE_WIDTH=80, smaller skill radii)
- Use window.innerWidth for mobile breakpoint detection (container overflows on mobile)
- Reduce timelineX, padding, spacing, and force simulation parameters on mobile
- Truncate role pill labels and skill labels on narrow viewports
- Reduce charge/collision/link-distance forces for tighter mobile layout
- Fix CSS grid overflow: add min-width:0 and overflow:hidden to .pathway-graph-sticky
- MOBILE_FALLBACK_HEIGHT adjusted to 380px (within 360-400px spec)
- Legend wraps gracefully via existing flex-wrap
This commit is contained in:
2026-02-16 03:22:21 +00:00
parent 408cd9573c
commit f48d98b7fc
2 changed files with 73 additions and 42 deletions
+67 -42
View File
@@ -12,14 +12,19 @@ interface CareerConstellationProps {
}
const MIN_HEIGHT = 400
const MOBILE_FALLBACK_HEIGHT = 360
const MOBILE_FALLBACK_HEIGHT = 380
// Desktop defaults — mobile overrides computed in the D3 effect
const ROLE_WIDTH = 104
const ROLE_HEIGHT = 32
const ROLE_RX = 16
const SKILL_RADIUS_DEFAULT = 7
const SKILL_RADIUS_ACTIVE = 11
const MOBILE_ROLE_WIDTH = 80
const MOBILE_SKILL_RADIUS_DEFAULT = 6
const MOBILE_SKILL_RADIUS_ACTIVE = 9
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
@@ -143,6 +148,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
if (!svgRef.current) return
const { width, height } = dimensions
// Use viewport width for responsive breakpoint — container.clientWidth overflows on mobile
const isMobile = window.innerWidth < 640
if (simulationRef.current) {
simulationRef.current.stop()
@@ -154,10 +161,19 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const minYear = Math.min(...years)
const maxYear = Math.max(...years)
const topPadding = 46
const bottomPadding = 46
const sidePadding = 56
const timelineX = Math.max(100, Math.min(160, width * 0.18))
// Responsive layout parameters
const rw = isMobile ? MOBILE_ROLE_WIDTH : ROLE_WIDTH
const rh = ROLE_HEIGHT
const rrx = ROLE_RX
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : SKILL_RADIUS_DEFAULT
const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : SKILL_RADIUS_ACTIVE
const topPadding = isMobile ? 32 : 46
const bottomPadding = isMobile ? 32 : 46
const sidePadding = isMobile ? 20 : 56
const timelineX = isMobile
? Math.max(60, width * 0.16)
: Math.max(100, Math.min(160, width * 0.18))
const yScale = d3.scaleLinear()
.domain([maxYear, minYear])
@@ -231,10 +247,10 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.data(tickYears)
.join('text')
.attr('class', 'year-label')
.attr('x', timelineX - 12)
.attr('x', timelineX - (isMobile ? 8 : 12))
.attr('y', d => yScale(d) + 4)
.attr('text-anchor', 'end')
.attr('font-size', '10')
.attr('font-size', isMobile ? '9' : '10')
.attr('font-family', 'var(--font-geist-mono)')
.attr('fill', 'var(--text-tertiary)')
.text(d => d)
@@ -249,7 +265,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0))
const roleInitialMap = new Map<string, { x: number; y: number }>()
// Consistent horizontal offset for all role nodes — anchored right of timeline
const roleX = Math.min(width - sidePadding - ROLE_WIDTH / 2, timelineX + 80 + ROLE_WIDTH / 2)
const roleGap = isMobile ? 40 : 80
const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2)
roleOrder.forEach((role) => {
roleInitialMap.set(role.id, {
@@ -281,11 +298,12 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.filter(Boolean) as Array<{ x: number; y: number }>
// Skill centroid: offset right of roles into the available distribution space
const skillSpaceStart = roleX + ROLE_WIDTH / 2 + 40
const skillGap = isMobile ? 20 : 40
const skillSpaceStart = roleX + rw / 2 + skillGap
const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2
const centroid = linkedRolePositions.length > 0
? {
x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + 60),
x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : 60)),
y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length,
}
: { x: skillSpaceMid, y: height * 0.5 }
@@ -297,7 +315,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
? Math.PI * 1.35
: Math.PI * 0.05
const angle = domainBaseAngle + ((hash % 360) * Math.PI / 180) * 0.18
const radius = 50 + (hash % 50)
const radius = (isMobile ? 25 : 50) + (hash % (isMobile ? 25 : 50))
const seededX = centroid.x + Math.cos(angle) * radius
const seededY = centroid.y + Math.sin(angle) * radius
@@ -339,11 +357,11 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
nodeSelection.filter(d => d.type === 'role')
.append('rect')
.attr('class', 'focus-ring')
.attr('x', -ROLE_WIDTH / 2 - 3)
.attr('y', -ROLE_HEIGHT / 2 - 3)
.attr('width', ROLE_WIDTH + 6)
.attr('height', ROLE_HEIGHT + 6)
.attr('rx', ROLE_RX + 2)
.attr('x', -rw / 2 - 3)
.attr('y', -rh / 2 - 3)
.attr('width', rw + 6)
.attr('height', rh + 6)
.attr('rx', rrx + 2)
.attr('fill', 'none')
.attr('stroke', 'transparent')
.attr('stroke-width', 2)
@@ -351,33 +369,37 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
nodeSelection.filter(d => d.type === 'role')
.append('rect')
.attr('class', 'node-circle')
.attr('x', -ROLE_WIDTH / 2)
.attr('y', -ROLE_HEIGHT / 2)
.attr('width', ROLE_WIDTH)
.attr('height', ROLE_HEIGHT)
.attr('rx', ROLE_RX)
.attr('x', -rw / 2)
.attr('y', -rh / 2)
.attr('width', rw)
.attr('height', rh)
.attr('rx', rrx)
.attr('fill', d => d.orgColor ?? 'var(--accent)')
.attr('fill-opacity', 0.12)
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
.attr('stroke-opacity', 0.4)
.attr('stroke-width', 1)
const mobileLabelMaxLen = 10
nodeSelection.filter(d => d.type === 'role')
.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', d => d.orgColor ?? 'var(--accent)')
.attr('font-size', '11')
.attr('font-size', isMobile ? '10' : '11')
.attr('font-weight', '600')
.attr('font-family', 'var(--font-ui)')
.attr('pointer-events', 'none')
.text(d => d.shortLabel ?? d.label.slice(0, 12))
.text(d => {
const label = d.shortLabel ?? d.label.slice(0, 12)
return isMobile && label.length > mobileLabelMaxLen ? `${label.slice(0, mobileLabelMaxLen - 1)}` : label
})
nodeSelection.filter(d => d.type === 'skill')
.append('circle')
.attr('class', 'focus-ring')
.attr('r', SKILL_RADIUS_ACTIVE + 3)
.attr('r', srActive + 3)
.attr('fill', 'none')
.attr('stroke', 'transparent')
.attr('stroke-width', 2)
@@ -385,7 +407,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
nodeSelection.filter(d => d.type === 'skill')
.append('circle')
.attr('class', 'node-circle')
.attr('r', SKILL_RADIUS_DEFAULT)
.attr('r', srDefault)
.attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E')
.attr('stroke', 'none')
.attr('fill-opacity', 0.2)
@@ -394,15 +416,16 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'middle')
.attr('dy', SKILL_RADIUS_ACTIVE + 14)
.attr('dy', srActive + 14)
.attr('fill', 'var(--text-secondary)')
.attr('font-size', '10')
.attr('font-size', isMobile ? '9' : '10')
.attr('font-family', 'var(--font-geist-mono)')
.attr('pointer-events', 'none')
.attr('opacity', 0)
.text(d => {
const label = d.shortLabel ?? d.label
return label.length > 16 ? `${label.slice(0, 15)}` : label
const maxLen = isMobile ? 12 : 16
return label.length > maxLen ? `${label.slice(0, maxLen - 1)}` : label
})
const roleConnectors = connectorGroup.selectAll('line.role-connector')
@@ -436,14 +459,14 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
if (dur > 0) {
skillNodes.select('.node-circle')
.transition().duration(dur)
.attr('r', SKILL_RADIUS_DEFAULT)
.attr('r', srDefault)
.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('r', srDefault)
.attr('fill-opacity', 0.2)
skillNodes.select('.node-label')
.attr('opacity', 0)
@@ -480,14 +503,14 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
if (dur > 0) {
skillNodes.select('.node-circle')
.transition().duration(dur)
.attr('r', d => isInGroup(d.id) ? SKILL_RADIUS_ACTIVE : SKILL_RADIUS_DEFAULT)
.attr('r', d => isInGroup(d.id) ? srActive : srDefault)
.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('r', d => isInGroup(d.id) ? srActive : srDefault)
.attr('fill-opacity', d => isInGroup(d.id) ? 0.85 : 0.2)
skillNodes.select('.node-label')
.attr('opacity', d => isInGroup(d.id) ? 1 : 0)
@@ -561,11 +584,11 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.alpha(0.65)
.alphaDecay(prefersReducedMotion ? 0.28 : 0.08)
.force('charge', d3.forceManyBody<SimNode>().strength(d =>
d.type === 'role' ? -120 : -55
d.type === 'role' ? (isMobile ? -80 : -120) : (isMobile ? -35 : -55)
))
.force('link', d3.forceLink<SimNode, SimLink>(links)
.id(d => d.id)
.distance(72)
.distance(isMobile ? 48 : 72)
.strength(d => (d as SimLink).strength * 0.5))
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.18))
.force('y', d3.forceY<SimNode>(d => {
@@ -575,22 +598,23 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
return d.homeY
}).strength(d => d.type === 'role' ? 0.98 : 0.18))
.force('collide', d3.forceCollide<SimNode>(d =>
d.type === 'role' ? Math.max(ROLE_WIDTH, ROLE_HEIGHT) / 2 + 10 : SKILL_RADIUS_ACTIVE + 16
d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 6 : 10) : srActive + (isMobile ? 10 : 16)
).iterations(2))
simulationRef.current = simulation
// Padding for skill label text below the node (radius + gap + line height)
const skillBottomPadding = SKILL_RADIUS_ACTIVE + 14 + 12
const skillBottomPadding = srActive + 14 + 12
const rightMargin = isMobile ? 16 : 40
const renderTick = () => {
nodes.forEach(d => {
if (d.type === 'role') {
d.x = Math.max(ROLE_WIDTH / 2 + 6, Math.min(width - ROLE_WIDTH / 2 - 6, d.x))
d.y = Math.max(ROLE_HEIGHT / 2 + topPadding, Math.min(height - ROLE_HEIGHT / 2 - bottomPadding, d.y))
d.x = Math.max(rw / 2 + 6, Math.min(width - rw / 2 - 6, d.x))
d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y))
} else {
d.x = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(width - SKILL_RADIUS_ACTIVE - 40, d.x))
d.y = Math.max(SKILL_RADIUS_ACTIVE + topPadding, Math.min(height - skillBottomPadding, d.y))
d.x = Math.max(srActive + 6, Math.min(width - srActive - rightMargin, d.x))
d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y))
}
})
@@ -609,7 +633,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
roleConnectors
.attr('x1', timelineX)
.attr('y1', d => d.y)
.attr('x2', d => d.x - ROLE_WIDTH / 2)
.attr('x2', d => d.x - rw / 2)
.attr('y2', d => d.y)
const nextNodePositions: Record<string, { x: number; y: number }> = {}
@@ -785,7 +809,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
: `${node.startYear}-present`
const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
const buttonWidth = node.type === 'role' ? ROLE_WIDTH : 34
const mobileBtn = window.innerWidth < 640
const buttonWidth = node.type === 'role' ? (mobileBtn ? MOBILE_ROLE_WIDTH : ROLE_WIDTH) : 34
const buttonHeight = node.type === 'role' ? ROLE_HEIGHT : 34
return (