From 7d7628c8a7e1f33cb99e6c662ba6440571a6bc83 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Mon, 16 Feb 2026 14:16:36 +0000 Subject: [PATCH] feat: phase 2 visual improvements for CareerConstellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../constellation/CareerConstellation.tsx | 40 ++--- .../constellation/ConstellationLegend.tsx | 11 +- src/components/constellation/constants.ts | 23 ++- src/hooks/useConstellationHighlight.ts | 61 ++++++-- src/hooks/useForceSimulation.ts | 143 ++++++++++++++++-- 5 files changed, 232 insertions(+), 46 deletions(-) diff --git a/src/components/constellation/CareerConstellation.tsx b/src/components/constellation/CareerConstellation.tsx index 92eb4c7..713ea46 100644 --- a/src/components/constellation/CareerConstellation.tsx +++ b/src/components/constellation/CareerConstellation.tsx @@ -116,6 +116,8 @@ const CareerConstellation: React.FC = ({ const linkSelectionRef = useRef | null>(null) const connectedMapRef = useRef>>(new Map()) + const skillRestRadiiRef = useRef>(new Map()) + const { applyGraphHighlight } = useConstellationHighlight({ nodeSelectionRef, linkSelectionRef, @@ -123,6 +125,7 @@ const CareerConstellation: React.FC = ({ srDefault, srActive, nodesRef, + skillRestRadii: skillRestRadiiRef.current, }) highlightGraphRef.current = applyGraphHighlight @@ -149,6 +152,9 @@ const CareerConstellation: React.FC = ({ 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 = ({ }, [focusedNodeId]) const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => { - if (e.key === 'Enter' || e.key === ' ') { - 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) - } - } + 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) }, [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 = {} + 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 = ({ style={{ display: 'block' }} /> - + } -export const ConstellationLegend: React.FC = ({ isTouch }) => { +export const ConstellationLegend: React.FC = ({ 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 = ({ isTouc flexShrink: 0, }} /> - {item.label} + {item.label}{domainCounts?.[item.domain] != null ? ` (${domainCounts[item.domain]})` : ''} ))} diff --git a/src/components/constellation/constants.ts b/src/components/constellation/constants.ts index b5e079f..4cd8d9a 100644 --- a/src/components/constellation/constants.ts +++ b/src/components/constellation/constants.ts @@ -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 = { clinical: '#059669', diff --git a/src/hooks/useConstellationHighlight.ts b/src/hooks/useConstellationHighlight.ts index 8869df9..aabe982 100644 --- a/src/hooks/useConstellationHighlight.ts +++ b/src/hooks/useConstellationHighlight.ts @@ -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 | null> linkSelectionRef: React.MutableRefObject | null> @@ -10,6 +24,7 @@ export function useConstellationHighlight(deps: { srDefault: number srActive: number nodesRef: React.MutableRefObject + skillRestRadii?: Map }) { 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]) diff --git a/src/hooks/useForceSimulation.ts b/src/hooks/useForceSimulation.ts index 39692b9..480f424 100644 --- a/src/hooks/useForceSimulation.ts +++ b/src/hooks/useForceSimulation.ts @@ -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 | null>(null) const linkSelectionRef = useRef | null>(null) const connectedMapRef = useRef>>(new Map()) + const skillRestRadiiRef = useRef>(new Map()) const layoutParamsRef = useRef(null) const [nodeButtonPositions, setNodeButtonPositions] = useState>({}) @@ -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() + 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, } }