mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(roadtrip): the day view drives the road picked in the road trip
A chosen alternative or a hand-bent drive is stored as vias, and only the road trip rail routed through them. The day route on the map, its connectors in the day list and on the phone, and the overview of all days asked the router for the bare stops, so they drew and measured the default road again. They now thread the same vias between two stops that follow each other in the stored list, with the anchor counted the way the rail counts it, and fold the answer back to one leg per stop pair. Only while the road trip addon is on; avoid settings and the routing engine stay with the rail. Refs #2580
This commit is contained in:
@@ -16,6 +16,8 @@ export interface DayRoutePoint {
|
||||
incoming_leg_transport_mode?: string | null
|
||||
/** Only on an accommodation bookend: the stay the day starts from or ends at. */
|
||||
hotel?: 'morning' | 'evening'
|
||||
/** Only on a planned stop: its assignment, which the road trip's vias are filed against. */
|
||||
assignmentId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +86,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places).map(item => [item.assignment.id, item]))
|
||||
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'place'; id: number; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'tour'; start: { lat: number; lng: number } | null; end: { lat: number; lng: number } | null; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
|
||||
const entries: Entry[] = merged.flatMap((item): Entry[] => {
|
||||
@@ -99,7 +101,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
}]
|
||||
if (!a.place?.lat || !a.place?.lng) return []
|
||||
return [{
|
||||
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
|
||||
kind: 'place', id: a.id, lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this place.
|
||||
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
|
||||
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
|
||||
@@ -144,7 +146,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
// The open run may be nothing but a far-away arrival endpoint — break rather
|
||||
// than draw the ocean.
|
||||
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming, assignmentId: entry.id })
|
||||
runHasPlace = true
|
||||
} else if (entry.from || entry.to) {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { RoadtripVia } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
|
||||
import type { AssignmentsMap, RouteSegment } from '../../types';
|
||||
import {
|
||||
dayViaLookup,
|
||||
foldViaLegs,
|
||||
roadtripStopList,
|
||||
routeLeg,
|
||||
threadVias,
|
||||
viaKeyOf,
|
||||
viasOnDayLeg,
|
||||
} from './dayRouteVias';
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>();
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() };
|
||||
});
|
||||
|
||||
const { calculateRouteWithLegs } = await import('./RouteCalculator');
|
||||
|
||||
const leg = (distance: number): RouteSegment => ({
|
||||
mid: [0, 0],
|
||||
from: [distance, 0],
|
||||
to: [distance + 1, 0],
|
||||
distance,
|
||||
duration: distance / 10,
|
||||
distanceText: '',
|
||||
durationText: '',
|
||||
walkingText: '',
|
||||
drivingText: '',
|
||||
});
|
||||
|
||||
const via = (dayId: number, afterOrderIndex: number, lat: number, sequence = 0): RoadtripVia =>
|
||||
({
|
||||
id: lat,
|
||||
day_id: dayId,
|
||||
after_order_index: afterOrderIndex,
|
||||
sequence,
|
||||
lat,
|
||||
lng: 0,
|
||||
created_at: '',
|
||||
}) as RoadtripVia;
|
||||
|
||||
const stop = (id: number, order: number, extra: Record<string, unknown> = {}) =>
|
||||
buildAssignment({ id, day_id: 1, order_index: order, place: buildPlace({ lat: 48 + order, lng: 2 }), ...extra });
|
||||
|
||||
// Stored order: hotel row, Paris, a place without coordinates, Lille, Brussels.
|
||||
const STORED: AssignmentsMap = {
|
||||
'1': [
|
||||
stop(14, 3),
|
||||
stop(11, 1),
|
||||
stop(10, 0, { accommodation_id: 5 }),
|
||||
buildAssignment({
|
||||
id: 12,
|
||||
day_id: 1,
|
||||
order_index: 2,
|
||||
place: buildPlace({ lat: null as unknown as number, lng: null as unknown as number }),
|
||||
}),
|
||||
stop(13, 2.5),
|
||||
],
|
||||
};
|
||||
|
||||
/** The lookup of a trip that has vias, which is never null. */
|
||||
function lookupFor(viasByDay: Record<number, RoadtripVia[]>) {
|
||||
const lookup = dayViaLookup(STORED, viasByDay);
|
||||
if (!lookup) throw new Error('expected a lookup');
|
||||
return lookup;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(calculateRouteWithLegs).mockReset();
|
||||
});
|
||||
|
||||
describe('dayRouteVias (#2580)', () => {
|
||||
it('FE-MAP-DRV-001: the stop list is the stored order with coordinates, hidden rows included', () => {
|
||||
expect(roadtripStopList(STORED['1']).map((a) => a.id)).toEqual([10, 11, 13, 14]);
|
||||
expect(roadtripStopList(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-002: a leg gets the vias filed behind its first stop, in sequence', () => {
|
||||
const lookup = lookupFor({ 1: [via(1, 1, 49.6, 1), via(1, 1, 49.3, 0), via(1, 2, 50.5)] });
|
||||
expect(lookup(1, 11, 13).map((v) => v.lat)).toEqual([49.3, 49.6]);
|
||||
expect(lookup(1, 13, 14).map((v) => v.lat)).toEqual([50.5]);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-003: two stops that do not follow each other in the stored list get none', () => {
|
||||
const lookup = lookupFor({ 1: [via(1, 1, 49.3)] });
|
||||
// Paris to Brussels skips Lille: the via belongs to the leg to Lille.
|
||||
expect(lookup(1, 11, 14)).toEqual([]);
|
||||
expect(lookup(1, 13, 11)).toEqual([]);
|
||||
expect(lookup(1, 999, 13)).toEqual([]);
|
||||
expect(lookup(2, 11, 13)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-004: a trip without vias routes as before', () => {
|
||||
expect(dayViaLookup(STORED, undefined)).toBeNull();
|
||||
expect(dayViaLookup(STORED, {})).toBeNull();
|
||||
expect(dayViaLookup(STORED, { 1: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-005: a ride, a hotel bookend or a missing lookup bends nothing', () => {
|
||||
const lookup = dayViaLookup(STORED, { 1: [via(1, 1, 49.3)] });
|
||||
expect(viasOnDayLeg(lookup, 1, 'driving', { assignmentId: 11 }, { assignmentId: 13 })).toHaveLength(1);
|
||||
expect(viasOnDayLeg(lookup, 1, 'walking', { assignmentId: 11 }, { assignmentId: 13 })).toHaveLength(1);
|
||||
expect(viasOnDayLeg(lookup, 1, 'train', { assignmentId: 11 }, { assignmentId: 13 })).toEqual([]);
|
||||
expect(viasOnDayLeg(lookup, 1, 'driving', { assignmentId: 11 }, {})).toEqual([]);
|
||||
expect(viasOnDayLeg(null, 1, 'driving', { assignmentId: 11 }, { assignmentId: 13 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-006: threading remembers where each stop went, folding gives one leg per stop pair', () => {
|
||||
const points = [
|
||||
{ lat: 1, lng: 0, k: 'a' },
|
||||
{ lat: 2, lng: 0, k: 'b' },
|
||||
{ lat: 3, lng: 0, k: 'c' },
|
||||
];
|
||||
const { waypoints, stopAt } = threadVias(points, (from) =>
|
||||
from.k === 'a'
|
||||
? [
|
||||
{ lat: 1.5, lng: 0 },
|
||||
{ lat: 1.7, lng: 0 },
|
||||
]
|
||||
: []
|
||||
);
|
||||
expect(waypoints.map((w) => w.lat)).toEqual([1, 1.5, 1.7, 2, 3]);
|
||||
expect(stopAt).toEqual([0, 3, 4]);
|
||||
|
||||
const folded = foldViaLegs([leg(1000), leg(2000), leg(3000), leg(4000)], stopAt, 'imperial');
|
||||
expect(folded.map((l) => l.distance)).toEqual([6000, 4000]);
|
||||
expect(folded[0].from).toEqual([1000, 0]);
|
||||
expect(folded[0].to).toEqual([3001, 0]);
|
||||
expect(folded[0].distanceText).toMatch(/mi/);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-007: legs are left alone when nothing was threaded or the answer does not line up', () => {
|
||||
const legs = [leg(1000), leg(2000)];
|
||||
expect(foldViaLegs(legs, [0, 1, 2], 'metric')).toBe(legs);
|
||||
expect(foldViaLegs(legs, [0, 2, 3], 'metric')).toBe(legs);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-008: the via key changes with the road and not with the order of the list', () => {
|
||||
const a = { 1: [via(1, 0, 49.3)], 2: [via(2, 0, 50.1)] };
|
||||
expect(viaKeyOf({ 2: a[2], 1: a[1] })).toBe(viaKeyOf(a));
|
||||
expect(viaKeyOf({ ...a, 1: [via(1, 0, 49.4)] })).not.toBe(viaKeyOf(a));
|
||||
expect(viaKeyOf(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-009: one leg is routed through its vias and read back as one leg in its mode', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
|
||||
coordinates: [],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
legs: [leg(1000), leg(2000)],
|
||||
});
|
||||
const lookup = dayViaLookup(STORED, { 1: [via(1, 1, 49.3)] });
|
||||
const options = { profile: 'driving', tripId: 7, dayId: 1 };
|
||||
|
||||
const seg = await routeLeg(
|
||||
{ lat: 49, lng: 2, assignmentId: 11 },
|
||||
{ lat: 50.5, lng: 2, assignmentId: 13 },
|
||||
lookup,
|
||||
options,
|
||||
'metric'
|
||||
);
|
||||
|
||||
expect(vi.mocked(calculateRouteWithLegs).mock.calls[0][0]).toEqual([
|
||||
{ lat: 49, lng: 2 },
|
||||
{ lat: 49.3, lng: 0 },
|
||||
{ lat: 50.5, lng: 2 },
|
||||
]);
|
||||
expect(seg?.distance).toBe(3000);
|
||||
expect(seg?.mode).toBe('driving');
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-010: a leg the router cannot answer is undefined', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValueOnce(new Error('down'));
|
||||
expect(
|
||||
await routeLeg({ lat: 1, lng: 1 }, { lat: 2, lng: 2 }, null, { profile: 'driving', dayId: 1 }, 'metric')
|
||||
).toBeUndefined();
|
||||
vi.mocked(calculateRouteWithLegs).mockResolvedValueOnce({ coordinates: [], distance: 0, duration: 0, legs: [] });
|
||||
expect(
|
||||
await routeLeg({ lat: 1, lng: 1 }, { lat: 2, lng: 2 }, null, { profile: 'driving', dayId: 1 }, 'metric')
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { RoadtripVia } from '@trek/shared';
|
||||
import { isCarrierMode, mergeRouteSegments, viasOnLeg } from '@trek/shared/roadtrip';
|
||||
import type { Assignment, AssignmentsMap, DistanceUnit, RouteSegment } from '../../types';
|
||||
import { calculateRouteWithLegs } from './RouteCalculator';
|
||||
|
||||
/**
|
||||
* The stops of a day as the road trip counts them, in the order it drives them.
|
||||
*
|
||||
* This is the index space a via's `after_order_index` lives in: the STORED list, sorted
|
||||
* by `order_index` and filtered to the rows with coordinates. The stop a lodging booking
|
||||
* put on the day and the service stops are counted too, even where the day list hides
|
||||
* them, because the rail and the server count them.
|
||||
*/
|
||||
export function roadtripStopList(dayAssignments: readonly Assignment[] | undefined): Assignment[] {
|
||||
return (dayAssignments ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.filter((a) => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number');
|
||||
}
|
||||
|
||||
/** The trip's vias, keyed by the day they are filed against. */
|
||||
export type ViasByDay = Record<number, readonly RoadtripVia[]>;
|
||||
|
||||
/** The vias shaping the drive between two assignments of a day, in driving order. */
|
||||
export type DayViaLookup = (dayId: number, fromAssignmentId: number, toAssignmentId: number) => readonly RoadtripVia[];
|
||||
|
||||
const NONE: readonly RoadtripVia[] = [];
|
||||
|
||||
/**
|
||||
* The road a traveller picked in the road trip, for the views that draw a day without it.
|
||||
*
|
||||
* A chosen alternative or a hand-bent drive is stored as vias filed against the stop
|
||||
* they leave, so the day view asks for the same points between the same two stops. Only
|
||||
* between two stops that follow each other in the stored list: with a stop the day list
|
||||
* hides in between, the vias belong to a leg the day view does not draw, and bending the
|
||||
* shortcut through them would be a road nobody chose.
|
||||
*
|
||||
* Null when the trip has no vias, so a trip without a road trip routes exactly as before.
|
||||
*/
|
||||
export function dayViaLookup(stored: AssignmentsMap, viasByDay: ViasByDay | undefined): DayViaLookup | null {
|
||||
if (!viasByDay || !Object.values(viasByDay).some((list) => list.length)) return null;
|
||||
const indexByDay = new Map<number, Map<number, number>>();
|
||||
return (dayId, fromAssignmentId, toAssignmentId) => {
|
||||
const vias = viasByDay[dayId];
|
||||
if (!vias?.length) return NONE;
|
||||
let index = indexByDay.get(dayId);
|
||||
if (!index) {
|
||||
index = new Map(roadtripStopList(stored[String(dayId)]).map((a, i) => [a.id, i]));
|
||||
indexByDay.set(dayId, index);
|
||||
}
|
||||
const from = index.get(fromAssignmentId);
|
||||
if (from === undefined || index.get(toAssignmentId) !== from + 1) return NONE;
|
||||
return viasOnLeg({ ownerDayId: dayId, ownerIndex: from }, {}, vias);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The vias of one leg of a drawn day: none unless both ends are planned stops, and none
|
||||
* on a ride, which is no road.
|
||||
*/
|
||||
export function viasOnDayLeg(
|
||||
lookup: DayViaLookup | null,
|
||||
dayId: number,
|
||||
mode: string,
|
||||
from: { assignmentId?: number },
|
||||
to: { assignmentId?: number }
|
||||
): readonly RoadtripVia[] {
|
||||
if (!lookup || isCarrierMode(mode) || from.assignmentId == null || to.assignmentId == null) return NONE;
|
||||
return lookup(dayId, from.assignmentId, to.assignmentId);
|
||||
}
|
||||
|
||||
/** A stable key for the via list, so a routing effect re-runs when the road changes and only then. */
|
||||
export function viaKeyOf(viasByDay: ViasByDay | undefined): string {
|
||||
return Object.entries(viasByDay ?? {})
|
||||
.map(
|
||||
([dayId, vias]) =>
|
||||
`${dayId}:${vias.map((v) => `${v.after_order_index}@${v.lat.toFixed(5)},${v.lng.toFixed(5)}`).join('|')}`
|
||||
)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* The waypoints to ask the router for: the stops with the vias of each leg threaded in
|
||||
* between. `stopAt[i]` is the waypoint the i-th stop became, which is how the answer's
|
||||
* legs (one per waypoint pair) are folded back to one per stop pair.
|
||||
*/
|
||||
export function threadVias<P extends { lat: number; lng: number }>(
|
||||
points: readonly P[],
|
||||
viasBetween: (from: P, to: P) => readonly { lat: number; lng: number }[]
|
||||
): { waypoints: { lat: number; lng: number }[]; stopAt: number[] } {
|
||||
const waypoints: { lat: number; lng: number }[] = [];
|
||||
const stopAt: number[] = [];
|
||||
points.forEach((point, i) => {
|
||||
stopAt.push(waypoints.length);
|
||||
waypoints.push({ lat: point.lat, lng: point.lng });
|
||||
if (i < points.length - 1)
|
||||
for (const via of viasBetween(point, points[i + 1])) waypoints.push({ lat: via.lat, lng: via.lng });
|
||||
});
|
||||
return { waypoints, stopAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* The router's legs read as one leg per stop pair again. Untouched when nothing was
|
||||
* threaded, or when the answer does not have a leg per waypoint pair (a plugin route may
|
||||
* not), since the pieces could not be told apart then.
|
||||
*/
|
||||
export function foldViaLegs(legs: RouteSegment[], stopAt: number[], unit: DistanceUnit | undefined): RouteSegment[] {
|
||||
const waypointCount = stopAt.length ? stopAt[stopAt.length - 1] + 1 : 0;
|
||||
if (waypointCount === stopAt.length || legs.length !== waypointCount - 1) return legs;
|
||||
const folded: RouteSegment[] = [];
|
||||
for (let i = 0; i < stopAt.length - 1; i++) {
|
||||
const parts = legs.slice(stopAt[i], stopAt[i + 1]);
|
||||
if (parts.length)
|
||||
folded.push(mergeRouteSegments(parts, unit === 'imperial' ? 'imperial' : 'metric') as RouteSegment);
|
||||
}
|
||||
return folded;
|
||||
}
|
||||
|
||||
/** One end of a leg: a planned stop carries its assignment, which the vias are filed against. */
|
||||
export type LegEnd = { lat: number; lng: number; assignmentId?: number };
|
||||
|
||||
/**
|
||||
* The drive between two points of a day as one leg, bent through the road trip's vias
|
||||
* between them and tagged with the mode it was routed in. Undefined when the router has
|
||||
* no road for it.
|
||||
*/
|
||||
export async function routeLeg(
|
||||
from: LegEnd,
|
||||
to: LegEnd,
|
||||
lookup: DayViaLookup | null,
|
||||
options: Parameters<typeof calculateRouteWithLegs>[1] & { profile: string; dayId: number },
|
||||
unit: DistanceUnit | undefined
|
||||
): Promise<RouteSegment | undefined> {
|
||||
const vias = viasOnDayLeg(lookup, options.dayId, options.profile, from, to);
|
||||
try {
|
||||
const waypoints = [from, ...vias, to].map((p) => ({ lat: p.lat, lng: p.lng }));
|
||||
const r = await calculateRouteWithLegs(waypoints, options);
|
||||
const leg = foldViaLegs(r.legs, [0, vias.length + 1], unit)[0];
|
||||
return leg ? { ...leg, mode: options.profile } : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -232,4 +232,26 @@ describe('tripRouteGeometry', () => {
|
||||
const byRoad = planTripRoute({ ...moving, reservations: [] }, 'driving')
|
||||
expect(byRoad.map(d => d.day.id)).toEqual([2])
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-013: a road picked in the road trip bends the overview and still counts as one leg (#2580)', async () => {
|
||||
// A lodging row hides in the plan but counts in the stored list, so the via behind
|
||||
// the first stop of day 1 is filed at 1.
|
||||
const hotel = buildAssignment({ day_id: 1, order_index: -1, accommodation_id: 3, place: buildPlace({ lat: 48.8, lng: 2.3 }) })
|
||||
const storedAssignments = { ...ASSIGNMENTS, '1': [hotel, ...ASSIGNMENTS['1']] }
|
||||
const viasByDay = { 1: [{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 48.95, lng: 2.6, created_at: '' }] }
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(async (points) => ({
|
||||
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
|
||||
distance: 0, duration: 0,
|
||||
legs: points.slice(1).map(() => leg(10000)),
|
||||
}))
|
||||
|
||||
const plan = planTripRoute({ ...input, storedAssignments, viasByDay }, 'driving')
|
||||
expect(plan[0].runs[0][0].waypoints.map(p => p.lat)).toEqual([48.86, 48.95, 48.90])
|
||||
expect(plan[1].runs[0][0].waypoints.map(p => p.lat)).toEqual([45.76, 45.80])
|
||||
|
||||
const summary = await routeTrip({ ...input, storedAssignments, viasByDay }, { profile: 'driving', tripId: 7, unit: 'metric' })
|
||||
expect(summary.days[0].segments).toHaveLength(1)
|
||||
expect(summary.days[0].distance).toBe(20000)
|
||||
expect(summary.days[1].distance).toBe(10000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from './dayRouteVias'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import type { Day, RouteSegment } from '../../types'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import type { AssignmentsMap, Day, DistanceUnit, RouteSegment } from '../../types'
|
||||
|
||||
/** One travel day of the overview: the roads it covers, in its own colour. */
|
||||
export interface TripOverviewDay {
|
||||
@@ -48,8 +50,16 @@ export interface TripRouteSummary {
|
||||
}
|
||||
|
||||
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
|
||||
* exactly as the single-day route builds them. */
|
||||
interface Chunk { points: DayRoutePoint[]; mode: string }
|
||||
* exactly as the single-day route builds them. `waypoints` are the points with the road
|
||||
* trip's vias threaded in, and `stopAt` says which waypoint each point became. */
|
||||
interface Chunk { points: DayRoutePoint[]; mode: string; waypoints: { lat: number; lng: number }[]; stopAt: number[] }
|
||||
|
||||
/** The day inputs plus the road trip's vias, which bend a day the way it was picked there (#2580). */
|
||||
interface TripRouteInputs extends DayRouteInputs {
|
||||
/** The stored day lists the vias are anchored in, hidden rows included. Absent: `assignments`. */
|
||||
storedAssignments?: AssignmentsMap
|
||||
viasByDay?: Record<number, readonly RoadtripVia[]>
|
||||
}
|
||||
|
||||
/** A day reduced to the routing requests it needs, in the order they draw. */
|
||||
export interface TripRoutePlanDay { day: Day; runs: Chunk[][]; tourLines?: [number, number][][] }
|
||||
@@ -62,14 +72,16 @@ export type TripRouteAnswers = Answer[][][]
|
||||
export const dayRouteColor = (day: Day): { line: string; casing: string } =>
|
||||
dayColor(day.day_number ?? 0)
|
||||
|
||||
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] {
|
||||
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string, dayId: number, viaLookup: DayViaLookup | null): Chunk[] {
|
||||
const chunks: Chunk[] = []
|
||||
let i = 0
|
||||
while (i < run.length - 1) {
|
||||
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
|
||||
let end = i + 1
|
||||
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
|
||||
chunks.push({ points: run.slice(i, end + 1), mode })
|
||||
const points = run.slice(i, end + 1)
|
||||
const { waypoints, stopAt } = threadVias(points, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to))
|
||||
chunks.push({ points, mode, waypoints, stopAt })
|
||||
i = end
|
||||
}
|
||||
return chunks
|
||||
@@ -83,7 +95,8 @@ const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p =
|
||||
* Pure and synchronous, so a caller can compare two plans (the map keys its routing
|
||||
* round on one) before spending a single request.
|
||||
*/
|
||||
export function planTripRoute(input: DayRouteInputs, profile: RouteProfileKey): TripRoutePlanDay[] {
|
||||
export function planTripRoute(input: TripRouteInputs, profile: RouteProfileKey): TripRoutePlanDay[] {
|
||||
const viaLookup = dayViaLookup(input.storedAssignments ?? input.assignments, input.viasByDay)
|
||||
return [...input.days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(day => ({
|
||||
@@ -98,7 +111,7 @@ export function planTripRoute(input: DayRouteInputs, profile: RouteProfileKey):
|
||||
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
|
||||
.map(({ day, runs, tourLines }) => ({
|
||||
day,
|
||||
runs: runs.map(run => chunkRun(run, day.default_transport_mode || profile)),
|
||||
runs: runs.map(run => chunkRun(run, day.default_transport_mode || profile, day.id, viaLookup)),
|
||||
tourLines,
|
||||
}))
|
||||
}
|
||||
@@ -205,9 +218,11 @@ const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
*/
|
||||
export async function routeTripLegs(
|
||||
plan: TripRoutePlanDay[],
|
||||
{ tripId, signal, onAnswer }: {
|
||||
{ tripId, signal, onAnswer, unit }: {
|
||||
tripId: number | null
|
||||
signal?: AbortSignal
|
||||
/** The unit a leg bent through vias has its text written again in. */
|
||||
unit?: DistanceUnit
|
||||
/** Called after each answer, for a caller that wants to draw as they arrive. */
|
||||
onAnswer?: (routed: Answer[][][]) => void
|
||||
},
|
||||
@@ -218,15 +233,14 @@ export async function routeTripLegs(
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
const waypoints = chunk.points.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
waypoints,
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
return
|
||||
} catch (err) {
|
||||
@@ -261,12 +275,13 @@ export async function routeTripLegs(
|
||||
* requests against a shared router, and no document is worth that wait.
|
||||
*/
|
||||
export async function routeTrip(
|
||||
input: DayRouteInputs,
|
||||
{ profile, tripId, signal, timeoutMs }: {
|
||||
input: TripRouteInputs,
|
||||
{ profile, tripId, signal, timeoutMs, unit }: {
|
||||
profile: RouteProfileKey
|
||||
tripId: number | null
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
unit?: DistanceUnit
|
||||
},
|
||||
): Promise<TripRouteSummary> {
|
||||
const plan = planTripRoute(input, profile)
|
||||
@@ -275,7 +290,7 @@ export async function routeTrip(
|
||||
const timer = deadline ? setTimeout(() => deadline.abort(), timeoutMs) : null
|
||||
if (deadline && signal) signal.addEventListener('abort', () => deadline.abort(), { once: true })
|
||||
try {
|
||||
const routed = await routeTripLegs(plan, { tripId, signal: deadline?.signal ?? signal })
|
||||
const routed = await routeTripLegs(plan, { tripId, signal: deadline?.signal ?? signal, unit })
|
||||
return summariseTripRoute(assembleTripRoute(plan, routed))
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
|
||||
@@ -278,6 +278,22 @@ describe('useTripRouteOverview', () => {
|
||||
expect(result.current.focusPoints).toBe(frame)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-015: a road picked in the road trip re-routes the overview through it (#2580)', async () => {
|
||||
const via = { id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.95, lng: 2.6, created_at: '' }
|
||||
const { result, rerender } = renderHook(
|
||||
({ viasByDay }: { viasByDay: Record<number, typeof via[]> }) =>
|
||||
useTripRouteOverview(7, DAYS, ASSIGNMENTS, [], [], 'driving', true, undefined, { storedAssignments: ASSIGNMENTS, viasByDay }),
|
||||
{ initialProps: { viasByDay: {} } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const asked = () => vi.mocked(calculateRouteWithLegs).mock.calls.map(call => call[0].map(p => p.lat))
|
||||
expect(asked()).toEqual([[48.86, 48.88], [45.76, 45.78]])
|
||||
|
||||
rerender({ viasByDay: { 1: [via] } })
|
||||
await waitFor(() => expect(asked()).toHaveLength(3))
|
||||
expect(asked()[2]).toEqual([48.86, 48.95, 48.88])
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-013: switching the overview back on publishes a fresh frame', async () => {
|
||||
const { result, rerender } = render()
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from './tripRouteGeometry'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
||||
|
||||
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
|
||||
@@ -18,6 +19,12 @@ export interface TripRouteOverview extends TripRouteSummary {
|
||||
const EMPTY: TripRouteOverview = { ...summariseTripRoute([]), loading: false }
|
||||
const EMPTY_PLACES: Place[] = []
|
||||
|
||||
/** The road trip's vias and the stored day lists their anchors count in. */
|
||||
interface TripRouteVias {
|
||||
storedAssignments: AssignmentsMap
|
||||
viasByDay: Record<number, RoadtripVia[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Every travel day's route at once, each day in its own colour, with the trip's total
|
||||
* distance (#1736).
|
||||
@@ -26,7 +33,8 @@ const EMPTY_PLACES: Place[] = []
|
||||
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
|
||||
* you are looking at it alone or at the whole trip. This hook is the React wrapper:
|
||||
* the planning and routing themselves live in `tripRouteGeometry`, so the PDF export
|
||||
* can draw the same trip without a component to hang a hook on.
|
||||
* can draw the same trip without a component to hang a hook on. A road picked in the road
|
||||
* trip is drawn here too (#2580).
|
||||
*/
|
||||
export function useTripRouteOverview(
|
||||
tripId: number | null,
|
||||
@@ -37,6 +45,7 @@ export function useTripRouteOverview(
|
||||
profile: RouteProfileKey,
|
||||
enabled: boolean,
|
||||
places: Place[] = EMPTY_PLACES,
|
||||
vias?: TripRouteVias,
|
||||
): TripRouteOverview {
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
|
||||
@@ -53,18 +62,20 @@ export function useTripRouteOverview(
|
||||
const frameRef = useRef<{ tripId: number | null; points: [number, number][] } | null>(null)
|
||||
const wasEnabled = useRef(false)
|
||||
|
||||
const storedAssignments = vias?.storedAssignments
|
||||
const viasByDay = vias?.viasByDay
|
||||
const plan = useMemo(
|
||||
() => (enabled
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places }, profile)
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay }, profile)
|
||||
: []),
|
||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places],
|
||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay],
|
||||
)
|
||||
|
||||
// Only geometry and mode decide whether legs have to be fetched again: renaming a
|
||||
// place or editing its notes must not fire a routing round.
|
||||
const planKey = useMemo(
|
||||
() => plan.map(({ day, runs, tourLines }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
|
||||
.map(chunks => chunks.map(c => `${c.mode}>${c.points.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|')}`).join('+'))
|
||||
.map(chunks => chunks.map(c => `${c.mode}>${c.waypoints.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|')}`).join('+'))
|
||||
.join('/')}:tours=${JSON.stringify(tourLines ?? [])}`).join(';'),
|
||||
[plan],
|
||||
)
|
||||
@@ -108,7 +119,7 @@ export function useTripRouteOverview(
|
||||
if (!loading && reframe) frameRef.current = { tripId, points: next.focusPoints }
|
||||
setResult({ ...next, focusPoints: loading ? frame : frameRef.current!.points, loading })
|
||||
}
|
||||
void routeTripLegs(plan, { tripId, signal: controller.signal, onAnswer: routed => publish(routed, true) })
|
||||
void routeTripLegs(plan, { tripId, signal: controller.signal, unit: distanceUnit, onAnswer: routed => publish(routed, true) })
|
||||
.then(routed => publish(routed, false))
|
||||
|
||||
return () => controller.abort()
|
||||
|
||||
Reference in New Issue
Block a user