8.1 KiB
Task: CareerConstellation Overhaul
Refactor, visually improve, and add chronological animation to the CareerConstellation D3 force chart — the centrepiece of the portfolio's Patient Pathway section.
Requirements
Phase 1 — Refactor the Monolith
Decompose src/components/CareerConstellation.tsx (1102 lines) into focused modules:
src/components/constellation/
CareerConstellation.tsx -- Orchestrator (< 300 lines)
MobileAccordion.tsx -- Mobile tap-to-expand accordion
ConstellationLegend.tsx -- Domain legend with node counts
AccessibleNodeOverlay.tsx -- Keyboard navigation button overlay
constants.ts -- All magic numbers as named exports
types.ts -- SimNode, SimLink, LayoutParams, local interfaces
src/hooks/
useForceSimulation.ts -- D3 simulation lifecycle (setup, forces, tick, cleanup)
useConstellationHighlight.ts -- applyGraphHighlight + connectedMap + highlight refs
useConstellationInteraction.ts -- Mouse/touch/pin handlers, callback refs
- Constants extracted (forces, sizes, opacities, durations)
- Types extracted (SimNode, SimLink, LayoutParams)
- MobileAccordion extracted as standalone component
- ConstellationLegend extracted
- AccessibleNodeOverlay extracted
- useForceSimulation hook created
- useConstellationHighlight hook created
- useConstellationInteraction hook created
- Orchestrator composed from hooks + sub-components (< 300 lines)
- All existing behaviour preserved (hover, click, tap, keyboard, mobile, detail panel)
npm run lint && npm run typecheck && npm run buildpasses
Phase 2 — Visual Improvements
Enhance the chart aesthetics while maintaining the PMR design language:
Links:
- Strength-weighted stroke width at rest:
0.5 + strength * 1.5(range 0.5–2px) - Domain-colored at rest (very low opacity:
0.08 + strength * 0.12) - Improved bezier curves: offset control point by vertical distance (
cx = (sx+tx)/2 + (ty-sy)*0.15) - On highlight: width
1 + strength * 2, domain color at higher opacity
Skill nodes:
- Thin domain-colored stroke at rest (
stroke-width: 1, stroke-opacity: 0.4) - Size encoding by connected role count:
baseRadius + roleCount * 0.8 - On highlight: subtle glow filter (feGaussianBlur, 2–3px stdDeviation, domain color)
Role nodes:
- Fill gradient: left-to-right from orgColor@0.08 to orgColor@0.18
- On highlight: fill-opacity 0.25, stroke-width 2, shadow-md filter
Entry animation (mount, replaced by over-time animation in Phase 3):
- Timeline guides fade in (200ms)
- Role nodes slide in from left along connectors (staggered 80ms, 300ms each)
- Skill nodes scale up from 0 (staggered 30ms, 250ms each)
- Links draw on via stroke-dashoffset (after source+target visible)
- Skipped entirely when
prefers-reduced-motion
Legend:
- Domain node counts displayed: "Technical (8) · Clinical (6) · Leadership (7)"
Phase 3 — Over-Time Animation
Build the constellation chronologically from 2009 to present:
Data changes:
- Modify
buildConstellationData()insrc/data/timeline.tsto include education entities - Education entities appear as nodes on the timeline (use
type: 'role'with education styling, or addtype: 'education') - Update
src/types/pmr.tsif new node types are needed - Timeline order (oldest first): A-Levels (2009) → MPharm (2011) → Pre-Reg (2015) → Duty Manager (2016) → Pharmacy Manager (2017) → High Cost Drugs (2022) → Deputy Head (2024) → Interim Head (2025)
Animation architecture:
- Create
useTimelineAnimationhook insrc/hooks/ - All nodes present in simulation from start but hidden (opacity: 0) — stable positions, no layout jitter
- Reveal chronologically: each role/education entity appears, then its skills animate in
- Skills already visible from earlier roles just get new links (reinforcement pulse: scale 1.3x → 1.0x over 350ms)
- Uses requestAnimationFrame + timestamp scheduler (not setTimeout chains)
- Animation state machine in refs: IDLE → PLAYING → PAUSED → HOLDING → RESETTING → loop back to PLAYING
- Auto-plays on load (after force simulation settles)
- Loops continuously: hold 3s at end → fade all 400ms → pause 200ms → restart
Visual effects during reveal:
- Role/education nodes scale from 0 with ease-out-back
- New skill nodes scale from 0 with ease-out
- Links draw on via stroke-dashoffset animation
- Year indicator overlay (top-left of SVG, monospace font, var(--text-tertiary))
Accessibility:
prefers-reduced-motion: skip animation entirely, show final state immediately- Play/pause button with appropriate aria-label
Phase 4 — Animation + Interaction Integration
Wire the animation to the existing highlight system:
- Hover/tap pauses animation, applies highlight normally (on visible nodes only)
- Highlight only operates on revealed nodes — unrevealed nodes stay at opacity 0
- Multiplicative opacity: animation visibility (0 or target) × highlight emphasis (1.0 or 0.15)
- Resume animation 800ms after last interaction ends (mouseout / background tap)
- Explicit pause via button stays paused until user clicks play again
- Play/pause toggle button (bottom-right of SVG area, subtle styling, larger touch target on mobile)
- Mobile accordion works during paused state
- Keyboard navigation works during paused state
- Click → detail panel works during paused state
Success Criteria
All of the following must be true for LOOP_COMPLETE:
npm run lint && npm run typecheck && npm run buildpasses with zero errors- CareerConstellation orchestrator is < 300 lines
- Education entities (A-Levels, MPharm) appear in the constellation
- Animation auto-plays on load and loops continuously
- Network builds chronologically from 2009 through to present
- Skills accumulate visually — existing skills get new links, not duplicated
- Hover/tap pauses animation and shows highlight on visible nodes
- Animation resumes after 800ms of no interaction
- Play/pause button visible and functional
- Existing interactions preserved: click → detail panel, keyboard nav, mobile accordion
prefers-reduced-motionshows final state immediately with no animation- Links show domain colors and strength-weighted width at rest
- No TypeScript
anytypes introduced - No dead code or commented-out blocks
Constraints
- TypeScript strict mode (
noUnusedLocals,noUnusedParameters) - Path alias:
@/*→src/* - Styling: Tailwind utilities + CSS custom properties for design tokens
- D3 v6 (already installed)
- Framer Motion for non-D3 animations; respect
prefers-reduced-motion - Design tokens: Primary teal #00897B, Accent coral #FF6B6B, PMR greens/teals/greys
- Font tokens:
--font-ui(Elvaro),--font-geist-mono(monospace),--font-primary/--font-secondary - No automated tests — quality gates are lint + typecheck + build
- D3 patterns: reference
.claude/skills/d3-visualization/for force layout examples
Key Architecture Decisions
-
"All nodes hidden" for animation — every node participates in the force simulation from the start (positions are stable). Reveal via opacity transitions only. Do NOT dynamically add/remove nodes from the simulation.
-
Ref-based animation state — the animation state machine lives in refs (not React state) to avoid re-renders in the rAF loop. Only sync to React state for UI controls (play/pause button).
-
Multiplicative opacity model — animation controls visibility (0 or target), highlight controls emphasis (1.0 or 0.15). Final opacity = animation × highlight. This prevents the two systems from conflicting.
-
Imperative D3 + React hybrid — D3 manages SVG rendering and force simulation imperatively via refs. React manages keyboard overlay buttons and UI controls. Follow the existing pattern in the codebase.
Status
Track progress here. Mark items complete as you go. When ALL success criteria are met, print LOOP_COMPLETE.