ekke 26c941b09a Analytical tools Stage 1 + drawing-tool fixes
Stage 1 of the GIS Analytical Tools concept — client-side spatial analysis:

- src/analysis/overlay.js: vector overlays (intersect / clip / difference /
  union-dissolve) on Turf.js, reprojecting to WGS84 and merging attributes
  (intersect keeps both layers', clip keeps only A's).
- src/analysis/zonal.js: vector-in-vector zonal statistics — count / sum /
  mean / min / max / total area per zone, with centroid-in-zone (default) or
  any-overlap membership.
- Bounding-box pre-filtering in both: only genuinely overlapping pairs reach
  the expensive geometry test. 43 zones x 25,004 parcels now completes in
  ~106 ms; previously it was refused as too large.
- src/analysis-modal.js + markup: Analysis panel with "Apply to" scoping —
  whole layer, current map view, selected features, or the catch of a drawn
  Circle/Area. Reached from a new "Analyse" dock button.
- MapView.addCOGLayer() for Cloud-Optimized GeoTIFF display (WebGLTile +
  GeoTIFF source, imported lazily); listVectorLayers(); getSelectedFeatures().
- Circle/Area analysis popup: one "Export" button (PDF folded into the export
  modal as a fourth format, field-rename table hidden for it) plus an
  "Analyse" button that opens the panel pre-scoped to the intersecting
  features.
- vite.config.js: code-split turf, geotiff and pako so the eager bundle is
  unchanged (~283 kB). Giving pako its own chunk also fixes a circular chunk
  between jspdf and geotiff, which share it via fast-png.

Drawing-tool fixes carried in the same working tree:

- Delete/Backspace key deletes the selection via the EditBar's Delete
  interaction (same undoable block as the button).
- Multi-select: shift-click toggles, Ctrl/Cmd-drag box-selects; helper layers
  (vertex overlay, GPS) excluded.
- Undo: split/merge/divide wrapped in undo blocks so one press reverses the
  whole operation; vertex-overlay churn no longer pollutes the undo stack;
  sources are re-scanned and the stack cleared when edit mode is entered, so
  deletes on sub-grouped layers are undoable. NOTE: the undo behaviour is not
  yet confirmed on-device.
- ol-ext TouchCursor gated to touch-only devices, so hybrid touchscreen
  laptops keep the normal cursor.

Service worker v12 -> v13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:09:06 +02:00

