Files
marco/app/services/osm.js
Râu Cao 2aa59f9384 Fetch place details from OSM API, support relations
* Much faster
* Has more place details, which allows us to locate relations, in
  addition to nodes and ways
2026-02-20 12:34:48 +04:00

239 lines
6.9 KiB
JavaScript

import Service, { service } from '@ember/service';
import { getLocalizedName } from '../utils/osm';
export default class OsmService extends Service {
@service settings;
controller = null;
cachedResults = null;
lastQueryKey = null;
async getNearbyPois(lat, lon, radius = 50) {
const queryKey = `${lat},${lon},${radius}`;
// Return cached results if the query is identical to the last one
if (this.lastQueryKey === queryKey && this.cachedResults) {
console.debug('Returning cached Overpass results for:', queryKey);
return this.cachedResults;
}
// Cancel previous request if it exists
if (this.controller) {
this.controller.abort();
}
this.controller = new AbortController();
const signal = this.controller.signal;
const query = `
[out:json][timeout:25];
(
nw["amenity"](around:${radius},${lat},${lon});
nw["shop"](around:${radius},${lat},${lon});
nw["tourism"](around:${radius},${lat},${lon});
nw["leisure"](around:${radius},${lat},${lon});
nw["historic"](around:${radius},${lat},${lon});
);
out center;
`.trim();
const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
try {
const res = await this.fetchWithRetry(url, { signal });
if (!res.ok) throw new Error('Overpass request failed');
const data = await res.json();
// Normalize data
const results = data.elements.map(this.normalizePoi);
// Update cache
this.lastQueryKey = queryKey;
this.cachedResults = results;
return results;
} catch (e) {
if (e.name === 'AbortError') {
console.debug('Overpass request aborted');
return [];
}
throw e;
}
}
normalizePoi(poi) {
return {
title: getLocalizedName(poi.tags),
lat: poi.lat || poi.center?.lat,
lon: poi.lon || poi.center?.lon,
url: poi.tags?.website,
osmId: String(poi.id),
osmType: poi.type,
osmTags: poi.tags || {},
description: poi.tags?.description,
};
}
async fetchWithRetry(url, options = {}, retries = 3) {
try {
// eslint-disable-next-line warp-drive/no-external-request-patterns
const res = await fetch(url, options);
if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) {
console.warn(
`Overpass request failed with ${res.status}. Retrying... (${retries} left)`
);
await new Promise((r) => setTimeout(r, 1000));
return this.fetchWithRetry(url, options, retries - 1);
}
return res;
} catch (e) {
if (retries > 0 && e.name !== 'AbortError') {
console.debug(`Retrying Overpass request... (${retries} left)`);
await new Promise((r) => setTimeout(r, 1000));
return this.fetchWithRetry(url, options, retries - 1);
}
throw e;
}
}
async getPoiById(id, type = null) {
// If type is provided, we can be specific.
// If not, we query both node and way.
let query;
if (type === 'node') {
query = `[out:json][timeout:25];node(${id});out center;`;
} else if (type === 'way') {
query = `[out:json][timeout:25];way(${id});out center;`;
} else {
query = `
[out:json][timeout:25];
(
node(${id});
way(${id});
);
out center;
`.trim();
}
const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
const res = await this.fetchWithRetry(url);
if (!res.ok) throw new Error('Overpass request failed');
const data = await res.json();
if (!data.elements[0]) return null;
return this.normalizePoi(data.elements[0]);
}
async fetchOsmObject(osmId, osmType) {
if (!osmId || !osmType) return null;
let url;
if (osmType === 'node') {
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
} else if (osmType === 'way') {
url = `https://www.openstreetmap.org/api/0.6/way/${osmId}/full.json`;
} else if (osmType === 'relation') {
url = `https://www.openstreetmap.org/api/0.6/relation/${osmId}/full.json`;
} else {
console.error('Unknown OSM type:', osmType);
return null;
}
try {
const res = await this.fetchWithRetry(url);
if (!res.ok) {
if (res.status === 410) {
console.warn('OSM object has been deleted');
return null;
}
throw new Error(`OSM API request failed: ${res.status}`);
}
const data = await res.json();
return this.normalizeOsmApiData(data.elements, osmId, osmType);
} catch (e) {
console.error('Failed to fetch OSM object:', e);
return null;
}
}
normalizeOsmApiData(elements, targetId, targetType) {
if (!elements || elements.length === 0) return null;
const mainElement = elements.find(
(el) => String(el.id) === String(targetId) && el.type === targetType
);
if (!mainElement) return null;
let lat = mainElement.lat;
let lon = mainElement.lon;
// If it's a way, calculate center from nodes
if (targetType === 'way' && mainElement.nodes) {
const nodeMap = new Map();
elements.forEach((el) => {
if (el.type === 'node') {
nodeMap.set(el.id, [el.lon, el.lat]);
}
});
const coords = mainElement.nodes
.map((id) => nodeMap.get(id))
.filter(Boolean);
if (coords.length > 0) {
// Simple average center
const sumLat = coords.reduce((sum, c) => sum + c[1], 0);
const sumLon = coords.reduce((sum, c) => sum + c[0], 0);
lat = sumLat / coords.length;
lon = sumLon / coords.length;
}
} else if (targetType === 'relation' && mainElement.members) {
// Find all nodes that are part of this relation (directly or via ways)
const allNodes = [];
const nodeMap = new Map();
elements.forEach((el) => {
if (el.type === 'node') {
nodeMap.set(el.id, el);
}
});
mainElement.members.forEach((member) => {
if (member.type === 'node') {
const node = nodeMap.get(member.ref);
if (node) allNodes.push(node);
} else if (member.type === 'way') {
const way = elements.find(
(el) => el.type === 'way' && el.id === member.ref
);
if (way && way.nodes) {
way.nodes.forEach((nodeId) => {
const node = nodeMap.get(nodeId);
if (node) allNodes.push(node);
});
}
}
});
if (allNodes.length > 0) {
const sumLat = allNodes.reduce((sum, n) => sum + n.lat, 0);
const sumLon = allNodes.reduce((sum, n) => sum + n.lon, 0);
lat = sumLat / allNodes.length;
lon = sumLon / allNodes.length;
}
}
return {
title: getLocalizedName(mainElement.tags),
lat,
lon,
url: mainElement.tags?.website,
osmId: String(mainElement.id),
osmType: mainElement.type,
osmTags: mainElement.tags || {},
description: mainElement.tags?.description,
};
}
}