Compare commits

...

14 Commits

Author SHA1 Message Date
2b219fe0cf 1.17.0
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
2026-03-27 15:17:16 +04:00
9fd6c4d64d Merge pull request 'When search requests fail, show error in toast notifications instead of empty search results' (#38) from feature/failed_requests into master
Some checks failed
CI / Lint (push) Successful in 28s
CI / Test (push) Failing after 44s
Reviewed-on: #38
2026-03-27 11:12:58 +00:00
8e5b2c7439 Fix lint error
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 44s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-03-27 15:05:56 +04:00
0f29430e1a When request retries exhaust, show error in toast notification
Some checks failed
CI / Lint (pull_request) Failing after 29s
CI / Test (pull_request) Failing after 44s
2026-03-27 15:01:04 +04:00
0059d89cc3 Add toast notifications 2026-03-27 15:00:36 +04:00
54e2766dc4 Merge pull request 'Add setting for hiding quick search buttons' (#36) from feature/settings_quick-search into master
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
Reviewed-on: #36
2026-03-27 10:09:18 +00:00
5978f67d48 Add setting for hiding quick search buttons
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 44s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-03-27 13:59:36 +04:00
d72e5f3de2 Merge pull request 'Add category search, and search result markers with icons' (#35) from feature/poi_type_search into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 47s
Reviewed-on: #35
2026-03-27 09:12:28 +00:00
582ab4f8b3 Fix lint errors
All checks were successful
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 47s
Release Drafter / Update release notes draft (pull_request) Successful in 38s
2026-03-23 18:32:18 +04:00
0ac6db65cb Add parking icon
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Test (pull_request) Waiting to run
2026-03-23 18:21:41 +04:00
86b20fd474 Abort search requests when clearing search box
Also adds abort support for Photon queries
2026-03-23 18:07:29 +04:00
8478e00253 Add loading indicator for search queries 2026-03-23 17:50:21 +04:00
818ec35071 Ensure map marker clicks preserve search context
Fixes the back button just closing the sidebar and clearing the whole
search after having seleted a result via map marker
2026-03-23 16:42:32 +04:00
46605dbd32 Add more icons 2026-03-23 16:07:33 +04:00
32 changed files with 731 additions and 126 deletions

View File

@@ -10,6 +10,7 @@ import CategoryChips from '#components/category-chips';
export default class AppHeaderComponent extends Component { export default class AppHeaderComponent extends Component {
@service storage; @service storage;
@service settings;
@tracked isUserMenuOpen = false; @tracked isUserMenuOpen = false;
@tracked searchQuery = ''; @tracked searchQuery = '';
@@ -49,9 +50,11 @@ export default class AppHeaderComponent extends Component {
/> />
</div> </div>
<div class="header-center {{if this.hasQuery 'searching'}}"> {{#if this.settings.showQuickSearchButtons}}
<CategoryChips @onSelect={{this.handleChipSelect}} /> <div class="header-center {{if this.hasQuery 'searching'}}">
</div> <CategoryChips @onSelect={{this.handleChipSelect}} />
</div>
{{/if}}
<div class="header-right"> <div class="header-right">
<div class="user-menu-container"> <div class="user-menu-container">

View File

@@ -18,6 +18,11 @@ export default class AppMenuSettings extends Component {
this.settings.updateMapKinetic(event.target.value === 'true'); this.settings.updateMapKinetic(event.target.value === 'true');
} }
@action
toggleQuickSearchButtons(event) {
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
}
@action @action
updatePhotonApi(event) { updatePhotonApi(event) {
this.settings.updatePhotonApi(event.target.value); this.settings.updatePhotonApi(event.target.value);
@@ -36,6 +41,30 @@ export default class AppMenuSettings extends Component {
<div class="sidebar-content"> <div class="sidebar-content">
<section class="settings-section"> <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"> <div class="form-group">
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label> <label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
<select <select

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,10 @@ 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

@@ -1099,6 +1099,20 @@ export default class MapComponent extends Component {
} }
} }
// Helper to transition with proper state
const transitionToPlace = (place) => {
// If we are currently in search mode OR have active search results,
// we want the "Back" button on the place details to return to the search results.
if (
this.router.currentRouteName === 'search' ||
(this.mapUi.currentSearch && this.mapUi.searchResults.length > 0)
) {
this.mapUi.returnToSearch = true;
}
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', place);
};
// Special handling when sidebar is OPEN // Special handling when sidebar is OPEN
if (this.args.isSidebarOpen) { if (this.args.isSidebarOpen) {
// If it's a bookmark or search result, we allow "switching" to it even if sidebar is open // If it's a bookmark or search result, we allow "switching" to it even if sidebar is open
@@ -1108,8 +1122,7 @@ export default class MapComponent extends Component {
'Clicked feature while sidebar open (switching):', 'Clicked feature while sidebar open (switching):',
targetPlace targetPlace
); );
this.mapUi.preventNextZoom = true; transitionToPlace(targetPlace);
this.router.transitionTo('place', targetPlace);
return; return;
} }
@@ -1123,15 +1136,13 @@ export default class MapComponent extends Component {
// Normal behavior (sidebar is closed) // Normal behavior (sidebar is closed)
if (clickedBookmark) { if (clickedBookmark) {
console.debug('Clicked bookmark:', clickedBookmark); console.debug('Clicked bookmark:', clickedBookmark);
this.mapUi.preventNextZoom = true; transitionToPlace(clickedBookmark);
this.router.transitionTo('place', clickedBookmark);
return; return;
} }
if (clickedSearchResult) { if (clickedSearchResult) {
console.debug('Clicked search result:', clickedSearchResult); console.debug('Clicked search result:', clickedSearchResult);
this.mapUi.preventNextZoom = true; transitionToPlace(clickedSearchResult);
this.router.transitionTo('place', clickedSearchResult);
return; return;
} }

View File

@@ -8,10 +8,11 @@ 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;
@service osm;
@service router; @service router;
@service mapUi; @service mapUi;
@service map; // Assuming we might need map context, but mostly we use router @service map; // Assuming we might need map context, but mostly we use router
@@ -178,6 +179,11 @@ export default class SearchBoxComponent extends Component {
@action @action
clear() { clear() {
this.searchTask.cancelAll();
this.mapUi.stopLoading();
this.osm.cancelAll();
this.photon.cancelAll();
this.query = ''; this.query = '';
this.results = []; this.results = [];
if (this.args.onQueryChange) { if (this.args.onQueryChange) {
@@ -211,7 +217,16 @@ 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}}

14
app/components/toast.gjs Normal file
View 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
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

@@ -9,6 +9,7 @@ export default class SearchRoute extends Route {
@service mapUi; @service mapUi;
@service storage; @service storage;
@service router; @service router;
@service toast;
queryParams = { queryParams = {
lat: { refreshModel: true }, lat: { refreshModel: true },
@@ -22,97 +23,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
@@ -170,11 +193,19 @@ export default class SearchRoute extends Route {
// Ensure pulse is stopped if we reach here // Ensure pulse is stopped if we reach here
this.mapUi.stopSearch(); this.mapUi.stopSearch();
this.mapUi.setSearchResults(model); this.mapUi.setSearchResults(model);
// Store current search params to allow "Up" navigation from place details
const { q, category, lat, lon } = this.paramsFor('search');
this.mapUi.currentSearch = { q, category, lat, lon };
} }
@action @action
error() { error(error, transition) {
this.mapUi.stopSearch(); 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
} }
} }

View File

@@ -13,6 +13,8 @@ export default class MapUiService extends Service {
@tracked selectionOptions = {}; @tracked selectionOptions = {};
@tracked preventNextZoom = false; @tracked preventNextZoom = false;
@tracked searchResults = []; @tracked searchResults = [];
@tracked currentSearch = null;
@tracked loadingState = null;
selectPlace(place, options = {}) { selectPlace(place, options = {}) {
this.selectedPlace = place; this.selectedPlace = place;
@@ -31,6 +33,7 @@ export default class MapUiService extends Service {
clearSearchResults() { clearSearchResults() {
this.searchResults = []; this.searchResults = [];
this.currentSearch = null;
} }
startSearch() { startSearch() {
@@ -68,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

@@ -9,6 +9,13 @@ export default class OsmService extends Service {
cachedResults = null; cachedResults = null;
lastQueryKey = null; lastQueryKey = null;
cancelAll() {
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
async getNearbyPois(lat, lon, radius = 50) { async getNearbyPois(lat, lon, radius = 50) {
const queryKey = `${lat},${lon},${radius}`; const queryKey = `${lat},${lon},${radius}`;
@@ -148,7 +155,7 @@ out center;
return []; return [];
} }
console.error('Category search failed', e); console.error('Category search failed', e);
return []; throw e;
} }
} }

View File

@@ -5,6 +5,15 @@ import { humanizeOsmTag } from '../utils/format-text';
export default class PhotonService extends Service { export default class PhotonService extends Service {
@service settings; @service settings;
controller = null;
cancelAll() {
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
get baseUrl() { get baseUrl() {
return this.settings.photonApi; return this.settings.photonApi;
} }
@@ -12,6 +21,12 @@ export default class PhotonService extends Service {
async search(query, lat, lon, limit = 10) { async search(query, lat, lon, limit = 10) {
if (!query || query.length < 2) return []; 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({ const params = new URLSearchParams({
q: query, q: query,
limit: String(limit), limit: String(limit),
@@ -25,7 +40,7 @@ export default class PhotonService extends Service {
const url = `${this.baseUrl}?${params.toString()}`; const url = `${this.baseUrl}?${params.toString()}`;
try { try {
const res = await this.fetchWithRetry(url); const res = await this.fetchWithRetry(url, { signal });
if (!res.ok) { if (!res.ok) {
throw new Error(`Photon request failed with status ${res.status}`); 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)); return data.features.map((f) => this.normalizeFeature(f));
} catch (e) { } catch (e) {
if (e.name === 'AbortError') {
return [];
}
console.error('Photon search error:', e); console.error('Photon search error:', e);
// Return empty array on error so UI doesn't break // Return empty array on error so UI doesn't break
return []; return [];

View File

@@ -5,6 +5,7 @@ export default class SettingsService extends Service {
@tracked overpassApi = 'https://overpass-api.de/api/interpreter'; @tracked overpassApi = 'https://overpass-api.de/api/interpreter';
@tracked mapKinetic = true; @tracked mapKinetic = true;
@tracked photonApi = 'https://photon.komoot.io/api/'; @tracked photonApi = 'https://photon.komoot.io/api/';
@tracked showQuickSearchButtons = true;
overpassApis = [ overpassApis = [
{ {
@@ -56,6 +57,13 @@ export default class SettingsService extends Service {
this.mapKinetic = savedKinetic === 'true'; this.mapKinetic = savedKinetic === 'true';
} }
// Default is true (initialized in class field) // Default is true (initialized in class field)
const savedShowQuickSearch = localStorage.getItem(
'marco:show-quick-search'
);
if (savedShowQuickSearch !== null) {
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
}
} }
updateOverpassApi(url) { updateOverpassApi(url) {
@@ -68,6 +76,11 @@ export default class SettingsService extends Service {
localStorage.setItem('marco:map-kinetic', String(enabled)); localStorage.setItem('marco:map-kinetic', String(enabled));
} }
updateShowQuickSearchButtons(enabled) {
this.showQuickSearchButtons = enabled;
localStorage.setItem('marco:show-quick-search', String(enabled));
}
updatePhotonApi(url) { updatePhotonApi(url) {
this.photonApi = url; this.photonApi = url;
} }

21
app/services/toast.js Normal file
View 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);
}
}

View File

@@ -1315,3 +1315,42 @@ 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;
}
/* 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);
}
}

View File

@@ -3,6 +3,7 @@ import { pageTitle } from 'ember-page-title';
import Map from '#components/map'; import Map from '#components/map';
import AppHeader from '#components/app-header'; import AppHeader from '#components/app-header';
import AppMenu from '#components/app-menu/index'; import AppMenu from '#components/app-menu/index';
import Toast from '#components/toast';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
@@ -89,6 +90,8 @@ export default class ApplicationComponent extends Component {
<AppMenu @onClose={{this.closeAppMenu}} /> <AppMenu @onClose={{this.closeAppMenu}} />
{{/if}} {{/if}}
<Toast />
{{outlet}} {{outlet}}
</template> </template>
} }

View File

@@ -77,9 +77,11 @@ export default class PlaceTemplate extends Component {
navigateBack(place) { navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked. // The sidebar calls this with null when "Back" is clicked.
if (place === null) { if (place === null) {
// If we came from search results, go back in history // If we have an active search context, return to it (UP navigation)
if (this.mapUi.returnToSearch) { if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
window.history.back(); this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch,
});
} else { } else {
// Otherwise just close the sidebar (return to map index) // Otherwise just close the sidebar (return to map index)
this.router.transitionTo('index'); this.router.transitionTo('index');

View File

@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
selectPlace(place) { selectPlace(place) {
if (place) { if (place) {
this.mapUi.returnToSearch = true; this.mapUi.returnToSearch = true;
// We don't need to manually set currentSearch here because
// it was already set in the route's setupController
this.router.transitionTo('place', place); this.router.transitionTo('place', place);
} }
} }

View File

@@ -8,6 +8,7 @@ import edit from 'feather-icons/dist/icons/edit.svg?raw';
import eyeglasses from '@waysidemapping/pinhead/dist/icons/eyeglasses.svg?raw'; import eyeglasses from '@waysidemapping/pinhead/dist/icons/eyeglasses.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw'; import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
import fancyMirrorWithReflectionAndStars from '@waysidemapping/pinhead/dist/icons/fancy_mirror_with_reflection_and_stars.svg?raw'; import fancyMirrorWithReflectionAndStars from '@waysidemapping/pinhead/dist/icons/fancy_mirror_with_reflection_and_stars.svg?raw';
import familyRestroomSymbol from '@waysidemapping/pinhead/dist/icons/family_restroom_symbol.svg?raw';
import gift from 'feather-icons/dist/icons/gift.svg?raw'; import gift from 'feather-icons/dist/icons/gift.svg?raw';
import globe from 'feather-icons/dist/icons/globe.svg?raw'; import globe from 'feather-icons/dist/icons/globe.svg?raw';
import heart from 'feather-icons/dist/icons/heart.svg?raw'; import heart from 'feather-icons/dist/icons/heart.svg?raw';
@@ -16,6 +17,7 @@ import info from 'feather-icons/dist/icons/info.svg?raw';
import instagram from 'feather-icons/dist/icons/instagram.svg?raw'; import instagram from 'feather-icons/dist/icons/instagram.svg?raw';
import logIn from 'feather-icons/dist/icons/log-in.svg?raw'; import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
import logOut from 'feather-icons/dist/icons/log-out.svg?raw'; import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
import mail from 'feather-icons/dist/icons/mail.svg?raw'; import mail from 'feather-icons/dist/icons/mail.svg?raw';
import map from 'feather-icons/dist/icons/map.svg?raw'; import map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw'; import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
@@ -32,17 +34,22 @@ 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';
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield_with_fire.svg?raw';
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw'; import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw'; import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw'; import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw'; import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw'; import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw'; import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw'; import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw'; import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
import clothesHanger from '@waysidemapping/pinhead/dist/icons/clothes_hanger.svg?raw'; import clothesHanger from '@waysidemapping/pinhead/dist/icons/clothes_hanger.svg?raw';
import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw'; import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw'; import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw';
@@ -61,11 +68,14 @@ import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw'; import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw'; import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw'; import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw'; import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw'; import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw'; import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw'; import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw'; import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw'; import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw'; import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw'; import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
@@ -76,6 +86,9 @@ import personSleepingInBed from '@waysidemapping/pinhead/dist/icons/person_sleep
import personSwimmingInWater from '@waysidemapping/pinhead/dist/icons/person_swimming_in_water.svg?raw'; import personSwimmingInWater from '@waysidemapping/pinhead/dist/icons/person_swimming_in_water.svg?raw';
import personSwingingGolfClub from '@waysidemapping/pinhead/dist/icons/person_swinging_golf_club.svg?raw'; import personSwingingGolfClub from '@waysidemapping/pinhead/dist/icons/person_swinging_golf_club.svg?raw';
import plantInRaisedPlanter from '@waysidemapping/pinhead/dist/icons/plant_in_raised_planter.svg?raw'; import plantInRaisedPlanter from '@waysidemapping/pinhead/dist/icons/plant_in_raised_planter.svg?raw';
import placeOfWorshipBuilding from '@waysidemapping/pinhead/dist/icons/place_of_worship_building.svg?raw';
import playStructureWithSlide from '@waysidemapping/pinhead/dist/icons/play_structure_with_slide.svg?raw';
import policeOfficerWithStopArm from '@waysidemapping/pinhead/dist/icons/police_officer_with_stop_arm.svg?raw';
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw'; import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw'; import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw'; import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
@@ -85,27 +98,35 @@ import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw'; import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw'; import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw'; import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw'; import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw'; import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
import wikipedia from '../icons/wikipedia.svg?raw'; import wikipedia from '../icons/wikipedia.svg?raw';
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
const ICONS = { const ICONS = {
activity, activity,
angelfish, angelfish,
'arrow-left': arrowLeft, 'arrow-left': arrowLeft,
barbell, barbell,
banknote,
'badge-shield-with-fire': badgeShieldWithFire,
'beach-umbrella-in-ground': beachUmbrellaInGround, 'beach-umbrella-in-ground': beachUmbrellaInGround,
'beer-mug-with-foam': beerMugWithFoam, 'beer-mug-with-foam': beerMugWithFoam,
bookmark, bookmark,
'boxing-glove-up': boxingGloveUp, 'boxing-glove-up': boxingGloveUp,
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw, 'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
bus,
camera, camera,
'check-square': checkSquare, 'check-square': checkSquare,
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl, 'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
'classical-building': classicalBuilding, 'classical-building': classicalBuilding,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag, 'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag, 'classical-building-with-flag': classicalBuildingWithFlag,
'commercial-building': commercialBuilding,
'clothes-hanger': clothesHanger, 'clothes-hanger': clothesHanger,
cleaver, cleaver,
cloth, cloth,
@@ -120,6 +141,7 @@ const ICONS = {
eyeglasses, eyeglasses,
facebook, facebook,
'fancy-mirror-with-reflection-and-stars': fancyMirrorWithReflectionAndStars, 'fancy-mirror-with-reflection-and-stars': fancyMirrorWithReflectionAndStars,
'family-restroom-symbol': familyRestroomSymbol,
film, film,
'fingernail-polished': fingernailPolished, 'fingernail-polished': fingernailPolished,
fish, fish,
@@ -131,6 +153,7 @@ const ICONS = {
globe, globe,
gravestone, gravestone,
'grecian-vase': grecianVase, 'grecian-vase': grecianVase,
'greek-cross': greekCross,
heart, heart,
home, home,
'ice-cream-on-cone': iceCreamOnCone, 'ice-cream-on-cone': iceCreamOnCone,
@@ -139,6 +162,7 @@ const ICONS = {
jewel, jewel,
'log-in': logIn, 'log-in': logIn,
'log-out': logOut, 'log-out': logOut,
'lowrise-building': lowriseBuilding,
mail, mail,
map, map,
'map-pin': mapPin, 'map-pin': mapPin,
@@ -146,8 +170,10 @@ const ICONS = {
'memorial-stone-with-inscription': memorialStoneWithInscription, 'memorial-stone-with-inscription': memorialStoneWithInscription,
menu, menu,
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna, 'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
'molar-tooth': molarTooth,
navigation, navigation,
'needle-and-spool-of-thread': needleAndSpoolOfThread, 'needle-and-spool-of-thread': needleAndSpoolOfThread,
'open-book': openBook,
palace, palace,
'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall, 'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall,
'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track': 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track':
@@ -161,6 +187,9 @@ const ICONS = {
phone, phone,
'plane-top-right': planeTopRight, 'plane-top-right': planeTopRight,
'plant-in-raised-planter': plantInRaisedPlanter, 'plant-in-raised-planter': plantInRaisedPlanter,
'place-of-worship-building': placeOfWorshipBuilding,
'play-structure-with-slide': playStructureWithSlide,
'police-officer-with-stop-arm': policeOfficerWithStopArm,
plus, plus,
'round-structure-with-flag': roundStructureWithFlag, 'round-structure-with-flag': roundStructureWithFlag,
'sailing-ship-in-water': sailingShipInWater, 'sailing-ship-in-water': sailingShipInWater,
@@ -174,13 +203,18 @@ const ICONS = {
'shopping-cart': shoppingCart, 'shopping-cart': shoppingCart,
'table-tennis-paddle': tableTennisPaddle, 'table-tennis-paddle': tableTennisPaddle,
'tattoo-machine': tattooMachine, 'tattoo-machine': tattooMachine,
toolbox,
target, target,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user, user,
'village-buildings': villageBuildings, 'village-buildings': villageBuildings,
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun, 'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
wikipedia, wikipedia,
parking_p: parkingP,
x, x,
zap, zap,
'loading-ring': loadingRing,
}; };
const FILLED_ICONS = [ const FILLED_ICONS = [
@@ -191,6 +225,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

@@ -20,10 +20,21 @@ export const POI_ICON_RULES = [
{ tags: { amenity: 'pub' }, icon: 'beer-mug-with-foam' }, { tags: { amenity: 'pub' }, icon: 'beer-mug-with-foam' },
{ tags: { amenity: 'bar' }, icon: 'cocktail' }, { tags: { amenity: 'bar' }, icon: 'cocktail' },
{ tags: { amenity: 'food_court' }, icon: 'fork-and-knife' }, { tags: { amenity: 'food_court' }, icon: 'fork-and-knife' },
{ tags: { amenity: 'childcare' }, icon: 'family-restroom-symbol' },
{ tags: { amenity: 'community_centre' }, icon: 'family-restroom-symbol' },
{ tags: { amenity: 'social_centre' }, icon: 'family-restroom-symbol' },
{ tags: { amenity: 'social_facility' }, icon: 'family-restroom-symbol' },
{ tags: { amenity: 'bank' }, icon: 'banknote' },
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
{ tags: { amenity: 'school' }, icon: 'open-book' },
{ tags: { shop: 'coffee' }, icon: 'coffee-bean' }, { tags: { shop: 'coffee' }, icon: 'coffee-bean' },
{ tags: { shop: 'tea' }, icon: 'coffee-bean' }, { tags: { shop: 'tea' }, icon: 'coffee-bean' },
{ tags: { shop: 'pastry' }, icon: 'donut' }, // Pastry shops often have donuts { tags: { shop: 'pastry' }, icon: 'donut' },
// Shopping // Shopping
{ tags: { shop: 'supermarket' }, icon: 'shopping-cart' }, { tags: { shop: 'supermarket' }, icon: 'shopping-cart' },
@@ -43,14 +54,12 @@ export const POI_ICON_RULES = [
{ tags: { shop: 'kiosk' }, icon: 'shopping-basket' }, { tags: { shop: 'kiosk' }, icon: 'shopping-basket' },
{ tags: { shop: 'leather' }, icon: 'shopping-bag' }, { tags: { shop: 'leather' }, icon: 'shopping-bag' },
{ tags: { shop: 'tailor' }, icon: 'needle-and-spool-of-thread' }, { tags: { shop: 'tailor' }, icon: 'needle-and-spool-of-thread' },
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
{ tags: { shop: 'jewelry' }, icon: 'jewel' }, { tags: { shop: 'jewelry' }, icon: 'jewel' },
{ tags: { shop: 'jewellery' }, icon: 'jewel' }, { tags: { shop: 'jewellery' }, icon: 'jewel' },
{ tags: { shop: 'tobacco' }, icon: 'cigarette-with-smoke-curl' }, { tags: { shop: 'tobacco' }, icon: 'cigarette-with-smoke-curl' },
{ tags: { shop: 'cannabis' }, icon: 'cigarette-with-smoke-curl' }, { tags: { shop: 'cannabis' }, icon: 'cigarette-with-smoke-curl' },
{ tags: { shop: 'florist' }, icon: 'flower-bouquet' }, { tags: { shop: 'florist' }, icon: 'flower-bouquet' },
{ tags: { shop: 'garden_centre' }, icon: 'plant-in-raised-planter' }, { tags: { shop: 'garden_centre' }, icon: 'plant-in-raised-planter' },
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
{ tags: { shop: 'estate_agent' }, icon: 'village-buildings' }, { tags: { shop: 'estate_agent' }, icon: 'village-buildings' },
{ {
tags: { shop: 'mobile_phone' }, tags: { shop: 'mobile_phone' },
@@ -62,14 +71,23 @@ export const POI_ICON_RULES = [
tags: { shop: 'beauty' }, tags: { shop: 'beauty' },
icon: 'fancy-mirror-with-reflection-and-stars', icon: 'fancy-mirror-with-reflection-and-stars',
}, },
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
{ tags: { office: true }, icon: 'commercial-building' },
{ tags: { craft: true }, icon: 'toolbox' },
{ tags: { shop: true }, icon: 'shopping-bag' },
// Natural // Natural
{ tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' }, { tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' },
{ tags: { leisure: 'park' }, icon: 'tree-and-bench-with-backrest' },
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
// Transport // Transport
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' }, { tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' }, { tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' }, { tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
{ tags: { bus: true }, icon: 'bus' },
{ {
tags: { railway: 'tram_stop' }, tags: { railway: 'tram_stop' },
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track', icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
@@ -138,9 +156,20 @@ export const POI_ICON_RULES = [
{ tags: { sport: 'stadium' }, icon: 'round-structure-with-flag' }, { tags: { sport: 'stadium' }, icon: 'round-structure-with-flag' },
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' }, { tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' }, { tags: { leisure: 'sports_centre' }, icon: 'person-running' },
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
{ tags: { sport: true }, icon: 'person-running' },
// Generic Catch-alls (must be last) // Healthcare
{ tags: { shop: true }, icon: 'shopping-basket' }, { tags: { amenity: 'dentist' }, icon: 'molar-tooth' },
{ 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' },
]; ];
/** /**

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.16.0", "version": "1.17.0",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6"> <meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png"> <meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-C4F17h3W.js"></script> <script type="module" crossorigin src="/assets/main-DM7YMuyX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CKp1bFPU.css"> <link rel="stylesheet" crossorigin href="/assets/main-OLSOzTKA.css">
</head> </head>
<body> <body>
</body> </body>

View File

@@ -64,25 +64,24 @@ module('Acceptance | navigation', function (hooks) {
this.owner.register('service:storage', MockStorageService); this.owner.register('service:storage', MockStorageService);
}); });
test('navigating from search results to place and back uses history', async function (assert) { test('navigating from search results to place and back returns to search', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui'); const mapUi = this.owner.lookup('service:map-ui');
const backStub = sinon.stub(window.history, 'back');
try { await visit('/search?lat=1&lon=1');
await visit('/search?lat=1&lon=1'); assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
await click('.place-item'); await click('.place-item');
assert.ok(currentURL().includes('/place/'), 'Navigated to place'); assert.ok(currentURL().includes('/place/'), 'Navigated to place');
assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set'); assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set');
// Click the back button in the sidebar // Click the back button in the sidebar
await click('.back-btn'); await click('.back-btn');
assert.true(backStub.calledOnce, 'window.history.back() was called'); assert.strictEqual(
} finally { currentURL(),
backStub.restore(); '/search?lat=1&lon=1',
} 'Returned to search results'
);
}); });
test('closing the sidebar resets the returnToSearch flag', async function (assert) { test('closing the sidebar resets the returnToSearch flag', async function (assert) {

View 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');
});
});

View File

@@ -218,4 +218,56 @@ module('Acceptance | search', function (hooks) {
// Ensure it shows "Results" not "Nearby" // Ensure it shows "Results" not "Nearby"
assert.dom('.sidebar-header h2').includesText('Results'); 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');
});
}); });

View File

@@ -14,11 +14,15 @@ module('Integration | Component | app-header', function (hooks) {
class MockRouterService extends Service {} class MockRouterService extends Service {}
class MockMapUiService extends Service {} class MockMapUiService extends Service {}
class MockMapService extends Service {} class MockMapService extends Service {}
class MockSettingsService extends Service {
showQuickSearchButtons = true;
}
this.owner.register('service:photon', MockPhotonService); this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:router', MockRouterService); this.owner.register('service:router', MockRouterService);
this.owner.register('service:map-ui', MockMapUiService); this.owner.register('service:map-ui', MockMapUiService);
this.owner.register('service:map', MockMapService); this.owner.register('service:map', MockMapService);
this.owner.register('service:settings', MockSettingsService);
await render( await render(
<template><AppHeader @onToggleMenu={{this.noop}} /></template> <template><AppHeader @onToggleMenu={{this.noop}} /></template>
@@ -43,11 +47,15 @@ module('Integration | Component | app-header', function (hooks) {
currentCenter = null; currentCenter = null;
} }
class MockMapService extends Service {} class MockMapService extends Service {}
class MockSettingsService extends Service {
showQuickSearchButtons = true;
}
this.owner.register('service:photon', MockPhotonService); this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:router', MockRouterService); this.owner.register('service:router', MockRouterService);
this.owner.register('service:map-ui', MockMapUiService); this.owner.register('service:map-ui', MockMapUiService);
this.owner.register('service:map', MockMapService); this.owner.register('service:map', MockMapService);
this.owner.register('service:settings', MockSettingsService);
await render( await render(
<template><AppHeader @onToggleMenu={{this.noop}} /></template> <template><AppHeader @onToggleMenu={{this.noop}} /></template>

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'
);
});
});

View File

@@ -0,0 +1,39 @@
import { getIconNameForTags } from 'marco/utils/osm-icons';
import { module, test } from 'qunit';
module('Unit | Utility | osm-icons', function () {
test('it returns molar-tooth for amenity=dentist', function (assert) {
let result = getIconNameForTags({ amenity: 'dentist' });
assert.strictEqual(result, 'molar-tooth');
});
test('it returns molar-tooth for healthcare=dentist', function (assert) {
let result = getIconNameForTags({ healthcare: 'dentist' });
assert.strictEqual(result, 'molar-tooth');
});
test('it returns greek-cross for healthcare=hospital (catch-all)', function (assert) {
let result = getIconNameForTags({ healthcare: 'hospital' });
assert.strictEqual(result, 'greek-cross');
});
test('it returns greek-cross for healthcare=yes (catch-all)', function (assert) {
let result = getIconNameForTags({ healthcare: 'yes' });
assert.strictEqual(result, 'greek-cross');
});
test('it returns shopping-basket for known shop types like convenience', function (assert) {
let result = getIconNameForTags({ shop: 'convenience' });
assert.strictEqual(result, 'shopping-basket');
});
test('it returns shopping-bag for unknown shop types (catch-all)', function (assert) {
let result = getIconNameForTags({ shop: 'unknown_shop_type' });
assert.strictEqual(result, 'shopping-bag');
});
test('it returns null for unknown tags', function (assert) {
let result = getIconNameForTags({ foo: 'bar' });
assert.strictEqual(result, null);
});
});