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
+38 -15
View File
@@ -17,6 +17,10 @@ function getSkillDomainColor(link: SimLink, nodes: SimNode[]): string {
return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
}
function resolveLinkId(end: SimNode | string): string {
return typeof end === 'string' ? end : end.id
}
export function useConstellationHighlight(deps: {
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
linkSelectionRef: React.MutableRefObject<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null>
@@ -25,6 +29,7 @@ export function useConstellationHighlight(deps: {
srActive: number
nodesRef: React.MutableRefObject<SimNode[]>
skillRestRadii?: Map<string, number>
visibleNodeIdsRef?: React.MutableRefObject<Set<string>>
}) {
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
@@ -36,11 +41,14 @@ export function useConstellationHighlight(deps: {
const { srDefault, srActive, connectedMap, skillRestRadii } = deps
const nodes = deps.nodesRef.current
const dur = prefersReducedMotion ? 0 : 180
const visibleIds = deps.visibleNodeIdsRef?.current
const isVisible = (id: string) => !visibleIds || visibleIds.size === 0 || visibleIds.has(id)
if (!activeNodeId) {
nodeSelection.style('opacity', '1')
// Reset — respect animation visibility
nodeSelection.style('opacity', d => isVisible(d.id) ? '1' : '0')
nodeSelection.filter(d => d.type === 'role')
nodeSelection.filter(d => d.type !== 'skill')
.attr('filter', null)
.select('.node-circle')
.attr('fill-opacity', null)
@@ -52,7 +60,7 @@ export function useConstellationHighlight(deps: {
if (dur > 0) {
skillNodes.select('.node-circle')
.transition().duration(dur)
.attr('r', d => getRestRadius(d))
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
.attr('fill-opacity', 0.35)
.attr('filter', null)
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
@@ -61,7 +69,7 @@ export function useConstellationHighlight(deps: {
.attr('opacity', 0.5)
} else {
skillNodes.select('.node-circle')
.attr('r', d => getRestRadius(d))
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
.attr('fill-opacity', 0.35)
.attr('filter', null)
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
@@ -72,7 +80,12 @@ export function useConstellationHighlight(deps: {
linkSelection
.attr('stroke', l => getSkillDomainColor(l, nodes))
.attr('stroke-width', l => LINK_BASE_WIDTH + l.strength * LINK_STRENGTH_WIDTH_FACTOR)
.attr('stroke-opacity', l => LINK_BASE_OPACITY + l.strength * LINK_STRENGTH_OPACITY_FACTOR)
.attr('stroke-opacity', l => {
const src = resolveLinkId(l.source)
const tgt = resolveLinkId(l.target)
if (!isVisible(src) || !isVisible(tgt)) return 0
return LINK_BASE_OPACITY + l.strength * LINK_STRENGTH_OPACITY_FACTOR
})
return
}
@@ -80,9 +93,12 @@ export function useConstellationHighlight(deps: {
const connected = connectedMap.get(activeNodeId) ?? new Set()
const isInGroup = (id: string) => id === activeNodeId || connected.has(id)
nodeSelection.style('opacity', d => isInGroup(d.id) ? '1' : '0.15')
nodeSelection.style('opacity', d => {
if (!isVisible(d.id)) return '0'
return isInGroup(d.id) ? '1' : '0.15'
})
nodeSelection.filter(d => d.type === 'role')
nodeSelection.filter(d => d.type !== 'skill')
.attr('filter', d => {
if (d.id === activeNodeId) return 'url(#shadow-md-filter)'
if (connected.has(d.id)) return 'url(#shadow-sm-filter)'
@@ -106,7 +122,10 @@ export function useConstellationHighlight(deps: {
if (dur > 0) {
skillNodes.select('.node-circle')
.transition().duration(dur)
.attr('r', d => isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d))
.attr('r', d => {
if (!isVisible(d.id)) return 0
return isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d)
})
.attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
.attr('filter', d => isInGroup(d.id) ? `url(#glow-${d.domain ?? 'technical'})` : null)
.attr('stroke-opacity', d => isInGroup(d.id) ? 0.8 : SKILL_STROKE_OPACITY)
@@ -115,7 +134,10 @@ export function useConstellationHighlight(deps: {
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
} else {
skillNodes.select('.node-circle')
.attr('r', d => isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d))
.attr('r', d => {
if (!isVisible(d.id)) return 0
return isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d)
})
.attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
.attr('filter', d => isInGroup(d.id) ? `url(#glow-${d.domain ?? 'technical'})` : null)
.attr('stroke-opacity', d => isInGroup(d.id) ? 0.8 : SKILL_STROKE_OPACITY)
@@ -125,8 +147,8 @@ export function useConstellationHighlight(deps: {
linkSelection
.attr('stroke', l => {
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
const src = resolveLinkId(l.source)
const tgt = resolveLinkId(l.target)
if (src === activeNodeId || tgt === activeNodeId) {
const skillId = src === activeNodeId ? tgt : src
const skillNode = nodes.find(n => n.id === skillId)
@@ -135,16 +157,17 @@ export function useConstellationHighlight(deps: {
return getSkillDomainColor(l, nodes)
})
.attr('stroke-opacity', l => {
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
const src = resolveLinkId(l.source)
const tgt = resolveLinkId(l.target)
if (!isVisible(src) || !isVisible(tgt)) return 0
if (src === activeNodeId || tgt === activeNodeId) {
return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2))
}
return LINK_BASE_OPACITY + l.strength * LINK_STRENGTH_OPACITY_FACTOR
})
.attr('stroke-width', l => {
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
const src = resolveLinkId(l.source)
const tgt = resolveLinkId(l.target)
if (src === activeNodeId || tgt === activeNodeId) {
return LINK_HIGHLIGHT_BASE_WIDTH + l.strength * LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR
}