From dece3057eaf510a70cc392395b98c1ebdf56be33 Mon Sep 17 00:00:00 2001 From: Matteo V Date: Tue, 6 Mar 2018 14:06:37 +0100 Subject: [PATCH] 2626 updates measure tool (#2701) * Fix #2626 Updated measure tool * revert on test file * Made some changes, added arcs on leaflet * made length formula and showlabel configurable * fixed default config, added documentation * fixed reducer default --- docs/developer-guide/local-config.md | 23 ++ package.json | 3 + .../actions/__tests__/measurement-test.js | 62 +++++ web/client/actions/measurement.js | 34 ++- .../map/leaflet/MeasurementSupport.jsx | 75 ++++-- .../map/openlayers/MeasurementSupport.jsx | 246 +++++++++++++++--- .../mapcontrols/measure/MeasureComponent.jsx | 155 ++++++++--- .../__tests__/MeasureComponent-test.jsx | 8 +- web/client/plugins/Measure.jsx | 12 +- web/client/plugins/map/index.js | 11 +- .../reducers/__tests__/measurement-test.js | 102 ++++++++ web/client/reducers/measurement.js | 72 ++++- web/client/themes/default/less/ol.less | 40 +++ web/client/translations/data.de-DE | 9 +- web/client/translations/data.en-US | 9 +- web/client/translations/data.es-ES | 9 +- web/client/translations/data.fr-FR | 9 +- web/client/translations/data.it-IT | 9 +- web/client/utils/CoordinatesUtils.js | 71 ++++- web/client/utils/MeasureUtils.js | 104 ++++---- .../utils/__tests__/CoordinatesUtils-test.js | 17 ++ .../utils/__tests__/MeasureUtils-test.js | 107 ++++++++ 22 files changed, 1023 insertions(+), 164 deletions(-) create mode 100644 web/client/actions/__tests__/measurement-test.js create mode 100644 web/client/reducers/__tests__/measurement-test.js create mode 100644 web/client/utils/__tests__/MeasureUtils-test.js diff --git a/docs/developer-guide/local-config.md b/docs/developer-guide/local-config.md index 64c50fe5b4..32fc1640e7 100644 --- a/docs/developer-guide/local-config.md +++ b/docs/developer-guide/local-config.md @@ -143,3 +143,26 @@ Set `selectedService` value to one of the ID of the services object ("Demo CSW S
Be careful to use unique IDs
Future implementations will try to detect the type from the url.
newService is used internally as the starting object for an empty service. + +
+

Measure Tool configuration

+Inside defaultState you can set lengthFormula, showLabel, uom: +- you can customize the formula used for length calculation from "haversine" or "vincenty" (default haversine) +- show or not the measurement label on the map after drawing a measurement (default true) +- set the default uom used for measure tool (default m and sqm) +
For the label you can chose whatever value you want. +
For the unit you can chose between: + - unit length values : ft, m, km, mi, nm standing for feets, meters, kilometers, miles, nautical miles + - unit area values : sqft, sqm, sqkm, sqmi, sqnm standing for square feets, square meters, square kilometers, square miles, square nautical miles + +example:
+``` +"measurement": { + "lengthFormula": "vincenty", + "showLabel": true, + "uom": { + "length": {"unit": "m", "label": "m"}, + "area": {"unit": "sqm", "label": "m²"} + } +} +``` diff --git a/package.json b/package.json index ba4dc8d5a5..af9bf809e8 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@carnesen/redux-add-action-listener-enhancer": "0.0.1", "@mapbox/togeojson": "0.16.0", "@turf/bbox": "4.1.0", + "@turf/great-circle": "5.1.5", "@turf/inside": "4.1.0", "@turf/line-intersect": "4.1.0", "@turf/polygon-to-linestring": "4.1.0", @@ -122,6 +123,7 @@ "leaflet.nontiledlayer": "1.0.7", "lodash": "4.16.6", "moment": "2.13.0", + "node-geo-distance": "1.2.0", "object-assign": "4.1.1", "ogc-schemas": "2.6.1", "openlayers": "4.6.4", @@ -185,6 +187,7 @@ "turf-bbox": "3.0.10", "turf-buffer": "3.0.10", "turf-intersect": "3.0.10", + "turf-point": "2.0.1", "turf-point-on-surface": "3.0.10", "turf-union": "3.0.10", "url": "0.10.3", diff --git a/web/client/actions/__tests__/measurement-test.js b/web/client/actions/__tests__/measurement-test.js new file mode 100644 index 0000000000..35eba0dfee --- /dev/null +++ b/web/client/actions/__tests__/measurement-test.js @@ -0,0 +1,62 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ + +const expect = require('expect'); +const { + toggleMeasurement, CHANGE_MEASUREMENT_TOOL, + changeMeasurementState, CHANGE_MEASUREMENT_STATE, + changeUom, CHANGE_UOM, + changeGeometry, CHANGED_GEOMETRY +} = require('../measurement'); +const feature = {type: "Feature", geometry: { + coordinates: [], + type: "LineString" +}}; +const measureState = { + len: 84321231.123, + lengthFormula: "vincenty", + feature +}; +describe('Test correctness of measurement actions', () => { + + it('Test toggleMeasurement action creator', () => { + const retval = toggleMeasurement(measureState); + expect(retval).toExist(); + expect(retval.type).toBe(CHANGE_MEASUREMENT_TOOL); + expect(retval.lengthFormula).toBe("vincenty"); + }); + + + it('Test changeMousePositionState action creator', () => { + const [uom, value, previousUom] = ["m", 42, { + length: {unit: 'km', label: 'km'}, + area: {unit: 'sqm', label: 'm²'} + }]; + const retval = changeUom(uom, value, previousUom); + expect(retval).toExist(); + expect(retval.type).toBe(CHANGE_UOM); + expect(retval.uom).toBe("m"); + expect(retval.value).toBe(42); + expect(retval.previousUom.length.label).toBe("km"); + }); + + it('Test changeGeometry action creator', () => { + + const retval = changeGeometry(feature); + expect(retval).toExist(); + expect(retval.type).toBe(CHANGED_GEOMETRY); + expect(retval.feature.geometry.type).toBe("LineString"); + }); + it('Test changeMeasurementState action creator', () => { + const retval = changeMeasurementState(measureState); + expect(retval).toExist(); + expect(retval.type).toBe(CHANGE_MEASUREMENT_STATE); + expect(retval.feature.geometry.type).toBe("LineString"); + }); + +}); diff --git a/web/client/actions/measurement.js b/web/client/actions/measurement.js index 8d430383e9..0d1f020872 100644 --- a/web/client/actions/measurement.js +++ b/web/client/actions/measurement.js @@ -1,12 +1,14 @@ -/** - * Copyright 2015, GeoSolutions Sas. +/* + * Copyright 2018, GeoSolutions Sas. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. - */ +*/ const CHANGE_MEASUREMENT_TOOL = 'CHANGE_MEASUREMENT_TOOL'; const CHANGE_MEASUREMENT_STATE = 'CHANGE_MEASUREMENT_STATE'; +const CHANGE_UOM = 'MEASUREMENT:CHANGE_UOM'; +const CHANGED_GEOMETRY = 'MEASUREMENT:CHANGED_GEOMETRY'; // TODO: the measurement control should use the "controls" state function toggleMeasurement(measurement) { @@ -22,6 +24,26 @@ function changeMeasurement(measurement) { }; } +/** + * @param {string} uom length or area + * @param {string} value unit of uom + * @param {object} previous uom object +*/ +function changeUom(uom, value, previousUom) { + return { + type: CHANGE_UOM, + uom, + value, + previousUom + }; +} + +function changeGeometry(feature) { + return { + type: CHANGED_GEOMETRY, + feature + }; +} function changeMeasurementState(measureState) { return { type: CHANGE_MEASUREMENT_STATE, @@ -35,13 +57,17 @@ function changeMeasurementState(measureState) { area: measureState.area, bearing: measureState.bearing, lenUnit: measureState.lenUnit, - areaUnit: measureState.areaUnit + areaUnit: measureState.areaUnit, + feature: measureState.feature }; } module.exports = { CHANGE_MEASUREMENT_TOOL, CHANGE_MEASUREMENT_STATE, + changeUom, CHANGE_UOM, + changeGeometry, CHANGED_GEOMETRY, changeMeasurement, + toggleMeasurement, changeMeasurementState }; diff --git a/web/client/components/map/leaflet/MeasurementSupport.jsx b/web/client/components/map/leaflet/MeasurementSupport.jsx index 815a052a1a..ab836f9a8d 100644 --- a/web/client/components/map/leaflet/MeasurementSupport.jsx +++ b/web/client/components/map/leaflet/MeasurementSupport.jsx @@ -3,8 +3,7 @@ const React = require('react'); const assign = require('object-assign'); var L = require('leaflet'); const {slice} = require('lodash'); -var CoordinatesUtils = require('../../../utils/CoordinatesUtils'); - +const {reproject, calculateAzimuth, calculateDistance, transformLineToArcs} = require('../../../utils/CoordinatesUtils'); require('leaflet-draw'); class MeasurementSupport extends React.Component { @@ -35,13 +34,13 @@ class MeasurementSupport extends React.Component { if (newProps.measurement.geomType && newProps.measurement.geomType !== this.props.measurement.geomType ) { this.addDrawInteraction(newProps); } - if (!newProps.measurement.geomType) { this.removeDrawInteraction(); } } onDrawStart = () => { + this.removeArcLayer(); this.drawing = true; }; @@ -52,12 +51,19 @@ class MeasurementSupport extends React.Component { // preserve the currently created layer to remove it later on this.lastLayer = evt.layer; + let feature = this.lastLayer && this.lastLayer.toGeoJSON() || {}; if (this.props.measurement.geomType === 'Point') { let pos = this.drawControl._marker.getLatLng(); let point = {x: pos.lng, y: pos.lat, srs: 'EPSG:4326'}; - let newMeasureState = assign({}, this.props.measurement, {point: point}); + let newMeasureState = assign({}, this.props.measurement, {point: point, feature}); + this.props.changeMeasurementState(newMeasureState); + } else { + let newMeasureState = assign({}, this.props.measurement, {feature}); this.props.changeMeasurementState(newMeasureState); } + if (this.props.measurement.lineMeasureEnabled && this.lastLayer) { + this.addArcsToMap([feature]); + } }; render() { @@ -66,10 +72,36 @@ class MeasurementSupport extends React.Component { if (drawingStrings) { L.drawLocal = drawingStrings; } - return null; } + /** + * This method adds arcs converting from a LineString features + */ + addArcsToMap = (features) => { + this.removeLastLayer(); + let newFeatures = features.map(f => { + return assign({}, f, { + geometry: assign({}, f.geometry, { + coordinates: transformLineToArcs(f.geometry.coordinates) + }) + }); + }); + this.arcLayer = L.geoJson(newFeatures, { + style: { + color: '#ffcc33', + opacity: 1, + weight: 1, + fillColor: '#ffffff', + fillOpacity: 0.2, + clickable: false + } + }); + this.props.map.addLayer(this.arcLayer); + if (newFeatures && newFeatures.length > 0) { + this.arcLayer.addData(newFeatures); + } + } updateMeasurementResults = () => { if (!this.drawing || !this.drawControl) { return; @@ -79,10 +111,15 @@ class MeasurementSupport extends React.Component { let bearing = 0; let currentLatLng = this.drawControl._currentLatLng; - if (this.props.measurement.geomType === 'LineString' && this.drawControl._markers && this.drawControl._markers.length > 0) { + if (this.props.measurement.geomType === 'LineString' && this.drawControl._markers && this.drawControl._markers.length > 1) { // calculate length - let previousLatLng = this.drawControl._markers[this.drawControl._markers.length - 1].getLatLng(); - distance = this.drawControl._measurementRunningTotal + currentLatLng.distanceTo(previousLatLng); + const reprojectedCoords = this.drawControl._markers.reduce((p, c) => { + const {lng, lat} = c.getLatLng(); + return [...p, [lng, lat]]; + }, []); + + distance = calculateDistance(reprojectedCoords, this.props.measurement.lengthFormula); + } else if (this.props.measurement.geomType === 'Polygon' && this.drawControl._poly) { // calculate area let latLngs = [...this.drawControl._poly.getLatLngs(), currentLatLng]; @@ -98,12 +135,12 @@ class MeasurementSupport extends React.Component { coords2 = [bearingMarkers[1].getLatLng().lng, bearingMarkers[1].getLatLng().lat]; } // in order to align the results between leaflet and openlayers the coords are repojected only for leaflet - coords1 = CoordinatesUtils.reproject(coords1, 'EPSG:4326', this.props.projection); - coords2 = CoordinatesUtils.reproject(coords2, 'EPSG:4326', this.props.projection); + coords1 = reproject(coords1, 'EPSG:4326', this.props.projection); + coords2 = reproject(coords2, 'EPSG:4326', this.props.projection); // calculate the azimuth as base for bearing information - bearing = CoordinatesUtils.calculateAzimuth(coords1, coords2, this.props.projection); + bearing = calculateAzimuth(coords1, coords2, this.props.projection); } - + // let drawn geom stay on the map let newMeasureState = assign({}, this.props.measurement, { point: null, // Point is set in onDraw.created @@ -199,9 +236,7 @@ class MeasurementSupport extends React.Component { if (this.drawControl !== null && this.drawControl !== undefined) { this.drawControl.disable(); this.drawControl = null; - if (this.lastLayer) { - this.props.map.removeLayer(this.lastLayer); - } + this.removeLastLayer(); this.props.map.off('draw:created', this.onDrawCreated, this); this.props.map.off('draw:drawstart', this.onDrawStart, this); this.props.map.off('click', this.mapClickHandler, this); @@ -210,6 +245,16 @@ class MeasurementSupport extends React.Component { } } }; + removeLastLayer = () => { + if (this.lastLayer) { + this.props.map.removeLayer(this.lastLayer); + } + } + removeArcLayer = () => { + if (this.arcLayer) { + this.props.map.removeLayer(this.arcLayer); + } + } } module.exports = MeasurementSupport; diff --git a/web/client/components/map/openlayers/MeasurementSupport.jsx b/web/client/components/map/openlayers/MeasurementSupport.jsx index 73a43e01e5..5a84729767 100644 --- a/web/client/components/map/openlayers/MeasurementSupport.jsx +++ b/web/client/components/map/openlayers/MeasurementSupport.jsx @@ -1,64 +1,101 @@ -const PropTypes = require('prop-types'); -/** - * Copyright 2015, GeoSolutions Sas. +/* + * Copyright 2018, GeoSolutions Sas. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. - */ +*/ const React = require('react'); +const PropTypes = require('prop-types'); +const {round} = require('lodash'); const assign = require('object-assign'); -var ol = require('openlayers'); -var CoordinatesUtils = require('../../../utils/CoordinatesUtils'); -var wgs84Sphere = new ol.Sphere(6378137); +const ol = require('openlayers'); +const wgs84Sphere = new ol.Sphere(6378137); +const {reprojectGeoJson, reproject, calculateAzimuth, calculateDistance, transformLineToArcs} = require('../../../utils/CoordinatesUtils'); +const {getFormattedLength, getFormattedArea, getFormattedBearingValue} = require('../../../utils/MeasureUtils'); +const {getMessageById} = require('../../../utils/LocaleUtils'); class MeasurementSupport extends React.Component { static propTypes = { map: PropTypes.object, projection: PropTypes.string, measurement: PropTypes.object, + uom: PropTypes.object, changeMeasurementState: PropTypes.func, + changeGeometry: PropTypes.func, updateOnMouseMove: PropTypes.bool }; + static contextTypes = { + messages: PropTypes.object + }; + static defaultProps = { updateOnMouseMove: false }; componentWillReceiveProps(newProps) { - if (newProps.measurement.geomType && newProps.measurement.geomType !== this.props.measurement.geomType ) { this.addDrawInteraction(newProps); } - if (!newProps.measurement.geomType) { this.removeDrawInteraction(); } } getPointCoordinate = (coordinate) => { - return CoordinatesUtils.reproject(coordinate, this.props.projection, 'EPSG:4326'); + return reproject(coordinate, this.props.projection, 'EPSG:4326'); }; render() { return null; } + replaceFeatures = (features) => { + this.source = new ol.source.Vector(); + features.forEach((geoJSON) => { + let geometry = reprojectGeoJson(geoJSON, "EPSG:4326", this.props.map.getView().getProjection().getCode()).geometry; + const feature = new ol.Feature({ + geometry: this.createOLGeometry(geometry) + }); + this.source.addFeature(feature); + }); + this.measureLayer.setSource(this.source); + }; + + createOLGeometry = ({type, coordinates, radius, center}) => { + let geometry; + switch (type) { + case "Point": { geometry = new ol.geom.Point(coordinates ? coordinates : []); break; } + case "LineString": { geometry = new ol.geom.LineString(coordinates ? coordinates : []); break; } + case "MultiPoint": { geometry = new ol.geom.MultiPoint(coordinates ? coordinates : []); break; } + case "MultiLineString": { geometry = new ol.geom.MultiLineString(coordinates ? coordinates : []); break; } + case "MultiPolygon": { geometry = new ol.geom.MultiPolygon(coordinates ? coordinates : []); break; } + // defaults is Polygon + default: { geometry = radius && center ? + ol.geom.Polygon.fromCircle(new ol.geom.Circle([center.x, center.y], radius), 100) : new ol.geom.Polygon(coordinates ? coordinates : []); + } + } + return geometry; + }; + addDrawInteraction = (newProps) => { - var source; var vector; var draw; var geometryType; + this.continueLineMsg = getMessageById(this.context.messages, "measureSupport.continueLine"); + this.continuePolygonMsg = getMessageById(this.context.messages, "measureSupport.continuePolygon"); // cleanup old interaction if (this.drawInteraction) { this.removeDrawInteraction(); } // create a layer to draw on - source = new ol.source.Vector(); + this.source = new ol.source.Vector(); + vector = new ol.layer.Vector({ - source: source, + source: this.source, zIndex: 1000000, style: new ol.style.Style({ fill: new ol.style.Fill({ @@ -87,7 +124,7 @@ class MeasurementSupport extends React.Component { // create an interaction to draw with draw = new ol.interaction.Draw({ - source: source, + source: this.source, type: /** @type {ol.geom.GeometryType} */ geometryType, style: new ol.style.Style({ fill: new ol.style.Fill({ @@ -115,21 +152,74 @@ class MeasurementSupport extends React.Component { this.props.map.on('pointermove', this.updateMeasurementResults, this); } - draw.on('drawstart', function(evt) { - // preserv the sketch feature of the draw controller + this.props.map.on('pointermove', this.pointerMoveHandler, this); + + draw.on('drawstart', (evt) => { + // preserve the sketch feature of the draw controller // to update length/area on drawing a new vertex this.sketchFeature = evt.feature; + this.drawing = true; + let oldtooltips = document.getElementsByClassName("tooltip-static") || []; + for (let i = 0; i < oldtooltips.length; i++) { + oldtooltips[i].parentNode.removeChild(oldtooltips[i]); + } + + if (this.props.measurement.showLabel) { + this.createMeasureTooltip(); + } // clear previous measurements - source.clear(); + this.source.clear(); + this.listener = this.sketchFeature.getGeometry().on('change', (e) => { + let geom = e.target; + let output; + if (geom instanceof ol.geom.Polygon) { + output = this.formatArea(geom); + this.tooltipCoord = geom.getInteriorPoint().getCoordinates(); + } else if (geom instanceof ol.geom.LineString) { + output = this.formatLength(geom); + this.tooltipCoord = geom.getLastCoordinate(); + } + if (this.props.measurement.showLabel) { + this.measureTooltipElement.innerHTML = output; + this.measureTooltip.setPosition(this.tooltipCoord); + } + }, this); + }, this); + draw.on('drawend', function(evt) { + this.drawing = false; + const geojsonFormat = new ol.format.GeoJSON(); + let newFeature = reprojectGeoJson(geojsonFormat.writeFeatureObject(evt.feature.clone()), this.props.map.getView().getProjection().getCode(), "EPSG:4326"); + this.props.changeGeometry(newFeature); + if (this.props.measurement.lineMeasureEnabled) { + // Calculate arc + let newCoords = transformLineToArcs(newFeature.geometry.coordinates); + const ft = assign({}, newFeature, { + geometry: assign({}, newFeature.geometry, + {coordinates: newCoords}) + }); + this.replaceFeatures([ft]); + } + if (this.props.measurement.showLabel) { + this.measureTooltipElement.className = 'tooltip tooltip-static'; + this.measureTooltip.setOffset([0, -7]); + ol.Observable.unByKey(this.listener); + } }, this); this.props.map.addInteraction(draw); + if (this.props.measurement.showLabel) { + this.createMeasureTooltip(); + } + this.createHelpTooltip(); + this.drawInteraction = draw; this.measureLayer = vector; }; removeDrawInteraction = () => { if (this.drawInteraction !== null) { + this.removeHelpTooltip(); + this.removeMeasureTooltips(); this.props.map.removeInteraction(this.drawInteraction); this.drawInteraction = null; this.props.map.removeLayer(this.measureLayer); @@ -141,6 +231,31 @@ class MeasurementSupport extends React.Component { } }; + /** + * Handle pointer move. + * @param {ol.MapBrowserEvent} evt The event. + */ + pointerMoveHandler = function(evt) { + if (evt.dragging) { + return null; + } + /** @type {string} */ + let helpMsg = getMessageById(this.context.messages, "measureSupport.startDrawing"); + + if (this.sketchFeature && this.drawing) { + let geom = (this.sketchFeature.getGeometry()); + if (geom instanceof ol.geom.Polygon) { + helpMsg = this.continuePolygonMsg; + } else if (geom instanceof ol.geom.LineString) { + helpMsg = this.continueLineMsg; + } + } + this.helpTooltipElement.innerHTML = helpMsg; + this.helpTooltip.setPosition(evt.coordinate); + + this.helpTooltipElement.classList.remove('hidden'); + }; + updateMeasurementResults = () => { if (!this.sketchFeature) { return; @@ -150,24 +265,26 @@ class MeasurementSupport extends React.Component { if (this.props.measurement.geomType === 'Bearing' && sketchCoords.length > 1) { // calculate the azimuth as base for bearing information - bearing = CoordinatesUtils.calculateAzimuth(sketchCoords[0], sketchCoords[1], this.props.projection); + bearing = calculateAzimuth(sketchCoords[0], sketchCoords[1], this.props.projection); if (sketchCoords.length > 2) { this.drawInteraction.sketchCoords_ = [sketchCoords[0], sketchCoords[1], sketchCoords[0]]; this.drawInteraction.finishDrawing(); } } + const geojsonFormat = new ol.format.GeoJSON(); + let feature = reprojectGeoJson(geojsonFormat.writeFeatureObject(this.sketchFeature.clone()), this.props.map.getView().getProjection().getCode(), "EPSG:4326"); let newMeasureState = assign({}, this.props.measurement, { point: this.props.measurement.geomType === 'Point' ? this.getPointCoordinate(sketchCoords) : null, - len: this.props.measurement.geomType === 'LineString' ? - this.calculateGeodesicDistance(sketchCoords) : 0, + len: this.props.measurement.geomType === 'LineString' ? calculateDistance(this.reprojectedCoordinates(sketchCoords), this.props.measurement.lengthFormula) : 0, area: this.props.measurement.geomType === 'Polygon' ? this.calculateGeodesicArea(this.sketchFeature.getGeometry().getLinearRing(0).getCoordinates()) : 0, bearing: this.props.measurement.geomType === 'Bearing' ? bearing : 0, lenUnit: this.props.measurement.lenUnit, - areaUnit: this.props.measurement.areaUnit + areaUnit: this.props.measurement.areaUnit, + feature } ); this.props.changeMeasurementState(newMeasureState); @@ -175,24 +292,93 @@ class MeasurementSupport extends React.Component { reprojectedCoordinates = (coordinates) => { return coordinates.map((coordinate) => { - let reprojectedCoordinate = CoordinatesUtils.reproject(coordinate, this.props.projection, 'EPSG:4326'); + let reprojectedCoordinate = reproject(coordinate, this.props.projection, 'EPSG:4326'); return [reprojectedCoordinate.x, reprojectedCoordinate.y]; }); }; - calculateGeodesicDistance = (coordinates) => { + calculateGeodesicArea = (coordinates) => { let reprojectedCoordinates = this.reprojectedCoordinates(coordinates); - let length = 0; - for (let i = 0; i < reprojectedCoordinates.length - 1; ++i) { - length += wgs84Sphere.haversineDistance(reprojectedCoordinates[i], reprojectedCoordinates[i + 1]); + return Math.abs(wgs84Sphere.geodesicArea(reprojectedCoordinates)); + }; + + /** + * Creates a new help tooltip + */ + createHelpTooltip = () => { + this.removeHelpTooltip(); + this.helpTooltipElement = document.createElement('div'); + this.helpTooltipElement.className = 'tooltip hidden'; + this.helpTooltip = new ol.Overlay({ + element: this.helpTooltipElement, + offset: [15, 0], + positioning: 'center-left' + }); + this.props.map.addOverlay(this.helpTooltip); + } + /** + * Creates a new measure tooltip + */ + createMeasureTooltip = () => { + this.removeMeasureTooltips(); + this.measureTooltipElement = document.createElement('div'); + this.measureTooltipElement.className = 'tooltip tooltip-measure'; + this.measureTooltip = new ol.Overlay({ + element: this.measureTooltipElement, + offset: [0, -15], + positioning: 'bottom-center' + }); + this.props.map.addOverlay(this.measureTooltip); + } + /** + * Format length output. + * @param {ol.geom.LineString} line The line. + * @return {string} The formatted length with uom chosen. + */ + formatLength = (line) => { + const sketchCoords = line.getCoordinates(); + if (this.props.measurement.geomType === 'Bearing' && sketchCoords.length > 1) { + // calculate the azimuth as base for bearing information + const bearing = calculateAzimuth(sketchCoords[0], sketchCoords[1], this.props.projection); + return getFormattedBearingValue(bearing); } - return length; + const reprojectedCoords = this.reprojectedCoordinates(sketchCoords); + const length = calculateDistance(reprojectedCoords, this.props.measurement.lengthFormula); + const {label, unit} = this.props.uom && this.props.uom.length; + const output = round(getFormattedLength(unit, length), 2); + return output + " " + (label); }; - calculateGeodesicArea = (coordinates) => { - let reprojectedCoordinates = this.reprojectedCoordinates(coordinates); - return Math.abs(wgs84Sphere.geodesicArea(reprojectedCoordinates)); + /** + * Format area output. + * @param {ol.geom.Polygon} polygon The polygon. + * @return {string} Formatted area. + */ + formatArea = (polygon) => { + const area = this.calculateGeodesicArea(polygon.getLinearRing(0).getCoordinates()); + const {label, unit} = this.props.uom && this.props.uom.area; + const output = round(getFormattedArea(unit, area), 2); + + return output + " " + label; }; + + removeHelpTooltip = () => { + if (this.helpTooltipElement && this.helpTooltipElement.parentNode) { + this.helpTooltipElement.parentNode.removeChild(this.helpTooltipElement); + } + } + removeMeasureTooltips = () => { + if (this.measureTooltipElement && this.measureTooltipElement.parentNode) { + let oldtooltips = document.getElementsByClassName("tooltip-static") || []; + for (let i = 0; i < oldtooltips.length; i++) { + oldtooltips[i].parentNode.removeChild(oldtooltips[i]); + } + oldtooltips = document.getElementsByClassName("tooltip-measure") || []; + for (let i = 0; i < oldtooltips.length; i++) { + oldtooltips[i].parentNode.removeChild(oldtooltips[i]); + } + } + } } module.exports = MeasurementSupport; diff --git a/web/client/components/mapcontrols/measure/MeasureComponent.jsx b/web/client/components/mapcontrols/measure/MeasureComponent.jsx index fe4ae73484..eed3c39d40 100644 --- a/web/client/components/mapcontrols/measure/MeasureComponent.jsx +++ b/web/client/components/mapcontrols/measure/MeasureComponent.jsx @@ -8,11 +8,11 @@ const PropTypes = require('prop-types'); */ const React = require('react'); -const {Panel, ButtonGroup, Tooltip, Glyphicon, Button} = require('react-bootstrap'); +const {Panel, ButtonGroup, Tooltip, Glyphicon, Button, Grid, Row, Col, FormGroup, Form} = require('react-bootstrap'); const ToggleButton = require('../../buttons/ToggleButton'); const NumberFormat = require('../../I18N/Number'); const Message = require('../../I18N/Message'); - +const {DropdownList} = require('react-widgets'); const measureUtils = require('../../../utils/MeasureUtils'); const localeUtils = require('../../../utils/LocaleUtils'); @@ -58,7 +58,10 @@ class MeasureComponent extends React.Component { inlineGlyph: PropTypes.bool, formatLength: PropTypes.func, formatArea: PropTypes.func, - formatBearing: PropTypes.func + formatBearing: PropTypes.func, + onChangeUom: PropTypes.func, + uomLengthValues: PropTypes.array, + uomAreaValues: PropTypes.array }; static contextTypes = { @@ -71,6 +74,20 @@ class MeasureComponent extends React.Component { sm: 4, md: 4 }, + uomLengthValues: [ + {value: "ft", label: "ft"}, + {value: "m", label: "m"}, + {value: "km", label: "km"}, + {value: "mi", label: "mi"}, + {value: "nm", label: "nm"} + ], + uomAreaValues: [ + {value: "sqft", label: "ft²"}, + {value: "sqm", label: "m²"}, + {value: "sqkm", label: "km²"}, + {value: "sqmi", label: "mi²"}, + {value: "sqnm", label: "nm²"} + ], id: "measure-result-panel", uom: { length: {unit: 'm', label: 'm'}, @@ -89,7 +106,8 @@ class MeasureComponent extends React.Component { bearingLabel: , formatLength: (uom, value) => measureUtils.getFormattedLength(uom, value), formatArea: (uom, value) => measureUtils.getFormattedArea(uom, value), - formatBearing: (value) => measureUtils.getFormattedBearingValue(round(value || 0, 6)) + formatBearing: (value) => measureUtils.getFormattedBearingValue(round(value || 0, 6)), + onChangeUom: () => {} }; shouldComponentUpdate(nextProps) { @@ -131,14 +149,55 @@ class MeasureComponent extends React.Component { renderMeasurements = () => { let decimalFormat = {style: "decimal", minimumIntegerDigits: 1, maximumFractionDigits: 2, minimumFractionDigits: 2}; return ( -
-

{this.props.lengthLabel}: - {this.props.uom.length.label}

-

{this.props.areaLabel}: - {this.props.uom.area.label}

-

{this.props.bearingLabel}: - {this.props.formatBearing(this.props.measurement.bearing || 0)}

-
+
+
+ + + + {this.props.lengthLabel}: + {this.props.uom.length.label} + + + { + this.props.onChangeUom("length", value, this.props.uom); + }} + data={this.props.uomLengthValues} + textField="label" + valueField="value" + /> + + + + + + + {this.props.areaLabel}: + {this.props.uom.area.label} + + + { + this.props.onChangeUom("area", value, this.props.uom); + }} + data={this.props.uomAreaValues} + textField="label" + valueField="value"/> + + + + + + + {this.props.bearingLabel}: + {this.props.formatBearing(this.props.measurement.bearing || 0)} + + + +
+
); }; @@ -169,33 +228,49 @@ class MeasureComponent extends React.Component { let {lineToolTip, areaToolTip, bearingToolTip} = this.getToolTips(); return (
- - - - {this.props.withReset ? - - : } +
+ + + + + + + + + +
+ +
+ + + {this.props.withReset ? + + : } + + +
+
); }; @@ -211,8 +286,10 @@ class MeasureComponent extends React.Component { render() { return ( + {this.props.showButtons && (this.props.useButtonGroup ? this.renderButtonGroup() : this.renderButtons()) } {this.renderPanel()} + ); } diff --git a/web/client/components/mapcontrols/measure/__tests__/MeasureComponent-test.jsx b/web/client/components/mapcontrols/measure/__tests__/MeasureComponent-test.jsx index 7852ea732e..bfae6019cb 100644 --- a/web/client/components/mapcontrols/measure/__tests__/MeasureComponent-test.jsx +++ b/web/client/components/mapcontrols/measure/__tests__/MeasureComponent-test.jsx @@ -200,22 +200,22 @@ describe("test the MeasureComponent", () => { cmp = ReactDOM.render( , document.getElementById("container") ); - expect(bearingSpan.innerHTML).toBe("N 45° 0' 0'' E"); + expect(bearingSpan.innerHTML).toBe("N 45° 0' 0'' E"); cmp = ReactDOM.render( , document.getElementById("container") ); - expect(bearingSpan.innerHTML).toBe("S 45° 0' 0'' E"); + expect(bearingSpan.innerHTML).toBe("S 45° 0' 0'' E"); cmp = ReactDOM.render( , document.getElementById("container") ); - expect(bearingSpan.innerHTML).toBe("S 45° 0' 0'' W"); + expect(bearingSpan.innerHTML).toBe("S 45° 0' 0'' W"); cmp = ReactDOM.render( , document.getElementById("container") ); - expect(bearingSpan.innerHTML).toBe("N 45° 0' 0'' W"); + expect(bearingSpan.innerHTML).toBe("N 45° 0' 0'' W"); }); it('test uom format area and lenght', () => { let measurement = { diff --git a/web/client/plugins/Measure.jsx b/web/client/plugins/Measure.jsx index 97106e0aec..8b4404dd8c 100644 --- a/web/client/plugins/Measure.jsx +++ b/web/client/plugins/Measure.jsx @@ -13,13 +13,17 @@ const Message = require('./locale/Message'); const assign = require('object-assign'); const {createSelector} = require('reselect'); -const {changeMeasurement} = require('../actions/measurement'); +const {changeMeasurement, changeUom} = require('../actions/measurement'); const {toggleControl} = require('../actions/controls'); const {MeasureDialog} = require('./measure/index'); const selector = (state) => { return { measurement: state.measurement || {}, + uom: state.measurement && state.measurement.uom || { + length: {unit: 'm', label: 'm'}, + area: {unit: 'sqm', label: 'm²'} + }, lineMeasureEnabled: state.measurement && state.measurement.lineMeasureEnabled || false, areaMeasureEnabled: state.measurement && state.measurement.areaMeasureEnabled || false, bearingMeasureEnabled: state.measurement && state.measurement.bearingMeasureEnabled || false @@ -27,12 +31,13 @@ const selector = (state) => { }; const toggleMeasureTool = toggleControl.bind(null, 'measure', null); /** - * Measure plugin. Allows to show the tool to measure dinstances, areas and bearing. + * Measure plugin. Allows to show the tool to measure dinstances, areas and bearing.
+ * See [Application Configuration](local-config) to understand how to configure lengthFormula, showLabel and uom * @class * @name Measure * @memberof plugins * @prop {boolean} showResults shows the measure in the panel itself. - */ + */ const Measure = connect( createSelector([ selector, @@ -45,6 +50,7 @@ const Measure = connect( )), { toggleMeasure: changeMeasurement, + onChangeUom: changeUom, onClose: toggleMeasureTool }, null, {pure: false})(MeasureDialog); diff --git a/web/client/plugins/map/index.js b/web/client/plugins/map/index.js index 12b0215d8a..901ebf2fec 100644 --- a/web/client/plugins/map/index.js +++ b/web/client/plugins/map/index.js @@ -11,7 +11,7 @@ const React = require('react'); const {creationError, changeMapView, clickOnMap} = require('../../actions/map'); const {layerLoading, layerLoad, layerError} = require('../../actions/layers'); const {changeMousePosition} = require('../../actions/mousePosition'); -const {changeMeasurementState} = require('../../actions/measurement'); +const {changeMeasurementState, changeGeometry} = require('../../actions/measurement'); const {changeSelectionState} = require('../../actions/selection'); const {changeLocateState, onLocateError} = require('../../actions/locate'); const {changeDrawingStatus, endDrawing, setCurrentStyle} = require('../../actions/draw'); @@ -46,9 +46,14 @@ module.exports = (mapType, actions) => { })(components.LMap); const MeasurementSupport = connect((state) => ({ - measurement: state.measurement || {} + measurement: state.measurement || {}, + uom: state.measurement && state.measurement.uom || { + length: {unit: 'm', label: 'm'}, + area: {unit: 'sqm', label: 'm²'} + } }), { - changeMeasurementState + changeMeasurementState, + changeGeometry })(components.MeasurementSupport || Empty); const Locate = connect((state) => ({ diff --git a/web/client/reducers/__tests__/measurement-test.js b/web/client/reducers/__tests__/measurement-test.js new file mode 100644 index 0000000000..83b3dae0cb --- /dev/null +++ b/web/client/reducers/__tests__/measurement-test.js @@ -0,0 +1,102 @@ +/* + * Copyright 2018, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +const expect = require('expect'); +const measurement = require('../measurement'); +const { + toggleMeasurement, + changeMeasurementState, + changeUom, + changeGeometry +} = require('../../actions/measurement'); +const {RESET_CONTROLS} = require('../../actions/controls'); +const feature = {type: "Feature", geometry: { + coordinates: [[2, 2], [3, 3]], + type: "LineString" +}}; +describe('Test the measurement reducer', () => { + + it('returns original state on unrecognized action', () => { + let state = measurement(undefined, {type: 'UNKNOWN'}); + expect(state.lineMeasureEnabled).toBe(false); + expect(state.areaMeasureEnabled).toBe(false); + expect(state.bearingMeasureEnabled).toBe(false); + expect(state.uom.area.unit).toBe("sqm"); + expect(state.uom.area.label).toBe("m²"); + expect(state.lengthFormula).toBe("haversine"); + }); + + it('CHANGE_MEASUREMENT_TOOL previous geomType LineString', () => { + const state = measurement( {geomType: "LineString"}, toggleMeasurement({ + geomType: "LineString" + })); + expect(state.geomType).toBe(null); + expect(state.lineMeasureEnabled).toBe(false); + expect(state.areaMeasureEnabled).toBe(false); + expect(state.bearingMeasureEnabled).toBe(false); + }); + it('CHANGE_MEASUREMENT_TOOL previous geomType empty', () => { + const state = measurement( {geomType: ""}, toggleMeasurement({ + geomType: "LineString" + })); + expect(state.geomType).toBe("LineString"); + expect(state.lineMeasureEnabled).toBe(true); + expect(state.areaMeasureEnabled).toBe(false); + expect(state.bearingMeasureEnabled).toBe(false); + }); + it('CHANGE_MEASUREMENT_TOOL previous geomType LineString, switch to Polygon', () => { + const state = measurement( {geomType: "LineString"}, toggleMeasurement({ + geomType: "Polygon" + })); + expect(state.geomType).toBe("Polygon"); + expect(state.lineMeasureEnabled).toBe(false); + expect(state.areaMeasureEnabled).toBe(true); + expect(state.bearingMeasureEnabled).toBe(false); + }); + + it('CHANGE_MEASUREMENT_STATE', () => { + let testAction = changeMeasurementState({ + lineMeasureEnabled: true, + areaMeasureEnabled: false, + bearingMeasureEnabled: false, + geomType: "LineString", + point: 0, + len: 120205, + area: 0, + bearing: 0, + lenUnit: "m", + areaUnit: "sqm", + feature + }); + let state = measurement( {}, testAction); + expect(state.lineMeasureEnabled).toBe(true); + expect(state.areaMeasureEnabled).toBe(false); + expect(state.bearingMeasureEnabled).toBe(false); + expect(state.geomType).toBe("LineString"); + expect(state.len).toBe(120205); + }); + + it('CHANGE_UOM', () => { + let state = measurement( {showLabel: true}, changeUom("length", {label: "km", value: "km"}, { + length: {unit: 'm', label: 'm'}, + area: {unit: 'sqm', label: 'm²'} + })); + expect(state.lenUnit).toBe("km"); + expect(state.uom.length.label).toBe("km"); + }); + it('CHANGED_GEOMETRY', () => { + let state = measurement( {feature: {}}, changeGeometry(feature)); + expect(state.feature.geometry.coordinates.length).toBe(2); + }); + it('RESET_CONTROLS', () => { + let state = measurement( {feature: {}}, {type: RESET_CONTROLS}); + expect(state.lineMeasureEnabled).toBe(false); + expect(state.areaMeasureEnabled).toBe(false); + expect(state.bearingMeasureEnabled).toBe(false); + }); + +}); diff --git a/web/client/reducers/measurement.js b/web/client/reducers/measurement.js index 31ca402901..faf3a2c9cc 100644 --- a/web/client/reducers/measurement.js +++ b/web/client/reducers/measurement.js @@ -1,33 +1,46 @@ -/** - * Copyright 2015, GeoSolutions Sas. +/* + * Copyright 2018, GeoSolutions Sas. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. - */ +*/ const { CHANGE_MEASUREMENT_TOOL, - CHANGE_MEASUREMENT_STATE + CHANGE_MEASUREMENT_STATE, + CHANGE_UOM, + CHANGED_GEOMETRY } = require('../actions/measurement'); const {TOGGLE_CONTROL, RESET_CONTROLS} = require('../actions/controls'); const assign = require('object-assign'); - -function measurement(state = { +const defaultState = { lineMeasureEnabled: false, areaMeasureEnabled: false, - bearingMeasureEnabled: false -}, action) { + bearingMeasureEnabled: false, + uom: { + length: {unit: 'm', label: 'm'}, + area: {unit: 'sqm', label: 'm²'} + }, + lengthFormula: "haversine", + showLabel: true +}; +function measurement(state = defaultState, action) { switch (action.type) { - case CHANGE_MEASUREMENT_TOOL: + case CHANGE_MEASUREMENT_TOOL: { return assign({}, state, { lineMeasureEnabled: action.geomType !== state.geomType && action.geomType === 'LineString', areaMeasureEnabled: action.geomType !== state.geomType && action.geomType === 'Polygon', bearingMeasureEnabled: action.geomType !== state.geomType && action.geomType === 'Bearing', - geomType: action.geomType === state.geomType ? null : action.geomType + geomType: action.geomType === state.geomType ? null : action.geomType, + len: 0, + area: 0, + bearing: 0, + feature: {} }); + } case CHANGE_MEASUREMENT_STATE: return assign({}, state, { lineMeasureEnabled: action.lineMeasureEnabled, @@ -39,25 +52,56 @@ function measurement(state = { area: action.area, bearing: action.bearing, lenUnit: action.lenUnit, - areaUnit: action.areaUnit + areaUnit: action.areaUnit, + feature: action.feature }); + case CHANGE_UOM: { + const prop = action.uom === "length" ? "lenUnit" : "lenArea"; + const {value, label} = action.value; + return assign({}, state, { + [prop]: value, + uom: assign({}, action.previousUom, { + [action.uom]: { + unit: value, + label + } + }) + }); + } + case CHANGED_GEOMETRY: { + const {feature} = action; + return assign({}, state, { + feature + }); + } case TOGGLE_CONTROL: { // TODO: remove this when the controls will be able to be mutually exclusive if (action.control === 'info') { return { + ...state, + len: 0, + area: 0, + bearing: 0, lineMeasureEnabled: false, areaMeasureEnabled: false, - bearingMeasureEnabled: false + bearingMeasureEnabled: false, + feature: {} }; } } - case RESET_CONTROLS: + case RESET_CONTROLS: { return { + ...state, + len: 0, + area: 0, + bearing: 0, lineMeasureEnabled: false, areaMeasureEnabled: false, - bearingMeasureEnabled: false + bearingMeasureEnabled: false, + feature: {} }; + } default: return state; } diff --git a/web/client/themes/default/less/ol.less b/web/client/themes/default/less/ol.less index 844af6c51e..c8d4b3da01 100644 --- a/web/client/themes/default/less/ol.less +++ b/web/client/themes/default/less/ol.less @@ -75,3 +75,43 @@ div.ol-scale-line-inner { background-color: transparent !important; color: @ms2-color-text !important; } + +.ol-viewport { + .ol-overlay-container.ol-selectable { + pointer-events: none; + } + + .tooltip { + z-index: 0; + position: relative; + background: rgba(0, 0, 0, 0.5); + border-radius: 4px; + color: white; + padding: 4px 8px; + opacity: 0.7; + white-space: nowrap; + } + .tooltip-measure { + opacity: 1; + font-weight: bold; + } + .tooltip-static { + background-color: #ffcc33; + color: black; + border: 1px solid white; + } + .tooltip-measure:before, + .tooltip-static:before { + border-top: 6px solid rgba(0, 0, 0, 0.5); + border-right: 6px solid transparent; + border-left: 6px solid transparent; + content: ""; + position: absolute; + bottom: -6px; + margin-left: -7px; + left: 50%; + } + .tooltip-static:before { + border-top-color: #ffcc33; + } +} diff --git a/web/client/translations/data.de-DE b/web/client/translations/data.de-DE index e64e739b40..566a962891 100644 --- a/web/client/translations/data.de-DE +++ b/web/client/translations/data.de-DE @@ -413,6 +413,11 @@ "redoBtnTooltip": "Gehe vorwärts" }, "infoFormatLbl": "Identifiziere das Antwortformat", + "measureSupport": { + "continueLine": "Klicken Sie auf, um mit dem Zeichnen der Linie fortzufahren", + "continuePolygon": "Klicken Sie, um mit dem Zeichnen des Polygons fortzufahren", + "startDrawing": "Klicken Sie, um mit dem Zeichnen zu beginnen" + }, "measureComponent": { "Measure": "Messen", "MeasureLength": "Distanz messen", @@ -425,7 +430,9 @@ "resetButtonText": "Zurücksetzen", "lengthLabel": "Länge", "areaLabel": "Fläche", - "bearingLabel": "Richtung" + "bearingLabel": "Richtung", + "formula": "Formel zur Entfernungsberechnung", + "showLabel": "Zeige Bezeichnungsetikett" }, "search":{ "placeholder": "Suche nache einer Adresse oder gib Koordinaten ein ...", diff --git a/web/client/translations/data.en-US b/web/client/translations/data.en-US index 1581ad82b7..568b4a419b 100644 --- a/web/client/translations/data.en-US +++ b/web/client/translations/data.en-US @@ -414,6 +414,11 @@ "redoBtnTooltip": "Go forward" }, "infoFormatLbl": "Identify response format", + "measureSupport": { + "continueLine": "Click to continue drawing the line", + "continuePolygon": "Click to continue drawing the polygon", + "startDrawing": "Click to start drawing" + }, "measureComponent": { "Measure": "Measure", "MeasureLength": "Measure Distance", @@ -426,7 +431,9 @@ "resetButtonText": "Reset", "lengthLabel": "Length", "areaLabel": "Area", - "bearingLabel": "Bearing" + "bearingLabel": "Bearing", + "formula": "Formula for distance calculation", + "showLabel": "Show measurement label" }, "search":{ "placeholder": "Search by location name or coordinates ...", diff --git a/web/client/translations/data.es-ES b/web/client/translations/data.es-ES index 7da0409fe1..6c47e7339f 100644 --- a/web/client/translations/data.es-ES +++ b/web/client/translations/data.es-ES @@ -413,6 +413,11 @@ "redoBtnTooltip": "Ir hacia adelante" }, "infoFormatLbl": "Indentificar el formato de respuesta", + "measureSupport": { + "continueLine": "Haga clic para seguir dibujando la línea", + "continuePolygon": "Haga clic para continuar dibujando el polígono", + "startDrawing": "Haga clic para comenzar a dibujar" + }, "measureComponent": { "Measure": "Medidas", "MeasureLength": "Medias de distancia", @@ -425,7 +430,9 @@ "resetButtonText": "Reset", "lengthLabel": "Longitud", "areaLabel": "Área", - "bearingLabel": "Rumbo" + "bearingLabel": "Rumbo", + "formula": "Fórmula para cálculo de distancia", + "showLabel": "Mostrar etiqueta de medición" }, "search":{ "placeholder": "Búsqueda por toponimo o coordenadas...", diff --git a/web/client/translations/data.fr-FR b/web/client/translations/data.fr-FR index 7b348c467a..d1107349b5 100644 --- a/web/client/translations/data.fr-FR +++ b/web/client/translations/data.fr-FR @@ -414,6 +414,11 @@ "redoBtnTooltip": "Aller en avant" }, "infoFormatLbl": "Informations, format de la réponse", + "measureSupport": { + "continueLine": "Cliquez pour continuer à dessiner la ligne", + "continuePolygon": "Cliquez pour continuer à dessiner le polygone", + "startDrawing": "Cliquez pour commencer à dessiner" + }, "measureComponent": { "Measure": "Mesurer", "MeasureLength": "Mesure de distance", @@ -426,7 +431,9 @@ "resetButtonText": "Recommencer", "lengthLabel": "Longueur", "areaLabel": "Surface", - "bearingLabel": "Direction" + "bearingLabel": "Direction", + "formula": "Formule pour le calcul de la distance", + "showLabel": "Afficher l'étiquette de mesure" }, "search":{ "placeholder": "Recherche par toponyme ou coordonnées...", diff --git a/web/client/translations/data.it-IT b/web/client/translations/data.it-IT index 45a49570ec..f03a9a82e2 100644 --- a/web/client/translations/data.it-IT +++ b/web/client/translations/data.it-IT @@ -413,6 +413,11 @@ "redoBtnTooltip": "Vai avanti" }, "infoFormatLbl": "Formato risposte interrogazioni su mappa", + "measureSupport": { + "continueLine": "Clicca per continuare a disegnare la linea", + "continuePolygon": "Clicca per continuare a disegnare il poligono", + "startDrawing": "Clicca per iniziare a disegnare" + }, "measureComponent": { "Measure": "Misura", "MeasureLength": "Misura Distanza", @@ -425,7 +430,9 @@ "resetButtonText": "Azzerare", "lengthLabel": "Lunghezza", "areaLabel": "Area", - "bearingLabel": "Direzione" + "bearingLabel": "Direzione", + "formula": "Formula per il calcolo della distanza", + "showLabel": "Mostra etichetta di misurazione" }, "search":{ "placeholder": "Cerca un indirizzo o inserisci coordinate...", diff --git a/web/client/utils/CoordinatesUtils.js b/web/client/utils/CoordinatesUtils.js index 9657465867..899527b638 100644 --- a/web/client/utils/CoordinatesUtils.js +++ b/web/client/utils/CoordinatesUtils.js @@ -4,8 +4,11 @@ * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. - */ +*/ +const geo = require('node-geo-distance'); +const ol = require('openlayers'); +const wgs84Sphere = new ol.Sphere(6378137); const Proj4js = require('proj4').default; const proj4 = Proj4js; const axios = require('../libs/ajax'); @@ -14,7 +17,38 @@ const {isArray, flattenDeep, chunk, cloneDeep} = require('lodash'); const lineIntersect = require('@turf/line-intersect'); const polygonToLinestring = require('@turf/polygon-to-linestring'); const {head} = require('lodash'); - +const greatCircle = require('@turf/great-circle').default; +const toPoint = require('turf-point'); +const FORMULAS = { + /** + @param coordinates in EPSG:4326 + return vincenty distance between two points + */ + "vincenty": (coordinates) => { + let length = 0; + for (let i = 0; i < coordinates.length - 1; ++i) { + const p1 = coordinates[i]; + const p2 = coordinates[i + 1]; + const [x1, y1] = p1; + const [x2, y2] = p2; + length += parseFloat(geo.vincentySync({longitude: x1, latitude: y1}, {longitude: x2, latitude: y2})); + } + return length; + }, + /** + @param coordinates in EPSG:4326 + return distance between two points using Geodesic formula + */ + "haversine": (coordinates) => { + let length = 0; + for (let i = 0; i < coordinates.length - 1; ++i) { + const p1 = coordinates[i]; + const p2 = coordinates[i + 1]; + length += parseFloat(wgs84Sphere.haversineDistance(p1, p2)); + } + return length; + } +}; // Checks if `list` looks like a `[x, y]`. function isXY(list) { return list.length >= 2 && @@ -393,6 +427,18 @@ const CoordinatesUtils = { return azimuth; }, + /** + * Calculate length based on haversine or vincenty formula + * @param {object[]} coords projected in EPSG:4326 + * @return {number} length in meters + */ + calculateDistance: (coords = [], formula = "haversine") => { + if (coords.length >= 2 && Object.keys(FORMULAS).indexOf(formula) !== -1) { + return FORMULAS[formula](coords); + } + return 0; + }, + FORMULAS, /** * Extend an extent given another one * @@ -496,6 +542,27 @@ const CoordinatesUtils = { points[0].push(points[0][0]); return points; }, + /** + * Generate arcs between a series of points + * @param {number[]} coordinates of points of a LineString reprojected in 4326 + * @param {object} options of the great circle drawMethod + * npoints: number of points + * offset: offset controls the likelyhood that lines will be split which cross the dateline. The higher the number the more likely. + * properties: line feature properties + * @return {number[]} for each couple of points it creates an arc of 100 points by default + */ + transformLineToArcs: (coordinates, options = {npoints: 100, offset: 10, properties: {}}) => { + let arcs = []; + for (let i = 0; i < coordinates.length - 1; ++i) { + const p1 = coordinates[i]; + const p2 = coordinates[i + 1]; + const start = toPoint(p1); + const end = toPoint(p2); + const grCircle = greatCircle(start, end, options); + arcs = [...arcs, ...grCircle.geometry.coordinates]; + } + return arcs; + }, coordsOLtoLeaflet: ({coordinates, type}) => { switch (type) { case "Polygon": { diff --git a/web/client/utils/MeasureUtils.js b/web/client/utils/MeasureUtils.js index a5c38e1483..8012b4cdae 100644 --- a/web/client/utils/MeasureUtils.js +++ b/web/client/utils/MeasureUtils.js @@ -4,34 +4,78 @@ * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. - */ +*/ +function degToDms(deg) { + // convert decimal deg to minutes and seconds + var d = Math.floor(deg); + var minfloat = (deg - d) * 60; + var m = Math.floor(minfloat); + var secfloat = (minfloat - m) * 60; + var s = Math.floor(secfloat); + + return "" + d + "° " + m + "' " + s + "'' "; +} + function getFormattedBearingValue(azimuth = 0) { var bearing = ""; if (azimuth >= 0 && azimuth < 90) { - bearing = "N " + this.degToDms(azimuth) + " E"; + bearing = "N " + degToDms(azimuth) + "E"; } else if (azimuth > 90 && azimuth <= 180) { - bearing = "S " + this.degToDms(180.0 - azimuth) + " E"; + bearing = "S " + degToDms(180.0 - azimuth) + "E"; } else if (azimuth > 180 && azimuth < 270) { - bearing = "S " + this.degToDms(azimuth - 180.0 ) + " W"; + bearing = "S " + degToDms(azimuth - 180.0 ) + "W"; } else if (azimuth >= 270 && azimuth <= 360) { - bearing = "N " + this.degToDms(360 - azimuth ) + " W"; + bearing = "N " + degToDms(360 - azimuth ) + "W"; } return bearing; } +function mToft(length) { + return length * 3.28084; +} + +function mTokm(length) { + return length * 0.001; +} + +function mTomi(length) { + return length * 0.000621371; +} + +function mTonm(length) { + return length * 0.000539956803; +} + +function sqmTosqft(area) { + return area * 10.7639; +} + +function sqmTosqkm(area) { + return area * 0.000001; +} + +function sqmTosqmi(area) { + return area * 0.000000386102159; +} +function sqmTosqnm(area) { + return area * 0.00000029155; +} + function getFormattedLength(unit = "m", length = 0) { switch (unit) { case 'm': return length; case 'ft': - return this.mToft(length); + return mToft(length); case 'km': - return this.mTokm(length); + return mTokm(length); case 'mi': - return this.mTomi(length); + return mTomi(length); + case 'nm': + return mTonm(length); default: return length; } @@ -42,50 +86,18 @@ function getFormattedArea(unit = "sqm", area = 0) { case 'sqm': return area; case 'sqft': - return this.sqmTosqft(area); + return sqmTosqft(area); case 'sqkm': - return this.sqmTosqkm(area); + return sqmTosqkm(area); case 'sqmi': - return this.sqmTosqmi(area); + return sqmTosqmi(area); + case 'sqnm': + return sqmTosqnm(area); default: return area; } } -function degToDms(deg) { - // convert decimal deg to minutes and seconds - var d = Math.floor(deg); - var minfloat = (deg - d) * 60; - var m = Math.floor(minfloat); - var secfloat = (minfloat - m) * 60; - var s = Math.floor(secfloat); - - return "" + d + "° " + m + "' " + s + "'' "; -} - -function mToft(length) { - return length * 3.28084; -} - -function mTokm(length) { - return length * 0.001; -} - -function mTomi(length) { - return length * 0.000621371; -} - -function sqmTosqft(area) { - return area * 10.7639; -} - -function sqmTosqkm(area) { - return area * 0.000001; -} - -function sqmTosqmi(area) { - return area * 0.000000386102159; -} module.exports = { getFormattedBearingValue, @@ -95,7 +107,9 @@ module.exports = { mToft, mTokm, mTomi, + mTonm, sqmTosqmi, sqmTosqkm, + sqmTosqnm, sqmTosqft }; diff --git a/web/client/utils/__tests__/CoordinatesUtils-test.js b/web/client/utils/__tests__/CoordinatesUtils-test.js index ae42137362..41b3be4f0a 100644 --- a/web/client/utils/__tests__/CoordinatesUtils-test.js +++ b/web/client/utils/__tests__/CoordinatesUtils-test.js @@ -449,4 +449,21 @@ describe('CoordinatesUtils', () => { expect(CoordinatesUtils.determineCrs("EPSG:3004")).toBe(null); expect(CoordinatesUtils.determineCrs({crs: "EPSG:3004"}).crs).toBe("EPSG:3004"); }); + it('test calculateDistance', () => { + expect(CoordinatesUtils.calculateDistance([[1, 1], [2, 2]], "haversine")).toNotBe(null); + expect(CoordinatesUtils.calculateDistance([[1, 1], [2, 2]], "haversine")).toBe(157401.56104583552); + expect(CoordinatesUtils.calculateDistance([[1, 1], [2, 2]], "vincenty")).toBe(156876.149); + }); + it('test calculate Geodesic Distance', () => { + expect(CoordinatesUtils.FORMULAS.haversine([[1, 1], [2, 2]] )).toNotBe(null); + expect(CoordinatesUtils.FORMULAS.haversine([[1, 1], [2, 2]] )).toBe(157401.56104583552); + }); + it('test calculate vincenty Distance', () => { + expect(CoordinatesUtils.FORMULAS.vincenty([[1, 1], [2, 2]] )).toNotBe(null); + expect(CoordinatesUtils.FORMULAS.vincenty([[1, 1], [2, 2]] )).toBe(156876.149); + }); + it('test transformLineToArcs', () => { + expect(CoordinatesUtils.transformLineToArcs([[1, 1], [2, 2]] )).toNotBe(null); + expect(CoordinatesUtils.transformLineToArcs([[1, 1], [2, 2]] ).length).toBe(100); + }); }); diff --git a/web/client/utils/__tests__/MeasureUtils-test.js b/web/client/utils/__tests__/MeasureUtils-test.js new file mode 100644 index 0000000000..614a9755b0 --- /dev/null +++ b/web/client/utils/__tests__/MeasureUtils-test.js @@ -0,0 +1,107 @@ +/* + * Copyright 2017, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ +const expect = require('expect'); +const { + getFormattedBearingValue, + getFormattedLength, + getFormattedArea, + degToDms, + mToft, + mTokm, + mTomi, + mTonm, + sqmTosqmi, + sqmTosqkm, + sqmTosqnm, + sqmTosqft +} = require('../MeasureUtils'); + + +describe('MeasureUtils', () => { + beforeEach( () => { + + }); + afterEach(() => { + + }); + it('test conversion meters to feet', () => { + const val = mToft(1); + expect(val).toBe(3.28084); + }); + it('test conversion meters to kilometers', () => { + const val = mTokm(1); + expect(val).toBe(0.001); + }); + it('test conversion meters to miles', () => { + const val = mTomi(1); + expect(val).toBe(0.000621371); + }); + it('test conversion meters to nauticalmiles', () => { + const val = mTonm(1); + expect(val).toBe(0.000539956803); + }); + it('test conversion squaremeters to squarefeet', () => { + const val = sqmTosqft(1); + expect(val).toBe(10.7639); + }); + it('test conversion squaremeters to squarekilometers', () => { + const val = sqmTosqkm(1); + expect(val).toBe(0.000001); + }); + it('test conversion squaremeters to squaremiles', () => { + const val = sqmTosqmi(1); + expect(val).toBe(0.000000386102159); + }); + it('test conversion squaremeters to squarenauticalmiles', () => { + const val = sqmTosqnm(1); + expect(val).toBe(0.00000029155); + }); + it('test getFormattedLength', () => { + let val = getFormattedLength("m", 1); + expect(val).toBe(1); + val = getFormattedLength(undefined, 1); + expect(val).toBe(1); + val = getFormattedLength("ft", 1); + expect(val).toBe(3.28084); + val = getFormattedLength("km", 1); + expect(val).toBe(0.001); + val = getFormattedLength("mi", 1); + expect(val).toBe(0.000621371); + val = getFormattedLength("nm", 1); + expect(val).toBe(0.000539956803); + }); + it('test getFormattedArea', () => { + let val = getFormattedArea("sqm", 1); + expect(val).toBe(1); + val = getFormattedArea(undefined, 1); + expect(val).toBe(1); + val = getFormattedArea("sqft", 1); + expect(val).toBe(10.7639); + val = getFormattedArea("sqkm", 1); + expect(val).toBe(0.000001); + val = getFormattedArea("sqmi", 1); + expect(val).toBe(0.000000386102159); + val = getFormattedArea("sqnm", 1); + expect(val).toBe(0.00000029155); + }); + it('test degToDms', () => { + let val = degToDms(1.111); + expect(val).toBe("1° 6' 39'' "); + }); + it('test getFormattedBearingValue', () => { + let val = getFormattedBearingValue(1.111); + expect(val).toBe("N 1° 6' 39'' E"); + val = getFormattedBearingValue(91.111); + expect(val).toBe("S 88° 53' 20'' E"); + val = getFormattedBearingValue(181.111); + expect(val).toBe("S 1° 6' 39'' W"); + val = getFormattedBearingValue(281.111); + expect(val).toBe("N 78° 53' 20'' W"); + }); + +});