Merge pull request 'Various search UI improvements' (#51) from ui/search into master
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s

Reviewed-on: #51
This commit was merged in pull request #51.
This commit is contained in:
2026-04-27 14:50:02 +00:00
18 changed files with 382 additions and 247 deletions

View File

@@ -782,14 +782,19 @@ export default class MapComponent extends Component {
// Check if mobile (width <= 768px matches CSS) // Check if mobile (width <= 768px matches CSS)
if (size[0] <= 768) { if (size[0] <= 768) {
// On mobile, the bottom 50% is covered by the sheet. // On mobile, the bottom 50% is covered by the sheet.
// We want the pin to be in the center of the TOP 50% (visible area). // We want the pin to be in the center of the TOP 50% (visible area), minus the header.
// That means the pin should be at y = height * 0.25 (25% down from top).
// The map center is at y = height * 0.50.
// So the pin is "above" the center by 25% of the height in pixels.
// To put the pin there, the map center needs to be "below" the pin by that amount.
const height = size[1]; const height = size[1];
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center const headerEl = document.querySelector('.app-header');
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
// Visible area is from headerHeight to height / 2 (bottom sheet covers bottom 50%)
const visibleCenterY = headerHeight + (height / 2 - headerHeight) / 2;
// The map center is at y = height * 0.50.
// So the pin is "above" the center by (height/2 - visibleCenterY) pixels.
// To put the pin there, the map center needs to be "below" the pin by that amount.
const offsetPixels = height / 2 - visibleCenterY; // Distance from desired pin pos to map center
const offsetMapUnits = offsetPixels * resolution; const offsetMapUnits = offsetPixels * resolution;
// Shift center SOUTH (decrease Y). // Shift center SOUTH (decrease Y).
@@ -849,6 +854,9 @@ export default class MapComponent extends Component {
let targetPixelY = pixel[1]; let targetPixelY = pixel[1];
let needsPan = false; let needsPan = false;
const headerEl = document.querySelector('.app-header');
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
// 1. Mobile Bottom Sheet Logic (Screen <= 768px) // 1. Mobile Bottom Sheet Logic (Screen <= 768px)
if (size[0] <= 768) { if (size[0] <= 768) {
const height = size[1]; const height = size[1];
@@ -856,7 +864,7 @@ export default class MapComponent extends Component {
// If in bottom half // If in bottom half
if (pixel[1] > splitPoint) { if (pixel[1] > splitPoint) {
targetPixelY = height * 0.25; // Target: Center of top half targetPixelY = headerHeight + (height / 2 - headerHeight) / 2; // Target: Center of visible area above bottom sheet
needsPan = true; needsPan = true;
} }
} }
@@ -877,11 +885,10 @@ export default class MapComponent extends Component {
// 3. Header Logic (Any screen size) // 3. Header Logic (Any screen size)
// Check if the (potentially new) target Y is under the header // Check if the (potentially new) target Y is under the header
const headerHeight = 60; const minTopDistance = headerHeight + 20; // Provide some padding
const minTopDistance = headerHeight + 20; // 80px
if (targetPixelY < minTopDistance) { if (targetPixelY < minTopDistance) {
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header targetPixelY = minTopDistance + 30; // Move it clear of header
needsPan = true; needsPan = true;
} }
@@ -1144,6 +1151,8 @@ export default class MapComponent extends Component {
this.mapUi.returnToSearch = true; this.mapUi.returnToSearch = true;
} }
this.mapUi.preventNextZoom = true; this.mapUi.preventNextZoom = true;
this.mapUi.selectPlace(place, { preventZoom: true });
this.mapUi.showSidebar();
this.router.transitionTo('place', place); this.router.transitionTo('place', place);
}; };

View File

@@ -1,6 +1,16 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import { getDistance } from '../utils/geo';
export default class SearchController extends Controller { export default class SearchController extends Controller {
@service osm;
@service photon;
@service mapUi;
@service storage;
@service router;
@service toast;
queryParams = ['lat', 'lon', 'q', 'selected', 'category']; queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
lat = null; lat = null;
@@ -8,4 +18,175 @@ export default class SearchController extends Controller {
q = null; q = null;
selected = null; selected = null;
category = null; category = null;
fetchResultsTask = task({ restartable: true }, async (params) => {
// Hide sidebar and clear previous results immediately to signal a new search
this.mapUi.hideSidebar();
this.mapUi.clearSearchResults();
const lat = params.lat ? parseFloat(params.lat) : null;
const lon = params.lon ? parseFloat(params.lon) : null;
let pois = [];
let loadingType = null;
let loadingValue = null;
try {
// Case 0: Category Search (category parameter present)
if (params.category && lat && lon) {
loadingType = 'category';
loadingValue = params.category;
this.mapUi.startLoading(loadingType, loadingValue);
// We need bounds. If we have active map state, use it.
let bounds = this.mapUi.currentBounds;
// 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,
};
}
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) {
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);
}
} catch (error) {
console.error('Search request failed.', error);
this.toast.show('Search request failed. Please try again.');
this.mapUi.stopSearch();
return;
} finally {
if (loadingType && loadingValue) {
this.mapUi.stopLoading(loadingType, loadingValue);
}
}
// Check if any of these are already bookmarked
// We resolve them to the bookmark version if they exist
pois = pois.map((p) => {
const saved = this.storage.findPlaceById(p.osmId);
return saved || p;
});
const targetName = params.selected || params.q;
if (targetName && pois.length > 0) {
let matchedPlace = null;
// 1. Exact Name Match
matchedPlace = pois.find(
(p) =>
p.osmTags &&
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
);
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
// Note: MapComponent had logic for <=20m + type match.
// We might want to pass the 'type' in queryParams if we want to be that precise.
// For now, let's stick to name or very close proximity.
if (!matchedPlace) {
const topCandidate = pois[0];
if (topCandidate._distance <= 10) {
matchedPlace = topCandidate;
}
}
if (matchedPlace) {
// Direct transition!
this.router.replaceWith('place', matchedPlace);
this.mapUi.stopSearch();
return;
}
}
this.mapUi.setSearchResults(pois);
this.mapUi.showSidebar();
this.mapUi.stopSearch();
});
} }

View File

@@ -96,6 +96,7 @@ export default class PlaceRoute extends Route {
if (model) { if (model) {
const options = { preventZoom: this.mapUi.preventNextZoom }; const options = { preventZoom: this.mapUi.preventNextZoom };
this.mapUi.selectPlace(model, options); this.mapUi.selectPlace(model, options);
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = false; this.mapUi.preventNextZoom = false;
} }
// Stop the pulse animation if it was running (e.g. redirected from search) // Stop the pulse animation if it was running (e.g. redirected from search)

View File

@@ -22,6 +22,7 @@ export default class PlaceNewRoute extends Route {
this.mapUi.updateCreationCoordinates(model.lat, model.lon); this.mapUi.updateCreationCoordinates(model.lat, model.lon);
} }
this.mapUi.startCreating(); this.mapUi.startCreating();
this.mapUi.showSidebar();
} }
deactivate() { deactivate() {

View File

@@ -1,14 +1,9 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { getDistance } from '../utils/geo';
export default class SearchRoute extends Route { export default class SearchRoute extends Route {
@service osm;
@service photon;
@service mapUi; @service mapUi;
@service storage;
@service router;
@service toast; @service toast;
queryParams = { queryParams = {
@@ -19,186 +14,29 @@ export default class SearchRoute extends Route {
category: { refreshModel: true }, category: { refreshModel: true },
}; };
async model(params) { model(params) {
const lat = params.lat ? parseFloat(params.lat) : null; // Just return params, doing the async fetch in the controller
const lon = params.lon ? parseFloat(params.lon) : null; return params;
let pois = [];
let loadingType = null;
let loadingValue = null;
try {
// Case 0: Category Search (category parameter present)
if (params.category && lat && lon) {
loadingType = 'category';
loadingValue = params.category;
this.mapUi.startLoading(loadingType, loadingValue);
// We need bounds. If we have active map state, use it.
let bounds = this.mapUi.currentBounds;
// 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,
};
}
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) {
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
// We resolve them to the bookmark version if they exist
pois = pois.map((p) => {
const saved = this.storage.findPlaceById(p.osmId);
return saved || p;
});
return pois;
}
afterModel(model, transition) {
const { q, selected } = transition.to.queryParams;
// Heuristic Match Logic (ported from MapComponent)
// If 'selected' is provided (from map click), try to find that specific feature.
// If 'q' is provided (from text search), try to find an exact match to auto-select.
const targetName = selected || q;
if (targetName && model.length > 0) {
let matchedPlace = null;
// 1. Exact Name Match
matchedPlace = model.find(
(p) =>
p.osmTags &&
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
);
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
// Note: MapComponent had logic for <=20m + type match.
// We might want to pass the 'type' in queryParams if we want to be that precise.
// For now, let's stick to name or very close proximity.
if (!matchedPlace) {
const topCandidate = model[0];
if (topCandidate._distance <= 10) {
matchedPlace = topCandidate;
}
}
if (matchedPlace) {
// Direct transition!
this.router.replaceWith('place', matchedPlace);
return;
}
}
// Stop the pulse animation since search is done (and we are staying here)
this.mapUi.stopSearch();
} }
setupController(controller, model) { setupController(controller, model) {
super.setupController(controller, model); super.setupController(controller, model);
// Ensure pulse is stopped if we reach here
this.mapUi.stopSearch(); // Trigger the background task to fetch results
this.mapUi.setSearchResults(model); controller.fetchResultsTask.perform(model);
// Store current search params to allow "Up" navigation from place details // Store current search params to allow "Up" navigation from place details
const { q, category, lat, lon } = this.paramsFor('search'); const { q, category, lat, lon } = this.paramsFor('search');
this.mapUi.currentSearch = { q, category, lat, lon }; this.mapUi.currentSearch = { q, category, lat, lon };
} }
resetController(controller, isExiting) {
if (isExiting) {
controller.fetchResultsTask.cancelAll();
this.mapUi.stopSearch();
}
}
@action @action
error(error, transition) { error(error, transition) {
this.mapUi.stopSearch(); this.mapUi.stopSearch();
@@ -206,6 +44,6 @@ export default class SearchRoute extends Route {
if (transition) { if (transition) {
transition.abort(); transition.abort();
} }
return false; // Prevent bubble and stop transition return false;
} }
} }

View File

@@ -17,6 +17,15 @@ export default class MapUiService extends Service {
@tracked searchResults = []; @tracked searchResults = [];
@tracked currentSearch = null; @tracked currentSearch = null;
@tracked loadingState = null; @tracked loadingState = null;
@tracked isSidebarVisible = false;
showSidebar() {
this.isSidebarVisible = true;
}
hideSidebar() {
this.isSidebarVisible = false;
}
selectPlace(place, options = {}) { selectPlace(place, options = {}) {
this.selectedPlace = place; this.selectedPlace = place;

View File

@@ -11,6 +11,7 @@ import { getLocalizedName } from '../utils/osm';
export default class StorageService extends Service { export default class StorageService extends Service {
@service osm; @service osm;
@service toast;
rs; rs;
widget; widget;
@tracked placesInView = []; @tracked placesInView = [];
@@ -23,10 +24,13 @@ export default class StorageService extends Service {
@tracked connected = false; @tracked connected = false;
@tracked userAddress = null; @tracked userAddress = null;
@tracked isWidgetOpen = false; @tracked isWidgetOpen = false;
isNewConnection = true;
constructor() { constructor() {
super(...arguments); super(...arguments);
this.checkInitialConnectionState();
this.rs = new RemoteStorage({ this.rs = new RemoteStorage({
modules: [Places], modules: [Places],
}); });
@@ -57,6 +61,12 @@ export default class StorageService extends Service {
this.rs.on('connected', () => { this.rs.on('connected', () => {
this.connected = true; this.connected = true;
this.userAddress = this.rs.remote.userAddress; this.userAddress = this.rs.remote.userAddress;
if (this.isNewConnection) {
this.toast.show('Remote storage connected', 3000);
this.isNewConnection = false;
}
this.loadLists(); this.loadLists();
}); });
@@ -72,6 +82,7 @@ export default class StorageService extends Service {
this.loadedPrefixes = []; this.loadedPrefixes = [];
this.lists = []; this.lists = [];
this.initialSyncDone = false; this.initialSyncDone = false;
this.isNewConnection = true;
}); });
this.rs.on('sync-done', () => { this.rs.on('sync-done', () => {
@@ -93,6 +104,31 @@ export default class StorageService extends Service {
}); });
} }
checkInitialConnectionState() {
this.isNewConnection = true;
try {
if (window.localStorage) {
const keys = [
'remotestorage:wireclient',
'remotestorage:dropbox',
'remotestorage:googledrive',
];
for (const key of keys) {
const data = window.localStorage.getItem(key);
if (data) {
const parsed = JSON.parse(data);
if (parsed && parsed.token) {
this.isNewConnection = false;
break;
}
}
}
}
} catch (e) {
console.warn('Failed to check localStorage for existing connection:', e);
}
}
handlePlaceChange(event) { handlePlaceChange(event) {
const { newValue, relativePath } = event; const { newValue, relativePath } = event;

View File

@@ -1365,10 +1365,10 @@ span.icon {
@media (width <= 768px) { @media (width <= 768px) {
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */ /* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
/* Center Y = (height/2) / 2 = height/4 = 25% */ /* Center Y = (height/2) / 2 = height/4 = 25% + half header height */
.map-container.sidebar-open .map-crosshair { .map-container.sidebar-open .map-crosshair {
left: 50%; /* Reset desktop shift */ left: 50%; /* Reset desktop shift */
top: 25%; top: calc(25% + 30px); /* 30px approx half header height */
} }
} }

View File

@@ -18,12 +18,13 @@ export default class ApplicationComponent extends Component {
@tracked isAppMenuOpen = false; @tracked isAppMenuOpen = false;
get isSidebarOpen() { get isSidebarOpen() {
// We consider the sidebar "open" if we are in search or place routes. // We consider the sidebar "open" if we are in search or place routes AND it's visible.
// This helps the map know if it should shift the center or adjust view. // This helps the map know if it should shift the center or adjust view.
return ( return (
this.router.currentRouteName === 'place' || this.mapUi.isSidebarVisible &&
this.router.currentRouteName === 'place.new' || (this.router.currentRouteName === 'place' ||
this.router.currentRouteName === 'search' this.router.currentRouteName === 'place.new' ||
this.router.currentRouteName === 'search')
); );
} }
@@ -48,13 +49,12 @@ export default class ApplicationComponent extends Component {
handleOutsideClick() { handleOutsideClick() {
if (this.isAppMenuOpen) { if (this.isAppMenuOpen) {
this.closeAppMenu(); this.closeAppMenu();
} else if (this.router.currentRouteName === 'search') { } else if (
this.router.transitionTo('index'); this.router.currentRouteName === 'search' ||
} else if (this.router.currentRouteName === 'place') { this.router.currentRouteName === 'place'
// If in place route, decide if we want to go back to search or index ) {
// For now, let's go to index or maybe back to search if search params exist? this.mapUi.clearSelection();
// Simplest behavior: clear selection this.mapUi.hideSidebar();
this.router.transitionTo('index');
} }
} }

View File

@@ -79,6 +79,7 @@ export default class PlaceTemplate extends Component {
if (place === null) { if (place === null) {
// If we have an active search context, return to it (UP navigation) // If we have an active search context, return to it (UP navigation)
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) { if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
this.mapUi.showSidebar();
this.router.transitionTo('search', { this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch, queryParams: this.mapUi.currentSearch,
}); });
@@ -88,23 +89,26 @@ export default class PlaceTemplate extends Component {
} }
} else { } else {
// If a place is selected (unlikely in this view, but possible if we add related links) // If a place is selected (unlikely in this view, but possible if we add related links)
this.mapUi.showSidebar();
this.router.transitionTo('place', place); this.router.transitionTo('place', place);
} }
} }
@action @action
close() { close() {
// Clear search results so we don't fall back to the list this.mapUi.clearSelection();
this.router.transitionTo('index'); this.mapUi.hideSidebar();
} }
<template> <template>
<PlacesSidebar {{#if this.mapUi.isSidebarVisible}}
@selectedPlace={{this.place}} <PlacesSidebar
@onClose={{this.close}} @selectedPlace={{this.place}}
@onSelect={{this.navigateBack}} @onClose={{this.close}}
@onBookmarkChange={{this.refreshMap}} @onSelect={{this.navigateBack}}
@onUpdate={{this.handleUpdate}} @onBookmarkChange={{this.refreshMap}}
/> @onUpdate={{this.handleUpdate}}
/>
{{/if}}
</template> </template>
} }

View File

@@ -56,28 +56,30 @@ export default class PlaceNewTemplate extends Component {
} }
<template> <template>
<div class="sidebar"> {{#if this.mapUi.isSidebarVisible}}
<div class="sidebar-header"> <div class="sidebar">
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" /> <div class="sidebar-header">
New Place</h2> <h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
<button type="button" class="close-btn" {{on "click" this.close}}><Icon New Place</h2>
@name="x" <button
@size={{20}} type="button"
@color="#333" class="close-btn"
/></button> {{on "click" this.close}}
</div> ><Icon @name="x" @size={{20}} @color="#333" /></button>
</div>
<div class="sidebar-content"> <div class="sidebar-content">
<p class="helper-text"> <p class="helper-text">
Drag the map to position the crosshair. Drag the map to position the crosshair.
</p> </p>
<PlaceEditForm <PlaceEditForm
@place={{this.initialPlace}} @place={{this.initialPlace}}
@onSave={{this.savePlace}} @onSave={{this.savePlace}}
@onCancel={{this.close}} @onCancel={{this.close}}
/> />
</div>
</div> </div>
</div> {{/if}}
</template> </template>
} }

View File

@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
selectPlace(place) { selectPlace(place) {
if (place) { if (place) {
this.mapUi.returnToSearch = true; this.mapUi.returnToSearch = true;
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
// We don't need to manually set currentSearch here because // We don't need to manually set currentSearch here because
// it was already set in the route's setupController // it was already set in the route's setupController
this.router.transitionTo('place', place); this.router.transitionTo('place', place);
@@ -19,14 +21,16 @@ export default class SearchTemplate extends Component {
@action @action
close() { close() {
this.router.transitionTo('index'); this.mapUi.hideSidebar();
} }
<template> <template>
<PlacesSidebar {{#if this.mapUi.isSidebarVisible}}
@places={{@model}} <PlacesSidebar
@onSelect={{this.selectPlace}} @places={{this.mapUi.searchResults}}
@onClose={{this.close}} @onSelect={{this.selectPlace}}
/> @onClose={{this.close}}
/>
{{/if}}
</template> </template>
} }

View File

@@ -116,6 +116,7 @@ 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 treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw'; import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw'; import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw'; import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
import loadingRing from '../icons/270-ring.svg?raw'; import loadingRing from '../icons/270-ring.svg?raw';
@@ -243,6 +244,7 @@ const ICONS = {
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol, 'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
whatsapp, whatsapp,
wikipedia, wikipedia,
winding_way_wide: windingWayWide,
parking_p: parkingP, parking_p: parkingP,
car, car,
x, x,

View File

@@ -109,7 +109,9 @@ export const POI_ICON_RULES = [
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' }, { tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
// Historic // Historic
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
{ tags: { historic: 'bridge' }, icon: 'bridge' }, { tags: { historic: 'bridge' }, icon: 'bridge' },
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
{ tags: { historic: 'fort' }, icon: 'fort' }, { tags: { historic: 'fort' }, icon: 'fort' },
{ tags: { historic: 'castle' }, icon: 'palace' }, { tags: { historic: 'castle' }, icon: 'palace' },
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' }, { tags: { historic: 'building' }, icon: 'classical-building-with-flag' },

View File

@@ -140,12 +140,14 @@ module('Acceptance | map search reset', function (hooks) {
bubbles: true, bubbles: true,
}); });
// Wait for transition to index // Wait for transition or UI update
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
assert.strictEqual(
currentURL(), // Sidebar should be hidden, but we should stay on the search route
'/', assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
'Should have transitioned to index (closed sidebar)' assert.ok(
currentURL().includes('category=coffee'),
'Should have stayed on the search route with markers intact'
); );
// Second Click (Start new search) // Second Click (Start new search)

View File

@@ -95,8 +95,8 @@ module('Acceptance | navigation', function (hooks) {
// Click the Close (X) button // Click the Close (X) button
await click('.close-btn'); await click('.close-btn');
assert.strictEqual(currentURL(), '/', 'Returned to index'); assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar'); assert.ok(currentURL().includes('/place/'), 'Remains on place route');
}); });
test('navigating directly to place and back closes sidebar', async function (assert) { test('navigating directly to place and back closes sidebar', async function (assert) {

View File

@@ -1,17 +1,32 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { visit, click, fillIn, currentURL } from '@ember/test-helpers'; import { visit, click, fillIn, currentURL, settled } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers'; import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service'; import Service from '@ember/service';
import { Promise } from 'rsvp'; import { Promise } from 'rsvp';
let photonResolve;
let osmResolve;
class MockPhotonService extends Service { class MockPhotonService extends Service {
cancelAll() {} cancelAll() {}
async search(query) { async search(query) {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 50));
if (query === 'slow') { if (query === 'slow') {
await new Promise((resolve) => setTimeout(resolve, 200)); // Return a promise that we can manually resolve in the test
// to avoid race conditions with native setTimeout
return new Promise((resolve) => {
photonResolve = () => {
resolve([
{
title: 'Test Place',
lat: 1,
lon: 1,
osmId: '123',
osmType: 'node',
},
]);
};
});
} }
return [ return [
{ {
@@ -29,9 +44,12 @@ class MockOsmService extends Service {
cancelAll() {} cancelAll() {}
async getCategoryPois(bounds, category) { async getCategoryPois(bounds, category) {
await new Promise((resolve) => setTimeout(resolve, 50));
if (category === 'slow_category') { if (category === 'slow_category') {
await new Promise((resolve) => setTimeout(resolve, 200)); return new Promise((resolve) => {
osmResolve = () => {
resolve([]);
};
});
} }
return []; return [];
} }
@@ -44,6 +62,8 @@ module('Acceptance | search loading', function (hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
hooks.beforeEach(function () { hooks.beforeEach(function () {
photonResolve = null;
osmResolve = null;
this.owner.register('service:photon', MockPhotonService); this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:osm', MockOsmService); this.owner.register('service:osm', MockOsmService);
}); });
@@ -66,7 +86,12 @@ module('Acceptance | search loading', function (hooks) {
'Loading state is set for text search' 'Loading state is set for text search'
); );
// Resolve the manual promise so the task can finish deterministically
photonResolve();
await searchPromise; await searchPromise;
await settled(); // Wait for ember-concurrency tasks to fully settle
assert.strictEqual( assert.strictEqual(
mapUi.loadingState, mapUi.loadingState,
null, null,
@@ -83,7 +108,12 @@ module('Acceptance | search loading', function (hooks) {
'Loading state is set for category search' 'Loading state is set for category search'
); );
// Resolve the manual promise
osmResolve();
await catPromise; await catPromise;
await settled();
assert.strictEqual( assert.strictEqual(
mapUi.loadingState, mapUi.loadingState,
null, null,
@@ -122,6 +152,7 @@ module('Acceptance | search loading', function (hooks) {
// 4. Click the clear button (should be visible since input has value) // 4. Click the clear button (should be visible since input has value)
await click('.search-clear-btn'); await click('.search-clear-btn');
// Wait for the click and transition to settle
// Verify loading state is cleared immediately // Verify loading state is cleared immediately
assert.strictEqual( assert.strictEqual(
@@ -130,6 +161,11 @@ module('Acceptance | search loading', function (hooks) {
'Loading state is cleared immediately after clicking clear' 'Loading state is cleared immediately after clicking clear'
); );
// Clean up the dangling promise
if (photonResolve) {
photonResolve();
}
// Verify we are back on index (or at least query is gone) // Verify we are back on index (or at least query is gone)
assert.strictEqual(currentURL(), '/', 'Navigated to index'); assert.strictEqual(currentURL(), '/', 'Navigated to index');
}); });

View File

@@ -36,6 +36,8 @@ module('Unit | Route | place', function (hooks) {
selectPlaceCalled = true; selectPlaceCalled = true;
} }
stopSearch() {} stopSearch() {}
showSidebar() {}
hideSidebar() {}
} }
this.owner.register('service:osm', OsmStub); this.owner.register('service:osm', OsmStub);
@@ -76,6 +78,8 @@ module('Unit | Route | place', function (hooks) {
class MapUiStub extends Service { class MapUiStub extends Service {
selectPlace() {} selectPlace() {}
stopSearch() {} stopSearch() {}
showSidebar() {}
hideSidebar() {}
} }
this.owner.register('service:osm', OsmStub); this.owner.register('service:osm', OsmStub);
@@ -110,6 +114,8 @@ module('Unit | Route | place', function (hooks) {
class MapUiStub extends Service { class MapUiStub extends Service {
selectPlace() {} selectPlace() {}
stopSearch() {} stopSearch() {}
showSidebar() {}
hideSidebar() {}
} }
this.owner.register('service:osm', OsmStub); this.owner.register('service:osm', OsmStub);
@@ -155,6 +161,8 @@ module('Unit | Route | place', function (hooks) {
assert.ok(options.preventZoom, 'Prevented zoom on update'); assert.ok(options.preventZoom, 'Prevented zoom on update');
} }
stopSearch() {} stopSearch() {}
showSidebar() {}
hideSidebar() {}
} }
this.owner.register('service:storage', StorageStub); this.owner.register('service:storage', StorageStub);