Add loading indicator for search queries

This commit is contained in:
2026-03-23 17:39:37 +04:00
parent 818ec35071
commit 8478e00253
9 changed files with 310 additions and 84 deletions

View File

@@ -5,6 +5,7 @@ import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import { POI_CATEGORIES } from '../utils/poi-categories';
import { eq, and } from 'ember-truth-helpers';
export default class CategoryChipsComponent extends Component {
@service router;
@@ -41,6 +42,7 @@ export default class CategoryChipsComponent extends Component {
class="category-chip"
{{on "click" (fn this.searchCategory category)}}
aria-label={{category.label}}
disabled={{and (eq this.mapUi.loadingState.type "category") (eq this.mapUi.loadingState.value category.id)}}
>
<Icon @name={{category.icon}} @size={{16}} />
<span>{{category.label}}</span>

View File

@@ -8,7 +8,7 @@ import { task, timeout } from 'ember-concurrency';
import Icon from '#components/icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag';
import { POI_CATEGORIES } from '../utils/poi-categories';
import eq from 'ember-truth-helpers/helpers/eq';
import { eq, or } from 'ember-truth-helpers';
export default class SearchBoxComponent extends Component {
@service photon;
@@ -211,7 +211,11 @@ export default class SearchBoxComponent extends Component {
/>
<button type="submit" class="search-submit-btn" aria-label="Search">
<Icon @name="search" @size={{20}} @color="#5f6368" />
{{#if (or (eq this.mapUi.loadingState.type "text") (eq this.mapUi.loadingState.type "category"))}}
<Icon @name="loading-ring" @size={{20}} />
{{else}}
<Icon @name="search" @size={{20}} @color="#5f6368" />
{{/if}}
</button>
{{#if this.query}}

1
app/icons/270-ring.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-ember-extension="1"><path d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite"/></path></svg>

After

Width:  |  Height:  |  Size: 464 B

View File

@@ -22,97 +22,119 @@ export default class SearchRoute extends Route {
const lat = params.lat ? parseFloat(params.lat) : null;
const lon = params.lon ? parseFloat(params.lon) : null;
let pois = [];
let loadingType = null;
let loadingValue = null;
// 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;
try {
// Case 0: Category Search (category parameter present)
if (params.category && lat && lon) {
loadingType = 'category';
loadingValue = params.category;
this.mapUi.startLoading(loadingType, loadingValue);
// 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,
};
}
// We need bounds. If we have active map state, use it.
let bounds = this.mapUi.currentBounds;
pois = await this.osm.getCategoryPois(bounds, params.category, lat, lon);
// 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)
else if (params.q) {
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);
// Search local bookmarks by name
const queryLower = params.q.toLowerCase();
const localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
// 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);
// 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,
};
}
});
}
// Case 2: Nearby Search (lat/lon present, no q)
else if (lat && lon) {
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)
pois = await this.osm.getCategoryPois(
bounds,
params.category,
lat,
lon
);
if (!exists) {
pois.push(local);
}
});
// Sort by distance from click
pois = pois
.map((p) => {
return {
// 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);
}))
.sort((a, b) => a._distance - b._distance);
}
// Case 1: Text Search (q parameter present)
else if (params.q) {
loadingType = 'text';
loadingValue = params.q;
this.mapUi.startLoading(loadingType, loadingValue);
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);
// Search local bookmarks by name
const queryLower = params.q.toLowerCase();
const localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
// 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);
}
});
}
// 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);
}
} finally {
if (loadingType && loadingValue) {
this.mapUi.stopLoading(loadingType, loadingValue);
}
}
// Check if any of these are already bookmarked

View File

@@ -14,6 +14,7 @@ export default class MapUiService extends Service {
@tracked preventNextZoom = false;
@tracked searchResults = [];
@tracked currentSearch = null;
@tracked loadingState = null;
selectPlace(place, options = {}) {
this.selectedPlace = place;
@@ -70,4 +71,26 @@ export default class MapUiService extends Service {
updateBounds(bounds) {
this.currentBounds = bounds;
}
startLoading(type, value) {
this.loadingState = { type, value };
}
stopLoading(type = null, value = null) {
// If no arguments provided, force stop (legacy/cleanup)
if (!type && !value) {
this.loadingState = null;
return;
}
// Only clear if the current state matches the request
// This prevents a previous search from clearing the state of a new search
if (
this.loadingState &&
this.loadingState.type === type &&
this.loadingState.value === value
) {
this.loadingState = null;
}
}
}

View File

@@ -1315,3 +1315,9 @@ button.create-place {
.category-chip:active {
background: #eee;
}
.category-chip:disabled {
opacity: 0.75;
cursor: not-allowed;
pointer-events: none;
}

View File

@@ -34,6 +34,7 @@ import user from 'feather-icons/dist/icons/user.svg?raw';
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
import loadingRing from '../icons/270-ring.svg?raw';
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
@@ -211,6 +212,7 @@ const ICONS = {
wikipedia,
x,
zap,
'loading-ring': loadingRing,
};
const FILLED_ICONS = [
@@ -221,6 +223,7 @@ const FILLED_ICONS = [
'shopping-basket',
'camera',
'person-sleeping-in-bed',
'loading-ring',
];
export function getIcon(name) {