From 568aa483cced3c3ce8262cf3dfbd3ffc619f91d0 Mon Sep 17 00:00:00 2001 From: AuroreC <chayrouse@datakode.fr> Date: Thu, 17 Nov 2022 11:58:38 +0100 Subject: [PATCH] Organizations : Map added + Dashboard admin : map added --- .../Api/OrganizationsController.php | 2 + ...30_185437_add_lat_lng_to_address_table.php | 33 ++++ package-lock.json | 15 ++ package.json | 1 + public/mix-manifest.json | 3 + .../AdminView/Components/MaturityCyberMap.vue | 143 +++++++++++++++++- .../Organizations/Single/AddressBlock.vue | 25 ++- .../Organizations/Single/PositionBlock.vue | 103 +++++++++++++ .../components/Organizations/Single/index.vue | 4 +- resources/sass/app.scss | 2 + 10 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2022_10_30_185437_add_lat_lng_to_address_table.php create mode 100644 resources/js/components/Organizations/Single/PositionBlock.vue diff --git a/app/Http/Controllers/Api/OrganizationsController.php b/app/Http/Controllers/Api/OrganizationsController.php index a1f58fc8..03ff5b72 100644 --- a/app/Http/Controllers/Api/OrganizationsController.php +++ b/app/Http/Controllers/Api/OrganizationsController.php @@ -83,6 +83,8 @@ public function save(OrganizationRequest $request, $id = null) $address->cp = $data['address']['cp']; $address->city = $data['address']['city']; $address->codeInsee = $data['address']['codeInsee']; + $address->lat = $data['address']['lat'] ?? null; + $address->lng = $data['address']['lng'] ?? null; $address->save(); $organization->referent_id = $this->UpdateReferent($referent, $data['referent']); diff --git a/database/migrations/2022_10_30_185437_add_lat_lng_to_address_table.php b/database/migrations/2022_10_30_185437_add_lat_lng_to_address_table.php new file mode 100644 index 00000000..e2372bcd --- /dev/null +++ b/database/migrations/2022_10_30_185437_add_lat_lng_to_address_table.php @@ -0,0 +1,33 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::table('address', function (Blueprint $table) { + $table->string('lat')->nullable(); + $table->string('lng')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('address', function (Blueprint $table) { + $table->dropColumn(['lat', 'lng']); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index d2691b28..7229bede 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "chartjs-plugin-stacked100": "^1.2.1", "dotenv": "^16.0.1", "leaflet": "^1.9.2", + "leaflet.markercluster": "^1.5.3", "vue-chartjs": "^4.1.1", "vue-property-decorator": "^9.1.2", "vue-tables-2": "^2.3.5", @@ -8303,6 +8304,14 @@ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.2.tgz", "integrity": "sha512-Kc77HQvWO+y9y2oIs3dn5h5sy2kr3j41ewdqCMEUA4N89lgfUUfOBy7wnnHEstDpefiGFObq12FdopGRMx4J7g==" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -19230,6 +19239,12 @@ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.2.tgz", "integrity": "sha512-Kc77HQvWO+y9y2oIs3dn5h5sy2kr3j41ewdqCMEUA4N89lgfUUfOBy7wnnHEstDpefiGFObq12FdopGRMx4J7g==" }, + "leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "requires": {} + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index 78330a76..32a01045 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "chartjs-plugin-stacked100": "^1.2.1", "dotenv": "^16.0.1", "leaflet": "^1.9.2", + "leaflet.markercluster": "^1.5.3", "vue-chartjs": "^4.1.1", "vue-property-decorator": "^9.1.2", "vue-tables-2": "^2.3.5", diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 31ef7126..332f6348 100644 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -10,6 +10,9 @@ "/images/logoblancetorange.png": "/images/logoblancetorange.png", "/images/rectangle.png": "/images/rectangle.png", "/images/soluris-logo-white.png": "/images/soluris-logo-white.png", + "/images/vendor/leaflet/dist/layers-2x.png": "/images/vendor/leaflet/dist/layers-2x.png", + "/images/vendor/leaflet/dist/layers.png": "/images/vendor/leaflet/dist/layers.png", + "/images/vendor/leaflet/dist/marker-icon.png": "/images/vendor/leaflet/dist/marker-icon.png", "/images/vendor/vuejs-datatable/docs/icons.png": "/images/vendor/vuejs-datatable/docs/icons.png", "/images/vendor/vuejs-datatable/docs/icons@2x.png": "/images/vendor/vuejs-datatable/docs/icons@2x.png", "/images/vendor/vuejs-datatable/docs/widgets.png": "/images/vendor/vuejs-datatable/docs/widgets.png", diff --git a/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue b/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue index b33d0655..fc044ca0 100644 --- a/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue +++ b/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue @@ -1,19 +1,158 @@ <template> <div class="card py-3 px-2"> <div class="card-title mb-3">Cartographie de la maturité cyber</div> + + <select v-model="measureFilter" class="form-control form-control-sm mb-3"> + <option value="">Toutes les mesures de sécurité fondamentales</option> + <option v-for="m in fundamentalMeasures" :key="m.id" :value="m.id">{{m.short_name}}</option> + </select> + + <div class="row"> + <div class='my-2 col-md-6 text-right' @click="stripetext('green')" :style= "[disabledFilter.includes('green') ? {'text-decoration':'line-through'} : {'text-decoration':''}]"> + <div class="mr-2 d-inline-block" style="height: 15px; width: 35px; background-color: green;"></div><span class="text-muted small">En règle</span> + </div> + <div class='my-2 col-md-6 text-left' @click="stripetext('red')" :style= "[disabledFilter.includes('red') ? {'text-decoration':'line-through'} : {'text-decoration':''}]"> + <div class="mr-2 d-inline-block" style="height: 15px; width: 35px; background-color: red;"></div><span class="text-muted small">En alerte</span> + </div> + </div> + + <div id="map"></div> + </div> </template> <script> +import L from 'leaflet'; + +import 'leaflet.markercluster'; +import 'leaflet.markercluster/dist/MarkerCluster.Default.css' + export default { name: 'MaturityCyberMap', props : [], data () { return { + seuilAlerte : parseInt(this.$seuilAlerte), + + map : null, + markerClusterGroup : null, + array_positions : [], + measureFilter : "", + disabledFilter : [], + } }, computed: { + measures () { + return this.$store.state.measures.all + }, + evaluations () { + return this.$store.state.evaluations.all + }, + fundamentalMeasuresId () { + return this.fundamentalMeasures.map(m => m.id) + }, + fundamentalMeasures () { + return this.$store.state.measures.all.filter( m => m.fundamental) + }, + organizations () { + let orgs = this.$store.state.organizations.all.map( org => { + let evaluations = this.evaluations.filter( e => e.organization_id === org.id) + .filter( e => e.status === 2) + .sort( (a,b) => new Date(b.updated_at) - new Date(a.updated_at)) + + + const getInsideSeuilAlerte = evaluations.length ? evaluations[0].measure_levels.filter( ml => this.fundamentalMeasuresId.includes(ml.measure_id)).filter( ml => ml.actual_level <= this.seuilAlerte) : [] + return {...org, evaluations : evaluations, seuilAlert : getInsideSeuilAlerte} + }) + + return orgs .filter( org => this.disabledFilter.length ? this.disabledFilter.length > 1 ? false : this.disabledFilter.includes('red') ? !org.seuilAlert.length : this.disabledFilter.includes('green') ? org.seuilAlert.length : true : true) + .filter( org => this.measureFilter ? org.seuilAlert.find(a => a.measure_id === this.measureFilter) : true) + }, + + }, + mounted () { + this.initMap() + }, + watch : { + organizations () { + this.initMarkers() + } + }, + methods : { + stripetext(id){ + if (this.disabledFilter.includes(id)) { + this.disabledFilter = this.disabledFilter.filter(f => f !== id) + } else { + this.disabledFilter.push(id) + } + }, + initMap () { + this.map = L.map('map') + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + // zoom: 5, + // center: [46.9, 1.9], + maxZoom: 19, + attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' + }).addTo(this.map); + this.map.setView([46.9, 1.9], 5); // FRANCE BOUNDS + this.map.options.maxZoom = 14; + this.initMarkers() + }, + initMarkers () { + this.markerClusterGroup && this.map.removeLayer(this.markerClusterGroup) + this.markerClusterGroup = L.markerClusterGroup({ + maxClusterRadius: function() { return 30; } + }); + + this.array_positions = []; + + this.organizations.forEach( org => { + + if (org.evaluations.length) { // Cette carte affiche un point par structure ayant au moins une évaluation + + let color = org.seuilAlert.length ? "red" : "green" + + const style = { radius: 5, fillColor: color, fillOpacity: 1, color: color, organization: org} + const latlng = org.address && org.address.lat && org.address.lng? [parseFloat(org.address.lat), parseFloat(org.address.lng)] : [] - } + if (latlng.length) { + let measureText = org.seuilAlert.map(a => a.measure.short_name) + let alertText = org.seuilAlert.length ? "<br/><i><small>Mesures de sécurité fondamentales en alerte :</small></i> " + measureText.join(', ') : "" + + this.markerClusterGroup.addLayer(L.circleMarker(latlng, style) + .bindPopup(` +<b>${org.name}</b> <br> +<i><small>Indice de maturité cyber : </small></i>${org.evaluations[0].maturity_cyber} +${alertText}`) + + .on('mouseover', function() { + this.openPopup()}) + .on('mouseout', function() { + this.closePopup(); + }) + ) + this.array_positions.push(latlng); + } + } + }) + this.map.addLayer(this.markerClusterGroup); + this.array_positions.length && this.map.fitBounds(this.array_positions) + + }, + }, +} +</script> +<style scoped> +#map { + height: 400px; +} +.vertical-center { + position: absolute; + top: 50%; + transform: translateY(-50%); +} +.text-muted { + letter-spacing: -0.5px; } -</script> \ No newline at end of file +</style> \ No newline at end of file diff --git a/resources/js/components/Organizations/Single/AddressBlock.vue b/resources/js/components/Organizations/Single/AddressBlock.vue index 714c3b8a..1a3aaa71 100644 --- a/resources/js/components/Organizations/Single/AddressBlock.vue +++ b/resources/js/components/Organizations/Single/AddressBlock.vue @@ -46,20 +46,24 @@ <div v-if="$v.address.city.$error" class="text-danger">Champ obligatoire</div> </div> <div class="form-group"> - <label :class="{ 'text-danger': submitted && $v.address.codeInsee.$error }" for="insee">Code - INSEE <i class="text-muted small">(Obligatoire)</i></label> + <label :class="{ 'text-danger': submitted && $v.address.codeInsee.$error }" for="insee">Code INSEE <i class="text-muted small">(Obligatoire)</i></label> <input id="insee" v-model="address.codeInsee" type="text" class="form-control form-control-sm" :class="{ 'is-invalid': submitted && $v.address.codeInsee.$error }"> <div v-if="$v.address.codeInsee.$error" class="text-danger">Champ obligatoire</div> </div> + <div class="form-group"> + <PositionBlock :address="address" @addressError="addressError($event)" /> + <div v-if="$v.address.lat.$error || $v.address.lng.$error" class="text-danger">Localisation obligatoire</div> + </div> </div> </div> </div> </template> <script> import {required, integer} from 'vuelidate/lib/validators' +import PositionBlock from './PositionBlock.vue' export default { name: 'AddressBlock', @@ -69,13 +73,18 @@ export default { address: this.orgAddress } }, + components : { + PositionBlock, + }, computed: {}, validations: { address: { address: {required}, cp: {required, integer}, city: {required}, - codeInsee: {required} + codeInsee: {required}, + lat: {required}, + lng: {required}, } }, @@ -88,7 +97,15 @@ export default { } else { return true } - } + }, + addressError (message) { + console.log('test error') + this.$parent.toaster(this, message, 'bg-red') + }, + updateAddressLoc (address) { + this.address.lat = address.lat + this.address.lng = address.lng + }, } } </script> diff --git a/resources/js/components/Organizations/Single/PositionBlock.vue b/resources/js/components/Organizations/Single/PositionBlock.vue new file mode 100644 index 00000000..4d0950cb --- /dev/null +++ b/resources/js/components/Organizations/Single/PositionBlock.vue @@ -0,0 +1,103 @@ +<template> + <div> + <button @click.prevent="locateAddress" class="btn btn-primary btn-sm mb-2"><i :class="loading ? 'fa fa-spinner fa-spin' : ''"></i> Localiser l'adresse</button> + <div id="map"></div> + </div> +</template> +<script> +import L from 'leaflet'; +import axios from 'axios' + +export default { + name: 'PositionBlock', + props: ['address'], + data() { + return { + loading : false, + map : null, + } + }, + computed: { + fullAddress () { + return `${this.address.address} ${this.address.cp} ${this.address.city}` + } + }, + mounted () { + this.initMap() + this.address && this.address.lat && this.address.lng && this.setMarker(parseFloat(this.address.lat), parseFloat(this.address.lng)) + }, + methods: { + initMap () { + this.map = L.map('map') + + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' + }).addTo(this.map); + + this.map.setView([46.9, 1.9], 5); // FRANCE BOUNDS + }, + setMarker (lat, lng) { + this.marker = L.circleMarker([lat, lng], {draggable: true, radius: 7,fillColor:'red',fillOpacity:0.5,color: 'red'}) + this.address.lat = lat + this.address.lng = lat + + this.marker.on({ + mousedown: () => this.map.on('mousemove', (e) => { + this.marker.setLatLng(e.latlng); + this.map.dragging.disable() + + this.address.lat = e.latlng.lat + this.address.lng = e.latlng.lng + this.$emit('updateAddressLoc', this.address) + }), + mouseup: () => { + this.map.dragging.enable() + this.map.off('mousemove') + } + }) + this.marker.addTo(this.map) + + const latLngs = [ this.marker.getLatLng() ]; + const markerBounds = L.latLngBounds(latLngs); + this.map.fitBounds(markerBounds); + }, + async locateAddress () { + console.log('locateAddress') + if (!this.address.address || !this.address.cp || !this.address.city) { + this.$emit('addressError', "L'adresse n'est pas complète") + return + } else { + this.loading = true + await axios.get('https://api-adresse.data.gouv.fr/search/?type=housenumber&autocomplete=1&q=' + this.fullAddress, + { + transformRequest: (data, headers) => { + delete headers.common['X-CSRF-TOKEN']; + } + } + ) + .then(res => { + this.loading = false + + if (!res.data.features.length) { + this.$emit('addressError', "L'adresse n'a pas été trouvé") + return; + } else { + this.enableMap = true + const pointData = res.data.features[0] + + this.marker && this.map.removeLayer(this.marker) + + this.setMarker(pointData.geometry.coordinates[1], pointData.geometry.coordinates[0]) + } + }) + } + } + } +} +</script> +<style scoped> +#map { + height: 400px; +} +</style> diff --git a/resources/js/components/Organizations/Single/index.vue b/resources/js/components/Organizations/Single/index.vue index 74b21ea1..e3a9f377 100644 --- a/resources/js/components/Organizations/Single/index.vue +++ b/resources/js/components/Organizations/Single/index.vue @@ -120,7 +120,9 @@ const defaultOrg = { moreInfos: null, cp: null, city: null, - codeInsee: null + codeInsee: null, + lat : null, + lng : null, }, referent: { id: null, diff --git a/resources/sass/app.scss b/resources/sass/app.scss index 5e44f0ec..fa6d34b4 100644 --- a/resources/sass/app.scss +++ b/resources/sass/app.scss @@ -9,6 +9,8 @@ @import '~bootstrap/scss/bootstrap'; @import '~admin-lte/build/scss/adminlte'; +@import '~leaflet/dist/leaflet.css'; + .bg-grey { background-color : #454545!important; -- GitLab