Compare commits
25 Commits
438bf0c31c
...
v1.17.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
913d5c915c
|
|||
|
89f667b17e
|
|||
|
22d4ef8d96
|
|||
|
b17793af9d
|
|||
|
dc9e0f210a
|
|||
|
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>
|
||||
|
||||
@@ -119,6 +119,28 @@ export default class MapComponent extends Component {
|
||||
|
||||
const searchResultStyle = (feature) => {
|
||||
const originalPlace = feature.get('originalPlace');
|
||||
|
||||
// If this place is currently selected, hide the search result marker
|
||||
// because the main red drop pin will be shown instead.
|
||||
const selectedPlace = this.mapUi.selectedPlace;
|
||||
if (selectedPlace) {
|
||||
const isSameOsmId =
|
||||
originalPlace.osmId &&
|
||||
selectedPlace.osmId &&
|
||||
originalPlace.osmId === selectedPlace.osmId;
|
||||
const isSameId =
|
||||
originalPlace.id &&
|
||||
selectedPlace.id &&
|
||||
originalPlace.id === selectedPlace.id;
|
||||
const isSameCoords =
|
||||
originalPlace.lat === selectedPlace.lat &&
|
||||
originalPlace.lon === selectedPlace.lon;
|
||||
|
||||
if (isSameOsmId || isSameId || isSameCoords) {
|
||||
return new Style({}); // Empty style makes it invisible
|
||||
}
|
||||
}
|
||||
|
||||
// Some search results might be just the place object without separate tags
|
||||
// If it's a raw place object, it might have osmTags property.
|
||||
// Or it might be the tags object itself.
|
||||
@@ -599,6 +621,11 @@ export default class MapComponent extends Component {
|
||||
const selected = this.mapUi.selectedPlace;
|
||||
const options = this.mapUi.selectionOptions || {};
|
||||
|
||||
// Force a redraw of the search results layer so it can hide/show the selected pin
|
||||
if (this.searchResultsSource) {
|
||||
this.searchResultsSource.changed();
|
||||
}
|
||||
|
||||
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||
|
||||
// Clear any previous shape
|
||||
@@ -1099,6 +1126,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 +1149,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 +1163,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 +1210,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,109 @@ 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 climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.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';
|
||||
import car from '@waysidemapping/pinhead/dist/icons/car.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,
|
||||
climbing_wall: climbingWall,
|
||||
'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 +141,84 @@ 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,
|
||||
car,
|
||||
x,
|
||||
zap,
|
||||
'loading-ring': loadingRing,
|
||||
};
|
||||
|
||||
const FILLED_ICONS = [
|
||||
@@ -136,6 +229,7 @@ const FILLED_ICONS = [
|
||||
'shopping-basket',
|
||||
'camera',
|
||||
'person-sleeping-in-bed',
|
||||
'loading-ring',
|
||||
];
|
||||
|
||||
export function getIcon(name) {
|
||||
|
||||
@@ -18,23 +18,81 @@ 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: { amenity: 'driving_school' }, icon: 'car' },
|
||||
|
||||
{ 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 +112,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 +139,39 @@ 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: { sport: 'climbing' }, icon: 'climbing_wall' },
|
||||
{ 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 +194,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.2",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -102,7 +102,7 @@
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waysidemapping/pinhead": "^15.17.0",
|
||||
"@waysidemapping/pinhead": "^15.20.0",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.0.0"
|
||||
}
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@waysidemapping/pinhead':
|
||||
specifier: ^15.17.0
|
||||
version: 15.17.0
|
||||
specifier: ^15.20.0
|
||||
version: 15.20.0
|
||||
ember-concurrency:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@babel/core@7.28.6)
|
||||
@@ -1654,8 +1654,8 @@ packages:
|
||||
peerDependencies:
|
||||
'@warp-drive/core': 5.8.1
|
||||
|
||||
'@waysidemapping/pinhead@15.17.0':
|
||||
resolution: {integrity: sha512-XcL/0Ll+gkRIpXlO+skwd6USynA+mX3DNwqrWDMhgRmLP4DNRPTeaecK64BBxk1bB/F9Xi/9kgN6JA5zbdgejQ==}
|
||||
'@waysidemapping/pinhead@15.20.0':
|
||||
resolution: {integrity: sha512-JD9XINaMhtEy3VEjvc+l4r1sLwbyOKoYdD2IYY2QNKP3FeeNwE/2gcUly631JH9jPymoFeOix0f3o9L/n9YDSQ==}
|
||||
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
@@ -7245,7 +7245,7 @@ snapshots:
|
||||
- '@glint/template'
|
||||
- supports-color
|
||||
|
||||
'@waysidemapping/pinhead@15.17.0': {}
|
||||
'@waysidemapping/pinhead@15.20.0': {}
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
|
||||
2
release/assets/main-B8Ckz4Ru.js
Normal file
2
release/assets/main-B8Ckz4Ru.js
Normal file
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
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-B8Ckz4Ru.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