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
This commit is contained in:
2026-02-20 12:34:48 +04:00
parent bcf8ca4255
commit 2aa59f9384
5 changed files with 242 additions and 5 deletions

View File

@@ -129,7 +129,7 @@ export default class PlaceDetails extends Component {
const lat = this.place.lat;
const lon = this.place.lon;
if (!lat || !lon) return '';
return `${lat}, ${lon}`;
return `${Number(lat).toFixed(6)}, ${Number(lon).toFixed(6)}`;
}
get osmUrl() {

View File

@@ -9,7 +9,11 @@ export default class PlaceRoute extends Route {
async model(params) {
const id = params.place_id;
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
if (
id.startsWith('osm:node:') ||
id.startsWith('osm:way:') ||
id.startsWith('osm:relation:')
) {
const [, type, osmId] = id.split(':');
console.debug(`Fetching explicit OSM ${type}:`, osmId);
return this.loadOsmPlace(osmId, type);
@@ -62,7 +66,8 @@ export default class PlaceRoute extends Route {
async loadOsmPlace(id, type = null) {
try {
const poi = await this.osm.getPoiById(id, type);
// Use the direct OSM API fetch instead of Overpass for single object lookups
const poi = await this.osm.fetchOsmObject(id, type);
if (poi) {
console.debug('Found OSM POI:', poi);
return poi;

View File

@@ -124,4 +124,115 @@ out center;
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,
};
}
}

View File

@@ -25,6 +25,16 @@ class MockOsmService extends Service {
osmType: 'node',
};
}
async fetchOsmObject(id, type) {
return {
osmId: id,
osmType: type,
lat: 1,
lon: 1,
osmTags: { name: 'Test Place', amenity: 'cafe' },
title: 'Test Place',
};
}
}
class MockStorageService extends Service {
@@ -82,7 +92,6 @@ module('Acceptance | navigation', function (hooks) {
// Click the Close (X) button
await click('.close-btn');
assert.strictEqual(currentURL(), '/', 'Returned to index');
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
@@ -95,7 +104,6 @@ module('Acceptance | navigation', function (hooks) {
assert.ok(currentURL().includes('/place/'), 'Visited place directly');
await click('.back-btn');
assert.strictEqual(currentURL(), '/', 'Returned to index/map');
assert.true(backStub.notCalled, 'window.history.back() was NOT called');

View File

@@ -0,0 +1,113 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
module('Unit | Service | osm', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let service = this.owner.lookup('service:osm');
assert.ok(service);
});
test('normalizeOsmApiData handles nodes correctly', function (assert) {
let service = this.owner.lookup('service:osm');
const elements = [
{
id: 123,
type: 'node',
lat: 52.5,
lon: 13.4,
tags: { name: 'Test Node' },
},
];
const result = service.normalizeOsmApiData(elements, 123, 'node');
assert.strictEqual(result.title, 'Test Node');
assert.strictEqual(result.lat, 52.5);
assert.strictEqual(result.lon, 13.4);
assert.strictEqual(result.osmId, '123');
assert.strictEqual(result.osmType, 'node');
});
test('normalizeOsmApiData calculates centroid for ways', function (assert) {
let service = this.owner.lookup('service:osm');
const elements = [
{
id: 456,
type: 'way',
nodes: [1, 2],
tags: { name: 'Test Way' },
},
{ id: 1, type: 'node', lat: 10, lon: 10 },
{ id: 2, type: 'node', lat: 20, lon: 20 },
];
const result = service.normalizeOsmApiData(elements, 456, 'way');
assert.strictEqual(result.title, 'Test Way');
assert.strictEqual(result.lat, 15); // (10+20)/2
assert.strictEqual(result.lon, 15); // (10+20)/2
assert.strictEqual(result.osmId, '456');
assert.strictEqual(result.osmType, 'way');
});
test('normalizeOsmApiData calculates centroid for relations with member nodes', function (assert) {
let service = this.owner.lookup('service:osm');
const elements = [
{
id: 789,
type: 'relation',
members: [
{ type: 'node', ref: 1, role: 'admin_centre' },
{ type: 'node', ref: 2, role: 'label' },
],
tags: { name: 'Test Relation' },
},
{ id: 1, type: 'node', lat: 10, lon: 10 },
{ id: 2, type: 'node', lat: 30, lon: 30 },
];
const result = service.normalizeOsmApiData(elements, 789, 'relation');
assert.strictEqual(result.title, 'Test Relation');
assert.strictEqual(result.lat, 20); // (10+30)/2
assert.strictEqual(result.lon, 20); // (10+30)/2
assert.strictEqual(result.osmId, '789');
assert.strictEqual(result.osmType, 'relation');
});
test('normalizeOsmApiData calculates centroid for relations with member ways', function (assert) {
let service = this.owner.lookup('service:osm');
/*
Relation 999
-> Way 888
-> Node 1 (10, 10)
-> Node 2 (20, 20)
*/
const elements = [
{
id: 999,
type: 'relation',
members: [{ type: 'way', ref: 888, role: 'outer' }],
tags: { name: 'Complex Relation' },
},
{
id: 888,
type: 'way',
nodes: [1, 2],
},
{ id: 1, type: 'node', lat: 10, lon: 10 },
{ id: 2, type: 'node', lat: 20, lon: 20 },
];
const result = service.normalizeOsmApiData(elements, 999, 'relation');
assert.strictEqual(result.title, 'Complex Relation');
// It averages all nodes found. In this case, Node 1 and Node 2.
assert.strictEqual(result.lat, 15); // (10+20)/2
assert.strictEqual(result.lon, 15); // (10+20)/2
assert.strictEqual(result.osmId, '999');
assert.strictEqual(result.osmType, 'relation');
});
});