From 2aa59f938486f93db9ccca487936c5bdbd8c847b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Fri, 20 Feb 2026 12:34:48 +0400 Subject: [PATCH] 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 --- app/components/place-details.gjs | 2 +- app/routes/place.js | 9 ++- app/services/osm.js | 111 +++++++++++++++++++++++++++ tests/acceptance/navigation-test.js | 12 ++- tests/unit/services/osm-test.js | 113 ++++++++++++++++++++++++++++ 5 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 tests/unit/services/osm-test.js diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index 5e7ad89..5a868fd 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -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() { diff --git a/app/routes/place.js b/app/routes/place.js index 451f58c..f71397a 100644 --- a/app/routes/place.js +++ b/app/routes/place.js @@ -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; diff --git a/app/services/osm.js b/app/services/osm.js index d4579c6..ed2e4ed 100644 --- a/app/services/osm.js +++ b/app/services/osm.js @@ -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, + }; + } } diff --git a/tests/acceptance/navigation-test.js b/tests/acceptance/navigation-test.js index c3769d9..00e5d1d 100644 --- a/tests/acceptance/navigation-test.js +++ b/tests/acceptance/navigation-test.js @@ -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'); diff --git a/tests/unit/services/osm-test.js b/tests/unit/services/osm-test.js new file mode 100644 index 0000000..1c28b1e --- /dev/null +++ b/tests/unit/services/osm-test.js @@ -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'); + }); +});