Major feature batch covering drawing-tool improvements, layer additions,
and offline-first capabilities. Largest changes in MapView.js (+1700),
main.js (+1500), public/sw.js (+367), and new modules under src/.
Drawing & editing toolkit
* Polygon Divide tool — sub-button under Split, divides a polygon into
N equal-area pieces via binary search; user picks the cutting edge
* UPN pick phase after Split and Divide — non-picked pieces have their
identifier fields cleared automatically
* Improved Merge algorithm — vertex-to-edge proximity (5 m tol.) with
hybrid lockstep extension; bold A/B labels on selected polygons
* Persistent vertex highlights — all vertices of the selected polygon
rendered as dots while edit mode is on, without subclassing ol-ext
* Toast notifications for merge/split/divide outcomes
* Shapefile import — addGeoJSONLayer now includes an image style so
Point features render (previously invisible)
Background & overlay layers
* DEAfrica Coastlines v0.4 (WMS) in Biophysical Environment
* DEAfrica Slope (SRTM 30m, style_slope) — semi-transparent background
* Contours hillshade — get_contours_hillshade.php → local SQLite cache
* OSM_roads — get_osm_roads.php → local SQLite cache, casing-stroke
style (black 3.5 px outer, #F0F1F0 1.5 px inner)
* External Source dialog — green + button in LayerSwitcher lets users
add WMS / WFS / XYZ layers at runtime
* Generic addWMSLayer / addXYZLayer with style, opacity, zIndex,
legendUrl, onlineOnly options
* TileWMS replaces ImageWMS (fixes 'Width exceeds 512' WMS errors)
* Legend panel — bottom-right, auto-shown for visible layers that
register a legendUrl
* Default base map setting in Settings, persisted in localStorage;
setBaseMap() on MapView
Offline tile cache (Phase 1 + 2)
* Service worker: per-host tile caches (osm / topo / satellite /
carto-light / carto-dark), counter-based eviction to prevent
iOS Safari memory-pressure reloads, GET_TILE_STATS /
CLEAR_TILE_CACHES message API
* pwa.js helpers: getActiveServiceWorker, onServiceWorkerControllerChange,
getTileCacheStats, clearTileCaches, getStorageEstimate
* Settings: Offline Map Tiles card with per-provider stats + clear
* Phase 2 download dialog: form to pick base map, area (current view /
district / Ghana), zoom range; live tile-count + size estimate;
progress bar with cancel; OfflineTileDownloader class with
concurrency + throttling
Local database management
* osm_roads table + saveOSMRoads / getLocalOSMRoads helpers
* CACHED_LAYER_TABLES allow-list with clearTable / clearAllCachedLayers
* Local Database Tables card: per-row Clear button (cached layers
only) + 'Refresh cached layers' header button with reload prompt
Build & infrastructure
* Shpjs lazy-loaded via dynamic import (saves ~140 kB from initial JS)
* chunkSizeWarningLimit raised to 900 kB (openlayers + sqlite3.wasm
can't be split further)
* Toast notification module (src/toast.js)
* Units module (src/units.js) for metric / imperial conversions
* PDF export module (src/pdf-export.js)
Documentation & SQL
* Topographic_Background_Layers_for_LUPMIS2.docx — research report
* OpenTopography_Workflow.svg/.png — ETL pipeline diagram
* LUPMIS2_Development_Status_Report.docx — April update section
* sql/create_landuse_parcels.sql — PostgreSQL schema for the LUSPA
land-use parcel specification (Feb 2026, revised), with PostGIS
geometry column and standard indices
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
606 lines
15 KiB
JavaScript
606 lines
15 KiB
JavaScript
/**
|
|
* MapTools - Drawing and Measurement Tools
|
|
*
|
|
* Provides:
|
|
* - Circle measurement tool with radius/area tooltip
|
|
* - Control bar with drawing tools
|
|
* - Line and polygon measurement
|
|
*
|
|
* Refactored from olmapstuffgis.js for LUPMIS PWA
|
|
*/
|
|
|
|
import { Draw } from 'ol/interaction';
|
|
import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style';
|
|
import { Vector as VectorLayer } from 'ol/layer';
|
|
import { Vector as VectorSource } from 'ol/source';
|
|
import Overlay from 'ol/Overlay';
|
|
import { LineString, Circle, Polygon } from 'ol/geom';
|
|
import { getLength, getArea } from 'ol/sphere';
|
|
import Feature from 'ol/Feature';
|
|
import { unByKey } from 'ol/Observable';
|
|
import { formatLength, formatArea, formatCircleExtent } from '../units.js';
|
|
|
|
// ol-ext imports
|
|
import EditBar from 'ol-ext/control/EditBar';
|
|
import Toggle from 'ol-ext/control/Toggle';
|
|
import Button from 'ol-ext/control/Button';
|
|
|
|
export class MapTools {
|
|
constructor(map, options = {}) {
|
|
this.map = map;
|
|
this.options = options;
|
|
|
|
// Create measurement layer
|
|
this.measureSource = new VectorSource();
|
|
this.measureLayer = new VectorLayer({
|
|
source: this.measureSource,
|
|
style: this.getMeasureStyle(),
|
|
title: 'Measurements',
|
|
zIndex: 100,
|
|
});
|
|
|
|
// Create drawing layer (utility layer for temporary draw interactions;
|
|
// hidden from LayerSwitcher since the EditBar has its own "Drawings" group)
|
|
this.drawSource = new VectorSource();
|
|
this.drawLayer = new VectorLayer({
|
|
source: this.drawSource,
|
|
style: this.getDrawStyle(),
|
|
title: 'Draw sketches',
|
|
displayInLayerSwitcher: false,
|
|
zIndex: 99,
|
|
});
|
|
|
|
// Insert both layers just before the last layer (Overlays group)
|
|
// so the LayerSwitcher order becomes: Overlays > Measurements > Markers > Base Maps
|
|
const layers = this.map.getLayers();
|
|
const overlayIdx = layers.getLength() - 1; // Overlays is the last layer
|
|
layers.insertAt(overlayIdx, this.drawLayer);
|
|
layers.insertAt(overlayIdx, this.measureLayer);
|
|
|
|
// Active interaction
|
|
this.activeInteraction = null;
|
|
this.measureTooltip = null;
|
|
this.measureTooltipElement = null;
|
|
|
|
// Callbacks
|
|
this.onMeasureCompleteCallbacks = [];
|
|
this.onDrawCompleteCallbacks = [];
|
|
}
|
|
|
|
/**
|
|
* Get style for measurement features
|
|
*/
|
|
getMeasureStyle() {
|
|
return new Style({
|
|
fill: new Fill({
|
|
color: 'rgba(255, 233, 106, 0.2)'
|
|
}),
|
|
stroke: new Stroke({
|
|
color: '#8B008B',
|
|
lineDash: [10, 10],
|
|
width: 2
|
|
}),
|
|
image: new CircleStyle({
|
|
radius: 5,
|
|
stroke: new Stroke({
|
|
color: '#8B008B'
|
|
}),
|
|
fill: new Fill({
|
|
color: 'rgba(255, 233, 106, 0.5)'
|
|
})
|
|
})
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get style for drawing features
|
|
*/
|
|
getDrawStyle() {
|
|
return new Style({
|
|
fill: new Fill({
|
|
color: 'rgba(255, 233, 106, 0.3)'
|
|
}),
|
|
stroke: new Stroke({
|
|
color: '#8B008B',
|
|
width: 2
|
|
}),
|
|
image: new CircleStyle({
|
|
radius: 6,
|
|
stroke: new Stroke({
|
|
color: '#8B008B',
|
|
width: 2
|
|
}),
|
|
fill: new Fill({
|
|
color: '#FFE96A'
|
|
})
|
|
})
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create measurement tooltip overlay
|
|
*/
|
|
createMeasureTooltip() {
|
|
if (this.measureTooltipElement) {
|
|
this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);
|
|
}
|
|
this.measureTooltipElement = document.createElement('div');
|
|
this.measureTooltipElement.className = 'measure-tooltip';
|
|
this.measureTooltip = new Overlay({
|
|
element: this.measureTooltipElement,
|
|
offset: [15, 0],
|
|
positioning: 'center-left',
|
|
stopEvent: false,
|
|
});
|
|
this.map.addOverlay(this.measureTooltip);
|
|
}
|
|
|
|
/**
|
|
* Remove any active interaction
|
|
*/
|
|
deactivate() {
|
|
if (this.activeInteraction) {
|
|
this.map.removeInteraction(this.activeInteraction);
|
|
this.activeInteraction = null;
|
|
}
|
|
if (this.measureTooltip) {
|
|
this.map.removeOverlay(this.measureTooltip);
|
|
this.measureTooltip = null;
|
|
}
|
|
if (this.measureTooltipElement && this.measureTooltipElement.parentNode) {
|
|
this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);
|
|
this.measureTooltipElement = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start circle measurement tool
|
|
* Draws a circle and shows radius + area in tooltip
|
|
*/
|
|
startCircleMeasure() {
|
|
this.deactivate();
|
|
this.createMeasureTooltip();
|
|
|
|
const drawCircle = new Draw({
|
|
source: this.measureSource,
|
|
type: 'Circle',
|
|
style: new Style({
|
|
fill: new Fill({
|
|
color: 'rgba(255, 233, 106, 0.2)'
|
|
}),
|
|
stroke: new Stroke({
|
|
color: 'rgba(139, 0, 139, 0.7)',
|
|
lineDash: [10, 10],
|
|
width: 2
|
|
}),
|
|
image: new CircleStyle({
|
|
radius: 5,
|
|
stroke: new Stroke({
|
|
color: 'rgba(139, 0, 139, 0.7)'
|
|
}),
|
|
fill: new Fill({
|
|
color: 'rgba(255, 233, 106, 0.5)'
|
|
})
|
|
})
|
|
})
|
|
});
|
|
|
|
this.activeInteraction = drawCircle;
|
|
this.map.addInteraction(drawCircle);
|
|
|
|
let listener;
|
|
|
|
drawCircle.on('drawstart', (evt) => {
|
|
const sketch = evt.feature;
|
|
|
|
listener = sketch.getGeometry().on('change', (e) => {
|
|
const geom = e.target;
|
|
|
|
if (geom instanceof Circle) {
|
|
const radius = geom.getRadius();
|
|
const area = formatCircleExtent(radius);
|
|
const radiusFormatted = formatLength(radius);
|
|
|
|
const output = `<strong>${radiusFormatted}</strong><br><small>${area}</small>`;
|
|
|
|
this.measureTooltipElement.innerHTML = output;
|
|
this.measureTooltip.setPosition(geom.getLastCoordinate());
|
|
}
|
|
});
|
|
});
|
|
|
|
drawCircle.on('drawend', (evt) => {
|
|
const feature = evt.feature;
|
|
const geom = feature.getGeometry();
|
|
const center = geom.getCenter();
|
|
const radius = geom.getRadius();
|
|
|
|
// Tag the circle feature so the dblclick handler can identify it
|
|
feature.set('_layerType', 'measure_circle');
|
|
feature.set('_radius', radius);
|
|
feature.set('_center', center);
|
|
|
|
// Create radius line for visualization
|
|
const radiusLine = new Feature({
|
|
geometry: new LineString([
|
|
center,
|
|
[center[0] + radius, center[1]]
|
|
])
|
|
});
|
|
radiusLine.set('_layerType', 'measure_circle_radius');
|
|
this.measureSource.addFeature(radiusLine);
|
|
|
|
// Make tooltip static
|
|
this.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';
|
|
this.measureTooltip.setOffset([0, -7]);
|
|
|
|
// Create new tooltip for next measurement
|
|
this.measureTooltipElement = null;
|
|
this.createMeasureTooltip();
|
|
|
|
unByKey(listener);
|
|
|
|
// Trigger callbacks
|
|
const result = {
|
|
type: 'circle',
|
|
center: center,
|
|
radius: radius,
|
|
area: Math.PI * radius * radius,
|
|
feature: feature,
|
|
};
|
|
this.onMeasureCompleteCallbacks.forEach(cb => cb(result));
|
|
});
|
|
|
|
return drawCircle;
|
|
}
|
|
|
|
/**
|
|
* Start line measurement tool
|
|
*/
|
|
startLineMeasure() {
|
|
this.deactivate();
|
|
this.createMeasureTooltip();
|
|
|
|
const drawLine = new Draw({
|
|
source: this.measureSource,
|
|
type: 'LineString',
|
|
style: this.getMeasureStyle(),
|
|
});
|
|
|
|
this.activeInteraction = drawLine;
|
|
this.map.addInteraction(drawLine);
|
|
|
|
let listener;
|
|
|
|
drawLine.on('drawstart', (evt) => {
|
|
const sketch = evt.feature;
|
|
|
|
listener = sketch.getGeometry().on('change', (e) => {
|
|
const geom = e.target;
|
|
const length = getLength(geom);
|
|
const output = formatLength(length);
|
|
|
|
this.measureTooltipElement.innerHTML = output;
|
|
this.measureTooltip.setPosition(geom.getLastCoordinate());
|
|
});
|
|
});
|
|
|
|
drawLine.on('drawend', (evt) => {
|
|
const feature = evt.feature;
|
|
const geom = feature.getGeometry();
|
|
const length = getLength(geom);
|
|
|
|
this.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';
|
|
this.measureTooltipElement = null;
|
|
this.createMeasureTooltip();
|
|
|
|
unByKey(listener);
|
|
|
|
const result = {
|
|
type: 'line',
|
|
length: length,
|
|
feature: feature,
|
|
};
|
|
this.onMeasureCompleteCallbacks.forEach(cb => cb(result));
|
|
});
|
|
|
|
return drawLine;
|
|
}
|
|
|
|
/**
|
|
* Start polygon/area measurement tool
|
|
*/
|
|
startAreaMeasure() {
|
|
this.deactivate();
|
|
this.createMeasureTooltip();
|
|
|
|
const drawPolygon = new Draw({
|
|
source: this.measureSource,
|
|
type: 'Polygon',
|
|
style: this.getMeasureStyle(),
|
|
});
|
|
|
|
this.activeInteraction = drawPolygon;
|
|
this.map.addInteraction(drawPolygon);
|
|
|
|
let listener;
|
|
|
|
drawPolygon.on('drawstart', (evt) => {
|
|
const sketch = evt.feature;
|
|
|
|
listener = sketch.getGeometry().on('change', (e) => {
|
|
const geom = e.target;
|
|
const area = getArea(geom);
|
|
const output = formatArea(area);
|
|
|
|
this.measureTooltipElement.innerHTML = output;
|
|
this.measureTooltip.setPosition(geom.getInteriorPoint().getCoordinates());
|
|
});
|
|
});
|
|
|
|
drawPolygon.on('drawend', (evt) => {
|
|
const feature = evt.feature;
|
|
const geom = feature.getGeometry();
|
|
const area = getArea(geom);
|
|
|
|
// Tag so the double-click handler can identify it
|
|
feature.set('_layerType', 'measure_area');
|
|
feature.set('_area', area);
|
|
|
|
this.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';
|
|
this.measureTooltipElement = null;
|
|
this.createMeasureTooltip();
|
|
|
|
unByKey(listener);
|
|
|
|
const result = {
|
|
type: 'polygon',
|
|
area: area,
|
|
feature: feature,
|
|
coordinate: geom.getInteriorPoint().getCoordinates(),
|
|
};
|
|
this.onMeasureCompleteCallbacks.forEach(cb => cb(result));
|
|
});
|
|
|
|
return drawPolygon;
|
|
}
|
|
|
|
/**
|
|
* Start point drawing tool
|
|
*/
|
|
startDrawPoint() {
|
|
this.deactivate();
|
|
|
|
const drawPoint = new Draw({
|
|
source: this.drawSource,
|
|
type: 'Point',
|
|
style: this.getDrawStyle(),
|
|
});
|
|
|
|
this.activeInteraction = drawPoint;
|
|
this.map.addInteraction(drawPoint);
|
|
|
|
drawPoint.on('drawend', (evt) => {
|
|
const result = {
|
|
type: 'point',
|
|
feature: evt.feature,
|
|
};
|
|
this.onDrawCompleteCallbacks.forEach(cb => cb(result));
|
|
});
|
|
|
|
return drawPoint;
|
|
}
|
|
|
|
/**
|
|
* Start line drawing tool
|
|
*/
|
|
startDrawLine() {
|
|
this.deactivate();
|
|
|
|
const drawLine = new Draw({
|
|
source: this.drawSource,
|
|
type: 'LineString',
|
|
style: this.getDrawStyle(),
|
|
});
|
|
|
|
this.activeInteraction = drawLine;
|
|
this.map.addInteraction(drawLine);
|
|
|
|
drawLine.on('drawend', (evt) => {
|
|
const result = {
|
|
type: 'line',
|
|
feature: evt.feature,
|
|
};
|
|
this.onDrawCompleteCallbacks.forEach(cb => cb(result));
|
|
});
|
|
|
|
return drawLine;
|
|
}
|
|
|
|
/**
|
|
* Start polygon drawing tool
|
|
*/
|
|
startDrawPolygon() {
|
|
this.deactivate();
|
|
|
|
const drawPolygon = new Draw({
|
|
source: this.drawSource,
|
|
type: 'Polygon',
|
|
style: this.getDrawStyle(),
|
|
});
|
|
|
|
this.activeInteraction = drawPolygon;
|
|
this.map.addInteraction(drawPolygon);
|
|
|
|
drawPolygon.on('drawend', (evt) => {
|
|
const result = {
|
|
type: 'polygon',
|
|
feature: evt.feature,
|
|
};
|
|
this.onDrawCompleteCallbacks.forEach(cb => cb(result));
|
|
});
|
|
|
|
return drawPolygon;
|
|
}
|
|
|
|
/**
|
|
* Clear all measurements
|
|
*/
|
|
clearMeasurements() {
|
|
this.measureSource.clear();
|
|
// Remove static tooltips
|
|
const tooltips = document.querySelectorAll('.measure-tooltip-static');
|
|
tooltips.forEach(el => el.parentNode.removeChild(el));
|
|
}
|
|
|
|
/**
|
|
* Clear all drawings
|
|
*/
|
|
clearDrawings() {
|
|
this.drawSource.clear();
|
|
}
|
|
|
|
/**
|
|
* Clear all (measurements + drawings)
|
|
*/
|
|
clearAll() {
|
|
this.clearMeasurements();
|
|
this.clearDrawings();
|
|
}
|
|
|
|
/**
|
|
* Register callback for measurement completion
|
|
*/
|
|
onMeasureComplete(callback) {
|
|
this.onMeasureCompleteCallbacks.push(callback);
|
|
}
|
|
|
|
/**
|
|
* Register callback for drawing completion
|
|
*/
|
|
onDrawComplete(callback) {
|
|
this.onDrawCompleteCallbacks.push(callback);
|
|
}
|
|
|
|
/**
|
|
* Create a control bar with measurement and drawing tools
|
|
* Returns the ol-ext Bar control
|
|
*/
|
|
createControlBar(options = {}) {
|
|
const position = options.position || 'top-left';
|
|
|
|
// Main control bar
|
|
const mainBar = new EditBar({
|
|
group: true,
|
|
className: 'map-tools-bar',
|
|
});
|
|
|
|
// Measurement toggle group
|
|
const measureBar = new EditBar({
|
|
toggleOne: true,
|
|
group: true,
|
|
});
|
|
|
|
// Circle measure button
|
|
const circleBtn = new Toggle({
|
|
html: '<span class="tool-icon">⭕</span>',
|
|
title: 'Measure Circle (radius & area)',
|
|
className: 'measure-circle-btn',
|
|
onToggle: (active) => {
|
|
if (active) {
|
|
this.startCircleMeasure();
|
|
} else {
|
|
this.deactivate();
|
|
}
|
|
}
|
|
});
|
|
measureBar.addControl(circleBtn);
|
|
|
|
// Line measure button
|
|
const lineBtn = new Toggle({
|
|
html: '<span class="tool-icon">📏</span>',
|
|
title: 'Measure Distance',
|
|
className: 'measure-line-btn',
|
|
onToggle: (active) => {
|
|
if (active) {
|
|
this.startLineMeasure();
|
|
} else {
|
|
this.deactivate();
|
|
}
|
|
}
|
|
});
|
|
measureBar.addControl(lineBtn);
|
|
|
|
// Area measure button
|
|
const areaBtn = new Toggle({
|
|
html: '<span class="tool-icon">⬛</span>',
|
|
title: 'Measure Area',
|
|
className: 'measure-area-btn',
|
|
onToggle: (active) => {
|
|
if (active) {
|
|
this.startAreaMeasure();
|
|
} else {
|
|
this.deactivate();
|
|
}
|
|
}
|
|
});
|
|
measureBar.addControl(areaBtn);
|
|
|
|
// Clear measurements button
|
|
const clearBtn = new Button({
|
|
html: '<span class="tool-icon">🗑️</span>',
|
|
title: 'Clear Measurements',
|
|
className: 'clear-measure-btn',
|
|
handleClick: () => {
|
|
this.clearMeasurements();
|
|
// Deactivate any active toggle
|
|
circleBtn.setActive(false);
|
|
lineBtn.setActive(false);
|
|
areaBtn.setActive(false);
|
|
}
|
|
});
|
|
measureBar.addControl(clearBtn);
|
|
|
|
mainBar.addControl(measureBar);
|
|
|
|
return mainBar;
|
|
}
|
|
|
|
/**
|
|
* Get the measure layer
|
|
*/
|
|
getMeasureLayer() {
|
|
return this.measureLayer;
|
|
}
|
|
|
|
/**
|
|
* Get the draw layer
|
|
*/
|
|
getDrawLayer() {
|
|
return this.drawLayer;
|
|
}
|
|
|
|
/**
|
|
* Get the measure source
|
|
*/
|
|
getMeasureSource() {
|
|
return this.measureSource;
|
|
}
|
|
|
|
/**
|
|
* Get the draw source
|
|
*/
|
|
getDrawSource() {
|
|
return this.drawSource;
|
|
}
|
|
|
|
/**
|
|
* Check if any tool is currently active
|
|
*/
|
|
isActive() {
|
|
return this.activeInteraction !== null;
|
|
}
|
|
}
|
|
|
|
export default MapTools;
|