From a76e149469a4aaba67e52ee2d3b552db54012763 Mon Sep 17 00:00:00 2001 From: Abdelkoddouss IZem <31613538+Abizem@users.noreply.github.com> Date: Fri, 23 Feb 2018 21:13:12 +0000 Subject: [PATCH 01/18] Update tasks.py --- app/api/tasks.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index 87d7b469..8003d400 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -24,7 +24,10 @@ from nodeodm.models import ProcessingNode class TaskIDsSerializer(serializers.BaseSerializer): def to_representation(self, obj): return obj.id - + +class geojsonSerializer(serializers.Serializer): + """docstring for geojsonSeria""" + geometry = serializers.JSONField(help_text="polygon contour to get volume") class TaskSerializer(serializers.ModelSerializer): project = serializers.PrimaryKeyRelatedField(queryset=models.Project.objects.all()) @@ -303,3 +306,16 @@ class TaskAssets(TaskNestedView): content_type=(mimetypes.guess_type(asset_filename)[0] or "application/zip")) response['Content-Disposition'] = "inline; filename={}".format(asset_filename) return response + +class TaskVolume(TaskNestedView): + def post(self, request, pk=None, project_pk=None): + task = self.get_and_check_task(request, pk, project_pk) + serializer = geojsonSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + # geometry = serializer.data.get('geometry') + # if geometry is None: + # raise exceptions.ValidationError("A geoson file are not available.") + result=task.get_volume(request.data) + response = Response(result, status=status.HTTP_200_OK) + return response + From 141e34211cf5557a129fc3d1114cda68755a3b8b Mon Sep 17 00:00:00 2001 From: Abdelkoddouss IZem <31613538+Abizem@users.noreply.github.com> Date: Fri, 23 Feb 2018 21:16:48 +0000 Subject: [PATCH 02/18] Update urls.py Volume url --- app/api/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/api/urls.py b/app/api/urls.py index 4b57ec80..2100292f 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -28,7 +28,8 @@ urlpatterns = [ url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/download/(?P.+)$', TaskDownloads.as_view()), url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/assets/(?P.+)$', TaskAssets.as_view()), + url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/volume$', TaskVolume.as_view()), url(r'^auth/', include('rest_framework.urls')), url(r'^token-auth/', obtain_jwt_token), -] \ No newline at end of file +] From f788913d3568c75c67a70d41249e8e39776e0df3 Mon Sep 17 00:00:00 2001 From: Abdelkoddouss IZem <31613538+Abizem@users.noreply.github.com> Date: Fri, 23 Feb 2018 21:17:39 +0000 Subject: [PATCH 03/18] Update urls.py --- app/api/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/urls.py b/app/api/urls.py index 2100292f..60cfad0d 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import url, include from app.api.presets import PresetViewSet from .projects import ProjectViewSet -from .tasks import TaskViewSet, TaskTiles, TaskTilesJson, TaskDownloads, TaskAssets +from .tasks import TaskViewSet, TaskTiles, TaskTilesJson, TaskDownloads, TaskAssets, TaskVolume from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView from rest_framework_nested import routers from rest_framework_jwt.views import obtain_jwt_token From b01c73705a768368da37f047110f7beb3348401f Mon Sep 17 00:00:00 2001 From: Abdelkoddouss IZem <31613538+Abizem@users.noreply.github.com> Date: Fri, 23 Feb 2018 21:29:26 +0000 Subject: [PATCH 04/18] Update task.py Add basic median plane function from where volume is extruded --- app/models/task.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/models/task.py b/app/models/task.py index 16ed2954..4359f402 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -3,6 +3,15 @@ import os import shutil import zipfile import uuid as uuid_module +import json + +import osgeo.ogr +import gdal +import struct +import statistics +from .vertex import rings +from .repro_json import reprojson +from .cliprasterpol import clip_raster from django.contrib.gis.gdal import GDALRaster from django.contrib.gis.gdal import OGRGeometry @@ -514,7 +523,25 @@ class Task(models.Model): self.status = status_codes.FAILED self.pending_action = None self.save() + + def get_volume(self, geojson): + try: + raster_path= self.assets_path("odm_dem", "dsm.tif") + raster=gdal.Open(raster_path) + gt=raster.GetGeoTransform() + rb=raster.GetRasterBand(1) + gdal.UseExceptions() + geosom = reprojson(geojson, raster) + coords=[(entry[0],entry[1]) for entry in rings(raster_path, geosom)] + GSD=gt[1] + volume=0 + med=statistics.median(entry[2] for entry in rings(raster_path, geosom)) + clip=clip_raster(raster_path, geosom, gt=None, nodata=-9999) + return ((clip-med)*GSD*GSD)[clip!=-9999.0].sum() + except FileNotFoundError as e: + logger.warning(e) + class Meta: permissions = ( ('view_task', 'Can view task'), From 0eeba94e92bbbf5981ff972a1263bf3e4d3bc11b Mon Sep 17 00:00:00 2001 From: Abdelkoddouss IZem <31613538+Abizem@users.noreply.github.com> Date: Fri, 23 Feb 2018 21:32:27 +0000 Subject: [PATCH 05/18] Create repro_json.py Reproject th geojson polygon to the DEM coordinate system --- app/models/repro_json.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 app/models/repro_json.py diff --git a/app/models/repro_json.py b/app/models/repro_json.py new file mode 100644 index 00000000..f8fd64e9 --- /dev/null +++ b/app/models/repro_json.py @@ -0,0 +1,35 @@ +import json +from osgeo import osr + + +def spatialref(epsg_code): + spatialref = osr.SpatialReference() + spatialref.ImportFromEPSG(epsg_code) + return spatialref + +def spatialrefWQT(dataset): + spatialref = osr.SpatialReference() + spatialref.ImportFromWkt(dataset.GetProjectionRef()) + return spatialref + +def reprojson(geojson, dataset): + + crsin= spatialref(4326) + crsout = spatialrefWQT(dataset) + + coordinate_transformation = osr.CoordinateTransformation(crsin, crsout) + + # Define dictionary representation of output feature collection + fc_out = {"geometry":{"type":"Polygon","coordinates":[]}} + + # Iterate through each feature of the feature collection + new_coords = [] + # Project/transform coordinate pairs of each ring + # (iteration required in case geometry type is MultiPolygon, or there are holes) + for ring in geojson['geometry']['coordinates']: + coords=[(entry[0],entry[1]) for entry in ring] + for i in range(len(coords)): + x2, y2, z= coordinate_transformation.TransformPoint(coords[i][0], coords[i][1]) + new_coords.append([x2, y2]) + fc_out['geometry']['coordinates'] = [new_coords] + return fc_out From ad3bfbede80d98d526d993381212ba788c863092 Mon Sep 17 00:00:00 2001 From: Abdelkoddouss IZem <31613538+Abizem@users.noreply.github.com> Date: Fri, 23 Feb 2018 21:34:51 +0000 Subject: [PATCH 06/18] Create vertex.py Extract polygon vertex in dem coordinate system (X, Y,Z) --- app/models/vertex.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 app/models/vertex.py diff --git a/app/models/vertex.py b/app/models/vertex.py new file mode 100644 index 00000000..c3f669ef --- /dev/null +++ b/app/models/vertex.py @@ -0,0 +1,40 @@ +from osgeo import ogr +import gdal +import struct +import json + +def convertJson(jsdata): + return json.dumps(jsdata) + +def rings(raster, geojson): + + src=gdal.Open(raster) + gtx=src.GetGeoTransform() + rbu=src.GetRasterBand(1) + gdal.UseExceptions() + + geo=convertJson(geojson) + + geojsom= ogr.Open(geo) + + layer1 = geojsom.GetLayer(0) + + vertices = [] + + for feat in layer1: + geom = feat.GetGeometryRef() + ring = geom.GetGeometryRef(0) + points = ring.GetPointCount() + + for p in range(points): + lon, lat, z = ring.GetPoint(p) + px = int((lon - gtx[0]) / gtx[1]) #x pixel + py = int((lat - gtx[3]) / gtx[5]) #y pixel + try: + structval=rbu.ReadRaster(px,py,1,1,buf_type=gdal.GDT_Float32) #Assumes 32 bit int- 'float' + intval = struct.unpack('f' , structval) #assume float + val=intval[0] + vertices.append((px, py, val)) + except: + val=-9999 #or some value to indicate a fail + return vertices From b9a3be7c7b12154bcde06498978768b505f6f9d9 Mon Sep 17 00:00:00 2001 From: Abdelkoddouss IZem <31613538+Abizem@users.noreply.github.com> Date: Fri, 23 Feb 2018 21:37:13 +0000 Subject: [PATCH 07/18] Create cliprasterpol.py Clip the corresponding user's geojson polygon from the DEM --- app/models/cliprasterpol.py | 158 ++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 app/models/cliprasterpol.py diff --git a/app/models/cliprasterpol.py b/app/models/cliprasterpol.py new file mode 100644 index 00000000..88a3209c --- /dev/null +++ b/app/models/cliprasterpol.py @@ -0,0 +1,158 @@ +from osgeo import gdal, gdalnumeric, ogr +from PIL import Image, ImageDraw +import os +import numpy as np +import json + +def clip_raster(raster, geojson, gt=None, nodata=-9999): + ''' + Clips a raster (given as either a gdal.Dataset or as a numpy.array + instance) to a polygon layer provided by a Shapefile (or other vector + layer). If a numpy.array is given, a "GeoTransform" must be provided + (via dataset.GetGeoTransform() in GDAL). Returns an array. Clip features + must be a dissolved, single-part geometry (not multi-part). Modified from: + + http://pcjericks.github.io/py-gdalogr-cookbook/raster_layers.html + #clip-a-geotiff-with-shapefile + + Arguments: + rast A gdal.Dataset or a NumPy array + features_path The path to the clipping features + gt An optional GDAL GeoTransform to use instead + nodata The NoData value; defaults to -9999. + ''' + def array_to_image(a): + ''' + Converts a gdalnumeric array to a Python Imaging Library (PIL) Image. + ''' + i = Image.fromstring('L',(a.shape[1], a.shape[0]), + (a.astype('b')).tostring()) + return i + + def convertJson(jsdata): + return json.dumps(jsdata) + + def image_to_array(i): + ''' + Converts a Python Imaging Library (PIL) array to a gdalnumeric image. + ''' + a = gdalnumeric.fromstring(i.tobytes(), 'b') + a.shape = i.im.size[1], i.im.size[0] + return a + + def world_to_pixel(geo_matrix, x, y): + ''' + Uses a gdal geomatrix (gdal.GetGeoTransform()) to calculate + the pixel location of a geospatial coordinate; from: + http://pcjericks.github.io/py-gdalogr-cookbook/raster_layers.html#clip-a-geotiff-with-shapefile + ''' + ulX = geo_matrix[0] + ulY = geo_matrix[3] + xDist = geo_matrix[1] + yDist = geo_matrix[5] + rtnX = geo_matrix[2] + rtnY = geo_matrix[4] + pixel = int((x - ulX) / xDist) + line = int((ulY - y) / xDist) + return (pixel, line) + + rast=gdal.Open(raster) + + # Can accept either a gdal.Dataset or numpy.array instance + if not isinstance(rast, np.ndarray): + gt = rast.GetGeoTransform() + rast = rast.ReadAsArray() + + # Create an OGR layer from a boundary shapefile + + geo = convertJson(geojson) + features = ogr.Open(geo) + if features.GetDriver().GetName() == 'ESRI Shapefile': + lyr = features.GetLayer(os.path.split(os.path.splitext(features_path)[0])[1]) + + else: + lyr = features.GetLayer() + + # Get the first feature + poly = lyr.GetNextFeature() + + # Convert the layer extent to image pixel coordinates + minX, maxX, minY, maxY = lyr.GetExtent() + ulX, ulY = world_to_pixel(gt, minX, maxY) + lrX, lrY = world_to_pixel(gt, maxX, minY) + + # Calculate the pixel size of the new image + pxWidth = int(lrX - ulX) + pxHeight = int(lrY - ulY) + + # If the clipping features extend out-of-bounds and ABOVE the raster... + if gt[3] < maxY: + # In such a case... ulY ends up being negative--can't have that! + iY = ulY + ulY = 0 + + # Multi-band image? + try: + clip = rast[:, ulY:lrY, ulX:lrX] + + except IndexError: + clip = rast[ulY:lrY, ulX:lrX] + + # Create a new geomatrix for the image + gt2 = list(gt) + gt2[0] = minX + gt2[3] = maxY + + # Map points to pixels for drawing the boundary on a blank 8-bit, + # black and white, mask image. + points = [] + pixels = [] + geom = poly.GetGeometryRef() + pts = geom.GetGeometryRef(0) + + for p in range(pts.GetPointCount()): + points.append((pts.GetX(p), pts.GetY(p))) + + for p in points: + pixels.append(world_to_pixel(gt2, p[0], p[1])) + + raster_poly = Image.new('L', (pxWidth, pxHeight), 1) + rasterize = ImageDraw.Draw(raster_poly) + rasterize.polygon(pixels, 0) # Fill with zeroes + + # If the clipping features extend out-of-bounds and ABOVE the raster... + if gt[3] < maxY: + # The clip features were "pushed down" to match the bounds of the + # raster; this step "pulls" them back up + premask = image_to_array(raster_poly) + # We slice out the piece of our clip features that are "off the map" + mask = np.ndarray((premask.shape[-2] - abs(iY), premask.shape[-1]), premask.dtype) + mask[:] = premask[abs(iY):, :] + mask.resize(premask.shape) # Then fill in from the bottom + + # Most importantly, push the clipped piece down + gt2[3] = maxY - (maxY - gt[3]) + + else: + mask = image_to_array(raster_poly) + + # Clip the image using the mask + try: + clip = gdalnumeric.choose(mask, (clip, nodata)) + + # If the clipping features extend out-of-bounds and BELOW the raster... + except ValueError: + # We have to cut the clipping features to the raster! + rshp = list(mask.shape) + if mask.shape[-2] != clip.shape[-2]: + rshp[0] = clip.shape[-2] + + if mask.shape[-1] != clip.shape[-1]: + rshp[1] = clip.shape[-1] + + mask.resize(*rshp, refcheck=False) + + clip = gdalnumeric.choose(mask, (clip, nodata)) + + # return (clip, ulX, ulY, gt2) + return clip From 7607a0108635c2bc3aac872c2657ffb0da05d543 Mon Sep 17 00:00:00 2001 From: Abdelkoddouss IZem <31613538+Abizem@users.noreply.github.com> Date: Fri, 23 Feb 2018 21:53:00 +0000 Subject: [PATCH 08/18] Update Map.jsx Need to install leaflet-draw via npm..then draw a polyon over the orthophoto annd you got the volume --- app/static/app/js/components/Map.jsx | 76 +++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/app/static/app/js/components/Map.jsx b/app/static/app/js/components/Map.jsx index b9efc01a..458dee20 100644 --- a/app/static/app/js/components/Map.jsx +++ b/app/static/app/js/components/Map.jsx @@ -7,6 +7,8 @@ import Leaflet from 'leaflet'; import async from 'async'; import 'leaflet-measure/dist/leaflet-measure.css'; import 'leaflet-measure/dist/leaflet-measure'; +import 'leaflet-draw/dist/leaflet.draw.css'; +import 'leaflet-draw/dist/leaflet.draw'; import '../vendor/leaflet/L.Control.MousePosition.css'; import '../vendor/leaflet/L.Control.MousePosition'; import '../vendor/leaflet/Leaflet.Autolayers/css/leaflet.auto-layers.css'; @@ -139,7 +141,7 @@ class Map extends React.Component { 3D `; - layer.bindPopup(popup); + //layer.bindPopup(popup); $('#layerOpacity', popup).on('change input', function() { layer.setOpacity($('#layerOpacity', popup).val()); @@ -184,6 +186,78 @@ class Map extends React.Component { secondaryAreaUnit: 'acres' }); measureControl.addTo(this.map); + + const featureGroup = L.featureGroup(); + featureGroup.addTo(this.map); + + new L.Control.Draw({ + draw: { + polygon: { + allowIntersection: false, // Restricts shapes to simple polygons + shapeOptions: { + color: '#707070' + } + }, + rectangle: false, + circle: false, + circlemarker: false, + marker: false + }, + edit: { + featureGroup: featureGroup, + edit: { + selectedPathOptions: { + maintainColor: true, + dashArray: '10, 10' + } + } + } + }).addTo(this.map); + + this.map.on(L.Draw.Event.CREATED, function(e) { + e.layer.feature = {geometry: {type: 'Polygon'} }; + featureGroup.addLayer(e.layer); + + var paramList; + $.ajax({ + type: 'POST', + async: false, + url: '/api/projects/4/tasks/7/volume', + data: JSON.stringify(e.layer.toGeoJSON()), + contentType: "application/json", + success: function (msg) { + paramList = msg; + }, + error: function (jqXHR, textStatus, errorThrown) { + alert("get session failed " + errorThrown); + } + }); + + e.layer.bindPopup('Volume: ' + paramList.toFixed(2) + 'Mètres Cubes (m3)'); + }); + + this.map.on(L.Draw.Event.EDITED, function(e) { + e.layers.eachLayer(function(layer) { + var paramList = null; + $.ajax({ + type: 'POST', + async: false, + url: '/api/projects/1/tasks/4/volume', + data: JSON.stringify(layer.toGeoJSON()), + contentType: "application/json", + success: function (msg) { + paramList = msg; + }, + error: function (jqXHR, textStatus, errorThrown) { + alert("get session failed " + errorThrown); + } + }); + + layer.setPopupContent('Volume: ' + paramList.toFixed(2) + 'Mètres Cubes (m3)'); + + }); + }); + if (showBackground) { this.basemaps = { From 2005194d946cdc35ee59c7c897ef51e680909a5c Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 19 Mar 2018 12:26:10 -0400 Subject: [PATCH 09/18] npm install support for plugins, started moving volume code into plugin, API url mountpoints --- app/api/tasks.py | 31 ++---- app/api/urls.py | 6 +- app/models/cliprasterpol.py | 158 --------------------------- app/models/repro_json.py | 35 ------ app/models/task.py | 28 ----- app/models/vertex.py | 40 ------- app/plugins/functions.py | 35 +++++- app/plugins/mount_point.py | 2 +- app/plugins/plugin_base.py | 14 ++- app/static/app/js/components/Map.jsx | 12 +- app/tests/test_plugins.py | 8 ++ app/urls.py | 4 +- plugins/posm-gcpi/plugin.py | 2 +- plugins/test/plugin.py | 2 +- plugins/test/public/package.json | 14 +++ plugins/volume/api.py | 21 ++++ plugins/volume/disabled | 0 plugins/volume/manifest.json | 2 +- plugins/volume/plugin.py | 29 ++++- plugins/volume/public/hello.js | 6 - plugins/volume/public/package.json | 14 +++ 21 files changed, 149 insertions(+), 314 deletions(-) delete mode 100644 app/models/cliprasterpol.py delete mode 100644 app/models/repro_json.py delete mode 100644 app/models/vertex.py create mode 100644 plugins/test/public/package.json create mode 100644 plugins/volume/api.py delete mode 100644 plugins/volume/disabled delete mode 100644 plugins/volume/public/hello.js create mode 100644 plugins/volume/public/package.json diff --git a/app/api/tasks.py b/app/api/tasks.py index 700ac4f0..e5d3b1cd 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -20,10 +20,6 @@ from .common import get_and_check_project, get_tile_json, path_traversal_check class TaskIDsSerializer(serializers.BaseSerializer): def to_representation(self, obj): return obj.id - -class geojsonSerializer(serializers.Serializer): - """docstring for geojsonSeria""" - geometry = serializers.JSONField(help_text="polygon contour to get volume") class TaskSerializer(serializers.ModelSerializer): project = serializers.PrimaryKeyRelatedField(queryset=models.Project.objects.all()) @@ -196,15 +192,15 @@ class TaskNestedView(APIView): queryset = models.Task.objects.all().defer('orthophoto_extent', 'dtm_extent', 'dsm_extent', 'console_output', ) permission_classes = (IsAuthenticatedOrReadOnly, ) - def get_and_check_task(self, request, pk, project_pk, annotate={}): + def get_and_check_task(self, request, pk, annotate={}): try: - task = self.queryset.annotate(**annotate).get(pk=pk, project=project_pk) + task = self.queryset.annotate(**annotate).get(pk=pk) except (ObjectDoesNotExist, ValidationError): raise exceptions.NotFound() # Check for permissions, unless the task is public if not task.public: - get_and_check_project(request, project_pk) + get_and_check_project(request, task.project.id) return task @@ -214,7 +210,7 @@ class TaskTiles(TaskNestedView): """ Get a tile image """ - task = self.get_and_check_task(request, pk, project_pk) + task = self.get_and_check_task(request, pk) tile_path = task.get_tile_path(tile_type, z, x, y) if os.path.isfile(tile_path): tile = open(tile_path, "rb") @@ -228,7 +224,7 @@ class TaskTilesJson(TaskNestedView): """ Get tile.json for this tasks's asset type """ - task = self.get_and_check_task(request, pk, project_pk) + task = self.get_and_check_task(request, pk) extent_map = { 'orthophoto': task.orthophoto_extent, @@ -259,7 +255,7 @@ class TaskDownloads(TaskNestedView): """ Downloads a task asset (if available) """ - task = self.get_and_check_task(request, pk, project_pk) + task = self.get_and_check_task(request, pk) # Check and download try: @@ -287,7 +283,7 @@ class TaskAssets(TaskNestedView): """ Downloads a task asset (if available) """ - task = self.get_and_check_task(request, pk, project_pk) + task = self.get_and_check_task(request, pk) # Check for directory traversal attacks try: @@ -305,16 +301,3 @@ class TaskAssets(TaskNestedView): content_type=(mimetypes.guess_type(asset_filename)[0] or "application/zip")) response['Content-Disposition'] = "inline; filename={}".format(asset_filename) return response - -class TaskVolume(TaskNestedView): - def post(self, request, pk=None, project_pk=None): - task = self.get_and_check_task(request, pk, project_pk) - serializer = geojsonSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - # geometry = serializer.data.get('geometry') - # if geometry is None: - # raise exceptions.ValidationError("A geoson file are not available.") - result=task.get_volume(request.data) - response = Response(result, status=status.HTTP_200_OK) - return response - diff --git a/app/api/urls.py b/app/api/urls.py index 60cfad0d..f9e1b1d6 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -1,8 +1,9 @@ from django.conf.urls import url, include from app.api.presets import PresetViewSet +from app.plugins import get_api_url_patterns from .projects import ProjectViewSet -from .tasks import TaskViewSet, TaskTiles, TaskTilesJson, TaskDownloads, TaskAssets, TaskVolume +from .tasks import TaskViewSet, TaskTiles, TaskTilesJson, TaskDownloads, TaskAssets from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView from rest_framework_nested import routers from rest_framework_jwt.views import obtain_jwt_token @@ -28,8 +29,9 @@ urlpatterns = [ url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/download/(?P.+)$', TaskDownloads.as_view()), url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/assets/(?P.+)$', TaskAssets.as_view()), - url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/volume$', TaskVolume.as_view()), url(r'^auth/', include('rest_framework.urls')), url(r'^token-auth/', obtain_jwt_token), ] + +urlpatterns += get_api_url_patterns() \ No newline at end of file diff --git a/app/models/cliprasterpol.py b/app/models/cliprasterpol.py deleted file mode 100644 index 88a3209c..00000000 --- a/app/models/cliprasterpol.py +++ /dev/null @@ -1,158 +0,0 @@ -from osgeo import gdal, gdalnumeric, ogr -from PIL import Image, ImageDraw -import os -import numpy as np -import json - -def clip_raster(raster, geojson, gt=None, nodata=-9999): - ''' - Clips a raster (given as either a gdal.Dataset or as a numpy.array - instance) to a polygon layer provided by a Shapefile (or other vector - layer). If a numpy.array is given, a "GeoTransform" must be provided - (via dataset.GetGeoTransform() in GDAL). Returns an array. Clip features - must be a dissolved, single-part geometry (not multi-part). Modified from: - - http://pcjericks.github.io/py-gdalogr-cookbook/raster_layers.html - #clip-a-geotiff-with-shapefile - - Arguments: - rast A gdal.Dataset or a NumPy array - features_path The path to the clipping features - gt An optional GDAL GeoTransform to use instead - nodata The NoData value; defaults to -9999. - ''' - def array_to_image(a): - ''' - Converts a gdalnumeric array to a Python Imaging Library (PIL) Image. - ''' - i = Image.fromstring('L',(a.shape[1], a.shape[0]), - (a.astype('b')).tostring()) - return i - - def convertJson(jsdata): - return json.dumps(jsdata) - - def image_to_array(i): - ''' - Converts a Python Imaging Library (PIL) array to a gdalnumeric image. - ''' - a = gdalnumeric.fromstring(i.tobytes(), 'b') - a.shape = i.im.size[1], i.im.size[0] - return a - - def world_to_pixel(geo_matrix, x, y): - ''' - Uses a gdal geomatrix (gdal.GetGeoTransform()) to calculate - the pixel location of a geospatial coordinate; from: - http://pcjericks.github.io/py-gdalogr-cookbook/raster_layers.html#clip-a-geotiff-with-shapefile - ''' - ulX = geo_matrix[0] - ulY = geo_matrix[3] - xDist = geo_matrix[1] - yDist = geo_matrix[5] - rtnX = geo_matrix[2] - rtnY = geo_matrix[4] - pixel = int((x - ulX) / xDist) - line = int((ulY - y) / xDist) - return (pixel, line) - - rast=gdal.Open(raster) - - # Can accept either a gdal.Dataset or numpy.array instance - if not isinstance(rast, np.ndarray): - gt = rast.GetGeoTransform() - rast = rast.ReadAsArray() - - # Create an OGR layer from a boundary shapefile - - geo = convertJson(geojson) - features = ogr.Open(geo) - if features.GetDriver().GetName() == 'ESRI Shapefile': - lyr = features.GetLayer(os.path.split(os.path.splitext(features_path)[0])[1]) - - else: - lyr = features.GetLayer() - - # Get the first feature - poly = lyr.GetNextFeature() - - # Convert the layer extent to image pixel coordinates - minX, maxX, minY, maxY = lyr.GetExtent() - ulX, ulY = world_to_pixel(gt, minX, maxY) - lrX, lrY = world_to_pixel(gt, maxX, minY) - - # Calculate the pixel size of the new image - pxWidth = int(lrX - ulX) - pxHeight = int(lrY - ulY) - - # If the clipping features extend out-of-bounds and ABOVE the raster... - if gt[3] < maxY: - # In such a case... ulY ends up being negative--can't have that! - iY = ulY - ulY = 0 - - # Multi-band image? - try: - clip = rast[:, ulY:lrY, ulX:lrX] - - except IndexError: - clip = rast[ulY:lrY, ulX:lrX] - - # Create a new geomatrix for the image - gt2 = list(gt) - gt2[0] = minX - gt2[3] = maxY - - # Map points to pixels for drawing the boundary on a blank 8-bit, - # black and white, mask image. - points = [] - pixels = [] - geom = poly.GetGeometryRef() - pts = geom.GetGeometryRef(0) - - for p in range(pts.GetPointCount()): - points.append((pts.GetX(p), pts.GetY(p))) - - for p in points: - pixels.append(world_to_pixel(gt2, p[0], p[1])) - - raster_poly = Image.new('L', (pxWidth, pxHeight), 1) - rasterize = ImageDraw.Draw(raster_poly) - rasterize.polygon(pixels, 0) # Fill with zeroes - - # If the clipping features extend out-of-bounds and ABOVE the raster... - if gt[3] < maxY: - # The clip features were "pushed down" to match the bounds of the - # raster; this step "pulls" them back up - premask = image_to_array(raster_poly) - # We slice out the piece of our clip features that are "off the map" - mask = np.ndarray((premask.shape[-2] - abs(iY), premask.shape[-1]), premask.dtype) - mask[:] = premask[abs(iY):, :] - mask.resize(premask.shape) # Then fill in from the bottom - - # Most importantly, push the clipped piece down - gt2[3] = maxY - (maxY - gt[3]) - - else: - mask = image_to_array(raster_poly) - - # Clip the image using the mask - try: - clip = gdalnumeric.choose(mask, (clip, nodata)) - - # If the clipping features extend out-of-bounds and BELOW the raster... - except ValueError: - # We have to cut the clipping features to the raster! - rshp = list(mask.shape) - if mask.shape[-2] != clip.shape[-2]: - rshp[0] = clip.shape[-2] - - if mask.shape[-1] != clip.shape[-1]: - rshp[1] = clip.shape[-1] - - mask.resize(*rshp, refcheck=False) - - clip = gdalnumeric.choose(mask, (clip, nodata)) - - # return (clip, ulX, ulY, gt2) - return clip diff --git a/app/models/repro_json.py b/app/models/repro_json.py deleted file mode 100644 index f8fd64e9..00000000 --- a/app/models/repro_json.py +++ /dev/null @@ -1,35 +0,0 @@ -import json -from osgeo import osr - - -def spatialref(epsg_code): - spatialref = osr.SpatialReference() - spatialref.ImportFromEPSG(epsg_code) - return spatialref - -def spatialrefWQT(dataset): - spatialref = osr.SpatialReference() - spatialref.ImportFromWkt(dataset.GetProjectionRef()) - return spatialref - -def reprojson(geojson, dataset): - - crsin= spatialref(4326) - crsout = spatialrefWQT(dataset) - - coordinate_transformation = osr.CoordinateTransformation(crsin, crsout) - - # Define dictionary representation of output feature collection - fc_out = {"geometry":{"type":"Polygon","coordinates":[]}} - - # Iterate through each feature of the feature collection - new_coords = [] - # Project/transform coordinate pairs of each ring - # (iteration required in case geometry type is MultiPolygon, or there are holes) - for ring in geojson['geometry']['coordinates']: - coords=[(entry[0],entry[1]) for entry in ring] - for i in range(len(coords)): - x2, y2, z= coordinate_transformation.TransformPoint(coords[i][0], coords[i][1]) - new_coords.append([x2, y2]) - fc_out['geometry']['coordinates'] = [new_coords] - return fc_out diff --git a/app/models/task.py b/app/models/task.py index 193684a1..569df118 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -3,15 +3,6 @@ import os import shutil import zipfile import uuid as uuid_module -import json - -import osgeo.ogr -import gdal -import struct -import statistics -from .vertex import rings -from .repro_json import reprojson -from .cliprasterpol import clip_raster import json from shlex import quote @@ -588,25 +579,6 @@ class Task(models.Model): self.pending_action = None self.save() - def get_volume(self, geojson): - try: - raster_path= self.assets_path("odm_dem", "dsm.tif") - raster=gdal.Open(raster_path) - gt=raster.GetGeoTransform() - rb=raster.GetRasterBand(1) - gdal.UseExceptions() - geosom = reprojson(geojson, raster) - coords=[(entry[0],entry[1]) for entry in rings(raster_path, geosom)] - GSD=gt[1] - volume=0 - med=statistics.median(entry[2] for entry in rings(raster_path, geosom)) - clip=clip_raster(raster_path, geosom, gt=None, nodata=-9999) - return ((clip-med)*GSD*GSD)[clip!=-9999.0].sum() - - except FileNotFoundError as e: - logger.warning(e) - - def find_all_files_matching(self, regex): directory = full_task_directory_path(self.id, self.project.id) return [os.path.join(directory, f) for f in os.listdir(directory) if diff --git a/app/models/vertex.py b/app/models/vertex.py deleted file mode 100644 index c3f669ef..00000000 --- a/app/models/vertex.py +++ /dev/null @@ -1,40 +0,0 @@ -from osgeo import ogr -import gdal -import struct -import json - -def convertJson(jsdata): - return json.dumps(jsdata) - -def rings(raster, geojson): - - src=gdal.Open(raster) - gtx=src.GetGeoTransform() - rbu=src.GetRasterBand(1) - gdal.UseExceptions() - - geo=convertJson(geojson) - - geojsom= ogr.Open(geo) - - layer1 = geojsom.GetLayer(0) - - vertices = [] - - for feat in layer1: - geom = feat.GetGeometryRef() - ring = geom.GetGeometryRef(0) - points = ring.GetPointCount() - - for p in range(points): - lon, lat, z = ring.GetPoint(p) - px = int((lon - gtx[0]) / gtx[1]) #x pixel - py = int((lat - gtx[3]) / gtx[5]) #y pixel - try: - structval=rbu.ReadRaster(px,py,1,1,buf_type=gdal.GDT_Float32) #Assumes 32 bit int- 'float' - intval = struct.unpack('f' , structval) #assume float - val=intval[0] - vertices.append((px, py, val)) - except: - val=-9999 #or some value to indicate a fail - return vertices diff --git a/app/plugins/functions.py b/app/plugins/functions.py index e49e5013..826c4840 100644 --- a/app/plugins/functions.py +++ b/app/plugins/functions.py @@ -1,6 +1,7 @@ import os import logging import importlib +import subprocess import django import json @@ -13,27 +14,48 @@ logger = logging.getLogger('app.logger') def register_plugins(): for plugin in get_active_plugins(): + + # Check for package.json in public directory + # and run npm install if needed + if plugin.path_exists("public/package.json") and not plugin.path_exists("public/node_modules"): + logger.info("Running npm install for {}".format(plugin.get_name())) + subprocess.call(['npm', 'install'], cwd=plugin.get_path("public")) + plugin.register() logger.info("Registered {}".format(plugin)) -def get_url_patterns(): +def get_app_url_patterns(): """ - @return the patterns to expose the /public directory of each plugin (if needed) + @return the patterns to expose the /public directory of each plugin (if needed) and + each mount point """ url_patterns = [] for plugin in get_active_plugins(): - for mount_point in plugin.mount_points(): + for mount_point in plugin.app_mount_points(): url_patterns.append(url('^plugins/{}/{}'.format(plugin.get_name(), mount_point.url), mount_point.view, *mount_point.args, **mount_point.kwargs)) - if plugin.has_public_path(): + if plugin.path_exists("public"): url_patterns.append(url('^plugins/{}/(.*)'.format(plugin.get_name()), django.views.static.serve, {'document_root': plugin.get_path("public")})) + return url_patterns + +def get_api_url_patterns(): + """ + @return the patterns to expose the plugin API mount points (if any) + """ + url_patterns = [] + for plugin in get_active_plugins(): + for mount_point in plugin.api_mount_points(): + url_patterns.append(url('^plugins/{}/{}'.format(plugin.get_name(), mount_point.url), + mount_point.view, + *mount_point.args, + **mount_point.kwargs)) return url_patterns @@ -85,6 +107,11 @@ def get_active_plugins(): return plugins +def get_plugin_by_name(name): + plugins = get_active_plugins() + res = list(filter(lambda p: p.get_name() == name, plugins)) + return res[0] if res else None + def get_plugins_path(): current_path = os.path.dirname(os.path.realpath(__file__)) diff --git a/app/plugins/mount_point.py b/app/plugins/mount_point.py index b1cd8fba..e6cf4f2b 100644 --- a/app/plugins/mount_point.py +++ b/app/plugins/mount_point.py @@ -5,7 +5,7 @@ class MountPoint: """ :param url: path to mount this view to, relative to plugins directory - :param view: Django view + :param view: Django/DjangoRestFramework view :param args: extra args to pass to url() call :param kwargs: extra kwargs to pass to url() call """ diff --git a/app/plugins/plugin_base.py b/app/plugins/plugin_base.py index d9b5a996..83244aa9 100644 --- a/app/plugins/plugin_base.py +++ b/app/plugins/plugin_base.py @@ -46,8 +46,8 @@ class PluginBase(ABC): """ return "plugins/{}/templates/{}".format(self.get_name(), path) - def has_public_path(self): - return os.path.isdir(self.get_path("public")) + def path_exists(self, path): + return os.path.exists(self.get_path(path)) def include_js_files(self): """ @@ -73,7 +73,7 @@ class PluginBase(ABC): """ return [] - def mount_points(self): + def app_mount_points(self): """ Should be overriden by plugins that want to connect custom Django views @@ -81,5 +81,13 @@ class PluginBase(ABC): """ return [] + def api_mount_points(self): + """ + Should be overriden by plugins that want to add + new API mount points + :return: [] of MountPoint objects + """ + return [] + def __str__(self): return "[{}]".format(self.get_module_name()) \ No newline at end of file diff --git a/app/static/app/js/components/Map.jsx b/app/static/app/js/components/Map.jsx index b90ec024..7c2d0352 100644 --- a/app/static/app/js/components/Map.jsx +++ b/app/static/app/js/components/Map.jsx @@ -4,8 +4,6 @@ import 'leaflet/dist/leaflet.css'; import Leaflet from 'leaflet'; import async from 'async'; -import 'leaflet-measure/dist/leaflet-measure.css'; -import 'leaflet-measure/dist/leaflet-measure'; import 'leaflet-draw/dist/leaflet.draw.css'; import 'leaflet-draw/dist/leaflet.draw'; @@ -105,6 +103,7 @@ class Map extends React.Component { // Associate metadata with this layer meta.name = info.name; + window.meta = meta; layer[Symbol.for("meta")] = meta; if (forceAddLayers || prevSelectedLayers.indexOf(layerId(layer)) !== -1){ @@ -185,8 +184,6 @@ class Map extends React.Component { map: this.map }); - measureControl.addTo(this.map); - const featureGroup = L.featureGroup(); featureGroup.addTo(this.map); @@ -217,12 +214,13 @@ class Map extends React.Component { this.map.on(L.Draw.Event.CREATED, function(e) { e.layer.feature = {geometry: {type: 'Polygon'} }; featureGroup.addLayer(e.layer); + const meta = window.meta; var paramList; $.ajax({ type: 'POST', async: false, - url: '/api/projects/4/tasks/7/volume', + url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, data: JSON.stringify(e.layer.toGeoJSON()), contentType: "application/json", success: function (msg) { @@ -238,11 +236,13 @@ class Map extends React.Component { this.map.on(L.Draw.Event.EDITED, function(e) { e.layers.eachLayer(function(layer) { + const meta = window.meta; + var paramList = null; $.ajax({ type: 'POST', async: false, - url: '/api/projects/1/tasks/4/volume', + url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, data: JSON.stringify(layer.toGeoJSON()), contentType: "application/json", success: function (msg) { diff --git a/app/tests/test_plugins.py b/app/tests/test_plugins.py index 349e8def..becb52c8 100644 --- a/app/tests/test_plugins.py +++ b/app/tests/test_plugins.py @@ -1,6 +1,9 @@ +import os + from django.test import Client from rest_framework import status +from app.plugins import get_plugin_by_name from .classes import BootTestCase class TestPlugins(BootTestCase): @@ -37,6 +40,11 @@ class TestPlugins(BootTestCase): # And our menu entry self.assertContains(res, '
  • Test
  • ', html=True) + # A node_modules directory has been created as a result of npm install + # because we have a package.json in the public director + test_plugin = get_plugin_by_name("test") + self.assertTrue(os.path.exists(test_plugin.get_path("public/node_modules"))) + # TODO: # test API endpoints # test python hooks diff --git a/app/urls.py b/app/urls.py index 248e80e3..2e86eeb6 100644 --- a/app/urls.py +++ b/app/urls.py @@ -4,7 +4,7 @@ from django.shortcuts import render_to_response from django.template import RequestContext from .views import app as app_views, public as public_views -from .plugins import get_url_patterns +from .plugins import get_app_url_patterns from app.boot import boot from webodm import settings @@ -30,7 +30,7 @@ urlpatterns = [ # TODO: is there a way to place plugins /public directories # into the static build directories and let nginx serve them? -urlpatterns += get_url_patterns() +urlpatterns += get_app_url_patterns() handler404 = app_views.handler404 handler500 = app_views.handler500 diff --git a/plugins/posm-gcpi/plugin.py b/plugins/posm-gcpi/plugin.py index 9d713846..b09cd720 100644 --- a/plugins/posm-gcpi/plugin.py +++ b/plugins/posm-gcpi/plugin.py @@ -6,7 +6,7 @@ class Plugin(PluginBase): def main_menu(self): return [Menu("GCP Interface", self.public_url(""), "fa fa-map-marker fa-fw")] - def mount_points(self): + def app_mount_points(self): return [ MountPoint('$', lambda request: render(request, self.template_path("app.html"), {'title': 'GCP Editor'})) ] diff --git a/plugins/test/plugin.py b/plugins/test/plugin.py index 6fcb6ef5..68dc2589 100644 --- a/plugins/test/plugin.py +++ b/plugins/test/plugin.py @@ -12,7 +12,7 @@ class Plugin(PluginBase): def include_css_files(self): return ['test.css'] - def mount_points(self): + def app_mount_points(self): return [ MountPoint('/app_mountpoint/$', lambda request: render(request, self.template_path("app.html"), {'title': 'Test'})) ] diff --git a/plugins/test/public/package.json b/plugins/test/public/package.json new file mode 100644 index 00000000..46cba507 --- /dev/null +++ b/plugins/test/public/package.json @@ -0,0 +1,14 @@ +{ + "name": "public", + "version": "1.0.0", + "description": "", + "main": "main.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "pad-left": "^2.1.0" + } +} diff --git a/plugins/volume/api.py b/plugins/volume/api.py new file mode 100644 index 00000000..e5de190c --- /dev/null +++ b/plugins/volume/api.py @@ -0,0 +1,21 @@ +from rest_framework import serializers +from rest_framework import status +from rest_framework.response import Response + +from app.api.tasks import TaskNestedView + + +class GeoJSONSerializer(serializers.Serializer): + geometry = serializers.JSONField(help_text="Polygon contour defining the volume area to compute") + + +class TaskVolume(TaskNestedView): + def post(self, request, pk=None): + task = self.get_and_check_task(request, pk) + serializer = GeoJSONSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + #result=task.get_volume(serializer.geometry) + return Response(serializer.geometry, status=status.HTTP_200_OK) + + + diff --git a/plugins/volume/disabled b/plugins/volume/disabled deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/volume/manifest.json b/plugins/volume/manifest.json index c4d68fe4..9ffd9d7e 100644 --- a/plugins/volume/manifest.json +++ b/plugins/volume/manifest.json @@ -3,7 +3,7 @@ "webodmMinVersion": "0.5.0", "description": "A plugin to compute volume measurements from a DSM", "version": "0.1.0", - "author": "Piero Toffanin", + "author": "Abdelkoddouss Izem, Piero Toffanin", "email": "pt@masseranolabs.com", "repository": "https://github.com/OpenDroneMap/WebODM", "tags": ["volume", "measurements"], diff --git a/plugins/volume/plugin.py b/plugins/volume/plugin.py index d9513cd1..6ffad223 100644 --- a/plugins/volume/plugin.py +++ b/plugins/volume/plugin.py @@ -1,5 +1,30 @@ +from app.plugins import MountPoint from app.plugins import PluginBase +from .api import TaskVolume class Plugin(PluginBase): - def include_js_files(self): - return ['hello.js'] \ No newline at end of file + def api_mount_points(self): + return [ + MountPoint('task/(?P[^/.]+)/calculate$', TaskVolume.as_view()) + ] + + + # def get_volume(self, geojson): + # try: + # raster_path= self.assets_path("odm_dem", "dsm.tif") + # raster=gdal.Open(raster_path) + # gt=raster.GetGeoTransform() + # rb=raster.GetRasterBand(1) + # gdal.UseExceptions() + # geosom = reprojson(geojson, raster) + # coords=[(entry[0],entry[1]) for entry in rings(raster_path, geosom)] + # GSD=gt[1] + # volume=0 + # print(rings(raster_path, geosom)) + # print(GSD) + # med=statistics.median(entry[2] for entry in rings(raster_path, geosom)) + # clip=clip_raster(raster_path, geosom, gt=None, nodata=-9999) + # return ((clip-med)*GSD*GSD)[clip!=-9999.0].sum() + # + # except FileNotFoundError as e: + # logger.warning(e) \ No newline at end of file diff --git a/plugins/volume/public/hello.js b/plugins/volume/public/hello.js deleted file mode 100644 index cf511f6a..00000000 --- a/plugins/volume/public/hello.js +++ /dev/null @@ -1,6 +0,0 @@ -PluginsAPI.Map.willAddControls(function(options){ - console.log("GOT: ", options); -}); -PluginsAPI.Map.didAddControls(function(options){ - console.log("GOT2: ", options); -}); \ No newline at end of file diff --git a/plugins/volume/public/package.json b/plugins/volume/public/package.json new file mode 100644 index 00000000..fff69c1b --- /dev/null +++ b/plugins/volume/public/package.json @@ -0,0 +1,14 @@ +{ + "name": "volume", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "leaflet-draw": "^1.0.2" + } +} From 9e07e297c6428b16a3194e161d1359107c8e0b93 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 19 Mar 2018 14:21:01 -0400 Subject: [PATCH 10/18] Changed default presets --- app/boot.py | 42 ++++++++++++++++++++++++++-------------- app/plugins/functions.py | 2 ++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/app/boot.py b/app/boot.py index 0e0a4dd7..9a929df3 100644 --- a/app/boot.py +++ b/app/boot.py @@ -3,7 +3,7 @@ import os import kombu from django.contrib.auth.models import Permission from django.contrib.auth.models import User, Group -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.core.files import File from django.db.utils import ProgrammingError from guardian.shortcuts import assign_perm @@ -60,18 +60,7 @@ def boot(): # Add permission to view processing nodes default_group.permissions.add(Permission.objects.get(codename="view_processingnode")) - # Add default presets - Preset.objects.get_or_create(name='DSM + DTM', system=True, - options=[{'name': 'dsm', 'value': True}, {'name': 'dtm', 'value': True}, {'name': 'mesh-octree-depth', 'value': 11}]) - Preset.objects.get_or_create(name='Fast Orthophoto', system=True, - options=[{'name': 'fast-orthophoto', 'value': True}]) - Preset.objects.get_or_create(name='High Quality', system=True, - options=[{'name': 'dsm', 'value': True}, - {'name': 'mesh-octree-depth', 'value': "12"}, - {'name': 'dem-resolution', 'value': "0.04"}, - {'name': 'orthophoto-resolution', 'value': "40"}, - ]) - Preset.objects.get_or_create(name='Default', system=True, options=[{'name': 'dsm', 'value': True}, {'name': 'mesh-octree-depth', 'value': 11}]) + add_default_presets() # Add settings default_theme, created = Theme.objects.get_or_create(name='Default') @@ -101,4 +90,29 @@ def boot(): except ProgrammingError: - logger.warning("Could not touch the database. If running a migration, this is expected.") \ No newline at end of file + logger.warning("Could not touch the database. If running a migration, this is expected.") + + +def add_default_presets(): + try: + Preset.objects.update_or_create(name='DSM + DTM', system=True, + defaults={ + 'options': [{'name': 'dsm', 'value': True}, {'name': 'dtm', 'value': True}, + {'name': 'mesh-octree-depth', 'value': 6}]}) + Preset.objects.update_or_create(name='Fast Orthophoto', system=True, + defaults={'options': [{'name': 'fast-orthophoto', 'value': True}]}) + Preset.objects.update_or_create(name='High Quality', system=True, + defaults={'options': [{'name': 'dsm', 'value': True}, + {'name': 'mesh-octree-depth', 'value': "12"}, + {'name': 'dem-resolution', 'value': "0.04"}, + {'name': 'orthophoto-resolution', 'value': "40"}, + ]}) + Preset.objects.update_or_create(name='Default', system=True, + defaults={'options': [{'name': 'dsm', 'value': True}, + {'name': 'mesh-octree-depth', 'value': 6}]}) + except MultipleObjectsReturned: + # Mostly to handle a legacy code problem where + # multiple system presets with the same name were + # created if we changed the options + Preset.objects.filter(system=True).delete() + add_default_presets() diff --git a/app/plugins/functions.py b/app/plugins/functions.py index 826c4840..cc86827b 100644 --- a/app/plugins/functions.py +++ b/app/plugins/functions.py @@ -59,6 +59,7 @@ def get_api_url_patterns(): return url_patterns + plugins = None def get_active_plugins(): # Cache plugins search @@ -107,6 +108,7 @@ def get_active_plugins(): return plugins + def get_plugin_by_name(name): plugins = get_active_plugins() res = list(filter(lambda p: p.get_name() == name, plugins)) From 149d7b67294a856256e1d7fb8bbe92e629e57894 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 19 Mar 2018 16:46:28 -0400 Subject: [PATCH 11/18] ES6 plugins support --- app/plugins/functions.py | 5 ++ .../app/js/classes/plugins/ApiFactory.js | 1 + app/static/app/js/components/Map.jsx | 78 ------------------ plugins/volume/plugin.py | 3 + plugins/volume/public/app.jsx | 5 ++ plugins/volume/public/main.js | 79 +++++++++++++++++++ plugins/volume/public/webpack.config.js | 73 +++++++++++++++++ 7 files changed, 166 insertions(+), 78 deletions(-) create mode 100644 plugins/volume/public/app.jsx create mode 100644 plugins/volume/public/main.js create mode 100644 plugins/volume/public/webpack.config.js diff --git a/app/plugins/functions.py b/app/plugins/functions.py index cc86827b..28e2ab43 100644 --- a/app/plugins/functions.py +++ b/app/plugins/functions.py @@ -21,6 +21,11 @@ def register_plugins(): logger.info("Running npm install for {}".format(plugin.get_name())) subprocess.call(['npm', 'install'], cwd=plugin.get_path("public")) + # Check for webpack.config.js (if we need to build it) + if plugin.path_exists("public/webpack.config.js") and not plugin.path_exists("public/build"): + logger.info("Running webpack for {}".format(plugin.get_name())) + subprocess.call(['webpack'], cwd=plugin.get_path("public")) + plugin.register() logger.info("Registered {}".format(plugin)) diff --git a/app/static/app/js/classes/plugins/ApiFactory.js b/app/static/app/js/classes/plugins/ApiFactory.js index 03205c68..e659dd2d 100644 --- a/app/static/app/js/classes/plugins/ApiFactory.js +++ b/app/static/app/js/classes/plugins/ApiFactory.js @@ -24,6 +24,7 @@ export default class ApiFactory{ this.events.addListener(`${api.namespace}::${eventName}`, (...args) => { Promise.all(callbackOrDeps.map(dep => SystemJS.import(dep))) .then((...deps) => { + console.log(eventName, deps); callbackOrUndef(...(Array.from(args).concat(...deps))); }); }); diff --git a/app/static/app/js/components/Map.jsx b/app/static/app/js/components/Map.jsx index 7c2d0352..7b70110d 100644 --- a/app/static/app/js/components/Map.jsx +++ b/app/static/app/js/components/Map.jsx @@ -4,9 +4,6 @@ import 'leaflet/dist/leaflet.css'; import Leaflet from 'leaflet'; import async from 'async'; -import 'leaflet-draw/dist/leaflet.draw.css'; -import 'leaflet-draw/dist/leaflet.draw'; - import '../vendor/leaflet/L.Control.MousePosition.css'; import '../vendor/leaflet/L.Control.MousePosition'; import '../vendor/leaflet/Leaflet.Autolayers/css/leaflet.auto-layers.css'; @@ -103,7 +100,6 @@ class Map extends React.Component { // Associate metadata with this layer meta.name = info.name; - window.meta = meta; layer[Symbol.for("meta")] = meta; if (forceAddLayers || prevSelectedLayers.indexOf(layerId(layer)) !== -1){ @@ -184,80 +180,6 @@ class Map extends React.Component { map: this.map }); - const featureGroup = L.featureGroup(); - featureGroup.addTo(this.map); - - new L.Control.Draw({ - draw: { - polygon: { - allowIntersection: false, // Restricts shapes to simple polygons - shapeOptions: { - color: '#707070' - } - }, - rectangle: false, - circle: false, - circlemarker: false, - marker: false - }, - edit: { - featureGroup: featureGroup, - edit: { - selectedPathOptions: { - maintainColor: true, - dashArray: '10, 10' - } - } - } - }).addTo(this.map); - - this.map.on(L.Draw.Event.CREATED, function(e) { - e.layer.feature = {geometry: {type: 'Polygon'} }; - featureGroup.addLayer(e.layer); - const meta = window.meta; - - var paramList; - $.ajax({ - type: 'POST', - async: false, - url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, - data: JSON.stringify(e.layer.toGeoJSON()), - contentType: "application/json", - success: function (msg) { - paramList = msg; - }, - error: function (jqXHR, textStatus, errorThrown) { - alert("get session failed " + errorThrown); - } - }); - - e.layer.bindPopup('Volume: ' + paramList.toFixed(2) + 'Mètres Cubes (m3)'); - }); - - this.map.on(L.Draw.Event.EDITED, function(e) { - e.layers.eachLayer(function(layer) { - const meta = window.meta; - - var paramList = null; - $.ajax({ - type: 'POST', - async: false, - url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, - data: JSON.stringify(layer.toGeoJSON()), - contentType: "application/json", - success: function (msg) { - paramList = msg; - }, - error: function (jqXHR, textStatus, errorThrown) { - alert("get session failed " + errorThrown); - } - }); - - layer.setPopupContent('Volume: ' + paramList.toFixed(2) + 'Mètres Cubes (m3)'); - - }); - }); - Leaflet.control.scale({ maxWidth: 250, }).addTo(this.map); diff --git a/plugins/volume/plugin.py b/plugins/volume/plugin.py index 6ffad223..e3054e0e 100644 --- a/plugins/volume/plugin.py +++ b/plugins/volume/plugin.py @@ -3,6 +3,9 @@ from app.plugins import PluginBase from .api import TaskVolume class Plugin(PluginBase): + def include_js_files(self): + return ['main.js'] + def api_mount_points(self): return [ MountPoint('task/(?P[^/.]+)/calculate$', TaskVolume.as_view()) diff --git a/plugins/volume/public/app.jsx b/plugins/volume/public/app.jsx new file mode 100644 index 00000000..04df8a44 --- /dev/null +++ b/plugins/volume/public/app.jsx @@ -0,0 +1,5 @@ +module.exports = class Hello{ + constructor(){ + console.log("INSTANTIATED"); + } +} \ No newline at end of file diff --git a/plugins/volume/public/main.js b/plugins/volume/public/main.js new file mode 100644 index 00000000..5e865db0 --- /dev/null +++ b/plugins/volume/public/main.js @@ -0,0 +1,79 @@ +PluginsAPI.Map.willAddControls([ + 'volume/build/app.js' + ], function(options, App){ + new App(); +}); + +/* + const featureGroup = L.featureGroup(); + featureGroup.addTo(this.map); + + new L.Control.Draw({ + draw: { + polygon: { + allowIntersection: false, // Restricts shapes to simple polygons + shapeOptions: { + color: '#707070' + } + }, + rectangle: false, + circle: false, + circlemarker: false, + marker: false + }, + edit: { + featureGroup: featureGroup, + edit: { + selectedPathOptions: { + maintainColor: true, + dashArray: '10, 10' + } + } + } + }).addTo(this.map); + + this.map.on(L.Draw.Event.CREATED, function(e) { + e.layer.feature = {geometry: {type: 'Polygon'} }; + featureGroup.addLayer(e.layer); + + var paramList; + $.ajax({ + type: 'POST', + async: false, + url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, + data: JSON.stringify(e.layer.toGeoJSON()), + contentType: "application/json", + success: function (msg) { + paramList = msg; + }, + error: function (jqXHR, textStatus, errorThrown) { + alert("get session failed " + errorThrown); + } + }); + + e.layer.bindPopup('Volume: ' + paramList.toFixed(2) + 'Mètres Cubes (m3)'); + }); + + this.map.on(L.Draw.Event.EDITED, function(e) { + e.layers.eachLayer(function(layer) { + const meta = window.meta; + + var paramList = null; + $.ajax({ + type: 'POST', + async: false, + url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, + data: JSON.stringify(layer.toGeoJSON()), + contentType: "application/json", + success: function (msg) { + paramList = msg; + }, + error: function (jqXHR, textStatus, errorThrown) { + alert("get session failed " + errorThrown); + } + }); + + layer.setPopupContent('Volume: ' + paramList.toFixed(2) + 'Mètres Cubes (m3)'); + + }); + });*/ \ No newline at end of file diff --git a/plugins/volume/public/webpack.config.js b/plugins/volume/public/webpack.config.js new file mode 100644 index 00000000..0b875a00 --- /dev/null +++ b/plugins/volume/public/webpack.config.js @@ -0,0 +1,73 @@ +// Magic to include node_modules of root WebODM's directory +process.env.NODE_PATH = "../../../node_modules"; +require("module").Module._initPaths(); + +let path = require("path"); +let webpack = require('webpack'); +let ExtractTextPlugin = require('extract-text-webpack-plugin'); +let LiveReloadPlugin = require('webpack-livereload-plugin'); + +module.exports = { + context: __dirname, + + entry: { + app: ['./app.jsx'] + }, + + output: { + path: path.join(__dirname, './build'), + filename: "[name].js", + libraryTarget: "amd" + }, + + plugins: [ + new LiveReloadPlugin(), + new ExtractTextPlugin('css/[name].css', { + allChunks: true + }) + ], + + module: { + rules: [ + { + test: /\.jsx?$/, + exclude: /(node_modules|bower_components)/, + use: [ + { + loader: 'babel-loader', + query: { + "plugins": [ + 'syntax-class-properties', + 'transform-class-properties' + ], + presets: ['es2015', 'react'] + } + } + ], + }, + { + test: /\.s?css$/, + use: ExtractTextPlugin.extract({ + use: 'css-loader!sass-loader' + }) + }, + { + test: /\.(png|jpg|jpeg|svg)/, + loader: "url-loader?limit=100000" + } + ] + }, + + resolve: { + modules: ['node_modules', 'bower_components'], + extensions: ['.js', '.jsx'] + }, + + externals: { + // require("jquery") is external and available + // on the global let jQuery + "jquery": "jQuery", + "SystemJS": "SystemJS", + "PluginsAPI": "PluginsAPI" + } +} \ No newline at end of file From b2daeae2f8033bd6dc0f44a5ec623f4ebbb00370 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 27 Mar 2018 14:35:16 -0400 Subject: [PATCH 12/18] Ability to load globals via JS --- app/static/app/js/classes/plugins/API.js | 9 +- .../app/js/classes/plugins/ApiFactory.js | 1 - app/static/app/js/components/Map.jsx | 3 +- app/static/app/js/vendor/globals-loader.js | 15 +++ plugins/volume/public/app.jsx | 93 ++++++++++++++++++- plugins/volume/public/main.js | 80 +--------------- plugins/volume/public/webpack.config.js | 5 +- 7 files changed, 120 insertions(+), 86 deletions(-) create mode 100644 app/static/app/js/vendor/globals-loader.js diff --git a/app/static/app/js/classes/plugins/API.js b/app/static/app/js/classes/plugins/API.js index c604cc82..dc81e1fb 100644 --- a/app/static/app/js/classes/plugins/API.js +++ b/app/static/app/js/classes/plugins/API.js @@ -11,10 +11,15 @@ if (!window.PluginsAPI){ SystemJS.config({ baseURL: '/plugins', map: { - css: '/static/app/js/vendor/css.js' + 'css': '/static/app/js/vendor/css.js', + 'globals-loader': '/static/app/js/vendor/globals-loader.js' }, meta: { - '*.css': { loader: 'css' } + '*.css': { loader: 'css' }, + + // Globals always available in the window object + 'jQuery': { loader: 'globals-loader', exports: '$' }, + 'leaflet': { loader: 'globals-loader', exports: 'L' } } }); diff --git a/app/static/app/js/classes/plugins/ApiFactory.js b/app/static/app/js/classes/plugins/ApiFactory.js index e659dd2d..03205c68 100644 --- a/app/static/app/js/classes/plugins/ApiFactory.js +++ b/app/static/app/js/classes/plugins/ApiFactory.js @@ -24,7 +24,6 @@ export default class ApiFactory{ this.events.addListener(`${api.namespace}::${eventName}`, (...args) => { Promise.all(callbackOrDeps.map(dep => SystemJS.import(dep))) .then((...deps) => { - console.log(eventName, deps); callbackOrUndef(...(Array.from(args).concat(...deps))); }); }); diff --git a/app/static/app/js/components/Map.jsx b/app/static/app/js/components/Map.jsx index 7b70110d..9d96478f 100644 --- a/app/static/app/js/components/Map.jsx +++ b/app/static/app/js/components/Map.jsx @@ -3,7 +3,6 @@ import '../css/Map.scss'; import 'leaflet/dist/leaflet.css'; import Leaflet from 'leaflet'; import async from 'async'; - import '../vendor/leaflet/L.Control.MousePosition.css'; import '../vendor/leaflet/L.Control.MousePosition'; import '../vendor/leaflet/Leaflet.Autolayers/css/leaflet.auto-layers.css'; @@ -137,7 +136,7 @@ class Map extends React.Component { 3D `; - //layer.bindPopup(popup); + layer.bindPopup(popup); $('#layerOpacity', popup).on('change input', function() { layer.setOpacity($('#layerOpacity', popup).val()); diff --git a/app/static/app/js/vendor/globals-loader.js b/app/static/app/js/vendor/globals-loader.js new file mode 100644 index 00000000..7bf59806 --- /dev/null +++ b/app/static/app/js/vendor/globals-loader.js @@ -0,0 +1,15 @@ +/* + SystemJS Globals loader plugin + Piero Toffanin 2018 +*/ + +// this code simply allows loading of global modules +// that are already defined in the window object +exports.fetch = function(load) { + var moduleName = load.name.split("/").pop(); + return moduleName; +} + +exports.instantiate = function(load){ + return window[load.source] || window[load.metadata.exports]; +} \ No newline at end of file diff --git a/plugins/volume/public/app.jsx b/plugins/volume/public/app.jsx index 04df8a44..d9646c0c 100644 --- a/plugins/volume/public/app.jsx +++ b/plugins/volume/public/app.jsx @@ -1,5 +1,92 @@ -module.exports = class Hello{ - constructor(){ - console.log("INSTANTIATED"); +import 'leaflet-draw'; +import 'leaflet-draw/dist/leaflet.draw.css'; +import $ from 'jquery'; +import L from 'leaflet'; + +module.exports = class App{ + constructor(map){ + this.map = map; + } + + setupVolumeControls(){ + const { map } = this; + + const editableLayers = new L.FeatureGroup(); + map.addLayer(editableLayers); + + const options = { + position: 'topright', + draw: { + toolbar: { + buttons: { + polygon: 'Draw an awesome polygon' + } + }, + polyline: false, + polygon: { + showArea: true, + showLength: true, + + allowIntersection: false, // Restricts shapes to simple polygons + drawError: { + color: '#e1e100', // Color the shape will turn when intersects + message: 'Oh snap! Area cannot have intersections!' // Message that will show when intersect + }, + shapeOptions: { + // color: '#bada55' + } + }, + circle: false, + rectangle: false, + marker: false, + circlemarker: false + }, + edit: { + featureGroup: editableLayers, + // remove: false + edit: { + selectedPathOptions: { + maintainColor: true, + dashArray: '10, 10' + } + } + } + }; + + const drawControl = new L.Control.Draw(options); + map.addControl(drawControl); + + // Is there a better way? + $(drawControl._container) + .find('a.leaflet-draw-draw-polygon') + .attr('title', 'Measure Volume'); + + map.on(L.Draw.Event.CREATED, (e) => { + const { layer } = e; + layer.feature = {geometry: {type: 'Polygon'} }; + + var paramList; + // $.ajax({ + // type: 'POST', + // async: false, + // url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, + // data: JSON.stringify(e.layer.toGeoJSON()), + // contentType: "application/json", + // success: function (msg) { + // paramList = msg; + // }, + // error: function (jqXHR, textStatus, errorThrown) { + // alert("get session failed " + errorThrown); + // } + // }); + + e.layer.bindPopup('Volume: test'); + + editableLayers.addLayer(layer); + }); + + map.on(L.Draw.Event.EDITED, (e) => { + console.log("EDITED ", e); + }); } } \ No newline at end of file diff --git a/plugins/volume/public/main.js b/plugins/volume/public/main.js index 5e865db0..7e09ce2f 100644 --- a/plugins/volume/public/main.js +++ b/plugins/volume/public/main.js @@ -1,79 +1,7 @@ PluginsAPI.Map.willAddControls([ - 'volume/build/app.js' + 'volume/build/app.js', + 'volume/build/app.css' ], function(options, App){ - new App(); + const app = new App(options.map); + app.setupVolumeControls(); }); - -/* - const featureGroup = L.featureGroup(); - featureGroup.addTo(this.map); - - new L.Control.Draw({ - draw: { - polygon: { - allowIntersection: false, // Restricts shapes to simple polygons - shapeOptions: { - color: '#707070' - } - }, - rectangle: false, - circle: false, - circlemarker: false, - marker: false - }, - edit: { - featureGroup: featureGroup, - edit: { - selectedPathOptions: { - maintainColor: true, - dashArray: '10, 10' - } - } - } - }).addTo(this.map); - - this.map.on(L.Draw.Event.CREATED, function(e) { - e.layer.feature = {geometry: {type: 'Polygon'} }; - featureGroup.addLayer(e.layer); - - var paramList; - $.ajax({ - type: 'POST', - async: false, - url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, - data: JSON.stringify(e.layer.toGeoJSON()), - contentType: "application/json", - success: function (msg) { - paramList = msg; - }, - error: function (jqXHR, textStatus, errorThrown) { - alert("get session failed " + errorThrown); - } - }); - - e.layer.bindPopup('Volume: ' + paramList.toFixed(2) + 'Mètres Cubes (m3)'); - }); - - this.map.on(L.Draw.Event.EDITED, function(e) { - e.layers.eachLayer(function(layer) { - const meta = window.meta; - - var paramList = null; - $.ajax({ - type: 'POST', - async: false, - url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, - data: JSON.stringify(layer.toGeoJSON()), - contentType: "application/json", - success: function (msg) { - paramList = msg; - }, - error: function (jqXHR, textStatus, errorThrown) { - alert("get session failed " + errorThrown); - } - }); - - layer.setPopupContent('Volume: ' + paramList.toFixed(2) + 'Mètres Cubes (m3)'); - - }); - });*/ \ No newline at end of file diff --git a/plugins/volume/public/webpack.config.js b/plugins/volume/public/webpack.config.js index 0b875a00..a5466d92 100644 --- a/plugins/volume/public/webpack.config.js +++ b/plugins/volume/public/webpack.config.js @@ -22,7 +22,7 @@ module.exports = { plugins: [ new LiveReloadPlugin(), - new ExtractTextPlugin('css/[name].css', { + new ExtractTextPlugin('[name].css', { allChunks: true }) ], @@ -68,6 +68,7 @@ module.exports = { // on the global let jQuery "jquery": "jQuery", "SystemJS": "SystemJS", - "PluginsAPI": "PluginsAPI" + "PluginsAPI": "PluginsAPI", + "leaflet": "leaflet" } } \ No newline at end of file From e3c36b15261d663e287164f41656c662cf99a1e5 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 29 Mar 2018 14:25:20 -0400 Subject: [PATCH 13/18] Modified measure plugin to include volume calculation. Started working on GRASS engine --- app/plugins/grass_engine.py | 98 +++++++++++++++++ app/static/app/css/theme.scss | 2 +- app/static/app/js/classes/plugins/API.js | 4 +- .../app/js/classes/plugins/ApiFactory.js | 7 +- app/static/app/js/classes/plugins/Map.js | 1 + app/static/app/js/css/Map.scss | 7 ++ app/static/app/js/main.jsx | 5 +- package.json | 2 +- plugins/measure/api.py | 39 +++++++ plugins/measure/calc_volume.grass | 20 ++++ plugins/measure/manifest.json | 10 +- plugins/measure/plugin.py | 30 +++++- plugins/measure/public/MeasurePopup.jsx | 102 ++++++++++++++++++ plugins/measure/public/MeasurePopup.scss | 5 + plugins/measure/public/app.jsx | 41 +++++++ plugins/measure/public/app.scss | 19 ++++ plugins/measure/public/dist | 1 + plugins/measure/public/images/cancel.png | Bin 397 -> 0 bytes plugins/measure/public/images/cancel_@2X.png | Bin 762 -> 0 bytes plugins/measure/public/images/check.png | Bin 387 -> 0 bytes plugins/measure/public/images/check_@2X.png | Bin 692 -> 0 bytes plugins/measure/public/images/focus.png | Bin 326 -> 0 bytes plugins/measure/public/images/focus_@2X.png | Bin 462 -> 0 bytes plugins/measure/public/images/rulers.png | Bin 192 -> 0 bytes plugins/measure/public/images/rulers_@2X.png | Bin 277 -> 0 bytes plugins/measure/public/images/start.png | Bin 491 -> 0 bytes plugins/measure/public/images/start_@2X.png | Bin 1003 -> 0 bytes plugins/measure/public/images/trash.png | Bin 279 -> 0 bytes plugins/measure/public/images/trash_@2X.png | Bin 460 -> 0 bytes plugins/measure/public/leaflet-measure.css | 1 - plugins/measure/public/leaflet-measure.min.js | 4 - plugins/measure/public/main.js | 13 +-- .../{volume => measure}/public/package.json | 6 +- .../public/webpack.config.js | 4 +- plugins/volume/__init__.py | 1 - plugins/volume/api.py | 21 ---- plugins/volume/manifest.json | 13 --- plugins/volume/plugin.py | 33 ------ plugins/volume/public/app.jsx | 92 ---------------- plugins/volume/public/main.js | 7 -- webpack.config.js | 3 +- 41 files changed, 393 insertions(+), 198 deletions(-) create mode 100644 app/plugins/grass_engine.py create mode 100644 plugins/measure/api.py create mode 100755 plugins/measure/calc_volume.grass create mode 100644 plugins/measure/public/MeasurePopup.jsx create mode 100644 plugins/measure/public/MeasurePopup.scss create mode 100644 plugins/measure/public/app.jsx create mode 100644 plugins/measure/public/app.scss create mode 120000 plugins/measure/public/dist delete mode 100644 plugins/measure/public/images/cancel.png delete mode 100644 plugins/measure/public/images/cancel_@2X.png delete mode 100644 plugins/measure/public/images/check.png delete mode 100644 plugins/measure/public/images/check_@2X.png delete mode 100644 plugins/measure/public/images/focus.png delete mode 100644 plugins/measure/public/images/focus_@2X.png delete mode 100644 plugins/measure/public/images/rulers.png delete mode 100644 plugins/measure/public/images/rulers_@2X.png delete mode 100644 plugins/measure/public/images/start.png delete mode 100644 plugins/measure/public/images/start_@2X.png delete mode 100644 plugins/measure/public/images/trash.png delete mode 100644 plugins/measure/public/images/trash_@2X.png delete mode 100644 plugins/measure/public/leaflet-measure.css delete mode 100644 plugins/measure/public/leaflet-measure.min.js rename plugins/{volume => measure}/public/package.json (71%) rename plugins/{volume => measure}/public/webpack.config.js (95%) delete mode 100644 plugins/volume/__init__.py delete mode 100644 plugins/volume/api.py delete mode 100644 plugins/volume/manifest.json delete mode 100644 plugins/volume/plugin.py delete mode 100644 plugins/volume/public/app.jsx delete mode 100644 plugins/volume/public/main.js diff --git a/app/plugins/grass_engine.py b/app/plugins/grass_engine.py new file mode 100644 index 00000000..94e6b6c8 --- /dev/null +++ b/app/plugins/grass_engine.py @@ -0,0 +1,98 @@ +import logging +import shutil +import tempfile +import subprocess +import os +from string import Template + +logger = logging.getLogger('app.logger') + +class GrassEngine: + def __init__(self): + self.grass_binary = shutil.which('grass7') or \ + shutil.which('grass72') or \ + shutil.which('grass74') + + if self.grass_binary is None: + logger.warning("Could not find a GRASS 7 executable. GRASS scripts will not work.") + else: + logger.info("Initializing GRASS engine using {}".format(self.grass_binary)) + + def create_context(self): + if self.grass_binary is None: raise GrassEngineException("GRASS engine is unavailable") + return GrassContext(self.grass_binary) + + + +class GrassContext: + def __init__(self, grass_binary): + self.grass_binary = grass_binary + self.cwd = tempfile.mkdtemp('_webodm_grass') + self.template_args = {} + self.location = None + + def add_file(self, filename, source, use_as_location=False): + param = os.path.splitext(filename)[0] # filename without extension + + dst_path = os.path.abspath(os.path.join(self.cwd, filename)) + with open(dst_path) as f: + f.write(source) + self.template_args[param] = dst_path + + if use_as_location: + self.set_location(self.template_args[param]) + + return dst_path + + def add_param(self, param, value): + self.template_args[param] = value + + def set_location(self, location): + """ + :param location: either a "epsg:XXXXX" string or a path to a geospatial file defining the location + """ + if not location.startsWith('epsg:'): + location = os.path.abspath(location) + self.location = location + + def execute(self, script): + """ + :param script: path to .grass script + :return: script output + """ + if self.location is None: raise GrassEngineException("Location is not set") + + script = os.path.abspath(script) + + # Create grass script via template substitution + try: + with open(script) as f: + script_content = f.read() + except FileNotFoundError: + raise GrassEngineException("Script does not exist: {}".format(script)) + + tmpl = Template(script_content) + + # Write script to disk + with open(os.path.join(self.cwd, 'script.sh')) as f: + f.write(tmpl.substitute(self.template_args)) + + # Execute it + p = subprocess.Popen([self.grass_binary, '-c', self.location, 'location', '--exec', 'sh', 'script.sh'], + cwd=self.cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = p.communicate() + + if p.returncode == 0: + return out + else: + raise GrassEngineException("Could not execute GRASS script {} from {}: {}".format(script, self.cwd, err)) + + def __del__(self): + # Cleanup + if os.path.exists(self.cwd): + shutil.rmtree(self.cwd) + +class GrassEngineException(Exception): + pass + +grass = GrassEngine() \ No newline at end of file diff --git a/app/static/app/css/theme.scss b/app/static/app/css/theme.scss index b9c7b148..ada5b17d 100644 --- a/app/static/app/css/theme.scss +++ b/app/static/app/css/theme.scss @@ -200,7 +200,7 @@ pre.prettyprint, } /* Failed */ -.task-list-item .status-label.error{ +.task-list-item .status-label.error, .theme-background-failed{ background-color: theme("failed"); } diff --git a/app/static/app/js/classes/plugins/API.js b/app/static/app/js/classes/plugins/API.js index dc81e1fb..d469304d 100644 --- a/app/static/app/js/classes/plugins/API.js +++ b/app/static/app/js/classes/plugins/API.js @@ -19,7 +19,9 @@ if (!window.PluginsAPI){ // Globals always available in the window object 'jQuery': { loader: 'globals-loader', exports: '$' }, - 'leaflet': { loader: 'globals-loader', exports: 'L' } + 'leaflet': { loader: 'globals-loader', exports: 'L' }, + 'ReactDOM': { loader: 'globals-loader', exports: 'ReactDOM' }, + 'React': { loader: 'globals-loader', exports: 'React' } } }); diff --git a/app/static/app/js/classes/plugins/ApiFactory.js b/app/static/app/js/classes/plugins/ApiFactory.js index 03205c68..239b49fe 100644 --- a/app/static/app/js/classes/plugins/ApiFactory.js +++ b/app/static/app/js/classes/plugins/ApiFactory.js @@ -41,11 +41,16 @@ export default class ApiFactory{ }; } - const obj = {}; + let obj = {}; api.endpoints.forEach(endpoint => { if (!Array.isArray(endpoint)) endpoint = [endpoint]; addEndpoint(obj, ...endpoint); }); + + if (api.helpers){ + obj = Object.assign(obj, api.helpers); + } + return obj; } diff --git a/app/static/app/js/classes/plugins/Map.js b/app/static/app/js/classes/plugins/Map.js index 368c861d..bb32622d 100644 --- a/app/static/app/js/classes/plugins/Map.js +++ b/app/static/app/js/classes/plugins/Map.js @@ -1,4 +1,5 @@ import Utils from '../Utils'; +import L from 'leaflet'; const { assert } = Utils; diff --git a/app/static/app/js/css/Map.scss b/app/static/app/js/css/Map.scss index ae2b1758..56fcc89c 100644 --- a/app/static/app/js/css/Map.scss +++ b/app/static/app/js/css/Map.scss @@ -16,10 +16,17 @@ } } + .leaflet-right .leaflet-control, .leaflet-control-measure.leaflet-control{ margin-right: 12px; } + .leaflet-touch .leaflet-control-layers-toggle{ + width: 30px; + height: 30px; + background-size: 20px; + } + .popup-opacity-slider{ margin-bottom: 6px; } diff --git a/app/static/app/js/main.jsx b/app/static/app/js/main.jsx index a867a094..29d442bf 100644 --- a/app/static/app/js/main.jsx +++ b/app/static/app/js/main.jsx @@ -1,10 +1,13 @@ import '../css/main.scss'; import './django/csrf'; import ReactDOM from 'react-dom'; +import React from 'react'; import PluginsAPI from './classes/plugins/API'; // Main is always executed first in the page -// We share the ReactDOM object to avoid having to include it +// We share some objects to avoid having to include it // as a dependency in each component (adds too much space overhead) window.ReactDOM = ReactDOM; +window.React = React; + diff --git a/package.json b/package.json index b68ed541..65e91d37 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "immutability-helper": "^2.0.0", "jest": "^21.0.1", "json-loader": "^0.5.4", - "leaflet": "^1.0.1", + "leaflet": "^1.3.1", "node-sass": "^3.10.1", "object.values": "^1.0.3", "proj4": "^2.4.3", diff --git a/plugins/measure/api.py b/plugins/measure/api.py new file mode 100644 index 00000000..f0c5234b --- /dev/null +++ b/plugins/measure/api.py @@ -0,0 +1,39 @@ +import os + +from rest_framework import serializers +from rest_framework import status +from rest_framework.response import Response + +from app.api.tasks import TaskNestedView + +from app.plugins.grass_engine import grass + + +class GeoJSONSerializer(serializers.Serializer): + area = serializers.JSONField(help_text="Polygon contour defining the volume area to compute") + + +class TaskVolume(TaskNestedView): + def post(self, request, pk=None): + task = self.get_and_check_task(request, pk) + if task.dsm_extent is None: + return Response({'error': 'No surface model available'}) + + serializer = GeoJSONSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + + # context = grass.create_context() + # context.add_file('area_file.geojson', serializer['area']) + # context.add_file('points_file.geojson', 'aaa') + # context.add_param('dsm_file', os.path.abspath(task.get_asset_download_path("dsm.tif"))) + # context.execute(os.path.join( + # os.path.dirname(os.path.abspath(__file__), + # "calc_volume.grass" + # ))) + + print(serializer['area']) + return Response(30, status=status.HTTP_200_OK) + + + diff --git a/plugins/measure/calc_volume.grass b/plugins/measure/calc_volume.grass new file mode 100755 index 00000000..30427b2f --- /dev/null +++ b/plugins/measure/calc_volume.grass @@ -0,0 +1,20 @@ +# area_file: Geospatial file containing the area to measure +# points_file: Geospatial file containing the points defining the area +# dsm_file: GeoTIFF DEM containing the surface +# ------ +# output: prints the volume to stdout + +v.import input=${area_file} output=polygon_area --overwrite +v.import input=${points_file} output=polygon_points --overwrite +v.buffer -s --overwrite input=polygon_area type=area output=region distance=3 minordistance=3 +g.region vector=region + +r.import input=${dsm_file} output=dsm --overwrite +v.what.rast map=polygon_points raster=dsm column=height +v.to.rast input=polygon_area output=r_polygon_area use=val value=255 --overwrite + +#v.surf.rst --overwrite input=polygon_points zcolumn=height elevation=dsm_below_pile mask=r_polygon_area +v.surf.bspline --overwrite input=polygon_points column=height raster_output=dsm_below_pile lambda_i=100 + +r.mapcalc expression='pile_height_above_dsm=dsm-dsm_below_pile' --overwrite +r.volume -f input=pile_height_above_dsm clump=r_polygon_area diff --git a/plugins/measure/manifest.json b/plugins/measure/manifest.json index 8cc635de..727b790f 100644 --- a/plugins/measure/manifest.json +++ b/plugins/measure/manifest.json @@ -1,13 +1,13 @@ { - "name": "Area/Length Measurements", + "name": "Volume/Area/Length Measurements", "webodmMinVersion": "0.5.0", - "description": "A plugin to compute area and length measurements on Leaflet", + "description": "A plugin to compute volume, area and length measurements on Leaflet", "version": "0.1.0", - "author": "Piero Toffanin", + "author": "Abdelkoddouss Izem, Piero Toffanin", "email": "pt@masseranolabs.com", "repository": "https://github.com/OpenDroneMap/WebODM", - "tags": ["area", "length", "measurements"], + "tags": ["volume", "area", "length", "measurements"], "homepage": "https://github.com/OpenDroneMap/WebODM", - "experimental": false, + "experimental": true, "deprecated": false } \ No newline at end of file diff --git a/plugins/measure/plugin.py b/plugins/measure/plugin.py index ac51ce35..48634441 100644 --- a/plugins/measure/plugin.py +++ b/plugins/measure/plugin.py @@ -1,5 +1,33 @@ +from app.plugins import MountPoint from app.plugins import PluginBase +from .api import TaskVolume class Plugin(PluginBase): def include_js_files(self): - return ['main.js'] + return ['main.js'] + + def api_mount_points(self): + return [ + MountPoint('task/(?P[^/.]+)/volume', TaskVolume.as_view()) + ] + + + # def get_volume(self, geojson): + # try: + # raster_path= self.assets_path("odm_dem", "dsm.tif") + # raster=gdal.Open(raster_path) + # gt=raster.GetGeoTransform() + # rb=raster.GetRasterBand(1) + # gdal.UseExceptions() + # geosom = reprojson(geojson, raster) + # coords=[(entry[0],entry[1]) for entry in rings(raster_path, geosom)] + # GSD=gt[1] + # volume=0 + # print(rings(raster_path, geosom)) + # print(GSD) + # med=statistics.median(entry[2] for entry in rings(raster_path, geosom)) + # clip=clip_raster(raster_path, geosom, gt=None, nodata=-9999) + # return ((clip-med)*GSD*GSD)[clip!=-9999.0].sum() + # + # except FileNotFoundError as e: + # logger.warning(e) \ No newline at end of file diff --git a/plugins/measure/public/MeasurePopup.jsx b/plugins/measure/public/MeasurePopup.jsx new file mode 100644 index 00000000..5b2a3e02 --- /dev/null +++ b/plugins/measure/public/MeasurePopup.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './MeasurePopup.scss'; +import $ from 'jquery'; +import L from 'leaflet'; + +module.exports = class MeasurePopup extends React.Component { + static defaultProps = { + map: {}, + model: {}, + resultFeature: {} + }; + static propTypes = { + map: PropTypes.object.isRequired, + model: PropTypes.object.isRequired, + resultFeature: PropTypes.object.isRequired + } + + constructor(props){ + super(props); + console.log(props); + + this.state = { + volume: null, // to be calculated, + error: "" + }; + } + + componentDidMount(){ + this.calculateVolume(); + } + + calculateVolume(){ + const { lastCoord } = this.props.model; + let layers = this.getLayersAtCoords(L.latLng( + lastCoord.dd.y, + lastCoord.dd.x + )); + + // Did we select a layer? + if (layers.length > 0){ + const layer = layers[layers.length - 1]; + const meta = layer[Symbol.for("meta")]; + if (meta){ + const task = meta.task; + + $.ajax({ + type: 'POST', + url: `/api/plugins/measure/task/${task.id}/volume`, + data: JSON.stringify({'area': this.props.resultFeature.toGeoJSON()}), + contentType: "application/json" + }).done(result => { + if (result.volume){ + this.setState({volume}); + }else if (result.error){ + this.setState({error: result.error}); + }else{ + this.setState({error: "Invalid response: " + result}); + } + }).fail(error => { + this.setState({error}); + }); + }else{ + console.warn("Cannot find [meta] symbol for layer: ", layer); + this.setState({volume: false}); + } + }else{ + this.setState({volume: false}); + } + } + + // @return the layers in the map + // at a specific lat/lon + getLayersAtCoords(latlng){ + const targetBounds = L.latLngBounds(latlng, latlng); + + const intersects = []; + for (let l in this.props.map._layers){ + const layer = this.props.map._layers[l]; + + if (layer.options && layer.options.bounds){ + if (targetBounds.intersects(layer.options.bounds)){ + intersects.push(layer); + } + } + } + + return intersects; + } + + render(){ + const { volume, error } = this.state; + + return (
    +

    Area: {this.props.model.areaDisplay}

    +

    Perimeter: {this.props.model.lengthDisplay}

    + {volume === null && !error &&

    Volume: computing...

    } + {typeof volume === "number" &&

    Volume: {volume.toFixed("3")} Cubic Meters

    } + {error &&

    Volume: {error}

    } +
    ); + } +} \ No newline at end of file diff --git a/plugins/measure/public/MeasurePopup.scss b/plugins/measure/public/MeasurePopup.scss new file mode 100644 index 00000000..004a85c4 --- /dev/null +++ b/plugins/measure/public/MeasurePopup.scss @@ -0,0 +1,5 @@ +.plugin-measure.popup{ + p{ + margin: 0; + } +} \ No newline at end of file diff --git a/plugins/measure/public/app.jsx b/plugins/measure/public/app.jsx new file mode 100644 index 00000000..64414a0a --- /dev/null +++ b/plugins/measure/public/app.jsx @@ -0,0 +1,41 @@ +import L from 'leaflet'; +import './app.scss'; +import './dist/leaflet-measure'; +import './dist/leaflet-measure.css'; +import MeasurePopup from './MeasurePopup'; +import ReactDOM from 'ReactDOM'; +import React from 'react'; +import $ from 'jquery'; + +module.exports = class App{ + constructor(map){ + this.map = map; + + L.control.measure({ + labels:{ + measureDistancesAndAreas: 'Measure volume, area and length', + areaMeasurement: 'Measurement' + }, + primaryLengthUnit: 'meters', + secondaryLengthUnit: 'feet', + primaryAreaUnit: 'sqmeters', + secondaryAreaUnit: 'acres' + }).addTo(map); + + map.on('measurepopupshown', ({popupContainer, model, resultFeature}) => { + // Only modify area popup, length popup is fine as default + if (model.area !== 0){ + const $container = $("
    "), + $popup = $(popupContainer); + + $popup.children("p").empty(); + $popup.children("h3:first-child").after($container); + + ReactDOM.render(, $container.get(0)); + } + }); + } +} \ No newline at end of file diff --git a/plugins/measure/public/app.scss b/plugins/measure/public/app.scss new file mode 100644 index 00000000..afb4fe8a --- /dev/null +++ b/plugins/measure/public/app.scss @@ -0,0 +1,19 @@ +.leaflet-control-measure, +.leaflet-measure-resultpopup{ + h3{ + font-size: 120%; + } +} + +.leaflet-control-measure-interaction{ + a{ + width: auto !important; + height: auto !important; + line-height: auto !important; + display: initial !important; + + &:hover{ + background-color: inherit !important; + } + } +} \ No newline at end of file diff --git a/plugins/measure/public/dist b/plugins/measure/public/dist new file mode 120000 index 00000000..2296f3a0 --- /dev/null +++ b/plugins/measure/public/dist @@ -0,0 +1 @@ +../../../../leaflet-measure-ex/dist/ \ No newline at end of file diff --git a/plugins/measure/public/images/cancel.png b/plugins/measure/public/images/cancel.png deleted file mode 100644 index a4e7c492ef6361dfcc8d2584c8b6b72212d8e9e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 397 zcmV;80doF{P)!kTFgIQ4~eb9Y&Z*6xo3t ziO^xR#}0cM&=A2!tyl|*+93wkU`c4Gq%yMt7eGib42k*AVgOARe4zFPxYJO&jKP^sj|g7?GKFo5Ya(6%nVqzEXjof8e~2DycWBznNb>RjX-HV`T$4{o$T ziH0BsbFz_VS_RZsqcIFzX75E%{O4O=bD8Y1*XxzzRLWHhqQ7A0KF&Iw{Bl#Q(HI7n r-n}+am#MIJ2KD!Y>_exMUjlprupxbFzN5i;00000NkvXXu0mjf1P-X} diff --git a/plugins/measure/public/images/cancel_@2X.png b/plugins/measure/public/images/cancel_@2X.png deleted file mode 100644 index dcc72f0c191f021818a99133bd298c558cca57ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 762 zcmV}PgtpK4o^yWBd+vSCJ(sAXvFZJG(^O#quK@%Ecvc>fD}rYb&q{GxH0Hd zj7;pck;Wn*Z%C?oO^ApF@x?5u+4NfX`J)EW$^9s(J`s3Y5A0x7pyt&2YH*{vTGQRT z*ufmB63xQ_fId-3MPr8_YO2+plPb|X*e(G^lks5s!5}iR*G6TW{9Wfx4w4r#kc8d59ha@VyRdw>(D*HC572U;zNs*z|t8h`hC~BvmM}(vvM3PX++_<^nrm z$G%jmt02Xe!A9W#K%j%s*x?<47Izb%x@!Pv1YX(0UER;X|BwRY>XA7$&-~AecTHE7 zc&P}UH468ZZnNG}F;gd97RjlCAlE1yKyRIN*`x(IMdjHP5p6fu3+`#DO&3)8)LLYc z+#&!(V+Y#=hb;~Th=eMK$Sp0qLRqBXGeDnZES-o4M-2clOKR3I^ib_2`aot}!1wB- zu+O%6;9Bd_98kFr(rex4)SP+*)`U2@vHJ4og8=}nuLd_D`1sFkk@v}X_x6L8bvzn7 z9HGcENPkmY;2Pj$H5{;9nE;TC2h&0Z0Bkv?%Ptc@+T+myD;#k7tGL(5_))82=&*qO s1UdkDURibt$f*|ONNy$L!3#IWZz+CGCOrV=;s5{u07*qoM6N<$f~%rcF8}}l diff --git a/plugins/measure/public/images/check.png b/plugins/measure/public/images/check.png deleted file mode 100644 index 55f274b1d85c7930ebcc253cbca86aaf358fb76e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 387 zcmV-}0et?6P)kkTFXF0T_m#_wLj?DDxk* z3E5`Y(vl5OYE00rt)bB-r<~+|L2HX(p>*EYkitRUJAORh_rC8Z zR%&)eK`_f-11-ZU0FolZ2F_x3-|N-q00L-TjCVaRc?IpUK?afQCXJ_u!w)q(qagUo zjkDnNQmIfpaQJBY4TP!yLbtJ3$i>*{e;7c0 zh2hXo3fu33{mk-!!K73u)|_6iK6l-uVXIGM!ATOIb-U$-*26Fy`j+3iG|`r+vd#?Z h@A$>XZnwMu_ysFna@?3hyu<(i002ovPDHLkV1fVxrO*HX diff --git a/plugins/measure/public/images/check_@2X.png b/plugins/measure/public/images/check_@2X.png deleted file mode 100644 index df8032e49651d82a009edfaf0ef29badc9c7b7f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 692 zcmV;l0!#ggP)8cT8;vkC&{spvFlokpk`~xNiS5m5zu($|ef)R1*y`KXG z`r}e^>pQ;ZyU+W5C+Fl`(MA)Qarac1@UBfY|V>Zl<)@zjtLoGF46@w;;II4jdA7qCnG2d2glOZRl=1y*(?r zLUcHQuA5^ynXbHSaO*plTp>ERRR*KQoL4#z7@OMak&FNAtn;u0`(sj~nDh1>fOGNr z|A8C)Z7`%R!90K_GUeWCRllw}Ep4>q2h}$*#lIr%UJPM@@4H^-`);^%xP9)q)jzqC zA3Q*08i$aJ$Ae+V=_TqA?meHIQ}8TZxo1YHr2-8(AQ2gD_Ng8o7~dprHrEOI+{N{8 z!F<7}hQWJpdsA1zYt`MMdvA~}2gnN9fsM0}PT-(JC$!r- zL9j5Mu1o>cLLNki1K5tnQ?FKSl9-DEH#qi#>MYdWgVJLE zo&rrr=O=u<5X=vX`tW>PZ!4!jfqny8s;@S2)N!~(;&R$q^-|>(b%pjnmOi->kb++tyAin zBDQq*+w0<;Q-l*XPCk^dHb*^hS#AA)o3n@P-5KImJ$B}rx+?A1gL}8{Fy>_EJ0Ive zQPK2nadfR@gNW9GA4_-4Fjiq$HH&fG{inWjW|{9SeJQQ5+s1%xdyConrH*~m`<=ay@wZ*H UQzopr05^1kr2qf` diff --git a/plugins/measure/public/images/focus_@2X.png b/plugins/measure/public/images/focus_@2X.png deleted file mode 100644 index 1eb7dd4cef4cd2eacd5738c37f37d5a14fdb4177..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 462 zcmV;<0WtoGP)KmA@_oQ5431=k5>{)p`h} ztk{-bL`}A`vz~xw;0YKKL7@}WqSb6fDV_i;5s5@G>wbzwX2+j9$ZGhybMk%XJLk-u zd*L4eST?B5H)k-dVm6%3ZzmPw9n|8cvawJLqrS5?cJPvO-F9z_f2~_9n}Oort%GTPtXJO+#m{5V775O;^bF=2}430o7v)4QaKN4gS(z>oQ{;dKfxHx z<^^{Ssv(o$ik$ok#vsyO3guGjh7kt_Lmrlc`Vaw_07%UmyGi9{b91!iOHmA9(&Uc~ z3<+Q$b+7Aerw@gWF%4nJ z@ErkR#;MwT(m=r^PZ!4!i{9jfganJT2M&CY=QK7jFtBkJKHO?=xPa5ccmmr$W2Kv1 z%sopa4zwJZs2FH`$86P3hG$)k#RoZfo85VMo-B8=aPYrxU|5;-^99?9$%l@Mv8gbs gv9YyH7I|UM#4zEtW%wM+Xpn6Tp00i_>zopr05O=u<5X=vX`tYCPZ!6Kh}O5$9k~uU@VL%j+}+P2sx($4>T3%sbg! diff --git a/plugins/measure/public/images/start.png b/plugins/measure/public/images/start.png deleted file mode 100644 index b8ca942b5d4c953be6afbed03e750a8d53909588..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 491 zcmVskiSn80UX8O_siwlUc3qk zn=#_z(1`>QWOJ06ffVDJi_{>Mq<@LS5i#f>dL8(&GqRBK!!}GEoE&s>FeL5S#@_on z)Jq!&KjX`LFYo&m{AH!qD0m%r6F`~95&$UBHkG>eJ8$;(9`yh~0H7SU?l?|*Ou|*s z$V4uOHMStY7J;NuTF55CDttx@p0P7B1B#2=K8UN8N^7?n!X>w)`(-kr)7=hW-C znHoS`m6q9^Ok!?joE#o@7Xq12M%KZqFr)f@%d23r7dmWd?$DpLFOZhmKE}k7MRAJoN#oLL`+&I~AT&}-g{4QBj5S--Ay|FQTotB2GwILGU h%opEA(Y-zZ`~laIr}6x~ZR7v|002ovPDHLkV1jU2-Bthq diff --git a/plugins/measure/public/images/start_@2X.png b/plugins/measure/public/images/start_@2X.png deleted file mode 100644 index 01da494c809064869254a0eb0ec9789b19563964..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1003 zcmV%o}=4 zVA2%b7#rFBTRje|wb<43U^^C{Bltl{YE*=~yUBh`$~rZ1rRC|W0-4@ICIp5hZKx8I zWV#z*8u$aij3%Jn-8&?us#0&v)jxJQeZ8(gci+ruu&)3d-dYIX#O3JX)vJ5vYo2sq zZnMe@Cml{oS`QFH7|iEdzy6;&-pmIS(b9&86al(ZpucoO2&-tpmGuhv4oh@&T_w|FVKXGSybe+Ox2el-0|08%_8 zX@kRCkD>!B$+OwnCI~efVi9AP+Fu6gAov2(MuQ;$J9fO#Tq^NIl0pb)^SRbDE0Z&Q zGXq9AP(ocdM$>;a66)?LTtNEV-Q7KER+6YwlH9!%m!pgIQ}t2gVeUvz@xw*}wk})% z5=n|^Rm4dWGi}t5#qP4WqD%7JdMe-+1Vy@ev*`Oc%9&<0R%8{rRAx)`T@*n_EL1OtN+*c z8WFCnyc{R@LE8Wf>%D&Y{TG$@W$5lL4q}|IeE9;-ceXBEsM^0~)ulKL2KP^wCR+%f zUCXt8yBJerWX>G65Jr$TR+kcBs$%~|hlZ^lXs>ol1mN)6u-^P=%dO>nH7~48U-2D- zA$LEt+Rz6Q+;1tx7e9R8_I%}L!y$4wJG(88z0*DHm$U=C>Jz;Jf8@a?&Fp4=H2tg| Z$G;w}gww(;|K0!q002ovPDHLkV1hTc*t`G$ diff --git a/plugins/measure/public/images/trash.png b/plugins/measure/public/images/trash.png deleted file mode 100644 index 7ff478a456103bbf4b4d6334c8a63a5012172b51..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 279 zcmeAS@N?(olHy`uVBq!ia0vp^+(693!3HF^taP6Tq*#ibJVQ8upoSx*1IXtr@Q5r1 zsuKobMuu5)B!GfDJzX3_G$tmeI4~7vE`M%pV4!y3$N&F-XK$|Pi`42lpAo-*ersc6 zqvJcy`C7`4?>pbG_%T+JjAI859AIG3tg#ok Tr1ob$&=U-vu6{1-oD!MKmAgsdtwaR{ z8xcgXi~0f)LF<6Ux=k*vrD$m-q_#0uNg>);so)C;W=WYwEVQujf}ln)ml=yBve}F5 zCSsM-of+nv`G;Xw!9(kbtMM>}@=zQDNVR8K09;l<`q4r3*{zimr`5E303nE)$z>OAt$8C=n1+%YW>i!}CiDACFAi`#exg0jjkYzIdKlVP zO6@{$p36qp+?pFH^9%~B5bWGdyl1SrvC@+$BWZquFyQs1&ctOBLIptmFR=ou{TL%{ zijg*@Q<%Np^D4U4|AY19by-iFZ+^X9eV!eO27`Ft6L+f~xCeI1^Nzv%Aw+fG-Y+(xeNdsiQ;TftdS_rHxT^ViZxfEgr#sw3cAvyWTefY z6E`t*Yy$%ku5Ny5;Sn&$h$zstFf%Kn3+}INwPN4M=!UE@YX5ft0000.04045?Math.pow((b+.055)/1.055,2.4):b/12.92,c=c>.04045?Math.pow((c+.055)/1.055,2.4):c/12.92,d=d>.04045?Math.pow((d+.055)/1.055,2.4):d/12.92;var e=.4124*b+.3576*c+.1805*d,f=.2126*b+.7152*c+.0722*d,g=.0193*b+.1192*c+.9505*d;return[100*e,100*f,100*g]}function l(a){var b,c,d,e=k(a),f=e[0],g=e[1],h=e[2];return f/=95.047,g/=100,h/=108.883,f=f>.008856?Math.pow(f,1/3):7.787*f+16/116,g=g>.008856?Math.pow(g,1/3):7.787*g+16/116,h=h>.008856?Math.pow(h,1/3):7.787*h+16/116,b=116*g-16,c=500*(f-g),d=200*(g-h),[b,c,d]}function m(a){return M(l(a))}function n(a){var b,c,d,e,f,g=a[0]/360,h=a[1]/100,i=a[2]/100;if(0==h)return f=255*i,[f,f,f];c=i<.5?i*(1+h):i+h-i*h,b=2*i-c,e=[0,0,0];for(var j=0;j<3;j++)d=g+1/3*-(j-1),d<0&&d++,d>1&&d--,f=6*d<1?b+6*(c-b)*d:2*d<1?c:3*d<2?b+(c-b)*(2/3-d)*6:b,e[j]=255*f;return e}function o(a){var b,c,d=a[0],e=a[1]/100,f=a[2]/100;return 0===f?[0,0,0]:(f*=2,e*=f<=1?f:2-f,c=(f+e)/2,b=2*e/(f+e),[d,100*b,100*c])}function p(a){return h(n(a))}function q(a){return i(n(a))}function s(a){return j(n(a))}function t(a){var b=a[0]/60,c=a[1]/100,d=a[2]/100,e=Math.floor(b)%6,f=b-Math.floor(b),g=255*d*(1-c),h=255*d*(1-c*f),i=255*d*(1-c*(1-f)),d=255*d;switch(e){case 0:return[d,i,g];case 1:return[h,d,g];case 2:return[g,d,i];case 3:return[g,h,d];case 4:return[i,g,d];case 5:return[d,g,h]}}function u(a){var b,c,d=a[0],e=a[1]/100,f=a[2]/100;return c=(2-e)*f,b=e*f,b/=c<=1?c:2-c,b=b||0,c/=2,[d,100*b,100*c]}function v(a){return h(t(a))}function w(a){return i(t(a))}function x(a){return j(t(a))}function y(a){var c,d,e,f,h=a[0]/360,i=a[1]/100,j=a[2]/100,k=i+j;switch(k>1&&(i/=k,j/=k),c=Math.floor(6*h),d=1-j,e=6*h-c,0!=(1&c)&&(e=1-e),f=i+e*(d-i),c){default:case 6:case 0:r=d,g=f,b=i;break;case 1:r=f,g=d,b=i;break;case 2:r=i,g=d,b=f;break;case 3:r=i,g=f,b=d;break;case 4:r=f,g=i,b=d;break;case 5:r=d,g=i,b=f}return[255*r,255*g,255*b]}function z(a){return e(y(a))}function A(a){return f(y(a))}function B(a){return i(y(a))}function C(a){return j(y(a))}function D(a){var b,c,d,e=a[0]/100,f=a[1]/100,g=a[2]/100,h=a[3]/100;return b=1-Math.min(1,e*(1-h)+h),c=1-Math.min(1,f*(1-h)+h),d=1-Math.min(1,g*(1-h)+h),[255*b,255*c,255*d]}function E(a){return e(D(a))}function F(a){return f(D(a))}function G(a){return h(D(a))}function H(a){return j(D(a))}function I(a){var b,c,d,e=a[0]/100,f=a[1]/100,g=a[2]/100;return b=3.2406*e+f*-1.5372+g*-.4986,c=e*-.9689+1.8758*f+.0415*g,d=.0557*e+f*-.204+1.057*g,b=b>.0031308?1.055*Math.pow(b,1/2.4)-.055:b*=12.92,c=c>.0031308?1.055*Math.pow(c,1/2.4)-.055:c*=12.92,d=d>.0031308?1.055*Math.pow(d,1/2.4)-.055:d*=12.92,b=Math.min(Math.max(0,b),1),c=Math.min(Math.max(0,c),1),d=Math.min(Math.max(0,d),1),[255*b,255*c,255*d]}function J(a){var b,c,d,e=a[0],f=a[1],g=a[2];return e/=95.047,f/=100,g/=108.883,e=e>.008856?Math.pow(e,1/3):7.787*e+16/116,f=f>.008856?Math.pow(f,1/3):7.787*f+16/116,g=g>.008856?Math.pow(g,1/3):7.787*g+16/116,b=116*f-16,c=500*(e-f),d=200*(f-g),[b,c,d]}function K(a){return M(J(a))}function L(a){var b,c,d,e,f=a[0],g=a[1],h=a[2];return f<=8?(c=100*f/903.3,e=7.787*(c/100)+16/116):(c=100*Math.pow((f+16)/116,3),e=Math.pow(c/100,1/3)),b=b/95.047<=.008856?b=95.047*(g/500+e-16/116)/7.787:95.047*Math.pow(g/500+e,3),d=d/108.883<=.008859?d=108.883*(e-h/200-16/116)/7.787:108.883*Math.pow(e-h/200,3),[b,c,d]}function M(a){var b,c,d,e=a[0],f=a[1],g=a[2];return b=Math.atan2(g,f),c=360*b/2/Math.PI,c<0&&(c+=360),d=Math.sqrt(f*f+g*g),[e,d,c]}function N(a){return I(L(a))}function O(a){var b,c,d,e=a[0],f=a[1],g=a[2];return d=g/360*2*Math.PI,b=f*Math.cos(d),c=f*Math.sin(d),[e,b,c]}function P(a){return L(O(a))}function Q(a){return N(O(a))}function R(a){return Y[a]}function S(a){return e(R(a))}function T(a){return f(R(a))}function U(a){return h(R(a))}function V(a){return i(R(a))}function W(a){return l(R(a))}function X(a){return k(R(a))}c.exports={rgb2hsl:e,rgb2hsv:f,rgb2hwb:h,rgb2cmyk:i,rgb2keyword:j,rgb2xyz:k,rgb2lab:l,rgb2lch:m,hsl2rgb:n,hsl2hsv:o,hsl2hwb:p,hsl2cmyk:q,hsl2keyword:s,hsv2rgb:t,hsv2hsl:u,hsv2hwb:v,hsv2cmyk:w,hsv2keyword:x,hwb2rgb:y,hwb2hsl:z,hwb2hsv:A,hwb2cmyk:B,hwb2keyword:C,cmyk2rgb:D,cmyk2hsl:E,cmyk2hsv:F,cmyk2hwb:G,cmyk2keyword:H,keyword2rgb:R,keyword2hsl:S,keyword2hsv:T,keyword2hwb:U,keyword2cmyk:V,keyword2lab:W,keyword2xyz:X,xyz2rgb:I,xyz2lab:J,xyz2lch:K,lab2xyz:L,lab2rgb:N,lab2lch:M,lch2lab:O,lch2xyz:P,lch2rgb:Q};var Y={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},Z={};for(var $ in Y)Z[JSON.stringify(Y[$])]=$},{}],3:[function(a,b,c){var d=a("./conversions"),e=function(){return new j};for(var f in d){e[f+"Raw"]=function(a){return function(b){return"number"==typeof b&&(b=Array.prototype.slice.call(arguments)),d[a](b)}}(f);var g=/(\w+)2(\w+)/.exec(f),h=g[1],i=g[2];e[h]=e[h]||{},e[h][i]=e[f]=function(a){return function(b){"number"==typeof b&&(b=Array.prototype.slice.call(arguments));var c=d[a](b);if("string"==typeof c||void 0===c)return c;for(var e=0;ec?(b+.05)/(c+.05):(c+.05)/(b+.05)},level:function(a){var b=this.contrast(a);return b>=7.1?"AAA":b>=4.5?"AA":""},dark:function(){var a=this.values.rgb,b=(299*a[0]+587*a[1]+114*a[2])/1e3;return b<128},light:function(){return!this.dark()},negate:function(){for(var a=[],b=0;b<3;b++)a[b]=255-this.values.rgb[b];return this.setValues("rgb",a),this},lighten:function(a){return this.values.hsl[2]+=this.values.hsl[2]*a,this.setValues("hsl",this.values.hsl),this},darken:function(a){return this.values.hsl[2]-=this.values.hsl[2]*a,this.setValues("hsl",this.values.hsl),this},saturate:function(a){return this.values.hsl[1]+=this.values.hsl[1]*a,this.setValues("hsl",this.values.hsl),this},desaturate:function(a){return this.values.hsl[1]-=this.values.hsl[1]*a,this.setValues("hsl",this.values.hsl),this},whiten:function(a){return this.values.hwb[1]+=this.values.hwb[1]*a,this.setValues("hwb",this.values.hwb),this},blacken:function(a){return this.values.hwb[2]+=this.values.hwb[2]*a,this.setValues("hwb",this.values.hwb),this},greyscale:function(){var a=this.values.rgb,b=.3*a[0]+.59*a[1]+.11*a[2];return this.setValues("rgb",[b,b,b]),this},clearer:function(a){return this.setValues("alpha",this.values.alpha-this.values.alpha*a),this},opaquer:function(a){return this.setValues("alpha",this.values.alpha+this.values.alpha*a),this},rotate:function(a){var b=this.values.hsl[0];return b=(b+a)%360,b=b<0?360+b:b,this.values.hsl[0]=b,this.setValues("hsl",this.values.hsl),this},mix:function(a,b){b=1-(null==b?.5:b);for(var c=2*b-1,d=this.alpha()-a.alpha(),e=((c*d==-1?c:(c+d)/(1+c*d))+1)/2,f=1-e,g=this.rgbArray(),h=a.rgbArray(),i=0;i0&&(c+=h(this._coords[a-1],this._coords[a]));this._calcedDistance=c}},distance:function(a){var b=d.extend({units:"meters"},a);if(this._internalDistanceCalc(),d.isFunction(g[b.units]))return g[b.units](this._calcedDistance)}}},{"./constants":9,"./units":14,underscore:15}],11:[function(a,b,c){var d=a("underscore");b.exports=function(a){return d.map(a,function(a){return[a[1],a[0]]})}},{underscore:15}],12:[function(a,b,c){var d=a("underscore"),e=a("./path"),f=a("./distance"),g=a("./area");d.extend(e.prototype,f,g),c.path=function(a,b){return new e(a,b)}},{"./area":8,"./distance":10,"./path":13,underscore:15}],13:[function(a,b,c){var d=a("./flipcoords"),e=function(a,b){this._options=b||{},a=a||[],this._coords=this._options.imBackwards===!0?d(a):a};b.exports=e},{"./flipcoords":11}],14:[function(a,b,c){c.meters={toFeet:function(a){return 3.28084*a},toKilometers:function(a){return.001*a},toMiles:function(a){return 621371e-9*a}},c.sqMeters={toSqMiles:function(a){return 3.86102e-7*a},toAcres:function(a){return 247105e-9*a}},c.degrees={toRadians:function(a){return a*Math.PI/180}}},{}],15:[function(a,b,c){(function(){var a=this,d=a._,e={},f=Array.prototype,g=Object.prototype,h=Function.prototype,i=f.push,j=f.slice,k=f.concat,l=g.toString,m=g.hasOwnProperty,n=f.forEach,o=f.map,p=f.reduce,q=f.reduceRight,r=f.filter,s=f.every,t=f.some,u=f.indexOf,v=f.lastIndexOf,w=Array.isArray,x=Object.keys,y=h.bind,z=function(a){return a instanceof z?a:this instanceof z?void(this._wrapped=a):new z(a)};"undefined"!=typeof c?("undefined"!=typeof b&&b.exports&&(c=b.exports=z),c._=z):a._=z,z.VERSION="1.5.2";var A=z.each=z.forEach=function(a,b,c){if(null!=a)if(n&&a.forEach===n)a.forEach(b,c);else if(a.length===+a.length){for(var d=0,f=a.length;d2;if(null==a&&(a=[]),p&&a.reduce===p)return d&&(b=z.bind(b,d)),e?a.reduce(b,c):a.reduce(b);if(A(a,function(a,f,g){e?c=b.call(d,c,a,f,g):(c=a,e=!0)}),!e)throw new TypeError(B);return c},z.reduceRight=z.foldr=function(a,b,c,d){var e=arguments.length>2;if(null==a&&(a=[]),q&&a.reduceRight===q)return d&&(b=z.bind(b,d)),e?a.reduceRight(b,c):a.reduceRight(b);var f=a.length;if(f!==+f){var g=z.keys(a);f=g.length}if(A(a,function(h,i,j){i=g?g[--f]:--f,e?c=b.call(d,c,a[i],i,j):(c=a[i],e=!0)}),!e)throw new TypeError(B);return c},z.find=z.detect=function(a,b,c){var d;return C(a,function(a,e,f){if(b.call(c,a,e,f))return d=a,!0}),d},z.filter=z.select=function(a,b,c){var d=[];return null==a?d:r&&a.filter===r?a.filter(b,c):(A(a,function(a,e,f){b.call(c,a,e,f)&&d.push(a)}),d)},z.reject=function(a,b,c){return z.filter(a,function(a,d,e){return!b.call(c,a,d,e)},c)},z.every=z.all=function(a,b,c){b||(b=z.identity);var d=!0;return null==a?d:s&&a.every===s?a.every(b,c):(A(a,function(a,f,g){if(!(d=d&&b.call(c,a,f,g)))return e}),!!d)};var C=z.some=z.any=function(a,b,c){b||(b=z.identity);var d=!1;return null==a?d:t&&a.some===t?a.some(b,c):(A(a,function(a,f,g){if(d||(d=b.call(c,a,f,g)))return e}),!!d)};z.contains=z.include=function(a,b){return null!=a&&(u&&a.indexOf===u?a.indexOf(b)!=-1:C(a,function(a){return a===b}))},z.invoke=function(a,b){var c=j.call(arguments,2),d=z.isFunction(b);return z.map(a,function(a){return(d?b:a[b]).apply(a,c)})},z.pluck=function(a,b){return z.map(a,function(a){return a[b]})},z.where=function(a,b,c){return z.isEmpty(b)?c?void 0:[]:z[c?"find":"filter"](a,function(a){for(var c in b)if(b[c]!==a[c])return!1;return!0})},z.findWhere=function(a,b){return z.where(a,b,!0)},z.max=function(a,b,c){if(!b&&z.isArray(a)&&a[0]===+a[0]&&a.length<65535)return Math.max.apply(Math,a);if(!b&&z.isEmpty(a))return-(1/0);var d={computed:-(1/0),value:-(1/0)};return A(a,function(a,e,f){var g=b?b.call(c,a,e,f):a;g>d.computed&&(d={value:a,computed:g})}),d.value},z.min=function(a,b,c){if(!b&&z.isArray(a)&&a[0]===+a[0]&&a.length<65535)return Math.min.apply(Math,a);if(!b&&z.isEmpty(a))return 1/0;var d={computed:1/0,value:1/0};return A(a,function(a,e,f){var g=b?b.call(c,a,e,f):a;gd||void 0===c)return 1;if(c>>1;c.call(d,a[h])=0}); -})},z.difference=function(a){var b=k.apply(f,j.call(arguments,1));return z.filter(a,function(a){return!z.contains(b,a)})},z.zip=function(){for(var a=z.max(z.pluck(arguments,"length").concat(0)),b=new Array(a),c=0;c=0;c--)b=[a[c].apply(this,b)];return b[0]}},z.after=function(a,b){return function(){if(--a<1)return b.apply(this,arguments)}},z.keys=x||function(a){if(a!==Object(a))throw new TypeError("Invalid object");var b=[];for(var c in a)z.has(a,c)&&b.push(c);return b},z.values=function(a){for(var b=z.keys(a),c=b.length,d=new Array(c),e=0;e":">",'"':""","'":"'"}};I.unescape=z.invert(I.escape);var J={escape:new RegExp("["+z.keys(I.escape).join("")+"]","g"),unescape:new RegExp("("+z.keys(I.unescape).join("|")+")","g")};z.each(["escape","unescape"],function(a){z[a]=function(b){return null==b?"":(""+b).replace(J[a],function(b){return I[a][b]})}}),z.result=function(a,b){if(null!=a){var c=a[b];return z.isFunction(c)?c.call(a):c}},z.mixin=function(a){A(z.functions(a),function(b){var c=z[b]=a[b];z.prototype[b]=function(){var a=[this._wrapped];return i.apply(a,arguments),O.call(this,c.apply(z,a))}})};var K=0;z.uniqueId=function(a){var b=++K+"";return a?a+b:b},z.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var L=/(.)^/,M={"'":"'","\\":"\\","\r":"r","\n":"n","\t":"t","\u2028":"u2028","\u2029":"u2029"},N=/\\|'|\r|\n|\t|\u2028|\u2029/g;z.template=function(a,b,c){var d;c=z.defaults({},c,z.templateSettings);var e=new RegExp([(c.escape||L).source,(c.interpolate||L).source,(c.evaluate||L).source].join("|")+"|$","g"),f=0,g="__p+='";a.replace(e,function(b,c,d,e,h){return g+=a.slice(f,h).replace(N,function(a){return"\\"+M[a]}),c&&(g+="'+\n((__t=("+c+"))==null?'':_.escape(__t))+\n'"),d&&(g+="'+\n((__t=("+d+"))==null?'':__t)+\n'"),e&&(g+="';\n"+e+"\n__p+='"),f=h+b.length,b}),g+="';\n",c.variable||(g="with(obj||{}){\n"+g+"}\n"),g="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+g+"return __p;\n";try{d=new Function(c.variable||"obj","_",g)}catch(a){throw a.source=g,a}if(b)return d(b,z);var h=function(a){return d.call(this,a,z)};return h.source="function("+(c.variable||"obj")+"){\n"+g+"}",h},z.chain=function(a){return z(a).chain()};var O=function(a){return this._chain?z(a).chain():a};z.mixin(z),A(["pop","push","reverse","shift","sort","splice","unshift"],function(a){var b=f[a];z.prototype[a]=function(){var c=this._wrapped;return b.apply(c,arguments),"shift"!=a&&"splice"!=a||0!==c.length||delete c[0],O.call(this,c)}}),A(["concat","join","slice"],function(a){var b=f[a];z.prototype[a]=function(){return O.call(this,b.apply(this._wrapped,arguments))}}),z.extend(z.prototype,{chain:function(){return this._chain=!0,this},value:function(){return this._wrapped}})}).call(this)},{}],16:[function(a,b,c){(function(){var a=this,d=a.humanize,e={};"undefined"!=typeof c?("undefined"!=typeof b&&b.exports&&(c=b.exports=e),c.humanize=e):("function"==typeof define&&define.amd&&define("humanize",function(){return e}),a.humanize=e),e.noConflict=function(){return a.humanize=d,this},e.pad=function(a,b,c,d){if(a+="",c?c.length>1&&(c=c.charAt(0)):c=" ",d=void 0===d?"left":"right","right"===d)for(;a.length4&&a<21?"th":{1:"st",2:"nd",3:"rd"}[a%10]||"th"},w:function(){return c.getDay()},z:function(){return(k.L()?g[k.n()]:f[k.n()])+k.j()-1},W:function(){var a=k.z()-k.N()+1.5;return e.pad(1+Math.floor(Math.abs(a)/7)+(a%7>3.5?1:0),2,"0")},F:function(){return j[c.getMonth()]},m:function(){return e.pad(k.n(),2,"0")},M:function(){return k.F().slice(0,3)},n:function(){return c.getMonth()+1},t:function(){return new Date(k.Y(),k.n(),0).getDate()},L:function(){return 1===new Date(k.Y(),1,29).getMonth()?1:0},o:function(){var a=k.n(),b=k.W();return k.Y()+(12===a&&b<9?-1:1===a&&b>9)},Y:function(){return c.getFullYear()},y:function(){return String(k.Y()).slice(-2)},a:function(){return c.getHours()>11?"pm":"am"},A:function(){return k.a().toUpperCase()},B:function(){var a=c.getTime()/1e3,b=a%86400+3600;b<0&&(b+=86400);var d=b/86.4%1e3;return a<0?Math.ceil(d):Math.floor(d)},g:function(){return k.G()%12||12},G:function(){return c.getHours()},h:function(){return e.pad(k.g(),2,"0")},H:function(){return e.pad(k.G(),2,"0")},i:function(){return e.pad(c.getMinutes(),2,"0")},s:function(){return e.pad(c.getSeconds(),2,"0")},u:function(){return e.pad(1e3*c.getMilliseconds(),6,"0")},O:function(){var a=c.getTimezoneOffset(),b=Math.abs(a);return(a>0?"-":"+")+e.pad(100*Math.floor(b/60)+b%60,4,"0")},P:function(){var a=k.O();return a.substr(0,3)+":"+a.substr(3,2)},Z:function(){return 60*-c.getTimezoneOffset()},c:function(){return"Y-m-d\\TH:i:sP".replace(d,h)},r:function(){return"D, d M Y H:i:s O".replace(d,h)},U:function(){return c.getTime()/1e3||0}};return a.replace(d,h)},e.numberFormat=function(a,b,c,d){b=isNaN(b)?2:Math.abs(b),c=void 0===c?".":c,d=void 0===d?",":d;var e=a<0?"-":"";a=Math.abs(+a||0);var f=parseInt(a.toFixed(b),10)+"",g=f.length>3?f.length%3:0;return e+(g?f.substr(0,g)+d:"")+f.substr(g).replace(/(\d{3})(?=\d)/g,"$1"+d)+(b?c+Math.abs(a-f).toFixed(b).slice(2):"")},e.naturalDay=function(a,b){a=void 0===a?e.time():a,b=void 0===b?"Y-m-d":b;var c=86400,d=new Date,f=new Date(d.getFullYear(),d.getMonth(),d.getDate()).getTime()/1e3;return a=f-c?"yesterday":a>=f&&a=f+c&&a-2)return(c>=0?"just ":"")+"now";if(c<60&&c>-60)return c>=0?Math.floor(c)+" seconds ago":"in "+Math.floor(-c)+" seconds";if(c<120&&c>-120)return c>=0?"about a minute ago":"in about a minute";if(c<3600&&c>-3600)return c>=0?Math.floor(c/60)+" minutes ago":"in "+Math.floor(-c/60)+" minutes";if(c<7200&&c>-7200)return c>=0?"about an hour ago":"in about an hour";if(c<86400&&c>-86400)return c>=0?Math.floor(c/3600)+" hours ago":"in "+Math.floor(-c/3600)+" hours";var d=172800;if(c-d)return c>=0?"1 day ago":"in 1 day";var f=2505600;if(c-f)return c>=0?Math.floor(c/86400)+" days ago":"in "+Math.floor(-c/86400)+" days";var g=5184e3;if(c-g)return c>=0?"about a month ago":"in about a month";var h=parseInt(e.date("Y",b),10),i=parseInt(e.date("Y",a),10),j=12*h+parseInt(e.date("n",b),10),k=12*i+parseInt(e.date("n",a),10),l=j-k;if(l<12&&l>-12)return l>=0?l+" months ago":"in "+-l+" months";var m=h-i;return m<2&&m>-2?m>=0?"a year ago":"in a year":m>=0?m+" years ago":"in "+-m+" years"},e.ordinal=function(a){a=parseInt(a,10),a=isNaN(a)?0:a;var b=a<0?"-":"";a=Math.abs(a);var c=a%100;return b+a+(c>4&&c<21?"th":{1:"st",2:"nd",3:"rd"}[a%10]||"th")},e.filesize=function(a,b,c,d,f,g){return b=void 0===b?1024:b,a<=0?"0 bytes":(a

    "),a=a.replace(/\n/g,"
    "),"

    "+a+"

    "},e.nl2br=function(a){return a.replace(/(\r\n|\n|\r)/g,"
    ")},e.truncatechars=function(a,b){return a.length<=b?a:a.substr(0,b)+"…"},e.truncatewords=function(a,b){var c=a.split(" ");return c.length1&&(a=e(a,Array.prototype.slice.call(arguments,1))),a},__n:function(a,b,c){var d;if("number"==typeof b){var f=a,g=b;d=this.translate(this.locale,f),d=e(parseInt(g,10)>1?d.other:d.one,Array.prototype.slice.call(arguments,1))}else{var h=a,i=b,g=c;d=this.translate(this.locale,h,i),d=e(parseInt(g,10)>1?d.other:d.one,[g]),arguments.length>3&&(d=e(d,Array.prototype.slice.call(arguments,3)))}return d},setLocale:function(a){if(a)return this.locales[a]||(this.devMode&&console.warn("Locale ("+a+") not found."),a=this.defaultLocale),this.locale=a},getLocale:function(){return this.locale},isPreferredLocale:function(){return!this.prefLocale||this.prefLocale===this.getLocale()},setLocaleFromSessionVar:function(a){if(a=a||this.request,a&&a.session&&a.session[this.sessionVarName]){var b=a.session[this.sessionVarName];this.locales[b]&&(this.devMode&&console.log("Overriding locale from query: "+b),this.setLocale(b))}},setLocaleFromQuery:function(a){if(a=a||this.request,a&&a.query&&a.query.lang){var b=(a.query.lang+"").toLowerCase();this.locales[b]&&(this.devMode&&console.log("Overriding locale from query: "+b),this.setLocale(b))}},setLocaleFromSubdomain:function(a){a=a||this.request,a&&a.headers&&a.headers.host&&/^([^.]+)/.test(a.headers.host)&&this.locales[RegExp.$1]&&(this.devMode&&console.log("Overriding locale from host: "+RegExp.$1),this.setLocale(RegExp.$1))},setLocaleFromCookie:function(a){if(a=a||this.request,a&&a.cookies&&this.cookieName&&a.cookies[this.cookieName]){var b=a.cookies[this.cookieName].toLowerCase();this.locales[b]&&(this.devMode&&console.log("Overriding locale from cookie: "+b),this.setLocale(b))}},setLocaleFromEnvironmentVariable:function(){if(c.env.LANG){var a=c.env.LANG.split("_")[0];this.locales[a]&&(this.devMode&&console.log("Overriding locale from environment variable: "+a),this.setLocale(a))}},preferredLocale:function(a){if(a=a||this.request,a&&a.headers){for(var b,c=a.headers["accept-language"]||"",d=/(^|,\s*)([a-z0-9-]+)/gi,e=this;!b&&(match=d.exec(c));){var f=match[2].toLowerCase(),g=f.split("-");e.locales[f]?b=f:g.length>1&&e.locales[g[0]]&&(b=g[0])}return b||this.defaultLocale}},translate:function(a,b,c){return a&&this.locales[a]||(this.devMode&&console.warn("WARN: No locale found. Using the default ("+this.defaultLocale+") as current locale"),a=this.defaultLocale,this.initLocale(a,{})),this.locales[a][b]||this.devMode&&(d(this.locales[a],b,c?{one:b,other:c}:void 0),this.writeFile(a)),d(this.locales[a],b,c?{one:b,other:c}:void 0)},readFile:function(a){var b=this.locateFile(a);if(!this.devMode&&h.localeCache[b])return void this.initLocale(a,h.localeCache[b]);try{var c,d=f.readFileSync(b);if("function"==typeof this.base){var e;try{e=this.base(a)}catch(b){console.error("base function threw exception for locale %s",a,b)}if("string"==typeof e)try{c=this.parse(f.readFileSync(this.locateFile(e)))}catch(b){console.error("unable to read or parse base file %s for locale %s",e,a,b)}}try{var g=this.parse(d);if(c){for(var i in g)c[i]=g[i];g=c}this.initLocale(a,g)}catch(a){console.error("unable to parse locales from file (maybe "+b+" is empty or invalid "+this.extension+"?): ",a)}}catch(c){f.existsSync(b)||this.writeFile(a)}},writeFile:function(a){if(!this.devMode)return void this.initLocale(a,{});try{f.lstatSync(this.directory)}catch(a){this.devMode&&console.log("creating locales dir in: "+this.directory),f.mkdirSync(this.directory,493)}this.initLocale(a,{});try{var b=this.locateFile(a),c=b+".tmp";f.writeFileSync(c,this.dump(this.locales[a],this.indent),"utf8"),f.statSync(c).isFile()?f.renameSync(c,b):console.error("unable to write locales to file (either "+c+" or "+b+" are not writeable?): ")}catch(a){console.error("unexpected error writing files (either "+c+" or "+b+" are not writeable?): ",a)}},locateFile:function(a){return g.normalize(this.directory+"/"+a+this.extension)},initLocale:function(a,b){if(!this.locales[a]&&(this.locales[a]=b,!this.devMode)){var c=this.locateFile(a);h.localeCache[c]||(h.localeCache[c]=b)}}}}).call(this,a("_process"))},{_process:20,fs:1,path:19,sprintf:21}],18:[function(a,b,c){b.exports=a("./i18n")},{"./i18n":17}],19:[function(a,b,c){(function(a){function b(a,b){for(var c=0,d=a.length-1;d>=0;d--){var e=a[d];"."===e?a.splice(d,1):".."===e?(a.splice(d,1),c++):c&&(a.splice(d,1),c--)}if(b)for(;c--;c)a.unshift("..");return a}function d(a,b){if(a.filter)return a.filter(b);for(var c=[],d=0;d=-1&&!e;f--){var g=f>=0?arguments[f]:a.cwd();if("string"!=typeof g)throw new TypeError("Arguments to path.resolve must be strings");g&&(c=g+"/"+c,e="/"===g.charAt(0))}return c=b(d(c.split("/"),function(a){return!!a}),!e).join("/"),(e?"/":"")+c||"."},c.normalize=function(a){var e=c.isAbsolute(a),f="/"===g(a,-1);return a=b(d(a.split("/"),function(a){return!!a}),!e).join("/"),a||e||(a="."),a&&f&&(a+="/"),(e?"/":"")+a},c.isAbsolute=function(a){return"/"===a.charAt(0)},c.join=function(){var a=Array.prototype.slice.call(arguments,0);return c.normalize(d(a,function(a,b){if("string"!=typeof a)throw new TypeError("Arguments to path.join must be strings");return a}).join("/"))},c.relative=function(a,b){function d(a){for(var b=0;b=0&&""===a[c];c--);return b>c?[]:a.slice(b,c-b+1)}a=c.resolve(a).substr(1),b=c.resolve(b).substr(1);for(var e=d(a.split("/")),f=d(b.split("/")),g=Math.min(e.length,f.length),h=g,i=0;i1)for(var c=1;c0;c[--b]=a);return c.join("")}var c=function(){return c.cache.hasOwnProperty(arguments[0])||(c.cache[arguments[0]]=c.parse(arguments[0])),c.format.call(null,c.cache[arguments[0]],arguments)};return c.object_stringify=function(a,b,d,e){var f="";if(null!=a)switch(typeof a){case"function":return"[Function"+(a.name?": "+a.name:"")+"]";case"object":if(a instanceof Error)return"["+a.toString()+"]";if(b>=d)return"[Object]";if(e&&(e=e.slice(0),e.push(a)),null!=a.length){f+="[";var g=[];for(var h in a)e&&e.indexOf(a[h])>=0?g.push("[Circular]"):g.push(c.object_stringify(a[h],b+1,d,e));f+=g.join(", ")+"]"}else{if("getMonth"in a)return"Date("+a+")";f+="{";var g=[];for(var i in a)a.hasOwnProperty(i)&&(e&&e.indexOf(a[i])>=0?g.push(i+": [Circular]"):g.push(i+": "+c.object_stringify(a[i],b+1,d,e)));f+=g.join(", ")+"}"}return f;case"string":return'"'+a+'"'}return""+a},c.format=function(e,f){var g,h,i,j,k,l,m,n=1,o=e.length,p="",q=[];for(h=0;h=0?"+"+g:g,l=j[4]?"0"==j[4]?"0":j[4].charAt(1):" ",m=j[6]-String(g).length,k=j[6]?b(l,m):"",q.push(j[5]?g+k:k+g)}return q.join("")},c.cache={},c.parse=function(a){for(var b=a,c=[],d=[],e=0;b;){if(null!==(c=/^[^\x25]+/.exec(b)))d.push(c[0]);else if(null!==(c=/^\x25{2}/.exec(b)))d.push("%");else{if(null===(c=/^\x25(?:([1-9]\d*)\$|\(([^\)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fosOuxX])/.exec(b)))throw new Error("[sprintf] "+b);if(c[2]){e|=1;var f=[],g=c[2],h=[];if(null===(h=/^([a-z_][a-z_\d]*)/i.exec(g)))throw new Error("[sprintf] "+g);for(f.push(h[1]);""!==(g=g.substring(h[0].length));)if(null!==(h=/^\.([a-z_][a-z_\d]*)/i.exec(g)))f.push(h[1]);else{if(null===(h=/^\[(\d+)\]/.exec(g)))throw new Error("[sprintf] "+g);f.push(h[1])}c[2]=f}else e|=2;if(3===e)throw new Error("[sprintf] mixing positional and named placeholders is not (yet) supported");d.push(c)}b=b.substring(c[0].length)}return d},c}(),e=function(a,b){var c=b.slice();return c.unshift(a),d.apply(null,c)};b.exports=d,d.sprintf=d,d.vsprintf=e},{}],22:[function(a,b,c){(function(){function a(a){function b(b,c,d,e,f,g){for(;f>=0&&f0?0:h-1;return arguments.length<3&&(e=c[g?g[i]:i],i+=a),b(c,d,e,g,i,h)}}function d(a){return function(b,c,d){c=w(c,d);for(var e=B(b),f=a>0?0:e-1;f>=0&&f0?g=f>=0?f:Math.max(f+h,g):h=f>=0?Math.min(f+1,h):f+h+1;else if(c&&f&&h)return f=c(d,e),d[f]===e?f:-1;if(e!==e)return f=b(m.call(d,g,h),u.isNaN),f>=0?f+g:-1;for(f=a>0?g:h-1;f>=0&&f=0&&b<=A};u.each=u.forEach=function(a,b,c){b=v(b,c);var d,e;if(C(a))for(d=0,e=a.length;d=0},u.invoke=function(a,b){var c=m.call(arguments,2),d=u.isFunction(b);return u.map(a,function(a){var e=d?b:a[b];return null==e?e:e.apply(a,c)})},u.pluck=function(a,b){return u.map(a,u.property(b))},u.where=function(a,b){return u.filter(a,u.matcher(b))},u.findWhere=function(a,b){return u.find(a,u.matcher(b))},u.max=function(a,b,c){var d,e,f=-(1/0),g=-(1/0);if(null==b&&null!=a){a=C(a)?a:u.values(a);for(var h=0,i=a.length;hf&&(f=d)}else b=w(b,c),u.each(a,function(a,c,d){e=b(a,c,d),(e>g||e===-(1/0)&&f===-(1/0))&&(f=a,g=e)});return f},u.min=function(a,b,c){var d,e,f=1/0,g=1/0;if(null==b&&null!=a){a=C(a)?a:u.values(a);for(var h=0,i=a.length;hd||void 0===c)return 1;if(cb?(g&&(clearTimeout(g),g=null),h=j,f=a.apply(d,e),g||(d=e=null)):g||c.trailing===!1||(g=setTimeout(i,k)),f}},u.debounce=function(a,b,c){var d,e,f,g,h,i=function(){var j=u.now()-g;j=0?d=setTimeout(i,b-j):(d=null,c||(h=a.apply(f,e),d||(f=e=null)))};return function(){f=this,e=arguments,g=u.now();var j=c&&!d;return d||(d=setTimeout(i,b)),j&&(h=a.apply(f,e),f=e=null),h}},u.wrap=function(a,b){return u.partial(b,a)},u.negate=function(a){return function(){return!a.apply(this,arguments)}},u.compose=function(){var a=arguments,b=a.length-1;return function(){for(var c=b,d=a[b].apply(this,arguments);c--;)d=a[c].call(this,d);return d}},u.after=function(a,b){return function(){if(--a<1)return b.apply(this,arguments)}},u.before=function(a,b){var c;return function(){return--a>0&&(c=b.apply(this,arguments)),a<=1&&(b=null),c}},u.once=u.partial(u.before,2);var G=!{toString:null}.propertyIsEnumerable("toString"),H=["valueOf","isPrototypeOf","toString","propertyIsEnumerable","hasOwnProperty","toLocaleString"];u.keys=function(a){if(!u.isObject(a))return[];if(q)return q(a);var b=[];for(var c in a)u.has(a,c)&&b.push(c);return G&&f(a,b),b},u.allKeys=function(a){if(!u.isObject(a))return[];var b=[];for(var c in a)b.push(c);return G&&f(a,b),b},u.values=function(a){for(var b=u.keys(a),c=b.length,d=Array(c),e=0;e":">",'"':""","'":"'","`":"`"},K=u.invert(J),L=function(a){var b=function(b){return a[b]},c="(?:"+u.keys(a).join("|")+")",d=RegExp(c),e=RegExp(c,"g");return function(a){return a=null==a?"":""+a,d.test(a)?a.replace(e,b):a}};u.escape=L(J),u.unescape=L(K),u.result=function(a,b,c){var d=null==a?void 0:a[b];return void 0===d&&(d=c),u.isFunction(d)?d.call(a):d};var M=0;u.uniqueId=function(a){var b=++M+"";return a?a+b:b},u.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var N=/(.)^/,O={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},P=/\\|'|\r|\n|\u2028|\u2029/g,Q=function(a){return"\\"+O[a]};u.template=function(a,b,c){!b&&c&&(b=c),b=u.defaults({},b,u.templateSettings);var d=RegExp([(b.escape||N).source,(b.interpolate||N).source,(b.evaluate||N).source].join("|")+"|$","g"),e=0,f="__p+='";a.replace(d,function(b,c,d,g,h){return f+=a.slice(e,h).replace(P,Q),e=h+b.length,c?f+="'+\n((__t=("+c+"))==null?'':_.escape(__t))+\n'":d?f+="'+\n((__t=("+d+"))==null?'':__t)+\n'":g&&(f+="';\n"+g+"\n__p+='"),b}),f+="';\n",b.variable||(f="with(obj||{}){\n"+f+"}\n"),f="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+f+"return __p;\n";try{var g=new Function(b.variable||"obj","_",f)}catch(a){throw a.source=f,a}var h=function(a){return g.call(this,a,u)},i=b.variable||"obj";return h.source="function("+i+"){\n"+f+"}",h},u.chain=function(a){var b=u(a);return b._chain=!0,b};var R=function(a,b){return a._chain?u(b).chain():b};u.mixin=function(a){u.each(u.functions(a),function(b){var c=u[b]=a[b];u.prototype[b]=function(){var a=[this._wrapped];return l.apply(a,arguments),R(this,c.apply(u,a))}})},u.mixin(u),u.each(["pop","push","reverse","shift","sort","splice","unshift"],function(a){var b=i[a];u.prototype[a]=function(){var c=this._wrapped;return b.apply(c,arguments),"shift"!==a&&"splice"!==a||0!==c.length||delete c[0],R(this,c)}}),u.each(["concat","join","slice"],function(a){var b=i[a];u.prototype[a]=function(){return R(this,b.apply(this._wrapped,arguments))}}),u.prototype.value=function(){return this._wrapped},u.prototype.valueOf=u.prototype.toJSON=u.prototype.value,u.prototype.toString=function(){return""+this._wrapped},"function"==typeof define&&define.amd&&define("underscore",[],function(){return u})}).call(this)},{}],23:[function(a,b,c){var d=a("underscore"),e=a("geocrunch"),f=function(a){return a<10?"0"+a.toString():a.toString()},g=function(a,b,c){var d=Math.abs(a),e=Math.floor(d),g=Math.floor(60*(d-e)),h=Math.round(3600*(d-e-g/60)*100)/100,i=d===a?b:c;return f(e)+"° "+f(g)+"' "+f(h)+'" '+i},h=function(a){var b=d.last(a),c=e.path(d.map(a,function(a){return[a.lng,a.lat]})),f=c.distance({units:"meters"}),h=c.area({units:"sqmeters"});return{lastCoord:{dd:{x:b.lng,y:b.lat},dms:{x:g(b.lng,"E","W"),y:g(b.lat,"N","S")}},length:f,area:h}};b.exports={measure:h}},{geocrunch:7,underscore:22}],24:[function(a,b,c){var d=function(a,b){return b||(b=document),b.querySelector(a)},e=function(a,b){return b||(b=document),Array.prototype.slice.call(b.querySelectorAll(a))},f=function(a){if(a)return a.setAttribute("style","display:none;"),a},g=function(a){if(a)return a.removeAttribute("style"),a};b.exports={$:d,$$:e,hide:f,show:g}},{}],25:[function(a,b,c){b.exports={measure:"Medir",measureDistancesAndAreas:"Medeix distancies i àreas",createNewMeasurement:"Crear nova medicio",startCreating:"Començi a crear la medicio afegint punts al mapa",finishMeasurement:"Acabar la medició",lastPoint:"Últim punt",area:"Área",perimeter:"Perómetre",pointLocation:"Localizació del punt",areaMeasurement:"Medició d'área",linearMeasurement:"Medició lineal",pathDistance:"Distancia de ruta",centerOnArea:"Centrar en aquesta área",centerOnLine:"Centrar en aquesta línia",centerOnLocation:"Centrar en aquesta localizació",cancel:"Cancel·lar",delete:"Eliminar",acres:"Acres",feet:"Peus",kilometers:"Quilòmetres",hectares:"Hectàreas",meters:"Metros",miles:"Milles",sqfeet:"Peus cuadrats",sqmeters:"Metres cuadrats",sqmiles:"Milles cuadrades",decPoint:".",thousandsSep:" "}},{}],26:[function(a,b,c){b.exports={measure:"测量",measureDistancesAndAreas:"同时测量距离和面积",createNewMeasurement:"开始一次新的测量",startCreating:"点击地图加点以开始创建测量",finishMeasurement:"完成测量",lastPoint:"最后点的坐标",area:"面积",perimeter:"周长",pointLocation:"点的坐标",areaMeasurement:"面积测量",linearMeasurement:"距离测量",pathDistance:"路径长度",centerOnArea:"该面积居中",centerOnLine:"该线段居中",centerOnLocation:"该位置居中",cancel:"取消",delete:"删除",acres:"公亩",feet:"英尺",kilometers:"公里",hectares:"公顷",meters:"米",miles:"英里",sqfeet:"平方英尺",sqmeters:"平方米",sqmiles:"平方英里",decPoint:".",thousandsSep:","}},{}],27:[function(a,b,c){b.exports={measure:"Mål",measureDistancesAndAreas:"Mål afstande og arealer",createNewMeasurement:"Lav en ny måling",startCreating:"Begynd målingen ved at tilføje punkter på kortet",finishMeasurement:"Afslut måling",lastPoint:"Sidste punkt",area:"Areal",perimeter:"Omkreds",pointLocation:"Punkt",areaMeasurement:"Areal",linearMeasurement:"Linje",pathDistance:"Sti afstand",centerOnArea:"Centrér dette område",centerOnLine:"Centrér denne linje",centerOnLocation:"Centrér dette punkt",cancel:"Annuller",delete:"Slet",acres:"acre",feet:"fod",kilometers:"km",hectares:"ha",meters:"m",miles:"mil",sqfeet:"kvadratfod",sqmeters:"m²",sqmiles:"kvadratmil",decPoint:",",thousandsSep:"."}},{}],28:[function(a,b,c){b.exports={measure:"Messung",measureDistancesAndAreas:"Messung von Abständen und Flächen",createNewMeasurement:"Eine neue Messung durchführen",startCreating:"Führen Sie die Messung durch, indem Sie der Karte Punkte hinzufügen.",finishMeasurement:"Messung beenden",lastPoint:"Letzter Punkt",area:"Fläche",perimeter:"Rand",pointLocation:"Lage des Punkts",areaMeasurement:"Gemessene Fläche",linearMeasurement:"Gemessener Abstand",pathDistance:"Abstand entlang des Pfads",centerOnArea:"Auf diese Fläche zentrieren",centerOnLine:"Auf diesen Linienzug zentrieren",centerOnLocation:"Auf diesen Ort zentrieren",cancel:"Abbrechen",delete:"Löschen",acres:"Morgen",feet:"Fuß",kilometers:"Kilometer",hectares:"Hektar",meters:"Meter",miles:"Meilen",sqfeet:"Quadratfuß",sqmeters:"Quadratmeter",sqmiles:"Quadratmeilen",decPoint:",",thousandsSep:"."}},{}],29:[function(a,b,c){b.exports={measure:"Messung",measureDistancesAndAreas:"Abstände und Flächen messen",createNewMeasurement:"Eine neue Messung durchführen",startCreating:"Messen sie, indem Sie der Karte Punkte hinzufügen",finishMeasurement:"Messung beenden",lastPoint:"Letzter Punkt",area:"Fläche",perimeter:"Umfang",pointLocation:"Lage des Punkts",areaMeasurement:"Fläche",linearMeasurement:"Abstand",pathDistance:"Umfang",centerOnArea:"Auf diese Fläche zentrieren",centerOnLine:"Auf diese Linie zentrieren",centerOnLocation:"Auf diesen Ort zentrieren",cancel:"Abbrechen",delete:"Löschen",acres:"Morgen",feet:"Fuß",kilometers:"Kilometer",hectares:"Hektar",meters:"Meter",miles:"Meilen",sqfeet:"Quadratfuß",sqmeters:"Quadratmeter",sqmiles:"Quadratmeilen",decPoint:".",thousandsSep:"'"}},{}],30:[function(a,b,c){b.exports={measure:"Measure",measureDistancesAndAreas:"Measure distances and areas",createNewMeasurement:"Create a new measurement",startCreating:"Start creating a measurement by adding points to the map",finishMeasurement:"Finish measurement",lastPoint:"Last point",area:"Area",perimeter:"Perimeter",pointLocation:"Point location",areaMeasurement:"Area measurement",linearMeasurement:"Linear measurement",pathDistance:"Path distance",centerOnArea:"Center on this area",centerOnLine:"Center on this line",centerOnLocation:"Center on this location",cancel:"Cancel",delete:"Delete",acres:"Acres",feet:"Feet",kilometers:"Kilometers",hectares:"Hectares",meters:"Meters",miles:"Miles",sqfeet:"Sq Feet",sqmeters:"Sq Meters",sqmiles:"Sq Miles",decPoint:".",thousandsSep:","}},{}],31:[function(a,b,c){b.exports={measure:"Measure",measureDistancesAndAreas:"Measure distances and areas",createNewMeasurement:"Create a new measurement",startCreating:"Start creating a measurement by adding points to the map",finishMeasurement:"Finish measurement",lastPoint:"Last point",area:"Area",perimeter:"Perimeter",pointLocation:"Point location",areaMeasurement:"Area measurement",linearMeasurement:"Linear measurement",pathDistance:"Path distance",centerOnArea:"Centre on this area",centerOnLine:"Centre on this line",centerOnLocation:"Centre on this location",cancel:"Cancel",delete:"Delete",acres:"Acres",feet:"Feet",kilometers:"Kilometres",hectares:"Hectares",meters:"Meters",miles:"Miles",sqfeet:"Sq Feet",sqmeters:"Sq Meters",sqmiles:"Sq Miles",decPoint:".",thousandsSep:","}},{}],32:[function(a,b,c){b.exports={measure:"Medición",measureDistancesAndAreas:"Mida distancias y áreas",createNewMeasurement:"Crear nueva medición",startCreating:"Empiece a crear la medición añadiendo puntos al mapa",finishMeasurement:"Terminar medición",lastPoint:"Último punto",area:"Área",perimeter:"Perímetro",pointLocation:"Localización del punto",areaMeasurement:"Medición de área",linearMeasurement:"Medición linear",pathDistance:"Distancia de ruta",centerOnArea:"Centrar en este área",centerOnLine:"Centrar en esta línea",centerOnLocation:"Centrar en esta localización",cancel:"Cancelar",delete:"Eliminar",acres:"Acres",feet:"Pies",kilometers:"Kilómetros",hectares:"Hectáreas",meters:"Metros",miles:"Millas",sqfeet:"Pies cuadrados",sqmeters:"Metros cuadrados",sqmiles:"Millas cuadradas",decPoint:".",thousandsSep:" "}},{}],33:[function(a,b,c){b.exports={measure:"اندازه گیری",measureDistancesAndAreas:"اندازه گیری فاصله و مساحت",createNewMeasurement:"ثبت اندازه گیری جدید",startCreating:"برای ثبت اندازه گیری جدید نقاطی را به نقشه اضافه کنید.",finishMeasurement:"پایان اندازه گیری",lastPoint:"آخرین نقطه",area:"مساحت",perimeter:"محیط",pointLocation:"مکان نقطه",areaMeasurement:"اندازه گیری مساحت",linearMeasurement:"اندازه گیری خطی",pathDistance:"فاصله مسیر",centerOnArea:"مرکز این سطح",centerOnLine:"مرکز این خط",centerOnLocation:"مرکز این مکان",cancel:"لغو",delete:"حذف",acres:"ایکر",feet:"پا",kilometers:"کیلومتر",hectares:"هکتار",meters:"متر",miles:"مایل",sqfeet:"پا مربع",sqmeters:"متر مربع",sqmiles:"مایل مربع",decPoint:"/",thousandsSep:","}},{}],34:[function(a,b,c){b.exports={measure:"Sukat",measureDistancesAndAreas:"Kalkulahin ang tamang distansya at sukat",createNewMeasurement:"Lumikha ng isang bagong pagsukat",startCreating:"Simulan ang paglikha ng isang pagsukat sa pamamagitan ng pagdaragdag ng mga puntos sa mapa",finishMeasurement:"Tapusin ang pagsukat",lastPoint:"Huling punto sa mapa",area:"Sukat",perimeter:"Palibot",pointLocation:"Lokasyon ng punto",areaMeasurement:"Kabuuang sukat",linearMeasurement:"Pagsukat ng guhit",pathDistance:"Distansya ng daanan",centerOnArea:"I-sentro sa lugar na ito",centerOnLine:"I-sentro sa linya na ito",centerOnLocation:"I-sentro sa lokasyong ito",cancel:"Kanselahin",delete:"Tanggalin",acres:"Acres",feet:"Talampakan",kilometers:"Kilometro",hectares:"Hektarya",meters:"Metro",miles:"Milya",sqfeet:"Talampakang Kwadrado",sqmeters:"Metro Kwadrado",sqmiles:"Milya Kwadrado",decPoint:".",thousandsSep:","}},{}],35:[function(a,b,c){b.exports={measure:"Mesure",measureDistancesAndAreas:"Mesurer les distances et superficies",createNewMeasurement:"Créer une nouvelle mesure",startCreating:"Débuter la création d'une nouvelle mesure en ajoutant des points sur la carte",finishMeasurement:"Finir la mesure",lastPoint:"Dernier point",area:"Superficie",perimeter:"Périmètre",pointLocation:"Placement du point",areaMeasurement:"Mesure de superficie",linearMeasurement:"Mesure linéaire",pathDistance:"Distance du chemin",centerOnArea:"Centrer sur cette zone",centerOnLine:"Centrer sur cette ligne",centerOnLocation:"Centrer à cet endroit",cancel:"Annuler",delete:"Supprimer",acres:"Acres",feet:"Pieds",kilometers:"Kilomètres",hectares:"Hectares",meters:"Mètres",miles:"Miles",sqfeet:"Pieds carrés",sqmeters:"Mètres carrés",sqmiles:"Miles carrés",decPoint:",",thousandsSep:" "}},{}],36:[function(a,b,c){b.exports={measure:"Misura",measureDistancesAndAreas:"Misura distanze e aree",createNewMeasurement:"Crea una nuova misurazione",startCreating:"Comincia a creare una misurazione aggiungendo punti alla mappa",finishMeasurement:"Misurazione conclusa",lastPoint:"Ultimo punto",area:"Area",perimeter:"Perimetro",pointLocation:"Posizione punto",areaMeasurement:"Misura area",linearMeasurement:"Misura lineare",pathDistance:"Distanza percorso",centerOnArea:"Centra su questa area",centerOnLine:"Centra su questa linea",centerOnLocation:"Centra su questa posizione",cancel:"Annulla",delete:"Cancella",acres:"Acri",feet:"Piedi",kilometers:"Chilometri",hectares:"Ettari",meters:"Metri",miles:"Miglia",sqfeet:"Piedi quadri",sqmeters:"Metri quadri",sqmiles:"Miglia quadre",decPoint:".",thousandsSep:","}},{}],37:[function(a,b,c){b.exports={measure:"Meet",measureDistancesAndAreas:"Meet afstanden en oppervlakten",createNewMeasurement:"Maak een nieuwe meting",startCreating:"Begin een meting door punten toe te voegen aan de kaart",finishMeasurement:"Beëindig meting",lastPoint:"Laatste punt",area:"Oppervlakte",perimeter:"Omtrek",pointLocation:"Locatie punt",areaMeasurement:"Oppervlakte meting",linearMeasurement:"Gemeten afstand",pathDistance:"Afstand over de lijn",centerOnArea:"Centreer op dit gebied",centerOnLine:"Centreer op deze lijn",centerOnLocation:"Centreer op deze locatie",cancel:"Annuleer",delete:"Wis",acres:"are",feet:"Voet",kilometers:"km",hectares:"ha",meters:"m",miles:"Mijl",sqfeet:"Vierkante Feet",sqmeters:"m2",sqmiles:"Vierkante Mijl",decPoint:",",thousandsSep:"."}},{}],38:[function(a,b,c){b.exports={measure:"Pomiar",measureDistancesAndAreas:"Pomiar odległości i powierzchni",createNewMeasurement:"Utwórz nowy pomiar",startCreating:"Rozpocznij tworzenie nowego pomiaru poprzez dodanie punktów na mapie",finishMeasurement:"Zakończ pomiar",lastPoint:"Ostatni punkt",area:"Powierzchnia",perimeter:"Obwód",pointLocation:"Punkt lokalizacji",areaMeasurement:"Pomiar powierzchni",linearMeasurement:"Pomiar liniowy",pathDistance:"Długość ścieżki",centerOnArea:"Środek tego obszaru",centerOnLine:"Środek tej linii",centerOnLocation:"Środek w tej lokalizacji",cancel:"Anuluj",delete:"Skasuj",acres:"akrów",feet:"stóp",kilometers:"kilometrów",hectares:"hektarów",meters:"metrów",miles:"mil",sqfeet:"stóp kwadratowych",sqmeters:"metrów kwadratowych",sqmiles:"mil kwadratowych",decPoint:",",thousandsSep:"."}},{}],39:[function(a,b,c){b.exports={measure:"Medidas",measureDistancesAndAreas:"Mede distâncias e áreas",createNewMeasurement:"Criar nova medida",startCreating:"Comece criando uma medida, adicionando pontos no mapa",finishMeasurement:"Finalizar medida",lastPoint:"Último ponto",area:"Área",perimeter:"Perímetro",pointLocation:"Localização do ponto",areaMeasurement:"Medida de área",linearMeasurement:"Medida linear",pathDistance:"Distância",centerOnArea:"Centralizar nesta área",centerOnLine:"Centralizar nesta linha",centerOnLocation:"Centralizar nesta localização",cancel:"Cancelar",delete:"Excluir",acres:"Acres",feet:"Pés",kilometers:"Quilômetros",hectares:"Hectares",meters:"Metros",miles:"Milhas",sqfeet:"Pés²",sqmeters:"Metros²",sqmiles:"Milhas²",decPoint:",",thousandsSep:"."}},{}],40:[function(a,b,c){b.exports={measure:"Medições",measureDistancesAndAreas:"Medir distâncias e áreas",createNewMeasurement:"Criar uma nova medição",startCreating:"Adicione pontos no mapa, para criar uma nova medição",finishMeasurement:"Finalizar medição",lastPoint:"Último ponto",area:"Área",perimeter:"Perímetro",pointLocation:"Localização do ponto",areaMeasurement:"Medição da área",linearMeasurement:"Medição linear",pathDistance:"Distância",centerOnArea:"Centrar nesta área",centerOnLine:"Centrar nesta linha",centerOnLocation:"Centrar nesta localização",cancel:"Cancelar",delete:"Eliminar",acres:"Acres",feet:"Pés",kilometers:"Kilômetros",hectares:"Hectares",meters:"Metros",miles:"Milhas",sqfeet:"Pés²",sqmeters:"Metros²",sqmiles:"Milhas²",decPoint:",",thousandsSep:"."}},{}],41:[function(a,b,c){b.exports={measure:"Измерение",measureDistancesAndAreas:"Измерение расстояний и площади",createNewMeasurement:"Создать новое измерение",startCreating:"Для начала измерения добавьте точку на карту",finishMeasurement:"Закончить измерение",lastPoint:"Последняя точка",area:"Область",perimeter:"Периметр",pointLocation:"Местоположение точки",areaMeasurement:"Измерение области",linearMeasurement:"Линейное измерение",pathDistance:"Расстояние",centerOnArea:"Сфокусироваться на данной области",centerOnLine:"Сфокусироваться на данной линии",centerOnLocation:"Сфокусироваться на данной местности",cancel:"Отменить",delete:"Удалить",acres:"акры",feet:"фут",kilometers:"км",hectares:"га",meters:"м",miles:"миль",sqfeet:"футов²",sqmeters:"м²",sqmiles:"миль²",decPoint:".",thousandsSep:","}},{}],42:[function(a,b,c){b.exports={measure:"Mäta",measureDistancesAndAreas:"Mäta avstånd och yta",createNewMeasurement:"Skapa ny mätning",startCreating:"Börja mätning genom att lägga till punkter på kartan",finishMeasurement:"Avsluta mätning",lastPoint:"Sista punkt",area:"Yta",perimeter:"Omkrets",pointLocation:"Punktens Läge",areaMeasurement:"Arealmätning",linearMeasurement:"Längdmätning",pathDistance:"Total linjelängd",centerOnArea:"Centrera på detta område",centerOnLine:"Centrera på denna linje",centerOnLocation:"Centrera på denna punkt",cancel:"Avbryt",delete:"Radera",acres:"Tunnland",feet:"Fot",kilometers:"Kilometer",hectares:"Hektar",meters:"Meter",miles:"Miles",sqfeet:"Kvadratfot",sqmeters:"Kvadratmeter",sqmiles:"Kvadratmiles",decPoint:",",thousandsSep:" "}},{}],43:[function(a,b,c){b.exports={measure:"Hesapla",measureDistancesAndAreas:"Uzaklık ve alan hesapla",createNewMeasurement:"Yeni hesaplama",startCreating:"Yeni nokta ekleyerek hesaplamaya başla",finishMeasurement:"Hesaplamayı bitir",lastPoint:"Son nokta",area:"Alan",perimeter:"Çevre uzunluğu",pointLocation:"Nokta yeri",areaMeasurement:"Alan hesaplaması",linearMeasurement:"Doğrusal hesaplama",pathDistance:"Yol uzunluğu",centerOnArea:"Bu alana odaklan",centerOnLine:"Bu doğtuya odaklan",centerOnLocation:"Bu yere odaklan",cancel:"Çıkış",delete:"Sil",acres:"Dönüm",feet:"Feet",kilometers:"Kilometre",hectares:"Hektar",meters:"Metre",miles:"Mil",sqfeet:"Feet kare",sqmeters:"Metre kare",sqmiles:"Mil kare",decPoint:".",thousandsSep:","}},{}],44:[function(a,b,c){(function(b){var c=a("underscore"),d="undefined"!=typeof window?window.L:"undefined"!=typeof b?b.L:null,e=a("humanize"),f=a("./units"),g=a("./calc"),h=a("./dom"),i=h.$,j=a("./mapsymbology"),k=c.template('<%= i18n.__(\'measure\') %>\n
    \n
    \n

    <%= i18n.__(\'measureDistancesAndAreas\') %>

    \n \n
    \n
    \n

    <%= i18n.__(\'measureDistancesAndAreas\') %>

    \n

    <%= i18n.__(\'startCreating\') %>

    \n
    \n \n
    \n
    '),l=c.template('
    \n

    <%= i18n.__(\'lastPoint\') %>

    \n

    <%= model.lastCoord.dms.y %> / <%= model.lastCoord.dms.x %>

    \n

    <%= humanize.numberFormat(model.lastCoord.dd.y, 6) %> / <%= humanize.numberFormat(model.lastCoord.dd.x, 6) %>

    \n
    \n<% if (model.pointCount > 1) { %>\n
    \n

    <%= i18n.__(\'pathDistance\') %> <%= model.lengthDisplay %>

    \n
    \n<% } %>\n<% if (model.pointCount > 2) { %>\n
    \n

    <%= i18n.__(\'area\') %> <%= model.areaDisplay %>

    \n
    \n<% } %>'),m=c.template('

    <%= i18n.__(\'pointLocation\') %>

    \n

    <%= model.lastCoord.dms.y %> / <%= model.lastCoord.dms.x %>

    \n

    <%= humanize.numberFormat(model.lastCoord.dd.y, 6) %> / <%= humanize.numberFormat(model.lastCoord.dd.x, 6) %>

    \n'),n=c.template('

    <%= i18n.__(\'linearMeasurement\') %>

    \n

    <%= model.lengthDisplay %>

    \n'),o=c.template('

    <%= i18n.__(\'areaMeasurement\') %>

    \n

    <%= model.areaDisplay %>

    \n

    <%= model.lengthDisplay %> <%= i18n.__(\'perimeter\') %>

    \n'),p=new(a("i18n-2"))({devMode:!1,locales:{ca:a("./i18n/ca"),cn:a("./i18n/cn"),da:a("./i18n/da"),de:a("./i18n/de"),de_CH:a("./i18n/de_CH"),en:a("./i18n/en"),en_UK:a("./i18n/en_UK"),es:a("./i18n/es"),fa:a("./i18n/fa"),fil_PH:a("./i18n/fil_PH"),fr:a("./i18n/fr"),it:a("./i18n/it"),nl:a("./i18n/nl"),pl:a("./i18n/pl"),pt_BR:a("./i18n/pt_BR"),pt_PT:a("./i18n/pt_PT"),ru:a("./i18n/ru"),sv:a("./i18n/sv"),tr:a("./i18n/tr")}});d.Control.Measure=d.Control.extend({_className:"leaflet-control-measure",options:{units:{},position:"topright",primaryLengthUnit:"feet",secondaryLengthUnit:"miles",primaryAreaUnit:"acres",activeColor:"#ABE67E",completedColor:"#C8F2BE",captureZIndex:1e4,popupOptions:{className:"leaflet-measure-resultpopup",autoPanPadding:[10,10]}},initialize:function(a){d.setOptions(this,a),this.options.units=d.extend({},f,this.options.units),this._symbols=new j(c.pick(this.options,"activeColor","completedColor")),p.setLocale(this.options.localization)},onAdd:function(a){return this._map=a,this._latlngs=[],this._initLayout(),a.on("click",this._collapse,this),this._layer=d.layerGroup().addTo(a),this._container},onRemove:function(a){a.off("click",this._collapse,this),a.removeLayer(this._layer)},_initLayout:function(){var a,b,c,e,f=this._className,g=this._container=d.DomUtil.create("div",f);g.innerHTML=k({model:{className:f},i18n:p}),g.setAttribute("aria-haspopup",!0),d.Browser.touch?d.DomEvent.on(g,"click",d.DomEvent.stopPropagation):(d.DomEvent.disableClickPropagation(g),d.DomEvent.disableScrollPropagation(g)),a=this.$toggle=i(".js-toggle",g),this.$interaction=i(".js-interaction",g),b=i(".js-start",g),c=i(".js-cancel",g),e=i(".js-finish",g),this.$startPrompt=i(".js-startprompt",g),this.$measuringPrompt=i(".js-measuringprompt",g),this.$startHelp=i(".js-starthelp",g),this.$results=i(".js-results",g),this.$measureTasks=i(".js-measuretasks",g),this._collapse(),this._updateMeasureNotStarted(),d.Browser.android||(d.DomEvent.on(g,"mouseenter",this._expand,this),d.DomEvent.on(g,"mouseleave",this._collapse,this)),d.DomEvent.on(a,"click",d.DomEvent.stop),d.Browser.touch?d.DomEvent.on(a,"click",this._expand,this):d.DomEvent.on(a,"focus",this._expand,this),d.DomEvent.on(b,"click",d.DomEvent.stop),d.DomEvent.on(b,"click",this._startMeasure,this),d.DomEvent.on(c,"click",d.DomEvent.stop),d.DomEvent.on(c,"click",this._finishMeasure,this),d.DomEvent.on(e,"click",d.DomEvent.stop),d.DomEvent.on(e,"click",this._handleMeasureDoubleClick,this); -},_expand:function(){h.hide(this.$toggle),h.show(this.$interaction)},_collapse:function(){this._locked||(h.hide(this.$interaction),h.show(this.$toggle))},_updateMeasureNotStarted:function(){h.hide(this.$startHelp),h.hide(this.$results),h.hide(this.$measureTasks),h.hide(this.$measuringPrompt),h.show(this.$startPrompt)},_updateMeasureStartedNoPoints:function(){h.hide(this.$results),h.show(this.$startHelp),h.show(this.$measureTasks),h.hide(this.$startPrompt),h.show(this.$measuringPrompt)},_updateMeasureStartedWithPoints:function(){h.hide(this.$startHelp),h.show(this.$results),h.show(this.$measureTasks),h.hide(this.$startPrompt),h.show(this.$measuringPrompt)},_startMeasure:function(){this._locked=!0,this._measureVertexes=d.featureGroup().addTo(this._layer),this._captureMarker=d.marker(this._map.getCenter(),{clickable:!0,zIndexOffset:this.options.captureZIndex,opacity:0}).addTo(this._layer),this._setCaptureMarkerIcon(),this._captureMarker.on("mouseout",this._handleMapMouseOut,this).on("dblclick",this._handleMeasureDoubleClick,this).on("click",this._handleMeasureClick,this),this._map.on("mousemove",this._handleMeasureMove,this).on("mouseout",this._handleMapMouseOut,this).on("move",this._centerCaptureMarker,this).on("resize",this._setCaptureMarkerIcon,this),d.DomEvent.on(this._container,"mouseenter",this._handleMapMouseOut,this),this._updateMeasureStartedNoPoints(),this._map.fire("measurestart",null,!1)},_finishMeasure:function(){var a=c.extend({},this._resultsModel,{points:this._latlngs});this._locked=!1,d.DomEvent.off(this._container,"mouseover",this._handleMapMouseOut,this),this._clearMeasure(),this._captureMarker.off("mouseout",this._handleMapMouseOut,this).off("dblclick",this._handleMeasureDoubleClick,this).off("click",this._handleMeasureClick,this),this._map.off("mousemove",this._handleMeasureMove,this).off("mouseout",this._handleMapMouseOut,this).off("move",this._centerCaptureMarker,this).off("resize",this._setCaptureMarkerIcon,this),this._layer.removeLayer(this._measureVertexes).removeLayer(this._captureMarker),this._measureVertexes=null,this._updateMeasureNotStarted(),this._collapse(),this._map.fire("measurefinish",a,!1)},_clearMeasure:function(){this._latlngs=[],this._resultsModel=null,this._measureVertexes.clearLayers(),this._measureDrag&&this._layer.removeLayer(this._measureDrag),this._measureArea&&this._layer.removeLayer(this._measureArea),this._measureBoundary&&this._layer.removeLayer(this._measureBoundary),this._measureDrag=null,this._measureArea=null,this._measureBoundary=null},_centerCaptureMarker:function(){this._captureMarker.setLatLng(this._map.getCenter())},_setCaptureMarkerIcon:function(){this._captureMarker.setIcon(d.divIcon({iconSize:this._map.getSize().multiplyBy(2)}))},_getMeasurementDisplayStrings:function(a){function b(a,b,e,f,g){var h;return b&&d[b]?(h=c(a,d[b],f,g),e&&d[e]&&(h=h+" ("+c(a,d[e],f,g)+")")):h=c(a,null,f,g),h}function c(a,b,c,d){return b&&b.factor&&b.display?e.numberFormat(a*b.factor,b.decimals||0,c||p.__("decPoint"),d||p.__("thousandsSep"))+" "+p.__([b.display])||b.display:e.numberFormat(a,0,c||p.__("decPoint"),d||p.__("thousandsSep"))}var d=this.options.units;return{lengthDisplay:b(a.length,this.options.primaryLengthUnit,this.options.secondaryLengthUnit,this.options.decPoint,this.options.thousandsSep),areaDisplay:b(a.area,this.options.primaryAreaUnit,this.options.secondaryAreaUnit,this.options.decPoint,this.options.thousandsSep)}},_updateResults:function(){var a=g.measure(this._latlngs),b=this._resultsModel=c.extend({},a,this._getMeasurementDisplayStrings(a),{pointCount:this._latlngs.length});this.$results.innerHTML=l({model:b,humanize:e,i18n:p})},_handleMeasureMove:function(a){this._measureDrag?this._measureDrag.setLatLng(a.latlng):this._measureDrag=d.circleMarker(a.latlng,this._symbols.getSymbol("measureDrag")).addTo(this._layer),this._measureDrag.bringToFront()},_handleMeasureDoubleClick:function(){var a,b,f,h,j,k,l=this._latlngs;this._finishMeasure(),l.length&&(l.length>2&&l.push(c.first(l)),a=g.measure(l),1===l.length?(b=d.circleMarker(l[0],this._symbols.getSymbol("resultPoint")),h=m({model:a,humanize:e,i18n:p})):2===l.length?(b=d.polyline(l,this._symbols.getSymbol("resultLine")),h=n({model:c.extend({},a,this._getMeasurementDisplayStrings(a)),humanize:e,i18n:p})):(b=d.polygon(l,this._symbols.getSymbol("resultArea")),h=o({model:c.extend({},a,this._getMeasurementDisplayStrings(a)),humanize:e,i18n:p})),f=d.DomUtil.create("div",""),f.innerHTML=h,j=i(".js-zoomto",f),j&&(d.DomEvent.on(j,"click",d.DomEvent.stop),d.DomEvent.on(j,"click",function(){b.getBounds?this._map.fitBounds(b.getBounds(),{padding:[20,20],maxZoom:17}):b.getLatLng&&this._map.panTo(b.getLatLng())},this)),k=i(".js-deletemarkup",f),k&&(d.DomEvent.on(k,"click",d.DomEvent.stop),d.DomEvent.on(k,"click",function(){this._layer.removeLayer(b)},this)),b.addTo(this._layer),b.bindPopup(f,this.options.popupOptions),b.getBounds?b.openPopup(b.getBounds().getCenter()):b.getLatLng&&b.openPopup(b.getLatLng()))},_handleMeasureClick:function(a){var b=this._map.mouseEventToLatLng(a.originalEvent),d=c.last(this._latlngs),e=this._symbols.getSymbol("measureVertex");d&&b.equals(d)||(this._latlngs.push(b),this._addMeasureArea(this._latlngs),this._addMeasureBoundary(this._latlngs),this._measureVertexes.eachLayer(function(a){a.setStyle(e),a._path.setAttribute("class",e.className)}),this._addNewVertex(b),this._measureBoundary&&this._measureBoundary.bringToFront(),this._measureVertexes.bringToFront()),this._updateResults(),this._updateMeasureStartedWithPoints()},_handleMapMouseOut:function(){this._measureDrag&&(this._layer.removeLayer(this._measureDrag),this._measureDrag=null)},_addNewVertex:function(a){d.circleMarker(a,this._symbols.getSymbol("measureVertexActive")).addTo(this._measureVertexes)},_addMeasureArea:function(a){return a.length<3?void(this._measureArea&&(this._layer.removeLayer(this._measureArea),this._measureArea=null)):void(this._measureArea?this._measureArea.setLatLngs(a):this._measureArea=d.polygon(a,this._symbols.getSymbol("measureArea")).addTo(this._layer))},_addMeasureBoundary:function(a){return a.length<2?void(this._measureBoundary&&(this._layer.removeLayer(this._measureBoundary),this._measureBoundary=null)):void(this._measureBoundary?this._measureBoundary.setLatLngs(a):this._measureBoundary=d.polyline(a,this._symbols.getSymbol("measureBoundary")).addTo(this._layer))}}),d.Map.mergeOptions({measureControl:!1}),d.Map.addInitHook(function(){this.options.measureControl&&(this.measureControl=(new d.Control.Measure).addTo(this))}),d.control.measure=function(a){return new d.Control.Measure(a)}}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./calc":23,"./dom":24,"./i18n/ca":25,"./i18n/cn":26,"./i18n/da":27,"./i18n/de":28,"./i18n/de_CH":29,"./i18n/en":30,"./i18n/en_UK":31,"./i18n/es":32,"./i18n/fa":33,"./i18n/fil_PH":34,"./i18n/fr":35,"./i18n/it":36,"./i18n/nl":37,"./i18n/pl":38,"./i18n/pt_BR":39,"./i18n/pt_PT":40,"./i18n/ru":41,"./i18n/sv":42,"./i18n/tr":43,"./mapsymbology":45,"./units":46,humanize:16,"i18n-2":18,underscore:22}],45:[function(a,b,c){var d=a("underscore"),e=a("color"),f=function(a){this.setOptions(a)};f.DEFAULTS={activeColor:"#ABE67E",completedColor:"#C8F2BE"},d.extend(f.prototype,{setOptions:function(a){return this._options=d.extend({},f.DEFAULTS,this._options,a),this},getSymbol:function(a){var b={measureDrag:{clickable:!1,radius:4,color:this._options.activeColor,weight:2,opacity:.7,fillColor:this._options.activeColor,fillOpacity:.5,className:"layer-measuredrag"},measureArea:{clickable:!1,stroke:!1,fillColor:this._options.activeColor,fillOpacity:.2,className:"layer-measurearea"},measureBoundary:{clickable:!1,color:this._options.activeColor,weight:2,opacity:.9,fill:!1,className:"layer-measureboundary"},measureVertex:{clickable:!1,radius:4,color:this._options.activeColor,weight:2,opacity:1,fillColor:this._options.activeColor,fillOpacity:.7,className:"layer-measurevertex"},measureVertexActive:{clickable:!1,radius:4,color:this._options.activeColor,weight:2,opacity:1,fillColor:e(this._options.activeColor).darken(.15),fillOpacity:.7,className:"layer-measurevertex active"},resultArea:{clickable:!0,color:this._options.completedColor,weight:2,opacity:.9,fillColor:this._options.completedColor,fillOpacity:.2,className:"layer-measure-resultarea"},resultLine:{clickable:!0,color:this._options.completedColor,weight:3,opacity:.9,fill:!1,className:"layer-measure-resultline"},resultPoint:{clickable:!0,radius:4,color:this._options.completedColor,weight:2,opacity:1,fillColor:this._options.completedColor,fillOpacity:.7,className:"layer-measure-resultpoint"}};return b[a]}}),b.exports=f},{color:6,underscore:22}],46:[function(a,b,c){b.exports={acres:{factor:24711e-8,display:"acres",decimals:2},feet:{factor:3.2808,display:"feet",decimals:0},kilometers:{factor:.001,display:"kilometers",decimals:2},hectares:{factor:1e-4,display:"hectares",decimals:2},meters:{factor:1,display:"meters",decimals:0},miles:{factor:3.2808/5280,display:"miles",decimals:2},sqfeet:{factor:10.7639,display:"sqfeet",decimals:0},sqmeters:{factor:1,display:"sqmeters",decimals:0},sqmiles:{factor:3.86102e-7,display:"sqmiles",decimals:2}}},{}]},{},[44]); \ No newline at end of file diff --git a/plugins/measure/public/main.js b/plugins/measure/public/main.js index 5fbc55a5..d89eb536 100644 --- a/plugins/measure/public/main.js +++ b/plugins/measure/public/main.js @@ -1,11 +1,6 @@ PluginsAPI.Map.willAddControls([ - 'measure/leaflet-measure.css', - 'measure/leaflet-measure.min.js' - ], function(options){ - L.control.measure({ - primaryLengthUnit: 'meters', - secondaryLengthUnit: 'feet', - primaryAreaUnit: 'sqmeters', - secondaryAreaUnit: 'acres' - }).addTo(options.map); + 'measure/build/app.js', + 'measure/build/app.css' + ], function(options, App){ + new App(options.map); }); diff --git a/plugins/volume/public/package.json b/plugins/measure/public/package.json similarity index 71% rename from plugins/volume/public/package.json rename to plugins/measure/public/package.json index fff69c1b..6373d65c 100644 --- a/plugins/volume/public/package.json +++ b/plugins/measure/public/package.json @@ -1,5 +1,5 @@ { - "name": "volume", + "name": "measure", "version": "1.0.0", "description": "", "main": "index.js", @@ -8,7 +8,5 @@ }, "author": "", "license": "ISC", - "dependencies": { - "leaflet-draw": "^1.0.2" - } + "dependencies": {} } diff --git a/plugins/volume/public/webpack.config.js b/plugins/measure/public/webpack.config.js similarity index 95% rename from plugins/volume/public/webpack.config.js rename to plugins/measure/public/webpack.config.js index a5466d92..391672a9 100644 --- a/plugins/volume/public/webpack.config.js +++ b/plugins/measure/public/webpack.config.js @@ -69,6 +69,8 @@ module.exports = { "jquery": "jQuery", "SystemJS": "SystemJS", "PluginsAPI": "PluginsAPI", - "leaflet": "leaflet" + "leaflet": "leaflet", + "ReactDOM": "ReactDOM", + "React": "React" } } \ No newline at end of file diff --git a/plugins/volume/__init__.py b/plugins/volume/__init__.py deleted file mode 100644 index 48aad58e..00000000 --- a/plugins/volume/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .plugin import * diff --git a/plugins/volume/api.py b/plugins/volume/api.py deleted file mode 100644 index e5de190c..00000000 --- a/plugins/volume/api.py +++ /dev/null @@ -1,21 +0,0 @@ -from rest_framework import serializers -from rest_framework import status -from rest_framework.response import Response - -from app.api.tasks import TaskNestedView - - -class GeoJSONSerializer(serializers.Serializer): - geometry = serializers.JSONField(help_text="Polygon contour defining the volume area to compute") - - -class TaskVolume(TaskNestedView): - def post(self, request, pk=None): - task = self.get_and_check_task(request, pk) - serializer = GeoJSONSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - #result=task.get_volume(serializer.geometry) - return Response(serializer.geometry, status=status.HTTP_200_OK) - - - diff --git a/plugins/volume/manifest.json b/plugins/volume/manifest.json deleted file mode 100644 index 9ffd9d7e..00000000 --- a/plugins/volume/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "Volume Measurements", - "webodmMinVersion": "0.5.0", - "description": "A plugin to compute volume measurements from a DSM", - "version": "0.1.0", - "author": "Abdelkoddouss Izem, Piero Toffanin", - "email": "pt@masseranolabs.com", - "repository": "https://github.com/OpenDroneMap/WebODM", - "tags": ["volume", "measurements"], - "homepage": "https://github.com/OpenDroneMap/WebODM", - "experimental": true, - "deprecated": false -} \ No newline at end of file diff --git a/plugins/volume/plugin.py b/plugins/volume/plugin.py deleted file mode 100644 index e3054e0e..00000000 --- a/plugins/volume/plugin.py +++ /dev/null @@ -1,33 +0,0 @@ -from app.plugins import MountPoint -from app.plugins import PluginBase -from .api import TaskVolume - -class Plugin(PluginBase): - def include_js_files(self): - return ['main.js'] - - def api_mount_points(self): - return [ - MountPoint('task/(?P[^/.]+)/calculate$', TaskVolume.as_view()) - ] - - - # def get_volume(self, geojson): - # try: - # raster_path= self.assets_path("odm_dem", "dsm.tif") - # raster=gdal.Open(raster_path) - # gt=raster.GetGeoTransform() - # rb=raster.GetRasterBand(1) - # gdal.UseExceptions() - # geosom = reprojson(geojson, raster) - # coords=[(entry[0],entry[1]) for entry in rings(raster_path, geosom)] - # GSD=gt[1] - # volume=0 - # print(rings(raster_path, geosom)) - # print(GSD) - # med=statistics.median(entry[2] for entry in rings(raster_path, geosom)) - # clip=clip_raster(raster_path, geosom, gt=None, nodata=-9999) - # return ((clip-med)*GSD*GSD)[clip!=-9999.0].sum() - # - # except FileNotFoundError as e: - # logger.warning(e) \ No newline at end of file diff --git a/plugins/volume/public/app.jsx b/plugins/volume/public/app.jsx deleted file mode 100644 index d9646c0c..00000000 --- a/plugins/volume/public/app.jsx +++ /dev/null @@ -1,92 +0,0 @@ -import 'leaflet-draw'; -import 'leaflet-draw/dist/leaflet.draw.css'; -import $ from 'jquery'; -import L from 'leaflet'; - -module.exports = class App{ - constructor(map){ - this.map = map; - } - - setupVolumeControls(){ - const { map } = this; - - const editableLayers = new L.FeatureGroup(); - map.addLayer(editableLayers); - - const options = { - position: 'topright', - draw: { - toolbar: { - buttons: { - polygon: 'Draw an awesome polygon' - } - }, - polyline: false, - polygon: { - showArea: true, - showLength: true, - - allowIntersection: false, // Restricts shapes to simple polygons - drawError: { - color: '#e1e100', // Color the shape will turn when intersects - message: 'Oh snap! Area cannot have intersections!' // Message that will show when intersect - }, - shapeOptions: { - // color: '#bada55' - } - }, - circle: false, - rectangle: false, - marker: false, - circlemarker: false - }, - edit: { - featureGroup: editableLayers, - // remove: false - edit: { - selectedPathOptions: { - maintainColor: true, - dashArray: '10, 10' - } - } - } - }; - - const drawControl = new L.Control.Draw(options); - map.addControl(drawControl); - - // Is there a better way? - $(drawControl._container) - .find('a.leaflet-draw-draw-polygon') - .attr('title', 'Measure Volume'); - - map.on(L.Draw.Event.CREATED, (e) => { - const { layer } = e; - layer.feature = {geometry: {type: 'Polygon'} }; - - var paramList; - // $.ajax({ - // type: 'POST', - // async: false, - // url: `/api/projects/${meta.task.project}/tasks/${meta.task.id}/volume`, - // data: JSON.stringify(e.layer.toGeoJSON()), - // contentType: "application/json", - // success: function (msg) { - // paramList = msg; - // }, - // error: function (jqXHR, textStatus, errorThrown) { - // alert("get session failed " + errorThrown); - // } - // }); - - e.layer.bindPopup('Volume: test'); - - editableLayers.addLayer(layer); - }); - - map.on(L.Draw.Event.EDITED, (e) => { - console.log("EDITED ", e); - }); - } -} \ No newline at end of file diff --git a/plugins/volume/public/main.js b/plugins/volume/public/main.js deleted file mode 100644 index 7e09ce2f..00000000 --- a/plugins/volume/public/main.js +++ /dev/null @@ -1,7 +0,0 @@ -PluginsAPI.Map.willAddControls([ - 'volume/build/app.js', - 'volume/build/app.css' - ], function(options, App){ - const app = new App(options.map); - app.setupVolumeControls(); -}); diff --git a/webpack.config.js b/webpack.config.js index a0051268..c729b948 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -73,6 +73,7 @@ module.exports = { // require("jquery") is external and available // on the global let jQuery "jquery": "jQuery", - "SystemJS": "SystemJS" + "SystemJS": "SystemJS", + "React": "React" } } \ No newline at end of file From ae0a0cbf6787bb8d6e17bd5aacd26565295893f6 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 29 Mar 2018 17:28:15 -0400 Subject: [PATCH 14/18] Volume measurements! Processing done in celery, updated README --- README.md | 2 +- app/boot.py | 4 +++ app/plugins/grass_engine.py | 48 +++++++++++++++++-------- app/tests/test_plugins.py | 3 +- plugins/measure/api.py | 37 +++++++++++++------ plugins/measure/calc_volume.grass | 8 ++++- plugins/measure/public/MeasurePopup.jsx | 7 ++-- plugins/measure/public/app.jsx | 4 +-- plugins/measure/public/dist | 1 - plugins/measure/public/package.json | 4 ++- requirements.txt | 1 + webodm/settings.py | 1 + worker/tasks.py | 7 ++++ 13 files changed, 89 insertions(+), 38 deletions(-) delete mode 120000 plugins/measure/public/dist diff --git a/README.md b/README.md index 4bd8be2f..ddd63486 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Developer, I'm looking to build an app that will stay behind a firewall and just - [X] 2D Map Display - [X] 3D Model Display - [ ] NDVI display -- [ ] Volumetric Measurements +- [X] Volumetric Measurements - [X] Cluster management and setup. - [ ] Mission Planner - [X] Plugins/Webhooks System diff --git a/app/boot.py b/app/boot.py index 9a929df3..ea898715 100644 --- a/app/boot.py +++ b/app/boot.py @@ -35,6 +35,10 @@ def boot(): if settings.DEBUG: logger.warning("Debug mode is ON (for development this is OK)") + # Make sure our app/media/tmp folder exists + if not os.path.exists(settings.MEDIA_TMP): + os.mkdir(settings.MEDIA_TMP) + # Check default group try: default_group, created = Group.objects.get_or_create(name='Default') diff --git a/app/plugins/grass_engine.py b/app/plugins/grass_engine.py index 94e6b6c8..885780ec 100644 --- a/app/plugins/grass_engine.py +++ b/app/plugins/grass_engine.py @@ -3,8 +3,12 @@ import shutil import tempfile import subprocess import os +import geojson + from string import Template +from webodm import settings + logger = logging.getLogger('app.logger') class GrassEngine: @@ -18,24 +22,28 @@ class GrassEngine: else: logger.info("Initializing GRASS engine using {}".format(self.grass_binary)) - def create_context(self): + def create_context(self, serialized_context = {}): if self.grass_binary is None: raise GrassEngineException("GRASS engine is unavailable") - return GrassContext(self.grass_binary) - + return GrassContext(self.grass_binary, **serialized_context) class GrassContext: - def __init__(self, grass_binary): + def __init__(self, grass_binary, tmpdir = None, template_args = {}, location = None): self.grass_binary = grass_binary - self.cwd = tempfile.mkdtemp('_webodm_grass') - self.template_args = {} - self.location = None + if tmpdir is None: + tmpdir = os.path.basename(tempfile.mkdtemp('_grass_engine', dir=settings.MEDIA_TMP)) + self.tmpdir = tmpdir + self.template_args = template_args + self.location = location + + def get_cwd(self): + return os.path.join(settings.MEDIA_TMP, self.tmpdir) def add_file(self, filename, source, use_as_location=False): param = os.path.splitext(filename)[0] # filename without extension - dst_path = os.path.abspath(os.path.join(self.cwd, filename)) - with open(dst_path) as f: + dst_path = os.path.abspath(os.path.join(self.get_cwd(), filename)) + with open(dst_path, 'w') as f: f.write(source) self.template_args[param] = dst_path @@ -51,7 +59,7 @@ class GrassContext: """ :param location: either a "epsg:XXXXX" string or a path to a geospatial file defining the location """ - if not location.startsWith('epsg:'): + if not location.startswith('epsg:'): location = os.path.abspath(location) self.location = location @@ -74,23 +82,33 @@ class GrassContext: tmpl = Template(script_content) # Write script to disk - with open(os.path.join(self.cwd, 'script.sh')) as f: + with open(os.path.join(self.get_cwd(), 'script.sh'), 'w') as f: f.write(tmpl.substitute(self.template_args)) # Execute it p = subprocess.Popen([self.grass_binary, '-c', self.location, 'location', '--exec', 'sh', 'script.sh'], - cwd=self.cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + cwd=self.get_cwd(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = p.communicate() + out = out.decode('utf-8').strip() + err = err.decode('utf-8').strip() + if p.returncode == 0: return out else: - raise GrassEngineException("Could not execute GRASS script {} from {}: {}".format(script, self.cwd, err)) + raise GrassEngineException("Could not execute GRASS script {} from {}: {}".format(script, self.get_cwd(), err)) + + def serialize(self): + return { + 'tmpdir': self.tmpdir, + 'template_args': self.template_args, + 'location': self.location + } def __del__(self): # Cleanup - if os.path.exists(self.cwd): - shutil.rmtree(self.cwd) + if os.path.exists(self.get_cwd()): + shutil.rmtree(self.get_cwd()) class GrassEngineException(Exception): pass diff --git a/app/tests/test_plugins.py b/app/tests/test_plugins.py index becb52c8..004d6e9c 100644 --- a/app/tests/test_plugins.py +++ b/app/tests/test_plugins.py @@ -46,5 +46,4 @@ class TestPlugins(BootTestCase): self.assertTrue(os.path.exists(test_plugin.get_path("public/node_modules"))) # TODO: - # test API endpoints - # test python hooks + # test GRASS engine diff --git a/plugins/measure/api.py b/plugins/measure/api.py index f0c5234b..1a2ee9b2 100644 --- a/plugins/measure/api.py +++ b/plugins/measure/api.py @@ -1,13 +1,16 @@ import os +import json from rest_framework import serializers from rest_framework import status from rest_framework.response import Response from app.api.tasks import TaskNestedView -from app.plugins.grass_engine import grass +from worker.tasks import execute_grass_script +from app.plugins.grass_engine import grass, GrassEngineException +from geojson import Feature, Point, FeatureCollection class GeoJSONSerializer(serializers.Serializer): area = serializers.JSONField(help_text="Polygon contour defining the volume area to compute") @@ -22,18 +25,30 @@ class TaskVolume(TaskNestedView): serializer = GeoJSONSerializer(data=request.data) serializer.is_valid(raise_exception=True) + area = serializer['area'].value + points = FeatureCollection([Feature(geometry=Point(coords)) for coords in area['geometry']['coordinates'][0]]) + dsm = os.path.abspath(task.get_asset_download_path("dsm.tif")) - # context = grass.create_context() - # context.add_file('area_file.geojson', serializer['area']) - # context.add_file('points_file.geojson', 'aaa') - # context.add_param('dsm_file', os.path.abspath(task.get_asset_download_path("dsm.tif"))) - # context.execute(os.path.join( - # os.path.dirname(os.path.abspath(__file__), - # "calc_volume.grass" - # ))) + try: + context = grass.create_context() + context.add_file('area_file.geojson', json.dumps(area)) + context.add_file('points_file.geojson', str(points)) + context.add_param('dsm_file', dsm) + context.set_location(dsm) + + output = execute_grass_script.delay(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "calc_volume.grass" + ), context.serialize()).get() + + cols = output.split(':') + if len(cols) == 7: + return Response({'volume': str(abs(float(cols[6])))}, status=status.HTTP_200_OK) + else: + raise GrassEngineException("Invalid GRASS output: {}".format(output)) + except GrassEngineException as e: + return Response({'error': str(e)}, status=status.HTTP_200_OK) - print(serializer['area']) - return Response(30, status=status.HTTP_200_OK) diff --git a/plugins/measure/calc_volume.grass b/plugins/measure/calc_volume.grass index 30427b2f..b990287e 100755 --- a/plugins/measure/calc_volume.grass +++ b/plugins/measure/calc_volume.grass @@ -7,9 +7,15 @@ v.import input=${area_file} output=polygon_area --overwrite v.import input=${points_file} output=polygon_points --overwrite v.buffer -s --overwrite input=polygon_area type=area output=region distance=3 minordistance=3 +r.external input=${dsm_file} output=dsm --overwrite + +g.region rast=dsm g.region vector=region -r.import input=${dsm_file} output=dsm --overwrite +# prevent : removing eventual existing mask +r.mask -r +r.mask vect=region + v.what.rast map=polygon_points raster=dsm column=height v.to.rast input=polygon_area output=r_polygon_area use=val value=255 --overwrite diff --git a/plugins/measure/public/MeasurePopup.jsx b/plugins/measure/public/MeasurePopup.jsx index 5b2a3e02..366570fc 100644 --- a/plugins/measure/public/MeasurePopup.jsx +++ b/plugins/measure/public/MeasurePopup.jsx @@ -18,10 +18,9 @@ module.exports = class MeasurePopup extends React.Component { constructor(props){ super(props); - console.log(props); this.state = { - volume: null, // to be calculated, + volume: null, // to be calculated error: "" }; } @@ -51,7 +50,7 @@ module.exports = class MeasurePopup extends React.Component { contentType: "application/json" }).done(result => { if (result.volume){ - this.setState({volume}); + this.setState({volume: parseFloat(result.volume)}); }else if (result.error){ this.setState({error: result.error}); }else{ @@ -95,7 +94,7 @@ module.exports = class MeasurePopup extends React.Component {

    Area: {this.props.model.areaDisplay}

    Perimeter: {this.props.model.lengthDisplay}

    {volume === null && !error &&

    Volume: computing...

    } - {typeof volume === "number" &&

    Volume: {volume.toFixed("3")} Cubic Meters

    } + {typeof volume === "number" &&

    Volume: {volume.toFixed("2")} Cubic Meters ({(volume * 35.3147).toFixed(2)} Cubic Feet)

    } {error &&

    Volume: {error}

    }
    ); } diff --git a/plugins/measure/public/app.jsx b/plugins/measure/public/app.jsx index 64414a0a..338fdeba 100644 --- a/plugins/measure/public/app.jsx +++ b/plugins/measure/public/app.jsx @@ -1,7 +1,7 @@ import L from 'leaflet'; import './app.scss'; -import './dist/leaflet-measure'; -import './dist/leaflet-measure.css'; +import 'leaflet-measure-ex/dist/leaflet-measure'; +import 'leaflet-measure-ex/dist/leaflet-measure.css'; import MeasurePopup from './MeasurePopup'; import ReactDOM from 'ReactDOM'; import React from 'react'; diff --git a/plugins/measure/public/dist b/plugins/measure/public/dist deleted file mode 120000 index 2296f3a0..00000000 --- a/plugins/measure/public/dist +++ /dev/null @@ -1 +0,0 @@ -../../../../leaflet-measure-ex/dist/ \ No newline at end of file diff --git a/plugins/measure/public/package.json b/plugins/measure/public/package.json index 6373d65c..5066d7c5 100644 --- a/plugins/measure/public/package.json +++ b/plugins/measure/public/package.json @@ -8,5 +8,7 @@ }, "author": "", "license": "ISC", - "dependencies": {} + "dependencies": { + "leaflet-measure-ex": "^3.0.4" + } } diff --git a/requirements.txt b/requirements.txt index 923ddd50..b81604fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,7 @@ djangorestframework-jwt==1.9.0 drf-nested-routers==0.11.1 funcsigs==1.0.2 futures==3.0.5 +geojson==2.3.0 gunicorn==19.7.1 itypes==1.1.0 kombu==4.1.0 diff --git a/webodm/settings.py b/webodm/settings.py index 4682fea9..7adfbe7f 100644 --- a/webodm/settings.py +++ b/webodm/settings.py @@ -249,6 +249,7 @@ CORS_ORIGIN_ALLOW_ALL = True # File uploads MEDIA_ROOT = os.path.join(BASE_DIR, 'app', 'media') +MEDIA_TMP = os.path.join(MEDIA_ROOT, 'tmp') # Store flash messages in cookies MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage' diff --git a/worker/tasks.py b/worker/tasks.py index 672993e7..c5603309 100644 --- a/worker/tasks.py +++ b/worker/tasks.py @@ -8,6 +8,7 @@ from django.db.models import Q from app.models import Project from app.models import Task +from app.plugins.grass_engine import grass from nodeodm import status_codes from nodeodm.models import ProcessingNode from webodm import settings @@ -77,3 +78,9 @@ def process_pending_tasks(): for task in tasks: process_task.delay(task.id) + + +@app.task +def execute_grass_script(script, serialized_context = {}): + ctx = grass.create_context(serialized_context) + return ctx.execute(script) From db81a67c53270c699bcac444aef5261417108d72 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 29 Mar 2018 17:55:19 -0400 Subject: [PATCH 15/18] Better error handling, tweaks --- app/static/app/js/components/Map.jsx | 2 +- app/static/app/js/main.jsx | 2 +- plugins/measure/api.py | 3 ++- plugins/measure/public/MeasurePopup.jsx | 4 +++- plugins/measure/public/MeasurePopup.scss | 6 ++++++ worker/tasks.py | 9 ++++++--- 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/app/static/app/js/components/Map.jsx b/app/static/app/js/components/Map.jsx index 9d96478f..a385b5fa 100644 --- a/app/static/app/js/components/Map.jsx +++ b/app/static/app/js/components/Map.jsx @@ -268,7 +268,7 @@ class Map extends React.Component { handleMapMouseDown(e){ // Make sure the share popup closes - this.shareButton.hidePopup(); + if (this.sharePopup) this.shareButton.hidePopup(); } render() { diff --git a/app/static/app/js/main.jsx b/app/static/app/js/main.jsx index 29d442bf..6bad3c37 100644 --- a/app/static/app/js/main.jsx +++ b/app/static/app/js/main.jsx @@ -6,7 +6,7 @@ import PluginsAPI from './classes/plugins/API'; // Main is always executed first in the page -// We share some objects to avoid having to include it +// We share some objects to avoid having to include them // as a dependency in each component (adds too much space overhead) window.ReactDOM = ReactDOM; window.React = React; diff --git a/plugins/measure/api.py b/plugins/measure/api.py index 1a2ee9b2..d86d321c 100644 --- a/plugins/measure/api.py +++ b/plugins/measure/api.py @@ -40,12 +40,13 @@ class TaskVolume(TaskNestedView): os.path.dirname(os.path.abspath(__file__)), "calc_volume.grass" ), context.serialize()).get() + if isinstance(output, dict) and 'error' in output: raise GrassEngineException(output['error']) cols = output.split(':') if len(cols) == 7: return Response({'volume': str(abs(float(cols[6])))}, status=status.HTTP_200_OK) else: - raise GrassEngineException("Invalid GRASS output: {}".format(output)) + raise GrassEngineException(output) except GrassEngineException as e: return Response({'error': str(e)}, status=status.HTTP_200_OK) diff --git a/plugins/measure/public/MeasurePopup.jsx b/plugins/measure/public/MeasurePopup.jsx index 366570fc..a59dae38 100644 --- a/plugins/measure/public/MeasurePopup.jsx +++ b/plugins/measure/public/MeasurePopup.jsx @@ -36,6 +36,8 @@ module.exports = class MeasurePopup extends React.Component { lastCoord.dd.x )); + console.log(layers); + // Did we select a layer? if (layers.length > 0){ const layer = layers[layers.length - 1]; @@ -95,7 +97,7 @@ module.exports = class MeasurePopup extends React.Component {

    Perimeter: {this.props.model.lengthDisplay}

    {volume === null && !error &&

    Volume: computing...

    } {typeof volume === "number" &&

    Volume: {volume.toFixed("2")} Cubic Meters ({(volume * 35.3147).toFixed(2)} Cubic Feet)

    } - {error &&

    Volume: {error}

    } + {error &&

    Volume: 200 ? 'long' : '')}>{error}

    } ); } } \ No newline at end of file diff --git a/plugins/measure/public/MeasurePopup.scss b/plugins/measure/public/MeasurePopup.scss index 004a85c4..b8241e1b 100644 --- a/plugins/measure/public/MeasurePopup.scss +++ b/plugins/measure/public/MeasurePopup.scss @@ -2,4 +2,10 @@ p{ margin: 0; } + + .error.long{ + overflow: scroll; + display: block; + max-height: 200px; + } } \ No newline at end of file diff --git a/worker/tasks.py b/worker/tasks.py index c5603309..ad84d38d 100644 --- a/worker/tasks.py +++ b/worker/tasks.py @@ -8,7 +8,7 @@ from django.db.models import Q from app.models import Project from app.models import Task -from app.plugins.grass_engine import grass +from app.plugins.grass_engine import grass, GrassEngineException from nodeodm import status_codes from nodeodm.models import ProcessingNode from webodm import settings @@ -82,5 +82,8 @@ def process_pending_tasks(): @app.task def execute_grass_script(script, serialized_context = {}): - ctx = grass.create_context(serialized_context) - return ctx.execute(script) + try: + ctx = grass.create_context(serialized_context) + return ctx.execute(script) + except GrassEngineException as e: + return {'error': str(e)} \ No newline at end of file From 09c90b9cc3e4d4aa01f7769b068f4bf7cc90e554 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 29 Mar 2018 18:56:15 -0400 Subject: [PATCH 16/18] Added grass-core, support for grass 7.6 --- Dockerfile | 2 +- app/plugins/grass_engine.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index b53b9fea..cab4dd12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN printf "deb http://mirror.steadfast.net/debian/ stable main contrib n RUN printf "deb http://mirror.steadfast.net/debian/ testing main contrib non-free\ndeb-src http://mirror.steadfast.net/debian/ testing main contrib non-free" > /etc/apt/sources.list.d/testing.list # Install Node.js GDAL, nginx, letsencrypt, psql -RUN apt-get -qq update && apt-get -qq install -t testing -y binutils libproj-dev gdal-bin nginx && apt-get -qq install -y gettext-base cron certbot postgresql-client-9.6 +RUN apt-get -qq update && apt-get -qq install -t testing -y binutils libproj-dev gdal-bin nginx grass-core && apt-get -qq install -y gettext-base cron certbot postgresql-client-9.6 # Install pip reqs diff --git a/app/plugins/grass_engine.py b/app/plugins/grass_engine.py index 885780ec..5d400945 100644 --- a/app/plugins/grass_engine.py +++ b/app/plugins/grass_engine.py @@ -15,7 +15,8 @@ class GrassEngine: def __init__(self): self.grass_binary = shutil.which('grass7') or \ shutil.which('grass72') or \ - shutil.which('grass74') + shutil.which('grass74') or \ + shutil.which('grass76') if self.grass_binary is None: logger.warning("Could not find a GRASS 7 executable. GRASS scripts will not work.") From 783386a25f0b347d7be752090da011af8f764fa3 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 29 Mar 2018 23:29:06 -0400 Subject: [PATCH 17/18] Removed commented code --- plugins/measure/plugin.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/plugins/measure/plugin.py b/plugins/measure/plugin.py index 48634441..9576fd0b 100644 --- a/plugins/measure/plugin.py +++ b/plugins/measure/plugin.py @@ -10,24 +10,3 @@ class Plugin(PluginBase): return [ MountPoint('task/(?P[^/.]+)/volume', TaskVolume.as_view()) ] - - - # def get_volume(self, geojson): - # try: - # raster_path= self.assets_path("odm_dem", "dsm.tif") - # raster=gdal.Open(raster_path) - # gt=raster.GetGeoTransform() - # rb=raster.GetRasterBand(1) - # gdal.UseExceptions() - # geosom = reprojson(geojson, raster) - # coords=[(entry[0],entry[1]) for entry in rings(raster_path, geosom)] - # GSD=gt[1] - # volume=0 - # print(rings(raster_path, geosom)) - # print(GSD) - # med=statistics.median(entry[2] for entry in rings(raster_path, geosom)) - # clip=clip_raster(raster_path, geosom, gt=None, nodata=-9999) - # return ((clip-med)*GSD*GSD)[clip!=-9999.0].sum() - # - # except FileNotFoundError as e: - # logger.warning(e) \ No newline at end of file From db4710cf307f4959bb79bd38406ddbf96c3d0f91 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 30 Mar 2018 08:38:55 -0400 Subject: [PATCH 18/18] Updated version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 65e91d37..719a7362 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WebODM", - "version": "0.5.0", + "version": "0.5.1", "description": "Open Source Drone Image Processing", "main": "index.js", "scripts": {