ekke ef12e4477b Offline tile cache, polygon Divide, topographic layer integrations
Major feature batch covering drawing-tool improvements, layer additions,
and offline-first capabilities. Largest changes in MapView.js (+1700),
main.js (+1500), public/sw.js (+367), and new modules under src/.

Drawing & editing toolkit
  * Polygon Divide tool — sub-button under Split, divides a polygon into
    N equal-area pieces via binary search; user picks the cutting edge
  * UPN pick phase after Split and Divide — non-picked pieces have their
    identifier fields cleared automatically
  * Improved Merge algorithm — vertex-to-edge proximity (5 m tol.) with
    hybrid lockstep extension; bold A/B labels on selected polygons
  * Persistent vertex highlights — all vertices of the selected polygon
    rendered as dots while edit mode is on, without subclassing ol-ext
  * Toast notifications for merge/split/divide outcomes
  * Shapefile import — addGeoJSONLayer now includes an image style so
    Point features render (previously invisible)

Background & overlay layers
  * DEAfrica Coastlines v0.4 (WMS) in Biophysical Environment
  * DEAfrica Slope (SRTM 30m, style_slope) — semi-transparent background
  * Contours hillshade — get_contours_hillshade.php → local SQLite cache
  * OSM_roads — get_osm_roads.php → local SQLite cache, casing-stroke
    style (black 3.5 px outer, #F0F1F0 1.5 px inner)
  * External Source dialog — green + button in LayerSwitcher lets users
    add WMS / WFS / XYZ layers at runtime
  * Generic addWMSLayer / addXYZLayer with style, opacity, zIndex,
    legendUrl, onlineOnly options
  * TileWMS replaces ImageWMS (fixes 'Width exceeds 512' WMS errors)
  * Legend panel — bottom-right, auto-shown for visible layers that
    register a legendUrl
  * Default base map setting in Settings, persisted in localStorage;
    setBaseMap() on MapView

Offline tile cache (Phase 1 + 2)
  * Service worker: per-host tile caches (osm / topo / satellite /
    carto-light / carto-dark), counter-based eviction to prevent
    iOS Safari memory-pressure reloads, GET_TILE_STATS /
    CLEAR_TILE_CACHES message API
  * pwa.js helpers: getActiveServiceWorker, onServiceWorkerControllerChange,
    getTileCacheStats, clearTileCaches, getStorageEstimate
  * Settings: Offline Map Tiles card with per-provider stats + clear
  * Phase 2 download dialog: form to pick base map, area (current view /
    district / Ghana), zoom range; live tile-count + size estimate;
    progress bar with cancel; OfflineTileDownloader class with
    concurrency + throttling

Local database management
  * osm_roads table + saveOSMRoads / getLocalOSMRoads helpers
  * CACHED_LAYER_TABLES allow-list with clearTable / clearAllCachedLayers
  * Local Database Tables card: per-row Clear button (cached layers
    only) + 'Refresh cached layers' header button with reload prompt

Build & infrastructure
  * Shpjs lazy-loaded via dynamic import (saves ~140 kB from initial JS)
  * chunkSizeWarningLimit raised to 900 kB (openlayers + sqlite3.wasm
    can't be split further)
  * Toast notification module (src/toast.js)
  * Units module (src/units.js) for metric / imperial conversions
  * PDF export module (src/pdf-export.js)

Documentation & SQL
  * Topographic_Background_Layers_for_LUPMIS2.docx — research report
  * OpenTopography_Workflow.svg/.png — ETL pipeline diagram
  * LUPMIS2_Development_Status_Report.docx — April update section
  * sql/create_landuse_parcels.sql — PostgreSQL schema for the LUSPA
    land-use parcel specification (Feb 2026, revised), with PostGIS
    geometry column and standard indices

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:55:30 +02:00

3556 lines
124 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* MapView Component
*
* OpenLayers map with ol-ext LayerSwitcher for base map selection.
*
* Usage:
* import { MapView } from './components/MapView.js';
*
* const map = new MapView('map', {
* center: [-1.5, 7.5], // Ghana
* zoom: 7,
* basemap: 'osm'
* });
*
* map.onClick((lon, lat) => console.log('Clicked:', lon, lat));
* map.addMarker(lon, lat, { name: 'Point A' });
*/
import Map from 'ol/Map';
import View from 'ol/View';
import Overlay from 'ol/Overlay';
import TileLayer from 'ol/layer/Tile';
import 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 }]) =>
`<option value="${key}">${emoji} ${label}</option>`
)
.join('\n ');
};
// Create emoji style helper
this.createEmojiStyle = (emoji, fontSize = 24) => {
return new Style({
text: new Text({
text: emoji,
font: `${fontSize}px sans-serif`,
textBaseline: 'bottom',
textAlign: 'center',
offsetY: -5,
}),
});
};
// Default marker style (pin emoji)
this.defaultStyle = this.createEmojiStyle('📍', 32);
// Selected marker style (larger)
this.selectedStyle = this.createEmojiStyle('📍', 42);
// Initialize category styles with emojis
this.categoryStyles = {};
for (const [category, { emoji }] of Object.entries(this.categoryEmojis)) {
this.categoryStyles[category] = this.createEmojiStyle(emoji, 32);
}
// Create base layers group
const baseLayers = this.createBaseLayers(options.basemap || '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: '<i class="bi bi-arrow-counterclockwise"></i>',
className: 'ol-undo',
title: 'Undo',
handleClick: () => {
if (this._undoRedo.hasUndo()) this._undoRedo.undo();
},
}),
new Button({
html: '<i class="bi bi-arrow-clockwise"></i>',
className: 'ol-redo',
title: 'Redo',
handleClick: () => {
if (this._undoRedo.hasRedo()) this._undoRedo.redo();
},
}),
new Button({
html: '<i class="bi bi-floppy"></i>',
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: '<i class="bi bi-slash-lg"></i>',
className: 'ol-split-line',
title: 'Split Lines',
name: 'SplitLine',
interaction: this._lineSplitInteraction,
autoActivate: true,
});
const splitPolyToggle = new Toggle({
html: '<i class="bi bi-scissors"></i>',
className: 'ol-split-polygon',
title: 'Split Polygons',
name: 'SplitPolygon',
interaction: this._polygonSplitInteraction,
});
const splitDivideToggle = new Toggle({
html: '<i class="bi bi-grid-3x3-gap"></i>',
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: '<i class="bi bi-union"></i>',
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: '<i class="bi bi-magnet"></i>',
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<Array<number>>}
*/
_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 = `
<div style="font-weight: 600; font-size: 14px; margin-bottom: 6px;">
${emoji} ${this.escapeHtml(name)}
</div>
`;
// Category badge
const categoryColors = {
'water': '#3b82f6',
'school': '#f59e0b',
'health': '#ef4444',
'market': '#8b5cf6',
'default': '#2d5016',
'other': '#6b7280'
};
const catColor = categoryColors[category] || '#6b7280';
html += `
<div style="margin-bottom: 6px;">
<span style="
background: ${catColor}20;
color: ${catColor};
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
">${category}</span>
</div>
`;
// Description if available
if (description) {
html += `
<div style="color: var(--muted-foreground, #7a7a7a); font-size: 12px; margin-bottom: 6px; line-height: 1.4;">
${this.escapeHtml(description)}
</div>
`;
}
// Coordinates
if (lon !== undefined && lat !== undefined) {
html += `
<div style="color: var(--muted-foreground, #7a7a7a); font-size: 11px; font-family: monospace;">
${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)}
</div>
`;
}
this.popupElement.innerHTML = html;
this.popup.setPosition(coordinate);
}
/**
* Hide the popup
*/
hidePopup() {
this.popup.setPosition(undefined);
}
/**
* Create the info popup overlay for double-click feature details
*/
createInfoPopup() {
this.infoPopupElement = document.createElement('div');
this.infoPopupElement.className = 'map-info-popup';
this.infoPopupElement.style.cssText = `
position: absolute;
background: 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 += `
<tr>
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">${this.escapeHtml(key)}</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${this.escapeHtml(String(value))}</td>
</tr>
`;
}
// Add measurement row based on geometry type
if (geomType === 'Polygon' || geomType === 'MultiPolygon') {
// Area for polygons
const areaSqm = getArea(geometry, { projection: 'EPSG:3857' });
const areaFormatted = formatAreaFull(areaSqm);
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">area</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${areaFormatted}</td>
</tr>
`;
} else if (geomType === 'LineString' || geomType === 'MultiLineString') {
// Length for lines
const lengthM = getLength(geometry, { projection: 'EPSG:3857' });
const lengthFormatted = formatLengthFull(lengthM);
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">length</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${lengthFormatted}</td>
</tr>
`;
} else if (geomType === 'Point') {
// Coordinates for points
const coords = toLonLat(geometry.getCoordinates());
const lon = coords[0].toFixed(6);
const lat = coords[1].toFixed(6);
rows += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">longitude</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${lon}</td>
</tr>
<tr>
<td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">latitude</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${lat}</td>
</tr>
`;
}
const html = `
<div style="background:${color};color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;border-radius:10px 10px 0 0;">
<span>${this.escapeHtml(title)}</span>
<button id="info-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<div style="padding:8px 4px;overflow-y:auto;flex:1 1 auto;min-height:0;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
${rows}
</table>
</div>
`;
this.infoPopupElement.innerHTML = html;
this.infoPopup.setPosition(coordinate);
// Close button handler
this.infoPopupElement.querySelector('#info-popup-close').addEventListener('click', () => {
this.hideInfoPopup();
});
}
/**
* Hide the info popup
*/
hideInfoPopup() {
this.infoPopup.setPosition(undefined);
}
// ============================================================================
// Circle Intersection Analysis
// ============================================================================
/**
* Analyse which features from overlay layers intersect a measurement circle
* and show the results in the info popup.
*
* @param {Feature} circleFeature - The measurement circle feature (Circle geometry)
* @param {Array} coordinate - Map coordinate for popup placement [x, y]
*/
/**
* 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 += `
<tr style="border-top:1px solid var(--border, #1e1a4b1f);">
<td colspan="2" style="padding:8px;color:#999;text-align:center;font-style:italic;">${row.value}</td>
</tr>`;
continue;
}
const labelColor = row.color || 'var(--muted-foreground, #7a7a7a)';
const border = row._first ? '' : 'border-top:1px solid var(--border, #1e1a4b1f);';
tableRows += `
<tr style="${border}">
<td style="padding:4px 8px;font-weight:600;color:${labelColor};white-space:nowrap;">${row.label}</td>
<td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${row.value}</td>
</tr>`;
}
return `
<div style="background:var(--brand-navy, #1e1a4b);color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;border-radius:10px 10px 0 0;">
<span>${emoji} ${title}</span>
<button id="info-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<div style="padding:8px 4px;overflow-y:auto;flex:1 1 auto;min-height:0;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
${tableRows}
</table>
</div>
<div style="padding:2px 8px 8px;text-align:right;flex-shrink:0;border-top:1px solid var(--border, #1e1a4b1f);">
<button id="info-popup-export-pdf"
style="background:var(--brand-navy,#1e1a4b);color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;">
📄 Export PDF
</button>
</div>`;
}
/**
* 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 += `
<div style="margin-bottom:8px;">
<label style="display:block;font-size:11px;font-weight:600;color:var(--muted-foreground, #7a7a7a);margin-bottom:2px;">${escapedKey}</label>
<input type="text" name="${escapedKey}" value="${escapedVal}"
style="width:100%;padding:6px 8px;border:1px solid var(--border, #1e1a4b1f);border-radius:4px;font-size:13px;color:var(--foreground, #1e1a4b);background:var(--muted, #f2f4f7);min-height:34px;"
/>
</div>
`;
}
const html = `
<div style="background:var(--primary, #005eb8);color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
<span>✏️ Edit Parcel</span>
<button class="parcel-edit-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<form class="parcel-edit-form" style="padding:10px 12px;overflow-y:auto;flex:1;">
${fieldsHtml}
<div style="display:flex;gap:8px;margin-top:10px;">
<button type="submit" style="flex:1;padding:8px 12px;background:var(--primary, #005eb8);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
💾 Save
</button>
<button type="button" class="parcel-edit-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
Cancel
</button>
</div>
</form>
`;
this.parcelEditElement.innerHTML = html;
this.parcelEditPopup.setPosition(coordinate);
// Close / Cancel handlers
this.parcelEditElement.querySelector('.parcel-edit-close').addEventListener('click', () => {
this.hideParcelEditPopup();
});
this.parcelEditElement.querySelector('.parcel-edit-cancel').addEventListener('click', () => {
this.hideParcelEditPopup();
});
// Form submit handler
const form = this.parcelEditElement.querySelector('.parcel-edit-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
// Collect all edited values
const formData = new FormData(form);
const updatedProps = {};
for (const [key, value] of formData.entries()) {
updatedProps[key] = value;
}
// Restore internal properties that were excluded from the form
updatedProps._layerType = 'parcel';
// Update the feature's properties in-place
for (const [key, value] of Object.entries(updatedProps)) {
this._parcelEditFeature.set(key, value);
}
// Notify external listeners
for (const cb of this._parcelEditCallbacks) {
cb(this._parcelEditFeature, updatedProps);
}
this.hideParcelEditPopup();
});
}
/**
* Hide the parcel edit popup.
*/
hideParcelEditPopup() {
this.parcelEditPopup.setPosition(undefined);
this._parcelEditFeature = null;
}
/**
* Register a callback for when a parcel edit is saved.
* Callback receives (feature, updatedProperties).
*
* @param {Function} callback
*/
onParcelEdit(callback) {
this._parcelEditCallbacks.push(callback);
}
// ============================================================================
// 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 = `
<div style="background:#10b981;color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
<span>🔗 Merged Parcel — Choose Identifier</span>
<button class="merge-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<div style="padding:12px;">
<p style="margin:0 0 10px;color:var(--muted-foreground, #7a7a7a);font-size:12px;">
Select which parcel's attributes the merged polygon should keep:
</p>
<label style="display:flex;align-items:center;padding:10px;border:2px solid var(--border, #1e1a4b1f);border-radius:8px;cursor:pointer;margin-bottom:8px;transition:border-color 0.15s;">
<input type="radio" name="merge-choice" value="A" checked
style="margin-right:10px;accent-color:#0ea5e9;width:16px;height:16px;" />
<div>
<div style="font-weight:600;color:#0ea5e9;">Parcel A</div>
<div style="font-size:12px;color:var(--muted-foreground, #7a7a7a);">${this.escapeHtml(labelA.field)}: ${this.escapeHtml(labelA.value)}</div>
</div>
</label>
<label style="display:flex;align-items:center;padding:10px;border:2px solid var(--border, #1e1a4b1f);border-radius:8px;cursor:pointer;margin-bottom:12px;transition:border-color 0.15s;">
<input type="radio" name="merge-choice" value="B"
style="margin-right:10px;accent-color:#f59e0b;width:16px;height:16px;" />
<div>
<div style="font-weight:600;color:#f59e0b;">Parcel B</div>
<div style="font-size:12px;color:var(--muted-foreground, #7a7a7a);">${this.escapeHtml(labelB.field)}: ${this.escapeHtml(labelB.value)}</div>
</div>
</label>
<div style="display:flex;gap:8px;">
<button class="merge-popup-confirm" style="flex:1;padding:8px 12px;background:#10b981;color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
✅ Confirm
</button>
<button class="merge-popup-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
Cancel
</button>
</div>
</div>
`;
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 = `
<div style="background:#8b5cf6;color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
<span>Divide Polygon</span>
<button class="divide-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<div style="padding:12px;">
<p style="margin:0 0 10px;color:var(--muted-foreground, #7a7a7a);font-size:12px;">
Enter the number of equal pieces:
</p>
<input type="number" class="divide-input" min="2" max="50" value="2"
style="width:100%;padding:8px 10px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:16px;font-weight:600;text-align:center;color:var(--foreground, #1e1a4b);background:var(--muted, #f2f4f7);min-height:40px;" />
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="divide-popup-confirm" style="flex:1;padding:8px 12px;background:#8b5cf6;color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
Divide
</button>
<button class="divide-popup-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
Cancel
</button>
</div>
</div>
`;
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 += `
<div style="margin-bottom:8px;">
<label style="display:block;font-size:11px;font-weight:600;color:var(--muted-foreground, #7a7a7a);margin-bottom:2px;">${escapedKey}</label>
<input type="text" name="${escapedKey}" value=""
style="width:100%;padding:6px 8px;border:1px solid var(--border, #1e1a4b1f);border-radius:4px;font-size:13px;color:var(--foreground, #1e1a4b);background:var(--muted, #f2f4f7);min-height:34px;"
/>
</div>
`;
}
// Area display
const geom = feature.getGeometry();
const areaSqm = getArea(geom, { projection: 'EPSG:3857' });
const areaFormatted = formatArea(areaSqm);
const html = `
<div style="background:var(--success, #006b3f);color:var(--success-foreground, #fff);padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
<span>📐 Polygon Attributes</span>
<button class="drawn-polygon-close" style="background:none;border:none;color:var(--success-foreground, #fff);font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<div style="padding:8px 12px;background:var(--muted, #f2f4f7);border-bottom:1px solid var(--border, #1e1a4b1f);font-size:12px;color:var(--muted-foreground, #7a7a7a);flex-shrink:0;">
Area: <strong>${areaFormatted}</strong>
</div>
<form class="drawn-polygon-form" style="padding:10px 12px;overflow-y:auto;flex:1;">
${fieldsHtml}
<div style="display:flex;gap:8px;margin-top:10px;">
<button type="submit" style="flex:1;padding:8px 12px;background:var(--success, #006b3f);color:var(--success-foreground, #fff);border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
💾 Save
</button>
<button type="button" class="drawn-polygon-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
Cancel
</button>
</div>
</form>
`;
this.drawnPolygonElement.innerHTML = html;
this.drawnPolygonPopup.setPosition(coordinate);
// Close / Cancel handlers
this.drawnPolygonElement.querySelector('.drawn-polygon-close').addEventListener('click', () => {
this.hideDrawnPolygonPopup();
});
this.drawnPolygonElement.querySelector('.drawn-polygon-cancel').addEventListener('click', () => {
this.hideDrawnPolygonPopup();
});
// Form submit handler
const form = this.drawnPolygonElement.querySelector('.drawn-polygon-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(form);
const props = {};
for (const [key, value] of formData.entries()) {
props[key] = value;
}
// Set properties on the feature
for (const [key, value] of Object.entries(props)) {
this._drawnPolygonFeature.set(key, value);
}
// Tag as parcel so it integrates with existing parcel tools
this._drawnPolygonFeature.set('_layerType', 'parcel');
// Notify listeners
for (const cb of this._drawnPolygonCallbacks) {
cb(this._drawnPolygonFeature, props);
}
this.hideDrawnPolygonPopup();
});
}
/**
* Hide the drawn polygon attribute popup.
*/
hideDrawnPolygonPopup() {
this.drawnPolygonPopup.setPosition(undefined);
this._drawnPolygonFeature = null;
}
/**
* Register a callback for when drawn polygon attributes are saved.
* Callback receives (feature, properties).
*
* @param {Function} callback
*/
onDrawnPolygonSave(callback) {
this._drawnPolygonCallbacks.push(callback);
}
/**
* Register a double-click callback.
* Callback receives (lon, lat, feature, event).
* Feature is the first feature found at the click pixel across all overlay layers,
* or null if no feature was hit.
* When a feature is hit, the default double-click-zoom is suppressed.
*/
onDblClick(callback) {
this.dblClickCallbacks.push(callback);
// Set up the listener once
if (this.dblClickCallbacks.length === 1) {
this.map.on('dblclick', (evt) => {
const [lon, lat] = toLonLat(evt.coordinate);
// Find any feature at the clicked pixel (overlay layers, not just markers)
let clickedFeature = null;
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
clickedFeature = feature;
return true; // stop at first hit
});
// If a feature was hit, prevent the default double-click zoom
if (clickedFeature) {
evt.preventDefault();
evt.stopPropagation();
}
// Call all registered callbacks
for (const cb of this.dblClickCallbacks) {
cb(lon, lat, clickedFeature, evt);
}
// Return false to suppress DoubleClickZoom interaction when on a feature
if (clickedFeature) return false;
});
}
return () => {
const idx = this.dblClickCallbacks.indexOf(callback);
if (idx > -1) this.dblClickCallbacks.splice(idx, 1);
};
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Create the Add Location popup form overlay
*/
createAddLocationPopup() {
// Create popup container element
this.addLocationPopupElement = document.createElement('div');
this.addLocationPopupElement.className = 'map-add-location-popup';
this.addLocationPopupElement.innerHTML = `
<div class="add-location-popup-header">
<span> Add Location</span>
<button type="button" class="add-location-popup-close" aria-label="Close">&times;</button>
</div>
<form id="map-add-location-form">
<div class="add-location-popup-field">
<label for="map-location-name">Name <span class="text-danger">*</span></label>
<input type="text" id="map-location-name" name="name" required placeholder="e.g., Water Point A">
</div>
<div class="add-location-popup-field">
<label for="map-location-category">Category</label>
<select id="map-location-category" name="category">
${this.getCategoryOptionsHtml()}
</select>
</div>
<div class="add-location-popup-field">
<label for="map-location-description">Description</label>
<textarea id="map-location-description" name="description" rows="2" placeholder="Optional notes..."></textarea>
</div>
<div class="add-location-popup-coords">
<small>📍 <span id="map-location-coords"></span></small>
</div>
<button type="submit" class="add-location-popup-submit"> Add Location</button>
</form>
`;
// Create the overlay
this.addLocationPopup = new Overlay({
element: this.addLocationPopupElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: true, // Prevent click from propagating
autoPan: true,
autoPanAnimation: {
duration: 250,
},
});
this.map.addOverlay(this.addLocationPopup);
// Store clicked coordinates
this.addLocationCoords = null;
// Set up close button handler
const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close');
closeBtn.addEventListener('click', () => {
this.hideAddLocationPopup();
});
// Store form submit callbacks
this.addLocationCallbacks = [];
}
/**
* Show the Add Location popup at the specified coordinate
*/
showAddLocationPopup(coordinate) {
const [lon, lat] = toLonLat(coordinate);
this.addLocationCoords = { lon, lat };
// Update coordinates display
const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords');
coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`;
// Reset form
const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
form.reset();
// Position and show popup
this.addLocationPopup.setPosition(coordinate);
}
/**
* Hide the Add Location popup
*/
hideAddLocationPopup() {
this.addLocationPopup.setPosition(undefined);
this.addLocationCoords = null;
}
/**
* Register a callback for when a location is submitted via the map popup
* Callback receives: { name, category, description, lon, lat }
*/
onAddLocation(callback) {
this.addLocationCallbacks.push(callback);
// Set up form submit handler (only once)
if (this.addLocationCallbacks.length === 1) {
const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
if (!this.addLocationCoords) return;
const formData = new FormData(form);
const data = {
name: formData.get('name'),
category: formData.get('category'),
description: formData.get('description'),
lon: this.addLocationCoords.lon,
lat: this.addLocationCoords.lat,
};
// Call all registered callbacks
this.addLocationCallbacks.forEach(cb => cb(data));
// Hide popup after submission
this.hideAddLocationPopup();
});
}
}
/**
* Create base layers group for LayerSwitcher
*/
createBaseLayers(defaultBasemap) {
const topoLayer = new TileLayer({
title: 'Topographic',
type: 'base',
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 (01). 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 (01). 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 = `
<div style="background:#10b981;color:#fff;padding:10px 14px;font-weight:600;display:flex;justify-content:space-between;align-items:center;">
<span>Add External Layer</span>
<button class="add-layer-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
<div style="padding:14px;display:flex;flex-direction:column;gap:10px;">
<div>
<label style="font-weight:600;font-size:12px;display:block;margin-bottom:4px;">Layer Type</label>
<div class="add-layer-types" style="display:flex;gap:6px;">
<label style="flex:1;display:flex;align-items:center;gap:4px;cursor:pointer;padding:6px 8px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:12px;font-weight:600;">
<input type="radio" name="add-layer-type" value="wms" checked style="accent-color:#10b981;"> WMS
</label>
<label style="flex:1;display:flex;align-items:center;gap:4px;cursor:pointer;padding:6px 8px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:12px;font-weight:600;">
<input type="radio" name="add-layer-type" value="wfs" style="accent-color:#10b981;"> WFS
</label>
<label style="flex:1;display:flex;align-items:center;gap:4px;cursor:pointer;padding:6px 8px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:12px;font-weight:600;">
<input type="radio" name="add-layer-type" value="xyz" style="accent-color:#10b981;"> XYZ
</label>
</div>
</div>
<div>
<label style="font-weight:600;font-size:12px;display:block;margin-bottom:4px;">Server URL</label>
<input type="text" class="add-layer-url" placeholder="https://example.com/wms"
style="width:100%;padding:8px 10px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:13px;background:var(--muted, #f2f4f7);color:var(--foreground, #1e1a4b);box-sizing:border-box;" />
</div>
<div class="add-layer-name-row">
<label style="font-weight:600;font-size:12px;display:block;margin-bottom:4px;">Layer Name</label>
<input type="text" class="add-layer-name" placeholder="workspace:layer_name"
style="width:100%;padding:8px 10px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:13px;background:var(--muted, #f2f4f7);color:var(--foreground, #1e1a4b);box-sizing:border-box;" />
<div style="font-size:11px;color:var(--muted-foreground, #7a7a7a);margin-top:2px;" class="add-layer-name-hint">
WMS LAYERS parameter (e.g. workspace:layer)
</div>
</div>
<div>
<label style="font-weight:600;font-size:12px;display:block;margin-bottom:4px;">Display Title</label>
<input type="text" class="add-layer-title" placeholder="My Layer"
style="width:100%;padding:8px 10px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:13px;background:var(--muted, #f2f4f7);color:var(--foreground, #1e1a4b);box-sizing:border-box;" />
</div>
<div style="display:flex;gap:8px;margin-top:4px;">
<button class="add-layer-confirm" style="flex:1;padding:8px 12px;background:#10b981;color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
Add Layer
</button>
<button class="add-layer-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
Cancel
</button>
</div>
</div>
`;
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 = `
<div style="font-weight:600;font-size:11px;margin-bottom:4px;line-height:1.3;">
${this._escapeHtml(title)}
</div>
<img src="${legendUrl}" alt="${this._escapeHtml(title)} legend"
style="display:block;max-width:100%;height:auto;border-radius:3px;"
onerror="this.style.display='none'" />
`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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<number>} [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<number>, 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;