4851 lines
178 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';
// NOTE: Cloud-Optimized GeoTIFF support (ol/source/GeoTIFF + ol/layer/WebGLTile)
// is imported lazily inside addCOGLayer(). The underlying geotiff.js library adds
// ~100 kB to the bundle, which shouldn't be paid by field users who never open a
// raster on a metered connection.
import OSM from 'ol/source/OSM';
import XYZ from 'ol/source/XYZ';
import { fromLonLat, toLonLat } from 'ol/proj';
import { Point, LineString, 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 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 DragBox from 'ol/interaction/DragBox';
import { click as clickCondition, platformModifierKeyOnly, shiftKeyOnly } 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';
import '../styles/layerswitcher.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 — hidden at startup; the user enables it from the
// LayerSwitcher when they want to see location markers / category pins.
this.markersLayer = new VectorLayer({
title: 'Markers',
source: this.markerSource,
style: (feature) => this.getFeatureStyle(feature),
visible: false,
});
// 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);
// Apply the LUSPA branded icon to the LayerSwitcher's collapse button.
// Done in JS so the URL respects Vite's BASE_URL — survives deployment
// under any sub-path.
// NOTE: folder name is `app-icons`, NOT `icons` — Apache aliases `/icons/`
// by default to its own directory-listing thumbnails, which would
// intercept this request server-side.
queueMicrotask(() => {
const btn = layerSwitcher.element?.querySelector(':scope > button');
if (btn) {
const baseUrl = (import.meta.env?.BASE_URL || '/').replace(/\/?$/, '/');
btn.style.backgroundImage = `url('${baseUrl}app-icons/luspa-72x72.png')`;
}
});
// ------------------------------------------------------------------
// Decorate each layer's <li> as it's rendered:
// • inject a type-tag chip (WMS / XYZ / VEC / …) next to the label
// • add a green "+" button to the "External Source" group header
// After each draw cycle, refresh the panel chrome (active count badge
// + footer reset button). Schedule once per cycle via a microtask.
// ------------------------------------------------------------------
let _lsChromeScheduled = false;
layerSwitcher.on('drawlist', (evt) => {
this._decorateLayerListItem(evt.layer, evt.li);
if (!_lsChromeScheduled) {
_lsChromeScheduled = true;
queueMicrotask(() => {
_lsChromeScheduled = false;
this._refreshLayerSwitcherChrome(layerSwitcher);
});
}
});
// Re-render the chrome whenever any layer's visibility changes (so the
// active-count badge updates even when the user toggles via the panel).
this.map.getLayers().on('change', () => {
this._refreshLayerSwitcherChrome(layerSwitcher);
});
// Hook visibility events on every layer (recursive into groups).
this._wireLayerSwitcherVisibilityHooks(layerSwitcher);
// 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);
// GPS rendering layers (current position + recorded trail) and the
// expandable "My Location" control (Locate Me + Record Trail sub-buttons).
this._initGpsRendering();
this._createLocationControl();
// Dedicated base-map picker — sits above the My Location button
this._createBaseMapPicker();
// 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.
// Find Overlays by reference rather than assuming it is the last layer —
// other layers (e.g. the GPS trail/position layers added by
// _initGpsRendering) may sit on top of it.
const mapLayers = this.map.getLayers();
const overlayIdx = mapLayers.getArray().indexOf(this.overlayGroup);
mapLayers.insertAt(overlayIdx >= 0 ? overlayIdx : mapLayers.getLength(), 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.
// Multi-select: a plain click selects a single feature (replacing the
// selection); Shift-click toggles a feature in/out of the selection so
// the user can build up a set to delete or move together. Shift-click is
// the ol default toggleCondition, but we set it explicitly for clarity.
this._selectInteraction = new Select({
condition: clickCondition,
toggleCondition: shiftKeyOnly,
filter: (feature, layer) => !!layer,
layers: (layer) => layer instanceof VectorLayer,
});
this._selectInteraction.setActive(false);
this.map.addInteraction(this._selectInteraction);
// 2b. Box-select — Ctrl/Cmd + drag draws a rectangle and adds every
// intersecting feature to the selection. This is the discoverable
// "select multiple" gesture (Shift-click also works, one at a time).
// Plain drag still pans the map; Shift-drag is left free. The box is
// only live while the Select tool itself is active (see the
// change:active wiring below) so it never interferes with drawing.
this._dragBoxSelect = new DragBox({ condition: platformModifierKeyOnly });
this._dragBoxSelect.setActive(false);
this.map.addInteraction(this._dragBoxSelect);
this._dragBoxSelect.on('boxend', () => {
const extent = this._dragBoxSelect.getGeometry().getExtent();
const selected = this._selectInteraction.getFeatures();
// Walk every visible vector layer the Select interaction would accept
// and add features whose geometry intersects the box.
this.map.getLayers().forEach((layer) => this._boxAddFromLayer(layer, extent, selected));
this._refreshVertexOverlay?.();
});
// Keep box-select armed only while the Select sub-tool is active.
this._selectInteraction.on('change:active', () => {
this._dragBoxSelect.setActive(this._selectInteraction.getActive());
});
// 3. Create a ModifyFeature interaction bound to the selection.
// Also starts inactive.
this._modifyInteraction = new ModifyFeature({
features: this._selectInteraction.getFeatures(),
});
this._modifyInteraction.setActive(false);
// 3b. Fire onFeatureModified callbacks when a modification completes.
// Consumers attach via onFeatureModified() and decide for themselves
// whether to react (e.g. the staged-import code persists geometry
// changes back to external_import_features).
this._modifyInteraction.on('modifyend', (evt) => {
if (!this._featureModifiedCallbacks?.length) return;
const features = evt.features?.getArray?.() || [];
for (const f of features) {
for (const cb of this._featureModifiedCallbacks) {
try { cb(f); } catch (err) { console.warn('[MapView] onFeatureModified callback failed:', err); }
}
}
});
// 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,
// Stable class so CSS can move this group (undo/redo/save/snap) to a
// second row on small screens — see `.ol-editbar-actions` media query.
className: 'ol-editbar-actions',
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);
});
// Small-screen layout: insert a zero-height, full-width flex line-break
// immediately BEFORE the action group. On phones (see the .ol-editbar
// media query) this forces the wrap to happen here, so the action group
// (undo/redo/save/snap) together with the Split and Merge toggles all land
// on a single second row instead of Split/Merge spilling onto a third row.
// The break is display:none on wider screens, so desktop layout is unchanged.
const editbarEl = this.editBar.element;
if (editbarEl && extraBar.element && extraBar.element.parentNode === editbarEl) {
const breakEl = document.createElement('div');
breakEl.className = 'ol-editbar-break';
editbarEl.insertBefore(breakEl, extraBar.element);
}
// 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
//
// The TouchCursor is only practical on *touch-only* devices (phones,
// tablets) where the finger is the sole pointing device. On hybrid
// laptops — a touchscreen plus a touchpad/mouse — the changed cursor
// gets in the way, because the user is most likely driving the map
// with the precise pointer, not the screen.
//
// The classic `'ontouchstart' in window` / `maxTouchPoints` test can't
// tell the two apart: it is true for both a tablet and a touchscreen
// laptop. We additionally consult CSS Media Queries Level 4 pointer
// features, which DO distinguish them:
//
// any-pointer: fine → at least one fine pointer exists
// (mouse / touchpad / stylus). True on a
// touch laptop, false on a phone/tablet.
// any-hover: hover → at least one device can hover. Same split.
//
// So we enable the TouchCursor only when the device is touch-capable
// AND exposes no fine/hovering pointer — i.e. a genuine touch-only
// device. `_isTouchOnlyDevice()` is also reactive: if the user later
// plugs in a mouse, `_refreshTouchCursor()` tears the cursor down.
if (this._isTouchOnlyDevice()) {
this.touchCursor = new TouchCursor({
className: 'ol-editbar-cursor',
});
this.map.addInteraction(this.touchCursor);
this.touchCursor.setActive(false);
console.log('[MapView] Touch-only device detected — TouchCursor added');
} else {
console.log('[MapView] Fine pointer available — TouchCursor skipped');
}
// React to pointer-capability changes (e.g. a Bluetooth mouse paired
// with a tablet, or a tablet docked to a trackpad keyboard). When a
// fine pointer appears we drop the TouchCursor; when the last one is
// removed on a touch device we add it back.
this._installPointerCapabilityWatcher();
// 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);
});
// 10. Keyboard Delete — pressing Delete or Backspace while edit mode is
// active deletes the currently-selected feature(s), mirroring the
// EditBar's Delete button (same undoable deletestart/deleteend block).
// Guarded so it never fires while the user is typing in a form field.
this._editKeyHandler = (e) => {
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (!this._editBarActive) return;
const t = e.target;
const tag = t && t.tagName;
if (t?.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (this._deleteSelectedFeatures()) {
e.preventDefault();
}
};
document.addEventListener('keydown', this._editKeyHandler);
console.log('[MapView] EditBar initialised with Drawings group, UndoRedo and SnapGuides (default:', this._snapGuidesEnabled ? 'ON' : 'OFF', ')');
}
/**
* Delete the currently-selected feature(s) via the EditBar's Delete
* interaction, so the removal is recorded as a single undoable block
* (deletestart/deleteend) exactly like the Delete button. Returns true if
* anything was deleted.
* @returns {boolean}
*/
_deleteSelectedFeatures() {
if (!this._editBarActive) return false;
const del = this.editBar?.getInteraction('Delete');
const features = this._selectInteraction?.getFeatures();
if (!del || !features || features.getLength() === 0) return false;
del.delete(features); // removes from all map sources; undoable
features.clear(); // drop the now-deleted features from selection
this._refreshVertexOverlay?.();
return true;
}
/**
* Box-select helper: add every feature of a (visible, Select-eligible) vector
* layer whose geometry intersects `extent` to the `selected` collection,
* skipping duplicates. Recurses into LayerGroups.
* @private
*/
_boxAddFromLayer(layer, extent, selected) {
if (!layer) return;
if (typeof layer.getLayers === 'function') {
layer.getLayers().forEach((l) => this._boxAddFromLayer(l, extent, selected));
return;
}
if (!(layer instanceof VectorLayer)) return;
if (layer.getVisible && !layer.getVisible()) return;
// Skip non-selectable helper layers: the vertex-edit overlay, GPS trail /
// position, and anything explicitly hidden from the layer switcher.
if (layer === this._vertexOverlayLayer ||
layer === this._gpsTrailLayer ||
layer === this._gpsPositionLayer ||
layer.get('displayInLayerSwitcher') === false) return;
const source = layer.getSource && layer.getSource();
if (!source || typeof source.forEachFeatureIntersectingExtent !== 'function') return;
source.forEachFeatureIntersectingExtent(extent, (feature) => {
if (!selected.getArray().includes(feature)) selected.push(feature);
});
}
/**
* 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) {
const wasActive = this._editBarActive;
this._editBarActive = !!active;
// On entering edit mode, (1) re-scan vector sources so UndoRedo watches
// every layer loaded since the EditBar was built — parcels, zones and
// imports are pushed into sub-groups AFTER init, and ol-ext's UndoRedo only
// auto-watches TOP-LEVEL map layer changes, so those sources would
// otherwise be invisible to undo (a delete records an empty block and undo
// does nothing); and (2) reset the undo history so it covers only this
// editing session and can't reach back into data-loading operations.
if (this._editBarActive && !wasActive && this._undoRedo) {
try {
this._undoRedo._watchSources();
this._undoRedo.clear();
} catch (err) {
console.warn('[MapView] UndoRedo re-watch/clear failed:', err);
}
}
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 (excluded
// from the undo stack — see _withoutUndoRecording / _silentClearSource).
if (!this._editBarActive && this._vertexOverlaySource) {
this._withoutUndoRecording(() => this._silentClearSource(this._vertexOverlaySource));
}
console.log('[MapView] Edit mode:', this._editBarActive ? 'ON' : 'OFF');
}
/**
* Check whether edit mode (select / modify) is currently active.
* @returns {boolean}
*/
isEditMode() {
return this._editBarActive;
}
// ============================================================================
// Pointer-capability detection (touch-only vs. hybrid laptop)
// ============================================================================
/**
* Decide whether the ol-ext TouchCursor should be used.
*
* Returns true ONLY for genuine touch-only devices (phones, tablets) —
* i.e. devices that are touch-capable but expose no fine pointer and no
* hover capability. Hybrid laptops (touchscreen + touchpad/mouse) return
* false, so they keep the normal cursor.
*
* @returns {boolean}
*/
_isTouchOnlyDevice() {
const hasTouch = ('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0) ||
(navigator.msMaxTouchPoints > 0);
if (!hasTouch) return false;
// Without matchMedia we can't refine the signal — fall back to the
// historical behaviour (treat any touch device as touch).
if (typeof window.matchMedia !== 'function') return true;
// `any-pointer: fine` is true when ANY attached pointing device is
// fine (mouse / touchpad / stylus); `any-hover: hover` when ANY device
// can hover. A phone/tablet satisfies neither; a touch laptop both.
const hasFinePointer = window.matchMedia('(any-pointer: fine)').matches;
const canHover = window.matchMedia('(any-hover: hover)').matches;
return !hasFinePointer && !canHover;
}
/**
* Add or remove the TouchCursor to match the current pointer capability,
* preserving its active state relative to edit mode. Called both at init
* and whenever the pointer-capability media queries change.
*/
_refreshTouchCursor() {
const wantCursor = this._isTouchOnlyDevice();
if (wantCursor && !this.touchCursor) {
this.touchCursor = new TouchCursor({ className: 'ol-editbar-cursor' });
this.map.addInteraction(this.touchCursor);
this.touchCursor.setActive(this._editBarActive);
console.log('[MapView] Pointer change → TouchCursor added');
} else if (!wantCursor && this.touchCursor) {
this.map.removeInteraction(this.touchCursor);
this.touchCursor = null;
console.log('[MapView] Pointer change → TouchCursor removed (fine pointer present)');
}
}
/**
* Watch the pointer-capability media queries and re-evaluate the
* TouchCursor when they change — e.g. a Bluetooth mouse paired with a
* tablet, or a 2-in-1 docked to / undocked from a keyboard-trackpad.
*/
_installPointerCapabilityWatcher() {
if (typeof window.matchMedia !== 'function') return;
if (this._pointerMediaQueries) return; // already installed
const queries = ['(any-pointer: fine)', '(any-hover: hover)']
.map((q) => window.matchMedia(q));
const onChange = () => this._refreshTouchCursor();
for (const mq of queries) {
// addEventListener is the modern API; addListener is the legacy
// fallback for older Safari.
if (typeof mq.addEventListener === 'function') {
mq.addEventListener('change', onChange);
} else if (typeof mq.addListener === 'function') {
mq.addListener(onChange);
}
}
this._pointerMediaQueries = queries;
this._pointerMediaListener = onChange;
}
// ============================================================================
// 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());
}
/**
* Run `fn` with the UndoRedo interaction's recording temporarily paused, so
* the vector-source mutations it performs are NOT pushed onto the undo stack.
*
* The vertex-overlay layer is a plain VectorLayer, and ol-ext's UndoRedo
* watches every vector source in the map — so without this, each selection's
* vertex-dot add/clear would land on the undo stack and undo would reverse
* those instead of the user's actual edit (the "first undo does nothing,
* second shows vertices" symptom). Our overlay source is a plain ol
* VectorSource (it never emits ol-ext's clearstart/clearend block events),
* so gating `_record` is sufficient to fully exclude it.
* @private
*/
_withoutUndoRecording(fn) {
const ur = this._undoRedo;
if (!ur) return fn();
const prev = ur._record;
ur._record = false;
try { return fn(); }
finally { ur._record = prev; }
}
/**
* Clear a vector source WITHOUT firing the clearstart/clearend events that
* ol-ext patches onto ol/source/Vector.prototype.clear. Those events drive
* UndoRedo's blockStart/blockEnd, which are NOT gated by the record flag — so
* a plain `source.clear()` on a helper layer pushes a stray (empty) block
* onto the undo stack even inside _withoutUndoRecording. Removing features
* one by one only fires `removefeature`, which IS gated by the record flag,
* so wrapped in _withoutUndoRecording this leaves the undo stack untouched.
* @private
*/
_silentClearSource(source) {
if (!source) return;
const feats = source.getFeatures();
for (let i = feats.length - 1; i >= 0; i--) {
source.removeFeature(feats[i]);
}
}
/**
* Rebuild the vertex overlay from the current Select interaction's features.
* No-ops when not in edit mode.
*/
_refreshVertexOverlay() {
if (!this._vertexOverlaySource) return;
// All overlay-source mutations are excluded from the undo stack.
this._withoutUndoRecording(() => {
this._silentClearSource(this._vertexOverlaySource);
// 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;display:flex;justify-content:flex-end;gap:6px;flex-shrink:0;border-top:1px solid var(--border, #1e1a4b1f);">
<button id="info-popup-analyse"
style="background:#fff;color:var(--brand-navy,#1e1a4b);border:1px solid var(--brand-navy,#1e1a4b);border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;">
🧮 Analyse
</button>
<button id="info-popup-export-gis"
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
</button>
</div>`;
}
/**
* Show the analysis popup, attach close + PDF / GIS export handlers.
*
* @param {string} emoji
* @param {string} title - e.g. "Area Analysis" / "Circle Analysis"
* @param {Array} dataRows - summary rows (PDF uses these)
* @param {number[]} coordinate - popup anchor
* @param {Object} [exportContext] - features + clip geometry for GIS export.
* When omitted, the Export GIS button is
* disabled (no features to export).
* { kind: 'area' | 'circle',
* clipGeometry,
* parcelFeatures, zoneFeatures, otherByLayer }
*/
_showAnalysisPopup(emoji, title, dataRows, coordinate, exportContext = null) {
this.infoPopupElement.innerHTML = this._buildAnalysisPopupHtml(emoji, title, dataRows);
this.infoPopup.setPosition(coordinate);
this.infoPopupElement.querySelector('#info-popup-close').addEventListener('click', () => {
this.hideInfoPopup();
});
// Summary rows, cleaned for the PDF report (HTML stripped, spacers removed).
// These travel with the export event so the export modal can offer PDF
// alongside the GIS formats — one "Export" entry point instead of two.
const pdfRows = dataRows
.filter((r) => !r.empty)
.map((r) => ({ label: r.label, value: String(r.value).replace(/<[^>]*>/g, '') }));
// Analyse — hand the intersecting features to the Analysis panel so the
// user can run overlays / zonal statistics on just what the circle or
// area caught. main.js owns the panel import.
const analyseBtn = this.infoPopupElement.querySelector('#info-popup-analyse');
if (analyseBtn) {
const totalFeatures = exportContext
? exportContext.parcelFeatures.length
+ exportContext.zoneFeatures.length
+ Object.values(exportContext.otherByLayer).reduce((s, arr) => s + arr.length, 0)
: 0;
if (!exportContext || totalFeatures === 0) {
analyseBtn.disabled = true;
analyseBtn.style.opacity = '0.5';
analyseBtn.style.cursor = 'not-allowed';
analyseBtn.title = 'No intersecting features to analyse';
} else {
analyseBtn.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('lupmis:analyse-features', {
detail: { title, ...exportContext },
}));
});
}
}
// GIS export — dispatches a window-level CustomEvent so main.js can open
// the format/field-rename modal without MapView pulling in the writers.
const gisBtn = this.infoPopupElement.querySelector('#info-popup-export-gis');
if (gisBtn) {
const total = exportContext
? exportContext.parcelFeatures.length
+ exportContext.zoneFeatures.length
+ Object.values(exportContext.otherByLayer).reduce((s, arr) => s + arr.length, 0)
: 0;
if (!exportContext || total === 0) {
gisBtn.disabled = true;
gisBtn.style.opacity = '0.5';
gisBtn.style.cursor = 'not-allowed';
gisBtn.title = 'No intersecting features to export';
} else {
gisBtn.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('lupmis:export-gis', {
detail: { title, pdfRows, ...exportContext },
}));
});
}
}
}
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, {
kind: 'circle',
clipGeometry: circlePoly,
parcelFeatures,
zoneFeatures,
otherByLayer,
});
}
/**
* 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, {
kind: 'area',
clipGeometry: polyGeom,
parcelFeatures,
zoneFeatures,
otherByLayer,
});
}
/**
* 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 callback fired after the user finishes modifying a feature
* with the EditBar Modify interaction. Callback receives the OL feature
* whose geometry just changed. Consumers (e.g. the import-staging code in
* main.js) inspect the feature's tags (_externalImportId / _clientUuid)
* to decide whether to react. Multiple callbacks are supported.
*
* @param {Function} callback - (feature) => void | Promise<void>
*/
onFeatureModified(callback) {
if (!this._featureModifiedCallbacks) this._featureModifiedCallbacks = [];
this._featureModifiedCallbacks.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. Hidden from the main LayerSwitcher — base maps are
// managed by the dedicated base-map picker (see _createBaseMapPicker)
// accessed via the layers-stack icon above the My Location button.
const baseGroup = new LayerGroup({
title: 'Base Maps',
layers: [
cartoLightLayer,
cartoDarkLayer,
satelliteLayer,
osmCycleLayer,
googleLayer,
osmLayer,
topoLayer,
],
});
baseGroup.set('displayInLayerSwitcher', false);
return baseGroup;
}
/**
* Switch the active base map by key.
* Sets exactly one base layer visible; hides all others.
*
* @param {string} key Basemap key: 'none' | 'topo' | 'osm' | 'satellite' | 'googlesat' | 'carto-light' | 'carto-dark' | 'cycle'
* @returns {boolean} true if the key matched a known base layer (or 'none')
*/
setBaseMap(key) {
if (!this._baseMapLayers) return false;
// 'none' switches the base map off entirely — hide every base layer so the
// map renders on a blank background (useful over imagery overlays / when a
// full-coverage overlay should stand alone).
if (key === 'none') {
for (const layer of this._baseMapLayers) layer.setVisible(false);
console.log('[MapView] Base map switched off (none)');
this.map.dispatchEvent({ type: 'basemapchange', key: 'none' });
return true;
}
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);
// Notify external UIs (Settings dropdown, base-map picker, …) so they
// can keep their visible state in sync.
this.map.dispatchEvent({ type: 'basemapchange', key });
}
return matched;
}
/**
* Build the floating "Base Map" picker — a small icon button stacked
* directly above the My Location control, plus a slide-out card with
* thumbnail chips for every selectable base map.
*
* Hidden in tandem with the main LayerSwitcher: clicking outside the
* picker (or making a selection) closes it.
*
* Two-way sync with the existing Settings dropdown is via the
* `basemapchange` event fired from setBaseMap().
*/
_createBaseMapPicker() {
// Configuration — must match the basemapKey set in createBaseLayers.
// The colour gradients hint at each base map's character so the chip is
// recognisable without rendering an actual tile preview.
const OPTIONS = [
{ key: 'topo', label: 'Topographic', grad: 'linear-gradient(135deg,#e8d5b7,#a67c52)' },
{ key: 'osm', label: 'OpenStreetMap',grad: 'linear-gradient(135deg,#d4e6f1,#85c1e9)' },
{ key: 'satellite', label: 'Satellite', grad: 'linear-gradient(135deg,#1b4332,#40916c)' },
{ key: 'googlesat', label: 'Google Sat', grad: 'linear-gradient(135deg,#2a5d3d,#4a8c5a)' },
{ key: 'carto-light', label: 'Carto Light', grad: 'linear-gradient(135deg,#f5f5f5,#d4d4d4)' },
{ key: 'carto-dark', label: 'Carto Dark', grad: 'linear-gradient(135deg,#1a1a2e,#0f3460)' },
// "None" turns the base map off — checkerboard hints at a blank/transparent background.
{ key: 'none', label: 'None', grad: 'repeating-conic-gradient(#e5e7eb 0 25%, #fff 0 50%) 50% / 12px 12px' },
];
const target = this.map.getTargetElement();
if (!target) return;
// ---------- Toggle button ----------
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ls-basemap-toggle';
btn.title = 'Switch base map';
btn.setAttribute('aria-label', 'Switch base map');
btn.innerHTML =
'<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">' +
'<path d="M9 2L16 5.8L9 9.6L2 5.8L9 2Z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>' +
'<path d="M2 9.2L9 13L16 9.2" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>' +
'<path d="M2 12.4L9 16.2L16 12.4" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-opacity=".4"/>' +
'</svg>';
target.appendChild(btn);
// ---------- Picker panel ----------
const panel = document.createElement('div');
panel.className = 'ls-basemap-panel';
panel.innerHTML =
'<div class="ls-basemap-header">Base Map</div>' +
'<div class="ls-basemap-grid">' +
OPTIONS.map((opt) => `
<label class="ls-bm-chip">
<input type="radio" name="lupmis-basemap" value="${opt.key}">
<div class="ls-bm-label">
<div class="ls-bm-thumb" style="background:${opt.grad};"></div>
<div class="ls-bm-name">${opt.label}</div>
</div>
</label>
`).join('') +
'</div>';
target.appendChild(panel);
this._basemapPanel = panel;
this._basemapToggle = btn;
/** Mark the radio matching the currently-visible base layer. */
const syncSelection = (key) => {
const k = key || this._baseMapLayers?.find((l) => l.getVisible())?.get('basemapKey');
panel.querySelectorAll('input[name="lupmis-basemap"]').forEach((r) => {
r.checked = (r.value === k);
});
};
syncSelection();
// ---------- Events ----------
// Toggle button → open / close the panel
btn.addEventListener('click', (e) => {
e.stopPropagation();
const open = !panel.classList.contains('open');
panel.classList.toggle('open', open);
btn.classList.toggle('active', open);
if (open) syncSelection();
});
// Click outside → close
document.addEventListener('click', (e) => {
if (!panel.classList.contains('open')) return;
if (panel.contains(e.target) || btn.contains(e.target)) return;
panel.classList.remove('open');
btn.classList.remove('active');
});
// Selection → apply, persist, close
panel.addEventListener('change', (e) => {
const radio = e.target.closest('input[type=radio][name="lupmis-basemap"]');
if (!radio) return;
const key = radio.value;
this.setBaseMap(key);
try { localStorage.setItem('default-basemap', key); } catch {}
panel.classList.remove('open');
btn.classList.remove('active');
});
// Keep the radio state synced when other UIs (Settings dropdown) change it
this.map.on('basemapchange', (evt) => syncSelection(evt.key));
}
// ============================================================================
// GPS: current-position + trail rendering, and the expandable Location control
//
// NOTE: MapView deliberately knows nothing about the GeoTracker engine,
// SQLocal, or sync. It only (a) renders what it's told and (b) emits UI
// intents via callbacks. main.js wires those intents to the GeoTracker so the
// map stays reusable/decoupled.
// ============================================================================
/** Create the vector layers used to draw the live position and the trail. */
_initGpsRendering() {
this._gpsPositionSource = new VectorSource();
this._gpsTrailSource = new VectorSource();
this._gpsTrailCoords = []; // [ [x,y], ... ] in map projection
// Trail line (drawn under the position marker)
this._gpsTrailLayer = new VectorLayer({
source: this._gpsTrailSource,
zIndex: 940,
style: new Style({
stroke: new Stroke({ color: '#ff6d00', width: 4, lineCap: 'round', lineJoin: 'round' }),
}),
properties: { title: 'GPS Trail', displayInLayerSwitcher: false },
});
// Current position: accuracy halo + solid dot
this._gpsPositionLayer = new VectorLayer({
source: this._gpsPositionSource,
zIndex: 950,
style: (feature) => {
if (feature.get('_kind') === 'accuracy') {
return new Style({
fill: new Fill({ color: 'rgba(0,94,184,0.12)' }),
stroke: new Stroke({ color: 'rgba(0,94,184,0.35)', width: 1 }),
});
}
return new Style({
image: new Circle({
radius: 7,
fill: new Fill({ color: '#005eb8' }),
stroke: new Stroke({ color: '#ffffff', width: 2.5 }),
}),
});
},
properties: { title: 'GPS Position', displayInLayerSwitcher: false },
});
this.map.addLayer(this._gpsTrailLayer);
this.map.addLayer(this._gpsPositionLayer);
this._gpsCallbacks = { locate: [], record: [] };
this._gpsRecording = false;
}
/** Register a callback fired when the user taps "Locate Me". */
onLocateMe(cb) { this._gpsCallbacks.locate.push(cb); }
/** Register a callback fired when the user toggles trail recording. Receives the desired state (true=start). */
onToggleRecording(cb) { this._gpsCallbacks.record.push(cb); }
/**
* Draw / move the current-position marker and accuracy halo.
* @param {number} lon
* @param {number} lat
* @param {number|null} [accuracy] horizontal accuracy in metres
*/
showCurrentPosition(lon, lat, accuracy = null) {
if (lon == null || lat == null) return;
const center = fromLonLat([lon, lat]);
this._gpsPositionSource.clear();
if (accuracy && accuracy > 0) {
// Approximate the accuracy circle in projected units. Good enough for a
// visual halo at typical zoom levels.
const resAtLat = accuracy / Math.cos((lat * Math.PI) / 180);
const halo = new Feature({ geometry: new PolygonGeom([this._circleRing(center, resAtLat)]) });
halo.set('_kind', 'accuracy');
this._gpsPositionSource.addFeature(halo);
}
const dot = new Feature({ geometry: new Point(center) });
dot.set('_kind', 'dot');
this._gpsPositionSource.addFeature(dot);
}
/** @private build a ring of coordinates approximating a circle (metres → projected). */
_circleRing(center, radiusMeters, segments = 48) {
// Convert metres to projected units (Web Mercator) roughly via the
// resolution at the centre latitude.
const ring = [];
const metersPerUnit = 1; // EPSG:3857 units are metres (approx near equator/locally)
const r = radiusMeters / metersPerUnit;
for (let i = 0; i <= segments; i++) {
const a = (i / segments) * 2 * Math.PI;
ring.push([center[0] + r * Math.cos(a), center[1] + r * Math.sin(a)]);
}
return ring;
}
/** Smoothly center the view on a coordinate. */
centerOn(lon, lat, zoom = 16) {
const view = this.map.getView();
view.animate({ center: fromLonLat([lon, lat]), zoom, duration: 500 });
}
/** Reset the trail line (call when a new recording starts). */
startTrailRender() {
this._gpsTrailCoords = [];
this._gpsTrailSource.clear();
}
/** Append a coordinate to the growing trail line. */
appendTrailPoint(lon, lat) {
if (lon == null || lat == null) return;
this._gpsTrailCoords.push(fromLonLat([lon, lat]));
this._gpsTrailSource.clear();
if (this._gpsTrailCoords.length >= 2) {
this._gpsTrailSource.addFeature(new Feature({ geometry: new LineString(this._gpsTrailCoords) }));
}
}
/** Remove the rendered trail (does not affect stored data). */
clearTrailRender() {
this._gpsTrailCoords = [];
this._gpsTrailSource.clear();
}
/** Reflect recording state on the control button. */
setRecordingState(active) {
this._gpsRecording = !!active;
if (this._recordBtn) {
this._recordBtn.classList.toggle('recording', this._gpsRecording);
this._recordBtn.title = this._gpsRecording ? 'Stop trail recording' : 'Record GPS trail';
this._recordBtn.innerHTML = this._gpsRecording
? '<i class="bi bi-stop-fill"></i>'
: '<i class="bi bi-record-circle"></i>';
}
if (this._locateToggle) this._locateToggle.classList.toggle('recording', this._gpsRecording);
}
/**
* Build the expandable "My Location" control: a main button that reveals two
* sub-buttons (Locate Me, Record Trail). Anchored at the same spot the old
* ol-ext GeolocationButton occupied (bottom-right), so the base-map picker
* still lines up above it.
*/
_createLocationControl() {
const target = this.map.getTargetElement();
if (!target) return;
// Main toggle
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'ls-locate-toggle';
toggle.title = 'My Location';
toggle.setAttribute('aria-label', 'My Location');
toggle.innerHTML = '<i class="bi bi-geo-alt-fill"></i>';
target.appendChild(toggle);
// Sub-button cluster (hidden until the main button is tapped)
const actions = document.createElement('div');
actions.className = 'ls-locate-actions';
actions.innerHTML =
'<button type="button" class="ls-locate-btn ls-locate-me" title="Locate me">' +
'<i class="bi bi-crosshair"></i></button>' +
'<button type="button" class="ls-locate-btn ls-locate-record" title="Record GPS trail">' +
'<i class="bi bi-record-circle"></i></button>';
target.appendChild(actions);
this._locateToggle = toggle;
this._locateActions = actions;
this._locateMeBtn = actions.querySelector('.ls-locate-me');
this._recordBtn = actions.querySelector('.ls-locate-record');
const close = () => { actions.classList.remove('open'); toggle.classList.remove('active'); };
const open = () => { actions.classList.add('open'); toggle.classList.add('active'); };
toggle.addEventListener('click', (e) => {
e.stopPropagation();
actions.classList.contains('open') ? close() : open();
});
// Tap outside closes the cluster (but never while recording, so the stop
// button stays reachable).
document.addEventListener('click', (e) => {
if (!actions.classList.contains('open')) return;
if (actions.contains(e.target) || toggle.contains(e.target)) return;
if (this._gpsRecording) return;
close();
});
this._locateMeBtn.addEventListener('click', (e) => {
e.stopPropagation();
for (const cb of this._gpsCallbacks.locate) { try { cb(); } catch (err) { console.error(err); } }
if (!this._gpsRecording) close();
});
this._recordBtn.addEventListener('click', (e) => {
e.stopPropagation();
const next = !this._gpsRecording;
for (const cb of this._gpsCallbacks.record) { try { cb(next); } catch (err) { console.error(err); } }
});
}
/**
* 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 {number[]} [styleOptions.strokeDash] - Dash pattern for the stroke
* (passed straight to ol/style/Stroke#lineDash, e.g. [4,4]). Useful for
* contextual overlays (grids, draft outlines) so they read differently
* from solid property boundaries.
* @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,
strokeDash = null,
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`).
const mainStroke = new Stroke({
color: strokeColor,
width: strokeWidth,
...(strokeDash ? { lineDash: strokeDash } : {}),
});
let layerStyle;
if (lineCasingColor) {
const casingW = lineCasingWidth != null ? lineCasingWidth : strokeWidth + 2;
layerStyle = [
new Style({
stroke: new Stroke({ color: lineCasingColor, width: casingW }),
}),
new Style({
stroke: mainStroke,
fill: fillStyle,
image: pointStyle,
}),
];
} else {
layerStyle = new Style({
stroke: mainStroke,
fill: fillStyle,
image: pointStyle,
});
}
const layer = new VectorLayer({
title: title,
source: source,
style: layerStyle,
});
layer.set('typeTag', styleOptions.typeTag || 'VEC');
// Derive a friendly "Vector / Polygon" / "Vector / Line" / "Vector / Point"
// subtitle from the first feature's geometry type, unless the caller
// already supplied one in styleOptions.
//
// Layers created EMPTY (parcels, OSM_roads, …, populated later from the
// API) leave the subtitle absent until the first feature arrives —
// see the `addfeature` listener below.
const describeFromGeom = (geomType) => {
if (!geomType) return null;
if (geomType.includes('Polygon')) return 'Vector / Polygon';
if (geomType.includes('LineString')) return 'Vector / Line';
if (geomType.includes('Point')) return 'Vector / Point';
return 'Vector';
};
if (styleOptions.typeDescription) {
layer.set('typeDescription', styleOptions.typeDescription);
} else {
const feats = source.getFeatures();
const initial = describeFromGeom(feats[0]?.getGeometry?.()?.getType?.());
if (initial) {
layer.set('typeDescription', initial);
} else {
// Source is empty — wait for the first feature and set then.
const once = (ev) => {
const desc = describeFromGeom(ev.feature.getGeometry?.()?.getType?.());
if (desc) layer.set('typeDescription', desc);
source.un('addfeature', once);
};
source.on('addfeature', once);
}
}
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,
});
wmsLayer.set('typeTag', 'WMS');
wmsLayer.set('typeDescription', 'WMS / Raster');
// 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,
});
xyzLayer.set('typeTag', 'XYZ');
xyzLayer.set('typeDescription', 'XYZ / Tile');
// 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 a Cloud-Optimized GeoTIFF (COG) raster layer.
*
* COGs are streamed by range-request and rendered on the GPU via
* WebGLTile, so a large DEM or satellite scene can be displayed without
* downloading the whole file. This is the display half of Stage 1's raster
* support (analysis on rasters runs server-side — see the concept note).
*
* @param {string} groupTitle Title of the target LayerGroup
* @param {string} title Display title for the layer
* @param {string} url URL of the .tif (must be a valid COG, CORS-enabled)
* @param {Object} [options]
* @param {boolean} [options.visible=true]
* @param {number} [options.opacity=1]
* @param {number} [options.zIndex]
* @param {boolean} [options.normalize=true] Auto-stretch values to 01 for display
* @param {number} [options.min] Explicit min for contrast stretch
* @param {number} [options.max] Explicit max for contrast stretch
* @param {Object} [options.style] OpenLayers WebGLTile style (e.g. a colour ramp)
* @param {string} [options.attributions]
* @returns {Promise<Object|null>} the created layer (async: the COG libraries
* are code-split and fetched on first use)
*/
async addCOGLayer(groupTitle, title, url, options = {}) {
const group = this.getLayerGroupByTitle(groupTitle);
if (!group) {
console.warn(`[MapView] Layer group "${groupTitle}" not found — cannot add COG layer "${title}"`);
return null;
}
// Lazy-load the GeoTIFF stack only when a raster is actually opened.
let WebGLTileLayer, GeoTIFFSource;
try {
[{ default: WebGLTileLayer }, { default: GeoTIFFSource }] = await Promise.all([
import('ol/layer/WebGLTile'),
import('ol/source/GeoTIFF'),
]);
} catch (err) {
console.error('[MapView] Could not load COG support:', err);
showToast('Raster (COG) support could not be loaded — check your connection.', 'error', 6000);
return null;
}
let cogLayer = null;
try {
const sourceOpts = {
sources: [{
url,
...(options.min !== undefined ? { min: options.min } : {}),
...(options.max !== undefined ? { max: options.max } : {}),
}],
// normalize=true stretches band values into 01 so single-band rasters
// (DEMs, indices) render without an explicit style.
normalize: options.normalize !== undefined ? options.normalize : true,
attributions: options.attributions,
};
const source = new GeoTIFFSource(sourceOpts);
cogLayer = new WebGLTileLayer({
title,
source,
visible: options.visible !== undefined ? options.visible : true,
opacity: options.opacity !== undefined ? options.opacity : 1,
zIndex: options.zIndex,
...(options.style ? { style: options.style } : {}),
});
cogLayer.set('typeTag', 'COG');
cogLayer.set('typeDescription', 'Cloud-Optimized GeoTIFF');
// Surface load failures — a non-COG or a CORS-blocked file is the usual cause.
source.on('error', (evt) => {
console.warn(`[MapView] COG "${title}" source error:`, evt);
showToast(`Raster "${title}" could not be loaded. Check that the file is a valid COG and is CORS-enabled.`,
'warning', 6000);
});
group.getLayers().push(cogLayer);
} catch (err) {
console.error(`[MapView] Failed to add COG layer "${title}":`, err);
showToast(`Could not add raster "${title}": ${err.message}`, 'error', 6000);
return null;
}
if (options.onlineOnly) this._attachOnlineOnlyHandler(cogLayer, title);
console.log(`[MapView] COG layer added: "${title}" → group "${groupTitle}"`);
return cogLayer;
}
/**
* The features currently selected with the Select tool (shift-click or
* Ctrl/Cmd box-select). Used by the Analysis panel to restrict an analysis
* to a hand-picked subset.
* @returns {Array<import('ol/Feature').default>}
*/
getSelectedFeatures() {
if (!this._selectInteraction) return [];
return this._selectInteraction.getFeatures().getArray().slice();
}
/**
* List the vector layers a user can pick as analysis inputs.
*
* Walks the layer tree (recursing into groups) and returns the real,
* feature-bearing layers — excluding helper overlays (vertex handles, GPS
* trail/position, snap guides) which are never meaningful analysis inputs.
*
* @param {Object} [opts]
* @param {boolean} [opts.visibleOnly=false] only include currently-visible layers
* @param {boolean} [opts.nonEmptyOnly=true] skip layers with no features loaded
* @returns {Array<{layer: VectorLayer, title: string, count: number}>}
*/
listVectorLayers(opts = {}) {
const { visibleOnly = false, nonEmptyOnly = true } = opts;
const out = [];
const walk = (layer) => {
if (!layer) return;
if (typeof layer.getLayers === 'function') {
layer.getLayers().forEach(walk);
return;
}
if (!(layer instanceof VectorLayer)) return;
if (layer === this._vertexOverlayLayer ||
layer === this._gpsTrailLayer ||
layer === this._gpsPositionLayer ||
layer.get('displayInLayerSwitcher') === false) return;
if (visibleOnly && layer.getVisible && !layer.getVisible()) return;
const source = layer.getSource && layer.getSource();
if (!source || typeof source.getFeatures !== 'function') return;
const count = source.getFeatures().length;
if (nonEmptyOnly && count === 0) return;
out.push({ layer, title: layer.get('title') || '(untitled layer)', count });
};
this.map.getLayers().forEach(walk);
return out;
}
// ============================================================================
// 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;
}
// Tag for the LayerSwitcher chip
layer.set('typeTag', type.toUpperCase()); // 'WMS' | 'WFS' | 'XYZ'
layer.set('typeDescription', {
wms: 'WMS / Raster',
wfs: 'WFS / Vector',
xyz: 'XYZ / Tile',
}[type] || type.toUpperCase());
// User-added external layers ARE removable — they're not part of the
// app's built-in data model.
layer.set('removable', true);
group.getLayers().push(layer);
showToast(`Layer "${title}" added to External Source.`, 'success', 3000);
console.log(`[MapView] External ${type.toUpperCase()} layer added: "${title}"`);
}
// ============================================================================
// LayerSwitcher decoration (Option A — visual refresh)
// ============================================================================
/**
* Decorate a layer's <li> after ol-ext renders it:
* • inject a type-tag chip next to the layer label
* • inject the green "+" button on the External Source group header
*
* Idempotent — safe to call repeatedly; each injected element checks
* whether it already exists in the row.
*/
_decorateLayerListItem(layer, li) {
// 1. Type-tag chip (e.g. WMS / XYZ / VEC) next to the layer name.
// Inserted INSIDE the label's <span> so it doesn't collide with the
// label's left padding (where ol-ext draws the checkbox via ::before).
const tag = layer.get('typeTag'); // 'WMS' | 'WFS' | 'XYZ' | 'VEC' | 'GEO' | 'BASE'
if (tag) {
const labelSpan = li.querySelector(':scope > .li-content > label > span');
if (labelSpan && !labelSpan.querySelector(':scope > .ls-type-tag')) {
const chip = document.createElement('span');
chip.className = `ls-type-tag ls-type-tag-${String(tag).toLowerCase()}`;
chip.textContent = String(tag);
chip.title = `${tag} layer`;
labelSpan.appendChild(chip);
}
}
// 2. Replace ol-ext's bar-drawn +/- chevron with the GeoView chevron SVG.
// The SVG uses stroke="currentColor", so CSS `color` (set in
// layerswitcher.css) tints it. Rotation handles the open/closed state.
const btnBar = li.querySelector(':scope > .ol-layerswitcher-buttons');
if (btnBar) {
const chevronEl = btnBar.querySelector(':scope > .expend-layers, :scope > .collapse-layers');
if (chevronEl && !chevronEl.querySelector(':scope > svg.ls-chevron-svg')) {
chevronEl.innerHTML =
'<svg class="ls-chevron-svg" width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">' +
'<path d="M3 2L7 5.5L3 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>' +
'</svg>';
}
}
// 3. Layer-type subtitle ("Vector / Polygon", "WMS / Raster", …) below
// the layer name. Only rendered when layer.get('typeDescription') is
// set. For layers that start empty and gain features later (the API
// loaders), we listen for `change:typeDescription` and update or
// insert the subtitle then.
const content = li.querySelector(':scope > .li-content');
const ensureSubtitle = () => {
if (!content) return;
const text = layer.get('typeDescription');
let sub = content.querySelector(':scope > .ls-layer-subtitle');
if (!text) {
if (sub) sub.remove();
return;
}
if (!sub) {
sub = document.createElement('div');
sub.className = 'ls-layer-subtitle';
const label = content.querySelector(':scope > label');
if (label && label.nextSibling) {
content.insertBefore(sub, label.nextSibling);
} else {
content.appendChild(sub);
}
}
sub.textContent = text;
};
ensureSubtitle();
if (!layer._lsSubtitleHooked) {
layer._lsSubtitleHooked = true;
layer.on('change:typeDescription', () => {
// The <li> may not exist any more (panel re-rendered between events)
// — guard with a fresh lookup via the LayerSwitcher next time it draws.
// For now we just call ensureSubtitle bound to the original li.
ensureSubtitle();
});
}
// 4. Per-layer Remove button — only for layers explicitly marked
// `removable: true` (external sources, imported files, …). Built-in
// layers (Parcels, OSM_roads, district boundary, …) are NOT removable
// so the user can't accidentally delete them.
if (layer.get('removable') === true && btnBar && !btnBar.querySelector(':scope > .ls-remove-btn')) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'ls-remove-btn';
removeBtn.title = 'Remove this layer';
removeBtn.setAttribute('aria-label', 'Remove layer');
removeBtn.innerHTML =
'<svg width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">' +
'<path d="M2 2l7 7M9 2L2 9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"></path>' +
'</svg>';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._removeLayer(layer);
});
btnBar.appendChild(removeBtn);
}
// 5b. Import-state chip on layers staged via the external-dataset import
// flow (LUPMIS2_Import_Upload_Design.docx §3.2). The chip's text and
// behaviour depend on `_externalImportStatus`:
// 'mapped' → "Upload N" (click → upload)
// 'uploading' → spinner (disabled)
// 'submitted' → "✓ submitted" (in upload_tmp, awaiting review)
// 'migrated' → "✓ live" (supervisor promoted to lu_parcels)
// 'failed' → "N errors — fix?"
// 'other'/null→ no chip
// Clicks dispatch a window-level CustomEvent so main.js can react
// without MapView knowing anything about staging.
const importId = layer.get('_externalImportId');
if (importId != null) {
const labelSpan = li.querySelector(':scope > .li-content > label > span');
let chip = labelSpan ? labelSpan.querySelector(':scope > .ls-import-chip') : null;
const status = layer.get('_externalImportStatus') || 'mapped';
const featureCount = layer.getSource()?.getFeatures().length ?? 0;
const errorCount = layer.get('_externalImportErrorCount') ?? 0;
// Decide chip appearance.
const chipSpec = (() => {
switch (status) {
case 'mapped':
return { text: `Upload ${featureCount}`, cls: 'ls-import-chip-mapped',
title: 'Upload this dataset to the database', clickable: true };
case 'uploading':
return { text: '…', cls: 'ls-import-chip-uploading',
title: 'Uploading…', clickable: false };
case 'submitted':
return { text: '✓ submitted', cls: 'ls-import-chip-submitted',
title: 'Uploaded — awaiting supervisor review', clickable: false };
case 'migrated':
return { text: '✓ live', cls: 'ls-import-chip-migrated',
title: 'Approved by supervisor and live on the server', clickable: false };
case 'failed':
return { text: `${errorCount} errors — fix?`, cls: 'ls-import-chip-failed',
title: 'Some rows failed; click to review', clickable: true };
case 'other':
case null:
case undefined:
default:
return null;
}
})();
if (!chipSpec) {
if (chip) chip.remove();
} else if (labelSpan) {
if (!chip) {
chip = document.createElement('span');
chip.className = 'ls-import-chip';
labelSpan.appendChild(chip);
}
chip.className = `ls-import-chip ${chipSpec.cls}`;
chip.textContent = chipSpec.text;
chip.title = chipSpec.title;
chip.style.cursor = chipSpec.clickable ? 'pointer' : 'default';
chip.style.opacity = chipSpec.clickable ? '1' : '0.85';
// Replace any prior listener by cloning the node.
const fresh = chip.cloneNode(true);
chip.replaceWith(fresh);
chip = fresh;
if (chipSpec.clickable) {
chip.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
window.dispatchEvent(new CustomEvent('lupmis:import-chip-click', {
detail: { importId, status, layer },
}));
});
}
}
}
// 5. "+" button on the External Source group
const groupTitle = (layer.get('title') || '').toLowerCase();
if (groupTitle.includes('external')) {
this._externalSourceGroup = layer;
// btnBar already resolved above (same .ol-layerswitcher-buttons element)
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:22px !important;height:22px !important;border-radius:50%;
background:#41b6a6 !important;color:#fff !important;
font-size:15px !important;font-weight:700;
cursor:pointer;line-height:1 !important;
margin:0 4px 0 0;vertical-align:middle;
transition:background 0.2s;box-sizing:border-box;border:none;
`;
addBtn.addEventListener('mouseenter', () => { addBtn.style.background = '#329686'; });
addBtn.addEventListener('mouseleave', () => { addBtn.style.background = '#41b6a6'; });
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showAddLayerDialog();
});
btnBar.prepend(addBtn);
}
}
}
/**
* Remove a layer from its parent group, after confirmation. Only called
* from the per-layer × button injected by `_decorateLayerListItem` — and
* that button is only injected for layers marked `removable: true`, so
* built-in layers (Parcels, OSM_roads, …) can never reach this path.
*
* @param {Layer} layer
*/
_removeLayer(layer) {
const title = layer.get('title') || 'this layer';
if (!confirm(`Remove "${title}" from the map?\n\nThis only affects the current session — built-in layers cannot be removed.`)) {
return;
}
// Find the parent group that owns this layer and call .remove() on its
// collection. Walk recursively from the overlay group.
const visit = (group) => {
const layers = group.getLayers();
if (layers.getArray().includes(layer)) {
layers.remove(layer);
return true;
}
let removed = false;
layers.forEach((child) => {
if (!removed && child.getLayers) {
removed = visit(child);
}
});
return removed;
};
const ok = visit(this.overlayGroup);
if (ok) {
console.log(`[MapView] Removed layer "${title}"`);
showToast(`Removed "${title}" from the map.`, 'info', 3000);
} else {
console.warn(`[MapView] Could not find layer "${title}" in any group`);
}
}
/**
* Inject (or refresh) the panel chrome — an "active count" badge at the top
* and a footer row with "Reset overlays" button at the bottom.
*
* The chrome lives in `.panel-container` (the wrapping <div>), not inside
* the `<ul class="panel">` — that way the badge and footer are siblings of
* the layer list rather than malformed children of a `<ul>`.
*
* Called from drawlist via queueMicrotask, so it runs once per redraw cycle
* regardless of how many layers are in the panel.
*/
_refreshLayerSwitcherChrome(layerSwitcher) {
const panelContainer = layerSwitcher.element?.querySelector('.panel-container');
const ul = layerSwitcher.element?.querySelector('ul.panel');
if (!panelContainer || !ul) return;
// --- Active-count badge (top of panel-container, before the <ul>) ---
let badge = panelContainer.querySelector(':scope > .ls-active-badge');
if (!badge) {
badge = document.createElement('div');
badge.className = 'ls-active-badge';
badge.innerHTML = `
<span class="ls-active-badge-title">Layers</span>
<span class="ls-active-badge-count">0 active</span>
`;
panelContainer.insertBefore(badge, ul);
}
// --- Footer row (bottom of panel-container, after the <ul>) ---
let footer = panelContainer.querySelector(':scope > .ls-footer-row');
if (!footer) {
footer = document.createElement('div');
footer.className = 'ls-footer-row';
footer.innerHTML = `
<span class="ls-footer-note">— layers total</span>
<button type="button" class="ls-footer-btn"
title="Hide every overlay (base map stays on)">
Reset
</button>
`;
panelContainer.appendChild(footer);
footer.querySelector('.ls-footer-btn').addEventListener('click', (e) => {
e.stopPropagation();
this._resetAllOverlays();
});
}
// --- Update counters ---
const counts = this._countLayers();
badge.querySelector('.ls-active-badge-count').textContent =
`${counts.activeOverlays} active`;
footer.querySelector('.ls-footer-note').textContent =
`${counts.totalOverlays} overlay${counts.totalOverlays === 1 ? '' : 's'}`;
}
/**
* Walk the overlay group recursively, returning the number of *leaf* layers
* (i.e. not groups) and how many of them are currently visible.
* Excludes base maps and internal layers (Markers, Drawings, vertex overlay).
*/
_countLayers() {
let totalOverlays = 0;
let activeOverlays = 0;
const HIDDEN_INTERNAL = new Set(['__vertex_highlight__']);
const visit = (group) => {
group.getLayers().forEach((layer) => {
if (layer.get('displayInLayerSwitcher') === false) return;
if (HIDDEN_INTERNAL.has(layer.get('title'))) return;
if (layer.getLayers) {
visit(layer);
} else {
totalOverlays++;
if (layer.getVisible()) activeOverlays++;
}
});
};
if (this.overlayGroup) visit(this.overlayGroup);
return { totalOverlays, activeOverlays };
}
/**
* Hide every overlay layer (base maps stay on). Wired to the footer
* "Reset overlays" button.
*/
_resetAllOverlays() {
const HIDDEN_INTERNAL = new Set(['__vertex_highlight__']);
const visit = (group) => {
group.getLayers().forEach((layer) => {
if (layer.get('displayInLayerSwitcher') === false) return;
if (HIDDEN_INTERNAL.has(layer.get('title'))) return;
if (layer.getLayers) {
visit(layer);
} else {
layer.setVisible(false);
}
});
};
if (this.overlayGroup) visit(this.overlayGroup);
console.log('[MapView] Reset overlays — all hidden');
}
/**
* Hook `change:visible` on every overlay leaf so the active-count badge
* stays in sync even when ol-ext doesn't re-fire drawlist (e.g. ticking
* a checkbox just toggles visibility without rebuilding the list).
* Also re-hooks when a new layer is added.
*/
_wireLayerSwitcherVisibilityHooks(layerSwitcher) {
const refresh = () => this._refreshLayerSwitcherChrome(layerSwitcher);
const hookLayer = (layer) => {
if (layer._lsVisHooked) return;
layer._lsVisHooked = true;
layer.on('change:visible', refresh);
};
const visit = (group) => {
group.getLayers().forEach((layer) => {
if (layer.getLayers) {
visit(layer);
// Listen for layers added later to this group
if (!group._lsAddHooked) {
group._lsAddHooked = true;
group.getLayers().on('add', (ev) => {
const added = ev.element;
if (added.getLayers) visit(added); else hookLayer(added);
refresh();
});
}
} else {
hookLayer(layer);
}
});
};
if (this.overlayGroup) visit(this.overlayGroup);
}
// ============================================================================
// 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;