feat: US-004 - Viewport-proportional scaling for large screens

This commit is contained in:
2026-02-16 09:50:07 +00:00
parent 76692682da
commit f3e9b58e8d
3 changed files with 60 additions and 31 deletions
+1 -1
View File
@@ -76,7 +76,7 @@
"Verify in browser at 1440px and 2560px widths: elements clearly legible and well-proportioned"
],
"priority": 4,
"passes": false,
"passes": true,
"notes": "Compute scaleFactor in the dimensions useEffect that already handles containerHeight and resize. Use window.innerWidth (not container.clientWidth — known overflow issue on mobile). Create scaled constants: const scaledRoleWidth = Math.round(ROLE_WIDTH * scaleFactor), etc. Apply throughout D3 rendering where base constants are used. Force simulation parameters also scale: charge strength, link distance, collision radius. The isMobile check (window.innerWidth < 640) bypasses scaling entirely, using MOBILE_ constants as-is. The existing MOBILE_ROLE_WIDTH (80), MOBILE_SKILL_RADIUS_DEFAULT (6), MOBILE_SKILL_RADIUS_ACTIVE (9) remain unchanged. Store scaleFactor in a ref or state so D3 code can access it. Use the d3-viz skill."
},
{
+22
View File
@@ -24,6 +24,8 @@
- applyGraphHighlight is the single source of truth for all visual states (resting, highlighted, dimmed)
- Resting state values (US-003): skill fill-opacity 0.35, skill label opacity 0.5, link stroke-opacity 0.15, dimmed node opacity 0.15, active skill fill-opacity 0.9
- Initial D3 rendering values MUST match applyGraphHighlight resting values — initial stroke-opacity, fill-opacity, label opacity are set during node/link creation AND in the highlight function
- Viewport-proportional scaling: dimensions state includes { width, height, scaleFactor }. D3 effect uses `const sf = isMobile ? 1 : scaleFactor`. All desktop pixel values scaled via Math.round(value * sf)
- scaleFactor formula: Math.max(1, Math.min(1.6, viewportWidth / 1440)) — 1.0x at ≤1440px, 1.6x at ≥2560px. Only active at ≥1024px viewport
- Use the d3-viz skill for all D3 rendering stories
- Consultation entries ordered reverse-chronologically (newest first) — new entries go at the end of the array
- Constellation role nodes, skill mappings, and links are in constellation.ts — adding nodes there automatically extends yScale domain and screen reader description
@@ -73,3 +75,23 @@
- The highlighted branch also has a fallback opacity for non-active links/labels — remember to update those too (3 places total: initial render, resting branch, highlighted branch fallback)
- The constellation ResizeObserver + containerHeight system handles narrower columns automatically — no explicit graph resize code needed
---
## 2026-02-16 - US-004
- Added viewport-proportional scaling: scaleFactor = Math.max(1, Math.min(1.6, viewportWidth / 1440))
- scaleFactor stored in dimensions state alongside width/height, computed in resize useEffect
- Created local `sf` variable in D3 effect (isMobile ? 1 : scaleFactor) to bypass scaling on mobile
- Scaled node sizes: ROLE_WIDTH (104→~166), ROLE_HEIGHT (32→~51), ROLE_RX (16→~26), SKILL_RADIUS_DEFAULT (7→~11), SKILL_RADIUS_ACTIVE (11→~18)
- Scaled font sizes: year labels (10→11 base, scales to ~18), role labels (11→12 base, scales to ~19), skill labels (10→11 base, scales to ~18)
- Scaled spacing: topPadding, bottomPadding, sidePadding, timelineX, roleGap, skillGap, centroid offsets, seeding radius, rightMargin, skillBottomPadding, label dy offset
- Scaled force simulation: charge (-120→~-192 role, -55→~-88 skill), link distance (72→~115), collision radius offset (10→~16 role, 16→~26 skill)
- Scaled accessibility button sizes to match scaled SVG nodes
- Mobile (< 640px) completely bypasses scaling (sf=1), uses MOBILE_ constants unchanged
- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json, Ralph/progress.txt
- Browser verified at 1440px (sf=1.0, identical to pre-change) and 2560px (sf=1.6, all elements clearly larger and well-proportioned)
- **Learnings for future iterations:**
- Store scaleFactor in the dimensions state object, not a separate ref — keeps it synced with width/height changes
- Use `const sf = isMobile ? 1 : scaleFactor` at top of D3 effect to avoid repeating the mobile guard everywhere
- Every hardcoded pixel value in the D3 effect that relates to element sizing, spacing, or force params needs sf multiplication on desktop path
- Math.round() wraps all scaled values to avoid sub-pixel rendering artifacts
- Accessibility overlay buttons in the React JSX also need scaling — they use base constants directly, not the D3-scoped variables
---
+37 -30
View File
@@ -104,7 +104,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover })
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT })
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null)
const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})
@@ -132,7 +132,12 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
// Use viewport width for breakpoint check since container may overflow on mobile
const viewportWidth = window.innerWidth
const height = getHeight(viewportWidth, containerHeight)
setDimensions({ width, height })
// Viewport-proportional scaling: 1.0x at 1440px, up to 1.6x at 2560px+
// Only applies on desktop (>=1024px); mobile/tablet stays at 1.0
const scaleFactor = viewportWidth >= 1024
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
: 1
setDimensions({ width, height, scaleFactor })
}
updateDimensions()
@@ -147,9 +152,10 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const svg = d3.select(svgRef.current)
if (!svgRef.current) return
const { width, height } = dimensions
const { width, height, scaleFactor } = dimensions
// Use viewport width for responsive breakpoint — container.clientWidth overflows on mobile
const isMobile = window.innerWidth < 640
const sf = isMobile ? 1 : scaleFactor
if (simulationRef.current) {
simulationRef.current.stop()
@@ -161,19 +167,19 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const minYear = Math.min(...years)
const maxYear = Math.max(...years)
// 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
// Responsive layout parameters — desktop values scale proportionally with viewport
const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf)
const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf)
const rrx = isMobile ? ROLE_RX : Math.round(ROLE_RX * 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 topPadding = isMobile ? 32 : 46
const bottomPadding = isMobile ? 32 : 46
const sidePadding = isMobile ? 20 : 56
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 timelineX = isMobile
? Math.max(60, width * 0.16)
: Math.max(100, Math.min(160, width * 0.18))
: Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18))
const yScale = d3.scaleLinear()
.domain([maxYear, minYear])
@@ -247,10 +253,10 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.data(tickYears)
.join('text')
.attr('class', 'year-label')
.attr('x', timelineX - (isMobile ? 8 : 12))
.attr('y', d => yScale(d) + 4)
.attr('x', timelineX - (isMobile ? 8 : Math.round(12 * sf)))
.attr('y', d => yScale(d) + Math.round(4 * sf))
.attr('text-anchor', 'end')
.attr('font-size', isMobile ? '9' : '10')
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
.attr('font-family', 'var(--font-geist-mono)')
.attr('fill', 'var(--text-tertiary)')
.text(d => d)
@@ -265,7 +271,7 @@ 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 roleGap = isMobile ? 40 : 80
const roleGap = isMobile ? 40 : Math.round(80 * sf)
const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2)
roleOrder.forEach((role) => {
@@ -298,12 +304,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 skillGap = isMobile ? 20 : 40
const skillGap = isMobile ? 20 : Math.round(40 * 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 : 60)),
x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(60 * sf))),
y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length,
}
: { x: skillSpaceMid, y: height * 0.5 }
@@ -315,7 +321,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 = (isMobile ? 25 : 50) + (hash % (isMobile ? 25 : 50))
const radius = (isMobile ? 25 : Math.round(50 * sf)) + (hash % (isMobile ? 25 : Math.round(50 * sf)))
const seededX = centroid.x + Math.cos(angle) * radius
const seededY = centroid.y + Math.sin(angle) * radius
@@ -387,7 +393,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', d => d.orgColor ?? 'var(--accent)')
.attr('font-size', isMobile ? '10' : '11')
.attr('font-size', isMobile ? '10' : `${Math.round(12 * sf)}`)
.attr('font-weight', '600')
.attr('font-family', 'var(--font-ui)')
.attr('pointer-events', 'none')
@@ -416,9 +422,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'middle')
.attr('dy', srActive + 14)
.attr('dy', srActive + Math.round(14 * sf))
.attr('fill', 'var(--text-secondary)')
.attr('font-size', isMobile ? '9' : '10')
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
.attr('font-family', 'var(--font-geist-mono)')
.attr('pointer-events', 'none')
.attr('opacity', 0.5)
@@ -584,11 +590,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' ? (isMobile ? -80 : -120) : (isMobile ? -35 : -55)
d.type === 'role' ? (isMobile ? -80 : Math.round(-120 * sf)) : (isMobile ? -35 : Math.round(-55 * sf))
))
.force('link', d3.forceLink<SimNode, SimLink>(links)
.id(d => d.id)
.distance(isMobile ? 48 : 72)
.distance(isMobile ? 48 : Math.round(72 * sf))
.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 => {
@@ -598,14 +604,14 @@ 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(rw, rh) / 2 + (isMobile ? 6 : 10) : srActive + (isMobile ? 10 : 16)
d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 6 : Math.round(10 * sf)) : srActive + (isMobile ? 10 : Math.round(16 * sf))
).iterations(2))
simulationRef.current = simulation
// Padding for skill label text below the node (radius + gap + line height)
const skillBottomPadding = srActive + 14 + 12
const rightMargin = isMobile ? 16 : 40
const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
const rightMargin = isMobile ? 16 : Math.round(40 * sf)
const renderTick = () => {
nodes.forEach(d => {
@@ -810,8 +816,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
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
const btnSf = mobileBtn ? 1 : dimensions.scaleFactor
const buttonWidth = node.type === 'role' ? (mobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf)
const buttonHeight = node.type === 'role' ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf)
return (
<button