/** * 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 ImageLayer from 'ol/layer/Image'; import LayerGroup from 'ol/layer/Group'; import VectorLayer from 'ol/layer/Vector'; import VectorImageLayer from 'ol/layer/VectorImage'; import VectorSource from 'ol/source/Vector'; import ImageWMS from 'ol/source/ImageWMS'; import TileWMS from 'ol/source/TileWMS'; 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'; import { formatLength, formatLengthFull, formatArea, formatAreaFull } from '../units.js'; // 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-ext SnapGuides — snaps drawing vertices to alignment guides import SnapGuides from 'ol-ext/interaction/SnapGuides'; // ol Select interaction (for custom multi-layer Select) import Select from 'ol/interaction/Select'; import { click as clickCondition } from 'ol/events/condition'; // ol-ext Split interaction (for line splitting) and Toggle control import Split from 'ol-ext/interaction/Split'; import Toggle from 'ol-ext/control/Toggle'; import TextButton from 'ol-ext/control/TextButton'; // Custom polygon split interaction import { PolygonSplitInteraction } from '../interactions/PolygonSplitInteraction.js'; // Custom polygon merge interaction import { PolygonMergeInteraction } from '../interactions/PolygonMergeInteraction.js'; // Custom polygon divide interaction import { PolygonDivideInteraction } from '../interactions/PolygonDivideInteraction.js'; // Toast notifications import { showToast } from '../toast.js'; // 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 }]) => `` ) .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 || 'topo'); // 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: true, trash: false, oninfo: null, }); this.map.addControl(layerSwitcher); // Inject "Add Layer" button into the "External Source" group header layerSwitcher.on('drawlist', (evt) => { const groupTitle = (evt.layer.get('title') || '').toLowerCase(); if (groupTitle.includes('external')) { // Store reference to the actual External group for later use this._externalSourceGroup = evt.layer; const btnBar = evt.li.querySelector('.ol-layerswitcher-buttons'); if (btnBar && !btnBar.querySelector('.ol-add-layer')) { const addBtn = document.createElement('span'); addBtn.className = 'ol-add-layer'; addBtn.title = 'Add external layer'; addBtn.textContent = '+'; addBtn.style.cssText = ` display:inline-flex !important;align-items:center;justify-content:center; width:20px !important;height:20px !important;border-radius:50%; background:#10b981 !important;color:#fff !important; font-size:16px !important;font-weight:700; cursor:pointer;line-height:1 !important; margin:2px 4px 2px 2px;vertical-align:middle; transition:background 0.2s;box-sizing:border-box; `; addBtn.addEventListener('mouseenter', () => { addBtn.style.background = '#059669'; }); addBtn.addEventListener('mouseleave', () => { addBtn.style.background = '#10b981'; }); addBtn.addEventListener('click', (e) => { e.stopPropagation(); this.showAddLayerDialog(); }); btnBar.prepend(addBtn); } } }); // Create the add-layer dialog (hidden by default) this._createAddLayerDialog(); // Create the legend panel (shows legends for visible layers that have one) this._createLegendPanel(); // Add ScaleBar control this.scaleBar = new ScaleLine({ bar: true, steps: 4, text: true, minWidth: 140, }); this.map.addControl(this.scaleBar); // 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(); // Create merge identifier (UPN) chooser popup this.createMergePopup(); // Create divide polygon popup (number input) this.createDividePopup(); // 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: false, }, }); this.map.addControl(this.editBar); // 5b. Persistent vertex overlay — when edit mode is active and the user // selects a polygon (or line) for modification, render a small dot // at every vertex so the user can see all editable nodes at a glance. // ol-ext's ModifyFeature only renders the closest vertex on hover; this // overlay complements that without subclassing the interaction. this._setupVertexOverlay(); // 6. Add extra buttons (Undo, Redo, Save) as a sub-bar // inside the EditBar so they appear inline. const extraBar = new Bar({ group: true, controls: [ new Button({ html: '', className: 'ol-undo', title: 'Undo', handleClick: () => { if (this._undoRedo.hasUndo()) this._undoRedo.undo(); }, }), new Button({ html: '', className: 'ol-redo', title: 'Redo', handleClick: () => { if (this._undoRedo.hasRedo()) this._undoRedo.redo(); }, }), new Button({ html: '', className: 'ol-save', title: 'Save drawings', handleClick: () => { this.dispatchEditEvent('save'); }, }), ], }); this.editBar.addControl(extraBar); // 6a-split. Custom Split tool with Lines / Polygons sub-categories. // The default ol-ext Split only handles LineString. We add a parent // Toggle with a sub-bar containing two sub-toggles: "Lines" (ol-ext // Split) and "Polygons" (our PolygonSplitInteraction). // No explicit sources → both interactions search ALL visible vector layers, // so they work on drawn features, parcels, zones, and any other polygon layer. this._lineSplitInteraction = new Split(); this._polygonSplitInteraction = new PolygonSplitInteraction(); this.map.addInteraction(this._lineSplitInteraction); this.map.addInteraction(this._polygonSplitInteraction); this._lineSplitInteraction.setActive(false); this._polygonSplitInteraction.setActive(false); // When a parcel is split, the user picks which piece keeps the UPN. this._polygonSplitInteraction.on('splitpick', (evt) => { const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID']; for (const feat of evt.features) { if (feat === evt.picked) continue; for (const field of idFields) { if (feat.get(field) !== undefined) { feat.set(field, ''); } } } }); // Polygon Divide interaction (parameter-driven equal-area division) this._polygonDivideInteraction = new PolygonDivideInteraction(); this.map.addInteraction(this._polygonDivideInteraction); this._polygonDivideInteraction.setActive(false); const splitLineToggle = new Toggle({ html: '', className: 'ol-split-line', title: 'Split Lines', name: 'SplitLine', interaction: this._lineSplitInteraction, autoActivate: true, }); const splitPolyToggle = new Toggle({ html: '', className: 'ol-split-polygon', title: 'Split Polygons', name: 'SplitPolygon', interaction: this._polygonSplitInteraction, }); const splitDivideToggle = new Toggle({ html: '', className: 'ol-split-divide', title: 'Divide Polygon', name: 'DividePolygon', interaction: this._polygonDivideInteraction, }); const splitSubBar = new Bar({ toggleOne: true, autoDeactivate: true, controls: [splitLineToggle, splitPolyToggle, splitDivideToggle], }); const splitParentToggle = new Toggle({ className: 'ol-split', title: 'Split', name: 'Split', bar: splitSubBar, onToggle: (active) => { if (!active) { this._lineSplitInteraction.setActive(false); this._polygonSplitInteraction.setActive(false); this._polygonDivideInteraction.setActive(false); } }, }); this.editBar.addControl(splitParentToggle); // Listen for divide form request → show divide popup this._polygonDivideInteraction.on('divideform', (evt) => { this.showDividePopup(evt.feature, evt.source, evt.coordinate); }); this._polygonDivideInteraction.on('dividecancel', () => { this.hideDividePopup(); }); // When a parcel is divided, the user picks which piece keeps the UPN. // The picked piece gets the original properties; all others get UPN cleared. this._polygonDivideInteraction.on('dividepick', (evt) => { const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID']; for (const feat of evt.features) { if (feat === evt.picked) continue; // Clear identifier fields on the non-picked pieces for (const field of idFields) { if (feat.get(field) !== undefined) { feat.set(field, ''); } } } }); // 6a-merge. Polygon Merge tool — select two adjacent polygons, click shared // edges, and merge them into one. For parcels, a UPN chooser popup appears. this._polygonMergeInteraction = new PolygonMergeInteraction(); this.map.addInteraction(this._polygonMergeInteraction); this._polygonMergeInteraction.setActive(false); const mergeToggle = new Toggle({ html: '', className: 'ol-merge', title: 'Merge Polygons', name: 'Merge', interaction: this._polygonMergeInteraction, }); this.editBar.addControl(mergeToggle); // Listen for merged-parcel event → show UPN chooser this._polygonMergeInteraction.on('mergedparcel', (evt) => { this.showMergeIdentifierPopup(evt.merged, evt.propsA, evt.propsB, evt.coordinate); }); // 6b. SnapGuides — shows alignment guides while drawing. // Uses VectorImageLayer for GPU-friendly canvas rendering instead of // re-creating individual SVG elements on every guide update. this._snapGuidesEnabled = localStorage.getItem('snap-guides-enabled') === '1'; this._snapGuides = new SnapGuides({ pixelTolerance: 10, vectorClass: VectorImageLayer, }); this.map.addInteraction(this._snapGuides); // Connect SnapGuides to whichever draw interaction becomes active. // setDrawInteraction() only tracks one at a time, so we re-bind // whenever a draw tool is activated. const drawToolNames = ['DrawPoint', 'DrawLine', 'DrawPolygon', 'DrawHole', 'DrawRegular']; for (const name of drawToolNames) { const interaction = this.editBar.getInteraction(name); if (interaction) { interaction.on('change:active', () => { if (interaction.getActive()) { this._snapGuides.setDrawInteraction(interaction); } }); } } // Also connect SnapGuides to the Modify interaction for vertex editing if (this._modifyInteraction) { this._snapGuides.setModifyInteraction(this._modifyInteraction); } // 6c. Snap-guides toggle button (magnet icon) — persisted in localStorage const snapToggleBtn = new Button({ html: '', className: 'ol-snap-toggle' + (this._snapGuidesEnabled ? ' ol-active' : ''), title: 'Toggle Snap Guides', handleClick: () => { this._snapGuidesEnabled = !this._snapGuidesEnabled; localStorage.setItem('snap-guides-enabled', this._snapGuidesEnabled ? '1' : '0'); // Update visual state snapToggleBtn.element.classList.toggle('ol-active', this._snapGuidesEnabled); // Activate or deactivate the interaction if (this._snapGuides) { this._snapGuides.setActive(this._snapGuidesEnabled && this._editBarActive); } console.log('[MapView] Snap guides:', this._snapGuidesEnabled ? 'ON' : 'OFF'); }, }); this._snapToggleBtn = snapToggleBtn; extraBar.addControl(snapToggleBtn); // 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 and SnapGuides (default:', this._snapGuidesEnabled ? 'ON' : 'OFF', ')'); } /** * 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 SnapGuides — only active when both edit mode AND the user toggle are on if (this._snapGuides) { this._snapGuides.setActive(this._snapGuidesEnabled && this._editBarActive); } // Toggle TouchCursor if (this.touchCursor) { this.touchCursor.setActive(this._editBarActive); } // Clear persistent vertex highlights when leaving edit mode if (!this._editBarActive && this._vertexOverlaySource) { this._vertexOverlaySource.clear(); } console.log('[MapView] Edit mode:', this._editBarActive ? 'ON' : 'OFF'); } /** * Check whether edit mode (select / modify) is currently active. * @returns {boolean} */ isEditMode() { return this._editBarActive; } // ============================================================================ // Persistent Vertex Highlight Overlay // ============================================================================ /** * Create a vector layer that renders a small dot at every vertex of any * currently-selected feature (polygon, multipolygon, line, multiline). * Only active while edit mode is on. * * Hooks: * - `select` event from the Select interaction → rebuild dots for the new selection * - `change` event on the selected feature → reposition dots when a vertex is dragged */ _setupVertexOverlay() { this._vertexOverlaySource = new VectorSource(); this._vertexOverlayLayer = new VectorLayer({ title: '__vertex_highlight__', source: this._vertexOverlaySource, // Render above all other overlays but below ModifyFeature's hover indicator zIndex: 990, style: new Style({ image: new Circle({ radius: 4, fill: new Fill({ color: 'rgba(14,165,233,0.85)' }), // brand blue stroke: new Stroke({ color: '#fff', width: 1.2 }), }), }), }); // Hide from LayerSwitcher — purely visual, not user-toggleable this._vertexOverlayLayer.set('displayInLayerSwitcher', false); this.map.addLayer(this._vertexOverlayLayer); // Bound handler so we can attach/detach by reference this._onSelectedFeatureGeomChange = () => this._refreshVertexOverlay(); // Track which feature(s) we're listening on, so we can unhook cleanly this._vertexTrackedFeatures = new Set(); // When the selection changes, swap which features we listen to and rebuild dots this._selectInteraction.on('select', () => this._refreshVertexOverlay()); } /** * Rebuild the vertex overlay from the current Select interaction's features. * No-ops when not in edit mode. */ _refreshVertexOverlay() { if (!this._vertexOverlaySource) return; this._vertexOverlaySource.clear(); // Detach change listeners from previously-tracked features if (this._vertexTrackedFeatures) { for (const f of this._vertexTrackedFeatures) { f.un('change', this._onSelectedFeatureGeomChange); } this._vertexTrackedFeatures.clear(); } if (!this._editBarActive || !this._selectInteraction) return; const selected = this._selectInteraction.getFeatures().getArray(); for (const feat of selected) { const geom = feat.getGeometry(); if (!geom) continue; const type = geom.getType(); if (!['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'].includes(type)) { continue; } const coords = this._collectAllVertices(geom); for (const c of coords) { this._vertexOverlaySource.addFeature(new Feature(new Point(c))); } // Listen for vertex moves on this feature feat.on('change', this._onSelectedFeatureGeomChange); this._vertexTrackedFeatures.add(feat); } } /** * Walk a (Multi)Polygon or (Multi)LineString geometry and return the flat * list of vertex coordinates. Polygon rings have a duplicate closing vertex * (last == first) which is dropped here so we don't render two dots on top * of each other. * * @param {Geometry} geom * @returns {Array>} */ _collectAllVertices(geom) { const out = []; const isCoord = (v) => Array.isArray(v) && typeof v[0] === 'number'; const visitRing = (ring, isPolygonRing) => { const len = isPolygonRing && ring.length > 1 ? ring.length - 1 : ring.length; for (let i = 0; i < len; i++) out.push(ring[i]); }; const type = geom.getType(); const coords = geom.getCoordinates(); switch (type) { case 'Polygon': // coords = [outerRing, hole1, hole2, …] for (const ring of coords) visitRing(ring, true); break; case 'MultiPolygon': // coords = [poly1, poly2, …]; each poly = [outerRing, hole1, …] for (const poly of coords) for (const ring of poly) visitRing(ring, true); break; case 'LineString': visitRing(coords, false); break; case 'MultiLineString': for (const line of coords) visitRing(line, false); break; default: // Fallback: deep walk to find arrays of [x, y] const walk = (v) => { if (isCoord(v)) out.push(v); else if (Array.isArray(v)) for (const sub of v) walk(sub); }; walk(coords); } return out; } /** * 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; } /** * Update the ScaleBar units ('metric' or 'imperial'). * @param {'metric'|'imperial'} system */ setScaleBarUnits(system) { if (this.scaleBar) { this.scaleBar.setUnits(system === 'imperial' ? 'imperial' : 'metric'); } } /** * 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: var(--card, #fff); color: var(--card-foreground, #1e1a4b); 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 = `
${emoji} ${this.escapeHtml(name)}
`; // Category badge const categoryColors = { 'water': '#3b82f6', 'school': '#f59e0b', 'health': '#ef4444', 'market': '#8b5cf6', 'default': '#2d5016', 'other': '#6b7280' }; const catColor = categoryColors[category] || '#6b7280'; html += `
${category}
`; // Description if available if (description) { html += `
${this.escapeHtml(description)}
`; } // Coordinates if (lon !== undefined && lat !== undefined) { html += `
${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)}
`; } this.popupElement.innerHTML = html; this.popup.setPosition(coordinate); } /** * Hide the popup */ hidePopup() { this.popup.setPosition(undefined); } /** * 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: var(--card, #fff); color: var(--card-foreground, #1e1a4b); 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; max-height: 70vh; display: flex; flex-direction: column; 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 += ` ${this.escapeHtml(key)} ${this.escapeHtml(String(value))} `; } // Add measurement row based on geometry type if (geomType === 'Polygon' || geomType === 'MultiPolygon') { // Area for polygons const areaSqm = getArea(geometry, { projection: 'EPSG:3857' }); const areaFormatted = formatAreaFull(areaSqm); rows += ` area ${areaFormatted} `; } else if (geomType === 'LineString' || geomType === 'MultiLineString') { // Length for lines const lengthM = getLength(geometry, { projection: 'EPSG:3857' }); const lengthFormatted = formatLengthFull(lengthM); rows += ` length ${lengthFormatted} `; } 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 += ` longitude ${lon} latitude ${lat} `; } const html = `
${this.escapeHtml(title)}
${rows}
`; 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] */ /** * Collect intersection results (parcels, zones, other) into a * structured { label, value } array for both HTML and PDF rendering. */ _collectIntersectionRows(parcelFeatures, zoneFeatures, otherByLayer) { const dataRows = []; if (parcelFeatures.length > 0) { dataRows.push({ label: 'Parcels', value: String(parcelFeatures.length), color: '#0ea5e9' }); } if (zoneFeatures.length > 0) { const names = zoneFeatures.map(f => f.get('colzonename') || f.get('zone_name') || f.get('name') || 'unnamed' ); dataRows.push({ label: 'Zones', value: String(zoneFeatures.length), color: '#7c3aed' }); dataRows.push({ label: 'Zone Names', value: names.map(n => this.escapeHtml(n)).join(', '), color: '#7c3aed' }); } for (const [title, features] of Object.entries(otherByLayer)) { dataRows.push({ label: this.escapeHtml(title), value: `${features.length} feature(s)` }); } if (dataRows.length === 0) { dataRows.push({ label: '', value: 'No intersecting features found', empty: true }); } return dataRows; } /** * Build the full popup HTML for an analysis popup (circle or area). * * @param {string} emoji - Header emoji * @param {string} title - e.g. "Circle Analysis" * @param {Array<{label:string, value:string, color?:string, empty?:boolean}>} dataRows * @returns {string} HTML */ _buildAnalysisPopupHtml(emoji, title, dataRows) { let tableRows = ''; for (const row of dataRows) { if (row.empty) { tableRows += ` ${row.value} `; continue; } const labelColor = row.color || 'var(--muted-foreground, #7a7a7a)'; const border = row._first ? '' : 'border-top:1px solid var(--border, #1e1a4b1f);'; tableRows += ` ${row.label} ${row.value} `; } return `
${emoji} ${title}
${tableRows}
`; } /** * Show the analysis popup, attach close + PDF export handlers. */ _showAnalysisPopup(emoji, title, dataRows, coordinate) { this.infoPopupElement.innerHTML = this._buildAnalysisPopupHtml(emoji, title, dataRows); this.infoPopup.setPosition(coordinate); this.infoPopupElement.querySelector('#info-popup-close').addEventListener('click', () => { this.hideInfoPopup(); }); // PDF export — dynamic import so jspdf is only loaded on demand this.infoPopupElement.querySelector('#info-popup-export-pdf')?.addEventListener('click', () => { // Strip HTML from values and remove the color/empty keys for the PDF const pdfRows = dataRows .filter(r => !r.empty) .map(r => ({ label: r.label, value: r.value.replace(/<[^>]*>/g, '') })); import('../pdf-export.js').then(({ exportAnalysisPDF }) => { exportAnalysisPDF({ title, rows: pdfRows }); }).catch(err => { console.error('[MapView] PDF export failed:', err); }); }); } 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 = {}; const intersectsCircle = (feature) => { const geom = feature.getGeometry(); if (!geom) return false; const fExtent = geom.getExtent(); if ( fExtent[2] < circleExtent[0] || fExtent[0] > circleExtent[2] || fExtent[3] < circleExtent[1] || fExtent[1] > circleExtent[3] ) { return false; } return circlePoly.intersectsExtent(fExtent) && this._geometriesIntersect(circlePoly, geom); }; 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; const candidates = source.getFeaturesInExtent(circleExtent); for (const f of candidates) { 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); } } } }); }; scanGroup(this.overlayGroup, 'Overlays'); // Build structured data rows const radiusFormatted = formatLength(radius); const areaSqm = Math.PI * radius * radius; const areaFormatted = formatArea(areaSqm); const dataRows = [ { label: 'Radius', value: radiusFormatted, _first: true }, { label: 'Area', value: areaFormatted }, ...this._collectIntersectionRows(parcelFeatures, zoneFeatures, otherByLayer), ]; this._showAnalysisPopup('⭕', 'Circle Analysis', dataRows, coordinate); } /** * Show an intersection-analysis popup for a measured area polygon. * Same logic as showCircleIntersectionPopup but works with an * arbitrary Polygon geometry instead of a circle. * * @param {Feature} polygonFeature - The measure_area feature * @param {number[]} coordinate - Map coordinate for the popup anchor */ showAreaIntersectionPopup(polygonFeature, coordinate) { const polyGeom = polygonFeature.getGeometry(); if (!polyGeom) return; const polyExtent = polyGeom.getExtent(); // Compute area via ol/sphere for geodesic accuracy const areaSqm = getArea(polyGeom, { projection: 'EPSG:3857' }); const areaFormatted = formatArea(areaSqm); // Compute perimeter const perimeterM = getLength(polyGeom, { projection: 'EPSG:3857' }); const perimeterFormatted = formatLength(perimeterM); // Collect intersecting features grouped by layer type const parcelFeatures = []; const zoneFeatures = []; const otherByLayer = {}; const intersectsPoly = (feature) => { const geom = feature.getGeometry(); if (!geom) return false; const fExtent = geom.getExtent(); if ( fExtent[2] < polyExtent[0] || fExtent[0] > polyExtent[2] || fExtent[3] < polyExtent[1] || fExtent[1] > polyExtent[3] ) { return false; } return polyGeom.intersectsExtent(fExtent) && this._geometriesIntersect(polyGeom, geom); }; 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; const candidates = source.getFeaturesInExtent(polyExtent); for (const f of candidates) { const fType = f.get('_layerType'); if (fType === 'measure_area' || fType === 'measure_circle' || fType === 'measure_circle_radius') continue; if (!intersectsPoly(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); } } } }); }; scanGroup(this.overlayGroup, 'Overlays'); // Build structured data rows const dataRows = [ { label: 'Area', value: areaFormatted, _first: true }, { label: 'Perimeter', value: perimeterFormatted }, ...this._collectIntersectionRows(parcelFeatures, zoneFeatures, otherByLayer), ]; this._showAnalysisPopup('📐', 'Area Analysis', dataRows, coordinate); } /** * 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: var(--card, #fff); color: var(--card-foreground, #1e1a4b); 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 += `
`; } const html = `
✏️ Edit Parcel
${fieldsHtml}
`; 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); } // ============================================================================ // Merge Identifier (UPN) Chooser Popup // ============================================================================ /** * Create the merge identifier popup overlay. * Shown after two parcels are merged so the user can choose which UPN to keep. */ createMergePopup() { this.mergePopupElement = document.createElement('div'); this.mergePopupElement.className = 'map-merge-popup'; this.mergePopupElement.style.cssText = ` position: absolute; background: var(--card, #fff); color: var(--card-foreground, #1e1a4b); 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; z-index: 1002; border: 2px solid #10b981; overflow: hidden; display: flex; flex-direction: column; `; this.mergePopup = new Overlay({ element: this.mergePopupElement, positioning: 'bottom-center', offset: [0, -10], stopEvent: true, autoPan: true, autoPanAnimation: { duration: 250 }, }); this.map.addOverlay(this.mergePopup); } /** * Show the merge identifier popup so the user can pick which parcel's * attributes (including UPN) the merged polygon should inherit. * * @param {Feature} mergedFeature The newly created merged feature * @param {Object} propsA Properties from original parcel A * @param {Object} propsB Properties from original parcel B * @param {Array} coordinate Map coordinate [x, y] for popup placement */ showMergeIdentifierPopup(mergedFeature, propsA, propsB, coordinate) { // Extract identifiers — try common parcel ID field names const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID']; const getLabel = (props) => { for (const field of idFields) { if (props[field] !== undefined && props[field] !== null && String(props[field]).trim()) { return { field, value: String(props[field]) }; } } return { field: 'id', value: 'Unknown' }; }; const labelA = getLabel(propsA); const labelB = getLabel(propsB); const html = `
🔗 Merged Parcel — Choose Identifier

Select which parcel's attributes the merged polygon should keep:

`; this.mergePopupElement.innerHTML = html; this.mergePopup.setPosition(coordinate); // Close / Cancel — keep parcel A properties (the default from clone) const close = () => { this.mergePopup.setPosition(undefined); }; this.mergePopupElement.querySelector('.merge-popup-close').addEventListener('click', close); this.mergePopupElement.querySelector('.merge-popup-cancel').addEventListener('click', close); // Confirm — apply chosen parcel's properties this.mergePopupElement.querySelector('.merge-popup-confirm').addEventListener('click', () => { const choice = this.mergePopupElement.querySelector('input[name="merge-choice"]:checked').value; const chosenProps = choice === 'A' ? propsA : propsB; // Copy all properties (except geometry) onto the merged feature const skipKeys = ['geometry']; for (const [key, value] of Object.entries(chosenProps)) { if (skipKeys.includes(key)) continue; mergedFeature.set(key, value); } // Ensure _layerType is preserved mergedFeature.set('_layerType', 'parcel'); // Notify parcel edit callbacks for (const cb of this._parcelEditCallbacks) { cb(mergedFeature, chosenProps); } close(); }); // Highlight radio labels on selection const labels = this.mergePopupElement.querySelectorAll('label'); const radios = this.mergePopupElement.querySelectorAll('input[name="merge-choice"]'); const updateHighlight = () => { labels.forEach((lbl) => { const radio = lbl.querySelector('input'); lbl.style.borderColor = radio.checked ? (radio.value === 'A' ? '#0ea5e9' : '#f59e0b') : 'var(--border, #1e1a4b1f)'; }); }; radios.forEach((r) => r.addEventListener('change', updateHighlight)); updateHighlight(); } // ============================================================================ // Divide Polygon Popup (number input) // ============================================================================ /** * Create the divide polygon popup overlay. * Shown after the user selects a polygon with the Divide tool, so they * can enter the number of equal pieces. */ createDividePopup() { this.dividePopupElement = document.createElement('div'); this.dividePopupElement.className = 'map-divide-popup'; this.dividePopupElement.style.cssText = ` position: absolute; background: var(--card, #fff); color: var(--card-foreground, #1e1a4b); 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: 260px; max-width: 320px; z-index: 1002; border: 2px solid #8b5cf6; overflow: hidden; display: flex; flex-direction: column; `; this.dividePopup = new Overlay({ element: this.dividePopupElement, positioning: 'bottom-center', offset: [0, -10], stopEvent: true, autoPan: true, autoPanAnimation: { duration: 250 }, }); this.map.addOverlay(this.dividePopup); } /** * Show the divide popup so the user can enter the number of divisions. * * @param {Feature} feature The selected polygon feature * @param {VectorSource} source The source containing the feature * @param {Array} coordinate Map coordinate [x, y] for popup placement */ showDividePopup(feature, source, coordinate) { const html = `
Divide Polygon

Enter the number of equal pieces:

`; this.dividePopupElement.innerHTML = html; this.dividePopup.setPosition(coordinate); const input = this.dividePopupElement.querySelector('.divide-input'); input.focus(); input.select(); // Close / Cancel const cancel = () => { this.hideDividePopup(); this._polygonDivideInteraction.cancelDivide(); }; this.dividePopupElement.querySelector('.divide-popup-close').addEventListener('click', cancel); this.dividePopupElement.querySelector('.divide-popup-cancel').addEventListener('click', cancel); // Confirm this.dividePopupElement.querySelector('.divide-popup-confirm').addEventListener('click', () => { const n = parseInt(input.value, 10); if (!n || n < 2) { input.style.borderColor = '#ef4444'; return; } this.hideDividePopup(); this._polygonDivideInteraction.performDivide(n); }); // Allow Enter key to confirm input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.dividePopupElement.querySelector('.divide-popup-confirm').click(); } }); } /** * Hide the divide popup. */ hideDividePopup() { this.dividePopup.setPosition(undefined); } // ============================================================================ // 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 += `
`; } // Area display const geom = feature.getGeometry(); const areaSqm = getArea(geom, { projection: 'EPSG:3857' }); const areaFormatted = formatArea(areaSqm); const html = `
📐 Polygon Attributes
Area: ${areaFormatted}
${fieldsHtml}
`; 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 = `
➕ Add Location
📍
`; // 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', zIndex: -100, 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', }), }); topoLayer.set('basemapKey', 'topo'); const cartoLightLayer = new TileLayer({ title: 'Carto Light', type: 'base', zIndex: -100, 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', }), }); cartoLightLayer.set('basemapKey', 'carto-light'); const cartoDarkLayer = new TileLayer({ title: 'Carto Dark', type: 'base', zIndex: -100, 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', }), }); cartoDarkLayer.set('basemapKey', 'carto-dark'); const osmCycleLayer = new TileLayer({ title: 'OSM Cycle map', type: 'base', zIndex: -100, visible: false, //defaultBasemap === 'osm', source: new OSM({ "url" : "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=ae1339c46dd3446b9c491e7336d38760" }), }); osmCycleLayer.set('basemapKey', 'cycle'); const satelliteLayer = new TileLayer({ title: 'Satellite', type: 'base', zIndex: -100, 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', }), }); satelliteLayer.set('basemapKey', 'satellite'); const googleLayer = new TileLayer({ title: 'Google Sat', type: 'base', zIndex: -100, 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', }), }); googleLayer.set('basemapKey', 'googlesat'); const osmLayer = new TileLayer({ title: 'OpenStreetMap', type: 'base', zIndex: -100, visible: defaultBasemap === 'osm', source: new OSM(), }); osmLayer.set('basemapKey', 'osm'); // Remember the base-map layers so setBaseMap() can toggle visibility later this._baseMapLayers = [ cartoLightLayer, cartoDarkLayer, osmCycleLayer, satelliteLayer, googleLayer, osmLayer, topoLayer, ]; // Return LayerGroup for LayerSwitcher // Note: ol-ext LayerSwitcher iterates layers in reverse — the LAST item // in this array appears at the TOP of the base-map list in the UI. return new LayerGroup({ title: 'Base Maps', layers: [ cartoLightLayer, cartoDarkLayer, satelliteLayer, osmCycleLayer, googleLayer, osmLayer, topoLayer, // ← displayed at the top of the base map stack ], }); } /** * Switch the active base map by key. * Sets exactly one base layer visible; hides all others. * * @param {string} key Basemap key: 'topo' | 'osm' | 'satellite' | 'googlesat' | 'carto-light' | 'carto-dark' | 'cycle' * @returns {boolean} true if the key matched a known base layer */ setBaseMap(key) { if (!this._baseMapLayers) return false; let matched = false; for (const layer of this._baseMapLayers) { const on = layer.get('basemapKey') === key; layer.setVisible(on); if (on) matched = true; } if (matched) console.log('[MapView] Base map switched to:', key); return matched; } /** * 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)', // Optional line "casing": a thicker darker stroke drawn UNDERNEATH the // main stroke. Used for road-like layers to make light-colored lines // visible on any base map. Set lineCasingColor to enable; the casing // width defaults to strokeWidth + 2. lineCasingColor = null, lineCasingWidth = null, pointRadius = 5, pointFillColor = null, // defaults to strokeColor pointStrokeColor = '#ffffff', pointStrokeWidth = 1.5, } = styleOptions; const source = new VectorSource({ features: new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857', }), }); // Build per-geometry styles. OpenLayers picks `image` for Point / // MultiPoint, `stroke`+`fill` for Polygon / MultiPolygon, and `stroke` // alone for LineString / MultiLineString. Putting all three on a single // Style is enough — but a Style with only stroke+fill leaves Points // invisible, which is what was happening on shapefile import. const fillStyle = new Fill({ color: fillColor }); const pointStyle = new Circle({ radius: pointRadius, fill: new Fill({ color: pointFillColor || strokeColor }), stroke: new Stroke({ color: pointStrokeColor, width: pointStrokeWidth }), }); // If a line casing is requested, return an array of two Styles per // feature: the casing renders first (underneath), then the inner stroke. // For polygons the casing also outlines them; for points the casing has // no effect (Point geometries only render `image`). let layerStyle; if (lineCasingColor) { const casingW = lineCasingWidth != null ? lineCasingWidth : strokeWidth + 2; layerStyle = [ new Style({ stroke: new Stroke({ color: lineCasingColor, width: casingW }), }), new Style({ stroke: new Stroke({ color: strokeColor, width: strokeWidth }), fill: fillStyle, image: pointStyle, }), ]; } else { layerStyle = new Style({ stroke: new Stroke({ color: strokeColor, width: strokeWidth }), fill: fillStyle, image: pointStyle, }); } const layer = new VectorLayer({ title: title, source: source, style: layerStyle, }); 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; } /** * Add a WMS layer to a layer group. * * @param {string} groupTitle Title of the target LayerGroup (e.g. 'Biophysical Environment') * @param {string} title Display title for the layer * @param {string} url WMS server URL * @param {string} layers WMS LAYERS parameter * @param {Object} [options] Extra options * @param {string} [options.serverType='geoserver'] Server type hint ('geoserver'|'mapserver'|'qgis'|null) * @param {string} [options.style] WMS STYLES parameter (e.g. 'colours' for DEAfrica DEM) * @param {boolean} [options.visible=true] Initial visibility * @param {string} [options.attributions] Attribution HTML * @param {number} [options.opacity=1] Layer opacity (0–1). Use ~0.5 for background-style layers. * @param {number} [options.zIndex] Render z-index. Use negative values (e.g. -10) to force the * layer behind all default-z-index layers regardless of group order. * @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible. * @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on * while offline, explaining that the layer requires connectivity. * @returns {TileLayer|null} The created layer, or null if group not found */ addWMSLayer(groupTitle, title, url, layers, options = {}) { const group = this.getLayerGroupByTitle(groupTitle); if (!group) { console.warn(`[MapView] Layer group "${groupTitle}" not found — cannot add WMS layer "${title}"`); return null; } const params = { LAYERS: layers, TILED: true, WIDTH: 256, HEIGHT: 256 }; if (options.style !== undefined) params.STYLES = options.style; const wmsSource = new TileWMS({ url, params, serverType: options.serverType !== undefined ? options.serverType : 'geoserver', crossOrigin: 'anonymous', hidpi: false, attributions: options.attributions, }); const wmsLayer = new TileLayer({ title, visible: options.visible !== undefined ? options.visible : true, source: wmsSource, opacity: options.opacity !== undefined ? options.opacity : 1, zIndex: options.zIndex, }); // Show toast on tile load errors (e.g. server rejects request) wmsSource.on('tileloaderror', () => { showToast(`WMS layer "${title}" — tile load error. Check the URL and layer name.`, 'warning', 5000); }); group.getLayers().push(wmsLayer); // Register legend AFTER push so that a failure here doesn't block the LayerSwitcher if (options.legendUrl) { try { this._registerLegend(wmsLayer, title, options.legendUrl); } catch (err) { console.warn(`[MapView] Could not register legend for "${title}":`, err); } } // Online-only warning: when the user toggles the layer on while offline, // surface a toast explaining why nothing will render. if (options.onlineOnly) { this._attachOnlineOnlyHandler(wmsLayer, title); } console.log(`[MapView] WMS layer added: "${title}" → group "${groupTitle}"`); return wmsLayer; } /** * Add an XYZ tile layer to a layer group. * * @param {string} groupTitle Title of the target LayerGroup * @param {string} title Display title for the layer * @param {string} url XYZ tile URL template (with {z}/{x}/{y} placeholders) * @param {Object} [options] Extra options * @param {boolean} [options.visible=true] Initial visibility * @param {string} [options.attributions] Attribution HTML * @param {number} [options.maxZoom=19] Maximum zoom level * @param {number} [options.opacity=1] Layer opacity (0–1). Use ~0.5 for background-style layers. * @param {number} [options.zIndex] Render z-index. Use negative values to force behind other layers. * @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible. * @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on * while offline, explaining that the layer requires connectivity. * @returns {TileLayer|null} The created layer, or null if group not found */ addXYZLayer(groupTitle, title, url, options = {}) { const group = this.getLayerGroupByTitle(groupTitle); if (!group) { console.warn(`[MapView] Layer group "${groupTitle}" not found — cannot add XYZ layer "${title}"`); return null; } const xyzSource = new XYZ({ url, crossOrigin: 'anonymous', maxZoom: options.maxZoom !== undefined ? options.maxZoom : 19, attributions: options.attributions, }); const xyzLayer = new TileLayer({ title, visible: options.visible !== undefined ? options.visible : true, source: xyzSource, opacity: options.opacity !== undefined ? options.opacity : 1, zIndex: options.zIndex, }); // Show toast on tile load errors xyzSource.on('tileloaderror', () => { showToast(`XYZ layer "${title}" — tile load error. Check the URL.`, 'warning', 5000); }); group.getLayers().push(xyzLayer); // Register legend AFTER push so that a failure here doesn't block the LayerSwitcher if (options.legendUrl) { try { this._registerLegend(xyzLayer, title, options.legendUrl); } catch (err) { console.warn(`[MapView] Could not register legend for "${title}":`, err); } } // Online-only warning: when the user toggles the layer on while offline, // surface a toast explaining why nothing will render. if (options.onlineOnly) { this._attachOnlineOnlyHandler(xyzLayer, title); } console.log(`[MapView] XYZ layer added: "${title}" → group "${groupTitle}"`); return xyzLayer; } // ============================================================================ // Add External Layer Dialog // ============================================================================ /** * Create the add-layer dialog overlay (hidden by default). * Appended to the map target element so it stays within the map viewport. */ _createAddLayerDialog() { this._addLayerDialog = document.createElement('div'); this._addLayerDialog.className = 'map-add-layer-dialog'; this._addLayerDialog.style.cssText = ` display:none;position:absolute;top:0;left:0;right:0;bottom:0; z-index:1100;background:rgba(0,0,0,0.4); align-items:center;justify-content:center; `; const card = document.createElement('div'); card.style.cssText = ` background:var(--card, #fff);color:var(--card-foreground, #1e1a4b); border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.35); font-family:var(--font-body, 'Exo', sans-serif);font-size:13px; width:340px;max-width:90vw;border:2px solid #10b981;overflow:hidden; `; card.innerHTML = `
Add External Layer
WMS LAYERS parameter (e.g. workspace:layer)
`; this._addLayerDialog.appendChild(card); this.map.getTargetElement().appendChild(this._addLayerDialog); // Type radio change — toggle layer name row visibility const nameRow = card.querySelector('.add-layer-name-row'); const nameHint = card.querySelector('.add-layer-name-hint'); const urlInput = card.querySelector('.add-layer-url'); card.querySelectorAll('input[name="add-layer-type"]').forEach((radio) => { radio.addEventListener('change', () => { const type = radio.value; if (type === 'xyz') { nameRow.style.display = 'none'; urlInput.placeholder = 'https://example.com/tiles/{z}/{x}/{y}.png'; } else { nameRow.style.display = ''; urlInput.placeholder = type === 'wms' ? 'https://example.com/wms' : 'https://example.com/wfs'; nameHint.textContent = type === 'wms' ? 'WMS LAYERS parameter (e.g. workspace:layer)' : 'WFS typename (e.g. workspace:layer)'; } }); }); // Close / Cancel const close = () => this._hideAddLayerDialog(); card.querySelector('.add-layer-close').addEventListener('click', close); card.querySelector('.add-layer-cancel').addEventListener('click', close); this._addLayerDialog.addEventListener('click', (e) => { if (e.target === this._addLayerDialog) close(); }); // Confirm card.querySelector('.add-layer-confirm').addEventListener('click', () => { const type = card.querySelector('input[name="add-layer-type"]:checked').value; const url = card.querySelector('.add-layer-url').value.trim(); const layerName = card.querySelector('.add-layer-name').value.trim(); const title = card.querySelector('.add-layer-title').value.trim(); if (!url) { card.querySelector('.add-layer-url').style.borderColor = '#ef4444'; return; } if ((type === 'wms' || type === 'wfs') && !layerName) { card.querySelector('.add-layer-name').style.borderColor = '#ef4444'; return; } if (!title) { card.querySelector('.add-layer-title').style.borderColor = '#ef4444'; return; } this._addExternalLayer(type, url, layerName, title); this._hideAddLayerDialog(); }); // Enter key to confirm card.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); card.querySelector('.add-layer-confirm').click(); } if (e.key === 'Escape') { e.preventDefault(); close(); } }); } /** * Show the add-layer dialog. */ showAddLayerDialog() { const dlg = this._addLayerDialog; // Reset form dlg.querySelector('.add-layer-url').value = ''; dlg.querySelector('.add-layer-name').value = ''; dlg.querySelector('.add-layer-title').value = ''; dlg.querySelectorAll('input[name="add-layer-type"]')[0].checked = true; dlg.querySelector('.add-layer-name-row').style.display = ''; dlg.querySelector('.add-layer-url').placeholder = 'https://example.com/wms'; dlg.querySelector('.add-layer-name-hint').textContent = 'WMS LAYERS parameter (e.g. workspace:layer)'; // Reset border colours dlg.querySelectorAll('input[type="text"]').forEach((inp) => { inp.style.borderColor = 'var(--border, #1e1a4b1f)'; }); dlg.style.display = 'flex'; dlg.querySelector('.add-layer-url').focus(); } /** * Hide the add-layer dialog. */ _hideAddLayerDialog() { this._addLayerDialog.style.display = 'none'; } /** * Add an external layer to the "External Source" group. * * @param {string} type 'wms' | 'wfs' | 'xyz' * @param {string} url Server URL * @param {string} layerName WMS LAYERS / WFS typename (ignored for XYZ) * @param {string} title Display title in layer switcher */ _addExternalLayer(type, url, layerName, title) { const group = this._externalSourceGroup; if (!group) { showToast('Layer group "External Source" not found.', 'error', 4000); return; } let layer; switch (type) { case 'wms': { const wmsSrc = new TileWMS({ url, params: { LAYERS: layerName, TILED: true, WIDTH: 256, HEIGHT: 256 }, serverType: 'geoserver', crossOrigin: 'anonymous', hidpi: false, }); layer = new TileLayer({ title, visible: true, source: wmsSrc, }); wmsSrc.on('tileloaderror', () => { showToast(`WMS "${title}" — tile load error. Check URL and layer name.`, 'warning', 5000); }); break; } case 'wfs': { const wfsUrl = `${url}${url.includes('?') ? '&' : '?'}` + `service=WFS&version=1.1.0&request=GetFeature` + `&typename=${encodeURIComponent(layerName)}` + `&outputFormat=application/json&srsname=EPSG:3857`; const wfsSource = new VectorSource({ url: wfsUrl, format: new GeoJSON(), }); wfsSource.on('featuresloaderror', () => { showToast(`WFS "${title}" — load error. Check URL and layer name.`, 'warning', 5000); }); layer = new VectorLayer({ title, visible: true, source: wfsSource, style: new Style({ stroke: new Stroke({ color: '#e11d48', width: 2 }), fill: new Fill({ color: 'rgba(225,29,72,0.15)' }), }), }); break; } case 'xyz': layer = new TileLayer({ title, visible: true, source: new XYZ({ url, crossOrigin: 'anonymous', }), }); layer.getSource().on('tileloaderror', () => { showToast(`XYZ "${title}" — tile load error. Check the URL template.`, 'warning', 5000); }); break; default: showToast(`Unknown layer type: ${type}`, 'error', 4000); return; } group.getLayers().push(layer); showToast(`Layer "${title}" added to External Source.`, 'success', 3000); console.log(`[MapView] External ${type.toUpperCase()} layer added: "${title}"`); } // ============================================================================ // Online-Only Layer Helper // ============================================================================ /** * Attach a `change:visible` listener that shows an info toast when the user * toggles a layer ON while the device is offline. Used for layers that fetch * tiles or features from a remote service and therefore have no useful * cached state. * * The check uses navigator.onLine, which is the same signal as the rest of * the app's online detection. * * @param {Layer} layer * @param {string} title Display title used in the toast message */ _attachOnlineOnlyHandler(layer, title) { layer.set('onlineOnly', true); layer.on('change:visible', () => { if (layer.getVisible() && !navigator.onLine) { showToast( `"${title}" requires an internet connection. Connect to view this layer.`, 'info', 5000, ); } }); } // ============================================================================ // Legend Panel — shows legend images for visible layers that have one // ============================================================================ /** * Create the legend panel, positioned bottom-right inside the map target. * Hidden when no visible layers have a registered legend. */ _createLegendPanel() { this._legendPanel = document.createElement('div'); this._legendPanel.className = 'map-legend-panel'; this._legendPanel.style.cssText = ` position:absolute;right:10px;bottom:40px;z-index:900; display:none;flex-direction:column;gap:6px; background:var(--card, #fff);color:var(--card-foreground, #1e1a4b); border:1px solid var(--border, #1e1a4b1f);border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.15); font-family:var(--font-body, 'Exo', sans-serif);font-size:11px; max-width:220px;max-height:60%;overflow-y:auto; padding:8px 10px; `; this.map.getTargetElement().appendChild(this._legendPanel); // Map of layer → { wrapper, title, imgUrl } this._legendEntries = new Map(); } /** * Register a layer's legend image and wire up visibility tracking. * Called from addWMSLayer / addXYZLayer when a legendUrl is supplied. * * @param {Layer} layer The OpenLayers layer * @param {string} title Display title for the legend header * @param {string} legendUrl URL of the legend image */ _registerLegend(layer, title, legendUrl) { if (!this._legendPanel) return; // Build the legend entry — a div with header + image const wrapper = document.createElement('div'); wrapper.className = 'map-legend-entry'; wrapper.style.cssText = 'border-bottom:1px solid var(--border, #1e1a4b1f);padding-bottom:6px;'; wrapper.innerHTML = `
${this._escapeHtml(title)}
${this._escapeHtml(title)} legend `; this._legendEntries.set(layer, wrapper); // Listen for visibility changes. Wrap in try/catch so a DOM error here // cannot break the LayerSwitcher's click handler (which fires change:visible // synchronously and relies on a subsequent setTimeout to update the checkbox). const update = () => { try { this._updateLegendPanel(); } catch (err) { console.warn('[MapView] legend panel update failed:', err); } }; layer.on('change:visible', update); // Trigger initial state update(); } /** * Refresh the legend panel contents: include entries for each visible * registered layer, and show/hide the panel based on whether any are visible. */ _updateLegendPanel() { if (!this._legendPanel) return; // Rebuild children from scratch in a stable order (Map iteration order = insertion order) const children = []; for (const [layer, wrapper] of this._legendEntries) { if (layer.getVisible()) children.push(wrapper); } // Remove trailing bottom-border on the last entry for a clean look this._legendEntries.forEach((w) => { w.style.borderBottom = '1px solid var(--border, #1e1a4b1f)'; w.style.paddingBottom = '6px'; }); if (children.length > 0) { children[children.length - 1].style.borderBottom = 'none'; children[children.length - 1].style.paddingBottom = '0'; } // Swap the DOM children this._legendPanel.replaceChildren(...children); this._legendPanel.style.display = children.length > 0 ? 'flex' : 'none'; } /** * Escape HTML special characters for safe text insertion. */ _escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * 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; } // ============================================================================ // Extent Helpers (used by offline-tile downloader) // ============================================================================ /** * Get the current map view's visible extent in EPSG:3857 (Web Mercator). * @returns {Array} [minX, minY, maxX, maxY] */ getCurrentViewExtent() { const view = this.map.getView(); const size = this.map.getSize(); if (!size) return null; return view.calculateExtent(size); } /** * Get the bounding extent of the District Boundary layer (if present). * Searches the overlay group for a vector layer titled "District Boundary" * and returns the extent of its source. * * @returns {{ extent: Array, title: string } | null} */ getDistrictBoundaryExtent() { let found = null; const visit = (group) => { group.getLayers().forEach((layer) => { if (layer.getLayers) { visit(layer); // sub-group } else if (layer.get('title') === 'District Boundary') { const src = layer.getSource && layer.getSource(); if (src && typeof src.getExtent === 'function') { const ex = src.getExtent(); if (ex && Number.isFinite(ex[0])) { found = { extent: ex, title: layer.get('title') }; } } } }); }; visit(this.overlayGroup); return found; } /** * 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;