8b674ffe14
- Add education entities (A-Levels, MPharm) to constellation data - Add 'education' node type with dashed border styling - Create useTimelineAnimation hook with rAF scheduler + state machine (IDLE → PLAYING → PAUSED → HOLDING → RESETTING → loop) - Chronological reveal: entities oldest-first with skill stagger, link draw-on, reinforcement pulse for already-visible skills - Year indicator overlay (monospace, top-left) - Multiplicative opacity: animation visibility × highlight emphasis - Highlight system respects visibleNodeIdsRef (unrevealed stay hidden) - Interaction pause/resume wired to animation hook - Play/pause button (bottom-right, larger touch target on mobile) - prefers-reduced-motion: shows final state immediately, no animation - Remove Phase 2 entry animation (replaced by timeline animation)
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
import * as d3 from 'd3'
|
|
import { constellationLinks } from '@/data/constellation'
|
|
import { timelineEntities } from '@/data/timeline'
|
|
import {
|
|
ANIM_ENTITY_REVEAL_MS,
|
|
ANIM_SKILL_REVEAL_MS,
|
|
ANIM_SKILL_STAGGER_MS,
|
|
ANIM_LINK_DRAW_MS,
|
|
ANIM_LINK_STAGGER_MS,
|
|
ANIM_REINFORCEMENT_MS,
|
|
ANIM_STEP_GAP_MS,
|
|
ANIM_HOLD_MS,
|
|
ANIM_RESET_MS,
|
|
ANIM_RESTART_DELAY_MS,
|
|
ANIM_INTERACTION_RESUME_MS,
|
|
ANIM_SETTLE_ALPHA,
|
|
prefersReducedMotion,
|
|
} from '@/components/constellation/constants'
|
|
import type { SimNode, SimLink, AnimationState, AnimationStep } from '@/components/constellation/types'
|
|
|
|
// Pre-compute animation steps from timeline entities (oldest first)
|
|
const sortedEntities = [...timelineEntities].sort(
|
|
(a, b) => a.dateRange.startYear - b.dateRange.startYear
|
|
)
|
|
|
|
function buildAnimationSteps(): AnimationStep[] {
|
|
const seen = new Set<string>()
|
|
return sortedEntities.map(entity => {
|
|
const skillIds = entity.skills
|
|
const newSkillIds = skillIds.filter(s => !seen.has(s))
|
|
const reinforcedSkillIds = skillIds.filter(s => seen.has(s))
|
|
skillIds.forEach(s => seen.add(s))
|
|
const linkPairs = constellationLinks
|
|
.filter(l => l.source === entity.id)
|
|
.map(l => ({ source: l.source, target: l.target }))
|
|
return {
|
|
entityId: entity.id,
|
|
startYear: entity.dateRange.startYear,
|
|
skillIds,
|
|
newSkillIds,
|
|
reinforcedSkillIds,
|
|
linkPairs,
|
|
}
|
|
})
|
|
}
|
|
|
|
const animationSteps = buildAnimationSteps()
|
|
|
|
interface UseTimelineAnimationDeps {
|
|
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
|
|
linkSelectionRef: React.MutableRefObject<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null>
|
|
simulationRef: React.MutableRefObject<d3.Simulation<SimNode, SimLink> | null>
|
|
yearIndicatorRef: React.MutableRefObject<d3.Selection<SVGTextElement, unknown, null, undefined> | null>
|
|
connectorSelectionRef: React.MutableRefObject<d3.Selection<SVGLineElement, SimNode, SVGGElement, unknown> | null>
|
|
timelineGroupRef: React.MutableRefObject<d3.Selection<SVGGElement, unknown, null, undefined> | null>
|
|
skillRestRadiiRef: React.MutableRefObject<Map<string, number>>
|
|
srDefault: number
|
|
dimensionsTrigger: number
|
|
}
|
|
|
|
export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|
const animationStateRef = useRef<AnimationState>('IDLE')
|
|
const visibleNodeIdsRef = useRef<Set<string>>(new Set())
|
|
const currentStepRef = useRef(0)
|
|
const rafIdRef = useRef(0)
|
|
const timeoutIdsRef = useRef<number[]>([])
|
|
const userPausedRef = useRef(false)
|
|
const interactionPausedRef = useRef(false)
|
|
const resumeTimerRef = useRef(0)
|
|
const [isPlaying, setIsPlaying] = useState(false)
|
|
|
|
const scheduleTimeout = useCallback((fn: () => void, ms: number) => {
|
|
const id = window.setTimeout(fn, ms)
|
|
timeoutIdsRef.current.push(id)
|
|
return id
|
|
}, [])
|
|
|
|
const cancelAll = useCallback(() => {
|
|
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
|
|
rafIdRef.current = 0
|
|
timeoutIdsRef.current.forEach(id => clearTimeout(id))
|
|
timeoutIdsRef.current = []
|
|
if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
|
|
resumeTimerRef.current = 0
|
|
}, [])
|
|
|
|
const hideAll = useCallback(() => {
|
|
const nodeSel = deps.nodeSelectionRef.current
|
|
const linkSel = deps.linkSelectionRef.current
|
|
const connSel = deps.connectorSelectionRef.current
|
|
const tlGroup = deps.timelineGroupRef.current
|
|
const yearInd = deps.yearIndicatorRef.current
|
|
if (!nodeSel || !linkSel) return
|
|
|
|
// Interrupt any running D3 transitions
|
|
nodeSel.interrupt()
|
|
linkSel.interrupt()
|
|
nodeSel.selectAll('*').interrupt()
|
|
connSel?.interrupt()
|
|
tlGroup?.interrupt()
|
|
|
|
nodeSel.style('opacity', '0')
|
|
linkSel.attr('opacity', 0)
|
|
connSel?.attr('opacity', 0)
|
|
tlGroup?.attr('opacity', 0)
|
|
yearInd?.attr('opacity', 0)
|
|
|
|
// Reset skill radii to 0
|
|
nodeSel.filter((d: SimNode) => d.type === 'skill')
|
|
.select('.node-circle')
|
|
.attr('r', 0)
|
|
|
|
visibleNodeIdsRef.current = new Set()
|
|
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.timelineGroupRef, deps.yearIndicatorRef])
|
|
|
|
const showFinalState = useCallback(() => {
|
|
const nodeSel = deps.nodeSelectionRef.current
|
|
const linkSel = deps.linkSelectionRef.current
|
|
const connSel = deps.connectorSelectionRef.current
|
|
const tlGroup = deps.timelineGroupRef.current
|
|
if (!nodeSel || !linkSel) return
|
|
|
|
const allIds = new Set<string>()
|
|
animationSteps.forEach(step => {
|
|
allIds.add(step.entityId)
|
|
step.skillIds.forEach(s => allIds.add(s))
|
|
})
|
|
visibleNodeIdsRef.current = allIds
|
|
|
|
nodeSel.style('opacity', '1')
|
|
linkSel.attr('opacity', null)
|
|
connSel?.attr('opacity', null)
|
|
tlGroup?.attr('opacity', 1)
|
|
|
|
nodeSel.filter((d: SimNode) => d.type === 'skill')
|
|
.select('.node-circle')
|
|
.attr('r', (d: SimNode) => deps.skillRestRadiiRef.current.get(d.id) ?? deps.srDefault)
|
|
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.timelineGroupRef, deps.skillRestRadiiRef, deps.srDefault])
|
|
|
|
const revealStep = useCallback((stepIdx: number, onComplete: () => void) => {
|
|
const nodeSel = deps.nodeSelectionRef.current
|
|
const linkSel = deps.linkSelectionRef.current
|
|
const connSel = deps.connectorSelectionRef.current
|
|
const yearInd = deps.yearIndicatorRef.current
|
|
const tlGroup = deps.timelineGroupRef.current
|
|
if (!nodeSel || !linkSel) return
|
|
|
|
const step = animationSteps[stepIdx]
|
|
if (!step) { onComplete(); return }
|
|
|
|
// Show timeline guides on first step
|
|
if (stepIdx === 0 && tlGroup) {
|
|
tlGroup.transition().duration(200).attr('opacity', 1)
|
|
}
|
|
|
|
// Update year indicator
|
|
if (yearInd) {
|
|
yearInd.text(step.startYear)
|
|
.transition().duration(200).attr('opacity', 0.6)
|
|
}
|
|
|
|
// Reveal entity node
|
|
const entityGroup = nodeSel.filter((d: SimNode) => d.id === step.entityId)
|
|
entityGroup
|
|
.style('opacity', '0')
|
|
.transition()
|
|
.duration(ANIM_ENTITY_REVEAL_MS)
|
|
.ease(d3.easeBackOut.overshoot(1.2))
|
|
.style('opacity', '1')
|
|
|
|
// Reveal entity connector
|
|
if (connSel) {
|
|
connSel.filter((d: SimNode) => d.id === step.entityId)
|
|
.attr('opacity', 0)
|
|
.transition()
|
|
.duration(ANIM_ENTITY_REVEAL_MS)
|
|
.attr('opacity', 1)
|
|
}
|
|
|
|
visibleNodeIdsRef.current.add(step.entityId)
|
|
|
|
// Reveal new skills (staggered)
|
|
step.newSkillIds.forEach((skillId, i) => {
|
|
scheduleTimeout(() => {
|
|
if (animationStateRef.current !== 'PLAYING') return
|
|
const skillGroup = nodeSel.filter((d: SimNode) => d.id === skillId)
|
|
skillGroup
|
|
.style('opacity', '0')
|
|
.transition()
|
|
.duration(ANIM_SKILL_REVEAL_MS)
|
|
.style('opacity', '1')
|
|
|
|
const restR = deps.skillRestRadiiRef.current.get(skillId) ?? deps.srDefault
|
|
skillGroup.select('.node-circle')
|
|
.attr('r', 0)
|
|
.transition()
|
|
.duration(ANIM_SKILL_REVEAL_MS)
|
|
.ease(d3.easeBackOut)
|
|
.attr('r', restR)
|
|
|
|
visibleNodeIdsRef.current.add(skillId)
|
|
}, i * ANIM_SKILL_STAGGER_MS)
|
|
})
|
|
|
|
// Reinforcement pulse for already-visible skills
|
|
step.reinforcedSkillIds.forEach((skillId, i) => {
|
|
scheduleTimeout(() => {
|
|
if (animationStateRef.current !== 'PLAYING') return
|
|
const restR = deps.skillRestRadiiRef.current.get(skillId) ?? deps.srDefault
|
|
const skillCircle = nodeSel.filter((d: SimNode) => d.id === skillId).select('.node-circle')
|
|
skillCircle
|
|
.transition()
|
|
.duration(ANIM_REINFORCEMENT_MS / 2)
|
|
.attr('r', restR * 1.3)
|
|
.transition()
|
|
.duration(ANIM_REINFORCEMENT_MS / 2)
|
|
.attr('r', restR)
|
|
}, i * ANIM_SKILL_STAGGER_MS)
|
|
})
|
|
|
|
// Reveal links (staggered, after skills start appearing)
|
|
const linkDelay = Math.max(step.newSkillIds.length, 1) * ANIM_SKILL_STAGGER_MS
|
|
step.linkPairs.forEach((pair, i) => {
|
|
scheduleTimeout(() => {
|
|
if (animationStateRef.current !== 'PLAYING') return
|
|
// Only reveal if both endpoints are visible
|
|
if (!visibleNodeIdsRef.current.has(pair.source) || !visibleNodeIdsRef.current.has(pair.target)) return
|
|
|
|
const linkEl = linkSel.filter((l: SimLink) => {
|
|
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
|
|
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
|
|
return src === pair.source && tgt === pair.target
|
|
})
|
|
|
|
linkEl.each(function () {
|
|
const el = d3.select(this)
|
|
const pathEl = this as SVGPathElement
|
|
const length = pathEl.getTotalLength()
|
|
el.attr('opacity', 1)
|
|
.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)
|
|
})
|
|
})
|
|
}, linkDelay + i * ANIM_LINK_STAGGER_MS)
|
|
})
|
|
|
|
// Calculate total step duration and call onComplete
|
|
const skillDuration = Math.max(step.newSkillIds.length, 1) * ANIM_SKILL_STAGGER_MS + ANIM_SKILL_REVEAL_MS
|
|
const linkDuration = linkDelay + step.linkPairs.length * ANIM_LINK_STAGGER_MS + ANIM_LINK_DRAW_MS
|
|
const totalStepMs = Math.max(ANIM_ENTITY_REVEAL_MS, skillDuration, linkDuration)
|
|
|
|
scheduleTimeout(onComplete, totalStepMs + ANIM_STEP_GAP_MS)
|
|
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, deps.timelineGroupRef, deps.skillRestRadiiRef, deps.srDefault, scheduleTimeout])
|
|
|
|
const runAnimation = useCallback(() => {
|
|
if (prefersReducedMotion) return
|
|
|
|
const advanceStep = () => {
|
|
if (animationStateRef.current !== 'PLAYING') return
|
|
|
|
const stepIdx = currentStepRef.current
|
|
if (stepIdx >= animationSteps.length) {
|
|
// All steps done — hold then reset
|
|
animationStateRef.current = 'HOLDING'
|
|
scheduleTimeout(() => {
|
|
if (userPausedRef.current || interactionPausedRef.current) return
|
|
animationStateRef.current = 'RESETTING'
|
|
|
|
// Fade year indicator
|
|
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
|
|
// Fade all
|
|
deps.nodeSelectionRef.current
|
|
?.transition().duration(ANIM_RESET_MS).style('opacity', '0')
|
|
deps.linkSelectionRef.current
|
|
?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
deps.connectorSelectionRef.current
|
|
?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
deps.timelineGroupRef.current
|
|
?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
|
|
scheduleTimeout(() => {
|
|
if (userPausedRef.current) return
|
|
// Reset skill radii
|
|
deps.nodeSelectionRef.current
|
|
?.filter((d: SimNode) => d.type === 'skill')
|
|
.select('.node-circle')
|
|
.attr('r', 0)
|
|
|
|
visibleNodeIdsRef.current = new Set()
|
|
currentStepRef.current = 0
|
|
animationStateRef.current = 'PLAYING'
|
|
setIsPlaying(true)
|
|
|
|
scheduleTimeout(advanceStep, ANIM_RESTART_DELAY_MS)
|
|
}, ANIM_RESET_MS + 50)
|
|
}, ANIM_HOLD_MS)
|
|
return
|
|
}
|
|
|
|
revealStep(stepIdx, () => {
|
|
currentStepRef.current = stepIdx + 1
|
|
advanceStep()
|
|
})
|
|
}
|
|
|
|
// Wait for simulation to settle
|
|
const waitForSettle = () => {
|
|
const sim = deps.simulationRef.current
|
|
if (!sim || sim.alpha() > ANIM_SETTLE_ALPHA) {
|
|
rafIdRef.current = requestAnimationFrame(waitForSettle)
|
|
return
|
|
}
|
|
|
|
// Simulation settled — hide everything and start
|
|
hideAll()
|
|
animationStateRef.current = 'PLAYING'
|
|
setIsPlaying(true)
|
|
currentStepRef.current = 0
|
|
|
|
scheduleTimeout(advanceStep, 100)
|
|
}
|
|
|
|
rafIdRef.current = requestAnimationFrame(waitForSettle)
|
|
}, [deps.simulationRef, deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, deps.timelineGroupRef, hideAll, revealStep, scheduleTimeout])
|
|
|
|
const togglePlayPause = useCallback(() => {
|
|
if (prefersReducedMotion) return
|
|
|
|
if (userPausedRef.current) {
|
|
// Resume
|
|
userPausedRef.current = false
|
|
interactionPausedRef.current = false
|
|
animationStateRef.current = 'RESETTING'
|
|
|
|
// Reset and restart
|
|
hideAll()
|
|
currentStepRef.current = 0
|
|
|
|
scheduleTimeout(() => {
|
|
animationStateRef.current = 'PLAYING'
|
|
setIsPlaying(true)
|
|
runAnimation()
|
|
}, ANIM_RESTART_DELAY_MS)
|
|
} else {
|
|
// Pause
|
|
userPausedRef.current = true
|
|
cancelAll()
|
|
animationStateRef.current = 'PAUSED'
|
|
setIsPlaying(false)
|
|
}
|
|
}, [hideAll, cancelAll, runAnimation, scheduleTimeout])
|
|
|
|
const pauseForInteraction = useCallback(() => {
|
|
if (prefersReducedMotion || userPausedRef.current) return
|
|
if (animationStateRef.current === 'IDLE') return
|
|
interactionPausedRef.current = true
|
|
cancelAll()
|
|
animationStateRef.current = 'PAUSED'
|
|
// Don't setIsPlaying(false) — interaction pause is temporary
|
|
if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
|
|
}, [cancelAll])
|
|
|
|
const resumeAfterInteraction = useCallback(() => {
|
|
if (prefersReducedMotion || userPausedRef.current) return
|
|
if (!interactionPausedRef.current) return
|
|
|
|
if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
|
|
resumeTimerRef.current = window.setTimeout(() => {
|
|
if (userPausedRef.current) return
|
|
interactionPausedRef.current = false
|
|
|
|
// Resume from current state — restart the animation loop from current position
|
|
animationStateRef.current = 'PLAYING'
|
|
setIsPlaying(true)
|
|
|
|
const advanceFromCurrent = () => {
|
|
if (animationStateRef.current !== 'PLAYING') return
|
|
const stepIdx = currentStepRef.current
|
|
if (stepIdx >= animationSteps.length) {
|
|
// We were at the end — hold then reset
|
|
animationStateRef.current = 'HOLDING'
|
|
scheduleTimeout(() => {
|
|
if (userPausedRef.current || interactionPausedRef.current) return
|
|
animationStateRef.current = 'RESETTING'
|
|
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
deps.nodeSelectionRef.current?.transition().duration(ANIM_RESET_MS).style('opacity', '0')
|
|
deps.linkSelectionRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
deps.connectorSelectionRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
deps.timelineGroupRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
scheduleTimeout(() => {
|
|
if (userPausedRef.current) return
|
|
deps.nodeSelectionRef.current
|
|
?.filter((d: SimNode) => d.type === 'skill')
|
|
.select('.node-circle')
|
|
.attr('r', 0)
|
|
visibleNodeIdsRef.current = new Set()
|
|
currentStepRef.current = 0
|
|
animationStateRef.current = 'PLAYING'
|
|
setIsPlaying(true)
|
|
scheduleTimeout(advanceFromCurrent, ANIM_RESTART_DELAY_MS)
|
|
}, ANIM_RESET_MS + 50)
|
|
}, ANIM_HOLD_MS)
|
|
return
|
|
}
|
|
revealStep(stepIdx, () => {
|
|
currentStepRef.current = stepIdx + 1
|
|
advanceFromCurrent()
|
|
})
|
|
}
|
|
|
|
advanceFromCurrent()
|
|
}, ANIM_INTERACTION_RESUME_MS)
|
|
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, deps.timelineGroupRef, revealStep, scheduleTimeout])
|
|
|
|
// Start animation on mount / dimension change
|
|
useEffect(() => {
|
|
if (prefersReducedMotion) {
|
|
// Show final state immediately after a tick to let simulation refs populate
|
|
const id = requestAnimationFrame(() => {
|
|
showFinalState()
|
|
})
|
|
return () => cancelAnimationFrame(id)
|
|
}
|
|
|
|
// Reset and start animation
|
|
cancelAll()
|
|
userPausedRef.current = false
|
|
interactionPausedRef.current = false
|
|
animationStateRef.current = 'IDLE'
|
|
visibleNodeIdsRef.current = new Set()
|
|
currentStepRef.current = 0
|
|
runAnimation()
|
|
|
|
return () => {
|
|
cancelAll()
|
|
animationStateRef.current = 'IDLE'
|
|
}
|
|
}, [deps.dimensionsTrigger, cancelAll, runAnimation, showFinalState])
|
|
|
|
return {
|
|
animationStateRef,
|
|
visibleNodeIdsRef,
|
|
isPlaying,
|
|
togglePlayPause,
|
|
pauseForInteraction,
|
|
resumeAfterInteraction,
|
|
}
|
|
}
|