From 5bc2ffdf6066da9cdab00a1d3383d4598d3f3c5c Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Wed, 28 Jun 2023 17:26:46 +0200 Subject: [PATCH 01/15] simulation: drafts a warped map view left of GET Details: - Lots of new utils and helpers to get a transformation that "warps" a part of the map to something straighter - Hacky GeoJSON data loading using an hidden map - Drafts a rendering of the final transformed data --- front/package.json | 2 + .../views/SimulationResults.tsx | 27 ++- front/src/common/Map/Layers/GeoJSONs.tsx | 70 +++--- .../src/common/Map/WarpedMap/DataDisplay.tsx | 82 +++++++ front/src/common/Map/WarpedMap/DataLoader.tsx | 89 ++++++++ front/src/common/Map/WarpedMap/WarpedMap.tsx | 145 ++++++++++++ front/src/common/Map/WarpedMap/core/grids.tsx | 150 +++++++++++++ .../src/common/Map/WarpedMap/core/helpers.ts | 210 ++++++++++++++++++ .../common/Map/WarpedMap/core/projection.ts | 173 +++++++++++++++ .../src/common/Map/WarpedMap/core/quadtree.ts | 103 +++++++++ .../src/common/Map/WarpedMap/core/vec-lib.ts | 51 +++++ front/yarn.lock | 18 ++ 12 files changed, 1085 insertions(+), 35 deletions(-) create mode 100644 front/src/common/Map/WarpedMap/DataDisplay.tsx create mode 100644 front/src/common/Map/WarpedMap/DataLoader.tsx create mode 100644 front/src/common/Map/WarpedMap/WarpedMap.tsx create mode 100644 front/src/common/Map/WarpedMap/core/grids.tsx create mode 100644 front/src/common/Map/WarpedMap/core/helpers.ts create mode 100644 front/src/common/Map/WarpedMap/core/projection.ts create mode 100644 front/src/common/Map/WarpedMap/core/quadtree.ts create mode 100644 front/src/common/Map/WarpedMap/core/vec-lib.ts diff --git a/front/package.json b/front/package.json index af2a39a425c..487f7b6117e 100644 --- a/front/package.json +++ b/front/package.json @@ -28,6 +28,7 @@ "@turf/boolean-point-in-polygon": "^6.3.0", "@turf/center": "^6.5.0", "@turf/combine": "^6.5.0", + "@turf/destination": "^6.5.0", "@turf/distance": "^6.3.0", "@turf/explode": "^6.5.0", "@turf/helpers": "^6.3.0", @@ -38,6 +39,7 @@ "@turf/line-split": "^6.5.0", "@turf/nearest-point": "^6.3.0", "@turf/nearest-point-on-line": "^6.3.0", + "@turf/simplify": "^6.5.0", "@turf/transform-translate": "^6.5.0", "@vitejs/plugin-react-swc": "^3.1.0", "ajv": "^8.12.0", diff --git a/front/src/applications/operationalStudies/views/SimulationResults.tsx b/front/src/applications/operationalStudies/views/SimulationResults.tsx index bd101cb01bd..1bc3aa18205 100644 --- a/front/src/applications/operationalStudies/views/SimulationResults.tsx +++ b/front/src/applications/operationalStudies/views/SimulationResults.tsx @@ -29,6 +29,7 @@ import cx from 'classnames'; import { Infra, osrdEditoastApi } from 'common/api/osrdEditoastApi'; import { getSelectedTrain } from 'reducers/osrdsimulation/selectors'; import ScenarioLoader from 'modules/scenario/components/ScenarioLoader'; +import { WarpedMap } from 'common/Map/WarpedMap/WarpedMap'; const MAP_MIN_HEIGHT = 450; @@ -130,6 +131,7 @@ export default function SimulationResults({ isDisplayed, collapsedTimetable, inf if (!displaySimulation || isUpdating) { return
{waitingMessage()}
; } + return (
{/* SIMULATION : STICKY BAR */} @@ -153,15 +155,22 @@ export default function SimulationResults({ isDisplayed, collapsedTimetable, inf {/* SIMULATION : SPACE TIME CHART */} -
-
- {displaySimulation && ( - - )} +
+ + +
+
+ {displaySimulation && ( + + )} +
diff --git a/front/src/common/Map/Layers/GeoJSONs.tsx b/front/src/common/Map/Layers/GeoJSONs.tsx index b8ee1b85f1f..99a4332b8c2 100644 --- a/front/src/common/Map/Layers/GeoJSONs.tsx +++ b/front/src/common/Map/Layers/GeoJSONs.tsx @@ -2,7 +2,7 @@ import { useSelector } from 'react-redux'; import React, { FC, useEffect, useMemo, useState } from 'react'; import chroma from 'chroma-js'; import { Feature, FeatureCollection } from 'geojson'; -import { isPlainObject, keyBy, mapValues } from 'lodash'; +import { isPlainObject, keyBy, mapValues, omit } from 'lodash'; import { AnyLayer, Layer, Source, LayerProps } from 'react-map-gl/maplibre'; import { FilterSpecification } from 'maplibre-gl'; import { getInfraID } from 'reducers/osrdconf/selectors'; @@ -333,6 +333,7 @@ const GeoJSONs: FC<{ fingerprint?: string | number; isEmphasized?: boolean; beforeId?: string; + renderAll?: boolean; }> = ({ colors, layersSettings, @@ -343,6 +344,7 @@ const GeoJSONs: FC<{ prefix = 'editor/', isEmphasized = true, beforeId, + renderAll, }) => { const infraID = useSelector(getInfraID); const selectedPrefix = `${prefix}selected/`; @@ -386,38 +388,54 @@ const GeoJSONs: FC<{ [hiddenColors, layerContext] ); - const sources = useMemo( - () => - SOURCES_DEFINITION.flatMap((source) => - !layers || layers.has(source.entityType) - ? [ - { - id: `${prefix}geo/${source.entityType}`, - url: `${MAP_URL}/layer/${source.entityType}/mvt/geo/?infra=${infraID}`, - layers: source - .getLayers({ ...hiddenLayerContext, sourceTable: source.entityType }, prefix) - .map((layer) => adaptFilter(layer, (hidden || []).concat(selection || []), [])), - }, - { - id: `${selectedPrefix}geo/${source.entityType}`, - url: `${MAP_URL}/layer/${source.entityType}/mvt/geo/?infra=${infraID}`, - layers: source - .getLayers({ ...layerContext, sourceTable: source.entityType }, selectedPrefix) - .map((layer) => adaptFilter(layer, hidden || [], selection || [])), - }, - ] - : [] - ), - [hidden, hiddenLayerContext, layerContext, layers, infraID, prefix, selectedPrefix, selection] - ); + const sources = useMemo(() => { + const res = SOURCES_DEFINITION.flatMap((source) => + !layers || layers.has(source.entityType) + ? [ + { + id: `${prefix}geo/${source.entityType}`, + url: `${MAP_URL}/layer/${source.entityType}/mvt/geo/?infra=${infraID}`, + layers: source + .getLayers({ ...hiddenLayerContext, sourceTable: source.entityType }, prefix) + .map((layer) => adaptFilter(layer, (hidden || []).concat(selection || []), [])), + }, + { + id: `${selectedPrefix}geo/${source.entityType}`, + url: `${MAP_URL}/layer/${source.entityType}/mvt/geo/?infra=${infraID}`, + layers: source + .getLayers({ ...layerContext, sourceTable: source.entityType }, selectedPrefix) + .map((layer) => adaptFilter(layer, hidden || [], selection || [])), + }, + ] + : [] + ); + + return renderAll + ? res.map((source) => ({ + ...source, + layers: source.layers.map((layer) => omit(layer, 'minzoom') as typeof layer), + })) + : res; + }, [ + hidden, + hiddenLayerContext, + layerContext, + layers, + infraID, + prefix, + selectedPrefix, + selection, + renderAll, + ]); if (skipSources) { return null; } + return ( <> {sources.map((source) => ( - + {source.layers.map((layer) => ( ))} diff --git a/front/src/common/Map/WarpedMap/DataDisplay.tsx b/front/src/common/Map/WarpedMap/DataDisplay.tsx new file mode 100644 index 00000000000..274db8aeda0 --- /dev/null +++ b/front/src/common/Map/WarpedMap/DataDisplay.tsx @@ -0,0 +1,82 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { omit } from 'lodash'; + +import maplibregl from 'maplibre-gl'; +import ReactMapGL, { Layer, MapRef } from 'react-map-gl'; +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; +import { featureCollection } from '@turf/helpers'; +import { Feature, GeoJSON } from 'geojson'; + +import colors from '../Consts/colors'; +import { EditorSource, SourcesDefinitionsIndex } from '../Layers/GeoJSONs'; +import osmBlankStyle from '../Layers/osmBlankStyle'; +import { LayerType } from '../../../applications/editor/tools/types'; +import { LayerContext } from '../Layers/types'; +import { ALL_SIGNAL_LAYERS } from '../Consts/SignalsNames'; +import { RootState } from '../../../reducers'; +import { LoaderFill } from '../../Loader'; + +const DataDisplay: FC<{ + layers: Set; + bbox: BBox2d; + data: Partial> | null; +}> = ({ data, bbox, layers }) => { + const prefix = 'warped/'; + const [map, setMap] = useState(null); + const { mapStyle, layersSettings, showIGNBDORTHO } = useSelector((s: RootState) => s.map); + + useEffect(() => { + if (!map) return; + + map.fitBounds(bbox, { animate: false }); + }, [map, bbox]); + + const layerContext: LayerContext = useMemo( + () => ({ + colors: colors[mapStyle], + signalsList: ALL_SIGNAL_LAYERS, + symbolsList: ALL_SIGNAL_LAYERS, + sourceLayer: 'geo', + prefix: '', + isEmphasized: false, + showIGNBDORTHO, + layersSettings, + }), + [colors, mapStyle, showIGNBDORTHO, layersSettings] + ); + const sources = useMemo( + () => + Array.from(layers).map((layer) => ({ + source: layer, + id: `${prefix}geo/${layer}`, + layers: SourcesDefinitionsIndex[layer](layerContext, prefix).map( + (props) => omit(props, 'source-layer') as typeof props + ), + })), + [layers] + ); + + return data ? ( + + + {sources.map((s) => ( + + ))} + + ) : ( + + ); +}; + +export default DataDisplay; diff --git a/front/src/common/Map/WarpedMap/DataLoader.tsx b/front/src/common/Map/WarpedMap/DataLoader.tsx new file mode 100644 index 00000000000..0c36e586b60 --- /dev/null +++ b/front/src/common/Map/WarpedMap/DataLoader.tsx @@ -0,0 +1,89 @@ +import React, { FC, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { createPortal } from 'react-dom'; + +import { GeoJSON } from 'geojson'; +import maplibregl from 'maplibre-gl'; +import ReactMapGL, { MapRef } from 'react-map-gl'; +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; + +import { LayerType } from '../../../applications/editor/tools/types'; +import osmBlankStyle from '../Layers/osmBlankStyle'; +import GeoJSONs from '../Layers/GeoJSONs'; +import colors from '../Consts/colors'; +import { getMap } from '../../../reducers/map/selectors'; + +const DataLoader: FC<{ + bbox: BBox2d; + getGeoJSONs: (data: Partial>) => void; + layers: Set; +}> = ({ bbox, getGeoJSONs, layers }) => { + const { mapStyle, layersSettings } = useSelector(getMap); + const [map, setMap] = useState(null); + const [state, setState] = useState<'idle' | 'render' | 'loaded'>('idle'); + + useEffect(() => { + if (!map) return; + + map.fitBounds(bbox, { animate: false }); + setTimeout(() => { + setState('render'); + }, 0); + }, [map, bbox]); + + useEffect(() => { + if (state === 'render') { + const m = map as MapRef; + + const querySources = () => { + const data: Partial> = {}; + layers.forEach((layer) => { + data[layer] = m.querySourceFeatures(`editor/geo/${layer}`, { sourceLayer: layer }); + }); + getGeoJSONs(data); + setState('loaded'); + }; + + m.on('idle', querySources); + + return () => { + m.off('idle', querySources); + }; + } + + return undefined; + }, [state]); + + return state !== 'loaded' + ? createPortal( +
+ + {state === 'render' && ( + + )} + +
, + document.body + ) + : null; +}; + +export default DataLoader; diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx new file mode 100644 index 00000000000..163d1162e10 --- /dev/null +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -0,0 +1,145 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { mapValues } from 'lodash'; + +import bbox from '@turf/bbox'; +import simplify from '@turf/simplify'; +import { lineString } from '@turf/helpers'; +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; +import { Feature, FeatureCollection, GeoJSON, Geometry, LineString, Position } from 'geojson'; + +import { + extendLine, + featureToPointsGrid, + getGridIndex, + getSamples, + pointsGridToZone, +} from './core/helpers'; +import { getQuadTree } from './core/quadtree'; +import { getGrids, straightenGrid } from './core/grids'; +import { clipAndProjectGeoJSON, projectBetweenGrids } from './core/projection'; +import { RootState } from '../../../reducers'; +import { LoaderFill } from '../../Loader'; +import { osrdEditoastApi } from '../../api/osrdEditoastApi'; +import { LayerType } from '../../../applications/editor/tools/types'; +import DataLoader from './DataLoader'; +import DataDisplay from './DataDisplay'; + +export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { + const layers = useMemo( + () => + new Set(['buffer_stops', 'detectors', 'signals', 'switches', 'track_sections']), + [] + ); + const [transformedData, setTransformedData] = useState + > | null>(null); + const pathBBox = useMemo(() => bbox(path) as BBox2d, [path]); + + // Transformation function: + const { regularBBox, transform } = useMemo(() => { + // Simplify the input path to get something "straighter", so that we can see + // in the final warped map the small curves of the initial path: + const simplifiedPath = simplify(path, { tolerance: 0.01 }); + + // Cut the simplified path as N equal length segments + const sample = getSamples(simplifiedPath, 30); + const samplePath = lineString(sample.points.map((point) => point.geometry.coordinates)); + + // Extend the sample, so that we can warp things right before and right + // after the initial path: + const extendedSamples = extendLine(samplePath, sample.step); + const steps = extendedSamples.geometry.coordinates.length - 1; + + // Generate our base grids: + const { regular, warped } = getGrids(extendedSamples); + + // Improve the warped grid, to get it less discontinuous: + const betterWarped = straightenGrid(warped, steps, { force: 0.8, iterations: 3 }); + + // Index the grids: + const regularIndex = getGridIndex(regular); + const warpedQuadTree = getQuadTree(betterWarped, 4); + + // Return projection function and exact warped grid boundaries: + const zone = pointsGridToZone(featureToPointsGrid(betterWarped, steps)); + const projection = (position: Position) => + projectBetweenGrids(warpedQuadTree, regularIndex, position); + + // Finally we have a proper transformation function that takes any feature + // as input, clips it to the grid contour polygon, and projects it the + // regular grid: + return { + regularBBox: bbox(regular) as BBox2d, + transform: (f: T): T | null => + clipAndProjectGeoJSON(f, projection, zone), + }; + }, [path]); + + return ( +
+ { + setTransformedData( + mapValues( + data, + (geoJSONs) => + geoJSONs?.flatMap((geoJSON) => { + const transformed = transform(geoJSON); + return transformed ? [transformed] : []; + }) || [] + ) + ); + }} + layers={layers} + /> +
+ +
+
+ ); +}; + +export const WarpedMap: FC = () => { + const [state, setState] = useState< + | { type: 'idle' } + | { type: 'loading' } + | { type: 'ready'; path: Feature } + | { type: 'error'; message?: string } + >({ type: 'idle' }); + const pathfindingID = useSelector( + (s: RootState) => s.osrdsimulation.selectedProjection?.path + ) as number; + const [getPath] = osrdEditoastApi.useLazyGetPathfindingByIdQuery(); + + useEffect(() => { + setState({ type: 'loading' }); + getPath({ id: pathfindingID }) + .then(({ data, isError, error }) => { + if (isError) { + setState({ type: 'error', message: error as string }); + } else { + const coordinates = data?.geographic?.coordinates as Position[] | null; + + setState( + coordinates + ? { type: 'ready', path: lineString(coordinates) } + : { type: 'error', message: 'No coordinates' } + ); + } + }) + .catch((error) => setState({ type: 'error', message: error })); + }, [pathfindingID]); + + return state.type === 'ready' ? : ; +}; diff --git a/front/src/common/Map/WarpedMap/core/grids.tsx b/front/src/common/Map/WarpedMap/core/grids.tsx new file mode 100644 index 00000000000..c46ac39fddd --- /dev/null +++ b/front/src/common/Map/WarpedMap/core/grids.tsx @@ -0,0 +1,150 @@ +/* eslint-disable prefer-destructuring, no-plusplus */ +import { Feature, LineString, Position } from 'geojson'; +import { clamp, cloneDeep, meanBy } from 'lodash'; +import length from '@turf/length'; +import center from '@turf/center'; +import { featureCollection, lineString, polygon } from '@turf/helpers'; +import destination from '@turf/destination'; +import bearing from '@turf/bearing'; + +import { + featureToPointsGrid, + GridFeature, + Grids, + PointsGrid, + pointsGridToFeature, +} from './helpers'; +import vec, { Vec2 } from './vec-lib'; + +/** + * This function takes a path, and returns two isomorphic grids: + * - The `regular` grid is vertical grid, with each pair of triangles making a + * perfectly regular triangle (considering the earth is flat, ie lat/lng are + * considered as x/y) + * - The `warped` grid follows the path + * Using these two grids, it becomes possible to project any point from one grid + * to the other. + */ +export function getGrids(line: Feature, params?: { stripsPerSide?: number }): Grids { + const l = line.geometry.coordinates.length; + if (l <= 2) throw new Error('line must have at least 3 points'); + + const stripsPerSide = params?.stripsPerSide || 2; + + const totalLength = length(line); + const step = totalLength / (l - 1); + const c = center(line); + const flatGrid = featureCollection([]) as GridFeature; + + // Generate flat line: + const flatLineStart = destination(c, totalLength / 2, 180); + const flatLinePoints: Position[] = []; + for (let i = 0; i < l; i++) + flatLinePoints.push(destination(flatLineStart, -i * step, 180).geometry.coordinates); + const flatLine = lineString(flatLinePoints); + + // Generate flat grid: + for (let i = 0; i < l - 1; i++) { + const p0 = flatLine.geometry.coordinates[i]; + const p1 = flatLine.geometry.coordinates[i + 1]; + for (let direction = -1; direction <= 1; direction += 2) { + for (let j = 0; j < stripsPerSide; j++) { + const p00 = destination(p0, step * j, direction * 90).geometry.coordinates; + const p01 = destination(p0, step * (j + 1), direction * 90).geometry.coordinates; + const p10 = destination(p1, step * j, direction * 90).geometry.coordinates; + const p11 = destination(p1, step * (j + 1), direction * 90).geometry.coordinates; + flatGrid.features.push( + polygon([[p00, p10, p01, p00]], { triangleId: `step:${i}/strip:${direction * j}/inside` }) + ); + flatGrid.features.push( + polygon([[p11, p10, p01, p11]], { + triangleId: `step:${i}/strip:${direction * j}/outside`, + }) + ); + } + } + } + + // Generate "twisted" grid: + // 1. Store points for each triangle: + const points: PointsGrid = []; + for (let i = 0; i < l; i++) { + points[i] = {}; + const p = line.geometry.coordinates[i]; + const pP = line.geometry.coordinates[i === 0 ? i : i - 1]; + const pN = line.geometry.coordinates[i === l - 1 ? i : i + 1]; + const angle = bearing(pP, pN) + 90; + points[i][0] = p; + for (let direction = -1; direction <= 1; direction += 2) { + for (let j = 1; j <= stripsPerSide; j++) { + points[i][direction * j] = destination(p, step * j * direction, angle).geometry.coordinates; + } + } + } + + // 2. Store triangles: + const grid = pointsGridToFeature(points); + + return { warped: grid, regular: flatGrid }; +} + +/** + * This grid created by `getGrids` are a bit brute, and can have some weird + * knots, when the input path is too curved. This function helps to get a + * better warped curve, by moving each point (except the path and the first and + * last rows) towards the barycenter of its neighbors. + */ +export function straightenGrid( + grid: GridFeature, + steps: number, + settings?: { force?: number; iterations?: number } +): GridFeature { + const force = clamp(settings?.force || 0.5, 0, 1); + const iterations = Math.max(1, settings?.iterations || 1); + + if (iterations > 1) { + let iter = grid; + for (let i = 0; i < iterations; i++) iter = straightenGrid(iter, steps, { force }); + return iter; + } + + const points = featureToPointsGrid(grid, steps); + const rows = points.length; + const newPoints: PointsGrid = []; + const stripsPerSide = (Object.keys(points[0]).length - 1) / 2; + + newPoints[0] = cloneDeep(points[0]); + newPoints[rows - 1] = cloneDeep(points[rows - 1]); + + for (let i = 1; i < rows - 1; i++) { + newPoints[i] = {}; + + for (let direction = -1; direction <= 1; direction += 2) { + newPoints[i][0] = points[i][0]; + + for (let j = 1; j <= stripsPerSide; j++) { + const current = points[i][direction * j]; + const top = (points[i - 1] || {})[direction * j]; + const bottom = (points[i + 1] || {})[direction * j]; + const left = (points[i] || {})[direction * j - 1]; + const right = (points[i] || {})[direction * j + 1]; + + const neighbors = [ + top, + bottom, + // When there is no neighbor on one side, we mirror the one from the + // other side: + left || vec.add(current as Vec2, vec.vector(right as Vec2, current as Vec2)), + right || vec.add(current as Vec2, vec.vector(left as Vec2, current as Vec2)), + ]; + + newPoints[i][direction * j] = [ + meanBy(neighbors, (a) => a[0]) * force + current[0] * (1 - force), + meanBy(neighbors, (a) => a[1]) * force + current[1] * (1 - force), + ]; + } + } + } + + return pointsGridToFeature(newPoints); +} diff --git a/front/src/common/Map/WarpedMap/core/helpers.ts b/front/src/common/Map/WarpedMap/core/helpers.ts new file mode 100644 index 00000000000..e543a1ea151 --- /dev/null +++ b/front/src/common/Map/WarpedMap/core/helpers.ts @@ -0,0 +1,210 @@ +/* eslint-disable prefer-destructuring, no-plusplus */ +import { Feature, FeatureCollection, LineString, Point, Polygon, Position } from 'geojson'; +import { clamp, first, keyBy, last } from 'lodash'; +import length from '@turf/length'; +import { featureCollection, point, polygon } from '@turf/helpers'; +import along from '@turf/along'; +import distance from '@turf/distance'; + +import vec, { Vec2 } from './vec-lib'; +import { PolygonZone } from '../../../../types'; + +/** + * Useful types: + */ +export type TriangleProperties = { triangleId: string }; +export type Triangle = Feature; +export type BarycentricCoordinates = [number, number, number]; +export type GridFeature = FeatureCollection; +export type GridIndex = Record; +export type Grids = { + regular: GridFeature; + warped: GridFeature; +}; +export type PointsGrid = Record[]; + +/** + * Path manipulation helpers: + */ +export function getSamples( + line: Feature, + samples: number +): { step: number; points: Feature[] } { + if (samples <= 1) throw new Error('samples must be an integer greater than 1'); + + const points: Feature[] = []; + const l = length(line, { units: 'meters' }); + const step = l / (samples - 1); + for (let i = 0; i < samples; i++) { + if (!i) { + points.push(point(first(line.geometry.coordinates) as Position)); + } else if (i === samples - 1) { + points.push(point(last(line.geometry.coordinates) as Position)); + } else { + const at = clamp(step * i, 0, l); + points.push(along(line, at, { units: 'meters' })); + } + } + + return { step, points }; +} + +export function extendLine(line: Feature, lengthToAdd: number): Feature { + if (lengthToAdd <= 1) throw new Error('lengthToAdd must be a positive'); + + const points = line.geometry.coordinates; + const firstPoint = points[0] as Vec2; + const second = points[1] as Vec2; + const lastPoint = points[points.length - 1] as Vec2; + const secondToLast = points[points.length - 2] as Vec2; + + return { + ...line, + geometry: { + ...line.geometry, + coordinates: [ + vec.add( + firstPoint, + vec.multiply( + vec.vector(second, firstPoint), + lengthToAdd / distance(second, firstPoint, { units: 'meters' }) + ) + ), + ...points, + vec.add( + lastPoint, + vec.multiply( + vec.vector(secondToLast, lastPoint), + lengthToAdd / distance(secondToLast, lastPoint, { units: 'meters' }) + ) + ), + ], + }, + }; +} + +/** + * Grid helpers: + */ +export function getGridIndex(grid: GridFeature): GridIndex { + return keyBy(grid.features, (feature) => feature.properties.triangleId); +} + +export function featureToPointsGrid(grid: GridFeature, steps: number): PointsGrid { + const points: PointsGrid = []; + const gridIndex = getGridIndex(grid); + const stripsPerSide = grid.features.length / steps / 2 / 2; + + for (let i = 0; i < steps; i++) { + points[i] = points[i] || {}; + points[i + 1] = points[i + 1] || {}; + for (let direction = -1; direction <= 1; direction += 2) { + for (let j = 0; j < stripsPerSide; j++) { + const inside = gridIndex[`step:${i}/strip:${direction * j}/inside`]; + const outside = gridIndex[`step:${i}/strip:${direction * j}/outside`]; + const [[p00, p10, p01]] = inside.geometry.coordinates; + const [[p11]] = outside.geometry.coordinates; + + points[i][direction * j] = p00; + points[i][direction * (j + 1)] = p01; + points[i + 1][direction * j] = p10; + points[i + 1][direction * (j + 1)] = p11; + } + } + } + + return points; +} +export function pointsGridToFeature(points: PointsGrid): GridFeature { + const grid = featureCollection([]) as GridFeature; + const steps = points.length - 1; + const stripsPerSide = (Object.keys(points[0]).length - 1) / 2; + + for (let i = 0; i < steps; i++) { + for (let direction = -1; direction <= 1; direction += 2) { + for (let j = 0; j < stripsPerSide; j++) { + const p00 = points[i][direction * j]; + const p01 = points[i][direction * (j + 1)]; + const p10 = points[i + 1][direction * j]; + const p11 = points[i + 1][direction * (j + 1)]; + grid.features.push( + polygon([[p00, p10, p01, p00]], { + triangleId: `step:${i}/strip:${direction * j}/inside`, + }) as Triangle + ); + grid.features.push( + polygon([[p11, p10, p01, p11]], { + triangleId: `step:${i}/strip:${direction * j}/outside`, + }) as Triangle + ); + } + } + } + + return grid; +} + +export function pointsGridToZone(points: PointsGrid): PolygonZone { + const firstRow = points[0]; + const lastRow = points[points.length - 1]; + const stripsPerSide = (Object.keys(firstRow).length - 1) / 2; + const border: Position[] = []; + + // Add first row: + for (let i = -stripsPerSide; i <= stripsPerSide; i++) { + border.push(points[0][i]); + } + + // Add all intermediary rows: + for (let i = 1, l = points.length - 1; i < l; i++) { + border.push(points[i][stripsPerSide]); + border.unshift(points[i][-stripsPerSide]); + } + + // Add last row: + for (let i = stripsPerSide; i >= -stripsPerSide; i--) { + border.push(lastRow[i]); + } + + // Close path: + border.push(border[0]); + + return { + type: 'polygon', + points: border, + }; +} + +/** + * Triangle helpers: + */ +export function getBarycentricCoordinates( + [x, y]: Position, + { + geometry: { + coordinates: [[[x0, y0], [x1, y1], [x2, y2]]], + }, + }: Triangle +): BarycentricCoordinates { + const denominator = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2); + const a = ((y1 - y2) * (x - x2) + (x2 - x1) * (y - y2)) / denominator; + const b = ((y2 - y0) * (x - x2) + (x0 - x2) * (y - y2)) / denominator; + const c = 1 - a - b; + + return [a, b, c]; +} + +export function isInTriangle([a, b, c]: BarycentricCoordinates): boolean { + return a >= 0 && a <= 1 && b >= 0 && b <= 1 && c >= 0 && c <= 1; +} + +export function getPointInTriangle( + [a, b, c]: BarycentricCoordinates, + { + geometry: { + coordinates: [[[x0, y0], [x1, y1], [x2, y2]]], + }, + }: Triangle +): Position { + return [a * x0 + b * x1 + c * x2, a * y0 + b * y1 + c * y2]; +} diff --git a/front/src/common/Map/WarpedMap/core/projection.ts b/front/src/common/Map/WarpedMap/core/projection.ts new file mode 100644 index 00000000000..c0b04eb67dc --- /dev/null +++ b/front/src/common/Map/WarpedMap/core/projection.ts @@ -0,0 +1,173 @@ +/* eslint-disable prefer-destructuring, no-plusplus */ +import { Feature, FeatureCollection, Geometry, Position } from 'geojson'; +import { keyBy } from 'lodash'; + +import { + getBarycentricCoordinates, + getPointInTriangle, + GridIndex, + isInTriangle, + Triangle, +} from './helpers'; +import { getElements, Quad } from './quadtree'; +import { Zone } from '../../../../types'; +import { clip } from '../../../../utils/mapboxHelper'; + +export type Projection = (position: Position) => Position | null; + +/** + * This function maps the position of a point from one grid to another grid. + * It can take either a GridIndex or a QuadTree for the source grid, but beware + * that it is absolutely faster using a QuadTree. + */ +export function projectBetweenGrids( + gridFrom: GridIndex, + gridTo: GridIndex, + position: Position +): Position | null; +export function projectBetweenGrids( + quadTreeFrom: Quad, + gridTo: GridIndex, + position: Position +): Position | null; +export function projectBetweenGrids( + from: GridIndex | Quad, + gridTo: GridIndex, + position: Position +): Position | null { + let triangles: GridIndex; + + if (from.type === 'quad') { + triangles = keyBy( + getElements(position, from as Quad), + (feature) => feature.properties.triangleId + ); + } else { + triangles = from as GridIndex; + } + + const keys = Object.keys(triangles); + for (let i = 0, l = keys.length; i < l; i++) { + const key = keys[i]; + const triangle = triangles[key]; + const barycentricCoordinates = getBarycentricCoordinates(position, triangle); + if (isInTriangle(barycentricCoordinates)) { + return getPointInTriangle(barycentricCoordinates, gridTo[key]); + } + } + + return null; +} + +export function projectGeometry( + geometry: G, + project: Projection +): G | null { + if (!geometry) return null; + + switch (geometry.type) { + case 'Point': { + const newCoordinates = project(geometry.coordinates); + + return newCoordinates + ? { + ...geometry, + coordinates: newCoordinates, + } + : null; + } + case 'MultiPoint': + case 'LineString': { + const newPoints = geometry.coordinates.flatMap((p) => { + const newP = project(p); + return newP ? [newP] : []; + }); + + return newPoints.length + ? { + ...geometry, + coordinates: newPoints, + } + : null; + } + case 'Polygon': + case 'MultiLineString': { + const newPaths = geometry.coordinates.flatMap((path) => { + const newPath = path.flatMap((p) => { + const newP = project(p); + return newP ? [newP] : []; + }); + + return newPath.length ? [newPath] : []; + }); + + return newPaths.length + ? { + ...geometry, + coordinates: newPaths, + } + : null; + } + case 'MultiPolygon': { + const newMultiPaths = geometry.coordinates.flatMap((paths) => { + const newPaths = paths.flatMap((path) => { + const newPath = path.flatMap((p) => { + const newP = project(p); + return newP ? [newP] : []; + }); + + return newPath.length ? [newPath] : []; + }); + + return newPaths.length ? [newPaths] : []; + }); + + return newMultiPaths.length + ? { + ...geometry, + coordinates: newMultiPaths, + } + : null; + } + case 'GeometryCollection': + return { + ...geometry, + geometries: geometry.geometries.map((g) => projectGeometry(g, project)), + }; + default: + return geometry; + } +} + +export function clipAndProjectGeoJSON( + geojson: T, + projection: Projection, + zone: Zone +): T | null { + if (geojson.type === 'FeatureCollection') + return { + ...geojson, + features: geojson.features.flatMap((f) => { + const res = clipAndProjectGeoJSON(f, projection, zone); + return res ? [res] : []; + }), + }; + + if (geojson.type === 'Feature') { + const clippedFeature = clip(geojson, zone) as Feature | null; + + if (clippedFeature) { + const newGeometry = projectGeometry(clippedFeature.geometry, projection); + return newGeometry + ? ({ + ...clippedFeature, + geometry: newGeometry, + } as T) + : null; + } + + return null; + } + + return projectGeometry(geojson, projection) as T | null; +} diff --git a/front/src/common/Map/WarpedMap/core/quadtree.ts b/front/src/common/Map/WarpedMap/core/quadtree.ts new file mode 100644 index 00000000000..f6dc88fa329 --- /dev/null +++ b/front/src/common/Map/WarpedMap/core/quadtree.ts @@ -0,0 +1,103 @@ +/* eslint-disable prefer-destructuring, no-plusplus */ +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; +import { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from 'geojson'; +import bbox from '@turf/bbox'; + +export type Leaf = { + type: 'leaf'; + elements: T[]; +}; +export type Quad = { + type: 'quad'; + bbox: BBox2d; + children: [QuadChild | null, QuadChild | null, QuadChild | null, QuadChild | null]; +}; +export type QuadChild = Quad | Leaf; + +export function bboxIntersect([mx1, my1, Mx1, My1]: BBox2d, [mx2, my2, Mx2, My2]: BBox2d): boolean { + return !(mx1 > Mx2) && !(Mx1 < mx2) && !(my1 > My2) && !(My1 < my2); +} + +export function getNewQuadChild(box: BBox2d, isLeaf?: boolean): QuadChild { + return isLeaf + ? { + type: 'leaf', + elements: [], + } + : { + type: 'quad', + bbox: box, + children: [null, null, null, null], + }; +} + +export function getQuadTree( + collection: FeatureCollection, + depth: number +): Quad> { + const boundingBox = bbox(collection) as BBox2d; + const root = getNewQuadChild(boundingBox) as Quad>; + + for (let i = 0, l = collection.features.length; i < l; i++) { + const feature = collection.features[i]; + const fBBox = bbox(feature) as BBox2d; + + let quads: QuadChild>[] = [root]; + for (let d = 0; d < depth; d++) { + if (!quads.length) break; + + const newQuads: QuadChild>[] = []; + for (let j = 0, quadsCount = quads.length; j < quadsCount; j++) { + const quad = quads[j]; + if (quad.type !== 'quad') break; + + const [x1, y1, x2, y2] = quad.bbox; + const ax = (x1 + x2) / 2; + const ay = (y1 + y2) / 2; + + const candidates: BBox2d[] = [ + [x1, y1, ax, ay], + [ax, y1, x2, ay], + [x1, ay, ax, y2], + [ax, ay, x2, y2], + ]; + for (let k = 0; k < candidates.length; k++) { + const candidate = candidates[k]; + if (bboxIntersect(fBBox, candidate)) { + quad.children[k] = quad.children[k] || getNewQuadChild(candidate, d === depth - 1); + newQuads.push(quad.children[k] as QuadChild>); + } + } + } + + quads = newQuads; + } + + for (let j = 0, k = quads.length; j < k; j++) { + const quad = quads[j]; + if (quad.type !== 'leaf') break; + + quad.elements.push(feature); + } + } + + return root; +} + +export function getElements(point: Position, quadTree: Quad): T[] { + const [x, y] = point; + const [minX, minY, maxX, maxY] = quadTree.bbox; + const avgX = (minX + maxX) / 2; + const avgY = (minY + maxY) / 2; + + let child: QuadChild | null; + if (x < avgX && y < avgY) child = quadTree.children[0]; + else if (x > avgX && y < avgY) child = quadTree.children[1]; + else if (x < avgX && y > avgY) child = quadTree.children[2]; + else child = quadTree.children[3]; + + if (!child) return []; + if (child.type === 'quad') return getElements(point, child); + + return child.elements; +} diff --git a/front/src/common/Map/WarpedMap/core/vec-lib.ts b/front/src/common/Map/WarpedMap/core/vec-lib.ts new file mode 100644 index 00000000000..bff1fd083a2 --- /dev/null +++ b/front/src/common/Map/WarpedMap/core/vec-lib.ts @@ -0,0 +1,51 @@ +export type Vec2 = [number, number]; + +function x(v: Vec2): number { + return v[0]; +} +function y(v: Vec2): number { + return v[1]; +} + +function vector(from: Vec2, to: Vec2): Vec2 { + return [x(to) - x(from), y(to) - y(from)]; +} +function add(v1: Vec2, v2: Vec2): Vec2 { + return [x(v1) + x(v2), y(v1) + y(v2)]; +} +function multiply(v: Vec2, factor: number): Vec2 { + return [x(v) * factor, y(v) * factor]; +} +function divide(v: Vec2, ratio: number): Vec2 { + return multiply(v, 1 / ratio); +} +function length(v: Vec2): number { + return Math.sqrt(x(v) ** 2 + y(v) ** 2); +} +function distance(p1: Vec2, p2: Vec2): number { + return length(vector(p1, p2)); +} +function normalize(v: Vec2): Vec2 { + return divide(v, length(v)); +} +function dot(v1: Vec2, v2: Vec2): number { + return x(v1) * x(v2) + y(v1) + y(v2); +} +function angle(v1: Vec2, v2: Vec2): number { + return Math.acos(dot(v1, v2) / length(v1) / length(v2)); +} + +const lib = { + x, + y, + vector, + add, + multiply, + divide, + length, + distance, + normalize, + dot, + angle, +}; +export default lib; diff --git a/front/yarn.lock b/front/yarn.lock index 84080911225..1d153498a67 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -3534,6 +3534,14 @@ "@turf/bbox" "^6.5.0" "@turf/helpers" "^6.5.0" +"@turf/clean-coords@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/clean-coords/-/clean-coords-6.5.0.tgz#6690adf764ec4b649710a8a20dab7005efbea53f" + integrity sha512-EMX7gyZz0WTH/ET7xV8MyrExywfm9qUi0/MY89yNffzGIEHuFfqwhcCqZ8O00rZIPZHUTxpmsxQSTfzJJA1CPw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/clone@^6.5.0": version "6.5.0" resolved "https://registry.yarnpkg.com/@turf/clone/-/clone-6.5.0.tgz#895860573881ae10a02dfff95f274388b1cda51a" @@ -3695,6 +3703,16 @@ "@turf/helpers" "^6.5.0" "@turf/invariant" "^6.5.0" +"@turf/simplify@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/simplify/-/simplify-6.5.0.tgz#ec435460bde0985b781618b05d97146c32c8bc16" + integrity sha512-USas3QqffPHUY184dwQdP8qsvcVH/PWBYdXY5am7YTBACaQOMAlf6AKJs9FT8jiO6fQpxfgxuEtwmox+pBtlOg== + dependencies: + "@turf/clean-coords" "^6.5.0" + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/square@^6.5.0": version "6.5.0" resolved "https://registry.yarnpkg.com/@turf/square/-/square-6.5.0.tgz#ab43eef99d39c36157ab5b80416bbeba1f6b2122" From 5cf805897b4d9f3a4abc5376b866fe4b6dd82c03 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Thu, 29 Jun 2023 13:18:01 +0200 Subject: [PATCH 02/15] simulation: fixes missing points in warped map --- front/src/common/Map/WarpedMap/WarpedMap.tsx | 18 ++++++++---------- front/src/common/Map/WarpedMap/core/grids.tsx | 6 ++++-- front/src/common/Map/WarpedMap/core/helpers.ts | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx index 163d1162e10..7accbf11969 100644 --- a/front/src/common/Map/WarpedMap/WarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -52,7 +52,7 @@ export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { const steps = extendedSamples.geometry.coordinates.length - 1; // Generate our base grids: - const { regular, warped } = getGrids(extendedSamples); + const { regular, warped } = getGrids(extendedSamples, { stripsPerSide: 12 }); // Improve the warped grid, to get it less discontinuous: const betterWarped = straightenGrid(warped, steps, { force: 0.8, iterations: 3 }); @@ -77,22 +77,20 @@ export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { }, [path]); return ( -
+
{ setTransformedData( - mapValues( - data, - (geoJSONs) => - geoJSONs?.flatMap((geoJSON) => { - const transformed = transform(geoJSON); - return transformed ? [transformed] : []; - }) || [] + mapValues(data, (geoJSONs: GeoJSON[]) => + geoJSONs.flatMap((geoJSON) => { + const transformed = transform(geoJSON); + return transformed ? [transformed] : []; + }) ) ); }} - layers={layers} />
, params?: { stripsPerSide?: n const p10 = destination(p1, step * j, direction * 90).geometry.coordinates; const p11 = destination(p1, step * (j + 1), direction * 90).geometry.coordinates; flatGrid.features.push( - polygon([[p00, p10, p01, p00]], { triangleId: `step:${i}/strip:${direction * j}/inside` }) + polygon([[p00, p10, p01, p00]], { + triangleId: `step:${i}/strip:${direction * (j + 1)}/inside`, + }) ); flatGrid.features.push( polygon([[p11, p10, p01, p11]], { - triangleId: `step:${i}/strip:${direction * j}/outside`, + triangleId: `step:${i}/strip:${direction * (j + 1)}/outside`, }) ); } diff --git a/front/src/common/Map/WarpedMap/core/helpers.ts b/front/src/common/Map/WarpedMap/core/helpers.ts index e543a1ea151..d360508699d 100644 --- a/front/src/common/Map/WarpedMap/core/helpers.ts +++ b/front/src/common/Map/WarpedMap/core/helpers.ts @@ -100,8 +100,8 @@ export function featureToPointsGrid(grid: GridFeature, steps: number): PointsGri points[i + 1] = points[i + 1] || {}; for (let direction = -1; direction <= 1; direction += 2) { for (let j = 0; j < stripsPerSide; j++) { - const inside = gridIndex[`step:${i}/strip:${direction * j}/inside`]; - const outside = gridIndex[`step:${i}/strip:${direction * j}/outside`]; + const inside = gridIndex[`step:${i}/strip:${direction * (j + 1)}/inside`]; + const outside = gridIndex[`step:${i}/strip:${direction * (j + 1)}/outside`]; const [[p00, p10, p01]] = inside.geometry.coordinates; const [[p11]] = outside.geometry.coordinates; @@ -129,12 +129,12 @@ export function pointsGridToFeature(points: PointsGrid): GridFeature { const p11 = points[i + 1][direction * (j + 1)]; grid.features.push( polygon([[p00, p10, p01, p00]], { - triangleId: `step:${i}/strip:${direction * j}/inside`, + triangleId: `step:${i}/strip:${direction * (j + 1)}/inside`, }) as Triangle ); grid.features.push( polygon([[p11, p10, p01, p11]], { - triangleId: `step:${i}/strip:${direction * j}/outside`, + triangleId: `step:${i}/strip:${direction * (j + 1)}/outside`, }) as Triangle ); } From 88c46a5f806b1e464ac2d803f31ef3c840f11702 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Thu, 29 Jun 2023 16:06:04 +0200 Subject: [PATCH 03/15] simulation: adds OSM data to warped map --- .../views/SimulationResults.tsx | 2 +- front/src/common/Map/Layers/GeoJSONs.tsx | 14 ++- front/src/common/Map/Layers/OSM.tsx | 60 +++++++------ front/src/common/Map/Layers/OrderedLayer.tsx | 2 +- .../src/common/Map/WarpedMap/DataDisplay.tsx | 89 +++++++++++++++---- front/src/common/Map/WarpedMap/DataLoader.tsx | 87 ++++++++++++++---- front/src/common/Map/WarpedMap/WarpedMap.tsx | 49 ++++++---- 7 files changed, 215 insertions(+), 88 deletions(-) diff --git a/front/src/applications/operationalStudies/views/SimulationResults.tsx b/front/src/applications/operationalStudies/views/SimulationResults.tsx index 1bc3aa18205..0fe9dbe5f02 100644 --- a/front/src/applications/operationalStudies/views/SimulationResults.tsx +++ b/front/src/applications/operationalStudies/views/SimulationResults.tsx @@ -44,7 +44,7 @@ export default function SimulationResults({ isDisplayed, collapsedTimetable, inf const timeTableRef = useRef(null); const [extViewport, setExtViewport] = useState(undefined); - const [heightOfSpaceTimeChart, setHeightOfSpaceTimeChart] = useState(400); + const [heightOfSpaceTimeChart, setHeightOfSpaceTimeChart] = useState(600); const [heightOfSpeedSpaceChart, setHeightOfSpeedSpaceChart] = useState(250); diff --git a/front/src/common/Map/Layers/GeoJSONs.tsx b/front/src/common/Map/Layers/GeoJSONs.tsx index 99a4332b8c2..92cbe4bd396 100644 --- a/front/src/common/Map/Layers/GeoJSONs.tsx +++ b/front/src/common/Map/Layers/GeoJSONs.tsx @@ -47,6 +47,7 @@ import { } from './extensions/SNCF/SNCF_LPV_PANELS'; import { LayerContext } from './types'; import { getCatenariesProps, getCatenariesTextParams } from './Catenaries'; +import OrderedLayer from './OrderedLayer'; const SIGNAL_TYPE_KEY = 'extensions_sncf_installation_type'; @@ -315,11 +316,16 @@ export const EditorSource: FC<{ id?: string; data: Feature | FeatureCollection; layers: AnyLayer[]; -}> = ({ id, data, layers }) => ( + layerOrder?: number; +}> = ({ id, data, layers, layerOrder }) => ( - {layers.map((layer) => ( - - ))} + {layers.map((layer) => + typeof layerOrder === 'number' ? ( + + ) : ( + + ) + )} ); diff --git a/front/src/common/Map/Layers/OSM.tsx b/front/src/common/Map/Layers/OSM.tsx index 81cb5088a3d..1c95bbc7aff 100644 --- a/front/src/common/Map/Layers/OSM.tsx +++ b/front/src/common/Map/Layers/OSM.tsx @@ -7,45 +7,49 @@ import mapStyleDarkJson from 'assets/mapstyles/OSMDarkStyle.json'; import mapStyleBluePrintJson from 'assets/mapstyles/OSMBluePrintStyle.json'; import { OSM_URL } from 'common/Map/const'; -import OrderedLayer from 'common/Map/Layers/OrderedLayer'; +import OrderedLayer, { OrderedLayerProps } from 'common/Map/Layers/OrderedLayer'; interface OSMProps { mapStyle: string; layerOrder?: number; } -function OSM(props: OSMProps) { - const { mapStyle, layerOrder } = props; - - function getMapStyle(): LayerProps[] { - switch (mapStyle) { - case 'empty': - return [] as LayerProps[]; - case 'dark': - return mapStyleDarkJson as LayerProps[]; - case 'blueprint': - return mapStyleBluePrintJson as LayerProps[]; - default: - return mapStyleJson as LayerProps[]; - } +export function getMapStyle(mapStyle: string): LayerProps[] { + switch (mapStyle) { + case 'empty': + return [] as LayerProps[]; + case 'dark': + return mapStyleDarkJson as LayerProps[]; + case 'blueprint': + return mapStyleBluePrintJson as LayerProps[]; + default: + return mapStyleJson as LayerProps[]; } +} + +export function genLayerProps( + mapStyle: string, + layerOrder?: number +): (OrderedLayerProps & { key?: string })[] { + const osmStyle = getMapStyle(mapStyle); + return osmStyle.map((layer) => ({ + ...layer, + key: layer.id, + id: `osm/${layer.id}`, + layerOrder, + })); +} + +export function genLayers(mapStyle: string, layerOrder?: number) { + return genLayerProps(mapStyle, layerOrder).map((props) => ); +} - const genLayers = () => { - const osmStyle = getMapStyle(); - return osmStyle.map((layer) => { - const layerProps = { - ...layer, - key: layer.id, - id: `osm/${layer.id}`, - layerOrder, - }; - return ; - }); - }; +function OSM(props: OSMProps) { + const { mapStyle, layerOrder } = props; return ( - {genLayers()} + {genLayers(mapStyle, layerOrder)} ); } diff --git a/front/src/common/Map/Layers/OrderedLayer.tsx b/front/src/common/Map/Layers/OrderedLayer.tsx index ab7f0b9b244..28488055c43 100644 --- a/front/src/common/Map/Layers/OrderedLayer.tsx +++ b/front/src/common/Map/Layers/OrderedLayer.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Layer, LayerProps, useMap } from 'react-map-gl/maplibre'; import { isNumber } from 'lodash'; -type OrderedLayerProps = LayerProps & { +export type OrderedLayerProps = LayerProps & { layerOrder?: number; }; diff --git a/front/src/common/Map/WarpedMap/DataDisplay.tsx b/front/src/common/Map/WarpedMap/DataDisplay.tsx index 274db8aeda0..a8bec6c2409 100644 --- a/front/src/common/Map/WarpedMap/DataDisplay.tsx +++ b/front/src/common/Map/WarpedMap/DataDisplay.tsx @@ -1,12 +1,12 @@ import React, { FC, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { omit } from 'lodash'; +import { omit, map, groupBy } from 'lodash'; import maplibregl from 'maplibre-gl'; -import ReactMapGL, { Layer, MapRef } from 'react-map-gl'; +import ReactMapGL, { Layer, MapRef, Source } from 'react-map-gl'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { featureCollection } from '@turf/helpers'; -import { Feature, GeoJSON } from 'geojson'; +import { FeatureCollection } from 'geojson'; import colors from '../Consts/colors'; import { EditorSource, SourcesDefinitionsIndex } from '../Layers/GeoJSONs'; @@ -16,21 +16,46 @@ import { LayerContext } from '../Layers/types'; import { ALL_SIGNAL_LAYERS } from '../Consts/SignalsNames'; import { RootState } from '../../../reducers'; import { LoaderFill } from '../../Loader'; +import { genLayerProps } from '../Layers/OSM'; +import { LAYER_GROUPS_ORDER, LAYERS } from '../../../config/layerOrder'; +import OrderedLayer, { OrderedLayerProps } from '../Layers/OrderedLayer'; +import VirtualLayers from '../../../applications/operationalStudies/components/SimulationResults/SimulationResultsMap/VirtualLayers'; + +const OSRD_LAYER_ORDERS: Record = { + buffer_stops: LAYER_GROUPS_ORDER[LAYERS.BUFFER_STOPS.GROUP], + detectors: LAYER_GROUPS_ORDER[LAYERS.DETECTORS.GROUP], + signals: LAYER_GROUPS_ORDER[LAYERS.SIGNALS.GROUP], + switches: LAYER_GROUPS_ORDER[LAYERS.SWITCHES.GROUP], + track_sections: LAYER_GROUPS_ORDER[LAYERS.TRACKS_SCHEMATIC.GROUP], + // Unused: + catenaries: 0, + lpv: 0, + lpv_panels: 0, + routes: 0, + speed_sections: 0, + errors: 0, +}; const DataDisplay: FC<{ - layers: Set; bbox: BBox2d; - data: Partial> | null; -}> = ({ data, bbox, layers }) => { + osrdLayers: Set; + osrdData?: Partial>; + osmData?: Record; +}> = ({ bbox, osrdLayers, osrdData, osmData }) => { const prefix = 'warped/'; - const [map, setMap] = useState(null); + const [mapRef, setMapRef] = useState(null); const { mapStyle, layersSettings, showIGNBDORTHO } = useSelector((s: RootState) => s.map); useEffect(() => { - if (!map) return; + if (!mapRef) return; - map.fitBounds(bbox, { animate: false }); - }, [map, bbox]); + const avgLon = (bbox[0] + bbox[2]) / 2; + const thinBBox: BBox2d = [avgLon, bbox[1], avgLon, bbox[3]]; + setTimeout(() => { + mapRef.fitBounds(thinBBox, { animate: false }); + mapRef.resize(); + }, 0); + }, [mapRef, bbox]); const layerContext: LayerContext = useMemo( () => ({ @@ -45,32 +70,60 @@ const DataDisplay: FC<{ }), [colors, mapStyle, showIGNBDORTHO, layersSettings] ); - const sources = useMemo( + const osrdSources = useMemo( () => - Array.from(layers).map((layer) => ({ + Array.from(osrdLayers).map((layer) => ({ source: layer, + order: OSRD_LAYER_ORDERS[layer], id: `${prefix}geo/${layer}`, layers: SourcesDefinitionsIndex[layer](layerContext, prefix).map( (props) => omit(props, 'source-layer') as typeof props ), })), - [layers] + [osrdLayers] + ); + const osmSources = useMemo( + () => + groupBy( + genLayerProps( + mapStyle, + LAYER_GROUPS_ORDER[LAYERS.BACKGROUND.GROUP] + ) as (OrderedLayerProps & { + 'source-layer': string; + })[], + (layer) => layer['source-layer'] + ), + [mapStyle] ); - return data ? ( + return osrdData && osmData ? ( - {sources.map((s) => ( + + {map(osmSources, (layers, sourceLayer) => ( + + {layers.map((layer) => ( + + ))} + + ))} + {osrdSources.map((s) => ( ))} diff --git a/front/src/common/Map/WarpedMap/DataLoader.tsx b/front/src/common/Map/WarpedMap/DataLoader.tsx index 0c36e586b60..f7e52e9033a 100644 --- a/front/src/common/Map/WarpedMap/DataLoader.tsx +++ b/front/src/common/Map/WarpedMap/DataLoader.tsx @@ -1,26 +1,45 @@ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { createPortal } from 'react-dom'; -import { GeoJSON } from 'geojson'; -import maplibregl from 'maplibre-gl'; -import ReactMapGL, { MapRef } from 'react-map-gl'; +import maplibregl, { MapGeoJSONFeature } from 'maplibre-gl'; +import { Feature, FeatureCollection } from 'geojson'; +import ReactMapGL, { Source, MapRef, MapboxGeoJSONFeature } from 'react-map-gl'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; +import { featureCollection } from '@turf/helpers'; +import { groupBy, mapValues } from 'lodash'; import { LayerType } from '../../../applications/editor/tools/types'; import osmBlankStyle from '../Layers/osmBlankStyle'; import GeoJSONs from '../Layers/GeoJSONs'; import colors from '../Consts/colors'; import { getMap } from '../../../reducers/map/selectors'; +import { OSM_URL } from '../const'; +import { genLayers } from '../Layers/OSM'; + +function simplifyFeature(feature: MapboxGeoJSONFeature): Feature { + const f = feature as unknown as MapGeoJSONFeature; + return { + type: 'Feature', + id: f.id, + properties: { ...f.properties, sourceLayer: f.sourceLayer }, + // eslint-disable-next-line no-underscore-dangle + geometry: f.geometry || f._geometry, + }; +} const DataLoader: FC<{ bbox: BBox2d; - getGeoJSONs: (data: Partial>) => void; + getGeoJSONs: ( + osrdData: Partial>, + osmData: Record + ) => void; layers: Set; }> = ({ bbox, getGeoJSONs, layers }) => { const { mapStyle, layersSettings } = useSelector(getMap); const [map, setMap] = useState(null); const [state, setState] = useState<'idle' | 'render' | 'loaded'>('idle'); + const osmLayers = useMemo(() => genLayers(mapStyle), [mapStyle]); useEffect(() => { if (!map) return; @@ -36,11 +55,40 @@ const DataLoader: FC<{ const m = map as MapRef; const querySources = () => { - const data: Partial> = {}; + // Retrieve OSRD data: + const osrdData: Partial> = {}; layers.forEach((layer) => { - data[layer] = m.querySourceFeatures(`editor/geo/${layer}`, { sourceLayer: layer }); + osrdData[layer] = featureCollection( + m + .querySourceFeatures(`editor/geo/${layer}`, { sourceLayer: layer }) + .map(simplifyFeature) + ); }); - getGeoJSONs(data); + + // Retrieve OSM data: + // (we have to force cast, because in our weird setup, osmSource is + // typed as if it was from mapbox when it actually comes from maplibre) + const osmSource = m.getSource('osm') as unknown as { vectorLayerIds: string[] }; + const osmFeatures: Feature[] = osmSource.vectorLayerIds.flatMap((layer) => + m.querySourceFeatures('osm', { sourceLayer: layer }).map((feature) => { + const f = simplifyFeature(feature); + + return { + ...f, + properties: { + ...(f.properties || {}), + sourceLayer: layer, + }, + }; + }) + ); + const osmData: Record = mapValues( + groupBy(osmFeatures, (feature) => feature.properties?.sourceLayer), + (features) => featureCollection(features) + ); + + // Finalize: + getGeoJSONs(osrdData, osmData); setState('loaded'); }; @@ -60,8 +108,8 @@ const DataLoader: FC<{ className="position-absolute" style={{ bottom: '110%', - height: 500, - width: 500, + height: 3000, + width: 3000, }} > {state === 'render' && ( - + <> + + {osmLayers} + + + )}
, diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx index 7accbf11969..e10b4ea0f13 100644 --- a/front/src/common/Map/WarpedMap/WarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -1,12 +1,12 @@ import React, { FC, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { mapValues } from 'lodash'; +import { isNil, mapValues, omitBy } from 'lodash'; import bbox from '@turf/bbox'; import simplify from '@turf/simplify'; import { lineString } from '@turf/helpers'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; -import { Feature, FeatureCollection, GeoJSON, Geometry, LineString, Position } from 'geojson'; +import { Feature, FeatureCollection, Geometry, LineString, Position } from 'geojson'; import { extendLine, @@ -25,15 +25,18 @@ import { LayerType } from '../../../applications/editor/tools/types'; import DataLoader from './DataLoader'; import DataDisplay from './DataDisplay'; +type TransformedData = { + osm: Record; + osrd: Partial>; +}; + export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { const layers = useMemo( () => new Set(['buffer_stops', 'detectors', 'signals', 'switches', 'track_sections']), [] ); - const [transformedData, setTransformedData] = useState - > | null>(null); + const [transformedData, setTransformedData] = useState(null); const pathBBox = useMemo(() => bbox(path) as BBox2d, [path]); // Transformation function: @@ -43,7 +46,7 @@ export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { const simplifiedPath = simplify(path, { tolerance: 0.01 }); // Cut the simplified path as N equal length segments - const sample = getSamples(simplifiedPath, 30); + const sample = getSamples(simplifiedPath, 15); const samplePath = lineString(sample.points.map((point) => point.geometry.coordinates)); // Extend the sample, so that we can warp things right before and right @@ -52,10 +55,10 @@ export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { const steps = extendedSamples.geometry.coordinates.length - 1; // Generate our base grids: - const { regular, warped } = getGrids(extendedSamples, { stripsPerSide: 12 }); + const { regular, warped } = getGrids(extendedSamples, { stripsPerSide: 3 }); // Improve the warped grid, to get it less discontinuous: - const betterWarped = straightenGrid(warped, steps, { force: 0.8, iterations: 3 }); + const betterWarped = straightenGrid(warped, steps, { force: 0.8, iterations: 5 }); // Index the grids: const regularIndex = getGridIndex(regular); @@ -81,28 +84,36 @@ export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { { - setTransformedData( - mapValues(data, (geoJSONs: GeoJSON[]) => - geoJSONs.flatMap((geoJSON) => { - const transformed = transform(geoJSON); - return transformed ? [transformed] : []; - }) - ) - ); + getGeoJSONs={(osrdData, osmData) => { + const transformed = { + osm: omitBy( + mapValues(osmData, (collection) => transform(collection)), + isNil + ), + osrd: omitBy( + mapValues(osrdData, (collection: FeatureCollection) => transform(collection)), + isNil + ), + } as TransformedData; + setTransformedData(transformed); }} />
- +
); From 410b7670df55890272e60835758b410962d5fe2a Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Thu, 29 Jun 2023 17:12:26 +0200 Subject: [PATCH 04/15] simulation: improves WarpedMap performances, adds some logs --- front/src/common/Map/WarpedMap/DataDisplay.tsx | 14 ++++++++------ front/src/common/Map/WarpedMap/DataLoader.tsx | 14 ++++++++++++-- front/src/common/Map/WarpedMap/WarpedMap.tsx | 11 ++++++----- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/front/src/common/Map/WarpedMap/DataDisplay.tsx b/front/src/common/Map/WarpedMap/DataDisplay.tsx index a8bec6c2409..d51800eea5c 100644 --- a/front/src/common/Map/WarpedMap/DataDisplay.tsx +++ b/front/src/common/Map/WarpedMap/DataDisplay.tsx @@ -85,12 +85,14 @@ const DataDisplay: FC<{ const osmSources = useMemo( () => groupBy( - genLayerProps( - mapStyle, - LAYER_GROUPS_ORDER[LAYERS.BACKGROUND.GROUP] - ) as (OrderedLayerProps & { - 'source-layer': string; - })[], + ( + genLayerProps( + mapStyle, + LAYER_GROUPS_ORDER[LAYERS.BACKGROUND.GROUP] + ) as (OrderedLayerProps & { + 'source-layer': string; + })[] + ).filter((layer) => !layer.id?.match(/-en$/)), (layer) => layer['source-layer'] ), [mapStyle] diff --git a/front/src/common/Map/WarpedMap/DataLoader.tsx b/front/src/common/Map/WarpedMap/DataLoader.tsx index f7e52e9033a..c8aafbc18e2 100644 --- a/front/src/common/Map/WarpedMap/DataLoader.tsx +++ b/front/src/common/Map/WarpedMap/DataLoader.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import React, { FC, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { createPortal } from 'react-dom'; @@ -28,6 +29,8 @@ function simplifyFeature(feature: MapboxGeoJSONFeature): Feature { }; } +const TIME_LABEL = 'Loading OSRD and OSM data around warped path'; + const DataLoader: FC<{ bbox: BBox2d; getGeoJSONs: ( @@ -46,6 +49,7 @@ const DataLoader: FC<{ map.fitBounds(bbox, { animate: false }); setTimeout(() => { + console.time(TIME_LABEL); setState('render'); }, 0); }, [map, bbox]); @@ -56,6 +60,7 @@ const DataLoader: FC<{ const querySources = () => { // Retrieve OSRD data: + let osrdFeaturesCount = 0; const osrdData: Partial> = {}; layers.forEach((layer) => { osrdData[layer] = featureCollection( @@ -63,6 +68,7 @@ const DataLoader: FC<{ .querySourceFeatures(`editor/geo/${layer}`, { sourceLayer: layer }) .map(simplifyFeature) ); + osrdFeaturesCount += osrdData[layer]?.features.length || 0; }); // Retrieve OSM data: @@ -87,6 +93,10 @@ const DataLoader: FC<{ (features) => featureCollection(features) ); + console.timeEnd(TIME_LABEL); + console.log(' - OSRD features: ', osrdFeaturesCount); + console.log(' - OSM features: ', osmFeatures.length); + // Finalize: getGeoJSONs(osrdData, osmData); setState('loaded'); @@ -108,8 +118,8 @@ const DataLoader: FC<{ className="position-absolute" style={{ bottom: '110%', - height: 3000, - width: 3000, + height: 1200, + width: 1200, }} > >; }; +const TIME_LABEL = 'Warping OSRD and OSM data'; + export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { - const layers = useMemo( - () => - new Set(['buffer_stops', 'detectors', 'signals', 'switches', 'track_sections']), - [] - ); + const layers = useMemo(() => new Set(['track_sections']), []); const [transformedData, setTransformedData] = useState(null); const pathBBox = useMemo(() => bbox(path) as BBox2d, [path]); @@ -85,6 +84,7 @@ export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { bbox={pathBBox} layers={layers} getGeoJSONs={(osrdData, osmData) => { + console.time(TIME_LABEL); const transformed = { osm: omitBy( mapValues(osmData, (collection) => transform(collection)), @@ -95,6 +95,7 @@ export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { isNil ), } as TransformedData; + console.timeEnd(TIME_LABEL); setTransformedData(transformed); }} /> From 3de583dae4f1210749f49c39c3b75fed7083abec Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Thu, 29 Jun 2023 17:39:00 +0200 Subject: [PATCH 05/15] simulation: improves WarpedMap Details: - Deduplicates OSM queried features - Improves rendering and data querying --- front/src/common/Map/WarpedMap/DataLoader.tsx | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/front/src/common/Map/WarpedMap/DataLoader.tsx b/front/src/common/Map/WarpedMap/DataLoader.tsx index c8aafbc18e2..c754cbb6dfd 100644 --- a/front/src/common/Map/WarpedMap/DataLoader.tsx +++ b/front/src/common/Map/WarpedMap/DataLoader.tsx @@ -8,7 +8,7 @@ import { Feature, FeatureCollection } from 'geojson'; import ReactMapGL, { Source, MapRef, MapboxGeoJSONFeature } from 'react-map-gl'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { featureCollection } from '@turf/helpers'; -import { groupBy, mapValues } from 'lodash'; +import { map, sum, uniqBy } from 'lodash'; import { LayerType } from '../../../applications/editor/tools/types'; import osmBlankStyle from '../Layers/osmBlankStyle'; @@ -40,23 +40,23 @@ const DataLoader: FC<{ layers: Set; }> = ({ bbox, getGeoJSONs, layers }) => { const { mapStyle, layersSettings } = useSelector(getMap); - const [map, setMap] = useState(null); + const [mapRef, setMapRef] = useState(null); const [state, setState] = useState<'idle' | 'render' | 'loaded'>('idle'); const osmLayers = useMemo(() => genLayers(mapStyle), [mapStyle]); useEffect(() => { - if (!map) return; + if (!mapRef) return; - map.fitBounds(bbox, { animate: false }); + mapRef.fitBounds(bbox, { animate: false }); setTimeout(() => { console.time(TIME_LABEL); setState('render'); }, 0); - }, [map, bbox]); + }, [mapRef, bbox]); useEffect(() => { if (state === 'render') { - const m = map as MapRef; + const m = mapRef as MapRef; const querySources = () => { // Retrieve OSRD data: @@ -64,9 +64,12 @@ const DataLoader: FC<{ const osrdData: Partial> = {}; layers.forEach((layer) => { osrdData[layer] = featureCollection( - m - .querySourceFeatures(`editor/geo/${layer}`, { sourceLayer: layer }) - .map(simplifyFeature) + uniqBy( + m + .querySourceFeatures(`editor/geo/${layer}`, { sourceLayer: layer }) + .map(simplifyFeature), + (f) => f.id + ) ); osrdFeaturesCount += osrdData[layer]?.features.length || 0; }); @@ -75,27 +78,25 @@ const DataLoader: FC<{ // (we have to force cast, because in our weird setup, osmSource is // typed as if it was from mapbox when it actually comes from maplibre) const osmSource = m.getSource('osm') as unknown as { vectorLayerIds: string[] }; - const osmFeatures: Feature[] = osmSource.vectorLayerIds.flatMap((layer) => - m.querySourceFeatures('osm', { sourceLayer: layer }).map((feature) => { - const f = simplifyFeature(feature); - - return { - ...f, - properties: { - ...(f.properties || {}), - sourceLayer: layer, - }, - }; - }) - ); - const osmData: Record = mapValues( - groupBy(osmFeatures, (feature) => feature.properties?.sourceLayer), - (features) => featureCollection(features) + let incrementalID = 1; + const osmData: Record = osmSource.vectorLayerIds.reduce( + (iter, sourceLayer) => ({ + ...iter, + [sourceLayer]: featureCollection( + uniqBy( + m.querySourceFeatures('osm', { sourceLayer }).map(simplifyFeature), + // eslint-disable-next-line no-plusplus + (f) => (f.id ? `osm-${f.id}` : `generated-${++incrementalID}`) // only deduplicate features with IDs + ) + ), + }), + {} ); + const osmFeaturesCount = sum(map(osmData, (collection) => collection.features.length)); console.timeEnd(TIME_LABEL); console.log(' - OSRD features: ', osrdFeaturesCount); - console.log(' - OSM features: ', osmFeatures.length); + console.log(' - OSM features: ', osmFeaturesCount); // Finalize: getGeoJSONs(osrdData, osmData); @@ -123,7 +124,7 @@ const DataLoader: FC<{ }} > Date: Fri, 30 Jun 2023 11:08:41 +0200 Subject: [PATCH 06/15] simulation: displays path and extremities in WarpedMap --- .../src/common/Map/WarpedMap/DataDisplay.tsx | 44 +++++++++++++++++-- front/src/common/Map/WarpedMap/WarpedMap.tsx | 8 +++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/front/src/common/Map/WarpedMap/DataDisplay.tsx b/front/src/common/Map/WarpedMap/DataDisplay.tsx index d51800eea5c..49bee133c0d 100644 --- a/front/src/common/Map/WarpedMap/DataDisplay.tsx +++ b/front/src/common/Map/WarpedMap/DataDisplay.tsx @@ -1,12 +1,12 @@ import React, { FC, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { omit, map, groupBy } from 'lodash'; +import { omit, map, groupBy, first, last } from 'lodash'; import maplibregl from 'maplibre-gl'; -import ReactMapGL, { Layer, MapRef, Source } from 'react-map-gl'; +import ReactMapGL, { Layer, LineLayer, MapRef, Marker, Source } from 'react-map-gl'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { featureCollection } from '@turf/helpers'; -import { FeatureCollection } from 'geojson'; +import { Feature, FeatureCollection, MultiLineString } from 'geojson'; import colors from '../Consts/colors'; import { EditorSource, SourcesDefinitionsIndex } from '../Layers/GeoJSONs'; @@ -20,6 +20,8 @@ import { genLayerProps } from '../Layers/OSM'; import { LAYER_GROUPS_ORDER, LAYERS } from '../../../config/layerOrder'; import OrderedLayer, { OrderedLayerProps } from '../Layers/OrderedLayer'; import VirtualLayers from '../../../applications/operationalStudies/components/SimulationResults/SimulationResultsMap/VirtualLayers'; +import originSVG from '../../../assets/pictures/origin.svg'; +import destinationSVG from '../../../assets/pictures/destination.svg'; const OSRD_LAYER_ORDERS: Record = { buffer_stops: LAYER_GROUPS_ORDER[LAYERS.BUFFER_STOPS.GROUP], @@ -36,12 +38,22 @@ const OSRD_LAYER_ORDERS: Record = { errors: 0, }; +const PATH_STYLE: LineLayer = { + id: 'data', + type: 'line', + paint: { + 'line-width': 5, + 'line-color': 'rgba(210, 225, 0, 0.75)', + }, +}; + const DataDisplay: FC<{ bbox: BBox2d; osrdLayers: Set; osrdData?: Partial>; osmData?: Record; -}> = ({ bbox, osrdLayers, osrdData, osmData }) => { + path?: Feature; +}> = ({ bbox, osrdLayers, osrdData, osmData, path }) => { const prefix = 'warped/'; const [mapRef, setMapRef] = useState(null); const { mapStyle, layersSettings, showIGNBDORTHO } = useSelector((s: RootState) => s.map); @@ -98,6 +110,15 @@ const DataDisplay: FC<{ [mapStyle] ); + const origin = useMemo( + () => (path ? first(first(path.geometry.coordinates)) : undefined), + [path] + ); + const destination = useMemo( + () => (path ? last(last(path.geometry.coordinates)) : undefined), + [path] + ); + return osrdData && osmData ? ( ))} + {path && ( + + + + )} + {origin && ( + + Origin + + )} + {destination && ( + + Destination + + )} ) : ( diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx index 6a47beeae17..32d3f164d78 100644 --- a/front/src/common/Map/WarpedMap/WarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -5,7 +5,7 @@ import { isNil, mapValues, omitBy } from 'lodash'; import bbox from '@turf/bbox'; import simplify from '@turf/simplify'; -import { lineString } from '@turf/helpers'; +import { lineString, multiLineString } from '@turf/helpers'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { Feature, FeatureCollection, Geometry, LineString, Position } from 'geojson'; @@ -78,6 +78,11 @@ export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { }; }, [path]); + const transformedPath = useMemo( + () => transform(multiLineString([path.geometry.coordinates])), + [transform, path] + ); + return (
}> = ({ path }) => { bbox={regularBBox} osrdData={transformedData?.osrd} osmData={transformedData?.osm} + path={transformedPath || undefined} />
From b4dd3f4f30a86d9be3a0baddd2fb3dc4f1739a55 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Fri, 30 Jun 2023 11:09:00 +0200 Subject: [PATCH 07/15] simulation: gradually improves displayed OSRD data --- front/src/common/Map/WarpedMap/WarpedMap.tsx | 74 +++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx index 32d3f164d78..ce1b12eee08 100644 --- a/front/src/common/Map/WarpedMap/WarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import React, { FC, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { isNil, mapValues, omitBy } from 'lodash'; +import _, { isEmpty, isNil, mapValues, omitBy } from 'lodash'; import bbox from '@turf/bbox'; import simplify from '@turf/simplify'; @@ -22,18 +22,61 @@ import { clipAndProjectGeoJSON, projectBetweenGrids } from './core/projection'; import { RootState } from '../../../reducers'; import { LoaderFill } from '../../Loader'; import { osrdEditoastApi } from '../../api/osrdEditoastApi'; -import { LayerType } from '../../../applications/editor/tools/types'; +import { + EditoastType, + LAYER_TO_EDITOAST_DICT, + LayerType, +} from '../../../applications/editor/tools/types'; import DataLoader from './DataLoader'; import DataDisplay from './DataDisplay'; +import { getMixedEntities } from '../../../applications/editor/data/api'; +import { flattenEntity } from '../../../applications/editor/data/utils'; +import { getInfraID } from '../../../reducers/osrdconf/selectors'; + +const TIME_LABEL = 'Warping OSRD and OSM data'; +const OSRD_BATCH_SIZE = 500; type TransformedData = { osm: Record; osrd: Partial>; }; -const TIME_LABEL = 'Warping OSRD and OSM data'; +async function getImprovedOSRDData( + infra: number | string, + data: Partial> +): Promise> { + const queries = _(data) + .flatMap((collection: FeatureCollection, layerType: LayerType) => { + const editoastType = LAYER_TO_EDITOAST_DICT[layerType]; + return collection.features.flatMap((feature) => + feature.properties?.fromEditoast || typeof feature.properties?.id !== 'string' + ? [] + : [ + { + id: feature.properties.id, + type: editoastType, + }, + ] + ); + }) + .take(OSRD_BATCH_SIZE) + .value() as unknown as { id: string; type: EditoastType }[]; + + if (!queries.length) return {}; + + return mapValues(await getMixedEntities(infra, queries), (e) => + flattenEntity({ + ...e, + properties: { + ...e.properties, + fromEditoast: true, + }, + }) + ); +} export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { + const infraID = useSelector(getInfraID); const layers = useMemo(() => new Set(['track_sections']), []); const [transformedData, setTransformedData] = useState(null); const pathBBox = useMemo(() => bbox(path) as BBox2d, [path]); @@ -83,6 +126,31 @@ export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { [transform, path] ); + /** + * This effect tries to gradually improve the quality of the OSRD data. + * Initially, all OSRD entities are with "simplified" geometries, due to the + * fact that they are loaded directly using an unzoomed map. + */ + useEffect(() => { + if (!transformedData?.osrd) return; + + getImprovedOSRDData(infraID as number, transformedData.osrd).then((betterFeatures) => { + if (!isEmpty(betterFeatures)) { + const betterTransformedFeatures = mapValues(betterFeatures, transform); + const newTransformedOSRDData = mapValues( + transformedData.osrd, + (collection: FeatureCollection) => ({ + ...collection, + features: collection.features.map( + (feature) => betterTransformedFeatures[feature.properties?.id] || feature + ), + }) + ); + setTransformedData({ ...transformedData, osrd: newTransformedOSRDData }); + } + }); + }, [transformedData]); + return (
Date: Fri, 1 Sep 2023 16:24:19 +0200 Subject: [PATCH 08/15] simulation: refactors WarpedMap sources Details: - Simplifies code structure - Adapts new imports rules (ie "no relative import") - WarpedMap now renders already warped data, and no more calls any data or method from the store - SimulationWarpedMap loads everything it needs from the store (current path, selected train...), handles warping the data, and gives everything to a WarpedMap --- .../views/SimulationResults.tsx | 4 +- .../src/common/Map/WarpedMap/DataDisplay.tsx | 173 --------- front/src/common/Map/WarpedMap/DataLoader.tsx | 40 +-- .../Map/WarpedMap/SimulationWarpedMap.tsx | 157 ++++++++ front/src/common/Map/WarpedMap/WarpedMap.tsx | 335 +++++++----------- front/src/common/Map/WarpedMap/core/grids.tsx | 103 +++++- .../src/common/Map/WarpedMap/core/helpers.ts | 155 +++----- .../common/Map/WarpedMap/core/projection.ts | 16 +- .../src/common/Map/WarpedMap/core/quadtree.ts | 9 + front/src/common/Map/WarpedMap/getWarping.ts | 58 +++ 10 files changed, 535 insertions(+), 515 deletions(-) delete mode 100644 front/src/common/Map/WarpedMap/DataDisplay.tsx create mode 100644 front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx create mode 100644 front/src/common/Map/WarpedMap/getWarping.ts diff --git a/front/src/applications/operationalStudies/views/SimulationResults.tsx b/front/src/applications/operationalStudies/views/SimulationResults.tsx index 0fe9dbe5f02..8ff5202b242 100644 --- a/front/src/applications/operationalStudies/views/SimulationResults.tsx +++ b/front/src/applications/operationalStudies/views/SimulationResults.tsx @@ -29,7 +29,7 @@ import cx from 'classnames'; import { Infra, osrdEditoastApi } from 'common/api/osrdEditoastApi'; import { getSelectedTrain } from 'reducers/osrdsimulation/selectors'; import ScenarioLoader from 'modules/scenario/components/ScenarioLoader'; -import { WarpedMap } from 'common/Map/WarpedMap/WarpedMap'; +import SimulationWarpedMap from 'common/Map/WarpedMap/SimulationWarpedMap'; const MAP_MIN_HEIGHT = 450; @@ -156,7 +156,7 @@ export default function SimulationResults({ isDisplayed, collapsedTimetable, inf {/* SIMULATION : SPACE TIME CHART */}
- +
= { - buffer_stops: LAYER_GROUPS_ORDER[LAYERS.BUFFER_STOPS.GROUP], - detectors: LAYER_GROUPS_ORDER[LAYERS.DETECTORS.GROUP], - signals: LAYER_GROUPS_ORDER[LAYERS.SIGNALS.GROUP], - switches: LAYER_GROUPS_ORDER[LAYERS.SWITCHES.GROUP], - track_sections: LAYER_GROUPS_ORDER[LAYERS.TRACKS_SCHEMATIC.GROUP], - // Unused: - catenaries: 0, - lpv: 0, - lpv_panels: 0, - routes: 0, - speed_sections: 0, - errors: 0, -}; - -const PATH_STYLE: LineLayer = { - id: 'data', - type: 'line', - paint: { - 'line-width': 5, - 'line-color': 'rgba(210, 225, 0, 0.75)', - }, -}; - -const DataDisplay: FC<{ - bbox: BBox2d; - osrdLayers: Set; - osrdData?: Partial>; - osmData?: Record; - path?: Feature; -}> = ({ bbox, osrdLayers, osrdData, osmData, path }) => { - const prefix = 'warped/'; - const [mapRef, setMapRef] = useState(null); - const { mapStyle, layersSettings, showIGNBDORTHO } = useSelector((s: RootState) => s.map); - - useEffect(() => { - if (!mapRef) return; - - const avgLon = (bbox[0] + bbox[2]) / 2; - const thinBBox: BBox2d = [avgLon, bbox[1], avgLon, bbox[3]]; - setTimeout(() => { - mapRef.fitBounds(thinBBox, { animate: false }); - mapRef.resize(); - }, 0); - }, [mapRef, bbox]); - - const layerContext: LayerContext = useMemo( - () => ({ - colors: colors[mapStyle], - signalsList: ALL_SIGNAL_LAYERS, - symbolsList: ALL_SIGNAL_LAYERS, - sourceLayer: 'geo', - prefix: '', - isEmphasized: false, - showIGNBDORTHO, - layersSettings, - }), - [colors, mapStyle, showIGNBDORTHO, layersSettings] - ); - const osrdSources = useMemo( - () => - Array.from(osrdLayers).map((layer) => ({ - source: layer, - order: OSRD_LAYER_ORDERS[layer], - id: `${prefix}geo/${layer}`, - layers: SourcesDefinitionsIndex[layer](layerContext, prefix).map( - (props) => omit(props, 'source-layer') as typeof props - ), - })), - [osrdLayers] - ); - const osmSources = useMemo( - () => - groupBy( - ( - genLayerProps( - mapStyle, - LAYER_GROUPS_ORDER[LAYERS.BACKGROUND.GROUP] - ) as (OrderedLayerProps & { - 'source-layer': string; - })[] - ).filter((layer) => !layer.id?.match(/-en$/)), - (layer) => layer['source-layer'] - ), - [mapStyle] - ); - - const origin = useMemo( - () => (path ? first(first(path.geometry.coordinates)) : undefined), - [path] - ); - const destination = useMemo( - () => (path ? last(last(path.geometry.coordinates)) : undefined), - [path] - ); - - return osrdData && osmData ? ( - - - - {map(osmSources, (layers, sourceLayer) => ( - - {layers.map((layer) => ( - - ))} - - ))} - {osrdSources.map((s) => ( - - ))} - {path && ( - - - - )} - {origin && ( - - Origin - - )} - {destination && ( - - Destination - - )} - - ) : ( - - ); -}; - -export default DataDisplay; diff --git a/front/src/common/Map/WarpedMap/DataLoader.tsx b/front/src/common/Map/WarpedMap/DataLoader.tsx index c754cbb6dfd..84131e156ac 100644 --- a/front/src/common/Map/WarpedMap/DataLoader.tsx +++ b/front/src/common/Map/WarpedMap/DataLoader.tsx @@ -3,34 +3,31 @@ import React, { FC, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { createPortal } from 'react-dom'; -import maplibregl, { MapGeoJSONFeature } from 'maplibre-gl'; -import { Feature, FeatureCollection } from 'geojson'; -import ReactMapGL, { Source, MapRef, MapboxGeoJSONFeature } from 'react-map-gl'; +import { FeatureCollection } from 'geojson'; +import ReactMapGL, { MapRef, Source } from 'react-map-gl/maplibre'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { featureCollection } from '@turf/helpers'; import { map, sum, uniqBy } from 'lodash'; -import { LayerType } from '../../../applications/editor/tools/types'; -import osmBlankStyle from '../Layers/osmBlankStyle'; -import GeoJSONs from '../Layers/GeoJSONs'; -import colors from '../Consts/colors'; -import { getMap } from '../../../reducers/map/selectors'; -import { OSM_URL } from '../const'; -import { genLayers } from '../Layers/OSM'; - -function simplifyFeature(feature: MapboxGeoJSONFeature): Feature { - const f = feature as unknown as MapGeoJSONFeature; - return { - type: 'Feature', - id: f.id, - properties: { ...f.properties, sourceLayer: f.sourceLayer }, - // eslint-disable-next-line no-underscore-dangle - geometry: f.geometry || f._geometry, - }; -} +import { LayerType } from 'applications/editor/tools/types'; +import osmBlankStyle from 'common/Map/Layers/osmBlankStyle'; +import GeoJSONs from 'common/Map/Layers/GeoJSONs'; +import colors from 'common/Map/Consts/colors'; +import { getMap } from 'reducers/map/selectors'; +import { OSM_URL } from 'common/Map/const'; +import { genLayers } from 'common/Map/Layers/OSM'; +import { simplifyFeature } from 'common/Map/WarpedMap/core/helpers'; const TIME_LABEL = 'Loading OSRD and OSM data around warped path'; +/** + * This component handles loading entities from MapLibre vector servers, and retrieving them as GeoJSONs from the + * MapLibre `querySourceFeatures` method. + * It's quite dirty (it has to mount a map in the DOM, but somewhere it won't be visible), but necessary until we get + * proper APIs for both OSRD data and OSM data. + * + * It is designed as a component instead of a hook to simplify mounting/unmounting the temporary invisible map. + */ const DataLoader: FC<{ bbox: BBox2d; getGeoJSONs: ( @@ -125,7 +122,6 @@ const DataLoader: FC<{ > diff --git a/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx new file mode 100644 index 00000000000..f560157b575 --- /dev/null +++ b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx @@ -0,0 +1,157 @@ +/* eslint-disable no-console */ +import { useSelector } from 'react-redux'; +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { isEmpty, isNil, mapValues, omitBy } from 'lodash'; + +import bbox from '@turf/bbox'; +import { lineString } from '@turf/helpers'; +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; +import { Feature, FeatureCollection, LineString, Position } from 'geojson'; + +import { RootState } from 'reducers'; +import { LoaderFill } from 'common/Loader'; +import { osrdEditoastApi } from 'common/api/osrdEditoastApi'; +import { LayerType } from 'applications/editor/tools/types'; +import DataLoader from 'common/Map/WarpedMap/DataLoader'; +import getWarping, { WarpingFunction } from 'common/Map/WarpedMap/getWarping'; +import WarpedMap from 'common/Map/WarpedMap/WarpedMap'; +import { TrainPosition } from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/types'; +import { getInfraID } from 'reducers/osrdconf/selectors'; +import { getImprovedOSRDData } from 'common/Map/WarpedMap/core/helpers'; + +const TIME_LABEL = 'Warping OSRD and OSM data'; + +interface PathStatePayload { + path: Feature; + pathBBox: BBox2d; + regularBBox: BBox2d; + transform: WarpingFunction; +} + +interface DataStatePayload { + osm: Record; + osrd: Partial>; + itinerary?: Feature; + trains?: (TrainPosition & { isSelected?: true })[]; +} + +/** + * This component handles loading the simulation path, all the surrounding data (OSM and OSRD), transforms them, and + * then mounts a WarpedMap with all that data: + */ +const SimulationWarpedMap: FC = () => { + const infraID = useSelector(getInfraID); + const [state, setState] = useState< + | { type: 'idle' } + | { type: 'loading' } + | { type: 'error'; message?: string } + | ({ + type: 'pathLoaded'; + } & PathStatePayload) + | ({ + type: 'dataLoaded'; + } & PathStatePayload & + DataStatePayload) + >({ type: 'idle' }); + const pathfindingID = useSelector( + (s: RootState) => s.osrdsimulation.selectedProjection?.path + ) as number; + const [getPath] = osrdEditoastApi.useLazyGetPathfindingByIdQuery(); + const layers = useMemo(() => new Set(['track_sections']), []); + + /** + * This effect handles loading the simulation path, and retrieve the warping function: + */ + useEffect(() => { + setState({ type: 'loading' }); + getPath({ id: pathfindingID }) + .then(({ data, isError, error }) => { + if (isError) { + setState({ type: 'error', message: error as string }); + } else if (!data?.geographic?.coordinates) { + setState({ type: 'error', message: 'No coordinates' }); + } else { + const path = lineString(data?.geographic?.coordinates as Position[]); + const pathBBox = bbox(path) as BBox2d; + const { regularBBox, transform } = getWarping(path); + + setState({ type: 'pathLoaded', path, pathBBox, regularBBox, transform }); + } + }) + .catch((error) => setState({ type: 'error', message: error })); + }, [pathfindingID]); + + /** + * This effect tries to gradually improve the quality of the OSRD data. + * Initially, all OSRD entities are with "simplified" geometries, due to the + * fact that they are loaded directly using an unzoomed map. + */ + useEffect(() => { + if (state.type !== 'dataLoaded') return; + + getImprovedOSRDData(infraID as number, state.osrd).then((betterFeatures) => { + if (!isEmpty(betterFeatures)) { + const betterTransformedFeatures = mapValues(betterFeatures, state.transform); + const newTransformedOSRDData = mapValues(state.osrd, (collection: FeatureCollection) => ({ + ...collection, + features: collection.features.map( + (feature) => betterTransformedFeatures[feature.properties?.id] || feature + ), + })); + setState({ ...state, osrd: newTransformedOSRDData }); + } + }); + }, [state]); + + if (state.type === 'idle' || state.type === 'loading' || state.type === 'error') + return ; + + if (state.type === 'pathLoaded') + return ( + { + console.time(TIME_LABEL); + const transformed = { + osm: omitBy( + mapValues(osmData, (collection) => state.transform(collection)), + isNil + ) as DataStatePayload['osm'], + osrd: omitBy( + mapValues(osrdData, (collection: FeatureCollection) => state.transform(collection)), + isNil + ) as DataStatePayload['osrd'], + }; + console.timeEnd(TIME_LABEL); + setState({ ...state, ...transformed, type: 'dataLoaded' }); + }} + /> + ); + + return ( +
+
+ +
+
+ ); +}; + +export default SimulationWarpedMap; diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx index ce1b12eee08..c064f58708a 100644 --- a/front/src/common/Map/WarpedMap/WarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -1,229 +1,138 @@ /* eslint-disable no-console */ -import React, { FC, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import _, { isEmpty, isNil, mapValues, omitBy } from 'lodash'; - -import bbox from '@turf/bbox'; -import simplify from '@turf/simplify'; -import { lineString, multiLineString } from '@turf/helpers'; +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { groupBy, map, omit } from 'lodash'; +import { featureCollection } from '@turf/helpers'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; -import { Feature, FeatureCollection, Geometry, LineString, Position } from 'geojson'; - -import { - extendLine, - featureToPointsGrid, - getGridIndex, - getSamples, - pointsGridToZone, -} from './core/helpers'; -import { getQuadTree } from './core/quadtree'; -import { getGrids, straightenGrid } from './core/grids'; -import { clipAndProjectGeoJSON, projectBetweenGrids } from './core/projection'; -import { RootState } from '../../../reducers'; -import { LoaderFill } from '../../Loader'; -import { osrdEditoastApi } from '../../api/osrdEditoastApi'; -import { - EditoastType, - LAYER_TO_EDITOAST_DICT, - LayerType, -} from '../../../applications/editor/tools/types'; -import DataLoader from './DataLoader'; -import DataDisplay from './DataDisplay'; -import { getMixedEntities } from '../../../applications/editor/data/api'; -import { flattenEntity } from '../../../applications/editor/data/utils'; -import { getInfraID } from '../../../reducers/osrdconf/selectors'; - -const TIME_LABEL = 'Warping OSRD and OSM data'; -const OSRD_BATCH_SIZE = 500; - -type TransformedData = { - osm: Record; - osrd: Partial>; +import { Feature, FeatureCollection, LineString } from 'geojson'; +import ReactMapGL, { Layer, MapRef, Source } from 'react-map-gl/maplibre'; + +import { RootState } from 'reducers'; +import { LAYER_GROUPS_ORDER, LAYERS } from 'config/layerOrder'; +import colors from 'common/Map/Consts/colors'; +import { ALL_SIGNAL_LAYERS } from 'common/Map/Consts/SignalsNames'; +import { LayerType } from 'applications/editor/tools/types'; +import { TrainPosition } from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/types'; +import VirtualLayers from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/VirtualLayers'; +import { LayerContext } from 'common/Map/Layers/types'; +import { EditorSource, SourcesDefinitionsIndex } from 'common/Map/Layers/GeoJSONs'; +import OrderedLayer, { OrderedLayerProps } from 'common/Map/Layers/OrderedLayer'; +import { genLayerProps } from 'common/Map/Layers/OSM'; +import osmBlankStyle from 'common/Map/Layers/osmBlankStyle'; + +const OSRD_LAYER_ORDERS: Record = { + buffer_stops: LAYER_GROUPS_ORDER[LAYERS.BUFFER_STOPS.GROUP], + detectors: LAYER_GROUPS_ORDER[LAYERS.DETECTORS.GROUP], + signals: LAYER_GROUPS_ORDER[LAYERS.SIGNALS.GROUP], + switches: LAYER_GROUPS_ORDER[LAYERS.SWITCHES.GROUP], + track_sections: LAYER_GROUPS_ORDER[LAYERS.TRACKS_SCHEMATIC.GROUP], + // Unused: + catenaries: 0, + lpv: 0, + lpv_panels: 0, + routes: 0, + speed_sections: 0, + errors: 0, }; -async function getImprovedOSRDData( - infra: number | string, - data: Partial> -): Promise> { - const queries = _(data) - .flatMap((collection: FeatureCollection, layerType: LayerType) => { - const editoastType = LAYER_TO_EDITOAST_DICT[layerType]; - return collection.features.flatMap((feature) => - feature.properties?.fromEditoast || typeof feature.properties?.id !== 'string' - ? [] - : [ - { - id: feature.properties.id, - type: editoastType, - }, - ] - ); - }) - .take(OSRD_BATCH_SIZE) - .value() as unknown as { id: string; type: EditoastType }[]; - - if (!queries.length) return {}; - - return mapValues(await getMixedEntities(infra, queries), (e) => - flattenEntity({ - ...e, - properties: { - ...e.properties, - fromEditoast: true, - }, - }) +/** + * This component handles displaying warped data. The data must be warped before being given to this component. + * Check `SimulationWarpedMap` to see an example use case. + */ +const WarpedMap: FC<{ + bbox: BBox2d; + osrdLayers: Set; + // Data to display on the map (must be transformed already): + osrdData: Partial>; + osmData: Record; + trains?: (TrainPosition & { isSelected?: true })[]; + itinerary?: Feature; +}> = ({ bbox, osrdLayers, osrdData, osmData }) => { + const prefix = 'warped/'; + const [mapRef, setMapRef] = useState(null); + const { mapStyle, layersSettings, showIGNBDORTHO } = useSelector((s: RootState) => s.map); + + // Main OSM and OSRD data: + const layerContext: LayerContext = useMemo( + () => ({ + colors: colors[mapStyle], + signalsList: ALL_SIGNAL_LAYERS, + symbolsList: ALL_SIGNAL_LAYERS, + sourceLayer: 'geo', + prefix: '', + isEmphasized: false, + showIGNBDORTHO, + layersSettings, + }), + [colors, mapStyle, showIGNBDORTHO, layersSettings] ); -} - -export const PathWarpedMap: FC<{ path: Feature }> = ({ path }) => { - const infraID = useSelector(getInfraID); - const layers = useMemo(() => new Set(['track_sections']), []); - const [transformedData, setTransformedData] = useState(null); - const pathBBox = useMemo(() => bbox(path) as BBox2d, [path]); - - // Transformation function: - const { regularBBox, transform } = useMemo(() => { - // Simplify the input path to get something "straighter", so that we can see - // in the final warped map the small curves of the initial path: - const simplifiedPath = simplify(path, { tolerance: 0.01 }); - - // Cut the simplified path as N equal length segments - const sample = getSamples(simplifiedPath, 15); - const samplePath = lineString(sample.points.map((point) => point.geometry.coordinates)); - - // Extend the sample, so that we can warp things right before and right - // after the initial path: - const extendedSamples = extendLine(samplePath, sample.step); - const steps = extendedSamples.geometry.coordinates.length - 1; - - // Generate our base grids: - const { regular, warped } = getGrids(extendedSamples, { stripsPerSide: 3 }); - - // Improve the warped grid, to get it less discontinuous: - const betterWarped = straightenGrid(warped, steps, { force: 0.8, iterations: 5 }); - - // Index the grids: - const regularIndex = getGridIndex(regular); - const warpedQuadTree = getQuadTree(betterWarped, 4); - - // Return projection function and exact warped grid boundaries: - const zone = pointsGridToZone(featureToPointsGrid(betterWarped, steps)); - const projection = (position: Position) => - projectBetweenGrids(warpedQuadTree, regularIndex, position); - - // Finally we have a proper transformation function that takes any feature - // as input, clips it to the grid contour polygon, and projects it the - // regular grid: - return { - regularBBox: bbox(regular) as BBox2d, - transform: (f: T): T | null => - clipAndProjectGeoJSON(f, projection, zone), - }; - }, [path]); - - const transformedPath = useMemo( - () => transform(multiLineString([path.geometry.coordinates])), - [transform, path] + const osrdSources = useMemo( + () => + Array.from(osrdLayers).map((layer) => ({ + source: layer, + order: OSRD_LAYER_ORDERS[layer], + id: `${prefix}geo/${layer}`, + layers: SourcesDefinitionsIndex[layer](layerContext, prefix).map( + (props) => omit(props, 'source-layer') as typeof props + ), + })), + [osrdLayers] + ); + const osmSources = useMemo( + () => + groupBy( + ( + genLayerProps( + mapStyle, + LAYER_GROUPS_ORDER[LAYERS.BACKGROUND.GROUP] + ) as (OrderedLayerProps & { + 'source-layer': string; + })[] + ).filter((layer) => !layer.id?.match(/-en$/)), + (layer) => layer['source-layer'] + ), + [mapStyle] ); - /** - * This effect tries to gradually improve the quality of the OSRD data. - * Initially, all OSRD entities are with "simplified" geometries, due to the - * fact that they are loaded directly using an unzoomed map. - */ + // This effect handles the map initial position: useEffect(() => { - if (!transformedData?.osrd) return; + if (!mapRef) return; - getImprovedOSRDData(infraID as number, transformedData.osrd).then((betterFeatures) => { - if (!isEmpty(betterFeatures)) { - const betterTransformedFeatures = mapValues(betterFeatures, transform); - const newTransformedOSRDData = mapValues( - transformedData.osrd, - (collection: FeatureCollection) => ({ - ...collection, - features: collection.features.map( - (feature) => betterTransformedFeatures[feature.properties?.id] || feature - ), - }) - ); - setTransformedData({ ...transformedData, osrd: newTransformedOSRDData }); - } - }); - }, [transformedData]); + const avgLon = (bbox[0] + bbox[2]) / 2; + const thinBBox: BBox2d = [avgLon, bbox[1], avgLon, bbox[3]]; + setTimeout(() => { + mapRef.fitBounds(thinBBox, { animate: false }); + mapRef.resize(); + }, 0); + }, [mapRef, bbox]); return ( -
- { - console.time(TIME_LABEL); - const transformed = { - osm: omitBy( - mapValues(osmData, (collection) => transform(collection)), - isNil - ), - osrd: omitBy( - mapValues(osrdData, (collection: FeatureCollection) => transform(collection)), - isNil - ), - } as TransformedData; - console.timeEnd(TIME_LABEL); - setTransformedData(transformed); - }} - /> -
- + + + {map(osmSources, (layers, sourceLayer) => ( + + {layers.map((layer) => ( + + ))} + + ))} + {osrdSources.map((s) => ( + -
-
+ ))} +
); }; -export const WarpedMap: FC = () => { - const [state, setState] = useState< - | { type: 'idle' } - | { type: 'loading' } - | { type: 'ready'; path: Feature } - | { type: 'error'; message?: string } - >({ type: 'idle' }); - const pathfindingID = useSelector( - (s: RootState) => s.osrdsimulation.selectedProjection?.path - ) as number; - const [getPath] = osrdEditoastApi.useLazyGetPathfindingByIdQuery(); - - useEffect(() => { - setState({ type: 'loading' }); - getPath({ id: pathfindingID }) - .then(({ data, isError, error }) => { - if (isError) { - setState({ type: 'error', message: error as string }); - } else { - const coordinates = data?.geographic?.coordinates as Position[] | null; - - setState( - coordinates - ? { type: 'ready', path: lineString(coordinates) } - : { type: 'error', message: 'No coordinates' } - ); - } - }) - .catch((error) => setState({ type: 'error', message: error })); - }, [pathfindingID]); - - return state.type === 'ready' ? : ; -}; +export default WarpedMap; diff --git a/front/src/common/Map/WarpedMap/core/grids.tsx b/front/src/common/Map/WarpedMap/core/grids.tsx index 665e4be72e7..95951059369 100644 --- a/front/src/common/Map/WarpedMap/core/grids.tsx +++ b/front/src/common/Map/WarpedMap/core/grids.tsx @@ -1,6 +1,6 @@ /* eslint-disable prefer-destructuring, no-plusplus */ import { Feature, LineString, Position } from 'geojson'; -import { clamp, cloneDeep, meanBy } from 'lodash'; +import { clamp, cloneDeep, keyBy, meanBy } from 'lodash'; import length from '@turf/length'; import center from '@turf/center'; import { featureCollection, lineString, polygon } from '@turf/helpers'; @@ -8,13 +8,106 @@ import destination from '@turf/destination'; import bearing from '@turf/bearing'; import { - featureToPointsGrid, GridFeature, + GridIndex, Grids, PointsGrid, - pointsGridToFeature, -} from './helpers'; -import vec, { Vec2 } from './vec-lib'; + Triangle, +} from 'common/Map/WarpedMap/core/helpers'; +import vec, { Vec2 } from 'common/Map/WarpedMap/core/vec-lib'; +import { PolygonZone } from 'types'; + +/** + * Base helpers to manipulate grids: + */ +export function getGridIndex(grid: GridFeature): GridIndex { + return keyBy(grid.features, (feature) => feature.properties.triangleId); +} + +export function featureToPointsGrid(grid: GridFeature, steps: number): PointsGrid { + const points: PointsGrid = []; + const gridIndex = getGridIndex(grid); + const stripsPerSide = grid.features.length / steps / 2 / 2; + + for (let i = 0; i < steps; i++) { + points[i] = points[i] || {}; + points[i + 1] = points[i + 1] || {}; + for (let direction = -1; direction <= 1; direction += 2) { + for (let j = 0; j < stripsPerSide; j++) { + const inside = gridIndex[`step:${i}/strip:${direction * (j + 1)}/inside`]; + const outside = gridIndex[`step:${i}/strip:${direction * (j + 1)}/outside`]; + const [[p00, p10, p01]] = inside.geometry.coordinates; + const [[p11]] = outside.geometry.coordinates; + + points[i][direction * j] = p00; + points[i][direction * (j + 1)] = p01; + points[i + 1][direction * j] = p10; + points[i + 1][direction * (j + 1)] = p11; + } + } + } + + return points; +} +export function pointsGridToFeature(points: PointsGrid): GridFeature { + const grid = featureCollection([]) as GridFeature; + const steps = points.length - 1; + const stripsPerSide = (Object.keys(points[0]).length - 1) / 2; + + for (let i = 0; i < steps; i++) { + for (let direction = -1; direction <= 1; direction += 2) { + for (let j = 0; j < stripsPerSide; j++) { + const p00 = points[i][direction * j]; + const p01 = points[i][direction * (j + 1)]; + const p10 = points[i + 1][direction * j]; + const p11 = points[i + 1][direction * (j + 1)]; + grid.features.push( + polygon([[p00, p10, p01, p00]], { + triangleId: `step:${i}/strip:${direction * (j + 1)}/inside`, + }) as Triangle + ); + grid.features.push( + polygon([[p11, p10, p01, p11]], { + triangleId: `step:${i}/strip:${direction * (j + 1)}/outside`, + }) as Triangle + ); + } + } + } + + return grid; +} + +export function pointsGridToZone(points: PointsGrid): PolygonZone { + const firstRow = points[0]; + const lastRow = points[points.length - 1]; + const stripsPerSide = (Object.keys(firstRow).length - 1) / 2; + const border: Position[] = []; + + // Add first row: + for (let i = -stripsPerSide; i <= stripsPerSide; i++) { + border.push(points[0][i]); + } + + // Add all intermediary rows: + for (let i = 1, l = points.length - 1; i < l; i++) { + border.push(points[i][stripsPerSide]); + border.unshift(points[i][-stripsPerSide]); + } + + // Add last row: + for (let i = stripsPerSide; i >= -stripsPerSide; i--) { + border.push(lastRow[i]); + } + + // Close path: + border.push(border[0]); + + return { + type: 'polygon', + points: border, + }; +} /** * This function takes a path, and returns two isomorphic grids: diff --git a/front/src/common/Map/WarpedMap/core/helpers.ts b/front/src/common/Map/WarpedMap/core/helpers.ts index d360508699d..bc6b6f4698a 100644 --- a/front/src/common/Map/WarpedMap/core/helpers.ts +++ b/front/src/common/Map/WarpedMap/core/helpers.ts @@ -1,13 +1,16 @@ /* eslint-disable prefer-destructuring, no-plusplus */ import { Feature, FeatureCollection, LineString, Point, Polygon, Position } from 'geojson'; -import { clamp, first, keyBy, last } from 'lodash'; +import _, { clamp, first, last, mapValues } from 'lodash'; import length from '@turf/length'; -import { featureCollection, point, polygon } from '@turf/helpers'; +import { point } from '@turf/helpers'; import along from '@turf/along'; import distance from '@turf/distance'; +import { MapGeoJSONFeature } from 'maplibre-gl'; -import vec, { Vec2 } from './vec-lib'; -import { PolygonZone } from '../../../../types'; +import { EditoastType, LAYER_TO_EDITOAST_DICT, LayerType } from 'applications/editor/tools/types'; +import { getMixedEntities } from 'applications/editor/data/api'; +import { flattenEntity } from 'applications/editor/data/utils'; +import vec, { Vec2 } from 'common/Map/WarpedMap/core/vec-lib'; /** * Useful types: @@ -83,98 +86,6 @@ export function extendLine(line: Feature, lengthToAdd: number): Feat }; } -/** - * Grid helpers: - */ -export function getGridIndex(grid: GridFeature): GridIndex { - return keyBy(grid.features, (feature) => feature.properties.triangleId); -} - -export function featureToPointsGrid(grid: GridFeature, steps: number): PointsGrid { - const points: PointsGrid = []; - const gridIndex = getGridIndex(grid); - const stripsPerSide = grid.features.length / steps / 2 / 2; - - for (let i = 0; i < steps; i++) { - points[i] = points[i] || {}; - points[i + 1] = points[i + 1] || {}; - for (let direction = -1; direction <= 1; direction += 2) { - for (let j = 0; j < stripsPerSide; j++) { - const inside = gridIndex[`step:${i}/strip:${direction * (j + 1)}/inside`]; - const outside = gridIndex[`step:${i}/strip:${direction * (j + 1)}/outside`]; - const [[p00, p10, p01]] = inside.geometry.coordinates; - const [[p11]] = outside.geometry.coordinates; - - points[i][direction * j] = p00; - points[i][direction * (j + 1)] = p01; - points[i + 1][direction * j] = p10; - points[i + 1][direction * (j + 1)] = p11; - } - } - } - - return points; -} -export function pointsGridToFeature(points: PointsGrid): GridFeature { - const grid = featureCollection([]) as GridFeature; - const steps = points.length - 1; - const stripsPerSide = (Object.keys(points[0]).length - 1) / 2; - - for (let i = 0; i < steps; i++) { - for (let direction = -1; direction <= 1; direction += 2) { - for (let j = 0; j < stripsPerSide; j++) { - const p00 = points[i][direction * j]; - const p01 = points[i][direction * (j + 1)]; - const p10 = points[i + 1][direction * j]; - const p11 = points[i + 1][direction * (j + 1)]; - grid.features.push( - polygon([[p00, p10, p01, p00]], { - triangleId: `step:${i}/strip:${direction * (j + 1)}/inside`, - }) as Triangle - ); - grid.features.push( - polygon([[p11, p10, p01, p11]], { - triangleId: `step:${i}/strip:${direction * (j + 1)}/outside`, - }) as Triangle - ); - } - } - } - - return grid; -} - -export function pointsGridToZone(points: PointsGrid): PolygonZone { - const firstRow = points[0]; - const lastRow = points[points.length - 1]; - const stripsPerSide = (Object.keys(firstRow).length - 1) / 2; - const border: Position[] = []; - - // Add first row: - for (let i = -stripsPerSide; i <= stripsPerSide; i++) { - border.push(points[0][i]); - } - - // Add all intermediary rows: - for (let i = 1, l = points.length - 1; i < l; i++) { - border.push(points[i][stripsPerSide]); - border.unshift(points[i][-stripsPerSide]); - } - - // Add last row: - for (let i = stripsPerSide; i >= -stripsPerSide; i--) { - border.push(lastRow[i]); - } - - // Close path: - border.push(border[0]); - - return { - type: 'polygon', - points: border, - }; -} - /** * Triangle helpers: */ @@ -208,3 +119,55 @@ export function getPointInTriangle( ): Position { return [a * x0 + b * x1 + c * x2, a * y0 + b * y1 + c * y2]; } + +/** + * Data helpers: + */ +const OSRD_BATCH_SIZE = 500; +export async function getImprovedOSRDData( + infra: number | string, + data: Partial> +): Promise> { + const queries = _(data) + .flatMap((collection: FeatureCollection, layerType: LayerType) => { + const editoastType = LAYER_TO_EDITOAST_DICT[layerType]; + return collection.features.flatMap((feature) => + feature.properties?.fromEditoast || typeof feature.properties?.id !== 'string' + ? [] + : [ + { + id: feature.properties.id, + type: editoastType, + }, + ] + ); + }) + .take(OSRD_BATCH_SIZE) + .value() as unknown as { id: string; type: EditoastType }[]; + + if (!queries.length) return {}; + + return mapValues(await getMixedEntities(infra, queries), (e) => + flattenEntity({ + ...e, + properties: { + ...e.properties, + fromEditoast: true, + }, + }) + ); +} + +/** + * This helper takes a MapboxGeoJSONFeature (ie a data item extracted from a MapLibre instance through the + * `querySourceFeatures` method), and returns a proper and clean GeoJSON Feature object. + */ +export function simplifyFeature(feature: MapGeoJSONFeature): Feature { + return { + type: 'Feature', + id: feature.id, + properties: { ...feature.properties, sourceLayer: feature.sourceLayer }, + // eslint-disable-next-line no-underscore-dangle + geometry: feature.geometry || feature._geometry, + }; +} diff --git a/front/src/common/Map/WarpedMap/core/projection.ts b/front/src/common/Map/WarpedMap/core/projection.ts index c0b04eb67dc..4dd13a37abe 100644 --- a/front/src/common/Map/WarpedMap/core/projection.ts +++ b/front/src/common/Map/WarpedMap/core/projection.ts @@ -2,16 +2,16 @@ import { Feature, FeatureCollection, Geometry, Position } from 'geojson'; import { keyBy } from 'lodash'; +import { Zone } from 'types'; +import { clip } from 'utils/mapHelper'; import { getBarycentricCoordinates, getPointInTriangle, GridIndex, isInTriangle, Triangle, -} from './helpers'; -import { getElements, Quad } from './quadtree'; -import { Zone } from '../../../../types'; -import { clip } from '../../../../utils/mapboxHelper'; +} from 'common/Map/WarpedMap/core/helpers'; +import { getElements, Quad } from 'common/Map/WarpedMap/core/quadtree'; export type Projection = (position: Position) => Position | null; @@ -59,6 +59,10 @@ export function projectBetweenGrids( return null; } +/** + * This function projects any geometry, following a given projection function (ie. any function that transforms + * coordinates into new coordinates). + */ export function projectGeometry( geometry: G, project: Projection @@ -139,6 +143,10 @@ export function projectGeometry( } } +/** + * This function takes a geometry, a feature or a features collection, clips them into a given zone, and projects them + * onto a given projection function. If everything is clipped out, then `null` is returned instead. + */ export function clipAndProjectGeoJSON( geojson: T, projection: Projection, diff --git a/front/src/common/Map/WarpedMap/core/quadtree.ts b/front/src/common/Map/WarpedMap/core/quadtree.ts index f6dc88fa329..1cb618eacb2 100644 --- a/front/src/common/Map/WarpedMap/core/quadtree.ts +++ b/front/src/common/Map/WarpedMap/core/quadtree.ts @@ -3,6 +3,7 @@ import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from 'geojson'; import bbox from '@turf/bbox'; +// The following types help describing a full QuadTree: export type Leaf = { type: 'leaf'; elements: T[]; @@ -31,6 +32,10 @@ export function getNewQuadChild(box: BBox2d, isLeaf?: boolean): QuadChild }; } +/** + * This function takes a collection of GeoJSON features and a depth, and returns a QuadTree of the given depth, with all + * features properly indexed. + */ export function getQuadTree( collection: FeatureCollection, depth: number @@ -84,6 +89,10 @@ export function getQuadTree(point: Position, quadTree: Quad): T[] { const [x, y] = point; const [minX, minY, maxX, maxY] = quadTree.bbox; diff --git a/front/src/common/Map/WarpedMap/getWarping.ts b/front/src/common/Map/WarpedMap/getWarping.ts new file mode 100644 index 00000000000..2c44957eb90 --- /dev/null +++ b/front/src/common/Map/WarpedMap/getWarping.ts @@ -0,0 +1,58 @@ +import bbox from '@turf/bbox'; +import simplify from '@turf/simplify'; +import { lineString } from '@turf/helpers'; +import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; +import { Feature, FeatureCollection, Geometry, LineString, Position } from 'geojson'; + +import { extendLine, getSamples } from 'common/Map/WarpedMap/core/helpers'; +import { getQuadTree } from 'common/Map/WarpedMap/core/quadtree'; +import { + featureToPointsGrid, + getGridIndex, + getGrids, + pointsGridToZone, + straightenGrid, +} from 'common/Map/WarpedMap/core/grids'; +import { clipAndProjectGeoJSON, projectBetweenGrids } from 'common/Map/WarpedMap/core/projection'; + +export type WarpingFunction = ReturnType['transform']; + +export default function getWarping(warpPath: Feature) { + // Simplify the input path to get something "straighter", so that we can see + // in the final warped map the small curves of the initial path: + // TODO: Detect loops and remove them from the simplifiedPath + const simplifiedPath = simplify(warpPath, { tolerance: 0.01 }); + + // Cut the simplified path as N equal length segments + const sample = getSamples(simplifiedPath, 15); + const samplePath = lineString(sample.points.map((point) => point.geometry.coordinates)); + + // Extend the sample, so that we can warp things right before and right + // after the initial path: + const extendedSamples = extendLine(samplePath, sample.step); + const steps = extendedSamples.geometry.coordinates.length - 1; + + // Generate our base grids, so that we can start shaping our transformation function: + const { regular, warped } = getGrids(extendedSamples, { stripsPerSide: 3 }); + + // Improve the warped grid, to get it less discontinuous: + const betterWarped = straightenGrid(warped, steps, { force: 0.8, iterations: 5 }); + + // Index the grids: + const regularIndex = getGridIndex(regular); + const warpedQuadTree = getQuadTree(betterWarped, 4); + + // Return projection function and exact warped grid boundaries: + const zone = pointsGridToZone(featureToPointsGrid(betterWarped, steps)); + const projection = (position: Position) => + projectBetweenGrids(warpedQuadTree, regularIndex, position); + + // Finally we have a proper transformation function that takes any feature + // as input, clips it to the grid contour polygon, and projects it the + // regular grid: + return { + regularBBox: bbox(regular) as BBox2d, + transform: (f: T): T | null => + clipAndProjectGeoJSON(f, projection, zone), + }; +} From 70e373dfbad5ff5bc8a30eeb901454fc9f05125d Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Fri, 1 Sep 2023 17:52:00 +0200 Subject: [PATCH 09/15] simulation: highlights itinerary in WarpedMap --- front/src/common/Map/WarpedMap/DataLoader.tsx | 43 ++++++++++++----- .../Map/WarpedMap/SimulationWarpedMap.tsx | 47 +++++++++++++++++-- front/src/common/Map/WarpedMap/WarpedMap.tsx | 20 +++++++- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/front/src/common/Map/WarpedMap/DataLoader.tsx b/front/src/common/Map/WarpedMap/DataLoader.tsx index 84131e156ac..13a5655671f 100644 --- a/front/src/common/Map/WarpedMap/DataLoader.tsx +++ b/front/src/common/Map/WarpedMap/DataLoader.tsx @@ -4,22 +4,25 @@ import { useSelector } from 'react-redux'; import { createPortal } from 'react-dom'; import { FeatureCollection } from 'geojson'; -import ReactMapGL, { MapRef, Source } from 'react-map-gl/maplibre'; +import ReactMapGL, { LayerProps, MapRef, Source } from 'react-map-gl/maplibre'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { featureCollection } from '@turf/helpers'; import { map, sum, uniqBy } from 'lodash'; +import mapStyleJson from 'assets/mapstyles/OSMStyle.json'; import { LayerType } from 'applications/editor/tools/types'; import osmBlankStyle from 'common/Map/Layers/osmBlankStyle'; import GeoJSONs from 'common/Map/Layers/GeoJSONs'; import colors from 'common/Map/Consts/colors'; import { getMap } from 'reducers/map/selectors'; import { OSM_URL } from 'common/Map/const'; -import { genLayers } from 'common/Map/Layers/OSM'; import { simplifyFeature } from 'common/Map/WarpedMap/core/helpers'; +import OrderedLayer from 'common/Map/Layers/OrderedLayer'; const TIME_LABEL = 'Loading OSRD and OSM data around warped path'; +const OSM_LAYERS = new Set(['building', 'water', 'water_name', 'waterway', 'poi']); + /** * This component handles loading entities from MapLibre vector servers, and retrieving them as GeoJSONs from the * MapLibre `querySourceFeatures` method. @@ -39,7 +42,18 @@ const DataLoader: FC<{ const { mapStyle, layersSettings } = useSelector(getMap); const [mapRef, setMapRef] = useState(null); const [state, setState] = useState<'idle' | 'render' | 'loaded'>('idle'); - const osmLayers = useMemo(() => genLayers(mapStyle), [mapStyle]); + const osmLayers = useMemo(() => { + const osmStyle = (mapStyleJson as LayerProps[]).filter( + (layer) => layer.id && OSM_LAYERS.has(layer.id) + ); + return osmStyle + .map((layer) => ({ + ...layer, + key: layer.id, + id: `osm/${layer.id}`, + })) + .map((layer) => ); + }, []); useEffect(() => { if (!mapRef) return; @@ -77,16 +91,19 @@ const DataLoader: FC<{ const osmSource = m.getSource('osm') as unknown as { vectorLayerIds: string[] }; let incrementalID = 1; const osmData: Record = osmSource.vectorLayerIds.reduce( - (iter, sourceLayer) => ({ - ...iter, - [sourceLayer]: featureCollection( - uniqBy( - m.querySourceFeatures('osm', { sourceLayer }).map(simplifyFeature), - // eslint-disable-next-line no-plusplus - (f) => (f.id ? `osm-${f.id}` : `generated-${++incrementalID}`) // only deduplicate features with IDs - ) - ), - }), + (iter, sourceLayer) => + OSM_LAYERS.has(sourceLayer) + ? { + ...iter, + [sourceLayer]: featureCollection( + uniqBy( + m.querySourceFeatures('osm', { sourceLayer }).map(simplifyFeature), + // eslint-disable-next-line no-plusplus + (f) => (f.id ? `osm-${f.id}` : `generated-${++incrementalID}`) // only deduplicate features with IDs + ) + ), + } + : iter, {} ); const osmFeaturesCount = sum(map(osmData, (collection) => collection.features.length)); diff --git a/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx index f560157b575..f634bb3f6c5 100644 --- a/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx @@ -1,6 +1,6 @@ /* eslint-disable no-console */ import { useSelector } from 'react-redux'; -import React, { FC, useEffect, useMemo, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { isEmpty, isNil, mapValues, omitBy } from 'lodash'; import bbox from '@turf/bbox'; @@ -18,6 +18,7 @@ import WarpedMap from 'common/Map/WarpedMap/WarpedMap'; import { TrainPosition } from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/types'; import { getInfraID } from 'reducers/osrdconf/selectors'; import { getImprovedOSRDData } from 'common/Map/WarpedMap/core/helpers'; +import { getSelectedTrain } from 'reducers/osrdsimulation/selectors'; const TIME_LABEL = 'Warping OSRD and OSM data'; @@ -31,8 +32,16 @@ interface PathStatePayload { interface DataStatePayload { osm: Record; osrd: Partial>; - itinerary?: Feature; - trains?: (TrainPosition & { isSelected?: true })[]; +} + +function useAsyncMemo(fn: () => Promise, defaultValue: D): T | D { + const [v, setV] = useState(defaultValue); + + useEffect(() => { + fn().then(setV); + }, [fn]); + + return v; } /** @@ -59,6 +68,34 @@ const SimulationWarpedMap: FC = () => { const [getPath] = osrdEditoastApi.useLazyGetPathfindingByIdQuery(); const layers = useMemo(() => new Set(['track_sections']), []); + // Itinerary handling: + const simulation = useSelector( + (rootState: RootState) => rootState.osrdsimulation.simulation.present + ); + const selectedTrain = useSelector(getSelectedTrain); + const getItinerary = useCallback(async () => { + if (!selectedTrain) return undefined; + if (state.type !== 'dataLoaded') return undefined; + + const foundTrain = simulation.trains.find((train) => train.id === selectedTrain.id); + if (!foundTrain) return undefined; + + const { data: path } = await getPath({ id: foundTrain.path }); + if (!path) return undefined; + + return state.transform(lineString(path.geographic.coordinates)) || undefined; + }, []); + const itinerary: Feature | undefined = useAsyncMemo(getItinerary, undefined); + + // Trains handling: + const getTrains = useCallback( + async () => + // TODO + [], + [] + ); + const trains: (TrainPosition & { isSelected?: true })[] = useAsyncMemo(getTrains, []); + /** * This effect handles loading the simulation path, and retrieve the warping function: */ @@ -146,8 +183,8 @@ const SimulationWarpedMap: FC = () => { bbox={state.regularBBox} osrdData={state.osrd} osmData={state.osm} - trains={state.trains} - itinerary={state.itinerary} + itinerary={itinerary} + trains={trains} />
diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx index c064f58708a..557139ebe33 100644 --- a/front/src/common/Map/WarpedMap/WarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -14,6 +14,8 @@ import { ALL_SIGNAL_LAYERS } from 'common/Map/Consts/SignalsNames'; import { LayerType } from 'applications/editor/tools/types'; import { TrainPosition } from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/types'; import VirtualLayers from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/VirtualLayers'; +import RenderItinerary from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/RenderItinerary'; +import TrainHoverPosition from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/TrainHoverPosition'; import { LayerContext } from 'common/Map/Layers/types'; import { EditorSource, SourcesDefinitionsIndex } from 'common/Map/Layers/GeoJSONs'; import OrderedLayer, { OrderedLayerProps } from 'common/Map/Layers/OrderedLayer'; @@ -47,7 +49,7 @@ const WarpedMap: FC<{ osmData: Record; trains?: (TrainPosition & { isSelected?: true })[]; itinerary?: Feature; -}> = ({ bbox, osrdLayers, osrdData, osmData }) => { +}> = ({ bbox, osrdLayers, osrdData, osmData, trains, itinerary }) => { const prefix = 'warped/'; const [mapRef, setMapRef] = useState(null); const { mapStyle, layersSettings, showIGNBDORTHO } = useSelector((s: RootState) => s.map); @@ -131,6 +133,22 @@ const WarpedMap: FC<{ layerOrder={s.order} /> ))} + {itinerary && ( + + )} + {itinerary && + trains?.map((train) => ( + + ))} ); }; From b2e9941f3aac65f98cbb2aa9f866f5a82acc29d9 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Tue, 5 Sep 2023 18:40:00 +0200 Subject: [PATCH 10/15] simulation: displays trains in SimulationWarpedMap Details: - Adds utils/useAsyncMemo to simplify async data retrieving code - Moves `getSimulationHoverPositions` and related helpers from SimulationResultsMap component code to new file SimulationResultsMap/helpers to make them reusable from elsewhere - Uses those helpers to get similar trains to display in SimulationWarpedMap (after some warping magic) --- .../SimulationResultsMap.tsx | 169 ++---------------- .../SimulationResultsMap/helpers.ts | 148 +++++++++++++++ .../Map/WarpedMap/SimulationWarpedMap.tsx | 100 +++++++---- front/src/common/Map/WarpedMap/WarpedMap.tsx | 2 +- front/src/utils/mapHelper.ts | 14 +- front/src/utils/useAsyncMemo.ts | 41 +++++ 6 files changed, 284 insertions(+), 190 deletions(-) create mode 100644 front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/helpers.ts create mode 100644 front/src/utils/useAsyncMemo.ts diff --git a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap.tsx b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap.tsx index 83b2934af6f..d285fec5a40 100644 --- a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap.tsx +++ b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap.tsx @@ -3,23 +3,16 @@ import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { MapLayerMouseEvent } from 'maplibre-gl'; import WebMercatorViewport from 'viewport-mercator-project'; -import ReactMapGL, { AttributionControl, ScaleControl, MapRef } from 'react-map-gl/maplibre'; +import ReactMapGL, { AttributionControl, MapRef, ScaleControl } from 'react-map-gl/maplibre'; import { Feature, LineString } from 'geojson'; -import { lineString, point, BBox } from '@turf/helpers'; -import along from '@turf/along'; +import { BBox, lineString, point } from '@turf/helpers'; import bbox from '@turf/bbox'; import lineLength from '@turf/length'; import lineSlice from '@turf/line-slice'; -import { last } from 'lodash'; import { updateTimePositionValues } from 'reducers/osrdsimulation/actions'; import { getPresentSimulation, getSelectedTrain } from 'reducers/osrdsimulation/selectors'; -import { - AllowancesSettings, - PositionValues, - PositionSpeedTime, - Train, -} from 'reducers/osrdsimulation/types'; +import { PositionSpeedTime, Train } from 'reducers/osrdsimulation/types'; import { updateViewport, Viewport } from 'reducers/map'; import { RootState } from 'reducers'; import { TrainPosition } from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/types'; @@ -51,12 +44,11 @@ import TracksSchematic from 'common/Map/Layers/TracksSchematic'; import TrainHoverPosition from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/TrainHoverPosition'; import colors from 'common/Map/Consts/colors'; -import { datetime2Isostring, datetime2sec, timeString2datetime } from 'utils/timeManipulation'; +import { datetime2Isostring } from 'utils/timeManipulation'; import osmBlankStyle from 'common/Map/Layers/osmBlankStyle'; import { getDirection, interpolateOnPosition, - interpolateOnTime, } from 'applications/operationalStudies/components/SimulationResults/ChartHelpers/ChartHelpers'; import { LAYER_GROUPS_ORDER, LAYERS } from 'config/layerOrder'; @@ -69,33 +61,12 @@ import { CUSTOM_ATTRIBUTION } from 'common/Map/const'; import { SimulationReport, osrdEditoastApi } from 'common/api/osrdEditoastApi'; import Terrain from 'common/Map/Layers/Terrain'; import { getTerrain3DExaggeration } from 'reducers/map/selectors'; - -function getPosition( - positionValues: PositionValues, - allowancesSettings?: AllowancesSettings, - trainId?: number, - baseKey?: string -) { - const key = ( - allowancesSettings && trainId && allowancesSettings[trainId]?.ecoBlocks - ? `eco_${baseKey}` - : baseKey - ) as keyof PositionValues; - return positionValues[key] as PositionSpeedTime; -} +import { getRegimeKey, getSimulationHoverPositions } from './SimulationResultsMap/helpers'; interface MapProps { setExtViewport: (viewport: Viewport) => void; } -type InterpoledTrain = { - name: string; - id: number; - head_positions?: PositionSpeedTime; - tail_positions?: PositionSpeedTime; - speeds?: PositionSpeedTime; -}; - const Map: FC = ({ setExtViewport }) => { const [mapLoaded, setMapLoaded] = useState(false); const { viewport, mapSearchMarker, mapStyle, mapTrackSources, showOSM } = useSelector( @@ -124,123 +95,6 @@ const Map: FC = ({ setExtViewport }) => { ); const mapRef = React.useRef(null); - /** - * - * @param {int} trainId - * @returns correct key (eco or base) to get positions in a train simulation - */ - const getRegimeKey = (trainId: number) => - allowancesSettings && allowancesSettings[trainId]?.ecoBlocks ? 'eco' : 'base'; - - const createOtherPoints = (): InterpoledTrain[] => { - const timePositionDate = timeString2datetime(timePosition); - let actualTime = 0; - if (timePositionDate instanceof Date) { - actualTime = datetime2sec(timePositionDate); - } else { - console.warn('Try to create Other Train Point from unspecified current time Position'); - return []; - } - - // First find trains where actual time from position is between start & stop - const concernedTrains: InterpoledTrain[] = []; - simulation.trains.forEach((train, idx: number) => { - const baseOrEco = getRegimeKey(train.id); - const trainRegime = train[baseOrEco]; - if (trainRegime && trainRegime.head_positions[0]) { - const trainTime = trainRegime.head_positions[0][0].time; - const train2ndTime = last(last(trainRegime.head_positions))?.time as number; - if ( - actualTime >= trainTime && - actualTime <= train2ndTime && - train.id !== selectedTrain?.id - ) { - const interpolation = interpolateOnTime( - train[baseOrEco], - ['time', 'position'], - ['head_positions', 'tail_positions', 'speeds'], - actualTime - ) as Record; - if (interpolation.head_positions && interpolation.speeds) { - concernedTrains.push({ - ...interpolation, - name: train.name, - id: idx, - }); - } - } - } - }); - return concernedTrains; - }; - - // specifies the position of the trains when hovering over the simulation - const getSimulationHoverPositions = () => { - if (geojsonPath) { - const line = lineString(geojsonPath.geometry.coordinates); - if (selectedTrain) { - const headPositionRaw = getPosition( - positionValues, - allowancesSettings, - selectedTrain.id, - 'headPosition' - ); - const tailPositionRaw = getPosition( - positionValues, - allowancesSettings, - selectedTrain.id, - 'tailPosition' - ); - if (headPositionRaw) { - setTrainHoverPosition(() => { - const headDistanceAlong = headPositionRaw.position / 1000; - const tailDistanceAlong = tailPositionRaw.position / 1000; - const headPosition = along(line, headDistanceAlong, { - units: 'kilometers', - }); - const tailPosition = tailPositionRaw - ? along(line, tailDistanceAlong, { units: 'kilometers' }) - : headPosition; - const trainLength = Math.abs(headDistanceAlong - tailDistanceAlong); - return { - id: 'main-train', - headPosition, - tailPosition, - headDistanceAlong, - tailDistanceAlong, - speedTime: positionValues.speed, - trainLength, - }; - }); - } - } - - // Found trains including timePosition, and organize them with geojson collection of points - setOtherTrainsHoverPosition( - createOtherPoints().map((train) => { - const headDistanceAlong = (train.head_positions?.position ?? 0) / 1000; - const tailDistanceAlong = (train.tail_positions?.position ?? 0) / 1000; - const headPosition = along(line, headDistanceAlong, { - units: 'kilometers', - }); - const tailPosition = train.tail_positions - ? along(line, tailDistanceAlong, { units: 'kilometers' }) - : headPosition; - const trainLength = Math.abs(headDistanceAlong - tailDistanceAlong); - return { - id: `other-train-${train.id}`, - headPosition, - tailPosition, - headDistanceAlong, - tailDistanceAlong, - speedTime: positionValues.speed, - trainLength, - }; - }) - ); - } - }; - const zoomToFeature = (boundingBox: BBox) => { const [minLng, minLat, maxLng, maxLat] = boundingBox; const viewportTemp = new WebMercatorViewport({ ...viewport, width: 600, height: 400 }); @@ -372,7 +226,16 @@ const Map: FC = ({ setExtViewport }) => { useEffect(() => { if (timePosition && geojsonPath) { - getSimulationHoverPositions(); + const trains = getSimulationHoverPositions( + geojsonPath, + simulation, + timePosition, + positionValues, + selectedTrain?.id, + allowancesSettings + ); + setTrainHoverPosition(trains.find((train) => train.isSelected)); + setOtherTrainsHoverPosition(trains.filter((train) => !train.isSelected)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [timePosition]); @@ -431,7 +294,7 @@ const Map: FC = ({ setExtViewport }) => { )} - {/* Have to duplicate objects with sourceLayer to avoid cache problems */} + {/* Have to duplicate objects with sourceLayer to avoid cache problems */} {mapTrackSources === 'geographic' ? ( <> , + simulation: SimulationSnapshot, + timePosition: string, + positionValues: PositionValues, + selectedTrainId?: number, + allowancesSettings?: AllowancesSettings +) { + const line = lineString(geojsonPath.geometry.coordinates); + let positions: (TrainPosition & { isSelected?: boolean })[] = []; + + if (selectedTrainId) { + const headPositionRaw = getPosition( + positionValues, + allowancesSettings, + selectedTrainId, + 'headPosition' + ); + const tailPositionRaw = getPosition( + positionValues, + allowancesSettings, + selectedTrainId, + 'tailPosition' + ); + if (headPositionRaw) { + const headDistanceAlong = headPositionRaw.position / 1000; + const tailDistanceAlong = tailPositionRaw.position / 1000; + const headPosition = along(line, headDistanceAlong, { + units: 'kilometers', + }); + const tailPosition = tailPositionRaw + ? along(line, tailDistanceAlong, { units: 'kilometers' }) + : headPosition; + const trainLength = Math.abs(headDistanceAlong - tailDistanceAlong); + positions.push({ + id: 'main-train', + headPosition, + tailPosition, + headDistanceAlong, + tailDistanceAlong, + speedTime: positionValues.speed, + trainLength, + isSelected: true, + }); + } + } + + // Found trains including timePosition, and organize them with geojson collection of points + const timePositionDate = timeString2datetime(timePosition) || new Date(timePosition); + let actualTime = 0; + if (timePositionDate instanceof Date) { + actualTime = datetime2sec(timePositionDate); + } else { + console.warn('Try to create Other Train Point from unspecified current time Position'); + return []; + } + + // First find trains where actual time from position is between start & stop + const concernedTrains: InterpoledTrain[] = []; + simulation.trains.forEach((train, idx: number) => { + const baseOrEco = getRegimeKey(train.id); + const trainRegime = train[baseOrEco]; + if (trainRegime && trainRegime.head_positions[0]) { + const trainTime = trainRegime.head_positions[0][0].time; + const train2ndTime = last(last(trainRegime.head_positions))?.time as number; + if (actualTime >= trainTime && actualTime <= train2ndTime && train.id !== selectedTrainId) { + const interpolation = interpolateOnTime( + train[baseOrEco], + ['time', 'position'], + ['head_positions', 'tail_positions', 'speeds'], + actualTime + ) as Record; + if (interpolation.head_positions && interpolation.speeds) { + concernedTrains.push({ + ...interpolation, + name: train.name, + id: idx, + }); + } + } + } + }); + positions = positions.concat( + concernedTrains.map((train) => { + const headDistanceAlong = (train.head_positions?.position ?? 0) / 1000; + const tailDistanceAlong = (train.tail_positions?.position ?? 0) / 1000; + const headPosition = along(line, headDistanceAlong, { + units: 'kilometers', + }); + const tailPosition = train.tail_positions + ? along(line, tailDistanceAlong, { units: 'kilometers' }) + : headPosition; + const trainLength = Math.abs(headDistanceAlong - tailDistanceAlong); + return { + id: `other-train-${train.id}`, + headPosition, + tailPosition, + headDistanceAlong, + tailDistanceAlong, + speedTime: positionValues.speed, + trainLength, + }; + }) + ); + + return positions; +} diff --git a/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx index f634bb3f6c5..17e40f6848a 100644 --- a/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx @@ -1,9 +1,10 @@ /* eslint-disable no-console */ import { useSelector } from 'react-redux'; -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; import { isEmpty, isNil, mapValues, omitBy } from 'lodash'; import bbox from '@turf/bbox'; +import length from '@turf/length'; import { lineString } from '@turf/helpers'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { Feature, FeatureCollection, LineString, Position } from 'geojson'; @@ -19,6 +20,8 @@ import { TrainPosition } from 'applications/operationalStudies/components/Simula import { getInfraID } from 'reducers/osrdconf/selectors'; import { getImprovedOSRDData } from 'common/Map/WarpedMap/core/helpers'; import { getSelectedTrain } from 'reducers/osrdsimulation/selectors'; +import { AsyncMemoState, getAsyncMemoData, useAsyncMemo } from 'utils/useAsyncMemo'; +import { getSimulationHoverPositions } from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/helpers'; const TIME_LABEL = 'Warping OSRD and OSM data'; @@ -34,16 +37,6 @@ interface DataStatePayload { osrd: Partial>; } -function useAsyncMemo(fn: () => Promise, defaultValue: D): T | D { - const [v, setV] = useState(defaultValue); - - useEffect(() => { - fn().then(setV); - }, [fn]); - - return v; -} - /** * This component handles loading the simulation path, all the surrounding data (OSM and OSRD), transforms them, and * then mounts a WarpedMap with all that data: @@ -69,32 +62,77 @@ const SimulationWarpedMap: FC = () => { const layers = useMemo(() => new Set(['track_sections']), []); // Itinerary handling: - const simulation = useSelector( - (rootState: RootState) => rootState.osrdsimulation.simulation.present - ); + const { + positionValues, + timePosition, + allowancesSettings, + simulation: { present: simulation }, + } = useSelector((s: RootState) => s.osrdsimulation); const selectedTrain = useSelector(getSelectedTrain); - const getItinerary = useCallback(async () => { - if (!selectedTrain) return undefined; - if (state.type !== 'dataLoaded') return undefined; + const itineraryState: AsyncMemoState | null> = useAsyncMemo(async () => { + if (!selectedTrain) return null; + if (state.type !== 'dataLoaded') return null; const foundTrain = simulation.trains.find((train) => train.id === selectedTrain.id); - if (!foundTrain) return undefined; + if (!foundTrain) return null; const { data: path } = await getPath({ id: foundTrain.path }); - if (!path) return undefined; + if (!path) return null; - return state.transform(lineString(path.geographic.coordinates)) || undefined; - }, []); - const itinerary: Feature | undefined = useAsyncMemo(getItinerary, undefined); + return lineString(path.geographic.coordinates); + }, [selectedTrain, state.type, simulation]); + const warpedItinerary = useMemo(() => { + const itinerary = getAsyncMemoData(itineraryState); + if (itinerary && state.type === 'dataLoaded') return state.transform(itinerary) || undefined; + return undefined; + }, [itineraryState, state]); // Trains handling: - const getTrains = useCallback( - async () => - // TODO - [], - [] - ); - const trains: (TrainPosition & { isSelected?: true })[] = useAsyncMemo(getTrains, []); + const trainsState: AsyncMemoState<(TrainPosition & { isSelected?: boolean })[]> = + useAsyncMemo(async () => { + const path = getAsyncMemoData(itineraryState); + if (!path || state.type !== 'dataLoaded') return []; + + const transformedPath = state.transform(path) as typeof path; + console.log('PATH', path); + console.log('TRANSFORMED PATH', transformedPath); + return getSimulationHoverPositions( + path, + simulation, + timePosition, + positionValues, + selectedTrain?.id, + allowancesSettings + ).map((train) => { + const transformedTrain = { ...train }; + const pathLength = length(path); + const transformedPathLength = length(transformedPath); + + // Transform positions: + transformedTrain.headPosition = state.transform( + train.headPosition + ) as TrainPosition['headPosition']; + transformedTrain.tailPosition = state.transform( + train.tailPosition + ) as TrainPosition['tailPosition']; + + // Interpolate positions: + transformedTrain.headDistanceAlong = + (train.headDistanceAlong / pathLength) * transformedPathLength; + transformedTrain.tailDistanceAlong = + (train.tailDistanceAlong / pathLength) * transformedPathLength; + + return transformedTrain; + }); + }, [ + itineraryState, + simulation, + timePosition, + positionValues, + selectedTrain, + allowancesSettings, + state, + ]); /** * This effect handles loading the simulation path, and retrieve the warping function: @@ -183,8 +221,8 @@ const SimulationWarpedMap: FC = () => { bbox={state.regularBBox} osrdData={state.osrd} osmData={state.osm} - itinerary={itinerary} - trains={trains} + itinerary={warpedItinerary} + trains={getAsyncMemoData(trainsState) || undefined} />
diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx index 557139ebe33..715a99256b1 100644 --- a/front/src/common/Map/WarpedMap/WarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -47,7 +47,7 @@ const WarpedMap: FC<{ // Data to display on the map (must be transformed already): osrdData: Partial>; osmData: Record; - trains?: (TrainPosition & { isSelected?: true })[]; + trains?: (TrainPosition & { isSelected?: boolean })[]; itinerary?: Feature; }> = ({ bbox, osrdLayers, osrdData, osmData, trains, itinerary }) => { const prefix = 'warped/'; diff --git a/front/src/utils/mapHelper.ts b/front/src/utils/mapHelper.ts index ff8890ef499..f83d1cbde31 100644 --- a/front/src/utils/mapHelper.ts +++ b/front/src/utils/mapHelper.ts @@ -7,7 +7,7 @@ import lineIntersect from '@turf/line-intersect'; import lineSlice from '@turf/line-slice'; import WebMercatorViewport from 'viewport-mercator-project'; import { ViewState } from 'react-map-gl/maplibre'; -import { BBox, Coord, featureCollection } from '@turf/helpers'; +import { BBox, Coord, featureCollection, lineString } from '@turf/helpers'; import { Feature, FeatureCollection, @@ -90,7 +90,7 @@ export function zoneToBBox(zone: Zone): BBox { export function intersectPolygonLine( poly: Feature, line: Feature -): Feature | null { +): Feature | null { const lines: Position[][] = line.geometry.type === 'MultiLineString' ? line.geometry.coordinates @@ -130,7 +130,11 @@ export function intersectPolygonLine( }); }); - return res.geometry.coordinates.length ? res : null; + if (res.geometry.coordinates.length > 1) return res; + if (res.geometry.coordinates.length === 1) + return lineString(res.geometry.coordinates[0], res.properties); + + return null; } /** @@ -365,8 +369,8 @@ export function getMapMouseEventNearestFeature( case 'MultiLineString': { const points: FeatureCollection = { type: 'FeatureCollection', - features: feature.geometry.coordinates.map((lineString) => - nearestPointOnLine({ type: 'LineString', coordinates: lineString }, coord) + features: feature.geometry.coordinates.map((coordinates) => + nearestPointOnLine({ type: 'LineString', coordinates }, coord) ), }; const pt = nearestPoint(coord, points); diff --git a/front/src/utils/useAsyncMemo.ts b/front/src/utils/useAsyncMemo.ts new file mode 100644 index 00000000000..21b4c1b094e --- /dev/null +++ b/front/src/utils/useAsyncMemo.ts @@ -0,0 +1,41 @@ +import { DependencyList, useEffect, useState } from 'react'; + +export type AsyncMemoState = + | { type: 'loading'; previousData?: T } + | { type: 'error'; error?: Error } + | { type: 'ready'; data: T }; + +/** + * This function helps to retrieve the data in an AsyncMemoState. It's truly just sugar. + */ +export function getAsyncMemoData(state: AsyncMemoState): T | undefined { + if (state.type === 'ready') return state.data; + if (state.type === 'loading') return state.previousData; + return undefined; +} + +/** + * This hook helps to manipulate asynchronous memoized values, without having to rewrite every time the same boilerplate + * code to handle loading state and errors. + */ +export function useAsyncMemo(fn: () => Promise, deps: DependencyList): AsyncMemoState { + const [state, setState] = useState>({ type: 'loading' }); + + useEffect(() => { + let aborted = false; + setState({ type: 'loading', previousData: getAsyncMemoData(state) }); + fn() + .then((data) => { + if (!aborted) setState({ type: 'ready', data }); + }) + .catch((error) => { + if (!aborted) setState({ type: 'error', error }); + }); + + return () => { + aborted = true; + }; + }, deps); + + return state; +} From 1ba33f6d4af39b29d34f60a1725b2af4e9449ffb Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Wed, 6 Sep 2023 16:05:24 +0200 Subject: [PATCH 11/15] simulation: adds toggle button to warped map --- .../views/SimulationResults.tsx | 12 ++- .../Map/WarpedMap/SimulationWarpedMap.scss | 11 +++ .../Map/WarpedMap/SimulationWarpedMap.tsx | 98 ++++++++++--------- 3 files changed, 71 insertions(+), 50 deletions(-) create mode 100644 front/src/common/Map/WarpedMap/SimulationWarpedMap.scss diff --git a/front/src/applications/operationalStudies/views/SimulationResults.tsx b/front/src/applications/operationalStudies/views/SimulationResults.tsx index 8ff5202b242..acd45941f2a 100644 --- a/front/src/applications/operationalStudies/views/SimulationResults.tsx +++ b/front/src/applications/operationalStudies/views/SimulationResults.tsx @@ -43,6 +43,7 @@ export default function SimulationResults({ isDisplayed, collapsedTimetable, inf const { t } = useTranslation(['translation', 'simulation', 'allowances']); const timeTableRef = useRef(null); const [extViewport, setExtViewport] = useState(undefined); + const [showWarpedMap, setShowWarpedMap] = useState(false); const [heightOfSpaceTimeChart, setHeightOfSpaceTimeChart] = useState(600); @@ -155,8 +156,15 @@ export default function SimulationResults({ isDisplayed, collapsedTimetable, inf {/* SIMULATION : SPACE TIME CHART */} -
- +
+ +
; @@ -41,7 +44,7 @@ interface DataStatePayload { * This component handles loading the simulation path, all the surrounding data (OSM and OSRD), transforms them, and * then mounts a WarpedMap with all that data: */ -const SimulationWarpedMap: FC = () => { +const SimulationWarpedMap: FC<{ collapsed?: boolean }> = ({ collapsed }) => { const infraID = useSelector(getInfraID); const [state, setState] = useState< | { type: 'idle' } @@ -94,8 +97,6 @@ const SimulationWarpedMap: FC = () => { if (!path || state.type !== 'dataLoaded') return []; const transformedPath = state.transform(path) as typeof path; - console.log('PATH', path); - console.log('TRANSFORMED PATH', transformedPath); return getSimulationHoverPositions( path, simulation, @@ -178,53 +179,54 @@ const SimulationWarpedMap: FC = () => { }); }, [state]); - if (state.type === 'idle' || state.type === 'loading' || state.type === 'error') - return ; - - if (state.type === 'pathLoaded') - return ( - { - console.time(TIME_LABEL); - const transformed = { - osm: omitBy( - mapValues(osmData, (collection) => state.transform(collection)), - isNil - ) as DataStatePayload['osm'], - osrd: omitBy( - mapValues(osrdData, (collection: FeatureCollection) => state.transform(collection)), - isNil - ) as DataStatePayload['osrd'], - }; - console.timeEnd(TIME_LABEL); - setState({ ...state, ...transformed, type: 'dataLoaded' }); - }} - /> - ); - return ( -
-
- + {state.type === 'pathLoaded' && ( + { + console.time(TIME_LABEL); + const transformed = { + osm: omitBy( + mapValues(osmData, (collection) => state.transform(collection)), + isNil + ) as DataStatePayload['osm'], + osrd: omitBy( + mapValues(osrdData, (collection: FeatureCollection) => state.transform(collection)), + isNil + ) as DataStatePayload['osrd'], + }; + console.timeEnd(TIME_LABEL); + setState({ ...state, ...transformed, type: 'dataLoaded' }); + }} /> -
+ )} + {(state.type !== 'dataLoaded') && ( + + )} + {state.type === 'dataLoaded' && ( +
+ +
+ )}
); }; From 18e18f2965630e966ae897e920932c4ba1583ba6 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Thu, 7 Sep 2023 10:58:29 +0200 Subject: [PATCH 12/15] simulation: syncs warped map zoom and GET's zoom Details: - Updates SpaceTimeChart so that it calls `dispatchUpdateChart` when the graph is updated. I feel like it was missing, since this `dispatchUpdateChart` is called in `drawAllTrain` so the global chart is already "somehow" synchronized with the one in SpaceTimeChart - Allows WarpedMap to be given a bounding box, in which case interactions are disabled - Updates SimulationWarpedMap so that it computes a bounding box to fit SpaceTimeChart viewport, and gives it to WarpedMap --- .../SpaceTimeChart/SpaceTimeChart.jsx | 2 + .../Map/WarpedMap/SimulationWarpedMap.tsx | 83 +++++++++++++++++-- front/src/common/Map/WarpedMap/WarpedMap.tsx | 27 +++++- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/front/src/applications/operationalStudies/components/SimulationResults/SpaceTimeChart/SpaceTimeChart.jsx b/front/src/applications/operationalStudies/components/SimulationResults/SpaceTimeChart/SpaceTimeChart.jsx index 7ae876d7fea..2ec6adbb334 100644 --- a/front/src/applications/operationalStudies/components/SimulationResults/SpaceTimeChart/SpaceTimeChart.jsx +++ b/front/src/applications/operationalStudies/components/SimulationResults/SpaceTimeChart/SpaceTimeChart.jsx @@ -226,6 +226,8 @@ export default function SpaceTimeChart(props) { ); moveGridLinesOnMouseMove(); } + + dispatchUpdateChart(chart); // eslint-disable-next-line react-hooks/exhaustive-deps }, [chart]); diff --git a/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx index e1a2d05649c..d5ebe0d3fd1 100644 --- a/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx @@ -1,13 +1,14 @@ /* eslint-disable no-console */ import { useSelector } from 'react-redux'; import React, { FC, useEffect, useMemo, useState } from 'react'; -import { isEmpty, isNil, mapValues, omitBy } from 'lodash'; +import { clamp, first, isEmpty, isNil, last, mapValues, omitBy } from 'lodash'; import bbox from '@turf/bbox'; import length from '@turf/length'; import { lineString } from '@turf/helpers'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { Feature, FeatureCollection, LineString, Position } from 'geojson'; +import { LngLatBoundsLike } from 'maplibre-gl'; import { RootState } from 'reducers'; import { LoaderFill } from 'common/Loader'; @@ -22,11 +23,12 @@ import { getImprovedOSRDData } from 'common/Map/WarpedMap/core/helpers'; import { getSelectedTrain } from 'reducers/osrdsimulation/selectors'; import { AsyncMemoState, getAsyncMemoData, useAsyncMemo } from 'utils/useAsyncMemo'; import { getSimulationHoverPositions } from 'applications/operationalStudies/components/SimulationResults/SimulationResultsMap/helpers'; +import { clip } from 'utils/mapHelper'; import './SimulationWarpedMap.scss'; const TIME_LABEL = 'Warping OSRD and OSM data'; -const WIDTH = 200; +const WIDTH = 300; interface PathStatePayload { path: Feature; @@ -64,6 +66,78 @@ const SimulationWarpedMap: FC<{ collapsed?: boolean }> = ({ collapsed }) => { const [getPath] = osrdEditoastApi.useLazyGetPathfindingByIdQuery(); const layers = useMemo(() => new Set(['track_sections']), []); + // Boundaries handling (ie zoom sync): + const chart = useSelector((s: RootState) => s.osrdsimulation.chart); + const syncedBoundingBox: LngLatBoundsLike = useMemo(() => { + if (chart && state.type === 'dataLoaded') { + const { y, height } = chart; + const { path, transform, regularBBox } = state; + const l = length(path, { units: 'meters' }); + + const yStart = y(0); + const yEnd = y(l); + + const transformedPath = transform(path) as typeof path; + const latStart = (first(transformedPath.geometry.coordinates) as Position)[1]; + const latEnd = (last(transformedPath.geometry.coordinates) as Position)[1]; + + /** + * Here, `y` is the function provided by d3 to scale distance in meters from the beginning of the path to pixels + * from the bottom of the `SpaceTimeChart` (going upwards) to the related point. + * So, `yStart` is the y coordinate of the start of the path at the current zoom level, and yEnd is the y + * coordinate of the end of the path. + * Finally, `height` is the height in pixels of the SpaceTimeChart. + * + * Also, we now `latStart` and `latEnd`, which are the latitudes of the first and the last points of our + * transformed path. + * + * We are looking for `latBottom` and `latTop` so that our warped map is as much "aligned" as we can with the + * `SpaceTimeChart`. According to Thalès, we know that: + * + * (latStart - latBottom) / yStart = (latTop - latBottom) / height = (latEnd - latStart) / (yEnd - yStart) + * + * That explains the following computations: + */ + const ratio = (latEnd - latStart) / (yEnd - yStart); + const latBottom = clamp(latStart - yStart * ratio, -90, 90); + const latTop = clamp(latBottom + height * ratio, -90, 90); + + // Since we are here describing a bounding box where only the latBottom and latTop are important, it will have a + // 0 width, and we just need to specify the middle longitude (based on the visible part of the path on the screen, + // so depending on the latTop and latBottom values): + const clippedPath = clip(transformedPath, { + type: 'rectangle', + points: [ + [regularBBox[0], latTop], + [regularBBox[2], latBottom], + ], + }) as typeof transformedPath; + const clippedPathBBox = bbox(clippedPath) as BBox2d; + const lngAverage = (clippedPathBBox[0] + clippedPathBBox[2]) / 2; + + return [ + [lngAverage, latTop], + [lngAverage, latBottom], + ] as LngLatBoundsLike; + } + + if (state.type === 'dataLoaded' || state.type === 'pathLoaded') { + const { regularBBox } = state; + const lngAverage = (regularBBox[0] + regularBBox[2]) / 2; + + return [ + [lngAverage, regularBBox[1]], + [lngAverage, regularBBox[3]], + ]; + } + + // This should never occur: + return [ + [0, 0], + [0, 0], + ] as LngLatBoundsLike; + }, [chart, state]); + // Itinerary handling: const { positionValues, @@ -205,9 +279,7 @@ const SimulationWarpedMap: FC<{ collapsed?: boolean }> = ({ collapsed }) => { }} /> )} - {(state.type !== 'dataLoaded') && ( - - )} + {state.type !== 'dataLoaded' && } {state.type === 'dataLoaded' && (
= ({ collapsed }) => { osmData={state.osm} itinerary={warpedItinerary} trains={getAsyncMemoData(trainsState) || undefined} + boundingBox={syncedBoundingBox} />
)} diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx index 715a99256b1..d7ad00e6845 100644 --- a/front/src/common/Map/WarpedMap/WarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -21,6 +21,7 @@ import { EditorSource, SourcesDefinitionsIndex } from 'common/Map/Layers/GeoJSON import OrderedLayer, { OrderedLayerProps } from 'common/Map/Layers/OrderedLayer'; import { genLayerProps } from 'common/Map/Layers/OSM'; import osmBlankStyle from 'common/Map/Layers/osmBlankStyle'; +import { LngLatBoundsLike } from 'maplibre-gl'; const OSRD_LAYER_ORDERS: Record = { buffer_stops: LAYER_GROUPS_ORDER[LAYERS.BUFFER_STOPS.GROUP], @@ -44,12 +45,13 @@ const OSRD_LAYER_ORDERS: Record = { const WarpedMap: FC<{ bbox: BBox2d; osrdLayers: Set; + boundingBox?: LngLatBoundsLike; // Data to display on the map (must be transformed already): osrdData: Partial>; osmData: Record; trains?: (TrainPosition & { isSelected?: boolean })[]; itinerary?: Feature; -}> = ({ bbox, osrdLayers, osrdData, osmData, trains, itinerary }) => { +}> = ({ bbox, osrdLayers, osrdData, osmData, trains, itinerary, boundingBox }) => { const prefix = 'warped/'; const [mapRef, setMapRef] = useState(null); const { mapStyle, layersSettings, showIGNBDORTHO } = useSelector((s: RootState) => s.map); @@ -103,13 +105,30 @@ const WarpedMap: FC<{ const avgLon = (bbox[0] + bbox[2]) / 2; const thinBBox: BBox2d = [avgLon, bbox[1], avgLon, bbox[3]]; setTimeout(() => { - mapRef.fitBounds(thinBBox, { animate: false }); + mapRef.fitBounds(boundingBox || thinBBox, { animate: false }); mapRef.resize(); }, 0); - }, [mapRef, bbox]); + }, [mapRef, bbox, boundingBox]); + + // This effect handles the map initial position: + useEffect(() => { + if (!mapRef || !boundingBox) return; + + mapRef.fitBounds(boundingBox); + mapRef.resize(); + }, [boundingBox]); return ( - + {map(osmSources, (layers, sourceLayer) => ( From b9375956afd005a01305f13f810bb22b8f9445d2 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Thu, 7 Sep 2023 15:03:52 +0200 Subject: [PATCH 13/15] simulation: fixes trains display on WarpedMap Details: - TrainHoverPosition no more reads train, allowancesSettings and viewport from global store, and requires them in props instead (this allows showing not the selected train, and not in the main map) - Adds the proper trains in the TrainPosition that are not related to the selected train - Adapts SimulationWarpedMap, WarpedMap and SimulationResultsMap to these changes --- .../SimulationResultsMap.tsx | 36 ++++--- .../TrainHoverPosition.tsx | 27 ++--- .../SimulationResultsMap/helpers.ts | 9 +- .../SimulationResultsMap/types.ts | 1 + .../Map/WarpedMap/SimulationWarpedMap.scss | 6 ++ .../Map/WarpedMap/SimulationWarpedMap.tsx | 99 +++++++++++-------- front/src/common/Map/WarpedMap/WarpedMap.tsx | 43 ++++++-- 7 files changed, 145 insertions(+), 76 deletions(-) diff --git a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap.tsx b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap.tsx index d285fec5a40..1739f5b27a6 100644 --- a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap.tsx +++ b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { MapLayerMouseEvent } from 'maplibre-gl'; @@ -9,6 +9,7 @@ import { BBox, lineString, point } from '@turf/helpers'; import bbox from '@turf/bbox'; import lineLength from '@turf/length'; import lineSlice from '@turf/line-slice'; +import { keyBy } from 'lodash'; import { updateTimePositionValues } from 'reducers/osrdsimulation/actions'; import { getPresentSimulation, getSelectedTrain } from 'reducers/osrdsimulation/selectors'; @@ -76,6 +77,7 @@ const Map: FC = ({ setExtViewport }) => { (state: RootState) => state.osrdsimulation ); const simulation = useSelector(getPresentSimulation); + const trains = useMemo(() => keyBy(simulation.trains, 'id'), [simulation.trains]); const selectedTrain = useSelector(getSelectedTrain); const terrain3DExaggeration = useSelector(getTerrain3DExaggeration); const [geojsonPath, setGeojsonPath] = useState>(); @@ -226,7 +228,7 @@ const Map: FC = ({ setExtViewport }) => { useEffect(() => { if (timePosition && geojsonPath) { - const trains = getSimulationHoverPositions( + const positions = getSimulationHoverPositions( geojsonPath, simulation, timePosition, @@ -234,8 +236,8 @@ const Map: FC = ({ setExtViewport }) => { selectedTrain?.id, allowancesSettings ); - setTrainHoverPosition(trains.find((train) => train.isSelected)); - setOtherTrainsHoverPosition(trains.filter((train) => !train.isSelected)); + setTrainHoverPosition(positions.find((train) => train.isSelected)); + setOtherTrainsHoverPosition(positions.filter((train) => !train.isSelected)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [timePosition]); @@ -440,23 +442,31 @@ const Map: FC = ({ setExtViewport }) => { /> )} - {geojsonPath && selectedTrainHoverPosition && ( + {geojsonPath && selectedTrainHoverPosition && selectedTrain && ( )} {geojsonPath && - otherTrainsHoverPosition.map((pt) => ( - - ))} + otherTrainsHoverPosition.map((pt) => + trains[pt.trainId] ? ( + + ) : null + )} ); diff --git a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/TrainHoverPosition.tsx b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/TrainHoverPosition.tsx index 148bb0c2572..ba5d6f25a18 100644 --- a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/TrainHoverPosition.tsx +++ b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/TrainHoverPosition.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { Source, Marker } from 'react-map-gl/maplibre'; import along from '@turf/along'; import lineSliceAlong from '@turf/line-slice-along'; @@ -11,10 +10,8 @@ import { Feature, LineString } from 'geojson'; import cx from 'classnames'; import { mapValues, get } from 'lodash'; -import { RootState } from 'reducers'; import { Viewport } from 'reducers/map'; -import { AllowancesSetting } from 'reducers/osrdsimulation/types'; -import { getSelectedTrain } from 'reducers/osrdsimulation/selectors'; +import { AllowancesSetting, AllowancesSettings, Train } from 'reducers/osrdsimulation/types'; import { sec2time } from 'utils/timeManipulation'; import { boundedValue } from 'utils/numbers'; import { getCurrentBearing } from 'utils/geometry'; @@ -176,9 +173,12 @@ function getTrainPieces( interface TrainHoverPositionProps { point: TrainPosition; - isSelectedTrain?: boolean; geojsonPath: Feature; layerOrder: number; + train: Train; + viewport: Viewport; + allowancesSettings?: AllowancesSettings; + isSelectedTrain?: boolean; } const labelShiftFactor = { @@ -187,13 +187,18 @@ const labelShiftFactor = { }; function TrainHoverPosition(props: TrainHoverPositionProps) { - const { point, isSelectedTrain = false, geojsonPath, layerOrder } = props; - const { allowancesSettings } = useSelector((state: RootState) => state.osrdsimulation); - const { viewport } = useSelector((state: RootState) => state.map); - const selectedTrain = useSelector(getSelectedTrain); + const { + point, + isSelectedTrain = false, + geojsonPath, + layerOrder, + viewport, + train, + allowancesSettings, + } = props; - if (selectedTrain && geojsonPath && point.headDistanceAlong && point.tailDistanceAlong) { - const { ecoBlocks } = get(allowancesSettings, selectedTrain.id, {} as AllowancesSetting); + if (train && geojsonPath && point.headDistanceAlong && point.tailDistanceAlong) { + const { ecoBlocks } = get(allowancesSettings, train.id, {} as AllowancesSetting); const fill = getFill(isSelectedTrain as boolean, ecoBlocks); const label = getSpeedAndTimeLabel(isSelectedTrain, ecoBlocks, point); diff --git a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/helpers.ts b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/helpers.ts index 86e986e2229..7ef8ba96ac2 100644 --- a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/helpers.ts +++ b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/helpers.ts @@ -16,6 +16,7 @@ import { TrainPosition } from './types'; export type InterpoledTrain = { name: string; id: number; + trainId: number; head_positions?: PositionSpeedTime; tail_positions?: PositionSpeedTime; speeds?: PositionSpeedTime; @@ -75,6 +76,7 @@ export function getSimulationHoverPositions( const trainLength = Math.abs(headDistanceAlong - tailDistanceAlong); positions.push({ id: 'main-train', + trainId: selectedTrainId, headPosition, tailPosition, headDistanceAlong, @@ -116,6 +118,7 @@ export function getSimulationHoverPositions( ...interpolation, name: train.name, id: idx, + trainId: train.id, }); } } @@ -134,11 +137,15 @@ export function getSimulationHoverPositions( const trainLength = Math.abs(headDistanceAlong - tailDistanceAlong); return { id: `other-train-${train.id}`, + trainId: train.id, headPosition, tailPosition, headDistanceAlong, tailDistanceAlong, - speedTime: positionValues.speed, + speedTime: { + speed: train.speeds?.speed || positionValues.speed.speed, + time: positionValues?.speed?.time, + }, trainLength, }; }) diff --git a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/types.ts b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/types.ts index 5bb90d87631..398518f0fce 100644 --- a/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/types.ts +++ b/front/src/applications/operationalStudies/components/SimulationResults/SimulationResultsMap/types.ts @@ -2,6 +2,7 @@ import { Point, Feature } from 'geojson'; export interface TrainPosition { id: string; + trainId: number; headPosition: Feature; tailPosition: Feature; headDistanceAlong: number; diff --git a/front/src/common/Map/WarpedMap/SimulationWarpedMap.scss b/front/src/common/Map/WarpedMap/SimulationWarpedMap.scss index f4a7df9ac49..c6bb789e67d 100644 --- a/front/src/common/Map/WarpedMap/SimulationWarpedMap.scss +++ b/front/src/common/Map/WarpedMap/SimulationWarpedMap.scss @@ -8,4 +8,10 @@ .warped-map { overflow: hidden; transition: 0.2s; + + .buttons { + position: absolute; + bottom: 2em; + right: 2em; + } } diff --git a/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx index d5ebe0d3fd1..5ae7bf674f9 100644 --- a/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/SimulationWarpedMap.tsx @@ -1,7 +1,8 @@ /* eslint-disable no-console */ import { useSelector } from 'react-redux'; import React, { FC, useEffect, useMemo, useState } from 'react'; -import { clamp, first, isEmpty, isNil, last, mapValues, omitBy } from 'lodash'; +import { clamp, first, isEmpty, isNil, keyBy, last, mapValues, omitBy } from 'lodash'; +import { PiLinkBold, PiLinkBreakBold } from 'react-icons/pi'; import bbox from '@turf/bbox'; import length from '@turf/length'; @@ -26,6 +27,7 @@ import { getSimulationHoverPositions } from 'applications/operationalStudies/com import { clip } from 'utils/mapHelper'; import './SimulationWarpedMap.scss'; +import { Train } from '../../../reducers/osrdsimulation/types'; const TIME_LABEL = 'Warping OSRD and OSM data'; const WIDTH = 300; @@ -65,6 +67,7 @@ const SimulationWarpedMap: FC<{ collapsed?: boolean }> = ({ collapsed }) => { ) as number; const [getPath] = osrdEditoastApi.useLazyGetPathfindingByIdQuery(); const layers = useMemo(() => new Set(['track_sections']), []); + const [mode, setMode] = useState<'manual' | 'auto'>('auto'); // Boundaries handling (ie zoom sync): const chart = useSelector((s: RootState) => s.osrdsimulation.chart); @@ -165,49 +168,51 @@ const SimulationWarpedMap: FC<{ collapsed?: boolean }> = ({ collapsed }) => { }, [itineraryState, state]); // Trains handling: - const trainsState: AsyncMemoState<(TrainPosition & { isSelected?: boolean })[]> = - useAsyncMemo(async () => { - const path = getAsyncMemoData(itineraryState); - if (!path || state.type !== 'dataLoaded') return []; + const trainsIndex = useMemo(() => keyBy(simulation.trains, 'id'), [simulation.trains]); + const trainsPositionsState: AsyncMemoState< + (TrainPosition & { train: Train; isSelected?: boolean })[] + > = useAsyncMemo(async () => { + const path = getAsyncMemoData(itineraryState); + if (!path || state.type !== 'dataLoaded') return []; - const transformedPath = state.transform(path) as typeof path; - return getSimulationHoverPositions( - path, - simulation, - timePosition, - positionValues, - selectedTrain?.id, - allowancesSettings - ).map((train) => { - const transformedTrain = { ...train }; - const pathLength = length(path); - const transformedPathLength = length(transformedPath); - - // Transform positions: - transformedTrain.headPosition = state.transform( - train.headPosition - ) as TrainPosition['headPosition']; - transformedTrain.tailPosition = state.transform( - train.tailPosition - ) as TrainPosition['tailPosition']; - - // Interpolate positions: - transformedTrain.headDistanceAlong = - (train.headDistanceAlong / pathLength) * transformedPathLength; - transformedTrain.tailDistanceAlong = - (train.tailDistanceAlong / pathLength) * transformedPathLength; - - return transformedTrain; - }); - }, [ - itineraryState, + const transformedPath = state.transform(path) as typeof path; + return getSimulationHoverPositions( + path, simulation, timePosition, positionValues, - selectedTrain, - allowancesSettings, - state, - ]); + selectedTrain?.id, + allowancesSettings + ).map((position) => { + const transformedTrain = { ...position }; + const pathLength = length(path); + const transformedPathLength = length(transformedPath); + + // Transform positions: + transformedTrain.headPosition = state.transform( + position.headPosition + ) as TrainPosition['headPosition']; + transformedTrain.tailPosition = state.transform( + position.tailPosition + ) as TrainPosition['tailPosition']; + + // Interpolate positions: + transformedTrain.headDistanceAlong = + (position.headDistanceAlong / pathLength) * transformedPathLength; + transformedTrain.tailDistanceAlong = + (position.tailDistanceAlong / pathLength) * transformedPathLength; + + return { ...transformedTrain, train: trainsIndex[position.trainId] }; + }); + }, [ + itineraryState, + simulation, + timePosition, + positionValues, + selectedTrain, + allowancesSettings, + state, + ]); /** * This effect handles loading the simulation path, and retrieve the warping function: @@ -295,9 +300,19 @@ const SimulationWarpedMap: FC<{ collapsed?: boolean }> = ({ collapsed }) => { osrdData={state.osrd} osmData={state.osm} itinerary={warpedItinerary} - trains={getAsyncMemoData(trainsState) || undefined} - boundingBox={syncedBoundingBox} + trainsPositions={getAsyncMemoData(trainsPositionsState) || undefined} + boundingBox={mode === 'auto' ? syncedBoundingBox : undefined} + allowancesSettings={allowancesSettings} /> +
+ +
)}
diff --git a/front/src/common/Map/WarpedMap/WarpedMap.tsx b/front/src/common/Map/WarpedMap/WarpedMap.tsx index d7ad00e6845..9965cd9c483 100644 --- a/front/src/common/Map/WarpedMap/WarpedMap.tsx +++ b/front/src/common/Map/WarpedMap/WarpedMap.tsx @@ -1,11 +1,12 @@ /* eslint-disable no-console */ import { useSelector } from 'react-redux'; -import React, { FC, useEffect, useMemo, useState } from 'react'; +import React, { FC, PropsWithChildren, useEffect, useMemo, useState } from 'react'; import { groupBy, map, omit } from 'lodash'; import { featureCollection } from '@turf/helpers'; import { BBox2d } from '@turf/helpers/dist/js/lib/geojson'; import { Feature, FeatureCollection, LineString } from 'geojson'; import ReactMapGL, { Layer, MapRef, Source } from 'react-map-gl/maplibre'; +import { LngLatBoundsLike } from 'maplibre-gl'; import { RootState } from 'reducers'; import { LAYER_GROUPS_ORDER, LAYERS } from 'config/layerOrder'; @@ -21,7 +22,8 @@ import { EditorSource, SourcesDefinitionsIndex } from 'common/Map/Layers/GeoJSON import OrderedLayer, { OrderedLayerProps } from 'common/Map/Layers/OrderedLayer'; import { genLayerProps } from 'common/Map/Layers/OSM'; import osmBlankStyle from 'common/Map/Layers/osmBlankStyle'; -import { LngLatBoundsLike } from 'maplibre-gl'; +import { Viewport } from '../../../reducers/map'; +import { AllowancesSettings, Train } from '../../../reducers/osrdsimulation/types'; const OSRD_LAYER_ORDERS: Record = { buffer_stops: LAYER_GROUPS_ORDER[LAYERS.BUFFER_STOPS.GROUP], @@ -49,12 +51,23 @@ const WarpedMap: FC<{ // Data to display on the map (must be transformed already): osrdData: Partial>; osmData: Record; - trains?: (TrainPosition & { isSelected?: boolean })[]; + trainsPositions?: (TrainPosition & { train: Train; isSelected?: boolean })[]; itinerary?: Feature; -}> = ({ bbox, osrdLayers, osrdData, osmData, trains, itinerary, boundingBox }) => { + allowancesSettings?: AllowancesSettings; +}> = ({ + bbox, + osrdLayers, + osrdData, + osmData, + trainsPositions, + itinerary, + boundingBox, + allowancesSettings, +}) => { const prefix = 'warped/'; const [mapRef, setMapRef] = useState(null); const { mapStyle, layersSettings, showIGNBDORTHO } = useSelector((s: RootState) => s.map); + const [viewport, setViewport] = useState(null); // Main OSM and OSRD data: const layerContext: LayerContext = useMemo( @@ -110,7 +123,7 @@ const WarpedMap: FC<{ }, 0); }, [mapRef, bbox, boundingBox]); - // This effect handles the map initial position: + // This effect handles bounding box updates: useEffect(() => { if (!mapRef || !boundingBox) return; @@ -123,6 +136,14 @@ const WarpedMap: FC<{ ref={setMapRef} mapStyle={osmBlankStyle} style={{ width: '100%', height: '100%' }} + onMove={(e) => { + setViewport({ + ...e.viewState, + transformRequest: undefined, + width: e.target.getContainer().offsetWidth, + height: e.target.getContainer().offsetHeight, + }); + }} // Viewport specifics: dragPan={!boundingBox} doubleClickZoom={!boundingBox} @@ -159,13 +180,17 @@ const WarpedMap: FC<{ /> )} {itinerary && - trains?.map((train) => ( + viewport && + trainsPositions?.map((position) => ( ))} From be12bf9e2c070c94e0256fd6571f3a3ea60be7b2 Mon Sep 17 00:00:00 2001 From: Alexis Jacomy Date: Thu, 14 Sep 2023 11:31:12 +0200 Subject: [PATCH 14/15] simulation: handles warped map review feedbacks Most of the updates are: - Adds various comments - Clarifies getWarping output naming - Fixes some comment blocks syntax --- .../SpaceTimeChart/SpaceTimeChart.jsx | 1 + .../views/SimulationResults.tsx | 2 +- front/src/common/Map/Layers/GeoJSONs.tsx | 93 +++++++++--------- front/src/common/Map/Layers/OSM.tsx | 10 +- .../Map/WarpedMap/SimulationWarpedMap.scss | 28 +++--- .../Map/WarpedMap/SimulationWarpedMap.tsx | 95 ++++++++++--------- front/src/common/Map/WarpedMap/WarpedMap.tsx | 23 +++-- front/src/common/Map/WarpedMap/core/grids.tsx | 61 ++++++++---- .../src/common/Map/WarpedMap/core/helpers.ts | 18 ++-- .../src/common/Map/WarpedMap/core/quadtree.ts | 2 +- front/src/common/Map/WarpedMap/getWarping.ts | 20 ++-- 11 files changed, 198 insertions(+), 155 deletions(-) diff --git a/front/src/applications/operationalStudies/components/SimulationResults/SpaceTimeChart/SpaceTimeChart.jsx b/front/src/applications/operationalStudies/components/SimulationResults/SpaceTimeChart/SpaceTimeChart.jsx index 2ec6adbb334..e8a53f101b2 100644 --- a/front/src/applications/operationalStudies/components/SimulationResults/SpaceTimeChart/SpaceTimeChart.jsx +++ b/front/src/applications/operationalStudies/components/SimulationResults/SpaceTimeChart/SpaceTimeChart.jsx @@ -227,6 +227,7 @@ export default function SpaceTimeChart(props) { moveGridLinesOnMouseMove(); } + // Required to sync the camera in SimulationWarpedMap: dispatchUpdateChart(chart); // eslint-disable-next-line react-hooks/exhaustive-deps }, [chart]); diff --git a/front/src/applications/operationalStudies/views/SimulationResults.tsx b/front/src/applications/operationalStudies/views/SimulationResults.tsx index acd45941f2a..ba48ced5bd2 100644 --- a/front/src/applications/operationalStudies/views/SimulationResults.tsx +++ b/front/src/applications/operationalStudies/views/SimulationResults.tsx @@ -156,7 +156,7 @@ export default function SimulationResults({ isDisplayed, collapsedTimetable, inf {/* SIMULATION : SPACE TIME CHART */} -
+