Rehaul of graph component
This commit is contained in:
+9
-11
@@ -1,11 +1,11 @@
|
||||
# Session Handoff
|
||||
|
||||
_Generated: 2026-02-16 14:36:25 UTC_
|
||||
_Generated: 2026-02-16 15:06:20 UTC_
|
||||
|
||||
## Git Context
|
||||
|
||||
- **Branch:** `master`
|
||||
- **HEAD:** aca5771: chore: auto-commit before merge (loop primary)
|
||||
- **HEAD:** e9a7581: chore: auto-commit before merge (loop primary)
|
||||
|
||||
## Tasks
|
||||
|
||||
@@ -70,18 +70,16 @@ Session completed successfully. No pending work.
|
||||
**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
|
||||
|
||||
### Phase 1 — Refactor the Monolith
|
||||
|
||||
Decompose `src/components/CareerConstellation.tsx` (1102 lines) into focused modules:
|
||||
### 1. Reduce link opacity (`src/components/constellation/constants.ts`)
|
||||
- Lower `LINK_BASE_OPACITY` from `0.08` → `0.04`
|
||||
- Lower `LINK_STRENGTH_OPACITY_FACTOR` from `0.12` → `0.06`
|
||||
- Makes skill connection lines subtler so job pills are visually clearer
|
||||
|
||||
```
|
||||
src/components/constellation/
|
||||
CareerConstellation.tsx -- Orchestrator (< 300 lines)
|
||||
MobileAccordion.tsx -- Mobile tap-to-e...
|
||||
### 2. White backgro...
|
||||
```
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
+1
-1
@@ -45,7 +45,7 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [phase, setPhase] = useState<Phase>('login')
|
||||
const [phase, setPhase] = useState<Phase>('boot')
|
||||
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -69,7 +69,7 @@ const COLORS = {
|
||||
}
|
||||
|
||||
const BOOT_CONFIG: BootConfig = {
|
||||
header: 'CLINICAL TERMINAL v3.2.1',
|
||||
header: 'CV Management Information System v1.0.0',
|
||||
lines: [
|
||||
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
|
||||
{ type: 'separator', text: '---', style: 'dim' },
|
||||
@@ -88,7 +88,7 @@ const BOOT_CONFIG: BootConfig = {
|
||||
timing: {
|
||||
lineDelay: 220,
|
||||
cursorBlinkInterval: 300,
|
||||
holdAfterComplete: 900,
|
||||
holdAfterComplete: 1000,
|
||||
fadeOutDuration: 600,
|
||||
cursorShrinkDuration: 600,
|
||||
ecgStartDelay: 0,
|
||||
|
||||
@@ -128,7 +128,7 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '20px',
|
||||
marginBottom: '14px',
|
||||
marginBottom: '1=px',
|
||||
paddingBottom: '14px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
cursor: 'pointer',
|
||||
@@ -182,7 +182,7 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '7px',
|
||||
marginBottom: '16px',
|
||||
marginBottom: '0px',
|
||||
}}
|
||||
>
|
||||
{consultation.examination.map((bullet, index) => (
|
||||
@@ -250,7 +250,7 @@ export function DashboardLayout() {
|
||||
const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null)
|
||||
const [highlightedRoleId, setHighlightedRoleId] = useState<string | 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 patientSummaryRef = useRef<HTMLDivElement>(null)
|
||||
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(() => {
|
||||
const el = patientSummaryRef.current
|
||||
if (!el) return
|
||||
const mq = window.matchMedia('(min-width: 1024px)')
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (mq.matches) {
|
||||
setSidebarForceCollapsed(!entry.isIntersecting)
|
||||
}
|
||||
if (!entry.isIntersecting) setConstellationReady(true)
|
||||
},
|
||||
{ threshold: 0 },
|
||||
)
|
||||
observer.observe(el)
|
||||
const handleResize = () => {
|
||||
if (!mq.matches) setSidebarForceCollapsed(false)
|
||||
}
|
||||
mq.addEventListener('change', handleResize)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
mq.removeEventListener('change', handleResize)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Measure the chronology stream height so the constellation graph can match it
|
||||
@@ -436,7 +426,6 @@ export function DashboardLayout() {
|
||||
activeSection={activeSection}
|
||||
onNavigate={scrollToSection}
|
||||
onSearchClick={handleSearchClick}
|
||||
forceCollapsed={sidebarForceCollapsed}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -465,31 +454,7 @@ export function DashboardLayout() {
|
||||
<ParentSection title="Patient Pathway" tileId="patient-pathway">
|
||||
<div className="pathway-columns">
|
||||
<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">
|
||||
<LastConsultationSubsection highlightedRoleId={highlightedRoleId} />
|
||||
@@ -506,6 +471,7 @@ export function DashboardLayout() {
|
||||
onNodeHover={handleNodeHover}
|
||||
highlightedNodeId={highlightedNodeId}
|
||||
containerHeight={chronologyHeight}
|
||||
animationReady={constellationReady}
|
||||
/>
|
||||
</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 FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out
|
||||
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)
|
||||
@@ -344,7 +345,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
||||
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
|
||||
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
|
||||
const textEndWX = textStartWX + totalTextW
|
||||
const textEndWX = SKIP_TEXT ? textStartWX : textStartWX + totalTextW
|
||||
const textLayout = layoutText(
|
||||
textStartWX, LETTER_W, LETTER_G, SPACE_W,
|
||||
baselineY, 0, Infinity
|
||||
@@ -354,7 +355,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED
|
||||
const holdEndTime = textEndTime
|
||||
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 bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
|
||||
const exitEndTime = bgTransitionEndTime
|
||||
@@ -500,7 +501,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
const isTextPhase = headWX > textStartWX
|
||||
const isTextDone = elapsed >= textEndTime
|
||||
|
||||
if (isTextPhase) {
|
||||
if (isTextPhase && !SKIP_TEXT) {
|
||||
ctx.save()
|
||||
|
||||
// Clip for progressive reveal
|
||||
|
||||
@@ -23,7 +23,6 @@ interface SidebarProps {
|
||||
activeSection: string
|
||||
onNavigate: (tileId: string) => void
|
||||
onSearchClick: () => void
|
||||
forceCollapsed?: boolean
|
||||
}
|
||||
|
||||
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 [isMobileExpanded, setIsMobileExpanded] = useState(false)
|
||||
|
||||
@@ -185,7 +184,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick, forc
|
||||
return () => mediaQuery.removeEventListener('change', listener)
|
||||
}, [])
|
||||
|
||||
const isExpanded = (isDesktop && !forceCollapsed) || isMobileExpanded
|
||||
const isExpanded = isDesktop || isMobileExpanded
|
||||
|
||||
const handleNavActivate = (tileId: string) => {
|
||||
onNavigate(tileId)
|
||||
@@ -196,7 +195,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick, forc
|
||||
|
||||
return (
|
||||
<>
|
||||
{(!isDesktop || forceCollapsed) && isMobileExpanded && (
|
||||
{!isDesktop && isMobileExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close sidebar navigation"
|
||||
@@ -235,7 +234,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick, forc
|
||||
}}
|
||||
className={isExpanded ? 'pmr-scrollbar' : undefined}
|
||||
>
|
||||
{(!isDesktop || forceCollapsed) && (
|
||||
{!isDesktop && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? 'Collapse sidebar navigation' : 'Expand sidebar navigation'}
|
||||
|
||||
@@ -34,7 +34,7 @@ function TimelineInterventionItem({
|
||||
onHighlight,
|
||||
}: TimelineInterventionItemProps) {
|
||||
const isEducation = entity.kind === 'education'
|
||||
const interventionLabel = isEducation ? 'Education Intervention' : 'Career Intervention'
|
||||
const interventionLabel = isEducation ? 'Education' : 'Employment'
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -76,9 +76,9 @@ function TimelineInterventionItem({
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
padding: '12px 14px',
|
||||
padding: '8px 8px',
|
||||
cursor: 'pointer',
|
||||
minHeight: '44px',
|
||||
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
@@ -113,15 +113,13 @@ function TimelineInterventionItem({
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginBottom: '6px',
|
||||
|
||||
}}
|
||||
>
|
||||
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
|
||||
{interventionLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
@@ -131,7 +129,8 @@ function TimelineInterventionItem({
|
||||
>
|
||||
{entity.title}
|
||||
</div>
|
||||
<div
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
@@ -139,17 +138,23 @@ function TimelineInterventionItem({
|
||||
}}
|
||||
>
|
||||
{entity.organization}
|
||||
</div>
|
||||
<div
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
paddingLeft: '6px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '3px',
|
||||
}}
|
||||
>
|
||||
{entity.dateRange.display}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<ChevronRight
|
||||
@@ -160,6 +165,7 @@ function TimelineInterventionItem({
|
||||
marginTop: '2px',
|
||||
transform: isExpanded ? 'rotate(90deg)' : 'none',
|
||||
transition: 'transform 0.15s ease-out',
|
||||
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ interface CareerConstellationProps {
|
||||
onNodeHover?: (id: string | null) => void
|
||||
highlightedNodeId?: string | null
|
||||
containerHeight?: number | null
|
||||
animationReady?: boolean
|
||||
}
|
||||
|
||||
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
|
||||
@@ -35,6 +36,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
onNodeHover,
|
||||
highlightedNodeId,
|
||||
containerHeight,
|
||||
animationReady = false,
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -66,6 +68,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const CHANGE_THRESHOLD = 0.3
|
||||
|
||||
const updateDimensions = () => {
|
||||
const width = container.clientWidth
|
||||
const viewportWidth = window.innerWidth
|
||||
@@ -73,13 +78,28 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
const scaleFactor = viewportWidth >= 1024
|
||||
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
||||
: 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()
|
||||
const observer = new ResizeObserver(updateDimensions)
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(updateDimensions, 2000)
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
}
|
||||
}, [containerHeight])
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
||||
@@ -157,6 +177,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
skillRestRadiiRef,
|
||||
srDefault,
|
||||
dimensionsTrigger: dimensions.width + dimensions.height,
|
||||
ready: animationReady,
|
||||
})
|
||||
|
||||
// Sync visibleNodeIdsRef from animation hook
|
||||
@@ -231,12 +252,15 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
role="img"
|
||||
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
||||
style={{ display: 'block' }}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: dimensions.height,
|
||||
opacity: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
||||
@@ -249,6 +273,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
onToggle={animation.togglePlayPause}
|
||||
isMobile={isMobile}
|
||||
visible={chartInView}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouc
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '8px 12px',
|
||||
pointerEvents: 'none',
|
||||
|
||||
@@ -1,25 +1,72 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface PlayPauseButtonProps {
|
||||
isPlaying: boolean
|
||||
onToggle: () => void
|
||||
isMobile: boolean
|
||||
visible?: boolean
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({ isPlaying, onToggle, isMobile, visible = true }) => {
|
||||
const size = isMobile ? 44 : 36
|
||||
const offset = isMobile ? 8 : 12
|
||||
export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({
|
||||
isPlaying, onToggle, isMobile, visible = true, containerRef,
|
||||
}) => {
|
||||
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 (
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={onToggle}
|
||||
aria-label={isPlaying ? 'Pause animation' : 'Play animation'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: offset,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
top: topPos,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
@@ -30,20 +77,23 @@ export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({ isPlaying, onT
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: visible ? 0.85 : 0,
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
transition: 'opacity 150ms ease',
|
||||
opacity: showButton ? 0.85 : 0,
|
||||
pointerEvents: showButton ? 'auto' : 'none',
|
||||
transition: scrolling
|
||||
? 'opacity 150ms ease, top 80ms linear'
|
||||
: 'opacity 500ms ease, top 80ms linear',
|
||||
zIndex: 5,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
|
||||
onMouseLeave={e => { if (visible) e.currentTarget.style.opacity = '0.85' }}
|
||||
onMouseEnter={e => { if (showButton) e.currentTarget.style.opacity = '1' }}
|
||||
onMouseLeave={e => { if (showButton) e.currentTarget.style.opacity = '0.85' }}
|
||||
>
|
||||
{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="8" y="1" width="4" height="12" rx="1" />
|
||||
</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" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
@@ -13,25 +13,40 @@ export const MOBILE_LABEL_MAX_LEN = 10
|
||||
|
||||
// Animation / opacity
|
||||
export const HIGHLIGHT_DIM_OPACITY = 0.15
|
||||
export const SKILL_REST_OPACITY = 0.35
|
||||
export const SKILL_REST_OPACITY = 0.6
|
||||
export const SKILL_ACTIVE_OPACITY = 0.9
|
||||
export const LABEL_REST_OPACITY = 0.5
|
||||
export const LABEL_REST_OPACITY = 0.6
|
||||
|
||||
// Link visual params
|
||||
export const LINK_BASE_WIDTH = 0.5
|
||||
export const LINK_STRENGTH_WIDTH_FACTOR = 1.5
|
||||
export const LINK_BASE_OPACITY = 0.04
|
||||
export const LINK_STRENGTH_OPACITY_FACTOR = 0.06
|
||||
export const LINK_BASE_WIDTH = 0.7
|
||||
export const LINK_STRENGTH_WIDTH_FACTOR = 0
|
||||
export const LINK_BASE_OPACITY = 0
|
||||
export const LINK_STRENGTH_OPACITY_FACTOR = 0
|
||||
export const LINK_HIGHLIGHT_BASE_WIDTH = 1
|
||||
export const LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR = 2
|
||||
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
|
||||
export const SKILL_STROKE_WIDTH = 1
|
||||
export const SKILL_STROKE_OPACITY = 0.4
|
||||
export const SKILL_SIZE_ROLE_FACTOR = 0.8
|
||||
export const SKILL_GLOW_STD_DEVIATION = 2.5
|
||||
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
|
||||
export const ENTRY_GUIDE_FADE_MS = 200
|
||||
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
|
||||
|
||||
// Timeline animation
|
||||
export const ANIM_ENTITY_REVEAL_MS = 600
|
||||
export const ANIM_SKILL_REVEAL_MS = 350
|
||||
export const ANIM_SKILL_STAGGER_MS = 60
|
||||
export const ANIM_LINK_DRAW_MS = 300
|
||||
export const ANIM_LINK_STAGGER_MS = 40
|
||||
export const ANIM_REINFORCEMENT_MS = 350
|
||||
export const ANIM_STEP_GAP_MS = 400
|
||||
export const ANIM_HOLD_MS = 3000
|
||||
export const ANIM_RESET_MS = 400
|
||||
export const ANIM_RESTART_DELAY_MS = 200
|
||||
export const ANIM_CHRONOLOGICAL_ENABLED = true
|
||||
export const ANIM_ENTITY_REVEAL_MS = 2000
|
||||
export const ANIM_SKILL_REVEAL_MS = 2000
|
||||
export const ANIM_SKILL_STAGGER_MS = 200
|
||||
export const ANIM_LINK_DRAW_MS = 600
|
||||
export const ANIM_LINK_STAGGER_MS = 200
|
||||
export const ANIM_REINFORCEMENT_MS = 700
|
||||
export const ANIM_STEP_GAP_MS = 1000
|
||||
export const ANIM_HOLD_MS = 15000
|
||||
export const ANIM_RESET_MS = 800
|
||||
export const ANIM_RESTART_DELAY_MS = 400
|
||||
export const ANIM_INTERACTION_RESUME_MS = 800
|
||||
export const ANIM_SETTLE_ALPHA = 0.05
|
||||
export const ANIM_MONTH_STEP_MS = 80
|
||||
|
||||
// Domain color map
|
||||
export const DOMAIN_COLOR_MAP: Record<string, string> = {
|
||||
@@ -60,6 +77,14 @@ export const DOMAIN_COLOR_MAP: Record<string, string> = {
|
||||
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)
|
||||
export const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
export const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
|
||||
|
||||
@@ -45,6 +45,7 @@ export type AnimationState = 'IDLE' | 'PLAYING' | 'PAUSED' | 'HOLDING' | 'RESETT
|
||||
export interface AnimationStep {
|
||||
entityId: string
|
||||
startYear: number
|
||||
startMonth: number // 0-indexed (0 = January)
|
||||
skillIds: string[]
|
||||
newSkillIds: string[]
|
||||
reinforcedSkillIds: string[]
|
||||
|
||||
@@ -459,6 +459,7 @@ export function buildConstellationData(): {
|
||||
shortLabel: entity.graphLabel,
|
||||
organization: entity.organization,
|
||||
startYear: entity.dateRange.startYear,
|
||||
startDate: entity.dateRange.start,
|
||||
endYear: entity.dateRange.endYear,
|
||||
orgColor: entity.orgColor,
|
||||
}))
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import type * as d3 from 'd3'
|
||||
import { select as d3select } from 'd3'
|
||||
import {
|
||||
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
||||
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
||||
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
||||
LINK_HIGHLIGHT_BASE_WIDTH, LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR,
|
||||
SKILL_STROKE_OPACITY,
|
||||
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'
|
||||
import type { SimNode, SimLink } from '@/components/constellation/types'
|
||||
|
||||
@@ -42,7 +48,7 @@ export function useConstellationHighlight(deps: {
|
||||
const nodes = deps.nodesRef.current
|
||||
const dur = prefersReducedMotion ? 0 : 180
|
||||
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) {
|
||||
// Reset — respect animation visibility
|
||||
@@ -51,10 +57,13 @@ export function useConstellationHighlight(deps: {
|
||||
nodeSelection.filter(d => d.type !== 'skill')
|
||||
.attr('filter', null)
|
||||
.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('stroke-opacity', 0.4)
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-opacity', ROLE_STROKE_OPACITY_DEFAULT)
|
||||
.attr('stroke-width', ROLE_STROKE_WIDTH_DEFAULT)
|
||||
|
||||
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
||||
@@ -62,20 +71,20 @@ export function useConstellationHighlight(deps: {
|
||||
skillNodes.select('.node-circle')
|
||||
.transition().duration(dur)
|
||||
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
|
||||
.attr('fill-opacity', 0.35)
|
||||
.attr('fill-opacity', SKILL_REST_OPACITY)
|
||||
.attr('filter', null)
|
||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||
skillNodes.select('.node-label')
|
||||
.transition().duration(dur)
|
||||
.attr('opacity', 0.5)
|
||||
.attr('opacity', LABEL_REST_OPACITY)
|
||||
} else {
|
||||
skillNodes.select('.node-circle')
|
||||
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
|
||||
.attr('fill-opacity', 0.35)
|
||||
.attr('fill-opacity', SKILL_REST_OPACITY)
|
||||
.attr('filter', null)
|
||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||
skillNodes.select('.node-label')
|
||||
.attr('opacity', 0.5)
|
||||
.attr('opacity', LABEL_REST_OPACITY)
|
||||
}
|
||||
|
||||
linkSelection
|
||||
@@ -96,7 +105,7 @@ export function useConstellationHighlight(deps: {
|
||||
|
||||
nodeSelection.style('opacity', d => {
|
||||
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')
|
||||
@@ -106,14 +115,17 @@ export function useConstellationHighlight(deps: {
|
||||
return null
|
||||
})
|
||||
.select('.node-circle')
|
||||
.attr('fill', d => d.id === activeNodeId ? '#FFFFFF' : null)
|
||||
.attr('fill-opacity', d => d.id === activeNodeId ? 1 : null)
|
||||
.attr('stroke-opacity', d => {
|
||||
if (d.id === activeNodeId) return 1
|
||||
if (connected.has(d.id)) return 0.7
|
||||
return 0.4
|
||||
.each(function (d) {
|
||||
const el = d3select(this)
|
||||
el.attr('fill', d.id === activeNodeId ? ROLE_FILL_ACTIVE : el.attr('data-base-fill'))
|
||||
})
|
||||
.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 getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
||||
@@ -128,23 +140,23 @@ export function useConstellationHighlight(deps: {
|
||||
if (!isVisible(d.id)) return 0
|
||||
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('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')
|
||||
.transition().duration(dur)
|
||||
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
|
||||
.attr('opacity', d => isInGroup(d.id) ? 1 : LABEL_REST_OPACITY)
|
||||
} else {
|
||||
skillNodes.select('.node-circle')
|
||||
.attr('r', d => {
|
||||
if (!isVisible(d.id)) return 0
|
||||
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('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')
|
||||
.attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
|
||||
.attr('opacity', d => isInGroup(d.id) ? 1 : LABEL_REST_OPACITY)
|
||||
}
|
||||
|
||||
linkSelection
|
||||
|
||||
+189
-77
@@ -6,12 +6,14 @@ import {
|
||||
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
||||
MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN,
|
||||
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
||||
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
||||
DOMAIN_COLOR_MAP, HIDDEN_ENTITY_IDS, prefersReducedMotion,
|
||||
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
||||
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
||||
LINK_BEZIER_VERTICAL_OFFSET,
|
||||
SKILL_STROKE_WIDTH, SKILL_STROKE_OPACITY, SKILL_SIZE_ROLE_FACTOR,
|
||||
SKILL_GLOW_STD_DEVIATION,
|
||||
SKILL_Y_OFFSET_STEP, SKILL_Y_OFFSET_STEP_MOBILE,
|
||||
SKILL_Y_GLOBAL_OFFSET_RATIO, SKILL_X_OVERLAP_MAX_RATIO,
|
||||
} from '@/components/constellation/constants'
|
||||
import type { SimNode, SimLink, LayoutParams } from '@/components/constellation/types'
|
||||
|
||||
@@ -28,13 +30,24 @@ function isEntityNode(type: string): boolean {
|
||||
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 {
|
||||
if (width < 1024) return 520
|
||||
if (width < 768) return 520
|
||||
if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight)
|
||||
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(
|
||||
svgRef: React.RefObject<SVGSVGElement | null>,
|
||||
@@ -70,9 +83,11 @@ export function useForceSimulation(
|
||||
|
||||
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 maxYear = Math.max(...years)
|
||||
const maxYear = Math.max(...years, currentFractionalYear)
|
||||
|
||||
const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf)
|
||||
const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf)
|
||||
@@ -94,9 +109,12 @@ export function useForceSimulation(
|
||||
}
|
||||
layoutParamsRef.current = layoutParams
|
||||
|
||||
const yScale = d3.scaleLinear()
|
||||
.domain([maxYear, minYear])
|
||||
// Power scale gives more space to recent (dense) years, compresses older ones
|
||||
const yearSpan = maxYear - minYear
|
||||
const rawScale = d3.scalePow().exponent(0.5)
|
||||
.domain([0, yearSpan])
|
||||
.range([topPadding, height - bottomPadding])
|
||||
const yScale = (year: number) => rawScale(maxYear - year)
|
||||
|
||||
// Background rect
|
||||
svg.append('rect')
|
||||
@@ -149,31 +167,62 @@ export function useForceSimulation(
|
||||
.attr('id', `role-grad-${i}`)
|
||||
.attr('x1', '0%').attr('y1', '0%')
|
||||
.attr('x2', '100%').attr('y2', '0%')
|
||||
grad.append('stop').attr('offset', '0%').attr('stop-color', color).attr('stop-opacity', 0.08)
|
||||
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0.18)
|
||||
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.3)
|
||||
})
|
||||
const orgColorGradientMap = new Map(uniqueOrgColors.map((c, i) => [c, `url(#role-grad-${i})`]))
|
||||
|
||||
// Year indicator (for animation)
|
||||
const yearIndicator = svg.append('text')
|
||||
.attr('class', 'year-indicator')
|
||||
.attr('x', sidePadding + 8)
|
||||
.attr('y', topPadding - 4)
|
||||
.attr('font-size', isMobile ? '18' : `${Math.round(24 * sf)}`)
|
||||
.attr('font-family', 'var(--font-ui)')
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
// Date indicator group (for animation) — month + year with clip mask for scroll effect
|
||||
const dateFontSize = isMobile ? 18 : Math.round(24 * sf)
|
||||
const dateX = width * 0.1
|
||||
const dateY = topPadding - 4
|
||||
const lineHeight = Math.round(dateFontSize * 1.3)
|
||||
const clipId = 'date-indicator-clip'
|
||||
|
||||
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)
|
||||
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
|
||||
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
|
||||
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')
|
||||
.data(tickYears)
|
||||
.join('line')
|
||||
.attr('class', 'year-guide')
|
||||
.attr('data-year', d => d)
|
||||
.attr('x1', sidePadding)
|
||||
.attr('x2', width - sidePadding)
|
||||
.attr('y1', d => yScale(d))
|
||||
@@ -183,10 +232,16 @@ export function useForceSimulation(
|
||||
.attr('stroke-width', 1)
|
||||
.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')
|
||||
.attr('x1', timelineX)
|
||||
.attr('x2', timelineX)
|
||||
.attr('y1', topPadding - 12)
|
||||
.attr('class', 'axis-line')
|
||||
.attr('x1', axisX)
|
||||
.attr('x2', axisX)
|
||||
.attr('y1', topTickY - 12)
|
||||
.attr('y2', height - bottomPadding + 12)
|
||||
.attr('stroke', 'var(--border)')
|
||||
.attr('stroke-width', 1)
|
||||
@@ -195,86 +250,124 @@ export function useForceSimulation(
|
||||
.data(tickYears)
|
||||
.join('line')
|
||||
.attr('class', 'year-tick')
|
||||
.attr('x1', timelineX)
|
||||
.attr('x2', d => timelineX + (roleNodes.some(r => r.startYear === d) ? 8 : 6))
|
||||
.attr('data-year', d => d)
|
||||
.attr('x1', axisX)
|
||||
.attr('x2', d => axisX - (roleNodes.some(r => r.startYear === d) ? 8 : 6))
|
||||
.attr('y1', d => yScale(d))
|
||||
.attr('y2', d => yScale(d))
|
||||
.attr('stroke', 'var(--border)')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.8 : 0.4)
|
||||
.attr('stroke-opacity', 1)
|
||||
|
||||
timelineGroup.selectAll('text.year-label')
|
||||
.data(tickYears)
|
||||
.join('text')
|
||||
.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('text-anchor', 'end')
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
||||
.attr('font-family', 'var(--font-ui)')
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.attr('opacity', 1)
|
||||
.text(d => d)
|
||||
|
||||
// Prepare data
|
||||
const links: SimLink[] = constellationLinks.map(l => ({
|
||||
// Prepare data — filter out hidden entities and their exclusive links/skills
|
||||
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,
|
||||
target: l.target,
|
||||
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 roleGap = isMobile ? 40 : Math.round(56 * sf)
|
||||
const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2)
|
||||
const roleGap = isMobile ? 54 : Math.round(54 * sf)
|
||||
const roleX = axisX - roleGap - rw / 2
|
||||
|
||||
roleOrder.forEach((role) => {
|
||||
roleInitialMap.set(role.id, {
|
||||
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)) {
|
||||
const pos = roleInitialMap.get(n.id)!
|
||||
return { ...n, x: pos.x, y: pos.y, vx: 0, vy: 0, homeX: pos.x, homeY: pos.y }
|
||||
}
|
||||
|
||||
const roleIds = constellationLinks.filter(l => l.target === n.id).map(l => l.source)
|
||||
const linkedRolePositions = roleIds
|
||||
.map(roleId => roleInitialMap.get(roleId))
|
||||
.filter(Boolean) as Array<{ x: number; y: number }>
|
||||
|
||||
const skillGap = isMobile ? 20 : Math.round(28 * sf)
|
||||
const skillSpaceStart = roleX + rw / 2 + skillGap
|
||||
const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2
|
||||
const centroid = linkedRolePositions.length > 0
|
||||
? {
|
||||
x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(40 * sf))),
|
||||
y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length,
|
||||
}
|
||||
: { x: skillSpaceMid, y: height * 0.5 }
|
||||
|
||||
const hash = hashString(n.id)
|
||||
const domainBaseAngle = n.domain === 'clinical'
|
||||
? Math.PI * 0.5
|
||||
: n.domain === 'leadership'
|
||||
? Math.PI * 1.35
|
||||
: Math.PI * 0.05
|
||||
const angle = domainBaseAngle + ((hash % 360) * Math.PI / 180) * 0.18
|
||||
const radius = (isMobile ? 25 : Math.round(35 * sf)) + (hash % (isMobile ? 25 : Math.round(35 * sf)))
|
||||
let homeX = skillZoneLeft + (hash % 1000) / 1000 * skillZoneWidth
|
||||
|
||||
const seededX = centroid.x + Math.cos(angle) * radius
|
||||
const seededY = centroid.y + Math.sin(angle) * radius
|
||||
// X-offset for overlapping groups: stronger push for skills further left
|
||||
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
|
||||
|
||||
// Build connected map
|
||||
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.target)) connectedMap.set(l.target, new Set())
|
||||
connectedMap.get(l.source)!.add(l.target)
|
||||
@@ -291,7 +384,7 @@ export function useForceSimulation(
|
||||
skillRestRadiiRef.current = skillRestRadii
|
||||
|
||||
// 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
|
||||
const linkGroup = svg.append('g').attr('class', 'links')
|
||||
@@ -339,6 +432,16 @@ export function useForceSimulation(
|
||||
.attr('stroke', 'transparent')
|
||||
.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)
|
||||
.append('rect')
|
||||
.attr('class', 'node-circle')
|
||||
@@ -348,8 +451,9 @@ export function useForceSimulation(
|
||||
.attr('height', rh)
|
||||
.attr('rx', rrx)
|
||||
.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-opacity', 0.4)
|
||||
.attr('stroke-opacity', 0.8)
|
||||
.attr('stroke-width', 1)
|
||||
.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-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')
|
||||
.append('text')
|
||||
.attr('class', 'node-label')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', srActive + Math.round(14 * sf))
|
||||
.attr('fill', 'var(--text-secondary)')
|
||||
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
||||
.attr('font-size', skillFontSize)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('pointer-events', 'none')
|
||||
.attr('opacity', 0.5)
|
||||
.text(d => {
|
||||
.each(function (d) {
|
||||
const label = d.shortLabel ?? d.label
|
||||
const maxLen = isMobile ? 12 : width < 500 ? 12 : 16
|
||||
return label.length > maxLen ? `${label.slice(0, maxLen - 1)}…` : label
|
||||
const words = label.split(/\s+/)
|
||||
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
|
||||
@@ -423,15 +536,15 @@ export function useForceSimulation(
|
||||
))
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
||||
.id(d => d.id)
|
||||
.distance(isMobile ? 56 : Math.round(72 * sf))
|
||||
.strength(d => (d as SimLink).strength * 0.5))
|
||||
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.25))
|
||||
.distance(isMobile ? 56 : Math.round(120 * sf))
|
||||
.strength(d => (d as SimLink).strength * 0.15))
|
||||
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.6))
|
||||
.force('y', d3.forceY<SimNode>(d => {
|
||||
if (isEntityNode(d.type)) {
|
||||
return yScale(d.startYear ?? minYear)
|
||||
return yScale(fractionalYear(d))
|
||||
}
|
||||
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 =>
|
||||
isEntityNode(d.type) ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
|
||||
).iterations(3))
|
||||
@@ -439,15 +552,14 @@ export function useForceSimulation(
|
||||
simulationRef.current = simulation
|
||||
|
||||
const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
|
||||
const rightMargin = isMobile ? 16 : Math.round(32 * sf)
|
||||
|
||||
const renderTick = () => {
|
||||
nodes.forEach(d => {
|
||||
if (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))
|
||||
} 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))
|
||||
}
|
||||
})
|
||||
@@ -465,9 +577,9 @@ export function useForceSimulation(
|
||||
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
|
||||
roleConnectors
|
||||
.attr('x1', timelineX)
|
||||
.attr('x1', d => d.x + rw / 2)
|
||||
.attr('y1', d => d.y)
|
||||
.attr('x2', d => d.x - rw / 2)
|
||||
.attr('x2', axisX)
|
||||
.attr('y2', d => d.y)
|
||||
|
||||
const nextNodePositions: Record<string, { x: number; y: number }> = {}
|
||||
|
||||
@@ -15,14 +15,19 @@ import {
|
||||
ANIM_RESTART_DELAY_MS,
|
||||
ANIM_INTERACTION_RESUME_MS,
|
||||
ANIM_SETTLE_ALPHA,
|
||||
ANIM_MONTH_STEP_MS,
|
||||
ANIM_CHRONOLOGICAL_ENABLED,
|
||||
HIDDEN_ENTITY_IDS,
|
||||
prefersReducedMotion,
|
||||
} from '@/components/constellation/constants'
|
||||
import type { SimNode, SimLink, AnimationState, AnimationStep } from '@/components/constellation/types'
|
||||
|
||||
// Pre-compute animation steps from timeline entities (oldest first)
|
||||
const sortedEntities = [...timelineEntities].sort(
|
||||
(a, b) => a.dateRange.startYear - b.dateRange.startYear
|
||||
)
|
||||
// Pre-compute animation steps from timeline entities (newest first → reverse chronological)
|
||||
const sortedEntities = [...timelineEntities]
|
||||
.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[] {
|
||||
const seen = new Set<string>()
|
||||
@@ -34,9 +39,11 @@ function buildAnimationSteps(): AnimationStep[] {
|
||||
const linkPairs = constellationLinks
|
||||
.filter(l => l.source === entity.id)
|
||||
.map(l => ({ source: l.source, target: l.target }))
|
||||
const startDate = new Date(entity.dateRange.start)
|
||||
return {
|
||||
entityId: entity.id,
|
||||
startYear: entity.dateRange.startYear,
|
||||
startMonth: startDate.getMonth(),
|
||||
skillIds,
|
||||
newSkillIds,
|
||||
reinforcedSkillIds,
|
||||
@@ -57,6 +64,7 @@ interface UseTimelineAnimationDeps {
|
||||
skillRestRadiiRef: React.MutableRefObject<Map<string, number>>
|
||||
srDefault: number
|
||||
dimensionsTrigger: number
|
||||
ready?: boolean
|
||||
}
|
||||
|
||||
export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
@@ -68,7 +76,10 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
const userPausedRef = useRef(false)
|
||||
const interactionPausedRef = useRef(false)
|
||||
const resumeTimerRef = useRef(0)
|
||||
const displayedMonthRef = useRef(-1) // 0-indexed, -1 = not yet shown
|
||||
const displayedYearRef = useRef(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [animationInitialized, setAnimationInitialized] = useState(false)
|
||||
|
||||
const scheduleTimeout = useCallback((fn: () => void, ms: number) => {
|
||||
const id = window.setTimeout(fn, ms)
|
||||
@@ -76,6 +87,93 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
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(() => {
|
||||
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = 0
|
||||
@@ -99,12 +197,15 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
nodeSel.selectAll('*').interrupt()
|
||||
connSel?.interrupt()
|
||||
tlGroup?.interrupt()
|
||||
yearInd?.interrupt()
|
||||
yearInd?.selectAll('*').interrupt()
|
||||
|
||||
nodeSel.style('opacity', '0')
|
||||
linkSel.attr('opacity', 0)
|
||||
linkSel.attr('stroke-opacity', 0)
|
||||
connSel?.attr('opacity', 0)
|
||||
tlGroup?.attr('opacity', 0)
|
||||
yearInd?.attr('opacity', 0)
|
||||
displayedMonthRef.current = -1
|
||||
displayedYearRef.current = 0
|
||||
|
||||
// Reset skill radii to 0
|
||||
nodeSel.filter((d: SimNode) => d.type === 'skill')
|
||||
@@ -112,6 +213,23 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
.attr('r', 0)
|
||||
|
||||
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])
|
||||
|
||||
const showFinalState = useCallback(() => {
|
||||
@@ -128,38 +246,43 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
})
|
||||
visibleNodeIdsRef.current = allIds
|
||||
|
||||
nodeSel.style('opacity', '1')
|
||||
linkSel.attr('opacity', null)
|
||||
connSel?.attr('opacity', null)
|
||||
nodeSel.style('opacity', (d: SimNode) => allIds.has(d.id) ? '1' : '0')
|
||||
linkSel.attr('stroke-opacity', null)
|
||||
connSel?.attr('opacity', (d: SimNode) => allIds.has(d.id) ? null : 0)
|
||||
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')
|
||||
.select('.node-circle')
|
||||
.attr('r', (d: SimNode) => deps.skillRestRadiiRef.current.get(d.id) ?? 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 linkSel = deps.linkSelectionRef.current
|
||||
const connSel = deps.connectorSelectionRef.current
|
||||
const yearInd = deps.yearIndicatorRef.current
|
||||
const tlGroup = deps.timelineGroupRef.current
|
||||
if (!nodeSel || !linkSel) return
|
||||
|
||||
const step = animationSteps[stepIdx]
|
||||
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
|
||||
const entityGroup = nodeSel.filter((d: SimNode) => d.id === step.entityId)
|
||||
entityGroup
|
||||
@@ -237,7 +360,7 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
const el = d3.select(this)
|
||||
const pathEl = this as SVGPathElement
|
||||
const length = pathEl.getTotalLength()
|
||||
el.attr('opacity', 1)
|
||||
el.attr('stroke-opacity', 1)
|
||||
.attr('stroke-dasharray', `${length} ${length}`)
|
||||
.attr('stroke-dashoffset', length)
|
||||
.transition()
|
||||
@@ -258,7 +381,16 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
const totalStepMs = Math.max(ANIM_ENTITY_REVEAL_MS, skillDuration, linkDuration)
|
||||
|
||||
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(() => {
|
||||
if (prefersReducedMotion) return
|
||||
@@ -274,18 +406,16 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
if (userPausedRef.current || interactionPausedRef.current) return
|
||||
animationStateRef.current = 'RESETTING'
|
||||
|
||||
// Fade year indicator
|
||||
// Fade date indicator
|
||||
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
|
||||
// Fade all
|
||||
deps.nodeSelectionRef.current
|
||||
?.transition().duration(ANIM_RESET_MS).style('opacity', '0')
|
||||
deps.linkSelectionRef.current
|
||||
?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
?.transition().duration(ANIM_RESET_MS).attr('stroke-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(() => {
|
||||
if (userPausedRef.current) return
|
||||
@@ -296,6 +426,8 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
.attr('r', 0)
|
||||
|
||||
visibleNodeIdsRef.current = new Set()
|
||||
displayedMonthRef.current = -1
|
||||
displayedYearRef.current = 0
|
||||
currentStepRef.current = 0
|
||||
animationStateRef.current = 'PLAYING'
|
||||
setIsPlaying(true)
|
||||
@@ -330,7 +462,7 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (prefersReducedMotion) return
|
||||
@@ -393,9 +525,8 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
animationStateRef.current = 'RESETTING'
|
||||
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('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.timelineGroupRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
scheduleTimeout(() => {
|
||||
if (userPausedRef.current) return
|
||||
deps.nodeSelectionRef.current
|
||||
@@ -403,6 +534,8 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
.select('.node-circle')
|
||||
.attr('r', 0)
|
||||
visibleNodeIdsRef.current = new Set()
|
||||
displayedMonthRef.current = -1
|
||||
displayedYearRef.current = 0
|
||||
currentStepRef.current = 0
|
||||
animationStateRef.current = 'PLAYING'
|
||||
setIsPlaying(true)
|
||||
@@ -419,11 +552,13 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
|
||||
advanceFromCurrent()
|
||||
}, 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(() => {
|
||||
if (prefersReducedMotion) {
|
||||
if (!deps.ready) return
|
||||
|
||||
if (prefersReducedMotion || !ANIM_CHRONOLOGICAL_ENABLED) {
|
||||
// Show final state immediately after a tick to let simulation refs populate
|
||||
const id = requestAnimationFrame(() => {
|
||||
showFinalState()
|
||||
@@ -444,12 +579,13 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
cancelAll()
|
||||
animationStateRef.current = 'IDLE'
|
||||
}
|
||||
}, [deps.dimensionsTrigger, cancelAll, runAnimation, showFinalState])
|
||||
}, [deps.dimensionsTrigger, deps.ready, cancelAll, runAnimation, showFinalState])
|
||||
|
||||
return {
|
||||
animationStateRef,
|
||||
visibleNodeIdsRef,
|
||||
isPlaying,
|
||||
animationInitialized,
|
||||
togglePlayPause,
|
||||
pauseForInteraction,
|
||||
resumeAfterInteraction,
|
||||
|
||||
+7
-9
@@ -351,6 +351,10 @@ html {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dashboard-grid > :first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: maintain 2 columns with generous gap */
|
||||
@@ -405,16 +409,10 @@ html {
|
||||
|
||||
.timeline-intervention-item {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
@@ -441,7 +439,7 @@ html {
|
||||
/* Tablet+: 2 columns */
|
||||
@media (min-width: 768px) {
|
||||
.pathway-columns {
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3.5fr);
|
||||
align-items: start;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ export interface ConstellationNode {
|
||||
shortLabel?: string // abbreviated for small nodes
|
||||
organization?: string
|
||||
startYear?: number
|
||||
startDate?: string // ISO date for fractional year positioning
|
||||
endYear?: number | null
|
||||
orgColor?: string
|
||||
domain?: 'clinical' | 'technical' | 'leadership'
|
||||
|
||||
Reference in New Issue
Block a user