Skip to content

Commit 4fb2649

Browse files
train extremities (#2139)
* save * save * helpers * mediator train head * save * fix label * tail shape * make head of the train precisely at the end of the triangle * change points to generate triangles * tests * avoid degenerate trainGeoJsonPath * avoid out of bounds crash
1 parent 5caae43 commit 4fb2649

File tree

7 files changed

+381
-25
lines changed

7 files changed

+381
-25
lines changed

front/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"@turf/along": "^6.5.0",
1515
"@turf/bbox": "^6.0.1",
1616
"@turf/bbox-clip": "^6.3.0",
17+
"@turf/bearing": "^6.5.0",
18+
"@turf/bezier-spline": "^6.5.0",
1719
"@turf/boolean-intersects": "^6.3.0",
1820
"@turf/boolean-point-in-polygon": "^6.3.0",
1921
"@turf/distance": "^6.3.0",
@@ -25,6 +27,7 @@
2527
"@turf/line-split": "^6.5.0",
2628
"@turf/nearest-point": "^6.3.0",
2729
"@turf/nearest-point-on-line": "^6.3.0",
30+
"@turf/transform-translate": "^6.5.0",
2831
"axios": "^0.27.2",
2932
"classnames": "^2.3.2",
3033
"d3": "^5.16.0",

front/src/applications/osrd/components/SimulationMap/TrainHoverPosition.tsx

+148-20
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import React from 'react';
22
import { useSelector } from 'react-redux';
33
import { Layer, Source, Marker } from 'react-map-gl';
4+
import along from '@turf/along';
45
import lineSliceAlong from '@turf/line-slice-along';
56
import length from '@turf/length';
7+
import bezierSpline from '@turf/bezier-spline';
8+
import transformTranslate from '@turf/transform-translate';
9+
import { Point, polygon, lineString } from '@turf/helpers';
610
import { Feature, LineString } from 'geojson';
711
import cx from 'classnames';
8-
import { get } from 'lodash';
12+
import { mapValues, get } from 'lodash';
913

1014
import { RootState } from 'reducers';
1115
import { Viewport } from 'reducers/map';
1216
import { AllowancesSetting } from 'reducers/osrdsimulation';
1317
import { datetime2time } from 'utils/timeManipulation';
1418
import { boundedValue } from 'utils/numbers';
19+
import { getCurrentBearing } from 'utils/geometry';
1520
import { TrainPosition } from './types';
1621

1722
function getFill(isSelectedTrain: boolean, ecoBlocks: boolean) {
@@ -46,31 +51,135 @@ function getSpeedAndTimeLabel(isSelectedTrain: boolean, ecoBlocks: boolean, poin
4651
);
4752
}
4853

54+
interface TriangleSideDimensions {
55+
left: number;
56+
right: number;
57+
up: number;
58+
upWidth: number;
59+
down: number;
60+
}
61+
4962
// When the train is backward, lineSliceAlong will crash. we need to have head and tail in the right order
50-
export function makeDisplayedHeadAndTail(point: TrainPosition, geojsonPath: Feature<LineString>) {
63+
export function makeDisplayedHeadAndTail(
64+
point: TrainPosition,
65+
geojsonPath: Feature<LineString>,
66+
sideDimensions: {
67+
head: TriangleSideDimensions;
68+
tail: TriangleSideDimensions;
69+
}
70+
) {
5171
const pathLength = length(geojsonPath);
52-
const trueHead = Math.max(point.tailDistanceAlong, point.headDistanceAlong);
53-
const trueTail = Math.min(point.tailDistanceAlong, point.headDistanceAlong);
72+
const [trueTail, trueHead] = [point.tailDistanceAlong, point.headDistanceAlong].sort(
73+
(a, b) => a - b
74+
);
75+
76+
const headMinusTriangle = trueHead - sideDimensions.head.up;
77+
const tailPlusTriangle = Math.min(trueTail + sideDimensions.tail.down, headMinusTriangle);
78+
const boundedHead = boundedValue(headMinusTriangle, [0, pathLength]);
79+
const boundedTail = boundedValue(tailPlusTriangle, [0, pathLength]);
80+
81+
const headPosition = along(geojsonPath, boundedHead);
82+
const tailPosition = along(geojsonPath, boundedTail);
5483
return {
55-
head: boundedValue(trueHead, 0, pathLength),
56-
tail: boundedValue(trueTail, 0, pathLength),
84+
headDistance: boundedHead,
85+
tailDistance: boundedTail,
86+
headPosition,
87+
tailPosition,
5788
};
5889
}
5990

60-
function getLengthFactorToKeepLabelPlacedCorrectlyWhenZooming(viewport: Viewport, threshold = 12) {
91+
function getzoomPowerOf2LengthFactor(viewport: Viewport, threshold = 12) {
6192
return 2 ** (threshold - viewport?.zoom);
6293
}
6394

95+
function getTriangleSideDimensions(zoomLengthFactor: number, size = 1) {
96+
const scaleNumber = (x: number) => x * zoomLengthFactor * size;
97+
const head = {
98+
left: 0.05,
99+
right: 0.05,
100+
up: 0.1,
101+
upWidth: 0.019,
102+
down: 0.02,
103+
};
104+
const tail = {
105+
left: 0.05,
106+
right: 0.05,
107+
up: 0.05,
108+
upWidth: 0.019,
109+
down: 0.02,
110+
};
111+
return {
112+
head: mapValues(head, scaleNumber),
113+
tail: mapValues(tail, scaleNumber),
114+
};
115+
}
116+
117+
function getHeadTriangle(
118+
trainGeoJsonPath: Feature<LineString>,
119+
position: Feature<Point>,
120+
sideDimensions: Record<string, number>
121+
) {
122+
const bearing = getCurrentBearing(trainGeoJsonPath);
123+
const left = transformTranslate(position, sideDimensions.left, bearing - 90);
124+
const right = transformTranslate(position, sideDimensions.right, bearing + 90);
125+
const up = transformTranslate(position, sideDimensions.up, bearing);
126+
const down = transformTranslate(position, sideDimensions.down, bearing + 180);
127+
const upLeft = transformTranslate(up, sideDimensions.upWidth, bearing - 90);
128+
const upRight = transformTranslate(up, sideDimensions.upWidth, bearing + 90);
129+
const coordinates = [
130+
down.geometry.coordinates,
131+
left.geometry.coordinates,
132+
upLeft.geometry.coordinates,
133+
upRight.geometry.coordinates,
134+
right.geometry.coordinates,
135+
down.geometry.coordinates,
136+
];
137+
const contour = lineString(coordinates);
138+
const bezier = bezierSpline(contour);
139+
const triangle = polygon([bezier.geometry.coordinates]);
140+
return triangle;
141+
}
142+
143+
function getTrainGeoJsonPath(
144+
geojsonPath: Feature<LineString>,
145+
tailDistance: number,
146+
headDistance: number
147+
) {
148+
const threshold = 0.0005;
149+
if (headDistance - tailDistance > threshold) {
150+
return lineSliceAlong(geojsonPath, tailDistance, headDistance);
151+
}
152+
return lineSliceAlong(geojsonPath, headDistance - threshold, headDistance);
153+
}
154+
155+
function getTrainPieces(
156+
point: TrainPosition,
157+
geojsonPath: Feature<LineString>,
158+
zoomLengthFactor: number
159+
) {
160+
const sideDimensions = getTriangleSideDimensions(zoomLengthFactor, 2);
161+
const { tailDistance, headDistance, headPosition, tailPosition } = makeDisplayedHeadAndTail(
162+
point,
163+
geojsonPath,
164+
sideDimensions
165+
);
166+
const trainGeoJsonPath = getTrainGeoJsonPath(geojsonPath, tailDistance, headDistance);
167+
const headTriangle = getHeadTriangle(trainGeoJsonPath, headPosition, sideDimensions.head);
168+
const rearTriangle = getHeadTriangle(trainGeoJsonPath, tailPosition, sideDimensions.tail);
169+
return [trainGeoJsonPath, headTriangle, rearTriangle];
170+
}
171+
64172
interface TrainHoverPositionProps {
65173
point: TrainPosition;
66174
isSelectedTrain?: boolean;
67175
geojsonPath: Feature<LineString>;
68176
}
69177

70-
const shiftFactor = {
71-
long: 1 / 450,
72-
lat: 1 / 1000,
178+
const labelShiftFactor = {
179+
long: 0.005,
180+
lat: 0.0011,
73181
};
182+
74183
function TrainHoverPosition(props: TrainHoverPositionProps) {
75184
const { point, isSelectedTrain = false, geojsonPath } = props;
76185
const { selectedTrain, allowancesSettings } = useSelector(
@@ -84,32 +193,51 @@ function TrainHoverPosition(props: TrainHoverPositionProps) {
84193
const label = getSpeedAndTimeLabel(isSelectedTrain, ecoBlocks, point);
85194

86195
if (geojsonPath && point.headDistanceAlong && point.tailDistanceAlong) {
87-
const zoomLengthFactor = getLengthFactorToKeepLabelPlacedCorrectlyWhenZooming(viewport);
88-
const { tail, head } = makeDisplayedHeadAndTail(point, geojsonPath);
89-
const trainGeoJsonPath = lineSliceAlong(geojsonPath, tail, head);
90-
196+
const zoomLengthFactor = getzoomPowerOf2LengthFactor(viewport);
197+
const [trainGeoJsonPath, headTriangle, rearTriangle] = getTrainPieces(
198+
point,
199+
geojsonPath,
200+
zoomLengthFactor
201+
);
91202
return (
92203
<>
93204
<Marker
94205
className="map-search-marker"
95206
longitude={
96-
point.headPosition.geometry.coordinates[0] + zoomLengthFactor * shiftFactor.long
207+
point.headPosition.geometry.coordinates[0] + zoomLengthFactor * labelShiftFactor.long
208+
}
209+
latitude={
210+
point.headPosition.geometry.coordinates[1] + zoomLengthFactor * labelShiftFactor.lat
97211
}
98-
latitude={point.headPosition.geometry.coordinates[1] + zoomLengthFactor * shiftFactor.lat}
99212
>
100213
{label}
101214
</Marker>
215+
<Source type="geojson" data={headTriangle}>
216+
<Layer
217+
id={`${point.id}-head`}
218+
type="fill"
219+
paint={{
220+
'fill-color': fill,
221+
}}
222+
/>
223+
</Source>
224+
<Source type="geojson" data={rearTriangle}>
225+
<Layer
226+
id={`${point.id}-rear`}
227+
type="fill"
228+
paint={{
229+
'fill-color': fill,
230+
}}
231+
/>
232+
</Source>
102233
<Source type="geojson" data={trainGeoJsonPath}>
103234
<Layer
104235
id={`${point.id}-path`}
105236
type="line"
106237
paint={{
107-
'line-width': 8,
238+
'line-width': 16,
108239
'line-color': fill,
109240
}}
110-
layout={{
111-
'line-cap': 'round',
112-
}}
113241
/>
114242
</Source>
115243
</>

front/src/applications/osrd/components/SimulationMap/__tests__/TrainHoverPosition.spec.ts

+98-4
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,25 @@ const unitKmSquare = [
2121

2222
const unitKmLine = [convertKmCoordsToDegree([0, 0]), convertKmCoordsToDegree([1, 0])];
2323

24+
const makeSideDimensions = (headUp = 0, tailDown = 0) => ({
25+
head: {
26+
left: 0,
27+
right: 0,
28+
up: headUp,
29+
upWidth: 0,
30+
down: 0,
31+
},
32+
tail: {
33+
left: 0,
34+
right: 0,
35+
up: 0,
36+
upWidth: 0,
37+
down: tailDown,
38+
},
39+
});
2440
describe('makeDisplayedHeadAndTail', () => {
2541
describe('normal train', () => {
26-
it('should return train head and tail to display', () => {
42+
it('should return train head and tail distance along the path', () => {
2743
const trainPosition: TrainPosition = {
2844
id: 'train',
2945
headPosition: pointFromKmCoords([1, 0]),
@@ -35,10 +51,78 @@ describe('makeDisplayedHeadAndTail', () => {
3551
};
3652
const pathPoints = unitKmSquare;
3753
const pathLineString = lineString(pathPoints);
38-
const { head, tail } = makeDisplayedHeadAndTail(trainPosition, pathLineString);
54+
const sideDimensions = makeSideDimensions();
55+
const { headDistance: head, tailDistance: tail } = makeDisplayedHeadAndTail(
56+
trainPosition,
57+
pathLineString,
58+
sideDimensions
59+
);
3960
expect(head).toEqual(1);
4061
expect(tail).toEqual(0);
4162
});
63+
it('should take the size of the tail and head triangle into account', () => {
64+
const trainPosition: TrainPosition = {
65+
id: 'train',
66+
headPosition: pointFromKmCoords([1, 0]),
67+
tailPosition: pointFromKmCoords([0, 0]),
68+
headDistanceAlong: 1,
69+
tailDistanceAlong: 0,
70+
speedTime: { speed: 0, time: 0 },
71+
trainLength: 1,
72+
};
73+
const pathPoints = unitKmSquare;
74+
const pathLineString = lineString(pathPoints);
75+
const sideDimensions = makeSideDimensions(0.1, 0.1);
76+
const { headDistance: head, tailDistance: tail } = makeDisplayedHeadAndTail(
77+
trainPosition,
78+
pathLineString,
79+
sideDimensions
80+
);
81+
expect(head).toEqual(0.9);
82+
expect(tail).toEqual(0.1);
83+
});
84+
it('should avoid overlapping head and tail. Head should be exact/tail should not exceed over the head', () => {
85+
const trainPosition: TrainPosition = {
86+
id: 'train',
87+
headPosition: pointFromKmCoords([1, 0]),
88+
tailPosition: pointFromKmCoords([0, 0]),
89+
headDistanceAlong: 1,
90+
tailDistanceAlong: 0,
91+
speedTime: { speed: 0, time: 0 },
92+
trainLength: 1,
93+
};
94+
const pathPoints = unitKmSquare;
95+
const pathLineString = lineString(pathPoints);
96+
const sideDimensions = makeSideDimensions(0.6, 0.7);
97+
const { headDistance: head, tailDistance: tail } = makeDisplayedHeadAndTail(
98+
trainPosition,
99+
pathLineString,
100+
sideDimensions
101+
);
102+
expect(head).toEqual(0.4);
103+
expect(tail).toEqual(0.4);
104+
});
105+
test('triangle correction should not make head/tail go beyound track limits', () => {
106+
const trainPosition: TrainPosition = {
107+
id: 'train',
108+
headPosition: pointFromKmCoords([1, 0]),
109+
tailPosition: pointFromKmCoords([0, 0]),
110+
headDistanceAlong: 1,
111+
tailDistanceAlong: 0,
112+
speedTime: { speed: 0, time: 0 },
113+
trainLength: 1,
114+
};
115+
const pathPoints = unitKmSquare;
116+
const pathLineString = lineString(pathPoints);
117+
const sideDimensions = makeSideDimensions(10, 10);
118+
const { headDistance: head, tailDistance: tail } = makeDisplayedHeadAndTail(
119+
trainPosition,
120+
pathLineString,
121+
sideDimensions
122+
);
123+
expect(head).toEqual(0);
124+
expect(tail).toEqual(0);
125+
});
42126
});
43127
describe('backward train', () => {
44128
it('should return train head and tail', () => {
@@ -53,7 +137,12 @@ describe('makeDisplayedHeadAndTail', () => {
53137
};
54138
const pathPoints = unitKmSquare;
55139
const pathLineString = lineString(pathPoints);
56-
const { head, tail } = makeDisplayedHeadAndTail(trainPosition, pathLineString);
140+
const sideDimensions = makeSideDimensions();
141+
const { headDistance: head, tailDistance: tail } = makeDisplayedHeadAndTail(
142+
trainPosition,
143+
pathLineString,
144+
sideDimensions
145+
);
57146
expect(head).toEqual(1);
58147
expect(tail).toEqual(0);
59148
});
@@ -71,7 +160,12 @@ describe('makeDisplayedHeadAndTail', () => {
71160
};
72161
const pathPoints = unitKmLine;
73162
const pathLineString = lineString(pathPoints);
74-
const { head, tail } = makeDisplayedHeadAndTail(trainPosition, pathLineString);
163+
const sideDimensions = makeSideDimensions();
164+
const { headDistance: head, tailDistance: tail } = makeDisplayedHeadAndTail(
165+
trainPosition,
166+
pathLineString,
167+
sideDimensions
168+
);
75169
expect(head).toBeCloseTo(1, 6);
76170
expect(tail).toBeCloseTo(1, 6);
77171
});

0 commit comments

Comments
 (0)