@@ -43,6 +43,9 @@ type DeployedWaypoint = {
4343 loading ?: boolean ;
4444} ;
4545
46+ const NO_TRACK_SPECIFIED_ID = '__no_track_specified__' ;
47+ const NO_TRACK_SPECIFIED_SYMBOL = '[ ]' ;
48+
4649type StationLabel = { type ?: 'label' ; label : string } | { type : 'requestedPoint' } ;
4750function extractStationLabel (
4851 stationLabel : StationLabel | undefined ,
@@ -58,11 +61,18 @@ function getOperationalPointReference(
5861 op : PathOperationalPoint | undefined
5962) : OperationalPointReference | undefined {
6063 if ( ! op ) return undefined ;
61- if ( op . opId ) return { type : 'id' , operational_point : op . opId } ;
64+ // Only use the opId when it refers to a real infra OP. Virtual OPs (unrecognised, created
65+ // by usePathProjection when pathfinding fails) have a synthetic id like "virtual_op_Zürich"
66+ // and an empty part.track — they must be matched by trigram/uic instead.
67+ if ( op . opId && op . part ?. track ) return { type : 'id' , operational_point : op . opId } ;
68+ // Normalize empty string ch to null — virtual OPs store ch as '' when the original
69+ // secondary_code was null (see usePathProjection createVirtualOp), and passing ''
70+ // to the backend would filter for OPs with an empty secondary_code rather than any.
71+ const ch = op . extensions ?. sncf ?. ch || null ;
6272 const trigram = op . extensions ?. sncf ?. trigram ;
63- if ( trigram ) return { type : 'trigram' , trigram, secondary_code : op . extensions ?. sncf ?. ch } ;
73+ if ( trigram ) return { type : 'trigram' , trigram, secondary_code : ch } ;
6474 const uic = op . extensions ?. identifier ?. uic ;
65- if ( uic != null ) return { type : 'uic' , uic, secondary_code : op . extensions ?. sncf ?. ch } ;
75+ if ( uic != null ) return { type : 'uic' , uic, secondary_code : ch } ;
6676 return undefined ;
6777}
6878
@@ -89,12 +99,10 @@ const useTrackOccupancy = ({
8999 infraId,
90100 timetableItemProjections,
91101 pathOperationalPoints,
92- pathfindingHasFailed = false ,
93102} : {
94103 infraId : number ;
95104 timetableItemProjections : TrainSpaceTimeData [ ] ;
96105 pathOperationalPoints : PathOperationalPoint [ ] ;
97- pathfindingHasFailed ?: boolean ;
98106} ) : {
99107 deployedWaypoints : DeployedWaypoint [ ] ;
100108 toggleWaypoint : ( waypointId : string , selectedState ?: boolean ) => void ;
@@ -171,7 +179,7 @@ const useTrackOccupancy = ({
171179 const fetchTrackOccupancy = useCallback (
172180 async (
173181 opRef : OperationalPointReference | undefined | null ,
174- opId : string | undefined | null ,
182+ waypointId : string | undefined | null ,
175183 trainsCollection : Record < TimetableItemId , TrainSpaceTimeData >
176184 ) : Promise < MovableOccupancyZone [ ] > => {
177185 if ( ! opRef ) return [ ] ;
@@ -200,11 +208,16 @@ const useTrackOccupancy = ({
200208 if ( pacedResp ?. data ) {
201209 for ( const trackItem of pacedResp . data ) {
202210 const { local_track_name : localTrackName , trains } = trackItem ;
203- if ( ! localTrackName ) continue ;
204- const trackId = opId
205- ? localTrackNameToTrackIdRef . current . get ( opId ) ?. get ( localTrackName )
206- : undefined ;
207- if ( ! trackId ) continue ;
211+ let trackId : string ;
212+ if ( ! localTrackName ) {
213+ trackId = NO_TRACK_SPECIFIED_ID ;
214+ } else {
215+ const mappedTrackId = waypointId
216+ ? localTrackNameToTrackIdRef . current . get ( waypointId ) ?. get ( localTrackName )
217+ : undefined ;
218+ // If the track name isn't found in infra, use the name itself as a virtual track ID
219+ trackId = mappedTrackId ?? localTrackName ;
220+ }
208221 for ( const occupation of trains ) {
209222 const pacedId = formatEditoastIdToPacedTrainId ( occupation . train_schedule_id ) ;
210223 const train = trainsCollection [ pacedId ] ;
@@ -288,32 +301,58 @@ const useTrackOccupancy = ({
288301 const deployedWaypoints = useMemo ( ( ) => {
289302 const res : DeployedWaypoint [ ] = [ ] ;
290303
291- if ( tracksState . type === 'ok' )
292- forEach ( pathOperationalPointsState , ( opState , waypointId ) => {
293- const op = pathOpsByWaypointId . get ( waypointId ) ;
294- if ( opState . selected && op ?. opId ) {
295- const tracks = tracksState . data [ op . opId ] ;
296- res . push ( {
297- waypointId,
298- operationalPointId : op . opId ,
299- operationalPointPosition : op . position ,
300- operationalPointName : op . extensions ?. identifier ?. name || undefined ,
301- zones : opState . zones . data ?. map ( ( zone ) => {
302- const trainStationLabels = trainsStationLabelsRef . current [ zone . trainId ] ;
303- return {
304- ...zone ,
305- originStation : extractStationLabel ( trainStationLabels ?. origin , t ) ,
306- destinationStation : extractStationLabel ( trainStationLabels ?. destination , t ) ,
307- } ;
308- } ) ,
309- loading : opState . zones . type === 'loading' ,
310- tracks,
311- } ) ;
304+ forEach ( pathOperationalPointsState , ( opState , waypointId ) => {
305+ const op = pathOpsByWaypointId . get ( waypointId ) ;
306+ if ( opState . selected && op ) {
307+ const infraTracks = ( tracksState . data ?? { } ) [ waypointId ] || [ ] ;
308+ const infraTrackIds = new Set ( infraTracks . map ( ( track ) => track . id ) ) ;
309+ const trackMapping = localTrackNameToTrackIdRef . current . get ( waypointId ) ;
310+
311+ // Remap zones whose trackId is a local_track_name stored before the infra mapping was
312+ // ready (race condition between fetchTrackOccupancy and loadAllTracks). Once the infra
313+ // mapping is available via localTrackNameToTrackIdRef, resolve them to the real track
314+ // section ID so zones land on the correct infra track row.
315+ const resolvedZones = opState . zones . data ?. map ( ( zone ) => {
316+ if ( ! infraTrackIds . has ( zone . trackId ) && trackMapping ) {
317+ const remappedId = trackMapping . get ( zone . trackId ) ;
318+ if ( remappedId ) return { ...zone , trackId : remappedId } ;
319+ }
320+ return zone ;
321+ } ) ;
322+
323+ // Collect virtual tracks: zones whose trackId still isn't found in the infrastructure
324+ const virtualTrackIds = new Set < string > ( ) ;
325+ if ( resolvedZones ) {
326+ for ( const zone of resolvedZones ) {
327+ if ( ! infraTrackIds . has ( zone . trackId ) ) virtualTrackIds . add ( zone . trackId ) ;
328+ }
312329 }
313- } ) ;
330+ const virtualTracks : Track [ ] = [ ...virtualTrackIds ] . map ( ( id ) => ( {
331+ id,
332+ name : id === NO_TRACK_SPECIFIED_ID ? NO_TRACK_SPECIFIED_SYMBOL : id ,
333+ } ) ) ;
334+
335+ res . push ( {
336+ waypointId,
337+ operationalPointId : op ?. opId ?? waypointId ,
338+ operationalPointPosition : op . position ,
339+ operationalPointName : op . extensions ?. identifier ?. name || undefined ,
340+ zones : resolvedZones ?. map ( ( zone ) => {
341+ const trainStationLabels = trainsStationLabelsRef . current [ zone . trainId ] ;
342+ return {
343+ ...zone ,
344+ originStation : extractStationLabel ( trainStationLabels ?. origin , t ) ,
345+ destinationStation : extractStationLabel ( trainStationLabels ?. destination , t ) ,
346+ } ;
347+ } ) ,
348+ loading : opState . zones . type === 'loading' ,
349+ tracks : [ ...infraTracks , ...virtualTracks ] ,
350+ } ) ;
351+ }
352+ } ) ;
314353
315354 return res ;
316- } , [ pathOperationalPointsState , pathOpsByWaypointId , t ] ) ;
355+ } , [ pathOperationalPointsState , pathOpsByWaypointId , tracksState , t ] ) ;
317356
318357 const toggleWaypoint = useCallback (
319358 ( waypointId : string , selectedState ?: boolean ) => {
@@ -333,7 +372,7 @@ const useTrackOccupancy = ({
333372 ( ids ) =>
334373 fetchTrackOccupancy (
335374 getOperationalPointReference ( waypoint ) ,
336- waypoint . opId ,
375+ waypointId ,
337376 Object . fromEntries ( ids . map ( ( id ) => [ id , timetableItemProjectionsById . get ( id ) ! ] ) )
338377 ) ,
339378 {
@@ -385,7 +424,7 @@ const useTrackOccupancy = ({
385424
386425 const trains = Object . fromEntries ( Array . from ( timetableItemProjectionsById . entries ( ) ) ) ;
387426
388- fetchTrackOccupancy ( opRef , waypoint . opId , trains ) . then ( ( newZones ) => {
427+ fetchTrackOccupancy ( opRef , waypointId , trains ) . then ( ( newZones ) => {
389428 if ( ! newZones . length ) return ;
390429
391430 updatePathOperationalPointState ( waypointId , ( state ) =>
@@ -450,14 +489,13 @@ const useTrackOccupancy = ({
450489
451490 // Fetch new occupation if dragging has stopped:
452491 if ( stopPanning ) {
453- const draggedTrainEditoastId = draggedTrainId ;
454492 await Promise . all (
455493 [ ...impactedPathOperationalPointIDs ] . map ( async ( waypointId ) => {
456494 const newZones = await fetchTrackOccupancy (
457495 getOperationalPointReference ( pathOpsByWaypointId . get ( waypointId ) ) ,
458- pathOpsByWaypointId . get ( waypointId ) ?. opId ,
496+ waypointId ,
459497 {
460- [ draggedTrainEditoastId ] : newTrainData ,
498+ [ draggedTrainId ] : newTrainData ,
461499 }
462500 ) ;
463501
@@ -489,30 +527,26 @@ const useTrackOccupancy = ({
489527
490528 // Load all tracks from all waypoints on mount / waypoints update:
491529 useEffect ( ( ) => {
492- if ( pathfindingHasFailed ) {
493- return ;
494- }
495-
496530 let aborted = false ;
497531
498532 const pathOperationalPointsWithoutTracks = pathOperationalPoints . filter (
499533 ( op ) => ! ( tracksState . data || { } ) [ op . waypointId ]
500534 ) ;
501535 const loadAllTracks = async (
502- operationalPointReferences : { operational_point : string ; type : 'id' } [ ]
536+ opsWithReferences : { waypointId : string ; reference : OperationalPointReference } [ ]
503537 ) => {
504538 setTracksState ( ( state ) => ( { type : 'loading' , data : state . data || { } } ) ) ;
505539
506540 try {
507541 const data = await postInfraByInfraIdMatchOperationalPoints ( {
508542 infraId,
509- body : { operational_point_references : operationalPointReferences } ,
543+ body : { operational_point_references : opsWithReferences . map ( ( o ) => o . reference ) } ,
510544 } ) . unwrap ( ) ;
511545
512546 if ( aborted ) return ;
513547
514548 const allTrackIds = data . related_operational_points . flatMap ( ( [ points ] ) =>
515- points . parts . map ( ( part ) => part . track )
549+ points ? points . parts . map ( ( part ) => part . track ) : [ ]
516550 ) ;
517551 const fetchedTrackSections = await getTrackSectionsByIds ( allTrackIds ) ;
518552
@@ -523,20 +557,21 @@ const useTrackOccupancy = ({
523557
524558 localTrackNameToTrackIdRef . current = new Map ( ) ;
525559
526- data . related_operational_points . forEach ( ( [ operationalPoint ] ) => {
560+ opsWithReferences . forEach ( ( { waypointId : wId } , i ) => {
561+ const [ operationalPoint ] = data . related_operational_points [ i ] ;
527562 if ( ! operationalPoint ) return ;
528563 const mapping = new Map < string , string > ( ) ;
529564 for ( const part of operationalPoint . parts ) {
530565 mapping . set ( part . local_track_name , part . track ) ;
531566 }
532- localTrackNameToTrackIdRef . current . set ( operationalPoint . id , mapping ) ;
567+ localTrackNameToTrackIdRef . current . set ( wId , mapping ) ;
533568 } ) ;
534569
535570 const loadedTracks = fromPairs (
536- operationalPointReferences . map ( ( { operational_point } , i ) => [
537- operational_point ,
571+ opsWithReferences . map ( ( { waypointId : wId } , i ) => [
572+ wId ,
538573 uniqBy (
539- data . related_operational_points [ i ] [ 0 ] . parts . map ( ( part ) => {
574+ ( data . related_operational_points [ i ] [ 0 ] ? .parts ?? [ ] ) . map ( ( part ) => {
540575 const trackPart = trackSectionByTrackId . get ( part . track ) ;
541576 return {
542577 id : part . track ,
@@ -548,24 +583,32 @@ const useTrackOccupancy = ({
548583 ) ,
549584 ] )
550585 ) ;
551- setTracksState ( {
586+ setTracksState ( ( state ) => ( {
552587 type : 'ok' ,
553- data : loadedTracks ,
554- } ) ;
588+ data : { ... ( state . data ?? { } ) , ... loadedTracks } ,
589+ } ) ) ;
555590 } catch ( e ) {
556591 console . error ( e ) ;
557592 }
558593 } ;
559- const waypointsPayload = pathOperationalPointsWithoutTracks . flatMap ( ( op ) =>
560- op . opId ? [ { operational_point : op . opId , type : 'id' as const } ] : [ ]
561- ) ;
562- if ( ! waypointsPayload . length ) return noop ;
594+ const waypointsPayload = pathOperationalPointsWithoutTracks . flatMap < {
595+ waypointId : string ;
596+ reference : OperationalPointReference ;
597+ } > ( ( op ) => {
598+ const reference = getOperationalPointReference ( op ) ;
599+ if ( ! reference ) return [ ] ;
600+ return [ { waypointId : op . waypointId , reference } ] ;
601+ } ) ;
602+ if ( ! waypointsPayload . length ) {
603+ setTracksState ( ( state ) => ( { type : 'ok' , data : state . data || { } } ) ) ;
604+ return noop ;
605+ }
563606
564607 loadAllTracks ( waypointsPayload ) ;
565608 return ( ) => {
566609 aborted = true ;
567610 } ;
568- } , [ pathOperationalPoints , pathfindingHasFailed ] ) ;
611+ } , [ pathOperationalPoints ] ) ;
569612
570613 // Update train data for all deployed waypoints on trains update:
571614 useEffect ( ( ) => {
@@ -620,7 +663,7 @@ const useTrackOccupancy = ({
620663 forEach ( pathOperationalPointsState , async ( _ , waypointId ) => {
621664 const newZones = await fetchTrackOccupancy (
622665 getOperationalPointReference ( pathOpsByWaypointId . get ( waypointId ) ) ,
623- pathOpsByWaypointId . get ( waypointId ) ?. opId ,
666+ waypointId ,
624667 Object . fromEntries (
625668 [ ...addedTrainIDs , ...modifiedTrainIDs ] . map ( ( id ) => [
626669 id ,
0 commit comments