From 790672a4adc76de8903990886c7746a51357bccf Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 26 Jul 2016 15:51:41 +0200 Subject: [PATCH] WIP implement displaying and editing of the export area References #4 --- gulpfile.js | 3 +- src/ExportServiceProvider.php | 2 + .../Api/TransectExportAreaController.php | 86 ++++++++ src/Http/routes.php | 16 ++ src/Transect.php | 85 ++++++++ src/public/assets/scripts/annotations.js | 1 + .../ExportAreaSettingsController.js | 37 ++++ .../js/annotations/factories/ExportArea.js | 25 +++ .../js/annotations/services/exportArea.js | 190 ++++++++++++++++++ .../views/annotationsScripts.blade.php | 4 + .../views/annotationsSettings.blade.php | 14 ++ tests/ExportModuleTransectTest.php | 64 ++++++ ...ersApiTransectExportAreaControllerTest.php | 73 +++++++ 13 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 src/Http/Controllers/Api/TransectExportAreaController.php create mode 100644 src/Transect.php create mode 100644 src/public/assets/scripts/annotations.js create mode 100644 src/resources/assets/js/annotations/controllers/ExportAreaSettingsController.js create mode 100644 src/resources/assets/js/annotations/factories/ExportArea.js create mode 100644 src/resources/assets/js/annotations/services/exportArea.js create mode 100644 src/resources/views/annotationsScripts.blade.php create mode 100644 src/resources/views/annotationsSettings.blade.php create mode 100644 tests/ExportModuleTransectTest.php create mode 100644 tests/Http/Controllers/Api/ExportModuleHttpControllersApiTransectExportAreaControllerTest.php diff --git a/gulpfile.js b/gulpfile.js index f7240fd..d4f786b 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -10,9 +10,10 @@ elixir(function (mix) { process.chdir('src'); // mix.sass('main.scss', 'public/assets/styles/main.css'); mix.angular('resources/assets/js/projects/', 'public/assets/scripts', 'projects.js'); + mix.angular('resources/assets/js/annotations/', 'public/assets/scripts', 'annotations.js'); mix.task('publish', 'public/assets/**/*'); }); gulp.task('publish', function () { - gulp.src('').pipe(shell('php ../../../../artisan vendor:publish --provider="Dias\\Modules\\Export\\ExportServiceProvider" --force')); + gulp.src('').pipe(shell('php ../../../../artisan vendor:publish --provider="Dias\\Modules\\Export\\ExportServiceProvider" --tag public --force')); }); diff --git a/src/ExportServiceProvider.php b/src/ExportServiceProvider.php index f96142d..eeda644 100644 --- a/src/ExportServiceProvider.php +++ b/src/ExportServiceProvider.php @@ -37,6 +37,8 @@ public function boot(Modules $modules,Router $router) $modules->addMixin('export', 'projectsShow'); $modules->addMixin('export', 'projectsShowScripts'); + $modules->addMixin('export', 'annotationsSettings'); + $modules->addMixin('export', 'annotationsScripts'); } /** diff --git a/src/Http/Controllers/Api/TransectExportAreaController.php b/src/Http/Controllers/Api/TransectExportAreaController.php new file mode 100644 index 0000000..283bb6c --- /dev/null +++ b/src/Http/Controllers/Api/TransectExportAreaController.php @@ -0,0 +1,86 @@ +authorize('access', $transect); + + return Transect::convert($transect)->exportArea; + } + + /** + * Set the export area + * + * @api {post} transects/:id/export-area Set the export area + * @apiGroup Transects + * @apiName StoreTransectsExportArea + * @apiPermission admin + * + * @apiParam (Required attributes) {Number[]} coordinates Coordinates of the export area formatted as `[x1, y1, x2, y2]` array of integers + * + * @param int $id Transect ID + * @return \Illuminate\Http\Response + */ + public function store($id) + { + $transect = BaseTransect::findOrFail($id); + $this->authorize('update', $transect); + $this->validate($this->request, Transect::$storeRules); + + $transect = Transect::convert($transect); + + try { + $transect->exportArea = $this->request->input('coordinates'); + $transect->save(); + } catch (Exception $e) { + return $this->buildFailedValidationResponse($this->request, [ + 'coordinates' => $e->getMessage(), + ]); + } + } + + /** + * Remove the export area + * + * @api {delete} transects/:id/export-area Remove the export area + * @apiGroup Transects + * @apiName DestroyTransectsExportArea + * @apiPermission admin + * + * @param int $id Transect ID + * @return \Illuminate\Http\Response + */ + public function destroy($id) + { + $transect = BaseTransect::findOrFail($id); + $this->authorize('update', $transect); + $transect = Transect::convert($transect); + $transect->exportArea = null; + $transect->save(); + } +} diff --git a/src/Http/routes.php b/src/Http/routes.php index 2b67504..c63a502 100644 --- a/src/Http/routes.php +++ b/src/Http/routes.php @@ -5,18 +5,34 @@ 'prefix' => 'api/v1', 'middleware' => 'auth.api', ], function ($router) { + $router->get('projects/{id}/reports/basic', [ 'uses' => 'ReportsController@basic', ]); + $router->get('projects/{id}/reports/extended', [ 'uses' => 'ReportsController@extended', ]); + $router->get('projects/{id}/reports/full', [ 'uses' => 'ReportsController@full', ]); + + $router->get('transects/{id}/export-area', [ + 'uses' => 'TransectExportAreaController@show', + ]); + + $router->post('transects/{id}/export-area', [ + 'uses' => 'TransectExportAreaController@store', + ]); + + $router->delete('transects/{id}/export-area', [ + 'uses' => 'TransectExportAreaController@destroy', + ]); }); // this route should be public (is protected by random uids) $router->get('api/v1/reports/{uid}/{filename}', [ 'uses' => 'Api\ReportsController@show', ]); + diff --git a/src/Transect.php b/src/Transect.php new file mode 100644 index 0000000..ff76f0f --- /dev/null +++ b/src/Transect.php @@ -0,0 +1,85 @@ + 'required|array', + ]; + + /** + * Converts a regular Dias transect to an export transect + * + * @param BaseTransect $transect Regular Dias transect instance + * + * @return Transect + */ + public static function convert(BaseTransect $transect) + { + $instance = new static; + $instance->setRawAttributes($transect->attributes); + $instance->exists = $transect->exists; + return $instance->setRelations($transect->relations); + } + + /** + * Return the dynamic attribute for the export area + * + * @return array + */ + public function getExportAreaAttribute() + { + return array_get($this->attrs, self::EXPORT_AREA_ATTRIBUTE); + } + + /** + * Set or update the dynamic attribute for the export area + * + * @param array $value The value to set + */ + public function setExportAreaAttribute($value) + { + if (!is_array($value) && !is_null($value)) { + throw new Exception("Export area coordinates must be an array!"); + } + + $attrs = $this->attrs; + + if ($value === null) { + unset($attrs[self::EXPORT_AREA_ATTRIBUTE]); + } else { + if (sizeof($value) !== 4) { + throw new Exception("Malformed export area coordinates!"); + } + + foreach ($value as $coordinate) { + if (!is_int($coordinate)) { + throw new Exception("Malformed export area coordinates!"); + } + } + + $attrs[self::EXPORT_AREA_ATTRIBUTE] = $value; + } + + $this->attrs = $attrs; + } +} diff --git a/src/public/assets/scripts/annotations.js b/src/public/assets/scripts/annotations.js new file mode 100644 index 0000000..57b2c71 --- /dev/null +++ b/src/public/assets/scripts/annotations.js @@ -0,0 +1 @@ +angular.module("dias.annotations").controller("ExportAreaSettingsController",["$scope","exportArea",function(e,t){"use strict";e.setDefaultSettings("export_area_opacity","1"),e.edit=function(){e.isShown()||(e.settings.export_area_opacity="1"),t.toggleEdit()},e.isEditing=t.isEditing,e.isShown=function(){return"0"!==e.settings.export_area_opacity},e["delete"]=function(){t.hasArea()&&confirm("Do you really want to delete the export area?")&&t.deleteArea()},e.$on("image.shown",t.updateHeight),e.$watch("settings.export_area_opacity",t.setOpacity)}]),angular.module("dias.annotations").factory("ExportArea",["$resource","URL",function(e,t){"use strict";return e(t+"/api/v1/transects/:transect_id/export-area")}]),angular.module("dias.annotations").service("exportArea",["map","styles","ExportArea","TRANSECT_ID","EXPORT_AREA",function(e,t,o,n,i){"use strict";var r,a,s=[new ol.style.Style({stroke:new ol.style.Stroke({color:t.colors.white,width:4}),image:new ol.style.Circle({radius:6,fill:new ol.style.Fill({color:"#666666"}),stroke:new ol.style.Stroke({color:t.colors.white,width:2,lineDash:[2]})})}),new ol.style.Style({stroke:new ol.style.Stroke({color:"#666666",width:1,lineDash:[2]})})],l=!1,c=new ol.Collection,u=new ol.source.Vector({features:c}),d=new ol.layer.Vector({source:u,style:s,zIndex:4,updateWhileAnimating:!0,updateWhileInteracting:!0});e.addLayer(d);var g=new ol.interaction.Draw({source:u,type:"Rectangle",style:s,minPoints:2,maxPoints:2,geometryFunction:function(e,t){e=e[0],e.length>1&&(e=[e[0],[e[0][0],e[1][1]],e[1],[e[1][0],e[0][1]]]);var o=t;return o?o.setCoordinates([e]):o=new ol.geom.Rectangle([e]),o}});g.on("drawend",function(e){h(),a=e.feature,p()});var y=new ol.interaction.Modify({features:c,style:s,deleteCondition:ol.events.condition.never}),h=function(){void 0!==a&&(u.removeFeature(a),a=void 0)},f=function(){h(),o["delete"]({transect_id:n})},p=function(){a&&(coordinates=m(a.getGeometry().getCoordinates()),o.save({transect_id:n},{coordinates:coordinates},function(){i=coordinates}))};y.on("modifyend",p);var w=function(e){return[[e[0],r-e[1]],[e[0],r-e[3]],[e[2],r-e[3]],[e[2],r-e[1]]]},m=function(e){return e=e[0],e=[e[0][0],r-e[0][1],e[2][0],r-e[2][1]],e.map(Math.round)},v=function(){if(i&&4===i.length){var e=new ol.geom.Rectangle([w(i)]);a?a.setGeometry(e):(a=new ol.Feature({geometry:e}),u.addFeature(a))}};this.updateHeight=function(e,t){r!==t.height&&(r=t.height,v())},this.toggleEdit=function(){l?(e.removeInteraction(g),e.removeInteraction(y)):(e.addInteraction(g),e.addInteraction(y)),l=!l},this.isEditing=function(){return l},this.setOpacity=function(e){d.setOpacity(e)},this.deleteArea=f,this.hasArea=function(){return!!a}}]); \ No newline at end of file diff --git a/src/resources/assets/js/annotations/controllers/ExportAreaSettingsController.js b/src/resources/assets/js/annotations/controllers/ExportAreaSettingsController.js new file mode 100644 index 0000000..33ae0e7 --- /dev/null +++ b/src/resources/assets/js/annotations/controllers/ExportAreaSettingsController.js @@ -0,0 +1,37 @@ +/** + * @namespace dias.annotations + * @ngdoc controller + * @name ExportAreaSettingsController + * @memberOf dias.annotations + * @description Controller for ATE example patches settings + */ +angular.module('dias.annotations').controller('ExportAreaSettingsController', function ($scope, exportArea) { + "use strict"; + + $scope.setDefaultSettings('export_area_opacity', '1'); + + $scope.edit = function () { + if (!$scope.isShown()) { + $scope.settings.export_area_opacity = '1'; + } + + exportArea.toggleEdit(); + }; + + $scope.isEditing = exportArea.isEditing; + + $scope.isShown = function () { + return $scope.settings.export_area_opacity !== '0'; + }; + + $scope.delete = function () { + if (exportArea.hasArea() && confirm('Do you really want to delete the export area?')) { + exportArea.deleteArea(); + } + }; + + $scope.$on('image.shown', exportArea.updateHeight); + + $scope.$watch('settings.export_area_opacity', exportArea.setOpacity); + } +); diff --git a/src/resources/assets/js/annotations/factories/ExportArea.js b/src/resources/assets/js/annotations/factories/ExportArea.js new file mode 100644 index 0000000..65ec091 --- /dev/null +++ b/src/resources/assets/js/annotations/factories/ExportArea.js @@ -0,0 +1,25 @@ +/** + * @ngdoc factory + * @name ExportArea + * @memberOf dias.annotations + * @description Provides the resource for the export area of a transect + * @requires $resource + * @returns {Object} A new [ngResource](https://docs.angularjs.org/api/ngResource/service/$resource) object + * @example +// get the export area +var area = ExportArea.query({transect_id: 1}, function () { + console.log(area); // [10, 20, 30, 40] +}); + +// set the area +ExportArea.save({transect_id: 1}, {coordinates: [10, 20, 30, 40]}); + +// delete the area +ExportArea.delete({transect_id: 1}); + * + */ +angular.module('dias.annotations').factory('ExportArea', function ($resource, URL) { + "use strict"; + + return $resource(URL + '/api/v1/transects/:transect_id/export-area'); +}); diff --git a/src/resources/assets/js/annotations/services/exportArea.js b/src/resources/assets/js/annotations/services/exportArea.js new file mode 100644 index 0000000..e70f9af --- /dev/null +++ b/src/resources/assets/js/annotations/services/exportArea.js @@ -0,0 +1,190 @@ +/** + * @namespace dias.annotations + * @ngdoc service + * @name exportArea + * @memberOf dias.annotations + * @description Manages the export area drawn on the map + */ +angular.module('dias.annotations').service('exportArea', function (map, styles, ExportArea, TRANSECT_ID, EXPORT_AREA) { + "use strict"; + + // a circle with a red and white stroke + var style = [ + new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: styles.colors.white, + width: 4 + }), + image: new ol.style.Circle({ + radius: 6, + fill: new ol.style.Fill({ + color: '#666666' + }), + stroke: new ol.style.Stroke({ + color: styles.colors.white, + width: 2, + lineDash: [2] + }) + }) + }), + new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: '#666666', + width: 1, + lineDash: [2] + }) + }) + ]; + var height; + var area; + + var editing = false; + + var collection = new ol.Collection(); + var source = new ol.source.Vector({ + features: collection + }); + var layer = new ol.layer.Vector({ + source: source, + style: style, + zIndex: 4, + updateWhileAnimating: true, + updateWhileInteracting: true + }); + map.addLayer(layer); + + var draw = new ol.interaction.Draw({ + source: source, + type: 'Rectangle', + style: style, + minPoints: 2, + maxPoints: 2, + geometryFunction: function (coordinates, opt_geometry) { + coordinates = coordinates[0]; + if (coordinates.length > 1) { + coordinates = [ + coordinates[0], + [coordinates[0][0], coordinates[1][1]], + coordinates[1], + [coordinates[1][0], coordinates[0][1]] + ]; + } + var geometry = opt_geometry; + if (geometry) { + geometry.setCoordinates([coordinates]); + } else { + geometry = new ol.geom.Rectangle([coordinates]); + } + return geometry; + } + }); + + draw.on('drawend', function (e) { + removeAreaFeature(); + area = e.feature; + saveArea(); + }); + + var modify = new ol.interaction.Modify({ + features: collection, + style: style, + deleteCondition: ol.events.condition.never + }); + + var removeAreaFeature = function () { + if (area !== undefined) { + source.removeFeature(area); + area = undefined; + } + }; + + var deleteArea = function () { + removeAreaFeature(); + ExportArea.delete({transect_id: TRANSECT_ID}); + }; + + var saveArea = function () { + if (area) { + coordinates = fromOlCoordinates(area.getGeometry().getCoordinates()); + ExportArea.save({transect_id: TRANSECT_ID}, { + coordinates: coordinates + }, function () { + EXPORT_AREA = coordinates; + }); + } + // ------------------ + // TODO HANDLE ERRORS + // ------------------ + }; + modify.on('modifyend', saveArea); + + var toOlCoordinates = function (cooridnates) { + return [ + // swap y coordinates for OpenLayers + [cooridnates[0], height - cooridnates[1]], + [cooridnates[0], height - cooridnates[3]], + [cooridnates[2], height - cooridnates[3]], + [cooridnates[2], height - cooridnates[1]], + ]; + }; + + var fromOlCoordinates = function (coordinates) { + coordinates = coordinates[0]; + coordinates = [ + coordinates[0][0], height - coordinates[0][1], + coordinates[2][0], height - coordinates[2][1], + ]; + + return coordinates.map(Math.round); + }; + + var update = function () { + if (!EXPORT_AREA || EXPORT_AREA.length !== 4) { + return; + } + + var geometry = new ol.geom.Rectangle([ + toOlCoordinates(EXPORT_AREA) + ]); + if (!area) { + area = new ol.Feature({geometry: geometry}); + source.addFeature(area); + } else { + area.setGeometry(geometry); + } + }; + + this.updateHeight = function (e, image) { + if (height !== image.height) { + height = image.height; + update(); + } + }; + + this.toggleEdit = function () { + if (!editing) { + map.addInteraction(draw); + map.addInteraction(modify); + } else { + map.removeInteraction(draw); + map.removeInteraction(modify); + } + + editing = !editing; + }; + + this.isEditing = function () { + return editing; + }; + + this.setOpacity = function (o) { + layer.setOpacity(o); + }; + + this.deleteArea = deleteArea; + + this.hasArea = function () { + return !!area; + }; + } +); diff --git a/src/resources/views/annotationsScripts.blade.php b/src/resources/views/annotationsScripts.blade.php new file mode 100644 index 0000000..ebc1170 --- /dev/null +++ b/src/resources/views/annotationsScripts.blade.php @@ -0,0 +1,4 @@ + + diff --git a/src/resources/views/annotationsSettings.blade.php b/src/resources/views/annotationsSettings.blade.php new file mode 100644 index 0000000..8faff70 --- /dev/null +++ b/src/resources/views/annotationsSettings.blade.php @@ -0,0 +1,14 @@ +
+

