From 727692cf4e61acffb8928af1fc47c1281ec40a70 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Jul 2026 18:14:20 +0800 Subject: [PATCH] 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) --- .../components/Map/RouteCalculator.test.ts | 219 +++++++--------- client/src/components/Map/RouteCalculator.ts | 245 ++++++++++-------- .../Planner/DayPlanSidebar.test.tsx | 2 +- .../src/components/Planner/DayPlanSidebar.tsx | 4 +- .../Planner/placeGoogleMaps.test.ts | 31 +-- .../src/components/Planner/placeGoogleMaps.ts | 15 +- .../Planner/placeOpenStreetMap.test.ts | 12 +- .../components/Planner/placeOpenStreetMap.ts | 8 +- .../hooks/useRouteCalculation.test.ts | 2 +- 9 files changed, 241 insertions(+), 297 deletions(-) diff --git a/client/src/components/Map/RouteCalculator.test.ts b/client/src/components/Map/RouteCalculator.test.ts index 6e847c7d..ac97bcbc 100644 --- a/client/src/components/Map/RouteCalculator.test.ts +++ b/client/src/components/Map/RouteCalculator.test.ts @@ -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' +vi.mock('../../api/client', () => ({ + default: { + post: vi.fn(), + }, +})) -const buildOsrmRouteResponse = (distance = 5000, duration = 360) => ({ - code: 'Ok', - routes: [ - { - geometry: { coordinates: [[2.3522, 48.8566], [2.3600, 48.8600]] }, - distance, - duration, - legs: [{ distance, duration }], - }, - ], +const axios = (await import('../../api/client')).default +const mockPost = axios.post as ReturnType + +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]] diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index 995e0404..6eb5a860 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -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() 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), + } + + 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 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 + 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(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 { 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 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 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 } } - ) - 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) + 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 result + + return { coordinates: allCoords, distance: totalDistance, duration: totalDuration, legs: allLegs } } function getDistanceUnit(): DistanceUnit { diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index a4763248..f2db837f 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -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({ diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index d37dfdfa..df9ac5f6 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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')} diff --git a/client/src/components/Planner/placeGoogleMaps.test.ts b/client/src/components/Planner/placeGoogleMaps.test.ts index 11f0ae4e..e6a47a86 100644 --- a/client/src/components/Planner/placeGoogleMaps.test.ts +++ b/client/src/components/Planner/placeGoogleMaps.test.ts @@ -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() }) diff --git a/client/src/components/Planner/placeGoogleMaps.ts b/client/src/components/Planner/placeGoogleMaps.ts index 3d4e619f..6599423d 100644 --- a/client/src/components/Planner/placeGoogleMaps.ts +++ b/client/src/components/Planner/placeGoogleMaps.ts @@ -1,19 +1,12 @@ import type { AssignmentPlace, Place } from '../../types' type PlaceLike = Pick -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 } diff --git a/client/src/components/Planner/placeOpenStreetMap.test.ts b/client/src/components/Planner/placeOpenStreetMap.test.ts index b386ded3..0d59b8f2 100644 --- a/client/src/components/Planner/placeOpenStreetMap.test.ts +++ b/client/src/components/Planner/placeOpenStreetMap.test.ts @@ -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', () => { diff --git a/client/src/components/Planner/placeOpenStreetMap.ts b/client/src/components/Planner/placeOpenStreetMap.ts index 93ef294a..b3053237 100644 --- a/client/src/components/Planner/placeOpenStreetMap.ts +++ b/client/src/components/Planner/placeOpenStreetMap.ts @@ -2,16 +2,12 @@ import type { AssignmentPlace, Place } from '../../types' type PlaceLike = Pick -// 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 } diff --git a/client/tests/integration/hooks/useRouteCalculation.test.ts b/client/tests/integration/hooks/useRouteCalculation.test.ts index 2a47027e..48ed8361 100644 --- a/client/tests/integration/hooks/useRouteCalculation.test.ts +++ b/client/tests/integration/hooks/useRouteCalculation.test.ts @@ -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(), }; });