2026-03-04 12:59:40 +01:00

2168 lines
69 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, Polygon as PolygonGeom } from 'ol/geom';
import Feature from 'ol/Feature';
import { Style, Circle, Fill, Stroke, Text } from 'ol/style';
import GeoJSON from 'ol/format/GeoJSON';
import { getArea, getLength } from 'ol/sphere';
import { fromCircle } from 'ol/geom/Polygon';
import ScaleLine from 'ol/control/ScaleLine';
// ol-ext LayerSwitcher
import LayerSwitcher from 'ol-ext/control/LayerSwitcher';
// ol-ext GeolocationButton
import GeolocationButton from 'ol-ext/control/GeolocationButton';
// ol-ext SearchNominatim
import SearchNominatim from 'ol-ext/control/SearchNominatim';
// ol-ext EditBar for drawing/editing features
import EditBar from 'ol-ext/control/EditBar';
import Bar from 'ol-ext/control/Bar';
import Button from 'ol-ext/control/Button';
// ol-ext TouchCursor for touch-enabled devices
import TouchCursor from 'ol-ext/interaction/TouchCursor';
// ol-ext ModifyFeature for cross-layer modification
import ModifyFeature from 'ol-ext/interaction/ModifyFeature';
// ol-ext UndoRedo interaction
import UndoRedo from 'ol-ext/interaction/UndoRedo';
// ol Select interaction (for custom multi-layer Select)
import Select from 'ol/interaction/Select';
import { click as clickCondition } from 'ol/events/condition';
// 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 and label mapping
// Add new categories here - they will automatically appear in the dropdown
this.categoryEmojis = {
'default': { emoji: '📍', label: 'Default' },
'water': { emoji: '💧', label: 'Water Point' },
'school': { emoji: '🏫', label: 'School' },
'health': { emoji: '🏥', label: 'Health Facility' },
'market': { emoji: '🏪', label: 'Market' },
'other': { emoji: '📌', label: 'Other' }
};
// Helper to get emoji for a category
this.getEmoji = (category) => {
const cat = this.categoryEmojis[category];
return cat ? cat.emoji : '📍';
};
// Helper to generate category options HTML for select dropdowns
this.getCategoryOptionsHtml = () => {
return Object.entries(this.categoryEmojis)
.map(([key, { emoji, label }]) =>
`<option value="${key}">${emoji} ${label}</option>`
)
.join('\n ');
};
// 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),
});
// Overlay layers group (for remote data like boundaries)
this.overlayGroup = new LayerGroup({
title: 'Overlays',
});
// Create map
// Layer order (bottom → top): Base Maps, Markers, Overlays
// MapTools will insert Measurements and Drawings between Markers and Overlays.
// initEditBar() will insert its Drawings group above those.
// Final LayerSwitcher order (top → bottom):
// Overlays, Drawings, Measurements, Markers, Base Maps
this.map = new Map({
target: targetId,
layers: [
baseLayers,
this.markersLayer,
this.overlayGroup,
],
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 ScaleLine control
this.map.addControl(new ScaleLine());
// 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;
// Add SearchNominatim control
const searchNominatim = new SearchNominatim({
placeholder: 'Search location...',
typing: 300, // Delay before search (ms)
minLength: 3, // Minimum characters to start search
maxItems: 10, // Maximum results to show
collapsed: true, // Start collapsed
// Limit search to improve relevance (can be adjusted)
// countrycodes: 'gh', // Uncomment to limit to Ghana
});
this.map.addControl(searchNominatim);
// Handle search result selection
searchNominatim.on('select', (event) => {
const searchResult = event.search;
if (searchResult) {
// SearchNominatim returns a plain object with lon/lat properties (as strings)
const lon = parseFloat(searchResult.lon);
const lat = parseFloat(searchResult.lat);
const lonLat = [lon, lat];
const coordinate = fromLonLat(lonLat);
// Navigate to the selected location
this.navigateTo(lon, lat, 14);
// Trigger search select callbacks
const result = {
coordinate: coordinate,
lonLat: lonLat,
name: searchResult.display_name || searchResult.name || 'Unknown',
searchResult: searchResult,
};
this.searchSelectCallbacks.forEach(cb => cb(result));
}
});
// Store reference for external access
this.searchNominatim = searchNominatim;
this.searchSelectCallbacks = [];
// Track selected feature
this.selectedFeature = null;
// Create popup overlay for hover
this.createPopup();
// Create info popup for double-click feature details
this.createInfoPopup();
// Create Add Location popup form
this.createAddLocationPopup();
// Create editable parcel form popup
this.createParcelEditPopup();
// Create drawn polygon attribute popup
this.createDrawnPolygonPopup();
// Double-click callbacks
this.dblClickCallbacks = [];
// EditBar is set up lazily via initEditBar() once the Drawings
// layer/group is available (called from main.js after loadLayers).
this.editBar = null;
this.drawingsSource = null;
this.drawingsLayer = null;
this.touchCursor = null;
this._editBarActive = false;
}
// ============================================================================
// EditBar + Drawings Layer + TouchCursor
// ============================================================================
/**
* Initialise the EditBar with a dedicated "Drawings" LayerGroup.
*
* A "Drawings" LayerGroup is created at the top of the overlay stack
* containing a "sketches" VectorLayer for storing drawn features.
* The EditBar, Select and Modify interactions are only active while
* edit mode is on; in all other cases normal click / double-click
* behaviour is preserved.
*
* Call this once from main.js after the layer groups have been created.
*/
initEditBar() {
// 1. Create a "Drawings" LayerGroup with a "sketches" VectorLayer inside
this.drawingsSource = new VectorSource();
this.drawingsLayer = new VectorLayer({
title: 'sketches',
source: this.drawingsSource,
style: new Style({
stroke: new Stroke({ color: '#f59e0b', width: 2.5 }),
fill: new Fill({ color: 'rgba(245,158,11,0.15)' }),
image: new Circle({
radius: 6,
fill: new Fill({ color: '#f59e0b' }),
stroke: new Stroke({ color: '#fff', width: 1.5 }),
}),
}),
});
this._drawingsGroup = new LayerGroup({
title: 'Drawings',
layers: [this.drawingsLayer],
});
// Insert as a top-level map layer just before the Overlays group
// so the LayerSwitcher order is: Overlays > Drawings > Measurements > Markers > Base Maps
const mapLayers = this.map.getLayers();
const overlayIdx = mapLayers.getLength() - 1; // Overlays is the last layer
mapLayers.insertAt(overlayIdx, this._drawingsGroup);
// 2. Create a Select interaction that works on ALL vector layers.
// It starts INACTIVE so it doesn't steal clicks from normal handlers.
this._selectInteraction = new Select({
condition: clickCondition,
filter: (feature, layer) => !!layer,
layers: (layer) => layer instanceof VectorLayer,
});
this._selectInteraction.setActive(false);
this.map.addInteraction(this._selectInteraction);
// 3. Create a ModifyFeature interaction bound to the selection.
// Also starts inactive.
this._modifyInteraction = new ModifyFeature({
features: this._selectInteraction.getFeatures(),
});
this._modifyInteraction.setActive(false);
// 4. UndoRedo interaction — watches the drawings source
this._undoRedo = new UndoRedo();
this.map.addInteraction(this._undoRedo);
// 5. Build the EditBar — all interactions enabled.
this.editBar = new EditBar({
source: this.drawingsSource,
interactions: {
Select: this._selectInteraction,
ModifySelect: this._modifyInteraction,
DrawPoint: true,
DrawLine: true,
DrawPolygon: true,
DrawRegular: true,
DrawHole: true,
Delete: true,
Info: true,
Transform: true,
Split: true,
Offset: true,
},
});
this.map.addControl(this.editBar);
// 6. Add extra buttons (Undo, Redo, Colour, Save) as a sub-bar
// inside the EditBar so they appear inline.
this._fillColor = '#f59e0b';
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = this._fillColor;
colorInput.title = 'Fill colour';
colorInput.style.cssText = 'width:28px;height:28px;border:none;padding:0;cursor:pointer;background:transparent;';
colorInput.addEventListener('input', (e) => {
this._fillColor = e.target.value;
const hex = this._fillColor;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
this.drawingsLayer.setStyle(new Style({
stroke: new Stroke({ color: hex, width: 2.5 }),
fill: new Fill({ color: `rgba(${r},${g},${b},0.15)` }),
image: new Circle({
radius: 6,
fill: new Fill({ color: hex }),
stroke: new Stroke({ color: '#fff', width: 1.5 }),
}),
}));
console.log('[MapView] Fill colour changed to', hex);
});
const extraBar = new Bar({
group: true,
controls: [
new Button({
html: '<i class="bi bi-arrow-counterclockwise"></i>',
className: 'ol-undo',
title: 'Undo',
handleClick: () => {
if (this._undoRedo.hasUndo()) this._undoRedo.undo();
},
}),
new Button({
html: '<i class="bi bi-arrow-clockwise"></i>',
className: 'ol-redo',
title: 'Redo',
handleClick: () => {
if (this._undoRedo.hasRedo()) this._undoRedo.redo();
},
}),
new Button({
html: colorInput,
className: 'ol-colorpicker',
title: 'Fill colour',
}),
new Button({
html: '<i class="bi bi-floppy"></i>',
className: 'ol-save',
title: 'Save drawings',
handleClick: () => {
this.dispatchEditEvent('save');
},
}),
],
});
this.editBar.addControl(extraBar);
// Start hidden — use the full setEditMode(false) so the Select +
// Modify interactions are deactivated (the EditBar constructor may
// have re-activated them).
this.setEditMode(false);
// 7. Link EditBar visibility to the Drawings group's visibility.
this._drawingsGroup.on('change:visible', () => {
const visible = this._drawingsGroup.getVisible();
this.setEditMode(visible);
});
// 8. Touch-device detection & TouchCursor setup
const isTouchDevice = ('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0) ||
(navigator.msMaxTouchPoints > 0);
if (isTouchDevice) {
this.touchCursor = new TouchCursor({
className: 'ol-editbar-cursor',
});
this.map.addInteraction(this.touchCursor);
this.touchCursor.setActive(false);
console.log('[MapView] Touch device detected — TouchCursor added');
}
// 9. Listen for polygon features drawn via EditBar's DrawPolygon tool.
// When a Polygon is added to the drawings source, show the attribute popup.
this.drawingsSource.on('addfeature', (evt) => {
const feature = evt.feature;
const geom = feature.getGeometry();
if (!geom || geom.getType() !== 'Polygon') return;
const coordinate = geom.getInteriorPoint().getCoordinates();
this.showDrawnPolygonPopup(feature, coordinate);
});
console.log('[MapView] EditBar initialised with Drawings group, UndoRedo, colour picker');
}
/**
* Dispatch a custom edit event (e.g. 'save').
* External code can listen via mapView.onEditEvent('save', callback).
* @param {string} type
*/
dispatchEditEvent(type) {
if (!this._editEventListeners) return;
const listeners = this._editEventListeners[type];
if (listeners) {
listeners.forEach((fn) => fn());
}
}
/**
* Listen for custom edit events (e.g. 'save').
* @param {string} type - Event name
* @param {Function} callback
*/
onEditEvent(type, callback) {
if (!this._editEventListeners) this._editEventListeners = {};
if (!this._editEventListeners[type]) this._editEventListeners[type] = [];
this._editEventListeners[type].push(callback);
}
/**
* Toggle edit mode on or off.
*
* When ON: EditBar is visible, Select + Modify interactions are active.
* When OFF: EditBar is hidden, Select + Modify are deactivated, any
* current selection is cleared so normal click / double-click
* events work without interference.
*
* @param {boolean} active
*/
setEditMode(active) {
this._editBarActive = !!active;
if (this.editBar) {
this.editBar.setVisible(this._editBarActive);
if (!this._editBarActive) {
// Deactivate all EditBar controls (DrawPoint, DrawLine, etc.)
// so no draw interaction stays active in the background.
this.editBar.deactivateControls();
}
}
// Activate / deactivate Select + Modify
if (this._selectInteraction) {
if (!this._editBarActive) {
// Clear any current selection first
this._selectInteraction.getFeatures().clear();
}
this._selectInteraction.setActive(this._editBarActive);
}
if (this._modifyInteraction) {
this._modifyInteraction.setActive(this._editBarActive);
}
// Toggle TouchCursor
if (this.touchCursor) {
this.touchCursor.setActive(this._editBarActive);
}
console.log('[MapView] Edit mode:', this._editBarActive ? 'ON' : 'OFF');
}
/**
* Check whether edit mode (select / modify) is currently active.
* @returns {boolean}
*/
isEditMode() {
return this._editBarActive;
}
/**
* Get the Drawings layer for external access.
* @returns {VectorLayer}
*/
getDrawingsLayer() {
return this.drawingsLayer;
}
/**
* Get the Drawings source for external access.
* @returns {VectorSource}
*/
getDrawingsSource() {
return this.drawingsSource;
}
/**
* Get the EditBar control for external access.
* @returns {EditBar}
*/
getEditBar() {
return this.editBar;
}
/**
* 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: var(--font-body, 'Exo', sans-serif);
font-size: 13px;
min-width: 150px;
max-width: 280px;
pointer-events: none;
z-index: 1000;
border: 1px solid var(--border, #1e1a4b1f);
`;
// 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;
}
// Only find features that are location markers (have 'name' property)
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => {
// Only return features that have a 'name' property (location markers)
if (f.get('name')) {
return f;
}
return null;
});
if (feature && feature !== currentFeature) {
currentFeature = feature;
this.showPopup(feature, evt.coordinate);
} else if (!feature && currentFeature) {
currentFeature = null;
this.hidePopup();
}
// Update cursor - only show pointer for location markers
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.getEmoji(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',
'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: var(--muted-foreground, #7a7a7a); font-size: 12px; margin-bottom: 6px; line-height: 1.4;">
${this.escapeHtml(description)}
</div>
`;
}
// Coordinates
if (lon !== undefined && lat !== undefined) {
html += `
<div style="color: var(--muted-foreground, #7a7a7a); 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);
}
/**
* Create the info popup overlay for double-click feature details
*/
createInfoPopup() {
this.infoPopupElement = document.createElement('div');
this.infoPopupElement.className = 'map-info-popup';
this.infoPopupElement.style.cssText = `
position: absolute;
background: white;
border-radius: 10px;
padding: 0;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 13px;
min-width: 220px;
max-width: 320px;
z-index: 1001;
border: 1px solid var(--border, #1e1a4b1f);
overflow: hidden;
`;
this.infoPopup = new Overlay({
element: this.infoPopupElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: true,
autoPan: true,
autoPanAnimation: { duration: 250 },
});
this.map.addOverlay(this.infoPopup);
}
/**
* Show the info popup with feature attributes and area
* @param {Feature} feature - OpenLayers feature
* @param {Array} coordinate - Map coordinate [x, y]
* @param {Object} [options] - Display options
* @param {string} [options.title='Feature Info'] - Popup header title
* @param {string} [options.color='#e11d48'] - Header background colour
*/
showInfoPopup(feature, coordinate, options = {}) {
const { title = 'Feature Info', color = '#e11d48' } = options;
const properties = feature.getProperties();
const geometry = feature.getGeometry();
const geomType = geometry.getType();
// Build attributes table rows (skip geometry and internal keys)
const skipKeys = ['geometry', '_layerType'];
let rows = '';
for (const [key, value] of Object.entries(properties)) {
if (skipKeys.includes(key) || value === undefined || value === null) continue;
rows += `
<tr>
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">${this.escapeHtml(key)}</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${this.escapeHtml(String(value))}</td>
</tr>
`;
}
// Add measurement row based on geometry type
if (geomType === 'Polygon' || geomType === 'MultiPolygon') {
// Area for polygons
const areaSqm = getArea(geometry, { projection: 'EPSG:3857' });
const areaFormatted = areaSqm > 1_000_000
? `${(areaSqm / 1_000_000).toFixed(2)} km\u00B2 (${areaSqm.toLocaleString('en', { maximumFractionDigits: 0 })} m\u00B2)`
: `${areaSqm.toLocaleString('en', { maximumFractionDigits: 0 })} m\u00B2`;
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">area</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${areaFormatted}</td>
</tr>
`;
} else if (geomType === 'LineString' || geomType === 'MultiLineString') {
// Length for lines
const lengthM = getLength(geometry, { projection: 'EPSG:3857' });
const lengthFormatted = lengthM >= 1000
? `${(lengthM / 1000).toFixed(2)} km (${lengthM.toLocaleString('en', { maximumFractionDigits: 0 })} m)`
: `${lengthM.toLocaleString('en', { maximumFractionDigits: 1 })} m`;
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">length</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${lengthFormatted}</td>
</tr>
`;
} else if (geomType === 'Point') {
// Coordinates for points
const coords = toLonLat(geometry.getCoordinates());
const lon = coords[0].toFixed(6);
const lat = coords[1].toFixed(6);
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">longitude</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${lon}</td>
</tr>
<tr>
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">latitude</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${lat}</td>
</tr>
`;
}
const html = `
<div style="background:${color};color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;">
<span>${this.escapeHtml(title)}</span>
<button id="info-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<div style="padding:8px 4px;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
${rows}
</table>
</div>
`;
this.infoPopupElement.innerHTML = html;
this.infoPopup.setPosition(coordinate);
// Close button handler
this.infoPopupElement.querySelector('#info-popup-close').addEventListener('click', () => {
this.hideInfoPopup();
});
}
/**
* Hide the info popup
*/
hideInfoPopup() {
this.infoPopup.setPosition(undefined);
}
// ============================================================================
// Circle Intersection Analysis
// ============================================================================
/**
* Analyse which features from overlay layers intersect a measurement circle
* and show the results in the info popup.
*
* @param {Feature} circleFeature - The measurement circle feature (Circle geometry)
* @param {Array} coordinate - Map coordinate for popup placement [x, y]
*/
showCircleIntersectionPopup(circleFeature, coordinate) {
const circleGeom = circleFeature.getGeometry();
if (!circleGeom || typeof circleGeom.getCenter !== 'function') return;
// Convert the OL Circle to a polygon (64 sides) for intersection testing
const circlePoly = fromCircle(circleGeom, 64);
const circleExtent = circlePoly.getExtent();
const radius = circleFeature.get('_radius') || circleGeom.getRadius();
// Collect intersecting features grouped by layer type
const parcelFeatures = [];
const zoneFeatures = [];
const otherByLayer = {}; // title → count
/**
* Check whether a feature's geometry intersects the circle polygon.
* Uses a fast extent pre-check, then a full geometry test.
*/
const intersectsCircle = (feature) => {
const geom = feature.getGeometry();
if (!geom) return false;
// Fast pre-check: bounding-box overlap
const fExtent = geom.getExtent();
if (
fExtent[2] < circleExtent[0] ||
fExtent[0] > circleExtent[2] ||
fExtent[3] < circleExtent[1] ||
fExtent[1] > circleExtent[3]
) {
return false;
}
// Full geometry test
return circlePoly.intersectsExtent(fExtent) && this._geometriesIntersect(circlePoly, geom);
};
/**
* Walk a LayerGroup recursively and test every VectorLayer's features.
*/
const scanGroup = (group, groupTitle) => {
group.getLayers().forEach((layer) => {
if (layer instanceof LayerGroup) {
scanGroup(layer, layer.get('title') || groupTitle);
} else if (layer instanceof VectorLayer && layer.getVisible()) {
const layerTitle = layer.get('title') || groupTitle || 'Unknown';
const source = layer.getSource();
if (!source) return;
// Use getFeaturesInExtent for speed, then refine
const candidates = source.getFeaturesInExtent(circleExtent);
for (const f of candidates) {
// Skip the circle feature itself and its radius line
const fType = f.get('_layerType');
if (fType === 'measure_circle' || fType === 'measure_circle_radius') continue;
if (!intersectsCircle(f)) continue;
if (fType === 'parcel') {
parcelFeatures.push(f);
} else if (fType === 'collector_zone') {
zoneFeatures.push(f);
} else {
if (!otherByLayer[layerTitle]) otherByLayer[layerTitle] = [];
otherByLayer[layerTitle].push(f);
}
}
}
});
};
// Scan the overlays group
scanGroup(this.overlayGroup, 'Overlays');
// ---- Build popup HTML ----
const radiusFormatted = radius >= 1000
? `${(radius / 1000).toFixed(2)} km`
: `${Math.round(radius)} m`;
const areaSqm = Math.PI * radius * radius;
const areaFormatted = areaSqm > 1_000_000
? `${(areaSqm / 1_000_000).toFixed(2)} km²`
: `${areaSqm.toLocaleString('en', { maximumFractionDigits: 0 })} m²`;
let rows = `
<tr>
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">radius</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${radiusFormatted}</td>
</tr>
<tr>
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">area</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${areaFormatted}</td>
</tr>
`;
// Parcels summary
if (parcelFeatures.length > 0) {
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td style="padding:4px 8px;font-weight:600;color:#0ea5e9;white-space:nowrap;">parcels</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${parcelFeatures.length}</td>
</tr>
`;
}
// Zones summary
if (zoneFeatures.length > 0) {
const zoneNames = zoneFeatures.map(f => {
return f.get('colzonename') || f.get('zone_name') || f.get('name') || 'unnamed';
});
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td style="padding:4px 8px;font-weight:600;color:#7c3aed;white-space:nowrap;">zones</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${zoneFeatures.length}</td>
</tr>
<tr>
<td style="padding:4px 8px;font-weight:600;color:#7c3aed;white-space:nowrap;">zone names</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${zoneNames.map(n => this.escapeHtml(n)).join(', ')}</td>
</tr>
`;
}
// Other layers
for (const [title, features] of Object.entries(otherByLayer)) {
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">${this.escapeHtml(title)}</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${features.length} feature(s)</td>
</tr>
`;
}
// Nothing found
if (parcelFeatures.length === 0 && zoneFeatures.length === 0 && Object.keys(otherByLayer).length === 0) {
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td colspan="2" style="padding:8px;color:#999;text-align:center;font-style:italic;">No intersecting features found</td>
</tr>
`;
}
const html = `
<div style="background:var(--brand-navy, #1e1a4b);color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;">
<span>⭕ Circle Analysis</span>
<button id="info-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<div style="padding:8px 4px;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
${rows}
</table>
</div>
`;
this.infoPopupElement.innerHTML = html;
this.infoPopup.setPosition(coordinate);
this.infoPopupElement.querySelector('#info-popup-close').addEventListener('click', () => {
this.hideInfoPopup();
});
}
/**
* Test whether two geometries truly intersect (beyond just extent overlap).
* Works for Polygon/MultiPolygon against any geometry type.
*
* @param {Geometry} geomA - First geometry (usually the circle polygon)
* @param {Geometry} geomB - Second geometry
* @returns {boolean}
* @private
*/
_geometriesIntersect(geomA, geomB) {
const typeB = geomB.getType();
// For polygons / multi-polygons: check if any coordinate of B is inside A,
// or if any coordinate of A is inside B (covers overlap & containment).
if (typeB === 'Polygon' || typeB === 'MultiPolygon') {
// Check if any vertex of B lies inside A (use flatCoordinates for efficiency)
const flatB = geomB.getFlatCoordinates();
const stride = geomB.getStride();
for (let i = 0; i < flatB.length; i += stride) {
if (geomA.intersectsCoordinate([flatB[i], flatB[i + 1]])) return true;
}
// Check if any vertex of A lies inside B
const flatA = geomA.getFlatCoordinates();
const strideA = geomA.getStride();
for (let i = 0; i < flatA.length; i += strideA) {
if (geomB.intersectsCoordinate([flatA[i], flatA[i + 1]])) return true;
}
return false;
}
if (typeB === 'Point') {
return geomA.intersectsCoordinate(geomB.getCoordinates());
}
if (typeB === 'LineString' || typeB === 'MultiLineString') {
const flatB = geomB.getFlatCoordinates();
const stride = geomB.getStride();
for (let i = 0; i < flatB.length; i += stride) {
if (geomA.intersectsCoordinate([flatB[i], flatB[i + 1]])) return true;
}
return false;
}
// Fallback: extent overlap is good enough
return true;
}
// ============================================================================
// Parcel Edit Popup (single-click editable form)
// ============================================================================
/**
* Create the parcel edit popup overlay with a dynamic form.
*/
createParcelEditPopup() {
this.parcelEditElement = document.createElement('div');
this.parcelEditElement.className = 'map-parcel-edit-popup';
this.parcelEditElement.style.cssText = `
position: absolute;
background: white;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 13px;
min-width: 280px;
max-width: 360px;
max-height: 420px;
z-index: 1002;
border: 2px solid var(--primary, #005eb8);
overflow: hidden;
display: flex;
flex-direction: column;
`;
this.parcelEditPopup = new Overlay({
element: this.parcelEditElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: true,
autoPan: true,
autoPanAnimation: { duration: 250 },
});
this.map.addOverlay(this.parcelEditPopup);
// Callbacks for save events
this._parcelEditCallbacks = [];
// Track the current feature being edited
this._parcelEditFeature = null;
}
/**
* Show the parcel edit popup with an editable form for all feature attributes.
* Internal keys (_layerType, geometry) are excluded from the form.
*
* @param {Feature} feature - The OL feature to edit
* @param {Array} coordinate - Map coordinate [x, y]
*/
showParcelEditPopup(feature, coordinate) {
this._parcelEditFeature = feature;
const properties = feature.getProperties();
// Keys to skip in the form
const skipKeys = ['geometry', '_layerType'];
// Build form fields from feature properties
let fieldsHtml = '';
for (const [key, value] of Object.entries(properties)) {
if (skipKeys.includes(key)) continue;
const displayVal = (value === null || value === undefined) ? '' : String(value);
const escapedKey = this.escapeHtml(key);
const escapedVal = this.escapeHtml(displayVal);
fieldsHtml += `
<div style="margin-bottom:8px;">
<label style="display:block;font-size:11px;font-weight:600;color:var(--muted-foreground, #7a7a7a);margin-bottom:2px;">${escapedKey}</label>
<input type="text" name="${escapedKey}" value="${escapedVal}"
style="width:100%;padding:6px 8px;border:1px solid var(--border, #1e1a4b1f);border-radius:4px;font-size:13px;color:var(--foreground, #1e1a4b);min-height:34px;"
/>
</div>
`;
}
const html = `
<div style="background:var(--primary, #005eb8);color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
<span>✏️ Edit Parcel</span>
<button class="parcel-edit-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<form class="parcel-edit-form" style="padding:10px 12px;overflow-y:auto;flex:1;">
${fieldsHtml}
<div style="display:flex;gap:8px;margin-top:10px;">
<button type="submit" style="flex:1;padding:8px 12px;background:var(--primary, #005eb8);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
💾 Save
</button>
<button type="button" class="parcel-edit-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
Cancel
</button>
</div>
</form>
`;
this.parcelEditElement.innerHTML = html;
this.parcelEditPopup.setPosition(coordinate);
// Close / Cancel handlers
this.parcelEditElement.querySelector('.parcel-edit-close').addEventListener('click', () => {
this.hideParcelEditPopup();
});
this.parcelEditElement.querySelector('.parcel-edit-cancel').addEventListener('click', () => {
this.hideParcelEditPopup();
});
// Form submit handler
const form = this.parcelEditElement.querySelector('.parcel-edit-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
// Collect all edited values
const formData = new FormData(form);
const updatedProps = {};
for (const [key, value] of formData.entries()) {
updatedProps[key] = value;
}
// Restore internal properties that were excluded from the form
updatedProps._layerType = 'parcel';
// Update the feature's properties in-place
for (const [key, value] of Object.entries(updatedProps)) {
this._parcelEditFeature.set(key, value);
}
// Notify external listeners
for (const cb of this._parcelEditCallbacks) {
cb(this._parcelEditFeature, updatedProps);
}
this.hideParcelEditPopup();
});
}
/**
* Hide the parcel edit popup.
*/
hideParcelEditPopup() {
this.parcelEditPopup.setPosition(undefined);
this._parcelEditFeature = null;
}
/**
* Register a callback for when a parcel edit is saved.
* Callback receives (feature, updatedProperties).
*
* @param {Function} callback
*/
onParcelEdit(callback) {
this._parcelEditCallbacks.push(callback);
}
// ============================================================================
// Drawn Polygon Attribute Popup
// ============================================================================
/**
* Create the drawn polygon attribute popup overlay.
* Shown after the area measurement polygon is completed so the user can
* attach parcel-like attributes to the drawn polygon.
*/
createDrawnPolygonPopup() {
this.drawnPolygonElement = document.createElement('div');
this.drawnPolygonElement.className = 'map-drawn-polygon-popup';
this.drawnPolygonElement.style.cssText = `
position: absolute;
background: var(--card, #fff);
border-radius: var(--radius-xl, 0.75rem);
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 13px;
min-width: 280px;
max-width: 360px;
max-height: 420px;
z-index: 1002;
border: 2px solid var(--success, #006b3f);
overflow: hidden;
display: flex;
flex-direction: column;
`;
this.drawnPolygonPopup = new Overlay({
element: this.drawnPolygonElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: true,
autoPan: true,
autoPanAnimation: { duration: 250 },
});
this.map.addOverlay(this.drawnPolygonPopup);
this._drawnPolygonCallbacks = [];
this._drawnPolygonFeature = null;
}
/**
* Get attribute keys from existing parcel features on the map.
* Scans the overlay group for the first feature with _layerType='parcel'
* and returns its property key names (excluding internal keys).
*
* @returns {string[]} Array of attribute key names
*/
getParcelAttributeKeys() {
const skipKeys = ['geometry', '_layerType'];
const keys = [];
const scanGroup = (group) => {
if (keys.length > 0) return;
group.getLayers().forEach((layer) => {
if (keys.length > 0) return;
if (layer instanceof LayerGroup) {
scanGroup(layer);
} else if (layer instanceof VectorLayer) {
const source = layer.getSource();
if (!source) return;
for (const f of source.getFeatures()) {
if (f.get('_layerType') !== 'parcel') continue;
const props = f.getProperties();
for (const key of Object.keys(props)) {
if (!skipKeys.includes(key)) keys.push(key);
}
return; // one parcel is enough for the schema
}
}
});
};
scanGroup(this.overlayGroup);
return keys;
}
/**
* Show the drawn polygon attribute popup.
* Discovers attribute keys from existing parcel features and creates
* a blank form with those fields.
*
* @param {Feature} feature - The drawn polygon feature
* @param {Array} coordinate - Map coordinate [x, y] for popup placement
*/
showDrawnPolygonPopup(feature, coordinate) {
this._drawnPolygonFeature = feature;
// Discover attribute keys from existing parcels
const attributeKeys = this.getParcelAttributeKeys();
if (attributeKeys.length === 0) {
console.warn('[MapView] No parcel attributes found — cannot build form');
return;
}
// Build form fields (all blank)
let fieldsHtml = '';
for (const key of attributeKeys) {
const escapedKey = this.escapeHtml(key);
fieldsHtml += `
<div style="margin-bottom:8px;">
<label style="display:block;font-size:11px;font-weight:600;color:var(--muted-foreground, #7a7a7a);margin-bottom:2px;">${escapedKey}</label>
<input type="text" name="${escapedKey}" value=""
style="width:100%;padding:6px 8px;border:1px solid var(--border, #1e1a4b1f);border-radius:4px;font-size:13px;color:var(--foreground, #1e1a4b);min-height:34px;"
/>
</div>
`;
}
// Area display
const geom = feature.getGeometry();
const areaSqm = getArea(geom, { projection: 'EPSG:3857' });
const areaFormatted = areaSqm > 1_000_000
? `${(areaSqm / 1_000_000).toFixed(2)} km\u00B2`
: `${areaSqm.toLocaleString('en', { maximumFractionDigits: 0 })} m\u00B2`;
const html = `
<div style="background:var(--success, #006b3f);color:var(--success-foreground, #fff);padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
<span>📐 Polygon Attributes</span>
<button class="drawn-polygon-close" style="background:none;border:none;color:var(--success-foreground, #fff);font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<div style="padding:8px 12px;background:var(--muted, #f2f4f7);border-bottom:1px solid var(--border, #1e1a4b1f);font-size:12px;color:var(--muted-foreground, #7a7a7a);flex-shrink:0;">
Area: <strong>${areaFormatted}</strong>
</div>
<form class="drawn-polygon-form" style="padding:10px 12px;overflow-y:auto;flex:1;">
${fieldsHtml}
<div style="display:flex;gap:8px;margin-top:10px;">
<button type="submit" style="flex:1;padding:8px 12px;background:var(--success, #006b3f);color:var(--success-foreground, #fff);border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
💾 Save
</button>
<button type="button" class="drawn-polygon-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
Cancel
</button>
</div>
</form>
`;
this.drawnPolygonElement.innerHTML = html;
this.drawnPolygonPopup.setPosition(coordinate);
// Close / Cancel handlers
this.drawnPolygonElement.querySelector('.drawn-polygon-close').addEventListener('click', () => {
this.hideDrawnPolygonPopup();
});
this.drawnPolygonElement.querySelector('.drawn-polygon-cancel').addEventListener('click', () => {
this.hideDrawnPolygonPopup();
});
// Form submit handler
const form = this.drawnPolygonElement.querySelector('.drawn-polygon-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(form);
const props = {};
for (const [key, value] of formData.entries()) {
props[key] = value;
}
// Set properties on the feature
for (const [key, value] of Object.entries(props)) {
this._drawnPolygonFeature.set(key, value);
}
// Tag as parcel so it integrates with existing parcel tools
this._drawnPolygonFeature.set('_layerType', 'parcel');
// Notify listeners
for (const cb of this._drawnPolygonCallbacks) {
cb(this._drawnPolygonFeature, props);
}
this.hideDrawnPolygonPopup();
});
}
/**
* Hide the drawn polygon attribute popup.
*/
hideDrawnPolygonPopup() {
this.drawnPolygonPopup.setPosition(undefined);
this._drawnPolygonFeature = null;
}
/**
* Register a callback for when drawn polygon attributes are saved.
* Callback receives (feature, properties).
*
* @param {Function} callback
*/
onDrawnPolygonSave(callback) {
this._drawnPolygonCallbacks.push(callback);
}
/**
* Register a double-click callback.
* Callback receives (lon, lat, feature, event).
* Feature is the first feature found at the click pixel across all overlay layers,
* or null if no feature was hit.
* When a feature is hit, the default double-click-zoom is suppressed.
*/
onDblClick(callback) {
this.dblClickCallbacks.push(callback);
// Set up the listener once
if (this.dblClickCallbacks.length === 1) {
this.map.on('dblclick', (evt) => {
const [lon, lat] = toLonLat(evt.coordinate);
// Find any feature at the clicked pixel (overlay layers, not just markers)
let clickedFeature = null;
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
clickedFeature = feature;
return true; // stop at first hit
});
// If a feature was hit, prevent the default double-click zoom
if (clickedFeature) {
evt.preventDefault();
evt.stopPropagation();
}
// Call all registered callbacks
for (const cb of this.dblClickCallbacks) {
cb(lon, lat, clickedFeature, evt);
}
// Return false to suppress DoubleClickZoom interaction when on a feature
if (clickedFeature) return false;
});
}
return () => {
const idx = this.dblClickCallbacks.indexOf(callback);
if (idx > -1) this.dblClickCallbacks.splice(idx, 1);
};
}
/**
* 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">
${this.getCategoryOptionsHtml()}
</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.getEmoji(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, label, fontSize }
*/
setCategoryStyles(styles) {
for (const [category, config] of Object.entries(styles)) {
// Update category mapping if provided
if (config.emoji) {
if (!this.categoryEmojis[category]) {
this.categoryEmojis[category] = { emoji: config.emoji, label: config.label || category };
} else {
this.categoryEmojis[category].emoji = config.emoji;
if (config.label) {
this.categoryEmojis[category].label = config.label;
}
}
}
// Create/update style
const emoji = this.getEmoji(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)
*
* Single-click is delayed by 300 ms so that a double-click can cancel it.
* If the click lands on an overlay feature (e.g. district boundary) the
* single-click is suppressed entirely — only double-click will fire.
*/
onClick(callback) {
this.clickCallbacks.push(callback);
// Set up click handler if this is the first callback
if (this.clickCallbacks.length === 1) {
this._clickTimer = null;
// Double-click cancels any pending single-click
this.map.on('dblclick', () => {
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = null;
}
});
this.map.on('click', (evt) => {
// Cancel any previous pending click
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = null;
}
// When NOT in edit / draw mode, immediately clear any feature
// the Select interaction may have grabbed on this click so the
// user never sees a selection flash.
if (!this._editBarActive && this._selectInteraction) {
this._selectInteraction.getFeatures().clear();
}
// Check what features sit under the click pixel
let hasOverlayFeature = false;
let hasParcelFeature = false;
let markerFeature = null;
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (feature.get('_layerType') === 'parcel') {
hasParcelFeature = true;
}
if (feature.get('name')) {
markerFeature = feature;
}
hasOverlayFeature = true;
});
// If an overlay feature was hit, suppress single-click
// UNLESS it's a parcel or a location marker
if (hasOverlayFeature && !hasParcelFeature && !markerFeature) {
return;
}
// Delay the single-click to allow double-click to cancel it
const [lon, lat] = toLonLat(evt.coordinate);
this._clickTimer = setTimeout(() => {
this._clickTimer = null;
// Find location marker at pixel
let clickedFeature = null;
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (feature.get('name')) {
clickedFeature = feature;
return true;
}
});
for (const cb of this.clickCallbacks) {
cb(lon, lat, clickedFeature, evt);
}
}, 300);
});
}
// 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);
// Only find location markers (features with 'name' property)
let hoveredFeature = null;
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (feature.get('name')) {
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
}
/**
* Add a GeoJSON layer (visible in LayerSwitcher).
* By default the layer is added to the root overlay group.
* Pass a targetGroup (LayerGroup) to nest it inside a specific group.
*
* @param {Object} geojson - GeoJSON FeatureCollection or Feature
* @param {string} title - Layer title for the LayerSwitcher
* @param {Object} [styleOptions] - Optional style configuration
* @param {string} [styleOptions.strokeColor='#3b82f6'] - Stroke color
* @param {number} [styleOptions.strokeWidth=2] - Stroke width
* @param {string} [styleOptions.fillColor='rgba(59,130,246,0.1)'] - Fill color
* @param {LayerGroup} [targetGroup] - Optional group to add the layer to
* @returns {VectorLayer} The created layer
*/
addGeoJSONLayer(geojson, title, styleOptions = {}, targetGroup = null) {
const {
strokeColor = '#3b82f6',
strokeWidth = 2,
fillColor = 'rgba(59,130,246,0.1)',
} = styleOptions;
const source = new VectorSource({
features: new GeoJSON().readFeatures(geojson, {
featureProjection: 'EPSG:3857',
}),
});
const layer = new VectorLayer({
title: title,
source: source,
style: new Style({
stroke: new Stroke({ color: strokeColor, width: strokeWidth }),
fill: new Fill({ color: fillColor }),
}),
});
const group = targetGroup || this.overlayGroup;
group.getLayers().push(layer);
console.log('[MapView] GeoJSON layer added:', title, '→', source.getFeatures().length, 'features',
targetGroup ? `(in group "${targetGroup.get('title')}")` : '');
return layer;
}
/**
* Add a LayerGroup to the overlay group.
* Used to create layer categories from the remote catalogue;
* individual vector layers will be added into these groups later.
*
* @param {number|string} id - Unique layer group id (from the API)
* @param {string} title - Group title for the LayerSwitcher
* @param {string} [description=''] - Group description (stored as property)
* @returns {LayerGroup} The created (empty) layer group
*/
addLayerGroup(id, title, description = '') {
const group = new LayerGroup({
title: title.trim(),
});
// Store metadata for later use
group.set('layerId', id);
group.set('description', description);
this.overlayGroup.getLayers().push(group);
console.log('[MapView] Layer group added:', title.trim(), '(id:', id + ')');
return group;
}
/**
* Find a LayerGroup inside the overlay group by its layerId.
*
* @param {number|string} id - The layerId to search for
* @returns {LayerGroup|null} The matching group, or null
*/
getLayerGroup(id) {
let found = null;
this.overlayGroup.getLayers().forEach((layer) => {
if (layer.get('layerId') === id) {
found = layer;
}
});
return found;
}
/**
* Find a LayerGroup inside the overlay group by its title.
*
* @param {string} title - The group title to search for
* @returns {LayerGroup|null} The matching group, or null
*/
getLayerGroupByTitle(title) {
let found = null;
this.overlayGroup.getLayers().forEach((layer) => {
if (layer.get('title') === title) {
found = layer;
}
});
return found;
}
/**
* Get the overlay LayerGroup for advanced usage
*/
getOverlayGroup() {
return this.overlayGroup;
}
/**
* 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();
}
/**
* Register a callback for when a search result is selected
* Callback receives: { coordinate, lonLat: [lon, lat], name, searchResult }
* Navigation to the location happens automatically
*/
onSearchSelect(callback) {
this.searchSelectCallbacks.push(callback);
}
/**
* Navigate/fly to a specific location
* @param {number} lon - Longitude
* @param {number} lat - Latitude
* @param {number} zoom - Zoom level (default: 14)
* @param {number} duration - Animation duration in ms (default: 500)
*/
navigateTo(lon, lat, zoom = 14, duration = 500) {
const coordinate = fromLonLat([lon, lat]);
this.map.getView().animate({
center: coordinate,
zoom: zoom,
duration: duration,
});
}
}
// Export OpenLayers utilities for convenience
export { fromLonLat, toLonLat };
export default MapView;