From 9364e49337ca0f0f248e858e54205b925f03ba1b Mon Sep 17 00:00:00 2001 From: Arthur Bougeard Date: Fri, 25 Sep 2026 13:40:03 +0200 Subject: [PATCH 1/2] front: function to project topological offset to geometrical offset (or reversed) Signed-off-by: Arthur Bougeard --- front/src/utils/__tests__/geometry.spec.ts | 32 ++++++++++++++- front/src/utils/geometry.ts | 47 +++++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/front/src/utils/__tests__/geometry.spec.ts b/front/src/utils/__tests__/geometry.spec.ts index ab194b89b78..15dbecdcc60 100644 --- a/front/src/utils/__tests__/geometry.spec.ts +++ b/front/src/utils/__tests__/geometry.spec.ts @@ -2,8 +2,11 @@ import { point, lineString, featureCollection } from '@turf/helpers'; import type { Feature, Point } from 'geojson'; import { describe, it, expect } from 'vitest'; -import type { GeoJsonLineString as LineString } from 'common/api/osrdEditoastApi'; -import { getTangent, nearestPointOnLine } from 'utils/geometry'; +import type { + GeoJsonLineString as LineString, + CorePropertyGeometryProjection as GeometryProjection, +} from 'common/api/osrdEditoastApi'; +import { convertGeomTopoTrackOffset, getTangent, nearestPointOnLine } from 'utils/geometry'; import lineNorthenLatitude from './assets/line-northern-latitude.json'; import linePointOnLeft from './assets/line-point-on-left.json'; @@ -71,3 +74,28 @@ describe('nearestPointOnLine', () => { }); }); }); + +describe('topoGeomOffsetConversion', () => { + const projection: GeometryProjection = { + topo_offsets: [0, 1000, 1000, 3000], + geom_offsets: [0, 700, 900, 2500], + }; + it('topo to geom', () => { + const geomOffset1 = convertGeomTopoTrackOffset(projection, 500, 'topo_to_geom'); + expect(geomOffset1).toEqual(350); + const geomOffset2 = convertGeomTopoTrackOffset(projection, 0, 'topo_to_geom'); + expect(geomOffset2).toEqual(0); + const geomOffset3 = convertGeomTopoTrackOffset(projection, 3000, 'topo_to_geom'); + expect(geomOffset3).toEqual(2500); + }); + it('geom to topo', () => { + const topoOffset1 = convertGeomTopoTrackOffset(projection, 1200, 'geom_to_topo'); + expect(topoOffset1).toEqual(1375); + const topoOffset2 = convertGeomTopoTrackOffset(projection, 800, 'geom_to_topo'); + expect(topoOffset2).toEqual(1000); + }); + it('edge case: several identic input offsets', () => { + const geomOffset = convertGeomTopoTrackOffset(projection, 1000, 'topo_to_geom'); + expect(geomOffset).toEqual(800); + }); +}); diff --git a/front/src/utils/geometry.ts b/front/src/utils/geometry.ts index 274d7b6fb1e..073a3815069 100644 --- a/front/src/utils/geometry.ts +++ b/front/src/utils/geometry.ts @@ -13,7 +13,7 @@ import type { } from 'geojson'; import { minBy } from 'lodash'; -import type { GeoJsonLineString } from 'common/api/osrdEditoastApi'; +import type { CorePropertyGeometryProjection, GeoJsonLineString } from 'common/api/osrdEditoastApi'; export function getTangent( tangentPoint: Position, @@ -173,3 +173,48 @@ export function getBarycenter(coords: Position[]): Position { const sum = coords.reduce((acc, [lon, lat]) => [acc[0] + lon, acc[1] + lat], [0, 0]); return [sum[0] / n, sum[1] / n]; } + +export function convertGeomTopoTrackOffset( + geomProjection: CorePropertyGeometryProjection, + offset: number, + conversionDirection: 'topo_to_geom' | 'geom_to_topo' +): number { + const inputOffsets = + conversionDirection === 'topo_to_geom' + ? geomProjection.topo_offsets + : geomProjection.geom_offsets; + const outputOffsets = + conversionDirection === 'topo_to_geom' + ? geomProjection.geom_offsets + : geomProjection.topo_offsets; + + if (offset < 0) throw new Error('Offset cannot be negative'); + + const index = inputOffsets.findIndex((value) => value >= offset); + if (index === -1) throw new Error('Offset is out of bounds'); + + if (inputOffsets[index] === offset) { + // counting all the identic input offsets + let upperIndex = index; + while (upperIndex + 1 < inputOffsets.length && inputOffsets[upperIndex + 1] === offset) { + upperIndex += 1; + } + // case with several identic input offsets + if (upperIndex !== index) { + // we return the middle between the lower value and the upper value of the output offsets + return Math.floor((outputOffsets[index] + outputOffsets[upperIndex]) / 2); + } + } + + if (index > 0) { + const relativeInputOffset = offset - inputOffsets[index - 1]; + const inputTrackLength = inputOffsets[index] - inputOffsets[index - 1]; + const outputTrackLength = outputOffsets[index] - outputOffsets[index - 1]; + const relativeOutputOffset = Math.floor( + (relativeInputOffset / inputTrackLength) * outputTrackLength + ); + return outputOffsets[index - 1] + relativeOutputOffset; + } else { + return inputOffsets[index]; + } +} From f9111e2c91d4c42df769c9259f7234e50a359ae5 Mon Sep 17 00:00:00 2001 From: Arthur Bougeard Date: Fri, 25 Sep 2026 13:58:59 +0200 Subject: [PATCH 2/2] front: use topological to geometric projection for constraint display Signed-off-by: Arthur Bougeard --- .../Itinerary/ItineraryModal.tsx | 1 + .../IncompatibleConstraints.tsx | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/ItineraryModal.tsx b/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/ItineraryModal.tsx index 016223fb734..400879f3974 100644 --- a/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/ItineraryModal.tsx +++ b/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/ItineraryModal.tsx @@ -1144,6 +1144,7 @@ const ItineraryModal = ({ geometry={pathProperties?.geometry} pathLength={pathProperties?.length} incompatibleConstraints={pathProperties?.incompatibleConstraints} + geometryProjection={pathProperties?.geom_projection} /> diff --git a/front/src/modules/pathfinding/components/IncompatibleConstraints/IncompatibleConstraints.tsx b/front/src/modules/pathfinding/components/IncompatibleConstraints/IncompatibleConstraints.tsx index 241a5de98a9..3412eae6ebd 100644 --- a/front/src/modules/pathfinding/components/IncompatibleConstraints/IncompatibleConstraints.tsx +++ b/front/src/modules/pathfinding/components/IncompatibleConstraints/IncompatibleConstraints.tsx @@ -11,10 +11,12 @@ import { useTranslation } from 'react-i18next'; import { useMap, type MapLayerMouseEvent } from 'react-map-gl/maplibre'; import type { + CorePropertyGeometryProjection, GeoJsonLineString, CoreIncompatibleConstraints as IncompatibleConstraintsType, } from 'common/api/osrdEditoastApi'; import Collapsable from 'common/Collapsable'; +import { convertGeomTopoTrackOffset } from 'utils/geometry'; import { getMapMouseEventNearestFeature } from 'utils/mapHelper'; import IncompatibleConstraintsFilters from './IncompatibleConstrainstFilters'; @@ -32,12 +34,14 @@ type IncompatibleConstraintsProps = { geometry?: GeoJsonLineString; pathLength?: number; incompatibleConstraints?: IncompatibleConstraintsType; + geometryProjection?: CorePropertyGeometryProjection; }; const IncompatibleConstraints = ({ geometry, pathLength, incompatibleConstraints, + geometryProjection, }: IncompatibleConstraintsProps) => { const { t } = useTranslation('operational-studies', { keyPrefix: 'manageTrainSchedule' }); const map = useMap(); @@ -139,7 +143,10 @@ const IncompatibleConstraints = ({ // When pathProperties changes // => reset state useEffect(() => { - const data = geometry && incompatibleConstraints ? incompatibleConstraints : undefined; + const data = + geometry && incompatibleConstraints && geometryProjection + ? incompatibleConstraints + : undefined; const dataPairs = Object.entries(data || {}); @@ -161,11 +168,17 @@ const IncompatibleConstraints = ({ .map(([key, value]) => value.map((e) => { const id = `${key}-${e.range.start}-${e.range.end}`; + const start = geometryProjection + ? convertGeomTopoTrackOffset(geometryProjection, e.range.start, 'topo_to_geom') + : e.range.start * ratio; + const end = geometryProjection + ? convertGeomTopoTrackOffset(geometryProjection, e.range.end, 'topo_to_geom') + : e.range.end * ratio; return { id, type: key as IncompatibleConstraintType, - start: e.range.start * ratio, - end: e.range.end * ratio, + start, + end, value: 'value' in e ? e.value : undefined, bbox: bbox( lineSliceAlong(geometry as LineString, e.range.start * ratio, e.range.end * ratio, {