Rehaul of graph component
This commit is contained in:
+9
-11
@@ -1,11 +1,11 @@
|
|||||||
# Session Handoff
|
# Session Handoff
|
||||||
|
|
||||||
_Generated: 2026-02-16 14:36:25 UTC_
|
_Generated: 2026-02-16 15:06:20 UTC_
|
||||||
|
|
||||||
## Git Context
|
## Git Context
|
||||||
|
|
||||||
- **Branch:** `master`
|
- **Branch:** `master`
|
||||||
- **HEAD:** aca5771: chore: auto-commit before merge (loop primary)
|
- **HEAD:** e9a7581: chore: auto-commit before merge (loop primary)
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
@@ -70,18 +70,16 @@ Session completed successfully. No pending work.
|
|||||||
**Original objective:**
|
**Original objective:**
|
||||||
|
|
||||||
```
|
```
|
||||||
# Task: CareerConstellation Overhaul
|
# Task: Career Constellation Chart & Layout Polish
|
||||||
|
|
||||||
Refactor, visually improve, and add chronological animation to the CareerConstellation D3 force chart — the centrepiece of the portfolio's Patient Pathway section.
|
Visual polish and layout adjustments to the career constellation chart, sidebar, and repeat medications section. 12 discrete changes across 10 files.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Phase 1 — Refactor the Monolith
|
### 1. Reduce link opacity (`src/components/constellation/constants.ts`)
|
||||||
|
- Lower `LINK_BASE_OPACITY` from `0.08` → `0.04`
|
||||||
Decompose `src/components/CareerConstellation.tsx` (1102 lines) into focused modules:
|
- Lower `LINK_STRENGTH_OPACITY_FACTOR` from `0.12` → `0.06`
|
||||||
|
- Makes skill connection lines subtler so job pills are visually clearer
|
||||||
|
|
||||||
```
|
### 2. White backgro...
|
||||||
src/components/constellation/
|
|
||||||
CareerConstellation.tsx -- Orchestrator (< 300 lines)
|
|
||||||
MobileAccordion.tsx -- Mobile tap-to-e...
|
|
||||||
```
|
```
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
+1
-1
@@ -45,7 +45,7 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [phase, setPhase] = useState<Phase>('login')
|
const [phase, setPhase] = useState<Phase>('boot')
|
||||||
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
|
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BOOT_CONFIG: BootConfig = {
|
const BOOT_CONFIG: BootConfig = {
|
||||||
header: 'CLINICAL TERMINAL v3.2.1',
|
header: 'CV Management Information System v1.0.0',
|
||||||
lines: [
|
lines: [
|
||||||
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
|
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
|
||||||
{ type: 'separator', text: '---', style: 'dim' },
|
{ type: 'separator', text: '---', style: 'dim' },
|
||||||
@@ -88,7 +88,7 @@ const BOOT_CONFIG: BootConfig = {
|
|||||||
timing: {
|
timing: {
|
||||||
lineDelay: 220,
|
lineDelay: 220,
|
||||||
cursorBlinkInterval: 300,
|
cursorBlinkInterval: 300,
|
||||||
holdAfterComplete: 900,
|
holdAfterComplete: 1000,
|
||||||
fadeOutDuration: 600,
|
fadeOutDuration: 600,
|
||||||
cursorShrinkDuration: 600,
|
cursorShrinkDuration: 600,
|
||||||
ecgStartDelay: 0,
|
ecgStartDelay: 0,
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: '20px',
|
gap: '20px',
|
||||||
marginBottom: '14px',
|
marginBottom: '1=px',
|
||||||
paddingBottom: '14px',
|
paddingBottom: '14px',
|
||||||
borderBottom: '1px solid var(--border-light)',
|
borderBottom: '1px solid var(--border-light)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -182,7 +182,7 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '7px',
|
gap: '7px',
|
||||||
marginBottom: '16px',
|
marginBottom: '0px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{consultation.examination.map((bullet, index) => (
|
{consultation.examination.map((bullet, index) => (
|
||||||
@@ -250,7 +250,7 @@ export function DashboardLayout() {
|
|||||||
const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null)
|
const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null)
|
||||||
const [highlightedRoleId, setHighlightedRoleId] = useState<string | null>(null)
|
const [highlightedRoleId, setHighlightedRoleId] = useState<string | null>(null)
|
||||||
const [chronologyHeight, setChronologyHeight] = useState<number | null>(null)
|
const [chronologyHeight, setChronologyHeight] = useState<number | null>(null)
|
||||||
const [sidebarForceCollapsed, setSidebarForceCollapsed] = useState(false)
|
const [constellationReady, setConstellationReady] = useState(false)
|
||||||
const chronologyRef = useRef<HTMLDivElement>(null)
|
const chronologyRef = useRef<HTMLDivElement>(null)
|
||||||
const patientSummaryRef = useRef<HTMLDivElement>(null)
|
const patientSummaryRef = useRef<HTMLDivElement>(null)
|
||||||
const activeSection = useActiveSection()
|
const activeSection = useActiveSection()
|
||||||
@@ -260,28 +260,18 @@ export function DashboardLayout() {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sidebar collapse when patient summary scrolls out of view (desktop only)
|
// Signal constellation animation readiness when patient summary scrolls out of view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = patientSummaryRef.current
|
const el = patientSummaryRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const mq = window.matchMedia('(min-width: 1024px)')
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (mq.matches) {
|
if (!entry.isIntersecting) setConstellationReady(true)
|
||||||
setSidebarForceCollapsed(!entry.isIntersecting)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ threshold: 0 },
|
{ threshold: 0 },
|
||||||
)
|
)
|
||||||
observer.observe(el)
|
observer.observe(el)
|
||||||
const handleResize = () => {
|
return () => observer.disconnect()
|
||||||
if (!mq.matches) setSidebarForceCollapsed(false)
|
|
||||||
}
|
|
||||||
mq.addEventListener('change', handleResize)
|
|
||||||
return () => {
|
|
||||||
observer.disconnect()
|
|
||||||
mq.removeEventListener('change', handleResize)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Measure the chronology stream height so the constellation graph can match it
|
// Measure the chronology stream height so the constellation graph can match it
|
||||||
@@ -436,7 +426,6 @@ export function DashboardLayout() {
|
|||||||
activeSection={activeSection}
|
activeSection={activeSection}
|
||||||
onNavigate={scrollToSection}
|
onNavigate={scrollToSection}
|
||||||
onSearchClick={handleSearchClick}
|
onSearchClick={handleSearchClick}
|
||||||
forceCollapsed={sidebarForceCollapsed}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -465,31 +454,7 @@ export function DashboardLayout() {
|
|||||||
<ParentSection title="Patient Pathway" tileId="patient-pathway">
|
<ParentSection title="Patient Pathway" tileId="patient-pathway">
|
||||||
<div className="pathway-columns">
|
<div className="pathway-columns">
|
||||||
<div ref={chronologyRef} className="chronology-stream" data-tile-id="section-experience">
|
<div ref={chronologyRef} className="chronology-stream" data-tile-id="section-experience">
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: '14px',
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid var(--border-light)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
background: 'var(--bg-dashboard)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.06em',
|
|
||||||
color: 'var(--text-tertiary)',
|
|
||||||
marginBottom: '4px',
|
|
||||||
fontFamily: 'var(--font-geist-mono)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clinical Record Stream
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
||||||
Chronological role and education entries. Select items to inspect full records.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="chronology-item">
|
<div className="chronology-item">
|
||||||
<LastConsultationSubsection highlightedRoleId={highlightedRoleId} />
|
<LastConsultationSubsection highlightedRoleId={highlightedRoleId} />
|
||||||
@@ -506,6 +471,7 @@ export function DashboardLayout() {
|
|||||||
onNodeHover={handleNodeHover}
|
onNodeHover={handleNodeHover}
|
||||||
highlightedNodeId={highlightedNodeId}
|
highlightedNodeId={highlightedNodeId}
|
||||||
containerHeight={chronologyHeight}
|
containerHeight={chronologyHeight}
|
||||||
|
animationReady={constellationReady}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const HOLD_SECONDS = 2 // Hold after text completes, before flatline/transition
|
|||||||
const FLATLINE_DRAW_SECONDS = 0.3 // Time to draw flatline
|
const FLATLINE_DRAW_SECONDS = 0.3 // Time to draw flatline
|
||||||
const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out
|
const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out
|
||||||
const BG_TRANSITION_SECONDS = 0.2 // Background color transition
|
const BG_TRANSITION_SECONDS = 0.2 // Background color transition
|
||||||
|
const SKIP_TEXT = true // Skip text phase — transition directly after heartbeats
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Letter Definitions (ECG waveform shapes for each letter)
|
// Letter Definitions (ECG waveform shapes for each letter)
|
||||||
@@ -344,7 +345,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
||||||
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
|
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
|
||||||
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
|
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
|
||||||
const textEndWX = textStartWX + totalTextW
|
const textEndWX = SKIP_TEXT ? textStartWX : textStartWX + totalTextW
|
||||||
const textLayout = layoutText(
|
const textLayout = layoutText(
|
||||||
textStartWX, LETTER_W, LETTER_G, SPACE_W,
|
textStartWX, LETTER_W, LETTER_G, SPACE_W,
|
||||||
baselineY, 0, Infinity
|
baselineY, 0, Infinity
|
||||||
@@ -354,7 +355,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED
|
const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED
|
||||||
const holdEndTime = textEndTime
|
const holdEndTime = textEndTime
|
||||||
const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS
|
const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS
|
||||||
const fadeStartTime = flatlineEndTime + HOLD_SECONDS
|
const fadeStartTime = flatlineEndTime + (SKIP_TEXT ? 0.3 : HOLD_SECONDS)
|
||||||
const fadeEndTime = fadeStartTime + FADE_TO_BLACK_SECONDS
|
const fadeEndTime = fadeStartTime + FADE_TO_BLACK_SECONDS
|
||||||
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
|
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
|
||||||
const exitEndTime = bgTransitionEndTime
|
const exitEndTime = bgTransitionEndTime
|
||||||
@@ -500,7 +501,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
const isTextPhase = headWX > textStartWX
|
const isTextPhase = headWX > textStartWX
|
||||||
const isTextDone = elapsed >= textEndTime
|
const isTextDone = elapsed >= textEndTime
|
||||||
|
|
||||||
if (isTextPhase) {
|
if (isTextPhase && !SKIP_TEXT) {
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
// Clip for progressive reveal
|
// Clip for progressive reveal
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ interface SidebarProps {
|
|||||||
activeSection: string
|
activeSection: string
|
||||||
onNavigate: (tileId: string) => void
|
onNavigate: (tileId: string) => void
|
||||||
onSearchClick: () => void
|
onSearchClick: () => void
|
||||||
forceCollapsed?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavSection {
|
interface NavSection {
|
||||||
@@ -163,7 +162,7 @@ function AlertFlag({ alert }: AlertFlagProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({ activeSection, onNavigate, onSearchClick, forceCollapsed }: SidebarProps) {
|
export default function Sidebar({ activeSection, onNavigate, onSearchClick }: SidebarProps) {
|
||||||
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches)
|
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches)
|
||||||
const [isMobileExpanded, setIsMobileExpanded] = useState(false)
|
const [isMobileExpanded, setIsMobileExpanded] = useState(false)
|
||||||
|
|
||||||
@@ -185,7 +184,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick, forc
|
|||||||
return () => mediaQuery.removeEventListener('change', listener)
|
return () => mediaQuery.removeEventListener('change', listener)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isExpanded = (isDesktop && !forceCollapsed) || isMobileExpanded
|
const isExpanded = isDesktop || isMobileExpanded
|
||||||
|
|
||||||
const handleNavActivate = (tileId: string) => {
|
const handleNavActivate = (tileId: string) => {
|
||||||
onNavigate(tileId)
|
onNavigate(tileId)
|
||||||
@@ -196,7 +195,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick, forc
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(!isDesktop || forceCollapsed) && isMobileExpanded && (
|
{!isDesktop && isMobileExpanded && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close sidebar navigation"
|
aria-label="Close sidebar navigation"
|
||||||
@@ -235,7 +234,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick, forc
|
|||||||
}}
|
}}
|
||||||
className={isExpanded ? 'pmr-scrollbar' : undefined}
|
className={isExpanded ? 'pmr-scrollbar' : undefined}
|
||||||
>
|
>
|
||||||
{(!isDesktop || forceCollapsed) && (
|
{!isDesktop && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={isExpanded ? 'Collapse sidebar navigation' : 'Expand sidebar navigation'}
|
aria-label={isExpanded ? 'Collapse sidebar navigation' : 'Expand sidebar navigation'}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function TimelineInterventionItem({
|
|||||||
onHighlight,
|
onHighlight,
|
||||||
}: TimelineInterventionItemProps) {
|
}: TimelineInterventionItemProps) {
|
||||||
const isEducation = entity.kind === 'education'
|
const isEducation = entity.kind === 'education'
|
||||||
const interventionLabel = isEducation ? 'Education Intervention' : 'Career Intervention'
|
const interventionLabel = isEducation ? 'Education' : 'Employment'
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
@@ -76,9 +76,9 @@ function TimelineInterventionItem({
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
padding: '12px 14px',
|
padding: '8px 8px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
minHeight: '44px',
|
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
@@ -113,14 +113,12 @@ function TimelineInterventionItem({
|
|||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '6px',
|
gap: '6px',
|
||||||
marginBottom: '6px',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
|
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
|
||||||
{interventionLabel}
|
{interventionLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
@@ -130,6 +128,7 @@ function TimelineInterventionItem({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entity.title}
|
{entity.title}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -139,17 +138,23 @@ function TimelineInterventionItem({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entity.organization}
|
{entity.organization}
|
||||||
</div>
|
|
||||||
<div
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
|
paddingLeft: '6px',
|
||||||
fontFamily: 'var(--font-geist-mono)',
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
color: 'var(--text-tertiary)',
|
color: 'var(--text-tertiary)',
|
||||||
marginTop: '3px',
|
marginTop: '3px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entity.dateRange.display}
|
{entity.dateRange.display}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -160,6 +165,7 @@ function TimelineInterventionItem({
|
|||||||
marginTop: '2px',
|
marginTop: '2px',
|
||||||
transform: isExpanded ? 'rotate(90deg)' : 'none',
|
transform: isExpanded ? 'rotate(90deg)' : 'none',
|
||||||
transition: 'transform 0.15s ease-out',
|
transition: 'transform 0.15s ease-out',
|
||||||
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface CareerConstellationProps {
|
|||||||
onNodeHover?: (id: string | null) => void
|
onNodeHover?: (id: string | null) => void
|
||||||
highlightedNodeId?: string | null
|
highlightedNodeId?: string | null
|
||||||
containerHeight?: number | null
|
containerHeight?: number | null
|
||||||
|
animationReady?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
|
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
|
||||||
@@ -35,6 +36,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
onNodeHover,
|
onNodeHover,
|
||||||
highlightedNodeId,
|
highlightedNodeId,
|
||||||
containerHeight,
|
containerHeight,
|
||||||
|
animationReady = false,
|
||||||
}) => {
|
}) => {
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -66,6 +68,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const CHANGE_THRESHOLD = 0.3
|
||||||
|
|
||||||
const updateDimensions = () => {
|
const updateDimensions = () => {
|
||||||
const width = container.clientWidth
|
const width = container.clientWidth
|
||||||
const viewportWidth = window.innerWidth
|
const viewportWidth = window.innerWidth
|
||||||
@@ -73,13 +78,28 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
const scaleFactor = viewportWidth >= 1024
|
const scaleFactor = viewportWidth >= 1024
|
||||||
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
||||||
: 1
|
: 1
|
||||||
setDimensions({ width, height, scaleFactor })
|
setDimensions(prev => {
|
||||||
|
const widthDelta = Math.abs(prev.width - width) / prev.width
|
||||||
|
const heightDelta = Math.abs(prev.height - height) / prev.height
|
||||||
|
if (widthDelta < CHANGE_THRESHOLD && heightDelta < CHANGE_THRESHOLD) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return { width, height, scaleFactor }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial measurement (no debounce)
|
||||||
updateDimensions()
|
updateDimensions()
|
||||||
const observer = new ResizeObserver(updateDimensions)
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(updateDimensions, 2000)
|
||||||
|
})
|
||||||
observer.observe(container)
|
observer.observe(container)
|
||||||
return () => observer.disconnect()
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
}
|
||||||
}, [containerHeight])
|
}, [containerHeight])
|
||||||
|
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
||||||
@@ -157,6 +177,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
skillRestRadiiRef,
|
skillRestRadiiRef,
|
||||||
srDefault,
|
srDefault,
|
||||||
dimensionsTrigger: dimensions.width + dimensions.height,
|
dimensionsTrigger: dimensions.width + dimensions.height,
|
||||||
|
ready: animationReady,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sync visibleNodeIdsRef from animation hook
|
// Sync visibleNodeIdsRef from animation hook
|
||||||
@@ -231,12 +252,15 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
width={dimensions.width}
|
|
||||||
height={dimensions.height}
|
|
||||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
||||||
style={{ display: 'block' }}
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
height: dimensions.height,
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
||||||
@@ -249,6 +273,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
|||||||
onToggle={animation.togglePlayPause}
|
onToggle={animation.togglePlayPause}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
visible={chartInView}
|
visible={chartInView}
|
||||||
|
containerRef={containerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouc
|
|||||||
right: 0,
|
right: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
gap: '2px',
|
gap: '2px',
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
|||||||
@@ -1,25 +1,72 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface PlayPauseButtonProps {
|
interface PlayPauseButtonProps {
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({ isPlaying, onToggle, isMobile, visible = true }) => {
|
export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({
|
||||||
const size = isMobile ? 44 : 36
|
isPlaying, onToggle, isMobile, visible = true, containerRef,
|
||||||
const offset = isMobile ? 8 : 12
|
}) => {
|
||||||
|
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
|
||||||
|
const scale = vw >= 1440 ? 1.75 : vw >= 1280 ? 1.5 : vw >= 1080 ? 1.25 : 1
|
||||||
|
const size = isMobile ? 44 : Math.round(36 * scale)
|
||||||
|
const offset = isMobile ? 8 : Math.round(12 * scale)
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const [topPos, setTopPos] = useState(56)
|
||||||
|
const [scrolling, setScrolling] = useState(false)
|
||||||
|
const debounceRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const scrollParent = container.closest('.dashboard-main') as HTMLElement | null
|
||||||
|
if (!scrollParent) return
|
||||||
|
|
||||||
|
const margin = isMobile ? 12 : 56
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const cRect = container.getBoundingClientRect()
|
||||||
|
const sRect = scrollParent.getBoundingClientRect()
|
||||||
|
const visibleTop = Math.max(sRect.top, cRect.top) + margin + 50
|
||||||
|
const visibleBottom = Math.min(sRect.bottom, cRect.bottom) - size - 12
|
||||||
|
const targetY = Math.min(visibleTop, visibleBottom)
|
||||||
|
const relativeTop = targetY - cRect.top
|
||||||
|
setTopPos(Math.max(margin, relativeTop))
|
||||||
|
|
||||||
|
setScrolling(true)
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = window.setTimeout(() => setScrolling(false), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollParent.addEventListener('scroll', update, { passive: true })
|
||||||
|
window.addEventListener('resize', update, { passive: true })
|
||||||
|
update()
|
||||||
|
// Don't start hidden — clear the initial scroll trigger
|
||||||
|
setScrolling(false)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollParent.removeEventListener('scroll', update)
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
}
|
||||||
|
}, [containerRef, isMobile, size])
|
||||||
|
|
||||||
|
const showButton = visible && !scrolling
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={btnRef}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
aria-label={isPlaying ? 'Pause animation' : 'Play animation'}
|
aria-label={isPlaying ? 'Pause animation' : 'Play animation'}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: offset,
|
left: offset,
|
||||||
top: '50%',
|
top: topPos,
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
@@ -30,20 +77,23 @@ export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({ isPlaying, onT
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
opacity: visible ? 0.85 : 0,
|
opacity: showButton ? 0.85 : 0,
|
||||||
pointerEvents: visible ? 'auto' : 'none',
|
pointerEvents: showButton ? 'auto' : 'none',
|
||||||
transition: 'opacity 150ms ease',
|
transition: scrolling
|
||||||
|
? 'opacity 150ms ease, top 80ms linear'
|
||||||
|
: 'opacity 500ms ease, top 80ms linear',
|
||||||
|
zIndex: 5,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
|
onMouseEnter={e => { if (showButton) e.currentTarget.style.opacity = '1' }}
|
||||||
onMouseLeave={e => { if (visible) e.currentTarget.style.opacity = '0.85' }}
|
onMouseLeave={e => { if (showButton) e.currentTarget.style.opacity = '0.85' }}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="var(--text-secondary)">
|
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 14 14" fill="var(--text-secondary)">
|
||||||
<rect x="2" y="1" width="4" height="12" rx="1" />
|
<rect x="2" y="1" width="4" height="12" rx="1" />
|
||||||
<rect x="8" y="1" width="4" height="12" rx="1" />
|
<rect x="8" y="1" width="4" height="12" rx="1" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="var(--text-secondary)">
|
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 14 14" fill="var(--text-secondary)">
|
||||||
<polygon points="3,1 13,7 3,13" />
|
<polygon points="3,1 13,7 3,13" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,25 +13,40 @@ export const MOBILE_LABEL_MAX_LEN = 10
|
|||||||
|
|
||||||
// Animation / opacity
|
// Animation / opacity
|
||||||
export const HIGHLIGHT_DIM_OPACITY = 0.15
|
export const HIGHLIGHT_DIM_OPACITY = 0.15
|
||||||
export const SKILL_REST_OPACITY = 0.35
|
export const SKILL_REST_OPACITY = 0.6
|
||||||
export const SKILL_ACTIVE_OPACITY = 0.9
|
export const SKILL_ACTIVE_OPACITY = 0.9
|
||||||
export const LABEL_REST_OPACITY = 0.5
|
export const LABEL_REST_OPACITY = 0.6
|
||||||
|
|
||||||
// Link visual params
|
// Link visual params
|
||||||
export const LINK_BASE_WIDTH = 0.5
|
export const LINK_BASE_WIDTH = 0.7
|
||||||
export const LINK_STRENGTH_WIDTH_FACTOR = 1.5
|
export const LINK_STRENGTH_WIDTH_FACTOR = 0
|
||||||
export const LINK_BASE_OPACITY = 0.04
|
export const LINK_BASE_OPACITY = 0
|
||||||
export const LINK_STRENGTH_OPACITY_FACTOR = 0.06
|
export const LINK_STRENGTH_OPACITY_FACTOR = 0
|
||||||
export const LINK_HIGHLIGHT_BASE_WIDTH = 1
|
export const LINK_HIGHLIGHT_BASE_WIDTH = 1
|
||||||
export const LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR = 2
|
export const LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR = 2
|
||||||
export const LINK_BEZIER_VERTICAL_OFFSET = 0.15
|
export const LINK_BEZIER_VERTICAL_OFFSET = 0.15
|
||||||
|
|
||||||
|
// Role node visual params
|
||||||
|
export const ROLE_STROKE_OPACITY_DEFAULT = 1
|
||||||
|
export const ROLE_STROKE_OPACITY_ACTIVE = 1
|
||||||
|
export const ROLE_STROKE_OPACITY_CONNECTED = 0.9
|
||||||
|
export const ROLE_STROKE_WIDTH_DEFAULT = 1
|
||||||
|
export const ROLE_STROKE_WIDTH_ACTIVE = 2
|
||||||
|
export const ROLE_FILL_OPACITY_ACTIVE = 1
|
||||||
|
export const ROLE_FILL_ACTIVE = '#FFFFFF'
|
||||||
|
|
||||||
// Skill node visual params
|
// Skill node visual params
|
||||||
export const SKILL_STROKE_WIDTH = 1
|
export const SKILL_STROKE_WIDTH = 1
|
||||||
export const SKILL_STROKE_OPACITY = 0.4
|
export const SKILL_STROKE_OPACITY = 0.4
|
||||||
export const SKILL_SIZE_ROLE_FACTOR = 0.8
|
export const SKILL_SIZE_ROLE_FACTOR = 0.8
|
||||||
export const SKILL_GLOW_STD_DEVIATION = 2.5
|
export const SKILL_GLOW_STD_DEVIATION = 2.5
|
||||||
|
export const SKILL_ACTIVE_STROKE_OPACITY = 0.1
|
||||||
|
|
||||||
|
// Skill overlap offsets
|
||||||
|
export const SKILL_Y_OFFSET_STEP = 25
|
||||||
|
export const SKILL_Y_OFFSET_STEP_MOBILE = 20
|
||||||
|
export const SKILL_Y_GLOBAL_OFFSET_RATIO = -0.05
|
||||||
|
export const SKILL_X_OVERLAP_MAX_RATIO = 1
|
||||||
// Entry animation
|
// Entry animation
|
||||||
export const ENTRY_GUIDE_FADE_MS = 200
|
export const ENTRY_GUIDE_FADE_MS = 200
|
||||||
export const ENTRY_ROLE_STAGGER_MS = 80
|
export const ENTRY_ROLE_STAGGER_MS = 80
|
||||||
@@ -40,18 +55,20 @@ export const ENTRY_SKILL_STAGGER_MS = 30
|
|||||||
export const ENTRY_SKILL_DURATION_MS = 250
|
export const ENTRY_SKILL_DURATION_MS = 250
|
||||||
|
|
||||||
// Timeline animation
|
// Timeline animation
|
||||||
export const ANIM_ENTITY_REVEAL_MS = 600
|
export const ANIM_CHRONOLOGICAL_ENABLED = true
|
||||||
export const ANIM_SKILL_REVEAL_MS = 350
|
export const ANIM_ENTITY_REVEAL_MS = 2000
|
||||||
export const ANIM_SKILL_STAGGER_MS = 60
|
export const ANIM_SKILL_REVEAL_MS = 2000
|
||||||
export const ANIM_LINK_DRAW_MS = 300
|
export const ANIM_SKILL_STAGGER_MS = 200
|
||||||
export const ANIM_LINK_STAGGER_MS = 40
|
export const ANIM_LINK_DRAW_MS = 600
|
||||||
export const ANIM_REINFORCEMENT_MS = 350
|
export const ANIM_LINK_STAGGER_MS = 200
|
||||||
export const ANIM_STEP_GAP_MS = 400
|
export const ANIM_REINFORCEMENT_MS = 700
|
||||||
export const ANIM_HOLD_MS = 3000
|
export const ANIM_STEP_GAP_MS = 1000
|
||||||
export const ANIM_RESET_MS = 400
|
export const ANIM_HOLD_MS = 15000
|
||||||
export const ANIM_RESTART_DELAY_MS = 200
|
export const ANIM_RESET_MS = 800
|
||||||
|
export const ANIM_RESTART_DELAY_MS = 400
|
||||||
export const ANIM_INTERACTION_RESUME_MS = 800
|
export const ANIM_INTERACTION_RESUME_MS = 800
|
||||||
export const ANIM_SETTLE_ALPHA = 0.05
|
export const ANIM_SETTLE_ALPHA = 0.05
|
||||||
|
export const ANIM_MONTH_STEP_MS = 80
|
||||||
|
|
||||||
// Domain color map
|
// Domain color map
|
||||||
export const DOMAIN_COLOR_MAP: Record<string, string> = {
|
export const DOMAIN_COLOR_MAP: Record<string, string> = {
|
||||||
@@ -60,6 +77,14 @@ export const DOMAIN_COLOR_MAP: Record<string, string> = {
|
|||||||
leadership: '#D97706',
|
leadership: '#D97706',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entities hidden from the constellation (education + early career roles)
|
||||||
|
export const HIDDEN_ENTITY_IDS = new Set([
|
||||||
|
'pre-reg-pharmacist-2015',
|
||||||
|
'duty-pharmacy-manager-2016',
|
||||||
|
'uea-mpharm-2011',
|
||||||
|
'highworth-alevels-2009',
|
||||||
|
])
|
||||||
|
|
||||||
// Media queries (evaluated once at module level)
|
// Media queries (evaluated once at module level)
|
||||||
export const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
export const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
export const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
|
export const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export type AnimationState = 'IDLE' | 'PLAYING' | 'PAUSED' | 'HOLDING' | 'RESETT
|
|||||||
export interface AnimationStep {
|
export interface AnimationStep {
|
||||||
entityId: string
|
entityId: string
|
||||||
startYear: number
|
startYear: number
|
||||||
|
startMonth: number // 0-indexed (0 = January)
|
||||||
skillIds: string[]
|
skillIds: string[]
|
||||||
newSkillIds: string[]
|
newSkillIds: string[]
|
||||||
reinforcedSkillIds: string[]
|
reinforcedSkillIds: string[]
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ export function buildConstellationData(): {
|
|||||||
shortLabel: entity.graphLabel,
|
shortLabel: entity.graphLabel,
|
||||||
organization: entity.organization,
|
organization: entity.organization,
|
||||||
startYear: entity.dateRange.startYear,
|
startYear: entity.dateRange.startYear,
|
||||||
|
startDate: entity.dateRange.start,
|
||||||
endYear: entity.dateRange.endYear,
|
endYear: entity.dateRange.endYear,
|
||||||
orgColor: entity.orgColor,
|
orgColor: entity.orgColor,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useRef, useCallback } from 'react'
|
import { useRef, useCallback } from 'react'
|
||||||
import type * as d3 from 'd3'
|
import type * as d3 from 'd3'
|
||||||
|
import { select as d3select } from 'd3'
|
||||||
import {
|
import {
|
||||||
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
||||||
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
||||||
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
||||||
LINK_HIGHLIGHT_BASE_WIDTH, LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR,
|
LINK_HIGHLIGHT_BASE_WIDTH, LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR,
|
||||||
SKILL_STROKE_OPACITY,
|
SKILL_STROKE_OPACITY, SKILL_ACTIVE_STROKE_OPACITY,
|
||||||
|
SKILL_REST_OPACITY, SKILL_ACTIVE_OPACITY, LABEL_REST_OPACITY,
|
||||||
|
HIGHLIGHT_DIM_OPACITY,
|
||||||
|
ROLE_STROKE_OPACITY_DEFAULT, ROLE_STROKE_OPACITY_ACTIVE, ROLE_STROKE_OPACITY_CONNECTED,
|
||||||
|
ROLE_STROKE_WIDTH_DEFAULT, ROLE_STROKE_WIDTH_ACTIVE,
|
||||||
|
ROLE_FILL_OPACITY_ACTIVE, ROLE_FILL_ACTIVE,
|
||||||
} from '@/components/constellation/constants'
|
} from '@/components/constellation/constants'
|
||||||
import type { SimNode, SimLink } from '@/components/constellation/types'
|
import type { SimNode, SimLink } from '@/components/constellation/types'
|
||||||
|
|
||||||
@@ -42,7 +48,7 @@ export function useConstellationHighlight(deps: {
|
|||||||
const nodes = deps.nodesRef.current
|
const nodes = deps.nodesRef.current
|
||||||
const dur = prefersReducedMotion ? 0 : 180
|
const dur = prefersReducedMotion ? 0 : 180
|
||||||
const visibleIds = deps.visibleNodeIdsRef?.current
|
const visibleIds = deps.visibleNodeIdsRef?.current
|
||||||
const isVisible = (id: string) => !visibleIds || visibleIds.size === 0 || visibleIds.has(id)
|
const isVisible = (id: string) => !visibleIds || visibleIds.has(id)
|
||||||
|
|
||||||
if (!activeNodeId) {
|
if (!activeNodeId) {
|
||||||
// Reset — respect animation visibility
|
// Reset — respect animation visibility
|
||||||
@@ -51,10 +57,13 @@ export function useConstellationHighlight(deps: {
|
|||||||
nodeSelection.filter(d => d.type !== 'skill')
|
nodeSelection.filter(d => d.type !== 'skill')
|
||||||
.attr('filter', null)
|
.attr('filter', null)
|
||||||
.select('.node-circle')
|
.select('.node-circle')
|
||||||
.attr('fill', null)
|
.each(function () {
|
||||||
|
const el = d3select(this)
|
||||||
|
el.attr('fill', el.attr('data-base-fill'))
|
||||||
|
})
|
||||||
.attr('fill-opacity', null)
|
.attr('fill-opacity', null)
|
||||||
.attr('stroke-opacity', 0.4)
|
.attr('stroke-opacity', ROLE_STROKE_OPACITY_DEFAULT)
|
||||||
.attr('stroke-width', 1)
|
.attr('stroke-width', ROLE_STROKE_WIDTH_DEFAULT)
|
||||||
|
|
||||||
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||||
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
||||||
@@ -62,20 +71,20 @@ export function useConstellationHighlight(deps: {
|
|||||||
skillNodes.select('.node-circle')
|
skillNodes.select('.node-circle')
|
||||||
.transition().duration(dur)
|
.transition().duration(dur)
|
||||||
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
|
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
|
||||||
.attr('fill-opacity', 0.35)
|
.attr('fill-opacity', SKILL_REST_OPACITY)
|
||||||
.attr('filter', null)
|
.attr('filter', null)
|
||||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||||
skillNodes.select('.node-label')
|
skillNodes.select('.node-label')
|
||||||
.transition().duration(dur)
|
.transition().duration(dur)
|
||||||
.attr('opacity', 0.5)
|
.attr('opacity', LABEL_REST_OPACITY)
|
||||||
} else {
|
} else {
|
||||||
skillNodes.select('.node-circle')
|
skillNodes.select('.node-circle')
|
||||||
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
|
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
|
||||||
.attr('fill-opacity', 0.35)
|
.attr('fill-opacity', SKILL_REST_OPACITY)
|
||||||
.attr('filter', null)
|
.attr('filter', null)
|
||||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||||
skillNodes.select('.node-label')
|
skillNodes.select('.node-label')
|
||||||
.attr('opacity', 0.5)
|
.attr('opacity', LABEL_REST_OPACITY)
|
||||||
}
|
}
|
||||||
|
|
||||||
linkSelection
|
linkSelection
|
||||||
@@ -96,7 +105,7 @@ export function useConstellationHighlight(deps: {
|
|||||||
|
|
||||||
nodeSelection.style('opacity', d => {
|
nodeSelection.style('opacity', d => {
|
||||||
if (!isVisible(d.id)) return '0'
|
if (!isVisible(d.id)) return '0'
|
||||||
return isInGroup(d.id) ? '1' : '0.15'
|
return isInGroup(d.id) ? '1' : String(HIGHLIGHT_DIM_OPACITY)
|
||||||
})
|
})
|
||||||
|
|
||||||
nodeSelection.filter(d => d.type !== 'skill')
|
nodeSelection.filter(d => d.type !== 'skill')
|
||||||
@@ -106,14 +115,17 @@ export function useConstellationHighlight(deps: {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
.select('.node-circle')
|
.select('.node-circle')
|
||||||
.attr('fill', d => d.id === activeNodeId ? '#FFFFFF' : null)
|
.each(function (d) {
|
||||||
.attr('fill-opacity', d => d.id === activeNodeId ? 1 : null)
|
const el = d3select(this)
|
||||||
.attr('stroke-opacity', d => {
|
el.attr('fill', d.id === activeNodeId ? ROLE_FILL_ACTIVE : el.attr('data-base-fill'))
|
||||||
if (d.id === activeNodeId) return 1
|
|
||||||
if (connected.has(d.id)) return 0.7
|
|
||||||
return 0.4
|
|
||||||
})
|
})
|
||||||
.attr('stroke-width', d => d.id === activeNodeId ? 2 : 1)
|
.attr('fill-opacity', d => d.id === activeNodeId ? ROLE_FILL_OPACITY_ACTIVE : null)
|
||||||
|
.attr('stroke-opacity', d => {
|
||||||
|
if (d.id === activeNodeId) return ROLE_STROKE_OPACITY_ACTIVE
|
||||||
|
if (connected.has(d.id)) return ROLE_STROKE_OPACITY_CONNECTED
|
||||||
|
return ROLE_STROKE_OPACITY_DEFAULT
|
||||||
|
})
|
||||||
|
.attr('stroke-width', d => d.id === activeNodeId ? ROLE_STROKE_WIDTH_ACTIVE : ROLE_STROKE_WIDTH_DEFAULT)
|
||||||
|
|
||||||
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||||
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
||||||
@@ -128,23 +140,23 @@ export function useConstellationHighlight(deps: {
|
|||||||
if (!isVisible(d.id)) return 0
|
if (!isVisible(d.id)) return 0
|
||||||
return isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d)
|
return isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d)
|
||||||
})
|
})
|
||||||
.attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
|
.attr('fill-opacity', d => isInGroup(d.id) ? SKILL_ACTIVE_OPACITY : SKILL_REST_OPACITY)
|
||||||
.attr('filter', d => isInGroup(d.id) ? `url(#glow-${d.domain ?? 'technical'})` : null)
|
.attr('filter', d => isInGroup(d.id) ? `url(#glow-${d.domain ?? 'technical'})` : null)
|
||||||
.attr('stroke-opacity', d => isInGroup(d.id) ? 0.8 : SKILL_STROKE_OPACITY)
|
.attr('stroke-opacity', d => isInGroup(d.id) ? SKILL_ACTIVE_STROKE_OPACITY : SKILL_STROKE_OPACITY)
|
||||||
skillNodes.select('.node-label')
|
skillNodes.select('.node-label')
|
||||||
.transition().duration(dur)
|
.transition().duration(dur)
|
||||||
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
|
.attr('opacity', d => isInGroup(d.id) ? 1 : LABEL_REST_OPACITY)
|
||||||
} else {
|
} else {
|
||||||
skillNodes.select('.node-circle')
|
skillNodes.select('.node-circle')
|
||||||
.attr('r', d => {
|
.attr('r', d => {
|
||||||
if (!isVisible(d.id)) return 0
|
if (!isVisible(d.id)) return 0
|
||||||
return isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d)
|
return isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d)
|
||||||
})
|
})
|
||||||
.attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
|
.attr('fill-opacity', d => isInGroup(d.id) ? SKILL_ACTIVE_OPACITY : SKILL_REST_OPACITY)
|
||||||
.attr('filter', d => isInGroup(d.id) ? `url(#glow-${d.domain ?? 'technical'})` : null)
|
.attr('filter', d => isInGroup(d.id) ? `url(#glow-${d.domain ?? 'technical'})` : null)
|
||||||
.attr('stroke-opacity', d => isInGroup(d.id) ? 0.8 : SKILL_STROKE_OPACITY)
|
.attr('stroke-opacity', d => isInGroup(d.id) ? SKILL_ACTIVE_STROKE_OPACITY : SKILL_STROKE_OPACITY)
|
||||||
skillNodes.select('.node-label')
|
skillNodes.select('.node-label')
|
||||||
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
|
.attr('opacity', d => isInGroup(d.id) ? 1 : LABEL_REST_OPACITY)
|
||||||
}
|
}
|
||||||
|
|
||||||
linkSelection
|
linkSelection
|
||||||
|
|||||||
+189
-77
@@ -6,12 +6,14 @@ import {
|
|||||||
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
||||||
MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN,
|
MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN,
|
||||||
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
||||||
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
DOMAIN_COLOR_MAP, HIDDEN_ENTITY_IDS, prefersReducedMotion,
|
||||||
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
||||||
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
||||||
LINK_BEZIER_VERTICAL_OFFSET,
|
LINK_BEZIER_VERTICAL_OFFSET,
|
||||||
SKILL_STROKE_WIDTH, SKILL_STROKE_OPACITY, SKILL_SIZE_ROLE_FACTOR,
|
SKILL_STROKE_WIDTH, SKILL_STROKE_OPACITY, SKILL_SIZE_ROLE_FACTOR,
|
||||||
SKILL_GLOW_STD_DEVIATION,
|
SKILL_GLOW_STD_DEVIATION,
|
||||||
|
SKILL_Y_OFFSET_STEP, SKILL_Y_OFFSET_STEP_MOBILE,
|
||||||
|
SKILL_Y_GLOBAL_OFFSET_RATIO, SKILL_X_OVERLAP_MAX_RATIO,
|
||||||
} from '@/components/constellation/constants'
|
} from '@/components/constellation/constants'
|
||||||
import type { SimNode, SimLink, LayoutParams } from '@/components/constellation/types'
|
import type { SimNode, SimLink, LayoutParams } from '@/components/constellation/types'
|
||||||
|
|
||||||
@@ -28,13 +30,24 @@ function isEntityNode(type: string): boolean {
|
|||||||
return type === 'role' || type === 'education'
|
return type === 'role' || type === 'education'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fractionalYear(node: { startDate?: string; startYear?: number }): number {
|
||||||
|
if (node.startDate) {
|
||||||
|
const d = new Date(node.startDate)
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const start = new Date(year, 0, 1).getTime()
|
||||||
|
const end = new Date(year + 1, 0, 1).getTime()
|
||||||
|
return year + (d.getTime() - start) / (end - start)
|
||||||
|
}
|
||||||
|
return node.startYear ?? 2016
|
||||||
|
}
|
||||||
|
|
||||||
function getHeight(width: number, containerHeight?: number | null): number {
|
function getHeight(width: number, containerHeight?: number | null): number {
|
||||||
if (width < 1024) return 520
|
if (width < 768) return 520
|
||||||
if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight)
|
if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight)
|
||||||
return 400
|
return 400
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleNodes = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')
|
const roleNodes = constellationNodes.filter(n => (n.type === 'role' || n.type === 'education') && !HIDDEN_ENTITY_IDS.has(n.id))
|
||||||
|
|
||||||
export function useForceSimulation(
|
export function useForceSimulation(
|
||||||
svgRef: React.RefObject<SVGSVGElement | null>,
|
svgRef: React.RefObject<SVGSVGElement | null>,
|
||||||
@@ -70,9 +83,11 @@ export function useForceSimulation(
|
|||||||
|
|
||||||
svg.selectAll('*').remove()
|
svg.selectAll('*').remove()
|
||||||
|
|
||||||
const years = roleNodes.map(n => n.startYear ?? 2016)
|
const years = roleNodes.map(n => fractionalYear(n))
|
||||||
|
const now = new Date()
|
||||||
|
const currentFractionalYear = now.getFullYear() + now.getMonth() / 12
|
||||||
const minYear = Math.min(...years)
|
const minYear = Math.min(...years)
|
||||||
const maxYear = Math.max(...years)
|
const maxYear = Math.max(...years, currentFractionalYear)
|
||||||
|
|
||||||
const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf)
|
const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf)
|
||||||
const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf)
|
const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf)
|
||||||
@@ -94,9 +109,12 @@ export function useForceSimulation(
|
|||||||
}
|
}
|
||||||
layoutParamsRef.current = layoutParams
|
layoutParamsRef.current = layoutParams
|
||||||
|
|
||||||
const yScale = d3.scaleLinear()
|
// Power scale gives more space to recent (dense) years, compresses older ones
|
||||||
.domain([maxYear, minYear])
|
const yearSpan = maxYear - minYear
|
||||||
|
const rawScale = d3.scalePow().exponent(0.5)
|
||||||
|
.domain([0, yearSpan])
|
||||||
.range([topPadding, height - bottomPadding])
|
.range([topPadding, height - bottomPadding])
|
||||||
|
const yScale = (year: number) => rawScale(maxYear - year)
|
||||||
|
|
||||||
// Background rect
|
// Background rect
|
||||||
svg.append('rect')
|
svg.append('rect')
|
||||||
@@ -149,31 +167,62 @@ export function useForceSimulation(
|
|||||||
.attr('id', `role-grad-${i}`)
|
.attr('id', `role-grad-${i}`)
|
||||||
.attr('x1', '0%').attr('y1', '0%')
|
.attr('x1', '0%').attr('y1', '0%')
|
||||||
.attr('x2', '100%').attr('y2', '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', '0%').attr('stop-color', color).attr('stop-opacity', 0.15)
|
||||||
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0.18)
|
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0.3)
|
||||||
})
|
})
|
||||||
const orgColorGradientMap = new Map(uniqueOrgColors.map((c, i) => [c, `url(#role-grad-${i})`]))
|
const orgColorGradientMap = new Map(uniqueOrgColors.map((c, i) => [c, `url(#role-grad-${i})`]))
|
||||||
|
|
||||||
// Year indicator (for animation)
|
// Date indicator group (for animation) — month + year with clip mask for scroll effect
|
||||||
const yearIndicator = svg.append('text')
|
const dateFontSize = isMobile ? 18 : Math.round(24 * sf)
|
||||||
.attr('class', 'year-indicator')
|
const dateX = width * 0.1
|
||||||
.attr('x', sidePadding + 8)
|
const dateY = topPadding - 4
|
||||||
.attr('y', topPadding - 4)
|
const lineHeight = Math.round(dateFontSize * 1.3)
|
||||||
.attr('font-size', isMobile ? '18' : `${Math.round(24 * sf)}`)
|
const clipId = 'date-indicator-clip'
|
||||||
.attr('font-family', 'var(--font-ui)')
|
|
||||||
.attr('fill', 'var(--text-tertiary)')
|
const dateClip = defs.append('clipPath').attr('id', clipId)
|
||||||
|
dateClip.append('rect')
|
||||||
|
.attr('x', dateX - 4)
|
||||||
|
.attr('y', dateY - dateFontSize - 2)
|
||||||
|
.attr('width', isMobile ? 120 : Math.round(160 * sf))
|
||||||
|
.attr('height', lineHeight + 4)
|
||||||
|
|
||||||
|
const dateGroup = svg.append('g')
|
||||||
|
.attr('class', 'date-indicator')
|
||||||
|
.attr('clip-path', `url(#${clipId})`)
|
||||||
.attr('opacity', 0)
|
.attr('opacity', 0)
|
||||||
yearIndicatorRef.current = yearIndicator as unknown as d3.Selection<SVGTextElement, unknown, null, undefined>
|
|
||||||
|
dateGroup.append('text')
|
||||||
|
.attr('class', 'date-month')
|
||||||
|
.attr('x', dateX)
|
||||||
|
.attr('y', dateY)
|
||||||
|
.attr('font-size', dateFontSize)
|
||||||
|
.attr('font-family', 'var(--font-geist-mono)')
|
||||||
|
.attr('font-weight', 500)
|
||||||
|
.attr('fill', 'var(--text-tertiary)')
|
||||||
|
.attr('letter-spacing', '0.08em')
|
||||||
|
|
||||||
|
dateGroup.append('text')
|
||||||
|
.attr('class', 'date-year')
|
||||||
|
.attr('x', dateX + (isMobile ? 52 : Math.round(68 * sf)))
|
||||||
|
.attr('y', dateY)
|
||||||
|
.attr('font-size', dateFontSize)
|
||||||
|
.attr('font-family', 'var(--font-geist-mono)')
|
||||||
|
.attr('font-weight', 300)
|
||||||
|
.attr('fill', 'var(--text-tertiary)')
|
||||||
|
.attr('opacity', 0.6)
|
||||||
|
|
||||||
|
yearIndicatorRef.current = dateGroup as unknown as d3.Selection<SVGTextElement, unknown, null, undefined>
|
||||||
|
|
||||||
// Timeline guides
|
// Timeline guides
|
||||||
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
|
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
|
||||||
timelineGroupRef.current = timelineGroup as unknown as d3.Selection<SVGGElement, unknown, null, undefined>
|
timelineGroupRef.current = timelineGroup as unknown as d3.Selection<SVGGElement, unknown, null, undefined>
|
||||||
|
|
||||||
const tickYears = d3.range(minYear, maxYear + 1)
|
const tickYears = d3.range(Math.ceil(minYear), Math.floor(maxYear) + 1)
|
||||||
timelineGroup.selectAll('line.year-guide')
|
timelineGroup.selectAll('line.year-guide')
|
||||||
.data(tickYears)
|
.data(tickYears)
|
||||||
.join('line')
|
.join('line')
|
||||||
.attr('class', 'year-guide')
|
.attr('class', 'year-guide')
|
||||||
|
.attr('data-year', d => d)
|
||||||
.attr('x1', sidePadding)
|
.attr('x1', sidePadding)
|
||||||
.attr('x2', width - sidePadding)
|
.attr('x2', width - sidePadding)
|
||||||
.attr('y1', d => yScale(d))
|
.attr('y1', d => yScale(d))
|
||||||
@@ -183,10 +232,16 @@ export function useForceSimulation(
|
|||||||
.attr('stroke-width', 1)
|
.attr('stroke-width', 1)
|
||||||
.attr('stroke-dasharray', '3 4')
|
.attr('stroke-dasharray', '3 4')
|
||||||
|
|
||||||
|
const labelSpace = isMobile ? 26 : Math.round(28 * sf)
|
||||||
|
const axisRightPadding = isMobile ? 16 : Math.round(12 * sf)
|
||||||
|
const axisX = width - axisRightPadding - labelSpace
|
||||||
|
|
||||||
|
const topTickY = tickYears.length > 0 ? yScale(tickYears[0]) : topPadding
|
||||||
timelineGroup.append('line')
|
timelineGroup.append('line')
|
||||||
.attr('x1', timelineX)
|
.attr('class', 'axis-line')
|
||||||
.attr('x2', timelineX)
|
.attr('x1', axisX)
|
||||||
.attr('y1', topPadding - 12)
|
.attr('x2', axisX)
|
||||||
|
.attr('y1', topTickY - 12)
|
||||||
.attr('y2', height - bottomPadding + 12)
|
.attr('y2', height - bottomPadding + 12)
|
||||||
.attr('stroke', 'var(--border)')
|
.attr('stroke', 'var(--border)')
|
||||||
.attr('stroke-width', 1)
|
.attr('stroke-width', 1)
|
||||||
@@ -195,86 +250,124 @@ export function useForceSimulation(
|
|||||||
.data(tickYears)
|
.data(tickYears)
|
||||||
.join('line')
|
.join('line')
|
||||||
.attr('class', 'year-tick')
|
.attr('class', 'year-tick')
|
||||||
.attr('x1', timelineX)
|
.attr('data-year', d => d)
|
||||||
.attr('x2', d => timelineX + (roleNodes.some(r => r.startYear === d) ? 8 : 6))
|
.attr('x1', axisX)
|
||||||
|
.attr('x2', d => axisX - (roleNodes.some(r => r.startYear === d) ? 8 : 6))
|
||||||
.attr('y1', d => yScale(d))
|
.attr('y1', d => yScale(d))
|
||||||
.attr('y2', d => yScale(d))
|
.attr('y2', d => yScale(d))
|
||||||
.attr('stroke', 'var(--border)')
|
.attr('stroke', 'var(--border)')
|
||||||
.attr('stroke-width', 1)
|
.attr('stroke-width', 1)
|
||||||
.attr('stroke-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.8 : 0.4)
|
.attr('stroke-opacity', 1)
|
||||||
|
|
||||||
timelineGroup.selectAll('text.year-label')
|
timelineGroup.selectAll('text.year-label')
|
||||||
.data(tickYears)
|
.data(tickYears)
|
||||||
.join('text')
|
.join('text')
|
||||||
.attr('class', 'year-label')
|
.attr('class', 'year-label')
|
||||||
.attr('x', width - sidePadding)
|
.attr('data-year', d => d)
|
||||||
|
.attr('x', axisX + 8)
|
||||||
.attr('y', d => yScale(d) + Math.round(4 * sf))
|
.attr('y', d => yScale(d) + Math.round(4 * sf))
|
||||||
.attr('text-anchor', 'end')
|
.attr('text-anchor', 'start')
|
||||||
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
||||||
.attr('font-family', 'var(--font-ui)')
|
.attr('font-family', 'var(--font-ui)')
|
||||||
.attr('fill', 'var(--text-tertiary)')
|
.attr('fill', 'var(--text-tertiary)')
|
||||||
|
.attr('opacity', 1)
|
||||||
.text(d => d)
|
.text(d => d)
|
||||||
|
|
||||||
// Prepare data
|
// Prepare data — filter out hidden entities and their exclusive links/skills
|
||||||
const links: SimLink[] = constellationLinks.map(l => ({
|
const visibleLinks = constellationLinks.filter(l => !HIDDEN_ENTITY_IDS.has(l.source))
|
||||||
|
const visibleSkillIds = new Set(visibleLinks.map(l => l.target))
|
||||||
|
const visibleNodeData = constellationNodes.filter(n =>
|
||||||
|
HIDDEN_ENTITY_IDS.has(n.id) ? false : (isEntityNode(n.type) || visibleSkillIds.has(n.id))
|
||||||
|
)
|
||||||
|
const links: SimLink[] = visibleLinks.map(l => ({
|
||||||
source: l.source,
|
source: l.source,
|
||||||
target: l.target,
|
target: l.target,
|
||||||
strength: l.strength,
|
strength: l.strength,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0))
|
const roleOrder = [...roleNodes].sort((a, b) => fractionalYear(a) - fractionalYear(b))
|
||||||
const roleInitialMap = new Map<string, { x: number; y: number }>()
|
const roleInitialMap = new Map<string, { x: number; y: number }>()
|
||||||
const roleGap = isMobile ? 40 : Math.round(56 * sf)
|
const roleGap = isMobile ? 54 : Math.round(54 * sf)
|
||||||
const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2)
|
const roleX = axisX - roleGap - rw / 2
|
||||||
|
|
||||||
roleOrder.forEach((role) => {
|
roleOrder.forEach((role) => {
|
||||||
roleInitialMap.set(role.id, {
|
roleInitialMap.set(role.id, {
|
||||||
x: roleX,
|
x: roleX,
|
||||||
y: yScale(role.startYear ?? minYear),
|
y: yScale(fractionalYear(role)),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const nodes: SimNode[] = constellationNodes.map(n => {
|
// Skills occupy the left ~65% of the chart
|
||||||
|
const skillZoneRight = roleX - rw / 2 - (isMobile ? 16 : Math.round(24 * sf))
|
||||||
|
const skillZoneLeft = sidePadding + srActive
|
||||||
|
const skillZoneWidth = skillZoneRight - skillZoneLeft
|
||||||
|
|
||||||
|
// Pre-compute skill homeY and group by role-set to offset overlaps
|
||||||
|
const skillRoleKey = new Map<string, string>() // skillId -> sorted role key
|
||||||
|
const skillBaseY = new Map<string, number>() // skillId -> base homeY
|
||||||
|
const roleKeyGroups = new Map<string, string[]>() // roleKey -> [skillIds]
|
||||||
|
|
||||||
|
visibleNodeData.filter(n => n.type === 'skill').forEach(n => {
|
||||||
|
const roleIds = visibleLinks.filter(l => l.target === n.id).map(l => l.source)
|
||||||
|
const key = roleIds.slice().sort().join('|')
|
||||||
|
skillRoleKey.set(n.id, key)
|
||||||
|
|
||||||
|
const positions = roleIds
|
||||||
|
.map(roleId => roleInitialMap.get(roleId))
|
||||||
|
.filter(Boolean) as Array<{ x: number; y: number }>
|
||||||
|
const baseY = positions.length > 0
|
||||||
|
? positions.reduce((sum, p) => sum + p.y, 0) / positions.length
|
||||||
|
: height * 0.5
|
||||||
|
skillBaseY.set(n.id, baseY)
|
||||||
|
|
||||||
|
if (!roleKeyGroups.has(key)) roleKeyGroups.set(key, [])
|
||||||
|
roleKeyGroups.get(key)!.push(n.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// For groups with >1 skill sharing the same roles, apply alternating y-offsets
|
||||||
|
// and x-offsets that scale stronger for skills further left in the zone
|
||||||
|
const skillYOffset = new Map<string, number>()
|
||||||
|
const offsetStep = isMobile ? SKILL_Y_OFFSET_STEP_MOBILE : Math.round(SKILL_Y_OFFSET_STEP * sf)
|
||||||
|
roleKeyGroups.forEach(ids => {
|
||||||
|
if (ids.length <= 1) return
|
||||||
|
ids.forEach((id, i) => {
|
||||||
|
const centered = i - (ids.length - 1) / 2
|
||||||
|
skillYOffset.set(id, centered * offsetStep)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodes: SimNode[] = visibleNodeData.map(n => {
|
||||||
if (isEntityNode(n.type)) {
|
if (isEntityNode(n.type)) {
|
||||||
const pos = roleInitialMap.get(n.id)!
|
const pos = roleInitialMap.get(n.id)!
|
||||||
return { ...n, x: pos.x, y: pos.y, vx: 0, vy: 0, homeX: pos.x, homeY: pos.y }
|
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 hash = hashString(n.id)
|
||||||
const domainBaseAngle = n.domain === 'clinical'
|
let homeX = skillZoneLeft + (hash % 1000) / 1000 * skillZoneWidth
|
||||||
? 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
|
// X-offset for overlapping groups: stronger push for skills further left
|
||||||
const seededY = centroid.y + Math.sin(angle) * radius
|
const key = skillRoleKey.get(n.id) ?? ''
|
||||||
|
const group = roleKeyGroups.get(key)
|
||||||
|
if (group && group.length > 1) {
|
||||||
|
const posInZone = (homeX - skillZoneLeft) / skillZoneWidth // 0 (left) to 1 (right)
|
||||||
|
const pushStrength = 1 - (posInZone * 0) // stronger for left-positioned skills
|
||||||
|
const idx = group.indexOf(n.id)
|
||||||
|
const centered = idx - (group.length - 1) / 2
|
||||||
|
const maxXOffset = skillZoneWidth * SKILL_X_OVERLAP_MAX_RATIO
|
||||||
|
homeX += centered * pushStrength * maxXOffset / Math.max(1, (group.length - 1) / 2)
|
||||||
|
homeX = Math.max(skillZoneLeft, Math.min(skillZoneRight, homeX))
|
||||||
|
}
|
||||||
|
|
||||||
return { ...n, x: seededX, y: seededY, vx: 0, vy: 0, homeX: seededX, homeY: seededY }
|
const homeY = (skillBaseY.get(n.id) ?? height * 0.5) + (skillYOffset.get(n.id) ?? 0) - height * SKILL_Y_GLOBAL_OFFSET_RATIO
|
||||||
|
|
||||||
|
return { ...n, x: homeX, y: homeY, vx: 0, vy: 0, homeX, homeY }
|
||||||
})
|
})
|
||||||
|
|
||||||
nodesRef.current = nodes
|
nodesRef.current = nodes
|
||||||
|
|
||||||
// Build connected map
|
// Build connected map
|
||||||
const connectedMap = new Map<string, Set<string>>()
|
const connectedMap = new Map<string, Set<string>>()
|
||||||
constellationLinks.forEach(l => {
|
visibleLinks.forEach(l => {
|
||||||
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
|
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
|
||||||
if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set())
|
if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set())
|
||||||
connectedMap.get(l.source)!.add(l.target)
|
connectedMap.get(l.source)!.add(l.target)
|
||||||
@@ -291,7 +384,7 @@ export function useForceSimulation(
|
|||||||
skillRestRadiiRef.current = skillRestRadii
|
skillRestRadiiRef.current = skillRestRadii
|
||||||
|
|
||||||
// Node-by-id lookup for link domain color resolution
|
// Node-by-id lookup for link domain color resolution
|
||||||
const nodeById = new Map(constellationNodes.map(n => [n.id, n]))
|
const nodeById = new Map(visibleNodeData.map(n => [n.id, n]))
|
||||||
|
|
||||||
// Create SVG groups
|
// Create SVG groups
|
||||||
const linkGroup = svg.append('g').attr('class', 'links')
|
const linkGroup = svg.append('g').attr('class', 'links')
|
||||||
@@ -339,6 +432,16 @@ export function useForceSimulation(
|
|||||||
.attr('stroke', 'transparent')
|
.attr('stroke', 'transparent')
|
||||||
.attr('stroke-width', 2)
|
.attr('stroke-width', 2)
|
||||||
|
|
||||||
|
nodeSelection.filter(entityFilter)
|
||||||
|
.append('rect')
|
||||||
|
.attr('class', 'node-bg')
|
||||||
|
.attr('x', -rw / 2)
|
||||||
|
.attr('y', -rh / 2)
|
||||||
|
.attr('width', rw)
|
||||||
|
.attr('height', rh)
|
||||||
|
.attr('rx', rrx)
|
||||||
|
.attr('fill', 'var(--surface)')
|
||||||
|
|
||||||
nodeSelection.filter(entityFilter)
|
nodeSelection.filter(entityFilter)
|
||||||
.append('rect')
|
.append('rect')
|
||||||
.attr('class', 'node-circle')
|
.attr('class', 'node-circle')
|
||||||
@@ -348,8 +451,9 @@ export function useForceSimulation(
|
|||||||
.attr('height', rh)
|
.attr('height', rh)
|
||||||
.attr('rx', rrx)
|
.attr('rx', rrx)
|
||||||
.attr('fill', d => orgColorGradientMap.get(d.orgColor ?? 'var(--accent)') ?? d.orgColor ?? 'var(--accent)')
|
.attr('fill', d => orgColorGradientMap.get(d.orgColor ?? 'var(--accent)') ?? d.orgColor ?? 'var(--accent)')
|
||||||
|
.attr('data-base-fill', d => orgColorGradientMap.get(d.orgColor ?? 'var(--accent)') ?? d.orgColor ?? 'var(--accent)')
|
||||||
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
|
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
|
||||||
.attr('stroke-opacity', 0.4)
|
.attr('stroke-opacity', 0.8)
|
||||||
.attr('stroke-width', 1)
|
.attr('stroke-width', 1)
|
||||||
.attr('stroke-dasharray', d => d.type === 'education' ? '4 3' : null)
|
.attr('stroke-dasharray', d => d.type === 'education' ? '4 3' : null)
|
||||||
|
|
||||||
@@ -387,20 +491,29 @@ export function useForceSimulation(
|
|||||||
.attr('stroke-width', SKILL_STROKE_WIDTH)
|
.attr('stroke-width', SKILL_STROKE_WIDTH)
|
||||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||||
|
|
||||||
|
const skillFontSize = isMobile ? 9 : Math.round(11 * sf)
|
||||||
|
const skillLineHeight = Math.round(skillFontSize * 1.15)
|
||||||
|
const skillLabelOffset = srActive + Math.round(14 * sf)
|
||||||
|
|
||||||
nodeSelection.filter(d => d.type === 'skill')
|
nodeSelection.filter(d => d.type === 'skill')
|
||||||
.append('text')
|
.append('text')
|
||||||
.attr('class', 'node-label')
|
.attr('class', 'node-label')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dy', srActive + Math.round(14 * sf))
|
|
||||||
.attr('fill', 'var(--text-secondary)')
|
.attr('fill', 'var(--text-secondary)')
|
||||||
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
.attr('font-size', skillFontSize)
|
||||||
.attr('font-family', 'var(--font-geist-mono)')
|
.attr('font-family', 'var(--font-geist-mono)')
|
||||||
.attr('pointer-events', 'none')
|
.attr('pointer-events', 'none')
|
||||||
.attr('opacity', 0.5)
|
.attr('opacity', 0.5)
|
||||||
.text(d => {
|
.each(function (d) {
|
||||||
const label = d.shortLabel ?? d.label
|
const label = d.shortLabel ?? d.label
|
||||||
const maxLen = isMobile ? 12 : width < 500 ? 12 : 16
|
const words = label.split(/\s+/)
|
||||||
return label.length > maxLen ? `${label.slice(0, maxLen - 1)}…` : label
|
const el = d3.select(this)
|
||||||
|
words.forEach((word, i) => {
|
||||||
|
el.append('tspan')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('dy', i === 0 ? skillLabelOffset : skillLineHeight)
|
||||||
|
.text(word)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Entity connectors to timeline
|
// Entity connectors to timeline
|
||||||
@@ -423,15 +536,15 @@ export function useForceSimulation(
|
|||||||
))
|
))
|
||||||
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
||||||
.id(d => d.id)
|
.id(d => d.id)
|
||||||
.distance(isMobile ? 56 : Math.round(72 * sf))
|
.distance(isMobile ? 56 : Math.round(120 * sf))
|
||||||
.strength(d => (d as SimLink).strength * 0.5))
|
.strength(d => (d as SimLink).strength * 0.15))
|
||||||
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.25))
|
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.6))
|
||||||
.force('y', d3.forceY<SimNode>(d => {
|
.force('y', d3.forceY<SimNode>(d => {
|
||||||
if (isEntityNode(d.type)) {
|
if (isEntityNode(d.type)) {
|
||||||
return yScale(d.startYear ?? minYear)
|
return yScale(fractionalYear(d))
|
||||||
}
|
}
|
||||||
return d.homeY
|
return d.homeY
|
||||||
}).strength(d => isEntityNode(d.type) ? 0.98 : 0.18))
|
}).strength(d => isEntityNode(d.type) ? 0.98 : 0.25))
|
||||||
.force('collide', d3.forceCollide<SimNode>(d =>
|
.force('collide', d3.forceCollide<SimNode>(d =>
|
||||||
isEntityNode(d.type) ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
|
isEntityNode(d.type) ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
|
||||||
).iterations(3))
|
).iterations(3))
|
||||||
@@ -439,15 +552,14 @@ export function useForceSimulation(
|
|||||||
simulationRef.current = simulation
|
simulationRef.current = simulation
|
||||||
|
|
||||||
const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
|
const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
|
||||||
const rightMargin = isMobile ? 16 : Math.round(32 * sf)
|
|
||||||
|
|
||||||
const renderTick = () => {
|
const renderTick = () => {
|
||||||
nodes.forEach(d => {
|
nodes.forEach(d => {
|
||||||
if (isEntityNode(d.type)) {
|
if (isEntityNode(d.type)) {
|
||||||
d.x = Math.max(rw / 2 + 6, Math.min(width - rw / 2 - 6, d.x))
|
d.x = Math.max(rw / 2 + 6, Math.min(axisX - roleGap - rw / 2 + rw / 2, d.x))
|
||||||
d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y))
|
d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y))
|
||||||
} else {
|
} else {
|
||||||
d.x = Math.max(srActive + 6, Math.min(width - srActive - rightMargin, d.x))
|
d.x = Math.max(srActive + 6, Math.min(skillZoneRight, d.x))
|
||||||
d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y))
|
d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -465,9 +577,9 @@ export function useForceSimulation(
|
|||||||
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||||
|
|
||||||
roleConnectors
|
roleConnectors
|
||||||
.attr('x1', timelineX)
|
.attr('x1', d => d.x + rw / 2)
|
||||||
.attr('y1', d => d.y)
|
.attr('y1', d => d.y)
|
||||||
.attr('x2', d => d.x - rw / 2)
|
.attr('x2', axisX)
|
||||||
.attr('y2', d => d.y)
|
.attr('y2', d => d.y)
|
||||||
|
|
||||||
const nextNodePositions: Record<string, { x: number; y: number }> = {}
|
const nextNodePositions: Record<string, { x: number; y: number }> = {}
|
||||||
|
|||||||
@@ -15,14 +15,19 @@ import {
|
|||||||
ANIM_RESTART_DELAY_MS,
|
ANIM_RESTART_DELAY_MS,
|
||||||
ANIM_INTERACTION_RESUME_MS,
|
ANIM_INTERACTION_RESUME_MS,
|
||||||
ANIM_SETTLE_ALPHA,
|
ANIM_SETTLE_ALPHA,
|
||||||
|
ANIM_MONTH_STEP_MS,
|
||||||
|
ANIM_CHRONOLOGICAL_ENABLED,
|
||||||
|
HIDDEN_ENTITY_IDS,
|
||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
} from '@/components/constellation/constants'
|
} from '@/components/constellation/constants'
|
||||||
import type { SimNode, SimLink, AnimationState, AnimationStep } from '@/components/constellation/types'
|
import type { SimNode, SimLink, AnimationState, AnimationStep } from '@/components/constellation/types'
|
||||||
|
|
||||||
// Pre-compute animation steps from timeline entities (oldest first)
|
// Pre-compute animation steps from timeline entities (newest first → reverse chronological)
|
||||||
const sortedEntities = [...timelineEntities].sort(
|
const sortedEntities = [...timelineEntities]
|
||||||
(a, b) => a.dateRange.startYear - b.dateRange.startYear
|
.filter(e => !HIDDEN_ENTITY_IDS.has(e.id))
|
||||||
)
|
.sort((a, b) => b.dateRange.startYear - a.dateRange.startYear)
|
||||||
|
|
||||||
|
const MONTH_ABBREVS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
|
||||||
|
|
||||||
function buildAnimationSteps(): AnimationStep[] {
|
function buildAnimationSteps(): AnimationStep[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -34,9 +39,11 @@ function buildAnimationSteps(): AnimationStep[] {
|
|||||||
const linkPairs = constellationLinks
|
const linkPairs = constellationLinks
|
||||||
.filter(l => l.source === entity.id)
|
.filter(l => l.source === entity.id)
|
||||||
.map(l => ({ source: l.source, target: l.target }))
|
.map(l => ({ source: l.source, target: l.target }))
|
||||||
|
const startDate = new Date(entity.dateRange.start)
|
||||||
return {
|
return {
|
||||||
entityId: entity.id,
|
entityId: entity.id,
|
||||||
startYear: entity.dateRange.startYear,
|
startYear: entity.dateRange.startYear,
|
||||||
|
startMonth: startDate.getMonth(),
|
||||||
skillIds,
|
skillIds,
|
||||||
newSkillIds,
|
newSkillIds,
|
||||||
reinforcedSkillIds,
|
reinforcedSkillIds,
|
||||||
@@ -57,6 +64,7 @@ interface UseTimelineAnimationDeps {
|
|||||||
skillRestRadiiRef: React.MutableRefObject<Map<string, number>>
|
skillRestRadiiRef: React.MutableRefObject<Map<string, number>>
|
||||||
srDefault: number
|
srDefault: number
|
||||||
dimensionsTrigger: number
|
dimensionsTrigger: number
|
||||||
|
ready?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||||
@@ -68,7 +76,10 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
const userPausedRef = useRef(false)
|
const userPausedRef = useRef(false)
|
||||||
const interactionPausedRef = useRef(false)
|
const interactionPausedRef = useRef(false)
|
||||||
const resumeTimerRef = useRef(0)
|
const resumeTimerRef = useRef(0)
|
||||||
|
const displayedMonthRef = useRef(-1) // 0-indexed, -1 = not yet shown
|
||||||
|
const displayedYearRef = useRef(0)
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [animationInitialized, setAnimationInitialized] = useState(false)
|
||||||
|
|
||||||
const scheduleTimeout = useCallback((fn: () => void, ms: number) => {
|
const scheduleTimeout = useCallback((fn: () => void, ms: number) => {
|
||||||
const id = window.setTimeout(fn, ms)
|
const id = window.setTimeout(fn, ms)
|
||||||
@@ -76,6 +87,93 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
return id
|
return id
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Scroll the month/year indicator from current position to target, one month at a time
|
||||||
|
const scrollDateIndicator = useCallback((
|
||||||
|
targetMonth: number,
|
||||||
|
targetYear: number,
|
||||||
|
onComplete: () => void,
|
||||||
|
) => {
|
||||||
|
const dateGroup = deps.yearIndicatorRef.current
|
||||||
|
if (!dateGroup) { onComplete(); return }
|
||||||
|
|
||||||
|
const monthText = dateGroup.select('.date-month') as d3.Selection<SVGTextElement, unknown, null, undefined>
|
||||||
|
const yearText = dateGroup.select('.date-year') as d3.Selection<SVGTextElement, unknown, null, undefined>
|
||||||
|
const lineHeight = parseFloat(monthText.attr('font-size') || '24') * 1.3
|
||||||
|
|
||||||
|
// First step: just show immediately if nothing displayed yet
|
||||||
|
if (displayedMonthRef.current === -1) {
|
||||||
|
displayedMonthRef.current = targetMonth
|
||||||
|
displayedYearRef.current = targetYear
|
||||||
|
monthText.text(MONTH_ABBREVS[targetMonth])
|
||||||
|
yearText.text(targetYear)
|
||||||
|
dateGroup.transition().duration(400).attr('opacity', 0.6)
|
||||||
|
onComplete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total months to scroll backwards
|
||||||
|
const fromTotal = displayedYearRef.current * 12 + displayedMonthRef.current
|
||||||
|
const toTotal = targetYear * 12 + targetMonth
|
||||||
|
const monthSteps = fromTotal - toTotal // positive = scrolling back in time
|
||||||
|
if (monthSteps <= 0) {
|
||||||
|
// Same or forward — just snap
|
||||||
|
displayedMonthRef.current = targetMonth
|
||||||
|
displayedYearRef.current = targetYear
|
||||||
|
monthText.text(MONTH_ABBREVS[targetMonth])
|
||||||
|
yearText.text(targetYear)
|
||||||
|
onComplete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentMonth = displayedMonthRef.current
|
||||||
|
let currentYear = displayedYearRef.current
|
||||||
|
let step = 0
|
||||||
|
|
||||||
|
const tickMonth = () => {
|
||||||
|
if (step >= monthSteps) {
|
||||||
|
onComplete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step back one month
|
||||||
|
currentMonth--
|
||||||
|
if (currentMonth < 0) {
|
||||||
|
currentMonth = 11
|
||||||
|
currentYear--
|
||||||
|
// Animate year change with vertical slide
|
||||||
|
yearText
|
||||||
|
.transition().duration(ANIM_MONTH_STEP_MS * 0.4)
|
||||||
|
.attr('dy', lineHeight * 0.4)
|
||||||
|
.attr('opacity', 0)
|
||||||
|
.transition().duration(0)
|
||||||
|
.attr('dy', -lineHeight * 0.4)
|
||||||
|
.text(currentYear)
|
||||||
|
.transition().duration(ANIM_MONTH_STEP_MS * 0.4)
|
||||||
|
.attr('dy', 0)
|
||||||
|
.attr('opacity', 0.6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate month with vertical slide
|
||||||
|
monthText
|
||||||
|
.transition().duration(ANIM_MONTH_STEP_MS * 0.4)
|
||||||
|
.attr('dy', lineHeight * 0.4)
|
||||||
|
.attr('opacity', 0)
|
||||||
|
.transition().duration(0)
|
||||||
|
.attr('dy', -lineHeight * 0.4)
|
||||||
|
.text(MONTH_ABBREVS[currentMonth])
|
||||||
|
.transition().duration(ANIM_MONTH_STEP_MS * 0.4)
|
||||||
|
.attr('dy', 0)
|
||||||
|
.attr('opacity', 1)
|
||||||
|
|
||||||
|
displayedMonthRef.current = currentMonth
|
||||||
|
displayedYearRef.current = currentYear
|
||||||
|
step++
|
||||||
|
scheduleTimeout(tickMonth, ANIM_MONTH_STEP_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
tickMonth()
|
||||||
|
}, [deps.yearIndicatorRef, scheduleTimeout])
|
||||||
|
|
||||||
const cancelAll = useCallback(() => {
|
const cancelAll = useCallback(() => {
|
||||||
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
|
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
|
||||||
rafIdRef.current = 0
|
rafIdRef.current = 0
|
||||||
@@ -99,12 +197,15 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
nodeSel.selectAll('*').interrupt()
|
nodeSel.selectAll('*').interrupt()
|
||||||
connSel?.interrupt()
|
connSel?.interrupt()
|
||||||
tlGroup?.interrupt()
|
tlGroup?.interrupt()
|
||||||
|
yearInd?.interrupt()
|
||||||
|
yearInd?.selectAll('*').interrupt()
|
||||||
|
|
||||||
nodeSel.style('opacity', '0')
|
nodeSel.style('opacity', '0')
|
||||||
linkSel.attr('opacity', 0)
|
linkSel.attr('stroke-opacity', 0)
|
||||||
connSel?.attr('opacity', 0)
|
connSel?.attr('opacity', 0)
|
||||||
tlGroup?.attr('opacity', 0)
|
|
||||||
yearInd?.attr('opacity', 0)
|
yearInd?.attr('opacity', 0)
|
||||||
|
displayedMonthRef.current = -1
|
||||||
|
displayedYearRef.current = 0
|
||||||
|
|
||||||
// Reset skill radii to 0
|
// Reset skill radii to 0
|
||||||
nodeSel.filter((d: SimNode) => d.type === 'skill')
|
nodeSel.filter((d: SimNode) => d.type === 'skill')
|
||||||
@@ -112,6 +213,23 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
.attr('r', 0)
|
.attr('r', 0)
|
||||||
|
|
||||||
visibleNodeIdsRef.current = new Set()
|
visibleNodeIdsRef.current = new Set()
|
||||||
|
|
||||||
|
// Show full axis immediately — axis stays visible throughout animation
|
||||||
|
if (tlGroup) {
|
||||||
|
tlGroup.attr('opacity', 1)
|
||||||
|
let minTickY = Infinity
|
||||||
|
tlGroup.selectAll<SVGLineElement, number>('line.year-tick').each(function () {
|
||||||
|
const y = parseFloat(d3.select(this).attr('y1'))
|
||||||
|
if (y < minTickY) minTickY = y
|
||||||
|
})
|
||||||
|
if (minTickY < Infinity) {
|
||||||
|
tlGroup.select('.axis-line').attr('y1', minTickY - 12)
|
||||||
|
}
|
||||||
|
tlGroup.selectAll('line.year-tick').attr('stroke-opacity', 0.8)
|
||||||
|
tlGroup.selectAll('text.year-label').attr('opacity', 1)
|
||||||
|
tlGroup.selectAll('line.year-guide').attr('stroke-opacity', 0.25)
|
||||||
|
}
|
||||||
|
setAnimationInitialized(true)
|
||||||
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.timelineGroupRef, deps.yearIndicatorRef])
|
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.timelineGroupRef, deps.yearIndicatorRef])
|
||||||
|
|
||||||
const showFinalState = useCallback(() => {
|
const showFinalState = useCallback(() => {
|
||||||
@@ -128,38 +246,43 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
})
|
})
|
||||||
visibleNodeIdsRef.current = allIds
|
visibleNodeIdsRef.current = allIds
|
||||||
|
|
||||||
nodeSel.style('opacity', '1')
|
nodeSel.style('opacity', (d: SimNode) => allIds.has(d.id) ? '1' : '0')
|
||||||
linkSel.attr('opacity', null)
|
linkSel.attr('stroke-opacity', null)
|
||||||
connSel?.attr('opacity', null)
|
connSel?.attr('opacity', (d: SimNode) => allIds.has(d.id) ? null : 0)
|
||||||
tlGroup?.attr('opacity', 1)
|
tlGroup?.attr('opacity', 1)
|
||||||
|
|
||||||
|
setAnimationInitialized(true)
|
||||||
|
|
||||||
|
// Show full axis
|
||||||
|
if (tlGroup) {
|
||||||
|
// Find the topmost tick y to set axis line extent
|
||||||
|
let minTickY = Infinity
|
||||||
|
tlGroup.selectAll<SVGLineElement, number>('line.year-tick').each(function () {
|
||||||
|
const y = parseFloat(d3.select(this).attr('y1'))
|
||||||
|
if (y < minTickY) minTickY = y
|
||||||
|
})
|
||||||
|
if (minTickY < Infinity) {
|
||||||
|
tlGroup.select('.axis-line').attr('y1', minTickY - 12)
|
||||||
|
}
|
||||||
|
tlGroup.selectAll('line.year-tick').attr('stroke-opacity', 0.8)
|
||||||
|
tlGroup.selectAll('text.year-label').attr('opacity', 1)
|
||||||
|
tlGroup.selectAll('line.year-guide').attr('stroke-opacity', 0.25)
|
||||||
|
}
|
||||||
|
|
||||||
nodeSel.filter((d: SimNode) => d.type === 'skill')
|
nodeSel.filter((d: SimNode) => d.type === 'skill')
|
||||||
.select('.node-circle')
|
.select('.node-circle')
|
||||||
.attr('r', (d: SimNode) => deps.skillRestRadiiRef.current.get(d.id) ?? deps.srDefault)
|
.attr('r', (d: SimNode) => deps.skillRestRadiiRef.current.get(d.id) ?? deps.srDefault)
|
||||||
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.timelineGroupRef, deps.skillRestRadiiRef, deps.srDefault])
|
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.timelineGroupRef, deps.skillRestRadiiRef, deps.srDefault])
|
||||||
|
|
||||||
const revealStep = useCallback((stepIdx: number, onComplete: () => void) => {
|
const revealEntityAndSkills = useCallback((stepIdx: number, onComplete: () => void) => {
|
||||||
const nodeSel = deps.nodeSelectionRef.current
|
const nodeSel = deps.nodeSelectionRef.current
|
||||||
const linkSel = deps.linkSelectionRef.current
|
const linkSel = deps.linkSelectionRef.current
|
||||||
const connSel = deps.connectorSelectionRef.current
|
const connSel = deps.connectorSelectionRef.current
|
||||||
const yearInd = deps.yearIndicatorRef.current
|
|
||||||
const tlGroup = deps.timelineGroupRef.current
|
|
||||||
if (!nodeSel || !linkSel) return
|
if (!nodeSel || !linkSel) return
|
||||||
|
|
||||||
const step = animationSteps[stepIdx]
|
const step = animationSteps[stepIdx]
|
||||||
if (!step) { onComplete(); return }
|
if (!step) { onComplete(); return }
|
||||||
|
|
||||||
// Show timeline guides on first step
|
|
||||||
if (stepIdx === 0 && tlGroup) {
|
|
||||||
tlGroup.transition().duration(200).attr('opacity', 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update year indicator
|
|
||||||
if (yearInd) {
|
|
||||||
yearInd.text(step.startYear)
|
|
||||||
.transition().duration(200).attr('opacity', 0.6)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reveal entity node
|
// Reveal entity node
|
||||||
const entityGroup = nodeSel.filter((d: SimNode) => d.id === step.entityId)
|
const entityGroup = nodeSel.filter((d: SimNode) => d.id === step.entityId)
|
||||||
entityGroup
|
entityGroup
|
||||||
@@ -237,7 +360,7 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
const el = d3.select(this)
|
const el = d3.select(this)
|
||||||
const pathEl = this as SVGPathElement
|
const pathEl = this as SVGPathElement
|
||||||
const length = pathEl.getTotalLength()
|
const length = pathEl.getTotalLength()
|
||||||
el.attr('opacity', 1)
|
el.attr('stroke-opacity', 1)
|
||||||
.attr('stroke-dasharray', `${length} ${length}`)
|
.attr('stroke-dasharray', `${length} ${length}`)
|
||||||
.attr('stroke-dashoffset', length)
|
.attr('stroke-dashoffset', length)
|
||||||
.transition()
|
.transition()
|
||||||
@@ -258,7 +381,16 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
const totalStepMs = Math.max(ANIM_ENTITY_REVEAL_MS, skillDuration, linkDuration)
|
const totalStepMs = Math.max(ANIM_ENTITY_REVEAL_MS, skillDuration, linkDuration)
|
||||||
|
|
||||||
scheduleTimeout(onComplete, totalStepMs + ANIM_STEP_GAP_MS)
|
scheduleTimeout(onComplete, totalStepMs + ANIM_STEP_GAP_MS)
|
||||||
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, deps.timelineGroupRef, deps.skillRestRadiiRef, deps.srDefault, scheduleTimeout])
|
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.skillRestRadiiRef, deps.srDefault, scheduleTimeout])
|
||||||
|
|
||||||
|
const revealStep = useCallback((stepIdx: number, onComplete: () => void) => {
|
||||||
|
const step = animationSteps[stepIdx]
|
||||||
|
if (!step) { onComplete(); return }
|
||||||
|
|
||||||
|
// Run date scroll and entity/skills reveal concurrently
|
||||||
|
scrollDateIndicator(step.startMonth, step.startYear, () => {})
|
||||||
|
revealEntityAndSkills(stepIdx, onComplete)
|
||||||
|
}, [scrollDateIndicator, revealEntityAndSkills])
|
||||||
|
|
||||||
const runAnimation = useCallback(() => {
|
const runAnimation = useCallback(() => {
|
||||||
if (prefersReducedMotion) return
|
if (prefersReducedMotion) return
|
||||||
@@ -274,18 +406,16 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
if (userPausedRef.current || interactionPausedRef.current) return
|
if (userPausedRef.current || interactionPausedRef.current) return
|
||||||
animationStateRef.current = 'RESETTING'
|
animationStateRef.current = 'RESETTING'
|
||||||
|
|
||||||
// Fade year indicator
|
// Fade date indicator
|
||||||
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||||
|
|
||||||
// Fade all
|
// Fade all
|
||||||
deps.nodeSelectionRef.current
|
deps.nodeSelectionRef.current
|
||||||
?.transition().duration(ANIM_RESET_MS).style('opacity', '0')
|
?.transition().duration(ANIM_RESET_MS).style('opacity', '0')
|
||||||
deps.linkSelectionRef.current
|
deps.linkSelectionRef.current
|
||||||
?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
?.transition().duration(ANIM_RESET_MS).attr('stroke-opacity', 0)
|
||||||
deps.connectorSelectionRef.current
|
deps.connectorSelectionRef.current
|
||||||
?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||||
deps.timelineGroupRef.current
|
|
||||||
?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
||||||
|
|
||||||
scheduleTimeout(() => {
|
scheduleTimeout(() => {
|
||||||
if (userPausedRef.current) return
|
if (userPausedRef.current) return
|
||||||
@@ -296,6 +426,8 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
.attr('r', 0)
|
.attr('r', 0)
|
||||||
|
|
||||||
visibleNodeIdsRef.current = new Set()
|
visibleNodeIdsRef.current = new Set()
|
||||||
|
displayedMonthRef.current = -1
|
||||||
|
displayedYearRef.current = 0
|
||||||
currentStepRef.current = 0
|
currentStepRef.current = 0
|
||||||
animationStateRef.current = 'PLAYING'
|
animationStateRef.current = 'PLAYING'
|
||||||
setIsPlaying(true)
|
setIsPlaying(true)
|
||||||
@@ -330,7 +462,7 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rafIdRef.current = requestAnimationFrame(waitForSettle)
|
rafIdRef.current = requestAnimationFrame(waitForSettle)
|
||||||
}, [deps.simulationRef, deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, deps.timelineGroupRef, hideAll, revealStep, scheduleTimeout])
|
}, [deps.simulationRef, deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, hideAll, revealStep, scheduleTimeout])
|
||||||
|
|
||||||
const togglePlayPause = useCallback(() => {
|
const togglePlayPause = useCallback(() => {
|
||||||
if (prefersReducedMotion) return
|
if (prefersReducedMotion) return
|
||||||
@@ -393,9 +525,8 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
animationStateRef.current = 'RESETTING'
|
animationStateRef.current = 'RESETTING'
|
||||||
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||||
deps.nodeSelectionRef.current?.transition().duration(ANIM_RESET_MS).style('opacity', '0')
|
deps.nodeSelectionRef.current?.transition().duration(ANIM_RESET_MS).style('opacity', '0')
|
||||||
deps.linkSelectionRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
deps.linkSelectionRef.current?.transition().duration(ANIM_RESET_MS).attr('stroke-opacity', 0)
|
||||||
deps.connectorSelectionRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
deps.connectorSelectionRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||||
deps.timelineGroupRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
|
||||||
scheduleTimeout(() => {
|
scheduleTimeout(() => {
|
||||||
if (userPausedRef.current) return
|
if (userPausedRef.current) return
|
||||||
deps.nodeSelectionRef.current
|
deps.nodeSelectionRef.current
|
||||||
@@ -403,6 +534,8 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
.select('.node-circle')
|
.select('.node-circle')
|
||||||
.attr('r', 0)
|
.attr('r', 0)
|
||||||
visibleNodeIdsRef.current = new Set()
|
visibleNodeIdsRef.current = new Set()
|
||||||
|
displayedMonthRef.current = -1
|
||||||
|
displayedYearRef.current = 0
|
||||||
currentStepRef.current = 0
|
currentStepRef.current = 0
|
||||||
animationStateRef.current = 'PLAYING'
|
animationStateRef.current = 'PLAYING'
|
||||||
setIsPlaying(true)
|
setIsPlaying(true)
|
||||||
@@ -419,11 +552,13 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
|
|
||||||
advanceFromCurrent()
|
advanceFromCurrent()
|
||||||
}, ANIM_INTERACTION_RESUME_MS)
|
}, ANIM_INTERACTION_RESUME_MS)
|
||||||
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, deps.timelineGroupRef, revealStep, scheduleTimeout])
|
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, revealStep, scheduleTimeout])
|
||||||
|
|
||||||
// Start animation on mount / dimension change
|
// Start animation on mount / dimension change — wait for ready signal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prefersReducedMotion) {
|
if (!deps.ready) return
|
||||||
|
|
||||||
|
if (prefersReducedMotion || !ANIM_CHRONOLOGICAL_ENABLED) {
|
||||||
// Show final state immediately after a tick to let simulation refs populate
|
// Show final state immediately after a tick to let simulation refs populate
|
||||||
const id = requestAnimationFrame(() => {
|
const id = requestAnimationFrame(() => {
|
||||||
showFinalState()
|
showFinalState()
|
||||||
@@ -444,12 +579,13 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
|||||||
cancelAll()
|
cancelAll()
|
||||||
animationStateRef.current = 'IDLE'
|
animationStateRef.current = 'IDLE'
|
||||||
}
|
}
|
||||||
}, [deps.dimensionsTrigger, cancelAll, runAnimation, showFinalState])
|
}, [deps.dimensionsTrigger, deps.ready, cancelAll, runAnimation, showFinalState])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
animationStateRef,
|
animationStateRef,
|
||||||
visibleNodeIdsRef,
|
visibleNodeIdsRef,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
animationInitialized,
|
||||||
togglePlayPause,
|
togglePlayPause,
|
||||||
pauseForInteraction,
|
pauseForInteraction,
|
||||||
resumeAfterInteraction,
|
resumeAfterInteraction,
|
||||||
|
|||||||
+7
-9
@@ -351,6 +351,10 @@ html {
|
|||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-grid > :first-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: maintain 2 columns with generous gap */
|
/* Desktop: maintain 2 columns with generous gap */
|
||||||
@@ -405,16 +409,10 @@ html {
|
|||||||
|
|
||||||
.timeline-intervention-item {
|
.timeline-intervention-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-intervention-item--education {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-intervention-item--education > div {
|
|
||||||
width: min(100%, 94%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-intervention-pill {
|
.timeline-intervention-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -441,7 +439,7 @@ html {
|
|||||||
/* Tablet+: 2 columns */
|
/* Tablet+: 2 columns */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.pathway-columns {
|
.pathway-columns {
|
||||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
grid-template-columns: minmax(0, 2fr) minmax(0, 3.5fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 22px;
|
gap: 22px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export interface ConstellationNode {
|
|||||||
shortLabel?: string // abbreviated for small nodes
|
shortLabel?: string // abbreviated for small nodes
|
||||||
organization?: string
|
organization?: string
|
||||||
startYear?: number
|
startYear?: number
|
||||||
|
startDate?: string // ISO date for fractional year positioning
|
||||||
endYear?: number | null
|
endYear?: number | null
|
||||||
orgColor?: string
|
orgColor?: string
|
||||||
domain?: 'clinical' | 'technical' | 'leadership'
|
domain?: 'clinical' | 'technical' | 'leadership'
|
||||||
|
|||||||
Reference in New Issue
Block a user