import Component from '@glimmer/component'; import { service } from '@ember/service'; import { modifier } from 'ember-modifier'; import 'ol/ol.css'; import Map from 'ol/Map.js'; import { defaults as defaultControls, Control } from 'ol/control.js'; import View from 'ol/View.js'; import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js'; import Overlay from 'ol/Overlay.js'; import LayerGroup from 'ol/layer/Group.js'; import VectorLayer from 'ol/layer/Vector.js'; import VectorSource from 'ol/source/Vector.js'; import Feature from 'ol/Feature.js'; import Point from 'ol/geom/Point.js'; 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'; export default class MapComponent extends Component { @service osm; @service storage; mapInstance; bookmarkSource; searchOverlay; searchOverlayElement; setupMap = modifier((element) => { if (this.mapInstance) return; const openfreemap = new LayerGroup(); // Create a vector source and layer for bookmarks this.bookmarkSource = new VectorSource(); const bookmarkLayer = new VectorLayer({ source: this.bookmarkSource, style: [ new Style({ image: new Circle({ radius: 10, fill: new Fill({ color: 'rgba(0, 0, 0, 0.2)' }), displacement: [0, -2], }), }), new Style({ image: new Circle({ radius: 9, fill: new Fill({ color: '#ffcc33' }), // Gold/Yellow stroke: new Stroke({ color: '#fff', width: 2, }), }), }), ], zIndex: 10, // Ensure it sits above the map tiles }); const view = new View({ center: fromLonLat([99.05738, 7.55087]), zoom: 13.0, projection: 'EPSG:3857', }); this.mapInstance = new Map({ target: element, layers: [openfreemap, bookmarkLayer], view: view, controls: defaultControls({ zoom: false, rotate: false, attribution: true }), }); apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty'); this.searchOverlayElement = document.createElement('div'); this.searchOverlayElement.className = 'search-pulse'; this.searchOverlay = new Overlay({ element: this.searchOverlayElement, positioning: 'center-center', stopEvent: false, }); this.mapInstance.addOverlay(this.searchOverlay); // Geolocation Pulse Overlay this.locationOverlayElement = document.createElement('div'); this.locationOverlayElement.className = 'search-pulse blue'; this.locationOverlay = new Overlay({ element: this.locationOverlayElement, positioning: 'center-center', stopEvent: false, }); this.mapInstance.addOverlay(this.locationOverlay); // Geolocation Setup const geolocation = new Geolocation({ trackingOptions: { enableHighAccuracy: true, }, projection: view.getProjection(), }); const positionFeature = new Feature(); positionFeature.setStyle( new Style({ image: new Circle({ radius: 6, fill: new Fill({ color: '#3399CC', }), stroke: new Stroke({ color: '#fff', width: 2, }), }), }) ); const geolocationSource = new VectorSource({ features: [positionFeature], }); const geolocationLayer = new VectorLayer({ source: geolocationSource, zIndex: 15, }); geolocation.on('change:position', function () { const coordinates = geolocation.getPosition(); positionFeature.setGeometry(coordinates ? new Point(coordinates) : null); }); // Locate Me Control const locateElement = document.createElement('div'); locateElement.className = 'ol-control ol-locate'; const locateBtn = document.createElement('button'); locateBtn.innerHTML = '⊙'; locateBtn.title = 'Locate Me'; locateElement.appendChild(locateBtn); // Track active sessions to prevent race conditions let locateTimeout; let locateListenerKey; // Helper to stop tracking and cleanup UI const stopLocating = () => { // Clear timeout if (locateTimeout) { clearTimeout(locateTimeout); locateTimeout = null; } // Remove listener try { if (locateListenerKey) { geolocation.un('change:position', zoomToLocation); locateListenerKey = null; } } catch (e) { /* ignore */ } // Hide pulse if (this.locationOverlayElement) { this.locationOverlayElement.classList.remove('active'); } }; const zoomToLocation = () => { const coordinates = geolocation.getPosition(); const accuracyGeometry = geolocation.getAccuracyGeometry(); const accuracy = geolocation.getAccuracy(); if (!coordinates) return; const size = this.mapInstance.getSize(); const view = this.mapInstance.getView(); let targetResolution = null; // Update Pulse Overlay Position & Size (but don't force it active) if (this.locationOverlayElement) { const resolution = view.getResolution(); const pointResolution = getPointResolution( view.getProjection(), resolution, coordinates ); const diameterInMeters = (accuracy || 50) * 2; const diameterInPixels = diameterInMeters / pointResolution; this.locationOverlayElement.style.width = `${diameterInPixels}px`; this.locationOverlayElement.style.height = `${diameterInPixels}px`; this.locationOverlay.setPosition(coordinates); } // Check Target Accuracy (<= 20m) for early exit // Only if we have a valid accuracy reading if (accuracy && accuracy <= 20) { stopLocating(); } // 1. Try to use the exact geometry (circular polygon) if available if (accuracyGeometry) { const extent = accuracyGeometry.getExtent(); const fitResolution = view.getResolutionForExtent(extent, size); targetResolution = fitResolution * 3.162; // Scale for 10% area coverage } // 2. Fallback to using the scalar accuracy (meters) if geometry is missing else if (accuracy) { const viewportWidthMeters = 6.325 * accuracy; const minDimensionPixels = Math.min(size[0], size[1]); const requiredResolutionMeters = viewportWidthMeters / minDimensionPixels; const metersPerMapUnit = getPointResolution( view.getProjection(), 1, coordinates ); targetResolution = requiredResolutionMeters / metersPerMapUnit; } let viewOptions = { center: coordinates, duration: 1000, }; if (targetResolution) { const maxResolution = view.getResolutionForZoom(17); // Use 17 as safe max zoom for accuracy < 20m viewOptions.resolution = Math.max(targetResolution, maxResolution); } else { viewOptions.zoom = 16; } this.mapInstance.getView().animate(viewOptions); }; locateBtn.addEventListener('click', () => { // 1. Clear any previous session stopLocating(); geolocation.setTracking(true); const coordinates = geolocation.getPosition(); // 2. Activate Pulse immediately if (this.locationOverlayElement) { this.locationOverlayElement.classList.add('active'); } // 3. Zoom immediately if we have data if (coordinates) { zoomToLocation(); } // 4. Start Following locateListenerKey = geolocation.on('change:position', zoomToLocation); // 5. Set Safety Timeout (10s) locateTimeout = setTimeout(() => { stopLocating(); }, 10000); }); const locateControl = new Control({ element: locateElement, }); this.mapInstance.addLayer(geolocationLayer); this.mapInstance.addControl(locateControl); this.mapInstance.on('singleclick', this.handleMapClick); // Load places when map moves this.mapInstance.on('moveend', this.handleMapMove); // Change cursor to pointer when hovering over a clickable feature this.mapInstance.on('pointermove', (e) => { const pixel = this.mapInstance.getEventPixel(e.originalEvent); const hit = this.mapInstance.hasFeatureAtPixel(pixel, { hitTolerance: 10, }); this.mapInstance.getTarget().style.cursor = hit ? 'pointer' : ''; }); // Load initial bookmarks this.storage.rs.on('ready', () => { // Initial load based on current view this.handleMapMove(); }); // Listen for remote storage changes // this.storage.rs.on('connected', () => { // this.loadBookmarks(); // }); }); // 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 const places = this.storage.savedPlaces; this.loadBookmarks(places); }); async loadBookmarks(places = []) { try { if (!this.bookmarkSource) return; if (!places || places.length === 0) { // Fallback or explicit check if we have tracked property usage? // The service updates 'savedPlaces'. We should probably use that if we want reactiveness. places = this.storage.savedPlaces; } // Previously: const places = await this.storage.places.getPlaces(); // We no longer want to fetch everything blindly. // We rely on 'savedPlaces' being updated by handleMapMove calling storage.loadPlacesInBounds. this.bookmarkSource.clear(); if (places && Array.isArray(places)) { places.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, isBookmark: true, // Marker property to distinguish originalPlace: place, }); this.bookmarkSource.addFeature(feature); } }); } } catch (e) { console.error('Failed to load bookmarks:', e); } } handleMapMove = async () => { if (!this.mapInstance) return; const size = this.mapInstance.getSize(); const extent = this.mapInstance.getView().calculateExtent(size); const [minLon, minLat] = toLonLat([extent[0], extent[1]]); const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]); const bbox = { minLat, minLon, maxLat, maxLon }; await this.storage.loadPlacesInBounds(bbox); this.loadBookmarks(this.storage.savedPlaces); }; handleMapClick = async (event) => { // Check if user clicked on a rendered feature (POI or Bookmark) FIRST const features = this.mapInstance.getFeaturesAtPixel(event.pixel, { hitTolerance: 10, }); let clickedBookmark = null; let selectedFeatureName = null; let selectedFeatureType = null; if (features && features.length > 0) { const bookmarkFeature = features.find((f) => f.get('isBookmark')); if (bookmarkFeature) { clickedBookmark = bookmarkFeature.get('originalPlace'); } // Also get visual props for standard map click logic later const props = features[0].getProperties(); if (props.name) { selectedFeatureName = props.name; selectedFeatureType = props.class || props.subclass; } } // 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) { console.log( 'Clicked bookmark while sidebar open (switching):', clickedBookmark ); if (this.args.onPlacesFound) { this.args.onPlacesFound([], clickedBookmark); } return; } // Otherwise (empty map or non-bookmark feature), close the sidebar if (this.args.onOutsideClick) { this.args.onOutsideClick(); } return; } // Normal behavior (sidebar is closed) if (clickedBookmark) { console.log('Clicked bookmark:', clickedBookmark); if (this.args.onPlacesFound) { this.args.onPlacesFound([], clickedBookmark); } return; } const coords = toLonLat(event.coordinate); const [lon, lat] = coords; // Determine search radius based on whether we clicked a named feature const searchRadius = selectedFeatureName ? 30 : 50; // Show visual feedback (pulse) if (this.searchOverlayElement) { const view = this.mapInstance.getView(); const resolutionAtPoint = getPointResolution( view.getProjection(), view.getResolution(), event.coordinate ); const diameterInMeters = searchRadius * 2; const diameterInPixels = diameterInMeters / resolutionAtPoint; 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); // Sort by distance from click pois = pois .map((p) => { // Use center lat/lon for ways/relations if available, else lat/lon const pLat = p.lat || p.center?.lat; const pLon = p.lon || p.center?.lon; return { ...p, _distance: pLat && pLon ? getDistance(lat, lon, pLat, pLon) : 9999, }; }) .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.tags && (p.tags.name === selectedFeatureName || p.tags['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.tags.amenity || topCandidate.tags.shop || topCandidate.tags.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'); } } }; }