/** * 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, } from './ui/tooltip'; import { useElementSize } from '../hooks/useElementSize'; // TODO make use of the actual theme colors;some colors are not matching the classes in the comments // 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: '#f59e0b', // yellow-500 therapeuticMin: '#22c55e', // green-500 therapeuticMax: '#ef4444', // red-500 dayDivider: '#9ca3af', // gray-400 // Tooltip cursor cursor: '#6b7280' // gray-500 } as const; const SimulationChart = React.memo(({ combinedProfile, templateProfile, chartView, showDayTimeOnXAxis, showDayReferenceLines, showIntakeTimeLines, showTherapeuticRange, therapeuticRange, simulationDays, displayedDays, yAxisMin, yAxisMax, days, t }: any) => { const totalHours = (parseInt(simulationDays, 10) || 3) * 24; const dispDays = parseInt(displayedDays, 10) || 2; const simDays = parseInt(simulationDays, 10) || 3; // Calculate chart dimensions using debounced element size observer const containerRef = React.useRef(null); const { width: containerWidth } = useElementSize(containerRef, 150); // Guard against invalid dimensions during initial render const yAxisWidth = 80; const minContainerWidth = yAxisWidth + 100; // Minimum 100px for chart area const safeContainerWidth = Math.max(containerWidth, minContainerWidth); // Track current theme for chart styling const [isDarkTheme, setIsDarkTheme] = React.useState(false); React.useEffect(() => { const checkTheme = () => { setIsDarkTheme(document.documentElement.classList.contains('dark')); }; checkTheme(); // Use MutationObserver to detect theme changes const observer = new MutationObserver(checkTheme); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); return () => observer.disconnect(); }, []); // Calculate scrollable width using safe container width const scrollableWidth = safeContainerWidth - yAxisWidth; // Calculate chart width for scrollable area const chartWidth = simDays <= dispDays ? scrollableWidth : Math.ceil((scrollableWidth / dispDays) * simDays); // Use shorter captions on narrow containers to reduce wrapping const isCompactLabels = safeContainerWidth < 640; // tweakable threshold for mobile // Precompute series labels with translations 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]); // Dynamically calculate tick interval based on available pixel width const xTickInterval = React.useMemo(() => { // Aim for ~46px per label to avoid overlaps on narrow screens //const MIN_PX_PER_TICK = 46; 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 x-axis ticks for continuous time axis const xAxisTicks = React.useMemo(() => { const ticks = []; for (let i = 0; i <= totalHours; i += xTickInterval) { ticks.push(i); } return ticks; }, [totalHours, xTickInterval]); // Custom tick renderer for x-axis to handle 12h/24h/continuous formats and dark mode // Memoized to prevent unnecessary re-renders const XAxisTick = React.useCallback((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} ); }, [showDayTimeOnXAxis, isDarkTheme, t]); // Custom tick renderer for y-axis to handle dark mode // Memoized to prevent unnecessary re-renders const YAxisTick = React.useCallback((props: any) => { const { x, y, payload } = props; return ( {payload.value} ); }, [isDarkTheme]); // Calculate Y-axis domain based on data and user settings const yAxisDomain = 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); } }); } // Calculate final domain min let domainMin: number; if (!isNaN(numMin)) { // max value provided via settings // User set yAxisMin explicitly domainMin = numMin; } else if (dataMin !== Infinity) { // data exists // Auto mode: add 10% padding below so the line is not flush with x-axis const range = dataMax - dataMin; const padding = range * 0.1; domainMin = Math.max(0, dataMin - padding); } else { // no data domainMin = 0; } // Calculate final domain max let domainMax: number; if (!isNaN(numMax)) { // max value provided via settings if (dataMax !== -Infinity) { // User set yAxisMax explicitly // Add padding to dataMax and use the higher of manual or (dataMax + padding) const range = dataMax - dataMin; const padding = range * 0.1; const dataMaxWithPadding = dataMax + padding; // Use manual max only if it's higher than dataMax + padding domainMax = Math.max(numMax, dataMaxWithPadding); } else { // No data, use manual max as-is domainMax = numMax; } } else if (dataMax !== -Infinity) { // data exists // Auto mode: add 10% padding above const range = dataMax - dataMin; const padding = range * 0.1; domainMax = dataMax + padding; } else { // no data domainMax = 100; } return [domainMin, domainMax]; }, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]); // Check which days have deviations (differ from regular plan) 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 regular plan) 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]); // Extract all intake times from all days for intake time reference lines const intakeTimes = React.useMemo(() => { if (!days || !Array.isArray(days)) return []; const times: Array<{ hour: number; dayIndex: number; doseIndex: number }> = []; const simDaysCount = parseInt(simulationDays, 10) || 3; // Iterate through each simulated day for (let dayNum = 1; dayNum <= simDaysCount; dayNum++) { // Determine which schedule to use for this day let daySchedule; if (dayNum === 1 || days.length === 1) { // First day or only one schedule exists: use template/first schedule daySchedule = days.find(d => d.isTemplate) || days[0]; } else { // For subsequent days, use the corresponding schedule if it exists, otherwise use template const scheduleIndex = dayNum - 1; daySchedule = days[scheduleIndex] || days.find(d => d.isTemplate) || days[0]; } if (daySchedule && daySchedule.doses) { daySchedule.doses.forEach((dose: any, doseIdx: number) => { if (dose.time) { const [hours, minutes] = dose.time.split(':').map(Number); const hoursSinceStart = (dayNum - 1) * 24 + hours + minutes / 60; times.push({ hour: hoursSinceStart, dayIndex: dayNum, doseIndex: doseIdx + 1 // 1-based index }); } }); } } return times; }, [days, simulationDays]); // 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]); // Render legend with tooltips for full names (custom legend renderer) 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]); // Don't render chart if dimensions are invalid (prevents crash during initialization) if (chartWidth <= 0 || scrollableWidth <= 0) { return (

{t('loadingChart', { defaultValue: 'Loading chart...' })}

); } // Render the chart 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 */ } } ticks={xAxisTicks} tickCount={xAxisTicks.length} //tickCount={200} //interval={1} allowDecimals={false} allowDataOverflow={false} /> } allowDecimals={true} allowDataOverflow={false} /> { 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') && therapeuticRange.min && !isNaN(parseFloat(therapeuticRange.min)) && ( )} {showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && therapeuticRange.max && !isNaN(parseFloat(therapeuticRange.max)) && ( )} {showIntakeTimeLines && intakeTimes.map((intake, idx) => { // Determine label position offset if day lines are also shown const labelOffsetY = showDayReferenceLines !== false ? 20 : 5; // More spacing when day lines are shown return ( { const { viewBox } = props; // Position at top-right of the reference line with proper offsets // x: subtract 5px from right edge to create gap between line and text // y: add offset + ~12px (font size) since y is the text baseline, not top const x = viewBox.x + viewBox.width - 5; const y = viewBox.y + labelOffsetY + 12; // 12px ≈ 0.75rem font size return ( {intake.doseIndex} ); }} stroke="#c0c0c0" strokeDasharray="3 3" xAxisId="hours" yAxisId="concentration" /> ); })} {[...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') && ( )}
); }, (prevProps, nextProps) => { // Custom comparison function to prevent unnecessary re-renders // Only re-render if relevant props actually changed return ( prevProps.combinedProfile === nextProps.combinedProfile && prevProps.templateProfile === nextProps.templateProfile && prevProps.chartView === nextProps.chartView && prevProps.showDayTimeOnXAxis === nextProps.showDayTimeOnXAxis && prevProps.showDayReferenceLines === nextProps.showDayReferenceLines && prevProps.showIntakeTimeLines === nextProps.showIntakeTimeLines && prevProps.showTherapeuticRange === nextProps.showTherapeuticRange && prevProps.therapeuticRange?.min === nextProps.therapeuticRange?.min && prevProps.therapeuticRange?.max === nextProps.therapeuticRange?.max && prevProps.simulationDays === nextProps.simulationDays && prevProps.displayedDays === nextProps.displayedDays && prevProps.yAxisMin === nextProps.yAxisMin && prevProps.yAxisMax === nextProps.yAxisMax && prevProps.days === nextProps.days ); }); SimulationChart.displayName = 'SimulationChart'; export default SimulationChart;