Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions front/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,7 @@
"powerRestrictionIncompatibility_one": "{{count}} power restriction code isn't compatible with the electrification.",
"powerRestrictionIncompatibility_other": "{{count}} power restriction codes aren't compatible with the electrification.",
"propagationMenu": {
"atThisTime": "at this time",
"atThisWaypoint": "at this waypoint",
"fromDeparture": "from departure",
"shiftAllWaypoints": "shift all waypoints",
Expand Down
1 change: 1 addition & 0 deletions front/public/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,7 @@
"powerRestrictionIncompatibility_one": "{{count}} code de restriction de puissance n'est pas compatible avec l'électrification.",
"powerRestrictionIncompatibility_other": "{{count}} codes de restriction de puissance ne sont pas compatibles avec l'électrification.",
"propagationMenu": {
"atThisTime": "appliquer à cet horaire",
"atThisWaypoint": "appliquer à ce point",
"fromDeparture": "propager vers le début",
"shiftAllWaypoints": "décaler tout le sillon",
Expand Down
1 change: 1 addition & 0 deletions front/src/modules/timesStops/TimeCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ const TimeCell = ({
newValue={editedDate}
onSelectMode={handleSelectPropagationMode}
disableFromDeparture={column.id === 'requestedArrival' && isFirstRow}
disableAtThisTime={row.original.stopDuration === null}
disableToDestination={isLastRow}
isOriginArrival={column.id === 'requestedArrival' && isFirstRow}
/>
Expand Down
70 changes: 22 additions & 48 deletions front/src/modules/timesStops/TimePropagationMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,70 +14,44 @@ type TimePropagationMenuProps = {
newValue: Date | null;
onSelectMode: (mode: PropagationMode) => void;
disableFromDeparture?: boolean;
disableAtThisTime?: boolean;
disableToDestination?: boolean;
isOriginArrival?: boolean;
};

const MODE_ITEMS: { mode: PropagationMode; icon: React.ReactNode; className?: string }[] = [
{ mode: 'shiftAllWaypoints', icon: <ArrowBoth /> },
{ mode: 'fromDeparture', icon: <ArrowUp /> },
{ mode: 'atThisWaypoint', icon: <Dot variant="base" />, className: 'selected' },
{ mode: 'atThisTime', icon: <Dot variant="base" /> },
{ mode: 'toDestination', icon: <ArrowDown /> },
];

const TimePropagationMenu = ({
isOpen,
anchorRef,
oldValue,
newValue,
onSelectMode,
disableFromDeparture = false,
disableAtThisTime = false,
disableToDestination = false,
isOriginArrival = false,
}: TimePropagationMenuProps) => {
const { t } = useTranslation('translation', { keyPrefix: 'timeStopTable.propagationMenu' });
const shiftAllWaypointsDeltaLabel = formatPropagationDeltaLabelByMode(
oldValue,
newValue,
'shiftAllWaypoints'
);
const fromDepartureDeltaLabel = formatPropagationDeltaLabelByMode(
oldValue,
newValue,
'fromDeparture'
);
const atThisWaypointDeltaLabel = formatPropagationDeltaLabelByMode(
oldValue,
newValue,
'atThisWaypoint',
isOriginArrival
);
const toDestinationDeltaLabel = formatPropagationDeltaLabelByMode(
oldValue,
newValue,
'toDestination',
isOriginArrival
);
const selectMode = (mode: PropagationMode) => () => onSelectMode(mode);
const disabledByMode: Partial<Record<PropagationMode, boolean>> = {
fromDeparture: disableFromDeparture,
atThisTime: disableAtThisTime,
toDestination: disableToDestination,
};

const items: OSRDMenuItem[] = [
{
title: `${shiftAllWaypointsDeltaLabel} ${t('shiftAllWaypoints')}`,
icon: <ArrowBoth />,
onClick: selectMode('shiftAllWaypoints'),
},
{
title: `${fromDepartureDeltaLabel} ${t('fromDeparture')}`,
icon: <ArrowUp />,
disabled: disableFromDeparture,
onClick: selectMode('fromDeparture'),
},
{
title: `${atThisWaypointDeltaLabel} ${t('atThisWaypoint')}`,
icon: <Dot variant="base" />,
className: 'selected',
onClick: selectMode('atThisWaypoint'),
},
{
title: `${toDestinationDeltaLabel} ${t('toDestination')}`,
icon: <ArrowDown />,
disabled: disableToDestination,
onClick: selectMode('toDestination'),
},
];
const items: OSRDMenuItem[] = MODE_ITEMS.map(({ mode, icon, className }) => ({
title: `${formatPropagationDeltaLabelByMode(oldValue, newValue, mode, isOriginArrival)} ${t(mode)}`,
icon,
className,
disabled: disabledByMode[mode],
onClick: () => onSelectMode(mode),
}));

return (
<PropagationMenu
Expand Down
21 changes: 19 additions & 2 deletions front/src/modules/timesStops/helpers/stopDurationPropagation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,27 @@ import type { ScheduleItem, TimetableType } from 'common/api/osrdEditoastApi';
import type { Train } from 'reducers/osrdconf/types';
import { Duration, subtractDurationFromStartTime } from 'utils/duration';

import { ONE_DAY } from '../consts';
import type { StopDurationUpdate, PropagationResult } from '../types';
import { cascadeArrivals } from './arrivalCascade';
import { insertScheduleItemInOrder } from './cellUpdate';
import { formatSignedDelta, getTruncatedToSecondStartTime } from './utils';
import {
formatSignedDelta,
getTruncatedToSecondSchedule,
getTruncatedToSecondStartTime,
} from './utils';

/** Bring negative stop durations back within a day. */
export const normalizeStopDurations = (schedule: ScheduleItem[]): ScheduleItem[] =>
schedule.map((item) => {
const stop = item.stop_for ? getTruncatedToSecondSchedule(item.stop_for) : null;
if (!stop || stop.ms >= 0) return item;

return {
...item,
stop_for: new Duration({ milliseconds: stop.ms + ONE_DAY.ms }).toISOString(),
};
});

export const formatStopDurationDeltaLabel = (
oldValue: Duration | null,
Expand Down Expand Up @@ -62,7 +79,7 @@ export const propagateStopDuration = (
// Shift every scheduled arrival after the edited point by +delta, in path order. Bump +24h
// if a shifted arrival ends up before the previous departure.
const updatedSchedule = cascadeArrivals({
schedule: updatedScheduleStop,
schedule: normalizeStopDurations(updatedScheduleStop),
path: selectedTrain.path,
fromPathIndex: editedPathIndex + 1,
shift: (arrival) => arrival.add(delta),
Expand Down
200 changes: 153 additions & 47 deletions front/src/modules/timesStops/helpers/timePropagation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,19 @@ import {
} from 'utils/duration';

import type {
ArrivalUpdate,
BatchTimesUpdate,
CellUpdate,
PropagationMode,
PropagationResult,
RequestedTimeField,
} from '../types';
import { cascadeArrivals } from './arrivalCascade';
import { propagateStopDuration } from './stopDurationPropagation';
import { formatSignedDelta, getTruncatedToSecondStartTime } from './utils';

const isOriginArrivalUpdate = (
update: Exclude<CellUpdate, BatchTimesUpdate>
): update is ArrivalUpdate => update.field === 'requestedArrival' && update.row.opOnPathIndex === 0;
import { normalizeStopDurations, propagateStopDuration } from './stopDurationPropagation';
import {
formatSignedDelta,
getTruncatedToSecondSchedule,
getTruncatedToSecondStartTime,
} from './utils';

const toHmsDuration = (date: StartTime) =>
date instanceof Date
Expand All @@ -39,7 +39,7 @@ const computeDeltaForPropagationMode = (
): Duration | null => {
if (!oldValue || !newValue) return null;
// At the origin arrival, or for shiftAll and fromDeparture, only HH:mm:ss is compared (start_time absorbs the shift).
// For atThisWaypoint and toDestination, full date-times are compared, so it can produce a D+1.
// For atThisTime, atThisWaypoint and toDestination, full date-times are compared, so it can produce a D+1.
return isOriginArrival || mode === 'shiftAllWaypoints' || mode === 'fromDeparture'
? toHmsDuration(newValue).sub(toHmsDuration(oldValue))
: subtractStartTime(newValue, oldValue);
Expand Down Expand Up @@ -96,6 +96,50 @@ const propagateFromEditedPoint = (
};
};

/**
* Apply an update to the edited point alone.
* - atThisWaypoint: the waypoint moves as a block, arrival and departure move, stop duration is kept.
* - atThisTime: only the edited time moves, the stop duration absorbs the change.
*/
const applyAtThisPoint = (
delta: Duration,
editedPathStepId: string,
field: RequestedTimeField,
mode: 'atThisWaypoint' | 'atThisTime',
selectedTrain: Train,
timetableType: TimetableType
): PropagationResult | undefined => {
const editedPathIndex = selectedTrain.path.findIndex((step) => step.id === editedPathStepId);
if (editedPathIndex < 0) return undefined;

const editedSchedule = (selectedTrain.schedule ?? []).map((item) => {
if (item.at !== editedPathStepId) return item;
const stop = item.stop_for ? getTruncatedToSecondSchedule(item.stop_for) : null;

// An edited departure atThisTime is the stop moving by +delta, the arrival doesn't move.
if (field === 'requestedDeparture' && mode === 'atThisTime')
return stop ? { ...item, stop_for: stop.add(delta).toISOString() } : item;

// Otherwise the arrival moves by +delta, either alone — the waypoint moves as a block and its
// departure follows — or against a stop moving by -delta, which keeps that departure in place.
if (!item.arrival) return item;
const arrival = getTruncatedToSecondSchedule(item.arrival).add(delta).toISOString();
return mode === 'atThisTime' && stop
? { ...item, arrival, stop_for: stop.sub(delta).toISOString() }
: { ...item, arrival };
});

return {
updatedPath: selectedTrain.path,
updatedSchedule: cascadeArrivals({
schedule: normalizeStopDurations(editedSchedule),
path: selectedTrain.path,
fromPathIndex: editedPathIndex,
}),
updatedStartTime: getTruncatedToSecondStartTime(selectedTrain, timetableType),
};
};

const propagateShiftAll = (
delta: Duration,
selectedTrain: Train,
Expand All @@ -117,54 +161,116 @@ export const propagateTime = (
if (update.field !== 'requestedArrival' && update.field !== 'requestedDeparture')
return undefined;

const oldValue = update.row[update.field];
const newValue = update.value;
const isOriginArrival = isOriginArrivalUpdate(update);
const { pathStepId, stopDuration, opOnPathIndex } = update.row;
const isArrivalUpdate = update.field === 'requestedArrival';
const isOrigin = opOnPathIndex === 0;
const delta = computeDeltaForPropagationMode(
oldValue,
newValue,
update.row[update.field],
update.value,
update.propagationMode,
isOriginArrival
isArrivalUpdate && isOrigin
);
if (delta === null) return undefined;

// A departure update propagated toDestination is the same delta applied to the stop duration.
if (update.field === 'requestedDeparture' && update.propagationMode === 'toDestination') {
return propagateStopDuration(
{
row: update.row,
field: 'stopDuration',
value: (update.row.stopDuration ?? Duration.zero).add(delta).total('second'),
propagationMode: 'toDestination',
},
selectedTrain,
timetableType
);
}
if (delta === null || !pathStepId) return undefined;

if (update.propagationMode === 'shiftAllWaypoints')
return propagateShiftAll(delta, selectedTrain, timetableType);
switch (update.propagationMode) {
Comment thread
SharglutDev marked this conversation as resolved.
case 'shiftAllWaypoints':
return propagateShiftAll(delta, selectedTrain, timetableType);

case 'toDestination':
// A departure update propagated toDestination is the same delta applied to the stop duration.
if (!isArrivalUpdate)
return propagateStopDuration(
{
row: update.row,
field: 'stopDuration',
value: (stopDuration ?? Duration.zero).add(delta).total('second'),
propagationMode: 'toDestination',
},
selectedTrain,
timetableType
);

if (isOriginArrival) {
// At origin, every mode but toDestination only moves start_time. Following offsets are compensated so
// their absolute times stay the same — which is exactly what fromDeparture does.
return update.propagationMode === 'toDestination'
? propagateShiftAll(delta, selectedTrain, timetableType)
: propagateFromEditedPoint(
// At the origin the arrival is start_time, so moving it along with everything after it moves the whole train.
return isOrigin
? propagateShiftAll(delta, selectedTrain, timetableType)
: propagateFromEditedPoint(
delta,
pathStepId,
selectedTrain,
'toDestination',
timetableType
);

case 'fromDeparture':
// An arrival update propagated fromDeparture is the opposite delta applied to the stop duration if it exists.
return isArrivalUpdate && !isOrigin && stopDuration
? propagateStopDuration(
{
row: update.row,
field: 'stopDuration',
value: stopDuration.sub(delta).total('second'),
propagationMode: 'fromDeparture',
},
selectedTrain,
timetableType
)
: propagateFromEditedPoint(
delta,
pathStepId,
selectedTrain,
'fromDeparture',
timetableType
);

case 'atThisTime':
// The stop duration absorbs the change, so a point without one is left to the
// generic single-row edit.
if (!stopDuration) return undefined;

// At origin arrival, it means moving start_time and compensating the offsets after it, which a stop duration update does.
return isOrigin && isArrivalUpdate
? propagateStopDuration(
{
row: update.row,
field: 'stopDuration',
value: stopDuration.sub(delta).total('second'),
propagationMode: 'fromDeparture',
},
selectedTrain,
timetableType
)
: applyAtThisPoint(
delta,
pathStepId,
update.field,
'atThisTime',
selectedTrain,
timetableType
);

case 'atThisWaypoint':
// At origin, the point only moves start_time. Following offsets are compensated so their
// absolute times stay the same — which is exactly what fromDeparture does.
if (isOrigin)
return propagateFromEditedPoint(
delta,
update.row.pathStepId!,
pathStepId,
selectedTrain,
'fromDeparture',
timetableType
);
}

if (update.propagationMode === 'atThisWaypoint' || !update.row.pathStepId) return undefined;
return propagateFromEditedPoint(
delta,
update.row.pathStepId,
selectedTrain,
update.propagationMode,
timetableType
);
// Anywhere else, an arrival update changes nothing but its own cell, so it is left to the
// generic single-row edit, while a departure update takes the arrival along with it.
return isArrivalUpdate
? undefined
: applyAtThisPoint(
delta,
pathStepId,
update.field,
'atThisWaypoint',
selectedTrain,
timetableType
);
}
};
Loading
Loading