From 3c6565b4efaeeaf7c089ec3af3854255f5e48223 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Fri, 29 Nov 2024 14:31:38 +0100 Subject: [PATCH] front: nge saving node's positions Signed-off-by: Benoit Simard --- .../MacroEditor/MacroEditorState.ts | 191 ++++++ .../components/MacroEditor/ngeToOsrd.ts | 147 +++- .../components/MacroEditor/nodeStore.ts | 57 -- .../components/MacroEditor/osrdToNge.ts | 645 ++++++++++-------- .../components/Scenario/ScenarioContent.tsx | 17 +- 5 files changed, 692 insertions(+), 365 deletions(-) create mode 100644 front/src/applications/operationalStudies/components/MacroEditor/MacroEditorState.ts delete mode 100644 front/src/applications/operationalStudies/components/MacroEditor/nodeStore.ts diff --git a/front/src/applications/operationalStudies/components/MacroEditor/MacroEditorState.ts b/front/src/applications/operationalStudies/components/MacroEditor/MacroEditorState.ts new file mode 100644 index 00000000000..2fe947e8fa4 --- /dev/null +++ b/front/src/applications/operationalStudies/components/MacroEditor/MacroEditorState.ts @@ -0,0 +1,191 @@ +import { sortBy } from 'lodash'; + +import type { + MacroNodeResponse, + ScenarioResponse, + SearchResultItemOperationalPoint, + TrainScheduleResult, +} from 'common/api/osrdEditoastApi'; + +export type NodeIndex = { + node: MacroNodeResponse & { geocoord?: { lat: number; lng: number } }; + saved: boolean; +}; + +export default class MacroEditorState { + /** + * Storing nodes by path item key + * It's the main storage for node. + * The saved attribut is to know if the data comes from the API + * If the value is a string, it's a key redirection + */ + nodesByPathKey: Record; + + /** + * We keep a dictionnary of id/key to be able to find a node by its id + */ + nodesIdToKey: Record; + + /** + * Storing labels + */ + labels: Set; + + /** + * NGE resource + */ + ngeResource = { id: 1, capacity: 0 }; + + /** + * Default constructor + */ + constructor( + public readonly scenario: ScenarioResponse, + public trainSchedules: TrainScheduleResult[] + ) { + // Empty + this.labels = new Set([]); + this.nodesIdToKey = {}; + this.nodesByPathKey = {}; + this.ngeResource = { id: 1, capacity: trainSchedules.length }; + } + + /** + * Check if we have duplicates + * Ex: one key is trigram and an other is uic (with the same trigram), we should keep trigram + * What we do : + * - Make a list of key,trigram + * - aggregate on trigram to build a list of key + * - filter if the array is of size 1 (ie, no dedup todo) + * - sort the keys by priority + * - add redirection in the nodesByPathKey + */ + dedupNodes(): void { + const trigramAggreg = Object.entries(this.nodesByPathKey) + .filter(([_, value]) => typeof value !== 'string' && value.node.trigram) + .map(([key, value]) => ({ key, trigram: (value as NodeIndex).node.trigram! })) + .reduce( + (acc, curr) => { + acc[curr.trigram] = [...(acc[curr.trigram] || []), curr.key]; + return acc; + }, + {} as Record + ); + + for (const trig of Object.keys(trigramAggreg)) { + if (trigramAggreg[trig].length < 2) { + delete trigramAggreg[trig]; + } + trigramAggreg[trig] = sortBy(trigramAggreg[trig], (key) => { + if (key.startsWith('op_id:')) return 1; + if (key.startsWith('trigram:')) return 2; + if (key.startsWith('uic:')) return 3; + // default + return 4; + }); + } + + Object.values(trigramAggreg).forEach((mergeList) => { + const mainNodeKey = mergeList[0]; + mergeList.slice(1).forEach((key) => { + this.nodesByPathKey[key] = mainNodeKey; + }); + }); + } + + /** + * Store and index the node. + */ + indexNode(node: MacroNodeResponse, saved = false) { + // Remove in the id index, its previous value + const prevNode = this.getNodeByKey(node.path_item_key); + if (prevNode && typeof prevNode !== 'string') { + const prevId = prevNode.node.id; + delete this.nodesIdToKey[prevId]; + } + + // Index + this.nodesByPathKey[node.path_item_key] = { node, saved }; + this.nodesIdToKey[node.id] = node.path_item_key; + node.labels.forEach((l) => { + if (l) this.labels.add(l); + }); + } + + /** + * Update node's data by its key + */ + updateNodeDataByKey(key: string, data: Partial, saved?: boolean) { + const indexedNode = this.getNodeByKey(key); + if (indexedNode) { + this.indexNode( + { ...indexedNode.node, ...data }, + saved === undefined ? indexedNode.saved : saved + ); + } + } + + /** + * Delete a node by its key + */ + deleteNodeByKey(key: string) { + const indexedNode = this.getNodeByKey(key); + if (indexedNode) { + delete this.nodesIdToKey[indexedNode.node.id]; + delete this.nodesByPathKey[key]; + } + } + + /** + * Get a node by its key. + */ + getNodeByKey(key: string): NodeIndex | null { + let result: NodeIndex | null = null; + let currentKey: string | null = key; + while (currentKey !== null) { + const found: string | NodeIndex | undefined = this.nodesByPathKey[currentKey]; + if (typeof found === 'string') { + currentKey = found; + } else { + currentKey = null; + result = found || null; + } + } + return result; + } + + /** + * Get a node by its id. + */ + getNodeById(id: number) { + const key = this.nodesIdToKey[id]; + return this.getNodeByKey(key); + } + + /** + * Given an path step, returns its pathKey + */ + static getPathKey(item: TrainScheduleResult['path'][0]): string { + if ('trigram' in item) + return `trigram:${item.trigram}${item.secondary_code ? `/${item.secondary_code}` : ''}`; + if ('operational_point' in item) return `op_id:${item.operational_point}`; + if ('uic' in item) + return `uic:${item.uic}${item.secondary_code ? `/${item.secondary_code}` : ''}`; + + return `track_offset:${item.track}+${item.offset}`; + } + + /** + * Given a search result item, returns all possible pathKeys, ordered by weight. + */ + static getPathKeys(item: SearchResultItemOperationalPoint): string[] { + const result = []; + result.push(`op_id:${item.obj_id}`); + result.push(`trigram:${item.trigram}${'ch' in item ? `/${item.ch}` : ''}`); + result.push(`uic:${item.uic}${'ch' in item ? `/${item.ch}` : ''}`); + item.track_sections.forEach((ts) => { + result.push(`track_offset:${ts.track}+${ts.position}`); + }); + return result; + } +} diff --git a/front/src/applications/operationalStudies/components/MacroEditor/ngeToOsrd.ts b/front/src/applications/operationalStudies/components/MacroEditor/ngeToOsrd.ts index e09ffd1d9d4..18f16f4824c 100644 --- a/front/src/applications/operationalStudies/components/MacroEditor/ngeToOsrd.ts +++ b/front/src/applications/operationalStudies/components/MacroEditor/ngeToOsrd.ts @@ -2,6 +2,7 @@ import { compact, uniq } from 'lodash'; import { osrdEditoastApi, + type MacroNodeResponse, type SearchResultItemOperationalPoint, type TrainScheduleBase, type TrainScheduleResult, @@ -10,7 +11,7 @@ import type { AppDispatch } from 'store'; import { formatToIsoDate } from 'utils/date'; import { calculateTimeDifferenceInSeconds, formatDurationAsISO8601 } from 'utils/timeManipulation'; -import nodeStore from './nodeStore'; +import type MacroEditorState from './MacroEditorState'; import { DEFAULT_TRAINRUN_FREQUENCIES, DEFAULT_TRAINRUN_FREQUENCY } from './osrdToNge'; import type { NetzgrafikDto, @@ -375,28 +376,152 @@ const handleTrainrunOperation = async ({ } }; -const handleUpdateNode = (timeTableId: number, node: NodeDto) => { - const { betriebspunktName: trigram, positionX, positionY } = node; - nodeStore.set(timeTableId, { trigram, positionX, positionY }); +const apiCreateNode = async ( + state: MacroEditorState, + dispatch: AppDispatch, + node: Omit +) => { + try { + const createPromise = dispatch( + osrdEditoastApi.endpoints.postProjectsByProjectIdStudiesAndStudyIdScenariosScenarioIdMacroNodes.initiate( + { + projectId: state.scenario.project.id, + studyId: state.scenario.study_id, + scenarioId: state.scenario.id, + macroNodeForm: node, + } + ) + ); + const newNode = await createPromise.unwrap(); + state.indexNode(newNode, true); + } catch (e) { + console.error(e); + } +}; + +const apiUpdateNode = async ( + state: MacroEditorState, + dispatch: AppDispatch, + node: MacroNodeResponse +) => { + try { + await dispatch( + osrdEditoastApi.endpoints.putProjectsByProjectIdStudiesAndStudyIdScenariosScenarioIdMacroNodesNodeId.initiate( + { + projectId: state.scenario.project.id, + studyId: state.scenario.study_id, + scenarioId: state.scenario.id, + nodeId: node.id, + macroNodeForm: node, + } + ) + ); + state.indexNode(node, true); + } catch (e) { + console.error(e); + } +}; + +const apiDeleteNode = async ( + state: MacroEditorState, + dispatch: AppDispatch, + node: MacroNodeResponse +) => { + try { + await dispatch( + osrdEditoastApi.endpoints.deleteProjectsByProjectIdStudiesAndStudyIdScenariosScenarioIdMacroNodesNodeId.initiate( + { + projectId: state.scenario.project.id, + studyId: state.scenario.study_id, + scenarioId: state.scenario.id, + nodeId: node.id, + } + ) + ); + state.deleteNodeByKey(node.path_item_key); + } catch (e) { + console.error(e); + } }; -const handleNodeOperation = ({ +/** + * Cast a NGE node to a node. + */ +const castNgeNode = ( + node: NetzgrafikDto['nodes'][0], + labels: NetzgrafikDto['labels'] +): Omit => ({ + id: node.id, + trigram: node.betriebspunktName, + full_name: node.fullName, + connection_time: node.connectionTime, + position_x: node.positionX, + position_y: node.positionY, + labels: node.labelIds + .map((id) => { + const ngeLabel = labels.find((e) => e.id === id); + if (ngeLabel) return ngeLabel.label; + return null; + }) + .filter((n) => n !== null) as string[], +}); + +const handleNodeOperation = async ({ + state, type, node, - timeTableId, + netzgrafikDto, + dispatch, }: { + state: MacroEditorState; type: NGEEvent['type']; node: NodeDto; - timeTableId: number; + netzgrafikDto: NetzgrafikDto; + dispatch: AppDispatch; }) => { + const indexNode = state.getNodeById(node.id); switch (type) { case 'create': case 'update': { - handleUpdateNode(timeTableId, node); + if (indexNode) { + if (indexNode.saved) { + // Update the key if trigram has changed and key is based on it + let nodeKey = indexNode.node.path_item_key; + if (nodeKey.startsWith('trigram:') && indexNode.node.trigram !== node.betriebspunktName) { + nodeKey = `trigram:${node.betriebspunktName}`; + } + await apiUpdateNode(state, dispatch, { + ...indexNode.node, + ...castNgeNode(node, netzgrafikDto.labels), + id: indexNode.node.id, + path_item_key: nodeKey, + }); + } else { + const newNode = { + ...indexNode.node, + ...castNgeNode(node, netzgrafikDto.labels), + }; + // Create the node + await apiCreateNode(state, dispatch, newNode); + // keep track of the ID given by NGE + state.nodesIdToKey[node.id] = newNode.path_item_key; + } + } else { + // It's an unknown node, we need to create it in the db + // We assume that `betriebspunktName` is a trigram + const key = `trigram:${node.betriebspunktName}`; + // Create the node + await apiCreateNode(state, dispatch, { + ...castNgeNode(node, netzgrafikDto.labels), + path_item_key: key, + }); + // keep track of the ID given by NGE + state.nodesIdToKey[node.id] = key; + } break; } case 'delete': { - nodeStore.delete(timeTableId, node.betriebspunktName); + if (indexNode) await apiDeleteNode(state, dispatch, indexNode.node); break; } default: @@ -445,6 +570,7 @@ const handleLabelOperation = async ({ const handleOperation = async ({ event, dispatch, + state, infraId, timeTableId, netzgrafikDto, @@ -453,6 +579,7 @@ const handleOperation = async ({ }: { event: NGEEvent; dispatch: AppDispatch; + state: MacroEditorState; infraId: number; timeTableId: number; netzgrafikDto: NetzgrafikDto; @@ -462,7 +589,7 @@ const handleOperation = async ({ const { type } = event; switch (event.objectType) { case 'node': - handleNodeOperation({ type, node: event.node, timeTableId }); + await handleNodeOperation({ state, dispatch, netzgrafikDto, type, node: event.node }); break; case 'trainrun': { await handleTrainrunOperation({ diff --git a/front/src/applications/operationalStudies/components/MacroEditor/nodeStore.ts b/front/src/applications/operationalStudies/components/MacroEditor/nodeStore.ts deleted file mode 100644 index ad944193624..00000000000 --- a/front/src/applications/operationalStudies/components/MacroEditor/nodeStore.ts +++ /dev/null @@ -1,57 +0,0 @@ -type Node = { - trigram: string; - positionX: number; - positionY: number; -}; - -const NODES_LOCAL_STORAGE_KEY = 'macro_nodes'; - -let nodesCache: Map | null; - -const loadNodes = () => { - if (nodesCache) return nodesCache; - - let entries = []; - const rawNodes = localStorage.getItem(NODES_LOCAL_STORAGE_KEY); - if (rawNodes) { - const parsedNodes = JSON.parse(rawNodes); - if (Array.isArray(parsedNodes)) { - entries = parsedNodes; - } else { - console.error( - `Error loading nodes from localStorage: expected an array, but received: '${typeof parsedNodes}' type.` - ); - } - } - - nodesCache = new Map(entries); - return nodesCache; -}; - -const saveNodes = (nodes: Map) => { - const entries = [...nodes.entries()]; - localStorage.setItem(NODES_LOCAL_STORAGE_KEY, JSON.stringify(entries)); -}; - -const getNodeKey = (timetableId: number, trigram: string) => `${timetableId}:${trigram}`; - -const nodeStore = { - get(timetableId: number, trigram: string) { - return loadNodes().get(getNodeKey(timetableId, trigram)); - }, - set(timetableId: number, node: Node) { - // TODO: save position for nodes without trigram too - if (!node.trigram) return; - const nodes = loadNodes(); - nodes.set(getNodeKey(timetableId, node.trigram), node); - saveNodes(nodes); - }, - delete(timetableId: number, trigram: string) { - if (!trigram) return; - const nodes = loadNodes(); - nodes.delete(getNodeKey(timetableId, trigram)); - saveNodes(nodes); - }, -}; - -export default nodeStore; diff --git a/front/src/applications/operationalStudies/components/MacroEditor/osrdToNge.ts b/front/src/applications/operationalStudies/components/MacroEditor/osrdToNge.ts index de9c6db3a5e..94c2333a95f 100644 --- a/front/src/applications/operationalStudies/components/MacroEditor/osrdToNge.ts +++ b/front/src/applications/operationalStudies/components/MacroEditor/osrdToNge.ts @@ -1,32 +1,28 @@ -import { compact } from 'lodash'; +import { isNil, uniqBy } from 'lodash'; -import { osrdEditoastApi } from 'common/api/osrdEditoastApi'; import type { - SearchResultItemOperationalPoint, + MacroNodeResponse, SearchPayload, SearchQuery, - TrainScheduleResult, + SearchResultItemOperationalPoint, } from 'common/api/osrdEditoastApi'; +import { osrdEditoastApi } from 'common/api/osrdEditoastApi'; import type { AppDispatch } from 'store'; -import nodeStore from './nodeStore'; -import { findOpFromPathItem, addDurationToDate } from './utils'; -import type { - NodeDto, - PortDto, - TimeLockDto, - TrainrunDto, - TrainrunSectionDto, - TrainrunCategory, - TrainrunFrequency, - TrainrunTimeCategory, - NetzgrafikDto, - LabelDto, - LabelGroupDto, +import MacroEditorState, { type NodeIndex } from './MacroEditorState'; +import { addDurationToDate } from './utils'; +import { + type PortDto, + type TimeLockDto, + type TrainrunSectionDto, + type TrainrunCategory, + type TrainrunTimeCategory, + type TrainrunFrequency, + type NetzgrafikDto, + type LabelGroupDto, + PortAlignment, } from '../NGE/types'; -import { PortAlignment } from '../NGE/types'; -// TODO: make this optional in NGE since it's SBB-specific const TRAINRUN_CATEGORY_HALTEZEITEN = { HaltezeitIPV: { haltezeit: 0, no_halt: false }, HaltezeitA: { haltezeit: 0, no_halt: false }, @@ -129,11 +125,8 @@ const DEFAULT_TIME_LOCK: TimeLockDto = { * Build a search query to fetch all operational points from their UICs, * trigrams and IDs. */ -const buildOpQuery = ( - infraId: number, - trainSchedules: TrainScheduleResult[] -): SearchPayload | null => { - const pathItems = trainSchedules.map((schedule) => schedule.path).flat(); +const buildOpQuery = (state: MacroEditorState): SearchPayload | null => { + const pathItems = state.trainSchedules.flatMap((train) => train.path); const pathItemQueries = []; const pathItemSet = new Set(); for (const item of pathItems) { @@ -170,7 +163,7 @@ const buildOpQuery = ( return { object: 'operationalpoint', - query: ['and', ['=', ['infra_id'], infraId], ['or', ...pathItemQueries]], + query: ['and', ['=', ['infra_id'], state.scenario.infra_id], ['or', ...pathItemQueries]], }; }; @@ -178,19 +171,26 @@ const buildOpQuery = ( * Execute the search payload and collect all result pages. */ const executeSearch = async ( - searchPayload: SearchPayload, + state: MacroEditorState, dispatch: AppDispatch ): Promise => { + const searchPayload = buildOpQuery(state); + if (!searchPayload) { + return []; + } const pageSize = 100; let done = false; const searchResults: SearchResultItemOperationalPoint[] = []; for (let page = 1; !done; page += 1) { const searchPromise = dispatch( - osrdEditoastApi.endpoints.postSearch.initiate({ - page, - pageSize, - searchPayload, - }) + osrdEditoastApi.endpoints.postSearch.initiate( + { + page, + pageSize, + searchPayload, + }, + { track: false } + ) ); const results = (await searchPromise.unwrap()) as SearchResultItemOperationalPoint[]; searchResults.push(...results); @@ -200,152 +200,222 @@ const executeSearch = async ( }; /** - * Convert geographic coordinates (latitude/longitude) into screen coordinates - * (pixels). + * Apply a layout on nodes and save the new position. + * Nodes that are saved are fixed. */ -const convertGeoCoords = (nodes: NodeDto[]) => { - const xCoords = nodes.map((node) => node.positionX); - const yCoords = nodes.map((node) => node.positionY); +const applyLayout = (state: MacroEditorState) => { + const indexedNodes = uniqBy( + state.trainSchedules.flatMap((ts) => ts.path), + MacroEditorState.getPathKey + ).map((pathItem) => { + const key = MacroEditorState.getPathKey(pathItem); + return state.getNodeByKey(key)!; + }); + + const geoNodes = indexedNodes.filter((n) => n.node.geocoord); + const xCoords = geoNodes.map((n) => n.node.geocoord!.lng); + const yCoords = geoNodes.map((n) => n.node.geocoord!.lat); const minX = Math.min(...xCoords); const minY = Math.min(...yCoords); const maxX = Math.max(...xCoords); const maxY = Math.max(...yCoords); const width = maxX - minX; const height = maxY - minY; + // TODO: grab NGE component size const scaleX = 800; const scaleY = 500; const padding = 0.1; - for (const node of nodes) { - const normalizedX = (node.positionX - minX) / (width || 1); - const normalizedY = 1 - (node.positionY - minY) / (height || 1); - const paddedX = normalizedX * (1 - 2 * padding) + padding; - const paddedY = normalizedY * (1 - 2 * padding) + padding; - node.positionX = scaleX * paddedX; - node.positionY = scaleY * paddedY; + for (const n of indexedNodes) { + if (!n.saved && n.node.geocoord !== undefined) { + const normalizedX = (n.node.geocoord.lng - minX) / (width || 1); + const normalizedY = 1 - (n.node.geocoord.lat - minY) / (height || 1); + const paddedX = normalizedX * (1 - 2 * padding) + padding; + const paddedY = normalizedY * (1 - 2 * padding) + padding; + state.updateNodeDataByKey(n.node.path_item_key, { + position_x: Math.round(scaleX * paddedX), + position_y: Math.round(scaleY * paddedY), + }); + } } }; -const importTimetable = async ( - infraId: number, - timetableId: number, +/** + * Get nodes of the scenario that are saved in the DB. + */ +const apiGetSavedNodes = async ( + state: MacroEditorState, dispatch: AppDispatch -): Promise => { - const timetablePromise = dispatch( - osrdEditoastApi.endpoints.getTimetableById.initiate({ id: timetableId }) - ); - const { train_ids } = await timetablePromise.unwrap(); - - const trainSchedulesPromise = dispatch( - osrdEditoastApi.endpoints.postTrainSchedule.initiate({ - body: { ids: train_ids }, - }) - ); - const trainSchedules = (await trainSchedulesPromise.unwrap()).filter( - (trainSchedule) => trainSchedule.path.length >= 2 - ); +): Promise => { + const pageSize = 100; + let page = 1; + let reachEnd = false; + const result: MacroNodeResponse[] = []; + while (!reachEnd) { + const promise = dispatch( + osrdEditoastApi.endpoints.getProjectsByProjectIdStudiesAndStudyIdScenariosScenarioIdMacroNodes.initiate( + { + projectId: state.scenario.project.id, + studyId: state.scenario.study_id, + scenarioId: state.scenario.id, + pageSize, + page, + }, + { forceRefetch: true, subscribe: false } + ) + ); + // need to unsubscribe on get call to avoid cache issue + const { data } = await promise; + if (data) result.push(...data.results); + reachEnd = isNil(data?.next); + page += 1; + } + return result; +}; - const searchPayload = buildOpQuery(infraId, trainSchedules); - const searchResults = searchPayload ? await executeSearch(searchPayload, dispatch) : []; +const apiDeleteNode = async ( + state: MacroEditorState, + dispatch: AppDispatch, + node: MacroNodeResponse +) => { + try { + await dispatch( + osrdEditoastApi.endpoints.deleteProjectsByProjectIdStudiesAndStudyIdScenariosScenarioIdMacroNodesNodeId.initiate( + { + projectId: state.scenario.project.id, + studyId: state.scenario.study_id, + scenarioId: state.scenario.id, + nodeId: node.id, + } + ) + ); + } catch (e) { + console.error(e); + } +}; - const resource = { - id: 1, - capacity: trainSchedules.length, +/** + * Cast a node into NGE format. + */ +const castNodeToNge = ( + state: MacroEditorState, + node: NodeIndex['node'] +): NetzgrafikDto['nodes'][0] => { + const labelsList = Array.from(state.labels); + return { + id: node.id, + betriebspunktName: node.trigram || '', + fullName: node.full_name || '', + positionX: node.position_x, + positionY: node.position_y, + ports: [], + transitions: [], + connections: [], + resourceId: state.ngeResource.id, + perronkanten: 10, + connectionTime: node.connection_time, + trainrunCategoryHaltezeiten: TRAINRUN_CATEGORY_HALTEZEITEN, + symmetryAxis: 0, + warnings: [], + labelIds: (node.labels || []).map((l) => labelsList.findIndex((e) => e === l)), }; +}; - const nodes: NodeDto[] = []; - const nodesById = new Map(); - let nodeId = 0; - let nodePositionX = 0; - const createNode = ({ - trigram, - fullName, - positionX, - positionY, - }: { - trigram?: string; - fullName?: string; - positionX?: number; - positionY?: number; - }): NodeDto => { - if (positionX === undefined) { - positionX = nodePositionX; - nodePositionX += 200; - } - - const node = { - id: nodeId, - betriebspunktName: trigram || '', - fullName: fullName || '', - positionX, - positionY: positionY || 0, - ports: [], - transitions: [], - connections: [], - resourceId: resource.id, - perronkanten: 10, - connectionTime: 0, - trainrunCategoryHaltezeiten: TRAINRUN_CATEGORY_HALTEZEITEN, - symmetryAxis: 0, - warnings: [], - labelIds: [], +/** + * Load & index the data of the train schedule for the given scenario + */ +export const loadAndIndexNge = async ( + state: MacroEditorState, + dispatch: AppDispatch +): Promise => { + // Load path items + let nbNodesIndexed = 0; + state.trainSchedules + .flatMap((train) => train.path) + .forEach((pathItem, index) => { + const key = MacroEditorState.getPathKey(pathItem); + if (!state.getNodeByKey(key)) { + const macroNode = { + // negative is just to be sure that the id is not already taken + // by a node saved in the DB + id: index * -1, + path_item_key: key, + connection_time: 0, + labels: [], + // we put the nodes on a grid + position_x: (nbNodesIndexed % 8) * 200, + position_y: Math.trunc(nbNodesIndexed / 8), + }; + state.indexNode(macroNode); + nbNodesIndexed += 1; + } + }); + + // Enhance nodes by calling the search API + const searchResults = await executeSearch(state, dispatch); + searchResults.forEach((searchResult) => { + const macroNode = { + fullName: searchResult.name, + trigram: searchResult.trigram + (searchResult.ch ? `/${searchResult.ch}` : ''), + geocoord: { + lng: searchResult.geographic.coordinates[0], + lat: searchResult.geographic.coordinates[1], + }, }; + MacroEditorState.getPathKeys(searchResult).forEach((pathKey) => { + state.updateNodeDataByKey(pathKey, macroNode); + }); + }); - nodeId += 1; - nodes.push(node); - nodesById.set(node.id, node); + // Load saved nodes and update the indexed nodes + // If a saved node is not present in the train schedule, we delete it + // this can happen if we delete a TS on which a node was saved + const savedNodes = await apiGetSavedNodes(state, dispatch); + await Promise.all( + savedNodes.map(async (n) => { + if (state.getNodeByKey(n.path_item_key)) state.updateNodeDataByKey(n.path_item_key, n, true); + else await apiDeleteNode(state, dispatch, n); + }) + ); - return node; - }; + // Dedup nodes + state.dedupNodes(); - const DTOLabels: LabelDto[] = []; - // Create one NGE train run per OSRD train schedule - let labelId = 0; - const trainruns: TrainrunDto[] = trainSchedules.map((trainSchedule) => { - const formatedLabels: (LabelDto | undefined)[] = []; - let trainrunFrequency: TrainrunFrequency | undefined; - if (trainSchedule.labels) { - trainSchedule.labels.forEach((label) => { - // Frenquency labels management from OSRD (labels for moment manage 'frequency::30' and 'frequency::120') - if (label.includes('frequency')) { - const frequency = parseInt(label.split('::')[1], 10); - if (!trainrunFrequency || trainrunFrequency.frequency > frequency) { - const trainrunFrequencyFind = DEFAULT_TRAINRUN_FREQUENCIES.find( - (freq) => freq.frequency === frequency - ); - trainrunFrequency = trainrunFrequencyFind || trainrunFrequency; - } - return; - } - const DTOLabel = DTOLabels.find((DTOlabel) => DTOlabel.label === label); - if (DTOLabel) { - formatedLabels.push(DTOLabel); - } else { - const newDTOLabel: LabelDto = { - id: labelId, - label, - labelGroupId: DEFAULT_LABEL_GROUP.id, - labelRef: 'Trainrun', - }; - DTOLabels.push(newDTOLabel); - labelId += 1; - formatedLabels.push(newDTOLabel); - } - }); - } + // Index trainschedule labels + state.trainSchedules.forEach((ts) => { + ts.labels?.forEach((l) => { + state.labels.add(l); + }); + }); - return { + // Now that we have all nodes, we apply a layout + applyLayout(state); +}; + +/** + * Translate the train schedule in NGE "trainruns". + */ +const getNgeTrainruns = (state: MacroEditorState) => + state.trainSchedules + .filter((trainSchedule) => trainSchedule.path.length >= 2) + .map((trainSchedule) => ({ id: trainSchedule.id, name: trainSchedule.train_name, categoryId: DEFAULT_TRAINRUN_CATEGORY.id, - frequencyId: trainrunFrequency?.id || DEFAULT_TRAINRUN_FREQUENCY.id, + frequencyId: DEFAULT_TRAINRUN_FREQUENCY.id, trainrunTimeCategoryId: DEFAULT_TRAINRUN_TIME_CATEGORY.id, - labelIds: compact(formatedLabels).map((label) => label.id), - }; - }); + labelIds: (trainSchedule.labels || []).map((l) => + Array.from(state.labels).findIndex((e) => e === l) + ), + })); - let portId = 0; +/** + * Translate the train schedule in NGE "trainrunSection" & "nodes". + * It is needed to return the nodes as well, because we add ports & transitions on them + */ +const getNgeTrainrunSectionsWithNodes = (state: MacroEditorState) => { + let portId = 1; const createPort = (trainrunSectionId: number) => { const port = { id: portId, @@ -357,7 +427,7 @@ const importTimetable = async ( return port; }; - let transitionId = 0; + let transitionId = 1; const createTransition = (port1Id: number, port2Id: number) => { const transition = { id: transitionId, @@ -369,160 +439,147 @@ const importTimetable = async ( return transition; }; + // Track nge nodes + const ngeNodesByPathKey: Record = {}; let trainrunSectionId = 0; - const nodesByOpId = new Map(); - const trainrunSections: TrainrunSectionDto[] = trainSchedules - .map((trainSchedule) => { - // Figure out the node ID for each path item - const pathNodeIds = trainSchedule.path.map((pathItem) => { - const op = findOpFromPathItem(pathItem, searchResults); - if (op) { - let node = nodesByOpId.get(op.obj_id); - if (!node) { - node = createNode({ - trigram: op.trigram + (op.ch ? `/${op.ch}` : ''), - fullName: op.name, - positionX: op.geographic.coordinates[0], - positionY: op.geographic.coordinates[1], - }); - nodesByOpId.set(op.obj_id, node); - } - - return node.id; - } - - let trigram: string | undefined; - if ('trigram' in pathItem) { - trigram = pathItem.trigram; - if (pathItem.secondary_code) { - trigram += `/${pathItem.secondary_code}`; - } - } - - const node = createNode({ trigram }); - return node.id; - }); - - const startTime = new Date(trainSchedule.start_time); - const createTimeLock = (time: Date): TimeLockDto => ({ - time: time.getMinutes(), - // getTime() is in milliseconds, consecutiveTime is in minutes - consecutiveTime: (time.getTime() - startTime.getTime()) / (60 * 1000), - lock: false, - warning: null, - timeFormatter: null, - }); - - // OSRD describes the path in terms of nodes, NGE describes it in terms - // of sections between nodes. Iterate over path items two-by-two to - // convert them. - let prevPort: PortDto | null = null; - return pathNodeIds.slice(0, -1).map((sourceNodeId, i) => { - const targetNodeId = pathNodeIds[i + 1]; - - const sourcePort = createPort(trainrunSectionId); - const targetPort = createPort(trainrunSectionId); - - const sourceNode = nodesById.get(sourceNodeId)!; - const targetNode = nodesById.get(targetNodeId)!; - sourceNode.ports.push(sourcePort); - targetNode.ports.push(targetPort); - - const sourceScheduleEntry = trainSchedule.schedule!.find( - (entry) => entry.at === trainSchedule.path[i].id + const trainrunSections: TrainrunSectionDto[] = state.trainSchedules.flatMap((trainSchedule) => { + // Figure out the primary node key for each path item + const pathNodeKeys = trainSchedule.path.map((pathItem) => { + const node = state.getNodeByKey(MacroEditorState.getPathKey(pathItem)); + return node!.node.path_item_key; + }); + + const startTime = new Date(trainSchedule.start_time); + const createTimeLock = (time: Date): TimeLockDto => ({ + time: time.getMinutes(), + // getTime() is in milliseconds, consecutiveTime is in minutes + consecutiveTime: (time.getTime() - startTime.getTime()) / (60 * 1000), + lock: false, + warning: null, + timeFormatter: null, + }); + + // OSRD describes the path in terms of nodes, NGE describes it in terms + // of sections between nodes. Iterate over path items two-by-two to + // convert them. + let prevPort: PortDto | null = null; + return pathNodeKeys.slice(0, -1).map((sourceNodeKey, i) => { + // Get the source node or created it + if (!ngeNodesByPathKey[sourceNodeKey]) { + ngeNodesByPathKey[sourceNodeKey] = castNodeToNge( + state, + state.getNodeByKey(sourceNodeKey)!.node ); - const targetScheduleEntry = trainSchedule.schedule!.find( - (entry) => entry.at === trainSchedule.path[i + 1].id + } + const sourceNode = ngeNodesByPathKey[sourceNodeKey]; + + // Get the target node or created it + const targetNodeKey = pathNodeKeys[i + 1]; + if (!ngeNodesByPathKey[targetNodeKey]) { + ngeNodesByPathKey[targetNodeKey] = castNodeToNge( + state, + state.getNodeByKey(targetNodeKey)!.node ); + } + const targetNode = ngeNodesByPathKey[targetNodeKey]; + + // Adding port + const sourcePort = createPort(trainrunSectionId); + sourceNode.ports.push(sourcePort); + const targetPort = createPort(trainrunSectionId); + targetNode.ports.push(targetPort); + + // Adding schedule + const sourceScheduleEntry = trainSchedule.schedule!.find( + (entry) => entry.at === trainSchedule.path[i].id + ); + const targetScheduleEntry = trainSchedule.schedule!.find( + (entry) => entry.at === trainSchedule.path[i + 1].id + ); + + // Create a transition between the previous section and the one we're creating + if (prevPort) { + const transition = createTransition(prevPort.id, sourcePort.id); + transition.isNonStopTransit = !sourceScheduleEntry?.stop_for; + sourceNode.transitions.push(transition); + } + prevPort = targetPort; + + let sourceDeparture = { ...DEFAULT_TIME_LOCK }; + if (i === 0) { + sourceDeparture = createTimeLock(startTime); + } else if (sourceScheduleEntry && sourceScheduleEntry.arrival) { + sourceDeparture = createTimeLock( + addDurationToDate( + addDurationToDate(startTime, sourceScheduleEntry.arrival), + sourceScheduleEntry.stop_for || 'P0D' + ) + ); + } - // Create a transition between the previous section and the one we're - // creating - if (prevPort) { - const transition = createTransition(prevPort.id, sourcePort.id); - transition.isNonStopTransit = !sourceScheduleEntry?.stop_for; - sourceNode.transitions.push(transition); - } - prevPort = targetPort; - - let sourceDeparture = { ...DEFAULT_TIME_LOCK }; - if (i === 0) { - sourceDeparture = createTimeLock(startTime); - } else if (sourceScheduleEntry && sourceScheduleEntry.arrival) { - sourceDeparture = createTimeLock( - addDurationToDate( - addDurationToDate(startTime, sourceScheduleEntry.arrival), - sourceScheduleEntry.stop_for || 'P0D' - ) - ); - } - - let targetArrival = { ...DEFAULT_TIME_LOCK }; - if (targetScheduleEntry && targetScheduleEntry.arrival) { - targetArrival = createTimeLock(addDurationToDate(startTime, targetScheduleEntry.arrival)); - } - - const travelTime = { ...DEFAULT_TIME_LOCK }; - if (targetArrival.consecutiveTime !== null && sourceDeparture.consecutiveTime !== null) { - travelTime.time = targetArrival.consecutiveTime - sourceDeparture.consecutiveTime; - travelTime.consecutiveTime = travelTime.time; - } + let targetArrival = { ...DEFAULT_TIME_LOCK }; + if (targetScheduleEntry && targetScheduleEntry.arrival) { + targetArrival = createTimeLock(addDurationToDate(startTime, targetScheduleEntry.arrival)); + } - const trainrunSection = { - id: trainrunSectionId, - sourceNodeId, - sourcePortId: sourcePort.id, - targetNodeId, - targetPortId: targetPort.id, - travelTime, - sourceDeparture, - sourceArrival: { ...DEFAULT_TIME_LOCK }, - targetDeparture: { ...DEFAULT_TIME_LOCK }, - targetArrival, - numberOfStops: 0, - trainrunId: trainSchedule.id, - resourceId: resource.id, - path: { - path: [], - textPositions: [], - }, - specificTrainrunSectionFrequencyId: 0, - warnings: [], - }; - trainrunSectionId += 1; + const travelTime = { ...DEFAULT_TIME_LOCK }; + if (targetArrival.consecutiveTime !== null && sourceDeparture.consecutiveTime !== null) { + travelTime.time = targetArrival.consecutiveTime - sourceDeparture.consecutiveTime; + travelTime.consecutiveTime = travelTime.time; + } - return trainrunSection; - }); - }) - .flat(); - - convertGeoCoords([...nodesByOpId.values()]); - - // eslint-disable-next-line no-restricted-syntax - for (const node of nodes) { - // eslint-disable-next-line no-continue - if (!node.betriebspunktName) continue; - const savedNode = nodeStore.get(timetableId, node.betriebspunktName); - if (savedNode) { - node.positionX = savedNode.positionX; - node.positionY = savedNode.positionY; - } - } + const trainrunSection = { + id: trainrunSectionId, + sourceNodeId: sourceNode.id, + sourcePortId: sourcePort.id, + targetNodeId: targetNode.id, + targetPortId: targetPort.id, + travelTime, + sourceDeparture, + sourceArrival: { ...DEFAULT_TIME_LOCK }, + targetDeparture: { ...DEFAULT_TIME_LOCK }, + targetArrival, + numberOfStops: 0, + trainrunId: trainSchedule.id, + resourceId: state.ngeResource.id, + path: { + path: [], + textPositions: [], + }, + specificTrainrunSectionFrequencyId: 0, + warnings: [], + }; + + trainrunSectionId += 1; + return trainrunSection; + }); + }); return { - ...DEFAULT_DTO, - labels: DTOLabels, - labelGroups: [DEFAULT_LABEL_GROUP], - resources: [resource], - metadata: { - netzgrafikColors: [], - trainrunCategories: [DEFAULT_TRAINRUN_CATEGORY], - trainrunFrequencies: DEFAULT_TRAINRUN_FREQUENCIES, - trainrunTimeCategories: [DEFAULT_TRAINRUN_TIME_CATEGORY], - }, - nodes, - trainruns, trainrunSections, + nodes: Object.values(ngeNodesByPathKey), }; }; -export default importTimetable; +/** + * Return a compatible object for NGE + */ +export const getNgeDto = (state: MacroEditorState): NetzgrafikDto => ({ + ...DEFAULT_DTO, + labels: Array.from(state.labels).map((l, i) => ({ + id: i, + label: l, + labelGroupId: DEFAULT_LABEL_GROUP.id, + labelRef: 'Trainrun', + })), + labelGroups: [DEFAULT_LABEL_GROUP], + resources: [state.ngeResource], + metadata: { + netzgrafikColors: [], + trainrunCategories: [DEFAULT_TRAINRUN_CATEGORY], + trainrunFrequencies: [DEFAULT_TRAINRUN_FREQUENCY], + trainrunTimeCategories: [DEFAULT_TRAINRUN_TIME_CATEGORY], + }, + trainruns: getNgeTrainruns(state), + ...getNgeTrainrunSectionsWithNodes(state), +}); diff --git a/front/src/applications/operationalStudies/components/Scenario/ScenarioContent.tsx b/front/src/applications/operationalStudies/components/Scenario/ScenarioContent.tsx index 07865651c1e..93419088fd5 100644 --- a/front/src/applications/operationalStudies/components/Scenario/ScenarioContent.tsx +++ b/front/src/applications/operationalStudies/components/Scenario/ScenarioContent.tsx @@ -1,11 +1,15 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { ChevronRight } from '@osrd-project/ui-icons'; import cx from 'classnames'; import { useTranslation } from 'react-i18next'; +import MacroEditorState from 'applications/operationalStudies/components/MacroEditor/MacroEditorState'; import handleOperation from 'applications/operationalStudies/components/MacroEditor/ngeToOsrd'; -import importTimetableToNGE from 'applications/operationalStudies/components/MacroEditor/osrdToNge'; +import { + loadAndIndexNge, + getNgeDto, +} from 'applications/operationalStudies/components/MacroEditor/osrdToNge'; import MicroMacroSwitch from 'applications/operationalStudies/components/MicroMacroSwitch'; import NGE from 'applications/operationalStudies/components/NGE/NGE'; import type { NetzgrafikDto, NGEEvent } from 'applications/operationalStudies/components/NGE/types'; @@ -71,6 +75,7 @@ const ScenarioContent = ({ [setIsMacro, setCollapsedTimetable, collapsedTimetable] ); + const macroEditorState = useRef(); const [ngeDto, setNgeDto] = useState(); const [ngeUpsertedTrainSchedules, setNgeUpsertedTrainSchedules] = useState< Map @@ -83,11 +88,14 @@ const ScenarioContent = ({ } const doImport = async () => { - const dto = await importTimetableToNGE(scenario.infra_id, scenario.timetable_id, dispatch); + const state = new MacroEditorState(scenario, trainSchedules || []); + await loadAndIndexNge(state, dispatch); + const dto = getNgeDto(state); + macroEditorState.current = state; setNgeDto(dto); }; doImport(); - }, [scenario, isMacro]); + }, [scenario, isMacro, trainSchedules]); useEffect(() => { if (isMacro) { @@ -104,6 +112,7 @@ const ScenarioContent = ({ const handleNGEOperation = (event: NGEEvent, netzgrafikDto: NetzgrafikDto) => handleOperation({ event, + state: macroEditorState.current!, dispatch, infraId: infra.id, timeTableId: scenario.timetable_id,