Trek_CN/client/src/components/Map/RouteCalculator.ts
jubnl 727692cf4e feat: switch client route calculation and external links to Amap
- RouteCalculator now calls POST /api/routes/plan instead of OSRM directly
- generateGoogleMapsUrl renamed to generateAmapUrl with Amap URI format
- placeGoogleMaps.ts and placeOpenStreetMap.ts now return Amap marker URLs
- All related tests updated (32 RouteCalculator + 104 DayPlanSidebar + 23 hooks integration tests pass)
2026-07-11 18:14:20 +08:00

322 lines
12 KiB
TypeScript

import { useSettingsStore } from '../../store/settingsStore'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import { formatDistance } from '../../utils/units'
import axios from '../../api/client'
const routeCache = new Map<string, RouteWithLegs>()
const ROUTE_CACHE_MAX = 200
export async function calculateRoute(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling' = 'driving',
{ signal }: { signal?: AbortSignal } = {}
): Promise<RouteResult> {
if (!waypoints || waypoints.length < 2) {
throw new Error('At least 2 waypoints required')
}
const allCoords: [number, number][] = []
let totalDistance = 0
let totalDuration = 0
for (let i = 0; i < waypoints.length - 1; i++) {
const a = waypoints[i]
const b = waypoints[i + 1]
const pairKey = `${profile}:${getDistanceUnit()}:${a.lng},${a.lat}:${b.lng},${b.lat}`
let pairResult = routeCache.get(pairKey)
if (!pairResult) {
try {
const response = await axios.post('/routes/plan', {
mode: profile,
points: [a, b],
}, { signal })
const data = response.data
if (data && data.supported) {
const coords: [number, number][] = (data.coordinates || []).map(
(p: { lat: number; lng: number }) => [p.lat, p.lng] as [number, number]
)
const dist = coords.length >= 2 ? (data.distanceMeters || 0) : 0
const dur = coords.length >= 2 ? (data.durationSeconds || 0) : 0
const walkingDuration = dist / (5000 / 3600)
const leg: RouteSegment = {
mid: [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2],
from: [a.lat, a.lng],
to: [b.lat, b.lng],
distance: dist,
duration: dur,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(dur),
distanceText: formatRouteDistance(dist),
durationText: formatDuration(dur),
}
pairResult = { coordinates: coords, distance: dist, duration: dur, legs: [leg] }
routeCache.set(pairKey, pairResult)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
}
} catch (err) {
if (err instanceof Error && (err.name === 'CanceledError' || err.name === 'AbortError')) throw err
throw new Error('Route could not be calculated')
}
}
if (!pairResult || pairResult.coordinates.length < 2) {
throw new Error('No route found')
}
allCoords.push(...pairResult.coordinates)
totalDistance += pairResult.distance
totalDuration += pairResult.duration
}
const walkingDuration = totalDistance / (5000 / 3600)
return {
coordinates: allCoords,
distance: totalDistance,
duration: totalDuration,
distanceText: formatRouteDistance(totalDistance),
durationText: formatDuration(totalDuration),
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(totalDuration),
}
}
/**
* 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: Waypoint[][],
firstWay: Waypoint | undefined,
lastWay: Waypoint | undefined,
startHotel: Waypoint | null,
endHotel: Waypoint | null,
): Waypoint[][] {
const out: Waypoint[][] = []
if (startHotel && firstWay) out.push([startHotel, firstWay])
out.push(...runs)
if (endHotel && lastWay) out.push([lastWay, endHotel])
return out
}
export function generateAmapUrl(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://uri.amap.com/marker?position=${valid[0].lng},${valid[0].lat}`
}
return `https://uri.amap.com/navigation?from=${valid[0].lng},${valid[0].lat}&to=${valid[valid.length - 1].lng},${valid[valid.length - 1].lat}`
}
// 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<T extends Waypoint>(valid: T[], start?: Waypoint): T[] {
const visited = new Set<number>()
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<T extends Waypoint>(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<T extends Waypoint>(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
}
export async function calculateSegments(
waypoints: Waypoint[],
{ signal }: { signal?: AbortSignal } = {}
): Promise<RouteSegment[]> {
if (!waypoints || waypoints.length < 2) return []
const result = await calculateRouteWithLegs(waypoints, { signal, profile: 'driving' })
return result.legs
}
/**
* Fetches real road geometry and per-leg distance/duration from the server-side
* AMap adapter. Results are cached by the exact waypoint list. Throws on failure
* so callers can fall back to a straight line.
*/
export async function calculateRouteWithLegs(
waypoints: Waypoint[],
{ signal, profile = 'driving' }: { signal?: AbortSignal; profile?: 'driving' | 'walking' | 'cycling' } = {}
): Promise<RouteWithLegs> {
if (!waypoints || waypoints.length < 2) {
return { coordinates: [], distance: 0, duration: 0, legs: [] }
}
const allCoords: [number, number][] = []
const allLegs: RouteSegment[] = []
let totalDistance = 0
let totalDuration = 0
for (let i = 0; i < waypoints.length - 1; i++) {
const a = waypoints[i]
const b = waypoints[i + 1]
const pairKey = `${profile}:${getDistanceUnit()}:${a.lng},${a.lat}:${b.lng},${b.lat}`
let pairResult = routeCache.get(pairKey)
if (!pairResult) {
try {
const response = await axios.post('/routes/plan', {
mode: profile,
points: [a, b],
}, { signal })
const data = response.data
if (data && data.supported) {
const coords: [number, number][] = (data.coordinates || []).map(
(p: { lat: number; lng: number }) => [p.lat, p.lng] as [number, number]
)
const dist = coords.length >= 2 ? (data.distanceMeters || 0) : 0
const dur = coords.length >= 2 ? (data.durationSeconds || 0) : 0
const walkingDuration = dist / (5000 / 3600)
const leg: RouteSegment = {
mid: [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2],
from: [a.lat, a.lng],
to: [b.lat, b.lng],
distance: dist,
duration: dur,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(dur),
distanceText: formatRouteDistance(dist),
durationText: formatDuration(dur),
}
pairResult = { coordinates: coords, distance: dist, duration: dur, legs: [leg] }
routeCache.set(pairKey, pairResult)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
}
} catch (err) {
if (err instanceof Error && (err.name === 'CanceledError' || err.name === 'AbortError')) throw err
}
}
if (pairResult && pairResult.coordinates.length >= 2) {
allCoords.push(...pairResult.coordinates)
allLegs.push(...pairResult.legs)
totalDistance += pairResult.distance
totalDuration += pairResult.duration
} else {
allCoords.push([a.lat, a.lng], [b.lat, b.lng])
}
}
return { coordinates: allCoords, distance: totalDistance, duration: totalDuration, legs: allLegs }
}
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`
}