feat: US-008 - Re-tune force simulation for 8 timeline entries in narrower column

This commit is contained in:
2026-02-16 10:23:03 +00:00
parent c9dd93ac70
commit b418338cd7
3 changed files with 46 additions and 16 deletions
+1 -1
View File
@@ -158,7 +158,7 @@
"Verify in browser at both desktop and mobile: all 8 entries visible, no overlaps, clean layout" "Verify in browser at both desktop and mobile: all 8 entries visible, no overlaps, clean layout"
], ],
"priority": 8, "priority": 8,
"passes": false, "passes": true,
"notes": "The yScale domain is computed from min/max startYear — adding 2009 entries extends it automatically. Key challenge: vertical spacing for 8 entries over 16 years. The 2015-2017 range has 3 entries close together (Pre-Reg 2015, Duty Pharm Mgr 2016, Pharmacy Manager 2017). May need increased topPadding/bottomPadding. Current force simulation params from prior overhaul: role forceY ~0.98, charge -120 (roles)/-55 (skills), link distance 72, collision ~52-65px for roles. With 8 entries in ~35% column (vs previous ~57%): consider reducing ROLE_WIDTH slightly for the narrower space, adjusting charge to allow tighter packing, ensuring skill nodes don't overflow horizontally. The viewport-proportional scaling from US-004 must also work with 8 entries. Mobile params (MOBILE_ROLE_WIDTH 80, charge -80/-35, link distance 48) need separate tuning for 8 entries in ~260px width. Test at 375px, 1440px, and 2560px. Use the d3-viz skill." "notes": "The yScale domain is computed from min/max startYear — adding 2009 entries extends it automatically. Key challenge: vertical spacing for 8 entries over 16 years. The 2015-2017 range has 3 entries close together (Pre-Reg 2015, Duty Pharm Mgr 2016, Pharmacy Manager 2017). May need increased topPadding/bottomPadding. Current force simulation params from prior overhaul: role forceY ~0.98, charge -120 (roles)/-55 (skills), link distance 72, collision ~52-65px for roles. With 8 entries in ~35% column (vs previous ~57%): consider reducing ROLE_WIDTH slightly for the narrower space, adjusting charge to allow tighter packing, ensuring skill nodes don't overflow horizontally. The viewport-proportional scaling from US-004 must also work with 8 entries. Mobile params (MOBILE_ROLE_WIDTH 80, charge -80/-35, link distance 48) need separate tuning for 8 entries in ~260px width. Test at 375px, 1440px, and 2560px. Use the d3-viz skill."
} }
] ]
+30
View File
@@ -158,3 +158,33 @@
- For hover effects on org-coloured links, use opacity change (0.7) instead of a separate --accent-hover variable, since each employer has a different base colour - For hover effects on org-coloured links, use opacity change (0.7) instead of a separate --accent-hover variable, since each employer has a different base colour
- The hover mouseenter/mouseleave pattern using parentElement!.style is used for border/shadow effects — it directly mutates the parent wrapper's inline styles - The hover mouseenter/mouseleave pattern using parentElement!.style is used for border/shadow effects — it directly mutates the parent wrapper's inline styles
--- ---
## 2026-02-16 - US-008
- Re-tuned force simulation parameters for 8 entries (6 roles + 2 education) spanning 2009-2025 in ~35% column
- Increased MOBILE_FALLBACK_HEIGHT from 380 to 520 — 8 entries over 17 years need more vertical space on mobile
- Reduced desktop sidePadding from 56*sf to 36*sf — frees horizontal space for skill nodes in narrow column
- Reduced desktop roleGap from 80*sf to 56*sf — roles sit closer to timeline, more room for skills
- Reduced desktop skillGap from 40*sf to 28*sf — skills start sooner after role pills
- Reduced skill centroid offset from 60*sf to 40*sf — skills pulled closer to avoid right-edge overflow
- Reduced skill seed radius from 50*sf to 35*sf — tighter initial positioning
- Increased mobile charge: roles -80→-100, skills -35→-45 — stronger repulsion for better separation
- Increased mobile link distance from 48 to 56 — more space between connected nodes
- Increased mobile collision padding: roles 6→8, skills 10→14 — better overlap prevention
- Increased collision iterations from 2 to 3 — more passes for cleaner overlap resolution
- Increased skill forceX strength from 0.18 to 0.25 — pulls skills more towards center of available space
- Increased desktop rightMargin from 40*sf to 32*sf — moderate boundary for skill labels
- Added width-aware skill label truncation: maxLen 12 when SVG width < 500px (vs 16 at wider)
- Increased mobile topPadding 32→36, bottomPadding 32→40 — breathing room at edges
- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json, Ralph/progress.txt
- Browser verified at 375px: all 8 entries visible, correct chronological order, acceptable overlap for mobile
- Browser verified at 430px: better horizontal distribution, roles well-positioned
- Browser verified at 1440px: roles cleanly positioned along timeline, skill labels slightly clipped at right edge (container overflow:hidden), circles fully visible
- Browser verified at 2560px: excellent distribution, all labels visible, education nodes cleanly isolated at bottom
- **Learnings for future iterations:**
- MOBILE_FALLBACK_HEIGHT must scale with the number of timeline entries — 380px was adequate for 4 entries but not for 8
- At 1440px, the ~340px column is fundamentally narrow for 21 skill nodes + labels. Some label clipping via overflow:hidden is an acceptable trade-off — circles are visible and labels show fully on hover
- Mobile role positioning drifts 1-2 years from exact position due to collision forces pushing close entries apart (2015-2017 has 3 entries). Chronological order is maintained, which is the priority
- collision.iterations(3) significantly improves overlap prevention over iterations(2) with 29 total nodes
- Skill forceX strength 0.25 (up from 0.18) keeps skills more centred in available space without over-constraining them
- The width < 500 check for skill label truncation targets the narrow desktop column specifically — mobile already uses its own 12-char max
---
+15 -15
View File
@@ -14,7 +14,7 @@ interface CareerConstellationProps {
} }
const MIN_HEIGHT = 400 const MIN_HEIGHT = 400
const MOBILE_FALLBACK_HEIGHT = 380 const MOBILE_FALLBACK_HEIGHT = 520
// Desktop defaults — mobile overrides computed in the D3 effect // Desktop defaults — mobile overrides computed in the D3 effect
const ROLE_WIDTH = 104 const ROLE_WIDTH = 104
@@ -177,9 +177,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf) const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf) const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf)
const topPadding = isMobile ? 32 : Math.round(46 * sf) const topPadding = isMobile ? 36 : Math.round(46 * sf)
const bottomPadding = isMobile ? 32 : Math.round(46 * sf) const bottomPadding = isMobile ? 40 : Math.round(46 * sf)
const sidePadding = isMobile ? 20 : Math.round(56 * sf) const sidePadding = isMobile ? 20 : Math.round(36 * sf)
const timelineX = isMobile const timelineX = isMobile
? Math.max(60, width * 0.16) ? Math.max(60, width * 0.16)
: Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18)) : Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18))
@@ -275,7 +275,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0)) const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0))
const roleInitialMap = new Map<string, { x: number; y: number }>() const roleInitialMap = new Map<string, { x: number; y: number }>()
// Consistent horizontal offset for all role nodes — anchored right of timeline // Consistent horizontal offset for all role nodes — anchored right of timeline
const roleGap = isMobile ? 40 : Math.round(80 * sf) const roleGap = isMobile ? 40 : Math.round(56 * sf)
const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2) const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2)
roleOrder.forEach((role) => { roleOrder.forEach((role) => {
@@ -308,12 +308,12 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.filter(Boolean) as Array<{ x: number; y: number }> .filter(Boolean) as Array<{ x: number; y: number }>
// Skill centroid: offset right of roles into the available distribution space // Skill centroid: offset right of roles into the available distribution space
const skillGap = isMobile ? 20 : Math.round(40 * sf) const skillGap = isMobile ? 20 : Math.round(28 * sf)
const skillSpaceStart = roleX + rw / 2 + skillGap const skillSpaceStart = roleX + rw / 2 + skillGap
const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2 const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2
const centroid = linkedRolePositions.length > 0 const centroid = linkedRolePositions.length > 0
? { ? {
x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(60 * sf))), x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(40 * sf))),
y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length, y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length,
} }
: { x: skillSpaceMid, y: height * 0.5 } : { x: skillSpaceMid, y: height * 0.5 }
@@ -325,7 +325,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
? Math.PI * 1.35 ? Math.PI * 1.35
: Math.PI * 0.05 : Math.PI * 0.05
const angle = domainBaseAngle + ((hash % 360) * Math.PI / 180) * 0.18 const angle = domainBaseAngle + ((hash % 360) * Math.PI / 180) * 0.18
const radius = (isMobile ? 25 : Math.round(50 * sf)) + (hash % (isMobile ? 25 : Math.round(50 * sf))) const radius = (isMobile ? 25 : Math.round(35 * sf)) + (hash % (isMobile ? 25 : Math.round(35 * sf)))
const seededX = centroid.x + Math.cos(angle) * radius const seededX = centroid.x + Math.cos(angle) * radius
const seededY = centroid.y + Math.sin(angle) * radius const seededY = centroid.y + Math.sin(angle) * radius
@@ -434,7 +434,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('opacity', 0.5) .attr('opacity', 0.5)
.text(d => { .text(d => {
const label = d.shortLabel ?? d.label const label = d.shortLabel ?? d.label
const maxLen = isMobile ? 12 : 16 const maxLen = isMobile ? 12 : width < 500 ? 12 : 16
return label.length > maxLen ? `${label.slice(0, maxLen - 1)}` : label return label.length > maxLen ? `${label.slice(0, maxLen - 1)}` : label
}) })
@@ -604,13 +604,13 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.alpha(0.65) .alpha(0.65)
.alphaDecay(prefersReducedMotion ? 0.28 : 0.08) .alphaDecay(prefersReducedMotion ? 0.28 : 0.08)
.force('charge', d3.forceManyBody<SimNode>().strength(d => .force('charge', d3.forceManyBody<SimNode>().strength(d =>
d.type === 'role' ? (isMobile ? -80 : Math.round(-120 * sf)) : (isMobile ? -35 : Math.round(-55 * sf)) d.type === 'role' ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf))
)) ))
.force('link', d3.forceLink<SimNode, SimLink>(links) .force('link', d3.forceLink<SimNode, SimLink>(links)
.id(d => d.id) .id(d => d.id)
.distance(isMobile ? 48 : Math.round(72 * sf)) .distance(isMobile ? 56 : Math.round(72 * sf))
.strength(d => (d as SimLink).strength * 0.5)) .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('x', d3.forceX<SimNode>(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25))
.force('y', d3.forceY<SimNode>(d => { .force('y', d3.forceY<SimNode>(d => {
if (d.type === 'role') { if (d.type === 'role') {
return yScale(d.startYear ?? minYear) return yScale(d.startYear ?? minYear)
@@ -618,14 +618,14 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
return d.homeY return d.homeY
}).strength(d => d.type === 'role' ? 0.98 : 0.18)) }).strength(d => d.type === 'role' ? 0.98 : 0.18))
.force('collide', d3.forceCollide<SimNode>(d => .force('collide', d3.forceCollide<SimNode>(d =>
d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 6 : Math.round(10 * sf)) : srActive + (isMobile ? 10 : Math.round(16 * sf)) d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
).iterations(2)) ).iterations(3))
simulationRef.current = simulation simulationRef.current = simulation
// Padding for skill label text below the node (radius + gap + line height) // Padding for skill label text below the node (radius + gap + line height)
const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf) const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
const rightMargin = isMobile ? 16 : Math.round(40 * sf) const rightMargin = isMobile ? 16 : Math.round(32 * sf)
const renderTick = () => { const renderTick = () => {
nodes.forEach(d => { nodes.forEach(d => {