diff --git a/app/components/map.gjs b/app/components/map.gjs index 6b95cbf..0812a42 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -21,6 +21,7 @@ export default class MapComponent extends Component { @service osm; @service storage; @service mapUi; + @service router; mapInstance; bookmarkSource; @@ -510,6 +511,17 @@ export default class MapComponent extends Component { } } + // Sync the pulse animation with the UI service state + syncPulse = modifier(() => { + if (!this.searchOverlayElement) return; + + if (this.mapUi.isSearching) { + this.searchOverlayElement.classList.add('active'); + } else { + this.searchOverlayElement.classList.remove('active'); + } + }); + handleMapMove = async () => { if (!this.mapInstance) return; @@ -546,7 +558,6 @@ export default class MapComponent extends Component { }); let clickedBookmark = null; let selectedFeatureName = null; - let selectedFeatureType = null; if (features && features.length > 0) { console.debug(`Found ${features.length} features in map layer:`); @@ -561,7 +572,6 @@ export default class MapComponent extends Component { const props = features[0].getProperties(); if (props.name) { selectedFeatureName = props.name; - selectedFeatureType = props.class || props.subclass; } } @@ -573,9 +583,7 @@ export default class MapComponent extends Component { 'Clicked bookmark while sidebar open (switching):', clickedBookmark ); - if (this.args.onPlacesFound) { - this.args.onPlacesFound([], clickedBookmark); - } + this.router.transitionTo('place', clickedBookmark); return; } @@ -589,9 +597,7 @@ export default class MapComponent extends Component { // Normal behavior (sidebar is closed) if (clickedBookmark) { console.log('Clicked bookmark:', clickedBookmark); - if (this.args.onPlacesFound) { - this.args.onPlacesFound([], clickedBookmark); - } + this.router.transitionTo('place', clickedBookmark); return; } @@ -615,76 +621,21 @@ export default class MapComponent extends Component { this.searchOverlayElement.style.width = `${diameterInPixels}px`; this.searchOverlayElement.style.height = `${diameterInPixels}px`; this.searchOverlay.setPosition(event.coordinate); - this.searchOverlayElement.classList.add('active'); } - // 2. Fetch authoritative data via Overpass - try { - let pois = await this.osm.getNearbyPois(lat, lon, searchRadius); + // Start Search State + this.mapUi.startSearch(); - // Sort by distance from click - pois = pois - .map((p) => { - // p is already normalized by service, so lat/lon are at top level - return { - ...p, - _distance: getDistance(lat, lon, p.lat, p.lon), - }; - }) - .sort((a, b) => a._distance - b._distance); - - let matchedPlace = null; - - if (selectedFeatureName && pois.length > 0) { - // Heuristic: - // 1. Exact Name Match - matchedPlace = pois.find( - (p) => - p.osmTags && - (p.osmTags.name === selectedFeatureName || - p.osmTags['name:en'] === selectedFeatureName) - ); - - // 2. If no exact match, look for VERY close (<=20m) and matching type - if (!matchedPlace) { - const topCandidate = pois[0]; - if (topCandidate._distance <= 20) { - // Check type compatibility if available - // (visual tile 'class' is often 'cafe', osm tag is 'amenity'='cafe') - const pType = - topCandidate.osmTags.amenity || - topCandidate.osmTags.shop || - topCandidate.osmTags.tourism; - if ( - selectedFeatureType && - pType && - (selectedFeatureType === pType || - pType.includes(selectedFeatureType)) - ) { - console.log( - 'Heuristic match found (distance + type):', - topCandidate - ); - matchedPlace = topCandidate; - } else if (topCandidate._distance <= 10) { - // Even without type match, if it's super close (<=10m), it's likely the one. - console.log('Heuristic match found (proximity):', topCandidate); - matchedPlace = topCandidate; - } - } - } - } - - if (this.args.onPlacesFound) { - this.args.onPlacesFound(pois, matchedPlace); - } - } catch (error) { - console.error('Failed to fetch POIs:', error); - } finally { - if (this.searchOverlayElement) { - this.searchOverlayElement.classList.remove('active'); - } + // Transition to Search Route + const queryParams = { + lat: lat.toFixed(6), + lon: lon.toFixed(6), + }; + if (selectedFeatureName) { + queryParams.q = selectedFeatureName; } + + this.router.transitionTo('search', { queryParams }); }; } diff --git a/app/components/places-sidebar.gjs b/app/components/places-sidebar.gjs index 8bd63f4..8a942d5 100644 --- a/app/components/places-sidebar.gjs +++ b/app/components/places-sidebar.gjs @@ -23,13 +23,6 @@ export default class PlacesSidebar extends Component { if (this.args.onSelect) { this.args.onSelect(null); } - - // Fallback logic: if no list available, close sidebar - if (!this.args.places || this.args.places.length === 0) { - if (this.args.onClose) { - this.args.onClose(); - } - } } @action diff --git a/app/router.js b/app/router.js index 151dbf8..89c9419 100644 --- a/app/router.js +++ b/app/router.js @@ -8,4 +8,5 @@ export default class Router extends EmberRouter { Router.map(function () { this.route('place', { path: '/place/:place_id' }); + this.route('search'); }); diff --git a/app/routes/place.js b/app/routes/place.js index 1140696..bfc4c75 100644 --- a/app/routes/place.js +++ b/app/routes/place.js @@ -49,6 +49,8 @@ export default class PlaceRoute extends Route { if (model) { this.mapUi.selectPlace(model); } + // Stop the pulse animation if it was running (e.g. redirected from search) + this.mapUi.stopSearch(); } deactivate() { diff --git a/app/routes/search.js b/app/routes/search.js new file mode 100644 index 0000000..cbb37c4 --- /dev/null +++ b/app/routes/search.js @@ -0,0 +1,96 @@ +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { getDistance } from '../utils/geo'; + +export default class SearchRoute extends Route { + @service osm; + @service mapUi; + @service storage; + @service router; + + queryParams = { + lat: { refreshModel: true }, + lon: { refreshModel: true }, + q: { refreshModel: true }, + }; + + async model(params) { + // If no coordinates, we can't search + if (!params.lat || !params.lon) { + return []; + } + + const lat = parseFloat(params.lat); + const lon = parseFloat(params.lon); + const searchRadius = params.q ? 30 : 50; + + // Fetch POIs + let pois = await this.osm.getNearbyPois(lat, lon, searchRadius); + + // 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); + + // 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 } = transition.to.queryParams; + + // Heuristic Match Logic (ported from MapComponent) + if (q && model.length > 0) { + let matchedPlace = null; + + // 1. Exact Name Match + matchedPlace = model.find( + (p) => p.osmTags && (p.osmTags.name === q || p.osmTags['name:en'] === q) + ); + + // 2. High Proximity Match (<= 10m) + // 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) { + super.setupController(controller, model); + // Ensure pulse is stopped if we reach here + this.mapUi.stopSearch(); + } + + @action + error() { + this.mapUi.stopSearch(); + return true; // Bubble error + } +} diff --git a/app/services/map-ui.js b/app/services/map-ui.js index 54bb6bf..d3d9892 100644 --- a/app/services/map-ui.js +++ b/app/services/map-ui.js @@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking'; export default class MapUiService extends Service { @tracked selectedPlace = null; + @tracked isSearching = false; selectPlace(place) { this.selectedPlace = place; @@ -11,4 +12,12 @@ export default class MapUiService extends Service { clearSelection() { this.selectedPlace = null; } + + startSearch() { + this.isSearching = true; + } + + stopSearch() { + this.isSearching = false; + } } diff --git a/app/templates/application.gjs b/app/templates/application.gjs index a41932b..4ec36d4 100644 --- a/app/templates/application.gjs +++ b/app/templates/application.gjs @@ -1,26 +1,28 @@ import Component from '@glimmer/component'; import { pageTitle } from 'ember-page-title'; import Map from '#components/map'; -import PlacesSidebar from '#components/places-sidebar'; import AppHeader from '#components/app-header'; import SettingsPane from '#components/settings-pane'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; -import { eq } from 'ember-truth-helpers'; -import { and, or } from 'ember-truth-helpers'; +import { or } from 'ember-truth-helpers'; import { on } from '@ember/modifier'; export default class ApplicationComponent extends Component { @service storage; + @service mapUi; @service router; - @tracked nearbyPlaces = null; @tracked isSettingsOpen = false; - // @tracked bookmarksVersion = 0; // Moved to storage service get isSidebarOpen() { - return !!this.nearbyPlaces || this.router.currentRouteName === 'place'; + // We consider the sidebar "open" if we are in search or place routes. + // This helps the map know if it should shift the center or adjust view. + return ( + this.router.currentRouteName === 'place' || + this.router.currentRouteName === 'search' + ); } constructor() { @@ -30,32 +32,6 @@ export default class ApplicationComponent extends Component { this.storage; } - @action - showPlaces(places, selectedPlace = null) { - // Helper to resolve a place to its bookmark if it exists - const resolvePlace = (p) => { - if (!p) return null; - // We use the OSM ID to check if we already have this place saved - const saved = this.storage.findPlaceById(p.osmId); - return saved || p; - }; - - const resolvedSelected = resolvePlace(selectedPlace); - const resolvedPlaces = places ? places.map(resolvePlace) : []; - - // If we have a specific place, transition to the route - if (resolvedSelected) { - // Pass the FULL object model to avoid re-fetching! - // The Route's serialize() hook handles URL generation. - this.router.transitionTo('place', resolvedSelected); - this.nearbyPlaces = null; // Clear list when selecting specific - } else if (resolvedPlaces && resolvedPlaces.length > 0) { - // Show list case - this.nearbyPlaces = resolvedPlaces; - this.router.transitionTo('index'); - } - } - @action toggleSettings() { this.isSettingsOpen = !this.isSettingsOpen; @@ -66,29 +42,20 @@ export default class ApplicationComponent extends Component { this.isSettingsOpen = false; } - @action - selectFromList(place) { - if (place) { - // Optimize: Pass full object to avoid fetch - this.router.transitionTo('place', place); - } - } - @action handleOutsideClick() { if (this.isSettingsOpen) { this.closeSettings(); - } else { - this.closeSidebar(); + } else if (this.router.currentRouteName === 'search') { + this.router.transitionTo('index'); + } else if (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? + // Simplest behavior: clear selection + this.router.transitionTo('index'); } } - @action - closeSidebar() { - this.nearbyPlaces = null; - this.router.transitionTo('index'); - } - @action refreshBookmarks() { this.storage.notifyChange(); @@ -113,19 +80,10 @@ export default class ApplicationComponent extends Component { {{/if}} - {{#if (and (eq this.router.currentRouteName "index") this.nearbyPlaces)}} - - {{/if}} - {{#if this.isSettingsOpen}} {{/if}} diff --git a/app/templates/place.gjs b/app/templates/place.gjs index 2cde9d6..22b7843 100644 --- a/app/templates/place.gjs +++ b/app/templates/place.gjs @@ -7,6 +7,7 @@ import { tracked } from '@glimmer/tracking'; export default class PlaceTemplate extends Component { @service router; @service storage; + @service mapUi; @tracked localPlace = null; @@ -72,8 +73,26 @@ export default class PlaceTemplate extends Component { this.storage.notifyChange(); } + @action + navigateBack(place) { + // The sidebar calls this with null when "Back" is clicked. + if (place === null) { + // If we have history, go back (preserves search state) + if (window.history.length > 1) { + window.history.back(); + } else { + // Fallback if opened directly + this.router.transitionTo('index'); + } + } else { + // If a place is selected (unlikely in this view, but possible if we add related links) + this.router.transitionTo('place', place); + } + } + @action close() { + // Clear search results so we don't fall back to the list this.router.transitionTo('index'); } @@ -81,6 +100,7 @@ export default class PlaceTemplate extends Component { diff --git a/app/templates/search.gjs b/app/templates/search.gjs new file mode 100644 index 0000000..ed2f59b --- /dev/null +++ b/app/templates/search.gjs @@ -0,0 +1,28 @@ +import Component from '@glimmer/component'; +import PlacesSidebar from '#components/places-sidebar'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class SearchTemplate extends Component { + @service router; + + @action + selectPlace(place) { + if (place) { + this.router.transitionTo('place', place); + } + } + + @action + close() { + this.router.transitionTo('index'); + } + + +}