Update various improvements and minor changes
This commit is contained in:
@@ -165,7 +165,10 @@ const MedPlanAssistant = () => {
|
||||
onImportDays={(importedDays: any) => updateState('days', importedDays)}
|
||||
/>
|
||||
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="max-w-7xl mx-auto" style={{
|
||||
// TODO ideally we would have a value around 320px or similar for mobile devices but this causes layout issues (consider e.g. wrapping) and makes the chart hard to read
|
||||
minWidth: '410px' //minWidth: '320px'
|
||||
}}>
|
||||
<header className="mb-8">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="">
|
||||
|
||||
@@ -201,7 +201,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
{!collapsedDays.has(day.id) && (
|
||||
<CardContent className="space-y-3">
|
||||
{/* Dose table header */}
|
||||
<div className="grid grid-cols-[100px_1fr_auto_auto] gap-2 text-sm font-medium text-muted-foreground">
|
||||
<div className="grid grid-cols-[120px_1fr_auto] sm:grid-cols-[120px_1fr_auto_auto] gap-2 text-sm font-medium text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{t('time')}</span>
|
||||
<Tooltip>
|
||||
@@ -229,10 +229,10 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>{t('ldx')} (mg)</div>
|
||||
<div className="text-center">
|
||||
{/* <div className="sm:text-center">
|
||||
<Utensils className="h-4 w-4 inline" />
|
||||
</div>
|
||||
<div className="invisible">-</div>
|
||||
</div> */}
|
||||
<div className="hidden sm:block invisible">-</div>
|
||||
</div>
|
||||
|
||||
{/* Dose rows */}
|
||||
@@ -245,43 +245,49 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0';
|
||||
|
||||
return (
|
||||
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto_auto] gap-2 items-center">
|
||||
<FormTimeInput
|
||||
value={dose.time}
|
||||
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
|
||||
required={true}
|
||||
warning={hasDuplicateTime}
|
||||
errorMessage={t('errorTimeRequired')}
|
||||
warningMessage={t('warningDuplicateTime')}
|
||||
/>
|
||||
<FormNumericInput
|
||||
value={dose.ldx}
|
||||
onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)}
|
||||
increment={doseIncrement}
|
||||
min={0}
|
||||
unit="mg"
|
||||
required={true}
|
||||
warning={isZeroDose}
|
||||
errorMessage={t('errorNumberRequired')}
|
||||
warningMessage={t('warningZeroDose')}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
onClick={() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)}
|
||||
icon={<Utensils className="h-4 w-4" />}
|
||||
tooltip={dose.isFed ? t('doseWithFood') : t('doseFasted')}
|
||||
size="sm"
|
||||
variant={dose.isFed ? "default" : "outline"}
|
||||
className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
onClick={() => onRemoveDose(day.id, dose.id)}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
tooltip={t('removeDose')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={day.isTemplate && day.doses.length === 1}
|
||||
className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted"
|
||||
/>
|
||||
<div key={dose.id} className="space-y-2">
|
||||
<div className="grid grid-cols-[120px_1fr_auto] sm:grid-cols-[120px_1fr_auto_auto] gap-2 items-center">
|
||||
<FormTimeInput
|
||||
value={dose.time}
|
||||
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
|
||||
required={true}
|
||||
warning={hasDuplicateTime}
|
||||
errorMessage={t('errorTimeRequired')}
|
||||
warningMessage={t('warningDuplicateTime')}
|
||||
/>
|
||||
<FormNumericInput
|
||||
value={dose.ldx}
|
||||
onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)}
|
||||
increment={doseIncrement}
|
||||
min={0}
|
||||
max={200}
|
||||
//unit="mg"
|
||||
required={true}
|
||||
warning={isZeroDose}
|
||||
errorMessage={t('errorNumberRequired')}
|
||||
warningMessage={t('warningZeroDose')}
|
||||
inputWidth="w-[72px]"
|
||||
/>
|
||||
<div className="flex gap-2 sm:contents">
|
||||
<IconButtonWithTooltip
|
||||
onClick={() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)}
|
||||
icon={<Utensils className="h-4 w-4" />}
|
||||
tooltip={dose.isFed ? t('doseWithFood') : t('doseFasted')}
|
||||
size="sm"
|
||||
variant={dose.isFed ? "default" : "outline"}
|
||||
className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
onClick={() => onRemoveDose(day.id, dose.id)}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
tooltip={t('removeDose')}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={day.isTemplate && day.doses.length === 1}
|
||||
className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -403,6 +403,7 @@ const Settings = ({
|
||||
onChange={val => onUpdateTherapeuticRange('min', val)}
|
||||
increment={0.5}
|
||||
min={0}
|
||||
max={500}
|
||||
placeholder={t('min')}
|
||||
required={true}
|
||||
error={!!therapeuticRangeError || !therapeuticRange.min}
|
||||
@@ -414,6 +415,7 @@ const Settings = ({
|
||||
onChange={val => onUpdateTherapeuticRange('max', val)}
|
||||
increment={0.5}
|
||||
min={0}
|
||||
max={500}
|
||||
placeholder={t('max')}
|
||||
unit="ng/ml"
|
||||
required={true}
|
||||
@@ -485,6 +487,7 @@ const Settings = ({
|
||||
onChange={val => onUpdateUiSetting('yAxisMin', val)}
|
||||
increment={1}
|
||||
min={0}
|
||||
max={500}
|
||||
placeholder={t('auto')}
|
||||
allowEmpty={true}
|
||||
clearButton={true}
|
||||
@@ -497,6 +500,7 @@ const Settings = ({
|
||||
onChange={val => onUpdateUiSetting('yAxisMax', val)}
|
||||
increment={1}
|
||||
min={0}
|
||||
max={500}
|
||||
placeholder={t('auto')}
|
||||
unit="ng/ml"
|
||||
allowEmpty={true}
|
||||
@@ -688,7 +692,7 @@ const Settings = ({
|
||||
onChange={val => onUpdatePkParams('damph', { ...pkParams.damph, halfLife: val })}
|
||||
increment={0.5}
|
||||
min={5}
|
||||
max={34}
|
||||
max={50}
|
||||
unit="h"
|
||||
required={true}
|
||||
warning={eliminationWarning && !eliminationExtreme}
|
||||
@@ -726,7 +730,7 @@ const Settings = ({
|
||||
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, halfLife: val })}
|
||||
increment={0.1}
|
||||
min={0.5}
|
||||
max={2}
|
||||
max={5}
|
||||
unit="h"
|
||||
required={true}
|
||||
warning={conversionWarning}
|
||||
@@ -760,7 +764,7 @@ const Settings = ({
|
||||
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, absorptionHalfLife: val })}
|
||||
increment={0.1}
|
||||
min={0.5}
|
||||
max={2}
|
||||
max={5}
|
||||
unit="h"
|
||||
required={true}
|
||||
warning={absorptionWarning}
|
||||
@@ -836,7 +840,7 @@ const Settings = ({
|
||||
onChange={val => updateAdvanced('standardVd', 'customValue', val)}
|
||||
increment={10}
|
||||
min={50}
|
||||
max={800}
|
||||
max={2000}
|
||||
unit="L"
|
||||
required={true}
|
||||
/>
|
||||
@@ -905,7 +909,7 @@ const Settings = ({
|
||||
onChange={val => updateAdvanced('weightBasedVd', 'bodyWeight', val)}
|
||||
increment={1}
|
||||
min={20}
|
||||
max={150}
|
||||
max={300}
|
||||
unit={t('bodyWeightUnit')}
|
||||
required={true}
|
||||
/>
|
||||
@@ -943,7 +947,7 @@ const Settings = ({
|
||||
onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)}
|
||||
increment={0.1}
|
||||
min={0}
|
||||
max={2}
|
||||
max={5}
|
||||
unit={t('tmaxDelayUnit')}
|
||||
required={true}
|
||||
/>
|
||||
|
||||
@@ -67,6 +67,7 @@ const SimulationChart = ({
|
||||
}: any) => {
|
||||
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
|
||||
const dispDays = parseInt(displayedDays, 10) || 2;
|
||||
const simDays = parseInt(simulationDays, 10) || 3;
|
||||
|
||||
// Calculate chart dimensions
|
||||
const [containerWidth, setContainerWidth] = React.useState(1000);
|
||||
@@ -84,9 +85,19 @@ const SimulationChart = ({
|
||||
return () => window.removeEventListener('resize', updateWidth);
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
|
||||
// Use shorter captions on narrow containers to reduce wrapping
|
||||
const isCompactLabels = containerWidth < 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 });
|
||||
@@ -121,15 +132,10 @@ const SimulationChart = ({
|
||||
};
|
||||
}, [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(() => {
|
||||
// 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];
|
||||
|
||||
@@ -146,8 +152,8 @@ const SimulationChart = ({
|
||||
return selected ?? 24;
|
||||
}, [dispDays, scrollableWidth]);
|
||||
|
||||
// Generate ticks for continuous time axis
|
||||
const chartTicks = React.useMemo(() => {
|
||||
// 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);
|
||||
@@ -155,79 +161,120 @@ const SimulationChart = ({
|
||||
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);
|
||||
// Custom tick renderer for x-axis to handle 12h/24h/continuous formats
|
||||
const XAxisTick = (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>
|
||||
);
|
||||
}
|
||||
if (chartView === 'ldx' || chartView === 'both') {
|
||||
dataMin = Math.min(dataMin, point.ldx);
|
||||
dataMax = Math.max(dataMax, point.ldx);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
return [domainMin, domainMax];
|
||||
}, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]);
|
||||
|
||||
// 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 5% padding below so the line is not flush with x-axis
|
||||
const range = dataMax - dataMin;
|
||||
const padding = range * 0.05;
|
||||
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
|
||||
// User set yAxisMax explicitly - use it as-is without padding
|
||||
domainMax = numMax;
|
||||
} else if (dataMax !== -Infinity) { // data exists
|
||||
// Auto mode: add 5% padding above
|
||||
const range = dataMax - dataMin;
|
||||
const padding = range * 0.05;
|
||||
domainMax = dataMax + padding;
|
||||
} else { // no data
|
||||
domainMax = 100;
|
||||
}
|
||||
|
||||
return [domainMin, domainMax];
|
||||
}, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]);
|
||||
|
||||
// Check which days have deviations (differ from template)
|
||||
// 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 template)
|
||||
// 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;
|
||||
@@ -302,11 +349,6 @@ const chartDomain = React.useMemo(() => {
|
||||
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);
|
||||
|
||||
// Render legend with tooltips for full names (custom legend renderer)
|
||||
const renderLegend = React.useCallback((props: any) => {
|
||||
const { payload } = props;
|
||||
@@ -343,6 +385,7 @@ const chartDomain = React.useMemo(() => {
|
||||
);
|
||||
}, [seriesLabels]);
|
||||
|
||||
// Render the chart
|
||||
return (
|
||||
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden">
|
||||
{/* Fixed Legend at top */}
|
||||
@@ -404,56 +447,30 @@ const chartDomain = React.useMemo(() => {
|
||||
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
|
||||
{/** 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]}
|
||||
ticks={chartTicks}
|
||||
tickCount={chartTicks.length}
|
||||
interval={0}
|
||||
tick={<CustomTick />}
|
||||
/>;
|
||||
})()}
|
||||
|
||||
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={chartDomain as any}
|
||||
allowDecimals={false}
|
||||
domain={yAxisDomain as any}
|
||||
tickCount={20}
|
||||
interval={1}
|
||||
allowDecimals={false}
|
||||
allowDataOverflow={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
content={({ active, payload, label }) => {
|
||||
|
||||
@@ -34,7 +34,7 @@ const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
|
||||
|
||||
return (
|
||||
<CardHeader className={cn('pb-3', className)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap flex-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -48,7 +48,7 @@ const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
|
||||
</CardTitle>
|
||||
{isCollapsed ? <ChevronDown className="h-5 w-5 flex-shrink-0" /> : <ChevronUp className="h-5 w-5 flex-shrink-0" />}
|
||||
</button>
|
||||
{children}
|
||||
{children && <div className="flex items-center gap-2 flex-nowrap">{children}</div>}
|
||||
</div>
|
||||
{rightSection && <div className="flex items-center gap-2">{rightSection}</div>}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface NumericInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
|
||||
required?: boolean
|
||||
errorMessage?: string
|
||||
warningMessage?: string
|
||||
inputWidth?: string // Custom width for the input field (e.g., 'w-16', 'w-20')
|
||||
}
|
||||
|
||||
const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
@@ -49,6 +50,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
required = false,
|
||||
errorMessage = 'Time is required',
|
||||
warningMessage,
|
||||
inputWidth = 'w-20', // Default width
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
@@ -74,7 +76,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
}, [isInvalid, touched])
|
||||
// Determine decimal places based on increment
|
||||
const getDecimalPlaces = () => {
|
||||
const inc = String(increment || '1')
|
||||
const inc = String(increment || '1').replace(',', '.')
|
||||
const decimalIndex = inc.indexOf('.')
|
||||
if (decimalIndex === -1) return 0
|
||||
return inc.length - decimalIndex - 1
|
||||
@@ -97,7 +99,17 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
numValue = 0
|
||||
}
|
||||
|
||||
numValue += direction * numIncrement
|
||||
// Snap to nearest increment first, then move one increment in the desired direction
|
||||
if (direction > 0) {
|
||||
// For increment: round up to next increment value, ensuring at least one increment is added
|
||||
const snapped = Math.ceil(numValue / numIncrement) * numIncrement
|
||||
numValue = snapped > numValue ? snapped : snapped + numIncrement
|
||||
} else {
|
||||
// For decrement: round down to previous increment value, ensuring at least one increment is subtracted
|
||||
const snapped = Math.floor(numValue / numIncrement) * numIncrement
|
||||
numValue = snapped < numValue ? snapped : snapped - numIncrement
|
||||
}
|
||||
|
||||
numValue = Math.max(min, numValue)
|
||||
numValue = Math.min(max, numValue)
|
||||
onChange(formatValue(numValue))
|
||||
@@ -111,7 +123,10 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value
|
||||
let val = e.target.value
|
||||
// Replace comma with period to support European decimal separator
|
||||
val = val.replace(',', '.')
|
||||
// Allow any valid numeric input during typing (including partial values like "1", "12.", etc.)
|
||||
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
|
||||
onChange(val)
|
||||
}
|
||||
@@ -131,7 +146,11 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
}
|
||||
|
||||
if (inputValue !== '' && !isNaN(Number(inputValue))) {
|
||||
onChange(formatValue(inputValue))
|
||||
let numValue = Number(inputValue)
|
||||
// Enforce min/max constraints
|
||||
numValue = Math.max(min, numValue)
|
||||
numValue = Math.min(max, numValue)
|
||||
onChange(formatValue(numValue))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +195,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-20 h-9 z-20",
|
||||
inputWidth, "h-9 z-10",
|
||||
"rounded-none",
|
||||
getAlignmentClass(),
|
||||
hasError && "border-destructive focus-visible:ring-destructive",
|
||||
@@ -218,12 +237,12 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
</div>
|
||||
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
|
||||
{hasError && isFocused && errorMessage && (
|
||||
<div className="absolute top-full left-0 mt-1 z-25 w-64 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
|
||||
<div className="absolute top-full left-0 mt-1 z-20 w-64 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{hasWarning && isFocused && warningMessage && (
|
||||
<div className="absolute top-full left-0 mt-1 z-25 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg">
|
||||
<div className="absolute top-full left-0 mt-1 z-20 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg">
|
||||
{warningMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -51,6 +51,9 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
||||
const [isFocused, setIsFocused] = React.useState(false)
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
// Store original value when opening picker (for cancel/revert)
|
||||
const [originalValue, setOriginalValue] = React.useState<string>('')
|
||||
|
||||
// Current committed value parsed from prop
|
||||
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number)
|
||||
|
||||
@@ -128,26 +131,43 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
||||
const handlePickerOpen = (open: boolean) => {
|
||||
setIsPickerOpen(open)
|
||||
if (open) {
|
||||
// Reset staging when opening picker
|
||||
// Save original value for cancel/revert and reset staging
|
||||
setOriginalValue(value)
|
||||
setStagedHour(null)
|
||||
setStagedMinute(null)
|
||||
} else if (!open && originalValue) {
|
||||
// Closing without explicit Apply - revert to original value
|
||||
onChange(originalValue)
|
||||
setOriginalValue('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHourClick = (hour: number) => {
|
||||
setStagedHour(hour)
|
||||
// Update simulation immediately with new hour (keeping current or staged minute)
|
||||
const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes
|
||||
const formattedTime = `${String(hour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}`
|
||||
onChange(formattedTime)
|
||||
}
|
||||
|
||||
const handleMinuteClick = (minute: number) => {
|
||||
setStagedMinute(minute)
|
||||
// Update simulation immediately with new minute (keeping current or staged hour)
|
||||
const finalHour = stagedHour !== null ? stagedHour : pickerHours
|
||||
const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||||
onChange(formattedTime)
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
// Use staged values if selected, otherwise keep current values
|
||||
const finalHour = stagedHour !== null ? stagedHour : pickerHours
|
||||
const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes
|
||||
const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}`
|
||||
onChange(formattedTime)
|
||||
// Commit the current value (already updated in real-time) and close
|
||||
setOriginalValue('') // Clear original so revert doesn't happen on close
|
||||
setIsPickerOpen(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
// Revert to original value
|
||||
onChange(originalValue)
|
||||
setOriginalValue('')
|
||||
setIsPickerOpen(false)
|
||||
}
|
||||
|
||||
@@ -245,7 +265,15 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t('timePickerCancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -54,6 +54,54 @@ export const useAppState = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate numeric fields and replace empty/invalid values with defaults
|
||||
const validateNumericField = (value: any, defaultValue: any): any => {
|
||||
if (value === '' || value === null || value === undefined || isNaN(Number(value))) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Validate basic pkParams
|
||||
if (migratedPkParams.basic) {
|
||||
migratedPkParams.basic.eliminationHalfLife = validateNumericField(
|
||||
migratedPkParams.basic.eliminationHalfLife,
|
||||
defaults.pkParams.basic.eliminationHalfLife
|
||||
);
|
||||
migratedPkParams.basic.bodyWeight = validateNumericField(
|
||||
migratedPkParams.basic.bodyWeight,
|
||||
defaults.pkParams.basic.bodyWeight
|
||||
);
|
||||
}
|
||||
|
||||
// Validate advanced pkParams
|
||||
if (migratedPkParams.advanced) {
|
||||
migratedPkParams.advanced.conversionEfficiency = validateNumericField(
|
||||
migratedPkParams.advanced.conversionEfficiency,
|
||||
defaults.pkParams.advanced.conversionEfficiency
|
||||
);
|
||||
migratedPkParams.advanced.bioavailability = validateNumericField(
|
||||
migratedPkParams.advanced.bioavailability,
|
||||
defaults.pkParams.advanced.bioavailability
|
||||
);
|
||||
migratedPkParams.advanced.customVolumeOfDistribution = validateNumericField(
|
||||
migratedPkParams.advanced.customVolumeOfDistribution,
|
||||
defaults.pkParams.advanced.customVolumeOfDistribution
|
||||
);
|
||||
migratedPkParams.advanced.absorptionDelay = validateNumericField(
|
||||
migratedPkParams.advanced.absorptionDelay,
|
||||
defaults.pkParams.advanced.absorptionDelay
|
||||
);
|
||||
migratedPkParams.advanced.absorptionRateConstant = validateNumericField(
|
||||
migratedPkParams.advanced.absorptionRateConstant,
|
||||
defaults.pkParams.advanced.absorptionRateConstant
|
||||
);
|
||||
migratedPkParams.advanced.mealDelayFactor = validateNumericField(
|
||||
migratedPkParams.advanced.mealDelayFactor,
|
||||
defaults.pkParams.advanced.mealDelayFactor
|
||||
);
|
||||
}
|
||||
|
||||
setAppState({
|
||||
...defaults,
|
||||
...parsedState,
|
||||
|
||||
@@ -312,6 +312,7 @@ export const de = {
|
||||
timePickerHour: "Stunde",
|
||||
timePickerMinute: "Minute",
|
||||
timePickerApply: "Übernehmen",
|
||||
timePickerCancel: "Abbrechen",
|
||||
|
||||
// Sorting
|
||||
sortByTime: "Nach Zeit sortieren",
|
||||
|
||||
@@ -285,6 +285,7 @@ export const en = {
|
||||
timePickerHour: "Hour",
|
||||
timePickerMinute: "Minute",
|
||||
timePickerApply: "Apply",
|
||||
timePickerCancel: "Cancel",
|
||||
|
||||
// Sorting
|
||||
sortByTime: "Sort by time",
|
||||
|
||||
Reference in New Issue
Block a user