From bcf8ca42551ca81dc75e33fccd25df8eb577d35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 19 Feb 2026 16:28:07 +0400 Subject: [PATCH 01/28] Add service for Photon requests --- app/services/photon.js | 102 +++++++++++++++++++++++++++++ tests/unit/services/photon-test.js | 90 +++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 app/services/photon.js create mode 100644 tests/unit/services/photon-test.js diff --git a/app/services/photon.js b/app/services/photon.js new file mode 100644 index 0000000..4ea76cd --- /dev/null +++ b/app/services/photon.js @@ -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; + } + } +} diff --git a/tests/unit/services/photon-test.js b/tests/unit/services/photon-test.js new file mode 100644 index 0000000..f0e76cd --- /dev/null +++ b/tests/unit/services/photon-test.js @@ -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); + }); +}); 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 02/28] 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'); + }); +}); From 2734f086089ddf0a235fcd790deac8905a2fa88d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Fri, 20 Feb 2026 12:38:57 +0400 Subject: [PATCH 03/28] Formatting --- app/components/map.gjs | 30 ++++++++++++++++++------------ app/components/place-details.gjs | 12 ++++++------ app/templates/application.gjs | 14 +++++++------- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/app/components/map.gjs b/app/components/map.gjs index 2bcbfc4..dcb61b9 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -155,9 +155,6 @@ export default class MapComponent extends Component { `; element.appendChild(this.crosshairElement); - - - // Geolocation Pulse Overlay this.locationOverlayElement = document.createElement('div'); this.locationOverlayElement.className = 'search-pulse blue'; @@ -311,7 +308,7 @@ export default class MapComponent extends Component { }; const startLocating = () => { - console.debug('Getting current geolocation...') + console.debug('Getting current geolocation...'); // 1. Clear any previous session stopLocating(); @@ -374,11 +371,15 @@ export default class MapComponent extends Component { if (!this.mapInstance) return; // Remove existing DragPan interactions - this.mapInstance.getInteractions().getArray().slice().forEach((interaction) => { - if (interaction instanceof DragPan) { - this.mapInstance.removeInteraction(interaction); - } - }); + this.mapInstance + .getInteractions() + .getArray() + .slice() + .forEach((interaction) => { + if (interaction instanceof DragPan) { + this.mapInstance.removeInteraction(interaction); + } + }); // Add new DragPan with current setting const kinetic = this.settings.mapKinetic @@ -652,10 +653,15 @@ export default class MapComponent extends Component { const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect(); const crosshairRect = this.crosshairElement.getBoundingClientRect(); - const centerX = crosshairRect.left + crosshairRect.width / 2 - mapRect.left; - const centerY = crosshairRect.top + crosshairRect.height / 2 - mapRect.top; + const centerX = + crosshairRect.left + crosshairRect.width / 2 - mapRect.left; + const centerY = + crosshairRect.top + crosshairRect.height / 2 - mapRect.top; - const coordinate = this.mapInstance.getCoordinateFromPixel([centerX, centerY]); + const coordinate = this.mapInstance.getCoordinateFromPixel([ + centerX, + centerY, + ]); const center = toLonLat(coordinate); const lat = parseFloat(center[1].toFixed(6)); diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index 5a868fd..d82ffd4 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -21,11 +21,7 @@ export default class PlaceDetails extends Component { } get name() { - return ( - this.place.title || - getLocalizedName(this.tags) || - 'Unnamed Place' - ); + return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place'; } @action @@ -274,7 +270,11 @@ export default class PlaceDetails extends Component {

- + Google Maps diff --git a/app/templates/application.gjs b/app/templates/application.gjs index b5487df..1e99a01 100644 --- a/app/templates/application.gjs +++ b/app/templates/application.gjs @@ -17,8 +17,8 @@ export default class ApplicationComponent extends Component { @tracked isSettingsOpen = false; get isSidebarOpen() { - // We consider the sidebar "open" if we are in search or place routes. - // This helps the map know if it should shift the center or adjust view. + // We consider the sidebar "open" if we are in search or place routes. + // This helps the map know if it should shift the center or adjust view. return ( this.router.currentRouteName === 'place' || this.router.currentRouteName === 'place.new' || @@ -48,12 +48,12 @@ export default class ApplicationComponent extends Component { if (this.isSettingsOpen) { this.closeSettings(); } else if (this.router.currentRouteName === 'search') { - this.router.transitionTo('index'); + this.router.transitionTo('index'); } else if (this.router.currentRouteName === 'place') { - // If in place route, decide if we want to go back to search or index - // For now, let's go to index or maybe back to search if search params exist? - // Simplest behavior: clear selection - this.router.transitionTo('index'); + // If in place route, decide if we want to go back to search or index + // For now, let's go to index or maybe back to search if search params exist? + // Simplest behavior: clear selection + this.router.transitionTo('index'); } } From bf123056004567c657671f2ebd71bf24c747dcae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Fri, 20 Feb 2026 12:39:04 +0400 Subject: [PATCH 04/28] Add full-text search Add a search box with a quick results popover, as well full results in the sidebar on pressing enter. --- app/components/app-header.gjs | 3 + app/components/map.gjs | 17 +- app/components/places-sidebar.gjs | 26 +-- app/components/search-box.gjs | 178 ++++++++++++++++++ app/components/settings-pane.gjs | 46 +++-- app/controllers/search.js | 10 + app/routes/search.js | 111 +++++++---- app/services/map-ui.js | 5 + app/services/photon.js | 12 +- app/styles/app.css | 160 ++++++++++++++++ tests/acceptance/search-test.js | 147 +++++++++++++++ .../components/app-header-test.gjs | 19 ++ .../components/place-details-test.gjs | 37 ++++ .../components/search-box-test.gjs | 128 +++++++++++++ tests/unit/services/photon-test.js | 47 +++++ 15 files changed, 878 insertions(+), 68 deletions(-) create mode 100644 app/components/search-box.gjs create mode 100644 app/controllers/search.js create mode 100644 tests/acceptance/search-test.js create mode 100644 tests/integration/components/app-header-test.gjs create mode 100644 tests/integration/components/place-details-test.gjs create mode 100644 tests/integration/components/search-box-test.gjs diff --git a/app/components/app-header.gjs b/app/components/app-header.gjs index baad34a..d04b5bc 100644 --- a/app/components/app-header.gjs +++ b/app/components/app-header.gjs @@ -5,6 +5,7 @@ import { action } from '@ember/object'; import { on } from '@ember/modifier'; import Icon from '#components/icon'; import UserMenu from '#components/user-menu'; +import SearchBox from '#components/search-box'; export default class AppHeaderComponent extends Component { @service storage; @@ -31,6 +32,8 @@ export default class AppHeaderComponent extends Component { > + +

diff --git a/app/components/map.gjs b/app/components/map.gjs index dcb61b9..09dc19c 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -110,6 +110,10 @@ export default class MapComponent extends Component { }), }); + // Initialize the UI service with the map center + const initialCenter = toLonLat(view.getCenter()); + this.mapUi.updateCenter(initialCenter[1], initialCenter[0]); + apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty'); this.searchOverlayElement = document.createElement('div'); @@ -645,12 +649,18 @@ export default class MapComponent extends Component { handleMapMove = async () => { if (!this.mapInstance) return; + const view = this.mapInstance.getView(); + const center = toLonLat(view.getCenter()); + this.mapUi.updateCenter(center[1], center[0]); + // If in creation mode, update the coordinates in the service AND the URL if (this.mapUi.isCreating) { // Calculate coordinates under the crosshair element // We need the pixel position of the crosshair relative to the map viewport // The crosshair is positioned via CSS, so we can use getBoundingClientRect - const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect(); + const mapRect = this.mapInstance + .getTargetElement() + .getBoundingClientRect(); const crosshairRect = this.crosshairElement.getBoundingClientRect(); const centerX = @@ -786,10 +796,9 @@ export default class MapComponent extends Component { const queryParams = { lat: lat.toFixed(6), lon: lon.toFixed(6), + q: null, // Clear q to force spatial search + selected: selectedFeatureName || null, }; - if (selectedFeatureName) { - queryParams.q = selectedFeatureName; - } this.router.transitionTo('search', { queryParams }); }; diff --git a/app/components/places-sidebar.gjs b/app/components/places-sidebar.gjs index 359028c..bd1afaa 100644 --- a/app/components/places-sidebar.gjs +++ b/app/components/places-sidebar.gjs @@ -23,8 +23,10 @@ export default class PlacesSidebar extends Component { if (lat && lon) { this.router.transitionTo('place.new', { queryParams: { lat, lon } }); } else { - // Fallback (shouldn't happen in search context) - this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } }); + // Fallback (shouldn't happen in search context) + this.router.transitionTo('place.new', { + queryParams: { lat: 0, lon: 0 }, + }); } } @@ -152,7 +154,7 @@ export default class PlacesSidebar extends Component { {{on "click" this.clearSelection}} > {{else}} -

