Rehaul of graph component

This commit is contained in:
2026-02-16 23:16:46 +00:00
parent e9a7581aa5
commit 8178d03cb2
19 changed files with 586 additions and 254 deletions
+9 -11
View File
@@ -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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

+1 -1
View File
@@ -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(() => {
+2 -2
View File
@@ -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,
+8 -42
View File
@@ -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>
+4 -3
View File
@@ -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
+4 -5
View File
@@ -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,15 +113,13 @@ 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',
fontWeight: 600, fontWeight: 600,
@@ -131,7 +129,8 @@ function TimelineInterventionItem({
> >
{entity.title} {entity.title}
</div> </div>
<div </div>
<div
style={{ style={{
fontSize: '12px', fontSize: '12px',
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
@@ -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>
)} )}
+41 -16
View File
@@ -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
+1
View File
@@ -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[]
+1
View File
@@ -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,
})) }))
+35 -23
View File
@@ -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
View File
@@ -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 }> = {}
+172 -36
View File
@@ -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
View File
@@ -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;
} }
+1
View File
@@ -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'