>}
*/
_collectAllVertices(geom) {
const out = [];
const isCoord = (v) => Array.isArray(v) && typeof v[0] === 'number';
const visitRing = (ring, isPolygonRing) => {
const len = isPolygonRing && ring.length > 1 ? ring.length - 1 : ring.length;
for (let i = 0; i < len; i++) out.push(ring[i]);
};
const type = geom.getType();
const coords = geom.getCoordinates();
switch (type) {
case 'Polygon':
// coords = [outerRing, hole1, hole2, …]
for (const ring of coords) visitRing(ring, true);
break;
case 'MultiPolygon':
// coords = [poly1, poly2, …]; each poly = [outerRing, hole1, …]
for (const poly of coords) for (const ring of poly) visitRing(ring, true);
break;
case 'LineString':
visitRing(coords, false);
break;
case 'MultiLineString':
for (const line of coords) visitRing(line, false);
break;
default:
// Fallback: deep walk to find arrays of [x, y]
const walk = (v) => {
if (isCoord(v)) out.push(v);
else if (Array.isArray(v)) for (const sub of v) walk(sub);
};
walk(coords);
}
return out;
}
/**
* Get the Drawings layer for external access.
* @returns {VectorLayer}
*/
getDrawingsLayer() {
return this.drawingsLayer;
}
/**
* Get the Drawings source for external access.
* @returns {VectorSource}
*/
getDrawingsSource() {
return this.drawingsSource;
}
/**
* Get the EditBar control for external access.
* @returns {EditBar}
*/
getEditBar() {
return this.editBar;
}
/**
* Update the ScaleBar units ('metric' or 'imperial').
* @param {'metric'|'imperial'} system
*/
setScaleBarUnits(system) {
if (this.scaleBar) {
this.scaleBar.setUnits(system === 'imperial' ? 'imperial' : 'metric');
}
}
/**
* Create the popup overlay element and add to map
*/
createPopup() {
// Create popup container element
this.popupElement = document.createElement('div');
this.popupElement.className = 'map-popup';
this.popupElement.style.cssText = `
position: absolute;
background: var(--card, #fff);
color: var(--card-foreground, #1e1a4b);
border-radius: 8px;
padding: 10px 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 13px;
min-width: 150px;
max-width: 280px;
pointer-events: none;
z-index: 1000;
border: 1px solid var(--border, #1e1a4b1f);
`;
// Create the overlay
this.popup = new Overlay({
element: this.popupElement,
positioning: 'bottom-center',
offset: [0, -15],
stopEvent: false,
});
this.map.addOverlay(this.popup);
// Set up hover handler
this.setupHoverPopup();
}
/**
* Set up the hover popup behavior
*/
setupHoverPopup() {
let currentFeature = null;
this.map.on('pointermove', (evt) => {
if (evt.dragging) {
this.hidePopup();
return;
}
// Only find features that are location markers (have 'name' property)
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => {
// Only return features that have a 'name' property (location markers)
if (f.get('name')) {
return f;
}
return null;
});
if (feature && feature !== currentFeature) {
currentFeature = feature;
this.showPopup(feature, evt.coordinate);
} else if (!feature && currentFeature) {
currentFeature = null;
this.hidePopup();
}
// Update cursor - only show pointer for location markers
this.map.getTargetElement().style.cursor = feature ? 'pointer' : '';
});
// Hide popup when mouse leaves the map
this.map.getTargetElement().addEventListener('mouseleave', () => {
this.hidePopup();
currentFeature = null;
});
}
/**
* Show popup with feature attributes
*/
showPopup(feature, coordinate) {
const name = feature.get('name') || 'Unnamed';
const category = feature.get('category') || 'default';
const description = feature.get('description');
const lon = feature.get('lon');
const lat = feature.get('lat');
const emoji = this.getEmoji(category);
// Build popup content
let html = `
${emoji} ${this.escapeHtml(name)}
`;
// Category badge
const categoryColors = {
'water': '#3b82f6',
'school': '#f59e0b',
'health': '#ef4444',
'market': '#8b5cf6',
'default': '#2d5016',
'other': '#6b7280'
};
const catColor = categoryColors[category] || '#6b7280';
html += `
${category}
`;
// Description if available
if (description) {
html += `
${this.escapeHtml(description)}
`;
}
// Coordinates
if (lon !== undefined && lat !== undefined) {
html += `
${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)}
`;
}
this.popupElement.innerHTML = html;
this.popup.setPosition(coordinate);
}
/**
* Hide the popup
*/
hidePopup() {
this.popup.setPosition(undefined);
}
/**
* Create the info popup overlay for double-click feature details
*/
createInfoPopup() {
this.infoPopupElement = document.createElement('div');
this.infoPopupElement.className = 'map-info-popup';
this.infoPopupElement.style.cssText = `
position: absolute;
background: var(--card, #fff);
color: var(--card-foreground, #1e1a4b);
border-radius: 10px;
padding: 0;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 13px;
min-width: 220px;
max-width: 320px;
max-height: 70vh;
display: flex;
flex-direction: column;
z-index: 1001;
border: 1px solid var(--border, #1e1a4b1f);
overflow: hidden;
`;
this.infoPopup = new Overlay({
element: this.infoPopupElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: true,
autoPan: true,
autoPanAnimation: { duration: 250 },
});
this.map.addOverlay(this.infoPopup);
}
/**
* Show the info popup with feature attributes and area
* @param {Feature} feature - OpenLayers feature
* @param {Array} coordinate - Map coordinate [x, y]
* @param {Object} [options] - Display options
* @param {string} [options.title='Feature Info'] - Popup header title
* @param {string} [options.color='#e11d48'] - Header background colour
*/
showInfoPopup(feature, coordinate, options = {}) {
const { title = 'Feature Info', color = '#e11d48' } = options;
const properties = feature.getProperties();
const geometry = feature.getGeometry();
const geomType = geometry.getType();
// Build attributes table rows (skip geometry and internal keys)
const skipKeys = ['geometry', '_layerType'];
let rows = '';
for (const [key, value] of Object.entries(properties)) {
if (skipKeys.includes(key) || value === undefined || value === null) continue;
rows += `
${this.escapeHtml(key)}
${this.escapeHtml(String(value))}
`;
}
// Add measurement row based on geometry type
if (geomType === 'Polygon' || geomType === 'MultiPolygon') {
// Area for polygons
const areaSqm = getArea(geometry, { projection: 'EPSG:3857' });
const areaFormatted = formatAreaFull(areaSqm);
rows += `
area
${areaFormatted}
`;
} else if (geomType === 'LineString' || geomType === 'MultiLineString') {
// Length for lines
const lengthM = getLength(geometry, { projection: 'EPSG:3857' });
const lengthFormatted = formatLengthFull(lengthM);
rows += `
length
${lengthFormatted}
`;
} else if (geomType === 'Point') {
// Coordinates for points
const coords = toLonLat(geometry.getCoordinates());
const lon = coords[0].toFixed(6);
const lat = coords[1].toFixed(6);
rows += `
longitude
${lon}
latitude
${lat}
`;
}
const html = `
${this.escapeHtml(title)}
`;
this.infoPopupElement.innerHTML = html;
this.infoPopup.setPosition(coordinate);
// Close button handler
this.infoPopupElement.querySelector('#info-popup-close').addEventListener('click', () => {
this.hideInfoPopup();
});
}
/**
* Hide the info popup
*/
hideInfoPopup() {
this.infoPopup.setPosition(undefined);
}
// ============================================================================
// Circle Intersection Analysis
// ============================================================================
/**
* Analyse which features from overlay layers intersect a measurement circle
* and show the results in the info popup.
*
* @param {Feature} circleFeature - The measurement circle feature (Circle geometry)
* @param {Array} coordinate - Map coordinate for popup placement [x, y]
*/
/**
* Collect intersection results (parcels, zones, other) into a
* structured { label, value } array for both HTML and PDF rendering.
*/
_collectIntersectionRows(parcelFeatures, zoneFeatures, otherByLayer) {
const dataRows = [];
if (parcelFeatures.length > 0) {
dataRows.push({ label: 'Parcels', value: String(parcelFeatures.length), color: '#0ea5e9' });
}
if (zoneFeatures.length > 0) {
const names = zoneFeatures.map(f =>
f.get('colzonename') || f.get('zone_name') || f.get('name') || 'unnamed'
);
dataRows.push({ label: 'Zones', value: String(zoneFeatures.length), color: '#7c3aed' });
dataRows.push({ label: 'Zone Names', value: names.map(n => this.escapeHtml(n)).join(', '), color: '#7c3aed' });
}
for (const [title, features] of Object.entries(otherByLayer)) {
dataRows.push({ label: this.escapeHtml(title), value: `${features.length} feature(s)` });
}
if (dataRows.length === 0) {
dataRows.push({ label: '', value: 'No intersecting features found', empty: true });
}
return dataRows;
}
/**
* Build the full popup HTML for an analysis popup (circle or area).
*
* @param {string} emoji - Header emoji
* @param {string} title - e.g. "Circle Analysis"
* @param {Array<{label:string, value:string, color?:string, empty?:boolean}>} dataRows
* @returns {string} HTML
*/
_buildAnalysisPopupHtml(emoji, title, dataRows) {
let tableRows = '';
for (const row of dataRows) {
if (row.empty) {
tableRows += `
${row.value}
`;
continue;
}
const labelColor = row.color || 'var(--muted-foreground, #7a7a7a)';
const border = row._first ? '' : 'border-top:1px solid var(--border, #1e1a4b1f);';
tableRows += `
${row.label}
${row.value}
`;
}
return `
${emoji} ${title}
`;
}
/**
* 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 += `
${escapedKey}
`;
}
const html = `
✏️ Edit Parcel
×
`;
this.parcelEditElement.innerHTML = html;
this.parcelEditPopup.setPosition(coordinate);
// Close / Cancel handlers
this.parcelEditElement.querySelector('.parcel-edit-close').addEventListener('click', () => {
this.hideParcelEditPopup();
});
this.parcelEditElement.querySelector('.parcel-edit-cancel').addEventListener('click', () => {
this.hideParcelEditPopup();
});
// Form submit handler
const form = this.parcelEditElement.querySelector('.parcel-edit-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
// Collect all edited values
const formData = new FormData(form);
const updatedProps = {};
for (const [key, value] of formData.entries()) {
updatedProps[key] = value;
}
// Restore internal properties that were excluded from the form
updatedProps._layerType = 'parcel';
// Update the feature's properties in-place
for (const [key, value] of Object.entries(updatedProps)) {
this._parcelEditFeature.set(key, value);
}
// Notify external listeners
for (const cb of this._parcelEditCallbacks) {
cb(this._parcelEditFeature, updatedProps);
}
this.hideParcelEditPopup();
});
}
/**
* Hide the parcel edit popup.
*/
hideParcelEditPopup() {
this.parcelEditPopup.setPosition(undefined);
this._parcelEditFeature = null;
}
/**
* Register a callback for when a parcel edit is saved.
* Callback receives (feature, updatedProperties).
*
* @param {Function} callback
*/
onParcelEdit(callback) {
this._parcelEditCallbacks.push(callback);
}
// ============================================================================
// Merge Identifier (UPN) Chooser Popup
// ============================================================================
/**
* Create the merge identifier popup overlay.
* Shown after two parcels are merged so the user can choose which UPN to keep.
*/
createMergePopup() {
this.mergePopupElement = document.createElement('div');
this.mergePopupElement.className = 'map-merge-popup';
this.mergePopupElement.style.cssText = `
position: absolute;
background: var(--card, #fff);
color: var(--card-foreground, #1e1a4b);
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 13px;
min-width: 280px;
max-width: 360px;
z-index: 1002;
border: 2px solid #10b981;
overflow: hidden;
display: flex;
flex-direction: column;
`;
this.mergePopup = new Overlay({
element: this.mergePopupElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: true,
autoPan: true,
autoPanAnimation: { duration: 250 },
});
this.map.addOverlay(this.mergePopup);
}
/**
* Show the merge identifier popup so the user can pick which parcel's
* attributes (including UPN) the merged polygon should inherit.
*
* @param {Feature} mergedFeature The newly created merged feature
* @param {Object} propsA Properties from original parcel A
* @param {Object} propsB Properties from original parcel B
* @param {Array} coordinate Map coordinate [x, y] for popup placement
*/
showMergeIdentifierPopup(mergedFeature, propsA, propsB, coordinate) {
// Extract identifiers — try common parcel ID field names
const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID'];
const getLabel = (props) => {
for (const field of idFields) {
if (props[field] !== undefined && props[field] !== null && String(props[field]).trim()) {
return { field, value: String(props[field]) };
}
}
return { field: 'id', value: 'Unknown' };
};
const labelA = getLabel(propsA);
const labelB = getLabel(propsB);
const html = `
🔗 Merged Parcel — Choose Identifier
Select which parcel's attributes the merged polygon should keep:
Parcel A
${this.escapeHtml(labelA.field)}: ${this.escapeHtml(labelA.value)}
Parcel B
${this.escapeHtml(labelB.field)}: ${this.escapeHtml(labelB.value)}
`;
this.mergePopupElement.innerHTML = html;
this.mergePopup.setPosition(coordinate);
// Close / Cancel — keep parcel A properties (the default from clone)
const close = () => {
this.mergePopup.setPosition(undefined);
};
this.mergePopupElement.querySelector('.merge-popup-close').addEventListener('click', close);
this.mergePopupElement.querySelector('.merge-popup-cancel').addEventListener('click', close);
// Confirm — apply chosen parcel's properties
this.mergePopupElement.querySelector('.merge-popup-confirm').addEventListener('click', () => {
const choice = this.mergePopupElement.querySelector('input[name="merge-choice"]:checked').value;
const chosenProps = choice === 'A' ? propsA : propsB;
// Copy all properties (except geometry) onto the merged feature
const skipKeys = ['geometry'];
for (const [key, value] of Object.entries(chosenProps)) {
if (skipKeys.includes(key)) continue;
mergedFeature.set(key, value);
}
// Ensure _layerType is preserved
mergedFeature.set('_layerType', 'parcel');
// Notify parcel edit callbacks
for (const cb of this._parcelEditCallbacks) {
cb(mergedFeature, chosenProps);
}
close();
});
// Highlight radio labels on selection
const labels = this.mergePopupElement.querySelectorAll('label');
const radios = this.mergePopupElement.querySelectorAll('input[name="merge-choice"]');
const updateHighlight = () => {
labels.forEach((lbl) => {
const radio = lbl.querySelector('input');
lbl.style.borderColor = radio.checked ? (radio.value === 'A' ? '#0ea5e9' : '#f59e0b') : 'var(--border, #1e1a4b1f)';
});
};
radios.forEach((r) => r.addEventListener('change', updateHighlight));
updateHighlight();
}
// ============================================================================
// Divide Polygon Popup (number input)
// ============================================================================
/**
* Create the divide polygon popup overlay.
* Shown after the user selects a polygon with the Divide tool, so they
* can enter the number of equal pieces.
*/
createDividePopup() {
this.dividePopupElement = document.createElement('div');
this.dividePopupElement.className = 'map-divide-popup';
this.dividePopupElement.style.cssText = `
position: absolute;
background: var(--card, #fff);
color: var(--card-foreground, #1e1a4b);
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
font-family: var(--font-body, 'Exo', sans-serif);
font-size: 13px;
min-width: 260px;
max-width: 320px;
z-index: 1002;
border: 2px solid #8b5cf6;
overflow: hidden;
display: flex;
flex-direction: column;
`;
this.dividePopup = new Overlay({
element: this.dividePopupElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: true,
autoPan: true,
autoPanAnimation: { duration: 250 },
});
this.map.addOverlay(this.dividePopup);
}
/**
* Show the divide popup so the user can enter the number of divisions.
*
* @param {Feature} feature The selected polygon feature
* @param {VectorSource} source The source containing the feature
* @param {Array} coordinate Map coordinate [x, y] for popup placement
*/
showDividePopup(feature, source, coordinate) {
const html = `
Divide Polygon
`;
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 += `
${escapedKey}
`;
}
// Area display
const geom = feature.getGeometry();
const areaSqm = getArea(geom, { projection: 'EPSG:3857' });
const areaFormatted = formatArea(areaSqm);
const html = `
📐 Polygon Attributes
×
Area: ${areaFormatted}
`;
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 = `
`;
// Create the overlay
this.addLocationPopup = new Overlay({
element: this.addLocationPopupElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: true, // Prevent click from propagating
autoPan: true,
autoPanAnimation: {
duration: 250,
},
});
this.map.addOverlay(this.addLocationPopup);
// Store clicked coordinates
this.addLocationCoords = null;
// Set up close button handler
const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close');
closeBtn.addEventListener('click', () => {
this.hideAddLocationPopup();
});
// Store form submit callbacks
this.addLocationCallbacks = [];
}
/**
* Show the Add Location popup at the specified coordinate
*/
showAddLocationPopup(coordinate) {
const [lon, lat] = toLonLat(coordinate);
this.addLocationCoords = { lon, lat };
// Update coordinates display
const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords');
coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`;
// Reset form
const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
form.reset();
// Position and show popup
this.addLocationPopup.setPosition(coordinate);
}
/**
* Hide the Add Location popup
*/
hideAddLocationPopup() {
this.addLocationPopup.setPosition(undefined);
this.addLocationCoords = null;
}
/**
* Register a callback for when a location is submitted via the map popup
* Callback receives: { name, category, description, lon, lat }
*/
onAddLocation(callback) {
this.addLocationCallbacks.push(callback);
// Set up form submit handler (only once)
if (this.addLocationCallbacks.length === 1) {
const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
if (!this.addLocationCoords) return;
const formData = new FormData(form);
const data = {
name: formData.get('name'),
category: formData.get('category'),
description: formData.get('description'),
lon: this.addLocationCoords.lon,
lat: this.addLocationCoords.lat,
};
// Call all registered callbacks
this.addLocationCallbacks.forEach(cb => cb(data));
// Hide popup after submission
this.hideAddLocationPopup();
});
}
}
/**
* Create base layers group for LayerSwitcher
*/
createBaseLayers(defaultBasemap) {
const topoLayer = new TileLayer({
title: 'Topographic',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'topo',
source: new XYZ({
url: 'https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png',
attributions: 'Map data: © OpenTopoMap',
maxZoom: 17,
crossOrigin: 'anonymous',
}),
});
topoLayer.set('basemapKey', 'topo');
const cartoLightLayer = new TileLayer({
title: 'Carto Light',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'carto-light',
source: new XYZ({
url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
attributions: '© CARTO',
maxZoom: 19,
crossOrigin: 'anonymous',
}),
});
cartoLightLayer.set('basemapKey', 'carto-light');
const cartoDarkLayer = new TileLayer({
title: 'Carto Dark',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'carto-dark',
source: new XYZ({
url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
attributions: '© CARTO',
maxZoom: 19,
crossOrigin: 'anonymous',
}),
});
cartoDarkLayer.set('basemapKey', 'carto-dark');
const osmCycleLayer = new TileLayer({
title: 'OSM Cycle map',
type: 'base',
zIndex: -100,
visible: false, //defaultBasemap === 'osm',
source: new OSM({
"url" : "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=ae1339c46dd3446b9c491e7336d38760"
}),
});
osmCycleLayer.set('basemapKey', 'cycle');
const satelliteLayer = new TileLayer({
title: 'Satellite',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'satellite',
source: new XYZ({
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attributions: 'Tiles © Esri',
maxZoom: 19,
crossOrigin: 'anonymous',
}),
});
satelliteLayer.set('basemapKey', 'satellite');
const googleLayer = new TileLayer({
title: 'Google Sat',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'googlesat',
source: new XYZ({
// url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
url: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga',
attributions: 'Tiles © Google',
maxZoom: 19,
crossOrigin: 'anonymous',
}),
});
googleLayer.set('basemapKey', 'googlesat');
const osmLayer = new TileLayer({
title: 'OpenStreetMap',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'osm',
source: new OSM(),
});
osmLayer.set('basemapKey', 'osm');
// Remember the base-map layers so setBaseMap() can toggle visibility later
this._baseMapLayers = [
cartoLightLayer, cartoDarkLayer, osmCycleLayer,
satelliteLayer, googleLayer, osmLayer, topoLayer,
];
// Return LayerGroup for LayerSwitcher
// Note: ol-ext LayerSwitcher iterates layers in reverse — the LAST item
// in this array appears at the TOP of the base-map list in the UI.
return new LayerGroup({
title: 'Base Maps',
layers: [
cartoLightLayer,
cartoDarkLayer,
satelliteLayer,
osmCycleLayer,
googleLayer,
osmLayer,
topoLayer, // ← displayed at the top of the base map stack
],
});
}
/**
* Switch the active base map by key.
* Sets exactly one base layer visible; hides all others.
*
* @param {string} key Basemap key: 'topo' | 'osm' | 'satellite' | 'googlesat' | 'carto-light' | 'carto-dark' | 'cycle'
* @returns {boolean} true if the key matched a known base layer
*/
setBaseMap(key) {
if (!this._baseMapLayers) return false;
let matched = false;
for (const layer of this._baseMapLayers) {
const on = layer.get('basemapKey') === key;
layer.setVisible(on);
if (on) matched = true;
}
if (matched) console.log('[MapView] Base map switched to:', key);
return matched;
}
/**
* Get style for a feature (handles selection state)
*/
getFeatureStyle(feature) {
const category = feature.get('category') || 'default';
const emoji = this.getEmoji(category);
if (feature === this.selectedFeature) {
// Return selected style with the correct emoji and highlight
return [
// Background highlight circle
new Style({
image: new Circle({
radius: 22,
fill: new Fill({ color: 'rgba(220, 38, 38, 0.25)' }),
stroke: new Stroke({ color: '#dc2626', width: 3 }),
}),
}),
// Emoji on top, larger
new Style({
text: new Text({
text: emoji,
font: '40px sans-serif',
textBaseline: 'bottom',
textAlign: 'center',
offsetY: -5,
}),
}),
];
}
// Check for custom style
const customStyle = feature.get('style');
if (customStyle) {
return customStyle;
}
// Return category-based emoji style
if (this.categoryStyles[category]) {
return this.categoryStyles[category];
}
return this.defaultStyle;
}
/**
* Set category-based styles with emojis
* @param {Object} styles - Map of category to config { emoji, label, fontSize }
*/
setCategoryStyles(styles) {
for (const [category, config] of Object.entries(styles)) {
// Update category mapping if provided
if (config.emoji) {
if (!this.categoryEmojis[category]) {
this.categoryEmojis[category] = { emoji: config.emoji, label: config.label || category };
} else {
this.categoryEmojis[category].emoji = config.emoji;
if (config.label) {
this.categoryEmojis[category].label = config.label;
}
}
}
// Create/update style
const emoji = this.getEmoji(category);
const fontSize = config.fontSize || 28;
this.categoryStyles[category] = this.createEmojiStyle(emoji, fontSize);
}
// Refresh markers
this.markerSource.changed();
}
/**
* Add a single marker
*/
addMarker(lon, lat, properties = {}) {
console.log('[MapView] Adding marker at', lon, lat, 'with properties:', properties);
const feature = new Feature({
geometry: new Point(fromLonLat([lon, lat])),
...properties,
});
// Store original coordinates for easy access
feature.set('lon', lon);
feature.set('lat', lat);
this.markerSource.addFeature(feature);
console.log('[MapView] Marker added, total features:', this.markerSource.getFeatures().length);
return feature;
}
/**
* Add multiple markers from an array of location objects
*/
addMarkers(locations) {
console.log('[MapView] Adding', locations.length, 'markers');
const features = locations.map((loc) => {
const feature = new Feature({
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
id: loc.id,
name: loc.name,
description: loc.description,
category: loc.category,
lon: loc.longitude,
lat: loc.latitude,
});
return feature;
});
this.markerSource.addFeatures(features);
console.log('[MapView] Markers added, total features:', this.markerSource.getFeatures().length);
return features;
}
/**
* Clear all markers
*/
clearMarkers() {
this.markerSource.clear();
this.selectedFeature = null;
}
/**
* Remove a specific marker by feature or ID
*/
removeMarker(featureOrId) {
if (typeof featureOrId === 'object') {
this.markerSource.removeFeature(featureOrId);
} else {
const feature = this.markerSource.getFeatures().find(
f => f.get('id') === featureOrId
);
if (feature) {
this.markerSource.removeFeature(feature);
}
}
}
/**
* Get all markers
*/
getMarkers() {
return this.markerSource.getFeatures();
}
/**
* Find marker by ID
*/
findMarker(id) {
return this.markerSource.getFeatures().find(f => f.get('id') === id);
}
/**
* Select a marker (highlights it)
*/
selectMarker(featureOrId) {
if (typeof featureOrId === 'object') {
this.selectedFeature = featureOrId;
} else {
this.selectedFeature = this.findMarker(featureOrId);
}
this.markerSource.changed();
return this.selectedFeature;
}
/**
* Clear selection
*/
clearSelection() {
this.selectedFeature = null;
this.markerSource.changed();
}
/**
* Zoom to a specific location
*/
zoomTo(lon, lat, zoom = 15) {
this.map.getView().animate({
center: fromLonLat([lon, lat]),
zoom: zoom,
duration: 500,
});
}
/**
* Fit view to show all markers
*/
fitToMarkers(padding = 50) {
const extent = this.markerSource.getExtent();
if (extent && extent[0] !== Infinity) {
this.map.getView().fit(extent, {
padding: [padding, padding, padding, padding],
duration: 500,
maxZoom: 16,
});
}
}
/**
* Get current map center in lon/lat
*/
getCenter() {
const center = this.map.getView().getCenter();
return toLonLat(center);
}
/**
* Get current zoom level
*/
getZoom() {
return this.map.getView().getZoom();
}
/**
* Set map center
*/
setCenter(lon, lat) {
this.map.getView().setCenter(fromLonLat([lon, lat]));
}
/**
* Set zoom level
*/
setZoom(zoom) {
this.map.getView().setZoom(zoom);
}
/**
* Register click callback
* Callback receives (lon, lat, feature, event)
*
* Single-click is delayed by 300 ms so that a double-click can cancel it.
* If the click lands on an overlay feature (e.g. district boundary) the
* single-click is suppressed entirely — only double-click will fire.
*/
onClick(callback) {
this.clickCallbacks.push(callback);
// Set up click handler if this is the first callback
if (this.clickCallbacks.length === 1) {
this._clickTimer = null;
// Double-click cancels any pending single-click
this.map.on('dblclick', () => {
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = null;
}
});
this.map.on('click', (evt) => {
// Cancel any previous pending click
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = null;
}
// When NOT in edit / draw mode, immediately clear any feature
// the Select interaction may have grabbed on this click so the
// user never sees a selection flash.
if (!this._editBarActive && this._selectInteraction) {
this._selectInteraction.getFeatures().clear();
}
// Check what features sit under the click pixel
let hasOverlayFeature = false;
let hasParcelFeature = false;
let markerFeature = null;
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (feature.get('_layerType') === 'parcel') {
hasParcelFeature = true;
}
if (feature.get('name')) {
markerFeature = feature;
}
hasOverlayFeature = true;
});
// If an overlay feature was hit, suppress single-click
// UNLESS it's a parcel or a location marker
if (hasOverlayFeature && !hasParcelFeature && !markerFeature) {
return;
}
// Delay the single-click to allow double-click to cancel it
const [lon, lat] = toLonLat(evt.coordinate);
this._clickTimer = setTimeout(() => {
this._clickTimer = null;
// Find location marker at pixel
let clickedFeature = null;
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (feature.get('name')) {
clickedFeature = feature;
return true;
}
});
for (const cb of this.clickCallbacks) {
cb(lon, lat, clickedFeature, evt);
}
}, 300);
});
}
// Return unsubscribe function
return () => {
const index = this.clickCallbacks.indexOf(callback);
if (index > -1) {
this.clickCallbacks.splice(index, 1);
}
};
}
/**
* Register pointer move callback (for hover effects)
*/
onPointerMove(callback) {
this.map.on('pointermove', (evt) => {
if (evt.dragging) return;
const [lon, lat] = toLonLat(evt.coordinate);
// Only find location markers (features with 'name' property)
let hoveredFeature = null;
this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (feature.get('name')) {
hoveredFeature = feature;
return true;
}
});
// Change cursor
this.map.getTargetElement().style.cursor = hoveredFeature ? 'pointer' : '';
callback(lon, lat, hoveredFeature, evt);
});
}
/**
* Enable cursor change on marker hover
* Note: This is now handled automatically by the popup system
*/
enableHoverCursor() {
// Cursor changes are now handled by setupHoverPopup()
// This method is kept for backwards compatibility
}
/**
* Add a GeoJSON layer (visible in LayerSwitcher).
* By default the layer is added to the root overlay group.
* Pass a targetGroup (LayerGroup) to nest it inside a specific group.
*
* @param {Object} geojson - GeoJSON FeatureCollection or Feature
* @param {string} title - Layer title for the LayerSwitcher
* @param {Object} [styleOptions] - Optional style configuration
* @param {string} [styleOptions.strokeColor='#3b82f6'] - Stroke color
* @param {number} [styleOptions.strokeWidth=2] - Stroke width
* @param {string} [styleOptions.fillColor='rgba(59,130,246,0.1)'] - Fill color
* @param {LayerGroup} [targetGroup] - Optional group to add the layer to
* @returns {VectorLayer} The created layer
*/
addGeoJSONLayer(geojson, title, styleOptions = {}, targetGroup = null) {
const {
strokeColor = '#3b82f6',
strokeWidth = 2,
fillColor = 'rgba(59,130,246,0.1)',
// Optional line "casing": a thicker darker stroke drawn UNDERNEATH the
// main stroke. Used for road-like layers to make light-colored lines
// visible on any base map. Set lineCasingColor to enable; the casing
// width defaults to strokeWidth + 2.
lineCasingColor = null,
lineCasingWidth = null,
pointRadius = 5,
pointFillColor = null, // defaults to strokeColor
pointStrokeColor = '#ffffff',
pointStrokeWidth = 1.5,
} = styleOptions;
const source = new VectorSource({
features: new GeoJSON().readFeatures(geojson, {
featureProjection: 'EPSG:3857',
}),
});
// Build per-geometry styles. OpenLayers picks `image` for Point /
// MultiPoint, `stroke`+`fill` for Polygon / MultiPolygon, and `stroke`
// alone for LineString / MultiLineString. Putting all three on a single
// Style is enough — but a Style with only stroke+fill leaves Points
// invisible, which is what was happening on shapefile import.
const fillStyle = new Fill({ color: fillColor });
const pointStyle = new Circle({
radius: pointRadius,
fill: new Fill({ color: pointFillColor || strokeColor }),
stroke: new Stroke({ color: pointStrokeColor, width: pointStrokeWidth }),
});
// If a line casing is requested, return an array of two Styles per
// feature: the casing renders first (underneath), then the inner stroke.
// For polygons the casing also outlines them; for points the casing has
// no effect (Point geometries only render `image`).
let layerStyle;
if (lineCasingColor) {
const casingW = lineCasingWidth != null ? lineCasingWidth : strokeWidth + 2;
layerStyle = [
new Style({
stroke: new Stroke({ color: lineCasingColor, width: casingW }),
}),
new Style({
stroke: new Stroke({ color: strokeColor, width: strokeWidth }),
fill: fillStyle,
image: pointStyle,
}),
];
} else {
layerStyle = new Style({
stroke: new Stroke({ color: strokeColor, width: strokeWidth }),
fill: fillStyle,
image: pointStyle,
});
}
const layer = new VectorLayer({
title: title,
source: source,
style: layerStyle,
});
const group = targetGroup || this.overlayGroup;
group.getLayers().push(layer);
console.log('[MapView] GeoJSON layer added:', title, '→', source.getFeatures().length, 'features',
targetGroup ? `(in group "${targetGroup.get('title')}")` : '');
return layer;
}
/**
* Add a LayerGroup to the overlay group.
* Used to create layer categories from the remote catalogue;
* individual vector layers will be added into these groups later.
*
* @param {number|string} id - Unique layer group id (from the API)
* @param {string} title - Group title for the LayerSwitcher
* @param {string} [description=''] - Group description (stored as property)
* @returns {LayerGroup} The created (empty) layer group
*/
addLayerGroup(id, title, description = '') {
const group = new LayerGroup({
title: title.trim(),
});
// Store metadata for later use
group.set('layerId', id);
group.set('description', description);
this.overlayGroup.getLayers().push(group);
console.log('[MapView] Layer group added:', title.trim(), '(id:', id + ')');
return group;
}
/**
* Add a WMS layer to a layer group.
*
* @param {string} groupTitle Title of the target LayerGroup (e.g. 'Biophysical Environment')
* @param {string} title Display title for the layer
* @param {string} url WMS server URL
* @param {string} layers WMS LAYERS parameter
* @param {Object} [options] Extra options
* @param {string} [options.serverType='geoserver'] Server type hint ('geoserver'|'mapserver'|'qgis'|null)
* @param {string} [options.style] WMS STYLES parameter (e.g. 'colours' for DEAfrica DEM)
* @param {boolean} [options.visible=true] Initial visibility
* @param {string} [options.attributions] Attribution HTML
* @param {number} [options.opacity=1] Layer opacity (0–1). Use ~0.5 for background-style layers.
* @param {number} [options.zIndex] Render z-index. Use negative values (e.g. -10) to force the
* layer behind all default-z-index layers regardless of group order.
* @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible.
* @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on
* while offline, explaining that the layer requires connectivity.
* @returns {TileLayer|null} The created layer, or null if group not found
*/
addWMSLayer(groupTitle, title, url, layers, options = {}) {
const group = this.getLayerGroupByTitle(groupTitle);
if (!group) {
console.warn(`[MapView] Layer group "${groupTitle}" not found — cannot add WMS layer "${title}"`);
return null;
}
const params = { LAYERS: layers, TILED: true, WIDTH: 256, HEIGHT: 256 };
if (options.style !== undefined) params.STYLES = options.style;
const wmsSource = new TileWMS({
url,
params,
serverType: options.serverType !== undefined ? options.serverType : 'geoserver',
crossOrigin: 'anonymous',
hidpi: false,
attributions: options.attributions,
});
const wmsLayer = new TileLayer({
title,
visible: options.visible !== undefined ? options.visible : true,
source: wmsSource,
opacity: options.opacity !== undefined ? options.opacity : 1,
zIndex: options.zIndex,
});
// Show toast on tile load errors (e.g. server rejects request)
wmsSource.on('tileloaderror', () => {
showToast(`WMS layer "${title}" — tile load error. Check the URL and layer name.`, 'warning', 5000);
});
group.getLayers().push(wmsLayer);
// Register legend AFTER push so that a failure here doesn't block the LayerSwitcher
if (options.legendUrl) {
try {
this._registerLegend(wmsLayer, title, options.legendUrl);
} catch (err) {
console.warn(`[MapView] Could not register legend for "${title}":`, err);
}
}
// Online-only warning: when the user toggles the layer on while offline,
// surface a toast explaining why nothing will render.
if (options.onlineOnly) {
this._attachOnlineOnlyHandler(wmsLayer, title);
}
console.log(`[MapView] WMS layer added: "${title}" → group "${groupTitle}"`);
return wmsLayer;
}
/**
* Add an XYZ tile layer to a layer group.
*
* @param {string} groupTitle Title of the target LayerGroup
* @param {string} title Display title for the layer
* @param {string} url XYZ tile URL template (with {z}/{x}/{y} placeholders)
* @param {Object} [options] Extra options
* @param {boolean} [options.visible=true] Initial visibility
* @param {string} [options.attributions] Attribution HTML
* @param {number} [options.maxZoom=19] Maximum zoom level
* @param {number} [options.opacity=1] Layer opacity (0–1). Use ~0.5 for background-style layers.
* @param {number} [options.zIndex] Render z-index. Use negative values to force behind other layers.
* @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible.
* @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on
* while offline, explaining that the layer requires connectivity.
* @returns {TileLayer|null} The created layer, or null if group not found
*/
addXYZLayer(groupTitle, title, url, options = {}) {
const group = this.getLayerGroupByTitle(groupTitle);
if (!group) {
console.warn(`[MapView] Layer group "${groupTitle}" not found — cannot add XYZ layer "${title}"`);
return null;
}
const xyzSource = new XYZ({
url,
crossOrigin: 'anonymous',
maxZoom: options.maxZoom !== undefined ? options.maxZoom : 19,
attributions: options.attributions,
});
const xyzLayer = new TileLayer({
title,
visible: options.visible !== undefined ? options.visible : true,
source: xyzSource,
opacity: options.opacity !== undefined ? options.opacity : 1,
zIndex: options.zIndex,
});
// Show toast on tile load errors
xyzSource.on('tileloaderror', () => {
showToast(`XYZ layer "${title}" — tile load error. Check the URL.`, 'warning', 5000);
});
group.getLayers().push(xyzLayer);
// Register legend AFTER push so that a failure here doesn't block the LayerSwitcher
if (options.legendUrl) {
try {
this._registerLegend(xyzLayer, title, options.legendUrl);
} catch (err) {
console.warn(`[MapView] Could not register legend for "${title}":`, err);
}
}
// Online-only warning: when the user toggles the layer on while offline,
// surface a toast explaining why nothing will render.
if (options.onlineOnly) {
this._attachOnlineOnlyHandler(xyzLayer, title);
}
console.log(`[MapView] XYZ layer added: "${title}" → group "${groupTitle}"`);
return xyzLayer;
}
// ============================================================================
// Add External Layer Dialog
// ============================================================================
/**
* Create the add-layer dialog overlay (hidden by default).
* Appended to the map target element so it stays within the map viewport.
*/
_createAddLayerDialog() {
this._addLayerDialog = document.createElement('div');
this._addLayerDialog.className = 'map-add-layer-dialog';
this._addLayerDialog.style.cssText = `
display:none;position:absolute;top:0;left:0;right:0;bottom:0;
z-index:1100;background:rgba(0,0,0,0.4);
align-items:center;justify-content:center;
`;
const card = document.createElement('div');
card.style.cssText = `
background:var(--card, #fff);color:var(--card-foreground, #1e1a4b);
border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.35);
font-family:var(--font-body, 'Exo', sans-serif);font-size:13px;
width:340px;max-width:90vw;border:2px solid #10b981;overflow:hidden;
`;
card.innerHTML = `
Add External Layer
×
`;
this._addLayerDialog.appendChild(card);
this.map.getTargetElement().appendChild(this._addLayerDialog);
// Type radio change — toggle layer name row visibility
const nameRow = card.querySelector('.add-layer-name-row');
const nameHint = card.querySelector('.add-layer-name-hint');
const urlInput = card.querySelector('.add-layer-url');
card.querySelectorAll('input[name="add-layer-type"]').forEach((radio) => {
radio.addEventListener('change', () => {
const type = radio.value;
if (type === 'xyz') {
nameRow.style.display = 'none';
urlInput.placeholder = 'https://example.com/tiles/{z}/{x}/{y}.png';
} else {
nameRow.style.display = '';
urlInput.placeholder = type === 'wms'
? 'https://example.com/wms'
: 'https://example.com/wfs';
nameHint.textContent = type === 'wms'
? 'WMS LAYERS parameter (e.g. workspace:layer)'
: 'WFS typename (e.g. workspace:layer)';
}
});
});
// Close / Cancel
const close = () => this._hideAddLayerDialog();
card.querySelector('.add-layer-close').addEventListener('click', close);
card.querySelector('.add-layer-cancel').addEventListener('click', close);
this._addLayerDialog.addEventListener('click', (e) => {
if (e.target === this._addLayerDialog) close();
});
// Confirm
card.querySelector('.add-layer-confirm').addEventListener('click', () => {
const type = card.querySelector('input[name="add-layer-type"]:checked').value;
const url = card.querySelector('.add-layer-url').value.trim();
const layerName = card.querySelector('.add-layer-name').value.trim();
const title = card.querySelector('.add-layer-title').value.trim();
if (!url) {
card.querySelector('.add-layer-url').style.borderColor = '#ef4444';
return;
}
if ((type === 'wms' || type === 'wfs') && !layerName) {
card.querySelector('.add-layer-name').style.borderColor = '#ef4444';
return;
}
if (!title) {
card.querySelector('.add-layer-title').style.borderColor = '#ef4444';
return;
}
this._addExternalLayer(type, url, layerName, title);
this._hideAddLayerDialog();
});
// Enter key to confirm
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
card.querySelector('.add-layer-confirm').click();
}
if (e.key === 'Escape') {
e.preventDefault();
close();
}
});
}
/**
* Show the add-layer dialog.
*/
showAddLayerDialog() {
const dlg = this._addLayerDialog;
// Reset form
dlg.querySelector('.add-layer-url').value = '';
dlg.querySelector('.add-layer-name').value = '';
dlg.querySelector('.add-layer-title').value = '';
dlg.querySelectorAll('input[name="add-layer-type"]')[0].checked = true;
dlg.querySelector('.add-layer-name-row').style.display = '';
dlg.querySelector('.add-layer-url').placeholder = 'https://example.com/wms';
dlg.querySelector('.add-layer-name-hint').textContent = 'WMS LAYERS parameter (e.g. workspace:layer)';
// Reset border colours
dlg.querySelectorAll('input[type="text"]').forEach((inp) => {
inp.style.borderColor = 'var(--border, #1e1a4b1f)';
});
dlg.style.display = 'flex';
dlg.querySelector('.add-layer-url').focus();
}
/**
* Hide the add-layer dialog.
*/
_hideAddLayerDialog() {
this._addLayerDialog.style.display = 'none';
}
/**
* Add an external layer to the "External Source" group.
*
* @param {string} type 'wms' | 'wfs' | 'xyz'
* @param {string} url Server URL
* @param {string} layerName WMS LAYERS / WFS typename (ignored for XYZ)
* @param {string} title Display title in layer switcher
*/
_addExternalLayer(type, url, layerName, title) {
const group = this._externalSourceGroup;
if (!group) {
showToast('Layer group "External Source" not found.', 'error', 4000);
return;
}
let layer;
switch (type) {
case 'wms': {
const wmsSrc = new TileWMS({
url,
params: { LAYERS: layerName, TILED: true, WIDTH: 256, HEIGHT: 256 },
serverType: 'geoserver',
crossOrigin: 'anonymous',
hidpi: false,
});
layer = new TileLayer({
title,
visible: true,
source: wmsSrc,
});
wmsSrc.on('tileloaderror', () => {
showToast(`WMS "${title}" — tile load error. Check URL and layer name.`, 'warning', 5000);
});
break;
}
case 'wfs': {
const wfsUrl = `${url}${url.includes('?') ? '&' : '?'}` +
`service=WFS&version=1.1.0&request=GetFeature` +
`&typename=${encodeURIComponent(layerName)}` +
`&outputFormat=application/json&srsname=EPSG:3857`;
const wfsSource = new VectorSource({
url: wfsUrl,
format: new GeoJSON(),
});
wfsSource.on('featuresloaderror', () => {
showToast(`WFS "${title}" — load error. Check URL and layer name.`, 'warning', 5000);
});
layer = new VectorLayer({
title,
visible: true,
source: wfsSource,
style: new Style({
stroke: new Stroke({ color: '#e11d48', width: 2 }),
fill: new Fill({ color: 'rgba(225,29,72,0.15)' }),
}),
});
break;
}
case 'xyz':
layer = new TileLayer({
title,
visible: true,
source: new XYZ({
url,
crossOrigin: 'anonymous',
}),
});
layer.getSource().on('tileloaderror', () => {
showToast(`XYZ "${title}" — tile load error. Check the URL template.`, 'warning', 5000);
});
break;
default:
showToast(`Unknown layer type: ${type}`, 'error', 4000);
return;
}
group.getLayers().push(layer);
showToast(`Layer "${title}" added to External Source.`, 'success', 3000);
console.log(`[MapView] External ${type.toUpperCase()} layer added: "${title}"`);
}
// ============================================================================
// Online-Only Layer Helper
// ============================================================================
/**
* Attach a `change:visible` listener that shows an info toast when the user
* toggles a layer ON while the device is offline. Used for layers that fetch
* tiles or features from a remote service and therefore have no useful
* cached state.
*
* The check uses navigator.onLine, which is the same signal as the rest of
* the app's online detection.
*
* @param {Layer} layer
* @param {string} title Display title used in the toast message
*/
_attachOnlineOnlyHandler(layer, title) {
layer.set('onlineOnly', true);
layer.on('change:visible', () => {
if (layer.getVisible() && !navigator.onLine) {
showToast(
`"${title}" requires an internet connection. Connect to view this layer.`,
'info',
5000,
);
}
});
}
// ============================================================================
// Legend Panel — shows legend images for visible layers that have one
// ============================================================================
/**
* Create the legend panel, positioned bottom-right inside the map target.
* Hidden when no visible layers have a registered legend.
*/
_createLegendPanel() {
this._legendPanel = document.createElement('div');
this._legendPanel.className = 'map-legend-panel';
this._legendPanel.style.cssText = `
position:absolute;right:10px;bottom:40px;z-index:900;
display:none;flex-direction:column;gap:6px;
background:var(--card, #fff);color:var(--card-foreground, #1e1a4b);
border:1px solid var(--border, #1e1a4b1f);border-radius:8px;
box-shadow:0 4px 12px rgba(0,0,0,0.15);
font-family:var(--font-body, 'Exo', sans-serif);font-size:11px;
max-width:220px;max-height:60%;overflow-y:auto;
padding:8px 10px;
`;
this.map.getTargetElement().appendChild(this._legendPanel);
// Map of layer → { wrapper, title, imgUrl }
this._legendEntries = new Map();
}
/**
* Register a layer's legend image and wire up visibility tracking.
* Called from addWMSLayer / addXYZLayer when a legendUrl is supplied.
*
* @param {Layer} layer The OpenLayers layer
* @param {string} title Display title for the legend header
* @param {string} legendUrl URL of the legend image
*/
_registerLegend(layer, title, legendUrl) {
if (!this._legendPanel) return;
// Build the legend entry — a div with header + image
const wrapper = document.createElement('div');
wrapper.className = 'map-legend-entry';
wrapper.style.cssText = 'border-bottom:1px solid var(--border, #1e1a4b1f);padding-bottom:6px;';
wrapper.innerHTML = `
${this._escapeHtml(title)}
`;
this._legendEntries.set(layer, wrapper);
// Listen for visibility changes. Wrap in try/catch so a DOM error here
// cannot break the LayerSwitcher's click handler (which fires change:visible
// synchronously and relies on a subsequent setTimeout to update the checkbox).
const update = () => {
try { this._updateLegendPanel(); }
catch (err) { console.warn('[MapView] legend panel update failed:', err); }
};
layer.on('change:visible', update);
// Trigger initial state
update();
}
/**
* Refresh the legend panel contents: include entries for each visible
* registered layer, and show/hide the panel based on whether any are visible.
*/
_updateLegendPanel() {
if (!this._legendPanel) return;
// Rebuild children from scratch in a stable order (Map iteration order = insertion order)
const children = [];
for (const [layer, wrapper] of this._legendEntries) {
if (layer.getVisible()) children.push(wrapper);
}
// Remove trailing bottom-border on the last entry for a clean look
this._legendEntries.forEach((w) => {
w.style.borderBottom = '1px solid var(--border, #1e1a4b1f)';
w.style.paddingBottom = '6px';
});
if (children.length > 0) {
children[children.length - 1].style.borderBottom = 'none';
children[children.length - 1].style.paddingBottom = '0';
}
// Swap the DOM children
this._legendPanel.replaceChildren(...children);
this._legendPanel.style.display = children.length > 0 ? 'flex' : 'none';
}
/**
* Escape HTML special characters for safe text insertion.
*/
_escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Find a LayerGroup inside the overlay group by its layerId.
*
* @param {number|string} id - The layerId to search for
* @returns {LayerGroup|null} The matching group, or null
*/
getLayerGroup(id) {
let found = null;
this.overlayGroup.getLayers().forEach((layer) => {
if (layer.get('layerId') === id) {
found = layer;
}
});
return found;
}
/**
* Find a LayerGroup inside the overlay group by its title.
*
* @param {string} title - The group title to search for
* @returns {LayerGroup|null} The matching group, or null
*/
getLayerGroupByTitle(title) {
let found = null;
this.overlayGroup.getLayers().forEach((layer) => {
if (layer.get('title') === title) {
found = layer;
}
});
return found;
}
/**
* Get the overlay LayerGroup for advanced usage
*/
getOverlayGroup() {
return this.overlayGroup;
}
/**
* Get the OpenLayers map instance for advanced usage
*/
getMap() {
return this.map;
}
// ============================================================================
// Extent Helpers (used by offline-tile downloader)
// ============================================================================
/**
* Get the current map view's visible extent in EPSG:3857 (Web Mercator).
* @returns {Array} [minX, minY, maxX, maxY]
*/
getCurrentViewExtent() {
const view = this.map.getView();
const size = this.map.getSize();
if (!size) return null;
return view.calculateExtent(size);
}
/**
* Get the bounding extent of the District Boundary layer (if present).
* Searches the overlay group for a vector layer titled "District Boundary"
* and returns the extent of its source.
*
* @returns {{ extent: Array, title: string } | null}
*/
getDistrictBoundaryExtent() {
let found = null;
const visit = (group) => {
group.getLayers().forEach((layer) => {
if (layer.getLayers) {
visit(layer); // sub-group
} else if (layer.get('title') === 'District Boundary') {
const src = layer.getSource && layer.getSource();
if (src && typeof src.getExtent === 'function') {
const ex = src.getExtent();
if (ex && Number.isFinite(ex[0])) {
found = { extent: ex, title: layer.get('title') };
}
}
}
});
};
visit(this.overlayGroup);
return found;
}
/**
* Get the marker source for advanced usage
*/
getMarkerSource() {
return this.markerSource;
}
/**
* Get the markers layer for advanced usage
*/
getMarkersLayer() {
return this.markersLayer;
}
/**
* Update map size (call after container resize)
*/
updateSize() {
this.map.updateSize();
}
/**
* Register a callback for when a search result is selected
* Callback receives: { coordinate, lonLat: [lon, lat], name, searchResult }
* Navigation to the location happens automatically
*/
onSearchSelect(callback) {
this.searchSelectCallbacks.push(callback);
}
/**
* Navigate/fly to a specific location
* @param {number} lon - Longitude
* @param {number} lat - Latitude
* @param {number} zoom - Zoom level (default: 14)
* @param {number} duration - Animation duration in ms (default: 500)
*/
navigateTo(lon, lat, zoom = 14, duration = 500) {
const coordinate = fromLonLat([lon, lat]);
this.map.getView().animate({
center: coordinate,
zoom: zoom,
duration: duration,
});
}
}
// Export OpenLayers utilities for convenience
export { fromLonLat, toLonLat };
export default MapView;