800 lines
30 KiB
TypeScript
800 lines
30 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,
|
|
} 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<HTMLDivElement>(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<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]);
|
|
|
|
// 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 (
|
|
<text x={x} y={y + 12} textAnchor="middle" fontStyle="italic" fill={isDarkTheme ? '#ccc' : '#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={isDarkTheme ? '#ccc' : '#666'}>
|
|
{label}
|
|
</text>
|
|
);
|
|
}, [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 (
|
|
<text x={x} y={y + 4} textAnchor="end" fill={isDarkTheme ? '#ccc' : '#666'}>
|
|
{payload.value}
|
|
</text>
|
|
);
|
|
}, [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<number>();
|
|
|
|
const deviatingDays = new Set<number>();
|
|
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 (
|
|
<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-background text-foreground shadow-sm border border-border truncate inline-block max-w-[100px]"
|
|
>
|
|
{labelInfo.display}
|
|
</span>
|
|
</UiTooltipTrigger>
|
|
<UiTooltipContent className="bg-background text-foreground shadow-md border border-border max-w-xs">
|
|
<span className="font-medium">{labelInfo.full}</span>
|
|
</UiTooltipContent>
|
|
</UiTooltip>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}, [seriesLabels]);
|
|
|
|
// Don't render chart if dimensions are invalid (prevents crash during initialization)
|
|
if (chartWidth <= 0 || scrollableWidth <= 0) {
|
|
return (
|
|
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden items-center justify-center text-muted-foreground">
|
|
<p>{t('loadingChart', { defaultValue: 'Loading chart...' })}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Render the chart
|
|
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 */ }
|
|
<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]}
|
|
axisLine={{ stroke: isDarkTheme ? '#ccc' : '#666' }}
|
|
tick={<XAxisTick />}
|
|
ticks={xAxisTicks}
|
|
tickCount={xAxisTicks.length}
|
|
//tickCount={200}
|
|
//interval={1}
|
|
allowDecimals={false}
|
|
allowDataOverflow={false}
|
|
/>
|
|
<YAxis
|
|
yAxisId="concentration"
|
|
// FIXME
|
|
//label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', style: { fontStyle: 'italic', color: '#666' } }}
|
|
domain={yAxisDomain as any}
|
|
axisLine={{ stroke: isDarkTheme ? '#ccc' : '#666' }}
|
|
tick={<YAxisTick />}
|
|
allowDecimals={true}
|
|
allowDataOverflow={false}
|
|
/>
|
|
<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="bg-background border border-border rounded shadow-lg" style={{ margin: 0, padding: 10, whiteSpace: 'nowrap' }}>
|
|
<p className="text-foreground font-medium" style={{ margin: 0 }}>{t('time')}: {timeLabel}</p>
|
|
<ul 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="text-foreground"
|
|
style={{ display: 'block', paddingTop: 4, paddingBottom: 4, color: entry.color, opacity }}
|
|
>
|
|
<span title={labelInfo.full}>{labelInfo.display}</span>
|
|
<span>: </span>
|
|
<span>{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"
|
|
style={{ stroke: isDarkTheme ? '#666' : '#ccc' }}
|
|
/>
|
|
|
|
{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') && therapeuticRange.min && !isNaN(parseFloat(therapeuticRange.min)) && (
|
|
<ReferenceLine
|
|
y={parseFloat(therapeuticRange.min)}
|
|
label={{ value: t('refLineMin'), position: 'insideBottomLeft', style: { fontSize: '0.75rem', fontStyle: 'italic', fill: CHART_COLORS.therapeuticMin } }}
|
|
stroke={CHART_COLORS.therapeuticMin}
|
|
strokeDasharray="3 3"
|
|
xAxisId="hours"
|
|
yAxisId="concentration"
|
|
/>
|
|
)}
|
|
{showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && therapeuticRange.max && !isNaN(parseFloat(therapeuticRange.max)) && (
|
|
<ReferenceLine
|
|
y={parseFloat(therapeuticRange.max)}
|
|
label={{ value: t('refLineMax'), position: 'insideTopLeft', style: { fontSize: '0.75rem', fontStyle: 'italic', fill: CHART_COLORS.therapeuticMax } }}
|
|
stroke={CHART_COLORS.therapeuticMax}
|
|
strokeDasharray="3 3"
|
|
xAxisId="hours"
|
|
yAxisId="concentration"
|
|
/>
|
|
)}
|
|
|
|
{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 (
|
|
<ReferenceLine
|
|
key={`intake-${idx}`}
|
|
x={intake.hour}
|
|
label={(props: any) => {
|
|
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 (
|
|
<text
|
|
x={x}
|
|
y={y}
|
|
textAnchor="end"
|
|
fontSize="0.75rem"
|
|
fontStyle="italic"
|
|
fill="#a0a0a0"
|
|
>
|
|
{intake.doseIndex}
|
|
</text>
|
|
);
|
|
}}
|
|
stroke="#c0c0c0"
|
|
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>
|
|
);
|
|
}, (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;
|