mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(maps): localize map POI pin titles to the user's language
This commit is contained in:
committed by
Maurice
parent
fce26618a1
commit
e100c52676
@@ -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 = {
|
||||
|
||||
@@ -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<Set<string>>(() => new Set())
|
||||
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
|
||||
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +437,7 @@ async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
export async function searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
lang?: string,
|
||||
limit = 60,
|
||||
): Promise<PoiSearchResult> {
|
||||
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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string>) =>
|
||||
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:<lang> 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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user