Updated chart
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])),
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
+14
-5
@@ -402,7 +402,16 @@ export const timelineEntities: TimelineEntity[] = [...timelineEntitySeeds].sort(
|
||||
return b.dateRange.start.localeCompare(a.dateRange.start)
|
||||
})
|
||||
|
||||
export const timelineRoleEntities = timelineEntities
|
||||
export const timelineCareerEntities: TimelineEntity[] = timelineEntities.filter(
|
||||
(entity) => entity.kind === 'career',
|
||||
)
|
||||
|
||||
export const timelineEducationEntities: TimelineEntity[] = timelineEntities.filter(
|
||||
(entity) => entity.kind === 'education',
|
||||
)
|
||||
|
||||
// Compatibility alias retained for downstream consumers that still import role entities.
|
||||
export const timelineRoleEntities = timelineCareerEntities
|
||||
|
||||
function mapTimelineToConsultation(entity: TimelineEntity): Consultation {
|
||||
const codedEntries: CodedEntry[] = entity.codedEntries ?? entity.details.map((detail, index) => ({
|
||||
@@ -425,7 +434,7 @@ function mapTimelineToConsultation(entity: TimelineEntity): Consultation {
|
||||
}
|
||||
}
|
||||
|
||||
export const timelineConsultations: Consultation[] = timelineRoleEntities.map(mapTimelineToConsultation)
|
||||
export const timelineConsultations: Consultation[] = timelineCareerEntities.map(mapTimelineToConsultation)
|
||||
|
||||
const skillDomainByCategory: Record<string, 'technical' | 'clinical' | 'leadership'> = {
|
||||
Technical: 'technical',
|
||||
@@ -438,12 +447,12 @@ export function buildConstellationData(): {
|
||||
constellationNodes: ConstellationNode[]
|
||||
constellationLinks: ConstellationLink[]
|
||||
} {
|
||||
const roleSkillMappings: RoleSkillMapping[] = timelineRoleEntities.map((entity) => ({
|
||||
const roleSkillMappings: RoleSkillMapping[] = timelineCareerEntities.map((entity) => ({
|
||||
roleId: entity.id,
|
||||
skillIds: entity.skills,
|
||||
}))
|
||||
|
||||
const roleNodes: ConstellationNode[] = timelineRoleEntities.map((entity) => ({
|
||||
const roleNodes: ConstellationNode[] = timelineCareerEntities.map((entity) => ({
|
||||
id: entity.id,
|
||||
type: 'role',
|
||||
label: entity.title,
|
||||
@@ -462,7 +471,7 @@ export function buildConstellationData(): {
|
||||
domain: skillDomainByCategory[skill.category],
|
||||
}))
|
||||
|
||||
const constellationLinks: ConstellationLink[] = timelineRoleEntities.flatMap((entity) =>
|
||||
const constellationLinks: ConstellationLink[] = timelineCareerEntities.flatMap((entity) =>
|
||||
entity.skills.map((skillId) => ({
|
||||
source: entity.id,
|
||||
target: skillId,
|
||||
|
||||
Reference in New Issue
Block a user