/** * 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 as RechartsTooltip, Legend, ReferenceLine, ResponsiveContainer, } from 'recharts'; import { Tooltip as UiTooltip, TooltipTrigger as UiTooltipTrigger, TooltipContent as UiTooltipContent, TooltipProvider as UiTooltipProvider, } from './ui/tooltip'; // 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; // 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); }, []); // Use shorter captions on narrow containers to reduce wrapping const isCompactLabels = containerWidth < 640; // tweakable threshold for mobile const seriesLabels = React.useMemo>(() => { const damphFull = t('dAmphetamine'); const damphShort = t('dAmphetamineShort', { defaultValue: damphFull }); const ldxFull = t('lisdexamfetamine'); const ldxShort = t('lisdexamfetamineShort', { defaultValue: ldxFull }); const overlayFull = t('regularPlanOverlay'); const overlayShort = t('regularPlanOverlayShort', { defaultValue: overlayFull }); const useShort = isCompactLabels; return { combinedDamph: { full: damphFull, short: damphShort, display: useShort ? damphShort : damphFull, }, combinedLdx: { full: ldxFull, short: ldxShort, display: useShort ? ldxShort : ldxFull, }, templateDamph: { full: `${damphFull} (${overlayFull})`, short: `${damphShort} (${overlayShort})`, display: useShort ? `${damphShort} (${overlayShort})` : `${damphFull} (${overlayFull})`, }, templateLdx: { full: `${ldxFull} (${overlayFull})`, short: `${ldxShort} (${overlayShort})`, display: useShort ? `${ldxShort} (${overlayShort})` : `${ldxFull} (${overlayFull})`, }, }; }, [isCompactLabels, t]); const simDays = parseInt(simulationDays, 10) || 3; // Y-axis takes ~80px, scrollable area gets the rest const yAxisWidth = 80; const scrollableWidth = containerWidth - yAxisWidth; // Dynamically calculate tick interval based on available pixel width // Aim for ~46px per label to avoid overlaps on narrow screens const xTickInterval = React.useMemo(() => { const MIN_PX_PER_TICK = 46; const intervals = [1, 2, 3, 4, 6, 8, 12, 24]; const pxPerDay = scrollableWidth / Math.max(1, dispDays); const ticksPerDay = Math.floor(pxPerDay / MIN_PX_PER_TICK); // Plenty of room: allow hourly ticks if (ticksPerDay >= 16) return 1; // Extremely tight: show one tick per day boundary if (ticksPerDay <= 0) return 24; const idealInterval = 24 / ticksPerDay; const selected = intervals.find((value) => value >= idealInterval); return selected ?? 24; }, [dispDays, scrollableWidth]); // 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, useShort = false) => { if (dayNumber === 1) return t(useShort ? 'refLineRegularPlanShort' : 'refLineRegularPlan'); const hasSchedule = days && days.length >= dayNumber; const hasDeviation = daysWithDeviations.has(dayNumber); if (!hasDeviation) { return t(useShort ? 'refLineNoDeviationShort' : 'refLineNoDeviation'); } else if (!hasSchedule) { return t(useShort ? 'refLineRecoveringShort' : 'refLineRecovering'); } else { return t(useShort ? 'refLineIrregularIntakeShort' : '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 width for scrollable area const chartWidth = simDays <= dispDays ? scrollableWidth : Math.ceil((scrollableWidth / dispDays) * simDays); const renderLegend = React.useCallback((props: any) => { const { payload } = props; if (!payload) return null; return (
    {payload.map((item: any) => { const labelInfo = seriesLabels[item.dataKey] || { display: item.value, full: item.value }; const opacity = item.payload?.opacity ?? 1; return (
  • {labelInfo.display} {labelInfo.full}
  • ); })}
); }, [seriesLabels]); return (
{/* Fixed Legend at top */}
{renderLegend({ payload: [ ...(chartView === 'damph' || chartView === 'both' ? [ { dataKey: 'combinedDamph', value: seriesLabels.combinedDamph.display, color: CHART_COLORS.idealDamph, payload: { opacity: 1 }, }, ] : []), ...(chartView === 'ldx' || chartView === 'both' ? [ { dataKey: 'combinedLdx', value: seriesLabels.combinedLdx.display, color: CHART_COLORS.idealLdx, payload: { opacity: 1 }, }, ] : []), ...(templateProfile && daysWithDeviations.size > 0 && (chartView === 'damph' || chartView === 'both') ? [ { dataKey: 'templateDamph', value: seriesLabels.templateDamph.display, color: CHART_COLORS.idealDamph, payload: { opacity: 0.5 }, }, ] : []), ...(templateProfile && daysWithDeviations.size > 0 && (chartView === 'ldx' || chartView === 'both') ? [ { dataKey: 'templateLdx', value: seriesLabels.templateLdx.display, color: CHART_COLORS.idealLdx, payload: { opacity: 0.5 }, }, ] : []), ], })}
{/* 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}${t('unitHour')}`; } 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 labelInfo = seriesLabels[entry.dataKey] || { display: entry.name, full: entry.name }; const isTemplate = entry.dataKey?.toString().includes('template'); const opacity = isTemplate ? 0.5 : 1; const value = typeof entry.value === 'number' ? entry.value.toFixed(1) : entry.value; return (
  • {labelInfo.display} : {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 => { // Determine whether to use compact day labels to avoid overlap on narrow screens const pxPerDay = scrollableWidth / Math.max(1, dispDays); let label = ""; if (pxPerDay < 75) { // tweakable threshold, minimal label label = t('refLineDayShort', { x: day + 1 }); } else if (pxPerDay < 110) { // tweakable threshold, compact label label = t('refLineDayShort', { x: day + 1 }) + ' ' + getDayLabel(day + 1, true); } else { // full label label = t('refLineDayX', { x: day + 1 }) + ' ' + getDayLabel(day + 1); } return ( ); })} {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;