Show Nearby search in search input field #107

Merged
raucao merged 1 commits from feature/nearby_search_text into master 2026-09-17 21:48:47 +00:00
10 changed files with 178 additions and 50 deletions
+2 -2
View File
@@ -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 = '';
+2 -1
View File
@@ -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,
};
+6 -1
View File
@@ -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() {
+1
View File
@@ -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")
)
}}
<Icon @name="loading-ring" @size={{20}} />
+52 -40
View File
@@ -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.');
+1 -1
View File
@@ -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;
+12
View File
@@ -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);
}
+30 -4
View File
@@ -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'
);
});
+69 -1
View File
@@ -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');
});
});
@@ -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) {