feat: phase 3+4 timeline animation + education entities

- 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)
This commit is contained in:
2026-02-16 14:31:11 +00:00
parent 7d7628c8a7
commit 8b674ffe14
10 changed files with 675 additions and 171 deletions
+43 -92
View File
@@ -12,8 +12,6 @@ import {
LINK_BEZIER_VERTICAL_OFFSET,
SKILL_STROKE_WIDTH, SKILL_STROKE_OPACITY, SKILL_SIZE_ROLE_FACTOR,
SKILL_GLOW_STD_DEVIATION,
ENTRY_GUIDE_FADE_MS, ENTRY_ROLE_STAGGER_MS, ENTRY_ROLE_DURATION_MS,
ENTRY_SKILL_STAGGER_MS, ENTRY_SKILL_DURATION_MS,
} from '@/components/constellation/constants'
import type { SimNode, SimLink, LayoutParams } from '@/components/constellation/types'
@@ -26,13 +24,17 @@ function hashString(input: string): number {
return Math.abs(hash)
}
function isEntityNode(type: string): boolean {
return type === 'role' || type === 'education'
}
function getHeight(width: number, containerHeight?: number | null): number {
if (width < 1024) return 520
if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight)
return 400
}
const roleNodes = constellationNodes.filter(n => n.type === 'role')
const roleNodes = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')
export function useForceSimulation(
svgRef: React.RefObject<SVGSVGElement | null>,
@@ -46,6 +48,9 @@ export function useForceSimulation(
const nodesRef = useRef<SimNode[]>([])
const nodeSelectionRef = useRef<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>(null)
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null>(null)
const connectorSelectionRef = useRef<d3.Selection<SVGLineElement, SimNode, SVGGElement, unknown> | null>(null)
const yearIndicatorRef = useRef<d3.Selection<SVGTextElement, unknown, null, undefined> | null>(null)
const timelineGroupRef = useRef<d3.Selection<SVGGElement, unknown, null, undefined> | null>(null)
const connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
const skillRestRadiiRef = useRef<Map<string, number>>(new Map())
const layoutParamsRef = useRef<LayoutParams | null>(null)
@@ -138,7 +143,7 @@ export function useForceSimulation(
})
// Role gradient defs
const uniqueOrgColors = [...new Set(constellationNodes.filter(n => n.type === 'role').map(n => n.orgColor ?? 'var(--accent)'))]
const uniqueOrgColors = [...new Set(constellationNodes.filter(n => isEntityNode(n.type)).map(n => n.orgColor ?? 'var(--accent)'))]
uniqueOrgColors.forEach((color, i) => {
const grad = defs.append('linearGradient')
.attr('id', `role-grad-${i}`)
@@ -149,8 +154,20 @@ export function useForceSimulation(
})
const orgColorGradientMap = new Map(uniqueOrgColors.map((c, i) => [c, `url(#role-grad-${i})`]))
// Year indicator (for animation)
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)
yearIndicatorRef.current = yearIndicator as unknown as d3.Selection<SVGTextElement, unknown, null, undefined>
// Timeline guides
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
timelineGroupRef.current = timelineGroup as unknown as d3.Selection<SVGGElement, unknown, null, undefined>
const tickYears = d3.range(minYear, maxYear + 1)
timelineGroup.selectAll('line.year-guide')
@@ -218,7 +235,7 @@ export function useForceSimulation(
})
const nodes: SimNode[] = constellationNodes.map(n => {
if (n.type === 'role') {
if (isEntityNode(n.type)) {
const pos = roleInitialMap.get(n.id)!
return { ...n, x: pos.x, y: pos.y, vx: 0, vy: 0, homeX: pos.x, homeY: pos.y }
}
@@ -307,8 +324,10 @@ export function useForceSimulation(
nodeSelectionRef.current = nodeSelection
// Role nodes
nodeSelection.filter(d => d.type === 'role')
// Role + education entity nodes
const entityFilter = (d: SimNode) => isEntityNode(d.type)
nodeSelection.filter(entityFilter)
.append('rect')
.attr('class', 'focus-ring')
.attr('x', -rw / 2 - 3)
@@ -320,7 +339,7 @@ export function useForceSimulation(
.attr('stroke', 'transparent')
.attr('stroke-width', 2)
nodeSelection.filter(d => d.type === 'role')
nodeSelection.filter(entityFilter)
.append('rect')
.attr('class', 'node-circle')
.attr('x', -rw / 2)
@@ -332,8 +351,9 @@ export function useForceSimulation(
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
.attr('stroke-opacity', 0.4)
.attr('stroke-width', 1)
.attr('stroke-dasharray', d => d.type === 'education' ? '4 3' : null)
nodeSelection.filter(d => d.type === 'role')
nodeSelection.filter(entityFilter)
.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'middle')
@@ -383,35 +403,37 @@ export function useForceSimulation(
return label.length > maxLen ? `${label.slice(0, maxLen - 1)}` : label
})
// Role connectors to timeline
// Entity connectors to timeline
const roleConnectors = connectorGroup.selectAll('line.role-connector')
.data(nodes.filter(n => n.type === 'role'))
.data(nodes.filter(n => isEntityNode(n.type)))
.join('line')
.attr('class', 'role-connector')
.attr('stroke', 'var(--border)')
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.3)
connectorSelectionRef.current = roleConnectors as unknown as d3.Selection<SVGLineElement, SimNode, SVGGElement, unknown>
// Simulation
const simulation = d3.forceSimulation<SimNode>(nodes)
.alpha(0.65)
.alphaDecay(prefersReducedMotion ? 0.28 : 0.08)
.force('charge', d3.forceManyBody<SimNode>().strength(d =>
d.type === 'role' ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf))
isEntityNode(d.type) ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf))
))
.force('link', d3.forceLink<SimNode, SimLink>(links)
.id(d => d.id)
.distance(isMobile ? 56 : Math.round(72 * sf))
.strength(d => (d as SimLink).strength * 0.5))
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25))
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.25))
.force('y', d3.forceY<SimNode>(d => {
if (d.type === 'role') {
if (isEntityNode(d.type)) {
return yScale(d.startYear ?? minYear)
}
return d.homeY
}).strength(d => d.type === 'role' ? 0.98 : 0.18))
}).strength(d => isEntityNode(d.type) ? 0.98 : 0.18))
.force('collide', d3.forceCollide<SimNode>(d =>
d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
isEntityNode(d.type) ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
).iterations(3))
simulationRef.current = simulation
@@ -421,7 +443,7 @@ export function useForceSimulation(
const renderTick = () => {
nodes.forEach(d => {
if (d.type === 'role') {
if (isEntityNode(d.type)) {
d.x = Math.max(rw / 2 + 6, Math.min(width - rw / 2 - 6, d.x))
d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y))
} else {
@@ -475,77 +497,6 @@ export function useForceSimulation(
options.applyHighlight(options.resolveGraphFallback())
}
// Entry animation: set initial hidden state for non-reduced-motion
if (!prefersReducedMotion) {
timelineGroup.attr('opacity', 0)
linkSelection.attr('opacity', 0)
nodeSelection.filter(d => d.type === 'role').attr('opacity', 0)
nodeSelection.filter(d => d.type === 'skill')
.attr('opacity', 0)
.select('.node-circle').attr('r', 0)
roleConnectors.attr('opacity', 0)
}
let entryAnimationRan = false
const maybeRunEntryAnimation = () => {
if (entryAnimationRan || prefersReducedMotion) return
if (simulation.alpha() > 0.05) return
entryAnimationRan = true
const roleCount = nodes.filter(n => n.type === 'role').length
const skillCount = constellationNodes.filter(n => n.type === 'skill').length
// Timeline guides fade in
timelineGroup.transition().duration(ENTRY_GUIDE_FADE_MS).attr('opacity', 1)
// Role nodes staggered
nodeSelection.filter(d => d.type === 'role')
.transition()
.delay((_d, i) => ENTRY_GUIDE_FADE_MS + i * ENTRY_ROLE_STAGGER_MS)
.duration(ENTRY_ROLE_DURATION_MS)
.attr('opacity', 1)
// Role connectors follow their roles
roleConnectors
.transition()
.delay((_d, i) => ENTRY_GUIDE_FADE_MS + i * ENTRY_ROLE_STAGGER_MS)
.duration(ENTRY_ROLE_DURATION_MS)
.attr('opacity', 1)
// Skill nodes scale up
const roleAnimEnd = ENTRY_GUIDE_FADE_MS + roleCount * ENTRY_ROLE_STAGGER_MS + ENTRY_ROLE_DURATION_MS
nodeSelection.filter(d => d.type === 'skill')
.transition()
.delay((_d, i) => roleAnimEnd + i * ENTRY_SKILL_STAGGER_MS)
.duration(ENTRY_SKILL_DURATION_MS)
.attr('opacity', 1)
nodeSelection.filter(d => d.type === 'skill')
.select('.node-circle')
.transition()
.delay((_d, i) => roleAnimEnd + i * ENTRY_SKILL_STAGGER_MS)
.duration(ENTRY_SKILL_DURATION_MS)
.attr('r', d => skillRestRadii.get(d.id) ?? srDefault)
// Links draw on via stroke-dashoffset
const skillAnimEnd = roleAnimEnd + skillCount * ENTRY_SKILL_STAGGER_MS + ENTRY_SKILL_DURATION_MS
linkSelection
.each(function () {
const length = (this as SVGPathElement).getTotalLength()
d3.select(this)
.attr('stroke-dasharray', `${length} ${length}`)
.attr('stroke-dashoffset', length)
})
.attr('opacity', 1)
.transition()
.delay((_d, i) => skillAnimEnd + i * 15)
.duration(300)
.attr('stroke-dashoffset', 0)
.on('end', function () {
d3.select(this).attr('stroke-dasharray', null).attr('stroke-dashoffset', null)
})
}
if (prefersReducedMotion) {
simulation.stop()
for (let i = 0; i < 150; i++) {
@@ -553,10 +504,7 @@ export function useForceSimulation(
}
renderTick()
} else {
simulation.on('tick', () => {
renderTick()
maybeRunEntryAnimation()
})
simulation.on('tick', renderTick)
}
return () => {
@@ -569,6 +517,9 @@ export function useForceSimulation(
nodesRef,
nodeSelectionRef,
linkSelectionRef,
connectorSelectionRef,
yearIndicatorRef,
timelineGroupRef,
nodeButtonPositions,
layoutParams: layoutParamsRef.current,
connectedMap: connectedMapRef.current,