diff --git a/app/components/map.gjs b/app/components/map.gjs index b519344..4f787bf 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -16,15 +16,19 @@ import Geolocation from 'ol/Geolocation.js'; import { Style, Circle, Fill, Stroke } from 'ol/style.js'; import { apply } from 'ol-mapbox-style'; import { getDistance } from '../utils/geo'; +import Icon from '../components/icon'; export default class MapComponent extends Component { @service osm; @service storage; + @service mapUi; mapInstance; bookmarkSource; searchOverlay; searchOverlayElement; + selectedPinOverlay; + selectedPinElement; setupMap = modifier((element) => { if (this.mapInstance) return; @@ -105,6 +109,34 @@ export default class MapComponent extends Component { }); this.mapInstance.addOverlay(this.searchOverlay); + // Selected Pin Overlay (Red Marker) + // We create the element in the template (or JS) and attach it. + // Using JS creation to ensure it's cleanly managed by OpenLayers + this.selectedPinElement = document.createElement('div'); + this.selectedPinElement.className = 'selected-pin-container'; + + // Create the icon structure inside + const pinIcon = document.createElement('div'); + pinIcon.className = 'selected-pin'; + // We can't use the Glimmer component easily inside a raw DOM element created here. + // So we'll inject the SVG string directly or mount it. + // Feather icons are globally available if we used the script, but we are using the module approach. + // Simple SVG for Map Pin: + pinIcon.innerHTML = ``; + + const pinShadow = document.createElement('div'); + pinShadow.className = 'selected-pin-shadow'; + + this.selectedPinElement.appendChild(pinIcon); + this.selectedPinElement.appendChild(pinShadow); + + this.selectedPinOverlay = new Overlay({ + element: this.selectedPinElement, + positioning: 'bottom-center', // Important: Pin tip is at the bottom + stopEvent: false, // Let clicks pass through + }); + this.mapInstance.addOverlay(this.selectedPinOverlay); + // Geolocation Pulse Overlay this.locationOverlayElement = document.createElement('div'); this.locationOverlayElement.className = 'search-pulse blue'; @@ -312,6 +344,28 @@ export default class MapComponent extends Component { // }); }); + // Track the selected place from the UI Service (Router -> Map) + updateSelectedPin = modifier(() => { + const selected = this.mapUi.selectedPlace; + + if (!this.selectedPinOverlay || !this.selectedPinElement) return; + + if (selected && selected.lat && selected.lon) { + const coords = fromLonLat([selected.lon, selected.lat]); + this.selectedPinOverlay.setPosition(coords); + + // Reset animation by removing/adding class + this.selectedPinElement.classList.remove('active'); + // Force reflow + void this.selectedPinElement.offsetWidth; + this.selectedPinElement.classList.add('active'); + } else { + this.selectedPinElement.classList.remove('active'); + // Hide it effectively by moving it away or just relying on display:none in CSS + this.selectedPinOverlay.setPosition(undefined); + } + }); + // Re-fetch bookmarks when the version changes (triggered by parent action or service) updateBookmarks = modifier(() => { // Depend on the tracked storage.savedPlaces to automatically update when they change @@ -532,6 +586,7 @@ export default class MapComponent extends Component { class="map-container" {{this.setupMap}} {{this.updateBookmarks}} + {{this.updateSelectedPin}} style="position: absolute; inset: 0;" > diff --git a/app/routes/place.js b/app/routes/place.js index c8abc6e..7c614cf 100644 --- a/app/routes/place.js +++ b/app/routes/place.js @@ -4,6 +4,7 @@ import { service } from '@ember/service'; export default class PlaceRoute extends Route { @service storage; @service osm; + @service mapUi; async model(params) { const id = params.place_id; @@ -16,15 +17,8 @@ export default class PlaceRoute extends Route { } // 1. Try to find in local bookmarks - // We rely on the service maintaining the list let bookmark = this.storage.findPlaceById(id); - // If not found instantly, maybe wait for storage ready? - // For now assuming storage is reasonably fast or "ready" has fired. - // If we land here directly on refresh, "savedPlaces" might be empty initially. - // We could retry or wait, but simpler to fall back to OSM for now. - // Ideally, we await `storage.loadAllPlaces()` promise if it's pending. - if (bookmark) { console.log('Found in bookmarks:', bookmark.title); return bookmark; @@ -35,6 +29,18 @@ export default class PlaceRoute extends Route { return this.loadOsmPlace(id); } + afterModel(model) { + // Notify the Map UI to show the pin + if (model) { + this.mapUi.selectPlace(model); + } + } + + deactivate() { + // Clear the pin when leaving the route + this.mapUi.clearSelection(); + } + async loadOsmPlace(id, type = null) { try { const poi = await this.osm.getPoiById(id, type); diff --git a/app/services/map-ui.js b/app/services/map-ui.js new file mode 100644 index 0000000..54bb6bf --- /dev/null +++ b/app/services/map-ui.js @@ -0,0 +1,14 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class MapUiService extends Service { + @tracked selectedPlace = null; + + selectPlace(place) { + this.selectedPlace = place; + } + + clearSelection() { + this.selectedPlace = null; + } +} diff --git a/app/styles/app.css b/app/styles/app.css index 14de172..c5a9eee 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -248,3 +248,67 @@ span.icon { align-items: center; gap: 0.5rem; } + +/* Selected Pin Animation */ +.selected-pin-container { + position: absolute; + /* Center the bottom tip of the pin at the coordinate */ + transform: translate(-50%, -100%); + pointer-events: none; /* Let clicks pass through to the map features below if needed */ + display: none; +} + +.selected-pin-container.active { + display: block; + animation: dropIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; +} + +.selected-pin { + width: 40px; + height: 40px; + color: #ea4335; /* Google Red */ + filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3)); +} + +.selected-pin svg { + width: 100%; + height: 100%; + fill: #ea4335; + stroke: #b31412; /* Darker red stroke */ + stroke-width: 1; +} + +/* Optional: Small dot at the bottom to ground it */ +.selected-pin-shadow { + width: 10px; + height: 4px; + background: rgba(0, 0, 0, 0.3); + border-radius: 50%; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + z-index: -1; + opacity: 0; + animation: shadowFade 0.5s 0.2s forwards; +} + +@keyframes dropIn { + 0% { + transform: translate(-50%, -200%) scale(0); + opacity: 0; + } + 60% { + opacity: 1; + } + 100% { + transform: translate(-50%, -100%) scale(1); + opacity: 1; + } +} + +@keyframes shadowFade { + to { + opacity: 1; + } +}