WIP Search places by category
This commit is contained in:
@@ -6,10 +6,12 @@ import { on } from '@ember/modifier';
|
|||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import UserMenu from '#components/user-menu';
|
import UserMenu from '#components/user-menu';
|
||||||
import SearchBox from '#components/search-box';
|
import SearchBox from '#components/search-box';
|
||||||
|
import CategoryChips from '#components/category-chips';
|
||||||
|
|
||||||
export default class AppHeaderComponent extends Component {
|
export default class AppHeaderComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@tracked isUserMenuOpen = false;
|
@tracked isUserMenuOpen = false;
|
||||||
|
@tracked hasQuery = false;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleUserMenu() {
|
toggleUserMenu() {
|
||||||
@@ -21,10 +23,31 @@ export default class AppHeaderComponent extends Component {
|
|||||||
this.isUserMenuOpen = false;
|
this.isUserMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleQueryChange(query) {
|
||||||
|
this.hasQuery = !!query;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleChipSelect() {
|
||||||
|
// When a chip is selected, we might want to ensure the search box is cleared visually,
|
||||||
|
// although the route transition will happen.
|
||||||
|
// The SearchBox component manages its own state, so we rely on the route transition.
|
||||||
|
// However, if we want to clear the search box input from here, we'd need to control it.
|
||||||
|
// For now, let's just let the route change happen.
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<SearchBox @onToggleMenu={{@onToggleMenu}} />
|
<SearchBox
|
||||||
|
@onToggleMenu={{@onToggleMenu}}
|
||||||
|
@onQueryChange={{this.handleQueryChange}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||||
|
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|||||||
52
app/components/category-chips.gjs
Normal file
52
app/components/category-chips.gjs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
|
|
||||||
|
export default class CategoryChipsComponent extends Component {
|
||||||
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
get categories() {
|
||||||
|
return POI_CATEGORIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
searchCategory(category) {
|
||||||
|
// If passed an onSelect action, call it (e.g. to clear search box)
|
||||||
|
if (this.args.onSelect) {
|
||||||
|
this.args.onSelect(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryParams = { category: category.id, q: 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="category-chips-scroll">
|
||||||
|
<div class="category-chips-container">
|
||||||
|
{{#each this.categories as |category|}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="category-chip"
|
||||||
|
{{on "click" (fn this.searchCategory category)}}
|
||||||
|
aria-label={{category.label}}
|
||||||
|
>
|
||||||
|
<Icon @name={{category.icon}} @size={{16}} />
|
||||||
|
<span>{{category.label}}</span>
|
||||||
|
</button>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -914,6 +914,7 @@ export default class MapComponent extends Component {
|
|||||||
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
|
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
|
||||||
|
|
||||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||||
|
this.mapUi.updateBounds(bbox);
|
||||||
await this.storage.loadPlacesInBounds(bbox);
|
await this.storage.loadPlacesInBounds(bbox);
|
||||||
this.loadBookmarks(this.storage.placesInView);
|
this.loadBookmarks(this.storage.placesInView);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export default class SearchBoxComponent extends Component {
|
|||||||
@action
|
@action
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
this.query = event.target.value;
|
this.query = event.target.value;
|
||||||
|
if (this.args.onQueryChange) {
|
||||||
|
this.args.onQueryChange(this.query);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.query.length < 2) {
|
if (this.query.length < 2) {
|
||||||
this.results = [];
|
this.results = [];
|
||||||
return;
|
return;
|
||||||
@@ -35,6 +39,7 @@ export default class SearchBoxComponent extends Component {
|
|||||||
this.searchTask.perform();
|
this.searchTask.perform();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
searchTask = task({ restartable: true }, async () => {
|
searchTask = task({ restartable: true }, async () => {
|
||||||
await timeout(300);
|
await timeout(300);
|
||||||
|
|
||||||
@@ -121,8 +126,10 @@ export default class SearchBoxComponent extends Component {
|
|||||||
clear() {
|
clear() {
|
||||||
this.query = '';
|
this.query = '';
|
||||||
this.results = [];
|
this.results = [];
|
||||||
this.router.transitionTo('index'); // Or stay on current page?
|
if (this.args.onQueryChange) {
|
||||||
// Usually clear just clears the input.
|
this.args.onQueryChange('');
|
||||||
|
}
|
||||||
|
this.router.transitionTo('index');
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
|
|
||||||
export default class SearchController extends Controller {
|
export default class SearchController extends Controller {
|
||||||
queryParams = ['lat', 'lon', 'q', 'selected'];
|
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
||||||
|
|
||||||
lat = null;
|
lat = null;
|
||||||
lon = null;
|
lon = null;
|
||||||
q = null;
|
q = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
|
category = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default class SearchRoute extends Route {
|
|||||||
lon: { refreshModel: true },
|
lon: { refreshModel: true },
|
||||||
q: { refreshModel: true },
|
q: { refreshModel: true },
|
||||||
selected: { refreshModel: true },
|
selected: { refreshModel: true },
|
||||||
|
category: { refreshModel: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
async model(params) {
|
async model(params) {
|
||||||
@@ -22,8 +23,37 @@ export default class SearchRoute extends Route {
|
|||||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||||
let pois = [];
|
let pois = [];
|
||||||
|
|
||||||
|
// Case 0: Category Search (category parameter present)
|
||||||
|
if (params.category && lat && lon) {
|
||||||
|
// We need bounds. If we have active map state, use it.
|
||||||
|
let bounds = this.mapUi.currentBounds;
|
||||||
|
|
||||||
|
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||||
|
// or just use a fixed box around the center.
|
||||||
|
if (!bounds) {
|
||||||
|
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||||
|
// Let's take a safe box of ~1km radius.
|
||||||
|
const delta = 0.01;
|
||||||
|
bounds = {
|
||||||
|
minLat: lat - delta,
|
||||||
|
maxLat: lat + delta,
|
||||||
|
minLon: lon - delta,
|
||||||
|
maxLon: lon + delta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pois = await this.osm.getCategoryPois(bounds, params.category);
|
||||||
|
|
||||||
|
// Sort by distance from center
|
||||||
|
pois = pois
|
||||||
|
.map((p) => ({
|
||||||
|
...p,
|
||||||
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
}
|
||||||
// Case 1: Text Search (q parameter present)
|
// Case 1: Text Search (q parameter present)
|
||||||
if (params.q) {
|
else if (params.q) {
|
||||||
// Search with Photon (using lat/lon for bias if available)
|
// Search with Photon (using lat/lon for bias if available)
|
||||||
pois = await this.photon.search(params.q, lat, lon);
|
pois = await this.photon.search(params.q, lat, lon);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default class MapUiService extends Service {
|
|||||||
@tracked creationCoordinates = null;
|
@tracked creationCoordinates = null;
|
||||||
@tracked returnToSearch = false;
|
@tracked returnToSearch = false;
|
||||||
@tracked currentCenter = null;
|
@tracked currentCenter = null;
|
||||||
|
@tracked currentBounds = null;
|
||||||
@tracked searchBoxHasFocus = false;
|
@tracked searchBoxHasFocus = false;
|
||||||
@tracked selectionOptions = {};
|
@tracked selectionOptions = {};
|
||||||
@tracked preventNextZoom = false;
|
@tracked preventNextZoom = false;
|
||||||
@@ -54,4 +55,8 @@ export default class MapUiService extends Service {
|
|||||||
updateCenter(lat, lon) {
|
updateCenter(lat, lon) {
|
||||||
this.currentCenter = { lat, lon };
|
this.currentCenter = { lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBounds(bounds) {
|
||||||
|
this.currentBounds = bounds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
import { getCategoryById } from '../utils/poi-categories';
|
||||||
|
|
||||||
export default class OsmService extends Service {
|
export default class OsmService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -76,6 +77,48 @@ out center;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCategoryPois(bounds, categoryId) {
|
||||||
|
const category = getCategoryById(categoryId);
|
||||||
|
if (!category || !bounds) return [];
|
||||||
|
|
||||||
|
const { minLat, minLon, maxLat, maxLon } = bounds;
|
||||||
|
|
||||||
|
// Build the query parts for each filter string and type
|
||||||
|
const queryParts = [];
|
||||||
|
|
||||||
|
// Default types if not specified (legacy fallback)
|
||||||
|
const types = category.types || ['node', 'way', 'relation'];
|
||||||
|
|
||||||
|
category.filter.forEach((filterString) => {
|
||||||
|
types.forEach((type) => {
|
||||||
|
// We ensure we only fetch named POIs to reduce noise
|
||||||
|
queryParts.push(`${type}${filterString}[~"^name"~"."];`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
[out:json][timeout:25][bbox:${minLat},${minLon},${maxLat},${maxLon}];
|
||||||
|
(
|
||||||
|
${queryParts.join('\n ')}
|
||||||
|
);
|
||||||
|
out center;
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
// No caching for now as bounds change frequently
|
||||||
|
const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchWithRetry(url);
|
||||||
|
if (!res.ok) throw new Error('Overpass request failed');
|
||||||
|
const data = await res.json();
|
||||||
|
const results = data.elements.map(this.normalizePoi);
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Category search failed', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
normalizePoi(poi) {
|
normalizePoi(poi) {
|
||||||
const tags = poi.tags || {};
|
const tags = poi.tags || {};
|
||||||
const type = getPlaceType(tags) || 'Point of Interest';
|
const type = getPlaceType(tags) || 'Point of Interest';
|
||||||
|
|||||||
@@ -70,27 +70,94 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3000; /* Above sidebar (2000) and map */
|
z-index: 3000; /* Above sidebar (2000) and map */
|
||||||
pointer-events: none; /* Let clicks pass through to map where transparent */
|
pointer-events: none; /* Let clicks pass through to map where transparent */
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
display: grid;
|
||||||
|
/* Desktop: 1fr auto 1fr ensures the center element is absolutely centered */
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
grid-template-areas: 'search chips user';
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.app-header {
|
.app-header {
|
||||||
padding: 0 0.5rem;
|
padding: 0.5rem 0.5rem 0;
|
||||||
|
height: auto;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
'search user'
|
||||||
|
'chips chips';
|
||||||
|
row-gap: 8px; /* Increased spacing */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left,
|
.header-left,
|
||||||
.header-right {
|
.header-right,
|
||||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
.header-center {
|
||||||
|
pointer-events: auto; /* Re-enable clicks */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
grid-area: search;
|
||||||
|
/* Ensure it sits at the start of its grid area */
|
||||||
|
justify-self: start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width > 768px) {
|
||||||
|
.header-left {
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width > 768px) {
|
||||||
|
.header-left {
|
||||||
|
/* Desktop: Ensure minimum width for search box so it's not squeezed */
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
grid-area: user;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
grid-area: chips;
|
||||||
|
|
||||||
|
/* Desktop: Center the chips block in the available space */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0; /* Allow shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust scroll container for desktop centering */
|
||||||
|
@media (width > 768px) {
|
||||||
|
.header-center .category-chips-scroll {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
/* No need to reset min-width/max-width since they are only set in media query above */
|
||||||
|
.header-center {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide chips on mobile when searching to save space */
|
||||||
|
.header-center.searching {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-press {
|
.btn-press {
|
||||||
@@ -1190,3 +1257,53 @@ button.create-place {
|
|||||||
background: #eee;
|
background: #eee;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Category Chips */
|
||||||
|
.category-chips-scroll {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
/* Add padding for shadows */
|
||||||
|
padding: 4px 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge */
|
||||||
|
/* Remove top margin as spacing is handled by grid/layout */
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chips-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chips-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
/* Padding on sides so first/last chip isn't flush with screen edge */
|
||||||
|
padding: 0 4px;
|
||||||
|
width: max-content; /* Ensure it scrolls */
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 16px; /* Pill shape */
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip:active {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|||||||
60
app/utils/poi-categories.js
Normal file
60
app/utils/poi-categories.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// This configuration defines the "Quick Search" categories available in the UI.
|
||||||
|
//
|
||||||
|
// Structure:
|
||||||
|
// - id: The URL slug used for routing (e.g. ?category=restaurants)
|
||||||
|
// - label: The human-readable name displayed in the UI
|
||||||
|
// - icon: The icon name (must be registered in app/utils/icons.js)
|
||||||
|
// - filter: An array of Overpass QL query parts.
|
||||||
|
// - Each string in the array is an independent query condition.
|
||||||
|
// - Multiple strings act as an OR condition (union of results).
|
||||||
|
|
||||||
|
export const POI_CATEGORIES = [
|
||||||
|
{
|
||||||
|
id: 'restaurants',
|
||||||
|
label: 'Restaurants',
|
||||||
|
icon: 'search', // Placeholder
|
||||||
|
filter: ['["amenity"~"^(restaurant|fast_food|food_court|pub|cafe)$"]'],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coffee',
|
||||||
|
label: 'Coffee',
|
||||||
|
icon: 'search', // Placeholder
|
||||||
|
filter: [
|
||||||
|
'["amenity"~"^(cafe|ice_cream)$"]',
|
||||||
|
'["shop"~"^(coffee|tea)$"]',
|
||||||
|
'["cuisine"~"coffee_shop"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groceries',
|
||||||
|
label: 'Groceries',
|
||||||
|
icon: 'search', // Placeholder
|
||||||
|
filter: [
|
||||||
|
'["shop"~"^(supermarket|convenience|grocery|greengrocer|bakery|butcher|deli|farm|seafood)$"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'attractions',
|
||||||
|
label: 'Attractions',
|
||||||
|
icon: 'search', // Placeholder
|
||||||
|
filter: [
|
||||||
|
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park)$"]',
|
||||||
|
'["historic"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way', 'relation'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accommodation',
|
||||||
|
label: 'Hotels',
|
||||||
|
icon: 'search', // Placeholder
|
||||||
|
filter: ['["tourism"~"^(hotel|hostel|guest_house|apartment|motel)$"]'],
|
||||||
|
types: ['node', 'way', 'relation'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCategoryById(id) {
|
||||||
|
return POI_CATEGORIES.find((c) => c.id === id);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user