diff --git a/Ralph/prd.json b/Ralph/prd.json index 916c5fe..ef75a3a 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -176,7 +176,7 @@ "Verify in browser — nodes appear organised and intentional, not randomly scattered" ], "priority": 9, - "passes": false, + "passes": true, "notes": "The simulation is configured at lines ~515-532. Key parameters to tune: forceX/forceY strengths for roles (increase to ~1.0), forceX/forceY for skills (keep at 0.15-0.25 for organic clustering), charge strength (currently -85, may need adjustment with new pill-shaped roles), collide radius (needs to account for pill width for roles, and active radius + label for skills), link distance (currently 56, may need increase with larger role nodes). The alphaDecay is currently 0.06 for animated mode — could increase to 0.08-0.1 for faster settling. For the reduced-motion path, the 220 ticks (line 580) may need adjustment. Use the d3-viz skill for implementation." }, { diff --git a/Ralph/progress.txt b/Ralph/progress.txt index 2d3f47f..5a25539 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -76,6 +76,10 @@ - callbacksRef pattern in CareerConstellation prevents stale closures — always add new callbacks there - LastConsultationSubsection is defined inline in DashboardLayout.tsx, not a separate file - Link lines are `` elements (not ``) using quadratic bezier curves — tick handler sets `d` attr, not x1/y1/x2/y2. CSS transitions handle highlight animations on stroke properties +- Force simulation parameters: role forceX/Y strength ~1.0, skill forceX/Y ~0.18, charge -120 (role) / -55 (skill), link distance 72, collide iterations 2 +- Role homeX uses consistent offset (`timelineX + 80 + ROLE_WIDTH/2`), no jitter — roles align vertically +- Skill homeX pushed right of roles: `skillSpaceStart = roleX + ROLE_WIDTH/2 + 40` ensures skills cluster in the right-side space +- Boundary clamping accounts for `topPadding`/`bottomPadding` and `skillBottomPadding` (radius + gap + label line height) to prevent label clipping --- ## 2026-02-16 - US-005 @@ -138,3 +142,25 @@ - Using React.Fragment with the `.map()` allows conditional separator rendering (skip before first item) without extra wrapper divs - The container div's overflow:hidden clips the legend's border-radius corners cleanly --- + +## 2026-02-16 - US-009 +- Tuned D3 force simulation for clinical layout — role nodes firmly anchored, skill nodes distributed cleanly to the right +- Role positioning: removed jitter from homeX, all roles at consistent `timelineX + 80 + ROLE_WIDTH/2` offset +- Skill positioning: pushed centroid right of roles (`skillSpaceStart = roleX + ROLE_WIDTH/2 + 40`) so skills cluster in available right-side space +- Charge force: split by node type — roles get -120 (stronger repulsion for pill shapes), skills get -55 (moderate clustering) +- Link distance increased from 56 to 72 to account for wider pill-shaped role nodes +- Link strength reduced from `strength * 0.7` to `strength * 0.5` for more organic skill distribution +- Skill forceX/Y strength reduced from 0.2 to 0.18 for slightly more organic spread +- Role forceY reduced marginally from 1.0 to 0.98 (effectively still anchored but allows micro-adjustment) +- Collision force: skill radius increased to `SKILL_RADIUS_ACTIVE + 16` (27px) to prevent label overlap on hover; added `.iterations(2)` for better separation +- alphaDecay increased from 0.06 to 0.08 (animated) and 0.26 to 0.28 (reduced-motion) for faster settling (~1.5s) +- Reduced-motion tick count decreased from 220 to 150 to match faster alphaDecay +- Boundary clamping: roles now respect topPadding/bottomPadding; skills use skillBottomPadding (radius + gap + label height = 37px) and 40px right margin for label overflow +- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json, Ralph/progress.txt +- **Learnings for future iterations:** + - Split charge strength by node type (`d => d.type === 'role' ? -120 : -55`) — pill-shaped roles need stronger repulsion to avoid overlap while small skill nodes can cluster more tightly + - Collision `.iterations(2)` significantly improves separation quality for densely connected subgraphs at minimal performance cost + - Consistent role homeX (no jitter) creates a clean vertical column effect — visual order comes from the simulation, not random initial positioning + - Skill homeX centroid should be explicitly pushed right of the role column, not just inherited from role positions — the +60 offset plus skillSpaceStart ensures skills don't overlap role pills + - Boundary clamping must account for the full visual footprint including labels: for skills, that's radius + dy offset + text line height below the node center +--- diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 34df0e4..27b0494 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -248,11 +248,12 @@ 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 roleX = Math.min(width - sidePadding - ROLE_WIDTH / 2, timelineX + 80 + ROLE_WIDTH / 2) - roleOrder.forEach((role, index) => { - const jitter = (index % 2 === 0 ? -1 : 1) * 32 + roleOrder.forEach((role) => { roleInitialMap.set(role.id, { - x: Math.min(width - sidePadding, Math.max(timelineX + 64, timelineX + 124 + jitter)), + x: roleX, y: yScale(role.startYear ?? minYear), }) }) @@ -279,12 +280,15 @@ const CareerConstellation: React.FC = ({ .map(roleId => roleInitialMap.get(roleId)) .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 skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2 const centroid = linkedRolePositions.length > 0 ? { - x: linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length, + x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + 60), y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length, } - : { x: width * 0.55, y: height * 0.5 } + : { x: skillSpaceMid, y: height * 0.5 } const hash = hashString(n.id) const domainBaseAngle = n.domain === 'clinical' @@ -293,7 +297,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 = 54 + (hash % 46) + const radius = 50 + (hash % 50) const seededX = centroid.x + Math.cos(angle) * radius const seededY = centroid.y + Math.sin(angle) * radius @@ -547,33 +551,38 @@ const CareerConstellation: React.FC = ({ const simulation = d3.forceSimulation(nodes) .alpha(0.65) - .alphaDecay(prefersReducedMotion ? 0.26 : 0.06) - .force('charge', d3.forceManyBody().strength(-85)) + .alphaDecay(prefersReducedMotion ? 0.28 : 0.08) + .force('charge', d3.forceManyBody().strength(d => + d.type === 'role' ? -120 : -55 + )) .force('link', d3.forceLink(links) .id(d => d.id) - .distance(56) - .strength(d => (d as SimLink).strength * 0.7)) - .force('x', d3.forceX(d => d.homeX).strength(d => d.type === 'role' ? 1 : 0.2)) + .distance(72) + .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('y', d3.forceY(d => { if (d.type === 'role') { return yScale(d.startYear ?? minYear) } return d.homeY - }).strength(d => d.type === 'role' ? 1 : 0.2)) + }).strength(d => d.type === 'role' ? 0.98 : 0.18)) .force('collide', d3.forceCollide(d => - d.type === 'role' ? Math.max(ROLE_WIDTH, ROLE_HEIGHT) / 2 + 8 : SKILL_RADIUS_ACTIVE + 10 - )) + d.type === 'role' ? Math.max(ROLE_WIDTH, ROLE_HEIGHT) / 2 + 10 : SKILL_RADIUS_ACTIVE + 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 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 + 6, Math.min(height - ROLE_HEIGHT / 2 - 6, d.y)) + d.y = Math.max(ROLE_HEIGHT / 2 + topPadding, Math.min(height - ROLE_HEIGHT / 2 - bottomPadding, d.y)) } else { - d.x = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(width - SKILL_RADIUS_ACTIVE - 6, d.x)) - d.y = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(height - SKILL_RADIUS_ACTIVE - 6, d.y)) + 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)) } }) @@ -624,7 +633,7 @@ const CareerConstellation: React.FC = ({ if (prefersReducedMotion) { simulation.stop() - for (let i = 0; i < 220; i++) { + for (let i = 0; i < 150; i++) { simulation.tick() } renderTick()