diff --git a/src/components/RepeatMedicationsSubsection.tsx b/src/components/RepeatMedicationsSubsection.tsx index 2cb3346..3665f84 100644 --- a/src/components/RepeatMedicationsSubsection.tsx +++ b/src/components/RepeatMedicationsSubsection.tsx @@ -4,8 +4,8 @@ import { BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2, Pill, Users, FileCheck, Stethoscope, TrendingUp, Route, BookOpen, Store, - Presentation, Calculator, Banknote, Handshake, RefreshCw, Crown, - ChevronRight, + Presentation, Calculator, Banknote, Handshake, RefreshCw, + GitBranch, Workflow, UserPlus, ChevronRight, } from 'lucide-react' import { CardHeader } from './Card' import { skills } from '@/data/skills' @@ -17,7 +17,8 @@ const iconMap: Record = { BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2, Pill, Users, FileCheck, Stethoscope, TrendingUp, Route, BookOpen, Store, - Presentation, Calculator, Banknote, Handshake, RefreshCw, Crown, + Presentation, Calculator, Banknote, Handshake, RefreshCw, + GitBranch, Workflow, UserPlus, } @@ -210,6 +211,15 @@ interface RepeatMedicationsSubsectionProps { focusRelatedIds?: Set | null } +const frequencyRank = (freq: string): number => { + if (freq.includes('daily')) return freq.startsWith('4') ? 0 : freq.startsWith('3') ? 1 : freq.startsWith('1') ? 3 : 2 + if (freq === 'Daily') return 4 + if (freq.includes('weekly')) return freq.startsWith('2') ? 5 : freq.startsWith('1') ? 6 : 7 + if (freq === 'Weekly') return 7 + if (freq === 'Bi-monthly') return 8 + return 9 // As needed +} + export function RepeatMedicationsSubsection({ onNodeHighlight, focusRelatedIds }: RepeatMedicationsSubsectionProps) { const { openPanel } = useDetailPanel() const skillsCopy = getSkillsUICopy() @@ -219,7 +229,7 @@ export function RepeatMedicationsSubsection({ onNodeHighlight, focusRelatedIds } label, skills: skills .filter((s) => s.category === id) - .sort((a, b) => b.yearsOfExperience - a.yearsOfExperience), + .sort((a, b) => frequencyRank(a.frequency) - frequencyRank(b.frequency)), })) const handleSkillClick = (skill: SkillMedication) => { diff --git a/src/components/TimelineInterventionsSubsection.tsx b/src/components/TimelineInterventionsSubsection.tsx index 69e048a..f1b815b 100644 --- a/src/components/TimelineInterventionsSubsection.tsx +++ b/src/components/TimelineInterventionsSubsection.tsx @@ -4,11 +4,18 @@ import { motion, AnimatePresence } from 'framer-motion' import { ExpandableCardShell } from './ExpandableCardShell' import { useDetailPanel } from '@/contexts/DetailPanelContext' import { timelineEntities, timelineConsultations } from '@/data/timeline' +import { documents } from '@/data/documents' import { getExperienceEducationUICopy } from '@/lib/profile-content' import type { TimelineEntity } from '@/types/pmr' + +const timelineToDocumentId: Record = { + 'nhs-mary-seacole-2018': 'doc-mary-seacole', + 'uea-mpharm-2011': 'doc-mpharm', + 'highworth-alevels-2009': 'doc-alevels', +} import { hexToRgba, motionSafeTransition } from '@/lib/utils' -const VISIBLE_COUNT = 4 +const VISIBLE_COUNT = 5 interface TimelineInterventionItemProps { entity: TimelineEntity @@ -304,6 +311,12 @@ export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRo }, []) const handleViewFull = useCallback((entity: TimelineEntity) => { + if (entity.kind === 'education') { + const docId = timelineToDocumentId[entity.id] + const doc = docId ? documents.find((d) => d.id === docId) : undefined + if (doc) openPanel({ type: 'education', document: doc }) + return + } const consultation = consultationsById.get(entity.id) if (!consultation) return openPanel({ type: 'career-role', consultation }) diff --git a/src/components/detail/SkillsAllDetail.tsx b/src/components/detail/SkillsAllDetail.tsx index d045c11..1febbe8 100644 --- a/src/components/detail/SkillsAllDetail.tsx +++ b/src/components/detail/SkillsAllDetail.tsx @@ -4,8 +4,8 @@ import { BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2, Pill, Users, FileCheck, Stethoscope, TrendingUp, Route, BookOpen, Store, - Presentation, Calculator, Banknote, Handshake, RefreshCw, Crown, - ChevronRight, + Presentation, Calculator, Banknote, Handshake, RefreshCw, + GitBranch, Workflow, UserPlus, ChevronRight, } from 'lucide-react' import { skills } from '@/data/skills' import { useDetailPanel } from '@/contexts/DetailPanelContext' @@ -16,7 +16,8 @@ const iconMap: Record = { BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2, Pill, Users, FileCheck, Stethoscope, TrendingUp, Route, BookOpen, Store, - Presentation, Calculator, Banknote, Handshake, RefreshCw, Crown, + Presentation, Calculator, Banknote, Handshake, RefreshCw, + GitBranch, Workflow, UserPlus, } interface SkillsAllDetailProps { @@ -35,12 +36,21 @@ export function SkillsAllDetail({ category }: SkillsAllDetailProps) { } }, [category]) + const frequencyRank = (freq: string): number => { + if (freq.includes('daily')) return freq.startsWith('4') ? 0 : freq.startsWith('3') ? 1 : freq.startsWith('1') ? 3 : 2 + if (freq === 'Daily') return 4 + if (freq.includes('weekly')) return freq.startsWith('2') ? 5 : freq.startsWith('1') ? 6 : 7 + if (freq === 'Weekly') return 7 + if (freq === 'Bi-monthly') return 8 + return 9 // As needed + } + const groupedSkills = skillsCopy.categories.map(({ id, label }) => ({ id, label, skills: skills .filter((s) => s.category === id) - .sort((a, b) => b.yearsOfExperience - a.yearsOfExperience), + .sort((a, b) => frequencyRank(a.frequency) - frequencyRank(b.frequency)), })) const handleSkillClick = (skill: SkillMedication) => { diff --git a/src/data/profile-content.ts b/src/data/profile-content.ts index fd97944..ee4c4d9 100644 --- a/src/data/profile-content.ts +++ b/src/data/profile-content.ts @@ -90,13 +90,13 @@ export const profileContent: DeepReadonly = { title: '£2.6M Savings via Automated Algorithm', subtitle: '14,000 patients identified in 3 days', keywords: '2.6m savings automated algorithm python switching 14000 patients cost-effective alternatives prescribing analytics', - kpiId: 'years', + kpiId: 'algorithm', }, { - title: '1.2M Population Served', - subtitle: 'Norfolk & Waveney Integrated Care System', - keywords: '1.2m population served norfolk waveney ics integrated care system primary care secondary care commissioning', - kpiId: 'population', + title: '20M Prescriptions Decoded Annually', + subtitle: '90% coverage — free text to structured data', + keywords: '20m prescriptions decoded parsed free text structured data regex ai pipeline daily quantities adherence population scale', + kpiId: 'prescriptions', }, ], }, diff --git a/src/data/skills.ts b/src/data/skills.ts index e39c9b8..826303f 100644 --- a/src/data/skills.ts +++ b/src/data/skills.ts @@ -1,7 +1,7 @@ import type { SkillMedication } from '@/types/pmr' export const skills: SkillMedication[] = [ - // Technical (6 skills) + // Technical (8 skills) — sorted by frequency { id: 'data-analysis', name: 'Data Analysis', @@ -19,6 +19,21 @@ export const skills: SkillMedication[] = [ { year: 2024, description: 'Current: ICS-wide analytics strategy development' }, ], }, + { + id: 'ai-prompt-engineering', + name: 'AI Integration & Automation', + shortName: 'AI Integration', + frequency: '4x daily', + startYear: 2024, + yearsOfExperience: 2, + category: 'Technical', + status: 'Active', + icon: 'Bot', + prescribingHistory: [ + { year: 2024, description: 'Started: LLM-assisted code generation and data analysis workflows' }, + { year: 2025, description: 'Current: Agentic coding, prompt design for clinical data extraction' }, + ], + }, { id: 'python', name: 'Python', @@ -67,38 +82,6 @@ export const skills: SkillMedication[] = [ { year: 2025, description: 'Current: Self-serve analytics dashboards, incentive scheme tracking' }, ], }, - { - id: 'ai-prompt-engineering', - name: 'AI Integration & Automation', - shortName: 'AI Integration', - frequency: '4x daily', - startYear: 2024, - yearsOfExperience: 2, - category: 'Technical', - status: 'Active', - icon: 'Bot', - prescribingHistory: [ - { year: 2024, description: 'Started: LLM-assisted code generation and data analysis workflows' }, - { year: 2025, description: 'Current: Agentic coding, prompt design for clinical data extraction' }, - ], - }, - { - id: 'javascript-typescript', - name: 'JavaScript / TypeScript', - shortName: 'JS / TS', - frequency: 'As needed', - startYear: 2020, - yearsOfExperience: 4, - category: 'Technical', - status: 'Active', - icon: 'FileCode2', - prescribingHistory: [ - { year: 2020, description: 'Started: Web development for personal projects' }, - { year: 2022, description: 'Increased: React dashboard components' }, - { year: 2024, description: 'Current: CV/portfolio development, interactive tools' }, - ], - }, - { id: 'algorithm-design', name: 'Algorithm Design', @@ -132,8 +115,24 @@ export const skills: SkillMedication[] = [ { year: 2025, description: 'Current: ICS-wide data infrastructure' }, ], }, + { + id: 'javascript-typescript', + name: 'JavaScript / TypeScript', + shortName: 'JS / TS', + frequency: 'As needed', + startYear: 2020, + yearsOfExperience: 4, + category: 'Technical', + status: 'Active', + icon: 'FileCode2', + prescribingHistory: [ + { year: 2020, description: 'Started: Web development for personal projects' }, + { year: 2022, description: 'Increased: React dashboard components' }, + { year: 2024, description: 'Current: CV/portfolio development, interactive tools' }, + ], + }, - // Clinical (8 skills) + // Clinical (8 skills) — sorted by frequency { id: 'medicines-optimisation', name: 'Medicines Optimisation', @@ -151,52 +150,6 @@ export const skills: SkillMedication[] = [ { year: 2025, description: 'Current: £14.6M efficiency programme delivery' }, ], }, - { - id: 'population-health', - name: 'Population Health', - shortName: 'Pop. Health', - frequency: 'Daily', - startYear: 2024, - yearsOfExperience: 2, - category: 'Clinical', - status: 'Active', - icon: 'Users', - prescribingHistory: [ - { year: 2024, description: 'Started: 1.2M population coverage, ICS-wide analytics' }, - { year: 2025, description: 'Current: Health inequality analysis, population-level interventions' }, - ], - }, - { - id: 'nice-ta', - name: 'NICE TA Implementation', - shortName: 'NICE TA', - frequency: '1–2x weekly', - startYear: 2022, - yearsOfExperience: 4, - category: 'Clinical', - status: 'Active', - icon: 'FileCheck', - prescribingHistory: [ - { year: 2022, description: 'Started: High-cost drug pathway development' }, - { year: 2023, description: 'Increased: Multi-specialty pathway authoring' }, - { year: 2024, description: 'Current: Tirzepatide (TA1026) commissioning' }, - ], - }, - { - id: 'health-system-prescribing', - name: 'Health System Prescribing Mgmt', - shortName: 'Prescribing Mgmt', - frequency: 'Daily', - startYear: 2024, - yearsOfExperience: 2, - category: 'Clinical', - status: 'Active', - icon: 'Stethoscope', - prescribingHistory: [ - { year: 2024, description: 'Started: dm+d medicines data table, OME calculations, prescribing infrastructure' }, - { year: 2025, description: 'Current: Patient-level SQL analytics, self-serve prescribing model' }, - ], - }, { id: 'health-economics', name: 'Health Economics', @@ -213,6 +166,36 @@ export const skills: SkillMedication[] = [ { year: 2025, description: 'Current: Efficiency programme prioritisation' }, ], }, + { + id: 'population-health', + name: 'Population Health', + shortName: 'Pop. Health', + frequency: 'Daily', + startYear: 2024, + yearsOfExperience: 2, + category: 'Clinical', + status: 'Active', + icon: 'Users', + prescribingHistory: [ + { year: 2024, description: 'Started: 1.2M population coverage, ICS-wide analytics' }, + { year: 2025, description: 'Current: Health inequality analysis, population-level interventions' }, + ], + }, + { + id: 'health-system-prescribing', + name: 'Health System Prescribing Mgmt', + shortName: 'Prescribing Mgmt', + frequency: 'Daily', + startYear: 2024, + yearsOfExperience: 2, + category: 'Clinical', + status: 'Active', + icon: 'Stethoscope', + prescribingHistory: [ + { year: 2024, description: 'Started: dm+d medicines data table, OME calculations, prescribing infrastructure' }, + { year: 2025, description: 'Current: Patient-level SQL analytics, self-serve prescribing model' }, + ], + }, { id: 'clinical-pathways', name: 'Clinical Pathways', @@ -229,6 +212,22 @@ export const skills: SkillMedication[] = [ { year: 2024, description: 'Current: System-wide pathway governance' }, ], }, + { + id: 'nice-ta', + name: 'NICE TA Implementation', + shortName: 'NICE TA', + frequency: '1–2x weekly', + startYear: 2022, + yearsOfExperience: 4, + category: 'Clinical', + status: 'Active', + icon: 'FileCheck', + prescribingHistory: [ + { year: 2022, description: 'Started: High-cost drug pathway development' }, + { year: 2023, description: 'Increased: Multi-specialty pathway authoring' }, + { year: 2024, description: 'Current: Tirzepatide (TA1026) commissioning' }, + ], + }, { id: 'formulary-commissioning', name: 'Formulary & Commissioning', @@ -251,7 +250,7 @@ export const skills: SkillMedication[] = [ shortName: 'Community Pharm.', frequency: 'As needed', startYear: 2015, - yearsOfExperience: 11, + yearsOfExperience: 7, category: 'Clinical', status: 'Active', icon: 'Store', @@ -262,36 +261,37 @@ export const skills: SkillMedication[] = [ ], }, - // Strategic (6 skills) + // Strategic (6 skills) — sorted by frequency { - id: 'executive-comms', - name: 'Executive Communication', - shortName: 'Exec. Comms', - frequency: 'Bi-monthly', - startYear: 2024, - yearsOfExperience: 2, - category: 'Strategic', - status: 'Active', - icon: 'Presentation', - prescribingHistory: [ - { year: 2024, description: 'Started: CMO presentations, executive stakeholder engagement' }, - { year: 2025, description: 'Current: System-level programme board reporting' }, - ], - }, - { - id: 'financial-modelling', - name: 'Financial Scenario Modelling', - shortName: 'Financial Modelling', - frequency: 'Weekly', + id: 'stakeholder-engagement', + name: 'Stakeholder Engagement', + shortName: 'Stakeholders', + frequency: 'Daily', startYear: 2022, yearsOfExperience: 4, category: 'Strategic', status: 'Active', - icon: 'Calculator', + icon: 'Handshake', prescribingHistory: [ - { year: 2022, description: 'Started: High-cost drug financial impact modelling' }, - { year: 2024, description: 'Increased: DOAC switching scenario model, rebate mechanics' }, - { year: 2025, description: 'Current: Efficiency programme prioritisation, £215M budget forecasting' }, + { year: 2022, description: 'Started: Clinical lead engagement across care sectors' }, + { year: 2024, description: 'Increased: Executive communication, CMO presentations' }, + { year: 2025, description: 'Current: System-level programme board reporting' }, + ], + }, + { + id: 'healthcare-leadership', + name: 'Healthcare Leadership', + shortName: 'Leadership', + frequency: 'Daily', + startYear: 2018, + yearsOfExperience: 8, + category: 'Strategic', + status: 'Active', + icon: 'Users', + prescribingHistory: [ + { year: 2018, description: 'Started: NHS Mary Seacole Leadership Programme, system-level thinking' }, + { year: 2022, description: 'Increased: ICS-level strategic leadership, cross-sector engagement' }, + { year: 2025, description: 'Current: Interim Head of Population Health, CMO reporting line' }, ], }, { @@ -311,18 +311,33 @@ export const skills: SkillMedication[] = [ ], }, { - id: 'stakeholder-engagement', - name: 'Stakeholder Engagement', - shortName: 'Stakeholders', - frequency: 'Daily', + id: 'financial-modelling', + name: 'Financial Scenario Modelling', + shortName: 'Financial Modelling', + frequency: 'Weekly', startYear: 2022, yearsOfExperience: 4, category: 'Strategic', status: 'Active', - icon: 'Handshake', + icon: 'Calculator', prescribingHistory: [ - { year: 2022, description: 'Started: Clinical lead engagement across care sectors' }, - { year: 2024, description: 'Increased: Executive communication, CMO presentations' }, + { year: 2022, description: 'Started: High-cost drug financial impact modelling' }, + { year: 2024, description: 'Increased: DOAC switching scenario model, rebate mechanics' }, + { year: 2025, description: 'Current: Efficiency programme prioritisation, £215M budget forecasting' }, + ], + }, + { + id: 'executive-comms', + name: 'Executive Communication', + shortName: 'Exec. Comms', + frequency: 'Bi-monthly', + startYear: 2024, + yearsOfExperience: 2, + category: 'Strategic', + status: 'Active', + icon: 'Presentation', + prescribingHistory: [ + { year: 2024, description: 'Started: CMO presentations, executive stakeholder engagement' }, { year: 2025, description: 'Current: System-level programme board reporting' }, ], }, @@ -338,19 +353,20 @@ export const skills: SkillMedication[] = [ icon: 'RefreshCw', }, { - id: 'healthcare-leadership', - name: 'Healthcare Leadership', - shortName: 'Leadership', + id: 'team-development', + name: 'Team Development', + shortName: 'Team Dev', frequency: 'Daily', - startYear: 2018, - yearsOfExperience: 8, + startYear: 2011, + yearsOfExperience: 15, category: 'Strategic', status: 'Active', - icon: 'Crown', + icon: 'UserPlus', prescribingHistory: [ - { year: 2018, description: 'Started: NHS Mary Seacole Leadership Programme, system-level thinking' }, - { year: 2022, description: 'Increased: ICS-level strategic leadership, cross-sector engagement' }, - { year: 2025, description: 'Current: Interim Head of Population Health, CMO reporting line' }, + { year: 2017, description: 'Started: National induction training, NVQ supervision, NMS training video' }, + { year: 2018, description: 'Developed: Mary Seacole Programme, formal leadership development' }, + { year: 2024, description: 'Increased: Data literacy programme, self-serve analytics capability building' }, + { year: 2025, description: 'Current: Team-wide analytical upskilling, self-serve model adoption' }, ], }, ] diff --git a/src/data/timeline.ts b/src/data/timeline.ts index 81d8ac2..c06090e 100644 --- a/src/data/timeline.ts +++ b/src/data/timeline.ts @@ -66,6 +66,7 @@ const timelineEntitySeeds: TimelineEntity[] = [ 'stakeholder-engagement', 'change-management', 'healthcare-leadership', + 'team-development', ], skillStrengths: { 'data-analysis': 1.0, @@ -88,6 +89,7 @@ const timelineEntitySeeds: TimelineEntity[] = [ 'stakeholder-engagement': 0.9, 'change-management': 0.7, 'healthcare-leadership': 0.9, + 'team-development': 0.8, }, }, { @@ -149,6 +151,7 @@ const timelineEntitySeeds: TimelineEntity[] = [ 'stakeholder-engagement', 'change-management', 'healthcare-leadership', + 'team-development', ], skillStrengths: { 'data-analysis': 0.95, @@ -171,6 +174,7 @@ const timelineEntitySeeds: TimelineEntity[] = [ 'stakeholder-engagement': 0.9, 'change-management': 0.7, 'healthcare-leadership': 0.85, + 'team-development': 0.75, }, }, { @@ -281,6 +285,7 @@ const timelineEntitySeeds: TimelineEntity[] = [ 'change-management', 'stakeholder-engagement', 'healthcare-leadership', + 'team-development', ], skillStrengths: { 'data-analysis': 0.7, @@ -290,6 +295,7 @@ const timelineEntitySeeds: TimelineEntity[] = [ 'change-management': 0.6, 'stakeholder-engagement': 0.6, 'healthcare-leadership': 0.7, + 'team-development': 0.8, }, }, { @@ -375,13 +381,50 @@ const timelineEntitySeeds: TimelineEntity[] = [ 'change-management': 0.4, }, }, + { + id: 'nhs-mary-seacole-2018', + kind: 'education', + title: 'Mary Seacole Programme', + graphLabel: 'Mary Seacole', + organization: 'NHS Leadership Academy', + orgColor: '#6B21A8', + dateRange: { + start: '2018-01-01', + end: '2018-12-31', + display: '2018', + startYear: 2018, + endYear: 2018, + }, + description: 'Formal NHS leadership qualification providing theoretical grounding in healthcare leadership approaches, change management, and system-level thinking. Achieved programme score of 78%.', + details: [ + 'Programme score: 78%', + 'Healthcare leadership and change management', + 'System-level thinking and leading without authority', + ], + outcomes: [ + 'Theoretical grounding in healthcare leadership approaches', + 'Enhanced change management capabilities', + 'System-level strategic thinking skills', + ], + codedEntries: [ + { code: 'LDR001', description: 'NHS Leadership qualification — 78%' }, + { code: 'CHG001', description: 'Change management and system-level thinking' }, + ], + skills: ['healthcare-leadership', 'change-management', 'stakeholder-engagement', 'team-development'], + skillStrengths: { + 'healthcare-leadership': 0.7, + 'change-management': 0.6, + 'stakeholder-engagement': 0.5, + 'team-development': 0.5, + }, + }, { id: 'uea-mpharm-2011', kind: 'education', title: 'MPharm (Hons) 2:1', graphLabel: 'MPharm', organization: 'University of East Anglia', - orgColor: '#7B2D8E', + orgColor: '#6B21A8', dateRange: { start: '2011-09-01', end: '2015-06-30', @@ -416,7 +459,7 @@ const timelineEntitySeeds: TimelineEntity[] = [ title: 'A-Levels', graphLabel: 'A-Levels', organization: 'Highworth Grammar School', - orgColor: '#9C27B0', + orgColor: '#6B21A8', dateRange: { start: '2009-09-01', end: '2011-06-30', diff --git a/src/hooks/useForceSimulation.ts b/src/hooks/useForceSimulation.ts index ede3d29..441283a 100644 --- a/src/hooks/useForceSimulation.ts +++ b/src/hooks/useForceSimulation.ts @@ -26,6 +26,10 @@ function hashString(input: string): number { return Math.abs(hash) } +function isRoleNode(type: string): boolean { + return type === 'role' +} + function isEntityNode(type: string): boolean { return type === 'role' || type === 'education' } @@ -48,7 +52,8 @@ function getHeight(width: number, containerHeight?: number | null): number { return 400 } -const roleNodes = constellationNodes.filter(n => (n.type === 'role' || n.type === 'education') && !HIDDEN_ENTITY_IDS.has(n.id)) +const roleNodes = constellationNodes.filter(n => n.type === 'role' && !HIDDEN_ENTITY_IDS.has(n.id)) +const educationNodes = constellationNodes.filter(n => n.type === 'education' && !HIDDEN_ENTITY_IDS.has(n.id)) export function useForceSimulation( svgRef: React.RefObject, @@ -84,7 +89,8 @@ export function useForceSimulation( svg.selectAll('*').remove() - const years = roleNodes.map(n => fractionalYear(n)) + const allEntityNodes = [...roleNodes, ...educationNodes] + const years = allEntityNodes.map(n => fractionalYear(n)) const minYear = Math.min(...years) const maxYear = Math.max(...years) @@ -301,6 +307,16 @@ export function useForceSimulation( const skillZoneLeft = sidePadding + srActive const skillZoneWidth = skillZoneRight - skillZoneLeft + // Education nodes sit on the left side, timeline-anchored on Y + const educationInitialMap = new Map() + const eduX = skillZoneLeft + rw / 2 + (isMobile ? 8 : Math.round(12 * sf)) + educationNodes.forEach((edu) => { + educationInitialMap.set(edu.id, { + x: eduX, + y: yScale(fractionalYear(edu)), + }) + }) + // Pre-compute skill homeY and group by role-set to offset overlaps const skillRoleKey = new Map() // skillId -> sorted role key const skillBaseY = new Map() // skillId -> base homeY @@ -312,7 +328,7 @@ export function useForceSimulation( skillRoleKey.set(n.id, key) const positions = roleIds - .map(roleId => roleInitialMap.get(roleId)) + .map(roleId => roleInitialMap.get(roleId) ?? educationInitialMap.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 @@ -336,7 +352,11 @@ export function useForceSimulation( }) const nodes: SimNode[] = visibleNodeData.map(n => { - if (isEntityNode(n.type)) { + if (n.type === 'education') { + const pos = educationInitialMap.get(n.id)! + return { ...n, x: pos.x, y: pos.y, vx: 0, vy: 0, homeX: pos.x, homeY: pos.y } + } + if (isRoleNode(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 } } @@ -517,9 +537,9 @@ export function useForceSimulation( }) }) - // Entity connectors to timeline + // Role connectors to timeline (education nodes on left don't connect) const roleConnectors = connectorGroup.selectAll('line.role-connector') - .data(nodes.filter(n => isEntityNode(n.type))) + .data(nodes.filter(n => isRoleNode(n.type))) .join('line') .attr('class', 'role-connector') .attr('stroke', 'var(--border)') @@ -539,13 +559,15 @@ export function useForceSimulation( .id(d => d.id) .distance(isMobile ? 56 : Math.round(120 * sf)) .strength(d => (d as SimLink).strength * 0.15)) - .force('x', d3.forceX(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.6)) + .force('x', d3.forceX(d => d.homeX).strength(d => + isRoleNode(d.type) ? 1.0 : d.type === 'education' ? 0.9 : 0.6 + )) .force('y', d3.forceY(d => { if (isEntityNode(d.type)) { return yScale(fractionalYear(d)) } return d.homeY - }).strength(d => isEntityNode(d.type) ? 0.98 : 0.25)) + }).strength(d => isRoleNode(d.type) ? 0.98 : d.type === 'education' ? 0.85 : 0.25)) .force('collide', d3.forceCollide(d => isEntityNode(d.type) ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf)) ).iterations(3)) @@ -556,9 +578,12 @@ export function useForceSimulation( const renderTick = () => { nodes.forEach(d => { - if (isEntityNode(d.type)) { + if (isRoleNode(d.type)) { 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 if (d.type === 'education') { + d.x = Math.max(rw / 2 + 6, Math.min(skillZoneRight, 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(skillZoneRight, d.x)) d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y))