pwaLUPMIS2/src/components/MapTools.js
ekke ef12e4477b Offline tile cache, polygon Divide, topographic layer integrations
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>
2026-05-26 10:55:30 +02:00

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;