refactor(map): the road trip via helpers move to utils, so the route hook imports no component

This commit is contained in:
Maurice
2026-10-12 21:14:05 +00:00
parent 64e4c34472
commit 7b023c6b54
9 changed files with 139 additions and 94 deletions
@@ -0,0 +1,99 @@
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 } from '../../utils/dayRouteVias';
import { routeLeg } from './dayRouteLeg';
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),
],
};
beforeEach(() => {
vi.mocked(calculateRouteWithLegs).mockReset();
});
describe('dayRouteLeg (#2580)', () => {
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();
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { DistanceUnit, RouteSegment } from '../../types';
import { foldViaLegs, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias';
import { calculateRouteWithLegs } from './RouteCalculator';
/** 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;
}
}
@@ -1,185 +0,0 @@
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();
});
});
-144
View File
@@ -1,144 +0,0 @@
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;
}
}
@@ -1,6 +1,6 @@
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 { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
import { projectDayItinerary } from './dayTourProjection'
import { resolveLegMode } from '../Planner/legMode'
import { dayColor } from '../Roadtrip/dayColors'