import { useSettingsStore } from '../../store/settingsStore' import { pluginsApi } from '../../api/client' import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types' import { formatDistance } from '../../utils/units' // FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the // project-osrm.org demo is car-only (it ignores the profile in the URL). Use // the matching profile so walking routes follow footpaths, not the road network. const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = { driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving', walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot', cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike', } /** OSRM's own profile names, which a self-hosted instance serves under its own paths. */ const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = { driving: 'driving', walking: 'foot', cycling: 'bike', } /** * Where to ask for a route. * * The public FOSSGIS hosts allow roughly one request a second, which a road trip — every * leg of every day — runs into immediately. An instance that runs its own OSRM sets * `routing_base_url` and everything routes against that instead; the server has to name * the same origin in its connect-src, or the browser blocks the requests silently. * * A configured base is expected to serve the standard OSRM layout, * `/route/v1//…`, which is what `osrm-routed` does out of the box. */ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string { const configured = useSettingsStore.getState().settings.routing_base_url?.trim() if (!configured) return OSRM_PROFILE_BASE[profile] return `${configured.replace(/\/+$/, '')}/route/v1/${OSRM_PROFILE_PATH[profile]}` } /** * A routing request the host refused, as opposed to one it answered with "no route". * * The public OSRM instances allow roughly one request a second and answer 429 above that. * Both used to arrive as the same generic Error, so a caller could not tell "back off and * try again" from "these two points are not connected by road" — and a road trip, which * asks for every leg of every day, hits the first case constantly while the second is * rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one. */ export class RoutingRefusedError extends Error { constructor(readonly status: number, readonly retryAfterMs: number | null) { super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated') this.name = 'RoutingRefusedError' } /** True when waiting and asking again is the right response. */ get isRateLimit(): boolean { return this.status === 429 || this.status === 503 } } /** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */ function retryAfterMs(response: Response): number | null { const raw = response.headers.get('Retry-After') if (!raw) return null const seconds = Number(raw) if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000) const at = Date.parse(raw) return Number.isNaN(at) ? null : Math.max(0, at - Date.now()) } // Cache route responses keyed by the exact waypoint list. Routes are stable, so // this avoids re-hitting the public OSRM demo server on every day switch / reorder. const routeCache = new Map() const ROUTE_CACHE_MAX = 200 /** * A route profile is either one of the built-in OSRM profiles or a plugin profile * key `plugin:/` — the route toggle offers those for every * active routeProvider plugin, and calculateRouteWithLegs dispatches on the prefix. */ export type RouteProfileKey = 'driving' | 'walking' | 'cycling' | (string & {}) export function parsePluginProfile(profile: string): { pluginId: string; profileId: string } | null { if (!profile.startsWith('plugin:')) return null const rest = profile.slice('plugin:'.length) const slash = rest.indexOf('/') if (slash <= 0 || slash === rest.length - 1) return null return { pluginId: rest.slice(0, slash), profileId: rest.slice(slash + 1) } } /** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */ export async function calculateRoute( waypoints: Waypoint[], profile: 'driving' | 'walking' | 'cycling' = 'driving', { signal }: { signal?: AbortSignal } = {} ): Promise { if (!waypoints || waypoints.length < 2) { throw new Error('At least 2 waypoints required') } const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false` const response = await fetch(url, { signal }) if (!response.ok) { throw new RoutingRefusedError(response.status, retryAfterMs(response)) } const data = await response.json() if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { throw new Error('No route found') } const route = data.routes[0] const coordinates: [number, number][] = route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng]) const distance: number = route.distance let duration: number if (profile === 'walking') { duration = distance / (5000 / 3600) } else if (profile === 'cycling') { duration = distance / (15000 / 3600) } else { duration = route.duration } const walkingDuration = distance / (5000 / 3600) const drivingDuration: number = route.duration return { coordinates, distance, duration, distanceText: formatRouteDistance(distance), durationText: formatDuration(duration), walkingText: formatDuration(walkingDuration), drivingText: formatDuration(drivingDuration), } } /** * Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the * day's activity runs, so the drawn route starts and ends at the day's accommodation * (matching the sidebar's hotel connectors). A bookend is only added when both its * hotel and the first/last located waypoint exist; passing nulls leaves `runs` * untouched. The shared first/last waypoint is repeated so the polylines join. */ export function withHotelBookends( runs: T[][], firstWay: T | undefined, lastWay: T | undefined, startHotel: T | null, endHotel: T | null, ): T[][] { const out: T[][] = [] if (startHotel && firstWay) out.push([startHotel, firstWay]) out.push(...runs) if (endHotel && lastWay) out.push([lastWay, endHotel]) return out } export function generateGoogleMapsUrl(places: Waypoint[]): string | null { const valid = places.filter((p) => p.lat && p.lng) 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}` } /** A stop that can carry its name into a deep link that has somewhere to put one. */ export type NamedWaypoint = Waypoint & { name?: string | null } /** TREK's route profiles in CoMaps' vocabulary; a plugin profile has no equivalent and drives. */ function coMapsRouteType(profile: RouteProfileKey): string { if (profile === 'walking') return 'pedestrian' if (profile === 'cycling') return 'bicycle' return 'vehicle' } /** * Open a day's stops in CoMaps for offline navigation (#1904). * * CoMaps has two links and they trade against each other. `route` builds real * turn-by-turn in the given travel mode but takes a start and a destination and * nothing between them; `map` takes any number of named pins but routes nothing. * So a two-stop day goes as a route — everything it has fits, mode included — * and a longer one goes as pins, because handing over the whole day and letting * CoMaps route leg by leg beats quietly dropping the middle of someone's plan. * A day that needs the full itinerary as one navigable track has the GPX export. * * https rather than `cm://` for the same reason as `getCoMapsUrlForPlace`. */ export function generateCoMapsUrl(places: NamedWaypoint[], profile: RouteProfileKey = 'driving'): string | null { const valid = places.filter((p) => p.lat != null && p.lng != null) if (valid.length === 0) return null const label = (p: NamedWaypoint) => encodeURIComponent(p.name?.trim() || `${p.lat},${p.lng}`) if (valid.length === 2) { const [from, to] = valid return `https://comaps.at/route?sll=${from.lat},${from.lng}&saddr=${label(from)}` + `&dll=${to.lat},${to.lng}&daddr=${label(to)}&type=${coMapsRouteType(profile)}` } const pins = valid.map((p) => `ll=${p.lat},${p.lng}&n=${label(p)}`).join('&') return `https://comaps.at/map?v=1&${pins}` } // Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine. function sqDist(a: Waypoint, b: Waypoint): number { return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2 } // Length of visiting `order` in sequence, optionally pinned to a fixed start and/or end anchor. // With start === end this is a closed loop back to the anchor (a day out from and back to the hotel). function tourLength(order: Waypoint[], start?: Waypoint, end?: Waypoint): number { if (order.length === 0) return 0 let total = 0 if (start) total += Math.sqrt(sqDist(start, order[0])) for (let i = 0; i < order.length - 1; i++) total += Math.sqrt(sqDist(order[i], order[i + 1])) if (end) total += Math.sqrt(sqDist(order[order.length - 1], end)) return total } // Greedy nearest-neighbor ordering, seeded at the start anchor when there is one. function nearestNeighborOrder(valid: T[], start?: Waypoint): T[] { const visited = new Set() const result: T[] = [] let current: Waypoint if (start) { current = start } else { current = valid[0] visited.add(0) result.push(valid[0]) } while (result.length < valid.length) { let nearestIdx = -1 let minDist = Infinity for (let i = 0; i < valid.length; i++) { if (visited.has(i)) continue const d = sqDist(valid[i], current) if (d < minDist) { minDist = d; nearestIdx = i } } if (nearestIdx === -1) break visited.add(nearestIdx) current = valid[nearestIdx] result.push(valid[nearestIdx]) } return result } // 2-opt: repeatedly reverse a sub-segment whenever it shortens the tour. This removes the crossings // a pure nearest-neighbor pass leaves behind. The start/end anchors stay fixed, so a round trip // (start === end) is untangled into a clean loop rather than an open path. function twoOptImprove(order: T[], start?: Waypoint, end?: Waypoint): T[] { if (order.length < 3) return order let best = order let bestLen = tourLength(best, start, end) let improved = true while (improved) { improved = false for (let i = 0; i < best.length - 1; i++) { for (let j = i + 1; j < best.length; j++) { const candidate = best.slice(0, i).concat(best.slice(i, j + 1).reverse(), best.slice(j + 1)) const len = tourLength(candidate, start, end) if (len < bestLen - 1e-12) { best = candidate bestLen = len improved = true } } } } return best } /** * Reorders waypoints to minimize travel distance: a nearest-neighbor pass for a good starting order, * then 2-opt to untangle crossings. Optional anchors (e.g. the day's accommodation) pin the route's * ends — start === end makes it a loop out from and back to the hotel; a transfer day runs start → end. */ export function optimizeRoute(places: T[], anchors: RouteAnchors = {}): T[] { const { start, end } = anchors const valid = places.filter((p) => p.lat && p.lng) if (valid.length <= 1) return places // Two unanchored stops have no meaningful order to optimize; anchors can still flip them. if (valid.length === 2 && !start && !end) return places const order = twoOptImprove(nearestNeighborOrder(valid, start), start, end) // A round trip's loop direction is arbitrary, so orient it to begin at the stop nearest the hotel — // that reads naturally as "leave the hotel, head to the closest place, …, come back". if (start && end && start.lat === end.lat && start.lng === end.lng && order.length > 1) { if (sqDist(order[order.length - 1], start) < sqDist(order[0], start)) order.reverse() } return order } /** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */ export async function calculateSegments( waypoints: Waypoint[], { signal }: { signal?: AbortSignal } = {} ): Promise { if (!waypoints || waypoints.length < 2) return [] const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration` const response = await fetch(url, { signal }) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found') const legs = data.routes[0].legs return legs.map((leg: { distance: number; duration: number }, i: number): RouteSegment => { const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] const walkingDuration = leg.distance / (5000 / 3600) return { mid, from, to, distance: leg.distance, duration: leg.duration, walkingText: formatDuration(walkingDuration), drivingText: formatDuration(leg.duration), distanceText: formatRouteDistance(leg.distance), } }) } /** * One OSRM call per waypoint-run that returns BOTH the real road geometry (for the * map) and per-leg distance/duration (for the sidebar connectors). Results are cached * by the exact waypoint list. Throws on OSRM failure so callers can fall back to a * straight line. */ export async function calculateRouteWithLegs( waypoints: Waypoint[], { signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {} ): Promise { if (!waypoints || waypoints.length < 2) { return { coordinates: [], distance: 0, duration: 0, legs: [] } } const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') // The cached result carries formatted leg distances, so the active distance unit is // part of the key — otherwise switching km↔mi would return stale text (#1300). // A plugin route is trip-/day-specific (it may return different charging stops for // the same coordinates on a different day), so its key includes tripId/dayId; // the built-in OSRM profiles are context-free and leave those out. const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : '' const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}` const cached = routeCache.get(cacheKey) if (cached) return cached // Plugin profile (`plugin:/`): the server invokes that routeProvider // and normalizes its answer; null means the provider failed or refused, and the // throw makes callers fall back to straight lines exactly like an OSRM outage. const pluginProfile = parsePluginProfile(profile) if (pluginProfile) { if (tripId == null) throw new Error('Plugin routing needs a trip context') const { route } = await pluginsApi.pluginRoute(pluginProfile.pluginId, pluginProfile.profileId, { tripId, dayId: dayId ?? null, waypoints: waypoints.map((p) => ({ lat: p.lat, lng: p.lng })), }, { signal }) if (!route) throw new Error('No route found') const legs: RouteSegment[] = route.legs.map((leg, i): RouteSegment => { const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] return { mid, from, to, distance: leg.distance, duration: leg.duration, walkingText: formatDuration(leg.distance / (5000 / 3600)), drivingText: formatDuration(leg.duration), distanceText: formatRouteDistance(leg.distance), durationText: formatDuration(leg.duration), ...(leg.note ? { noteText: leg.note } : {}), } }) const result: RouteWithLegs = { coordinates: route.coordinates, distance: route.distance, duration: route.duration, legs, ...(route.viaPoints.length ? { vias: route.viaPoints } : {}), } routeCache.set(cacheKey, result) if (routeCache.size > ROUTE_CACHE_MAX) { const oldest = routeCache.keys().next().value if (oldest !== undefined) routeCache.delete(oldest) } return result } // Written as literals rather than narrowing `profile`: its type is an open string union // (plugins name their own modes), which no comparison narrows to the three OSRM knows. const osrmProfile: 'driving' | 'walking' | 'cycling' = profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving' const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration` const response = await fetch(url, { signal }) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found') const route = data.routes[0] const coordinates: [number, number][] = route.geometry.coordinates.map( ([lng, lat]: [number, number]) => [lat, lng] ) const legs: RouteSegment[] = (route.legs || []).map( (leg: { distance: number; duration: number }, i: number): RouteSegment => { const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] const walkingDuration = leg.distance / (5000 / 3600) return { mid, from, to, distance: leg.distance, duration: leg.duration, walkingText: formatDuration(walkingDuration), drivingText: formatDuration(leg.duration), distanceText: formatRouteDistance(leg.distance), durationText: formatDuration(leg.duration), } } ) const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs } routeCache.set(cacheKey, result) if (routeCache.size > ROUTE_CACHE_MAX) { const oldest = routeCache.keys().next().value if (oldest !== undefined) routeCache.delete(oldest) } return result } function getDistanceUnit(): DistanceUnit { return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric' } function formatRouteDistance(meters: number): string { const unit = getDistanceUnit() if (unit === 'metric' && meters < 1000) { return `${Math.round(meters)} m` } return formatDistance(meters / 1000, unit) } function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) if (h > 0) { return `${h} h ${m} min` } return `${m} min` } /** One way of driving a leg, as OSRM offers it. */ export interface RouteAlternative { coordinates: [number, number][] distance: number duration: number /** Where this route differs most from the first one — the point that would pin it. */ divergence: { lat: number; lng: number } | null /** Set when this way exists because a road class was left out of it. */ avoids?: 'motorway' | 'toll' } /** * Road classes worth asking the router to leave out, in the order they are tried. * * OSRM's own alternative search is conservative: it only offers a second road when the * detour is short enough and shares little enough with the first, so on a long motorway * run it usually answers with exactly one route — while a perfectly good, slower way over * the B-roads exists and is what somebody opening "other ways" is looking for. Excluding * a class asks a different question and reliably produces that road. * * Only for driving: the foot and bike profiles have no excludable classes, and asking * anyway earns an `InvalidOptions` for nothing. */ const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const /** Two lines are the same road if their length and their duration agree closely enough. */ function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean { return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60 } /** * One route with a road class left out, or null when the router will not or cannot. * * Every failure is a null: an instance built without excludable classes answers * `InvalidOptions`, a rate limit answers 429, and a leg with no way round the motorway * answers `NoRoute`. None of those is worth an error on screen — they all just mean this * particular question had no answer. */ async function routeExcluding( coords: string, profile: 'driving' | 'walking' | 'cycling', exclude: string, signal?: AbortSignal, ): Promise { try { const url = `${routeBaseFor(profile)}/${coords}?exclude=${exclude}&overview=full&geometries=geojson` const response = await fetch(url, { signal }) if (!response.ok) return null const data = await response.json() const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null if (!route?.geometry?.coordinates?.length) return null return { coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]), distance: route.distance, duration: route.duration, divergence: null, } } catch { return null } } /** * The ways of driving one leg, not one day. * * Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so * asking for a whole day would mean splitting it into one request per leg — which is the * bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once * instead of trickling in. Asked for a single leg, on demand, it costs one extra request. * * `divergence` is the point on each alternative that lies furthest from the default route. * It is what makes a choice persistable: saving that point as a via forces the router back * onto this road on every future request, without storing a polyline that would go stale * with the next OSM update. */ export async function calculateAlternatives( from: Waypoint, to: Waypoint, profile: 'driving' | 'walking' | 'cycling' = 'driving', { signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {}, ): Promise { const coords = `${from.lng},${from.lat};${to.lng},${to.lat}` const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson` const response = await fetch(url, { signal }) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return [] const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({ coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]), distance: r.distance, duration: r.duration, divergence: null, })) // Nothing but the router's own preference came back, which is what it answers on most // long legs. Rather than reporting "only one sensible way", ask a different question: // the same drive without the motorway, and failing that without the tolls. Tried one at // a time and stopped as soon as one lands, so a leg that does have a second road costs // one extra request rather than three, and the public hosts' one-per-second limit is // not spent on questions already answered. if (routes.length < 2 && profile === 'driving') { for (const exclude of EXCLUDABLE_CLASSES) { if (signal?.aborted) break const detour = await routeExcluding(coords, profile, exclude, signal) if (detour && !routes.some(r => sameRoad(r, detour))) { detour.avoids = exclude routes.push(detour) break } } } // The first route is what the router would have given anyway; the others are measured // against it so each one can be pinned by the point that makes it different. const [primary, ...rest] = routes for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates) return routes } /** The point of `line` that lies furthest from `reference`, in plain degrees. */ function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null { if (!line.length || !reference.length) return null // Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a // full cross product of two thousand-point lines is not worth the milliseconds. const step = Math.max(1, Math.floor(reference.length / 200)) let best: { lat: number; lng: number } | null = null let bestDist = -1 for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) { const [lat, lng] = line[i] let nearest = Infinity for (let j = 0; j < reference.length; j += step) { const dLat = lat - reference[j][0] const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180) const d = dLat * dLat + dLng * dLng if (d < nearest) nearest = d } if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } } } return best }