diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e40cc5a..7a2fafa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a service for notifications called "notify" (based actually on PNotify) - Added Spanish translation - Added report type pyramid +- Added report type map - Added gulp task `dev` that combine `watch` and `nodemon` ### Changed @@ -30,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Dependencies +- Added leaflet 1.7.1 - Updated angular to 1.8.2 - Updated angular-gettext to 2.4.2 - Updated arg to 5.0.1 diff --git a/dist/templates/templates.js b/dist/templates/templates.js index 900d0e8e..ce1c5821 100644 --- a/dist/templates/templates.js +++ b/dist/templates/templates.js @@ -35,15 +35,15 @@ $templateCache.put('partials/report/modals/dashboardListModal.html','\n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n
\n
Drop here your filters
\n \n
\n\n \n\n \n'); +$templateCache.put('partials/report/partials/drop-area.html',' \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n\n \n \n \n \n \n\n \n\n
\n
Drop here your filters
\n \n
\n\n \n\n \n'); $templateCache.put('partials/report/partials/filter.html','
  • \n \n \n
  • \n\n
  • \n \n\n
    \n
    \n\n Only show the first\n \n elements\n\n
    \n\n
    \n
  • \n'); -$templateCache.put('partials/report/partials/report-edit.html','\n
    \n\n\n
    \n
    \n
    \n \n \n \n \n \n \n \n \n \n\n \n
    \n
    \n
    \n\n
    \n\n
    \n
    \n \n Quick display with\n \n results.\n \n \n \n \n \n \n
    \n
    \n\n
    \n\n \n \n
    \n
    \n \n
    \n\n\n
    \n
    \n
    \n SQL query\n \n
    \n
    \n\n
    \n

    {{sql}}

    \n
    \n
    \n\n
    \n\n\n
    \n
    \n'); +$templateCache.put('partials/report/partials/report-edit.html','\n
    \n\n\n
    \n
    \n
    \n \n \n \n \n \n \n \n \n \n \n\n\n \n
    \n
    \n
    \n\n
    \n\n
    \n
    \n \n Quick display with\n \n results.\n \n \n \n \n \n \n
    \n
    \n\n
    \n\n \n \n
    \n
    \n \n
    \n\n\n
    \n
    \n
    \n SQL query\n \n
    \n
    \n\n
    \n

    {{sql}}

    \n
    \n
    \n\n
    \n\n\n
    \n
    \n'); $templateCache.put('partials/reports/criteria-input.html','\n
    \n \n\n \n \n\n \n
    \n \n \n \n \n \n \n \n \n \n \n\n
    \n\n \n
    \n \n \n \n \n \n \n \n
    \n\n \n and \n\n
    \n \n \n \n \n \n \n \n
    \n
    \n \n\n \n \n \n
    \n \n \n \n \n
    \n
    \n\n \n
    \n \n {{$item}}\n \n
    \n
    \n
    \n
    \n
    \n\n \n AND\n\n \n \n\n \n \n \n \n
    \n\n \n
    \n
    \n'); $templateCache.put('partials/reports/filter-prompt.html','
    \n \n \n \n \n\n
    \n \n \n \n\n \n
    \n \n\n
    \n \n
    \n\n \n\n \n \n \n \n \n\n \n\n \n
    \n
    \n
    \n
    \n\n
    \n \n
    \n
    \n'); $templateCache.put('partials/reports/list.html','
    \n
    \n
    \n \n
    \n\n
    \n \n\n \n
    \n
    \n
    \n'); -$templateCache.put('partials/reports/report-column-settings-modal.component.html','\n\n\n\n\n'); +$templateCache.put('partials/reports/report-column-settings-modal.component.html','\n\n\n\n\n'); $templateCache.put('partials/reports/report-dropzone.component.html','
    \n\n
    {{ vm.zoneInfo | translate }}
    \n \n
    \n'); -$templateCache.put('partials/reports/report-settings-modal.component.html','\n\n\n\n\n'); +$templateCache.put('partials/reports/report-settings-modal.component.html','\n\n\n\n\n'); $templateCache.put('partials/reports/reports-import-modal.html','\n\n\n\n\n'); $templateCache.put('partials/reports/reports-list-buttons.html','
    \n \n\n \n \n \n\n \n\n \n
    \n\n\n'); $templateCache.put('partials/reports/reports-table.component.html','\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    \n Name\n \n \n Layer\n \n \n Author\n \n \n Created on\n \n
    \n \n \n \n \n \n
    \n {{ report.reportName }}\n
    \n public\n shared\n
    \n
    {{ report.layerName }}{{ report.author }}{{ report.createdOn | date }}
    \n\n\n'); diff --git a/doc/user/index.rst b/doc/user/index.rst index 49cb2597..d6e68c37 100644 --- a/doc/user/index.rst +++ b/doc/user/index.rst @@ -30,3 +30,10 @@ Supported databases configuration start-the-server upgrade + +.. toctree:: + :caption: Reports + :maxdepth: 2 + :hidden: + + report-types diff --git a/doc/user/report-types.rst b/doc/user/report-types.rst new file mode 100644 index 00000000..4a447e5c --- /dev/null +++ b/doc/user/report-types.rst @@ -0,0 +1,6 @@ +Report types +============ + +.. toctree:: + + report-types/map diff --git a/doc/user/report-types/image/map-empty.png b/doc/user/report-types/image/map-empty.png new file mode 100644 index 00000000..d37437b8 Binary files /dev/null and b/doc/user/report-types/image/map-empty.png differ diff --git a/doc/user/report-types/image/map-report-settings.png b/doc/user/report-types/image/map-report-settings.png new file mode 100644 index 00000000..ceb1d4cf Binary files /dev/null and b/doc/user/report-types/image/map-report-settings.png differ diff --git a/doc/user/report-types/image/map-result.png b/doc/user/report-types/image/map-result.png new file mode 100644 index 00000000..819cee4b Binary files /dev/null and b/doc/user/report-types/image/map-result.png differ diff --git a/doc/user/report-types/image/map-type-column-settings.png b/doc/user/report-types/image/map-type-column-settings.png new file mode 100644 index 00000000..39915af2 Binary files /dev/null and b/doc/user/report-types/image/map-type-column-settings.png differ diff --git a/doc/user/report-types/map.rst b/doc/user/report-types/map.rst new file mode 100644 index 00000000..dd162742 --- /dev/null +++ b/doc/user/report-types/map.rst @@ -0,0 +1,76 @@ +Map +=== + +The *map* report type allows to display `GeoJSON `_ objects on a map. + +.. figure:: image/map-result.png + + Example of a map report + +To use it, select the map marker icon. + +.. figure:: image/map-empty.png + + Screenshot of an empty map report + +There are six different places where you can put your columns: + +GeoJSON column + This is the most important one. Place the column containing GeoJSON data here. + + If your database does not contain GeoJSON data, you can probably convert + other formats to GeoJSON using SQL functions. For instance, MySQL has + `ST_AsGeoJSON `_ + (you may need to modify the layer to use those functions). + + Note that icons rendering works only for GeoJSON 'point' type and maybe for a 'multipoint' object (not tested). + +Label column + If you drop a column here, its text content will be shown next to the map marker. + +Value column + If you drop a column here, its numeric content will be used to calculate a + color intensity. Low values get low intensity and high values get high + intensity. + +Group column + If you drop a column here, its content will be used to group markers + together. If two rows have the same value in this column, they belong to the + same group. Each group is then assigned a unique color hue (automatically + chosen by Urungi) for the map marker. + +Type column + If you drop a column here, its content will be used to assign a type to a + row. Each type can then be associated with a specific icon to change the map + marker appearance. + +Filters + These are regular filters and work the same as for other report types. + + +Report settings +--------------- + +.. figure:: image/map-report-settings.png + + Screenshot of the map report settings modal window + +In the report settings you can change the map layer. It's the map that will be +drawn under the GeoJSON objects. You can find different map layers on `the +OpenStreetMap Wiki `_. + + +Configure icons +--------------- + +If you use a *type* column, you can associate an icon with each type. To do +that, click on the gear wheel icon next to the type column. + +.. figure:: image/map-type-column-settings.png + + Screenshot of the map type column settings modal window + +Each distinct value contained in the type column will be displayed in the form. +Associate an icon with a type by entering the icon's name. Only +`Font Awesome 4 icons `_ are available at +the moment. diff --git a/package-lock.json b/package-lock.json index fdbf88dd..e56093da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "js-xlsx": "^0.8.22", "jsplumb": "^2.15.6", "knex": "^0.21.21", + "leaflet": "^1.7.1", "malihu-custom-scrollbar-plugin": "^3.1.5", "migrate-mongo": "^8.2.3", "moment": "^2.29.1", @@ -12866,6 +12867,11 @@ "node": ">= 0.10" } }, + "node_modules/leaflet": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.7.1.tgz", + "integrity": "sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw==" + }, "node_modules/less": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/less/-/less-3.9.0.tgz", @@ -30022,6 +30028,11 @@ "flush-write-stream": "^1.0.2" } }, + "leaflet": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.7.1.tgz", + "integrity": "sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw==" + }, "less": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/less/-/less-3.9.0.tgz", diff --git a/package.json b/package.json index 51d5efc1..f3923c62 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "js-xlsx": "^0.8.22", "jsplumb": "^2.15.6", "knex": "^0.21.21", + "leaflet": "^1.7.1", "malihu-custom-scrollbar-plugin": "^3.1.5", "migrate-mongo": "^8.2.3", "moment": "^2.29.1", diff --git a/public/css/main.css b/public/css/main.css index d305cff7..5241dd61 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -3181,3 +3181,13 @@ a.list-group-item:hover { border-color: #999; color: white; } + +.leaflet-tooltip { + background: none; + border: none; + box-shadow: none; +} + +.report-map-icon i { + font-size: 16px; +} diff --git a/public/images/gps-pin.png b/public/images/gps-pin.png new file mode 100644 index 00000000..e6c95340 Binary files /dev/null and b/public/images/gps-pin.png differ diff --git a/public/js/core/constants.js b/public/js/core/constants.js index 552224c4..3f43f2c5 100644 --- a/public/js/core/constants.js +++ b/public/js/core/constants.js @@ -1,4 +1,4 @@ -/* global PNotify: false, PNotifyBootstrap3: false, PNotifyFontAwesome4: false, moment: false, numeral: false */ +/* global PNotify: false, PNotifyBootstrap3: false, PNotifyFontAwesome4: false, moment: false, numeral: false, L : false */ (function () { 'use strict'; @@ -11,5 +11,6 @@ .constant('PNotifyFontAwesome4', PNotifyFontAwesome4) .constant('moment', moment) .constant('numeral', numeral) + .constant('L', L) .constant('base', base); })(); diff --git a/public/js/report/controller.js b/public/js/report/controller.js index 857e9821..e06a4730 100644 --- a/public/js/report/controller.js +++ b/public/js/report/controller.js @@ -110,6 +110,13 @@ angular.module('app').controller('reportCtrl', function ($scope, connection, $co $scope.selectedReport.properties.pivotKeys = {}; $scope.selectedReport.properties.pivotKeys.columns = []; $scope.selectedReport.properties.pivotKeys.rows = []; + $scope.selectedReport.properties.map = { + geojson: [], + value: [], + label: [], + group: [], + type: [], + }; $scope.selectedReport.properties.order = []; $scope.selectedReport.properties.filters = []; $scope.selectedReport.reportType = 'grid'; @@ -118,6 +125,7 @@ angular.module('app').controller('reportCtrl', function ($scope, connection, $co $scope.selectedReport.properties.height = 300; $scope.selectedReport.properties.range = ''; + $scope.selectedReport.properties.mapLayerUrl = ''; $scope.selectedReport.properties.legendPosition = 'bottom'; @@ -149,6 +157,13 @@ angular.module('app').controller('reportCtrl', function ($scope, connection, $co if (!report.properties.pivotKeys.rows) { report.properties.pivotKeys.rows = []; } if (!report.properties.order) { report.properties.order = []; } if (!report.properties.range) { report.properties.range = ''; } + if (!report.properties.map) { report.properties.map = {}; } + if (!report.properties.map.geojson) { report.properties.map.geojson = []; } + if (!report.properties.map.value) { report.properties.map.value = []; } + if (!report.properties.map.label) { report.properties.map.label = []; } + if (!report.properties.map.group) { report.properties.map.group = []; } + if (!report.properties.map.type) { report.properties.map.type = []; } + if (!report.properties.mapLayerUrl) { report.properties.mapLayerUrl = ''; } }; /* @@ -422,6 +437,12 @@ angular.module('app').controller('reportCtrl', function ($scope, connection, $co role: 'column' }; break; + case 'map': + choice = { + propertyBind: $scope.selectedReport.properties.ykeys, + role: 'column' + }; + break; } return choice; @@ -523,6 +544,14 @@ angular.module('app').controller('reportCtrl', function ($scope, connection, $co report.reportType = 'pyramid'; break; + case 'map': + moveContent(report.properties.columns, movedColumns); + moveContent(report.properties.xkeys, movedColumns); + moveContent(report.properties.pivotKeys.columns, movedColumns); + moveContent(report.properties.pivotKeys.rows, movedColumns); + report.reportType = 'map'; + break; + default: notify.error(gettextCatalog.getString('report type does not exist')); break; @@ -639,6 +668,10 @@ angular.module('app').controller('reportCtrl', function ($scope, connection, $co case 'pyramid': available = report.properties.xkeys.length > 0 && report.properties.ykeys.length > 0; break; + + case 'map': + available = report.properties.map.geojson.length === 1; + break; } return available; @@ -767,6 +800,7 @@ angular.module('app').controller('reportCtrl', function ($scope, connection, $co $scope.selectedReport.properties.legendPosition = settings.legendPosition; $scope.selectedReport.properties.height = settings.height; $scope.selectedReport.properties.maxValue = settings.maxValue; + $scope.selectedReport.properties.mapLayerUrl = settings.mapLayerUrl; $scope.selectedReport.theme = settings.theme; $scope.selectedReport.properties.range = settings.range; }, () => {}); diff --git a/public/js/report/directives/reportView.js b/public/js/report/directives/reportView.js index 53fa9edd..3ff96cc6 100644 --- a/public/js/report/directives/reportView.js +++ b/public/js/report/directives/reportView.js @@ -1,4 +1,4 @@ -angular.module('app').directive('reportView', function ($q, $timeout, reportModel, $compile, c3Charts, reportHtmlWidgets, grid, pivot, uuid, gettextCatalog, api) { +angular.module('app').directive('reportView', function ($q, $timeout, reportModel, $compile, c3Charts, reportHtmlWidgets, grid, pivot, map, uuid, gettextCatalog, api) { return { scope: { @@ -84,6 +84,10 @@ angular.module('app').directive('reportView', function ($q, $timeout, reportMode case 'indicator': $scope.changeContent(reportHtmlWidgets.generateIndicator($scope.report, $scope.data)); break; + + case 'map': + $scope.changeContent(`
    `); + return map.createMap($scope.report, $scope.data); } } else { $scope.changeContent('
    ' + gettextCatalog.getString('No data for this report') + '
    '); diff --git a/public/js/report/model.js b/public/js/report/model.js index 76884bd1..94f8ec3e 100644 --- a/public/js/report/model.js +++ b/public/js/report/model.js @@ -58,6 +58,9 @@ angular.module('app').service('reportModel', function ($q, api, connection, uuid case 'pyramid': chart.type = 'pyramid'; break; + case 'map': + chart.type = 'map'; + break; } if (['chart-line', 'chart-donut', 'chart-pie', 'pyramid'].indexOf(report.reportType) >= 0 && diff --git a/public/js/reports/constants.js b/public/js/reports/constants.js new file mode 100644 index 00000000..7fb3bc28 --- /dev/null +++ b/public/js/reports/constants.js @@ -0,0 +1,6 @@ +(function () { + 'use strict'; + + angular.module('app.reports') + .constant('mapDefaultTileLayerUrlTemplate', 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'); +})(); diff --git a/public/js/reports/map.service.js b/public/js/reports/map.service.js new file mode 100644 index 00000000..d8772f49 --- /dev/null +++ b/public/js/reports/map.service.js @@ -0,0 +1,133 @@ +(function () { + 'use strict'; + + angular.module('app.reports').factory('map', map); + + map.$inject = ['L', 'mapDefaultTileLayerUrlTemplate']; + + function map (L, mapDefaultTileLayerUrlTemplate) { + const service = { + createMap: createMap, + getGroups: getGroups, + getStyle: getStyle, + getGroupColor: getGroupColor, + getValuesBounds: getValuesBounds, + addMarkersToMap: addMarkersToMap, + }; + + return service; + + function createMap (report, dataRows) { + L.Icon.Default.prototype.options.imagePath = 'images/'; + const map = L.map('map', { + maxZoom: 10, + }); + + const mapBounds = []; + const mapProperties = report.properties.map; + const urlTemplate = report.properties.mapLayerUrl || mapDefaultTileLayerUrlTemplate; + + L.tileLayer(urlTemplate, { + attribution: '© OpenStreetMap contributors', + }).addTo(map); + + addMarkersToMap(mapProperties, dataRows, mapBounds, map); + getGroupColor(mapProperties, dataRows); + + map.fitBounds(mapBounds); + + return map; + } + + function getGroups (mapProperties, dataRows) { + const groups = new Set(); + if (mapProperties.group.length > 0 && mapProperties.group[0].id) { + for (const value of dataRows) { + const group = value[mapProperties.group[0].id]; + groups.add(group); + } + } + return groups; + } + + function getGroupColor (mapProperties, dataRows) { + const groupHue = new Map(); + let hueIndex = 0; + const groups = getGroups(mapProperties, dataRows); + const groupCount = groups.size; + for (const group of groups) { + const hue = hueIndex * (360 / groupCount); + hueIndex++; + groupHue.set(group, hue); + } + return groupHue; + } + + function getStyle (mapProperties, minValue, maxValue, row) { + if (mapProperties.value.length > 0) { + const value = row[mapProperties.value[0].id]; + const saturation = ((value - minValue) * 100) / (maxValue - minValue); + return { stroke: false, fillOpacity: 0.5, fillColor: `hsl(0, ${saturation}%, 50%)` }; + } + } + + function getValuesBounds (mapProperties, dataRows) { + const bounds = {}; + + if (mapProperties.value.length > 0 && mapProperties.value[0].id) { + const values = dataRows.map(r => r[mapProperties.value[0].id]); + bounds.minValue = Math.min(...values); + bounds.maxValue = Math.max(...values); + } + return bounds; + } + + function addMarkersToMap (mapProperties, dataRows, mapBounds, map) { + const groupHue = getGroupColor(mapProperties, dataRows); + const { minValue, maxValue } = getValuesBounds(mapProperties, dataRows); + + for (const row of dataRows) { + const geoJson = JSON.parse(row[mapProperties.geojson[0].id]); + const group = mapProperties.group.length ? row[mapProperties.group[0].id] : null; + const style = getStyle(mapProperties, minValue, maxValue, row); + + const marker = L.geoJSON(geoJson, { + pointToLayer: function (geoJsonPoint, latlng) { + return getMarker(mapProperties, latlng, group, row, groupHue); + }, + style: () => style, + }).addTo(map); + + if (mapProperties.label.length > 0) { + const label = row[mapProperties.label[0].id]; + marker.bindTooltip(label, { permanent: true, direction: 'center' }).openTooltip(); + } + + mapBounds.push(marker.getBounds()); + } + } + + function getMarker (mapProperties, latlng, group, row, groupHue) { + const hue = groupHue.get(group); + if (mapProperties.type[0]) { + const column = mapProperties.type[0]; + const type = row[column.id]; + if (column.icon && column.icon[type]) { + const iconClass = `fa fa-${column.icon[type]}`; + const pointMarker = L.divIcon({ + + html: ``, + className: 'report-map-icon', + }); + + return L.marker(latlng, { icon: pointMarker }); + } + } + return L.circleMarker(latlng, { + stroke: false, + fillColor: `hsl(${hue}, 100%, 50%)`, + fillOpacity: 0.5, + }); + } + } +})(); diff --git a/public/js/reports/report-column-settings-modal.component.js b/public/js/reports/report-column-settings-modal.component.js index db8c3b90..9a811f7d 100644 --- a/public/js/reports/report-column-settings-modal.component.js +++ b/public/js/reports/report-column-settings-modal.component.js @@ -12,9 +12,9 @@ }, }); - ReportColumnSettingsModalController.$inject = ['reportsService']; + ReportColumnSettingsModalController.$inject = ['reportsService', 'api']; - function ReportColumnSettingsModalController (reportsService) { + function ReportColumnSettingsModalController (reportsService, api) { const vm = this; vm.$onInit = $onInit; @@ -24,6 +24,7 @@ vm.isAggregatable = isAggregatable; vm.report = {}; vm.settings = {}; + vm.categories = []; function $onInit () { const column = vm.resolve.column; @@ -39,6 +40,11 @@ vm.settings.format = column.format || ''; vm.settings.calculateTotal = column.calculateTotal || false; + api.getReportFilterValues({ ...column, layerID: vm.report.selectedLayerID }).then((data) => { + vm.categories = data.data.map((e) => e.f); + }); + vm.settings.icon = column.icon || {}; + const aggregations = []; if (column.elementType === 'number') { diff --git a/public/js/reports/report-dropzone.component.js b/public/js/reports/report-dropzone.component.js index 2f5f62cf..c3bfbd54 100644 --- a/public/js/reports/report-dropzone.component.js +++ b/public/js/reports/report-dropzone.component.js @@ -9,6 +9,7 @@ report: '<', settingsAvailable: '<', elements: '<', + maxElements: '<', zoneInfo: '<', onDrop: '&', onRemove: '&', @@ -27,6 +28,10 @@ vm.getColumnDescription = reportsService.getColumnDescription; function onDropItem (ev) { + if (vm.elements.length >= vm.maxElements) { + return; + } + const type = 'application/vnd.urungi.layer-element+json'; const data = ev.dataTransfer.getData(type); const layerElement = JSON.parse(data); @@ -62,6 +67,7 @@ column.type = settings.type; column.format = settings.format; column.calculateTotal = settings.calculateTotal; + column.icon = settings.icon; }, () => {}); return modal; diff --git a/public/js/reports/report-settings-modal.component.js b/public/js/reports/report-settings-modal.component.js index f6d8dfd2..618d8ed2 100644 --- a/public/js/reports/report-settings-modal.component.js +++ b/public/js/reports/report-settings-modal.component.js @@ -35,6 +35,7 @@ vm.settings.height = report.properties.height; vm.settings.maxValue = report.properties.maxValue; vm.settings.range = report.properties.range; + vm.settings.mapLayerUrl = report.properties.mapLayerUrl; vm.settings.theme = report.theme; } } diff --git a/public/partials/report/partials/drop-area.html b/public/partials/report/partials/drop-area.html index 3b60323b..0cbb3649 100644 --- a/public/partials/report/partials/drop-area.html +++ b/public/partials/report/partials/drop-area.html @@ -16,6 +16,14 @@ + + + + + + + +
    @@ -25,4 +33,4 @@ - + diff --git a/public/partials/report/partials/report-edit.html b/public/partials/report/partials/report-edit.html index a467c987..36ef67f2 100644 --- a/public/partials/report/partials/report-edit.html +++ b/public/partials/report/partials/report-edit.html @@ -14,6 +14,8 @@ + +
    + +
    + +
    + + Type the name of a Font Awesome 4 icon (See the list) +
    +
    diff --git a/public/partials/reports/report-settings-modal.component.html b/public/partials/reports/report-settings-modal.component.html index 228e4781..d7196e61 100644 --- a/public/partials/reports/report-settings-modal.component.html +++ b/public/partials/reports/report-settings-modal.component.html @@ -64,7 +64,14 @@

    Report settings

    List your limits separated by a slash symbol
    (Example : 20/30/40/50/60)
    - + + +
    + +
    + + Default : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' +
    diff --git a/server/app.js b/server/app.js index 103fef95..951779e9 100644 --- a/server/app.js +++ b/server/app.js @@ -61,6 +61,7 @@ const staticPaths = [ { p: '/js-xlsx', root: 'js-xlsx/dist' }, { p: '/jsplumb', root: 'jsplumb' }, { p: '/font-awesome', root: 'font-awesome' }, + { p: '/leaflet', root: 'leaflet/dist' }, ]; for (const { p, root } of staticPaths) { staticRouter.use(p, express.static(path.join(__dirname, '..', 'node_modules', root))); diff --git a/server/custom/reports/controller.js b/server/custom/reports/controller.js index d6c9ffc3..e4e79df1 100644 --- a/server/custom/reports/controller.js +++ b/server/custom/reports/controller.js @@ -357,6 +357,21 @@ function generateQuery (report) { if (report.properties.pivotKeys && Array.isArray(report.properties.pivotKeys.rows)) { columns.push.apply(columns, report.properties.pivotKeys.rows); } + if (report.properties.map && Array.isArray(report.properties.map.geojson)) { + columns.push.apply(columns, report.properties.map.geojson); + } + if (report.properties.map && Array.isArray(report.properties.map.value)) { + columns.push.apply(columns, report.properties.map.value); + } + if (report.properties.map && Array.isArray(report.properties.map.label)) { + columns.push.apply(columns, report.properties.map.label); + } + if (report.properties.map && Array.isArray(report.properties.map.group)) { + columns.push.apply(columns, report.properties.map.group); + } + if (report.properties.map && Array.isArray(report.properties.map.type)) { + columns.push.apply(columns, report.properties.map.type); + } query.columns = columns; query.order = report.properties.order ? report.properties.order.slice() : []; diff --git a/server/models/report.js b/server/models/report.js index 28540ec5..973544c7 100644 --- a/server/models/report.js +++ b/server/models/report.js @@ -19,6 +19,7 @@ const reportColumnSchema = new mongoose.Schema({ layerID: mongoose.Schema.Types.ObjectId, sortType: Number, type: { type: String }, + icon: {}, }); reportColumnSchema.virtual('layerObject').get(function () { @@ -51,7 +52,15 @@ const ReportPropertiesSchema = new mongoose.Schema({ columns: [reportColumnSchema], filters: [reportFilterSchema], height: Number, + mapLayerUrl: String, legendPosition: String, + map: { + geojson: [reportColumnSchema], + label: [reportColumnSchema], + value: [reportColumnSchema], + group: [reportColumnSchema], + type: { type: [reportColumnSchema] }, + }, maxValue: Number, order: [reportColumnSchema], pivotKeys: { diff --git a/test/client/reports/map.service.spec.js b/test/client/reports/map.service.spec.js new file mode 100644 index 00000000..a184656d --- /dev/null +++ b/test/client/reports/map.service.spec.js @@ -0,0 +1,412 @@ +require('../../../public/js/core/core.module.js'); +require('../../../public/js/core/constants.js'); +require('../../../public/js/core/api.js'); +require('../../../public/js/core/connection.js'); +require('../../../public/js/layers/layers.module.js'); +require('../../../public/js/layers/layer.service.js'); +require('../../../public/js/reports/reports.module.js'); +require('../../../public/js/reports/constants.js'); +require('../../../public/js/reports/reports.service.js'); +require('../../../public/js/reports/map.service.js'); + +describe('map', function () { + beforeEach(angular.mock.module('app.core')); + beforeEach(angular.mock.module('app.reports')); + beforeEach(angular.mock.module('app.layers')); + + let map; + + beforeEach(inject(function (_map_, _L_) { + map = _map_; + })); + + describe('getGroups', function () { + it('should add group to groups', function () { + const mapProperties = { + geojson: [{ + id: 'eaiufraw', + elementID: 'aiuf', + elementLabel: 'geojson', + elementName: 'comp', + elementRole: 'dimension', + elementType: 'string' + }], + group: [{ + id: 'eagmeraw', + elementID: 'agme', + elementLabel: 'gid', + elementName: 'gid', + elementRole: 'dimension', + elementType: 'number' + }], + label: [{ + id: 'eafrsraw', + elementID: 'afrs', + elementLabel: 'label', + elementName: 'label', + elementRole: 'dimension', + elementType: 'string' + }], + type: [{ + id: 'eahaoraw', + elementID: 'ahao', + elementLabel: 'type', + elementName: 'type', + elementRole: 'dimension', + elementType: 'string' + }], + value: [{ + id: 'eaexaraw', + elementID: 'aexa', + elementLabel: 'value', + elementName: 'value', + elementRole: 'dimension', + elementType: 'number' + }], + + }; + + const dataRows = [{ + eaexaraw: 900, + eafrsraw: 'Gap', + eagmeraw: 2, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [1.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 100, + eafrsraw: 'Marseille', + eagmeraw: 2, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [4.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 500, + eafrsraw: 'Paris', + eagmeraw: 2, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [9.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Lyon', + eagmeraw: 1, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [0.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Aix', + eagmeraw: 1, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [10.3291015625, 28.90083790234091]}' + }, + ]; + + const result = map.getGroups(mapProperties, dataRows); + expect(result.size).toStrictEqual(2); + expect(result.has(2)).toStrictEqual(true); + expect(result.has(1)).toStrictEqual(true); + }); + }); + + describe('getGroupColor', function () { + it('should attribuate groups color', function () { + const mapProperties = { + geojson: [{ + id: 'eaiufraw', + elementID: 'aiuf', + elementLabel: 'geojson', + elementName: 'comp', + elementRole: 'dimension', + elementType: 'string' + }], + group: [{ + id: 'eagmeraw', + elementID: 'agme', + elementLabel: 'gid', + elementName: 'gid', + elementRole: 'dimension', + elementType: 'number' + }], + label: [{ + id: 'eafrsraw', + elementID: 'afrs', + elementLabel: 'label', + elementName: 'label', + elementRole: 'dimension', + elementType: 'string' + }], + type: [{ + id: 'eahaoraw', + elementID: 'ahao', + elementLabel: 'type', + elementName: 'type', + elementRole: 'dimension', + elementType: 'string' + }], + value: [{ + id: 'eaexaraw', + elementID: 'aexa', + elementLabel: 'value', + elementName: 'value', + elementRole: 'dimension', + elementType: 'number' + }], + + }; + const dataRows = [{ + eaexaraw: 900, + eafrsraw: 'Gap', + eagmeraw: 2, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [1.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 100, + eafrsraw: 'Marseille', + eagmeraw: 2, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [4.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 500, + eafrsraw: 'Paris', + eagmeraw: 2, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [9.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Lyon', + eagmeraw: 1, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [0.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Aix', + eagmeraw: 1, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [10.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Aix', + eagmeraw: 3, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [10.3291015625, 28.90083790234091]}' + }, + ]; + + const result = map.getGroupColor(mapProperties, dataRows); + expect(result.size).toStrictEqual(3); + expect(result.has(2)).toStrictEqual(true); + expect(result.has(1)).toStrictEqual(true); + expect(result.has(3)).toStrictEqual(true); + }); + }); + describe('getStyle', function () { + it('should apply color style into a value range', function () { + const mapProperties = { + geojson: [{ + id: 'eaiufraw', + elementID: 'aiuf', + elementLabel: 'geojson', + elementName: 'comp', + elementRole: 'dimension', + elementType: 'string' + }], + group: [{ + id: 'eagmeraw', + elementID: 'agme', + elementLabel: 'gid', + elementName: 'gid', + elementRole: 'dimension', + elementType: 'number' + }], + label: [{ + id: 'eafrsraw', + elementID: 'afrs', + elementLabel: 'label', + elementName: 'label', + elementRole: 'dimension', + elementType: 'string' + }], + type: [{ + id: 'eahaoraw', + elementID: 'ahao', + elementLabel: 'type', + elementName: 'type', + elementRole: 'dimension', + elementType: 'string' + }], + value: [{ + id: 'eaexaraw', + elementID: 'aexa', + elementLabel: 'value', + elementName: 'value', + elementRole: 'dimension', + elementType: 'number' + }], + + }; + const dataRows = [{ + eaexaraw: 900, + eafrsraw: 'Gap', + eagmeraw: 2, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [1.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 100, + eafrsraw: 'Marseille', + eagmeraw: 2, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [4.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 500, + eafrsraw: 'Paris', + eagmeraw: 2, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [9.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Lyon', + eagmeraw: 1, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [0.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Aix', + eagmeraw: 1, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [10.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Aix', + eagmeraw: 3, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [10.3291015625, 28.90083790234091]}' + }, + ]; + const values = []; + let minValue = 0; + let maxValue = 0; + + dataRows.forEach((e) => { + values.push(e.eaexaraw); + }); + + minValue = Math.min(...values); + maxValue = Math.max(...values); + + dataRows.forEach((e) => { + expect(e.eaexaraw).toBeGreaterThanOrEqual(minValue); + expect(e.eaexaraw).toBeLessThanOrEqual(maxValue); + }); + + const receivedObject = map.getStyle(mapProperties, minValue, maxValue, dataRows[0].eaexaraw); + expect(Object.keys(receivedObject).length).toStrictEqual(3); + expect(Object.keys(receivedObject)).toStrictEqual(['stroke', 'fillOpacity', 'fillColor']); + }); + }); + describe('getValuesBounds', function () { + it('should get min & max value for apply color instensity', function () { + const mapProperties = { + geojson: [{ + id: 'eaiufraw', + elementID: 'aiuf', + elementLabel: 'geojson', + elementName: 'comp', + elementRole: 'dimension', + elementType: 'string' + }], + group: [{ + id: 'eagmeraw', + elementID: 'agme', + elementLabel: 'gid', + elementName: 'gid', + elementRole: 'dimension', + elementType: 'number' + }], + label: [{ + id: 'eafrsraw', + elementID: 'afrs', + elementLabel: 'label', + elementName: 'label', + elementRole: 'dimension', + elementType: 'string' + }], + type: [{ + id: 'eahaoraw', + elementID: 'ahao', + elementLabel: 'type', + elementName: 'type', + elementRole: 'dimension', + elementType: 'string' + }], + value: [{ + id: 'eaexaraw', + elementID: 'aexa', + elementLabel: 'value', + elementName: 'value', + elementRole: 'dimension', + elementType: 'number' + }], + + }; + const dataRows = [{ + eaexaraw: 900, + eafrsraw: 'Gap', + eagmeraw: 2, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [1.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 100, + eafrsraw: 'Marseille', + eagmeraw: 2, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [4.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 500, + eafrsraw: 'Paris', + eagmeraw: 2, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [9.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Lyon', + eagmeraw: 1, + eahaoraw: 'client', + eaiufraw: '{"type": "Point", "coordinates": [0.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Aix', + eagmeraw: 1, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [10.3291015625, 28.90083790234091]}' + }, + { + eaexaraw: 350, + eafrsraw: 'Aix', + eagmeraw: 3, + eahaoraw: 'library', + eaiufraw: '{"type": "Point", "coordinates": [10.3291015625, 28.90083790234091]}' + }, + ]; + + const result = map.getValuesBounds(mapProperties, dataRows); + expect(result.maxValue).toStrictEqual(900); + expect(result.minValue).toStrictEqual(100); + }); + }); +}); diff --git a/test/client/reports/report-column-settings-modal.component.spec.js b/test/client/reports/report-column-settings-modal.component.spec.js index 82bbdfa7..778bbf3f 100644 --- a/test/client/reports/report-column-settings-modal.component.spec.js +++ b/test/client/reports/report-column-settings-modal.component.spec.js @@ -1,4 +1,8 @@ require('../../../public/js/core/core.module.js'); +require('../../../public/js/core/constants.js'); +require('../../../public/js/core/connection.js'); +require('../../../public/js/core/notify.service.js'); +require('../../../public/js/core/api.js'); require('../../../public/js/reports/reports.module.js'); require('../../../public/js/reports/reports.service.js'); require('../../../public/js/reports/report-column-settings-modal.component.js'); diff --git a/test/client/setup.js b/test/client/setup.js index d7cadb3b..f6925df8 100644 --- a/test/client/setup.js +++ b/test/client/setup.js @@ -15,6 +15,9 @@ Object.defineProperty(window, 'moment', { value: moment }); const numeral = require('numeral'); Object.defineProperty(window, 'numeral', { value: numeral }); +const L = require('leaflet'); +Object.defineProperty(window, 'L', { value: L }); + const angular = require('angular'); Object.defineProperty(window, 'angular', { value: angular }); diff --git a/views/index.ejs b/views/index.ejs index b69e2daf..72413a65 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -20,6 +20,7 @@ + @@ -72,8 +73,10 @@ + + @@ -113,11 +116,13 @@ + + @@ -173,6 +178,8 @@ + + +