diff --git a/geoengine_tools/README.rst b/geoengine_tools/README.rst index 4cede6d88..a488e8c55 100644 --- a/geoengine_tools/README.rst +++ b/geoengine_tools/README.rst @@ -49,6 +49,13 @@ This module provides: (``drawOnRecord``). - Generic geometry / SRID utilities (``geo_utils``): WKB hex decoding, Polygon → MultiPolygon normalization and SRID resolution helpers. +- A measure toolbox available to every user (read-only): live GPS + (WGS84) coordinates of the mouse on hover, a distance ruler, a polygon + surface/perimeter tool, a proximity check that flags pairs of objects + closer than a given threshold, and clipboard export of the last / all + measurements. Measurements are geodesic (``ol.sphere``), so they stay + correct in any map projection (e.g. EPSG:2056 with + ``geoengine_swisstopo``). **Table of contents** @@ -65,6 +72,24 @@ panel and click the pencil button: the record switches to edit mode and you can draw its new geometry on the map; it is saved automatically when the drawing ends. +A measure toolbox is available on the right side of the map for every +user: + +- **Coordinates** (crosshairs): toggle on, then move the mouse over the + map to read the live GPS (WGS84) coordinates. In a projected CRS the + native E/N coordinates are shown too. +- **Distance** (horizontal arrows): click to add points, double-click to + finish; the geodesic length of the line is displayed. +- **Surface** (square): click to draw a polygon, double-click to finish; + the geodesic area and perimeter are displayed. +- **Proximity** (compress): enter a minimum distance (e.g. 4 m); every + pair of map objects (parcels) closer than that threshold is + highlighted with the measured gap. +- **Copy last** (copy): copy the last measurement to the clipboard. +- **Copy all** (clipboard): copy every measurement taken to the + clipboard. +- **Eraser**: clear all measurements and return to normal selection. + For developers, the module patches ``GeoengineRenderer`` and exposes ``startDrawInteraction``, ``getGeometryFieldName`` and ``drawOnRecord`` on the prototype, plus generic helpers importable from diff --git a/geoengine_tools/readme/DESCRIPTION.md b/geoengine_tools/readme/DESCRIPTION.md index 6e6a567c2..43f5c5a8b 100644 --- a/geoengine_tools/readme/DESCRIPTION.md +++ b/geoengine_tools/readme/DESCRIPTION.md @@ -14,3 +14,9 @@ This module provides: lets a user redraw the geometry of an existing record (`drawOnRecord`). - Generic geometry / SRID utilities (`geo_utils`): WKB hex decoding, Polygon → MultiPolygon normalization and SRID resolution helpers. +- A measure toolbox available to every user (read-only): live GPS (WGS84) + coordinates of the mouse on hover, a distance ruler, a polygon + surface/perimeter tool, a proximity check that flags pairs of objects closer + than a given threshold, and clipboard export of the last / all measurements. + Measurements are geodesic (`ol.sphere`), so they stay correct in any map + projection (e.g. EPSG:2056 with `geoengine_swisstopo`). diff --git a/geoengine_tools/readme/USAGE.md b/geoengine_tools/readme/USAGE.md index 47d201c83..a62ac535c 100644 --- a/geoengine_tools/readme/USAGE.md +++ b/geoengine_tools/readme/USAGE.md @@ -4,6 +4,22 @@ To edit the geometry of an existing record, expand it in the **Records** panel and click the pencil button: the record switches to edit mode and you can draw its new geometry on the map; it is saved automatically when the drawing ends. +A measure toolbox is available on the right side of the map for every user: + +- **Coordinates** (crosshairs): toggle on, then move the mouse over the map to + read the live GPS (WGS84) coordinates. In a projected CRS the native E/N + coordinates are shown too. +- **Distance** (horizontal arrows): click to add points, double-click to + finish; the geodesic length of the line is displayed. +- **Surface** (square): click to draw a polygon, double-click to finish; the + geodesic area and perimeter are displayed. +- **Proximity** (compress): enter a minimum distance (e.g. 4 m); every pair of + map objects (parcels) closer than that threshold is highlighted with the + measured gap. +- **Copy last** (copy): copy the last measurement to the clipboard. +- **Copy all** (clipboard): copy every measurement taken to the clipboard. +- **Eraser**: clear all measurements and return to normal selection. + For developers, the module patches `GeoengineRenderer` and exposes `startDrawInteraction`, `getGeometryFieldName` and `drawOnRecord` on the prototype, plus generic helpers importable from diff --git a/geoengine_tools/static/description/index.html b/geoengine_tools/static/description/index.html index ccc28011c..536e18dd6 100644 --- a/geoengine_tools/static/description/index.html +++ b/geoengine_tools/static/description/index.html @@ -391,6 +391,13 @@

