Compare commits
20 Commits
438bf0c31c
...
v1.17.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
2b219fe0cf
|
|||
|
9fd6c4d64d
|
|||
|
8e5b2c7439
|
|||
|
0f29430e1a
|
|||
|
0059d89cc3
|
|||
|
54e2766dc4
|
|||
|
5978f67d48
|
|||
|
d72e5f3de2
|
|||
|
582ab4f8b3
|
|||
|
0ac6db65cb
|
|||
|
86b20fd474
|
|||
|
8478e00253
|
|||
|
818ec35071
|
|||
|
46605dbd32
|
|||
|
bcc51efecc
|
|||
|
8bec4b978e
|
|||
|
cd9676047d
|
|||
|
a92b44ec13
|
|||
|
0c2d1f8419
|
|||
|
bb77ed8337
|
@@ -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>
|
||||
|
||||
@@ -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
|
||||
if (this.args.isSidebarOpen) {
|
||||
// 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):',
|
||||
targetPlace
|
||||
);
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', targetPlace);
|
||||
transitionToPlace(targetPlace);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1123,15 +1136,13 @@ export default class MapComponent extends Component {
|
||||
// Normal behavior (sidebar is closed)
|
||||
if (clickedBookmark) {
|
||||
console.debug('Clicked bookmark:', clickedBookmark);
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
transitionToPlace(clickedBookmark);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickedSearchResult) {
|
||||
console.debug('Clicked search result:', clickedSearchResult);
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', clickedSearchResult);
|
||||
transitionToPlace(clickedSearchResult);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1172,6 +1183,7 @@ export default class MapComponent extends Component {
|
||||
lat: lat.toFixed(6),
|
||||
lon: lon.toFixed(6),
|
||||
q: null, // Clear q to force spatial search
|
||||
category: null, // Clear category to force spatial search
|
||||
selected: selectedFeatureName || null,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -170,11 +193,19 @@ export default class SearchRoute extends Route {
|
||||
// Ensure pulse is stopped if we reach here
|
||||
this.mapUi.stopSearch();
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export default class MapUiService extends Service {
|
||||
@tracked selectionOptions = {};
|
||||
@tracked preventNextZoom = false;
|
||||
@tracked searchResults = [];
|
||||
@tracked currentSearch = null;
|
||||
@tracked loadingState = null;
|
||||
|
||||
selectPlace(place, options = {}) {
|
||||
this.selectedPlace = place;
|
||||
@@ -31,6 +33,7 @@ export default class MapUiService extends Service {
|
||||
|
||||
clearSearchResults() {
|
||||
this.searchResults = [];
|
||||
this.currentSearch = null;
|
||||
}
|
||||
|
||||
startSearch() {
|
||||
@@ -68,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}`;
|
||||
|
||||
@@ -40,15 +47,26 @@ export default class OsmService extends Service {
|
||||
];
|
||||
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
||||
|
||||
const negativeFilters = {
|
||||
public_transport: ['stop_area', 'platform'],
|
||||
};
|
||||
|
||||
const negativeFiltersQuery = Object.entries(negativeFilters)
|
||||
.map(([key, values]) => {
|
||||
const valueRegex = `^(${values.join('|')})$`;
|
||||
return `["${key}"!~"${valueRegex}"]`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const query = `
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
node(around:${radius},${lat},${lon})
|
||||
[${typeKeysQuery}][~"^name"~"."];
|
||||
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
||||
way(around:${radius},${lat},${lon})
|
||||
[${typeKeysQuery}][~"^name"~"."];
|
||||
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
||||
relation(around:${radius},${lat},${lon})
|
||||
[${typeKeysQuery}][~"^name"~"."];
|
||||
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
||||
);
|
||||
out center;
|
||||
`.trim();
|
||||
@@ -137,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>
|
||||
}
|
||||
|
||||
@@ -77,9 +77,11 @@ export default class PlaceTemplate extends Component {
|
||||
navigateBack(place) {
|
||||
// The sidebar calls this with null when "Back" is clicked.
|
||||
if (place === null) {
|
||||
// If we came from search results, go back in history
|
||||
if (this.mapUi.returnToSearch) {
|
||||
window.history.back();
|
||||
// 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,
|
||||
});
|
||||
} else {
|
||||
// Otherwise just close the sidebar (return to map index)
|
||||
this.router.transitionTo('index');
|
||||
|
||||
@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
|
||||
selectPlace(place) {
|
||||
if (place) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||
import eyeglasses from '@waysidemapping/pinhead/dist/icons/eyeglasses.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 familyRestroomSymbol from '@waysidemapping/pinhead/dist/icons/family_restroom_symbol.svg?raw';
|
||||
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||
import heart from 'feather-icons/dist/icons/heart.svg?raw';
|
||||
@@ -13,11 +17,13 @@ import info from 'feather-icons/dist/icons/info.svg?raw';
|
||||
import instagram from 'feather-icons/dist/icons/instagram.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 lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
||||
import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||
@@ -25,58 +31,106 @@ import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
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';
|
||||
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 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 bus from '@waysidemapping/pinhead/dist/icons/bus.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 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 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 cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
|
||||
import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw';
|
||||
import cocktail from '@waysidemapping/pinhead/dist/icons/cocktail.svg?raw';
|
||||
import coffeeBean from '@waysidemapping/pinhead/dist/icons/coffee_bean.svg?raw';
|
||||
import comedyMaskAndTragedyMask from '@waysidemapping/pinhead/dist/icons/comedy_mask_and_tragedy_mask.svg?raw';
|
||||
import croissant from '@waysidemapping/pinhead/dist/icons/croissant.svg?raw';
|
||||
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
|
||||
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
|
||||
import film from '@waysidemapping/pinhead/dist/icons/film.svg?raw';
|
||||
import fingernailPolished from '@waysidemapping/pinhead/dist/icons/fingernail_polished.svg?raw';
|
||||
import fish from '@waysidemapping/pinhead/dist/icons/fish.svg?raw';
|
||||
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
|
||||
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
|
||||
import fort from '@waysidemapping/pinhead/dist/icons/fort.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 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 jewel from '@waysidemapping/pinhead/dist/icons/jewel.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 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 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 personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
|
||||
import personPlayingTennis from '@waysidemapping/pinhead/dist/icons/person_playing_tennis.svg?raw';
|
||||
import personRunning from '@waysidemapping/pinhead/dist/icons/person_running.svg?raw';
|
||||
import personSleepingInBed from '@waysidemapping/pinhead/dist/icons/person_sleeping_in_bed.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 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 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 scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
|
||||
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
|
||||
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.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 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 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 parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
activity,
|
||||
angelfish,
|
||||
'arrow-left': arrowLeft,
|
||||
barbell,
|
||||
banknote,
|
||||
'badge-shield-with-fire': badgeShieldWithFire,
|
||||
'beach-umbrella-in-ground': beachUmbrellaInGround,
|
||||
'beer-mug-with-foam': beerMugWithFoam,
|
||||
bookmark,
|
||||
'boxing-glove-up': boxingGloveUp,
|
||||
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||
bus,
|
||||
camera,
|
||||
'check-square': checkSquare,
|
||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||
'classical-building': classicalBuilding,
|
||||
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||
'commercial-building': commercialBuilding,
|
||||
'clothes-hanger': clothesHanger,
|
||||
cleaver,
|
||||
cloth,
|
||||
cocktail,
|
||||
clock,
|
||||
'coffee-bean': coffeeBean,
|
||||
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||
@@ -84,48 +138,83 @@ const ICONS = {
|
||||
'cup-and-saucer': cupAndSaucer,
|
||||
donut,
|
||||
edit,
|
||||
eyeglasses,
|
||||
facebook,
|
||||
'fancy-mirror-with-reflection-and-stars': fancyMirrorWithReflectionAndStars,
|
||||
'family-restroom-symbol': familyRestroomSymbol,
|
||||
film,
|
||||
'fingernail-polished': fingernailPolished,
|
||||
fish,
|
||||
'flag-checkered': flagCheckered,
|
||||
'flower-bouquet': flowerBouquet,
|
||||
'fork-and-knife': forkAndKnife,
|
||||
fort,
|
||||
gift,
|
||||
globe,
|
||||
gravestone,
|
||||
'grecian-vase': grecianVase,
|
||||
'greek-cross': greekCross,
|
||||
heart,
|
||||
home,
|
||||
'ice-cream-on-cone': iceCreamOnCone,
|
||||
info,
|
||||
instagram,
|
||||
jewel,
|
||||
'log-in': logIn,
|
||||
'log-out': logOut,
|
||||
'lowrise-building': lowriseBuilding,
|
||||
mail,
|
||||
map,
|
||||
'map-pin': mapPin,
|
||||
'market-stall': marketStall,
|
||||
'memorial-stone-with-inscription': memorialStoneWithInscription,
|
||||
menu,
|
||||
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
||||
'molar-tooth': molarTooth,
|
||||
navigation,
|
||||
'needle-and-spool-of-thread': needleAndSpoolOfThread,
|
||||
'open-book': openBook,
|
||||
palace,
|
||||
'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall,
|
||||
'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track':
|
||||
personBoardingTramWithDestinationDisplayAndPantographOnTramTrack,
|
||||
'person-jockeying-racehorse': personJockeyingRacehorse,
|
||||
'person-playing-tennis': personPlayingTennis,
|
||||
'person-running': personRunning,
|
||||
'person-sleeping-in-bed': personSleepingInBed,
|
||||
'person-swimming-in-water': personSwimmingInWater,
|
||||
'person-swinging-golf-club': personSwingingGolfClub,
|
||||
phone,
|
||||
'plane-top-right': planeTopRight,
|
||||
'plant-in-raised-planter': plantInRaisedPlanter,
|
||||
'place-of-worship-building': placeOfWorshipBuilding,
|
||||
'play-structure-with-slide': playStructureWithSlide,
|
||||
'police-officer-with-stop-arm': policeOfficerWithStopArm,
|
||||
plus,
|
||||
'round-structure-with-flag': roundStructureWithFlag,
|
||||
'sailing-ship-in-water': sailingShipInWater,
|
||||
'scissors-open': scissorsOpen,
|
||||
'shipwreck-in-water': shipwreckInWater,
|
||||
'shopping-bag': shoppingBag,
|
||||
search,
|
||||
server,
|
||||
settings,
|
||||
'shopping-basket': shoppingBasket,
|
||||
'shopping-cart': shoppingCart,
|
||||
'table-tennis-paddle': tableTennisPaddle,
|
||||
'tattoo-machine': tattooMachine,
|
||||
toolbox,
|
||||
target,
|
||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||
user,
|
||||
'village-buildings': villageBuildings,
|
||||
'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 = [
|
||||
@@ -136,6 +225,7 @@ const FILLED_ICONS = [
|
||||
'shopping-basket',
|
||||
'camera',
|
||||
'person-sleeping-in-bed',
|
||||
'loading-ring',
|
||||
];
|
||||
|
||||
export function getIcon(name) {
|
||||
|
||||
@@ -18,23 +18,80 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { amenity: 'restaurant' }, icon: 'fork-and-knife' },
|
||||
{ tags: { amenity: 'fast_food' }, icon: 'burger-and-drink-cup-with-straw' },
|
||||
{ tags: { amenity: 'pub' }, icon: 'beer-mug-with-foam' },
|
||||
{ tags: { amenity: 'bar' }, icon: 'cocktail' },
|
||||
{ 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: 'tea' }, icon: 'coffee-bean' },
|
||||
{ tags: { shop: 'pastry' }, icon: 'donut' }, // Pastry shops often have donuts
|
||||
{ tags: { shop: 'pastry' }, icon: 'donut' },
|
||||
|
||||
// Groceries
|
||||
// Shopping
|
||||
{ tags: { shop: 'supermarket' }, icon: 'shopping-cart' },
|
||||
{ tags: { shop: 'convenience' }, icon: 'shopping-basket' },
|
||||
{ tags: { shop: 'grocery' }, icon: 'shopping-basket' },
|
||||
{ tags: { shop: 'greengrocer' }, icon: 'shopping-basket' },
|
||||
{ tags: { shop: 'bakery' }, icon: 'croissant' },
|
||||
{ tags: { shop: 'butcher' }, icon: 'cleaver' },
|
||||
{ tags: { shop: 'seafood' }, icon: 'fish' },
|
||||
{ tags: { shop: 'deli' }, icon: 'shopping-basket' },
|
||||
{ tags: { shop: 'clothes' }, icon: 'clothes-hanger' },
|
||||
{ tags: { shop: 'clothing' }, icon: 'clothes-hanger' },
|
||||
{ tags: { shop: 'hairdresser' }, icon: 'scissors-open' },
|
||||
{ tags: { shop: 'optician' }, icon: 'eyeglasses' },
|
||||
{ tags: { shop: 'fabric' }, icon: 'cloth' },
|
||||
{ tags: { shop: 'flea_market' }, icon: 'market-stall' },
|
||||
{ tags: { shop: 'kiosk' }, icon: 'shopping-basket' },
|
||||
{ tags: { shop: 'leather' }, icon: 'shopping-bag' },
|
||||
{ tags: { shop: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
||||
{ tags: { shop: 'jewelry' }, icon: 'jewel' },
|
||||
{ tags: { shop: 'jewellery' }, icon: 'jewel' },
|
||||
{ tags: { shop: 'tobacco' }, icon: 'cigarette-with-smoke-curl' },
|
||||
{ tags: { shop: 'cannabis' }, icon: 'cigarette-with-smoke-curl' },
|
||||
{ tags: { shop: 'florist' }, icon: 'flower-bouquet' },
|
||||
{ tags: { shop: 'garden_centre' }, icon: 'plant-in-raised-planter' },
|
||||
{ tags: { shop: 'estate_agent' }, icon: 'village-buildings' },
|
||||
{
|
||||
tags: { shop: 'mobile_phone' },
|
||||
icon: 'mobile-phone-with-keypad-and-antenna',
|
||||
},
|
||||
{ tags: { beauty: 'nails' }, icon: 'fingernail-polished' },
|
||||
{ tags: { shop: 'tattoo' }, icon: 'tattoo-machine' },
|
||||
{
|
||||
tags: { shop: 'beauty' },
|
||||
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
|
||||
{ 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
|
||||
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'heliport' }, 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' },
|
||||
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
||||
},
|
||||
|
||||
// Tourism
|
||||
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
|
||||
@@ -54,13 +111,18 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||
{ tags: { historic: 'archaeological_site' }, icon: 'camera' },
|
||||
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
|
||||
{ tags: { historic: 'tomb' }, icon: 'gravestone' },
|
||||
{
|
||||
tags: { historic: 'monument' },
|
||||
icon: 'classical-building-with-dome-and-flag',
|
||||
},
|
||||
{ tags: { historic: 'ship' }, icon: 'sailing-ship-in-water' },
|
||||
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
||||
{ tags: { historic: 'ruin' }, icon: 'camera' },
|
||||
{ tags: { historic: 'yes' }, icon: 'camera' },
|
||||
|
||||
// Accommodation
|
||||
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
|
||||
@@ -76,15 +138,38 @@ export const POI_ICON_RULES = [
|
||||
tags: { sport: 'cricket' },
|
||||
icon: 'person-cricket-batting-at-cricket-ball',
|
||||
},
|
||||
{ tags: { sport: 'boxing' }, icon: 'boxing-glove-up' },
|
||||
{ tags: { sport: 'martial_arts' }, icon: 'boxing-glove-up' },
|
||||
{ tags: { sport: 'tennis' }, icon: 'person-playing-tennis' },
|
||||
{ tags: { sport: 'squash' }, icon: 'person-playing-tennis' },
|
||||
{ tags: { sport: 'padel' }, icon: 'person-playing-tennis' },
|
||||
{ tags: { sport: 'table_tennis' }, icon: 'table-tennis-paddle' },
|
||||
{ tags: { leisure: 'water_park' }, icon: 'person-swimming-in-water' },
|
||||
{ tags: { sport: 'swimming' }, icon: 'person-swimming-in-water' },
|
||||
{ tags: { sport: 'golf' }, icon: 'person-swinging-golf-club' },
|
||||
{ tags: { leisure: 'golf_course' }, icon: 'person-swinging-golf-club' },
|
||||
{ tags: { sport: 'horse_racing' }, icon: 'person-jockeying-racehorse' },
|
||||
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||
{ tags: { sport: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||
{ tags: { sport: 'fitness' }, icon: 'barbell' },
|
||||
{ tags: { sport: 'fitness_centre' }, icon: 'barbell' },
|
||||
{ tags: { leisure: 'fitness_centre' }, icon: 'barbell' },
|
||||
|
||||
{ tags: { sport: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
|
||||
{ tags: { sport: 'fitness_centre' }, icon: 'person-running' },
|
||||
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
|
||||
{ tags: { sport: true }, icon: 'person-running' },
|
||||
|
||||
// Healthcare
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -107,6 +192,12 @@ export function getIconNameForTags(tags) {
|
||||
// Check for exact match or if value is in a semicolon-separated list
|
||||
// e.g. "donut;coffee_shop"
|
||||
const values = tagValue.split(';').map((v) => v.trim());
|
||||
|
||||
// If expectedValue is boolean true, any value is a match
|
||||
if (expectedValue === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!values.includes(expectedValue)) {
|
||||
match = false;
|
||||
break;
|
||||
|
||||
@@ -13,7 +13,9 @@ export const POI_CATEGORIES = [
|
||||
id: 'restaurants',
|
||||
label: 'Restaurants',
|
||||
icon: 'fork-and-knife',
|
||||
filter: ['["amenity"~"^(restaurant|fast_food|food_court|pub|cafe)$"]'],
|
||||
filter: [
|
||||
'["amenity"~"^(restaurant|fast_food|food_court|pub|cafe)$"]["cuisine"!~"coffee"]',
|
||||
],
|
||||
types: ['node', 'way'],
|
||||
},
|
||||
{
|
||||
@@ -45,6 +47,7 @@ export const POI_CATEGORIES = [
|
||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||
'["historic"]',
|
||||
'["shop"="flea_market"]',
|
||||
],
|
||||
types: ['node', 'way', 'relation'],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
182
tests/acceptance/map-search-reset-test.js
Normal file
182
tests/acceptance/map-search-reset-test.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
import sinon from 'sinon';
|
||||
|
||||
module('Acceptance | map search reset', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
// Seed localStorage with a high zoom level to ensure map is interactive
|
||||
const highZoomState = {
|
||||
center: [13.4, 52.5],
|
||||
zoom: 18,
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
'marco:map-view',
|
||||
JSON.stringify(highZoomState)
|
||||
);
|
||||
|
||||
// Stub window.fetch using Sinon
|
||||
// We want to intercept map style requests and let everything else through
|
||||
this.fetchStub = sinon.stub(window, 'fetch');
|
||||
|
||||
this.fetchStub.callsFake(async (input, init) => {
|
||||
let url = input;
|
||||
if (typeof input === 'object' && input !== null && 'url' in input) {
|
||||
url = input.url;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof url === 'string' &&
|
||||
url.includes('tiles.openfreemap.org/styles/liberty')
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
version: 8,
|
||||
name: 'Liberty',
|
||||
sources: {
|
||||
openmaptiles: {
|
||||
type: 'vector',
|
||||
url: 'https://tiles.openfreemap.org/planet',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': '#123456',
|
||||
},
|
||||
},
|
||||
],
|
||||
glyphs:
|
||||
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Pass through to the original implementation
|
||||
return this.fetchStub.wrappedMethod(input, init);
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
window.localStorage.removeItem('marco:map-view');
|
||||
// Restore the original fetch
|
||||
this.fetchStub.restore();
|
||||
});
|
||||
|
||||
test('clicking the map clears the category search parameter', async function (assert) {
|
||||
// Mock OSM Service
|
||||
class MockOsmService extends Service {
|
||||
async getCategoryPois() {
|
||||
return [
|
||||
{
|
||||
title: 'Cafe Test',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmId: '123',
|
||||
osmType: 'N',
|
||||
},
|
||||
];
|
||||
}
|
||||
async getNearbyPois() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
// Mock Storage
|
||||
this.owner.register(
|
||||
'service:storage',
|
||||
class extends Service {
|
||||
rs = { on: () => {} };
|
||||
placesInView = [];
|
||||
savedPlaces = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 1. Visit a category search URL
|
||||
await visit('/search?category=coffee&lat=52.52&lon=13.405');
|
||||
|
||||
assert.dom('.sidebar-header').includesText('Results');
|
||||
assert.ok(
|
||||
currentURL().includes('category=coffee'),
|
||||
'URL should have category param'
|
||||
);
|
||||
|
||||
// 2. Click the map (First click closes sidebar)
|
||||
await waitFor('canvas', { timeout: 2000 });
|
||||
|
||||
const canvas = document.querySelector('canvas');
|
||||
if (canvas) {
|
||||
// First Click (Close Sidebar)
|
||||
await triggerEvent(canvas, 'pointerdown', {
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
});
|
||||
await triggerEvent(canvas, 'pointerup', {
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
});
|
||||
await triggerEvent(canvas, 'click', {
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
// Wait for transition to index
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/',
|
||||
'Should have transitioned to index (closed sidebar)'
|
||||
);
|
||||
|
||||
// Second Click (Start new search)
|
||||
// Click slightly differently to ensure fresh event
|
||||
await triggerEvent(canvas, 'pointerdown', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
});
|
||||
await triggerEvent(canvas, 'pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
});
|
||||
await triggerEvent(canvas, 'click', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Wait for transition
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
const newUrl = currentURL();
|
||||
assert.notOk(
|
||||
newUrl.includes('category=coffee'),
|
||||
`New URL ${newUrl} should not contain category param`
|
||||
);
|
||||
assert.ok(newUrl.includes('/search'), 'Should be on search route');
|
||||
});
|
||||
});
|
||||
@@ -64,25 +64,24 @@ module('Acceptance | navigation', function (hooks) {
|
||||
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 backStub = sinon.stub(window.history, 'back');
|
||||
|
||||
try {
|
||||
await visit('/search?lat=1&lon=1');
|
||||
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
|
||||
await visit('/search?lat=1&lon=1');
|
||||
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
|
||||
|
||||
await click('.place-item');
|
||||
assert.ok(currentURL().includes('/place/'), 'Navigated to place');
|
||||
assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set');
|
||||
await click('.place-item');
|
||||
assert.ok(currentURL().includes('/place/'), 'Navigated to place');
|
||||
assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set');
|
||||
|
||||
// Click the back button in the sidebar
|
||||
await click('.back-btn');
|
||||
// Click the back button in the sidebar
|
||||
await click('.back-btn');
|
||||
|
||||
assert.true(backStub.calledOnce, 'window.history.back() was called');
|
||||
} finally {
|
||||
backStub.restore();
|
||||
}
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/search?lat=1&lon=1',
|
||||
'Returned to search results'
|
||||
);
|
||||
});
|
||||
|
||||
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
||||
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
39
tests/unit/utils/osm-icons-test.js
Normal file
39
tests/unit/utils/osm-icons-test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user