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
@@ -8,8 +8,8 @@ interface AccessibleNodeOverlayProps {
dimensions: { width: number; height: number; scaleFactor: number }
onFocus: (nodeId: string) => void
onBlur: () => void
onClick: (nodeId: string, nodeType: 'role' | 'skill') => void
onKeyDown: (e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => void
onClick: (nodeId: string, nodeType: 'role' | 'skill' | 'education') => void
onKeyDown: (e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill' | 'education') => void
}
export const AccessibleNodeOverlay: React.FC<AccessibleNodeOverlayProps> = ({
@@ -22,10 +22,11 @@ export const AccessibleNodeOverlay: React.FC<AccessibleNodeOverlayProps> = ({
onKeyDown,
}) => {
const domainOrder: Record<string, number> = { technical: 0, clinical: 1, leadership: 2 }
const isEntity = (t: string) => t === 'role' || t === 'education'
const sorted = [...nodes].sort((a, b) => {
if (a.type === 'role' && b.type !== 'role') return -1
if (a.type !== 'role' && b.type === 'role') return 1
if (a.type === 'role' && b.type === 'role') {
if (isEntity(a.type) && !isEntity(b.type)) return -1
if (!isEntity(a.type) && isEntity(b.type)) return 1
if (isEntity(a.type) && isEntity(b.type)) {
return (b.startYear ?? 0) - (a.startYear ?? 0)
}
const da = domainOrder[a.domain ?? 'technical'] ?? 0
@@ -56,15 +57,16 @@ export const AccessibleNodeOverlay: React.FC<AccessibleNodeOverlayProps> = ({
: `${node.startYear}-present`
const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
const buttonWidth = node.type === 'role' ? (isMobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf)
const buttonHeight = node.type === 'role' ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf)
const isEntityBtn = isEntity(node.type)
const buttonWidth = isEntityBtn ? (isMobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf)
const buttonHeight = isEntityBtn ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf)
return (
<button
key={node.id}
type="button"
aria-label={
node.type === 'role'
isEntityBtn
? `${node.label} at ${node.organization}, ${yearRange}. Press Enter to view details.`
: `${node.label} skill node. Press Enter to view details.`
}
@@ -1,10 +1,11 @@
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'
import * as d3 from 'd3'
import { constellationNodes, roleSkillMappings } from '@/data/constellation'
import { timelineCareerEntities } from '@/data/timeline'
import { timelineEntities } from '@/data/timeline'
import { useForceSimulation, getHeight } from '@/hooks/useForceSimulation'
import { useConstellationHighlight } from '@/hooks/useConstellationHighlight'
import { useConstellationInteraction } from '@/hooks/useConstellationInteraction'
import { useTimelineAnimation } from '@/hooks/useTimelineAnimation'
import { MobileAccordion } from './MobileAccordion'
import { ConstellationLegend } from './ConstellationLegend'
import { AccessibleNodeOverlay } from './AccessibleNodeOverlay'
@@ -12,7 +13,7 @@ import {
MIN_HEIGHT,
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
supportsCoarsePointer,
supportsCoarsePointer, prefersReducedMotion,
} from './constants'
interface CareerConstellationProps {
@@ -24,29 +25,29 @@ interface CareerConstellationProps {
}
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
const careerEntityById = new Map(timelineCareerEntities.map(entity => [entity.id, entity]))
const careerEntityById = new Map(timelineEntities.map(entity => [entity.id, entity]))
const srDescription = buildScreenReaderDescription()
function buildScreenReaderDescription(): string {
const roles = constellationNodes.filter(n => n.type === 'role')
const entities = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')
const skills = constellationNodes.filter(n => n.type === 'skill')
const roleDescriptions = roles.map(role => {
const mapping = roleSkillMappings.find(m => m.roleId === role.id)
const entityDescriptions = entities.map(entity => {
const mapping = roleSkillMappings.find(m => m.roleId === entity.id)
const skillNames = mapping
? mapping.skillIds
.map(sid => skills.find(s => s.id === sid)?.label)
.filter(Boolean)
.join(', ')
: ''
const yearRange = role.endYear
? `${role.startYear}-${role.endYear}`
: `${role.startYear}-present`
return `${role.label} at ${role.organization} (${yearRange}): ${skillNames}`
const yearRange = entity.endYear
? `${entity.startYear}-${entity.endYear}`
: `${entity.startYear}-present`
return `${entity.label} at ${entity.organization} (${yearRange}): ${skillNames}`
})
return `Career constellation graph showing ${roles.length} roles and ${skills.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` +
roleDescriptions.join('. ') + '.'
return `Career constellation graph showing ${entities.length} roles and ${skills.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` +
entityDescriptions.join('. ') + '.'
}
const CareerConstellation: React.FC<CareerConstellationProps> = ({
@@ -69,7 +70,6 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
highlightedNodeIdRef.current = highlightedNodeId ?? null
}, [highlightedNodeId])
// ResizeObserver for container dimensions
useEffect(() => {
const container = containerRef.current
if (!container) return
@@ -90,7 +90,6 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
return () => observer.disconnect()
}, [containerHeight])
// Compute layout-dependent skill radii for highlight hook
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
const sf = isMobile ? 1 : dimensions.scaleFactor
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
@@ -103,20 +102,22 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const resolveRoleFallback = useCallback(() => {
const hId = highlightedNodeIdRef.current
if (hId && nodeById.get(hId)?.type === 'role') return hId
const hType = hId ? nodeById.get(hId)?.type : null
if (hId && hType && hType !== 'skill') return hId
const pId = pinnedNodeIdRef.current
if (pId && nodeById.get(pId)?.type === 'role') return pId
const pType = pId ? nodeById.get(pId)?.type : null
if (pId && pType && pType !== 'skill') return pId
return null
}, [])
// Highlight hook (needs to be created before simulation so we can pass applyHighlight)
// Shared refs for hooks
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
const nodesRef = useRef<import('./types').SimNode[]>([])
const nodeSelectionRef = useRef<d3.Selection<SVGGElement, import('./types').SimNode, SVGGElement, unknown> | null>(null)
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, import('./types').SimLink, SVGGElement, unknown> | null>(null)
const connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
const skillRestRadiiRef = useRef<Map<string, number>>(new Map())
const visibleNodeIdsRef = useRef<Set<string>>(new Set())
const { applyGraphHighlight } = useConstellationHighlight({
nodeSelectionRef,
@@ -126,11 +127,11 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
srActive,
nodesRef,
skillRestRadii: skillRestRadiiRef.current,
visibleNodeIdsRef,
})
highlightGraphRef.current = applyGraphHighlight
// Stable options ref for simulation to avoid re-creating on every render
const simOptionsRef = useRef({
resolveGraphFallback,
applyHighlight: applyGraphHighlight,
@@ -144,19 +145,31 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const sim = useForceSimulation(svgRef, dimensions, stableSimOptions)
// Sync simulation refs to our local refs for highlight/interaction hooks
// Sync simulation refs
useEffect(() => {
nodesRef.current = sim.nodesRef.current
nodeSelectionRef.current = sim.nodeSelectionRef.current
linkSelectionRef.current = sim.linkSelectionRef.current
if (sim.connectedMap.size > 0) {
connectedMapRef.current = sim.connectedMap
}
if (sim.skillRestRadii.size > 0) {
skillRestRadiiRef.current = sim.skillRestRadii
}
if (sim.connectedMap.size > 0) connectedMapRef.current = sim.connectedMap
if (sim.skillRestRadii.size > 0) skillRestRadiiRef.current = sim.skillRestRadii
})
// Animation hook
const animation = useTimelineAnimation({
nodeSelectionRef,
linkSelectionRef,
simulationRef: sim.simulationRef,
yearIndicatorRef: sim.yearIndicatorRef,
connectorSelectionRef: sim.connectorSelectionRef,
timelineGroupRef: sim.timelineGroupRef,
skillRestRadiiRef,
srDefault,
dimensionsTrigger: dimensions.width + dimensions.height,
})
// Sync visibleNodeIdsRef from animation hook
visibleNodeIdsRef.current = animation.visibleNodeIdsRef.current
// Interaction hook
const { pinnedNodeId, setPinnedNodeId, pinnedNodeIdRef } = useConstellationInteraction({
highlightGraphRef,
@@ -166,6 +179,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
resolveGraphFallback,
resolveRoleFallback,
dimensionsTrigger: dimensions.width + dimensions.height,
pauseForInteraction: animation.pauseForInteraction,
resumeAfterInteraction: animation.resumeAfterInteraction,
})
// External highlight sync
@@ -178,9 +193,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
svg.selectAll('.focus-ring').attr('stroke', 'transparent')
if (focusedNodeId) {
svg.selectAll<SVGGElement, { id: string }>('g.node')
.filter(d => d.id === focusedNodeId)
@@ -190,18 +203,17 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
}
}, [focusedNodeId])
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill' | 'education') => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId)
onNodeHover?.(nodeType === 'role' ? nodeId : resolveRoleFallback())
;(nodeType === 'role' ? onRoleClick : onSkillClick)(nodeId)
onNodeHover?.(nodeType !== 'skill' ? nodeId : resolveRoleFallback())
;(nodeType !== 'skill' ? onRoleClick : onSkillClick)(nodeId)
}, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback, setPinnedNodeId, pinnedNodeIdRef])
// Pinned career entity for mobile accordion
const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && n.type === 'role') : null
const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && (n.type === 'role' || n.type === 'education')) : null
const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) ?? null : null
const domainCounts = useMemo(() => {
const counts: Record<string, number> = {}
@@ -237,22 +249,50 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
<MobileAccordion
pinnedCareerEntity={pinnedCareerEntity}
show={showAccordion}
/>
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
{!prefersReducedMotion && (
<button
onClick={animation.togglePlayPause}
aria-label={animation.isPlaying ? 'Pause animation' : 'Play animation'}
style={{
position: 'absolute',
bottom: isMobile ? 8 : 12,
right: isMobile ? 8 : 12,
width: isMobile ? 44 : 36,
height: isMobile ? 44 : 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',
}}
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.6')}
>
{animation.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>
)}
<p
style={{
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
border: 0,
width: 1, height: 1, padding: 0, margin: -1,
overflow: 'hidden', clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap', border: 0,
}}
>
{srDescription}
@@ -266,7 +306,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
setFocusedNodeId(nodeId)
highlightGraphRef.current?.(nodeId)
const node = nodeById.get(nodeId)
if (node?.type === 'role') onNodeHover?.(nodeId)
if (node?.type !== 'skill') onNodeHover?.(nodeId)
}}
onBlur={() => {
setFocusedNodeId(null)
@@ -277,7 +317,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId)
if (nodeType === 'role') {
if (nodeType !== 'skill') {
onNodeHover?.(nodeId)
onRoleClick(nodeId)
} else {
+14
View File
@@ -39,6 +39,20 @@ export const ENTRY_ROLE_DURATION_MS = 300
export const ENTRY_SKILL_STAGGER_MS = 30
export const ENTRY_SKILL_DURATION_MS = 250
// Timeline animation
export const ANIM_ENTITY_REVEAL_MS = 600
export const ANIM_SKILL_REVEAL_MS = 350
export const ANIM_SKILL_STAGGER_MS = 60
export const ANIM_LINK_DRAW_MS = 300
export const ANIM_LINK_STAGGER_MS = 40
export const ANIM_REINFORCEMENT_MS = 350
export const ANIM_STEP_GAP_MS = 400
export const ANIM_HOLD_MS = 3000
export const ANIM_RESET_MS = 400
export const ANIM_RESTART_DELAY_MS = 200
export const ANIM_INTERACTION_RESUME_MS = 800
export const ANIM_SETTLE_ALPHA = 0.05
// Domain color map
export const DOMAIN_COLOR_MAP: Record<string, string> = {
clinical: '#059669',
+11
View File
@@ -39,3 +39,14 @@ export interface ConstellationCallbacks {
onSkillClick: (id: string) => void
onNodeHover?: (id: string | null) => void
}
export type AnimationState = 'IDLE' | 'PLAYING' | 'PAUSED' | 'HOLDING' | 'RESETTING'
export interface AnimationStep {
entityId: string
startYear: number
skillIds: string[]
newSkillIds: string[]
reinforcedSkillIds: string[]
linkPairs: Array<{ source: string; target: string }>
}
+4 -4
View File
@@ -447,14 +447,14 @@ export function buildConstellationData(): {
constellationNodes: ConstellationNode[]
constellationLinks: ConstellationLink[]
} {
const roleSkillMappings: RoleSkillMapping[] = timelineCareerEntities.map((entity) => ({
const roleSkillMappings: RoleSkillMapping[] = timelineEntities.map((entity) => ({
roleId: entity.id,
skillIds: entity.skills,
}))
const roleNodes: ConstellationNode[] = timelineCareerEntities.map((entity) => ({
const roleNodes: ConstellationNode[] = timelineEntities.map((entity) => ({
id: entity.id,
type: 'role',
type: entity.kind === 'education' ? 'education' as const : 'role' as const,
label: entity.title,
shortLabel: entity.graphLabel,
organization: entity.organization,
@@ -471,7 +471,7 @@ export function buildConstellationData(): {
domain: skillDomainByCategory[skill.category],
}))
const constellationLinks: ConstellationLink[] = timelineCareerEntities.flatMap((entity) =>
const constellationLinks: ConstellationLink[] = timelineEntities.flatMap((entity) =>
entity.skills.map((skillId) => ({
source: entity.id,
target: skillId,
+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
}
+10 -4
View File
@@ -11,6 +11,8 @@ export function useConstellationInteraction(deps: {
resolveGraphFallback: () => string | null
resolveRoleFallback: () => string | null
dimensionsTrigger: number
pauseForInteraction?: () => void
resumeAfterInteraction?: () => void
}) {
const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null)
const pinnedNodeIdRef = useRef<string | null>(null)
@@ -32,13 +34,15 @@ export function useConstellationInteraction(deps: {
pinnedNodeIdRef.current = null
deps.highlightGraphRef.current?.(null)
deps.callbacksRef.current.onNodeHover?.(null)
deps.resumeAfterInteraction?.()
}
})
nodeSelection.on('mouseenter.interaction', function(_event: MouseEvent, d: SimNode) {
if (supportsCoarsePointer) return
deps.pauseForInteraction?.()
deps.highlightGraphRef.current?.(d.id)
if (d.type === 'role') {
if (d.type !== 'skill') {
deps.callbacksRef.current.onNodeHover?.(d.id)
}
})
@@ -47,6 +51,7 @@ export function useConstellationInteraction(deps: {
if (supportsCoarsePointer) return
deps.highlightGraphRef.current?.(deps.resolveGraphFallback())
deps.callbacksRef.current.onNodeHover?.(deps.resolveRoleFallback())
deps.resumeAfterInteraction?.()
})
nodeSelection.on('click.interaction', function(_event: MouseEvent, d: SimNode) {
@@ -56,15 +61,17 @@ export function useConstellationInteraction(deps: {
pinnedNodeIdRef.current = null
deps.highlightGraphRef.current?.(null)
deps.callbacksRef.current.onNodeHover?.(null)
deps.resumeAfterInteraction?.()
} else {
setPinnedNodeId(d.id)
pinnedNodeIdRef.current = d.id
deps.pauseForInteraction?.()
deps.highlightGraphRef.current?.(d.id)
deps.callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : deps.resolveRoleFallback())
deps.callbacksRef.current.onNodeHover?.(d.type !== 'skill' ? d.id : deps.resolveRoleFallback())
}
}
if (d.type === 'role') {
if (d.type !== 'skill') {
deps.callbacksRef.current.onRoleClick(d.id)
} else {
deps.callbacksRef.current.onSkillClick(d.id)
@@ -72,7 +79,6 @@ export function useConstellationInteraction(deps: {
})
}, [deps])
// Re-bind events whenever selections change (triggered by simulation re-creation)
useEffect(() => {
bindEvents()
}, [deps.dimensionsTrigger, bindEvents])
+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,
+457
View File
@@ -0,0 +1,457 @@
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,
}
}
+1 -1
View File
@@ -187,7 +187,7 @@ export interface KPIStory {
// Constellation-specific types
export interface ConstellationNode {
id: string
type: 'role' | 'skill'
type: 'role' | 'skill' | 'education'
label: string
shortLabel?: string // abbreviated for small nodes
organization?: string