Also implements a short term cache for OSM place data, so we can load it multiple times without multiplying network requests where needed
464 lines
13 KiB
JavaScript
464 lines
13 KiB
JavaScript
import Service, { service } from '@ember/service';
|
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
|
import { getCategoryById } from '../utils/poi-categories';
|
|
|
|
export default class OsmService extends Service {
|
|
@service settings;
|
|
|
|
controller = null;
|
|
cachedResults = null;
|
|
lastQueryKey = null;
|
|
cachedPlaces = new Map();
|
|
|
|
cancelAll() {
|
|
if (this.controller) {
|
|
this.controller.abort();
|
|
this.controller = 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 typeKeys = [
|
|
'amenity',
|
|
'shop',
|
|
'tourism',
|
|
'historic',
|
|
'leisure',
|
|
'office',
|
|
'craft',
|
|
'building',
|
|
'landuse',
|
|
'public_transport',
|
|
'aeroway',
|
|
];
|
|
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
|
|
|
const negativeFilters = {
|
|
public_transport: ['stop_area', 'platform'],
|
|
};
|
|
|
|
const negativeFiltersQuery = Object.entries(negativeFilters)
|
|
.map(([key, values]) => {
|
|
const valueRegex = `^(${values.join('|')})$`;
|
|
return `["${key}"!~"${valueRegex}"]`;
|
|
})
|
|
.join('');
|
|
|
|
const query = `
|
|
[out:json][timeout:25];
|
|
(
|
|
node(around:${radius},${lat},${lon})
|
|
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
|
way(around:${radius},${lat},${lon})
|
|
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
|
relation(around:${radius},${lat},${lon})
|
|
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
|
);
|
|
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;
|
|
}
|
|
}
|
|
|
|
async getCategoryPois(bounds, categoryId, lat, lon) {
|
|
const category = getCategoryById(categoryId);
|
|
if (!category || !bounds) return [];
|
|
|
|
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
|
|
|
if (queryKey && this.lastQueryKey === queryKey && this.cachedResults) {
|
|
console.debug('Returning cached category results for:', queryKey);
|
|
return this.cachedResults;
|
|
}
|
|
|
|
if (this.controller) {
|
|
this.controller.abort();
|
|
}
|
|
this.controller = new AbortController();
|
|
const signal = this.controller.signal;
|
|
|
|
const { minLat, minLon, maxLat, maxLon } = bounds;
|
|
|
|
// Build the query parts for each filter string and type
|
|
const queryParts = [];
|
|
|
|
// Default types if not specified (legacy fallback)
|
|
const types = category.types || ['node', 'way', 'relation'];
|
|
|
|
category.filter.forEach((filterString) => {
|
|
types.forEach((type) => {
|
|
// We ensure we only fetch named POIs to reduce noise
|
|
queryParts.push(`${type}${filterString}[~"^name"~"."];`);
|
|
});
|
|
});
|
|
|
|
const query = `
|
|
[out:json][timeout:25][bbox:${minLat},${minLon},${maxLat},${maxLon}];
|
|
(
|
|
${queryParts.join('\n ')}
|
|
);
|
|
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();
|
|
const results = data.elements.map(this.normalizePoi);
|
|
|
|
if (queryKey) {
|
|
this.lastQueryKey = queryKey;
|
|
this.cachedResults = results;
|
|
}
|
|
|
|
return results;
|
|
} catch (e) {
|
|
if (e.name === 'AbortError') {
|
|
console.debug('Category search aborted');
|
|
return [];
|
|
}
|
|
console.error('Category search failed', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
normalizePoi(poi) {
|
|
const tags = poi.tags || {};
|
|
const type = getPlaceType(tags) || 'Point of Interest';
|
|
|
|
return {
|
|
title: getLocalizedName(tags),
|
|
lat: poi.lat || poi.center?.lat,
|
|
lon: poi.lon || poi.center?.lon,
|
|
url: tags.website,
|
|
osmId: String(poi.id),
|
|
osmType: poi.type,
|
|
osmTags: tags,
|
|
description: tags.description,
|
|
source: 'osm',
|
|
type: type,
|
|
};
|
|
}
|
|
|
|
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;
|
|
|
|
const cacheKey = `${osmType}:${osmId}`;
|
|
const cached = this.cachedPlaces.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < 10000) {
|
|
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
|
return cached.data;
|
|
}
|
|
|
|
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();
|
|
const normalizedData = this.normalizeOsmApiData(
|
|
data.elements,
|
|
osmId,
|
|
osmType
|
|
);
|
|
|
|
this.cachedPlaces.set(cacheKey, {
|
|
data: normalizedData,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Cleanup cache entry automatically after 10 seconds
|
|
setTimeout(() => {
|
|
this.cachedPlaces.delete(cacheKey);
|
|
}, 10000);
|
|
|
|
return normalizedData;
|
|
} catch (e) {
|
|
console.error('Failed to fetch OSM object:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
normalizeOsmApiData(elements, targetId, targetType) {
|
|
if (!elements || elements.length === 0) return null;
|
|
|
|
let mainElement = elements.find(
|
|
(el) => String(el.id) === String(targetId) && el.type === targetType
|
|
);
|
|
|
|
if (!mainElement) return null;
|
|
|
|
// Use a separate variable for the element we want to display (tags, id, specific coords)
|
|
// vs the element we use for geometry calculation (bbox).
|
|
let displayElement = mainElement;
|
|
|
|
// If it's a boundary relation, try to find the label or admin_centre node
|
|
// and use that as the display element (better coordinates and tags).
|
|
if (targetType === 'relation' && mainElement.members) {
|
|
const labelMember = mainElement.members.find(
|
|
(m) => m.role === 'label' && m.type === 'node'
|
|
);
|
|
const adminCentreMember = mainElement.members.find(
|
|
(m) => m.role === 'admin_centre' && m.type === 'node'
|
|
);
|
|
|
|
const targetMember = labelMember || adminCentreMember;
|
|
|
|
if (targetMember) {
|
|
const targetNode = elements.find(
|
|
(el) =>
|
|
String(el.id) === String(targetMember.ref) && el.type === 'node'
|
|
);
|
|
if (targetNode) {
|
|
displayElement = targetNode;
|
|
}
|
|
}
|
|
}
|
|
|
|
let lat = displayElement.lat;
|
|
let lon = displayElement.lon;
|
|
let bbox = null;
|
|
let geojson = null;
|
|
|
|
// 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) {
|
|
// Only override lat/lon if we haven't switched to a specific display node
|
|
if (displayElement === mainElement) {
|
|
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;
|
|
}
|
|
|
|
// Calculate BBox
|
|
const lats = coords.map((c) => c[1]);
|
|
const lons = coords.map((c) => c[0]);
|
|
bbox = {
|
|
minLat: Math.min(...lats),
|
|
maxLat: Math.max(...lats),
|
|
minLon: Math.min(...lons),
|
|
maxLon: Math.max(...lons),
|
|
};
|
|
|
|
// Construct GeoJSON
|
|
if (coords.length > 1) {
|
|
const first = coords[0];
|
|
const last = coords[coords.length - 1];
|
|
const isClosed = first[0] === last[0] && first[1] === last[1];
|
|
|
|
if (isClosed) {
|
|
geojson = {
|
|
type: 'Polygon',
|
|
coordinates: [coords],
|
|
};
|
|
} else {
|
|
geojson = {
|
|
type: 'LineString',
|
|
coordinates: coords,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} 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);
|
|
}
|
|
});
|
|
|
|
const segments = [];
|
|
|
|
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) {
|
|
const wayCoords = [];
|
|
way.nodes.forEach((nodeId) => {
|
|
const node = nodeMap.get(nodeId);
|
|
if (node) {
|
|
allNodes.push(node);
|
|
wayCoords.push([node.lon, node.lat]);
|
|
}
|
|
});
|
|
if (wayCoords.length > 1) {
|
|
segments.push(wayCoords);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (allNodes.length > 0) {
|
|
// Only override lat/lon if we haven't switched to a specific display node
|
|
if (displayElement === mainElement) {
|
|
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;
|
|
}
|
|
|
|
// Calculate BBox
|
|
const lats = allNodes.map((n) => n.lat);
|
|
const lons = allNodes.map((n) => n.lon);
|
|
bbox = {
|
|
minLat: Math.min(...lats),
|
|
maxLat: Math.max(...lats),
|
|
minLon: Math.min(...lons),
|
|
maxLon: Math.max(...lons),
|
|
};
|
|
}
|
|
|
|
if (segments.length > 0) {
|
|
geojson = {
|
|
type: 'MultiLineString',
|
|
coordinates: segments,
|
|
};
|
|
}
|
|
}
|
|
|
|
const tags = displayElement.tags || {};
|
|
const type = getPlaceType(tags) || 'Point of Interest';
|
|
|
|
return {
|
|
title: getLocalizedName(tags),
|
|
lat,
|
|
lon,
|
|
bbox,
|
|
geojson,
|
|
url: tags.website,
|
|
osmId: String(displayElement.id),
|
|
osmType: displayElement.type,
|
|
osmTags: tags,
|
|
description: tags.description,
|
|
source: 'osm',
|
|
type: type,
|
|
};
|
|
}
|
|
}
|