Nearby

+

Nearby

{{/if}} {{/each}} diff --git a/app/components/search-box.gjs b/app/components/search-box.gjs new file mode 100644 index 0000000..f0e1dc4 --- /dev/null +++ b/app/components/search-box.gjs @@ -0,0 +1,178 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { debounce } from '@ember/runloop'; +import Icon from '#components/icon'; + +export default class SearchBoxComponent extends Component { + @service photon; + @service router; + @service mapUi; + @service map; // Assuming we might need map context, but mostly we use router + + @tracked query = ''; + @tracked results = []; + @tracked isFocused = false; + @tracked isLoading = false; + + get showPopover() { + return this.isFocused && this.results.length > 0; + } + + @action + handleInput(event) { + this.query = event.target.value; + if (this.query.length < 2) { + this.results = []; + return; + } + + debounce(this, this.performSearch, 300); + } + + async performSearch() { + if (this.query.length < 2) return; + + this.isLoading = true; + try { + // Use map center if available for location bias + let lat, lon; + if (this.mapUi.currentCenter) { + ({ lat, lon } = this.mapUi.currentCenter); + } + const results = await this.photon.search(this.query, lat, lon); + this.results = results; + } catch (e) { + console.error('Search failed', e); + this.results = []; + } finally { + this.isLoading = false; + } + } + + @action + handleFocus() { + this.isFocused = true; + if (this.query.length >= 2 && this.results.length === 0) { + this.performSearch(); + } + } + + @action + handleBlur() { + // Delay hiding so clicks on results can register + setTimeout(() => { + this.isFocused = false; + }, 200); + } + + @action + handleSubmit(event) { + event.preventDefault(); + if (!this.query) return; + + let queryParams = { q: this.query, selected: null }; + + if (this.mapUi.currentCenter) { + const { lat, lon } = this.mapUi.currentCenter; + queryParams.lat = parseFloat(lat).toFixed(4); + queryParams.lon = parseFloat(lon).toFixed(4); + } + + this.router.transitionTo('search', { queryParams }); + this.isFocused = false; + } + + @action + selectResult(place) { + this.query = place.title; + this.results = []; // Hide popover + + // If it has an OSM ID, go to place details + if (place.osmId) { + // Format: osm:node:123 + // place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService + const id = `osm:${place.osmType}:${place.osmId}`; + this.router.transitionTo('place', id); + } else { + // Just a location (e.g. from Photon without OSM ID, though unlikely for Photon) + // Or we can treat it as a search query + this.router.transitionTo('search', { + queryParams: { + q: place.title, + lat: place.lat, + lon: place.lon, + selected: null, + }, + }); + } + } + + @action + clear() { + this.query = ''; + this.results = []; + this.router.transitionTo('index'); // Or stay on current page? + // Usually clear just clears the input. + } + + +} diff --git a/app/components/settings-pane.gjs b/app/components/settings-pane.gjs index 153a4c2..316edbc 100644 --- a/app/components/settings-pane.gjs +++ b/app/components/settings-pane.gjs @@ -62,7 +62,10 @@ export default class SettingsPane extends Component { {{#each this.settings.overpassApis as |api|}} @@ -73,24 +76,45 @@ export default class SettingsPane extends Component {

About

- Marco (as in Marco Polo) is an unhosted maps application - that respects your privacy and choices. + Marco + (as in + Marco Polo) is an unhosted maps application that respects your + privacy and choices.

- Connect your own remote storage to sync place bookmarks across - apps and devices. + Connect your own + remote storage + to sync place bookmarks across apps and devices.