diff --git a/Ralph/prd.json b/Ralph/prd.json index e9b36da..91f290f 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -158,7 +158,7 @@ "Verify in browser at both desktop and mobile: all 8 entries visible, no overlaps, clean layout" ], "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." } ] diff --git a/Ralph/progress.txt b/Ralph/progress.txt index d307a07..caf66be 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -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 - 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 +--- diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 86334dd..5f188f4 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -14,7 +14,7 @@ interface CareerConstellationProps { } const MIN_HEIGHT = 400 -const MOBILE_FALLBACK_HEIGHT = 380 +const MOBILE_FALLBACK_HEIGHT = 520 // Desktop defaults — mobile overrides computed in the D3 effect const ROLE_WIDTH = 104 @@ -177,9 +177,9 @@ const CareerConstellation: React.FC = ({ 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 topPadding = isMobile ? 32 : Math.round(46 * sf) - const bottomPadding = isMobile ? 32 : Math.round(46 * sf) - const sidePadding = isMobile ? 20 : Math.round(56 * sf) + const topPadding = isMobile ? 36 : Math.round(46 * sf) + const bottomPadding = isMobile ? 40 : Math.round(46 * sf) + const sidePadding = isMobile ? 20 : Math.round(36 * sf) const timelineX = isMobile ? Math.max(60, width * 0.16) : Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18)) @@ -275,7 +275,7 @@ const CareerConstellation: React.FC = ({ const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0)) const roleInitialMap = new Map() // 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) roleOrder.forEach((role) => { @@ -308,12 +308,12 @@ const CareerConstellation: React.FC = ({ .filter(Boolean) as Array<{ x: number; y: number }> // 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 skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2 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, } : { x: skillSpaceMid, y: height * 0.5 } @@ -325,7 +325,7 @@ const CareerConstellation: React.FC = ({ ? Math.PI * 1.35 : Math.PI * 0.05 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 seededY = centroid.y + Math.sin(angle) * radius @@ -434,7 +434,7 @@ const CareerConstellation: React.FC = ({ .attr('opacity', 0.5) .text(d => { 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 }) @@ -604,13 +604,13 @@ const CareerConstellation: React.FC = ({ .alpha(0.65) .alphaDecay(prefersReducedMotion ? 0.28 : 0.08) .force('charge', d3.forceManyBody().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(links) .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)) - .force('x', d3.forceX(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.18)) + .force('x', d3.forceX(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25)) .force('y', d3.forceY(d => { if (d.type === 'role') { return yScale(d.startYear ?? minYear) @@ -618,14 +618,14 @@ const CareerConstellation: React.FC = ({ return d.homeY }).strength(d => d.type === 'role' ? 0.98 : 0.18)) .force('collide', d3.forceCollide(d => - d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 6 : Math.round(10 * sf)) : srActive + (isMobile ? 10 : Math.round(16 * sf)) - ).iterations(2)) + d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf)) + ).iterations(3)) simulationRef.current = simulation // Padding for skill label text below the node (radius + gap + line height) 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 = () => { nodes.forEach(d => {