/**
* 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 }]) =>
``
)
.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
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: '',
className: 'ol-undo',
title: 'Undo',
handleClick: () => {
if (this._undoRedo.hasUndo()) this._undoRedo.undo();
},
}),
new Button({
html: '',
className: 'ol-redo',
title: 'Redo',
handleClick: () => {
if (this._undoRedo.hasRedo()) this._undoRedo.redo();
},
}),
new Button({
html: '',
className: 'ol-save',
title: 'Save drawings',
handleClick: () => {
this.dispatchEditEvent('save');
},
}),
],
});
this.editBar.addControl(extraBar);
// 6a-split. Custom Split tool with Lines / Polygons sub-categories.
// The default ol-ext Split only handles LineString. We add a parent
// Toggle with a sub-bar containing two sub-toggles: "Lines" (ol-ext
// Split) and "Polygons" (our PolygonSplitInteraction).
// No explicit sources โ both interactions search ALL visible vector layers,
// so they work on drawn features, parcels, zones, and any other polygon layer.
this._lineSplitInteraction = new Split();
this._polygonSplitInteraction = new PolygonSplitInteraction();
this.map.addInteraction(this._lineSplitInteraction);
this.map.addInteraction(this._polygonSplitInteraction);
this._lineSplitInteraction.setActive(false);
this._polygonSplitInteraction.setActive(false);
// When a parcel is split, the user picks which piece keeps the UPN.
this._polygonSplitInteraction.on('splitpick', (evt) => {
const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID'];
for (const feat of evt.features) {
if (feat === evt.picked) continue;
for (const field of idFields) {
if (feat.get(field) !== undefined) {
feat.set(field, '');
}
}
}
});
// Polygon Divide interaction (parameter-driven equal-area division)
this._polygonDivideInteraction = new PolygonDivideInteraction();
this.map.addInteraction(this._polygonDivideInteraction);
this._polygonDivideInteraction.setActive(false);
const splitLineToggle = new Toggle({
html: '',
className: 'ol-split-line',
title: 'Split Lines',
name: 'SplitLine',
interaction: this._lineSplitInteraction,
autoActivate: true,
});
const splitPolyToggle = new Toggle({
html: '',
className: 'ol-split-polygon',
title: 'Split Polygons',
name: 'SplitPolygon',
interaction: this._polygonSplitInteraction,
});
const splitDivideToggle = new Toggle({
html: '',
className: 'ol-split-divide',
title: 'Divide Polygon',
name: 'DividePolygon',
interaction: this._polygonDivideInteraction,
});
const splitSubBar = new Bar({
toggleOne: true,
autoDeactivate: true,
controls: [splitLineToggle, splitPolyToggle, splitDivideToggle],
});
const splitParentToggle = new Toggle({
className: 'ol-split',
title: 'Split',
name: 'Split',
bar: splitSubBar,
onToggle: (active) => {
if (!active) {
this._lineSplitInteraction.setActive(false);
this._polygonSplitInteraction.setActive(false);
this._polygonDivideInteraction.setActive(false);
}
},
});
this.editBar.addControl(splitParentToggle);
// Listen for divide form request โ show divide popup
this._polygonDivideInteraction.on('divideform', (evt) => {
this.showDividePopup(evt.feature, evt.source, evt.coordinate);
});
this._polygonDivideInteraction.on('dividecancel', () => {
this.hideDividePopup();
});
// When a parcel is divided, the user picks which piece keeps the UPN.
// The picked piece gets the original properties; all others get UPN cleared.
this._polygonDivideInteraction.on('dividepick', (evt) => {
const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID'];
for (const feat of evt.features) {
if (feat === evt.picked) continue;
// Clear identifier fields on the non-picked pieces
for (const field of idFields) {
if (feat.get(field) !== undefined) {
feat.set(field, '');
}
}
}
});
// 6a-merge. Polygon Merge tool โ select two adjacent polygons, click shared
// edges, and merge them into one. For parcels, a UPN chooser popup appears.
this._polygonMergeInteraction = new PolygonMergeInteraction();
this.map.addInteraction(this._polygonMergeInteraction);
this._polygonMergeInteraction.setActive(false);
const mergeToggle = new Toggle({
html: '',
className: 'ol-merge',
title: 'Merge Polygons',
name: 'Merge',
interaction: this._polygonMergeInteraction,
});
this.editBar.addControl(mergeToggle);
// Listen for merged-parcel event โ show UPN chooser
this._polygonMergeInteraction.on('mergedparcel', (evt) => {
this.showMergeIdentifierPopup(evt.merged, evt.propsA, evt.propsB, evt.coordinate);
});
// 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: '',
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>}
*/
_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 = `
`;
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
*/
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 = `
โ Add Location
`;
// Create the overlay
this.addLocationPopup = new Overlay({
element: this.addLocationPopupElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: true, // Prevent click from propagating
autoPan: true,
autoPanAnimation: {
duration: 250,
},
});
this.map.addOverlay(this.addLocationPopup);
// Store clicked coordinates
this.addLocationCoords = null;
// Set up close button handler
const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close');
closeBtn.addEventListener('click', () => {
this.hideAddLocationPopup();
});
// Store form submit callbacks
this.addLocationCallbacks = [];
}
/**
* Show the Add Location popup at the specified coordinate
*/
showAddLocationPopup(coordinate) {
const [lon, lat] = toLonLat(coordinate);
this.addLocationCoords = { lon, lat };
// Update coordinates display
const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords');
coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`;
// Reset form
const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
form.reset();
// Position and show popup
this.addLocationPopup.setPosition(coordinate);
}
/**
* Hide the Add Location popup
*/
hideAddLocationPopup() {
this.addLocationPopup.setPosition(undefined);
this.addLocationCoords = null;
}
/**
* Register a callback for when a location is submitted via the map popup
* Callback receives: { name, category, description, lon, lat }
*/
onAddLocation(callback) {
this.addLocationCallbacks.push(callback);
// Set up form submit handler (only once)
if (this.addLocationCallbacks.length === 1) {
const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
if (!this.addLocationCoords) return;
const formData = new FormData(form);
const data = {
name: formData.get('name'),
category: formData.get('category'),
description: formData.get('description'),
lon: this.addLocationCoords.lon,
lat: this.addLocationCoords.lat,
};
// Call all registered callbacks
this.addLocationCallbacks.forEach(cb => cb(data));
// Hide popup after submission
this.hideAddLocationPopup();
});
}
}
/**
* Create base layers group for LayerSwitcher
*/
createBaseLayers(defaultBasemap) {
const topoLayer = new TileLayer({
title: 'Topographic',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'topo',
source: new XYZ({
url: 'https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png',
attributions: 'Map data: ยฉ OpenTopoMap',
maxZoom: 17,
crossOrigin: 'anonymous',
}),
});
topoLayer.set('basemapKey', 'topo');
const cartoLightLayer = new TileLayer({
title: 'Carto Light',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'carto-light',
source: new XYZ({
url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
attributions: 'ยฉ CARTO',
maxZoom: 19,
crossOrigin: 'anonymous',
}),
});
cartoLightLayer.set('basemapKey', 'carto-light');
const cartoDarkLayer = new TileLayer({
title: 'Carto Dark',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'carto-dark',
source: new XYZ({
url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
attributions: 'ยฉ CARTO',
maxZoom: 19,
crossOrigin: 'anonymous',
}),
});
cartoDarkLayer.set('basemapKey', 'carto-dark');
const osmCycleLayer = new TileLayer({
title: 'OSM Cycle map',
type: 'base',
zIndex: -100,
visible: false, //defaultBasemap === 'osm',
source: new OSM({
"url" : "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=ae1339c46dd3446b9c491e7336d38760"
}),
});
osmCycleLayer.set('basemapKey', 'cycle');
const satelliteLayer = new TileLayer({
title: 'Satellite',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'satellite',
source: new XYZ({
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attributions: 'Tiles ยฉ Esri',
maxZoom: 19,
crossOrigin: 'anonymous',
}),
});
satelliteLayer.set('basemapKey', 'satellite');
const googleLayer = new TileLayer({
title: 'Google Sat',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'googlesat',
source: new XYZ({
// url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
url: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga',
attributions: 'Tiles ยฉ Google',
maxZoom: 19,
crossOrigin: 'anonymous',
}),
});
googleLayer.set('basemapKey', 'googlesat');
const osmLayer = new TileLayer({
title: 'OpenStreetMap',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'osm',
source: new OSM(),
});
osmLayer.set('basemapKey', 'osm');
// Remember the base-map layers so setBaseMap() can toggle visibility later
this._baseMapLayers = [
cartoLightLayer, cartoDarkLayer, osmCycleLayer,
satelliteLayer, googleLayer, osmLayer, topoLayer,
];
// Return LayerGroup. 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 =
'';
target.appendChild(btn);
// ---------- Picker panel ----------
const panel = document.createElement('div');
panel.className = 'ls-basemap-panel';
panel.innerHTML =
'
Base Map
' +
'
' +
OPTIONS.map((opt) => `
`).join('') +
'
';
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
? ''
: '';
}
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 = '';
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 =
'' +
'';
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 (0โ1). Use ~0.5 for background-style layers.
* @param {number} [options.zIndex] Render z-index. Use negative values (e.g. -10) to force the
* layer behind all default-z-index layers regardless of group order.
* @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible.
* @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on
* while offline, explaining that the layer requires connectivity.
* @returns {TileLayer|null} The created layer, or null if group not found
*/
addWMSLayer(groupTitle, title, url, layers, options = {}) {
const group = this.getLayerGroupByTitle(groupTitle);
if (!group) {
console.warn(`[MapView] Layer group "${groupTitle}" not found โ cannot add WMS layer "${title}"`);
return null;
}
const params = { LAYERS: layers, TILED: true, WIDTH: 256, HEIGHT: 256 };
if (options.style !== undefined) params.STYLES = options.style;
const wmsSource = new TileWMS({
url,
params,
serverType: options.serverType !== undefined ? options.serverType : 'geoserver',
crossOrigin: 'anonymous',
hidpi: false,
attributions: options.attributions,
});
const wmsLayer = new TileLayer({
title,
visible: options.visible !== undefined ? options.visible : true,
source: wmsSource,
opacity: options.opacity !== undefined ? options.opacity : 1,
zIndex: options.zIndex,
});
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 (0โ1). Use ~0.5 for background-style layers.
* @param {number} [options.zIndex] Render z-index. Use negative values to force behind other layers.
* @param {string} [options.legendUrl] URL of a legend image to display while the layer is visible.
* @param {boolean} [options.onlineOnly=false] If true, show a toast when the user toggles the layer on
* while offline, explaining that the layer requires connectivity.
* @returns {TileLayer|null} The created layer, or null if group not found
*/
addXYZLayer(groupTitle, title, url, options = {}) {
const group = this.getLayerGroupByTitle(groupTitle);
if (!group) {
console.warn(`[MapView] Layer group "${groupTitle}" not found โ cannot add XYZ layer "${title}"`);
return null;
}
const xyzSource = new XYZ({
url,
crossOrigin: 'anonymous',
maxZoom: options.maxZoom !== undefined ? options.maxZoom : 19,
attributions: options.attributions,
});
const xyzLayer = new TileLayer({
title,
visible: options.visible !== undefined ? options.visible : true,
source: xyzSource,
opacity: options.opacity !== undefined ? options.opacity : 1,
zIndex: options.zIndex,
});
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 0โ1 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