From e100c52676ff7282a2bcc6c22dd25ac60bc2f53a Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Fri, 24 Jul 2026 11:00:03 +0300 Subject: [PATCH] fix(maps): localize map POI pin titles to the user's language --- client/src/api/client.ts | 4 +- client/src/components/Map/usePoiExplore.ts | 6 ++- server/src/nest/maps/maps.controller.ts | 3 +- server/src/nest/maps/maps.service.ts | 4 +- server/src/services/mapsService.ts | 12 ++++- .../tests/unit/nest/maps.controller.test.ts | 6 +-- server/tests/unit/nest/maps.service.test.ts | 6 +-- .../tests/unit/services/mapsService.test.ts | 52 +++++++++++++++++++ 8 files changed, 78 insertions(+), 15 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index c12c13131..28c8a2890 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -885,8 +885,8 @@ export const mapsApi = { // OSM-only POI explore: places of a category within the current map viewport bbox. // Overpass can be slow on a fresh (uncached) area, so this call gets a longer // timeout than the global default instead of aborting at 8s and showing nothing. - pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) => - apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }), + pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) => + apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }), } export const airportsApi = { diff --git a/client/src/components/Map/usePoiExplore.ts b/client/src/components/Map/usePoiExplore.ts index 793d3c0f7..710adc757 100644 --- a/client/src/components/Map/usePoiExplore.ts +++ b/client/src/components/Map/usePoiExplore.ts @@ -1,5 +1,6 @@ import { useState, useRef, useCallback, useMemo } from 'react' import { mapsApi } from '../../api/client' +import { useTranslation } from '../../i18n' import type { Poi } from './poiCategories' export interface Bbox { south: number; west: number; north: number; east: number } @@ -17,6 +18,7 @@ function isAbortError(err: unknown): boolean { * Overpass load (and visual churn) down. */ export function usePoiExplore() { + const { locale } = useTranslation() const [active, setActive] = useState>(() => new Set()) const [byCat, setByCat] = useState>({}) const [loadingKeys, setLoadingKeys] = useState>(() => new Set()) @@ -54,7 +56,7 @@ export function usePoiExplore() { setLoading(key, true) setError(key, false) try { - const res = await mapsApi.pois(key, bbox, ctrl.signal) + const res = await mapsApi.pois(key, bbox, locale, ctrl.signal) // Drop the result if the user toggled this category off while the (slow) // Overpass request was in flight — otherwise stale results re-appear. setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev)) @@ -74,7 +76,7 @@ export function usePoiExplore() { delete abortRef.current[key] } } - }, [setLoading, setError]) + }, [setLoading, setError, locale]) const onViewportChange = useCallback((bbox: Bbox) => { bboxRef.current = bbox diff --git a/server/src/nest/maps/maps.controller.ts b/server/src/nest/maps/maps.controller.ts index 7cfb56c50..a53239e50 100644 --- a/server/src/nest/maps/maps.controller.ts +++ b/server/src/nest/maps/maps.controller.ts @@ -80,6 +80,7 @@ export class MapsController { @Query('west') west?: string, @Query('north') north?: string, @Query('east') east?: string, + @Query('lang') lang?: string, ) { if (!category) throw new HttpException({ error: 'A category is required' }, 400); const bbox = { south: Number(south), west: Number(west), north: Number(north), east: Number(east) }; @@ -87,7 +88,7 @@ export class MapsController { throw new HttpException({ error: 'A valid bbox (south, west, north, east) is required' }, 400); } try { - return await this.maps.pois(category, bbox); + return await this.maps.pois(category, bbox, lang); } catch (err: unknown) { throw toHttpException(err, 'POI search error', 500); } diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 87c71384a..91c2d6686 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -89,7 +89,7 @@ export class MapsService { } // OSM-only POI search by category within a viewport bbox (never calls Google). - pois(category: string, bbox: { south: number; west: number; north: number; east: number }) { - return searchOverpassPois(category, bbox); + pois(category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string) { + return searchOverpassPois(category, bbox, lang); } } diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 70280ef07..074c91d63 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -437,6 +437,7 @@ async function overpassFetch(query: string): Promise { export async function searchOverpassPois( category: string, bbox: { south: number; west: number; north: number; east: number }, + lang?: string, limit = 60, ): Promise { const filters = CATEGORY_OSM_FILTERS[category]; @@ -459,8 +460,15 @@ export async function searchOverpassPois( clamped = true; } + // OSM `name:*` tags are keyed by language subtag: prefer the user's language + // (the same localization the search/autocomplete path asks the geocoder for) + // over the native `name`. `int_name` is OSM's international/romanized name — a + // sensible fallback before the native one. Part of the cache key so a cached + // area isn't served with another language's titles. + const osmLang = toApiLang(lang).split('-')[0].toLowerCase(); + // Serve repeat pans/toggles of the same area straight from the cache. - const cacheKey = `${category}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`; + const cacheKey = `${category}|${osmLang}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`; const cached = POI_CACHE.get(cacheKey); if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value; if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching @@ -482,7 +490,7 @@ export async function searchOverpassPois( const pois: OverpassPoi[] = []; for (const el of elements) { const tags = el.tags || {}; - const name = tags.name || tags['name:en'] || tags.brand || null; + const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || null; if (!name) continue; // unnamed POIs aren't useful to add to a plan const lat = el.lat ?? el.center?.lat; const lng = el.lon ?? el.center?.lon; diff --git a/server/tests/unit/nest/maps.controller.test.ts b/server/tests/unit/nest/maps.controller.test.ts index d4b98306b..a3d9ec18c 100644 --- a/server/tests/unit/nest/maps.controller.test.ts +++ b/server/tests/unit/nest/maps.controller.test.ts @@ -98,11 +98,11 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { expect(pois).not.toHaveBeenCalled(); }); - it('delegates a valid request with a parsed numeric bbox', async () => { + it('delegates a valid request with a parsed numeric bbox and forwards lang', async () => { const pois = vi.fn().mockResolvedValue({ places: [] }); - const res = await makeController({ pois }).pois('cafe', '1', '2', '3', '4'); + const res = await makeController({ pois }).pois('cafe', '1', '2', '3', '4', 'fr'); expect(res).toEqual({ places: [] }); - expect(pois).toHaveBeenCalledWith('cafe', { south: 1, west: 2, north: 3, east: 4 }); + expect(pois).toHaveBeenCalledWith('cafe', { south: 1, west: 2, north: 3, east: 4 }, 'fr'); }); it('maps a service error, defaulting to 500', async () => { diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 25cda7f2c..5b175e4b1 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -109,10 +109,10 @@ describe('MapsService', () => { expect(maps.resolveGoogleMapsUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/x'); }); - it('pois forwards category and bbox through', () => { + it('pois forwards category, bbox and lang through', () => { const bbox = { south: 1, west: 2, north: 3, east: 4 }; - svc().pois('cafe', bbox); - expect(maps.searchOverpassPois).toHaveBeenCalledWith('cafe', bbox); + svc().pois('cafe', bbox, 'de'); + expect(maps.searchOverpassPois).toHaveBeenCalledWith('cafe', bbox, 'de'); }); }); diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index 4086a756c..276357159 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -583,6 +583,58 @@ describe('fetchOverpassDetails (fetch stubbed)', () => { }); }); +// ── searchOverpassPois localized names (#1655) ─────────────────────────────── + +describe('searchOverpassPois localized names (#1655)', () => { + // Elephant and Obelisk in Rome: OSM ships the native name plus localized tags. + const tags = { + name: 'Obelisco della Minerva', + 'name:en': 'Elephant and Obelisk', + 'name:de': 'Minerva-Obelisk', + int_name: 'Elephant Obelisk', + tourism: 'attraction', + }; + const stubOverpass = (elementTags: Record) => + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ elements: [{ type: 'node', id: 1, lat: 41.9, lon: 12.48, tags: elementTags }] }), + }), + ); + // Distinct bboxes per case so the module-level POI_CACHE (keyed by lang+bbox) + // never serves one case's names to another. + const bbox = (n: number) => ({ south: 41 + n / 100, west: 12, north: 42 + n / 100, east: 13 }); + + it('prefers name: for the user language over the native name', async () => { + stubOverpass(tags); + const { searchOverpassPois } = await import('../../../src/services/mapsService'); + const { pois } = await searchOverpassPois('sights', bbox(1), 'en-US'); + expect(pois[0].name).toBe('Elephant and Obelisk'); + }); + + it('localizes to a non-English language too', async () => { + stubOverpass(tags); + const { searchOverpassPois } = await import('../../../src/services/mapsService'); + const { pois } = await searchOverpassPois('sights', bbox(2), 'de-DE'); + expect(pois[0].name).toBe('Minerva-Obelisk'); + }); + + it('falls back to int_name when the language tag is absent', async () => { + stubOverpass({ name: tags.name, int_name: tags.int_name, tourism: 'attraction' }); + const { searchOverpassPois } = await import('../../../src/services/mapsService'); + const { pois } = await searchOverpassPois('sights', bbox(3), 'fr-FR'); + expect(pois[0].name).toBe('Elephant Obelisk'); + }); + + it('falls back to the native name when no localized tag exists', async () => { + stubOverpass({ name: tags.name, tourism: 'attraction' }); + const { searchOverpassPois } = await import('../../../src/services/mapsService'); + const { pois } = await searchOverpassPois('sights', bbox(4), 'fr-FR'); + expect(pois[0].name).toBe('Obelisco della Minerva'); + }); +}); + // ── fetchWikimediaPhoto (fetch stubbed) ─────────────────────────────────────── describe('fetchWikimediaPhoto (fetch stubbed)', () => {