/** * 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 = `${radiusFormatted}
${area}`; 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: '', 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: '📏', 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: '', 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: '🗑️', 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;