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] 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.