/** * Simulation Chart Component * * Visualizes pharmacokinetic concentration profiles over time using Recharts. * Displays ideal plan, deviated profile, and corrected profile with * therapeutic range indicators. Supports multiple chart views and x-axis formats. * * @author Andreas Weyer * @license MIT */ import React from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts'; // Chart color scheme const CHART_COLORS = { // d-Amphetamine profiles idealDamph: '#2563eb', // blue-600 (primary, solid, bold) deviatedDamph: '#f59e0b', // amber-500 (warning, dashed) correctedDamph: '#10b981', // emerald-500 (success, dash-dot) // Lisdexamfetamine profiles idealLdx: '#7c3aed', // violet-600 (primary, dashed) deviatedLdx: '#f97316', // orange-500 (warning, dashed) correctedLdx: '#059669', // emerald-600 (success, dash-dot) // Reference lines regularPlanDivider: '#22c55e', // green-500 deviationDayDivider: '#9ca3af', // gray-400 therapeuticMin: '#22c55e', // green-500 therapeuticMax: '#ef4444', // red-500 dayDivider: '#9ca3af', // gray-400 // Tooltip cursor cursor: '#6b7280' // gray-500 } as const; const SimulationChart = ({ combinedProfile, templateProfile, chartView, showDayTimeOnXAxis, showDayReferenceLines, showTherapeuticRange, therapeuticRange, simulationDays, displayedDays, yAxisMin, yAxisMax, days, t }: any) => { const totalHours = (parseInt(simulationDays, 10) || 3) * 24; const dispDays = parseInt(displayedDays, 10) || 2; // Dynamically calculate tick interval based on displayed days // Aim for ~40-50 pixels per tick for readability const xTickInterval = React.useMemo(() => { // Scale interval with displayed days: 1 day = 1h, 2 days = 2h, 3-4 days = 3h, 5+ days = 6h if (dispDays <= 1) return 1; if (dispDays <= 2) return 2; if (dispDays <= 4) return 3; if (dispDays <= 6) return 4; return 6; }, [dispDays]); // Generate ticks for continuous time axis const chartTicks = React.useMemo(() => { const ticks = []; for (let i = 0; i <= totalHours; i += xTickInterval) { ticks.push(i); } return ticks; }, [totalHours, xTickInterval]); const chartDomain = React.useMemo(() => { const numMin = parseFloat(yAxisMin); const numMax = parseFloat(yAxisMax); // Calculate actual data range if auto is needed let dataMin = Infinity; let dataMax = -Infinity; if (isNaN(numMin) || isNaN(numMax)) { // Scan through combined profile data to find actual min/max combinedProfile?.forEach((point: any) => { if (chartView === 'damph' || chartView === 'both') { dataMin = Math.min(dataMin, point.damph); dataMax = Math.max(dataMax, point.damph); } if (chartView === 'ldx' || chartView === 'both') { dataMin = Math.min(dataMin, point.ldx); dataMax = Math.max(dataMax, point.ldx); } }); // Also check template profile if shown templateProfile?.forEach((point: any) => { if (chartView === 'damph' || chartView === 'both') { dataMin = Math.min(dataMin, point.damph); dataMax = Math.max(dataMax, point.damph); } if (chartView === 'ldx' || chartView === 'both') { dataMin = Math.min(dataMin, point.ldx); dataMax = Math.max(dataMax, point.ldx); } }); // Add small padding (5% on each side) for better visualization const range = dataMax - dataMin; const padding = range * 0.05; dataMin = Math.max(0, dataMin - padding); // Don't go below 0 dataMax = dataMax + padding; } const domainMin = !isNaN(numMin) ? numMin : (dataMin !== Infinity ? Math.floor(dataMin) : 0); const domainMax = !isNaN(numMax) ? numMax : (dataMax !== -Infinity ? Math.ceil(dataMax) : 100); return [domainMin, domainMax]; }, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]); // Check which days have deviations (differ from template) const daysWithDeviations = React.useMemo(() => { if (!templateProfile || !combinedProfile) return new Set(); const deviatingDays = new Set(); const simDays = parseInt(simulationDays, 10) || 3; // Check each day starting from day 2 (day 1 is always template) for (let day = 2; day <= simDays; day++) { const dayStartHour = (day - 1) * 24; const dayEndHour = day * 24; // Sample points in this day to check for differences // Check every hour in the day for (let hour = dayStartHour; hour < dayEndHour; hour++) { const combinedPoint = combinedProfile.find((p: any) => Math.abs(p.timeHours - hour) < 0.1); const templatePoint = templateProfile.find((p: any) => Math.abs(p.timeHours - hour) < 0.1); if (combinedPoint && templatePoint) { // Consider it different if values differ by more than 0.01 (tolerance for floating point) const damphDiff = Math.abs(combinedPoint.damph - templatePoint.damph); const ldxDiff = Math.abs(combinedPoint.ldx - templatePoint.ldx); if (damphDiff > 0.01 || ldxDiff > 0.01) { deviatingDays.add(day); break; // Found deviation in this day, no need to check more hours } } } } return deviatingDays; }, [combinedProfile, templateProfile, simulationDays]); // Determine label for each day's reference line const getDayLabel = React.useCallback((dayNumber: number) => { if (dayNumber === 1) return t('refLineRegularPlan'); // Check if this day has an actual schedule entry (not auto-filled) const hasSchedule = days && days.length >= dayNumber; // Check if this day deviates from template const hasDeviation = daysWithDeviations.has(dayNumber); if (!hasDeviation) { // Matches template return t('refLineNoDeviation'); } else if (!hasSchedule) { // Deviates but no schedule = recovering return t('refLineRecovering'); } else { // Has deviation and has schedule = actual irregular intake return t('refLineIrregularIntake'); } }, [days, daysWithDeviations, t]); // Merge all profiles into a single dataset for proper tooltip synchronization const mergedData = React.useMemo(() => { const dataMap = new Map(); // Add combined profile data (actual plan with all days) combinedProfile?.forEach((point: any) => { dataMap.set(point.timeHours, { timeHours: point.timeHours, combinedDamph: point.damph, combinedLdx: point.ldx }); }); // Add template profile data (regular plan only) if provided // Only include points for days that have deviations templateProfile?.forEach((point: any) => { const pointDay = Math.ceil(point.timeHours / 24); // Only include template data for days with deviations if (daysWithDeviations.has(pointDay)) { const existing = dataMap.get(point.timeHours) || { timeHours: point.timeHours }; dataMap.set(point.timeHours, { ...existing, templateDamph: point.damph, templateLdx: point.ldx }); } }); return Array.from(dataMap.values()).sort((a, b) => a.timeHours - b.timeHours); }, [combinedProfile, templateProfile, daysWithDeviations]); // Calculate chart dimensions const [containerWidth, setContainerWidth] = React.useState(1000); const containerRef = React.useRef(null); React.useEffect(() => { const updateWidth = () => { if (containerRef.current) { setContainerWidth(containerRef.current.clientWidth); } }; updateWidth(); window.addEventListener('resize', updateWidth); return () => window.removeEventListener('resize', updateWidth); }, []); const simDays = parseInt(simulationDays, 10) || 3; // Y-axis takes ~80px, scrollable area gets the rest const yAxisWidth = 80; const scrollableWidth = containerWidth - yAxisWidth; // Calculate chart width for scrollable area const chartWidth = simDays <= dispDays ? scrollableWidth : Math.ceil((scrollableWidth / dispDays) * simDays); return (
{/* Fixed Legend at top */}
{ // Apply lighter color to template overlay entries in legend const isTemplate = value.includes(t('regularPlanOverlay')); return {value}; }} /> {/* Invisible lines just to show in legend */} {(chartView === 'damph' || chartView === 'both') && ( )} {(chartView === 'ldx' || chartView === 'both') && ( )} {templateProfile && daysWithDeviations.size > 0 && (chartView === 'damph' || chartView === 'both') && ( )} {templateProfile && daysWithDeviations.size > 0 && (chartView === 'ldx' || chartView === 'both') && ( )}
{/* Chart */}
{/* Scrollable chart area */}
{/** Custom tick renderer to italicize 'Noon' only in 12h mode */} {(() => { const CustomTick = (props: any) => { const { x, y, payload } = props; const h = payload.value as number; let label: string; if (showDayTimeOnXAxis === '24h') { label = `${h % 24}h`; } else if (showDayTimeOnXAxis === '12h') { const hour12 = h % 24; if (hour12 === 12) { label = t('tickNoon'); return ( {label} ); } const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12; const period = hour12 < 12 ? 'a' : 'p'; label = `${displayHour}${period}`; } else { label = `${h}`; } return ( {label} ); }; return } />; })()} { if (!active || !payload || payload.length === 0) return null; // Extract timeHours from the payload data point const timeHours = payload[0]?.payload?.timeHours ?? label; const h = typeof timeHours === 'number' ? timeHours : parseFloat(timeHours); // Format time to match x-axis format let timeLabel: string; if (showDayTimeOnXAxis === '24h') { timeLabel = `${h % 24}${t('unitHour')}`; } else if (showDayTimeOnXAxis === '12h') { const hour12 = h % 24; if (hour12 === 12) { timeLabel = t('tickNoon'); } else { const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12; const period = hour12 < 12 ? 'a' : 'p'; timeLabel = `${displayHour}${period}`; } } else { timeLabel = `${h}${t('unitHour')}`; } return (

{t('time')}: {timeLabel}

    {payload.map((entry: any, index: number) => { const isTemplate = entry.name?.includes(t('regularPlanOverlay')); const opacity = isTemplate ? 0.5 : 1; const value = typeof entry.value === 'number' ? entry.value.toFixed(1) : entry.value; return (
  • {entry.name} : {value} {t('unitNgml')}
  • ); })}
); }} wrapperStyle={{ pointerEvents: 'none', zIndex: 200 }} allowEscapeViewBox={{ x: false, y: false }} cursor={{ stroke: CHART_COLORS.cursor, strokeWidth: 1, strokeDasharray: '1 1' }} position={{ y: 0 }} /> {showDayReferenceLines !== false && [...Array(dispDays+1).keys()].map(day => ( ))} {showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && ( )} {showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && ( )} {[...Array(parseInt(simulationDays, 10) || 3).keys()].map(day => ( day > 0 && ( ) ))} {(chartView === 'damph' || chartView === 'both') && ( )} {(chartView === 'ldx' || chartView === 'both') && ( )} {templateProfile && (chartView === 'damph' || chartView === 'both') && ( )} {templateProfile && (chartView === 'ldx' || chartView === 'both') && ( )}
); }; export default SimulationChart;