55 * License text available at https://opensource.org/licenses/MIT
66 */
77
8+ import type Path from './Path' ;
89import type { PeriodSegmentData } from './Path' ;
10+ import type { ServiceGroup } from './PathServiceGrouping' ;
11+ import type { TimeAndDistance } from './PathTypes' ;
12+ import { pathGeographyUtils } from './PathGeographyUtils' ;
13+
14+ const getBaseSegmentsFromPath = ( path : Path ) : TimeAndDistance [ ] => path . attributes . data . segments || [ ] ;
915
1016// === Types ===
1117
@@ -22,6 +28,20 @@ export type ResolvedCheckpoint = Checkpoint & {
2228
2329export type EditMode = 'segment' | 'checkpoint' ;
2430
31+ /** Flat editing structure used by the segment times modal: serviceId → period → times per segment. */
32+ export type LocalSegmentTimes = Record < string , Record < string , number [ ] > > ;
33+
34+ /** Per-period target total travel time (in seconds) for a single service's checkpoint. */
35+ export type PeriodTargetTimes = Record < string , number > ;
36+
37+ /** Checkpoint target totals keyed by `${checkpointKey}_${serviceId}` → periods → seconds. */
38+ export type CheckpointTargetsByKey = Record < string , PeriodTargetTimes > ;
39+
40+ /** Nested structure mirroring `path.attributes.data.segmentsByServiceAndPeriod`. */
41+ export type SegmentsByServiceAndPeriod = Record < string , Record < string , PeriodSegmentData > > ;
42+
43+ export type AverageTimesByPeriod = ServiceGroup [ 'averageTimesByPeriod' ] ;
44+
2545// === Pure helpers ===
2646
2747/** Resolve a checkpoint's node IDs to their current indices in the nodes array.
@@ -73,3 +93,152 @@ export const buildPeriodSegmentData = (
7393 operatingTotal > 0 ? Math . round ( ( totalDistanceMeters / operatingTotal ) * 100 ) / 100 : 0
7494 } ;
7595} ;
96+
97+ /** Sum the current stored travel times of one service for the segments inside a checkpoint. */
98+ const sumSegmentTimesForCheckpoint = ( serviceTimes : number [ ] | undefined , checkpoint : ResolvedCheckpoint ) : number => {
99+ if ( ! serviceTimes ) return 0 ;
100+ let total = 0 ;
101+ for ( let i = checkpoint . fromNodeIndex ; i < checkpoint . toNodeIndex ; i ++ ) {
102+ total += serviceTimes [ i ] ?? 0 ;
103+ }
104+ return total ;
105+ } ;
106+
107+ /**
108+ * Distribute a per-period target total travel time across the segments of a checkpoint
109+ * for the representative service of a group. Writes the distributed times directly into
110+ * `data[group.serviceIds[0]][periodShortname]`. If OSRM returns usable times, the target
111+ * is scaled to match those proportions; otherwise the target is distributed evenly across
112+ * the checkpoint's segments.
113+ *
114+ * For periods that don't yet have any entry in `data`, falls back to the group's per-period
115+ * baseline, or to the path's base segment travel times when missing.
116+ */
117+ export const distributeCheckpointForService = ( params : {
118+ data : LocalSegmentTimes ;
119+ group : ServiceGroup ;
120+ checkpoint : ResolvedCheckpoint ;
121+ osrmTimes : number [ ] | null ;
122+ targetTimesByPeriod : PeriodTargetTimes ;
123+ baseSegments : TimeAndDistance [ ] ;
124+ } ) : void => {
125+ const { data, group, checkpoint, osrmTimes, targetTimesByPeriod, baseSegments } = params ;
126+ const serviceId = group . serviceIds [ 0 ] ;
127+ const averageTimesByPeriod = group . averageTimesByPeriod ;
128+ if ( ! data [ serviceId ] ) {
129+ data [ serviceId ] = { } ;
130+ }
131+
132+ for ( const [ periodShortname , targetTotalSeconds ] of Object . entries ( targetTimesByPeriod ) ) {
133+ if ( ! data [ serviceId ] [ periodShortname ] ) {
134+ data [ serviceId ] [ periodShortname ] = baseSegments . map ( ( seg , i ) => {
135+ const avg = averageTimesByPeriod ?. [ periodShortname ] ?. [ i ] ;
136+ return avg !== undefined ? avg : seg . travelTimeSeconds ;
137+ } ) ;
138+ }
139+
140+ // Skip periods where the target matches the current total for this service
141+ const currentTotal = sumSegmentTimesForCheckpoint ( data [ serviceId ] [ periodShortname ] , checkpoint ) ;
142+ if ( currentTotal === targetTotalSeconds ) continue ;
143+
144+ let scaledSegmentTimesSeconds : number [ ] | null = null ;
145+ if ( osrmTimes ) {
146+ scaledSegmentTimesSeconds = pathGeographyUtils . scaleTimesToTarget ( osrmTimes , targetTotalSeconds ) ;
147+ }
148+
149+ if ( ! scaledSegmentTimesSeconds ) {
150+ // Fallback: distribute evenly if OSRM fails
151+ const segmentCount = checkpoint . toNodeIndex - checkpoint . fromNodeIndex ;
152+ const timePerSegmentSeconds = Math . floor ( targetTotalSeconds / segmentCount ) ;
153+ const remainderSeconds = targetTotalSeconds - timePerSegmentSeconds * segmentCount ;
154+ scaledSegmentTimesSeconds = Array . from (
155+ { length : segmentCount } ,
156+ ( _ , i ) => timePerSegmentSeconds + ( i < remainderSeconds ? 1 : 0 )
157+ ) ;
158+ }
159+
160+ for ( let i = 0 ; i < scaledSegmentTimesSeconds . length ; i ++ ) {
161+ data [ serviceId ] [ periodShortname ] [ checkpoint . fromNodeIndex + i ] = scaledSegmentTimesSeconds [ i ] ;
162+ }
163+ }
164+ } ;
165+
166+ /**
167+ * Walk every checkpoint and, for any whose targets differ from current totals in at
168+ * least one service group, fetch OSRM times once and distribute them across each
169+ * affected service group. Mutates `dataToUpdate` in place.
170+ */
171+ export const applyPendingCheckpointDistributions = async ( params : {
172+ dataToUpdate : LocalSegmentTimes ;
173+ path : Path ;
174+ resolvedCheckpoints : ResolvedCheckpoint [ ] ;
175+ serviceGroups : ServiceGroup [ ] ;
176+ checkpointTargets : CheckpointTargetsByKey ;
177+ } ) : Promise < void > => {
178+ const { dataToUpdate, path, resolvedCheckpoints, serviceGroups, checkpointTargets } = params ;
179+ const baseSegments = getBaseSegmentsFromPath ( path ) ;
180+ for ( const checkpoint of resolvedCheckpoints ) {
181+ const needsDistribution = serviceGroups . some ( ( group ) => {
182+ const targets = checkpointTargets [ `${ getCheckpointKey ( checkpoint ) } _${ group . serviceIds [ 0 ] } ` ] ;
183+ if ( ! targets ) return false ;
184+ return Object . entries ( targets ) . some ( ( [ period , target ] ) => {
185+ const serviceTimes = dataToUpdate [ group . serviceIds [ 0 ] ] ?. [ period ] ;
186+ if ( ! serviceTimes ) return true ; // no stored data for this group/period = needs distribution
187+ return sumSegmentTimesForCheckpoint ( serviceTimes , checkpoint ) !== target ;
188+ } ) ;
189+ } ) ;
190+ if ( ! needsDistribution ) continue ;
191+
192+ // OSRM times are fetched once per checkpoint and reused across all service groups
193+ const osrmTimes = await pathGeographyUtils . calculateSegmentTimesForCheckpoint (
194+ path ,
195+ checkpoint . fromNodeIndex ,
196+ checkpoint . toNodeIndex
197+ ) ;
198+
199+ for ( const group of serviceGroups ) {
200+ const targets = checkpointTargets [ `${ getCheckpointKey ( checkpoint ) } _${ group . serviceIds [ 0 ] } ` ] ;
201+ if ( ! targets ) continue ;
202+ distributeCheckpointForService ( {
203+ data : dataToUpdate ,
204+ group,
205+ checkpoint,
206+ osrmTimes,
207+ targetTimesByPeriod : targets ,
208+ baseSegments
209+ } ) ;
210+ }
211+ }
212+ } ;
213+
214+ /**
215+ * Serialize the flat LocalSegmentTimes structure (already expanded across services in
216+ * each group) to the nested PeriodSegmentData shape that path.data.segmentsByServiceAndPeriod
217+ * expects. Uses buildPeriodSegmentData to compute totals and speeds per period.
218+ */
219+ export const buildSegmentsByServiceAndPeriod = ( params : {
220+ expandedData : LocalSegmentTimes ;
221+ path : Path ;
222+ dwellTimes : number [ ] ;
223+ } ) : SegmentsByServiceAndPeriod => {
224+ const { expandedData, path, dwellTimes } = params ;
225+ const baseSegments = getBaseSegmentsFromPath ( path ) ;
226+ const totalDistanceMeters = baseSegments . reduce ( ( sum , s ) => sum + ( s . distanceMeters ?? 0 ) , 0 ) ;
227+ const result : SegmentsByServiceAndPeriod = { } ;
228+ for ( const [ serviceId , periodEntries ] of Object . entries ( expandedData ) ) {
229+ for ( const [ periodShortname , times ] of Object . entries ( periodEntries ) ) {
230+ if ( ! times || times . length === 0 ) continue ;
231+ if ( ! result [ serviceId ] ) result [ serviceId ] = { } ;
232+ const segmentsForPeriod = times . map ( ( t , i ) => ( {
233+ travelTimeSeconds : t ,
234+ distanceMeters : baseSegments [ i ] ?. distanceMeters ?? null
235+ } ) ) ;
236+ result [ serviceId ] [ periodShortname ] = buildPeriodSegmentData (
237+ segmentsForPeriod ,
238+ dwellTimes ,
239+ totalDistanceMeters
240+ ) ;
241+ }
242+ }
243+ return result ;
244+ } ;
0 commit comments