diff --git a/src/components/constellation/AccessibleNodeOverlay.tsx b/src/components/constellation/AccessibleNodeOverlay.tsx index e44a68f..3f999d8 100644 --- a/src/components/constellation/AccessibleNodeOverlay.tsx +++ b/src/components/constellation/AccessibleNodeOverlay.tsx @@ -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 = ({ @@ -22,10 +22,11 @@ export const AccessibleNodeOverlay: React.FC = ({ onKeyDown, }) => { const domainOrder: Record = { 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 = ({ : `${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 ( + )}

{srDescription} @@ -266,7 +306,7 @@ const CareerConstellation: React.FC = ({ 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 = ({ setPinnedNodeId(nodeId) pinnedNodeIdRef.current = nodeId highlightGraphRef.current?.(nodeId) - if (nodeType === 'role') { + if (nodeType !== 'skill') { onNodeHover?.(nodeId) onRoleClick(nodeId) } else { diff --git a/src/components/constellation/constants.ts b/src/components/constellation/constants.ts index 4cd8d9a..98d0898 100644 --- a/src/components/constellation/constants.ts +++ b/src/components/constellation/constants.ts @@ -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 = { clinical: '#059669', diff --git a/src/components/constellation/types.ts b/src/components/constellation/types.ts index ff2c820..055ef8a 100644 --- a/src/components/constellation/types.ts +++ b/src/components/constellation/types.ts @@ -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 }> +} diff --git a/src/data/timeline.ts b/src/data/timeline.ts index ed7a2cc..a5a4406 100644 --- a/src/data/timeline.ts +++ b/src/data/timeline.ts @@ -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, diff --git a/src/hooks/useConstellationHighlight.ts b/src/hooks/useConstellationHighlight.ts index aabe982..a22ebd4 100644 --- a/src/hooks/useConstellationHighlight.ts +++ b/src/hooks/useConstellationHighlight.ts @@ -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 | null> linkSelectionRef: React.MutableRefObject | null> @@ -25,6 +29,7 @@ export function useConstellationHighlight(deps: { srActive: number nodesRef: React.MutableRefObject skillRestRadii?: Map + visibleNodeIdsRef?: React.MutableRefObject> }) { 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 } diff --git a/src/hooks/useConstellationInteraction.ts b/src/hooks/useConstellationInteraction.ts index 4ea4dbc..30cb493 100644 --- a/src/hooks/useConstellationInteraction.ts +++ b/src/hooks/useConstellationInteraction.ts @@ -11,6 +11,8 @@ export function useConstellationInteraction(deps: { resolveGraphFallback: () => string | null resolveRoleFallback: () => string | null dimensionsTrigger: number + pauseForInteraction?: () => void + resumeAfterInteraction?: () => void }) { const [pinnedNodeId, setPinnedNodeId] = useState(null) const pinnedNodeIdRef = useRef(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]) diff --git a/src/hooks/useForceSimulation.ts b/src/hooks/useForceSimulation.ts index 480f424..97c787f 100644 --- a/src/hooks/useForceSimulation.ts +++ b/src/hooks/useForceSimulation.ts @@ -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, @@ -46,6 +48,9 @@ export function useForceSimulation( const nodesRef = useRef([]) const nodeSelectionRef = useRef | null>(null) const linkSelectionRef = useRef | null>(null) + const connectorSelectionRef = useRef | null>(null) + const yearIndicatorRef = useRef | null>(null) + const timelineGroupRef = useRef | null>(null) const connectedMapRef = useRef>>(new Map()) const skillRestRadiiRef = useRef>(new Map()) const layoutParamsRef = useRef(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 + // Timeline guides const timelineGroup = svg.append('g').attr('class', 'timeline-guides') + timelineGroupRef.current = timelineGroup as unknown as d3.Selection 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 + // Simulation const simulation = d3.forceSimulation(nodes) .alpha(0.65) .alphaDecay(prefersReducedMotion ? 0.28 : 0.08) .force('charge', d3.forceManyBody().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(links) .id(d => d.id) .distance(isMobile ? 56 : Math.round(72 * sf)) .strength(d => (d as SimLink).strength * 0.5)) - .force('x', d3.forceX(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25)) + .force('x', d3.forceX(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.25)) .force('y', d3.forceY(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(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, diff --git a/src/hooks/useTimelineAnimation.ts b/src/hooks/useTimelineAnimation.ts new file mode 100644 index 0000000..f8a1c6b --- /dev/null +++ b/src/hooks/useTimelineAnimation.ts @@ -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() + 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 | null> + linkSelectionRef: React.MutableRefObject | null> + simulationRef: React.MutableRefObject | null> + yearIndicatorRef: React.MutableRefObject | null> + connectorSelectionRef: React.MutableRefObject | null> + timelineGroupRef: React.MutableRefObject | null> + skillRestRadiiRef: React.MutableRefObject> + srDefault: number + dimensionsTrigger: number +} + +export function useTimelineAnimation(deps: UseTimelineAnimationDeps) { + const animationStateRef = useRef('IDLE') + const visibleNodeIdsRef = useRef>(new Set()) + const currentStepRef = useRef(0) + const rafIdRef = useRef(0) + const timeoutIdsRef = useRef([]) + 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() + 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, + } +} diff --git a/src/types/pmr.ts b/src/types/pmr.ts index 711ddfb..2cc756f 100644 --- a/src/types/pmr.ts +++ b/src/types/pmr.ts @@ -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