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:
@@ -129,7 +129,7 @@ export default class PlaceDetails extends Component {
|
|||||||
const lat = this.place.lat;
|
const lat = this.place.lat;
|
||||||
const lon = this.place.lon;
|
const lon = this.place.lon;
|
||||||
if (!lat || !lon) return '';
|
if (!lat || !lon) return '';
|
||||||
return `${lat}, ${lon}`;
|
return `${Number(lat).toFixed(6)}, ${Number(lon).toFixed(6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get osmUrl() {
|
get osmUrl() {
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export default class PlaceRoute extends Route {
|
|||||||
async model(params) {
|
async model(params) {
|
||||||
const id = params.place_id;
|
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(':');
|
const [, type, osmId] = id.split(':');
|
||||||
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
||||||
return this.loadOsmPlace(osmId, type);
|
return this.loadOsmPlace(osmId, type);
|
||||||
@@ -62,7 +66,8 @@ export default class PlaceRoute extends Route {
|
|||||||
|
|
||||||
async loadOsmPlace(id, type = null) {
|
async loadOsmPlace(id, type = null) {
|
||||||
try {
|
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) {
|
if (poi) {
|
||||||
console.debug('Found OSM POI:', poi);
|
console.debug('Found OSM POI:', poi);
|
||||||
return poi;
|
return poi;
|
||||||
|
|||||||
@@ -124,4 +124,115 @@ out center;
|
|||||||
if (!data.elements[0]) return null;
|
if (!data.elements[0]) return null;
|
||||||
return this.normalizePoi(data.elements[0]);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ class MockOsmService extends Service {
|
|||||||
osmType: 'node',
|
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 {
|
class MockStorageService extends Service {
|
||||||
@@ -83,7 +93,6 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
// Click the Close (X) button
|
// Click the Close (X) button
|
||||||
await click('.close-btn');
|
await click('.close-btn');
|
||||||
|
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/', 'Returned to index');
|
assert.strictEqual(currentURL(), '/', 'Returned to index');
|
||||||
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
|
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
|
||||||
});
|
});
|
||||||
@@ -96,7 +105,6 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
|
|
||||||
await click('.back-btn');
|
await click('.back-btn');
|
||||||
|
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/', 'Returned to index/map');
|
assert.strictEqual(currentURL(), '/', 'Returned to index/map');
|
||||||
assert.true(backStub.notCalled, 'window.history.back() was NOT called');
|
assert.true(backStub.notCalled, 'window.history.back() was NOT called');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
113
tests/unit/services/osm-test.js
Normal file
113
tests/unit/services/osm-test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user