8b674ffe14
- 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)
92 lines
3.1 KiB
TypeScript
92 lines
3.1 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import * as d3 from 'd3'
|
|
import { supportsCoarsePointer } from '@/components/constellation/constants'
|
|
import type { SimNode, ConstellationCallbacks } from '@/components/constellation/types'
|
|
|
|
export function useConstellationInteraction(deps: {
|
|
highlightGraphRef: React.MutableRefObject<((id: string | null) => void) | null>
|
|
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
|
|
svgRef: React.RefObject<SVGSVGElement | null>
|
|
callbacksRef: React.MutableRefObject<ConstellationCallbacks>
|
|
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)
|
|
|
|
useEffect(() => {
|
|
pinnedNodeIdRef.current = pinnedNodeId
|
|
}, [pinnedNodeId])
|
|
|
|
const bindEvents = useCallback(() => {
|
|
const nodeSelection = deps.nodeSelectionRef.current
|
|
const svgEl = deps.svgRef.current
|
|
if (!nodeSelection || !svgEl) return
|
|
|
|
const svg = d3.select(svgEl)
|
|
|
|
svg.select('.bg-rect').on('click.interaction', () => {
|
|
if (supportsCoarsePointer) {
|
|
setPinnedNodeId(null)
|
|
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 !== 'skill') {
|
|
deps.callbacksRef.current.onNodeHover?.(d.id)
|
|
}
|
|
})
|
|
|
|
nodeSelection.on('mouseleave.interaction', function() {
|
|
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) {
|
|
if (supportsCoarsePointer) {
|
|
if (pinnedNodeIdRef.current === d.id) {
|
|
setPinnedNodeId(null)
|
|
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 !== 'skill' ? d.id : deps.resolveRoleFallback())
|
|
}
|
|
}
|
|
|
|
if (d.type !== 'skill') {
|
|
deps.callbacksRef.current.onRoleClick(d.id)
|
|
} else {
|
|
deps.callbacksRef.current.onSkillClick(d.id)
|
|
}
|
|
})
|
|
}, [deps])
|
|
|
|
useEffect(() => {
|
|
bindEvents()
|
|
}, [deps.dimensionsTrigger, bindEvents])
|
|
|
|
return {
|
|
pinnedNodeId,
|
|
setPinnedNodeId,
|
|
pinnedNodeIdRef,
|
|
}
|
|
}
|