diff --git a/Ralph/prd.json b/Ralph/prd.json index d8abddf..ff1349e 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -77,7 +77,7 @@ "Verify in browser — role nodes appear as labelled pill badges along the timeline" ], "priority": 4, - "passes": false, + "passes": true, "notes": "This changes role nodes from to with rounded corners. The nodeSelection code that filters d.type === 'role' (lines ~354-380) needs to append 'rect' instead of 'circle'. Position with x = -ROLE_WIDTH/2 and y = -ROLE_HEIGHT/2 so they centre on the force simulation position. The focus-ring can also become a rect. The text element stays largely the same but needs its positioning adjusted (no more dy offset needed if dominant-baseline is middle). The collision force for roles should use a radius roughly equal to Math.max(ROLE_WIDTH, ROLE_HEIGHT)/2 + padding. The connector line should go from the timeline X position to the left edge of the pill node. Use the d3-viz skill for implementation." }, { diff --git a/Ralph/progress.txt b/Ralph/progress.txt index a5cdbb1..6b679a7 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -16,6 +16,8 @@ - Use the d3-viz skill for all D3 rendering stories - yScale domain reversal automatically flows through all timeline elements (guides, dots, labels, role positions, simulation forces) — no per-element changes needed - Always use CSS custom properties (var(--border), var(--surface), var(--text-tertiary), etc.) for colours in D3 — never hardcode hex values +- SVG shadows: use with in , apply to groups via .attr('filter', 'url(#filter-id)'), clear with .attr('filter', null) +- Role nodes are already pill-shaped rects (ROLE_WIDTH=104, ROLE_HEIGHT=32, ROLE_RX=16) with orgColor badge styling — check before re-implementing ## 2026-02-16 - US-001 - Reversed yScale domain from [minYear, maxYear] to [maxYear, minYear] so 2025 appears at top @@ -55,3 +57,16 @@ - Year ticks as horizontal lines are positioned with x1=timelineX, x2=timelineX+width — they extend right from the timeline axis, not centred on it - The container div border + borderRadius + overflow:hidden creates a clean framed look for the SVG without needing an SVG-level border --- + +## 2026-02-16 - US-004 +- Added SVG filter defs for drop shadows: shadow-sm-filter (subtle, for hover/connected) and shadow-md-filter (stronger, for active/pinned) +- Updated applyGraphHighlight to apply shadow filters on role node `` elements during highlight states +- Resting state: no filter; connected role: shadow-sm; active/pinned role: shadow-md with stroke-opacity 1 and stroke-width 1.5 +- Note: most of US-004 (pill shape, orgColor styling, connector lines, focus rings, collision detection) was already implemented in prior iterations +- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json +- **Learnings for future iterations:** + - SVG drop shadows use `` with `` — apply to the parent `` group, not the individual shape, for proper rendering + - Filter bounds need generous overflow (x/y -20%, width/height 140%+) to avoid clipping the shadow + - When clearing a filter, use `.attr('filter', null)` — not empty string + - The role node pill rendering (rect with rx/ry, orgColor fill at 0.12, border at 0.4) was built incrementally across US-003 and US-004 — check existing code before implementing to avoid duplication +--- diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 5f4f12d..2849cb9 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -13,9 +13,10 @@ interface CareerConstellationProps { const MIN_HEIGHT = 400 const MOBILE_FALLBACK_HEIGHT = 360 -const ROLE_RADIUS = 30 +const ROLE_WIDTH = 104 +const ROLE_HEIGHT = 32 +const ROLE_RX = 16 const SKILL_RADIUS = 14 -const COLLIDE_RADIUS = 36 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches @@ -165,6 +166,27 @@ const CareerConstellation: React.FC = ({ .attr('fill', 'var(--surface)') .attr('rx', 6) + // SVG filter defs for role node shadows + const defs = svg.append('defs') + + const shadowSm = defs.append('filter') + .attr('id', 'shadow-sm-filter') + .attr('x', '-20%').attr('y', '-20%') + .attr('width', '140%').attr('height', '140%') + shadowSm.append('feDropShadow') + .attr('dx', 0).attr('dy', 1) + .attr('stdDeviation', 1.5) + .attr('flood-color', 'rgba(26,43,42,0.08)') + + const shadowMd = defs.append('filter') + .attr('id', 'shadow-md-filter') + .attr('x', '-30%').attr('y', '-30%') + .attr('width', '160%').attr('height', '160%') + shadowMd.append('feDropShadow') + .attr('dx', 0).attr('dy', 2) + .attr('stdDeviation', 3) + .attr('flood-color', 'rgba(26,43,42,0.12)') + // Timeline guides and subtle era lanes const timelineGroup = svg.append('g').attr('class', 'timeline-guides') @@ -285,6 +307,7 @@ const CareerConstellation: React.FC = ({ }) const linkGroup = svg.append('g').attr('class', 'links') + const connectorGroup = svg.append('g').attr('class', 'connectors') const nodeGroup = svg.append('g').attr('class', 'nodes') const linkSelection = linkGroup.selectAll('line') @@ -302,32 +325,42 @@ const CareerConstellation: React.FC = ({ .attr('data-node-id', d => d.id) nodeSelection.filter(d => d.type === 'role') - .append('circle') + .append('rect') .attr('class', 'focus-ring') - .attr('r', ROLE_RADIUS + 4) + .attr('x', -ROLE_WIDTH / 2 - 3) + .attr('y', -ROLE_HEIGHT / 2 - 3) + .attr('width', ROLE_WIDTH + 6) + .attr('height', ROLE_HEIGHT + 6) + .attr('rx', ROLE_RX + 2) .attr('fill', 'none') .attr('stroke', 'transparent') .attr('stroke-width', 2) nodeSelection.filter(d => d.type === 'role') - .append('circle') + .append('rect') .attr('class', 'node-circle') - .attr('r', ROLE_RADIUS) - .attr('fill', d => d.orgColor ?? '#0D6E6E') - .attr('stroke', '#FFFFFF') - .attr('stroke-width', 2) + .attr('x', -ROLE_WIDTH / 2) + .attr('y', -ROLE_HEIGHT / 2) + .attr('width', ROLE_WIDTH) + .attr('height', ROLE_HEIGHT) + .attr('rx', ROLE_RX) + .attr('fill', d => d.orgColor ?? 'var(--accent)') + .attr('fill-opacity', 0.12) + .attr('stroke', d => d.orgColor ?? 'var(--accent)') + .attr('stroke-opacity', 0.4) + .attr('stroke-width', 1) nodeSelection.filter(d => d.type === 'role') .append('text') .attr('class', 'node-label') .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'middle') - .attr('fill', '#FFFFFF') - .attr('font-size', '10') + .attr('dominant-baseline', 'central') + .attr('fill', d => d.orgColor ?? 'var(--accent)') + .attr('font-size', '11') .attr('font-weight', '600') .attr('font-family', 'var(--font-ui)') .attr('pointer-events', 'none') - .text(d => d.shortLabel ?? d.label.slice(0, 9)) + .text(d => d.shortLabel ?? d.label.slice(0, 12)) nodeSelection.filter(d => d.type === 'skill') .append('circle') @@ -352,6 +385,14 @@ const CareerConstellation: React.FC = ({ return label.length > 16 ? `${label.slice(0, 15)}…` : label }) + const roleConnectors = connectorGroup.selectAll('line.role-connector') + .data(nodes.filter(n => n.type === 'role')) + .join('line') + .attr('class', 'role-connector') + .attr('stroke', 'var(--border)') + .attr('stroke-width', 1) + .attr('stroke-opacity', 0.3) + const connectedMap = new Map>() constellationLinks.forEach(l => { if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set()) @@ -389,6 +430,11 @@ const CareerConstellation: React.FC = ({ const applyGraphHighlight = (activeNodeId: string | null) => { if (!activeNodeId) { nodeSelection.style('opacity', '1') + nodeSelection.filter(d => d.type === 'role') + .attr('filter', null) + .select('.node-circle') + .attr('stroke-opacity', 0.4) + .attr('stroke-width', 1) nodeSelection.filter(d => d.type === 'skill') .select('.node-circle') .attr('r', SKILL_RADIUS) @@ -407,6 +453,20 @@ const CareerConstellation: React.FC = ({ return '0.16' }) + nodeSelection.filter(d => d.type === 'role') + .attr('filter', d => { + if (d.id === activeNodeId) return 'url(#shadow-md-filter)' + if (connected.has(d.id)) return 'url(#shadow-sm-filter)' + return null + }) + .select('.node-circle') + .attr('stroke-opacity', d => { + if (d.id === activeNodeId) return 1 + if (connected.has(d.id)) return 0.7 + return 0.4 + }) + .attr('stroke-width', d => d.id === activeNodeId ? 1.5 : 1) + nodeSelection.filter(d => d.type === 'skill') .select('.node-circle') .attr('r', d => (d.id === activeNodeId || connected.has(d.id)) ? SKILL_RADIUS + 3 : SKILL_RADIUS) @@ -478,16 +538,20 @@ const CareerConstellation: React.FC = ({ return d.homeY }).strength(d => d.type === 'role' ? 1 : 0.2)) .force('collide', d3.forceCollide(d => - d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 8 + d.type === 'role' ? Math.max(ROLE_WIDTH, ROLE_HEIGHT) / 2 + 8 : SKILL_RADIUS + 8 )) simulationRef.current = simulation const renderTick = () => { nodes.forEach(d => { - const r = d.type === 'role' ? ROLE_RADIUS : SKILL_RADIUS - d.x = Math.max(r + 6, Math.min(width - r - 6, d.x)) - d.y = Math.max(r + 6, Math.min(height - r - 6, d.y)) + 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)) + } else { + d.x = Math.max(SKILL_RADIUS + 6, Math.min(width - SKILL_RADIUS - 6, d.x)) + d.y = Math.max(SKILL_RADIUS + 6, Math.min(height - SKILL_RADIUS - 6, d.y)) + } }) linkSelection @@ -498,6 +562,12 @@ const CareerConstellation: React.FC = ({ nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`) + roleConnectors + .attr('x1', timelineX) + .attr('y1', d => d.y) + .attr('x2', d => d.x - ROLE_WIDTH / 2) + .attr('y2', d => d.y) + const nextNodePositions: Record = {} nodes.forEach(node => { nextNodePositions[node.id] = { @@ -615,7 +685,8 @@ const CareerConstellation: React.FC = ({ : `${node.startYear}-present` const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 } - const buttonSize = node.type === 'role' ? 54 : 34 + const buttonWidth = node.type === 'role' ? ROLE_WIDTH : 34 + const buttonHeight = node.type === 'role' ? ROLE_HEIGHT : 34 return (