/**
* 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 = `
`;
// 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;