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:
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user