diff --git a/demo/src/app/geo/workspace/workspace.component.ts b/demo/src/app/geo/workspace/workspace.component.ts index 2068cbd923..5da6aa3c37 100644 --- a/demo/src/app/geo/workspace/workspace.component.ts +++ b/demo/src/app/geo/workspace/workspace.component.ts @@ -19,6 +19,7 @@ import { WFSDataSourceOptions } from '@igo2/geo'; import { MatPaginator } from '@angular/material/paginator'; +import { WorkspaceState } from '@igo2/integration'; @Component({ selector: 'app-workspace', @@ -51,7 +52,9 @@ export class AppWorkspaceComponent implements OnInit { zoom: 5 }; - public workspaceStore = new WorkspaceStore([]); + get workspaceStore(): WorkspaceStore { + return this.workspaceState.store; + } public selectedWorkspace$: Observable; @@ -62,7 +65,8 @@ export class AppWorkspaceComponent implements OnInit { constructor( private languageService: LanguageService, private dataSourceService: DataSourceService, - private layerService: LayerService + private layerService: LayerService, + public workspaceState: WorkspaceState, ) {} ngOnInit() { @@ -80,7 +84,6 @@ export class AppWorkspaceComponent implements OnInit { // This is why it's has been removed frome the actions's list. // Refer to the igo2 demo at https://infra-geo-ouverte.github.io/igo2/ entity.actionStore.view.filter((action) => { - console.log('action', action); return action.id !== 'wfsDownload'; }); } diff --git a/packages/common/src/lib/action/actionbar/actionbar-item.component.html b/packages/common/src/lib/action/actionbar/actionbar-item.component.html index 25f286d6f0..d2d711c419 100644 --- a/packages/common/src/lib/action/actionbar/actionbar-item.component.html +++ b/packages/common/src/lib/action/actionbar/actionbar-item.component.html @@ -21,7 +21,7 @@

{{title | translate}}

[ngClass]="ngClass$ | async"> + [checked]="checkCondition$ | async"> {{title | translate}} diff --git a/packages/common/src/lib/action/actionbar/actionbar-item.component.ts b/packages/common/src/lib/action/actionbar/actionbar-item.component.ts index f135bc2c09..1d578416b3 100644 --- a/packages/common/src/lib/action/actionbar/actionbar-item.component.ts +++ b/packages/common/src/lib/action/actionbar/actionbar-item.component.ts @@ -25,6 +25,8 @@ export class ActionbarItemComponent implements OnInit, OnDestroy { readonly disabled$: BehaviorSubject = new BehaviorSubject(false); + readonly checkCondition$: BehaviorSubject = new BehaviorSubject(undefined); + readonly icon$: BehaviorSubject = new BehaviorSubject(undefined); readonly tooltip$: BehaviorSubject = new BehaviorSubject(undefined); @@ -41,6 +43,8 @@ export class ActionbarItemComponent implements OnInit, OnDestroy { private icon$$: Subscription; + private checkCondition$$: Subscription; + private tooltip$$: Subscription; private noDisplay$$: Subscription; @@ -113,6 +117,13 @@ export class ActionbarItemComponent implements OnInit, OnDestroy { this.updateIcon(this.action.icon); } + if (isObservable(this.action.checkCondition)) { + this.checkCondition$$ = this.action.checkCondition + .subscribe((checkCondition: boolean) => this.updateCheckCondition(checkCondition)); + } else { + this.updateCheckCondition(this.action.checkCondition); + } + if (isObservable(this.action.tooltip)) { this.tooltip$$ = this.action.tooltip .subscribe((tooltip: string) => this.updateTooltip(tooltip)); @@ -153,6 +164,11 @@ export class ActionbarItemComponent implements OnInit, OnDestroy { this.display$$ = undefined; } + if (this.checkCondition$$ !== undefined) { + this.checkCondition$$.unsubscribe(); + this.checkCondition$$ = undefined; + } + if (this.icon$$ !== undefined) { this.icon$$.unsubscribe(); this.icon$$ = undefined; @@ -187,6 +203,10 @@ export class ActionbarItemComponent implements OnInit, OnDestroy { this.tooltip$.next(tooltip); } + private updateCheckCondition(checkCondition: boolean) { + this.checkCondition$.next(checkCondition); + } + private updateIcon(icon: string) { this.icon$.next(icon); } diff --git a/packages/common/src/lib/action/shared/action.interfaces.ts b/packages/common/src/lib/action/shared/action.interfaces.ts index c79d5d704a..8ffb2e0407 100644 --- a/packages/common/src/lib/action/shared/action.interfaces.ts +++ b/packages/common/src/lib/action/shared/action.interfaces.ts @@ -8,7 +8,7 @@ export interface Action { tooltip?: string | Observable; args?: any[]; checkbox?: boolean; - checkCondition?: boolean; + checkCondition?: boolean | Observable; display?: (...args: any[]) => Observable; availability?: (...args: any[]) => Observable; ngClass?: (...args: any[]) => Observable<{[key: string]: boolean}>; diff --git a/packages/common/src/lib/entity/entity-table-paginator/entity-table-paginator.component.scss b/packages/common/src/lib/entity/entity-table-paginator/entity-table-paginator.component.scss index e69de29bb2..5f7aa78779 100644 --- a/packages/common/src/lib/entity/entity-table-paginator/entity-table-paginator.component.scss +++ b/packages/common/src/lib/entity/entity-table-paginator/entity-table-paginator.component.scss @@ -0,0 +1,11 @@ +@import '../../../../../core/src/style/partial/media'; + +:host{ + margin-top: -10px; + padding-right: 15px; + + @include mobile { + margin-top: 0px; + padding-right: 5px; + } +} diff --git a/packages/common/src/lib/entity/entity-table-paginator/entity-table-paginator.component.ts b/packages/common/src/lib/entity/entity-table-paginator/entity-table-paginator.component.ts index 72387a8c6a..7ce41c790f 100644 --- a/packages/common/src/lib/entity/entity-table-paginator/entity-table-paginator.component.ts +++ b/packages/common/src/lib/entity/entity-table-paginator/entity-table-paginator.component.ts @@ -2,7 +2,7 @@ import { Component, Input, ChangeDetectionStrategy, - OnInit, + OnChanges, ViewChild, Output, EventEmitter, @@ -14,7 +14,7 @@ import { } from '../shared'; import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { BehaviorSubject, Subscription } from 'rxjs'; -import { LanguageService } from '@igo2/core'; +import { LanguageService, MediaService } from '@igo2/core'; import { EntityTablePaginatorOptions } from './entity-table-paginator.interface'; @Component({ @@ -23,7 +23,7 @@ import { EntityTablePaginatorOptions } from './entity-table-paginator.interface' styleUrls: ['./entity-table-paginator.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class EntityTablePaginatorComponent implements OnInit, OnDestroy { +export class EntityTablePaginatorComponent implements OnChanges, OnDestroy { public disabled: boolean = false; public hidePageSize: boolean = false; @@ -31,6 +31,7 @@ export class EntityTablePaginatorComponent implements OnInit, OnDestroy { public pageSize: number = 50; public pageSizeOptions: number[] = [5, 10, 20, 50, 100, 200]; public showFirstLastButtons: boolean = true; + private count$$: Subscription; private entitySortChange$$: Subscription; private paginationLabelTranslation$$: Subscription[] = []; @@ -58,12 +59,13 @@ export class EntityTablePaginatorComponent implements OnInit, OnDestroy { */ @Output() paginatorChange: EventEmitter = new EventEmitter(); - constructor(private languageService: LanguageService) { } + constructor(private languageService: LanguageService, private mediaService: MediaService) {} @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; - ngOnInit() { - this.store.stateView.count$.subscribe((count) => { + ngOnChanges() { + this.unsubscribeAll(); + this.count$$ = this.store.stateView.count$.subscribe((count) => { this.length = count; this.emitPaginator(); }); @@ -78,11 +80,16 @@ export class EntityTablePaginatorComponent implements OnInit, OnDestroy { initPaginatorOptions() { this.disabled = this.paginatorOptions?.disabled || this.disabled; - this.hidePageSize = this.paginatorOptions?.hidePageSize || this.hidePageSize; this.pageIndex = this.paginatorOptions?.pageIndex || this.pageIndex; this.pageSize = this.paginatorOptions?.pageSize || this.pageSize; this.pageSizeOptions = this.paginatorOptions?.pageSizeOptions || this.pageSizeOptions; - this.showFirstLastButtons = this.paginatorOptions?.showFirstLastButtons || this.showFirstLastButtons; + if (this.mediaService.isMobile()) { + this.showFirstLastButtons = false; + this.hidePageSize = true; + } else { + this.showFirstLastButtons = this.paginatorOptions?.showFirstLastButtons || this.showFirstLastButtons; + this.hidePageSize = this.paginatorOptions?.hidePageSize || this.hidePageSize; + } } translateLabels() { @@ -128,9 +135,14 @@ export class EntityTablePaginatorComponent implements OnInit, OnDestroy { return `${startIndex + 1} - ${endIndex} ${of.value} ${length}`; } - ngOnDestroy(): void { - this.entitySortChange$$.unsubscribe(); + private unsubscribeAll() { this.paginationLabelTranslation$$.map(sub => sub.unsubscribe()); + if (this.count$$) { this.count$$.unsubscribe(); } + if (this.entitySortChange$$) { this.entitySortChange$$.unsubscribe(); } + } + + ngOnDestroy(): void { + this.unsubscribeAll(); } emitPaginator() { diff --git a/packages/common/src/lib/entity/entity-table/entity-table.component.scss b/packages/common/src/lib/entity/entity-table/entity-table.component.scss index 3b71c28fd0..b4fae1ef5a 100644 --- a/packages/common/src/lib/entity/entity-table/entity-table.component.scss +++ b/packages/common/src/lib/entity/entity-table/entity-table.component.scss @@ -28,6 +28,10 @@ word-wrap: break-word; } +th.mat-header-cell, td.mat-cell, td.mat-footer-cell { + padding: 0 3px; +} + entity-table table.igo-entity-table-with-selection tr:hover { -moz-box-shadow: 2px 0px 2px 0px #dddddd; -webkit-box-shadow: 2px 0px 2px 0px #dddddd; diff --git a/packages/common/src/lib/entity/entity-table/entity-table.component.ts b/packages/common/src/lib/entity/entity-table/entity-table.component.ts index 6da679f27a..7dd3dcec27 100644 --- a/packages/common/src/lib/entity/entity-table/entity-table.component.ts +++ b/packages/common/src/lib/entity/entity-table/entity-table.component.ts @@ -7,7 +7,8 @@ import { ChangeDetectorRef, OnInit, OnDestroy, - AfterViewInit + OnChanges, + SimpleChanges } from '@angular/core'; import { BehaviorSubject, Subscription } from 'rxjs'; @@ -33,7 +34,7 @@ import { EntityTablePaginatorOptions } from '../entity-table-paginator/entity-ta styleUrls: ['./entity-table.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class EntityTableComponent implements OnInit, OnDestroy, AfterViewInit { +export class EntityTableComponent implements OnInit, OnChanges, OnDestroy { entitySortChange$: BehaviorSubject = new BehaviorSubject(false); @@ -78,7 +79,7 @@ export class EntityTableComponent implements OnInit, OnDestroy, AfterViewInit { @Input() store: EntityStore; /** - * Table paginator store + * Table paginator */ @Input() set paginator(value: MatPaginator) { this._paginator = value; @@ -132,7 +133,7 @@ export class EntityTableComponent implements OnInit, OnDestroy, AfterViewInit { }>(); /** - * Event emitted when an entity (row) is selected + * Event emitted when the table sort is changed. */ @Output() entitySortChange: EventEmitter<{column: EntityTableColumn, direction: string}> = new EventEmitter(undefined); @@ -189,19 +190,43 @@ export class EntityTableComponent implements OnInit, OnDestroy, AfterViewInit { * @internal */ ngOnInit() { + this.handleDatasource(); + this.dataSource.paginator = this.paginator; + } + + /** + * @internal + */ + ngOnChanges(changes: SimpleChanges) { + const store = changes.store; + if (store && store.currentValue !== store.previousValue) { + this.handleDatasource(); + } + } + + private handleDatasource() { + this.unsubscribeStore(); this.selection$$ = this.store.stateView .manyBy$((record: EntityRecord) => record.state.selected === true) .subscribe((records: EntityRecord[]) => { + const firstSelected = records[0]; + const firstSelectedStateviewPosition = this.store.stateView.all().indexOf(firstSelected); + const pageMax = this.paginator.pageSize * (this.paginator.pageIndex + 1); + const pageMin = pageMax - this.paginator.pageSize; + + if ( + this.paginator && + firstSelectedStateviewPosition < pageMin || + firstSelectedStateviewPosition >= pageMax) { + const pageToReach = Math.floor(firstSelectedStateviewPosition / this.paginator.pageSize); + this.dataSource.paginator.pageIndex = pageToReach; + } this.selectionState$.next(this.computeSelectionState(records)); }); - this.dataSource$$ = this.store.stateView.all$().subscribe((all) => { this.dataSource.data = all; }); - } - ngAfterViewInit() { - this.dataSource.paginator = this.paginator; } /** @@ -209,8 +234,16 @@ export class EntityTableComponent implements OnInit, OnDestroy, AfterViewInit { * @internal */ ngOnDestroy() { - this.selection$$.unsubscribe(); - this.dataSource$$.unsubscribe(); + this.unsubscribeStore(); + } + + private unsubscribeStore() { + if (this.selection$$) { + this.selection$$.unsubscribe(); + } + if (this.dataSource$$) { + this.dataSource$$.unsubscribe(); + } } /** diff --git a/packages/common/src/lib/flexible/flexible.component.ts b/packages/common/src/lib/flexible/flexible.component.ts index 4a6fad2b1d..8e1e2ea966 100644 --- a/packages/common/src/lib/flexible/flexible.component.ts +++ b/packages/common/src/lib/flexible/flexible.component.ts @@ -88,8 +88,7 @@ export class FlexibleComponent implements OnInit { expanded: this.expanded }; - const media = this.mediaService.media$.value; - if (media === 'mobile') { + if (this.mediaService.isMobile()) { Object.assign(sizes, { initial: this.initialMobile, collapsed: this.collapsedMobile, diff --git a/packages/common/src/lib/interactive-tour/interactive-tour.service.ts b/packages/common/src/lib/interactive-tour/interactive-tour.service.ts index e4c654fde6..18db133be8 100644 --- a/packages/common/src/lib/interactive-tour/interactive-tour.service.ts +++ b/packages/common/src/lib/interactive-tour/interactive-tour.service.ts @@ -35,12 +35,7 @@ export class InteractiveTourService { } public isMobile(): boolean { - const media = this.mediaService.getMedia(); - if (media === 'mobile') { - return true; - } else { - return false; - } + return this.mediaService.isMobile(); } public isTourDisplayInMobile(): boolean { diff --git a/packages/context/src/lib/context-import-export/context-import-export/context-import-export.component.html b/packages/context/src/lib/context-import-export/context-import-export/context-import-export.component.html index 917ad93237..c61db0c551 100644 --- a/packages/context/src/lib/context-import-export/context-import-export/context-import-export.component.html +++ b/packages/context/src/lib/context-import-export/context-import-export/context-import-export.component.html @@ -50,7 +50,7 @@

{{'igo.geo.importExportForm.importClarifications' | translate}}

{{'igo.context.contextImportExport.export.exportSelectAll' | translate}} - {{layer.title}} + {{layer.title}} diff --git a/packages/context/src/lib/context-import-export/context-import-export/context-import-export.component.ts b/packages/context/src/lib/context-import-export/context-import-export/context-import-export.component.ts index 87671becfc..d88e2ca7df 100644 --- a/packages/context/src/lib/context-import-export/context-import-export/context-import-export.component.ts +++ b/packages/context/src/lib/context-import-export/context-import-export/context-import-export.component.ts @@ -29,6 +29,7 @@ export class ContextImportExportComponent implements OnInit { public loading$ = new BehaviorSubject(false); public forceNaming = false; public layerList: Layer[]; + public userControlledLayerList: Layer[]; public res: DetailedContext; private clientSideFileSizeMax: number; public fileSizeMb: number; @@ -56,6 +57,7 @@ export class ContextImportExportComponent implements OnInit { (configFileSizeMb ? configFileSizeMb : 30) * Math.pow(1024, 2); this.fileSizeMb = this.clientSideFileSizeMax / Math.pow(1024, 2); this.layerList = this.contextService.getContextLayers(this.map); + this.userControlledLayerList = this.layerList.filter(layer => layer.showInLayerList); } importFiles(files: File[]) { @@ -128,7 +130,7 @@ export class ContextImportExportComponent implements OnInit { selectAll(e) { if (e._selected) { - this.form.controls.layers.setValue(this.layerList); + this.form.controls.layers.setValue(this.userControlledLayerList); e._selected = true; } if (e._selected === false) { diff --git a/packages/core/src/lib/media/media.service.ts b/packages/core/src/lib/media/media.service.ts index 46632ac3cc..895d971ee2 100644 --- a/packages/core/src/lib/media/media.service.ts +++ b/packages/core/src/lib/media/media.service.ts @@ -69,4 +69,9 @@ export class MediaService { isTouchScreen(): boolean { return 'ontouchstart' in document.documentElement ? true : false; } + + isMobile(): boolean { + const media = this.getMedia(); + return media === 'mobile'; + } } diff --git a/packages/core/src/lib/storage/storage.interface.ts b/packages/core/src/lib/storage/storage.interface.ts index 242d4ba6c8..64b0cee6e8 100644 --- a/packages/core/src/lib/storage/storage.interface.ts +++ b/packages/core/src/lib/storage/storage.interface.ts @@ -6,3 +6,18 @@ export enum StorageScope { export interface StorageOptions { key: string; } + +export interface StorageServiceEvent { + key: string; + scope: StorageScope; + event: StorageServiceEventEnum; + previousValue?: any; + currentValue?: any; +} + +export enum StorageServiceEventEnum { + ADDED = 'Added', + MODIFIED = 'Modified', + REMOVED = 'Removed' +} + diff --git a/packages/core/src/lib/storage/storage.service.ts b/packages/core/src/lib/storage/storage.service.ts index bf9d03f6ca..78aeff6374 100644 --- a/packages/core/src/lib/storage/storage.service.ts +++ b/packages/core/src/lib/storage/storage.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@angular/core'; import { ConfigService } from '../config/config.service'; -import { StorageScope, StorageOptions } from './storage.interface'; +import { StorageScope, StorageOptions, StorageServiceEvent, StorageServiceEventEnum } from './storage.interface'; +import { BehaviorSubject } from 'rxjs'; @Injectable({ providedIn: 'root' @@ -9,6 +10,8 @@ import { StorageScope, StorageOptions } from './storage.interface'; export class StorageService { protected options: StorageOptions; + public storageChange$: BehaviorSubject = new BehaviorSubject(undefined); + constructor(private config: ConfigService) { this.options = this.config.getConfig('storage') || { key: 'igo' }; } @@ -42,6 +45,7 @@ export class StorageService { value: string | object | boolean | number, scope: StorageScope = StorageScope.LOCAL ) { + const previousValue = this.get(key, scope); if (scope === StorageScope.SESSION) { sessionStorage.setItem( `${this.options.key}.${key}`, @@ -50,13 +54,25 @@ export class StorageService { } else { localStorage.setItem(`${this.options.key}.${key}`, JSON.stringify(value)); } + const currentValue = this.get(key, scope); + + if (currentValue !== previousValue) { + this.storageChange$.next({ + key, scope, + event: previousValue !== undefined ? StorageServiceEventEnum.MODIFIED : StorageServiceEventEnum.ADDED, + previousValue, + currentValue + }); + } } remove(key: string, scope: StorageScope = StorageScope.LOCAL) { + const previousValue = this.get(key, scope); if (scope === StorageScope.SESSION) { sessionStorage.removeItem(`${this.options.key}.${key}`); } else { localStorage.removeItem(`${this.options.key}.${key}`); } + this.storageChange$.next({key, scope, event: StorageServiceEventEnum.REMOVED, previousValue }); } } diff --git a/packages/geo/src/lib/catalog/catalog-browser/catalog-browser-layer.component.ts b/packages/geo/src/lib/catalog/catalog-browser/catalog-browser-layer.component.ts index d47953c2bd..9187d5a012 100644 --- a/packages/geo/src/lib/catalog/catalog-browser/catalog-browser-layer.component.ts +++ b/packages/geo/src/lib/catalog/catalog-browser/catalog-browser-layer.component.ts @@ -33,6 +33,8 @@ export class CatalogBrowserLayerComponent implements OnInit { public layerLegendShown$: BehaviorSubject = new BehaviorSubject(false); public igoLayer$ = new BehaviorSubject(undefined); + private mouseInsideAdd: boolean = false; + @Input() resolution: number; @Input() catalogAllowLegend = false; @@ -100,7 +102,9 @@ export class CatalogBrowserLayerComponent implements OnInit { if (typeof this.lastTimeoutRequest !== 'undefined') { clearTimeout(this.lastTimeoutRequest); } - + if (event.type === 'mouseenter' && this.mouseInsideAdd ) { + return; + } switch (event.type) { case 'click': if (!this.isPreview$.value) { @@ -119,12 +123,14 @@ export class CatalogBrowserLayerComponent implements OnInit { this.isPreview$.next(true); }, 500); } + this.mouseInsideAdd = true; break; case 'mouseleave': if (this.isPreview$.value) { this.remove(); this.isPreview$.next(false); } + this.mouseInsideAdd = false; break; default: break; diff --git a/packages/geo/src/lib/feature/feature-details/feature-details.component.ts b/packages/geo/src/lib/feature/feature-details/feature-details.component.ts index e50481a2f0..7637e4119d 100644 --- a/packages/geo/src/lib/feature/feature-details/feature-details.component.ts +++ b/packages/geo/src/lib/feature/feature-details/feature-details.component.ts @@ -9,14 +9,13 @@ import { import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { NetworkService, ConnectionState } from '@igo2/core'; -import { getEntityTitle, getEntityIcon, Toolbox } from '@igo2/common'; +import { getEntityTitle, getEntityIcon } from '@igo2/common'; +import type { Toolbox } from '@igo2/common'; import { Feature } from '../shared'; import { SearchSource } from '../../search/shared/sources/source'; import { IgoMap } from '../../map/shared/map'; -import olGeolocation from 'ol/Geolocation'; - @Component({ selector: 'igo-feature-details', templateUrl: './feature-details.component.html', diff --git a/packages/geo/src/lib/feature/shared/feature.interfaces.ts b/packages/geo/src/lib/feature/shared/feature.interfaces.ts index bb64acebfe..330b88cc5e 100644 --- a/packages/geo/src/lib/feature/shared/feature.interfaces.ts +++ b/packages/geo/src/lib/feature/shared/feature.interfaces.ts @@ -67,6 +67,9 @@ export interface FeatureStoreLoadingStrategyOptions motion?: FeatureMotion; } +export interface FeatureStoreInMapExtentStrategyOptions + extends FeatureStoreStrategyOptions {} + export interface FeatureStoreLoadingLayerStrategyOptions extends FeatureStoreStrategyOptions {} diff --git a/packages/geo/src/lib/feature/shared/strategies/in-map-extent.ts b/packages/geo/src/lib/feature/shared/strategies/in-map-extent.ts new file mode 100644 index 0000000000..08348e1c9e --- /dev/null +++ b/packages/geo/src/lib/feature/shared/strategies/in-map-extent.ts @@ -0,0 +1,117 @@ +import { unByKey } from 'ol/Observable'; +import * as olextent from 'ol/extent'; + +import { EntityStoreStrategy } from '@igo2/common'; + +import { FeatureStore } from '../store'; +import { FeatureStoreInMapExtentStrategyOptions, Feature } from '../feature.interfaces'; +import { Subscription } from 'rxjs'; +import { skipWhile } from 'rxjs/operators'; + +/** + * This strategy maintain the store features updated while the map is moved. + * The features's state inside the map are tagged inMapExtent = true; + */ +export class FeatureStoreInMapExtentStrategy extends EntityStoreStrategy { + + /** + * Subscription to the store's OL source changes + */ + private stores$$ = new Map(); + private states$$: Subscription[] = []; + private empty$$: Subscription; + + constructor(protected options: FeatureStoreInMapExtentStrategyOptions) { + super(options); + } + + /** + * Bind this strategy to a store and start watching for Ol source changes + * @param store Feature store + */ + bindStore(store: FeatureStore) { + super.bindStore(store); + if (this.active === true) { + this.watchStore(store); + } + this.empty$$ = store.empty$ + .pipe(skipWhile((empty) => !empty)) + .subscribe(() => this.updateEntitiesInExtent(store)); + } + + /** + * Unbind this strategy from a store and stop watching for Ol source changes + * @param store Feature store + */ + unbindStore(store: FeatureStore) { + super.unbindStore(store); + if (this.active === true) { + this.unwatchStore(store); + } + } + + /** + * Start watching all stores already bound to that strategy at once. + * @internal + */ + protected doActivate() { + this.stores.forEach((store: FeatureStore) => this.watchStore(store)); + } + + /** + * Stop watching all stores bound to that strategy + * @internal + */ + protected doDeactivate() { + this.unwatchAll(); + } + + /** + * Watch for a store's OL source changes + * @param store Feature store + */ + private watchStore(store: FeatureStore) { + if (this.stores$$.has(store)) { + return; + } + + this.updateEntitiesInExtent(store); + this.states$$.push(store.layer.map.viewController.state$.subscribe(() => { + this.updateEntitiesInExtent(store); + })); + } + + private updateEntitiesInExtent(store) { + store.state.updateAll({ inMapExtent: false }); + const mapExtent = store.layer.map.viewController.getExtent(); + const entitiesInMapExtent = store.entities$.value + .filter((entity: Feature) => olextent.intersects(entity.ol.getGeometry().getExtent(), mapExtent)); + if (entitiesInMapExtent.length > 0) { + store.state.updateMany(entitiesInMapExtent, { inMapExtent: true }, true); + } + } + + /** + * Stop watching for a store's OL source changes + * @param store Feature store + */ + private unwatchStore(store: FeatureStore) { + const key = this.stores$$.get(store); + if (key !== undefined) { + unByKey(key); + this.stores$$.delete(store); + } + } + + /** + * Stop watching for OL source changes in all stores. + */ + private unwatchAll() { + Array.from(this.stores$$.entries()).forEach((entries: [FeatureStore, string]) => { + unByKey(entries[1]); + }); + this.stores$$.clear(); + this.states$$.map(state => state.unsubscribe()); + if (this.empty$$) { this.empty$$.unsubscribe(); } + } +} diff --git a/packages/geo/src/lib/feature/shared/strategies/index.ts b/packages/geo/src/lib/feature/shared/strategies/index.ts index 229fe518b6..073bfb3246 100644 --- a/packages/geo/src/lib/feature/shared/strategies/index.ts +++ b/packages/geo/src/lib/feature/shared/strategies/index.ts @@ -1,3 +1,4 @@ +export * from './in-map-extent'; export * from './loading'; export * from './loading-layer'; export * from './selection'; diff --git a/packages/geo/src/lib/feature/shared/strategies/loading-layer.ts b/packages/geo/src/lib/feature/shared/strategies/loading-layer.ts index 8a0f83412a..b152b28c73 100644 --- a/packages/geo/src/lib/feature/shared/strategies/loading-layer.ts +++ b/packages/geo/src/lib/feature/shared/strategies/loading-layer.ts @@ -5,6 +5,7 @@ import { EntityStoreStrategy } from '@igo2/common'; import { FeatureStore } from '../store'; import { FeatureStoreLoadingLayerStrategyOptions } from '../feature.interfaces'; +import { ClusterDataSource } from '../../../datasource/shared/datasources/cluster-datasource'; /** * This strategy loads a layer's features into it's store counterpart. @@ -107,7 +108,13 @@ export class FeatureStoreLoadingLayerStrategy extends EntityStoreStrategy { * @param store Feature store */ private onSourceChanges(store: FeatureStore) { - const olFeatures = store.layer.ol.getSource().getFeatures(); + let olFeatures = store.layer.ol.getSource().getFeatures(); + + if (store.layer.dataSource instanceof ClusterDataSource) { + olFeatures = (olFeatures as any).flatMap((cluster: any) => + cluster.get('features') + ); + } if (olFeatures.length === 0) { store.clear(); } else { diff --git a/packages/geo/src/lib/feature/shared/strategies/selection.ts b/packages/geo/src/lib/feature/shared/strategies/selection.ts index 8269208471..eebe3d5fa8 100644 --- a/packages/geo/src/lib/feature/shared/strategies/selection.ts +++ b/packages/geo/src/lib/feature/shared/strategies/selection.ts @@ -22,7 +22,7 @@ import { import { FeatureStore } from '../store'; import { FeatureMotion } from '../feature.enums'; -class OlDragSelectInteraction extends OlDragBoxInteraction { +export class OlDragSelectInteraction extends OlDragBoxInteraction { constructor(options) { super(options); } diff --git a/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.html b/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.html index 9ab0d69ad4..7b2b9ebc51 100644 --- a/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.html +++ b/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.html @@ -129,6 +129,10 @@ {{'igo.geo.spatialFilter.exportLayer' | translate}} + +
diff --git a/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.scss b/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.scss index 05f47402b4..f4925199fd 100644 --- a/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.scss +++ b/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.scss @@ -66,6 +66,11 @@ width: 150px; } +.workspace-button { + left: 10px; + width: 150px; +} + .thematics { max-height: 150px; overflow: auto; diff --git a/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.ts b/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.ts index e81ac9134a..89fa93689d 100644 --- a/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.ts +++ b/packages/geo/src/lib/filter/spatial-filter/spatial-filter-item/spatial-filter-item.component.ts @@ -149,6 +149,8 @@ export class SpatialFilterItemComponent implements OnDestroy, OnInit { @Output() export = new EventEmitter(); + @Output() openWorkspace = new EventEmitter(); + public itemType: SpatialFilterItemType[] = [SpatialFilterItemType.Address, SpatialFilterItemType.Thematics]; public selectedItemType: SpatialFilterItemType = SpatialFilterItemType.Address; public selectedSourceAddress; diff --git a/packages/geo/src/lib/import-export/import-export/import-export.component.html b/packages/geo/src/lib/import-export/import-export/import-export.component.html index fc0b0087db..0b0da540b7 100644 --- a/packages/geo/src/lib/import-export/import-export/import-export.component.html +++ b/packages/geo/src/lib/import-export/import-export/import-export.component.html @@ -53,10 +53,30 @@

{{'igo.geo.importExportForm.exportNoLayersExportable' | translate}}

{{'igo.geo.importExportForm.exportLayerPlaceholder' | translate}} - - - {{layer.title}} + + + {{layers.length ? getLayerTitleById(layers[0]) : ''}} + + (+{{layers.length - 1}} {{(layers?.length === 2 ? 'igo.geo.importExportForm.other' : 'igo.geo.importExportForm.others') | translate }}) + + + +

{{layer.title}}

+

+ + {{'igo.geo.importExportForm.exportSelectedFeature' | translate}} + +

diff --git a/packages/geo/src/lib/import-export/import-export/import-export.component.scss b/packages/geo/src/lib/import-export/import-export/import-export.component.scss index b2bdaca7cb..da5be051fe 100644 --- a/packages/geo/src/lib/import-export/import-export/import-export.component.scss +++ b/packages/geo/src/lib/import-export/import-export/import-export.component.scss @@ -1,5 +1,10 @@ $slide-toggle-width: 60px; +mat-option.igo-export-layer-mat-option { + height: 5em; + line-height: 1em; +} + .import-export-toggle { padding: 10px; text-align: center; @@ -50,3 +55,8 @@ igo-spinner { } } } + +.export-select-trigger { + opacity: 0.75; + font-size: 0.75em; +} diff --git a/packages/geo/src/lib/import-export/import-export/import-export.component.ts b/packages/geo/src/lib/import-export/import-export/import-export.component.ts index 82343a916e..57c6d72afe 100644 --- a/packages/geo/src/lib/import-export/import-export/import-export.component.ts +++ b/packages/geo/src/lib/import-export/import-export/import-export.component.ts @@ -36,6 +36,12 @@ import { import { StyleService } from '../../layer/shared/style.service'; import { StyleListService } from '../style-list/style-list.service'; import { skipWhile } from 'rxjs/operators'; +import { EntityRecord, Workspace } from '@igo2/common'; +import type { WorkspaceStore } from '@igo2/common'; +import { WfsWorkspace } from '../../workspace/shared/wfs-workspace'; +import { FeatureWorkspace } from '../../workspace/shared/feature-workspace'; +import { MatSlideToggleChange } from '@angular/material/slide-toggle'; +import { MatSelectChange } from '@angular/material/select'; @Component({ selector: 'igo-import-export', @@ -76,6 +82,11 @@ export class ImportExportComponent implements OnDestroy, OnInit { @Input() map: IgoMap; + /** + * Store that holds the available workspaces. + */ + @Input() store: WorkspaceStore; + @Input() selectedMode = 'import'; @Output() selectMode = new EventEmitter(); @@ -86,6 +97,13 @@ export class ImportExportComponent implements OnDestroy, OnInit { @Output() exportOptionsChange = new EventEmitter(); + get layers() { + return this.form.get('layers').value; + } + set layers(value) { + this.form.patchValue({ layers: value }); + } + constructor( private importService: ImportService, private exportService: ExportService, @@ -124,18 +142,18 @@ export class ImportExportComponent implements OnDestroy, OnInit { .pipe(skipWhile((exportOptions) => !exportOptions)) .subscribe((exportOptions) => { this.form.patchValue(exportOptions, { emitEvent: true }); - if (exportOptions.layer) { + if (exportOptions.layers) { this.computeFormats( - exportOptions.layer.map((l) => this.map.getLayerById(l)) + exportOptions.layers.map((l) => this.map.getLayerById(l)) ); } }); this.formLayer$$ = this.form - .get('layer') - .valueChanges.subscribe((layerId) => { + .get('layers') + .valueChanges.subscribe((layersId) => { this.handlePreviousLayerSpecs(); - const layers = layerId.map((l) => this.map.getLayerById(l)); + const layers = layersId.map((l) => this.map.getLayerById(l)); this.computeFormats(layers); if ( @@ -194,7 +212,7 @@ export class ImportExportComponent implements OnDestroy, OnInit { .pipe(skipWhile((layers) => !layers)) .subscribe((layers) => { if (layers.length === 1) { - this.form.patchValue({ layer: layers[0].id }); + this.form.patchValue({ layers: layers[0].id }); } }); @@ -208,6 +226,52 @@ export class ImportExportComponent implements OnDestroy, OnInit { }); } + private getWorkspaceByLayerId(id: string): Workspace { + const wksFromLayerId = this.store + .all() + .find(workspace => (workspace as WfsWorkspace | FeatureWorkspace).layer.id === id); + if (wksFromLayerId) { + return wksFromLayerId; + } + return; + } + + public getLayerTitleById(id): string { + return this.map.getLayerById(id).title; + } + + + layerHasSelectedFeatures(layer: Layer): boolean { + const wksFromLayer = this.getWorkspaceByLayerId(layer.id); + if (wksFromLayer) { + const recs = wksFromLayer.entityStore.stateView + .firstBy((record: EntityRecord) => { + return record.state.selected === true; + }); + return recs ? true : false; + } + } + + public onlySelected(event: MatSlideToggleChange, id: string) { + let layersWithSelection = this.form.value.layersWithSelection; + if (event.checked) { + layersWithSelection.push(id); + } else { + layersWithSelection = layersWithSelection.filter(layerId => layerId !== id); + } + this.form.patchValue({ layersWithSelection }); + } + + public onlySelectedClick(event, id: string) { + if (this.form.value.layers.find(layerId => layerId === id)) { + event.stopPropagation(); + } + } + + public inLayersIdToExportSelectionOnly(layer: Layer): boolean { + return this.form.value.layersWithSelection.find(layerId => layerId === layer.id) ? true : false; + } + ngOnDestroy() { this.layers$$.unsubscribe(); this.exportableLayers$$.unsubscribe(); @@ -254,7 +318,7 @@ export class ImportExportComponent implements OnDestroy, OnInit { handleExportFormSubmit(data: ExportOptions) { this.loading$.next(true); - data.layer.forEach((layer) => { + data.layers.forEach((layer) => { const lay = this.map.getLayerById(layer); let filename = lay.title; if (data.name !== undefined) { @@ -274,18 +338,40 @@ export class ImportExportComponent implements OnDestroy, OnInit { return; } + const wks = this.getWorkspaceByLayerId(layer); let olFeatures; - if (data.featureInMapExtent) { - olFeatures = lay.dataSource.ol.getFeaturesInExtent( - lay.map.viewController.getExtent() - ); - } else { - olFeatures = lay.dataSource.ol.getFeatures(); + if (wks && wks.entityStore && wks.entityStore.stateView.all().length ) { + + if (data.layersWithSelection.indexOf(layer) !== -1 && data.featureInMapExtent) { + // Only export selected feature && into map extent + olFeatures = wks.entityStore.stateView.all() + .filter((e: EntityRecord) => e.state.inMapExtent && e.state.selected).map(e => (e.entity as Feature).ol); + } else if (data.layersWithSelection.indexOf(layer) !== -1 && !data.featureInMapExtent ) { + // Only export selected feature && (into map extent OR not) + olFeatures = wks.entityStore.stateView.all() + .filter((e: EntityRecord) => e.state.selected).map(e => (e.entity as Feature).ol); + } else if (data.featureInMapExtent) { + // Only into map extent + olFeatures = wks.entityStore.stateView.all() + .filter((e: EntityRecord) => e.state.inMapExtent).map(e => (e.entity as Feature).ol); + } else { + // All features + olFeatures = wks.entityStore.stateView.all().map(e => (e.entity as Feature).ol); + } } - if (lay.dataSource instanceof ClusterDataSource) { - olFeatures = olFeatures.flatMap((cluster: any) => - cluster.get('features') - ); + else { + if (data.featureInMapExtent) { + olFeatures = lay.dataSource.ol.getFeaturesInExtent( + lay.map.viewController.getExtent() + ); + } else { + olFeatures = lay.dataSource.ol.getFeatures(); + } + if (lay.dataSource instanceof ClusterDataSource) { + olFeatures = olFeatures.flatMap((cluster: any) => + cluster.get('features') + ); + } } this.exportService @@ -305,7 +391,8 @@ export class ImportExportComponent implements OnDestroy, OnInit { if (this.forceNaming) { this.form = this.formBuilder.group({ format: ['', [Validators.required]], - layer: ['', [Validators.required]], + layers: [[], [Validators.required]], + layersWithSelection: [[]], encoding: [EncodingFormat.UTF8, [Validators.required]], featureInMapExtent: [false, [Validators.required]], name: ['', [Validators.required]] @@ -313,7 +400,8 @@ export class ImportExportComponent implements OnDestroy, OnInit { } else { this.form = this.formBuilder.group({ format: ['', [Validators.required]], - layer: ['', [Validators.required]], + layers: [[], [Validators.required]], + layersWithSelection: [[]], encoding: [EncodingFormat.UTF8, [Validators.required]], featureInMapExtent: [false, [Validators.required]] }); diff --git a/packages/geo/src/lib/import-export/shared/export.interface.ts b/packages/geo/src/lib/import-export/shared/export.interface.ts index cf31f5585e..884237eb32 100644 --- a/packages/geo/src/lib/import-export/shared/export.interface.ts +++ b/packages/geo/src/lib/import-export/shared/export.interface.ts @@ -3,7 +3,8 @@ import { ExportFormat, EncodingFormat } from './export.type'; export interface ExportOptions { format?: ExportFormat; encoding?: EncodingFormat; - layer: string[]; + layers: string[]; + layersWithSelection?: string[]; name?: string; featureInMapExtent?: boolean; } diff --git a/packages/geo/src/lib/layer/layer-legend/layer-legend.component.ts b/packages/geo/src/lib/layer/layer-legend/layer-legend.component.ts index 4bc20d20f6..d17387e842 100644 --- a/packages/geo/src/lib/layer/layer-legend/layer-legend.component.ts +++ b/packages/geo/src/lib/layer/layer-legend/layer-legend.component.ts @@ -165,7 +165,7 @@ export class LayerLegendComponent implements OnInit, OnDestroy { if (layerOptions && layerOptions.legendOptions) { const translate = this.languageService.translate; const title = translate.instant('igo.geo.layer.legend.default'); - let stylesAvailable = [{ name: '', title } as ItemStyleOptions]; + const stylesAvailable = [{ name: '', title } as ItemStyleOptions]; if (layerOptions.legendOptions.stylesAvailable) { stylesAvailable.concat(layerOptions.legendOptions.stylesAvailable.filter(sA => ( sA.name.normalize('NFD').replace(/[\u0300-\u036f]/gi, '') !== 'default' && diff --git a/packages/geo/src/lib/query/shared/query.directive.ts b/packages/geo/src/lib/query/shared/query.directive.ts index 908c56612e..07b2607f92 100644 --- a/packages/geo/src/lib/query/shared/query.directive.ts +++ b/packages/geo/src/lib/query/shared/query.directive.ts @@ -1,4 +1,4 @@ -import { getEntityTitle } from '@igo2/common'; + import { Directive, Input, @@ -10,12 +10,13 @@ import { } from '@angular/core'; import { Subscription, Observable, of, zip } from 'rxjs'; +import { unByKey } from 'ol/Observable'; import OlFeature from 'ol/Feature'; import OlRenderFeature from 'ol/render/Feature'; import OlLayer from 'ol/layer/Layer'; -import OlDragBoxInteraction from 'ol/interaction/DragBox'; +import { DragBoxEvent as OlDragBoxEvent } from 'ol/interaction/DragBox'; import { MapBrowserPointerEvent as OlMapBrowserPointerEvent } from 'ol/MapBrowserEvent'; import { ListenerFunction } from 'ol/events'; @@ -27,6 +28,9 @@ import { featureFromOl } from '../../feature/shared/feature.utils'; import { QueryService } from './query.service'; import { layerIsQueryable, olLayerIsQueryable } from './query.utils'; import { AnyLayer } from '../../layer/shared/layers/any-layer'; +import { ctrlKeyDown } from '../../map/shared/map.utils'; +import { OlDragSelectInteraction } from '../../feature/shared/strategies/selection'; +import { VectorLayer } from '../../layer/shared/layers/vector-layer'; /** * This directive makes a map queryable with a click of with a drag box. @@ -50,12 +54,12 @@ export class QueryDirective implements AfterViewInit, OnDestroy { /** * OL drag box interaction */ - private olDragBoxInteraction: OlDragBoxInteraction; + private olDragSelectInteraction: OlDragSelectInteraction; /** * Ol drag box "end" event key */ - private olDragBoxInteractionEndKey: string; + private olDragSelectInteractionEndKey: string; /** * Whter to query features or not @@ -104,6 +108,7 @@ export class QueryDirective implements AfterViewInit, OnDestroy { */ ngAfterViewInit() { this.listenToMapClick(); + this.addDragBoxInteraction(); } /** @@ -113,6 +118,7 @@ export class QueryDirective implements AfterViewInit, OnDestroy { ngOnDestroy() { this.cancelOngoingQueries(); this.unlistenToMapClick(); + this.removeDragBoxInteraction(); } /** @@ -187,59 +193,82 @@ export class QueryDirective implements AfterViewInit, OnDestroy { ): Observable { const clickedFeatures = []; - this.map.ol.forEachFeatureAtPixel( - event.pixel, - (featureOL: OlFeature, layerOL: OlLayer) => { - if (featureOL) { - if (featureOL.get('features')) { - for (const feature of featureOL.get('features')) { - const newFeature = featureFromOl(feature, this.map.projection); + if (event.type === 'singleclick') { + this.map.ol.forEachFeatureAtPixel( + event.pixel, + (featureOL: OlFeature, layerOL: OlLayer) => { + const layer = this.map.getLayerById(layerOL.values_._layer.id); + if (featureOL) { + if (featureOL.get('features')) { + for (const feature of featureOL.get('features')) { + const newFeature = featureFromOl(feature, this.map.projection); + newFeature.meta = { + title: feature.values_.nom, + id: layerOL.values_._layer.id + '.' + feature.id_, + icon: feature.values_._icon, + sourceTitle: layerOL.values_.title, + alias: this.queryService.getAllowedFieldsAndAlias(layer), + // title: this.queryService.getQueryTitle(newFeature, layer) || newFeature.meta.title + }; + clickedFeatures.push(newFeature); + } + } else if (featureOL instanceof OlRenderFeature) { + const newFeature = renderFeatureFromOl( + featureOL, + this.map.projection, + layerOL + ); + newFeature.meta = { + id: layerOL.values_._layer.id + '.' + newFeature.meta.id, + sourceTitle: layerOL.values_.title, + alias: this.queryService.getAllowedFieldsAndAlias(layer), + title: this.queryService.getQueryTitle(newFeature, layer) || newFeature.meta.title + }; + clickedFeatures.push(newFeature); + } else { + const newFeature = featureFromOl( + featureOL, + this.map.projection, + layerOL + ); newFeature.meta = { - title: feature.values_.nom, - id: feature.id_, - icon: feature.values_._icon, - sourceTitle: layerOL.values_.title + id: layerOL.values_._layer.id + '.' + newFeature.meta.id, + sourceTitle: layerOL.values_.title, + alias: this.queryService.getAllowedFieldsAndAlias(layer), + title: this.queryService.getQueryTitle(newFeature, layer) || newFeature.meta.title }; clickedFeatures.push(newFeature); } - } else if (featureOL instanceof OlRenderFeature) { - const featureFromRender: OlFeature = featureOL; - const feature = renderFeatureFromOl( - featureOL, - this.map.projection, - layerOL - ); - clickedFeatures.push(feature); - } else { - const feature = featureFromOl( - featureOL, - this.map.projection, - layerOL - ); - clickedFeatures.push(feature); } + }, + { + hitTolerance: this.queryFeaturesHitTolerance || 0, + layerFilter: this.queryFeaturesCondition + ? this.queryFeaturesCondition + : olLayerIsQueryable } - }, - { - hitTolerance: this.queryFeaturesHitTolerance || 0, - layerFilter: this.queryFeaturesCondition - ? this.queryFeaturesCondition - : olLayerIsQueryable - } - ); + ); + } else if (event.type === 'boxend') { + const dragExtent = event.target.getGeometry().getExtent(); + this.map.layers + .filter(layerIsQueryable) + .filter(layer => layer instanceof VectorLayer && layer.visible) + .map(layer => { + const featuresOL = layer.dataSource.ol; + featuresOL.forEachFeatureIntersectingExtent(dragExtent, (olFeature: OlFeature) => { + const newFeature: Feature = featureFromOl(olFeature, this.map.projection, layer.ol); + newFeature.meta = { + id: layer.id + '.' + olFeature.id_, + icon: olFeature.values_._icon, + sourceTitle: layer.title, + alias: this.queryService.getAllowedFieldsAndAlias(layer), + title: this.queryService.getQueryTitle(newFeature, layer) || newFeature.meta.title + }; + clickedFeatures.push(newFeature); + }); + }); + } - const queryableLayers = this.map.layers.filter(layerIsQueryable); - clickedFeatures.forEach((feature: Feature) => { - queryableLayers.forEach((layer: AnyLayer) => { - if (typeof layer.ol.getSource().hasFeature !== 'undefined') { - if (layer.ol.getSource().hasFeature(feature.ol)) { - feature.meta.alias = this.queryService.getAllowedFieldsAndAlias(layer); - feature.meta.title = feature.meta.title || this.queryService.getQueryTitle(feature, layer); - feature.meta.sourceTitle = layer.title; - } - } - }); - }); return of(clickedFeatures); } @@ -251,4 +280,48 @@ export class QueryDirective implements AfterViewInit, OnDestroy { this.queries$$.forEach((sub: Subscription) => sub.unsubscribe()); this.queries$$ = []; } + + /** + * Add a drag box interaction and, on drag box end, select features + */ + private addDragBoxInteraction() { + let olDragSelectInteractionOnQuery; + const olInteractions = this.map.ol.getInteractions().getArray(); + + // There can only be one dragbox interaction, so find the current one, if any + // Don't keep a reference to the current dragbox because we don't want + // to remove it when this startegy is deactivated + for (const olInteraction of olInteractions) { + if (olInteraction instanceof OlDragSelectInteraction) { + olDragSelectInteractionOnQuery = olInteraction; + break; + } + } + // If no drag box interaction is found, create a new one and add it to the map + if (olDragSelectInteractionOnQuery === undefined) { + olDragSelectInteractionOnQuery = new OlDragSelectInteraction({ + condition: ctrlKeyDown + }); + this.map.ol.addInteraction(olDragSelectInteractionOnQuery); + this.olDragSelectInteraction = olDragSelectInteractionOnQuery; + } + + this.olDragSelectInteractionEndKey = olDragSelectInteractionOnQuery.on( + 'boxend', + (event: OlMapBrowserPointerEvent) => this.onMapEvent(event) + ); + } + + /** + * Remove drag box interaction + */ + private removeDragBoxInteraction() { + if (this.olDragSelectInteractionEndKey !== undefined) { + unByKey(this.olDragSelectInteractionEndKey); + } + if (this.olDragSelectInteraction !== undefined) { + this.map.ol.removeInteraction(this.olDragSelectInteraction); + } + this.olDragSelectInteraction = undefined; + } } diff --git a/packages/geo/src/lib/search/search-results/search-results-add-button.component.ts b/packages/geo/src/lib/search/search-results/search-results-add-button.component.ts index 96c016b727..81dff4c140 100644 --- a/packages/geo/src/lib/search/search-results/search-results-add-button.component.ts +++ b/packages/geo/src/lib/search/search-results/search-results-add-button.component.ts @@ -34,6 +34,8 @@ export class SearchResultAddButtonComponent implements OnInit, OnDestroy { private lastTimeoutRequest; + private mouseInsideAdd: boolean = false; + @Input() layer: SearchResult; /** @@ -94,6 +96,9 @@ export class SearchResultAddButtonComponent implements OnInit, OnDestroy { clearTimeout(this.lastTimeoutRequest); } + if (event.type === 'mouseenter' && this.mouseInsideAdd ) { + return; + } switch (event.type) { case 'click': if (!this.isPreview$.value) { @@ -112,12 +117,14 @@ export class SearchResultAddButtonComponent implements OnInit, OnDestroy { this.isPreview$.next(true); }, 500); } + this.mouseInsideAdd = true; break; case 'mouseleave': if (this.isPreview$.value) { this.remove(); this.isPreview$.next(false); } + this.mouseInsideAdd = false; break; default: break; diff --git a/packages/geo/src/lib/utils/commonVectorStyle.ts b/packages/geo/src/lib/utils/commonVectorStyle.ts new file mode 100644 index 0000000000..44a03da6e3 --- /dev/null +++ b/packages/geo/src/lib/utils/commonVectorStyle.ts @@ -0,0 +1,72 @@ +import * as olstyle from 'ol/style'; +import olFeature from 'ol/Feature'; + +import { Feature } from '../feature/shared/feature.interfaces'; +import { createOverlayMarkerStyle } from '../overlay/shared/overlay-marker-style.utils'; +import { createOverlayDefaultStyle } from '../overlay/shared/overlay.utils'; + + +/** + * Generate a style for selected features + * @param feature The feature to generate the style + * @returns A olStyle + */ +export function getSelectedMarkerStyle(feature: Feature | olFeature): olstyle.Style { + + const baseColor = [0, 255, 255]; + const strokeWidth = 4; + + const isOlFeature = feature instanceof olFeature; + const geometry = isOlFeature ? feature.getGeometry() : feature.geometry; + const geometryType = isOlFeature ? geometry.getType() : geometry.type; + + if (!geometry || geometryType === 'Point') { + return createOverlayMarkerStyle({ + text: isOlFeature ? undefined : feature.meta.mapTitle, + outlineColor: baseColor + }); + } else { + return createOverlayDefaultStyle({ + text: isOlFeature ? undefined : feature.meta.mapTitle, + strokeWidth, + strokeColor: baseColor + }); + } +} + +/** + * Generate a basic style for features + * @param feature The feature to generate the style + * @returns A olStyle + */ +export function getMarkerStyle(feature: Feature | olFeature): olstyle.Style { + + const baseColor = [0, 255, 255]; + + const isOlFeature = feature instanceof olFeature; + const geometry = isOlFeature ? feature.getGeometry() : feature.geometry; + const geometryType = isOlFeature ? geometry.getType() : geometry.type; + + if (!geometry || geometryType === 'Point') { + return createOverlayMarkerStyle({ + text: isOlFeature ? undefined : feature.meta.mapTitle, + opacity: 0.5, + outlineColor: baseColor + }); + } else if ( + geometryType === 'LineString' || + geometryType === 'MultiLineString' + ) { + return createOverlayDefaultStyle({ + text: isOlFeature ? undefined : feature.meta.mapTitle, + strokeOpacity: 0.5, + strokeColor: baseColor + }); + } else { + return createOverlayDefaultStyle({ + text: isOlFeature ? undefined : feature.meta.mapTitle, + fillOpacity: 0.15, + strokeColor: baseColor + }); + } +} diff --git a/packages/geo/src/lib/utils/index.ts b/packages/geo/src/lib/utils/index.ts index 0b355a8e9a..a87e84b75e 100644 --- a/packages/geo/src/lib/utils/index.ts +++ b/packages/geo/src/lib/utils/index.ts @@ -1,3 +1,4 @@ export * from './googleLinks'; export * from './id-generator'; +export * from './commonVectorStyle'; export * from './osmLinks'; diff --git a/packages/geo/src/lib/workspace/index.ts b/packages/geo/src/lib/workspace/index.ts index 5d12517e4f..a2897d2d53 100644 --- a/packages/geo/src/lib/workspace/index.ts +++ b/packages/geo/src/lib/workspace/index.ts @@ -1,4 +1,6 @@ export * from './shared'; export * from './workspace-selector/workspace-selector.directive'; export * from './workspace-selector/workspace-selector.module'; +export * from './workspace-updator/workspace-updator.directive'; +export * from './workspace-updator/workspace-updator.module'; export * from './widgets/index'; diff --git a/packages/geo/src/lib/workspace/shared/feature-workspace.service.ts b/packages/geo/src/lib/workspace/shared/feature-workspace.service.ts new file mode 100644 index 0000000000..b869af28a8 --- /dev/null +++ b/packages/geo/src/lib/workspace/shared/feature-workspace.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@angular/core'; + +import { + ActionStore, + EntityTableTemplate, + EntityStoreFilterCustomFuncStrategy, + EntityRecord, + EntityStoreStrategyFuncOptions, + EntityStoreFilterSelectionStrategy +} from '@igo2/common'; + +import { + FeatureStore, + FeatureStoreLoadingLayerStrategy, + FeatureStoreSelectionStrategy, + FeatureStoreInMapExtentStrategy, + Feature, + FeatureMotion +} from '../../feature'; +import { VectorLayer } from '../../layer'; +import { IgoMap } from '../../map'; +import { SourceFieldsOptionsParams, FeatureDataSource } from '../../datasource'; + +import { FeatureWorkspace } from './feature-workspace'; +import { skipWhile, take } from 'rxjs/operators'; +import { StorageService, StorageScope } from '@igo2/core'; + +@Injectable({ + providedIn: 'root' +}) +export class FeatureWorkspaceService { + + + get zoomAuto(): boolean { + return this.storageService.get('zoomAuto') as boolean; + } + + constructor(private storageService: StorageService) {} + + createWorkspace(layer: VectorLayer, map: IgoMap): FeatureWorkspace { + const wks = new FeatureWorkspace({ + id: layer.id, + title: layer.title, + layer, + map, + entityStore: this.createFeatureStore(layer, map), + actionStore: new ActionStore([]), + meta: { + tableTemplate: undefined + } + }); + this.createTableTemplate(wks, layer); + return wks; + + } + + private createFeatureStore(layer: VectorLayer, map: IgoMap): FeatureStore { + const store = new FeatureStore([], {map}); + store.bindLayer(layer); + + const loadingStrategy = new FeatureStoreLoadingLayerStrategy({}); + const inMapExtentStrategy = new FeatureStoreInMapExtentStrategy({}); + const selectedRecordStrategy = new EntityStoreFilterSelectionStrategy({}); + const selectionStrategy = new FeatureStoreSelectionStrategy({ + layer: new VectorLayer({ + zIndex: 300, + source: new FeatureDataSource(), + style: undefined, + showInLayerList: false, + exportable: false, + browsable: false + }), + map, + hitTolerance: 15, + motion: this.zoomAuto ? FeatureMotion.Default : FeatureMotion.None, + many: true, + dragBox: true + }); + this.storageService.set('rowsInMapExtent', true, StorageScope.SESSION); + store.addStrategy(loadingStrategy, true); + store.addStrategy(inMapExtentStrategy, true); + store.addStrategy(selectionStrategy, true); + store.addStrategy(selectedRecordStrategy, false); + store.addStrategy(this.createFilterInMapExtentStrategy(), true); + return store; + } + + private createTableTemplate(workspace: FeatureWorkspace, layer: VectorLayer): EntityTableTemplate { + const fields = layer.dataSource.options.sourceFields || []; + + if (fields.length === 0) { + workspace.entityStore.entities$.pipe( + skipWhile(val => val.length === 0), + take(1) + ).subscribe(entities => { + const columnsFromFeatures = (entities[0] as Feature).ol.getKeys() + .filter( + col => !col.startsWith('_') && + col !== 'geometry' && + col !== (entities[0] as Feature).ol.getGeometryName() && + !col.match(/boundedby/gi)) + .map(key => { + return { + name: `properties.${key}`, + title: key + }; + }); + workspace.meta.tableTemplate = { + selection: true, + sort: true, + columns: columnsFromFeatures + }; + }); + return; + } + const columns = fields.map((field: SourceFieldsOptionsParams) => { + return { + name: `properties.${field.name}`, + title: field.alias ? field.alias : field.name + }; + }); + workspace.meta.tableTemplate = { + selection: true, + sort: true, + columns + }; + } + + private createFilterInMapExtentStrategy(): EntityStoreFilterCustomFuncStrategy { + const filterClauseFunc = (record: EntityRecord) => { + return record.state.inMapExtent === true; + }; + return new EntityStoreFilterCustomFuncStrategy({filterClauseFunc} as EntityStoreStrategyFuncOptions); + } +} diff --git a/packages/geo/src/lib/workspace/shared/feature-workspace.ts b/packages/geo/src/lib/workspace/shared/feature-workspace.ts new file mode 100644 index 0000000000..805eb6a1f4 --- /dev/null +++ b/packages/geo/src/lib/workspace/shared/feature-workspace.ts @@ -0,0 +1,23 @@ +import { + Workspace, + WorkspaceOptions +} from '@igo2/common'; + +import { VectorLayer } from '../../layer'; +import { IgoMap } from '../../map'; + +export interface FeatureWorkspaceOptions extends WorkspaceOptions { + layer: VectorLayer; + map: IgoMap; +} + +export class FeatureWorkspace extends Workspace { + + get layer(): VectorLayer { return this.options.layer; } + + get map(): IgoMap { return this.options.map; } + + constructor(protected options: FeatureWorkspaceOptions) { + super(options); + } +} diff --git a/packages/geo/src/lib/workspace/shared/index.ts b/packages/geo/src/lib/workspace/shared/index.ts index 1af7adc41f..b6d089d46c 100644 --- a/packages/geo/src/lib/workspace/shared/index.ts +++ b/packages/geo/src/lib/workspace/shared/index.ts @@ -1,3 +1,5 @@ export * from './wfs-workspace'; -export * from './wfs-actions.service'; export * from './wfs-workspace.service'; +export * from './feature-workspace'; +export * from './feature-workspace.service'; +export * from './workspace.utils'; diff --git a/packages/geo/src/lib/workspace/shared/wfs-actions.service.ts b/packages/geo/src/lib/workspace/shared/wfs-actions.service.ts deleted file mode 100644 index cbf9bc8947..0000000000 --- a/packages/geo/src/lib/workspace/shared/wfs-actions.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Inject, Injectable } from '@angular/core'; - -import { Action, Widget } from '@igo2/common'; - -import { DownloadService } from '../../download'; -import { OgcFilterWidget } from '../widgets'; -import { WfsWorkspace } from './wfs-workspace'; - -@Injectable({ - providedIn: 'root' -}) -export class WfsActionsService { - - constructor( - @Inject(OgcFilterWidget) private ogcFilterWidget: Widget, - private downloadService: DownloadService - ) {} - - buildActions(workspace: WfsWorkspace): Action[] { - return [ - { - id: 'ogcFilter', - icon: 'filter-list', - title: 'igo.geo.workspace.ogcFilter.title', - tooltip: 'igo.geo.workspace.ogcFilter.tooltip', - handler: (widget: Widget, ws: WfsWorkspace) => { - ws.activateWidget(widget, { - map: ws.map, - layer: ws.layer - }); - }, - args: [this.ogcFilterWidget, workspace] - }, - { - id: 'wfsDownload', - icon: 'download', - title: 'igo.geo.workspace.wfsDownload.title', - tooltip: 'igo.geo.workspace.wfsDownload.tooltip', - handler: (ws: WfsWorkspace) => this.downloadService.open(ws.layer), - args: [workspace] - } - ]; - } -} diff --git a/packages/geo/src/lib/workspace/shared/wfs-workspace.service.ts b/packages/geo/src/lib/workspace/shared/wfs-workspace.service.ts index 802b41aac2..2970a3b6a1 100644 --- a/packages/geo/src/lib/workspace/shared/wfs-workspace.service.ts +++ b/packages/geo/src/lib/workspace/shared/wfs-workspace.service.ts @@ -2,29 +2,44 @@ import { Injectable } from '@angular/core'; import { ActionStore, - EntityTableTemplate + EntityTableTemplate, + EntityStoreFilterCustomFuncStrategy, + EntityRecord, + EntityStoreStrategyFuncOptions, + EntityStoreFilterSelectionStrategy } from '@igo2/common'; import { FeatureStore, FeatureStoreLoadingLayerStrategy, - FeatureStoreSelectionStrategy + FeatureStoreSelectionStrategy, + FeatureStoreInMapExtentStrategy, + Feature, + FeatureMotion } from '../../feature'; import { VectorLayer } from '../../layer'; import { IgoMap } from '../../map'; -import { SourceFieldsOptionsParams } from '../../datasource'; +import { SourceFieldsOptionsParams, FeatureDataSource } from '../../datasource'; import { WfsWorkspace } from './wfs-workspace'; +import { skipWhile, take } from 'rxjs/operators'; +import { StorageService, StorageScope } from '@igo2/core'; @Injectable({ providedIn: 'root' }) export class WfsWorkspaceService { - constructor() {} + get zoomAuto(): boolean { + return this.storageService.get('zoomAuto') as boolean; + } + + constructor( + private storageService: StorageService + ) {} - createWorkspace(layer: VectorLayer, map: IgoMap): WfsWorkspace { - return new WfsWorkspace({ + createWorkspace(layer: VectorLayer, map: IgoMap): WfsWorkspace { + const wks = new WfsWorkspace({ id: layer.id, title: layer.title, layer, @@ -32,9 +47,12 @@ export class WfsWorkspaceService { entityStore: this.createFeatureStore(layer, map), actionStore: new ActionStore([]), meta: { - tableTemplate: this.createTableTemplate(layer) + tableTemplate: undefined } }); + this.createTableTemplate(wks, layer); + return wks; + } private createFeatureStore(layer: VectorLayer, map: IgoMap): FeatureStore { @@ -42,30 +60,77 @@ export class WfsWorkspaceService { store.bindLayer(layer); const loadingStrategy = new FeatureStoreLoadingLayerStrategy({}); + const inMapExtentStrategy = new FeatureStoreInMapExtentStrategy({}); + const selectedRecordStrategy = new EntityStoreFilterSelectionStrategy({}); const selectionStrategy = new FeatureStoreSelectionStrategy({ + layer: new VectorLayer({ + zIndex: 300, + source: new FeatureDataSource(), + style: undefined, + showInLayerList: false, + exportable: false, + browsable: false + }), map, - hitTolerance: 5 + hitTolerance: 15, + motion: this.zoomAuto ? FeatureMotion.Default : FeatureMotion.None, + many: true, + dragBox: true }); + this.storageService.set('rowsInMapExtent', true, StorageScope.SESSION); store.addStrategy(loadingStrategy, true); + store.addStrategy(inMapExtentStrategy, true); store.addStrategy(selectionStrategy, true); - + store.addStrategy(selectedRecordStrategy, false); + store.addStrategy(this.createFilterInMapExtentStrategy(), true); return store; } - private createTableTemplate(layer: VectorLayer): EntityTableTemplate { + private createTableTemplate(workspace: WfsWorkspace, layer: VectorLayer): EntityTableTemplate { const fields = layer.dataSource.options.sourceFields || []; + + if (fields.length === 0) { + workspace.entityStore.entities$.pipe( + skipWhile(val => val.length === 0), + take(1) + ).subscribe(entities => { + const columnsFromFeatures = (entities[0] as Feature).ol.getKeys() + .filter( + col => !col.startsWith('_') && + col !== 'geometry' && + col !== (entities[0] as Feature).ol.getGeometryName() && + !col.match(/boundedby/gi)) + .map(key => { + return { + name: `properties.${key}`, + title: key + }; + }); + workspace.meta.tableTemplate = { + selection: true, + sort: true, + columns: columnsFromFeatures + }; + }); + return; + } const columns = fields.map((field: SourceFieldsOptionsParams) => { return { name: `properties.${field.name}`, title: field.alias ? field.alias : field.name }; }); - - return { + workspace.meta.tableTemplate = { selection: true, sort: true, columns }; } + private createFilterInMapExtentStrategy(): EntityStoreFilterCustomFuncStrategy { + const filterClauseFunc = (record: EntityRecord) => { + return record.state.inMapExtent === true; + }; + return new EntityStoreFilterCustomFuncStrategy({filterClauseFunc} as EntityStoreStrategyFuncOptions); + } } diff --git a/packages/geo/src/lib/workspace/shared/workspace.utils.ts b/packages/geo/src/lib/workspace/shared/workspace.utils.ts new file mode 100644 index 0000000000..91284adb28 --- /dev/null +++ b/packages/geo/src/lib/workspace/shared/workspace.utils.ts @@ -0,0 +1,27 @@ +import { WfsWorkspace } from './wfs-workspace'; +import { FeatureWorkspace } from './feature-workspace'; +import { Observable } from 'rxjs'; +import { EntityStoreFilterCustomFuncStrategy, EntityRecord } from '@igo2/common'; +import { map } from 'rxjs/operators'; +import { Feature } from '../../feature/shared/feature.interfaces'; + +export function mapExtentStrategyActiveToolTip(ws: WfsWorkspace | FeatureWorkspace): Observable { + return ws.entityStore.getStrategyOfType(EntityStoreFilterCustomFuncStrategy).active$.pipe( + map((active: boolean) => active ? 'igo.geo.workspace.inMapExtent.active.tooltip' : 'igo.geo.workspace.inMapExtent.inactive.tooltip') + ); +} + +export function featureMotionStrategyActiveToolTip(ws: WfsWorkspace | FeatureWorkspace): Observable { + return ws.entityStore.getStrategyOfType(EntityStoreFilterCustomFuncStrategy).active$.pipe( + map((active: boolean) => active ? 'igo.geo.workspace.zoomAuto.tooltip' : 'igo.geo.workspace.zoomAuto.tooltip') + ); +} + +export function noElementSelected(ws: WfsWorkspace | FeatureWorkspace): Observable { + return ws.entityStore.stateView.manyBy$((record: EntityRecord) => { + return record.state.selected === true; + }).pipe( + map((entities: EntityRecord[]) => entities.length >= 1) + ); +} + diff --git a/packages/geo/src/lib/workspace/widgets/ogc-filter/ogc-filter.component.html b/packages/geo/src/lib/workspace/widgets/ogc-filter/ogc-filter.component.html index 7525f950f5..f10b7733b5 100644 --- a/packages/geo/src/lib/workspace/widgets/ogc-filter/ogc-filter.component.html +++ b/packages/geo/src/lib/workspace/widgets/ogc-filter/ogc-filter.component.html @@ -1,13 +1,6 @@ - - -
- -
+ \ No newline at end of file diff --git a/packages/geo/src/lib/workspace/workspace-selector/workspace-selector.directive.ts b/packages/geo/src/lib/workspace/workspace-selector/workspace-selector.directive.ts index eec24bcd0e..906ac5f927 100644 --- a/packages/geo/src/lib/workspace/workspace-selector/workspace-selector.directive.ts +++ b/packages/geo/src/lib/workspace/workspace-selector/workspace-selector.directive.ts @@ -7,11 +7,13 @@ import { Workspace, WorkspaceStore, WorkspaceSelectorComponent } from '@igo2/com import { Layer, ImageLayer, VectorLayer } from '../../layer'; import { IgoMap } from '../../map'; -import { WFSDataSource, WMSDataSource } from '../../datasource'; +import { WFSDataSource, WMSDataSource, FeatureDataSource } from '../../datasource'; import { OgcFilterableDataSourceOptions } from '../../filter'; import { WfsWorkspaceService } from '../shared/wfs-workspace.service'; -import { WmsWorkspaceService } from '../shared/wms-workspace.service'; +// import { WmsWorkspaceService } from '../shared/wms-workspace.service'; +import { FeatureWorkspaceService } from '../shared/feature-workspace.service'; +import { FeatureStoreInMapExtentStrategy } from '../../feature/shared/strategies/in-map-extent'; @Directive({ selector: '[igoWorkspaceSelector]' @@ -19,6 +21,7 @@ import { WmsWorkspaceService } from '../shared/wms-workspace.service'; export class WorkspaceSelectorDirective implements OnInit, OnDestroy { private layers$$: Subscription; + private entities$$: Subscription[] = []; @Input() map: IgoMap; @@ -29,7 +32,8 @@ export class WorkspaceSelectorDirective implements OnInit, OnDestroy { constructor( private component: WorkspaceSelectorComponent, private wfsWorkspaceService: WfsWorkspaceService, - private wmsWorkspaceService: WmsWorkspaceService + // private wmsWorkspaceService: WmsWorkspaceService, + private featureWorkspaceService: FeatureWorkspaceService ) {} ngOnInit() { @@ -42,6 +46,7 @@ export class WorkspaceSelectorDirective implements OnInit, OnDestroy { ngOnDestroy() { this.layers$$.unsubscribe(); + this.entities$$.map(entities => entities.unsubscribe()); } private onLayersChange(layers: Layer[]) { @@ -61,6 +66,7 @@ export class WorkspaceSelectorDirective implements OnInit, OnDestroy { if (workspacesToRemove.length > 0) { workspacesToRemove.forEach((workspace: Workspace) => { + workspace.entityStore.deactivateStrategyOfType(FeatureStoreInMapExtentStrategy); workspace.deactivate(); }); this.workspaceStore.state.updateMany(workspacesToRemove, {active: false, selected: false}); @@ -78,9 +84,13 @@ export class WorkspaceSelectorDirective implements OnInit, OnDestroy { return; } if (layer.dataSource instanceof WFSDataSource) { - return this.wfsWorkspaceService.createWorkspace(layer as VectorLayer, this.map); - } else if (layer.dataSource instanceof WMSDataSource) { - return this.wmsWorkspaceService.createWorkspace(layer as ImageLayer, this.map); + const wfsWks = this.wfsWorkspaceService.createWorkspace(layer as VectorLayer, this.map); + return wfsWks; + /* } else if (layer.dataSource instanceof WMSDataSource) { + return this.wmsWorkspaceService.createWorkspace(layer as ImageLayer, this.map);*/ + } else if (layer.dataSource instanceof FeatureDataSource && (layer as VectorLayer).exportable === true) { + const featureWks = this.featureWorkspaceService.createWorkspace(layer as VectorLayer, this.map); + return featureWks; } return; @@ -91,7 +101,9 @@ export class WorkspaceSelectorDirective implements OnInit, OnDestroy { if (dataSource instanceof WFSDataSource) { return true; } - + if (dataSource instanceof FeatureDataSource) { + return true; + } if (dataSource instanceof WMSDataSource) { const dataSourceOptions = (dataSource.options || {}) as OgcFilterableDataSourceOptions; diff --git a/packages/geo/src/lib/workspace/workspace-updator/workspace-updator.directive.ts b/packages/geo/src/lib/workspace/workspace-updator/workspace-updator.directive.ts new file mode 100644 index 0000000000..82cfc9064a --- /dev/null +++ b/packages/geo/src/lib/workspace/workspace-updator/workspace-updator.directive.ts @@ -0,0 +1,114 @@ +import { Directive, Input, OnInit, OnDestroy, Output, EventEmitter } from '@angular/core'; + +import { Subscription } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; + +import { Workspace } from '@igo2/common'; +import type { WorkspaceStore } from '@igo2/common'; +import { Layer, ImageLayer, VectorLayer } from '../../layer'; +import { IgoMap } from '../../map'; +import { WFSDataSource, WMSDataSource, FeatureDataSource } from '../../datasource'; +import { OgcFilterableDataSourceOptions } from '../../filter'; + +import { WfsWorkspaceService } from '../shared/wfs-workspace.service'; +// import { WmsWorkspaceService } from '../shared/wms-workspace.service'; +import { FeatureWorkspaceService } from '../shared/feature-workspace.service'; +import { FeatureStoreInMapExtentStrategy } from '../../feature/shared/strategies/in-map-extent'; + +@Directive({ + selector: '[igoWorkspaceUpdator]' +}) +export class WorkspaceUpdatorDirective implements OnInit, OnDestroy { + + private layers$$: Subscription; + private entities$$: Subscription[] = []; + + @Input() map: IgoMap; + + @Input() workspaceStore: WorkspaceStore; + + constructor( + private wfsWorkspaceService: WfsWorkspaceService, + // private wmsWorkspaceService: WmsWorkspaceService, + private featureWorkspaceService: FeatureWorkspaceService + ) {} + + ngOnInit() { + this.layers$$ = this.map.layers$ + .pipe(debounceTime(50)) + .subscribe((layers: Layer[]) => + this.onLayersChange(layers) + ); + } + + ngOnDestroy() { + this.layers$$.unsubscribe(); + this.entities$$.map(entities => entities.unsubscribe()); + } + + private onLayersChange(layers: Layer[]) { + const editableLayers = layers.filter((layer: Layer) => + this.layerIsEditable(layer) + ); + const editableLayersIds = editableLayers.map((layer: Layer) => layer.id); + + const workspacesToAdd = editableLayers + .map((layer: VectorLayer) => this.getOrCreateWorkspace(layer)) + .filter((workspace: Workspace | undefined) => workspace !== undefined); + + const workspacesToRemove = this.workspaceStore.all() + .filter((workspace: Workspace) => { + return editableLayersIds.indexOf(workspace.id) < 0; + }); + + if (workspacesToRemove.length > 0) { + workspacesToRemove.forEach((workspace: Workspace) => { + workspace.entityStore.deactivateStrategyOfType(FeatureStoreInMapExtentStrategy); + workspace.deactivate(); + }); + this.workspaceStore.state.updateMany(workspacesToRemove, {active: false, selected: false}); + this.workspaceStore.deleteMany(workspacesToRemove); + } + + if (workspacesToAdd.length > 0) { + this.workspaceStore.insertMany(workspacesToAdd); + } + } + + private getOrCreateWorkspace(layer: VectorLayer | ImageLayer): Workspace | undefined { + const workspace = this.workspaceStore.get(layer.id); + if (workspace !== undefined) { + return; + } + if (layer.dataSource instanceof WFSDataSource) { + const wfsWks = this.wfsWorkspaceService.createWorkspace(layer as VectorLayer, this.map); + return wfsWks; + /*} else if (layer.dataSource instanceof WMSDataSource) { + return this.wmsWorkspaceService.createWorkspace(layer as ImageLayer, this.map);*/ + } else if (layer.dataSource instanceof FeatureDataSource && (layer as VectorLayer).exportable === true) { + const featureWks = this.featureWorkspaceService.createWorkspace(layer as VectorLayer, this.map); + return featureWks; + } + + return; + } + + private layerIsEditable(layer: Layer): boolean { + const dataSource = layer.dataSource; + if (dataSource instanceof WFSDataSource) { + return true; + } + if (dataSource instanceof FeatureDataSource) { + return true; + } + if (dataSource instanceof WMSDataSource) { + const dataSourceOptions = (dataSource.options || + {}) as OgcFilterableDataSourceOptions; + return ( + dataSourceOptions.ogcFilters && dataSourceOptions.ogcFilters.enabled + ); + } + + return false; + } +} diff --git a/packages/geo/src/lib/workspace/workspace-updator/workspace-updator.module.ts b/packages/geo/src/lib/workspace/workspace-updator/workspace-updator.module.ts new file mode 100644 index 0000000000..8999ae77da --- /dev/null +++ b/packages/geo/src/lib/workspace/workspace-updator/workspace-updator.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { WorkspaceUpdatorDirective } from './workspace-updator.directive'; + +/** + * @ignore + */ +@NgModule({ + imports: [ + CommonModule + ], + exports: [ + WorkspaceUpdatorDirective + ], + declarations: [ + WorkspaceUpdatorDirective + ] +}) +export class IgoWorkspaceUpdatorModule {} diff --git a/packages/geo/src/lib/workspace/workspace.module.ts b/packages/geo/src/lib/workspace/workspace.module.ts index 199e9fc7ef..b8d6179541 100644 --- a/packages/geo/src/lib/workspace/workspace.module.ts +++ b/packages/geo/src/lib/workspace/workspace.module.ts @@ -6,16 +6,19 @@ import { provideOgcFilterWidget } from './widgets/widgets'; import { IgoOgcFilterModule } from './widgets/ogc-filter/ogc-filter.module'; import { IgoWorkspaceSelectorModule } from './workspace-selector/workspace-selector.module'; +import { IgoWorkspaceUpdatorModule } from './workspace-updator/workspace-updator.module'; @NgModule({ imports: [ CommonModule, IgoWidgetModule, IgoWorkspaceSelectorModule, + IgoWorkspaceUpdatorModule, IgoOgcFilterModule ], exports: [ IgoWorkspaceSelectorModule, + IgoWorkspaceUpdatorModule, IgoOgcFilterModule ], declarations: [], diff --git a/packages/geo/src/locale/en.geo.json b/packages/geo/src/locale/en.geo.json index 869d7e50f4..0a3cdbea81 100644 --- a/packages/geo/src/locale/en.geo.json +++ b/packages/geo/src/locale/en.geo.json @@ -91,13 +91,16 @@ "exportTabTitle": "Export", "exportFeatureInExtent": "Export only features in map extent", "exportNoLayersExportable": "There is no exportable layer in your map", + "exportSelectedFeature": "Export only selected features", "importButton": "Import", "importProjPlaceholder": "Coordinate system", "importTabTitle": "Import", "importClarifications": "Clarifications", "importSizeMax": "The file size limit is {{size}} Mb", "importFormatAuthorized": "Formats authorized: geojson, kml, gpx, gml, shapefile", - "importShpZip": "Shapefiles must be zipped." + "importShpZip": "Shapefiles must be zipped.", + "other": "other", + "others": "others" }, "operators": { "caseSensitive": "Case sensitive", @@ -533,8 +536,18 @@ "ogcFilter.close": "Close", "ogcFilter.title": "Filters", "ogcFilter.tooltip": "Apply filters", - "wfsDownload.title": "Download", - "wfsDownload.tooltip": "Download" + "download.title": "Download", + "download.tooltip": "Download", + "inMapExtent.title": "Show records in map extent only", + "inMapExtent.active.tooltip": "Show records in map extent only", + "inMapExtent.inactive.tooltip": "Show all records", + "zoomAuto.title": "Zoom auto", + "zoomAuto.tooltip": "Zoom auto", + "selected.title": "Show selected records only", + "selected.tooltip": "Show selected records only", + "clearSelection.title": "Deselect all", + "clearSelection.tooltip": "Deselect all records" + }, "network": { "online": { diff --git a/packages/geo/src/locale/fr.geo.json b/packages/geo/src/locale/fr.geo.json index ded2135b6c..11e253fa6f 100644 --- a/packages/geo/src/locale/fr.geo.json +++ b/packages/geo/src/locale/fr.geo.json @@ -91,13 +91,16 @@ "exportTabTitle": "Exporter", "exportFeatureInExtent": "Seulement les entités contenues dans la carte", "exportNoLayersExportable": "Aucune couche exportable dans la carte courante", + "exportSelectedFeature": "Seulement les entités sélectionnées", "importButton": "Importer", "importProjPlaceholder": "Système de coordonnées", "importTabTitle": "Importer", "importClarifications": "Précisions", "importSizeMax": "La taille limite du fichier est de {{size}} Mo", "importFormatAuthorized": "Formats autorisés: geojson, kml, gpx, gml, shapefile", - "importShpZip": "Les shapefiles doivent être compressés (zippés)" + "importShpZip": "Les shapefiles doivent être compressés (zippés)", + "other": "autre", + "others": "autres" }, "operators": { "caseSensitive": "Sensible à la case", @@ -534,8 +537,17 @@ "ogcFilter.close": "Fermer", "ogcFilter.title": "Filtres", "ogcFilter.tooltip": "Appliquer des filtres sur la couche", - "wfsDownload.title": "Télécharger les données associées", - "wfsDownload.tooltip": "Télécharger les données associées" + "download.title": "Télécharger les données associées", + "download.tooltip": "Télécharger les données associées", + "inMapExtent.title": "Ne montrer que les enregistrements contenus dans la carte", + "inMapExtent.active.tooltip": "Ne montrer que les enregistrements contenus dans la carte", + "inMapExtent.inactive.tooltip": "Montrer tous les enregistrements", + "zoomAuto.title": "Zoom auto", + "zoomAuto.tooltip": "Zoom auto", + "selected.title": "Ne montrer que les enregistrements sélectionnés", + "selected.tooltip": "Ne montrer que les enregistrements sélectionnés", + "clearSelection.title": "Tout désélectionner", + "clearSelection.tooltip": "Désélectionner les enregistrements sélectionnés" }, "network": { "online": { diff --git a/packages/geo/src/public_api.ts b/packages/geo/src/public_api.ts index 7861a45bbf..9ed0ee927a 100644 --- a/packages/geo/src/public_api.ts +++ b/packages/geo/src/public_api.ts @@ -10,6 +10,12 @@ export * from './lib/datasource/datasource.module'; export * from './lib/directions/directions.module'; export * from './lib/download/download.module'; export * from './lib/workspace/workspace.module'; +// TODO CLEAN UP +// export * from './lib/workspace/shared/wfs-workspace'; +// export * from './lib/workspace/shared/feature-workspace'; +// export * from './lib/workspace/workspace-selector/workspace-selector.module'; +// export * from './lib/workspace/workspace-updator/workspace-updator.module'; +// export * from './lib/workspace/widgets/ogc-filter/ogc-filter.module'; export * from './lib/feature/feature.module'; export * from './lib/feature/feature-form/feature-form.module'; export * from './lib/feature/feature-details/feature-details.module'; diff --git a/packages/integration/src/lib/filter/spatial-filter-tool/spatial-filter-tool.component.html b/packages/integration/src/lib/filter/spatial-filter-tool/spatial-filter-tool.component.html index 5472fb9737..8a93594240 100644 --- a/packages/integration/src/lib/filter/spatial-filter-tool/spatial-filter-tool.component.html +++ b/packages/integration/src/lib/filter/spatial-filter-tool/spatial-filter-tool.component.html @@ -24,7 +24,9 @@ (toggleSearch)="getOutputToggleSearch()" (clearButtonEvent)="layers = $event" (clearSearchEvent)="getOutputClearSearch()" - (export)="activateExportTool()"> + (export)="activateExportTool()" + (openWorkspace)="activateWorkspace()" + > diff --git a/packages/integration/src/lib/filter/spatial-filter-tool/spatial-filter-tool.component.ts b/packages/integration/src/lib/filter/spatial-filter-tool/spatial-filter-tool.component.ts index a80a2ab3c3..e6fe231d15 100644 --- a/packages/integration/src/lib/filter/spatial-filter-tool/spatial-filter-tool.component.ts +++ b/packages/integration/src/lib/filter/spatial-filter-tool/spatial-filter-tool.component.ts @@ -31,6 +31,7 @@ import { ImportExportState } from './../../import-export/import-export.state'; import * as olstyle from 'ol/style'; import { MessageService, LanguageService } from '@igo2/core'; import { ToolState } from '../../tool/tool.state'; +import { WorkspaceState } from '../../workspace/workspace.state'; /** * Tool to apply spatial filter @@ -86,7 +87,8 @@ export class SpatialFilterToolComponent { private messageService: MessageService, private languageService: LanguageService, private importExportState: ImportExportState, - private toolState: ToolState + private toolState: ToolState, + private workspaceState: WorkspaceState ) {} getOutputType(event: SpatialFilterType) { @@ -108,10 +110,16 @@ export class SpatialFilterToolComponent { ids.push(layer.id); } this.importExportState.setMode('export'); - this.importExportState.setsExportOptions({ layer: ids } as ExportOptions); + this.importExportState.setsExportOptions({ layers: ids } as ExportOptions); this.toolState.toolbox.activateTool('importExport'); } + activateWorkspace() { + const layerToOpenWks = this.layers.filter(layer => !layer.title?.startsWith('Zone'))[0]; + this.workspaceState.workspacePanelExpanded = true; + this.workspaceState.setActiveWorkspaceByLayerId(layerToOpenWks.id); + } + private loadFilterList() { this.spatialFilterService .loadFilterList(this.queryType) @@ -242,13 +250,13 @@ export class SpatialFilterToolComponent { ) { return; } - if (layer.title.startsWith('Zone')) { + if (layer.title?.startsWith('Zone')) { this.map.removeLayer(layer); } } } for (const layer of this.map.layers) { - if (layer.title.startsWith('Zone')) { + if (layer.title?.startsWith('Zone')) { i++; } } @@ -316,7 +324,7 @@ export class SpatialFilterToolComponent { return; } for (const layer of this.map.layers) { - if (layer.title.startsWith(features[0].meta.title)) { + if (layer.title?.startsWith(features[0].meta.title)) { i++; } } @@ -392,7 +400,7 @@ export class SpatialFilterToolComponent { return; } for (const layer of this.map.layers) { - if (layer.title.startsWith(features[0].meta.title)) { + if (layer.title?.startsWith(features[0].meta.title)) { i++; } } diff --git a/packages/integration/src/lib/import-export/import-export-tool/import-export-tool.component.html b/packages/integration/src/lib/import-export/import-export-tool/import-export-tool.component.html index d6d1cf6ace..99f780a127 100644 --- a/packages/integration/src/lib/import-export/import-export-tool/import-export-tool.component.html +++ b/packages/integration/src/lib/import-export/import-export-tool/import-export-tool.component.html @@ -1,6 +1,7 @@ - + diff --git a/packages/integration/src/lib/import-export/import-export-tool/import-export-tool.component.ts b/packages/integration/src/lib/import-export/import-export-tool/import-export-tool.component.ts index 001688a75e..dc3d5718fe 100644 --- a/packages/integration/src/lib/import-export/import-export-tool/import-export-tool.component.ts +++ b/packages/integration/src/lib/import-export/import-export-tool/import-export-tool.component.ts @@ -1,10 +1,12 @@ import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core'; import { ToolComponent } from '@igo2/common'; +import type { WorkspaceStore } from '@igo2/common'; import { IgoMap, ExportOptions } from '@igo2/geo'; import { MapState } from '../../map/map.state'; import { ImportExportState } from '../import-export.state'; +import { WorkspaceState } from '../../workspace/workspace.state'; @ToolComponent({ name: 'importExport', @@ -24,11 +26,16 @@ export class ImportExportToolComponent implements OnInit { */ get map(): IgoMap { return this.mapState.map; } + get workspaceStore(): WorkspaceStore { + return this.workspaceState.store; + } + public importExportType$: string = 'layer'; constructor( private mapState: MapState, - public importExportState: ImportExportState + public importExportState: ImportExportState, + private workspaceState: WorkspaceState, ) {} ngOnInit(): void { diff --git a/packages/integration/src/lib/integration.module.ts b/packages/integration/src/lib/integration.module.ts index 52f51753f8..355f63ec2f 100644 --- a/packages/integration/src/lib/integration.module.ts +++ b/packages/integration/src/lib/integration.module.ts @@ -12,11 +12,13 @@ import { IgoAppPrintModule } from './print/print.module'; import { IgoAppSearchModule } from './search/search.module'; import { IgoAppFilterModule } from './filter/filter.module'; import { IgoAppAboutModule } from './about/about.module'; +import { IgoAppStorageModule } from './storage/storage.module'; @NgModule({ imports: [], declarations: [], exports: [ + IgoAppStorageModule, IgoAppAnalyticsModule, IgoAppContextModule, IgoAppCatalogModule, diff --git a/packages/integration/src/lib/map/map-details-tool/map-details-tool.component.html b/packages/integration/src/lib/map/map-details-tool/map-details-tool.component.html index bc6bd04f88..1839ec5bd1 100644 --- a/packages/integration/src/lib/map/map-details-tool/map-details-tool.component.html +++ b/packages/integration/src/lib/map/map-details-tool/map-details-tool.component.html @@ -11,6 +11,7 @@ + diff --git a/packages/integration/src/lib/map/map-details-tool/map-details-tool.component.ts b/packages/integration/src/lib/map/map-details-tool/map-details-tool.component.ts index 21b5ef89d0..ad9a22e623 100644 --- a/packages/integration/src/lib/map/map-details-tool/map-details-tool.component.ts +++ b/packages/integration/src/lib/map/map-details-tool/map-details-tool.component.ts @@ -134,7 +134,7 @@ export class MapDetailsToolComponent implements OnInit { } activateExport(id: string) { - this.importExportState.setsExportOptions({ layer: [id] } as ExportOptions); + this.importExportState.setsExportOptions({ layers: [id] } as ExportOptions); this.importExportState.setMode('export'); this.toolState.toolbox.activateTool('importExport'); } diff --git a/packages/integration/src/lib/map/map-tool/map-tool.component.html b/packages/integration/src/lib/map/map-tool/map-tool.component.html index 16ebdb0483..468a05da91 100644 --- a/packages/integration/src/lib/map/map-tool/map-tool.component.html +++ b/packages/integration/src/lib/map/map-tool/map-tool.component.html @@ -14,6 +14,7 @@ + diff --git a/packages/integration/src/lib/map/map-tool/map-tool.component.ts b/packages/integration/src/lib/map/map-tool/map-tool.component.ts index 2d21679d24..c3d1102586 100644 --- a/packages/integration/src/lib/map/map-tool/map-tool.component.ts +++ b/packages/integration/src/lib/map/map-tool/map-tool.component.ts @@ -76,7 +76,7 @@ export class MapToolComponent { ) {} activateExport(id: string) { - this.importExportState.setsExportOptions({ layer: [id] } as ExportOptions); + this.importExportState.setsExportOptions({ layers: [id] } as ExportOptions); this.importExportState.setMode('export'); this.toolState.toolbox.activateTool('importExport'); } diff --git a/packages/integration/src/lib/map/map-tools/map-tools.component.html b/packages/integration/src/lib/map/map-tools/map-tools.component.html index 7ce136183b..0c34659201 100644 --- a/packages/integration/src/lib/map/map-tools/map-tools.component.html +++ b/packages/integration/src/lib/map/map-tools/map-tools.component.html @@ -18,6 +18,7 @@ + = new BehaviorSubject(undefined); + + constructor( + private toolService: ToolService, + private importExportState: ImportExportState + ) {} + + toolToActivateFromOptions(toolToActivate: { tool: string; options: {[key: string]: any} }) { + if (!toolToActivate) { return; } + if (toolToActivate.tool === 'importExport') { + let exportOptions: ExportOptions = this.importExportState.exportOptions$.value; + if (!exportOptions) { + exportOptions = { + layers: toolToActivate.options.layers, + featureInMapExtent: toolToActivate.options.featureInMapExtent + }; + } else { + exportOptions.layers = toolToActivate.options.layers; + exportOptions.featureInMapExtent = toolToActivate.options.featureInMapExtent; + } + this.importExportState.setsExportOptions(exportOptions); + this.importExportState.setMode('export'); + } + + if (this.toolbox.getTool(toolToActivate.tool)) { + this.toolbox.activateTool(toolToActivate.tool); + this.openSidenav$.next(true); + } + } } diff --git a/packages/integration/src/lib/workspace/index.ts b/packages/integration/src/lib/workspace/index.ts index f112c37bf4..8a3144b688 100644 --- a/packages/integration/src/lib/workspace/index.ts +++ b/packages/integration/src/lib/workspace/index.ts @@ -1 +1,3 @@ +export * from './shared'; +export * from './workspace-button'; export * from './workspace.state'; diff --git a/packages/integration/src/lib/workspace/shared/feature-actions.service.ts b/packages/integration/src/lib/workspace/shared/feature-actions.service.ts new file mode 100644 index 0000000000..5f584850a6 --- /dev/null +++ b/packages/integration/src/lib/workspace/shared/feature-actions.service.ts @@ -0,0 +1,151 @@ +import { Injectable, OnDestroy } from '@angular/core'; + +import { Action, EntityStoreFilterCustomFuncStrategy, EntityStoreFilterSelectionStrategy } from '@igo2/common'; + +import { BehaviorSubject, Subscription } from 'rxjs'; +import { + FeatureWorkspace, + mapExtentStrategyActiveToolTip, + featureMotionStrategyActiveToolTip, + FeatureStoreSelectionStrategy, + FeatureMotion, + noElementSelected, + ExportOptions +} from '@igo2/geo'; +import { StorageService, StorageScope, StorageServiceEvent } from '@igo2/core'; +import { StorageState } from '../../storage/storage.state'; +import { skipWhile } from 'rxjs/operators'; +import { ToolState } from '../../tool/tool.state'; + +@Injectable({ + providedIn: 'root' +}) +export class FeatureActionsService implements OnDestroy { + + zoomAuto$: BehaviorSubject = new BehaviorSubject(false); + private storageChange$$: Subscription; + + + get storageService(): StorageService { + return this.storageState.storageService; + } + + get zoomAuto(): boolean { + return this.storageService.get('zoomAuto') as boolean; + } + + get rowsInMapExtent(): boolean { + return this.storageService.get('rowsInMapExtent') as boolean; + } + + constructor( + private storageState: StorageState, + private toolState: ToolState) {} + + ngOnDestroy(): void { + if (this.storageChange$$) { + this.storageChange$$.unsubscribe(); + } + } + + loadActions(workspace: FeatureWorkspace) { + const actions = this.buildActions(workspace); + workspace.actionStore.load(actions); + } + + buildActions(workspace: FeatureWorkspace): Action[] { + this.zoomAuto$.next(this.zoomAuto); + this.storageChange$$ = this.storageService.storageChange$ + .pipe(skipWhile((storageChange: StorageServiceEvent) => storageChange.key !== 'zoomAuto')) + .subscribe(() => { + this.zoomAuto$.next(this.zoomAuto); + this.handleZoomAuto(workspace); + } + ); + + return [ + { + id: 'zoomAuto', + checkbox: true, + title: 'igo.geo.workspace.zoomAuto.title', + tooltip: featureMotionStrategyActiveToolTip(workspace), + checkCondition: this.zoomAuto$, + handler: () => { + this.handleZoomAuto(workspace); + this.storageService.set('zoomAuto', !this.storageService.get('zoomAuto') as boolean); + } + }, + { + id: 'filterInMapExtent', + checkbox: true, + title: 'igo.geo.workspace.inMapExtent.title', + tooltip: mapExtentStrategyActiveToolTip(workspace), + checkCondition: this.rowsInMapExtent, + handler: () => { + const filterStrategy = workspace.entityStore + .getStrategyOfType(EntityStoreFilterCustomFuncStrategy); + if (filterStrategy.active) { + filterStrategy.deactivate(); + } else { + filterStrategy.activate(); + } + this.storageService + .set( + 'rowsInMapExtent', + !this.storageService.get('rowsInMapExtent') as boolean, + StorageScope.SESSION); + } + }, + { + id: 'selectedOnly', + checkbox: true, + title: 'igo.geo.workspace.selected.title', + tooltip: 'selectedOnly', + checkCondition: false, + handler: () => { + const filterStrategy = workspace.entityStore + .getStrategyOfType(EntityStoreFilterSelectionStrategy); + if (filterStrategy.active) { + filterStrategy.deactivate(); + } else { + filterStrategy.activate(); + } + } + }, + { + id: 'clearselection', + icon: 'select-off', + title: 'igo.geo.workspace.clearSelection.title', + tooltip: 'igo.geo.workspace.clearSelection.tooltip', + handler: (ws: FeatureWorkspace) => { + ws.entityStore.state.updateMany(ws.entityStore.view.all(), { selected: false }); + }, + args: [workspace], + availability: (ws: FeatureWorkspace) => noElementSelected(ws) + }, + { + id: 'featureDownload', + icon: 'download', + title: 'igo.geo.workspace.download.title', + tooltip: 'igo.geo.workspace.download.tooltip', + handler: (ws: FeatureWorkspace) => { + const filterStrategy = ws.entityStore.getStrategyOfType(EntityStoreFilterCustomFuncStrategy); + const filterSelectionStrategy = ws.entityStore.getStrategyOfType(EntityStoreFilterSelectionStrategy); + const layersWithSelection = filterSelectionStrategy.active ? [ws.layer.id] : []; + this.toolState.toolToActivateFromOptions({ + tool: 'importExport', + options: { layers: [ws.layer.id], featureInMapExtent: filterStrategy.active, layersWithSelection } as ExportOptions + }); + }, + args: [workspace] + } + + ]; + } + + private handleZoomAuto(workspace: FeatureWorkspace) { + const zoomStrategy = workspace.entityStore + .getStrategyOfType(FeatureStoreSelectionStrategy) as FeatureStoreSelectionStrategy; + zoomStrategy.setMotion(this.zoomAuto ? FeatureMotion.Default : FeatureMotion.None); + } +} diff --git a/packages/integration/src/lib/workspace/shared/index.ts b/packages/integration/src/lib/workspace/shared/index.ts new file mode 100644 index 0000000000..6f98086f9c --- /dev/null +++ b/packages/integration/src/lib/workspace/shared/index.ts @@ -0,0 +1,3 @@ + +export * from './wfs-actions.service'; +export * from './feature-actions.service'; diff --git a/packages/integration/src/lib/workspace/shared/wfs-actions.service.ts b/packages/integration/src/lib/workspace/shared/wfs-actions.service.ts new file mode 100644 index 0000000000..00c87fdaad --- /dev/null +++ b/packages/integration/src/lib/workspace/shared/wfs-actions.service.ts @@ -0,0 +1,158 @@ +import { Inject, Injectable, OnDestroy } from '@angular/core'; + +import { Action, Widget, EntityStoreFilterCustomFuncStrategy, EntityStoreFilterSelectionStrategy } from '@igo2/common'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { + WfsWorkspace, + mapExtentStrategyActiveToolTip, + featureMotionStrategyActiveToolTip, + FeatureStoreSelectionStrategy, + FeatureMotion, + noElementSelected, + ExportOptions, + OgcFilterWidget +} from '@igo2/geo'; +import { StorageService, StorageScope, StorageServiceEvent } from '@igo2/core'; +import { StorageState } from '../../storage/storage.state'; +import { skipWhile } from 'rxjs/operators'; +import { ToolState } from '../../tool/tool.state'; + +@Injectable({ + providedIn: 'root' +}) +export class WfsActionsService implements OnDestroy { + + zoomAuto$: BehaviorSubject = new BehaviorSubject(false); + private storageChange$$: Subscription; + + get storageService(): StorageService { + return this.storageState.storageService; + } + + get zoomAuto(): boolean { + return this.storageService.get('zoomAuto') as boolean; + } + + get rowsInMapExtent(): boolean { + return this.storageService.get('rowsInMapExtent') as boolean; + } + + constructor( + @Inject(OgcFilterWidget) private ogcFilterWidget: Widget, + private storageState: StorageState, + private toolState: ToolState) {} + + ngOnDestroy(): void { + if (this.storageChange$$) { + this.storageChange$$.unsubscribe(); + } + } + + loadActions(workspace: WfsWorkspace) { + const actions = this.buildActions(workspace); + workspace.actionStore.load(actions); + } + + buildActions(workspace: WfsWorkspace): Action[] { + this.zoomAuto$.next(this.zoomAuto); + this.storageService.storageChange$ + .pipe(skipWhile((storageChange: StorageServiceEvent) => storageChange.key !== 'zoomAuto')) + .subscribe(() => { + this.zoomAuto$.next(this.zoomAuto); + this.handleZoomAuto(workspace); + } + ); + return [ + { + id: 'zoomAuto', + checkbox: true, + title: 'igo.geo.workspace.zoomAuto.title', + tooltip: featureMotionStrategyActiveToolTip(workspace), + checkCondition: this.zoomAuto$, + handler: () => { + this.handleZoomAuto(workspace); + this.storageService.set('zoomAuto', !this.storageService.get('zoomAuto') as boolean); + } + }, + { + id: 'filterInMapExtent', + checkbox: true, + title: 'igo.geo.workspace.inMapExtent.title', + tooltip: mapExtentStrategyActiveToolTip(workspace), + checkCondition: this.rowsInMapExtent, + handler: () => { + const filterStrategy = workspace.entityStore + .getStrategyOfType(EntityStoreFilterCustomFuncStrategy); + if (filterStrategy.active) { + filterStrategy.deactivate(); + } else { + filterStrategy.activate(); + } + this.storageService.set('rowsInMapExtent', !this.storageService.get('rowsInMapExtent') as boolean, StorageScope.SESSION); + } + }, + { + id: 'selectedOnly', + checkbox: true, + title: 'igo.geo.workspace.selected.title', + tooltip: 'selectedOnly', + checkCondition: false, + handler: () => { + const filterStrategy = workspace.entityStore + .getStrategyOfType(EntityStoreFilterSelectionStrategy); + if (filterStrategy.active) { + filterStrategy.deactivate(); + } else { + filterStrategy.activate(); + } + } + }, + { + id: 'clearselection', + icon: 'select-off', + title: 'igo.geo.workspace.clearSelection.title', + tooltip: 'igo.geo.workspace.clearSelection.tooltip', + handler: (ws: WfsWorkspace) => { + ws.entityStore.state.updateMany(ws.entityStore.view.all(), { selected: false }); + }, + args: [workspace], + availability: (ws: WfsWorkspace) => noElementSelected(ws) + }, + { + id: 'wfsDownload', + icon: 'download', + title: 'igo.geo.workspace.download.title', + tooltip: 'igo.geo.workspace.download.tooltip', + handler: (ws: WfsWorkspace) => { + const filterStrategy = ws.entityStore.getStrategyOfType(EntityStoreFilterCustomFuncStrategy); + const filterSelectionStrategy = ws.entityStore.getStrategyOfType(EntityStoreFilterSelectionStrategy); + const layersWithSelection = filterSelectionStrategy.active ? [ws.layer.id] : []; + this.toolState.toolToActivateFromOptions({ + tool: 'importExport', + options: { layers: [ws.layer.id], featureInMapExtent: filterStrategy.active, layersWithSelection } as ExportOptions + }); + }, + args: [workspace] + }, + { + id: 'ogcFilter', + icon: 'filter', + title: 'igo.geo.workspace.ogcFilter.title', + tooltip: 'igo.geo.workspace.ogcFilter.tooltip', + handler: (widget: Widget, ws: WfsWorkspace) => { + ws.activateWidget(widget, { + map: ws.map, + layer: ws.layer + }); + }, + args: [this.ogcFilterWidget, workspace] + }, + ]; + } + + private handleZoomAuto(workspace: WfsWorkspace) { + const zoomStrategy = workspace.entityStore + .getStrategyOfType(FeatureStoreSelectionStrategy) as FeatureStoreSelectionStrategy; + zoomStrategy.setMotion(this.zoomAuto ? FeatureMotion.Default : FeatureMotion.None); + } +} diff --git a/packages/integration/src/lib/workspace/workspace-button/index.ts b/packages/integration/src/lib/workspace/workspace-button/index.ts new file mode 100644 index 0000000000..3b9a771bbc --- /dev/null +++ b/packages/integration/src/lib/workspace/workspace-button/index.ts @@ -0,0 +1 @@ +export * from './workspace-button.component'; diff --git a/packages/integration/src/lib/workspace/workspace-button/workspace-button.component.html b/packages/integration/src/lib/workspace/workspace-button/workspace-button.component.html new file mode 100644 index 0000000000..091a28dc07 --- /dev/null +++ b/packages/integration/src/lib/workspace/workspace-button/workspace-button.component.html @@ -0,0 +1,11 @@ + diff --git a/packages/integration/src/lib/workspace/workspace-button/workspace-button.component.scss b/packages/integration/src/lib/workspace/workspace-button/workspace-button.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/integration/src/lib/workspace/workspace-button/workspace-button.component.ts b/packages/integration/src/lib/workspace/workspace-button/workspace-button.component.ts new file mode 100644 index 0000000000..a5d5665a1f --- /dev/null +++ b/packages/integration/src/lib/workspace/workspace-button/workspace-button.component.ts @@ -0,0 +1,45 @@ +import { Component, Input, ChangeDetectionStrategy, OnInit, OnDestroy } from '@angular/core'; +import { VectorLayer } from '@igo2/geo'; +import type { Layer } from '@igo2/geo'; +import { WorkspaceState } from '../workspace.state'; +import { BehaviorSubject, Subscription } from 'rxjs'; + +@Component({ + selector: 'igo-workspace-button', + templateUrl: './workspace-button.component.html', + styleUrls: ['./workspace-button.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class WorkspaceButtonComponent implements OnInit, OnDestroy { + + public hasWorkspace$: BehaviorSubject = new BehaviorSubject(false); + private hasWorkspace$$: Subscription; + + @Input() layer: Layer; + + @Input() color: string = 'primary'; + + constructor(private workspaceState: WorkspaceState) {} + + ngOnInit(): void { + this.hasWorkspace$$ = this.workspaceState.workspaceEnabled$.subscribe(wksEnabled => + this.hasWorkspace$.next(wksEnabled && this.layer instanceof VectorLayer) + ); + } + + ngOnDestroy(): void { + this.hasWorkspace$$.unsubscribe(); + } + + activateWorkspace() { + if ( + this.workspaceState.workspace$.value && + (this.workspaceState.workspace$.value as any).layer.id === this.layer.id && + this.workspaceState.workspacePanelExpanded) { + this.workspaceState.workspacePanelExpanded = false; + } else { + this.workspaceState.workspacePanelExpanded = true; + this.workspaceState.setActiveWorkspaceByLayerId(this.layer.id); + } + } +} diff --git a/packages/integration/src/lib/workspace/workspace.module.ts b/packages/integration/src/lib/workspace/workspace.module.ts index 9a4b5d0279..d10bcd737e 100644 --- a/packages/integration/src/lib/workspace/workspace.module.ts +++ b/packages/integration/src/lib/workspace/workspace.module.ts @@ -1,8 +1,20 @@ import { NgModule } from '@angular/core'; +import { WorkspaceButtonComponent } from './workspace-button/workspace-button.component'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatIconModule } from '@angular/material/icon'; +import { IgoLanguageModule } from '@igo2/core'; +import { CommonModule } from '@angular/common'; @NgModule({ - imports: [], - declarations: [], - exports: [] + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + MatTooltipModule, + IgoLanguageModule + ], + declarations: [WorkspaceButtonComponent], + exports: [WorkspaceButtonComponent] }) export class IgoAppWorkspaceModule {} diff --git a/packages/integration/src/lib/workspace/workspace.state.ts b/packages/integration/src/lib/workspace/workspace.state.ts index f33aecfb73..7fbfd1af2b 100644 --- a/packages/integration/src/lib/workspace/workspace.state.ts +++ b/packages/integration/src/lib/workspace/workspace.state.ts @@ -1,8 +1,11 @@ -import { Injectable } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; -import { EntityRecord, Workspace, WorkspaceStore } from '@igo2/common'; +import { EntityRecord, Workspace, WorkspaceStore, Widget } from '@igo2/common'; +import { WfsWorkspace, FeatureWorkspace } from '@igo2/geo'; +import { FeatureActionsService } from './shared/feature-actions.service'; +import { WfsActionsService } from './shared/wfs-actions.service'; /** * Service that holds the state of the workspace module @@ -10,7 +13,20 @@ import { EntityRecord, Workspace, WorkspaceStore } from '@igo2/common'; @Injectable({ providedIn: 'root' }) -export class WorkspaceState { +export class WorkspaceState implements OnDestroy { + + public workspacePanelExpanded: boolean = false; + + readonly workspaceEnabled$: BehaviorSubject = new BehaviorSubject(false); + + /** Subscription to the active workspace */ + private activeWorkspace$$: Subscription; + + /** Subscription to the active workspace widget */ + private activeWorkspaceWidget$$: Subscription; + + /** Active widget observable. Only one may be active for all available workspaces */ + readonly activeWorkspaceWidget$: BehaviorSubject = new BehaviorSubject(undefined); /** * Observable of the active workspace @@ -23,7 +39,19 @@ export class WorkspaceState { get store(): WorkspaceStore { return this._store; } private _store: WorkspaceStore; - constructor() { + constructor( + private featureActionsService: FeatureActionsService, + private wfsActionsService: WfsActionsService, + ) { + this.initWorkspaces(); + } + + /** + * Initialize the workspace store. Each time a workspace is activated, + * subscribe to it's active widget. Tracking the active widget is useful + * to make sure only one widget is active at a time. + */ + private initWorkspaces() { this._store = new WorkspaceStore([]); this._store.stateView .firstBy$((record: EntityRecord) => record.state.active === true) @@ -31,6 +59,63 @@ export class WorkspaceState { const workspace = record ? record.entity : undefined; this.workspace$.next(workspace); }); + + this._store.stateView.all$() + .subscribe((workspaces: EntityRecord[]) => { + workspaces.map((wks: EntityRecord) => { + if (wks.entity.actionStore.empty) { + if (wks.entity instanceof WfsWorkspace) { + this.wfsActionsService.loadActions(wks.entity); + } else if (wks.entity instanceof FeatureWorkspace) { + this.featureActionsService.loadActions(wks.entity); + } + } + + }); + }); + + this.activeWorkspace$$ = this.workspace$ + .subscribe((workspace: Workspace) => { + if (this.activeWorkspaceWidget$$ !== undefined) { + this.activeWorkspaceWidget$$.unsubscribe(); + this.activeWorkspaceWidget$$ = undefined; + } + + if (workspace !== undefined) { + this.activeWorkspaceWidget$$ = workspace.widget$ + .subscribe((widget: Widget) => this.activeWorkspaceWidget$.next(widget)); + } + }); + } + + public setActiveWorkspaceByLayerId(id: string) { + const wksFromLayerId = this.store + .all() + .find(workspace => (workspace as WfsWorkspace | FeatureWorkspace).layer.id === id); + if (wksFromLayerId) { + this.store.activateWorkspace(wksFromLayerId); + } + } + + /** + * Teardown all the workspaces + * @internal + */ + ngOnDestroy() { + this.teardownWorkspaces(); + } + + /** + * Teardown the workspace store and any subscribers + */ + private teardownWorkspaces() { + this.store.clear(); + if (this.activeWorkspaceWidget$$ !== undefined) { + this.activeWorkspaceWidget$$.unsubscribe(); + } + if (this.activeWorkspace$$ !== undefined) { + this.activeWorkspace$$.unsubscribe(); + } } } diff --git a/packages/integration/src/locale/en.integration.json b/packages/integration/src/locale/en.integration.json index bddb735496..74c7153d7e 100644 --- a/packages/integration/src/locale/en.integration.json +++ b/packages/integration/src/locale/en.integration.json @@ -35,6 +35,9 @@ "importExportTool": { "importExportData": "Layer", "importExportContext": "Context" + }, + "workspace": { + "toggleWorkspace": "Open/Close the table view" } } } diff --git a/packages/integration/src/locale/fr.integration.json b/packages/integration/src/locale/fr.integration.json index 0d4152a4db..aa219bdcf9 100644 --- a/packages/integration/src/locale/fr.integration.json +++ b/packages/integration/src/locale/fr.integration.json @@ -35,6 +35,9 @@ "importExportTool": { "importExportData": "Couche", "importExportContext": "Contexte" + }, + "workspace": { + "toggleWorkspace": "Ouvrir/fermer la vue tabulaire" } } } diff --git a/packages/integration/src/public_api.ts b/packages/integration/src/public_api.ts index f0f105ed62..1b9cbe3aad 100644 --- a/packages/integration/src/public_api.ts +++ b/packages/integration/src/public_api.ts @@ -5,6 +5,7 @@ export * from './lib/integration.module'; export * from './lib/about/about.module'; export * from './lib/analytics/analytics.module'; +export * from './lib/storage/storage.module'; export * from './lib/context/context.module'; export * from './lib/catalog/catalog.module'; @@ -31,3 +32,4 @@ export * from './lib/measure'; export * from './lib/print'; export * from './lib/search'; export * from './lib/tool'; +export * from './lib/storage';