+ @can('update', $transect) + + + + + @endcan + Export area +

+ + + +
diff --git a/tests/ExportModuleTransectTest.php b/tests/ExportModuleTransectTest.php new file mode 100644 index 0000000..e4c0059 --- /dev/null +++ b/tests/ExportModuleTransectTest.php @@ -0,0 +1,64 @@ + [Transect::EXPORT_AREA_ATTRIBUTE => [1, 2, 3, 4]] + ]); + $exportTransect = Transect::convert($transect); + $this->assertEquals($transect->id, $exportTransect->id); + $this->assertTrue($exportTransect instanceof Transect); + $this->assertEquals(3, $exportTransect->exportArea[2]); + } + + public function testExportArea() + { + $transect = static::create(); + $transect->exportArea = [10, 20, 30, 40]; + $transect->save(); + + $expect = [10, 20, 30, 40]; + $this->assertEquals($expect, $transect->fresh()->exportArea); + + $transect->exportArea = null; + $transect->save(); + $this->assertNull($transect->fresh()->exportArea); + } + + public function testExportAreaNotThere() + { + $transect = static::create(['attrs' => ['something' => 'else']]); + // no error is thrown + $this->assertNull($transect->exportArea); + } + + public function testExportAreaTooShort() + { + $transect = static::create(); + $this->setExpectedException('Exception'); + $transect->exportArea = [10]; + } + + public function testExportInvalidType() + { + $transect = static::create(); + $this->setExpectedException('Exception'); + $transect->exportArea = 'abc'; + } + + public function testExportAreaNoInteger() + { + $transect = static::create(); + $this->setExpectedException('Exception'); + $transect->exportArea = ['10', 20, 30, 40]; + } +} diff --git a/tests/Http/Controllers/Api/ExportModuleHttpControllersApiTransectExportAreaControllerTest.php b/tests/Http/Controllers/Api/ExportModuleHttpControllersApiTransectExportAreaControllerTest.php new file mode 100644 index 0000000..e0115a4 --- /dev/null +++ b/tests/Http/Controllers/Api/ExportModuleHttpControllersApiTransectExportAreaControllerTest.php @@ -0,0 +1,73 @@ +transect()); + $transect->exportArea = [10, 20, 30, 40]; + $transect->save(); + + $this->doTestApiRoute('GET', "/api/v1/transects/{$transect->id}/export-area"); + + $this->beUser(); + $this->get("/api/v1/transects/{$transect->id}/export-area"); + $this->assertResponseStatus(403); + + $this->beGuest(); + $this->get("/api/v1/transects/{$transect->id}/export-area"); + $this->assertResponseOk(); + $this->seeJsonEquals([10, 20, 30, 40]); + } + + public function testStore() + { + $transect = Transect::convert($this->transect()); + + $this->doTestApiRoute('POST', "/api/v1/transects/{$transect->id}/export-area"); + + $this->beEditor(); + $this->post("/api/v1/transects/{$transect->id}/export-area", [ + 'coordinates' => [10, 20, 30, 40], + ]); + $this->assertResponseStatus(403); + + $this->beAdmin(); + $this->json('POST', "/api/v1/transects/{$transect->id}/export-area", [ + 'coordinates' => [10, 20], + ]); + $this->assertResponseStatus(422); + + $this->json('POST', "/api/v1/transects/{$transect->id}/export-area", [ + 'coordinates' => [10, 20, 30, '40'], + ]); + $this->assertResponseStatus(422); + + $this->post("/api/v1/transects/{$transect->id}/export-area", [ + 'coordinates' => [10, 20, 30, 40], + ]); + $this->assertResponseOk(); + $this->assertEquals([10, 20, 30, 40], $transect->fresh()->exportArea); + } + + public function testDestroy() + { + $transect = Transect::convert($this->transect()); + $transect->exportArea = [10, 20, 30, 40]; + $transect->save(); + + $this->doTestApiRoute('DELETE', "/api/v1/transects/{$transect->id}/export-area"); + + $this->beEditor(); + $this->delete("/api/v1/transects/{$transect->id}/export-area"); + $this->assertResponseStatus(403); + + $this->beAdmin(); + $this->assertNotNull($transect->fresh()->exportArea); + $this->delete("/api/v1/transects/{$transect->id}/export-area"); + $this->assertResponseOk(); + $this->assertNull($transect->fresh()->exportArea); + } +}