24 KiB
Phase 3+4 Plan — Over-Time Animation + Interaction Integration
Goal
Build the constellation chronologically from 2009 to present, replacing the Phase 2 entry animation with a looping timeline reveal. Wire animation to the existing highlight system using multiplicative opacity. Add play/pause control and reduced-motion support.
Task Order
Five tasks, built in dependency order. Tasks 1-2 are P1 (foundations), 3-5 are P2 (visual/integration/a11y).
Task 1: Data — Include education entities (task-1771251473-edda)
Files: src/data/timeline.ts, src/types/pmr.ts
src/types/pmr.ts changes:
- ConstellationNode.type — Add
'education'as a valid type:This allows education nodes to have distinct styling (e.g., dashed border, different shape) while sharing role-like positioning on the timeline.type: 'role' | 'skill' | 'education'
src/data/timeline.ts changes:
-
buildConstellationData()— Include education entities alongside career entities:- Change
timelineCareerEntities→timelineEntities(all entities) inroleSkillMappings,roleNodes, andconstellationLinksbuilders - For education entities, use
type: 'education'instead oftype: 'role' - Education entities already have
skills,skillStrengths,orgColor,graphLabel, anddateRange— no data changes needed - The
roleNodesbuilder becomesentityNodesconceptually but keep the variable name for minimal diff
Specific changes to
buildConstellationData():// Line 450: Change timelineCareerEntities → timelineEntities const roleSkillMappings = timelineEntities.map(entity => ({ roleId: entity.id, skillIds: entity.skills, })) // Line 455: Change timelineCareerEntities → timelineEntities, add education type const roleNodes = timelineEntities.map(entity => ({ id: entity.id, type: entity.kind === 'education' ? 'education' as const : 'role' as const, label: entity.title, shortLabel: entity.graphLabel, organization: entity.organization, startYear: entity.dateRange.startYear, endYear: entity.dateRange.endYear, orgColor: entity.orgColor, })) // Line 474: Change timelineCareerEntities → timelineEntities const constellationLinks = timelineEntities.flatMap(entity => ...) - Change
Impact on downstream:
constellationNodesnow includes 2 education nodes (A-Levels, MPharm)constellationLinksnow includes links from education entities to skillsroleSkillMappingsnow includes education entity mappingsuseForceSimulation.tsfiltersroleNodesat line 35 with.filter(n => n.type === 'role')— this needs updating to include'education'type for timeline placement:.filter(n => n.type === 'role' || n.type === 'education')- The orchestrator's
buildScreenReaderDescription()andcareerEntityByIdalready useconstellationNodesandtimelineCareerEntitiesrespectively — the description function should handle education nodes, and the entity lookup should extend to all timeline entities - The
nodeByIdlookup inuseForceSimulation.ts(line 277) usesconstellationNodesdirectly — no change needed
Education node visual styling (in useForceSimulation.ts):
- Education nodes should render like role nodes but with a dashed border to visually distinguish them
- Same
rw/rhdimensions, same gradient fill, butstroke-dasharray: '4 3' - Change role-specific rendering filters to include education:
.filter(d => d.type === 'role' || d.type === 'education')
Pitfall: The roleNodes constant at line 35 of useForceSimulation.ts is module-level, computed once. After adding education entities, it must include education nodes for year scale computation. Update to: const roleNodes = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')
Task 2: Hook — Create useTimelineAnimation (task-1771251475-c04e)
Files: src/hooks/useTimelineAnimation.ts (NEW), src/components/constellation/types.ts, src/components/constellation/constants.ts
Core Architecture:
The animation hook manages a state machine that reveals nodes chronologically. All nodes exist in the D3 simulation from the start (positions stable) but are hidden via opacity: 0. The hook uses requestAnimationFrame with a timestamp-based scheduler.
src/components/constellation/types.ts additions:
export type AnimationState = 'IDLE' | 'PLAYING' | 'PAUSED' | 'HOLDING' | 'RESETTING'
export interface AnimationStep {
entityId: string // The role/education entity being revealed
startYear: number // For year indicator display
skillIds: string[] // Skills to reveal with this entity
newSkillIds: string[] // Skills not yet visible (first appearance)
reinforcedSkillIds: string[] // Skills already visible (get pulse)
linkPairs: Array<{ source: string; target: string }> // Links to draw on
}
src/components/constellation/constants.ts additions:
// Timeline animation
export const ANIM_ENTITY_REVEAL_MS = 600 // Role/education node scale-in duration
export const ANIM_SKILL_REVEAL_MS = 350 // New skill node scale-in duration
export const ANIM_SKILL_STAGGER_MS = 60 // Stagger between skills within a step
export const ANIM_LINK_DRAW_MS = 300 // Link stroke-dashoffset draw-on
export const ANIM_LINK_STAGGER_MS = 40 // Stagger between links
export const ANIM_REINFORCEMENT_MS = 350 // Pulse duration for already-visible skills
export const ANIM_STEP_GAP_MS = 400 // Pause between steps (entities)
export const ANIM_HOLD_MS = 3000 // Hold at end before reset
export const ANIM_RESET_MS = 400 // Fade-all duration
export const ANIM_RESTART_DELAY_MS = 200 // Pause after reset before replaying
export const ANIM_INTERACTION_RESUME_MS = 800 // Resume delay after interaction ends
export const ANIM_SETTLE_ALPHA = 0.05 // Simulation alpha threshold to start
src/hooks/useTimelineAnimation.ts — Hook Design:
export function useTimelineAnimation(deps: {
nodeSelectionRef: React.MutableRefObject<d3.Selection<...> | null>
linkSelectionRef: React.MutableRefObject<d3.Selection<...> | null>
simulationRef: React.MutableRefObject<d3.Simulation<...> | null>
nodesRef: React.MutableRefObject<SimNode[]>
connectedMapRef: React.MutableRefObject<Map<string, Set<string>>>
skillRestRadiiRef: React.MutableRefObject<Map<string, number>>
srDefault: number
isMobile: boolean
sf: number
dimensionsTrigger: number
}): {
animationStateRef: React.MutableRefObject<AnimationState>
visibleNodeIdsRef: React.MutableRefObject<Set<string>>
isPlaying: boolean // React state for UI button
togglePlayPause: () => void
pauseForInteraction: () => void
resumeAfterInteraction: () => void
}
Animation Step Sequence:
-
Pre-compute steps from
timelineEntitiessorted oldest-first:A-Levels (2009) → MPharm (2011) → Pre-Reg (2015) → Duty Manager (2016) → Pharmacy Manager (2017) → HCD Pharm (2022) → Deputy Head (2024) → Interim Head (2025) -
For each step, determine:
newSkillIds: skills not invisibleNodeIdsset yetreinforcedSkillIds: skills already invisibleNodeIdssetlinkPairs: all links from this entity
-
Reveal sequence per step (all via D3 transitions): a. Entity node: scale from 0 with
ease-out-back(custom easing or D3d3.easeBackOut) b. Entity connector: fade in c. New skills: scale from 0 withease-out, staggered byANIM_SKILL_STAGGER_MSd. Reinforced skills: pulsetransform: scale(1.3)→scale(1.0)overANIM_REINFORCEMENT_MSe. Links: draw on viastroke-dashoffsetanimation, staggered f. UpdatevisibleNodeIdsset g. WaitANIM_STEP_GAP_MSbefore next step -
State machine in refs:
animationStateRef: current statecurrentStepRef: index of current entity steprafIdRef: requestAnimationFrame ID for cleanupvisibleNodeIdsRef: Set of revealed node IDs (shared with highlight system)
-
Loop cycle:
- After all steps: state →
HOLDING, waitANIM_HOLD_MS - Fade all nodes to opacity 0 over
ANIM_RESET_MS: state →RESETTING - Clear
visibleNodeIds, waitANIM_RESTART_DELAY_MS - State →
PLAYING, restart from step 0
- After all steps: state →
Key implementation details:
-
rAF scheduler: The main loop uses
requestAnimationFramewith accumulated elapsed time. Each frame checks if enough time has passed to advance to the next phase of the current step. This avoids setTimeout chains and gives smooth control. -
D3 transitions for node reveal: Rather than managing every frame in rAF, use D3 transitions for the actual visual changes (they handle interpolation). The rAF scheduler just triggers step transitions at the right time and manages state.
-
Initial hidden state: On mount (or dimension change), hide ALL entity/skill nodes and links at
opacity: 0. Skill nodes also getr: 0on their circles. This replaces the Phase 2 entry animation hiding logic. -
Wait for simulation: Don't start animation until
simulationRef.current.alpha() < ANIM_SETTLE_ALPHA. Check this in the rAF loop's first frame. -
Cleanup: On unmount or dimension change, cancel rAF, stop all D3 transitions on selections.
Relationship to highlight system:
- The hook exposes
visibleNodeIdsRef— the highlight system reads this to know which nodes can be highlighted - The hook exposes
pauseForInteraction()andresumeAfterInteraction()— called by interaction handlers - When paused for interaction, current step freezes but visible nodes remain visible
Task 3: Visual — Entry animation reveal effects (task-1771251477-81a2)
Files: src/hooks/useForceSimulation.ts, src/hooks/useTimelineAnimation.ts
src/hooks/useForceSimulation.ts changes:
-
Remove Phase 2 entry animation — Delete the entire
maybeRunEntryAnimationfunction and its related code (lines 479-559):- Remove initial hidden state setting (lines 479-487)
- Remove
entryAnimationRanflag andmaybeRunEntryAnimationfunction (lines 489-547) - Remove the
maybeRunEntryAnimation()call from tick handler (line 558) - The entry animation constants can remain in
constants.ts(no harm, or remove if desired)
-
Year indicator SVG element — Add a text element for displaying current year during animation:
- Append to SVG (after background rect, before timeline guides):
const yearIndicator = svg.append('text') .attr('class', 'year-indicator') .attr('x', sidePadding + 8) .attr('y', topPadding - 4) .attr('font-size', isMobile ? '18' : `${Math.round(24 * sf)}`) .attr('font-family', 'var(--font-geist-mono)') .attr('fill', 'var(--text-tertiary)') .attr('opacity', 0) - Expose via a ref so the animation hook can update it
- Append to SVG (after background rect, before timeline guides):
src/hooks/useTimelineAnimation.ts — Reveal effects:
-
Entity node reveal: Scale from 0 with
d3.easeBackOut:// Select the entity's <g> node, set initial transform-origin entityGroup .attr('opacity', 0) .attr('transform', d => `translate(${d.x},${d.y}) scale(0)`) .transition() .duration(ANIM_ENTITY_REVEAL_MS) .ease(d3.easeBackOut.overshoot(1.2)) .attr('opacity', 1) .attr('transform', d => `translate(${d.x},${d.y}) scale(1)`)Note: D3
<g>transform includes both translate and scale. The tick handler normally setstransform: translate(x,y). During animation, we need to temporarily override — use ananimatingNodesSet to skip tick-driven transform updates for nodes mid-transition.Better approach: Don't fight the tick handler. Instead, keep the group at
translate(x,y)via tick, and animate the child elements' opacity + the circle/rect scale:- Set entity group
opacity: 0initially - Transition group
opacity: 0 → 1 - For the
rect.node-circleinside, animate fromtransform: scale(0)toscale(1)using CSS transform-origin center - This avoids conflicting with the tick handler's group transform
- Set entity group
-
Skill node reveal: Scale
.node-circlefromr: 0:skillGroup.attr('opacity', 0) skillGroup.transition().duration(ANIM_SKILL_REVEAL_MS).attr('opacity', 1) skillGroup.select('.node-circle') .attr('r', 0) .transition().duration(ANIM_SKILL_REVEAL_MS).ease(d3.easeBackOut) .attr('r', restRadius) -
Link draw-on: Stroke-dashoffset animation:
linkEl.attr('opacity', 1) const length = linkEl.node().getTotalLength() linkEl .attr('stroke-dasharray', `${length} ${length}`) .attr('stroke-dashoffset', length) .transition().duration(ANIM_LINK_DRAW_MS) .attr('stroke-dashoffset', 0) .on('end', function() { d3.select(this).attr('stroke-dasharray', null).attr('stroke-dashoffset', null) }) -
Reinforcement pulse for already-visible skills:
skillCircle .transition().duration(ANIM_REINFORCEMENT_MS / 2) .attr('r', restRadius * 1.3) .transition().duration(ANIM_REINFORCEMENT_MS / 2) .attr('r', restRadius) -
Year indicator update:
yearIndicator .text(step.startYear) .transition().duration(200) .attr('opacity', 0.6) -
Reset animation (at loop end):
// Fade everything out nodeSelection.transition().duration(ANIM_RESET_MS).attr('opacity', 0) linkSelection.transition().duration(ANIM_RESET_MS).attr('opacity', 0) yearIndicator.transition().duration(ANIM_RESET_MS).attr('opacity', 0) // Also reset skill radii to 0, connector opacity to 0
Pitfall — Tick handler conflicts:
The tick handler (in useForceSimulation) calls nodeSelection.attr('transform', ...) every tick. During animation, nodes that are opacity: 0 still get positioned — that's fine (we want stable positions). The issue is if we animate transform on the group — tick will override it. Solution: Only animate opacity and child element attributes (r, scale via CSS), never the group's translate transform. The group transform is exclusively managed by the tick handler.
Pitfall — Link path changes during animation:
Links update their d attribute every tick. stroke-dasharray based on getTotalLength() will be slightly wrong as positions shift. Since we wait for alpha < 0.05, positions are nearly stable and the error is negligible. Clean up dasharray after animation ends.
Task 4: Integration — Wire animation to highlight system (task-1771251479-1473)
Files: src/hooks/useConstellationHighlight.ts, src/hooks/useConstellationInteraction.ts, src/components/constellation/CareerConstellation.tsx
Multiplicative Opacity Model:
finalOpacity = animationVisibility × highlightEmphasis
animationVisibility: 0 (hidden/not-yet-revealed) or target opacity (1.0 for groups, 0.35 for skill fills, etc.)highlightEmphasis: 1.0 (normal/connected) or 0.15 (dimmed)- Only operate highlight on nodes where
animationVisibility > 0
src/hooks/useConstellationHighlight.ts changes:
-
Add
visibleNodeIdsRefto deps:visibleNodeIdsRef?: React.MutableRefObject<Set<string>> -
Guard highlight against unrevealed nodes: In
applyGraphHighlight, whenactiveNodeIdis set:const visibleIds = deps.visibleNodeIdsRef?.current const isVisible = (id: string) => !visibleIds || visibleIds.has(id) // Only dim visible nodes; keep unrevealed at opacity 0 nodeSelection.style('opacity', d => { if (!isVisible(d.id)) return '0' return isInGroup(d.id) ? '1' : '0.15' })When resetting (no
activeNodeId):nodeSelection.style('opacity', d => { if (!isVisible(d.id)) return '0' return '1' }) -
Link visibility guard:
linkSelection.attr('opacity', l => { const src = /* resolve id */ const tgt = /* resolve id */ if (!isVisible(src) || !isVisible(tgt)) return 0 // normal highlight opacity })
src/hooks/useConstellationInteraction.ts changes:
-
Pause animation on interaction: Add
pauseForInteractionandresumeAfterInteractionto deps:pauseForInteraction?: () => void resumeAfterInteraction?: () => voidIn
mouseenter.interaction:deps.pauseForInteraction?.()In
mouseleave.interaction:deps.resumeAfterInteraction?.()In
click.interactionfor touch (pin):deps.pauseForInteraction?.() // On unpin (click same node or background): deps.resumeAfterInteraction?.()In background click (
.bg-rectclick handler):deps.resumeAfterInteraction?.()
src/components/constellation/CareerConstellation.tsx changes:
-
Wire useTimelineAnimation hook:
const { animationStateRef, visibleNodeIdsRef, isPlaying, togglePlayPause, pauseForInteraction, resumeAfterInteraction, } = useTimelineAnimation({ nodeSelectionRef, linkSelectionRef, simulationRef: sim.simulationRef, nodesRef, connectedMapRef, skillRestRadiiRef, srDefault, isMobile, sf, dimensionsTrigger: dimensions.width + dimensions.height, }) -
Pass
visibleNodeIdsRefto highlight hook deps -
Pass
pauseForInteractionandresumeAfterInteractionto interaction hook deps -
Sync
simulationRef— the orchestrator needs to passsim.simulationRefto the animation hook
Orchestrator line count impact: Adding the animation hook call (~12 lines), play/pause button (~10 lines), and additional deps (~4 lines) adds ~26 lines. Current orchestrator is 294 lines → ~320 lines. We can offset by:
- Moving
buildScreenReaderDescription()to a separate small utility (saves ~15 lines) - Or inlining the play/pause button compactly
Target: keep orchestrator under 330 lines (slight relaxation from 300 given the significant new functionality).
Task 5: Accessibility — reduced-motion + play/pause button (task-1771251482-f0e9)
Files: src/hooks/useTimelineAnimation.ts, src/components/constellation/CareerConstellation.tsx
Reduced motion (in useTimelineAnimation.ts):
-
If
prefersReducedMotion:- Skip the entire animation system
- Set all nodes + links to visible immediately (their final state)
visibleNodeIdsRefcontains all node IDs from startisPlayingisfalse,togglePlayPauseis a no-op- The hook returns early after setting initial visible state
-
Implementation:
if (prefersReducedMotion) { // Show everything immediately visibleNodeIdsRef.current = new Set(allNodeIds) animationStateRef.current = 'IDLE' // Set all node opacities to target values nodeSelectionRef.current?.style('opacity', '1') linkSelectionRef.current?.attr('opacity', 1) // Restore skill radii nodeSelectionRef.current?.filter(d => d.type === 'skill') .select('.node-circle') .attr('r', d => skillRestRadiiRef.current.get(d.id) ?? srDefault) return { isPlaying: false, ... } }
Play/Pause Button (in CareerConstellation.tsx):
-
JSX — positioned bottom-right of SVG area:
{!prefersReducedMotion && ( <button onClick={togglePlayPause} aria-label={isPlaying ? 'Pause animation' : 'Play animation'} style={{ position: 'absolute', bottom: 12, right: 12, width: 36, height: 36, borderRadius: '50%', border: '1px solid var(--border-light)', background: 'var(--surface)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.6, transition: 'opacity 150ms ease', // Larger touch target on mobile ...(isMobile && { width: 44, height: 44, bottom: 8, right: 8 }), }} onMouseEnter={e => (e.currentTarget.style.opacity = '1')} onMouseLeave={e => (e.currentTarget.style.opacity = '0.6')} > {isPlaying ? ( <svg width="14" height="14" viewBox="0 0 14 14" fill="var(--text-secondary)"> <rect x="2" y="1" width="4" height="12" rx="1" /> <rect x="8" y="1" width="4" height="12" rx="1" /> </svg> ) : ( <svg width="14" height="14" viewBox="0 0 14 14" fill="var(--text-secondary)"> <polygon points="3,1 13,7 3,13" /> </svg> )} </button> )} -
Interaction behavior:
- Explicit pause via button: stays paused until user clicks play
- This is different from interaction-pause (hover/tap), which auto-resumes after 800ms
- The
togglePlayPausein the hook must distinguish: set auserPausedRefflag - When
userPausedRefis true,resumeAfterInteraction()does NOT resume - Only
togglePlayPause()can unpause when user-paused
-
During paused state, all existing interactions work normally:
- Mobile accordion works (pinned entity visible)
- Keyboard navigation works (buttons overlay present for visible nodes)
- Click → detail panel works
- Highlight system operates on visible nodes only
Build & Verification Order
- Task 1 — Data changes (timeline.ts + pmr.ts type update). Run typecheck to catch all downstream type errors.
- Task 2 — Create useTimelineAnimation hook + new constants + types. Typecheck.
- Task 3 — Remove Phase 2 entry animation from useForceSimulation, add year indicator element. Wire reveal effects into animation hook. Typecheck + build.
- Task 4 — Wire highlight + interaction hooks to animation. Update orchestrator. Typecheck + build.
- Task 5 — Reduced-motion path + play/pause button. Full validation:
npm run lint && npm run typecheck && npm run build.
Pitfalls to Avoid
-
Tick handler transform conflict — Never animate the group's
translatetransform in the animation hook. The tick handler owns group transforms. Animate child element attributes (opacity, r, fill-opacity) only. -
D3 transition interruption — If a new transition starts on the same element while one is running, D3 interrupts the old one. The animation step scheduler must wait for transitions to complete before starting the next step. Use
transition.on('end', ...)or track completion. -
stale closure in rAF — The rAF callback captures refs at creation time. Always read from
.currentinside the rAF callback, never close over state values. -
Link opacity during animation — Links between two nodes should only become visible when BOTH source and target are in
visibleNodeIds. Check both ends before revealing. -
Skill radius during animation — When a skill node is first revealed, its
.node-circlestarts atr: 0and animates to its rest radius. The reinforcement pulse must use the correct rest radius fromskillRestRadiimap. -
Education node rendering —
useForceSimulation.tshas multiple.filter(d => d.type === 'role')calls for rendering role-specific elements (rect, text, focus-ring, connectors). All of these must be updated to.filter(d => d.type === 'role' || d.type === 'education'). -
connectedMap for education — Education entities link to skills just like career entities. The connectedMap is built from
constellationLinkswhich will now include education links. No special handling needed. -
Orchestrator line count — The orchestrator will grow beyond 300 lines. Extract
buildScreenReaderDescription()to a utility file to reclaim space. Alternatively, accept ~320-330 lines as reasonable given the new functionality. -
Dimension changes during animation — When dimensions change, the simulation re-creates. The animation hook must detect this (via
dimensionsTriggerdep) and restart from scratch — cancel current rAF, reset state to IDLE, re-hide all nodes, wait for simulation to settle, then start playing. -
AccessibleNodeOverlay — Currently renders buttons for all
constellationNodes. After adding education entities, these will automatically get buttons too. The button overlay should only show buttons for VISIBLE nodes during animation — add avisibleNodeIdsfilter, or keep all buttons but set invisible ones tovisibility: hidden.