fix(tours): Google Maps follows a tour on foot or by bike, CoMaps only gets a route it can follow (#2812)

The Google link carried no mode, so a hike opened as a drive, and a stop on
the equator or the prime meridian was dropped. CoMaps routes a start and an
end only; a tour with stops between would reach it as unrouted pins.
This commit is contained in:
Maurice
2026-11-13 10:49:28 +01:00
parent fcbb538c24
commit 5f6594f697
5 changed files with 50 additions and 9 deletions
@@ -256,6 +256,19 @@ describe('generateGoogleMapsUrl', () => {
expect(result).toContain('48.85,2.35')
expect(result).toContain('48.86,2.36')
})
it('FE-COMP-ROUTECALCULATOR-015b: a walk or ride carries its mode, a drive keeps the path link', () => {
const stops = [{ lat: 48.85, lng: 2.35 }, { lat: 48.86, lng: 2.36 }, { lat: 48.87, lng: 2.37 }]
expect(generateGoogleMapsUrl(stops, 'walking')).toBe(
'https://www.google.com/maps/dir/?api=1&origin=48.85,2.35&destination=48.87,2.37&waypoints=48.86%2C2.36&travelmode=walking',
)
expect(generateGoogleMapsUrl(stops, 'cycling')).toContain('&travelmode=bicycling')
expect(generateGoogleMapsUrl(stops, 'driving')).toBe('https://www.google.com/maps/dir/48.85,2.35/48.86,2.36/48.87,2.37')
})
it('FE-COMP-ROUTECALCULATOR-015c: a stop on the equator or the prime meridian is kept', () => {
expect(generateGoogleMapsUrl([{ lat: 0, lng: 0 }, { lat: 0, lng: 1 }])).toBe('https://www.google.com/maps/dir/0,0/0,1')
})
})
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
+18 -4
View File
@@ -382,14 +382,28 @@ export function withHotelBookends<T extends { lat: number; lng: number }>(
return out
}
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
const valid = places.filter((p) => p.lat && p.lng)
/** Google's directions modes for the profiles it has; anything else leaves the mode to Google. */
const GOOGLE_TRAVEL_MODE: Partial<Record<RouteProfileKey, string>> = { walking: 'walking', cycling: 'bicycling' }
/**
* Google Maps for a list of stops. A walking or cycling route goes as the
* documented directions link, which carries the mode; a drive keeps the short
* path link it always had. A stop on the equator or the prime meridian counts.
*/
export function generateGoogleMapsUrl(places: Waypoint[], profile?: RouteProfileKey): string | null {
const valid = places.filter((p) => p.lat != null && p.lng != null)
if (valid.length === 0) return null
if (valid.length === 1) {
return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
}
const stops = valid.map((p) => `${p.lat},${p.lng}`).join('/')
return `https://www.google.com/maps/dir/${stops}`
const mode = profile && GOOGLE_TRAVEL_MODE[profile]
const point = (p: Waypoint) => `${p.lat},${p.lng}`
if (mode) {
const between = valid.slice(1, -1).map(point).join('|')
return `https://www.google.com/maps/dir/?api=1&origin=${point(valid[0])}&destination=${point(valid[valid.length - 1])}`
+ (between ? `&waypoints=${encodeURIComponent(between)}` : '') + `&travelmode=${mode}`
}
return `https://www.google.com/maps/dir/${valid.map(point).join('/')}`
}
/** A stop that can carry its name into a deep link that has somewhere to put one. */
@@ -72,7 +72,7 @@ export function dayRouteTargets(stops: NamedWaypoint[], profile: RouteProfileKey
const push = (target: Omit<DayRouteTarget, 'url'>, url: string | null) => {
if (url) out.push({ ...target, url });
};
push({ id: 'google', label: 'Google Maps' }, generateGoogleMapsUrl(stops));
push({ id: 'google', label: 'Google Maps' }, generateGoogleMapsUrl(stops, profile));
if (showsAppleMaps()) push({ id: 'apple', label: 'Apple Maps' }, generateAppleMapsUrl(stops, profile));
push({ id: 'comaps', label: 'CoMaps' }, generateCoMapsUrl(stops, profile));
const located = stops.filter((p) => p.lat != null && p.lng != null).length;
@@ -39,6 +39,16 @@ describe('tourRouteApps (#2812)', () => {
const targets = tourRouteTargets('bike', line(2), 'Ride');
expect(targets.map((target) => target.id)).toEqual(['google', 'comaps']);
expect(targets.find((target) => target.id === 'comaps')?.url).toContain('type=bicycle');
expect(targets.find((target) => target.id === 'google')?.url).toBe('https://www.google.com/maps/dir/0,0/0,0.01');
expect(targets.find((target) => target.id === 'google')?.url).toBe(
'https://www.google.com/maps/dir/?api=1&origin=0,0&destination=0,0.01&travelmode=bicycling'
);
});
it('FE-TOUR-ROUTEAPPS-005: a longer tour goes to Google on foot with its stops between, and not to CoMaps', () => {
const targets = tourRouteTargets('hike', line(5), 'Ridge');
expect(targets.map((target) => target.id)).toEqual(['google']);
const url = targets[0].url;
expect(url).toContain('&travelmode=walking');
expect(url).toContain('&waypoints=0%2C0.01%7C0%2C0.02%7C0%2C0.03');
});
});
+7 -3
View File
@@ -67,8 +67,9 @@ function spreadIndices(line: ReadonlyArray<readonly [number, number]>, max: numb
/**
* The map apps a tour's route can be opened in, with the tour's mode. Only the
* apps that take the whole route: one that only gets the start (Waze, a geo:
* link) is what the tour's Navigation button already offers.
* apps that route the whole way: one that only gets the start (Waze, a geo:
* link) is what the tour's Navigation button already offers, and CoMaps routes
* a start and an end only, so with stops between it would get unrouted pins.
*/
export function tourRouteTargets(
type: TourTypeKey,
@@ -77,5 +78,8 @@ export function tourRouteTargets(
): DayRouteTarget[] {
const profile = tourAppProfile(type);
if (!profile) return [];
return dayRouteTargets(tourRouteStops(line, name), profile).filter((target) => !target.firstStopOnly);
const stops = tourRouteStops(line, name);
return dayRouteTargets(stops, profile).filter(
(target) => !target.firstStopOnly && !(target.id === 'comaps' && stops.length > 2)
);
}