GeoEngine Tools

(drawOnRecord).
  • Generic geometry / SRID utilities (geo_utils): WKB hex decoding, Polygon → MultiPolygon normalization and SRID resolution helpers.
  • +
  • A measure toolbox available to every user (read-only): live GPS +(WGS84) coordinates of the mouse on hover, a distance ruler, a polygon +surface/perimeter tool, a proximity check that flags pairs of objects +closer than a given threshold, and clipboard export of the last / all +measurements. Measurements are geodesic (ol.sphere), so they stay +correct in any map projection (e.g. EPSG:2056 with +geoengine_swisstopo).
  • Table of contents

    @@ -411,6 +418,24 @@

    Usage

    panel and click the pencil button: the record switches to edit mode and you can draw its new geometry on the map; it is saved automatically when the drawing ends.

    +

    A measure toolbox is available on the right side of the map for every +user:

    +

    For developers, the module patches GeoengineRenderer and exposes startDrawInteraction, getGeometryFieldName and drawOnRecord on the prototype, plus generic helpers importable from diff --git a/geoengine_tools/static/src/css/measure_tools.css b/geoengine_tools/static/src/css/measure_tools.css new file mode 100644 index 000000000..e27c8d10d --- /dev/null +++ b/geoengine_tools/static/src/css/measure_tools.css @@ -0,0 +1,94 @@ +/* Measure toolbox — positioned on the right side so it never collides with + the core draw/select/edit controls (left side, shown only to admins). */ +.geoengine-measure-coord-control { + top: 4em; + right: 0.5em; +} + +.geoengine-measure-distance-control { + top: 6em; + right: 0.5em; +} + +.geoengine-measure-area-control { + top: 8em; + right: 0.5em; +} + +.geoengine-measure-proximity-control { + top: 10em; + right: 0.5em; +} + +.geoengine-measure-copy-last-control { + top: 12em; + right: 0.5em; +} + +.geoengine-measure-copy-all-control { + top: 14em; + right: 0.5em; +} + +.geoengine-measure-clear-control { + top: 16em; + right: 0.5em; +} + +/* Coordinate readout toggle stays highlighted independently of the + core controls (it does not use the shared .selected-control class). */ +.geoengine-coord-active > i { + color: #71639e; +} + +/* Live GPS / native coordinate readout. The .ol-control class is intentionally + omitted (no button chrome), so position must be declared explicitly here — + otherwise bottom/left/transform are ignored and the box renders off-screen. */ +.geoengine-coord-box { + position: absolute; + z-index: 1000; + bottom: 0.5em; + left: 50%; + transform: translateX(-50%); + padding: 2px 8px; + font-family: monospace; + font-size: 12px; + white-space: nowrap; + color: #fff; + background-color: rgba(0, 0, 0, 0.7); + border-radius: 4px; + pointer-events: none; +} + +/* Measurement tooltips that follow the geometry. */ +.geoengine-measure-tooltip { + position: relative; + padding: 3px 7px; + font-size: 12px; + line-height: 1.3; + white-space: nowrap; + color: #fff; + background-color: rgba(0, 0, 0, 0.7); + border-radius: 4px; + pointer-events: none; +} + +.geoengine-measure-tooltip-measuring { + opacity: 0.9; + font-weight: bold; +} + +.geoengine-measure-tooltip-static { + background-color: #71639e; + color: #fff; +} + +.geoengine-measure-tooltip-static::before { + content: ""; + position: absolute; + bottom: -6px; + left: 50%; + margin-left: -6px; + border: 6px solid transparent; + border-top-color: #71639e; +} diff --git a/geoengine_tools/static/src/js/geoengine_measure_tools.esm.js b/geoengine_tools/static/src/js/geoengine_measure_tools.esm.js new file mode 100644 index 000000000..83a47b1eb --- /dev/null +++ b/geoengine_tools/static/src/js/geoengine_measure_tools.esm.js @@ -0,0 +1,554 @@ +/** @odoo-module */ + +/* global ol */ + +/** + * Measure toolbox for the GeoengineRenderer. + * + * Adds three read-only measurement tools (available to every user, not just + * geoengine admins, since measuring never alters data): + * + * - Coordinate readout: live GPS (WGS84) coordinates of the mouse on hover. + * When the map runs in a projected CRS (e.g. EPSG:2056 / CH1903+ LV95 from + * geoengine_swisstopo) the native E/N coordinates are shown as well. + * - Distance ruler: click to add points, double-click to finish; shows the + * geodesic length of the drawn line. + * - Polygon area: click to draw a polygon, double-click to finish; shows the + * geodesic area and perimeter. + * + * A "clear" button removes all measurements and restores normal selection. + * + * All measurements use ol.sphere (geodesic), so they stay correct in any map + * projection without depending on the projection-aware modules. + */ + +import {GeoengineRenderer} from "@base_geoengine/js/views/geoengine/geoengine_renderer/geoengine_renderer.esm"; +import {patch} from "@web/core/utils/patch"; + +// Below this gap (in meters) two objects are considered touching/overlapping +// rather than "too close", so the proximity check ignores them (no 0.00 m noise). +const MIN_PROXIMITY_GAP = 0.01; + +patch(GeoengineRenderer.prototype, { + /** + * Add the measure controls on top of the core controls. + */ + setupControls() { + super.setupControls(...arguments); + this._setupMeasureControls(); + }, + + /** + * Tearing down the active measure tool whenever a core control + * (draw/select/edit) takes over. The core controls all funnel through + * addSelectedClassToButton, so this is the single hook that keeps the + * measure tools mutually exclusive with the core interactions. + */ + addSelectedClassToButton(button) { + super.addSelectedClassToButton(button); + this._deactivateMeasureTools(); + }, + + // ---- Setup ---------------------------------------------------------- + + _setupMeasureControls() { + if (this._measureLayer) { + return; + } + this._measureTooltips = []; + this._measureButtons = []; + this._activeMeasureType = null; + // Plain-text record of every measurement taken (for clipboard export). + this._measureResults = []; + + // Vector layer holding the finished measurement geometries. + this._measureSource = new ol.source.Vector(); + this._measureLayer = new ol.layer.Vector({ + source: this._measureSource, + zIndex: 10000, + style: this._measureStyle(), + }); + this.map.addLayer(this._measureLayer); + + // Live coordinate readout box. + this._coordBox = document.createElement("div"); + this._coordBox.className = "geoengine-coord-box ol-unselectable"; + this._coordBox.style.display = "none"; + const coordControl = new ol.control.Control({element: this._coordBox}); + this.map.addControl(coordControl); + + // Toolbar buttons. + this._createMeasureControl( + "fa-crosshairs", + "geoengine-measure-coord-control ol-unselectable ol-control", + "Coordonnées GPS (survol)", + (button) => this._toggleCoordinateReadout(button) + ); + this._createMeasureControl( + "fa-arrows-h", + "geoengine-measure-distance-control ol-unselectable ol-control", + "Mesurer une distance", + (button) => this._startMeasure("LineString", button) + ); + this._createMeasureControl( + "fa-square-o", + "geoengine-measure-area-control ol-unselectable ol-control", + "Mesurer une surface / un périmètre", + (button) => this._startMeasure("Polygon", button) + ); + this._createMeasureControl( + "fa-compress", + "geoengine-measure-proximity-control ol-unselectable ol-control", + "Contrôler la distance minimale entre objets", + () => this._checkProximity() + ); + this._createMeasureControl( + "fa-copy", + "geoengine-measure-copy-last-control ol-unselectable ol-control", + "Copier la dernière mesure", + () => this._copyLast() + ); + this._createMeasureControl( + "fa-clipboard", + "geoengine-measure-copy-all-control ol-unselectable ol-control", + "Copier toutes les mesures", + () => this._copyAll() + ); + this._createMeasureControl( + "fa-eraser", + "geoengine-measure-clear-control ol-unselectable ol-control", + "Effacer les mesures", + () => this._clearMeasures() + ); + }, + + _createMeasureControl(iconClass, className, title, onClick) { + const {element, button} = this.createHtmlControl( + ``, + className + ); + button.setAttribute("title", title); + button.setAttribute("type", "button"); + button.addEventListener("click", () => onClick(button)); + this.map.addControl(new ol.control.Control({element})); + this._measureButtons.push(button); + return button; + }, + + // ---- Coordinate readout -------------------------------------------- + + _toggleCoordinateReadout(button) { + if (this._coordActive) { + this._coordActive = false; + this.map.un("pointermove", this._coordHandler); + button.classList.remove("geoengine-coord-active"); + this._coordBox.style.display = "none"; + return; + } + this._coordActive = true; + button.classList.add("geoengine-coord-active"); + this._coordBox.style.display = "block"; + this._coordBox.textContent = "—"; + this._coordHandler = (evt) => this._updateCoordReadout(evt.coordinate); + this.map.on("pointermove", this._coordHandler); + }, + + _updateCoordReadout(coordinate) { + const proj = this.map.getView().getProjection(); + const code = proj.getCode(); + let html = ""; + try { + const lonLat = ol.proj.transform(coordinate, proj, "EPSG:4326"); + html = `GPS (WGS84) : ${lonLat[1].toFixed(5)}, ${lonLat[0].toFixed(5)}`; + } catch { + html = "GPS : indisponible"; + } + // When the map is in a projected CRS, also show native E/N coordinates. + if (code !== "EPSG:4326" && code !== "EPSG:3857") { + html += `  |  ${code} : E ${coordinate[0].toFixed( + 2 + )} N ${coordinate[1].toFixed(2)}`; + } + this._coordBox.innerHTML = html; + }, + + // ---- Distance / area measurement ----------------------------------- + + _startMeasure(type, button) { + // Toggle off if the same tool is clicked again. + if (this._activeMeasureType === type) { + this._deactivateMeasureTools(); + this._restoreSelection(); + return; + } + + // Clear core interactions so clicks only feed the measure tool. + this.hidePopup(); + this.removeDrawInteraction(); + this.removeModifyInteraction(); + this.removeSelectInteraction(); + this._removeMeasureDraw(); + + this._highlightMeasureButton(button); + this._activeMeasureType = type; + + const draw = new ol.interaction.Draw({ + source: this._measureSource, + type, + style: this._measureStyle(true), + }); + this._measureDraw = draw; + this.map.addInteraction(draw); + + let tooltip = null; + let tooltipEl = null; + let changeKey = null; + + draw.on("drawstart", (evt) => { + const geom = evt.feature.getGeometry(); + ({tooltip, tooltipEl} = this._createMeasureTooltip()); + changeKey = geom.on("change", (e) => { + const g = e.target; + if (g instanceof ol.geom.Polygon) { + tooltipEl.innerHTML = this._formatArea(g); + tooltip.setPosition(g.getInteriorPoint().getCoordinates()); + } else { + tooltipEl.innerHTML = this._formatLength(g); + tooltip.setPosition(g.getLastCoordinate()); + } + }); + }); + + draw.on("drawend", (evt) => { + if (tooltipEl) { + tooltipEl.className = + "geoengine-measure-tooltip geoengine-measure-tooltip-static"; + tooltip.setOffset([0, -7]); + } + if (changeKey) { + ol.Observable.unByKey(changeKey); + } + this._recordMeasureResult(evt.feature.getGeometry()); + tooltip = null; + tooltipEl = null; + changeKey = null; + }); + }, + + _createMeasureTooltip() { + const el = document.createElement("div"); + el.className = "geoengine-measure-tooltip geoengine-measure-tooltip-measuring"; + const tooltip = new ol.Overlay({ + element: el, + offset: [0, -15], + positioning: "bottom-center", + stopEvent: false, + insertFirst: false, + }); + this.map.addOverlay(tooltip); + this._measureTooltips.push(tooltip); + return {tooltip, tooltipEl: el}; + }, + + _distText(meters) { + if (meters > 1000) { + return `${(meters / 1000).toFixed(3)} km`; + } + return `${meters.toFixed(2)} m`; + }, + + _areaText(squareMeters) { + if (squareMeters > 10000) { + return `${(squareMeters / 1000000).toFixed(4)} km² (${( + squareMeters / 10000 + ).toFixed(2)} ha)`; + } + return `${squareMeters.toFixed(2)} m²`; + }, + + _formatLength(line) { + return this._distText( + ol.sphere.getLength(line, {projection: this.map.getView().getProjection()}) + ); + }, + + _formatArea(polygon) { + const projection = this.map.getView().getProjection(); + const area = this._areaText(ol.sphere.getArea(polygon, {projection})); + const perimeter = this._distText(ol.sphere.getLength(polygon, {projection})); + return `Surface : ${area}
    Périmètre : ${perimeter}`; + }, + + /** + * Store a plain-text summary of a finished measurement so it can later be + * copied to the clipboard. + */ + _recordMeasureResult(geom) { + const projection = this.map.getView().getProjection(); + let text = ""; + if (geom instanceof ol.geom.Polygon) { + const area = this._areaText(ol.sphere.getArea(geom, {projection})); + const perimeter = this._distText(ol.sphere.getLength(geom, {projection})); + text = `Surface : ${area} | Périmètre : ${perimeter}`; + } else { + text = `Distance : ${this._distText( + ol.sphere.getLength(geom, {projection}) + )}`; + } + this._measureResults.push(text); + }, + + // ---- Clipboard export ---------------------------------------------- + + _copyLast() { + if (!this._measureResults || this._measureResults.length === 0) { + this._notify("Aucune mesure à copier.", "warning"); + return; + } + this._copyText(this._measureResults[this._measureResults.length - 1]); + }, + + _copyAll() { + if (!this._measureResults || this._measureResults.length === 0) { + this._notify("Aucune mesure à copier.", "warning"); + return; + } + this._copyText(this._measureResults.join("\n")); + }, + + async _copyText(text) { + try { + await navigator.clipboard.writeText(text); + this._notify("Copié dans le presse-papier."); + } catch { + this._notify("Échec de la copie dans le presse-papier.", "danger"); + } + }, + + _notify(message, type = "success") { + const notif = this.env && this.env.services && this.env.services.notification; + if (notif) { + notif.add(message, {type}); + } + }, + + // ---- Proximity check between objects -------------------------------- + + /** + * Ask for a minimum distance and highlight every pair of map objects + * (e.g. parcels) closer than that threshold, with the measured gap. + */ + _checkProximity() { + // eslint-disable-next-line no-alert + const input = window.prompt("Distance minimale entre objets (m) :", "4"); + if (input === null) { + return; + } + const threshold = parseFloat(String(input).replace(",", ".")); + if (!(threshold > 0)) { + this._notify("Valeur invalide.", "warning"); + return; + } + + const features = this._getMeasurableFeatures(); + if (features.length < 2) { + this._notify("Pas assez d'objets sur la carte.", "warning"); + return; + } + + const projection = this.map.getView().getProjection(); + let count = 0; + + for (let i = 0; i < features.length; i++) { + const a = features[i].getGeometry(); + const extA = a.getExtent(); + for (let j = i + 1; j < features.length; j++) { + const b = features[j].getGeometry(); + // Generous bbox pre-filter (map units ≈ meters) to skip far pairs. + if ( + !ol.extent.intersects( + ol.extent.buffer(extA, threshold * 5), + b.getExtent() + ) + ) { + continue; + } + const res = this._minDistanceBetween(a, b, projection); + // Skip touching/overlapping objects (adjacent parcels share a + // border → gap ≈ 0): those are not a real "too close" spacing. + if (res.distance < MIN_PROXIMITY_GAP || res.distance >= threshold) { + continue; + } + count++; + this._measureSource.addFeature( + new ol.Feature(new ol.geom.LineString([res.from, res.to])) + ); + const mid = [ + (res.from[0] + res.to[0]) / 2, + (res.from[1] + res.to[1]) / 2, + ]; + const {tooltip, tooltipEl} = this._createMeasureTooltip(); + tooltipEl.className = + "geoengine-measure-tooltip geoengine-measure-tooltip-static"; + tooltipEl.innerHTML = this._distText(res.distance); + tooltip.setOffset([0, -7]); + tooltip.setPosition(mid); + + const idA = features[i].getId() ?? "?"; + const idB = features[j].getId() ?? "?"; + this._measureResults.push( + `Proximité ${idA} ↔ ${idB} : ${this._distText(res.distance)}` + ); + } + } + + if (count === 0) { + this._notify(`Aucune distance inférieure à ${threshold} m.`, "info"); + } else { + this._notify(`${count} distance(s) inférieure(s) à ${threshold} m.`); + } + }, + + /** + * Collect every feature with a geometry from the "Overlays" vector layers + * (the measurement layer is separate, so it is never included). + */ + _getMeasurableFeatures() { + const features = []; + const seen = new Set(); + const group = this.map + .getLayers() + .getArray() + .find((l) => l.get("title") === "Overlays"); + if (!group || !group.getLayers) { + return features; + } + group + .getLayers() + .getArray() + .forEach((layer) => { + const src = layer.getSource && layer.getSource(); + if (src && src.getFeatures) { + src.getFeatures().forEach((f) => { + if (!f.getGeometry()) { + return; + } + // Dedupe the same object rendered in several layers + // (would otherwise produce a 0 m self-pair). + const id = f.getId(); + if (id !== undefined && seen.has(id)) { + return; + } + if (id !== undefined) { + seen.add(id); + } + features.push(f); + }); + } + }); + return features; + }, + + /** + * Minimum geodesic distance between two geometries. The minimum is always + * realized at a vertex of one geometry and its closest point on the other, + * so sampling all vertices both ways gives the exact gap. + */ + _minDistanceBetween(a, b, projection) { + let best = {distance: Infinity, from: null, to: null}; + for (const p of this._verticesOf(a)) { + const c = b.getClosestPoint(p); + const d = this._geodesicDistance(p, c, projection); + if (d < best.distance) { + best = {distance: d, from: p, to: c}; + } + } + for (const p of this._verticesOf(b)) { + const c = a.getClosestPoint(p); + const d = this._geodesicDistance(p, c, projection); + if (d < best.distance) { + best = {distance: d, from: c, to: p}; + } + } + return best; + }, + + _verticesOf(geom) { + const flat = geom.getFlatCoordinates(); + const stride = geom.getStride(); + const out = []; + for (let i = 0; i + 1 < flat.length; i += stride) { + out.push([flat[i], flat[i + 1]]); + } + return out; + }, + + _geodesicDistance(a, b, projection) { + return ol.sphere.getLength(new ol.geom.LineString([a, b]), {projection}); + }, + + // ---- Teardown helpers ---------------------------------------------- + + _highlightMeasureButton(button) { + document + .querySelectorAll(".selected-control") + .forEach((el) => el.classList.remove("selected-control")); + button.classList.add("selected-control"); + }, + + _removeMeasureDraw() { + if (this._measureDraw) { + this.map.removeInteraction(this._measureDraw); + this._measureDraw = undefined; + } + }, + + _deactivateMeasureTools() { + this._removeMeasureDraw(); + this._activeMeasureType = null; + if (this._measureButtons) { + this._measureButtons.forEach((b) => b.classList.remove("selected-control")); + } + }, + + _restoreSelection() { + if ( + this.selectClick === undefined && + this.drawInteraction === undefined && + this.modifyInteraction === undefined + ) { + this.registerInteraction(); + } + }, + + _clearMeasures() { + if (this._measureSource) { + this._measureSource.clear(); + } + if (this._measureTooltips) { + this._measureTooltips.forEach((t) => this.map.removeOverlay(t)); + this._measureTooltips = []; + } + this._measureResults = []; + this._deactivateMeasureTools(); + this._restoreSelection(); + }, + + // ---- Styling -------------------------------------------------------- + + _measureStyle(drawing = false) { + return new ol.style.Style({ + fill: new ol.style.Fill({color: "rgba(113, 99, 158, 0.2)"}), + stroke: new ol.style.Stroke({ + color: "#71639e", + lineDash: drawing ? [8, 8] : undefined, + width: 2, + }), + image: new ol.style.Circle({ + radius: 5, + fill: new ol.style.Fill({color: "#71639e"}), + stroke: new ol.style.Stroke({color: "#ffffff", width: 1.5}), + }), + }); + }, +});