diff --git a/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue b/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue index ecefd999458cfbcd174bd6ae9402a7bc5d5b508b..2fc77496d4686f9c59aeb8022792c8c369a43abb 100644 --- a/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue +++ b/src/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters.vue @@ -142,12 +142,16 @@ :class="['field column', { 'disabled': !isOnline }]" > <label>Type</label> - <Dropdown - :options="featureTypeTitles" - :selected="form.type.selected" - :selection.sync="form.type.selected" - :search="true" - :clearable="true" + <Multiselect + v-model="form.type" + :options="featureTypeOptions" + :multiple="true" + :searchable="false" + :close-on-select="false" + :show-labels="false" + placeholder="Sélectionner un type" + track-by="value" + label="name" /> </div> <div @@ -155,13 +159,16 @@ :class="['field column', { 'disabled': !isOnline }]" > <label>Statut</label> - <!-- //* giving an object mapped on key name --> - <Dropdown - :options="filteredStatusChoices" - :selected="form.status.selected.name" - :selection.sync="form.status.selected" - :search="true" - :clearable="true" + <Multiselect + v-model="form.status" + :options="statusOptions" + :multiple="true" + :searchable="false" + :close-on-select="false" + :show-labels="false" + placeholder="Sélectionner un statut" + track-by="value" + label="name" /> </div> <div @@ -200,11 +207,10 @@ <script> import { mapState, mapGetters } from 'vuex'; +import Multiselect from 'vue-multiselect'; import { statusChoices, allowedStatus2change } from '@/utils'; -import Dropdown from '@/components/Dropdown.vue'; - const initialPagination = { currentPage: 1, pagesize: 15, @@ -217,7 +223,7 @@ export default { name: 'FeaturesListAndMapFilters', components: { - Dropdown + Multiselect }, props: { @@ -247,12 +253,8 @@ export default { data() { return { form: { - type: { - selected: '', - }, - status: { - selected: '', - }, + type: [], + status: [], title: null, }, lat: null, @@ -295,7 +297,10 @@ export default { featureTypeTitles() { return this.feature_types.map((el) => el.title); }, - filteredStatusChoices() { + featureTypeOptions() { + return this.feature_types.map((el) => ({ name: el.title, value: el.slug })); + }, + statusOptions() { //* if project is not moderate, remove pending status return statusChoices.filter((el) => this.project && this.project.moderation ? true : el.value !== 'pending' @@ -309,11 +314,11 @@ export default { }, watch: { - 'form.type.selected'(newValue) { + 'form.type'(newValue) { this.$emit('set-filter', { type: newValue }); this.resetPaginationNfetchFeatures(); }, - 'form.status.selected': { + 'form.status': { deep: true, handler(newValue) { this.$emit('set-filter', { status: newValue }); @@ -407,6 +412,9 @@ export default { #form-filters { margin: 0; + label + div { + min-height: 42px; + } } .ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu { @@ -456,3 +464,9 @@ export default { } } </style> + +<style> +#form-filters .multiselect__tags { + white-space: normal !important; +} +</style> \ No newline at end of file diff --git a/src/services/feature-api.js b/src/services/feature-api.js index 04e9400fb61c06eed4e2c6bb6e704c8d8b9a5b40..aa2ba11505e7b567aa9feae4b84ee3deab83d630 100644 --- a/src/services/feature-api.js +++ b/src/services/feature-api.js @@ -3,10 +3,10 @@ import store from '../store'; const featureAPI = { - async getFeaturesBbox(project_slug, queryParams) { + async getFeaturesBbox(project_slug, queryString) { const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE; const response = await axios.get( - `${baseUrl}projects/${project_slug}/feature-bbox/${queryParams ? '?' + queryParams : ''}` + `${baseUrl}projects/${project_slug}/feature-bbox/${queryString ? '?' + queryString : ''}` ); if ( response.status === 200 && diff --git a/src/services/map-service.js b/src/services/map-service.js index a864d2cbdad7e2fb1e7f0b73e79324f03649c40c..fd61e476439f587bed287b4b2c7c44828e238444 100644 --- a/src/services/map-service.js +++ b/src/services/map-service.js @@ -596,81 +596,76 @@ const mapService = { window.layerMVT = this.mvtLayer; }, + /** + * Determines the style for a given feature based on its type and applicable filters. + * + * @param {Object} feature - The feature to style. + * @param {Array} featureTypes - An array of available feature types. + * @param {Object} formFilters - Filters applied through the form. + * @returns {ol.style.Style} - The OpenLayers style for the feature. + */ getStyle: function (feature, featureTypes, formFilters) { const properties = feature.getProperties(); let featureType; - // GeoJSON + + // Determine the feature type. Differentiate between GeoJSON and MVT sources. if (properties && properties.feature_type) { + // Handle GeoJSON feature type featureType = featureTypes .find((ft) => ft.slug === (properties.feature_type.slug || properties.feature_type)); - } else { //MVT + } else { + // Handle MVT feature type featureType = featureTypes.find((x) => x.slug.split('-')[0] === '' + properties.feature_type_id); } if (featureType) { + // Retrieve the style (color, opacity) for the feature. const { color, opacity } = this.retrieveFeatureStyle(featureType, properties); - const colorValue = - color && color.value && color.value.length ? - color.value : typeof color === 'string' && color.length ? - color : '#000000'; + let colorValue = '#000000'; // Default color + + // Determine the color value based on the feature type. + if (color && color.value && color.value.length) { + colorValue = color.value; + } else if (typeof color === 'string' && color.length) { + colorValue = color; + } + // Convert the color value to RGBA and apply the opacity. 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, - }, - ), - fill: new Fill( - { - color: rgbaColor, - }, - ), - }, - ); - const hiddenStyle = new Style(); // hide the feature to apply filters - // Filtre sur le feature type + rgbaColor[3] = opacity || 0.5; // Default opacity + + // Define the default style for the feature. + 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 }), + fill: new Fill({ color: rgbaColor }), + }); + + // Define a hidden style to apply when filters are active. + const hiddenStyle = new Style(); + + // Apply filters based on feature type, status, and title. if (formFilters) { - if (formFilters.type && formFilters.type.selected) { - if (featureType.title !== formFilters.type.selected) { - return hiddenStyle; - } + if (formFilters.type && formFilters.type.length > 0 && !formFilters.type.includes(featureType.slug)) { + return hiddenStyle; } - // Filtre sur le statut - if (formFilters.status && formFilters.status.selected.value) { - if (properties.status !== formFilters.status.selected.value) { - return hiddenStyle; - } + if (formFilters.status && formFilters.status.length > 0 && !formFilters.status.includes(properties.status)) { + return hiddenStyle; } - // Filtre sur le titre - if (formFilters.title) { - if (!properties.title.toLowerCase().includes(formFilters.title.toLowerCase())) { - return hiddenStyle; - } + if (formFilters.title && !properties.title.toLowerCase().includes(formFilters.title.toLowerCase())) { + return hiddenStyle; } } + + // Return the default style if no filters are applied or if the feature passes the filters. return defaultStyle; } else { console.error('No corresponding featureType found.'); - return; + return new Style(); } }, diff --git a/src/views/Feature/FeatureDetail.vue b/src/views/Feature/FeatureDetail.vue index 9068857a9ba1f8542588e13f2ecb36970d7da9da..e026674d96e069f8287afa7a1fe9fbb9699c20c5 100644 --- a/src/views/Feature/FeatureDetail.vue +++ b/src/views/Feature/FeatureDetail.vue @@ -508,7 +508,6 @@ export default { project_slug: this.slug, features: [this.currentFeature], featureTypes: this.feature_types, - addToMap: true, }); mapService.fitExtent(buffer(featureGroup.getExtent(),200)); diff --git a/src/views/Project/FeaturesListAndMap.vue b/src/views/Project/FeaturesListAndMap.vue index e25a394279fc71c27cf486a1e6eaccbc0c0a350e..8feba95fc9fc2111e5418548fcd6e08b9bef5d72 100644 --- a/src/views/Project/FeaturesListAndMap.vue +++ b/src/views/Project/FeaturesListAndMap.vue @@ -136,12 +136,8 @@ export default { featuresCount: 0, featuresWithGeomCount:0, form: { - type: { - selected: '', - }, - status: { - selected: '', - }, + type: [], + status: [], title: null, }, isDeleteModalOpen: false, @@ -241,14 +237,19 @@ export default { resetPagination() { this.pagination = { ...initialPagination }; }, + + /** + * Updates the filters based on the provided key-value pair. + * + * @param {Object} e - The key-value pair representing the filter to update. + */ setFilters(e) { const filter = Object.keys(e)[0]; - const value = Object.values(e)[0]; - if (filter === 'title') { - this.form[filter] = value; - } else { - this.form[filter].selected = value; + let value = Object.values(e)[0]; + if (value && Array.isArray(value)) { + value = value.map(el => el.value); } + this.form[filter] = value; }, toggleDeleteModal() { @@ -295,18 +296,19 @@ export default { const promises = this.checkedFeatures.map( (feature_id) => this.DELETE_FEATURE({ feature_id, noFeatureType: true }) ); - Promise.all(promises).then((response) => { - const deletedFeaturesCount = response.reduce((acc, curr) => curr.status === 204 ? acc += 1 : acc, 0); - const newFeaturesCount = initialFeaturesCount - deletedFeaturesCount; - const newPagesArray = this.createPagesArray(newFeaturesCount, this.pagination.pagesize); - const newLastPageNum = newPagesArray[newPagesArray.length - 1]; - this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []); - if (initialCurrentPage > newLastPageNum) { //* if page doesn't exist anymore - this.toPage(newLastPageNum); //* go to new last page - } else { - this.fetchPagedFeatures(); - } - }) + Promise.all(promises) + .then((response) => { + const deletedFeaturesCount = response.reduce((acc, curr) => curr.status === 204 ? acc += 1 : acc, 0); + const newFeaturesCount = initialFeaturesCount - deletedFeaturesCount; + const newPagesArray = this.createPagesArray(newFeaturesCount, this.pagination.pagesize); + const newLastPageNum = newPagesArray[newPagesArray.length - 1]; + this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []); + if (initialCurrentPage > newLastPageNum) { //* if page doesn't exist anymore + this.toPage(newLastPageNum); //* go to new last page + } else { + this.fetchPagedFeatures(); + } + }) .catch((err) => console.error(err)); this.toggleDeleteModal(); }, @@ -361,9 +363,9 @@ export default { this.fetchPagedFeatures(); }, - fetchBboxNfit(queryParams) { + fetchBboxNfit(queryString) { featureAPI - .getFeaturesBbox(this.projectSlug, queryParams) + .getFeaturesBbox(this.projectSlug, queryString) .then((bbox) => { if (bbox) { mapService.fitBounds(bbox); @@ -387,66 +389,73 @@ export default { return result; }, - buildQueryString() { - let queryString = ''; - const typeFilter = this.getFeatureTypeSlug(this.form.type.selected); - const statusFilter = this.form.status.selected.value; - + /** + * Updates the query parameters based on the current state of the pagination and form filters. + * This function sets various parameters like offset, feature_type_slug, status__value, title, + * and ordering to be used in an API request and to filter hidden features on mvt tiles. + */ + updateQueryParams() { + // empty queryparams to remove params when removed from the form + this.queryparams = {}; + // Update the 'offset' parameter based on the current pagination start value. this.queryparams['offset'] = this.pagination.start; - if (typeFilter) { - this.queryparams['feature_type_slug'] = typeFilter; - queryString += `&feature_type_slug=${typeFilter}`; + // Set 'feature_type_slug' if a type is selected in the form. + if (this.form.type.length > 0) { + this.queryparams['feature_type_slug'] = this.form.type; } - if (statusFilter) { - this.queryparams['status__value'] = statusFilter; - queryString += `&status__value=${statusFilter}`; + // Set 'status__value' if a status is selected in the form. + if (this.form.status.length > 0) { + this.queryparams['status__value'] = this.form.status; } + // Set 'title' if a title is entered in the form. if (this.form.title) { this.queryparams['title'] = this.form.title; - queryString += `&title=${this.form.title}`; - } - if (this.sort.column) { - let ordering = `${this.sort.ascending ? '-' : ''}${this.getAvalaibleField(this.sort.column)}`; - this.queryparams['ordering'] = ordering; - queryString += `&ordering=${ordering}`; } - return queryString; + // Update the 'ordering' parameter based on the current sorting state. + // Prepends a '-' for descending order if sort.ascending is false. + this.queryparams['ordering'] = `${this.sort.ascending ? '-' : ''}${this.getAvalaibleField(this.sort.column)}`; }, - fetchPagedFeatures(newUrl) { + /** + * Fetches paginated feature data from the API. + * This function is called to retrieve a specific page of features based on the current pagination settings and any applied filters. + * If the application is offline, it displays a message and does not proceed with the API call. + */ + fetchPagedFeatures() { + // Check if the application is online; if not, display a message and return. if (!this.isOnline) { this.DISPLAY_MESSAGE({ comment: 'Les signalements du projet non mis en cache ne sont pas accessibles en mode déconnecté', }); return; } - let url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?limit=${this.pagination.pagesize}&offset=${this.pagination.start}`; - //* if receiving next & previous url (// todo : might be not used anymore, to check) - if (newUrl && typeof newUrl === 'string') { - //newUrl = newUrl.replace("8000", "8010"); //* for dev uncomment when using proxy link - url = newUrl; - } - const queryString = this.buildQueryString(); - url += queryString; - this.$store.commit( - 'DISPLAY_LOADER', - 'Récupération des signalements en cours...' - ); + // Display a loading message. + this.$store.commit('DISPLAY_LOADER', 'Récupération des signalements en cours...'); + + // Update additional query parameters based on the current filter states. + this.updateQueryParams(); + const queryString = new URLSearchParams(this.queryparams).toString(); + // Construct the base URL with query parameters. + const url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?limit=${this.pagination.pagesize}&${queryString}`; + // Make an API call to get the paginated features. featureAPI.getPaginatedFeatures(url) .then((data) => { if (data) { + // Update the component state with the data received from the API. this.featuresCount = data.count; this.featuresWithGeomCount = data.geom_count; this.previous = data.previous; this.next = data.next; this.paginatedFeatures = data.results; } - //* bbox needs to be updated with the same filters + // If there are features, update the bounding box. if (this.paginatedFeatures.length) { this.fetchBboxNfit(queryString); } - this.onFilterChange(); //* use paginated event to watch change in filters and modify features on map + // Trigger actions on filter change. + this.onFilterChange(); + // Hide the loading message. this.$store.commit('DISCARD_LOADER'); }); }, @@ -476,10 +485,7 @@ export default { handleSortChange(sort) { this.sort = sort; - this.fetchPagedFeatures({ - filterType: undefined, - filterValue: undefined, - }); + this.fetchPagedFeatures(); }, toPage(pageNumber) { diff --git a/src/views/Project/ProjectDetail.vue b/src/views/Project/ProjectDetail.vue index 54b5a96c7c5902cecb74199e39d0ff6868a3fab7..af54519200e6519cd5db9bea736bb430a4651868 100644 --- a/src/views/Project/ProjectDetail.vue +++ b/src/views/Project/ProjectDetail.vue @@ -389,42 +389,51 @@ export default { this.featureTypeToDelete = featureType; this.OPEN_PROJECT_MODAL('deleteFeatureType'); }, - + /** + * Initializes the map if the project is accessible and the user has view permissions. + * This method sets up the map, loads vector tile layers, and handles offline features. + */ async initMap() { + // Check if the project is accessible and the user has view permissions if (this.project && this.permissions.can_view_project) { + // Initialize the map using the provided element reference await this.INITIATE_MAP({ el: this.$refs.map }); + // Check for offline features this.checkForOfflineFeature(); + // Define the URL for vector tile layers const mvtUrl = `${this.API_BASE_URL}features.mvt`; - mapService.addVectorTileLayer({ - url: mvtUrl, + // Define parameters for loading layers + const params = { project_slug: this.slug, featureTypes: this.feature_types, queryParams: { ordering: this.project.feature_browsing_default_sort, filter: this.project.feature_browsing_default_filter, - } + }, + }; + // Add vector tile layers to the map + mapService.addVectorTileLayer({ + url: mvtUrl, + ...params }); - + // Modify offline feature properties (setting color to 'red') this.arraysOffline.forEach((x) => (x.geojson.properties.color = 'red')); + // Extract offline features from arraysOffline const featuresOffline = this.arraysOffline.map((x) => x.geojson); + // Add offline features to the map if available if (featuresOffline && featuresOffline.length > 0) { mapService.addFeatures({ addToMap: true, - project_slug: this.slug, features: featuresOffline, - featureTypes: this.feature_types, - queryParams: { - ordering: this.project.feature_browsing_default_sort, - filter: this.project.feature_browsing_default_filter, - }, + ...params }); } - + // Get the bounding box of features and fit the map to it featureAPI.getFeaturesBbox(this.slug).then((bbox) => { if (bbox) { mapService.fitBounds(bbox); } - this.mapLoading = false; + this.mapLoading = false; // Mark map loading as complete }); } },