diff --git a/src/App.vue b/src/App.vue index fee4e7ee79ac8bcce90b3bf7a412877d5de02299..5c941e33ffd3add1778c4ecff63574bfde6f0394 100644 --- a/src/App.vue +++ b/src/App.vue @@ -68,9 +68,6 @@ export default { </script> <style> -@import "./assets/styles/base.css"; -@import "./assets/resources/semantic-ui-2.4.2/semantic.min.css"; - .vertical { flex-direction: column; justify-content: center; diff --git a/src/assets/styles/sidebar-layers.css b/src/assets/styles/sidebar-layers.css index 8ffe4dfc7c0afc5f10ba0c6aadeb25cab2f40e18..32a85c3dc477ad9b2f122216dd6d2b7f11dd19b0 100644 --- a/src/assets/styles/sidebar-layers.css +++ b/src/assets/styles/sidebar-layers.css @@ -142,7 +142,6 @@ } /* Layer item */ - .layer-item { padding-bottom: 0.5rem; } @@ -162,6 +161,7 @@ .range-container { display: flex; + min-width: 15em; /* give space for the bubble since adding a min-width to keep its shape */ } .range-output-bubble { @@ -170,6 +170,8 @@ padding: 4px 7px; border-radius: 40px; background-color: #2c3e50; + min-width: 2em; + text-align: center; } /* Overrides default padding of semantic-ui accordion */ diff --git a/src/components/Dropdown.vue b/src/components/Dropdown.vue index 2e9ce2602330d50e6753bb874c8df3efa18c7e22..77291019eb380735672cf83eb69fc8f7179473a1 100644 --- a/src/components/Dropdown.vue +++ b/src/components/Dropdown.vue @@ -40,6 +40,7 @@ <div :class="['menu', { 'visible transition': isOpen }]"> <div v-for="(option, index) in filteredOptions || ['No results found.']" + :id="option.name && Array.isArray(option.name) ? option.name[0] : option.name" :key="option + index" :class="[ filteredOptions ? 'item' : 'message', @@ -117,7 +118,7 @@ export default { created() { const crypto = window.crypto || window.msCrypto; - var array = new Uint32Array(1); + const array = new Uint32Array(1); this.identifier = Math.floor(crypto.getRandomValues(array) * 10000); window.addEventListener('mousedown', this.clickOutsideDropdown); }, diff --git a/src/components/FeatureType/SymbologySelector.vue b/src/components/FeatureType/SymbologySelector.vue index 29f419340b0416730f9d08a41c611d28750296c8..f058de487ce2339d98028fed2841d8d344b6e973 100644 --- a/src/components/FeatureType/SymbologySelector.vue +++ b/src/components/FeatureType/SymbologySelector.vue @@ -1,9 +1,9 @@ <template> <div> <div class="three fields"> - <div class="row-title"> + <h4 :class="['field', {'row-title' : title == 'Symbologie par défault :'}]"> {{ title }} - </div> + </h4> <div class="required inline field"> <label :for="form.color.id_for_label">{{ form.color.label }}</label> <input @@ -14,25 +14,26 @@ :name="form.color.html_name" > </div> - <!-- <div class="required inline field"> - <label>Symbole</label> - <button - class="ui icon button picker-button" - type="button" - @click="openIconSelectionModal" - > - <font-awesome-icon - :icon="['fas', form.icon]" - :style="{ color: form.color.value || '#000000' }" - class="icon alt" - /> - </button> - </div> --> + <div v-if="geomType === 'polygon' || title !== 'Symbologie par défault :'"> + <label>Opacité <span>(%)</span></label> + <div class="range-container"> + <input + id="opacity" + v-model="form.opacity" + type="range" + min="0" + max="1" + step="0.01" + > + <output class="range-output-bubble"> + {{ getOpacity(form.opacity) }} + </output> + </div> + </div> </div> <div ref="iconsPickerModal" - :class="isIconPickerModalOpen ? 'active' : ''" - class="ui dimmer modal transition" + :class="['ui dimmer modal transition', { active: isIconPickerModalOpen }]" > <div class="header"> Sélectionnez le symbole pour ce type de signalement : @@ -41,8 +42,7 @@ <div v-for="icon of iconsNamesList" :key="icon" - :class="form.icon === icon ? 'active' : ''" - class="icon-container" + :class="['icon-container', { active: form.icon === icon }]" @click="selectIcon(icon)" > <i @@ -83,6 +83,10 @@ export default { type: String, default: 'circle' }, + initOpacity: { + type: String, + default: '1' + }, geomType: { type: String, default: 'Point' @@ -104,6 +108,7 @@ export default { html_name: 'couleur', value: '#000000', }, + opacity: '0.5', } }; }, @@ -125,6 +130,9 @@ export default { if (this.initIcon) { this.form.icon = this.initIcon; } + if (this.initOpacity) { + this.form.opacity = this.initOpacity; + } this.$emit('set', { name: this.title, value: this.form @@ -138,7 +146,11 @@ export default { selectIcon(icon) { this.form.icon = icon; - } + }, + + getOpacity(opacity) { + return Math.round(parseFloat(opacity) * 100); + }, } }; </script> @@ -154,11 +166,16 @@ export default { .row-title { display: inline; font-size: 1.4em; + font-weight: normal; width: 33%; text-align: left; margin-left: 0.5em; } +.default { + margin-bottom: 2rem; +} + #couleur { width: 66%; cursor: pointer; diff --git a/src/components/Map/SidebarLayers.vue b/src/components/Map/SidebarLayers.vue index d53fa84e5254f2741fc15428f9a1a87d923c013c..c2faef528133167542f4fceb9e8b418b4c04f3bc 100644 --- a/src/components/Map/SidebarLayers.vue +++ b/src/components/Map/SidebarLayers.vue @@ -380,7 +380,6 @@ export default { </script> <style> -@import "../../assets/styles/sidebar-layers.css"; .queryable-layers-dropdown { margin-bottom: 1em; } diff --git a/src/components/Project/Detail/ProjectFeatureTypes.vue b/src/components/Project/Detail/ProjectFeatureTypes.vue index 81cfd78cd9125a44120f088e849d5c02eeff45de..7cb7968533a7c1f3a17d9d919486a52e05b3075b 100644 --- a/src/components/Project/Detail/ProjectFeatureTypes.vue +++ b/src/components/Project/Detail/ProjectFeatureTypes.vue @@ -25,6 +25,7 @@ </div> <div v-for="(type, index) in feature_types" + :id="type.title" :key="type.title + '-' + index" class="item" > diff --git a/src/main.js b/src/main.js index b5349516c720d93548c7d28f154d89092d7ebd3e..4bf957d27033d17d20e97b20b73a92e60d13335f 100644 --- a/src/main.js +++ b/src/main.js @@ -5,10 +5,13 @@ import App from './App.vue'; import './registerServiceWorker'; import router from '@/router'; import store from '@/store'; +import './assets/styles/base.css'; +import './assets/resources/semantic-ui-2.4.2/semantic.min.css'; import '@fortawesome/fontawesome-free/css/all.css'; import '@fortawesome/fontawesome-free/js/all.js'; import 'ol/ol.css'; import '@/assets/styles/openlayers-custom.css'; +import '@/assets/styles/sidebar-layers.css'; import { library } from '@fortawesome/fontawesome-svg-core'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; diff --git a/src/services/map-service.js b/src/services/map-service.js index 2c253118ea88805b72957f1a4c8255e4baf8c9b4..87156fe6e52733ddd58cc98706b0da17067e0666 100644 --- a/src/services/map-service.js +++ b/src/services/map-service.js @@ -113,7 +113,6 @@ const mapService = { addRouterToPopup(featureTypeSlug, featureId) { function goToFeatureDetail() { - console.log(featureTypeSlug, featureId); router.push({ name: 'details-signalement', params: { @@ -329,15 +328,29 @@ const mapService = { this.addLayers(layers); }, - retrieveFeatureColor: function (featureType, properties) { - const colorsStyle = featureType.colors_style; - if (featureType && colorsStyle && colorsStyle.custom_field_name) { - const currentValue = properties[colorsStyle.custom_field_name]; - const colorStyle = colorsStyle.colors[currentValue]; - return colorStyle ? colorStyle : featureType.color; - } else { - return featureType.color; + retrieveFeatureStyle: function (featureType, properties) { + const { colors_style, customfield_set } = featureType; + let { color, opacity } = featureType; + + if (colors_style && colors_style.custom_field_name && customfield_set) { + const fieldType = customfield_set.find((el) => el.name === colors_style.custom_field_name).field_type; + const currentValue = properties[colors_style.custom_field_name]; + + if (currentValue) { + switch (fieldType) { + case 'list' : + color = colors_style.colors[currentValue]; + opacity = colors_style.opacities[currentValue]; + break; + case 'char': //* if the custom field is supposed to be a string + //* check if its current value is empty or not, to select a color | https://redmine.neogeo.fr/issues/14048 + color = colors_style.value.colors[currentValue ? 'Non vide' : 'Vide']; + opacity = colors_style.value.opacities[currentValue ? 'Non vide' : 'Vide']; + break; + } + } } + return { color, opacity }; }, addVectorTileLayer: function (url, projectId, featureTypes, formFilters) { @@ -367,96 +380,93 @@ const mapService = { const properties = feature.getProperties(); let featureType; // GeoJSON - if(properties.feature_type){ + if(properties && properties.feature_type){ featureType = featureTypes .find((ft) => ft.slug === (properties.feature_type.slug || properties.feature_type)); } else { //MVT featureType = featureTypes.find((x) => x.slug.split('-')[0] === '' + properties.feature_type_id); } - const color = this.retrieveFeatureColor(featureType, properties); - const colorValue = + + if (featureType) { + const { color, opacity } = this.retrieveFeatureStyle(featureType, properties); + const colorValue = color.value && color.value.length ? color.value : typeof color === 'string' && color.length ? color : '#000000'; - const rgbaColor = asArray(colorValue); - rgbaColor[3] = 0.5;//opacity - const hiddenStyle = new Style(); - - const defaultStyle = new Style( - { - image: new Circle({ - fill: new Fill( - { - color: rgbaColor, - }, - ), + const rgbaColor = asArray(colorValue); + rgbaColor[3] = opacity || 0.5;//opacity + + const defaultStyle = new Style( + { + image: new Circle({ + fill: new Fill( + { + color: rgbaColor, + }, + ), + stroke: new Stroke( + { + color: colorValue, + width: 2, + }, + ), + radius: 5, + }), stroke: new Stroke( { color: colorValue, width: 2, }, ), - radius: 5, - }), - stroke: new Stroke( - { - color: colorValue, - width: 2, - }, - ), - fill: new Fill( - { - color: rgbaColor, - }, - ), - }, - ); - - // Filtre sur le feature type - if(formFilters){ - if (formFilters.type && formFilters.type.selected) { - if (featureType.title !== formFilters.type.selected) { - return hiddenStyle; + fill: new Fill( + { + color: rgbaColor, + }, + ), + }, + ); + + const hiddenStyle = new Style(); // hide the feature to apply filters + // Filtre sur le feature type + if(formFilters){ + if (formFilters.type && formFilters.type.selected) { + if (featureType.title !== formFilters.type.selected) { + return hiddenStyle; + } } - } - // Filtre sur le statut - if (formFilters.status && formFilters.status.selected.value) { - if (properties.status !== formFilters.status.selected.value) { - return hiddenStyle; + // Filtre sur le statut + if (formFilters.status && formFilters.status.selected.value) { + if (properties.status !== formFilters.status.selected.value) { + return hiddenStyle; + } } - } - // Filtre sur le titre - if (formFilters.title) { - if (!properties.title.toLowerCase().includes(formFilters.title.toLowerCase())) { - return hiddenStyle; + // Filtre sur le titre + if (formFilters.title) { + if (!properties.title.toLowerCase().includes(formFilters.title.toLowerCase())) { + return hiddenStyle; + } } } + return defaultStyle; + } else { + console.error('No corresponding featureType found.'); + return; } - - return defaultStyle; }, addFeatures: function (features, filter, featureTypes, addToMap = true) { - console.log(addToMap); + console.log('addToMap', addToMap); const drawSource = new VectorSource(); let retour; // TODO verifier utilité de cette boucle et remplacer par readFeatures plutot features.forEach((feature) => { - retour = new GeoJSON().readFeature(feature, { dataProjection: 'EPSG:4326', featureProjection: 'EPSG:3857' }, featureTypes); - drawSource.addFeature(retour); - // const featureProperties = feature.properties ? feature.properties : feature; - // const featureType = featureTypes - // .find((ft) => ft.slug === (featureProperties.feature_type.slug || featureProperties.feature_type)); - // let filters = []; - // if (filter) { - // const typeCheck = filter.featureType && featureProperties.feature_type.slug === filter.featureType; - // const statusCheck = filter.featureStatus && featureProperties.status.value === filter.featureStatus; - // const titleCheck = filter.featureTitle && featureProperties.title.includes(filter.featureTitle); - // filters = [typeCheck, statusCheck, titleCheck]; - // } - // console.log(featureType, filters); - + try { + retour = new GeoJSON().readFeature(feature, { dataProjection: 'EPSG:4326', featureProjection: 'EPSG:3857' }, featureTypes); + drawSource.addFeature(retour); + } catch (err) { + console.error(err); + } }); const styleFunction = (feature) => this.getStyle(feature, featureTypes, filter); const olLayer = new VectorLayer({ @@ -493,7 +503,7 @@ const mapService = { feature_type = feature.getProperties().feature_type || featureTypes.find((x) => x.slug.split('-')[0] === '' + feature.getProperties().feature_type_id); } - } else { //? TPS: I couldn't find when this code is used, is this still in use ? + } else { //? TPD: I couldn't find when this code is used, is this still in use ? status = feature.status; if (status) status = status.name; date_maj = feature.updated_on; @@ -538,7 +548,7 @@ const mapService = { </div> ${author} `; - const featureId = feature.getProperties ? feature.getProperties().feature_id : feature.id; + const featureId = feature.getProperties ? feature.getProperties().feature_id || feature.getId() : feature.id; //* feature.id was used with leaflet, with ol feature.getId replace it, but keeping it as fallback can prevent regression return { html, feature_type, featureId }; }, diff --git a/src/views/FeatureType/FeatureTypeSymbology.vue b/src/views/FeatureType/FeatureTypeSymbology.vue index 39ac0a9915a6a98059e2b3c4518b20d1ea53b316..148221bec18bd9dfd5ceea3925fcd5d48da1fc23 100644 --- a/src/views/FeatureType/FeatureTypeSymbology.vue +++ b/src/views/FeatureType/FeatureTypeSymbology.vue @@ -34,6 +34,10 @@ <p>{{ success }}</p> </div> </div> + <h1 v-if="project && feature_type"> + Éditer la symbologie du type de signalement "{{ feature_type.title }}" pour le + projet "{{ project.title }}" + </h1> <div class="fourteen wide column"> <form id="form-symbology-edit" @@ -42,48 +46,40 @@ enctype="multipart/form-data" class="ui form" > - <h1 v-if="project && feature_type"> - Éditer la symbologie du type de signalement "{{ feature_type.title }}" pour le - projet "{{ project.title }}" - </h1> <SymbologySelector v-if="feature_type" + class="default" :init-color="feature_type.color" :init-icon="feature_type.icon" + :init-opacity="feature_type.opacity" :geom-type="feature_type.geom_type" @set="setDefaultStyle" /> + <div class="ui divider" /> <div - v-if=" - feature_type && - feature_type.customfield_set.length > 0 && - feature_type.customfield_set.some(el => el.field_type === 'list') - " + v-if="customizableFields.length > 0" + class="field" > - <div class="ui divider" /> <label id="customfield-select-label" for="customfield-select" > - Personnaliser la symbologie d'une liste de valeurs: + Champ de personnalisation de la symbologie: </label> - <select - id="customfield-select" - v-model="selectedCustomfield" - class="ui dropdown" - > - <option - v-for="customfieldList of feature_type.customfield_set.filter(el => el.field_type === 'list')" - :key="customfieldList.name" - :value="customfieldList.name" - > - {{ customfieldList.label }} - </option> - </select> + <span id="custom_types-dropdown"> + <Dropdown + :options="customizableFields" + :selected="selectedCustomfield" + :selection.sync="selectedCustomfield" + /> + </span> </div> - <div v-if="selectedCustomfield"> + <div + v-if="selectedCustomfield" + class="field" + > <div - v-for="option of feature_type.customfield_set.find(el => el.name === selectedCustomfield).options" + v-for="option of selectedFieldOptions" :key="option" > <SymbologySelector @@ -98,13 +94,15 @@ feature_type.colors_style.value.icons[option] : null " + :init-opacity="getOpacity(feature_type, option)" :geom-type="feature_type.customfield_set.geomType" @set="setColorsStyle" /> </div> + <div class="ui divider" /> </div> - <div class="ui divider" /> <button + id="save-symbology" class="ui teal icon button margin-25" type="button" :disabled="!canSaveSymbology" @@ -127,12 +125,15 @@ import { isEqual } from 'lodash'; import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'; import SymbologySelector from '@/components/FeatureType/SymbologySelector.vue'; +import Dropdown from '@/components/Dropdown.vue'; + export default { name: 'FeatureTypeSymbology', components: { - SymbologySelector + SymbologySelector, + Dropdown, }, data() { @@ -140,7 +141,6 @@ export default { loading: false, error: null, success: null, - selectedCustomfield: null, form: { color: '#000000', icon: 'circle', @@ -148,10 +148,12 @@ export default { fields: [], colors: {}, icons: {}, + opacities: {}, custom_field_name: '', value: { colors: {}, - icons: {} + icons: {}, + opacities: {}, } }, }, @@ -170,12 +172,40 @@ export default { ...mapGetters('feature-type', [ 'feature_type' ]), + customizableFields() { + if (this.feature_type) { + let options = this.feature_type.customfield_set.filter(el => el.field_type === 'list' || el.field_type === 'char'); + options = options.map((el) => { + return { name: [el.name, `(${el.field_type === 'list' ? 'Liste de valeurs' : 'Chaîne de caractères'})`], value: el }; + }); + return options; + } + return []; + }, + selectedFieldOptions() { + if (this.selectedCustomfield) { + const customFieldSet = this.feature_type.customfield_set.find(el => el.name === this.selectedCustomfield); + if (customFieldSet.options.length > 0) { + return customFieldSet.options; + } else if (customFieldSet.field_type === 'char') { + return ['Vide', 'Non vide']; + } + } + return []; + }, + selectedCustomfield: { + get() { + return this.form.colors_style.custom_field_name; + }, + set(newValue) { + if (newValue && newValue.value) { + this.form.colors_style.custom_field_name = newValue.value.name; + } + } + } }, watch: { - selectedCustomfield(newValue) { - this.form.colors_style.custom_field_name = newValue; - }, feature_type(newValue) { if (newValue) { // Init form @@ -238,12 +268,15 @@ export default { ]), initForm() { - this.form.color = JSON.parse(JSON.stringify(this.feature_type.color)); - this.form.icon = JSON.parse(JSON.stringify(this.feature_type.icon)); + this.form.color = JSON.parse(JSON.stringify(this.feature_type.color)); //? wouldn't be better to use lodash: https://medium.com/@pmzubar/why-json-parse-json-stringify-is-a-bad-practice-to-clone-an-object-in-javascript-b28ac5e36521 + this.form.icon = JSON.parse(JSON.stringify(this.feature_type.icon)); //? since the library is already imported ? this.form.colors_style = { ...this.form.colors_style, ...JSON.parse(JSON.stringify(this.feature_type.colors_style)) }; + if (!this.form.colors_style.value['opacities']) { //* if the opacity values were never setted (would be better to find out why) + this.form.colors_style.value['opacities'] = {}; + } if (this.feature_type.colors_style && Object.keys(this.feature_type.colors_style.colors).length > 0) { this.selectedCustomfield = this.feature_type.customfield_set.find( @@ -253,17 +286,21 @@ export default { }, setDefaultStyle(e) { - const value = e.value; - this.form.color = value.color.value; - this.form.icon = value.icon; + const { color, icon, opacity } = e.value; + this.form.color = color.value; + this.form.icon = icon; + this.form.opacity = opacity; }, setColorsStyle(e) { const { name, value } = e; - this.form.colors_style.colors[name] = value.color; - this.form.colors_style.icons[name] = value.icon; - this.form.colors_style.value.colors[name] = value.color; - this.form.colors_style.value.icons[name] = value.icon; + const { color, icon, opacity } = value; + this.form.colors_style.colors[name] = color; + this.form.colors_style.icons[name] = icon; + this.form.colors_style.opacities[name] = opacity; + this.form.colors_style.value.colors[name] = color; + this.form.colors_style.value.icons[name] = icon; + this.form.colors_style.value.opacities[name] = opacity; //? why do we need to duplicate values ? for MVT ? }, sendFeatureSymbology() { @@ -292,6 +329,13 @@ export default { console.error(err); this.loading = false; }); + }, + + getOpacity(feature_type, optionName) { + if (feature_type.colors_style.value && feature_type.colors_style.value.opacities) { + return feature_type.colors_style.value.opacities[optionName]; + } + return null; } } }; @@ -305,14 +349,14 @@ h1 { form { text-align: left; - #customfield-select-label { - cursor: pointer; + //cursor: pointer; font-weight: 600; font-size: 1.1em; } - #customfield-select { - width: 50% !important; + + #custom_types-dropdown > .dropdown { + width: 50%; } } diff --git a/src/views/Project/FeaturesListAndMap.vue b/src/views/Project/FeaturesListAndMap.vue index 0deafc36a1d5e6df32b7f50788fc95fb7609cb60..5bb4915fd29e6a1c662930de4613b6dcfca42f39 100644 --- a/src/views/Project/FeaturesListAndMap.vue +++ b/src/views/Project/FeaturesListAndMap.vue @@ -351,7 +351,7 @@ export default { // --------- End sidebar events ---------- setTimeout(() => { const project_id = this.projectSlug.split('-')[0]; - const mvtUrl = `${this.API_BASE_URL}features.mvt/`; + const mvtUrl = `${this.API_BASE_URL}features.mvt`; mapService.addVectorTileLayer( mvtUrl, project_id,