diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index 3d6e8af4cf..45e262213f 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -204,6 +204,32 @@ def post_load(self, data, **kwargs): return data +class ListLastObsColumnConfig(Schema): + prop = fields.String(required=True) + name = fields.String(required=True) + + +class ListLastObsFiltersConfig(Schema): + TAXONOMY_GROUP2_INPN = fields.Boolean(load_default=True) + TAXONOMY_GROUP3_INPN = fields.Boolean(load_default=True) + + +class ListLastObsConfig(Schema): + + COLUMNS = fields.List( + fields.Nested(ListLastObsColumnConfig), + load_default=[ + {"prop": "nom_vern_or_lb_nom", "name": "Taxon"}, + {"prop": "date_min", "name": "Date"}, + {"prop": "observers", "name": "Observateur"}, + ], + ) + FILTERS = fields.Nested( + ListLastObsFiltersConfig, + load_default=ListLastObsFiltersConfig().load({}), + ) + + class GnPySchemaConf(Schema): SQLALCHEMY_DATABASE_URI = fields.String( required=True, @@ -281,6 +307,11 @@ class GnFrontEndConf(Schema): DISPLAY_STAT_BLOC = fields.Boolean(load_default=True) STAT_BLOC_TTL = fields.Integer(load_default=3600) DISPLAY_MAP_LAST_OBS = fields.Boolean(load_default=True) + DISPLAY_LIST_LAST_OBS = fields.Boolean(load_default=False) + LIST_LAST_OBS_CONFIG = fields.Nested( + ListLastObsConfig, + load_default=ListLastObsConfig().load({}), + ) MULTILINGUAL = fields.Boolean(load_default=False) ENABLE_PROFILES = fields.Boolean(load_default=True) diff --git a/config/default_config.toml.example b/config/default_config.toml.example index a198e1d4e2..f5425832be 100644 --- a/config/default_config.toml.example +++ b/config/default_config.toml.example @@ -131,6 +131,9 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *" # Affiche la carte des 100 dernieres observations sur la page d'accueil DISPLAY_MAP_LAST_OBS = true + # Affiche la liste des 100 dernieres observations sur la page d'accueil + # Si DISPLAY_MAP_LAST_OBS = true, la carte reste prioritaire sur la liste + DISPLAY_LIST_LAST_OBS = false # Affiche le selecteur de langue dans l'interface MULTILINGUAL = false @@ -146,6 +149,19 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *" # Activer l'affichage des informations liées aux profils de taxons (dans les modules Validation, Synthèse et Occtax) ENABLE_PROFILES = true +[FRONTEND.LIST_LAST_OBS_CONFIG] + # Colonnes affichées dans la liste des dernières observations + # Les colonnes doivent exister dans SYNTHESE.LIST_COLUMNS_FRONTEND + # ou SYNTHESE.ADDITIONAL_COLUMNS_FRONTEND, sinon elles ne pourront pas être prises en compte. + COLUMNS = [ + { prop = "nom_vern_or_lb_nom", name = "Taxon" }, + { prop = "date_min", name = "Date" }, + { prop = "observers", name = "Observateur" }, + ] + +[FRONTEND.LIST_LAST_OBS_CONFIG.FILTERS] + TAXONOMY_GROUP2_INPN = true + TAXONOMY_GROUP3_INPN = true # Afficher l'organisme de l'utilisateur dans la barre de navigation (true / false) DISPLAY_USER_ORGANISM = true diff --git a/frontend/cypress/fixtures/config.json b/frontend/cypress/fixtures/config.json index 1a639828d3..080aff0f45 100644 --- a/frontend/cypress/fixtures/config.json +++ b/frontend/cypress/fixtures/config.json @@ -197,7 +197,8 @@ "DISPLAY_EMAIL_INFO_OBS": true, "DISPLAY_STAT_BLOC": true, "PROD_MOD": true, - "DISPLAY_MAP_LAST_OBS": true + "DISPLAY_MAP_LAST_OBS": true, + "DISPLAY_LIST_LAST_OBS": false }, "DEFAULT_LANGUAGE": "fr", "ACCOUNT_MANAGEMENT": { diff --git a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.html b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.html index 31b396e15c..206e3e6230 100644 --- a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.html +++ b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.html @@ -1,10 +1,28 @@ -
- -

{{ message }}

-
- - + +
+ +

{{ message }}

+
+ + + +
+ + +
+
+ +
+ +
+ +

{{ message }}

+
+
diff --git a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.scss b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.scss index 1241f28ed1..6d3838c90a 100644 --- a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.scss +++ b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.scss @@ -1,10 +1,29 @@ +.LoadableLayout { + position: relative; + display: flex; + width: 100%; + height: 100%; + min-height: 0; + + &__content { + display: flex; + flex: 1 1 auto; + min-height: 0; + } +} + .SpinnerContainer { - // max-height: 100%; height: 100%; width: 100%; display: flex; align-items: center; justify-content: center; - z-index: 10; /* Ensure the spinner is above other content */ flex-direction: column; + + &--overlay { + position: absolute; + inset: 0; + z-index: 10; + background: rgba(255, 255, 255, 0.65); + } } diff --git a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.ts b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.ts index 736c1d90aa..caf0ee100e 100644 --- a/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.ts +++ b/frontend/src/app/GN2CommonModule/layouts/loadable-layout/loadable-layout.component.ts @@ -1,13 +1,22 @@ import { Component, Input } from '@angular/core'; +export enum LoadableLayoutMode { + Overlay = 'overlay', + Replace = 'replace', +} + @Component({ selector: 'gn-loadable-layout', templateUrl: 'loadable-layout.component.html', styleUrls: ['loadable-layout.component.scss'], }) export class LoadableLayoutComponent { + readonly LoadableLayoutMode = LoadableLayoutMode; + @Input() isLoading: boolean = false; @Input() message: string = null; + @Input() + mode: LoadableLayoutMode = LoadableLayoutMode.Overlay; } diff --git a/frontend/src/app/GN2CommonModule/map/geojson/geojson.component.ts b/frontend/src/app/GN2CommonModule/map/geojson/geojson.component.ts index fbf78854fe..7baed376d6 100644 --- a/frontend/src/app/GN2CommonModule/map/geojson/geojson.component.ts +++ b/frontend/src/app/GN2CommonModule/map/geojson/geojson.component.ts @@ -89,12 +89,22 @@ export class GeojsonComponent implements OnInit, OnChanges { } } if (changes.style && changes.style.currentValue !== undefined) { - if (this.currentGeojson) { - for (const key of Object.keys(this.currentGeojson['_layers'])) { - const layer = this.currentGeojson['_layers'][key]; - layer.setStyle(changes.style.currentValue); - } + this.applyStyle(changes.style.currentValue); + } + } + + private applyStyle(style) { + if (!this.currentGeojson || !this.currentGeojson['_layers']) { + return; + } + + for (const key of Object.keys(this.currentGeojson['_layers'])) { + const layer = this.currentGeojson['_layers'][key]; + if (!layer.setStyle) { + continue; } + + layer.setStyle(style); } } } diff --git a/frontend/src/app/GN2CommonModule/map/map.service.ts b/frontend/src/app/GN2CommonModule/map/map.service.ts index 563c1f4d8b..e681eff90a 100644 --- a/frontend/src/app/GN2CommonModule/map/map.service.ts +++ b/frontend/src/app/GN2CommonModule/map/map.service.ts @@ -175,7 +175,6 @@ export class MapService { const geojsonLayer = L.geoJSON(geojson?.features || geojson, { style: (feature) => { switch (feature.geometry.type) { - // No color nor opacity for linestrings case 'LineString': return style || this.lineStyle(); default: @@ -183,7 +182,7 @@ export class MapService { } }, pointToLayer: (feature, latlng) => { - return L.circleMarker(latlng); + return L.circleMarker(latlng, style || this.defaultStyle()); }, onEachFeature: onEachFeature, }); diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 933ea3c369..5a5751c2bf 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -17,6 +17,7 @@ import { GN2CommonModule } from '@geonature_common/GN2Common.module'; import { AppComponent } from './app.component'; import { routing } from './routing/app-routing.module'; // RoutingModule import { HomeContentComponent } from './components/home-content/home-content.component'; +import { HomeContentListObsComponent } from './components/home-content/home-content-list-obs/home-content-list-obs.component'; import { HomeDiscussionsComponent } from './components/home-content/home-discussions/home-discussions.component'; import { HomeValidationsComponent } from './components/home-content/home-validations/home-validations.component'; @@ -107,6 +108,7 @@ export function initApp(injector) { extend: true, }), LoginModule, + HomeContentListObsComponent, HomeDiscussionsComponent, HomeValidationsComponent, ], diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.html b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.html new file mode 100644 index 0000000000..dc871b0d0d --- /dev/null +++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.html @@ -0,0 +1,23 @@ +
+ + + +
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.scss b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.scss new file mode 100644 index 0000000000..9a229df333 --- /dev/null +++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.scss @@ -0,0 +1,48 @@ +.home-content-list-obs-filters { + display: flex; + flex-flow: row nowrap; + gap: 0.5rem; + align-items: center; + + ng-select { + width: 200px !important; + font-size: 0.875rem; + } + + ::ng-deep ng-select .ng-select-container { + min-height: 34px; + padding: 0 0.35rem; + } + + ::ng-deep ng-select .ng-value-container { + padding-left: 0.1rem; + } + + ::ng-deep ng-select .ng-input { + top: 6px; + padding-left: 0.1rem; + } + + ::ng-deep ng-select .ng-placeholder, + ::ng-deep ng-select .ng-value-label, + ::ng-deep ng-select .ng-input input { + font-size: 0.875rem; + line-height: 1.2; + } + + ::ng-deep ng-select .ng-clear-wrapper, + ::ng-deep ng-select .ng-arrow-wrapper { + padding-left: 0.3rem; + padding-right: 0.1rem; + } + + ::ng-deep ng-dropdown-panel .ng-dropdown-panel-items .ng-option { + white-space: nowrap; + } + + ::ng-deep ng-dropdown-panel .ng-dropdown-panel-items .ng-option span { + white-space: nowrap; + overflow: visible; + text-overflow: clip; + } +} diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.ts b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.ts new file mode 100644 index 0000000000..3ba98f7f97 --- /dev/null +++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-filters/home-content-list-obs-filters.component.ts @@ -0,0 +1,129 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { UntypedFormControl, ReactiveFormsModule } from '@angular/forms'; +import { DataFormService } from '@geonature_common/form/data-form.service'; +import { ConfigService } from '@geonature/services/config.service'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; + +interface HomeContentListObsFilters { + taxonomy_group2_inpn?: string[]; + taxonomy_group3_inpn?: string[]; + [key: string]: string[] | undefined; +} + +interface HomeContentListObsFilterOption { + value: string; +} + +@Component({ + standalone: true, + selector: 'pnx-home-content-list-obs-filters', + templateUrl: './home-content-list-obs-filters.component.html', + styleUrls: ['./home-content-list-obs-filters.component.scss'], + imports: [CommonModule, ReactiveFormsModule, NgSelectModule], +}) +export class HomeContentListObsFiltersComponent implements OnInit, OnDestroy { + @Output() filtersChange = new EventEmitter(); + + readonly group2InpnControl = new UntypedFormControl(null); + readonly group3InpnControl = new UntypedFormControl(null); + group2InpnOptions: HomeContentListObsFilterOption[] = []; + group3InpnOptions: HomeContentListObsFilterOption[] = []; + + private destroy$ = new Subject(); + + constructor( + private readonly dataFormService: DataFormService, + private readonly config: ConfigService + ) {} + + get showGroup2Filter(): boolean { + return this.config.FRONTEND?.LIST_LAST_OBS_CONFIG?.FILTERS?.TAXONOMY_GROUP2_INPN ?? true; + } + + get showGroup3Filter(): boolean { + return this.config.FRONTEND?.LIST_LAST_OBS_CONFIG?.FILTERS?.TAXONOMY_GROUP3_INPN ?? true; + } + + ngOnInit() { + if (this.showGroup2Filter) { + this.dataFormService + .getRegneAndGroup2Inpn() + .pipe( + map((data) => { + const allGroups = new Set(); + + Object.values(data ?? {}).forEach((groups: string[]) => { + groups.forEach((group) => { + if (group) { + allGroups.add(group); + } + }); + }); + + return Array.from(allGroups) + .sort((a, b) => a.localeCompare(b)) + .map((value) => ({ value })); + }), + takeUntil(this.destroy$) + ) + .subscribe((options) => { + this.group2InpnOptions = options; + }); + } + + if (this.showGroup3Filter) { + this.dataFormService + .getGroup3Inpn() + .pipe( + map((data) => + (data ?? []) + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .sort((a, b) => a.localeCompare(b)) + .map((value) => ({ value })) + ), + takeUntil(this.destroy$) + ) + .subscribe((options) => { + this.group3InpnOptions = options; + }); + } + + this.group2InpnControl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => { + this._emitFilters(); + }); + + this.group3InpnControl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => { + this._emitFilters(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + private _emitFilters() { + const filters: HomeContentListObsFilters = {}; + + if ( + this.showGroup2Filter && + typeof this.group2InpnControl.value === 'string' && + this.group2InpnControl.value.length > 0 + ) { + filters.taxonomy_group2_inpn = [this.group2InpnControl.value]; + } + + if ( + this.showGroup3Filter && + typeof this.group3InpnControl.value === 'string' && + this.group3InpnControl.value.length > 0 + ) { + filters.taxonomy_group3_inpn = [this.group3InpnControl.value]; + } + + this.filtersChange.emit(filters); + } +} diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.html b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.html new file mode 100644 index 0000000000..30f2239b3e --- /dev/null +++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.html @@ -0,0 +1,57 @@ + + + + +
+ + + + + {{ getCellValue(row, column.prop) }} + + + {{ getCellValue(row, column.prop) }} + +
+ + {{ getCellValue(row, column.prop) }} + +
+
+
+
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.scss b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.scss new file mode 100644 index 0000000000..23140ce09b --- /dev/null +++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.scss @@ -0,0 +1,128 @@ +:host { + display: flex; + min-height: 0; +} + +:host ::ng-deep gn-loadable-layout { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +:host ::ng-deep .ngx-datatable.material { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + font-size: 0.9rem; +} + +:host ::ng-deep .ngx-datatable.material .datatable-header { + display: flex; + align-items: center; + flex: 0 0 auto; +} + +:host ::ng-deep .ngx-datatable.material .datatable-header .datatable-header-inner { + display: flex; + align-items: center; + min-height: 100%; +} + +:host ::ng-deep .ngx-datatable.material .datatable-header .datatable-header-cell { + padding: 0.35rem 0.5rem; + font-weight: 600; + display: flex; + align-items: center; +} + +:host + ::ng-deep + .ngx-datatable.material + .datatable-header + .datatable-header-cell + .datatable-header-cell-template-wrap { + display: flex; + align-items: center; + min-height: 100%; +} + +:host ::ng-deep .ngx-datatable.material .datatable-body .datatable-body-row { + cursor: pointer; +} + +:host + ::ng-deep + .ngx-datatable.material + .datatable-body + .datatable-body-row.is-selected + .datatable-row-group { + background-color: rgb(117, 227, 118) !important; +} + +:host ::ng-deep .ngx-datatable.material .datatable-body .datatable-body-row .datatable-body-cell { + padding: 0.25rem 0.5rem; + line-height: 1.2; + display: flex; + align-items: center; +} + +:host ::ng-deep .ngx-datatable.material .datatable-footer { + padding-top: 0; + flex: 0 0 auto; +} + +::ng-deep .ngx-datatable { + height: 100%; + box-shadow: none !important; + ::ng-deep .visible { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + .datatable-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + } + } +} + +.TaxonCell { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + + &__thumbnail { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + flex: 0 0 2rem; + + &Image { + width: 2rem; + height: 2rem; + object-fit: cover; + border-radius: 0.25rem; + } + } + + &__label { + min-width: 0; + } + + &__link { + min-width: 0; + color: var(--purple); + filter: brightness(70%); + text-decoration: none; + + &:hover .TaxonCell__label { + text-decoration: underline; + } + } +} diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.ts b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.ts new file mode 100644 index 0000000000..bc221cb917 --- /dev/null +++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs-list/home-content-list-obs-list.component.ts @@ -0,0 +1,157 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + SimpleChanges, +} from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { GN2CommonModule } from '@geonature_common/GN2Common.module'; +import { ConfigService } from '@geonature/services/config.service'; +import { DataFormService } from '@geonature_common/form/data-form.service'; +import { getTaxonSheetRoute } from '@geonature/syntheseModule/taxon-sheet/taxon-sheet.route.service'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +interface HomeContentListObservationItem { + id_synthese: number; + cd_nom?: number | null; + nom_vern_or_lb_nom: string; + date_min: string | null; + observers: string | null; + [key: string]: unknown; +} + +interface HomeContentListObservationColumn { + name: string; + prop: string; +} + +@Component({ + standalone: true, + selector: 'pnx-home-content-list-obs-list', + templateUrl: './home-content-list-obs-list.component.html', + styleUrls: ['./home-content-list-obs-list.component.scss'], + imports: [GN2CommonModule, CommonModule, RouterModule], +}) +export class HomeContentListObsListComponent implements OnChanges, OnDestroy { + readonly pageSize = 9; + + tableRows: HomeContentListObservationItem[] = []; + private readonly _destroy$ = new Subject(); + private readonly _taxonThumbnailUrls = new Map(); + private readonly _loadingTaxonThumbnailIds = new Set(); + @Input() observations: HomeContentListObservationItem[] = []; + @Input() isLoading = false; + @Input() currentPage = 0; + @Input() selectedObservationId: number | null = null; + @Output() pageChange = new EventEmitter(); + @Output() observationSelect = new EventEmitter(); + + constructor( + private readonly config: ConfigService, + private readonly _dataFormService: DataFormService + ) {} + + ngOnChanges(changes: SimpleChanges) { + if (changes.observations || changes.selectedObservationId) { + this.tableRows = [...this.observations]; + this._prefetchTaxonThumbnails(); + } + } + + ngOnDestroy() { + this._destroy$.next(); + this._destroy$.complete(); + } + + get columns(): HomeContentListObservationColumn[] { + const configuredColumns = this.config.FRONTEND?.LIST_LAST_OBS_CONFIG?.COLUMNS; + return Array.isArray(configuredColumns) ? configuredColumns : []; + } + + onPage(event: any) { + this.pageChange.emit(event.offset); + } + + onActivate(event: any) { + if (event.type !== 'click' || !event.row?.id_synthese) { + return; + } + + this.observationSelect.emit(event.row.id_synthese); + } + + getRowClass = (row: HomeContentListObservationItem) => { + return { + 'is-selected': row.id_synthese === this.selectedObservationId, + }; + }; + + hasTaxonThumbnail(row: HomeContentListObservationItem): boolean { + return typeof row.cd_nom === 'number' && !!this._taxonThumbnailUrls.get(row.cd_nom); + } + + getTaxonThumbnailUrl(row: HomeContentListObservationItem): string { + return typeof row.cd_nom === 'number' ? (this._taxonThumbnailUrls.get(row.cd_nom) ?? '') : ''; + } + + private _prefetchTaxonThumbnails() { + const taxonIds = new Set( + this.tableRows + .map((row) => row.cd_nom) + .filter((cdNom): cdNom is number => typeof cdNom === 'number') + ); + + taxonIds.forEach((cdNom) => { + if (this._taxonThumbnailUrls.has(cdNom) || this._loadingTaxonThumbnailIds.has(cdNom)) { + return; + } + + this._loadingTaxonThumbnailIds.add(cdNom); + this._dataFormService + .getTaxonInfo(cdNom, ['medias', 'cd_nom']) + .pipe(takeUntil(this._destroy$)) + .subscribe({ + next: (taxonAttrAndMedias) => { + const media = taxonAttrAndMedias['medias']?.find( + (m) => m.id_type == this.config.TAXHUB.ID_TYPE_MAIN_PHOTO + ); + const mediaUrl = media + ? `${this._dataFormService.getTaxhubAPI()}/tmedias/thumbnail/${media.id_media}?h=96&w=96` + : null; + this._taxonThumbnailUrls.set(cdNom, mediaUrl); + this._loadingTaxonThumbnailIds.delete(cdNom); + }, + error: () => { + this._taxonThumbnailUrls.set(cdNom, null); + this._loadingTaxonThumbnailIds.delete(cdNom); + }, + }); + }); + } + + getCellValue(row: HomeContentListObservationItem, prop: string): string | number { + if (prop === 'date_min') { + return this.renderDate(row.date_min); + } + + const value = row[prop]; + return typeof value === 'string' || typeof value === 'number' ? value : ''; + } + + renderDate(date: string | null): string { + return date ? new Date(date).toLocaleDateString() : ''; + } + + hasTaxonSheetLink(row: HomeContentListObservationItem): boolean { + return this.config.SYNTHESE?.ENABLE_TAXON_SHEETS && typeof row.cd_nom === 'number'; + } + + getTaxonSheetUrl(cdNom: number): [string] { + return getTaxonSheetRoute(cdNom); + } +} diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.html b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.html new file mode 100644 index 0000000000..1cf937ef18 --- /dev/null +++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.html @@ -0,0 +1,31 @@ +
+ +
+ +
+ + + +
+
+
diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.scss b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.scss new file mode 100644 index 0000000000..bbdd3567bc --- /dev/null +++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.scss @@ -0,0 +1,48 @@ +.home-content-list-obs { + display: flex; + flex-direction: column; + flex: 1 1 auto; + height: 100%; + min-height: 0; + padding: 0.5rem; + + &-content { + display: flex; + flex-flow: row wrap; + gap: 0.1rem; + justify-content: space-between; + flex: 1 1 auto; + min-height: 0; + &-list { + flex-grow: 1; + flex-shrink: 1; + min-width: 250px; + width: 250px; + min-height: 0; + } + + &-map { + margin-top: 0.3rem; + flex-grow: 1; + flex-shrink: 1; + height: 100%; + min-width: 250px; + width: 250px; + border: 1px solid #dfe3e8; + border-radius: 4px; + overflow: hidden; + } + } +} + +// @media (max-width: 991.98px) { +// .home-content-list-obs { +// &-content { +// flex-direction: column; +// } + +// &-map { +// min-height: 320px; +// } +// } +// } diff --git a/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.ts b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.ts new file mode 100644 index 0000000000..cea5fe598b --- /dev/null +++ b/frontend/src/app/components/home-content/home-content-list-obs/home-content-list-obs.component.ts @@ -0,0 +1,365 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { GN2CommonModule } from '@geonature_common/GN2Common.module'; +import { DataFormService } from '@geonature_common/form/data-form.service'; +import { SyntheseDataService } from '@geonature_common/form/synthese-form/synthese-data.service'; +import { GeojsonComponent } from '@geonature_common/map/geojson/geojson.component'; +import { MapService } from '@geonature_common/map/map.service'; +import { Feature, FeatureCollection, Geometry } from 'geojson'; +import { Layer } from 'leaflet'; +import { Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; +import { HomeContentListObsFiltersComponent } from './home-content-list-obs-filters/home-content-list-obs-filters.component'; +import { HomeContentListObsListComponent } from './home-content-list-obs-list/home-content-list-obs-list.component'; + +interface HomeContentListObservationItem { + id_synthese: number; + cd_nom?: number | null; + nom_vern_or_lb_nom: string; + date_min: string | null; + observers: string | null; + geometry?: Geometry; +} + +type HomeContentListObservationFeature = Feature; +type HomeContentListObservationFeatureCollection = FeatureCollection< + Geometry, + HomeContentListObservationItem +>; + +interface HomeContentListObsFilters { + taxonomy_group2_inpn?: string[]; + taxonomy_group3_inpn?: string[]; +} + +@Component({ + standalone: true, + selector: 'pnx-home-content-list-obs', + templateUrl: './home-content-list-obs.component.html', + styleUrls: ['./home-content-list-obs.component.scss'], + imports: [ + CommonModule, + GN2CommonModule, + HomeContentListObsFiltersComponent, + HomeContentListObsListComponent, + ], +}) +export class HomeContentListObsComponent implements OnInit, OnDestroy { + @ViewChild(GeojsonComponent) private _geojsonComponent?: GeojsonComponent; + + readonly pageSize = 9; + readonly defaultMapFeatureStyle = { + color: '#3388FF', + weight: 3, + fill: false, + radius: 6, + }; + readonly selectedMapFeatureStyle = { + color: '#FF0000', + weight: 3, + fill: false, + radius: 6, + }; + + observations: HomeContentListObservationItem[] = []; + observationsGeoJson: HomeContentListObservationFeatureCollection = { + type: 'FeatureCollection', + features: [], + }; + isLoading = false; + filters: HomeContentListObsFilters = {}; + currentPage = 0; + selectedObservationId: number | null = null; + + private featureLayers = new Map(); + private selectedLayer: Layer | null = null; + private taxonThumbnailUrls = new Map(); + private loadingTaxonThumbnailIds = new Set(); + private destroy$ = new Subject(); + + constructor( + private _syntheseDataService: SyntheseDataService, + private _dataFormService: DataFormService, + private _mapService: MapService, + private _router: Router + ) {} + + ngOnInit() { + this._fetchObservations(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + onFiltersChange(filters: HomeContentListObsFilters) { + this.filters = filters; + this.currentPage = 0; + this._clearSelection(false); + this._fetchObservations(); + } + + onPageChange(offset: number) { + this.currentPage = offset; + } + + onObservationSelect(idSynthese: number) { + if (this.selectedObservationId === idSynthese) { + this._clearSelection(true); + return; + } + + this._showObservationPage(idSynthese); + this._selectObservation(idSynthese, true, true); + } + + readonly onEachFeature = (feature: HomeContentListObservationFeature, layer: Layer) => { + const idSynthese = feature.properties.id_synthese; + this.featureLayers.set(idSynthese, layer); + this._applyStyleToLayer(layer, this.defaultMapFeatureStyle); + + if ('bindPopup' in layer && typeof layer.bindPopup === 'function') { + layer.bindPopup(this._buildPopupContent(feature.properties)); + } + + if (idSynthese === this.selectedObservationId) { + this.selectedLayer = layer; + this._applyStyleToLayer(layer, this.selectedMapFeatureStyle); + } + + if ('on' in layer && typeof layer.on === 'function') { + layer.on('click', () => { + if (this.selectedObservationId === idSynthese) { + this._clearSelection(true); + return; + } + + this._showObservationPage(idSynthese); + this._selectObservation(idSynthese, false, false); + }); + } + }; + + private _fetchObservations() { + this.isLoading = true; + this.featureLayers.clear(); + this.selectedLayer = null; + this._syntheseDataService + .getSyntheseData(this.filters, { limit: 100, format: 'ungrouped_geom' }) + .pipe( + map((data) => + ((data?.features ?? []) as HomeContentListObservationFeature[]).sort((a, b) => { + const aTime = a.properties?.date_min ? new Date(a.properties.date_min).getTime() : 0; + const bTime = b.properties?.date_min ? new Date(b.properties.date_min).getTime() : 0; + return bTime - aTime; + }) + ), + takeUntil(this.destroy$) + ) + .subscribe({ + next: (features: HomeContentListObservationFeature[]) => { + this.observations = features.map((feature) => ({ + ...feature.properties, + geometry: feature.geometry ?? undefined, + })); + this.observationsGeoJson = { + type: 'FeatureCollection', + features: features.filter((feature) => !!feature.geometry), + }; + this._prefetchTaxonThumbnails(); + this.isLoading = false; + }, + error: () => { + this.observations = []; + this.observationsGeoJson = { + type: 'FeatureCollection', + features: [], + }; + this.isLoading = false; + }, + }); + } + + private _prefetchTaxonThumbnails() { + const taxonIds = new Set( + this.observations + .map((observation) => observation.cd_nom) + .filter((cdNom): cdNom is number => typeof cdNom === 'number') + ); + + taxonIds.forEach((cdNom) => { + if (this.taxonThumbnailUrls.has(cdNom) || this.loadingTaxonThumbnailIds.has(cdNom)) { + return; + } + + this.loadingTaxonThumbnailIds.add(cdNom); + this._dataFormService + .getTaxonInfo(cdNom, ['medias', 'cd_nom']) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (taxonAttrAndMedias) => { + const media = taxonAttrAndMedias['medias']?.find( + (m) => m.id_type == this._syntheseDataService.config.TAXHUB.ID_TYPE_MAIN_PHOTO + ); + const mediaUrl = media + ? `${this._dataFormService.getTaxhubAPI()}/tmedias/thumbnail/${media.id_media}?h=96&w=96` + : null; + this.taxonThumbnailUrls.set(cdNom, mediaUrl); + this.loadingTaxonThumbnailIds.delete(cdNom); + this._refreshPopupContentsForTaxon(cdNom); + }, + error: () => { + this.taxonThumbnailUrls.set(cdNom, null); + this.loadingTaxonThumbnailIds.delete(cdNom); + }, + }); + }); + } + + private _refreshPopupContentsForTaxon(cdNom: number) { + this.observations + .filter((observation) => observation.cd_nom === cdNom) + .forEach((observation) => { + const layer = this.featureLayers.get(observation.id_synthese); + if ( + !layer || + !('setPopupContent' in layer) || + typeof layer.setPopupContent !== 'function' + ) { + return; + } + + layer.setPopupContent(this._buildPopupContent(observation)); + }); + } + + private _showObservationPage(idSynthese: number) { + const observationIndex = this.observations.findIndex( + (observation) => observation.id_synthese === idSynthese + ); + if (observationIndex < 0) { + return; + } + + this.currentPage = Math.floor(observationIndex / this.pageSize); + } + + private _clearSelection(shouldClosePopup: boolean) { + this.selectedObservationId = null; + + if (!this.selectedLayer) { + return; + } + + this._applyStyleToLayer(this.selectedLayer, this.defaultMapFeatureStyle); + + if ( + shouldClosePopup && + 'closePopup' in this.selectedLayer && + typeof this.selectedLayer.closePopup === 'function' + ) { + this.selectedLayer.closePopup(); + } + + this.selectedLayer = null; + } + + private _selectObservation( + idSynthese: number, + shouldOpenPopup: boolean, + shouldCenterMap: boolean + ) { + this.selectedObservationId = idSynthese; + + if (this.selectedLayer) { + this._applyStyleToLayer(this.selectedLayer, this.defaultMapFeatureStyle); + } + + const layer = this.featureLayers.get(idSynthese); + if (!layer) { + this.selectedLayer = null; + return; + } + + this.selectedLayer = layer; + this._applyStyleToLayer(layer, this.selectedMapFeatureStyle); + this._focusLayer(layer, shouldOpenPopup, shouldCenterMap); + } + + private _applyStyleToLayer(layer: Layer, style) { + if ('setStyle' in layer && typeof layer.setStyle === 'function') { + layer.setStyle(style); + } + } + + private _focusLayer(layer: Layer, shouldOpenPopup: boolean, shouldCenterMap: boolean) { + const currentGeojson = this._geojsonComponent?.currentGeojson as any; + + const finalizeFocus = () => { + if (shouldCenterMap) { + this._centerMapOnLayer(layer); + } + + if ('openPopup' in layer && typeof layer.openPopup === 'function' && shouldOpenPopup) { + layer.openPopup(); + } + }; + + if ( + currentGeojson && + typeof currentGeojson.zoomToShowLayer === 'function' && + typeof currentGeojson.hasLayer === 'function' && + currentGeojson.hasLayer(layer) + ) { + currentGeojson.zoomToShowLayer(layer, finalizeFocus); + return; + } + + finalizeFocus(); + } + + private _centerMapOnLayer(layer: Layer) { + const map = this._mapService.map; + if (!map) { + return; + } + + if ('getLatLng' in layer && typeof layer.getLatLng === 'function') { + map.panTo(layer.getLatLng()); + return; + } + + if ('getBounds' in layer && typeof layer.getBounds === 'function') { + const bounds = layer.getBounds(); + if (bounds && bounds.isValid && bounds.isValid()) { + map.fitBounds(bounds, { maxZoom: 14 }); + } + } + } + + private _buildPopupContent(observation: HomeContentListObservationItem): string { + const url = new URL(window.location.href); + url.hash = this._router.serializeUrl( + this._router.createUrlTree(['synthese', 'occurrence', observation.id_synthese, 'details']) + ); + + const thumbnailUrl = + typeof observation.cd_nom === 'number' + ? (this.taxonThumbnailUrls.get(observation.cd_nom) ?? '') + : ''; + + return ` +
+ ${thumbnailUrl ? `` : ''} + + ${observation.nom_vern_or_lb_nom ?? ''}
+ Observé le: ${observation.date_min ?? ''}
+ Par: ${observation.observers ?? ''}
+ Voir l'observation +
+
+ `; + } +} diff --git a/frontend/src/app/components/home-content/home-content.component.html b/frontend/src/app/components/home-content/home-content.component.html index 9b1f250e87..e920ea261c 100644 --- a/frontend/src/app/components/home-content/home-content.component.html +++ b/frontend/src/app/components/home-content/home-content.component.html @@ -3,14 +3,18 @@ data-qa="pnx-home-content" >
-
+
-
+
@@ -28,13 +32,16 @@
- -
- + +
diff --git a/frontend/src/app/components/home-content/home-content.component.scss b/frontend/src/app/components/home-content/home-content.component.scss index cfdab54fdc..a89c2deddc 100644 --- a/frontend/src/app/components/home-content/home-content.component.scss +++ b/frontend/src/app/components/home-content/home-content.component.scss @@ -58,3 +58,36 @@ ngx-datatable-column { margin-left: 1rem; margin-right: 1rem; } + +.home-content-list-obs { + height: 100%; +} + +.home-content-side-panel { + display: flex; + min-height: 0; +} + +.home-content-last-obs-panel { + display: flex; + flex: 1 1 auto; + flex-direction: column; +} + +.home-content-last-obs-body { + flex: 1 1 auto; + min-height: 0; +} + +@media (max-width: 991.98px) { + .home-content-last-obs-panel { + height: auto; + min-height: 0; + } + + .home-content-last-obs-panel .panel-body-intro, + .home-content-last-obs-body { + overflow: visible; + height: auto; + } +} diff --git a/frontend/src/app/components/home-content/home-content.component.ts b/frontend/src/app/components/home-content/home-content.component.ts index d29c3ad8a1..da0e3cf546 100644 --- a/frontend/src/app/components/home-content/home-content.component.ts +++ b/frontend/src/app/components/home-content/home-content.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, OnInit } from '@angular/core'; import { LangChangeEvent, TranslateService } from '@ngx-translate/core'; @@ -21,7 +21,6 @@ import { HomeValidationsService } from './home-validations/home-validations.serv providers: [MapService, SyntheseDataService, HomeDiscussionsService, HomeValidationsService], }) export class HomeContentComponent implements OnInit, AfterViewInit { - public showLastObsMap: boolean = false; public showGeneralStat: boolean = false; public generalStat: any; public locale: string; @@ -43,9 +42,6 @@ export class HomeContentComponent implements OnInit, AfterViewInit { let synthese_module = this._moduleService.getModule('SYNTHESE'); let synthese_read_scope = synthese_module ? synthese_module.cruved['R'] : 0; - if (this.config.FRONTEND.DISPLAY_MAP_LAST_OBS && synthese_read_scope > 0) { - this.showLastObsMap = true; - } if (this.config.FRONTEND.DISPLAY_STAT_BLOC && synthese_read_scope > 0) { this.showGeneralStat = true; } @@ -83,6 +79,23 @@ export class HomeContentComponent implements OnInit, AfterViewInit { return this.displayDiscussions || this.displayValidations; } + get showLastObsMap(): boolean { + return this.hasSyntheseReadScope && this.config.FRONTEND.DISPLAY_MAP_LAST_OBS; + } + + get showLastObsList(): boolean { + return ( + this.hasSyntheseReadScope && + !this.config.FRONTEND.DISPLAY_MAP_LAST_OBS && + this.config.FRONTEND.DISPLAY_LIST_LAST_OBS + ); + } + + get hasSyntheseReadScope(): boolean { + const syntheseModule = this._moduleService.getModule('SYNTHESE'); + return (syntheseModule ? syntheseModule.cruved['R'] : 0) > 0; + } + get displayDiscussions(): boolean { return this._discussionsService.isAvailable; } diff --git a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs-container.component.ts b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs-container.component.ts index f97018eb4f..d798453b86 100644 --- a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs-container.component.ts +++ b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs-container.component.ts @@ -4,7 +4,6 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { SyntheseInfoObsComponent } from './synthese-info-obs/synthese-info-obs.component'; -import { Location } from '@angular/common'; @Component({ selector: 'pnx-synthese-info-obs-modal-container', @@ -16,7 +15,7 @@ export class SyntheseObsModalWrapperComponent implements OnDestroy { dialogResult: any; constructor( private modalService: NgbModal, - private location: Location, + private router: Router, route: ActivatedRoute ) { route.params.pipe(takeUntil(this.destroy)).subscribe((params) => { @@ -34,10 +33,10 @@ export class SyntheseObsModalWrapperComponent implements OnDestroy { // Go back to home page after the modal is closed this.dialogResult = this.currentDialog.result.then( (result) => { - this.location.back(); + this.router.navigate(['/synthese'], { replaceUrl: true }); }, (reason) => { - this.location.back(); + this.router.navigate(['/synthese'], { replaceUrl: true }); } ); }); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 5535e6e40a..f8692fd4b0 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -377,6 +377,7 @@ ng-select.ng-invalid .ng-select-container, display: flex; flex-direction: column; height: 55vh; + min-height: 550px; } .panel-body-intro {