Files
med-plan-assistant/src/components/simulation-chart.tsx

632 lines
24 KiB
TypeScript

/**
* 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<HTMLDivElement>(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<Record<string, { full: string; short: string; display: string }>>(() => {
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<number>();
const deviatingDays = new Set<number>();
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 (
<UiTooltipProvider>
<ul className="flex flex-wrap gap-2 text-xs leading-tight">
{payload.map((item: any) => {
const labelInfo = seriesLabels[item.dataKey] || { display: item.value, full: item.value };
const opacity = item.payload?.opacity ?? 1;
return (
<li key={item.dataKey} className="flex items-center gap-1 max-w-[140px]">
<span
className="inline-block w-3 h-3 rounded-sm"
style={{ backgroundColor: item.color, opacity }}
/>
<UiTooltip>
<UiTooltipTrigger asChild>
<span
className="px-1 py-0.5 rounded-sm bg-white text-black shadow-sm border border-muted truncate inline-block max-w-[100px]"
title={labelInfo.full}
>
{labelInfo.display}
</span>
</UiTooltipTrigger>
<UiTooltipContent className="bg-white text-black shadow-md border max-w-xs">
<span className="font-medium">{labelInfo.full}</span>
</UiTooltipContent>
</UiTooltip>
</li>
);
})}
</ul>
</UiTooltipProvider>
);
}, [seriesLabels]);
return (
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden">
{/* Fixed Legend at top */}
<div style={{ marginBottom: 8, paddingLeft: yAxisWidth + 10 }}>
{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 },
},
]
: []),
],
})}
</div>
{/* Chart */}
<div className="flex-grow flex overflow-y-hidden">
{/* Scrollable chart area */}
<div className="flex-grow overflow-x-auto overflow-y-hidden">
<div style={{ width: chartWidth, height: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={mergedData}
margin={{ top: 0, right: 20, left: 0, bottom: 5 }}
syncId="medPlanChart"
>
{/** 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 (
<text x={x} y={y + 12} textAnchor="middle" fontStyle="italic" fill="#666">
{label}
</text>
);
}
const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12;
const period = hour12 < 12 ? 'a' : 'p';
label = `${displayHour}${period}`;
} else {
label = `${h}`;
}
return (
<text x={x} y={y + 12} textAnchor="middle" fill="#666">
{label}
</text>
);
};
return <XAxis
xAxisId="hours"
//label={{ value: showDayTimeOnXAxis === 'continuous' ? t('axisLabelHours') : t('axisLabelTimeOfDay'), position: 'insideBottom', offset: -10, style: { fontStyle: 'italic', color: '#666' } }}
dataKey="timeHours"
type="number"
domain={[0, totalHours]}
ticks={chartTicks}
tickCount={chartTicks.length}
interval={0}
tick={<CustomTick />}
/>;
})()}
<YAxis
yAxisId="concentration"
// FIXME
//label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', style: { fontStyle: 'italic', color: '#666' } }}
domain={chartDomain as any}
allowDecimals={false}
tickCount={20}
/>
<RechartsTooltip
content={({ active, payload, label }) => {
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 (
<div className="recharts-default-tooltip" style={{ margin: 0, padding: 10, backgroundColor: 'rgb(255, 255, 255)', border: '1px solid rgb(204, 204, 204)', whiteSpace: 'nowrap' }}>
<p className="recharts-tooltip-label" style={{ margin: 0 }}>{t('time')}: {timeLabel}</p>
<ul className="recharts-tooltip-item-list" style={{ padding: 0, margin: 0 }}>
{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 (
<li
key={`item-${index}`}
className="recharts-tooltip-item"
style={{ display: 'block', paddingTop: 4, paddingBottom: 4, color: entry.color, opacity }}
>
<span className="recharts-tooltip-item-name" title={labelInfo.full}>{labelInfo.display}</span>
<span className="recharts-tooltip-item-separator">: </span>
<span className="recharts-tooltip-item-value">{value} {t('unitNgml')}</span>
</li>
);
})}
</ul>
</div>
);
}}
wrapperStyle={{ pointerEvents: 'none', zIndex: 200 }}
allowEscapeViewBox={{ x: false, y: false }}
cursor={{ stroke: CHART_COLORS.cursor, strokeWidth: 1, strokeDasharray: '1 1' }}
position={{ y: 0 }}
/>
<CartesianGrid strokeDasharray="1 1" xAxisId="hours" yAxisId="concentration" />
{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 (
<ReferenceLine
key={`day-${day + 1}`}
x={24 * (day + 1)}
label={{
value: `${label}`,
position: 'insideTopRight',
style: {
fontSize: '0.75rem',
fontStyle: 'italic',
fill: day === 0 ? CHART_COLORS.regularPlanDivider : CHART_COLORS.deviationDayDivider
}
}}
stroke={day === 0 ? CHART_COLORS.regularPlanDivider : CHART_COLORS.deviationDayDivider}
//strokeDasharray="0 0"
xAxisId="hours"
yAxisId="concentration"
/>
);
})}
{showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && (
<ReferenceLine
y={parseFloat(therapeuticRange.min) || 0}
label={{ value: t('refLineMin'), position: 'insideTopLeft' }}
stroke={CHART_COLORS.therapeuticMin}
strokeDasharray="3 3"
xAxisId="hours"
yAxisId="concentration"
/>
)}
{showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && (
<ReferenceLine
y={parseFloat(therapeuticRange.max) || 0}
label={{ value: t('refLineMax'), position: 'insideTopLeft' }}
stroke={CHART_COLORS.therapeuticMax}
strokeDasharray="3 3"
xAxisId="hours"
yAxisId="concentration"
/>
)}
{[...Array(parseInt(simulationDays, 10) || 3).keys()].map(day => (
day > 0 && (
<ReferenceLine
key={day}
x={day * 24}
stroke={CHART_COLORS.dayDivider}
strokeDasharray="5 5"
xAxisId="hours"
/>
)
))}
{(chartView === 'damph' || chartView === 'both') && (
<Line
type="monotone"
dataKey="combinedDamph"
name={seriesLabels.combinedDamph.display}
stroke={CHART_COLORS.idealDamph}
strokeWidth={2.5}
dot={false}
xAxisId="hours"
yAxisId="concentration"
connectNulls
/>
)}
{(chartView === 'ldx' || chartView === 'both') && (
<Line
type="monotone"
dataKey="combinedLdx"
name={seriesLabels.combinedLdx.display}
stroke={CHART_COLORS.idealLdx}
strokeWidth={2}
dot={false}
strokeDasharray="3 3"
xAxisId="hours"
yAxisId="concentration"
connectNulls
/>
)}
{templateProfile && (chartView === 'damph' || chartView === 'both') && (
<Line
type="monotone"
dataKey="templateDamph"
name={seriesLabels.templateDamph.display}
stroke={CHART_COLORS.idealDamph}
strokeWidth={2}
strokeDasharray="3 3"
dot={false}
xAxisId="hours"
yAxisId="concentration"
connectNulls
strokeOpacity={0.5}
/>
)}
{templateProfile && (chartView === 'ldx' || chartView === 'both') && (
<Line
type="monotone"
dataKey="templateLdx"
name={seriesLabels.templateLdx.display}
stroke={CHART_COLORS.idealLdx}
strokeWidth={1.5}
strokeDasharray="3 3"
dot={false}
xAxisId="hours"
yAxisId="concentration"
connectNulls
strokeOpacity={0.5}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
);
};
export default SimulationChart;