Update various improvements and minor changes
This commit is contained in:
@@ -165,7 +165,10 @@ const MedPlanAssistant = () => {
|
|||||||
onImportDays={(importedDays: any) => updateState('days', importedDays)}
|
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">
|
<header className="mb-8">
|
||||||
<div className="flex justify-between items-start gap-4">
|
<div className="flex justify-between items-start gap-4">
|
||||||
<div className="">
|
<div className="">
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
|||||||
{!collapsedDays.has(day.id) && (
|
{!collapsedDays.has(day.id) && (
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{/* Dose table header */}
|
{/* 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">
|
<div className="flex items-center gap-1">
|
||||||
<span>{t('time')}</span>
|
<span>{t('time')}</span>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -229,10 +229,10 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div>{t('ldx')} (mg)</div>
|
<div>{t('ldx')} (mg)</div>
|
||||||
<div className="text-center">
|
{/* <div className="sm:text-center">
|
||||||
<Utensils className="h-4 w-4 inline" />
|
<Utensils className="h-4 w-4 inline" />
|
||||||
</div>
|
</div> */}
|
||||||
<div className="invisible">-</div>
|
<div className="hidden sm:block invisible">-</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dose rows */}
|
{/* Dose rows */}
|
||||||
@@ -245,7 +245,8 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
|||||||
const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0';
|
const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto_auto] gap-2 items-center">
|
<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
|
<FormTimeInput
|
||||||
value={dose.time}
|
value={dose.time}
|
||||||
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
|
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
|
||||||
@@ -259,12 +260,15 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
|||||||
onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)}
|
onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)}
|
||||||
increment={doseIncrement}
|
increment={doseIncrement}
|
||||||
min={0}
|
min={0}
|
||||||
unit="mg"
|
max={200}
|
||||||
|
//unit="mg"
|
||||||
required={true}
|
required={true}
|
||||||
warning={isZeroDose}
|
warning={isZeroDose}
|
||||||
errorMessage={t('errorNumberRequired')}
|
errorMessage={t('errorNumberRequired')}
|
||||||
warningMessage={t('warningZeroDose')}
|
warningMessage={t('warningZeroDose')}
|
||||||
|
inputWidth="w-[72px]"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex gap-2 sm:contents">
|
||||||
<IconButtonWithTooltip
|
<IconButtonWithTooltip
|
||||||
onClick={() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)}
|
onClick={() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)}
|
||||||
icon={<Utensils className="h-4 w-4" />}
|
icon={<Utensils className="h-4 w-4" />}
|
||||||
@@ -283,6 +287,8 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
|||||||
className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted"
|
className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ const Settings = ({
|
|||||||
onChange={val => onUpdateTherapeuticRange('min', val)}
|
onChange={val => onUpdateTherapeuticRange('min', val)}
|
||||||
increment={0.5}
|
increment={0.5}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={500}
|
||||||
placeholder={t('min')}
|
placeholder={t('min')}
|
||||||
required={true}
|
required={true}
|
||||||
error={!!therapeuticRangeError || !therapeuticRange.min}
|
error={!!therapeuticRangeError || !therapeuticRange.min}
|
||||||
@@ -414,6 +415,7 @@ const Settings = ({
|
|||||||
onChange={val => onUpdateTherapeuticRange('max', val)}
|
onChange={val => onUpdateTherapeuticRange('max', val)}
|
||||||
increment={0.5}
|
increment={0.5}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={500}
|
||||||
placeholder={t('max')}
|
placeholder={t('max')}
|
||||||
unit="ng/ml"
|
unit="ng/ml"
|
||||||
required={true}
|
required={true}
|
||||||
@@ -485,6 +487,7 @@ const Settings = ({
|
|||||||
onChange={val => onUpdateUiSetting('yAxisMin', val)}
|
onChange={val => onUpdateUiSetting('yAxisMin', val)}
|
||||||
increment={1}
|
increment={1}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={500}
|
||||||
placeholder={t('auto')}
|
placeholder={t('auto')}
|
||||||
allowEmpty={true}
|
allowEmpty={true}
|
||||||
clearButton={true}
|
clearButton={true}
|
||||||
@@ -497,6 +500,7 @@ const Settings = ({
|
|||||||
onChange={val => onUpdateUiSetting('yAxisMax', val)}
|
onChange={val => onUpdateUiSetting('yAxisMax', val)}
|
||||||
increment={1}
|
increment={1}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={500}
|
||||||
placeholder={t('auto')}
|
placeholder={t('auto')}
|
||||||
unit="ng/ml"
|
unit="ng/ml"
|
||||||
allowEmpty={true}
|
allowEmpty={true}
|
||||||
@@ -688,7 +692,7 @@ const Settings = ({
|
|||||||
onChange={val => onUpdatePkParams('damph', { ...pkParams.damph, halfLife: val })}
|
onChange={val => onUpdatePkParams('damph', { ...pkParams.damph, halfLife: val })}
|
||||||
increment={0.5}
|
increment={0.5}
|
||||||
min={5}
|
min={5}
|
||||||
max={34}
|
max={50}
|
||||||
unit="h"
|
unit="h"
|
||||||
required={true}
|
required={true}
|
||||||
warning={eliminationWarning && !eliminationExtreme}
|
warning={eliminationWarning && !eliminationExtreme}
|
||||||
@@ -726,7 +730,7 @@ const Settings = ({
|
|||||||
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, halfLife: val })}
|
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, halfLife: val })}
|
||||||
increment={0.1}
|
increment={0.1}
|
||||||
min={0.5}
|
min={0.5}
|
||||||
max={2}
|
max={5}
|
||||||
unit="h"
|
unit="h"
|
||||||
required={true}
|
required={true}
|
||||||
warning={conversionWarning}
|
warning={conversionWarning}
|
||||||
@@ -760,7 +764,7 @@ const Settings = ({
|
|||||||
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, absorptionHalfLife: val })}
|
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, absorptionHalfLife: val })}
|
||||||
increment={0.1}
|
increment={0.1}
|
||||||
min={0.5}
|
min={0.5}
|
||||||
max={2}
|
max={5}
|
||||||
unit="h"
|
unit="h"
|
||||||
required={true}
|
required={true}
|
||||||
warning={absorptionWarning}
|
warning={absorptionWarning}
|
||||||
@@ -836,7 +840,7 @@ const Settings = ({
|
|||||||
onChange={val => updateAdvanced('standardVd', 'customValue', val)}
|
onChange={val => updateAdvanced('standardVd', 'customValue', val)}
|
||||||
increment={10}
|
increment={10}
|
||||||
min={50}
|
min={50}
|
||||||
max={800}
|
max={2000}
|
||||||
unit="L"
|
unit="L"
|
||||||
required={true}
|
required={true}
|
||||||
/>
|
/>
|
||||||
@@ -905,7 +909,7 @@ const Settings = ({
|
|||||||
onChange={val => updateAdvanced('weightBasedVd', 'bodyWeight', val)}
|
onChange={val => updateAdvanced('weightBasedVd', 'bodyWeight', val)}
|
||||||
increment={1}
|
increment={1}
|
||||||
min={20}
|
min={20}
|
||||||
max={150}
|
max={300}
|
||||||
unit={t('bodyWeightUnit')}
|
unit={t('bodyWeightUnit')}
|
||||||
required={true}
|
required={true}
|
||||||
/>
|
/>
|
||||||
@@ -943,7 +947,7 @@ const Settings = ({
|
|||||||
onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)}
|
onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)}
|
||||||
increment={0.1}
|
increment={0.1}
|
||||||
min={0}
|
min={0}
|
||||||
max={2}
|
max={5}
|
||||||
unit={t('tmaxDelayUnit')}
|
unit={t('tmaxDelayUnit')}
|
||||||
required={true}
|
required={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const SimulationChart = ({
|
|||||||
}: any) => {
|
}: any) => {
|
||||||
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
|
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
|
||||||
const dispDays = parseInt(displayedDays, 10) || 2;
|
const dispDays = parseInt(displayedDays, 10) || 2;
|
||||||
|
const simDays = parseInt(simulationDays, 10) || 3;
|
||||||
|
|
||||||
// Calculate chart dimensions
|
// Calculate chart dimensions
|
||||||
const [containerWidth, setContainerWidth] = React.useState(1000);
|
const [containerWidth, setContainerWidth] = React.useState(1000);
|
||||||
@@ -84,9 +85,19 @@ const SimulationChart = ({
|
|||||||
return () => window.removeEventListener('resize', updateWidth);
|
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
|
// Use shorter captions on narrow containers to reduce wrapping
|
||||||
const isCompactLabels = containerWidth < 640; // tweakable threshold for mobile
|
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 seriesLabels = React.useMemo<Record<string, { full: string; short: string; display: string }>>(() => {
|
||||||
const damphFull = t('dAmphetamine');
|
const damphFull = t('dAmphetamine');
|
||||||
const damphShort = t('dAmphetamineShort', { defaultValue: damphFull });
|
const damphShort = t('dAmphetamineShort', { defaultValue: damphFull });
|
||||||
@@ -121,15 +132,10 @@ const SimulationChart = ({
|
|||||||
};
|
};
|
||||||
}, [isCompactLabels, t]);
|
}, [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
|
// 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 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 MIN_PX_PER_TICK = 46;
|
||||||
const intervals = [1, 2, 3, 4, 6, 8, 12, 24];
|
const intervals = [1, 2, 3, 4, 6, 8, 12, 24];
|
||||||
|
|
||||||
@@ -146,8 +152,8 @@ const SimulationChart = ({
|
|||||||
return selected ?? 24;
|
return selected ?? 24;
|
||||||
}, [dispDays, scrollableWidth]);
|
}, [dispDays, scrollableWidth]);
|
||||||
|
|
||||||
// Generate ticks for continuous time axis
|
// Generate x-axis ticks for continuous time axis
|
||||||
const chartTicks = React.useMemo(() => {
|
const xAxisTicks = React.useMemo(() => {
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
for (let i = 0; i <= totalHours; i += xTickInterval) {
|
for (let i = 0; i <= totalHours; i += xTickInterval) {
|
||||||
ticks.push(i);
|
ticks.push(i);
|
||||||
@@ -155,7 +161,38 @@ const SimulationChart = ({
|
|||||||
return ticks;
|
return ticks;
|
||||||
}, [totalHours, xTickInterval]);
|
}, [totalHours, xTickInterval]);
|
||||||
|
|
||||||
const chartDomain = React.useMemo(() => {
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 numMin = parseFloat(yAxisMin);
|
||||||
const numMax = parseFloat(yAxisMax);
|
const numMax = parseFloat(yAxisMax);
|
||||||
|
|
||||||
@@ -195,9 +232,9 @@ const chartDomain = React.useMemo(() => {
|
|||||||
// User set yAxisMin explicitly
|
// User set yAxisMin explicitly
|
||||||
domainMin = numMin;
|
domainMin = numMin;
|
||||||
} else if (dataMin !== Infinity) { // data exists
|
} else if (dataMin !== Infinity) { // data exists
|
||||||
// Auto mode: add 5% padding below so the line is not flush with x-axis
|
// Auto mode: add 10% padding below so the line is not flush with x-axis
|
||||||
const range = dataMax - dataMin;
|
const range = dataMax - dataMin;
|
||||||
const padding = range * 0.05;
|
const padding = range * 0.1;
|
||||||
domainMin = Math.max(0, dataMin - padding);
|
domainMin = Math.max(0, dataMin - padding);
|
||||||
} else { // no data
|
} else { // no data
|
||||||
domainMin = 0;
|
domainMin = 0;
|
||||||
@@ -206,12 +243,22 @@ const chartDomain = React.useMemo(() => {
|
|||||||
// Calculate final domain max
|
// Calculate final domain max
|
||||||
let domainMax: number;
|
let domainMax: number;
|
||||||
if (!isNaN(numMax)) { // max value provided via settings
|
if (!isNaN(numMax)) { // max value provided via settings
|
||||||
// User set yAxisMax explicitly - use it as-is without padding
|
if (dataMax !== -Infinity) {
|
||||||
domainMax = numMax;
|
// User set yAxisMax explicitly
|
||||||
} else if (dataMax !== -Infinity) { // data exists
|
// Add padding to dataMax and use the higher of manual or (dataMax + padding)
|
||||||
// Auto mode: add 5% padding above
|
|
||||||
const range = dataMax - dataMin;
|
const range = dataMax - dataMin;
|
||||||
const padding = range * 0.05;
|
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;
|
domainMax = dataMax + padding;
|
||||||
} else { // no data
|
} else { // no data
|
||||||
domainMax = 100;
|
domainMax = 100;
|
||||||
@@ -220,14 +267,14 @@ const chartDomain = React.useMemo(() => {
|
|||||||
return [domainMin, domainMax];
|
return [domainMin, domainMax];
|
||||||
}, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]);
|
}, [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(() => {
|
const daysWithDeviations = React.useMemo(() => {
|
||||||
if (!templateProfile || !combinedProfile) return new Set<number>();
|
if (!templateProfile || !combinedProfile) return new Set<number>();
|
||||||
|
|
||||||
const deviatingDays = new Set<number>();
|
const deviatingDays = new Set<number>();
|
||||||
const simDays = parseInt(simulationDays, 10) || 3;
|
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++) {
|
for (let day = 2; day <= simDays; day++) {
|
||||||
const dayStartHour = (day - 1) * 24;
|
const dayStartHour = (day - 1) * 24;
|
||||||
const dayEndHour = day * 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);
|
return Array.from(dataMap.values()).sort((a, b) => a.timeHours - b.timeHours);
|
||||||
}, [combinedProfile, templateProfile, daysWithDeviations]);
|
}, [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)
|
// Render legend with tooltips for full names (custom legend renderer)
|
||||||
const renderLegend = React.useCallback((props: any) => {
|
const renderLegend = React.useCallback((props: any) => {
|
||||||
const { payload } = props;
|
const { payload } = props;
|
||||||
@@ -343,6 +385,7 @@ const chartDomain = React.useMemo(() => {
|
|||||||
);
|
);
|
||||||
}, [seriesLabels]);
|
}, [seriesLabels]);
|
||||||
|
|
||||||
|
// Render the chart
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden">
|
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden">
|
||||||
{/* Fixed Legend at top */}
|
{/* Fixed Legend at top */}
|
||||||
@@ -405,55 +448,29 @@ const chartDomain = React.useMemo(() => {
|
|||||||
syncId="medPlanChart"
|
syncId="medPlanChart"
|
||||||
>
|
>
|
||||||
{/** Custom tick renderer to italicize 'Noon' only in 12h mode */ }
|
{/** Custom tick renderer to italicize 'Noon' only in 12h mode */ }
|
||||||
{(() => {
|
<XAxis
|
||||||
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"
|
xAxisId="hours"
|
||||||
//label={{ value: showDayTimeOnXAxis === 'continuous' ? t('axisLabelHours') : t('axisLabelTimeOfDay'), position: 'insideBottom', offset: -10, style: { fontStyle: 'italic', color: '#666' } }}
|
//label={{ value: showDayTimeOnXAxis === 'continuous' ? t('axisLabelHours') : t('axisLabelTimeOfDay'), position: 'insideBottom', offset: -10, style: { fontStyle: 'italic', color: '#666' } }}
|
||||||
dataKey="timeHours"
|
dataKey="timeHours"
|
||||||
type="number"
|
type="number"
|
||||||
domain={[0, totalHours]}
|
domain={[0, totalHours]}
|
||||||
ticks={chartTicks}
|
tick={<XAxisTick />}
|
||||||
tickCount={chartTicks.length}
|
ticks={xAxisTicks}
|
||||||
interval={0}
|
tickCount={xAxisTicks.length}
|
||||||
tick={<CustomTick />}
|
//tickCount={200}
|
||||||
/>;
|
//interval={1}
|
||||||
})()}
|
allowDecimals={false}
|
||||||
|
allowDataOverflow={false}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="concentration"
|
yAxisId="concentration"
|
||||||
// FIXME
|
// FIXME
|
||||||
//label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', style: { fontStyle: 'italic', color: '#666' } }}
|
//label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', style: { fontStyle: 'italic', color: '#666' } }}
|
||||||
domain={chartDomain as any}
|
domain={yAxisDomain as any}
|
||||||
allowDecimals={false}
|
|
||||||
tickCount={20}
|
tickCount={20}
|
||||||
|
interval={1}
|
||||||
|
allowDecimals={false}
|
||||||
|
allowDataOverflow={false}
|
||||||
/>
|
/>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
content={({ active, payload, label }) => {
|
content={({ active, payload, label }) => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CardHeader className={cn('pb-3', className)}>
|
<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">
|
<div className="flex items-center gap-2 flex-wrap flex-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -48,7 +48,7 @@ const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
{isCollapsed ? <ChevronDown className="h-5 w-5 flex-shrink-0" /> : <ChevronUp className="h-5 w-5 flex-shrink-0" />}
|
{isCollapsed ? <ChevronDown className="h-5 w-5 flex-shrink-0" /> : <ChevronUp className="h-5 w-5 flex-shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
{children}
|
{children && <div className="flex items-center gap-2 flex-nowrap">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
{rightSection && <div className="flex items-center gap-2">{rightSection}</div>}
|
{rightSection && <div className="flex items-center gap-2">{rightSection}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface NumericInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
|
|||||||
required?: boolean
|
required?: boolean
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
warningMessage?: string
|
warningMessage?: string
|
||||||
|
inputWidth?: string // Custom width for the input field (e.g., 'w-16', 'w-20')
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||||
@@ -49,6 +50,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
required = false,
|
required = false,
|
||||||
errorMessage = 'Time is required',
|
errorMessage = 'Time is required',
|
||||||
warningMessage,
|
warningMessage,
|
||||||
|
inputWidth = 'w-20', // Default width
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
@@ -74,7 +76,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
}, [isInvalid, touched])
|
}, [isInvalid, touched])
|
||||||
// Determine decimal places based on increment
|
// Determine decimal places based on increment
|
||||||
const getDecimalPlaces = () => {
|
const getDecimalPlaces = () => {
|
||||||
const inc = String(increment || '1')
|
const inc = String(increment || '1').replace(',', '.')
|
||||||
const decimalIndex = inc.indexOf('.')
|
const decimalIndex = inc.indexOf('.')
|
||||||
if (decimalIndex === -1) return 0
|
if (decimalIndex === -1) return 0
|
||||||
return inc.length - decimalIndex - 1
|
return inc.length - decimalIndex - 1
|
||||||
@@ -97,7 +99,17 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
numValue = 0
|
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.max(min, numValue)
|
||||||
numValue = Math.min(max, numValue)
|
numValue = Math.min(max, numValue)
|
||||||
onChange(formatValue(numValue))
|
onChange(formatValue(numValue))
|
||||||
@@ -111,7 +123,10 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
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)) {
|
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
|
||||||
onChange(val)
|
onChange(val)
|
||||||
}
|
}
|
||||||
@@ -131,7 +146,11 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (inputValue !== '' && !isNaN(Number(inputValue))) {
|
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}
|
onFocus={handleFocus}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-20 h-9 z-20",
|
inputWidth, "h-9 z-10",
|
||||||
"rounded-none",
|
"rounded-none",
|
||||||
getAlignmentClass(),
|
getAlignmentClass(),
|
||||||
hasError && "border-destructive focus-visible:ring-destructive",
|
hasError && "border-destructive focus-visible:ring-destructive",
|
||||||
@@ -218,12 +237,12 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
</div>
|
</div>
|
||||||
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
|
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
|
||||||
{hasError && isFocused && errorMessage && (
|
{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}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasWarning && isFocused && warningMessage && (
|
{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}
|
{warningMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
|||||||
const [isFocused, setIsFocused] = React.useState(false)
|
const [isFocused, setIsFocused] = React.useState(false)
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
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
|
// Current committed value parsed from prop
|
||||||
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number)
|
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number)
|
||||||
|
|
||||||
@@ -128,26 +131,43 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
|||||||
const handlePickerOpen = (open: boolean) => {
|
const handlePickerOpen = (open: boolean) => {
|
||||||
setIsPickerOpen(open)
|
setIsPickerOpen(open)
|
||||||
if (open) {
|
if (open) {
|
||||||
// Reset staging when opening picker
|
// Save original value for cancel/revert and reset staging
|
||||||
|
setOriginalValue(value)
|
||||||
setStagedHour(null)
|
setStagedHour(null)
|
||||||
setStagedMinute(null)
|
setStagedMinute(null)
|
||||||
|
} else if (!open && originalValue) {
|
||||||
|
// Closing without explicit Apply - revert to original value
|
||||||
|
onChange(originalValue)
|
||||||
|
setOriginalValue('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHourClick = (hour: number) => {
|
const handleHourClick = (hour: number) => {
|
||||||
setStagedHour(hour)
|
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) => {
|
const handleMinuteClick = (minute: number) => {
|
||||||
setStagedMinute(minute)
|
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 = () => {
|
const handleApply = () => {
|
||||||
// Use staged values if selected, otherwise keep current values
|
// Commit the current value (already updated in real-time) and close
|
||||||
const finalHour = stagedHour !== null ? stagedHour : pickerHours
|
setOriginalValue('') // Clear original so revert doesn't happen on close
|
||||||
const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes
|
setIsPickerOpen(false)
|
||||||
const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}`
|
}
|
||||||
onChange(formattedTime)
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
// Revert to original value
|
||||||
|
onChange(originalValue)
|
||||||
|
setOriginalValue('')
|
||||||
setIsPickerOpen(false)
|
setIsPickerOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +265,15 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
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({
|
setAppState({
|
||||||
...defaults,
|
...defaults,
|
||||||
...parsedState,
|
...parsedState,
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ export const de = {
|
|||||||
timePickerHour: "Stunde",
|
timePickerHour: "Stunde",
|
||||||
timePickerMinute: "Minute",
|
timePickerMinute: "Minute",
|
||||||
timePickerApply: "Übernehmen",
|
timePickerApply: "Übernehmen",
|
||||||
|
timePickerCancel: "Abbrechen",
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
sortByTime: "Nach Zeit sortieren",
|
sortByTime: "Nach Zeit sortieren",
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ export const en = {
|
|||||||
timePickerHour: "Hour",
|
timePickerHour: "Hour",
|
||||||
timePickerMinute: "Minute",
|
timePickerMinute: "Minute",
|
||||||
timePickerApply: "Apply",
|
timePickerApply: "Apply",
|
||||||
|
timePickerCancel: "Cancel",
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
sortByTime: "Sort by time",
|
sortByTime: "Sort by time",
|
||||||
|
|||||||
Reference in New Issue
Block a user