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)
This commit is contained in:
jubnl 2026-07-11 18:14:20 +08:00
parent 95c67ec7a8
commit 727692cf4e
9 changed files with 241 additions and 297 deletions

View File

@ -1,109 +1,86 @@
import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
calculateRoute,
calculateSegments,
optimizeRoute,
generateGoogleMapsUrl,
generateAmapUrl,
withHotelBookends,
} from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
const buildOsrmRouteResponse = (distance = 5000, duration = 360) => ({
code: 'Ok',
routes: [
{
geometry: { coordinates: [[2.3522, 48.8566], [2.3600, 48.8600]] },
distance,
duration,
legs: [{ distance, duration }],
vi.mock('../../api/client', () => ({
default: {
post: vi.fn(),
},
}))
const axios = (await import('../../api/client')).default
const mockPost = axios.post as ReturnType<typeof vi.fn>
const buildRouteResponse = (distanceMeters = 5000, durationSeconds = 360) => ({
data: {
supported: true,
mode: 'driving',
distanceMeters,
durationSeconds,
coordinates: [{ lat: 48.8566, lng: 2.3522 }, { lat: 48.8600, lng: 2.3600 }],
legs: [{ mode: 'driving', distanceMeters, durationSeconds, coordinates: [{ lat: 48.8566, lng: 2.3522 }, { lat: 48.8600, lng: 2.3600 }] }],
provider: 'amap',
},
],
})
const wp1 = { lat: 48.8566, lng: 2.3522 }
const wp2 = { lat: 48.8600, lng: 2.3600 }
function wp(lat: number, lng: number) { return { lat, lng } }
beforeEach(() => {
vi.clearAllMocks()
})
// ── calculateRoute ─────────────────────────────────────────────────────────────
describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-001: throws when fewer than 2 waypoints', async () => {
await expect(calculateRoute([wp1])).rejects.toThrow('At least 2 waypoints required')
await expect(calculateRoute([wp(1, 2)])).rejects.toThrow('At least 2 waypoints required')
})
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
const result = await calculateRoute([wp1, wp2])
mockPost.mockResolvedValue(buildRouteResponse())
const result = await calculateRoute([wp(1, 2), wp(3, 4)])
expect(result.coordinates).toEqual([[48.8566, 2.3522], [48.8600, 2.3600]])
})
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
)
)
const result = await calculateRoute([wp1, wp2])
mockPost.mockResolvedValue(buildRouteResponse(1500, 360))
const result = await calculateRoute([wp(5, 6), wp(7, 8)])
expect(result.distanceText).toBe('1.5 km')
})
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(800, 360))
)
)
const result = await calculateRoute([wp1, wp2])
mockPost.mockResolvedValue(buildRouteResponse(800, 360))
const result = await calculateRoute([wp(9, 10), wp(11, 12)])
expect(result.distanceText).toBe('800 m')
})
it('FE-COMP-ROUTECALCULATOR-005: walking profile overrides duration with distance-based calculation', async () => {
const distance = 5000
const osrmDuration = 999
server.use(
http.get(`${OSRM_BASE}/walking/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
)
)
const result = await calculateRoute([wp1, wp2], 'walking')
const expectedDuration = distance / (5000 / 3600)
expect(result.duration).toBeCloseTo(expectedDuration)
expect(result.duration).not.toBe(osrmDuration)
it('FE-COMP-ROUTECALCULATOR-005: walking profile uses server-provided duration', async () => {
mockPost.mockResolvedValue(buildRouteResponse(5000, 3600))
const result = await calculateRoute([wp(13, 14), wp(15, 16)], 'walking')
expect(result.duration).toBe(3600)
})
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json({}, { status: 500 })
)
)
await expect(calculateRoute([wp1, wp2])).rejects.toThrow('Route could not be calculated')
it('FE-COMP-ROUTECALCULATOR-006: throws when server returns unsupported', async () => {
mockPost.mockResolvedValue({ data: { supported: false, reason: 'AMAP_NO_ROUTE' } })
await expect(calculateRoute([wp(17, 18), wp(19, 20)])).rejects.toThrow('No route found')
})
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json({ code: 'NoRoute', routes: [] })
)
)
await expect(calculateRoute([wp1, wp2])).rejects.toThrow('No route found')
it('FE-COMP-ROUTECALCULATOR-007: throws when server returns error', async () => {
const err = new Error('Request failed')
mockPost.mockRejectedValue(err)
await expect(calculateRoute([wp(21, 22), wp(23, 24)])).rejects.toThrow('Route could not be calculated')
})
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
mockPost.mockRejectedValue(Object.assign(new Error('canceled'), { name: 'CanceledError' }))
const controller = new AbortController()
controller.abort()
await expect(calculateRoute([wp1, wp2], 'driving', { signal: controller.signal })).rejects.toThrow()
await expect(calculateRoute([wp(25, 26), wp(27, 28)], 'driving', { signal: controller.signal })).rejects.toThrow()
})
})
@ -111,29 +88,20 @@ describe('calculateRoute', () => {
describe('calculateSegments', () => {
it('FE-COMP-ROUTECALCULATOR-009: returns empty array for fewer than 2 waypoints', async () => {
const result = await calculateSegments([wp1])
const result = await calculateSegments([wp(1, 2)])
expect(result).toEqual([])
})
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json({
code: 'Ok',
routes: [
{
legs: [{ distance: 1000, duration: 120 }],
},
],
})
)
)
const result = await calculateSegments([wp1, wp2])
mockPost.mockResolvedValue(buildRouteResponse(1000, 120))
const a = wp(52.5200, 13.4050)
const b = wp(52.5300, 13.4150)
const result = await calculateSegments([a, b])
expect(result).toHaveLength(1)
const seg = result[0]
const expectedMid: [number, number] = [
(wp1.lat + wp2.lat) / 2,
(wp1.lng + wp2.lng) / 2,
(a.lat + b.lat) / 2,
(a.lng + b.lng) / 2,
]
expect(seg.mid[0]).toBeCloseTo(expectedMid[0])
expect(seg.mid[1]).toBeCloseTo(expectedMid[1])
@ -145,58 +113,53 @@ describe('calculateSegments', () => {
describe('optimizeRoute', () => {
it('FE-COMP-ROUTECALCULATOR-011: returns input unchanged for 2 or fewer places', () => {
const places = [wp1, wp2]
const places = [wp(1, 1), wp(2, 2)]
const result = optimizeRoute(places)
expect(result).toHaveLength(2)
expect(result).toBe(places)
})
it('FE-COMP-ROUTECALCULATOR-012: nearest-neighbor reorders 3 waypoints correctly', () => {
// Note: filter uses `p.lat && p.lng`, so avoid zero values
const a = { lat: 1, lng: 1 }
const b = { lat: 10, lng: 1 }
const c = { lat: 2, lng: 1 }
const a = wp(1, 1)
const b = wp(10, 1)
const c = wp(2, 1)
const result = optimizeRoute([a, b, c])
// Starting from a(1,1), nearest is c(2,1) (dist=1), then b(10,1) (dist=8)
expect(result[0]).toEqual(a)
expect(result[1]).toEqual(c)
expect(result[2]).toEqual(b)
})
it('FE-COMP-ROUTECALCULATOR-016: start anchor begins the chain at the anchor-nearest stop', () => {
const a = { lat: 10, lng: 1 }
const b = { lat: 2, lng: 1 }
const c = { lat: 5, lng: 1 }
// From the accommodation anchor (1,1): nearest is b(2,1), then c(5,1), then a(10,1)
const result = optimizeRoute([a, b, c], { start: { lat: 1, lng: 1 } })
const a = wp(10, 1)
const b = wp(2, 1)
const c = wp(5, 1)
const result = optimizeRoute([a, b, c], { start: wp(1, 1) })
expect(result).toEqual([b, c, a])
})
it('FE-COMP-ROUTECALCULATOR-017: start + end anchors reorder a shuffled day and keep the end-nearest stop last', () => {
const a = { lat: 2, lng: 1 }
const b = { lat: 5, lng: 1 }
const c = { lat: 8, lng: 1 }
// Transfer day: start at hotel A (1,1), end at hotel B (9,1). c is nearest B, so it must be last.
const result = optimizeRoute([c, a, b], { start: { lat: 1, lng: 1 }, end: { lat: 9, lng: 1 } })
const a = wp(2, 1)
const b = wp(5, 1)
const c = wp(8, 1)
const result = optimizeRoute([c, a, b], { start: wp(1, 1), end: wp(9, 1) })
expect(result).toEqual([a, b, c])
})
it('FE-COMP-ROUTECALCULATOR-018: an anchor makes even a two-stop day sortable', () => {
const a = { lat: 10, lng: 1 }
const b = { lat: 2, lng: 1 }
// Without anchors two stops are returned unchanged; the start anchor orders them by proximity.
const result = optimizeRoute([a, b], { start: { lat: 1, lng: 1 } })
const a = wp(10, 1)
const b = wp(2, 1)
const result = optimizeRoute([a, b], { start: wp(1, 1) })
expect(result).toEqual([b, a])
})
it('FE-COMP-ROUTECALCULATOR-019: 2-opt untangles a round-trip into a clean loop around the hotel', () => {
const hotel = { lat: 48.8668, lng: 2.3013 } // Rue Marbeuf
const hotel = wp(48.8668, 2.3013)
const stops = [
{ id: 1, lat: 48.8565, lng: 2.3324 },
{ id: 2, lat: 48.8813, lng: 2.3151 },
{ id: 3, lat: 48.8796, lng: 2.308 },
{ id: 4, lat: 48.8723, lng: 2.2926 },
{ id: 5, lat: 48.866, lng: 2.3102 }, // nearest the hotel
{ id: 5, lat: 48.866, lng: 2.3102 },
]
const d = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) =>
Math.hypot(a.lat - b.lat, a.lng - b.lng)
@ -204,52 +167,44 @@ describe('optimizeRoute', () => {
d(hotel, order[0]) + order.slice(1).reduce((s, p, i) => s + d(order[i], p), 0) + d(order[order.length - 1], hotel)
const result = optimizeRoute(stops, { start: hotel, end: hotel })
// The optimized loop is no longer than the original order…
expect(loop(result)).toBeLessThanOrEqual(loop(stops) + 1e-9)
// …and the hotel-adjacent stop sits at one end of the loop, right next to the hotel.
expect([result[0].id, result[result.length - 1].id]).toContain(5)
})
it('FE-COMP-ROUTECALCULATOR-020: an end anchor without a start finishes at the stop nearest it', () => {
const a = { lat: 2, lng: 1 }
const b = { lat: 5, lng: 1 }
const c = { lat: 9, lng: 1 }
// a is nearest the end anchor, so the route must finish at a rather than start there.
const result = optimizeRoute([a, b, c], { end: { lat: 1, lng: 1 } })
const a = wp(2, 1)
const b = wp(5, 1)
const c = wp(9, 1)
const result = optimizeRoute([a, b, c], { end: wp(1, 1) })
expect(result[result.length - 1]).toEqual(a)
})
})
// ── generateGoogleMapsUrl ──────────────────────────────────────────────────────
// ── generateAmapUrl ────────────────────────────────────────────────────────────
describe('generateGoogleMapsUrl', () => {
describe('generateAmapUrl', () => {
it('FE-COMP-ROUTECALCULATOR-013: returns null for empty places', () => {
expect(generateGoogleMapsUrl([])).toBeNull()
expect(generateAmapUrl([])).toBeNull()
})
it('FE-COMP-ROUTECALCULATOR-014: single place returns search URL', () => {
const result = generateGoogleMapsUrl([{ lat: 48.85, lng: 2.35 }])
expect(result).toBe('https://www.google.com/maps/search/?api=1&query=48.85,2.35')
it('FE-COMP-ROUTECALCULATOR-014: single place returns marker URL', () => {
const result = generateAmapUrl([wp(48.85, 2.35)])
expect(result).toBe('https://uri.amap.com/marker?position=2.35,48.85')
})
it('FE-COMP-ROUTECALCULATOR-015: multiple places returns directions URL', () => {
const result = generateGoogleMapsUrl([
{ lat: 48.85, lng: 2.35 },
{ lat: 48.86, lng: 2.36 },
])
expect(result).toMatch(/^https:\/\/www\.google\.com\/maps\/dir\//)
expect(result).toContain('48.85,2.35')
expect(result).toContain('48.86,2.36')
it('FE-COMP-ROUTECALCULATOR-015: multiple places returns navigation URL', () => {
const result = generateAmapUrl([wp(48.85, 2.35), wp(48.86, 2.36)])
expect(result).toBe('https://uri.amap.com/navigation?from=2.35,48.85&to=2.36,48.86')
})
})
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
// ── withHotelBookends ──────────────────────────────────────────────────────────
describe('withHotelBookends', () => {
const hotel = { lat: 1, lng: 1 }
const a = { lat: 2, lng: 2 }
const b = { lat: 3, lng: 3 }
const evening = { lat: 4, lng: 4 }
const hotel = wp(1, 1)
const a = wp(2, 2)
const b = wp(3, 3)
const evening = wp(4, 4)
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
const runs = [[a, b]]

View File

@ -1,24 +1,11 @@
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 OSRM_BASE = 'https://router.project-osrm.org/route/v1'
// 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',
}
// 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<string, RouteWithLegs>()
const ROUTE_CACHE_MAX = 200
/** 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',
@ -28,44 +15,77 @@ export async function calculateRoute(
throw new Error('At least 2 waypoints required')
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
const allCoords: [number, number][] = []
let totalDistance = 0
let totalDuration = 0
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error('Route could not be calculated')
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),
}
const data = await response.json()
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 (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
if (!pairResult || pairResult.coordinates.length < 2) {
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
allCoords.push(...pairResult.coordinates)
totalDistance += pairResult.distance
totalDuration += pairResult.duration
}
const walkingDuration = distance / (5000 / 3600)
const drivingDuration: number = route.duration
const walkingDuration = totalDistance / (5000 / 3600)
return {
coordinates,
distance,
duration,
distanceText: formatRouteDistance(distance),
durationText: formatDuration(duration),
coordinates: allCoords,
distance: totalDistance,
duration: totalDuration,
distanceText: formatRouteDistance(totalDistance),
durationText: formatDuration(totalDuration),
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration),
drivingText: formatDuration(totalDuration),
}
}
@ -90,14 +110,13 @@ export function withHotelBookends(
return out
}
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
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://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
return `https://uri.amap.com/marker?position=${valid[0].lng},${valid[0].lat}`
}
const stops = valid.map((p) => `${p.lat},${p.lng}`).join('/')
return `https://www.google.com/maps/dir/${stops}`
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.
@ -192,44 +211,20 @@ export function optimizeRoute<T extends Waypoint>(places: T[], anchors: RouteAnc
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<RouteSegment[]> {
if (!waypoints || waypoints.length < 2) return []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
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),
}
})
const result = await calculateRouteWithLegs(waypoints, { signal, profile: 'driving' })
return result.legs
}
/**
* 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.
* 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[],
@ -239,49 +234,69 @@ export async function calculateRouteWithLegs(
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).
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
const allCoords: [number, number][] = []
const allLegs: RouteSegment[] = []
let totalDistance = 0
let totalDuration = 0
const url = `${OSRM_PROFILE_BASE[profile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
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}`
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
let pairResult = routeCache.get(pairKey)
if (!pairResult) {
try {
const response = await axios.post('/routes/plan', {
mode: profile,
points: [a, b],
}, { signal })
const route = data.routes[0]
const coordinates: [number, number][] = route.geometry.coordinates.map(
([lng, lat]: [number, number]) => [lat, lng]
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 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,
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(leg.duration),
distanceText: formatRouteDistance(leg.distance),
durationText: formatDuration(leg.duration),
drivingText: formatDuration(dur),
distanceText: formatRouteDistance(dist),
durationText: formatDuration(dur),
}
}
)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
routeCache.set(cacheKey, result)
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)
}
return result
}
} 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 {

View File

@ -46,7 +46,7 @@ vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(un
vi.mock('../Map/RouteCalculator', () => ({
calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }),
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
generateAmapUrl: vi.fn().mockReturnValue('https://uri.amap.com/...'),
optimizeRoute: vi.fn().mockImplementation((places) => places),
// One leg per waypoint gap; the connector between two stops reads distanceText.
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) => Promise.resolve({

View File

@ -6,7 +6,7 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 're
import { avatarSrc } from '../../utils/avatarSrc'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront } from 'lucide-react'
import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateAmapUrl } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
@ -2364,7 +2364,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng } : null
const evening = routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng } : null
const url = generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])])
const url = generateAmapUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])])
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}

View File

@ -4,32 +4,17 @@ import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
const base = { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, google_place_id: null, google_ftid: null } as any
describe('getGoogleMapsUrlForPlace', () => {
it('FE-PLACE-GMAPS-001: uses a valid ftid for a precise /place link', () => {
const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0x47e66e2964e34e2d:0x8ddca9ee380ef7e0' })
expect(url).toBe('https://www.google.com/maps/place/?q=Eiffel%20Tower&ftid=0x47e66e2964e34e2d:0x8ddca9ee380ef7e0')
})
it('FE-PLACE-GMAPS-002: falls back to query_place_id when there is no ftid', () => {
const url = getGoogleMapsUrlForPlace({ ...base, google_place_id: 'ChIJ123' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123')
})
it('FE-PLACE-GMAPS-003: ignores a malformed/hostile ftid and falls through to the place id', () => {
const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0xAB&q=evil', google_place_id: 'ChIJ123' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123')
})
it('FE-PLACE-GMAPS-004: uses the details URL when there is no ftid or place id', () => {
const url = getGoogleMapsUrlForPlace(base, 'https://maps.google.com/?cid=123')
expect(url).toBe('https://maps.google.com/?cid=123')
})
it('FE-PLACE-GMAPS-005: falls back to coordinates as a last resort', () => {
it('FE-PLACE-GMAPS-001: returns amap marker URL for a place with coordinates', () => {
const url = getGoogleMapsUrlForPlace(base)
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945')
expect(url).toBe('https://uri.amap.com/marker?position=2.2945,48.8584&name=Eiffel%20Tower')
})
it('FE-PLACE-GMAPS-006: returns null for no place or no location', () => {
it('FE-PLACE-GMAPS-002: uses details URL when coordinates are missing', () => {
const url = getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null }, 'https://example.com/details')
expect(url).toBe('https://example.com/details')
})
it('FE-PLACE-GMAPS-003: returns null for no place or no location', () => {
expect(getGoogleMapsUrlForPlace(null)).toBeNull()
expect(getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null })).toBeNull()
})

View File

@ -1,19 +1,12 @@
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i
export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null {
if (!place) return null
const ftid = place.google_ftid?.trim()
if (ftid && GOOGLE_FTID_RE.test(ftid)) {
return `https://www.google.com/maps/place/?q=${encodeURIComponent(place.name)}&ftid=${ftid}`
}
const placeId = place.google_place_id?.trim()
if (placeId) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}`
}
if (detailsUrl) return detailsUrl
if (place.lat == null || place.lng == null) return null
return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`
if (place.lat != null && place.lng != null) {
return `https://uri.amap.com/marker?position=${place.lng},${place.lat}&name=${encodeURIComponent(place.name)}`
}
return null
}

View File

@ -4,19 +4,19 @@ import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
const base = { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 } as any
describe('getOpenStreetMapUrlForPlace', () => {
it('FE-PLACE-OSM-001: drops a marker at the coordinates', () => {
it('FE-PLACE-OSM-001: drops an amap marker at the coordinates', () => {
const url = getOpenStreetMapUrlForPlace(base)
expect(url).toBe('https://www.openstreetmap.org/?mlat=48.8584&mlon=2.2945#map=16/48.8584/2.2945')
expect(url).toBe('https://uri.amap.com/marker?position=2.2945,48.8584&name=Eiffel%20Tower')
})
it('FE-PLACE-OSM-002: keeps coordinate 0 (falsy but valid)', () => {
it('FE-PLACE-OSM-002: handles coordinate 0 (falsy but valid with != null check)', () => {
const url = getOpenStreetMapUrlForPlace({ name: 'Null Island', lat: 0, lng: 0 })
expect(url).toBe('https://www.openstreetmap.org/?mlat=0&mlon=0#map=16/0/0')
expect(url).toBe('https://uri.amap.com/marker?position=0,0&name=Null%20Island')
})
it('FE-PLACE-OSM-003: falls back to a name search when there are no coordinates', () => {
it('FE-PLACE-OSM-003: falls back to a default position when there are no coordinates', () => {
const url = getOpenStreetMapUrlForPlace({ name: 'Café René', lat: null, lng: null })
expect(url).toBe('https://www.openstreetmap.org/search?query=Caf%C3%A9%20Ren%C3%A9')
expect(url).toBe('https://uri.amap.com/marker?position=116.397428,39.90923&name=Caf%C3%A9%20Ren%C3%A9')
})
it('FE-PLACE-OSM-004: returns null with neither coordinates nor a name', () => {

View File

@ -2,16 +2,12 @@ import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng'>
// Open a place on openstreetmap.org — the same map source TREK renders — with a
// marker at its coordinates, so people who prefer OSM (or route it on into OrganicMaps
// / CoMaps) can jump straight there. Falls back to a name search when the place has no
// coordinates. Requested in discussion #880.
export function getOpenStreetMapUrlForPlace(place: PlaceLike | null | undefined): string | null {
if (!place) return null
if (place.lat != null && place.lng != null) {
return `https://www.openstreetmap.org/?mlat=${place.lat}&mlon=${place.lng}#map=16/${place.lat}/${place.lng}`
return `https://uri.amap.com/marker?position=${place.lng},${place.lat}&name=${encodeURIComponent(place.name)}`
}
const name = place.name?.trim()
if (name) return `https://www.openstreetmap.org/search?query=${encodeURIComponent(name)}`
if (name) return `https://uri.amap.com/marker?position=116.397428,39.90923&name=${encodeURIComponent(name)}`
return null
}

View File

@ -13,7 +13,7 @@ vi.mock('../../../src/components/Map/RouteCalculator', async (importActual) => {
calculateRouteWithLegs: vi.fn(),
calculateRoute: vi.fn(),
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
generateGoogleMapsUrl: vi.fn(),
generateAmapUrl: vi.fn(),
};
});