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:
2026-02-16 14:16:36 +00:00
parent 65b265733e
commit 7d7628c8a7
5 changed files with 232 additions and 46 deletions
@@ -116,6 +116,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, import('./types').SimLink, 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 connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
const skillRestRadiiRef = useRef<Map<string, number>>(new Map())
const { applyGraphHighlight } = useConstellationHighlight({ const { applyGraphHighlight } = useConstellationHighlight({
nodeSelectionRef, nodeSelectionRef,
linkSelectionRef, linkSelectionRef,
@@ -123,6 +125,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
srDefault, srDefault,
srActive, srActive,
nodesRef, nodesRef,
skillRestRadii: skillRestRadiiRef.current,
}) })
highlightGraphRef.current = applyGraphHighlight highlightGraphRef.current = applyGraphHighlight
@@ -149,6 +152,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
if (sim.connectedMap.size > 0) { if (sim.connectedMap.size > 0) {
connectedMapRef.current = sim.connectedMap connectedMapRef.current = sim.connectedMap
} }
if (sim.skillRestRadii.size > 0) {
skillRestRadiiRef.current = sim.skillRestRadii
}
}) })
// Interaction hook // Interaction hook
@@ -185,27 +191,27 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
}, [focusedNodeId]) }, [focusedNodeId])
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => { 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() e.preventDefault()
setPinnedNodeId(nodeId) setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId) highlightGraphRef.current?.(nodeId)
if (nodeType === 'role') { onNodeHover?.(nodeType === 'role' ? nodeId : resolveRoleFallback())
onNodeHover?.(nodeId) ;(nodeType === 'role' ? onRoleClick : onSkillClick)(nodeId)
} else {
onNodeHover?.(resolveRoleFallback())
}
if (nodeType === 'role') {
onRoleClick(nodeId)
} else {
onSkillClick(nodeId)
}
}
}, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback, setPinnedNodeId, pinnedNodeIdRef]) }, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback, setPinnedNodeId, pinnedNodeIdRef])
// Pinned career entity for mobile accordion // 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') : null
const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) ?? null : 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 const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
return ( return (
@@ -229,7 +235,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
style={{ display: 'block' }} style={{ display: 'block' }}
/> />
<ConstellationLegend isTouch={supportsCoarsePointer} /> <ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
<MobileAccordion <MobileAccordion
pinnedCareerEntity={pinnedCareerEntity} pinnedCareerEntity={pinnedCareerEntity}
@@ -3,13 +3,14 @@ import { supportsCoarsePointer } from './constants'
interface ConstellationLegendProps { interface ConstellationLegendProps {
isTouch: boolean isTouch: boolean
domainCounts?: Record<string, number>
} }
export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch }) => { export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch, domainCounts }) => {
const items = [ const items = [
{ label: 'Technical', color: 'var(--accent)' }, { label: 'Technical', domain: 'technical', color: 'var(--accent)' },
{ label: 'Clinical', color: 'var(--success)' }, { label: 'Clinical', domain: 'clinical', color: 'var(--success)' },
{ label: 'Leadership', color: 'var(--amber)' }, { label: 'Leadership', domain: 'leadership', color: 'var(--amber)' },
] ]
return ( return (
@@ -42,7 +43,7 @@ export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouc
flexShrink: 0, flexShrink: 0,
}} }}
/> />
{item.label} {item.label}{domainCounts?.[item.domain] != null ? ` (${domainCounts[item.domain]})` : ''}
</span> </span>
</React.Fragment> </React.Fragment>
))} ))}
+22 -1
View File
@@ -15,9 +15,30 @@ export const MOBILE_LABEL_MAX_LEN = 10
export const HIGHLIGHT_DIM_OPACITY = 0.15 export const HIGHLIGHT_DIM_OPACITY = 0.15
export const SKILL_REST_OPACITY = 0.35 export const SKILL_REST_OPACITY = 0.35
export const SKILL_ACTIVE_OPACITY = 0.9 export const SKILL_ACTIVE_OPACITY = 0.9
export const LINK_REST_OPACITY = 0.15
export const LABEL_REST_OPACITY = 0.5 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 // Domain color map
export const DOMAIN_COLOR_MAP: Record<string, string> = { export const DOMAIN_COLOR_MAP: Record<string, string> = {
clinical: '#059669', clinical: '#059669',
+47 -14
View File
@@ -1,8 +1,22 @@
import { useRef, useCallback } from 'react' import { useRef, useCallback } from 'react'
import type * as d3 from 'd3' 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' 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: { export function useConstellationHighlight(deps: {
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null> nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
linkSelectionRef: React.MutableRefObject<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null> linkSelectionRef: React.MutableRefObject<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null>
@@ -10,6 +24,7 @@ export function useConstellationHighlight(deps: {
srDefault: number srDefault: number
srActive: number srActive: number
nodesRef: React.MutableRefObject<SimNode[]> nodesRef: React.MutableRefObject<SimNode[]>
skillRestRadii?: Map<string, number>
}) { }) {
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null) const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
@@ -18,7 +33,7 @@ export function useConstellationHighlight(deps: {
const linkSelection = deps.linkSelectionRef.current const linkSelection = deps.linkSelectionRef.current
if (!nodeSelection || !linkSelection) return if (!nodeSelection || !linkSelection) return
const { srDefault, srActive, connectedMap } = deps const { srDefault, srActive, connectedMap, skillRestRadii } = deps
const nodes = deps.nodesRef.current const nodes = deps.nodesRef.current
const dur = prefersReducedMotion ? 0 : 180 const dur = prefersReducedMotion ? 0 : 180
@@ -28,30 +43,36 @@ export function useConstellationHighlight(deps: {
nodeSelection.filter(d => d.type === 'role') nodeSelection.filter(d => d.type === 'role')
.attr('filter', null) .attr('filter', null)
.select('.node-circle') .select('.node-circle')
.attr('fill-opacity', null)
.attr('stroke-opacity', 0.4) .attr('stroke-opacity', 0.4)
.attr('stroke-width', 1) .attr('stroke-width', 1)
const skillNodes = nodeSelection.filter(d => d.type === 'skill') const skillNodes = nodeSelection.filter(d => d.type === 'skill')
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
if (dur > 0) { if (dur > 0) {
skillNodes.select('.node-circle') skillNodes.select('.node-circle')
.transition().duration(dur) .transition().duration(dur)
.attr('r', srDefault) .attr('r', d => getRestRadius(d))
.attr('fill-opacity', 0.35) .attr('fill-opacity', 0.35)
.attr('filter', null)
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
skillNodes.select('.node-label') skillNodes.select('.node-label')
.transition().duration(dur) .transition().duration(dur)
.attr('opacity', 0.5) .attr('opacity', 0.5)
} else { } else {
skillNodes.select('.node-circle') skillNodes.select('.node-circle')
.attr('r', srDefault) .attr('r', d => getRestRadius(d))
.attr('fill-opacity', 0.35) .attr('fill-opacity', 0.35)
.attr('filter', null)
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
skillNodes.select('.node-label') skillNodes.select('.node-label')
.attr('opacity', 0.5) .attr('opacity', 0.5)
} }
linkSelection linkSelection
.attr('stroke', 'var(--border-light)') .attr('stroke', l => getSkillDomainColor(l, nodes))
.attr('stroke-width', 1) .attr('stroke-width', l => LINK_BASE_WIDTH + l.strength * LINK_STRENGTH_WIDTH_FACTOR)
.attr('stroke-opacity', 0.15) .attr('stroke-opacity', l => LINK_BASE_OPACITY + l.strength * LINK_STRENGTH_OPACITY_FACTOR)
return return
} }
@@ -68,26 +89,36 @@ export function useConstellationHighlight(deps: {
return null return null
}) })
.select('.node-circle') .select('.node-circle')
.attr('fill-opacity', d => d.id === activeNodeId ? 0.25 : null)
.attr('stroke-opacity', d => { .attr('stroke-opacity', d => {
if (d.id === activeNodeId) return 1 if (d.id === activeNodeId) return 1
if (connected.has(d.id)) return 0.7 if (connected.has(d.id)) return 0.7
return 0.4 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 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) { if (dur > 0) {
skillNodes.select('.node-circle') skillNodes.select('.node-circle')
.transition().duration(dur) .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('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') skillNodes.select('.node-label')
.transition().duration(dur) .transition().duration(dur)
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5) .attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
} else { } else {
skillNodes.select('.node-circle') 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('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') skillNodes.select('.node-label')
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5) .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) const skillNode = nodes.find(n => n.id === skillId)
return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E' return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
} }
return 'var(--border-light)' return getSkillDomainColor(l, nodes)
}) })
.attr('stroke-opacity', l => { .attr('stroke-opacity', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id 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) { if (src === activeNodeId || tgt === activeNodeId) {
return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2)) 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 => { .attr('stroke-width', l => {
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id 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 tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
if (src === activeNodeId || tgt === activeNodeId) return 1.5 if (src === activeNodeId || tgt === activeNodeId) {
return 1 return LINK_HIGHLIGHT_BASE_WIDTH + l.strength * LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR
}
return LINK_BASE_WIDTH + l.strength * LINK_STRENGTH_WIDTH_FACTOR
}) })
}, [deps]) }, [deps])
+134 -9
View File
@@ -7,6 +7,13 @@ import {
MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN, MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN,
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE, MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
DOMAIN_COLOR_MAP, prefersReducedMotion, 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' } from '@/components/constellation/constants'
import type { SimNode, SimLink, LayoutParams } from '@/components/constellation/types' 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 nodeSelectionRef = useRef<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>(null)
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, SimLink, 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 connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
const skillRestRadiiRef = useRef<Map<string, number>>(new Map())
const layoutParamsRef = useRef<LayoutParams | null>(null) const layoutParamsRef = useRef<LayoutParams | null>(null)
const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({}) const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})
@@ -114,6 +122,33 @@ export function useForceSimulation(
.attr('stdDeviation', 3) .attr('stdDeviation', 3)
.attr('flood-color', 'rgba(26,43,42,0.12)') .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 // Timeline guides
const timelineGroup = svg.append('g').attr('class', 'timeline-guides') const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
@@ -230,6 +265,17 @@ export function useForceSimulation(
}) })
connectedMapRef.current = connectedMap 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 // Create SVG groups
const linkGroup = svg.append('g').attr('class', 'links') const linkGroup = svg.append('g').attr('class', 'links')
const connectorGroup = svg.append('g').attr('class', 'connectors') const connectorGroup = svg.append('g').attr('class', 'connectors')
@@ -239,9 +285,12 @@ export function useForceSimulation(
.data(links) .data(links)
.join('path') .join('path')
.attr('fill', 'none') .attr('fill', 'none')
.attr('stroke', 'var(--border-light)') .attr('stroke', d => {
.attr('stroke-width', 1) const skillNode = nodeById.get(d.target as string) ?? nodeById.get(d.source as string)
.attr('stroke-opacity', 0.15) 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 .style('transition', prefersReducedMotion
? 'none' ? 'none'
: 'stroke 150ms ease, stroke-opacity 150ms ease, stroke-width 150ms ease' : 'stroke 150ms ease, stroke-opacity 150ms ease, stroke-width 150ms ease'
@@ -279,8 +328,7 @@ export function useForceSimulation(
.attr('width', rw) .attr('width', rw)
.attr('height', rh) .attr('height', rh)
.attr('rx', rrx) .attr('rx', rrx)
.attr('fill', d => d.orgColor ?? 'var(--accent)') .attr('fill', d => orgColorGradientMap.get(d.orgColor ?? 'var(--accent)') ?? d.orgColor ?? 'var(--accent)')
.attr('fill-opacity', 0.12)
.attr('stroke', d => d.orgColor ?? 'var(--accent)') .attr('stroke', d => d.orgColor ?? 'var(--accent)')
.attr('stroke-opacity', 0.4) .attr('stroke-opacity', 0.4)
.attr('stroke-width', 1) .attr('stroke-width', 1)
@@ -312,10 +360,12 @@ export function useForceSimulation(
nodeSelection.filter(d => d.type === 'skill') nodeSelection.filter(d => d.type === 'skill')
.append('circle') .append('circle')
.attr('class', 'node-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('fill', d => DOMAIN_COLOR_MAP[d.domain ?? 'technical'] ?? '#0D6E6E')
.attr('stroke', 'none')
.attr('fill-opacity', 0.35) .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') nodeSelection.filter(d => d.type === 'skill')
.append('text') .append('text')
@@ -386,7 +436,7 @@ export function useForceSimulation(
const sy = (d.source as SimNode).y const sy = (d.source as SimNode).y
const tx = (d.target as SimNode).x const tx = (d.target as SimNode).x
const ty = (d.target as SimNode).y 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}` return `M${sx},${sy} Q${cx},${sy} ${tx},${ty}`
}) })
@@ -425,6 +475,77 @@ export function useForceSimulation(
options.applyHighlight(options.resolveGraphFallback()) 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) { if (prefersReducedMotion) {
simulation.stop() simulation.stop()
for (let i = 0; i < 150; i++) { for (let i = 0; i < 150; i++) {
@@ -432,7 +553,10 @@ export function useForceSimulation(
} }
renderTick() renderTick()
} else { } else {
simulation.on('tick', renderTick) simulation.on('tick', () => {
renderTick()
maybeRunEntryAnimation()
})
} }
return () => { return () => {
@@ -448,6 +572,7 @@ export function useForceSimulation(
nodeButtonPositions, nodeButtonPositions,
layoutParams: layoutParamsRef.current, layoutParams: layoutParamsRef.current,
connectedMap: connectedMapRef.current, connectedMap: connectedMapRef.current,
skillRestRadii: skillRestRadiiRef.current,
} }
} }