Updated chart

This commit is contained in:
2026-02-16 13:23:04 +00:00
parent 2e242a650a
commit 4dfb1607c1
21 changed files with 782 additions and 416 deletions
+61 -29
View File
@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import * as d3 from 'd3'
import { constellationNodes, constellationLinks, roleSkillMappings } from '@/data/constellation'
import { consultations } from '@/data/consultations'
import { timelineCareerEntities } from '@/data/timeline'
import type { ConstellationNode } from '@/types/pmr'
interface CareerConstellationProps {
@@ -36,6 +36,8 @@ const domainColorMap: Record<string, string> = {
leadership: '#D97706',
}
const roleNodes = constellationNodes.filter(n => n.type === 'role')
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
const careerEntityById = new Map(timelineCareerEntities.map(entity => [entity.id, entity]))
const srDescription = buildScreenReaderDescription()
function getHeight(width: number, containerHeight?: number | null): number {
@@ -116,17 +118,43 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
const resolveGraphFallback = useCallback(
() => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current,
[],
)
const resolveRoleFallback = useCallback(() => {
const highlightedId = highlightedNodeIdRef.current
if (highlightedId && nodeById.get(highlightedId)?.type === 'role') {
return highlightedId
}
const pinnedId = pinnedNodeIdRef.current
if (pinnedId && nodeById.get(pinnedId)?.type === 'role') {
return pinnedId
}
return null
}, [])
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId)
if (nodeType === 'role') {
onNodeHover?.(nodeId)
} else {
onNodeHover?.(resolveRoleFallback())
}
if (nodeType === 'role') {
onRoleClick(nodeId)
} else {
onSkillClick(nodeId)
}
}
}, [onRoleClick, onSkillClick])
}, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback])
useEffect(() => {
const container = containerRef.current
@@ -585,8 +613,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
nodeSelection.on('mouseleave', function() {
if (supportsCoarsePointer) return
applyGraphHighlight(highlightedNodeIdRef.current ?? pinnedNodeIdRef.current)
callbacksRef.current.onNodeHover?.(null)
applyGraphHighlight(resolveGraphFallback())
callbacksRef.current.onNodeHover?.(resolveRoleFallback())
})
nodeSelection.on('click', function(_event, d) {
@@ -601,7 +629,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
setPinnedNodeId(d.id)
pinnedNodeIdRef.current = d.id
applyGraphHighlight(d.id)
callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : null)
callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : resolveRoleFallback())
}
}
@@ -693,7 +721,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
return prev
})
applyGraphHighlight(highlightedNodeIdRef.current ?? pinnedNodeIdRef.current)
applyGraphHighlight(resolveGraphFallback())
}
if (prefersReducedMotion) {
@@ -709,7 +737,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
return () => {
simulation.stop()
}
}, [dimensions])
}, [dimensions, resolveGraphFallback, resolveRoleFallback])
useEffect(() => {
if (!svgRef.current) return
@@ -737,10 +765,10 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
setAccordionShowMore(false)
}, [pinnedNodeId])
// Find consultation for pinned role (accordion on mobile)
// Find canonical career entity for pinned role (accordion on mobile)
const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && n.type === 'role') : null
const pinnedConsultation = pinnedRoleNode ? consultations.find(c => c.id === pinnedRoleNode.id) : null
const showAccordion = supportsCoarsePointer && pinnedConsultation !== null && pinnedConsultation !== undefined
const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) : null
const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null && pinnedCareerEntity !== undefined
return (
<div
@@ -806,9 +834,9 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
{/* Mobile accordion: role details on tap */}
<AnimatePresence>
{showAccordion && pinnedConsultation && (
{showAccordion && pinnedCareerEntity && (
<motion.div
key={pinnedConsultation.id}
key={pinnedCareerEntity.id}
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
@@ -818,7 +846,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
<div
style={{
padding: '12px 16px',
borderTop: `1px solid ${pinnedConsultation.orgColor ?? 'var(--border-light)'}`,
borderTop: `1px solid ${pinnedCareerEntity.orgColor ?? 'var(--border-light)'}`,
fontFamily: 'var(--font-ui)',
}}
>
@@ -837,7 +865,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: pinnedConsultation.orgColor ?? 'var(--accent)',
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
flexShrink: 0,
}}
/>
@@ -848,7 +876,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
color: 'var(--text-primary)',
}}
>
{pinnedConsultation.role}
{pinnedCareerEntity.title}
</span>
</div>
<div
@@ -859,7 +887,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
paddingLeft: '14px',
}}
>
{pinnedConsultation.organization} · {pinnedConsultation.duration}
{pinnedCareerEntity.organization} · {pinnedCareerEntity.dateRange.display}
</div>
</div>
@@ -870,7 +898,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
listStyle: 'none',
}}
>
{(accordionShowMore ? pinnedConsultation.examination : pinnedConsultation.examination.slice(0, 3)).map((item, i) => (
{(accordionShowMore ? pinnedCareerEntity.details : pinnedCareerEntity.details.slice(0, 3)).map((item, i) => (
<li
key={i}
style={{
@@ -888,7 +916,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
width: '4px',
height: '4px',
borderRadius: '50%',
backgroundColor: pinnedConsultation.orgColor ?? 'var(--accent)',
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
opacity: 0.5,
flexShrink: 0,
marginTop: '7px',
@@ -899,7 +927,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
))}
</ul>
{accordionShowMore && pinnedConsultation.plan.length > 0 && (
{accordionShowMore && (pinnedCareerEntity.outcomes ?? []).length > 0 && (
<ul
style={{
margin: '8px 0 0',
@@ -907,7 +935,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
listStyle: 'none',
}}
>
{pinnedConsultation.plan.map((item, i) => (
{(pinnedCareerEntity.outcomes ?? []).map((item, i) => (
<li
key={i}
style={{
@@ -932,12 +960,12 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
}}
/>
{item}
</li>
))}
</ul>
</li>
))}
</ul>
)}
{pinnedConsultation.examination.length > 3 && (
{pinnedCareerEntity.details.length > 3 && (
<button
type="button"
onClick={() => setAccordionShowMore(prev => !prev)}
@@ -948,7 +976,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
padding: '4px 14px',
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
color: pinnedConsultation.orgColor ?? 'var(--accent)',
color: pinnedCareerEntity.orgColor ?? 'var(--accent)',
fontWeight: 500,
marginTop: '4px',
}}
@@ -1032,8 +1060,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
transform: 'translate(-50%, -50%)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
pointerEvents: 'auto',
cursor: 'default',
pointerEvents: 'none',
padding: 0,
opacity: 0,
}}
@@ -1046,14 +1074,18 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
}}
onBlur={() => {
setFocusedNodeId(null)
highlightGraphRef.current?.(pinnedNodeId)
onNodeHover?.(pinnedNodeId)
highlightGraphRef.current?.(resolveGraphFallback())
onNodeHover?.(resolveRoleFallback())
}}
onClick={() => {
setPinnedNodeId(node.id)
pinnedNodeIdRef.current = node.id
highlightGraphRef.current?.(node.id)
if (node.type === 'role') {
onNodeHover?.(node.id)
onRoleClick(node.id)
} else {
onNodeHover?.(resolveRoleFallback())
onSkillClick(node.id)
}
}}
+12 -5
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { motion } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import Sidebar from './Sidebar'
@@ -14,7 +14,7 @@ import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
import { ChatWidget } from './ChatWidget'
import { useActiveSection } from '@/hooks/useActiveSection'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { consultations } from '@/data/consultations'
import { timelineConsultations } from '@/data/timeline'
import { skills } from '@/data/skills'
import type { PaletteAction } from '@/lib/search'
@@ -54,7 +54,10 @@ interface LastConsultationSubsectionProps {
function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubsectionProps) {
const { openPanel } = useDetailPanel()
const consultation = consultations[0]
const consultation = timelineConsultations[0]
if (!consultation) {
return null
}
const isHighlighted = highlightedRoleId === consultation.id
const handleOpenPanel = () => {
@@ -250,6 +253,10 @@ export function DashboardLayout() {
const chronologyRef = useRef<HTMLDivElement>(null)
const activeSection = useActiveSection()
const { openPanel } = useDetailPanel()
const careerConsultationsById = useMemo(
() => new Map(timelineConsultations.map((consultation) => [consultation.id, consultation])),
[],
)
// Measure the chronology stream height so the constellation graph can match it
useEffect(() => {
@@ -283,12 +290,12 @@ export function DashboardLayout() {
// Constellation graph handlers
const handleRoleClick = useCallback(
(roleId: string) => {
const consultation = consultations.find((c) => c.id === roleId)
const consultation = careerConsultationsById.get(roleId)
if (consultation) {
openPanel({ type: 'career-role', consultation })
}
},
[openPanel],
[careerConsultationsById, openPanel],
)
const handleSkillClick = useCallback(
+6 -16
View File
@@ -278,26 +278,12 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
src={cvmisLogo}
alt="CVMIS"
style={{
width: '140px',
width: '25%',
height: 'auto',
display: 'block',
}}
/>
<div
style={{
flex: 1,
fontSize: '11px',
color: 'var(--text-tertiary)',
letterSpacing: '0.04em',
lineHeight: 1.1,
textAlign: 'center',
}}
>
CVMIS v1.0
</div>
</div>
<button
<button
type="button"
onClick={onSearchClick}
className="sidebar-control"
@@ -336,6 +322,10 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
</kbd>
</button>
</div>
<SectionTitle>Patient Data</SectionTitle>
<div
@@ -2,8 +2,7 @@ import React, { useMemo, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { consultations } from '@/data/consultations'
import { timelineEntities } from '@/data/timeline'
import { timelineEntities, timelineConsultations } from '@/data/timeline'
import type { TimelineEntity } from '@/types/pmr'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -144,7 +143,7 @@ function TimelineInterventionItem({
<div
style={{
fontSize: '11px',
fontFamily: 'var(--font-mono)',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
marginTop: '3px',
}}
@@ -240,7 +239,7 @@ function TimelineInterventionItem({
key={entry.code}
style={{
fontSize: '11px',
fontFamily: 'var(--font-mono)',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '4px',
background: hexToRgba(entity.orgColor, 0.08),
@@ -301,7 +300,7 @@ export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRo
const { openPanel } = useDetailPanel()
const consultationsById = useMemo(
() => new Map(consultations.map((consultation) => [consultation.id, consultation])),
() => new Map(timelineConsultations.map((consultation) => [consultation.id, consultation])),
[],
)
+2 -2
View File
@@ -116,7 +116,7 @@ function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle,
<div
style={{
fontSize: '11px',
fontFamily: 'var(--font-mono)',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
marginTop: '3px',
}}
@@ -215,7 +215,7 @@ function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle,
key={entry.code}
style={{
fontSize: '11px',
fontFamily: 'var(--font-mono)',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '4px',
background: hexToRgba(consultation.orgColor ?? '#0D6E6E', 0.08),