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:
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import { CardHeader } from './Card'
|
|||||||
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
|
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
|
||||||
import { ProjectsTile } from './tiles/ProjectsTile'
|
import { ProjectsTile } from './tiles/ProjectsTile'
|
||||||
import { ParentSection } from './ParentSection'
|
import { ParentSection } from './ParentSection'
|
||||||
import CareerConstellation from './CareerConstellation'
|
import CareerConstellation from './constellation/CareerConstellation'
|
||||||
import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsection'
|
import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsection'
|
||||||
import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
|
import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
|
||||||
import { ChatWidget } from './ChatWidget'
|
import { ChatWidget } from './ChatWidget'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useRef, useCallback } from 'react'
|
||||||
|
import type * as d3 from 'd3'
|
||||||
|
import { DOMAIN_COLOR_MAP, prefersReducedMotion } from '@/components/constellation/constants'
|
||||||
|
import type { SimNode, SimLink } from '@/components/constellation/types'
|
||||||
|
|
||||||
|
export function useConstellationHighlight(deps: {
|
||||||
|
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
|
||||||
|
linkSelectionRef: React.MutableRefObject<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null>
|
||||||
|
connectedMap: Map<string, Set<string>>
|
||||||
|
srDefault: number
|
||||||
|
srActive: number
|
||||||
|
nodesRef: React.MutableRefObject<SimNode[]>
|
||||||
|
}) {
|
||||||
|
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
|
||||||
|
|
||||||
|
const applyGraphHighlight = useCallback((activeNodeId: string | null) => {
|
||||||
|
const nodeSelection = deps.nodeSelectionRef.current
|
||||||
|
const linkSelection = deps.linkSelectionRef.current
|
||||||
|
if (!nodeSelection || !linkSelection) return
|
||||||
|
|
||||||
|
const { srDefault, srActive, connectedMap } = deps
|
||||||
|
const nodes = deps.nodesRef.current
|
||||||
|
const dur = prefersReducedMotion ? 0 : 180
|
||||||
|
|
||||||
|
if (!activeNodeId) {
|
||||||
|
nodeSelection.style('opacity', '1')
|
||||||
|
|
||||||
|
nodeSelection.filter(d => d.type === 'role')
|
||||||
|
.attr('filter', null)
|
||||||
|
.select('.node-circle')
|
||||||
|
.attr('stroke-opacity', 0.4)
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
|
||||||
|
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||||
|
if (dur > 0) {
|
||||||
|
skillNodes.select('.node-circle')
|
||||||
|
.transition().duration(dur)
|
||||||
|
.attr('r', srDefault)
|
||||||
|
.attr('fill-opacity', 0.35)
|
||||||
|
skillNodes.select('.node-label')
|
||||||
|
.transition().duration(dur)
|
||||||
|
.attr('opacity', 0.5)
|
||||||
|
} else {
|
||||||
|
skillNodes.select('.node-circle')
|
||||||
|
.attr('r', srDefault)
|
||||||
|
.attr('fill-opacity', 0.35)
|
||||||
|
skillNodes.select('.node-label')
|
||||||
|
.attr('opacity', 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
linkSelection
|
||||||
|
.attr('stroke', 'var(--border-light)')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
.attr('stroke-opacity', 0.15)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = connectedMap.get(activeNodeId) ?? new Set()
|
||||||
|
const isInGroup = (id: string) => id === activeNodeId || connected.has(id)
|
||||||
|
|
||||||
|
nodeSelection.style('opacity', d => isInGroup(d.id) ? '1' : '0.15')
|
||||||
|
|
||||||
|
nodeSelection.filter(d => d.type === 'role')
|
||||||
|
.attr('filter', d => {
|
||||||
|
if (d.id === activeNodeId) return 'url(#shadow-md-filter)'
|
||||||
|
if (connected.has(d.id)) return 'url(#shadow-sm-filter)'
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.select('.node-circle')
|
||||||
|
.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)
|
||||||
|
|
||||||
|
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||||
|
if (dur > 0) {
|
||||||
|
skillNodes.select('.node-circle')
|
||||||
|
.transition().duration(dur)
|
||||||
|
.attr('r', d => isInGroup(d.id) ? srActive : srDefault)
|
||||||
|
.attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
|
||||||
|
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('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
|
||||||
|
skillNodes.select('.node-label')
|
||||||
|
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
linkSelection
|
||||||
|
.attr('stroke', 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) {
|
||||||
|
const skillId = src === activeNodeId ? tgt : src
|
||||||
|
const skillNode = nodes.find(n => n.id === skillId)
|
||||||
|
return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
|
||||||
|
}
|
||||||
|
return 'var(--border-light)'
|
||||||
|
})
|
||||||
|
.attr('stroke-opacity', 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 Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2))
|
||||||
|
}
|
||||||
|
return 0.15
|
||||||
|
})
|
||||||
|
.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
|
||||||
|
})
|
||||||
|
}, [deps])
|
||||||
|
|
||||||
|
highlightGraphRef.current = applyGraphHighlight
|
||||||
|
|
||||||
|
return {
|
||||||
|
highlightGraphRef,
|
||||||
|
applyGraphHighlight,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import { supportsCoarsePointer } from '@/components/constellation/constants'
|
||||||
|
import type { SimNode, ConstellationCallbacks } from '@/components/constellation/types'
|
||||||
|
|
||||||
|
export function useConstellationInteraction(deps: {
|
||||||
|
highlightGraphRef: React.MutableRefObject<((id: string | null) => void) | null>
|
||||||
|
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
|
||||||
|
svgRef: React.RefObject<SVGSVGElement | null>
|
||||||
|
callbacksRef: React.MutableRefObject<ConstellationCallbacks>
|
||||||
|
resolveGraphFallback: () => string | null
|
||||||
|
resolveRoleFallback: () => string | null
|
||||||
|
dimensionsTrigger: number
|
||||||
|
}) {
|
||||||
|
const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null)
|
||||||
|
const pinnedNodeIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pinnedNodeIdRef.current = pinnedNodeId
|
||||||
|
}, [pinnedNodeId])
|
||||||
|
|
||||||
|
const bindEvents = useCallback(() => {
|
||||||
|
const nodeSelection = deps.nodeSelectionRef.current
|
||||||
|
const svgEl = deps.svgRef.current
|
||||||
|
if (!nodeSelection || !svgEl) return
|
||||||
|
|
||||||
|
const svg = d3.select(svgEl)
|
||||||
|
|
||||||
|
svg.select('.bg-rect').on('click.interaction', () => {
|
||||||
|
if (supportsCoarsePointer) {
|
||||||
|
setPinnedNodeId(null)
|
||||||
|
pinnedNodeIdRef.current = null
|
||||||
|
deps.highlightGraphRef.current?.(null)
|
||||||
|
deps.callbacksRef.current.onNodeHover?.(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nodeSelection.on('mouseenter.interaction', function(_event: MouseEvent, d: SimNode) {
|
||||||
|
if (supportsCoarsePointer) return
|
||||||
|
deps.highlightGraphRef.current?.(d.id)
|
||||||
|
if (d.type === 'role') {
|
||||||
|
deps.callbacksRef.current.onNodeHover?.(d.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nodeSelection.on('mouseleave.interaction', function() {
|
||||||
|
if (supportsCoarsePointer) return
|
||||||
|
deps.highlightGraphRef.current?.(deps.resolveGraphFallback())
|
||||||
|
deps.callbacksRef.current.onNodeHover?.(deps.resolveRoleFallback())
|
||||||
|
})
|
||||||
|
|
||||||
|
nodeSelection.on('click.interaction', function(_event: MouseEvent, d: SimNode) {
|
||||||
|
if (supportsCoarsePointer) {
|
||||||
|
if (pinnedNodeIdRef.current === d.id) {
|
||||||
|
setPinnedNodeId(null)
|
||||||
|
pinnedNodeIdRef.current = null
|
||||||
|
deps.highlightGraphRef.current?.(null)
|
||||||
|
deps.callbacksRef.current.onNodeHover?.(null)
|
||||||
|
} else {
|
||||||
|
setPinnedNodeId(d.id)
|
||||||
|
pinnedNodeIdRef.current = d.id
|
||||||
|
deps.highlightGraphRef.current?.(d.id)
|
||||||
|
deps.callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : deps.resolveRoleFallback())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.type === 'role') {
|
||||||
|
deps.callbacksRef.current.onRoleClick(d.id)
|
||||||
|
} else {
|
||||||
|
deps.callbacksRef.current.onSkillClick(d.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [deps])
|
||||||
|
|
||||||
|
// Re-bind events whenever selections change (triggered by simulation re-creation)
|
||||||
|
useEffect(() => {
|
||||||
|
bindEvents()
|
||||||
|
}, [deps.dimensionsTrigger, bindEvents])
|
||||||
|
|
||||||
|
return {
|
||||||
|
pinnedNodeId,
|
||||||
|
setPinnedNodeId,
|
||||||
|
pinnedNodeIdRef,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import { constellationNodes, constellationLinks } from '@/data/constellation'
|
||||||
|
import {
|
||||||
|
ROLE_WIDTH, ROLE_HEIGHT, ROLE_RX,
|
||||||
|
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
||||||
|
MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN,
|
||||||
|
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
||||||
|
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
||||||
|
} from '@/components/constellation/constants'
|
||||||
|
import type { SimNode, SimLink, LayoutParams } from '@/components/constellation/types'
|
||||||
|
|
||||||
|
function hashString(input: string): number {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + input.charCodeAt(i)
|
||||||
|
hash |= 0
|
||||||
|
}
|
||||||
|
return Math.abs(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeight(width: number, containerHeight?: number | null): number {
|
||||||
|
if (width < 1024) return 520
|
||||||
|
if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight)
|
||||||
|
return 400
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleNodes = constellationNodes.filter(n => n.type === 'role')
|
||||||
|
|
||||||
|
export function useForceSimulation(
|
||||||
|
svgRef: React.RefObject<SVGSVGElement | null>,
|
||||||
|
dimensions: { width: number; height: number; scaleFactor: number },
|
||||||
|
options: {
|
||||||
|
resolveGraphFallback: () => string | null
|
||||||
|
applyHighlight: (activeNodeId: string | null) => void
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
|
||||||
|
const nodesRef = useRef<SimNode[]>([])
|
||||||
|
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 layoutParamsRef = useRef<LayoutParams | null>(null)
|
||||||
|
const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const svg = d3.select(svgRef.current)
|
||||||
|
if (!svgRef.current) return
|
||||||
|
|
||||||
|
const { width, height, scaleFactor } = dimensions
|
||||||
|
const isMobile = window.innerWidth < 640
|
||||||
|
const sf = isMobile ? 1 : scaleFactor
|
||||||
|
|
||||||
|
if (simulationRef.current) {
|
||||||
|
simulationRef.current.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.selectAll('*').remove()
|
||||||
|
|
||||||
|
const years = roleNodes.map(n => n.startYear ?? 2016)
|
||||||
|
const minYear = Math.min(...years)
|
||||||
|
const maxYear = Math.max(...years)
|
||||||
|
|
||||||
|
const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf)
|
||||||
|
const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf)
|
||||||
|
const rrx = isMobile ? ROLE_RX : Math.round(ROLE_RX * sf)
|
||||||
|
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 topPadding = isMobile ? 36 : Math.round(46 * sf)
|
||||||
|
const bottomPadding = isMobile ? 40 : Math.round(46 * sf)
|
||||||
|
const sidePadding = isMobile ? 20 : Math.round(36 * sf)
|
||||||
|
const timelineX = isMobile
|
||||||
|
? Math.max(60, width * 0.16)
|
||||||
|
: Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18))
|
||||||
|
|
||||||
|
const layoutParams: LayoutParams = {
|
||||||
|
width, height, scaleFactor, isMobile,
|
||||||
|
rw, rh, rrx, srDefault, srActive,
|
||||||
|
topPadding, bottomPadding, sidePadding, timelineX, sf,
|
||||||
|
}
|
||||||
|
layoutParamsRef.current = layoutParams
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([maxYear, minYear])
|
||||||
|
.range([topPadding, height - bottomPadding])
|
||||||
|
|
||||||
|
// Background rect
|
||||||
|
svg.append('rect')
|
||||||
|
.attr('class', 'bg-rect')
|
||||||
|
.attr('width', width)
|
||||||
|
.attr('height', height)
|
||||||
|
.attr('fill', 'var(--surface)')
|
||||||
|
.attr('rx', 6)
|
||||||
|
|
||||||
|
// SVG filter defs
|
||||||
|
const defs = svg.append('defs')
|
||||||
|
|
||||||
|
const shadowSm = defs.append('filter')
|
||||||
|
.attr('id', 'shadow-sm-filter')
|
||||||
|
.attr('x', '-20%').attr('y', '-20%')
|
||||||
|
.attr('width', '140%').attr('height', '140%')
|
||||||
|
shadowSm.append('feDropShadow')
|
||||||
|
.attr('dx', 0).attr('dy', 1)
|
||||||
|
.attr('stdDeviation', 1.5)
|
||||||
|
.attr('flood-color', 'rgba(26,43,42,0.08)')
|
||||||
|
|
||||||
|
const shadowMd = defs.append('filter')
|
||||||
|
.attr('id', 'shadow-md-filter')
|
||||||
|
.attr('x', '-30%').attr('y', '-30%')
|
||||||
|
.attr('width', '160%').attr('height', '160%')
|
||||||
|
shadowMd.append('feDropShadow')
|
||||||
|
.attr('dx', 0).attr('dy', 2)
|
||||||
|
.attr('stdDeviation', 3)
|
||||||
|
.attr('flood-color', 'rgba(26,43,42,0.12)')
|
||||||
|
|
||||||
|
// Timeline guides
|
||||||
|
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
|
||||||
|
|
||||||
|
const tickYears = d3.range(minYear, maxYear + 1)
|
||||||
|
timelineGroup.selectAll('line.year-guide')
|
||||||
|
.data(tickYears)
|
||||||
|
.join('line')
|
||||||
|
.attr('class', 'year-guide')
|
||||||
|
.attr('x1', sidePadding)
|
||||||
|
.attr('x2', width - sidePadding)
|
||||||
|
.attr('y1', d => yScale(d))
|
||||||
|
.attr('y2', d => yScale(d))
|
||||||
|
.attr('stroke', 'var(--border-light)')
|
||||||
|
.attr('stroke-opacity', 0.25)
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
.attr('stroke-dasharray', '3 4')
|
||||||
|
|
||||||
|
timelineGroup.append('line')
|
||||||
|
.attr('x1', timelineX)
|
||||||
|
.attr('x2', timelineX)
|
||||||
|
.attr('y1', topPadding - 12)
|
||||||
|
.attr('y2', height - bottomPadding + 12)
|
||||||
|
.attr('stroke', 'var(--border)')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
|
||||||
|
timelineGroup.selectAll('line.year-tick')
|
||||||
|
.data(tickYears)
|
||||||
|
.join('line')
|
||||||
|
.attr('class', 'year-tick')
|
||||||
|
.attr('x1', timelineX)
|
||||||
|
.attr('x2', d => timelineX + (roleNodes.some(r => r.startYear === d) ? 8 : 6))
|
||||||
|
.attr('y1', d => yScale(d))
|
||||||
|
.attr('y2', d => yScale(d))
|
||||||
|
.attr('stroke', 'var(--border)')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
.attr('stroke-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.8 : 0.4)
|
||||||
|
|
||||||
|
timelineGroup.selectAll('text.year-label')
|
||||||
|
.data(tickYears)
|
||||||
|
.join('text')
|
||||||
|
.attr('class', 'year-label')
|
||||||
|
.attr('x', timelineX - (isMobile ? 8 : Math.round(12 * sf)))
|
||||||
|
.attr('y', d => yScale(d) + Math.round(4 * sf))
|
||||||
|
.attr('text-anchor', 'end')
|
||||||
|
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
||||||
|
.attr('font-family', 'var(--font-geist-mono)')
|
||||||
|
.attr('fill', 'var(--text-tertiary)')
|
||||||
|
.text(d => d)
|
||||||
|
|
||||||
|
// Prepare data
|
||||||
|
const links: SimLink[] = constellationLinks.map(l => ({
|
||||||
|
source: l.source,
|
||||||
|
target: l.target,
|
||||||
|
strength: l.strength,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0))
|
||||||
|
const roleInitialMap = new Map<string, { x: number; y: number }>()
|
||||||
|
const roleGap = isMobile ? 40 : Math.round(56 * sf)
|
||||||
|
const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2)
|
||||||
|
|
||||||
|
roleOrder.forEach((role) => {
|
||||||
|
roleInitialMap.set(role.id, {
|
||||||
|
x: roleX,
|
||||||
|
y: yScale(role.startYear ?? minYear),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodes: SimNode[] = constellationNodes.map(n => {
|
||||||
|
if (n.type === 'role') {
|
||||||
|
const pos = roleInitialMap.get(n.id)!
|
||||||
|
return { ...n, x: pos.x, y: pos.y, vx: 0, vy: 0, homeX: pos.x, homeY: pos.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleIds = constellationLinks.filter(l => l.target === n.id).map(l => l.source)
|
||||||
|
const linkedRolePositions = roleIds
|
||||||
|
.map(roleId => roleInitialMap.get(roleId))
|
||||||
|
.filter(Boolean) as Array<{ x: number; y: number }>
|
||||||
|
|
||||||
|
const skillGap = isMobile ? 20 : Math.round(28 * sf)
|
||||||
|
const skillSpaceStart = roleX + rw / 2 + skillGap
|
||||||
|
const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2
|
||||||
|
const centroid = linkedRolePositions.length > 0
|
||||||
|
? {
|
||||||
|
x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(40 * sf))),
|
||||||
|
y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length,
|
||||||
|
}
|
||||||
|
: { x: skillSpaceMid, y: height * 0.5 }
|
||||||
|
|
||||||
|
const hash = hashString(n.id)
|
||||||
|
const domainBaseAngle = n.domain === 'clinical'
|
||||||
|
? Math.PI * 0.5
|
||||||
|
: n.domain === 'leadership'
|
||||||
|
? Math.PI * 1.35
|
||||||
|
: Math.PI * 0.05
|
||||||
|
const angle = domainBaseAngle + ((hash % 360) * Math.PI / 180) * 0.18
|
||||||
|
const radius = (isMobile ? 25 : Math.round(35 * sf)) + (hash % (isMobile ? 25 : Math.round(35 * sf)))
|
||||||
|
|
||||||
|
const seededX = centroid.x + Math.cos(angle) * radius
|
||||||
|
const seededY = centroid.y + Math.sin(angle) * radius
|
||||||
|
|
||||||
|
return { ...n, x: seededX, y: seededY, vx: 0, vy: 0, homeX: seededX, homeY: seededY }
|
||||||
|
})
|
||||||
|
|
||||||
|
nodesRef.current = nodes
|
||||||
|
|
||||||
|
// Build connected map
|
||||||
|
const connectedMap = new Map<string, Set<string>>()
|
||||||
|
constellationLinks.forEach(l => {
|
||||||
|
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
|
||||||
|
if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set())
|
||||||
|
connectedMap.get(l.source)!.add(l.target)
|
||||||
|
connectedMap.get(l.target)!.add(l.source)
|
||||||
|
})
|
||||||
|
connectedMapRef.current = connectedMap
|
||||||
|
|
||||||
|
// Create SVG groups
|
||||||
|
const linkGroup = svg.append('g').attr('class', 'links')
|
||||||
|
const connectorGroup = svg.append('g').attr('class', 'connectors')
|
||||||
|
const nodeGroup = svg.append('g').attr('class', 'nodes')
|
||||||
|
|
||||||
|
const linkSelection = linkGroup.selectAll('path')
|
||||||
|
.data(links)
|
||||||
|
.join('path')
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', 'var(--border-light)')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
.attr('stroke-opacity', 0.15)
|
||||||
|
.style('transition', prefersReducedMotion
|
||||||
|
? 'none'
|
||||||
|
: 'stroke 150ms ease, stroke-opacity 150ms ease, stroke-width 150ms ease'
|
||||||
|
)
|
||||||
|
|
||||||
|
linkSelectionRef.current = linkSelection as unknown as d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown>
|
||||||
|
|
||||||
|
const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
|
||||||
|
.data(nodes)
|
||||||
|
.join('g')
|
||||||
|
.attr('class', d => `node node-${d.type}`)
|
||||||
|
.style('cursor', 'pointer')
|
||||||
|
.attr('data-node-id', d => d.id)
|
||||||
|
|
||||||
|
nodeSelectionRef.current = nodeSelection
|
||||||
|
|
||||||
|
// Role nodes
|
||||||
|
nodeSelection.filter(d => d.type === 'role')
|
||||||
|
.append('rect')
|
||||||
|
.attr('class', 'focus-ring')
|
||||||
|
.attr('x', -rw / 2 - 3)
|
||||||
|
.attr('y', -rh / 2 - 3)
|
||||||
|
.attr('width', rw + 6)
|
||||||
|
.attr('height', rh + 6)
|
||||||
|
.attr('rx', rrx + 2)
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', 'transparent')
|
||||||
|
.attr('stroke-width', 2)
|
||||||
|
|
||||||
|
nodeSelection.filter(d => d.type === 'role')
|
||||||
|
.append('rect')
|
||||||
|
.attr('class', 'node-circle')
|
||||||
|
.attr('x', -rw / 2)
|
||||||
|
.attr('y', -rh / 2)
|
||||||
|
.attr('width', rw)
|
||||||
|
.attr('height', rh)
|
||||||
|
.attr('rx', rrx)
|
||||||
|
.attr('fill', d => d.orgColor ?? 'var(--accent)')
|
||||||
|
.attr('fill-opacity', 0.12)
|
||||||
|
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
|
||||||
|
.attr('stroke-opacity', 0.4)
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
|
||||||
|
nodeSelection.filter(d => d.type === 'role')
|
||||||
|
.append('text')
|
||||||
|
.attr('class', 'node-label')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dominant-baseline', 'central')
|
||||||
|
.attr('fill', d => d.orgColor ?? 'var(--accent)')
|
||||||
|
.attr('font-size', isMobile ? '10' : `${Math.round(12 * sf)}`)
|
||||||
|
.attr('font-weight', '600')
|
||||||
|
.attr('font-family', 'var(--font-ui)')
|
||||||
|
.attr('pointer-events', 'none')
|
||||||
|
.text(d => {
|
||||||
|
const label = d.shortLabel ?? d.label.slice(0, 12)
|
||||||
|
return isMobile && label.length > MOBILE_LABEL_MAX_LEN ? `${label.slice(0, MOBILE_LABEL_MAX_LEN - 1)}…` : label
|
||||||
|
})
|
||||||
|
|
||||||
|
// Skill nodes
|
||||||
|
nodeSelection.filter(d => d.type === 'skill')
|
||||||
|
.append('circle')
|
||||||
|
.attr('class', 'focus-ring')
|
||||||
|
.attr('r', srActive + 3)
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', 'transparent')
|
||||||
|
.attr('stroke-width', 2)
|
||||||
|
|
||||||
|
nodeSelection.filter(d => d.type === 'skill')
|
||||||
|
.append('circle')
|
||||||
|
.attr('class', 'node-circle')
|
||||||
|
.attr('r', srDefault)
|
||||||
|
.attr('fill', d => DOMAIN_COLOR_MAP[d.domain ?? 'technical'] ?? '#0D6E6E')
|
||||||
|
.attr('stroke', 'none')
|
||||||
|
.attr('fill-opacity', 0.35)
|
||||||
|
|
||||||
|
nodeSelection.filter(d => d.type === 'skill')
|
||||||
|
.append('text')
|
||||||
|
.attr('class', 'node-label')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', srActive + Math.round(14 * sf))
|
||||||
|
.attr('fill', 'var(--text-secondary)')
|
||||||
|
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
||||||
|
.attr('font-family', 'var(--font-geist-mono)')
|
||||||
|
.attr('pointer-events', 'none')
|
||||||
|
.attr('opacity', 0.5)
|
||||||
|
.text(d => {
|
||||||
|
const label = d.shortLabel ?? d.label
|
||||||
|
const maxLen = isMobile ? 12 : width < 500 ? 12 : 16
|
||||||
|
return label.length > maxLen ? `${label.slice(0, maxLen - 1)}…` : label
|
||||||
|
})
|
||||||
|
|
||||||
|
// Role connectors to timeline
|
||||||
|
const roleConnectors = connectorGroup.selectAll('line.role-connector')
|
||||||
|
.data(nodes.filter(n => n.type === 'role'))
|
||||||
|
.join('line')
|
||||||
|
.attr('class', 'role-connector')
|
||||||
|
.attr('stroke', 'var(--border)')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
.attr('stroke-opacity', 0.3)
|
||||||
|
|
||||||
|
// Simulation
|
||||||
|
const simulation = d3.forceSimulation<SimNode>(nodes)
|
||||||
|
.alpha(0.65)
|
||||||
|
.alphaDecay(prefersReducedMotion ? 0.28 : 0.08)
|
||||||
|
.force('charge', d3.forceManyBody<SimNode>().strength(d =>
|
||||||
|
d.type === 'role' ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf))
|
||||||
|
))
|
||||||
|
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
||||||
|
.id(d => d.id)
|
||||||
|
.distance(isMobile ? 56 : Math.round(72 * sf))
|
||||||
|
.strength(d => (d as SimLink).strength * 0.5))
|
||||||
|
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25))
|
||||||
|
.force('y', d3.forceY<SimNode>(d => {
|
||||||
|
if (d.type === 'role') {
|
||||||
|
return yScale(d.startYear ?? minYear)
|
||||||
|
}
|
||||||
|
return d.homeY
|
||||||
|
}).strength(d => d.type === 'role' ? 0.98 : 0.18))
|
||||||
|
.force('collide', d3.forceCollide<SimNode>(d =>
|
||||||
|
d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
|
||||||
|
).iterations(3))
|
||||||
|
|
||||||
|
simulationRef.current = simulation
|
||||||
|
|
||||||
|
const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
|
||||||
|
const rightMargin = isMobile ? 16 : Math.round(32 * sf)
|
||||||
|
|
||||||
|
const renderTick = () => {
|
||||||
|
nodes.forEach(d => {
|
||||||
|
if (d.type === 'role') {
|
||||||
|
d.x = Math.max(rw / 2 + 6, Math.min(width - rw / 2 - 6, d.x))
|
||||||
|
d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y))
|
||||||
|
} else {
|
||||||
|
d.x = Math.max(srActive + 6, Math.min(width - srActive - rightMargin, d.x))
|
||||||
|
d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
linkSelection
|
||||||
|
.attr('d', d => {
|
||||||
|
const sx = (d.source as SimNode).x
|
||||||
|
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
|
||||||
|
return `M${sx},${sy} Q${cx},${sy} ${tx},${ty}`
|
||||||
|
})
|
||||||
|
|
||||||
|
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||||
|
|
||||||
|
roleConnectors
|
||||||
|
.attr('x1', timelineX)
|
||||||
|
.attr('y1', d => d.y)
|
||||||
|
.attr('x2', d => d.x - rw / 2)
|
||||||
|
.attr('y2', d => d.y)
|
||||||
|
|
||||||
|
const nextNodePositions: Record<string, { x: number; y: number }> = {}
|
||||||
|
nodes.forEach(node => {
|
||||||
|
nextNodePositions[node.id] = {
|
||||||
|
x: Math.round(node.x),
|
||||||
|
y: Math.round(node.y),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setNodeButtonPositions(prev => {
|
||||||
|
const prevKeys = Object.keys(prev)
|
||||||
|
const nextKeys = Object.keys(nextNodePositions)
|
||||||
|
if (prevKeys.length !== nextKeys.length) return nextNodePositions
|
||||||
|
|
||||||
|
for (const key of nextKeys) {
|
||||||
|
const prevPos = prev[key]
|
||||||
|
const nextPos = nextNodePositions[key]
|
||||||
|
if (!prevPos || prevPos.x !== nextPos.x || prevPos.y !== nextPos.y) {
|
||||||
|
return nextNodePositions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
|
options.applyHighlight(options.resolveGraphFallback())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
simulation.stop()
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
simulation.tick()
|
||||||
|
}
|
||||||
|
renderTick()
|
||||||
|
} else {
|
||||||
|
simulation.on('tick', renderTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
simulation.stop()
|
||||||
|
}
|
||||||
|
}, [dimensions, options])
|
||||||
|
|
||||||
|
return {
|
||||||
|
simulationRef,
|
||||||
|
nodesRef,
|
||||||
|
nodeSelectionRef,
|
||||||
|
linkSelectionRef,
|
||||||
|
nodeButtonPositions,
|
||||||
|
layoutParams: layoutParamsRef.current,
|
||||||
|
connectedMap: connectedMapRef.current,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getHeight }
|
||||||
Reference in New Issue
Block a user