diff --git a/app/components/map.gjs b/app/components/map.gjs index ace5138..fad1a0c 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -16,7 +16,7 @@ import Feature from 'ol/Feature.js'; import GeoJSON from 'ol/format/GeoJSON.js'; import Point from 'ol/geom/Point.js'; import Geolocation from 'ol/Geolocation.js'; -import { Style, Circle, Fill, Stroke } from 'ol/style.js'; +import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js'; import { apply } from 'ol-mapbox-style'; export default class MapComponent extends Component { @@ -28,6 +28,7 @@ export default class MapComponent extends Component { mapInstance; bookmarkSource; + searchResultsSource; selectedShapeSource; searchOverlay; searchOverlayElement; @@ -110,6 +111,47 @@ export default class MapComponent extends Component { zIndex: 10, // Ensure it sits above the map tiles }); + // Create a vector source and layer for search results + this.searchResultsSource = new VectorSource(); + let cachedSearchResultSvgUrl = null; + + const searchResultStyle = (feature) => { + if (!cachedSearchResultSvgUrl) { + const markerColor = + getComputedStyle(document.documentElement) + .getPropertyValue('--marker-color-primary') + .trim() || '#ea4335'; + + const svg = ` + + + + + + + + + + `; + cachedSearchResultSvgUrl = + 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.trim()); + } + + return new Style({ + image: new Icon({ + src: cachedSearchResultSvgUrl, + anchor: [0.5, 0.65], + scale: 1, + }), + }); + }; + + const searchResultLayer = new VectorLayer({ + source: this.searchResultsSource, + style: searchResultStyle, + zIndex: 11, // Above bookmarks (10) + }); + // Default view settings let center = [14.21683569, 27.060114248]; let zoom = 2.661; @@ -143,7 +185,7 @@ export default class MapComponent extends Component { this.mapInstance = new Map({ target: element, - layers: [openfreemap, selectedShapeLayer, bookmarkLayer], + layers: [openfreemap, selectedShapeLayer, searchResultLayer, bookmarkLayer], view: view, controls: defaultControls({ zoom: true, @@ -178,7 +220,7 @@ export default class MapComponent extends Component { const pinIcon = document.createElement('div'); pinIcon.className = 'selected-pin'; // Simple SVG for Map Pin - pinIcon.innerHTML = ``; + pinIcon.innerHTML = ``; const pinShadow = document.createElement('div'); pinShadow.className = 'selected-pin-shadow'; @@ -464,6 +506,33 @@ export default class MapComponent extends Component { ); }); + updateSearchResults = modifier(() => { + if (!this.searchResultsSource) return; + + this.searchResultsSource.clear(); + const results = this.mapUi.searchResults; + + if (!results || results.length === 0) return; + + const features = []; + results.forEach((place) => { + if (place.lat && place.lon) { + const feature = new Feature({ + geometry: new Point(fromLonLat([place.lon, place.lat])), + name: place.title, + id: place.id, + isSearchResult: true, + originalPlace: place, + }); + features.push(feature); + } + }); + + if (features.length > 0) { + this.searchResultsSource.addFeatures(features); + } + }); + // Track the selected place from the UI Service (Router -> Map) updateSelectedPin = modifier(() => { const selected = this.mapUi.selectedPlace; @@ -946,6 +1015,7 @@ export default class MapComponent extends Component { hitTolerance: 10, }); let clickedBookmark = null; + let clickedSearchResult = null; let selectedFeatureName = null; if (features && features.length > 0) { @@ -954,8 +1024,12 @@ export default class MapComponent extends Component { console.debug(f); } const bookmarkFeature = features.find((f) => f.get('isBookmark')); + const searchResultFeature = features.find((f) => f.get('isSearchResult')); + if (bookmarkFeature) { clickedBookmark = bookmarkFeature.get('originalPlace'); + } else if (searchResultFeature) { + clickedSearchResult = searchResultFeature.get('originalPlace'); } // Also get visual props for standard map click logic later const props = features[0].getProperties(); @@ -966,14 +1040,15 @@ export default class MapComponent extends Component { // Special handling when sidebar is OPEN if (this.args.isSidebarOpen) { - // If it's a bookmark, we allow "switching" to it even if sidebar is open - if (clickedBookmark) { + // If it's a bookmark or search result, we allow "switching" to it even if sidebar is open + const targetPlace = clickedBookmark || clickedSearchResult; + if (targetPlace) { console.debug( - 'Clicked bookmark while sidebar open (switching):', - clickedBookmark + 'Clicked feature while sidebar open (switching):', + targetPlace ); this.mapUi.preventNextZoom = true; - this.router.transitionTo('place', clickedBookmark); + this.router.transitionTo('place', targetPlace); return; } @@ -992,6 +1067,13 @@ export default class MapComponent extends Component { return; } + if (clickedSearchResult) { + console.debug('Clicked search result:', clickedSearchResult); + this.mapUi.preventNextZoom = true; + this.router.transitionTo('place', clickedSearchResult); + return; + } + // Require Zoom >= 17 for generic map searches // This prevents accidental searches when interacting with the map at a high level const currentZoom = this.mapInstance.getView().getZoom(); @@ -1041,6 +1123,7 @@ export default class MapComponent extends Component { {{this.setupMap}} {{this.updateInteractions}} {{this.updateBookmarks}} + {{this.updateSearchResults}} {{this.updateSelectedPin}} {{this.syncPulse}} {{this.syncCreationMode}} diff --git a/app/routes/index.js b/app/routes/index.js new file mode 100644 index 0000000..79e789c --- /dev/null +++ b/app/routes/index.js @@ -0,0 +1,10 @@ +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class IndexRoute extends Route { + @service mapUi; + + activate() { + this.mapUi.clearSearchResults(); + } +} diff --git a/app/routes/search.js b/app/routes/search.js index a1261a6..da437ef 100644 --- a/app/routes/search.js +++ b/app/routes/search.js @@ -169,6 +169,7 @@ export default class SearchRoute extends Route { super.setupController(controller, model); // Ensure pulse is stopped if we reach here this.mapUi.stopSearch(); + this.mapUi.setSearchResults(model); } @action diff --git a/app/services/map-ui.js b/app/services/map-ui.js index 3f38696..296fa5e 100644 --- a/app/services/map-ui.js +++ b/app/services/map-ui.js @@ -12,6 +12,7 @@ export default class MapUiService extends Service { @tracked searchBoxHasFocus = false; @tracked selectionOptions = {}; @tracked preventNextZoom = false; + @tracked searchResults = []; selectPlace(place, options = {}) { this.selectedPlace = place; @@ -24,6 +25,14 @@ export default class MapUiService extends Service { this.preventNextZoom = false; } + setSearchResults(results) { + this.searchResults = results || []; + } + + clearSearchResults() { + this.searchResults = []; + } + startSearch() { this.isSearching = true; this.isCreating = false; diff --git a/app/styles/app.css b/app/styles/app.css index e0dfbc2..706e4c7 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -6,6 +6,8 @@ --sidebar-width: 350px; --link-color: #2a7fff; --link-color-visited: #6a4fbf; + --marker-color-primary: #ea4335; + --marker-color-dark: #b31412; } html, @@ -872,15 +874,15 @@ span.icon { .selected-pin { width: 40px; height: 40px; - color: #ea4335; /* Google Red */ + color: var(--marker-color-primary); filter: drop-shadow(0 4px 6px rgb(0 0 0 / 30%)); } .selected-pin svg { width: 100%; height: 100%; - fill: #ea4335; - stroke: #b31412; /* Darker red stroke */ + fill: var(--marker-color-primary); + stroke: var(--marker-color-dark); stroke-width: 1; }