Show map pin for currently selected place

This commit is contained in:
Râu Cao 2026-01-21 18:55:54 +07:00
parent 25f50f9091
commit c61c2c0e7a
Signed by: raucao
GPG Key ID: 37036C356E56CC51
4 changed files with 146 additions and 7 deletions

View File

@ -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 <Icon> 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`;
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;"
></div>
</template>

View File

@ -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);

14
app/services/map-ui.js Normal file
View File

@ -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;
}
}

View File

@ -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;
}
}