pwa-drawing-tools/MapView.js
ekke 876e884509 files for /src
Mapview.js must be placed into /src/components
2026-01-27 09:51:21 +00:00

838 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = `
<div style="font-weight: 600; font-size: 14px; margin-bottom: 6px;">
${emoji} ${this.escapeHtml(name)}
</div>
`;
// 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 += `
<div style="margin-bottom: 6px;">
<span style="
background: ${catColor}20;
color: ${catColor};
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
">${category}</span>
</div>
`;
// Description if available
if (description) {
html += `
<div style="color: #555; font-size: 12px; margin-bottom: 6px; line-height: 1.4;">
${this.escapeHtml(description)}
</div>
`;
}
// Coordinates
if (lon !== undefined && lat !== undefined) {
html += `
<div style="color: #888; font-size: 11px; font-family: monospace;">
${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)}
</div>
`;
}
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 = `
<div class="add-location-popup-header">
<span> Add Location</span>
<button type="button" class="add-location-popup-close" aria-label="Close">&times;</button>
</div>
<form id="map-add-location-form">
<div class="add-location-popup-field">
<label for="map-location-name">Name <span class="text-danger">*</span></label>
<input type="text" id="map-location-name" name="name" required placeholder="e.g., Water Point A">
</div>
<div class="add-location-popup-field">
<label for="map-location-category">Category</label>
<select id="map-location-category" name="category">
<option value="default">📍 Default</option>
<option value="water">💧 Water Point</option>
<option value="school">🏫 School</option>
<option value="health">🏥 Health Facility</option>
<option value="market">🏪 Market</option>
<option value="other">📌 Other</option>
</select>
</div>
<div class="add-location-popup-field">
<label for="map-location-description">Description</label>
<textarea id="map-location-description" name="description" rows="2" placeholder="Optional notes..."></textarea>
</div>
<div class="add-location-popup-coords">
<small>📍 <span id="map-location-coords"></span></small>
</div>
<button type="submit" class="add-location-popup-submit"> Add Location</button>
</form>
`;
// 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;