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>
4851 lines
178 KiB
JavaScript
4851 lines
178 KiB
JavaScript
/**
|
||
* 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;">×</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;">×</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;">×</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;">×</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;">×</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;">×</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">×</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 (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<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 0–1 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;">×</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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* Find a LayerGroup inside the overlay group by its layerId.
|
||
*
|
||
* @param {number|string} id - The layerId to search for
|
||
* @returns {LayerGroup|null} The matching group, or null
|
||
*/
|
||
getLayerGroup(id) {
|
||
let found = null;
|
||
this.overlayGroup.getLayers().forEach((layer) => {
|
||
if (layer.get('layerId') === id) {
|
||
found = layer;
|
||
}
|
||
});
|
||
return found;
|
||
}
|
||
|
||
/**
|
||
* Find a LayerGroup inside the overlay group by its title.
|
||
*
|
||
* @param {string} title - The group title to search for
|
||
* @returns {LayerGroup|null} The matching group, or null
|
||
*/
|
||
getLayerGroupByTitle(title) {
|
||
let found = null;
|
||
this.overlayGroup.getLayers().forEach((layer) => {
|
||
if (layer.get('title') === title) {
|
||
found = layer;
|
||
}
|
||
});
|
||
return found;
|
||
}
|
||
|
||
/**
|
||
* Get the overlay LayerGroup for advanced usage
|
||
*/
|
||
getOverlayGroup() {
|
||
return this.overlayGroup;
|
||
}
|
||
|
||
/**
|
||
* Get the OpenLayers map instance for advanced usage
|
||
*/
|
||
getMap() {
|
||
return this.map;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Extent Helpers (used by offline-tile downloader)
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Get the current map view's visible extent in EPSG:3857 (Web Mercator).
|
||
* @returns {Array<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;
|