Compare commits
12 Commits
818ec35071
...
v1.17.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
2b219fe0cf
|
|||
|
9fd6c4d64d
|
|||
|
8e5b2c7439
|
|||
|
0f29430e1a
|
|||
|
0059d89cc3
|
|||
|
54e2766dc4
|
|||
|
5978f67d48
|
|||
|
d72e5f3de2
|
|||
|
582ab4f8b3
|
|||
|
0ac6db65cb
|
|||
|
86b20fd474
|
|||
|
8478e00253
|
@@ -10,6 +10,7 @@ import CategoryChips from '#components/category-chips';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@service settings;
|
||||
@tracked isUserMenuOpen = false;
|
||||
@tracked searchQuery = '';
|
||||
|
||||
@@ -49,9 +50,11 @@ export default class AppHeaderComponent extends Component {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||
</div>
|
||||
{{#if this.settings.showQuickSearchButtons}}
|
||||
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="header-right">
|
||||
<div class="user-menu-container">
|
||||
|
||||
@@ -18,6 +18,11 @@ export default class AppMenuSettings extends Component {
|
||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||
}
|
||||
|
||||
@action
|
||||
toggleQuickSearchButtons(event) {
|
||||
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
|
||||
}
|
||||
|
||||
@action
|
||||
updatePhotonApi(event) {
|
||||
this.settings.updatePhotonApi(event.target.value);
|
||||
@@ -36,6 +41,30 @@ export default class AppMenuSettings extends Component {
|
||||
|
||||
<div class="sidebar-content">
|
||||
<section class="settings-section">
|
||||
<div class="form-group">
|
||||
<label for="show-quick-search">Quick search buttons visible</label>
|
||||
<select
|
||||
id="show-quick-search"
|
||||
class="form-control"
|
||||
{{on "change" this.toggleQuickSearchButtons}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.showQuickSearchButtons "selected"}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.showQuickSearchButtons
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||
<select
|
||||
|
||||
@@ -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,10 @@ 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>
|
||||
|
||||
@@ -8,10 +8,11 @@ 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;
|
||||
@service osm;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service map; // Assuming we might need map context, but mostly we use router
|
||||
@@ -178,6 +179,11 @@ export default class SearchBoxComponent extends Component {
|
||||
|
||||
@action
|
||||
clear() {
|
||||
this.searchTask.cancelAll();
|
||||
this.mapUi.stopLoading();
|
||||
this.osm.cancelAll();
|
||||
this.photon.cancelAll();
|
||||
|
||||
this.query = '';
|
||||
this.results = [];
|
||||
if (this.args.onQueryChange) {
|
||||
@@ -211,7 +217,16 @@ 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}}
|
||||
|
||||
14
app/components/toast.gjs
Normal file
14
app/components/toast.gjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ToastComponent extends Component {
|
||||
@service toast;
|
||||
|
||||
<template>
|
||||
{{#if this.toast.isVisible}}
|
||||
<div class="toast-notification">
|
||||
{{this.toast.message}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
1
app/icons/270-ring.svg
Normal file
1
app/icons/270-ring.svg
Normal 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 |
@@ -9,6 +9,7 @@ export default class SearchRoute extends Route {
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
@service router;
|
||||
@service toast;
|
||||
|
||||
queryParams = {
|
||||
lat: { refreshModel: true },
|
||||
@@ -22,97 +23,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
|
||||
@@ -177,8 +200,12 @@ export default class SearchRoute extends Route {
|
||||
}
|
||||
|
||||
@action
|
||||
error() {
|
||||
error(error, transition) {
|
||||
this.mapUi.stopSearch();
|
||||
return true; // Bubble error
|
||||
this.toast.show('Search request failed. Please try again.');
|
||||
if (transition) {
|
||||
transition.abort();
|
||||
}
|
||||
return false; // Prevent bubble and stop transition
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ export default class OsmService extends Service {
|
||||
cachedResults = null;
|
||||
lastQueryKey = null;
|
||||
|
||||
cancelAll() {
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
this.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
async getNearbyPois(lat, lon, radius = 50) {
|
||||
const queryKey = `${lat},${lon},${radius}`;
|
||||
|
||||
@@ -148,7 +155,7 @@ out center;
|
||||
return [];
|
||||
}
|
||||
console.error('Category search failed', e);
|
||||
return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@ import { humanizeOsmTag } from '../utils/format-text';
|
||||
export default class PhotonService extends Service {
|
||||
@service settings;
|
||||
|
||||
controller = null;
|
||||
|
||||
cancelAll() {
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
this.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this.settings.photonApi;
|
||||
}
|
||||
@@ -12,6 +21,12 @@ export default class PhotonService extends Service {
|
||||
async search(query, lat, lon, limit = 10) {
|
||||
if (!query || query.length < 2) return [];
|
||||
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
}
|
||||
this.controller = new AbortController();
|
||||
const signal = this.controller.signal;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: String(limit),
|
||||
@@ -25,7 +40,7 @@ export default class PhotonService extends Service {
|
||||
const url = `${this.baseUrl}?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const res = await this.fetchWithRetry(url);
|
||||
const res = await this.fetchWithRetry(url, { signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Photon request failed with status ${res.status}`);
|
||||
}
|
||||
@@ -35,6 +50,9 @@ export default class PhotonService extends Service {
|
||||
|
||||
return data.features.map((f) => this.normalizeFeature(f));
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
return [];
|
||||
}
|
||||
console.error('Photon search error:', e);
|
||||
// Return empty array on error so UI doesn't break
|
||||
return [];
|
||||
|
||||
@@ -5,6 +5,7 @@ export default class SettingsService extends Service {
|
||||
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||
@tracked mapKinetic = true;
|
||||
@tracked photonApi = 'https://photon.komoot.io/api/';
|
||||
@tracked showQuickSearchButtons = true;
|
||||
|
||||
overpassApis = [
|
||||
{
|
||||
@@ -56,6 +57,13 @@ export default class SettingsService extends Service {
|
||||
this.mapKinetic = savedKinetic === 'true';
|
||||
}
|
||||
// Default is true (initialized in class field)
|
||||
|
||||
const savedShowQuickSearch = localStorage.getItem(
|
||||
'marco:show-quick-search'
|
||||
);
|
||||
if (savedShowQuickSearch !== null) {
|
||||
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
updateOverpassApi(url) {
|
||||
@@ -68,6 +76,11 @@ export default class SettingsService extends Service {
|
||||
localStorage.setItem('marco:map-kinetic', String(enabled));
|
||||
}
|
||||
|
||||
updateShowQuickSearchButtons(enabled) {
|
||||
this.showQuickSearchButtons = enabled;
|
||||
localStorage.setItem('marco:show-quick-search', String(enabled));
|
||||
}
|
||||
|
||||
updatePhotonApi(url) {
|
||||
this.photonApi = url;
|
||||
}
|
||||
|
||||
21
app/services/toast.js
Normal file
21
app/services/toast.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class ToastService extends Service {
|
||||
@tracked message = null;
|
||||
@tracked isVisible = false;
|
||||
timeoutId = null;
|
||||
|
||||
show(message, duration = 3000) {
|
||||
this.message = message;
|
||||
this.isVisible = true;
|
||||
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.isVisible = false;
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
@@ -1315,3 +1315,42 @@ button.create-place {
|
||||
.category-chip:active {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.category-chip:disabled {
|
||||
opacity: 0.75;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Toast Notification */
|
||||
.toast-notification {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgb(51 51 51 / 85%);
|
||||
backdrop-filter: blur(4px);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 999px;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
animation: fade-in-up 0.3s ease-out forwards;
|
||||
text-align: center;
|
||||
max-width: 90%;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 1rem);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { pageTitle } from 'ember-page-title';
|
||||
import Map from '#components/map';
|
||||
import AppHeader from '#components/app-header';
|
||||
import AppMenu from '#components/app-menu/index';
|
||||
import Toast from '#components/toast';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
@@ -89,6 +90,8 @@ export default class ApplicationComponent extends Component {
|
||||
<AppMenu @onClose={{this.closeAppMenu}} />
|
||||
{{/if}}
|
||||
|
||||
<Toast />
|
||||
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export default class PlaceTemplate extends Component {
|
||||
if (place === null) {
|
||||
// If we have an active search context, return to it (UP navigation)
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: this.mapUi.currentSearch,
|
||||
});
|
||||
} else {
|
||||
// Otherwise just close the sidebar (return to map index)
|
||||
|
||||
@@ -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';
|
||||
@@ -104,6 +105,7 @@ import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/w
|
||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||
|
||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
activity,
|
||||
@@ -209,8 +211,10 @@ const ICONS = {
|
||||
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||
wikipedia,
|
||||
parking_p: parkingP,
|
||||
x,
|
||||
zap,
|
||||
'loading-ring': loadingRing,
|
||||
};
|
||||
|
||||
const FILLED_ICONS = [
|
||||
@@ -221,6 +225,7 @@ const FILLED_ICONS = [
|
||||
'shopping-basket',
|
||||
'camera',
|
||||
'person-sleeping-in-bed',
|
||||
'loading-ring',
|
||||
];
|
||||
|
||||
export function getIcon(name) {
|
||||
|
||||
@@ -164,6 +164,9 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { healthcare: 'dentist' }, icon: 'molar-tooth' },
|
||||
{ tags: { healthcare: true }, icon: 'greek-cross' },
|
||||
|
||||
// Parking
|
||||
{ tags: { amenity: 'parking' }, icon: 'parking_p' },
|
||||
|
||||
// Buildings
|
||||
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
||||
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
release/assets/main-DM7YMuyX.js
Normal file
2
release/assets/main-DM7YMuyX.js
Normal file
File diff suppressed because one or more lines are too long
1
release/assets/main-OLSOzTKA.css
Normal file
1
release/assets/main-OLSOzTKA.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-C4F17h3W.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CKp1bFPU.css">
|
||||
<script type="module" crossorigin src="/assets/main-DM7YMuyX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-OLSOzTKA.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
136
tests/acceptance/search-loading-test.js
Normal file
136
tests/acceptance/search-loading-test.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, click, fillIn, currentURL } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import { Promise } from 'rsvp';
|
||||
|
||||
class MockPhotonService extends Service {
|
||||
cancelAll() {}
|
||||
|
||||
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 {
|
||||
cancelAll() {}
|
||||
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
test('clearing search stops loading indicator', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
// 1. Start from index
|
||||
await visit('/');
|
||||
|
||||
// 2. Type "slow" to trigger autocomplete (which is async)
|
||||
await fillIn('.search-input', 'slow');
|
||||
|
||||
// 3. Submit search to trigger route loading
|
||||
click('.search-submit-btn'); // Intentionally no await to not block on transition
|
||||
|
||||
// Wait for loading state to activate
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
assert.deepEqual(
|
||||
mapUi.loadingState,
|
||||
{ type: 'text', value: 'slow' },
|
||||
'Loading state is set'
|
||||
);
|
||||
|
||||
// 4. Click the clear button (should be visible since input has value)
|
||||
await click('.search-clear-btn');
|
||||
|
||||
// Verify loading state is cleared immediately
|
||||
assert.strictEqual(
|
||||
mapUi.loadingState,
|
||||
null,
|
||||
'Loading state is cleared immediately after clicking clear'
|
||||
);
|
||||
|
||||
// Verify we are back on index (or at least query is gone)
|
||||
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
||||
});
|
||||
});
|
||||
@@ -218,4 +218,56 @@ module('Acceptance | search', function (hooks) {
|
||||
// Ensure it shows "Results" not "Nearby"
|
||||
assert.dom('.sidebar-header h2').includesText('Results');
|
||||
});
|
||||
|
||||
test('search error handling prevents opening empty panel and shows toast', async function (assert) {
|
||||
// Mock Osm Service to throw an error
|
||||
class MockOsmService extends Service {
|
||||
async getCategoryPois() {
|
||||
throw new Error('Overpass request failed');
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
class MockMapService extends Service {
|
||||
getBounds() {
|
||||
return {
|
||||
minLat: 52.5,
|
||||
minLon: 13.4,
|
||||
maxLat: 52.6,
|
||||
maxLon: 13.5,
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:map', MockMapService);
|
||||
|
||||
await visit('/');
|
||||
|
||||
try {
|
||||
await visit('/search?category=coffee&lat=52.52&lon=13.405');
|
||||
} catch {
|
||||
// Aborted transition throws, which is expected
|
||||
}
|
||||
|
||||
assert.dom('.toast-notification').exists('Toast should be visible');
|
||||
assert
|
||||
.dom('.toast-notification')
|
||||
.hasText('Search request failed. Please try again.');
|
||||
assert.dom('.places-sidebar').doesNotExist('Results panel should not open');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,15 @@ module('Integration | Component | app-header', function (hooks) {
|
||||
class MockRouterService extends Service {}
|
||||
class MockMapUiService extends Service {}
|
||||
class MockMapService extends Service {}
|
||||
class MockSettingsService extends Service {
|
||||
showQuickSearchButtons = true;
|
||||
}
|
||||
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
this.owner.register('service:map', MockMapService);
|
||||
this.owner.register('service:settings', MockSettingsService);
|
||||
|
||||
await render(
|
||||
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||
@@ -43,11 +47,15 @@ module('Integration | Component | app-header', function (hooks) {
|
||||
currentCenter = null;
|
||||
}
|
||||
class MockMapService extends Service {}
|
||||
class MockSettingsService extends Service {
|
||||
showQuickSearchButtons = true;
|
||||
}
|
||||
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
this.owner.register('service:map', MockMapService);
|
||||
this.owner.register('service:settings', MockSettingsService);
|
||||
|
||||
await render(
|
||||
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||
|
||||
68
tests/unit/services/map-ui-test.js
Normal file
68
tests/unit/services/map-ui-test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user