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
@@ -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>
)}
+41 -16
View File
@@ -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
+1
View File
@@ -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[]