refactor: decompose CareerConstellation monolith into focused modules
Break 1102-line CareerConstellation.tsx into: - constellation/constants.ts: sizing, opacity, domain color tokens - constellation/types.ts: SimNode, SimLink, LayoutParams interfaces - hooks/useForceSimulation.ts: D3 simulation lifecycle - hooks/useConstellationHighlight.ts: highlight/dim logic - hooks/useConstellationInteraction.ts: mouse/touch/pin handlers - constellation/MobileAccordion.tsx: tap-to-expand role details - constellation/ConstellationLegend.tsx: domain legend - constellation/AccessibleNodeOverlay.tsx: keyboard navigation buttons - constellation/CareerConstellation.tsx: 288-line orchestrator All existing behaviour preserved. Quality gates pass.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import React from 'react'
|
||||
import type { ConstellationNode } from '@/types/pmr'
|
||||
import { ROLE_WIDTH, ROLE_HEIGHT, MOBILE_ROLE_WIDTH } from './constants'
|
||||
|
||||
interface AccessibleNodeOverlayProps {
|
||||
nodes: ConstellationNode[]
|
||||
nodeButtonPositions: Record<string, { x: number; y: number }>
|
||||
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
|
||||
}
|
||||
|
||||
export const AccessibleNodeOverlay: React.FC<AccessibleNodeOverlayProps> = ({
|
||||
nodes,
|
||||
nodeButtonPositions,
|
||||
dimensions,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const domainOrder: Record<string, number> = { technical: 0, clinical: 1, leadership: 2 }
|
||||
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') {
|
||||
return (b.startYear ?? 0) - (a.startYear ?? 0)
|
||||
}
|
||||
const da = domainOrder[a.domain ?? 'technical'] ?? 0
|
||||
const db = domainOrder[b.domain ?? 'technical'] ?? 0
|
||||
if (da !== db) return da - db
|
||||
return (a.label ?? '').localeCompare(b.label ?? '')
|
||||
})
|
||||
|
||||
const isMobileBtn = typeof window !== 'undefined' && window.innerWidth < 640
|
||||
const btnSf = isMobileBtn ? 1 : dimensions.scaleFactor
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Career nodes - use Tab to navigate and Enter to open details"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{sorted.map(node => {
|
||||
const yearRange = node.endYear
|
||||
? `${node.startYear}-${node.endYear}`
|
||||
: `${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)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
aria-label={
|
||||
node.type === 'role'
|
||||
? `${node.label} at ${node.organization}, ${yearRange}. Press Enter to view details.`
|
||||
: `${node.label} skill node. Press Enter to view details.`
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: buttonWidth,
|
||||
height: buttonHeight,
|
||||
top: `${position.y}px`,
|
||||
left: `${position.x}px`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
padding: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
onFocus={() => onFocus(node.id)}
|
||||
onBlur={onBlur}
|
||||
onClick={() => onClick(node.id, node.type)}
|
||||
onKeyDown={e => onKeyDown(e, node.id, node.type)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
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 { useForceSimulation, getHeight } from '@/hooks/useForceSimulation'
|
||||
import { useConstellationHighlight } from '@/hooks/useConstellationHighlight'
|
||||
import { useConstellationInteraction } from '@/hooks/useConstellationInteraction'
|
||||
import { MobileAccordion } from './MobileAccordion'
|
||||
import { ConstellationLegend } from './ConstellationLegend'
|
||||
import { AccessibleNodeOverlay } from './AccessibleNodeOverlay'
|
||||
import {
|
||||
MIN_HEIGHT,
|
||||
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
||||
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
||||
supportsCoarsePointer,
|
||||
} from './constants'
|
||||
|
||||
interface CareerConstellationProps {
|
||||
onRoleClick: (id: string) => void
|
||||
onSkillClick: (id: string) => void
|
||||
onNodeHover?: (id: string | null) => void
|
||||
highlightedNodeId?: string | null
|
||||
containerHeight?: number | null
|
||||
}
|
||||
|
||||
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
|
||||
const careerEntityById = new Map(timelineCareerEntities.map(entity => [entity.id, entity]))
|
||||
const srDescription = buildScreenReaderDescription()
|
||||
|
||||
function buildScreenReaderDescription(): string {
|
||||
const roles = constellationNodes.filter(n => n.type === 'role')
|
||||
const skills = constellationNodes.filter(n => n.type === 'skill')
|
||||
|
||||
const roleDescriptions = roles.map(role => {
|
||||
const mapping = roleSkillMappings.find(m => m.roleId === role.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}`
|
||||
})
|
||||
|
||||
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('. ') + '.'
|
||||
}
|
||||
|
||||
const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
onRoleClick,
|
||||
onSkillClick,
|
||||
onNodeHover,
|
||||
highlightedNodeId,
|
||||
containerHeight,
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover })
|
||||
const highlightedNodeIdRef = useRef<string | null>(highlightedNodeId ?? null)
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
|
||||
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
|
||||
|
||||
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
|
||||
|
||||
useEffect(() => {
|
||||
highlightedNodeIdRef.current = highlightedNodeId ?? null
|
||||
}, [highlightedNodeId])
|
||||
|
||||
// ResizeObserver for container dimensions
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const updateDimensions = () => {
|
||||
const width = container.clientWidth
|
||||
const viewportWidth = window.innerWidth
|
||||
const height = getHeight(viewportWidth, containerHeight)
|
||||
const scaleFactor = viewportWidth >= 1024
|
||||
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
||||
: 1
|
||||
setDimensions({ width, height, scaleFactor })
|
||||
}
|
||||
|
||||
updateDimensions()
|
||||
const observer = new ResizeObserver(updateDimensions)
|
||||
observer.observe(container)
|
||||
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)
|
||||
const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf)
|
||||
|
||||
const resolveGraphFallback = useCallback(
|
||||
() => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current,
|
||||
[],
|
||||
)
|
||||
|
||||
const resolveRoleFallback = useCallback(() => {
|
||||
const hId = highlightedNodeIdRef.current
|
||||
if (hId && nodeById.get(hId)?.type === 'role') return hId
|
||||
const pId = pinnedNodeIdRef.current
|
||||
if (pId && nodeById.get(pId)?.type === 'role') return pId
|
||||
return null
|
||||
}, [])
|
||||
|
||||
// Highlight hook (needs to be created before simulation so we can pass applyHighlight)
|
||||
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 { applyGraphHighlight } = useConstellationHighlight({
|
||||
nodeSelectionRef,
|
||||
linkSelectionRef,
|
||||
connectedMap: connectedMapRef.current,
|
||||
srDefault,
|
||||
srActive,
|
||||
nodesRef,
|
||||
})
|
||||
|
||||
highlightGraphRef.current = applyGraphHighlight
|
||||
|
||||
// Stable options ref for simulation to avoid re-creating on every render
|
||||
const simOptionsRef = useRef({
|
||||
resolveGraphFallback,
|
||||
applyHighlight: applyGraphHighlight,
|
||||
})
|
||||
simOptionsRef.current = { resolveGraphFallback, applyHighlight: applyGraphHighlight }
|
||||
|
||||
const stableSimOptions = useMemo(() => ({
|
||||
resolveGraphFallback: () => simOptionsRef.current.resolveGraphFallback(),
|
||||
applyHighlight: (id: string | null) => simOptionsRef.current.applyHighlight(id),
|
||||
}), [])
|
||||
|
||||
const sim = useForceSimulation(svgRef, dimensions, stableSimOptions)
|
||||
|
||||
// Sync simulation refs to our local refs for highlight/interaction hooks
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// Interaction hook
|
||||
const { pinnedNodeId, setPinnedNodeId, pinnedNodeIdRef } = useConstellationInteraction({
|
||||
highlightGraphRef,
|
||||
nodeSelectionRef,
|
||||
svgRef,
|
||||
callbacksRef,
|
||||
resolveGraphFallback,
|
||||
resolveRoleFallback,
|
||||
dimensionsTrigger: dimensions.width + dimensions.height,
|
||||
})
|
||||
|
||||
// External highlight sync
|
||||
useEffect(() => {
|
||||
if (!highlightGraphRef.current) return
|
||||
highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId)
|
||||
}, [highlightedNodeId, pinnedNodeId])
|
||||
|
||||
// Focus ring management
|
||||
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)
|
||||
.select('.focus-ring')
|
||||
.attr('stroke', 'var(--accent)')
|
||||
.attr('stroke-width', 2)
|
||||
}
|
||||
}, [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)
|
||||
}
|
||||
}
|
||||
}, [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 showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-light)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
role="img"
|
||||
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
|
||||
<ConstellationLegend isTouch={supportsCoarsePointer} />
|
||||
|
||||
<MobileAccordion
|
||||
pinnedCareerEntity={pinnedCareerEntity}
|
||||
show={showAccordion}
|
||||
/>
|
||||
|
||||
<p
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
{srDescription}
|
||||
</p>
|
||||
|
||||
<AccessibleNodeOverlay
|
||||
nodes={constellationNodes}
|
||||
nodeButtonPositions={sim.nodeButtonPositions}
|
||||
dimensions={dimensions}
|
||||
onFocus={(nodeId) => {
|
||||
setFocusedNodeId(nodeId)
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
const node = nodeById.get(nodeId)
|
||||
if (node?.type === 'role') onNodeHover?.(nodeId)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocusedNodeId(null)
|
||||
highlightGraphRef.current?.(resolveGraphFallback())
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
}}
|
||||
onClick={(nodeId, nodeType) => {
|
||||
setPinnedNodeId(nodeId)
|
||||
pinnedNodeIdRef.current = nodeId
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
if (nodeType === 'role') {
|
||||
onNodeHover?.(nodeId)
|
||||
onRoleClick(nodeId)
|
||||
} else {
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
onSkillClick(nodeId)
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleNodeKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CareerConstellation
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { supportsCoarsePointer } from './constants'
|
||||
|
||||
interface ConstellationLegendProps {
|
||||
isTouch: boolean
|
||||
}
|
||||
|
||||
export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch }) => {
|
||||
const items = [
|
||||
{ label: 'Technical', color: 'var(--accent)' },
|
||||
{ label: 'Clinical', color: 'var(--success)' },
|
||||
{ label: 'Leadership', color: 'var(--amber)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
fontSize: '10px',
|
||||
color: 'var(--text-tertiary)',
|
||||
lineHeight: '24px',
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={item.label}>
|
||||
{i > 0 && (
|
||||
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
|
||||
)}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: item.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
|
||||
<span style={{ opacity: 0.7 }}>{isTouch || supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import type { TimelineEntity } from '@/types/pmr'
|
||||
import { prefersReducedMotion } from './constants'
|
||||
|
||||
interface MobileAccordionProps {
|
||||
pinnedCareerEntity: TimelineEntity | null
|
||||
show: boolean
|
||||
}
|
||||
|
||||
export const MobileAccordion: React.FC<MobileAccordionProps> = ({ pinnedCareerEntity, show }) => {
|
||||
const [accordionShowMore, setAccordionShowMore] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setAccordionShowMore(false)
|
||||
}, [pinnedCareerEntity?.id])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && pinnedCareerEntity && (
|
||||
<motion.div
|
||||
key={pinnedCareerEntity.id}
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: `1px solid ${pinnedCareerEntity.orgColor ?? 'var(--border-light)'}`,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '2px' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{pinnedCareerEntity.title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-secondary)',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
paddingLeft: '14px',
|
||||
}}
|
||||
>
|
||||
{pinnedCareerEntity.organization} · {pinnedCareerEntity.dateRange.display}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul style={{ margin: 0, paddingLeft: '14px', listStyle: 'none' }}>
|
||||
{(accordionShowMore ? pinnedCareerEntity.details : pinnedCareerEntity.details.slice(0, 3)).map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: '1.5',
|
||||
marginBottom: '4px',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
|
||||
opacity: 0.5,
|
||||
flexShrink: 0,
|
||||
marginTop: '7px',
|
||||
}}
|
||||
/>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{accordionShowMore && (pinnedCareerEntity.outcomes ?? []).length > 0 && (
|
||||
<ul style={{ margin: '8px 0 0', paddingLeft: '14px', listStyle: 'none' }}>
|
||||
{(pinnedCareerEntity.outcomes ?? []).map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-tertiary)',
|
||||
lineHeight: '1.5',
|
||||
marginBottom: '4px',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--text-tertiary)',
|
||||
opacity: 0.4,
|
||||
flexShrink: 0,
|
||||
marginTop: '7px',
|
||||
}}
|
||||
/>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pinnedCareerEntity.details.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccordionShowMore(prev => !prev)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 14px',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: pinnedCareerEntity.orgColor ?? 'var(--accent)',
|
||||
fontWeight: 500,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{accordionShowMore ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Sizing
|
||||
export const MIN_HEIGHT = 400
|
||||
export const MOBILE_FALLBACK_HEIGHT = 520
|
||||
export const ROLE_WIDTH = 104
|
||||
export const ROLE_HEIGHT = 32
|
||||
export const ROLE_RX = 16
|
||||
export const SKILL_RADIUS_DEFAULT = 7
|
||||
export const SKILL_RADIUS_ACTIVE = 11
|
||||
export const MOBILE_ROLE_WIDTH = 80
|
||||
export const MOBILE_SKILL_RADIUS_DEFAULT = 6
|
||||
export const MOBILE_SKILL_RADIUS_ACTIVE = 9
|
||||
export const MOBILE_LABEL_MAX_LEN = 10
|
||||
|
||||
// Animation / opacity
|
||||
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
|
||||
|
||||
// Domain color map
|
||||
export const DOMAIN_COLOR_MAP: Record<string, string> = {
|
||||
clinical: '#059669',
|
||||
technical: '#0D6E6E',
|
||||
leadership: '#D97706',
|
||||
}
|
||||
|
||||
// Media queries (evaluated once at module level)
|
||||
export const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
export const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ConstellationNode } from '@/types/pmr'
|
||||
|
||||
export interface SimNode extends ConstellationNode {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
homeX: number
|
||||
homeY: number
|
||||
}
|
||||
|
||||
export interface SimLink {
|
||||
source: SimNode | string
|
||||
target: SimNode | string
|
||||
strength: number
|
||||
}
|
||||
|
||||
export interface LayoutParams {
|
||||
width: number
|
||||
height: number
|
||||
scaleFactor: number
|
||||
isMobile: boolean
|
||||
rw: number
|
||||
rh: number
|
||||
rrx: number
|
||||
srDefault: number
|
||||
srActive: number
|
||||
topPadding: number
|
||||
bottomPadding: number
|
||||
sidePadding: number
|
||||
timelineX: number
|
||||
sf: number
|
||||
}
|
||||
|
||||
export interface ConstellationCallbacks {
|
||||
onRoleClick: (id: string) => void
|
||||
onSkillClick: (id: string) => void
|
||||
onNodeHover?: (id: string | null) => void
|
||||
}
|
||||
Reference in New Issue
Block a user