Add service for Photon requests

This commit is contained in:
2026-02-19 16:28:07 +04:00
parent 20f63065ad
commit bcf8ca4255
2 changed files with 192 additions and 0 deletions

102
app/services/photon.js Normal file
View File

@@ -0,0 +1,102 @@
import Service from '@ember/service';
export default class PhotonService extends Service {
baseUrl = 'https://photon.komoot.io/api/';
async search(query, lat, lon, limit = 10) {
if (!query || query.length < 2) return [];
const params = new URLSearchParams({
q: query,
limit: String(limit),
});
if (lat && lon) {
params.append('lat', String(lat));
params.append('lon', String(lon));
}
const url = `${this.baseUrl}?${params.toString()}`;
try {
const res = await this.fetchWithRetry(url);
if (!res.ok) {
throw new Error(`Photon request failed with status ${res.status}`);
}
const data = await res.json();
if (!data.features) return [];
return data.features.map((f) => this.normalizeFeature(f));
} catch (e) {
console.error('Photon search error:', e);
// Return empty array on error so UI doesn't break
return [];
}
}
normalizeFeature(feature) {
const props = feature.properties || {};
const geom = feature.geometry || {};
const coords = geom.coordinates || [];
// Photon returns [lon, lat] for Point geometries
const lon = coords[0];
const lat = coords[1];
// Construct a description from address fields
// Priority: name -> street -> city -> state -> country
const addressParts = [];
if (props.street)
addressParts.push(
props.housenumber
? `${props.street} ${props.housenumber}`
: props.street
);
if (props.city && props.city !== props.name) addressParts.push(props.city);
if (props.state && props.state !== props.city)
addressParts.push(props.state);
if (props.country) addressParts.push(props.country);
const description = addressParts.join(', ');
const title = props.name || description || 'Unknown Place';
return {
title,
lat,
lon,
osmId: props.osm_id,
osmType: props.osm_type, // 'N', 'W', 'R'
osmTags: props, // Keep all properties as tags for now
description: props.name ? description : addressParts.slice(1).join(', '),
source: 'photon',
};
}
async fetchWithRetry(url, options = {}, retries = 3) {
try {
// eslint-disable-next-line warp-drive/no-external-request-patterns
const res = await fetch(url, options);
// Retry on 5xx errors or 429 Too Many Requests
if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) {
console.warn(
`Photon request failed with ${res.status}. Retrying... (${retries} left)`
);
// Exponential backoff or fixed delay? Let's do 1s fixed delay for simplicity
await new Promise((r) => setTimeout(r, 1000));
return this.fetchWithRetry(url, options, retries - 1);
}
return res;
} catch (e) {
// Retry on network errors (fetch throws) except AbortError
if (retries > 0 && e.name !== 'AbortError') {
console.debug(`Retrying Photon request... (${retries} left)`, e);
await new Promise((r) => setTimeout(r, 1000));
return this.fetchWithRetry(url, options, retries - 1);
}
throw e;
}
}
}

View File

@@ -0,0 +1,90 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
module('Unit | Service | photon', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let service = this.owner.lookup('service:photon');
assert.ok(service);
});
test('search handles successful response', async function (assert) {
let service = this.owner.lookup('service:photon');
// Mock fetch
const originalFetch = window.fetch;
window.fetch = async () => {
return {
ok: true,
json: async () => ({
features: [
{
properties: {
name: 'Test Place',
osm_id: 123,
osm_type: 'N',
city: 'Test City',
country: 'Test Country',
},
geometry: {
coordinates: [13.4, 52.5], // lon, lat
},
},
],
}),
};
};
try {
const results = await service.search('Test', 52.5, 13.4);
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].title, 'Test Place');
assert.strictEqual(results[0].lat, 52.5);
assert.strictEqual(results[0].lon, 13.4);
assert.strictEqual(results[0].description, 'Test City, Test Country');
} finally {
window.fetch = originalFetch;
}
});
test('search handles empty response', async function (assert) {
let service = this.owner.lookup('service:photon');
// Mock fetch
const originalFetch = window.fetch;
window.fetch = async () => {
return {
ok: true,
json: async () => ({ features: [] }),
};
};
try {
const results = await service.search('Nonexistent', 52.5, 13.4);
assert.strictEqual(results.length, 0);
} finally {
window.fetch = originalFetch;
}
});
test('normalizeFeature handles missing properties', function (assert) {
let service = this.owner.lookup('service:photon');
const feature = {
properties: {
street: 'Main St',
housenumber: '123',
city: 'Metropolis',
},
geometry: {
coordinates: [10, 20],
},
};
const result = service.normalizeFeature(feature);
assert.strictEqual(result.title, 'Main St 123, Metropolis'); // Fallback to address description
assert.strictEqual(result.lat, 20);
assert.strictEqual(result.lon, 10);
});
});