/** * MapView Component * * OpenLayers map with ol-ext LayerSwitcher for base map selection. * * Usage: * import { MapView } from './components/MapView.js'; * * const map = new MapView('map', { * center: [-1.5, 7.5], // Ghana * zoom: 7, * basemap: 'osm' * }); * * map.onClick((lon, lat) => console.log('Clicked:', lon, lat)); * map.addMarker(lon, lat, { name: 'Point A' }); */ import Map from 'ol/Map'; import View from 'ol/View'; import Overlay from 'ol/Overlay'; import TileLayer from 'ol/layer/Tile'; import LayerGroup from 'ol/layer/Group'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import OSM from 'ol/source/OSM'; import XYZ from 'ol/source/XYZ'; import { fromLonLat, toLonLat } from 'ol/proj'; import { Point } from 'ol/geom'; import Feature from 'ol/Feature'; import { Style, Circle, Fill, Stroke, Text } from 'ol/style'; // ol-ext LayerSwitcher import LayerSwitcher from 'ol-ext/control/LayerSwitcher'; // ol-ext GeolocationButton import GeolocationButton from 'ol-ext/control/GeolocationButton'; // CSS imports import 'ol/ol.css'; import 'ol-ext/dist/ol-ext.css'; export class MapView { constructor(targetId, options = {}) { this.options = options; this.markerSource = new VectorSource(); this.clickCallbacks = []; // Category emoji mapping this.categoryEmojis = { 'water': '💧', 'school': '🏫', 'health': '🏥', 'market': '🏪', 'hotel': '🏨', 'restaurant': '🍽️', 'default': '📍', 'other': '📌' }; // Create emoji style helper this.createEmojiStyle = (emoji, fontSize = 24) => { return new Style({ text: new Text({ text: emoji, font: `${fontSize}px sans-serif`, textBaseline: 'bottom', textAlign: 'center', offsetY: -5, }), }); }; // Default marker style (pin emoji) this.defaultStyle = this.createEmojiStyle('📍', 32); // Selected marker style (larger) this.selectedStyle = this.createEmojiStyle('📍', 42); // Initialize category styles with emojis this.categoryStyles = {}; for (const [category, emoji] of Object.entries(this.categoryEmojis)) { this.categoryStyles[category] = this.createEmojiStyle(emoji, 32); } // Create base layers group const baseLayers = this.createBaseLayers(options.basemap || 'osm'); // Markers layer this.markersLayer = new VectorLayer({ title: 'Markers', source: this.markerSource, style: (feature) => this.getFeatureStyle(feature), }); // Create map this.map = new Map({ target: targetId, layers: [ baseLayers, this.markersLayer, ], view: new View({ center: fromLonLat(options.center || [0, 0]), zoom: options.zoom || 2, minZoom: options.minZoom || 2, maxZoom: options.maxZoom || 19, }), }); // Add LayerSwitcher control const layerSwitcher = new LayerSwitcher({ collapsed: true, mouseover: true, extent: false, trash: false, oninfo: null, }); this.map.addControl(layerSwitcher); // Add GeolocationButton control const geolocationButton = new GeolocationButton({ title: 'My Location', delay: 3000, // Auto-center duration zoom: 16, // Zoom level when centering on location }); this.map.addControl(geolocationButton); // Store reference for external access this.geolocationButton = geolocationButton; // Track selected feature this.selectedFeature = null; // Create popup overlay for hover this.createPopup(); // Create Add Location popup form this.createAddLocationPopup(); } /** * Create the popup overlay element and add to map */ createPopup() { // Create popup container element this.popupElement = document.createElement('div'); this.popupElement.className = 'map-popup'; this.popupElement.style.cssText = ` position: absolute; background: white; border-radius: 8px; padding: 10px 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.25); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13px; min-width: 150px; max-width: 280px; pointer-events: none; z-index: 1000; border: 1px solid #e0e0e0; `; // Create the overlay this.popup = new Overlay({ element: this.popupElement, positioning: 'bottom-center', offset: [0, -15], stopEvent: false, }); this.map.addOverlay(this.popup); // Set up hover handler this.setupHoverPopup(); } /** * Set up the hover popup behavior */ setupHoverPopup() { let currentFeature = null; this.map.on('pointermove', (evt) => { if (evt.dragging) { this.hidePopup(); return; } const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f); if (feature && feature !== currentFeature) { currentFeature = feature; this.showPopup(feature, evt.coordinate); } else if (!feature && currentFeature) { currentFeature = null; this.hidePopup(); } // Update cursor this.map.getTargetElement().style.cursor = feature ? 'pointer' : ''; }); // Hide popup when mouse leaves the map this.map.getTargetElement().addEventListener('mouseleave', () => { this.hidePopup(); currentFeature = null; }); } /** * Show popup with feature attributes */ showPopup(feature, coordinate) { const name = feature.get('name') || 'Unnamed'; const category = feature.get('category') || 'default'; const description = feature.get('description'); const lon = feature.get('lon'); const lat = feature.get('lat'); const emoji = this.categoryEmojis[category] || '📍'; // Build popup content let html = `
${emoji} ${this.escapeHtml(name)}
`; // Category badge const categoryColors = { 'water': '#3b82f6', 'school': '#f59e0b', 'health': '#ef4444', 'market': '#8b5cf6', 'hotel': '#8b5cf6', 'restaurant': '#8b5cf6', 'default': '#2d5016', 'other': '#6b7280' }; const catColor = categoryColors[category] || '#6b7280'; html += `
${category}
`; // Description if available if (description) { html += `
${this.escapeHtml(description)}
`; } // Coordinates if (lon !== undefined && lat !== undefined) { html += `
${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)}
`; } this.popupElement.innerHTML = html; this.popup.setPosition(coordinate); } /** * Hide the popup */ hidePopup() { this.popup.setPosition(undefined); } /** * Escape HTML to prevent XSS */ escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Create the Add Location popup form overlay */ createAddLocationPopup() { // Create popup container element this.addLocationPopupElement = document.createElement('div'); this.addLocationPopupElement.className = 'map-add-location-popup'; this.addLocationPopupElement.innerHTML = `
➕ Add Location
📍
`; // Create the overlay this.addLocationPopup = new Overlay({ element: this.addLocationPopupElement, positioning: 'bottom-center', offset: [0, -10], stopEvent: true, // Prevent click from propagating autoPan: true, autoPanAnimation: { duration: 250, }, }); this.map.addOverlay(this.addLocationPopup); // Store clicked coordinates this.addLocationCoords = null; // Set up close button handler const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close'); closeBtn.addEventListener('click', () => { this.hideAddLocationPopup(); }); // Store form submit callbacks this.addLocationCallbacks = []; } /** * Show the Add Location popup at the specified coordinate */ showAddLocationPopup(coordinate) { const [lon, lat] = toLonLat(coordinate); this.addLocationCoords = { lon, lat }; // Update coordinates display const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords'); coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`; // Reset form const form = this.addLocationPopupElement.querySelector('#map-add-location-form'); form.reset(); // Position and show popup this.addLocationPopup.setPosition(coordinate); } /** * Hide the Add Location popup */ hideAddLocationPopup() { this.addLocationPopup.setPosition(undefined); this.addLocationCoords = null; } /** * Register a callback for when a location is submitted via the map popup * Callback receives: { name, category, description, lon, lat } */ onAddLocation(callback) { this.addLocationCallbacks.push(callback); // Set up form submit handler (only once) if (this.addLocationCallbacks.length === 1) { const form = this.addLocationPopupElement.querySelector('#map-add-location-form'); form.addEventListener('submit', (e) => { e.preventDefault(); if (!this.addLocationCoords) return; const formData = new FormData(form); const data = { name: formData.get('name'), category: formData.get('category'), description: formData.get('description'), lon: this.addLocationCoords.lon, lat: this.addLocationCoords.lat, }; // Call all registered callbacks this.addLocationCallbacks.forEach(cb => cb(data)); // Hide popup after submission this.hideAddLocationPopup(); }); } } /** * Create base layers group for LayerSwitcher */ createBaseLayers(defaultBasemap) { const topoLayer = new TileLayer({ title: 'Topographic', type: 'base', visible: defaultBasemap === 'topo', source: new XYZ({ url: 'https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png', attributions: 'Map data: © OpenTopoMap', maxZoom: 17, crossOrigin: 'anonymous', }), }); const cartoLightLayer = new TileLayer({ title: 'Carto Light', type: 'base', visible: defaultBasemap === 'carto-light', source: new XYZ({ url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', attributions: '© CARTO', maxZoom: 19, crossOrigin: 'anonymous', }), }); const cartoDarkLayer = new TileLayer({ title: 'Carto Dark', type: 'base', visible: defaultBasemap === 'carto-dark', source: new XYZ({ url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', attributions: '© CARTO', maxZoom: 19, crossOrigin: 'anonymous', }), }); const osmCycleLayer = new TileLayer({ title: 'OSM Cycle map', type: 'base', visible: false, //defaultBasemap === 'osm', source: new OSM({ "url" : "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=ae1339c46dd3446b9c491e7336d38760" }), }); const satelliteLayer = new TileLayer({ title: 'Satellite', type: 'base', visible: defaultBasemap === 'satellite', source: new XYZ({ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attributions: 'Tiles © Esri', maxZoom: 19, crossOrigin: 'anonymous', }), }); const googleLayer = new TileLayer({ title: 'Google Sat', type: 'base', visible: defaultBasemap === 'googlesat', source: new XYZ({ // url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', url: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga', attributions: 'Tiles © Google', maxZoom: 19, crossOrigin: 'anonymous', }), }); const osmLayer = new TileLayer({ title: 'OpenStreetMap', type: 'base', visible: defaultBasemap === 'osm', source: new OSM(), }); // Return LayerGroup for LayerSwitcher return new LayerGroup({ title: 'Base Maps', layers: [ cartoLightLayer, cartoDarkLayer, satelliteLayer, osmCycleLayer, topoLayer, googleLayer, osmLayer, ], }); } /** * Get style for a feature (handles selection state) */ getFeatureStyle(feature) { const category = feature.get('category') || 'default'; const emoji = this.categoryEmojis[category] || '📍'; if (feature === this.selectedFeature) { // Return selected style with the correct emoji and highlight return [ // Background highlight circle new Style({ image: new Circle({ radius: 22, fill: new Fill({ color: 'rgba(220, 38, 38, 0.25)' }), stroke: new Stroke({ color: '#dc2626', width: 3 }), }), }), // Emoji on top, larger new Style({ text: new Text({ text: emoji, font: '40px sans-serif', textBaseline: 'bottom', textAlign: 'center', offsetY: -5, }), }), ]; } // Check for custom style const customStyle = feature.get('style'); if (customStyle) { return customStyle; } // Return category-based emoji style if (this.categoryStyles[category]) { return this.categoryStyles[category]; } return this.defaultStyle; } /** * Set category-based styles with emojis * @param {Object} styles - Map of category to config { emoji, fontSize } */ setCategoryStyles(styles) { for (const [category, config] of Object.entries(styles)) { // Update emoji mapping if provided if (config.emoji) { this.categoryEmojis[category] = config.emoji; } // Create/update style const emoji = this.categoryEmojis[category] || '📍'; const fontSize = config.fontSize || 28; this.categoryStyles[category] = this.createEmojiStyle(emoji, fontSize); } // Refresh markers this.markerSource.changed(); } /** * Add a single marker */ addMarker(lon, lat, properties = {}) { console.log('[MapView] Adding marker at', lon, lat, 'with properties:', properties); const feature = new Feature({ geometry: new Point(fromLonLat([lon, lat])), ...properties, }); // Store original coordinates for easy access feature.set('lon', lon); feature.set('lat', lat); this.markerSource.addFeature(feature); console.log('[MapView] Marker added, total features:', this.markerSource.getFeatures().length); return feature; } /** * Add multiple markers from an array of location objects */ addMarkers(locations) { console.log('[MapView] Adding', locations.length, 'markers'); const features = locations.map((loc) => { const feature = new Feature({ geometry: new Point(fromLonLat([loc.longitude, loc.latitude])), id: loc.id, name: loc.name, description: loc.description, category: loc.category, lon: loc.longitude, lat: loc.latitude, }); return feature; }); this.markerSource.addFeatures(features); console.log('[MapView] Markers added, total features:', this.markerSource.getFeatures().length); return features; } /** * Clear all markers */ clearMarkers() { this.markerSource.clear(); this.selectedFeature = null; } /** * Remove a specific marker by feature or ID */ removeMarker(featureOrId) { if (typeof featureOrId === 'object') { this.markerSource.removeFeature(featureOrId); } else { const feature = this.markerSource.getFeatures().find( f => f.get('id') === featureOrId ); if (feature) { this.markerSource.removeFeature(feature); } } } /** * Get all markers */ getMarkers() { return this.markerSource.getFeatures(); } /** * Find marker by ID */ findMarker(id) { return this.markerSource.getFeatures().find(f => f.get('id') === id); } /** * Select a marker (highlights it) */ selectMarker(featureOrId) { if (typeof featureOrId === 'object') { this.selectedFeature = featureOrId; } else { this.selectedFeature = this.findMarker(featureOrId); } this.markerSource.changed(); return this.selectedFeature; } /** * Clear selection */ clearSelection() { this.selectedFeature = null; this.markerSource.changed(); } /** * Zoom to a specific location */ zoomTo(lon, lat, zoom = 15) { this.map.getView().animate({ center: fromLonLat([lon, lat]), zoom: zoom, duration: 500, }); } /** * Fit view to show all markers */ fitToMarkers(padding = 50) { const extent = this.markerSource.getExtent(); if (extent && extent[0] !== Infinity) { this.map.getView().fit(extent, { padding: [padding, padding, padding, padding], duration: 500, maxZoom: 16, }); } } /** * Get current map center in lon/lat */ getCenter() { const center = this.map.getView().getCenter(); return toLonLat(center); } /** * Get current zoom level */ getZoom() { return this.map.getView().getZoom(); } /** * Set map center */ setCenter(lon, lat) { this.map.getView().setCenter(fromLonLat([lon, lat])); } /** * Set zoom level */ setZoom(zoom) { this.map.getView().setZoom(zoom); } /** * Register click callback * Callback receives (lon, lat, feature, event) */ onClick(callback) { this.clickCallbacks.push(callback); // Set up click handler if this is the first callback if (this.clickCallbacks.length === 1) { this.map.on('click', (evt) => { const [lon, lat] = toLonLat(evt.coordinate); // Check if clicked on a feature let clickedFeature = null; this.map.forEachFeatureAtPixel(evt.pixel, (feature) => { clickedFeature = feature; return true; // Stop at first feature }); // Call all registered callbacks for (const cb of this.clickCallbacks) { cb(lon, lat, clickedFeature, evt); } }); } // Return unsubscribe function return () => { const index = this.clickCallbacks.indexOf(callback); if (index > -1) { this.clickCallbacks.splice(index, 1); } }; } /** * Register pointer move callback (for hover effects) */ onPointerMove(callback) { this.map.on('pointermove', (evt) => { if (evt.dragging) return; const [lon, lat] = toLonLat(evt.coordinate); let hoveredFeature = null; this.map.forEachFeatureAtPixel(evt.pixel, (feature) => { hoveredFeature = feature; return true; }); // Change cursor this.map.getTargetElement().style.cursor = hoveredFeature ? 'pointer' : ''; callback(lon, lat, hoveredFeature, evt); }); } /** * Enable cursor change on marker hover * Note: This is now handled automatically by the popup system */ enableHoverCursor() { // Cursor changes are now handled by setupHoverPopup() // This method is kept for backwards compatibility } /** * Get the OpenLayers map instance for advanced usage */ getMap() { return this.map; } /** * Get the marker source for advanced usage */ getMarkerSource() { return this.markerSource; } /** * Get the markers layer for advanced usage */ getMarkersLayer() { return this.markersLayer; } /** * Update map size (call after container resize) */ updateSize() { this.map.updateSize(); } } // Export OpenLayers utilities for convenience export { fromLonLat, toLonLat }; export default MapView;