Show Nearby search in search input field #107
@@ -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 = '';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user