838 lines
22 KiB
JavaScript
838 lines
22 KiB
JavaScript
/**
|
||
* 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">×</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;
|