Skip to content

Commit 5079011

Browse files
committed
Refactor segment times modal hook and extract util functions
1 parent 03b28a0 commit 5079011

7 files changed

Lines changed: 690 additions & 288 deletions

File tree

packages/transition-common/src/services/path/PathSegmentTimeUtils.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
* License text available at https://opensource.org/licenses/MIT
66
*/
77

8+
import type Path from './Path';
89
import 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

2329
export 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

Comments
 (0)