From f3e9b58e8d7b984c56e80d18dd8377d4ae43c542 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Mon, 16 Feb 2026 09:50:07 +0000 Subject: [PATCH] feat: US-004 - Viewport-proportional scaling for large screens --- Ralph/prd.json | 2 +- Ralph/progress.txt | 22 +++++++++ src/components/CareerConstellation.tsx | 67 ++++++++++++++------------ 3 files changed, 60 insertions(+), 31 deletions(-) diff --git a/Ralph/prd.json b/Ralph/prd.json index ebc6bd0..e5f0cc9 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -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." }, { diff --git a/Ralph/progress.txt b/Ralph/progress.txt index 8b22ecb..f2ff4d3 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -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 +--- diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index e7bd7be..0e818c2 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -104,7 +104,7 @@ const CareerConstellation: React.FC = ({ const simulationRef = useRef | 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(null) const [pinnedNodeId, setPinnedNodeId] = useState(null) const [nodeButtonPositions, setNodeButtonPositions] = useState>({}) @@ -132,7 +132,12 @@ const CareerConstellation: React.FC = ({ // 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 = ({ 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 = ({ 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 = ({ .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 = ({ 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 : 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 = ({ .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 = ({ ? 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 = ({ .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 = ({ .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 = ({ .alpha(0.65) .alphaDecay(prefersReducedMotion ? 0.28 : 0.08) .force('charge', d3.forceManyBody().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(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(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.18)) .force('y', d3.forceY(d => { @@ -598,14 +604,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 : 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 = ({ 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 (