From b2956367999c005a1b65305f0915420eb8711294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 17 Sep 2026 23:38:50 +0200 Subject: [PATCH] Show Nearby search in search input field Add a special Nearby category, show it in the input field, and make it dismissable via X button (same as other searches) --- app/components/app-header.gjs | 4 +- app/components/map.gjs | 3 +- app/components/places-sidebar.gjs | 7 +- app/components/search-box.gjs | 1 + app/controllers/search.js | 92 +++++++++++-------- app/services/osm.js | 2 +- app/utils/poi-categories.js | 12 +++ tests/acceptance/search-loading-test.js | 34 ++++++- tests/acceptance/search-test.js | 70 +++++++++++++- .../components/category-chips-test.gjs | 3 + 10 files changed, 178 insertions(+), 50 deletions(-) diff --git a/app/components/app-header.gjs b/app/components/app-header.gjs index fd4fe56..0e44e0d 100644 --- a/app/components/app-header.gjs +++ b/app/components/app-header.gjs @@ -9,7 +9,7 @@ import SearchBox from '#components/search-box'; import CategoryChips from '#components/category-chips'; import { and } from 'ember-truth-helpers'; import cachedImage from '../modifiers/cached-image'; -import { POI_CATEGORIES } from '../utils/poi-categories'; +import { getCategoryById } from '../utils/poi-categories'; export default class AppHeaderComponent extends Component { @service storage; @@ -43,7 +43,7 @@ export default class AppHeaderComponent extends Component { if (qp?.q) { this.searchQuery = qp.q; } else if (qp?.category) { - const category = POI_CATEGORIES.find((c) => c.id === qp.category); + const category = getCategoryById(qp.category); this.searchQuery = category ? category.label : qp.category; } else { this.searchQuery = ''; diff --git a/app/components/map.gjs b/app/components/map.gjs index 557ea33..20db2a4 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -21,6 +21,7 @@ import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js'; import { apply } from 'ol-mapbox-style'; import { getIcon } from '../utils/icons'; import { getIconNameForTags } from '../utils/osm-icons'; +import { NEARBY_CATEGORY } from '../utils/poi-categories'; export default class MapComponent extends Component { @service osm; @@ -1369,7 +1370,7 @@ export default class MapComponent extends Component { lat: lat.toFixed(6), lon: lon.toFixed(6), q: null, // Clear q to force spatial search - category: null, // Clear category to force spatial search + category: NEARBY_CATEGORY.id, // Represent nearby as a first-class category selected: selectedFeatureName || null, }; diff --git a/app/components/places-sidebar.gjs b/app/components/places-sidebar.gjs index 0011217..9b85726 100644 --- a/app/components/places-sidebar.gjs +++ b/app/components/places-sidebar.gjs @@ -11,6 +11,7 @@ import PlaceDetails from './place-details'; import Icon from './icon'; import humanizeOsmTag from '../helpers/humanize-osm-tag'; import { getLocalizedName, getPlaceType } from '../utils/osm'; +import { NEARBY_CATEGORY } from '../utils/poi-categories'; import restoreScroll from '../modifiers/restore-scroll'; export default class PlacesSidebar extends Component { @@ -150,7 +151,11 @@ export default class PlacesSidebar extends Component { get isNearbySearch() { const qp = this.router.currentRoute.queryParams; - return !qp.q && !qp.category && qp.lat && qp.lon; + // New searches use ?category=nearby; keep supporting legacy lat/lon-only URLs + return ( + qp.category === NEARBY_CATEGORY.id || + (!qp.q && !qp.category && qp.lat && qp.lon) + ); } get hasHeaderPhoto() { diff --git a/app/components/search-box.gjs b/app/components/search-box.gjs index f7f0ef3..34a7daf 100644 --- a/app/components/search-box.gjs +++ b/app/components/search-box.gjs @@ -270,6 +270,7 @@ export default class SearchBoxComponent extends Component { (or (eq this.mapUi.loadingState.type "text") (eq this.mapUi.loadingState.type "category") + (eq this.mapUi.loadingState.type "nearby") ) }} diff --git a/app/controllers/search.js b/app/controllers/search.js index fd16661..c600bb9 100644 --- a/app/controllers/search.js +++ b/app/controllers/search.js @@ -2,6 +2,7 @@ import Controller from '@ember/controller'; import { service } from '@ember/service'; import { task } from 'ember-concurrency'; import { getDistance } from '../utils/geo'; +import { NEARBY_CATEGORY } from '../utils/poi-categories'; export default class SearchController extends Controller { @service osm; @@ -19,6 +20,40 @@ export default class SearchController extends Controller { selected = null; category = null; + async fetchNearbyPois(lat, lon) { + const searchRadius = 50; + + // Fetch POIs from Overpass + let pois = await this.osm.getNearbyPois(lat, lon, searchRadius); + + // Get cached/saved places in search radius + const localMatches = this.storage.savedPlaces.filter((p) => { + const dist = getDistance(lat, lon, p.lat, p.lon); + return dist <= searchRadius; + }); + + // Merge local matches + localMatches.forEach((local) => { + const exists = pois.find( + (poi) => + (local.osmId && poi.osmId === local.osmId) || + (poi.id && poi.id === local.id) + ); + + if (!exists) { + pois.push(local); + } + }); + + // Sort by distance from the search center + return pois + .map((p) => ({ + ...p, + _distance: getDistance(lat, lon, p.lat, p.lon), + })) + .sort((a, b) => a._distance - b._distance); + } + fetchResultsTask = task({ restartable: true }, async (params) => { // 1. Check if the incoming parameters match our currently loaded search const isSameSearch = @@ -50,8 +85,22 @@ export default class SearchController extends Controller { let loadingValue = null; try { - // Case 0: Category Search (category parameter present) - if (params.category && lat && lon) { + // Case 0: Nearby Search (map click or ?category=nearby) + const isNearbySearch = + lat && + lon && + !params.q && + (!params.category || params.category === NEARBY_CATEGORY.id); + + if (isNearbySearch) { + loadingType = 'nearby'; + loadingValue = 'nearby'; + this.mapUi.startLoading(loadingType, loadingValue); + + pois = await this.fetchNearbyPois(lat, lon); + } + // Case 1: Category Search (category parameter present) + else if (params.category && lat && lon) { loadingType = 'category'; loadingValue = params.category; this.mapUi.startLoading(loadingType, loadingValue); @@ -88,7 +137,7 @@ export default class SearchController extends Controller { })) .sort((a, b) => a._distance - b._distance); } - // Case 1: Text Search (q parameter present) + // Case 2: Text Search (q parameter present) else if (params.q) { loadingType = 'text'; loadingValue = params.q; @@ -121,43 +170,6 @@ export default class SearchController extends Controller { } }); } - // Case 2: Nearby Search (lat/lon present, no q) - else if (lat && lon) { - // Nearby search does NOT trigger loading state (pulse is used instead) - const searchRadius = 50; // Default radius - - // Fetch POIs from Overpass - pois = await this.osm.getNearbyPois(lat, lon, searchRadius); - - // Get cached/saved places in search radius - const localMatches = this.storage.savedPlaces.filter((p) => { - const dist = getDistance(lat, lon, p.lat, p.lon); - return dist <= searchRadius; - }); - - // Merge local matches - localMatches.forEach((local) => { - const exists = pois.find( - (poi) => - (local.osmId && poi.osmId === local.osmId) || - (poi.id && poi.id === local.id) - ); - - if (!exists) { - pois.push(local); - } - }); - - // Sort by distance from click - pois = pois - .map((p) => { - return { - ...p, - _distance: getDistance(lat, lon, p.lat, p.lon), - }; - }) - .sort((a, b) => a._distance - b._distance); - } } catch (error) { console.error('Search request failed.', error); this.toast.show('Search request failed. Please try again.'); diff --git a/app/services/osm.js b/app/services/osm.js index 9544534..f4a4f6a 100644 --- a/app/services/osm.js +++ b/app/services/osm.js @@ -196,7 +196,7 @@ out center; async getCategoryPois(bounds, categoryId, lat, lon) { const category = getCategoryById(categoryId); - if (!category || !bounds) return []; + if (!category || !Array.isArray(category.filter) || !bounds) return []; const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null; diff --git a/app/utils/poi-categories.js b/app/utils/poi-categories.js index cd27c6d..bb813ac 100644 --- a/app/utils/poi-categories.js +++ b/app/utils/poi-categories.js @@ -63,6 +63,18 @@ export const POI_CATEGORIES = [ }, ]; +// Special-cased category for spatial "Nearby" searches (triggered by clicking +// the map). It intentionally lives outside POI_CATEGORIES so it does not show +// up as a quick-search chip or in the search autocomplete. Like other +// categories it is represented in the URL as ?category=nearby, which lets the +// UI display its label and offer a clear button. +export const NEARBY_CATEGORY = { + id: 'nearby', + label: 'Nearby', + icon: 'target', +}; + export function getCategoryById(id) { + if (id === NEARBY_CATEGORY.id) return NEARBY_CATEGORY; return POI_CATEGORIES.find((c) => c.id === id); } diff --git a/tests/acceptance/search-loading-test.js b/tests/acceptance/search-loading-test.js index eaed172..f6615c1 100644 --- a/tests/acceptance/search-loading-test.js +++ b/tests/acceptance/search-loading-test.js @@ -54,7 +54,19 @@ class MockOsmService extends Service { return []; } async getNearbyPois() { - return []; + return new Promise((resolve) => { + osmResolve = () => { + resolve([ + { + title: 'Nearby Place', + lat: 1, + lon: 1, + osmId: '999', + osmType: 'node', + }, + ]); + }; + }); } } @@ -68,7 +80,7 @@ module('Acceptance | search loading', function (hooks) { this.owner.register('service:osm', MockOsmService); }); - test('search shows loading indicator but nearby search does not', async function (assert) { + test('search shows loading indicator for text, category and nearby searches', async function (assert) { const mapUi = this.owner.lookup('service:map-ui'); // 1. Text Search @@ -121,11 +133,25 @@ module('Acceptance | search loading', function (hooks) { ); // 3. Nearby Search - await visit('/search?lat=1&lon=1'); + const nearbyPromise = visit('/search?category=nearby&lat=1&lon=1'); + await new Promise((r) => setTimeout(r, 10)); + + assert.deepEqual( + mapUi.loadingState, + { type: 'nearby', value: 'nearby' }, + 'Loading state is set for nearby search' + ); + + // Resolve the manual promise + osmResolve(); + + await nearbyPromise; + await settled(); + assert.strictEqual( mapUi.loadingState, null, - 'Loading state is NOT set for nearby search' + 'Loading state is cleared after nearby search' ); }); diff --git a/tests/acceptance/search-test.js b/tests/acceptance/search-test.js index 0e9c484..55fcfb2 100644 --- a/tests/acceptance/search-test.js +++ b/tests/acceptance/search-test.js @@ -277,6 +277,9 @@ module('Acceptance | search', function (hooks) { async getCategoryPois() { return []; } + async getNearbyPois() { + return []; + } } this.owner.register('service:osm', MockOsmService); @@ -336,7 +339,13 @@ module('Acceptance | search', function (hooks) { 'Search input is populated with mapped category label' ); - // 3. Go back to index + // 3. Visit a nearby search URL + await visit('/search?category=nearby&lat=52.52&lon=13.405'); + assert + .dom('.search-input') + .hasValue('Nearby', 'Search input is populated with the Nearby label'); + + // 4. Go back to index await visit('/'); assert .dom('.search-input') @@ -384,4 +393,63 @@ module('Acceptance | search', function (hooks) { assert.strictEqual(currentURL(), '/', 'Search route is cleared'); assert.strictEqual(mapUi.searchResults.length, 0, 'Results are cleared'); }); + + test('nearby search state is shown in the search box and can be dismissed', async function (assert) { + class MockOsmService extends Service { + cancelAll() {} + async getNearbyPois() { + return [ + { + title: 'Nearby Cafe', + lat: 52.521, + lon: 13.406, + osmId: '789', + osmType: 'N', + }, + ]; + } + } + this.owner.register('service:osm', MockOsmService); + + class MockStorageService extends Service { + savedPlaces = []; + findPlaceById() { + return null; + } + isPlaceSaved() { + return false; + } + rs = { on: () => {} }; + placesInView = []; + loadPlacesInBounds() { + return Promise.resolve(); + } + } + this.owner.register('service:storage', MockStorageService); + + const mapUi = this.owner.lookup('service:map-ui'); + + await visit('/search?category=nearby&lat=52.52&lon=13.405'); + + assert.dom('.sidebar-header h2').includesText('Nearby'); + assert.dom('.search-input').hasValue('Nearby', 'Input indicates Nearby'); + + // Closing the sidebar keeps the search (and its markers) active, so the + // search box must still indicate the Nearby search and offer dismissal. + await click('.close-btn'); + + assert.ok( + currentURL().includes('category=nearby'), + 'Still on the nearby search route with markers visible' + ); + assert + .dom('.search-input') + .hasValue('Nearby', 'Input still indicates Nearby after closing sidebar'); + assert.dom('.search-clear-btn').exists('Clear button remains available'); + + await click('.search-clear-btn'); + + assert.strictEqual(currentURL(), '/', 'Dismissing nearby clears the route'); + assert.strictEqual(mapUi.searchResults.length, 0, 'Results are cleared'); + }); }); diff --git a/tests/integration/components/category-chips-test.gjs b/tests/integration/components/category-chips-test.gjs index 100a664..b5df472 100644 --- a/tests/integration/components/category-chips-test.gjs +++ b/tests/integration/components/category-chips-test.gjs @@ -22,6 +22,9 @@ module('Integration | Component | category-chips', function (hooks) { // Check for some expected labels assert.dom(this.element).includesText('Restaurants'); assert.dom(this.element).includesText('Coffee'); + + // Nearby is an internal-only category and must not appear as a chip + assert.dom(this.element).doesNotIncludeText('Nearby'); }); test('clicking a chip triggers the @onSelect action', async function (assert) { -- 2.50.1