2168 lines
69 KiB
JavaScript
2168 lines
69 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, 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;">×</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;">×</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;">×</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;">×</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">×</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;
|