feat: phase 2 visual improvements for CareerConstellation
- Links: domain-colored with strength-weighted width/opacity, improved bezier curves - Skill nodes: domain-colored stroke, size encoding by connected role count, glow filter on highlight - Role nodes: gradient fill (orgColor 0.08→0.18), enhanced highlight with fill-opacity and stroke-width - Entry animation: staggered reveal (guides→roles→skills→links with stroke-dashoffset), skipped under prefers-reduced-motion - Legend: domain node counts displayed
This commit is contained in:
@@ -116,6 +116,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
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 { applyGraphHighlight } = useConstellationHighlight({
|
||||
nodeSelectionRef,
|
||||
linkSelectionRef,
|
||||
@@ -123,6 +125,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
srDefault,
|
||||
srActive,
|
||||
nodesRef,
|
||||
skillRestRadii: skillRestRadiiRef.current,
|
||||
})
|
||||
|
||||
highlightGraphRef.current = applyGraphHighlight
|
||||
@@ -149,6 +152,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
if (sim.connectedMap.size > 0) {
|
||||
connectedMapRef.current = sim.connectedMap
|
||||
}
|
||||
if (sim.skillRestRadii.size > 0) {
|
||||
skillRestRadiiRef.current = sim.skillRestRadii
|
||||
}
|
||||
})
|
||||
|
||||
// Interaction hook
|
||||
@@ -185,27 +191,27 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
}, [focusedNodeId])
|
||||
|
||||
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
setPinnedNodeId(nodeId)
|
||||
pinnedNodeIdRef.current = nodeId
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
if (nodeType === 'role') {
|
||||
onNodeHover?.(nodeId)
|
||||
} else {
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
}
|
||||
if (nodeType === 'role') {
|
||||
onRoleClick(nodeId)
|
||||
} else {
|
||||
onSkillClick(nodeId)
|
||||
}
|
||||
}
|
||||
onNodeHover?.(nodeType === 'role' ? nodeId : resolveRoleFallback())
|
||||
;(nodeType === 'role' ? 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 pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) ?? null : null
|
||||
const domainCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
constellationNodes.filter(n => n.type === 'skill').forEach(n => {
|
||||
const d = n.domain ?? 'technical'
|
||||
counts[d] = (counts[d] ?? 0) + 1
|
||||
})
|
||||
return counts
|
||||
}, [])
|
||||
|
||||
const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
|
||||
|
||||
return (
|
||||
@@ -229,7 +235,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
|
||||
<ConstellationLegend isTouch={supportsCoarsePointer} />
|
||||
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
||||
|
||||
<MobileAccordion
|
||||
pinnedCareerEntity={pinnedCareerEntity}
|
||||
|
||||
@@ -3,13 +3,14 @@ import { supportsCoarsePointer } from './constants'
|
||||
|
||||
interface ConstellationLegendProps {
|
||||
isTouch: boolean
|
||||
domainCounts?: Record<string, number>
|
||||
}
|
||||
|
||||
export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch }) => {
|
||||
export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch, domainCounts }) => {
|
||||
const items = [
|
||||
{ label: 'Technical', color: 'var(--accent)' },
|
||||
{ label: 'Clinical', color: 'var(--success)' },
|
||||
{ label: 'Leadership', color: 'var(--amber)' },
|
||||
{ label: 'Technical', domain: 'technical', color: 'var(--accent)' },
|
||||
{ label: 'Clinical', domain: 'clinical', color: 'var(--success)' },
|
||||
{ label: 'Leadership', domain: 'leadership', color: 'var(--amber)' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -42,7 +43,7 @@ export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouc
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{item.label}
|
||||
{item.label}{domainCounts?.[item.domain] != null ? ` (${domainCounts[item.domain]})` : ''}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -15,9 +15,30 @@ export const MOBILE_LABEL_MAX_LEN = 10
|
||||
export const HIGHLIGHT_DIM_OPACITY = 0.15
|
||||
export const SKILL_REST_OPACITY = 0.35
|
||||
export const SKILL_ACTIVE_OPACITY = 0.9
|
||||
export const LINK_REST_OPACITY = 0.15
|
||||
export const LABEL_REST_OPACITY = 0.5
|
||||
|
||||
// Link visual params
|
||||
export const LINK_BASE_WIDTH = 0.5
|
||||
export const LINK_STRENGTH_WIDTH_FACTOR = 1.5
|
||||
export const LINK_BASE_OPACITY = 0.08
|
||||
export const LINK_STRENGTH_OPACITY_FACTOR = 0.12
|
||||
export const LINK_HIGHLIGHT_BASE_WIDTH = 1
|
||||
export const LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR = 2
|
||||
export const LINK_BEZIER_VERTICAL_OFFSET = 0.15
|
||||
|
||||
// Skill node visual params
|
||||
export const SKILL_STROKE_WIDTH = 1
|
||||
export const SKILL_STROKE_OPACITY = 0.4
|
||||
export const SKILL_SIZE_ROLE_FACTOR = 0.8
|
||||
export const SKILL_GLOW_STD_DEVIATION = 2.5
|
||||
|
||||
// Entry animation
|
||||
export const ENTRY_GUIDE_FADE_MS = 200
|
||||
export const ENTRY_ROLE_STAGGER_MS = 80
|
||||
export const ENTRY_ROLE_DURATION_MS = 300
|
||||
export const ENTRY_SKILL_STAGGER_MS = 30
|
||||
export const ENTRY_SKILL_DURATION_MS = 250
|
||||
|
||||
// Domain color map
|
||||
export const DOMAIN_COLOR_MAP: Record<string, string> = {
|
||||
clinical: '#059669',
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import type * as d3 from 'd3'
|
||||
import { DOMAIN_COLOR_MAP, prefersReducedMotion } from '@/components/constellation/constants'
|
||||
import {
|
||||
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
||||
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
||||
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
||||
LINK_HIGHLIGHT_BASE_WIDTH, LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR,
|
||||
SKILL_STROKE_OPACITY,
|
||||
} from '@/components/constellation/constants'
|
||||
import type { SimNode, SimLink } from '@/components/constellation/types'
|
||||
|
||||
function getSkillDomainColor(link: SimLink, nodes: SimNode[]): string {
|
||||
const tgtId = typeof link.target === 'string' ? link.target : (link.target as SimNode).id
|
||||
const srcId = typeof link.source === 'string' ? link.source : (link.source as SimNode).id
|
||||
const skillId = nodes.find(n => n.id === tgtId)?.type === 'skill' ? tgtId : srcId
|
||||
const skillNode = nodes.find(n => n.id === skillId)
|
||||
return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
|
||||
}
|
||||
|
||||
export function useConstellationHighlight(deps: {
|
||||
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
|
||||
linkSelectionRef: React.MutableRefObject<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null>
|
||||
@@ -10,6 +24,7 @@ export function useConstellationHighlight(deps: {
|
||||
srDefault: number
|
||||
srActive: number
|
||||
nodesRef: React.MutableRefObject<SimNode[]>
|
||||
skillRestRadii?: Map<string, number>
|
||||
}) {
|
||||
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
|
||||
|
||||
@@ -18,7 +33,7 @@ export function useConstellationHighlight(deps: {
|
||||
const linkSelection = deps.linkSelectionRef.current
|
||||
if (!nodeSelection || !linkSelection) return
|
||||
|
||||
const { srDefault, srActive, connectedMap } = deps
|
||||
const { srDefault, srActive, connectedMap, skillRestRadii } = deps
|
||||
const nodes = deps.nodesRef.current
|
||||
const dur = prefersReducedMotion ? 0 : 180
|
||||
|
||||
@@ -28,30 +43,36 @@ export function useConstellationHighlight(deps: {
|
||||
nodeSelection.filter(d => d.type === 'role')
|
||||
.attr('filter', null)
|
||||
.select('.node-circle')
|
||||
.attr('fill-opacity', null)
|
||||
.attr('stroke-opacity', 0.4)
|
||||
.attr('stroke-width', 1)
|
||||
|
||||
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
||||
if (dur > 0) {
|
||||
skillNodes.select('.node-circle')
|
||||
.transition().duration(dur)
|
||||
.attr('r', srDefault)
|
||||
.attr('r', d => getRestRadius(d))
|
||||
.attr('fill-opacity', 0.35)
|
||||
.attr('filter', null)
|
||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||
skillNodes.select('.node-label')
|
||||
.transition().duration(dur)
|
||||
.attr('opacity', 0.5)
|
||||
} else {
|
||||
skillNodes.select('.node-circle')
|
||||
.attr('r', srDefault)
|
||||
.attr('r', d => getRestRadius(d))
|
||||
.attr('fill-opacity', 0.35)
|
||||
.attr('filter', null)
|
||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||
skillNodes.select('.node-label')
|
||||
.attr('opacity', 0.5)
|
||||
}
|
||||
|
||||
linkSelection
|
||||
.attr('stroke', 'var(--border-light)')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-opacity', 0.15)
|
||||
.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)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -68,26 +89,36 @@ export function useConstellationHighlight(deps: {
|
||||
return null
|
||||
})
|
||||
.select('.node-circle')
|
||||
.attr('fill-opacity', d => d.id === activeNodeId ? 0.25 : null)
|
||||
.attr('stroke-opacity', d => {
|
||||
if (d.id === activeNodeId) return 1
|
||||
if (connected.has(d.id)) return 0.7
|
||||
return 0.4
|
||||
})
|
||||
.attr('stroke-width', d => d.id === activeNodeId ? 1.5 : 1)
|
||||
.attr('stroke-width', d => d.id === activeNodeId ? 2 : 1)
|
||||
|
||||
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
||||
const getActiveRadius = (d: SimNode) => {
|
||||
const roleCount = (skillRestRadii?.get(d.id) ?? srDefault) - srDefault
|
||||
return srActive + roleCount
|
||||
}
|
||||
if (dur > 0) {
|
||||
skillNodes.select('.node-circle')
|
||||
.transition().duration(dur)
|
||||
.attr('r', d => isInGroup(d.id) ? srActive : srDefault)
|
||||
.attr('r', d => 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)
|
||||
skillNodes.select('.node-label')
|
||||
.transition().duration(dur)
|
||||
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
|
||||
} else {
|
||||
skillNodes.select('.node-circle')
|
||||
.attr('r', d => isInGroup(d.id) ? srActive : srDefault)
|
||||
.attr('r', d => 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)
|
||||
skillNodes.select('.node-label')
|
||||
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
|
||||
}
|
||||
@@ -101,7 +132,7 @@ export function useConstellationHighlight(deps: {
|
||||
const skillNode = nodes.find(n => n.id === skillId)
|
||||
return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
|
||||
}
|
||||
return 'var(--border-light)'
|
||||
return getSkillDomainColor(l, nodes)
|
||||
})
|
||||
.attr('stroke-opacity', l => {
|
||||
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
|
||||
@@ -109,13 +140,15 @@ export function useConstellationHighlight(deps: {
|
||||
if (src === activeNodeId || tgt === activeNodeId) {
|
||||
return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2))
|
||||
}
|
||||
return 0.15
|
||||
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
|
||||
if (src === activeNodeId || tgt === activeNodeId) return 1.5
|
||||
return 1
|
||||
if (src === activeNodeId || tgt === activeNodeId) {
|
||||
return LINK_HIGHLIGHT_BASE_WIDTH + l.strength * LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR
|
||||
}
|
||||
return LINK_BASE_WIDTH + l.strength * LINK_STRENGTH_WIDTH_FACTOR
|
||||
})
|
||||
}, [deps])
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN,
|
||||
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
||||
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
||||
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
||||
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
||||
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'
|
||||
|
||||
@@ -40,6 +47,7 @@ export function useForceSimulation(
|
||||
const nodeSelectionRef = useRef<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>(null)
|
||||
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | 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)
|
||||
const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})
|
||||
|
||||
@@ -114,6 +122,33 @@ export function useForceSimulation(
|
||||
.attr('stdDeviation', 3)
|
||||
.attr('flood-color', 'rgba(26,43,42,0.12)')
|
||||
|
||||
// Glow filters per domain
|
||||
Object.entries(DOMAIN_COLOR_MAP).forEach(([domain]) => {
|
||||
const glow = defs.append('filter')
|
||||
.attr('id', `glow-${domain}`)
|
||||
.attr('x', '-50%').attr('y', '-50%')
|
||||
.attr('width', '200%').attr('height', '200%')
|
||||
glow.append('feGaussianBlur')
|
||||
.attr('in', 'SourceGraphic')
|
||||
.attr('stdDeviation', SKILL_GLOW_STD_DEVIATION)
|
||||
.attr('result', 'blur')
|
||||
const merge = glow.append('feMerge')
|
||||
merge.append('feMergeNode').attr('in', 'blur')
|
||||
merge.append('feMergeNode').attr('in', 'SourceGraphic')
|
||||
})
|
||||
|
||||
// Role gradient defs
|
||||
const uniqueOrgColors = [...new Set(constellationNodes.filter(n => n.type === 'role').map(n => n.orgColor ?? 'var(--accent)'))]
|
||||
uniqueOrgColors.forEach((color, i) => {
|
||||
const grad = defs.append('linearGradient')
|
||||
.attr('id', `role-grad-${i}`)
|
||||
.attr('x1', '0%').attr('y1', '0%')
|
||||
.attr('x2', '100%').attr('y2', '0%')
|
||||
grad.append('stop').attr('offset', '0%').attr('stop-color', color).attr('stop-opacity', 0.08)
|
||||
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0.18)
|
||||
})
|
||||
const orgColorGradientMap = new Map(uniqueOrgColors.map((c, i) => [c, `url(#role-grad-${i})`]))
|
||||
|
||||
// Timeline guides
|
||||
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
|
||||
|
||||
@@ -230,6 +265,17 @@ export function useForceSimulation(
|
||||
})
|
||||
connectedMapRef.current = connectedMap
|
||||
|
||||
// Compute skill rest radii (size encoding by connected role count)
|
||||
const skillRestRadii = new Map<string, number>()
|
||||
nodes.filter(n => n.type === 'skill').forEach(n => {
|
||||
const roleCount = connectedMap.get(n.id)?.size ?? 0
|
||||
skillRestRadii.set(n.id, srDefault + roleCount * SKILL_SIZE_ROLE_FACTOR)
|
||||
})
|
||||
skillRestRadiiRef.current = skillRestRadii
|
||||
|
||||
// Node-by-id lookup for link domain color resolution
|
||||
const nodeById = new Map(constellationNodes.map(n => [n.id, n]))
|
||||
|
||||
// Create SVG groups
|
||||
const linkGroup = svg.append('g').attr('class', 'links')
|
||||
const connectorGroup = svg.append('g').attr('class', 'connectors')
|
||||
@@ -239,9 +285,12 @@ export function useForceSimulation(
|
||||
.data(links)
|
||||
.join('path')
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', 'var(--border-light)')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-opacity', 0.15)
|
||||
.attr('stroke', d => {
|
||||
const skillNode = nodeById.get(d.target as string) ?? nodeById.get(d.source as string)
|
||||
return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
|
||||
})
|
||||
.attr('stroke-width', d => LINK_BASE_WIDTH + d.strength * LINK_STRENGTH_WIDTH_FACTOR)
|
||||
.attr('stroke-opacity', d => LINK_BASE_OPACITY + d.strength * LINK_STRENGTH_OPACITY_FACTOR)
|
||||
.style('transition', prefersReducedMotion
|
||||
? 'none'
|
||||
: 'stroke 150ms ease, stroke-opacity 150ms ease, stroke-width 150ms ease'
|
||||
@@ -279,8 +328,7 @@ export function useForceSimulation(
|
||||
.attr('width', rw)
|
||||
.attr('height', rh)
|
||||
.attr('rx', rrx)
|
||||
.attr('fill', d => d.orgColor ?? 'var(--accent)')
|
||||
.attr('fill-opacity', 0.12)
|
||||
.attr('fill', d => orgColorGradientMap.get(d.orgColor ?? 'var(--accent)') ?? d.orgColor ?? 'var(--accent)')
|
||||
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
|
||||
.attr('stroke-opacity', 0.4)
|
||||
.attr('stroke-width', 1)
|
||||
@@ -312,10 +360,12 @@ export function useForceSimulation(
|
||||
nodeSelection.filter(d => d.type === 'skill')
|
||||
.append('circle')
|
||||
.attr('class', 'node-circle')
|
||||
.attr('r', srDefault)
|
||||
.attr('r', d => skillRestRadii.get(d.id) ?? srDefault)
|
||||
.attr('fill', d => DOMAIN_COLOR_MAP[d.domain ?? 'technical'] ?? '#0D6E6E')
|
||||
.attr('stroke', 'none')
|
||||
.attr('fill-opacity', 0.35)
|
||||
.attr('stroke', d => DOMAIN_COLOR_MAP[d.domain ?? 'technical'] ?? '#0D6E6E')
|
||||
.attr('stroke-width', SKILL_STROKE_WIDTH)
|
||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||
|
||||
nodeSelection.filter(d => d.type === 'skill')
|
||||
.append('text')
|
||||
@@ -386,7 +436,7 @@ export function useForceSimulation(
|
||||
const sy = (d.source as SimNode).y
|
||||
const tx = (d.target as SimNode).x
|
||||
const ty = (d.target as SimNode).y
|
||||
const cx = (sx + tx) / 2
|
||||
const cx = (sx + tx) / 2 + (ty - sy) * LINK_BEZIER_VERTICAL_OFFSET
|
||||
return `M${sx},${sy} Q${cx},${sy} ${tx},${ty}`
|
||||
})
|
||||
|
||||
@@ -425,6 +475,77 @@ 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++) {
|
||||
@@ -432,7 +553,10 @@ export function useForceSimulation(
|
||||
}
|
||||
renderTick()
|
||||
} else {
|
||||
simulation.on('tick', renderTick)
|
||||
simulation.on('tick', () => {
|
||||
renderTick()
|
||||
maybeRunEntryAnimation()
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -448,6 +572,7 @@ export function useForceSimulation(
|
||||
nodeButtonPositions,
|
||||
layoutParams: layoutParamsRef.current,
|
||||
connectedMap: connectedMapRef.current,
|
||||
skillRestRadii: skillRestRadiiRef.current,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user