diff --git a/app/controllers/search.js b/app/controllers/search.js index b5849aa..f96a803 100644 --- a/app/controllers/search.js +++ b/app/controllers/search.js @@ -1,6 +1,16 @@ 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 { + @service osm; + @service photon; + @service mapUi; + @service storage; + @service router; + @service toast; + queryParams = ['lat', 'lon', 'q', 'selected', 'category']; lat = null; @@ -8,4 +18,175 @@ export default class SearchController extends Controller { q = null; selected = 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(); + }); } diff --git a/app/routes/search.js b/app/routes/search.js index 1d9269c..bbf6e69 100644 --- a/app/routes/search.js +++ b/app/routes/search.js @@ -1,14 +1,9 @@ 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 photon; @service mapUi; - @service storage; - @service router; @service toast; queryParams = { @@ -19,187 +14,29 @@ export default class SearchRoute extends Route { category: { refreshModel: true }, }; - async model(params) { - 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); - } - } 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(); + model(params) { + // Just return params, doing the async fetch in the controller + return params; } setupController(controller, model) { super.setupController(controller, model); - // Ensure pulse is stopped if we reach here - this.mapUi.stopSearch(); - this.mapUi.setSearchResults(model); - this.mapUi.showSidebar(); + + // Trigger the background task to fetch results + controller.fetchResultsTask.perform(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 }; } + resetController(controller, isExiting) { + if (isExiting) { + controller.fetchResultsTask.cancelAll(); + this.mapUi.stopSearch(); + } + } + @action error(error, transition) { this.mapUi.stopSearch(); @@ -207,6 +44,6 @@ export default class SearchRoute extends Route { if (transition) { transition.abort(); } - return false; // Prevent bubble and stop transition + return false; } } diff --git a/app/templates/search.gjs b/app/templates/search.gjs index 058067d..49f83e3 100644 --- a/app/templates/search.gjs +++ b/app/templates/search.gjs @@ -27,7 +27,7 @@ export default class SearchTemplate extends Component {