Rehaul of graph component
This commit is contained in:
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user