feat: US-009 - Force simulation tuning for clinical layout
This commit is contained in:
+1
-1
@@ -176,7 +176,7 @@
|
|||||||
"Verify in browser — nodes appear organised and intentional, not randomly scattered"
|
"Verify in browser — nodes appear organised and intentional, not randomly scattered"
|
||||||
],
|
],
|
||||||
"priority": 9,
|
"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."
|
"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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,6 +76,10 @@
|
|||||||
- callbacksRef pattern in CareerConstellation prevents stale closures — always add new callbacks there
|
- callbacksRef pattern in CareerConstellation prevents stale closures — always add new callbacks there
|
||||||
- LastConsultationSubsection is defined inline in DashboardLayout.tsx, not a separate file
|
- LastConsultationSubsection is defined inline in DashboardLayout.tsx, not a separate file
|
||||||
- Link lines are `<path>` elements (not `<line>`) using quadratic bezier curves — tick handler sets `d` attr, not x1/y1/x2/y2. CSS transitions handle highlight animations on stroke properties
|
- Link lines are `<path>` elements (not `<line>`) 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
|
## 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
|
- 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
|
- 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
|
||||||
|
---
|
||||||
|
|||||||
@@ -248,11 +248,12 @@ 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
|
||||||
|
const roleX = Math.min(width - sidePadding - ROLE_WIDTH / 2, timelineX + 80 + ROLE_WIDTH / 2)
|
||||||
|
|
||||||
roleOrder.forEach((role, index) => {
|
roleOrder.forEach((role) => {
|
||||||
const jitter = (index % 2 === 0 ? -1 : 1) * 32
|
|
||||||
roleInitialMap.set(role.id, {
|
roleInitialMap.set(role.id, {
|
||||||
x: Math.min(width - sidePadding, Math.max(timelineX + 64, timelineX + 124 + jitter)),
|
x: roleX,
|
||||||
y: yScale(role.startYear ?? minYear),
|
y: yScale(role.startYear ?? minYear),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -279,12 +280,15 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
.map(roleId => roleInitialMap.get(roleId))
|
.map(roleId => roleInitialMap.get(roleId))
|
||||||
.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
|
||||||
|
const skillSpaceStart = roleX + ROLE_WIDTH / 2 + 40
|
||||||
|
const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2
|
||||||
const centroid = linkedRolePositions.length > 0
|
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,
|
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 hash = hashString(n.id)
|
||||||
const domainBaseAngle = n.domain === 'clinical'
|
const domainBaseAngle = n.domain === 'clinical'
|
||||||
@@ -293,7 +297,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 = 54 + (hash % 46)
|
const radius = 50 + (hash % 50)
|
||||||
|
|
||||||
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
|
||||||
@@ -547,33 +551,38 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
|
|
||||||
const simulation = d3.forceSimulation<SimNode>(nodes)
|
const simulation = d3.forceSimulation<SimNode>(nodes)
|
||||||
.alpha(0.65)
|
.alpha(0.65)
|
||||||
.alphaDecay(prefersReducedMotion ? 0.26 : 0.06)
|
.alphaDecay(prefersReducedMotion ? 0.28 : 0.08)
|
||||||
.force('charge', d3.forceManyBody<SimNode>().strength(-85))
|
.force('charge', d3.forceManyBody<SimNode>().strength(d =>
|
||||||
|
d.type === 'role' ? -120 : -55
|
||||||
|
))
|
||||||
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
||||||
.id(d => d.id)
|
.id(d => d.id)
|
||||||
.distance(56)
|
.distance(72)
|
||||||
.strength(d => (d as SimLink).strength * 0.7))
|
.strength(d => (d as SimLink).strength * 0.5))
|
||||||
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => d.type === 'role' ? 1 : 0.2))
|
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.18))
|
||||||
.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)
|
||||||
}
|
}
|
||||||
return d.homeY
|
return d.homeY
|
||||||
}).strength(d => d.type === 'role' ? 1 : 0.2))
|
}).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(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
|
simulationRef.current = simulation
|
||||||
|
|
||||||
|
// Padding for skill label text below the node (radius + gap + line height)
|
||||||
|
const skillBottomPadding = SKILL_RADIUS_ACTIVE + 14 + 12
|
||||||
|
|
||||||
const renderTick = () => {
|
const renderTick = () => {
|
||||||
nodes.forEach(d => {
|
nodes.forEach(d => {
|
||||||
if (d.type === 'role') {
|
if (d.type === 'role') {
|
||||||
d.x = Math.max(ROLE_WIDTH / 2 + 6, Math.min(width - ROLE_WIDTH / 2 - 6, d.x))
|
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 {
|
} else {
|
||||||
d.x = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(width - SKILL_RADIUS_ACTIVE - 6, d.x))
|
d.x = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(width - SKILL_RADIUS_ACTIVE - 40, d.x))
|
||||||
d.y = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(height - SKILL_RADIUS_ACTIVE - 6, d.y))
|
d.y = Math.max(SKILL_RADIUS_ACTIVE + topPadding, Math.min(height - skillBottomPadding, d.y))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -624,7 +633,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
|
|
||||||
if (prefersReducedMotion) {
|
if (prefersReducedMotion) {
|
||||||
simulation.stop()
|
simulation.stop()
|
||||||
for (let i = 0; i < 220; i++) {
|
for (let i = 0; i < 150; i++) {
|
||||||
simulation.tick()
|
simulation.tick()
|
||||||
}
|
}
|
||||||
renderTick()
|
renderTick()
|
||||||
|
|||||||
Reference in New Issue
Block a user