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

View File

@@ -8,7 +8,7 @@ import { task, timeout } from 'ember-concurrency';
import Icon from '#components/icon'; import Icon from '#components/icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag'; import humanizeOsmTag from '../helpers/humanize-osm-tag';
import { POI_CATEGORIES } from '../utils/poi-categories'; 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 { export default class SearchBoxComponent extends Component {
@service photon; @service photon;
@@ -211,7 +211,11 @@ export default class SearchBoxComponent extends Component {
/> />
<button type="submit" class="search-submit-btn" aria-label="Search"> <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> </button>
{{#if this.query}} {{#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 lat = params.lat ? parseFloat(params.lat) : null;
const lon = params.lon ? parseFloat(params.lon) : null; const lon = params.lon ? parseFloat(params.lon) : null;
let pois = []; let pois = [];
let loadingType = null;
let loadingValue = null;
// Case 0: Category Search (category parameter present) try {
if (params.category && lat && lon) { // Case 0: Category Search (category parameter present)
// We need bounds. If we have active map state, use it. if (params.category && lat && lon) {
let bounds = this.mapUi.currentBounds; 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) // We need bounds. If we have active map state, use it.
// or just use a fixed box around the center. let bounds = this.mapUi.currentBounds;
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, lat, lon); // 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.
// Sort by distance from center if (!bounds) {
pois = pois // Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
.map((p) => ({ // Let's take a safe box of ~1km radius.
...p, const delta = 0.01;
_distance: getDistance(lat, lon, p.lat, p.lon), bounds = {
})) minLat: lat - delta,
.sort((a, b) => a._distance - b._distance); maxLat: lat + delta,
} minLon: lon - delta,
// Case 1: Text Search (q parameter present) maxLon: lon + delta,
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);
} }
});
}
// 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.getCategoryPois(
pois = await this.osm.getNearbyPois(lat, lon, searchRadius); bounds,
params.category,
// Get cached/saved places in search radius lat,
const localMatches = this.storage.savedPlaces.filter((p) => { lon
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) { // Sort by distance from center
pois.push(local); pois = pois
} .map((p) => ({
});
// Sort by distance from click
pois = pois
.map((p) => {
return {
...p, ...p,
_distance: getDistance(lat, lon, p.lat, p.lon), _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 // Check if any of these are already bookmarked

View File

@@ -14,6 +14,7 @@ export default class MapUiService extends Service {
@tracked preventNextZoom = false; @tracked preventNextZoom = false;
@tracked searchResults = []; @tracked searchResults = [];
@tracked currentSearch = null; @tracked currentSearch = null;
@tracked loadingState = null;
selectPlace(place, options = {}) { selectPlace(place, options = {}) {
this.selectedPlace = place; this.selectedPlace = place;
@@ -70,4 +71,26 @@ export default class MapUiService extends Service {
updateBounds(bounds) { updateBounds(bounds) {
this.currentBounds = 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 { .category-chip:active {
background: #eee; 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 villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw'; import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.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 angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw'; import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
@@ -211,6 +212,7 @@ const ICONS = {
wikipedia, wikipedia,
x, x,
zap, zap,
'loading-ring': loadingRing,
}; };
const FILLED_ICONS = [ const FILLED_ICONS = [
@@ -221,6 +223,7 @@ const FILLED_ICONS = [
'shopping-basket', 'shopping-basket',
'camera', 'camera',
'person-sleeping-in-bed', 'person-sleeping-in-bed',
'loading-ring',
]; ];
export function getIcon(name) { export function getIcon(name) {

View File

@@ -0,0 +1,97 @@
import { module, test } from 'qunit';
import { visit } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { Promise } from 'rsvp';
class MockPhotonService extends Service {
async search(query) {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 50));
if (query === 'slow') {
await new Promise((resolve) => setTimeout(resolve, 200));
}
return [
{
title: 'Test Place',
lat: 1,
lon: 1,
osmId: '123',
osmType: 'node',
},
];
}
}
class MockOsmService extends Service {
async getCategoryPois(bounds, category) {
await new Promise((resolve) => setTimeout(resolve, 50));
if (category === 'slow_category') {
await new Promise((resolve) => setTimeout(resolve, 200));
}
return [];
}
async getNearbyPois() {
return [];
}
}
module('Acceptance | search loading', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:osm', MockOsmService);
});
test('search shows loading indicator but nearby search does not', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
// 1. Text Search
// Start a search and check for loading state immediately
const searchPromise = visit('/search?q=slow');
// We can't easily check the DOM mid-transition in acceptance tests without complicated helpers,
// so we check the service state which drives the UI.
// Wait a tiny bit for the route to start processing
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(
mapUi.loadingState,
{ type: 'text', value: 'slow' },
'Loading state is set for text search'
);
await searchPromise;
assert.strictEqual(
mapUi.loadingState,
null,
'Loading state is cleared after text search'
);
// 2. Category Search
const catPromise = visit('/search?category=slow_category&lat=1&lon=1');
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(
mapUi.loadingState,
{ type: 'category', value: 'slow_category' },
'Loading state is set for category search'
);
await catPromise;
assert.strictEqual(
mapUi.loadingState,
null,
'Loading state is cleared after category search'
);
// 3. Nearby Search
await visit('/search?lat=1&lon=1');
assert.strictEqual(
mapUi.loadingState,
null,
'Loading state is NOT set for nearby search'
);
});
});

View File

@@ -0,0 +1,68 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
module('Unit | Service | map-ui', function (hooks) {
setupTest(hooks);
test('it handles loading state correctly', function (assert) {
let service = this.owner.lookup('service:map-ui');
// Initial state
assert.strictEqual(
service.loadingState,
null,
'loadingState starts as null'
);
// Start loading search A
service.startLoading('search', 'A');
assert.deepEqual(
service.loadingState,
{ type: 'search', value: 'A' },
'loadingState is set to search A'
);
// Stop loading search A (successful case)
service.stopLoading('search', 'A');
assert.strictEqual(
service.loadingState,
null,
'loadingState is cleared when stopped with matching parameters'
);
});
test('it handles race condition: stopLoading only clears if parameters match', function (assert) {
let service = this.owner.lookup('service:map-ui');
// 1. Start loading search A
service.startLoading('search', 'A');
assert.deepEqual(service.loadingState, { type: 'search', value: 'A' });
// 2. Start loading search B (interruption)
// In a real app, search B would start before search A finishes.
service.startLoading('search', 'B');
assert.deepEqual(
service.loadingState,
{ type: 'search', value: 'B' },
'loadingState updates to search B'
);
// 3. Search A finishes and tries to stop loading
// The service should ignore this because current loading state is for B
service.stopLoading('search', 'A');
assert.deepEqual(
service.loadingState,
{ type: 'search', value: 'B' },
'loadingState remains search B even after stopping search A'
);
// 4. Search B finishes
service.stopLoading('search', 'B');
assert.strictEqual(
service.loadingState,
null,
'loadingState is cleared when search B stops'
);
});
});