/**
* 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;