diff --git a/package.json b/package.json index 7eaa5c9668..890145234c 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "ol": "^5.3.0", "proj4": "^2.5.0", "rxjs": "^6.4.0", + "scroll-into-view-if-needed": "^2.2.20", "stream": "^0.0.2", "ts-md5": "^1.2.4", "tslib": "^1.9.3", diff --git a/packages/common/package.json b/packages/common/package.json index 57dc447402..3c88c71702 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -19,6 +19,7 @@ "@angular/material": "^7.3.3", "@igo2/core": "^1.0.0", "@igo2/utils": "^1.0.0", + "scroll-into-view-if-needed": "^2.2.20", "typy": "^2.0.1" } } \ No newline at end of file diff --git a/packages/common/src/lib/entity/entity-selector/entity-selector.component.html b/packages/common/src/lib/entity/entity-selector/entity-selector.component.html index cb4cb93bd5..7f7b8be3b6 100644 --- a/packages/common/src/lib/entity/entity-selector/entity-selector.component.html +++ b/packages/common/src/lib/entity/entity-selector/entity-selector.component.html @@ -5,11 +5,11 @@ [multiple]="multi" [placeholder]="placeholder" (selectionChange)="onSelectionChange($event)"> - {{emptyText}} + {{emptyText}} {{multiText$ | async}} - - - {{titleAccessor(entity)}} + + + {{titleAccessor(record.entity)}} diff --git a/packages/common/src/lib/entity/entity-selector/entity-selector.component.ts b/packages/common/src/lib/entity/entity-selector/entity-selector.component.ts index e1359a1da9..00db39f6c2 100644 --- a/packages/common/src/lib/entity/entity-selector/entity-selector.component.ts +++ b/packages/common/src/lib/entity/entity-selector/entity-selector.component.ts @@ -28,16 +28,18 @@ export class EntitySelectorComponent implements OnInit, OnDestroy { * The selected entity * @internal */ - selected$ = new BehaviorSubject(undefined); + readonly selected$ = new BehaviorSubject(undefined); /** * The current multi select option text * @internal */ - multiText$ = new BehaviorSubject(undefined); + readonly multiText$ = new BehaviorSubject(undefined); readonly multiSelectValue = {id: 'IGO_MULTI_SELECT'}; + readonly emptyValue = {id: 'IGO_EMPTY_SELECT'}; + /** * Subscription to the selected entity */ @@ -139,6 +141,7 @@ export class EntitySelectorComponent implements OnInit, OnDestroy { } } + entities = entities.filter((entity: object) => entity !== this.emptyValue); if (entities.length === 0) { this.store.state.updateAll({selected: false}); } else { diff --git a/packages/common/src/lib/entity/entity-table/entity-table-row.directive.ts b/packages/common/src/lib/entity/entity-table/entity-table-row.directive.ts index f804f77ab8..34f8d04b52 100644 --- a/packages/common/src/lib/entity/entity-table/entity-table-row.directive.ts +++ b/packages/common/src/lib/entity/entity-table/entity-table-row.directive.ts @@ -8,6 +8,8 @@ import { HostListener } from '@angular/core'; +import scrollIntoView from 'scroll-into-view-if-needed'; + import { EntityTableScrollBehavior } from '../shared/entity.enums'; /** @@ -41,8 +43,7 @@ export class EntityTableRowDirective { /** * Whether the selected row should be highlighted */ - @Input() - highlightSelection: boolean = true; + @Input() highlightSelection: boolean = true; /** * Whether a row is selected @@ -109,8 +110,8 @@ export class EntityTableRowDirective { */ private scroll() { if (this._selected === true) { - this.el.nativeElement.scrollIntoView({ - behavior: this.scrollBehavior, + scrollIntoView(this.el.nativeElement, { + scrollMode: 'if-needed', block: 'center', inline: 'center' }); diff --git a/packages/common/src/lib/entity/entity-table/entity-table.component.html b/packages/common/src/lib/entity/entity-table/entity-table.component.html index fe6b91c57e..197046b5e2 100644 --- a/packages/common/src/lib/entity/entity-table/entity-table.component.html +++ b/packages/common/src/lib/entity/entity-table/entity-table.component.html @@ -4,6 +4,7 @@ matSort [ngClass]="getTableClass()" [dataSource]="dataSource" + [trackBy]="getTrackByFunction()" (matSortChange)="onSort($event)"> @@ -17,10 +18,10 @@ - + + (change)="onToggleRow($event.checked, record)" + [checked]="rowIsSelected(record)"> @@ -40,45 +41,45 @@ - - {{getValue(entity, column)}} + + {{getValue(record, column)}} - + - + - - + + - - + + @@ -95,13 +96,13 @@ + [selected]="rowIsSelected(record)" + (select)="onRowSelect(record)" + (click)="onRowClick(record)"> 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 ce326a5f60..e9ae8fcae2 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 @@ -5,18 +5,16 @@ import { EventEmitter, ChangeDetectionStrategy, ChangeDetectorRef, - SimpleChanges, OnInit, - OnDestroy, - OnChanges + OnDestroy } from '@angular/core'; import { BehaviorSubject, Subscription } from 'rxjs'; import { EntityRecord, + EntityState, EntityStore, - EntityStoreWatcher, EntityTableTemplate, EntityTableColumn, EntityTableColumnRenderer, @@ -30,7 +28,7 @@ import { styleUrls: ['./entity-table.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { +export class EntityTableComponent implements OnInit, OnDestroy { /** * Reference to the column renderer types @@ -39,7 +37,7 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { entityTableColumnRenderer = EntityTableColumnRenderer; /** - * Reference to the selection states + * Reference to the selection's state * @internal */ entityTableSelectionState = EntityTableSelectionState; @@ -48,12 +46,7 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { * Observable of the selection,s state * @internal */ - selectionState$: BehaviorSubject = new BehaviorSubject(undefined); - - /** - * Entity store watcher - */ - private watcher: EntityStoreWatcher; + readonly selectionState$: BehaviorSubject = new BehaviorSubject(undefined); /** * Subscription to the store's selection @@ -114,7 +107,7 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { * Data source consumable by the underlying material table * @internal */ - get dataSource(): BehaviorSubject { return this.store.view.all$(); } + get dataSource(): BehaviorSubject[]> { return this.store.stateView.all$(); } /** * Whether selection is supported @@ -155,28 +148,23 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { } /** - * When the store change, create a new watcher + * Unbind the store watcher * @internal */ - ngOnChanges(changes: SimpleChanges) { - const store = changes.store; - if (store && store.currentValue !== store.previousValue) { - if (this.watcher !== undefined) { - this.watcher.destroy(); - } - this.watcher = new EntityStoreWatcher(this.store, this.cdRef); - } + ngOnDestroy() { + this.selection$$.unsubscribe(); } /** - * Unbind the store watcher + * Trackby function + * @param record Record + * @param index Record index * @internal */ - ngOnDestroy() { - if (this.watcher !== undefined) { - this.watcher.destroy(); - } - this.selection$$.unsubscribe(); + getTrackByFunction() { + return (index: number, record: EntityRecord) => { + return record.ref; + }; } /** @@ -200,36 +188,36 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { .find((c: EntityTableColumn) => c.name === event.active); if (direction === 'asc' || direction === 'desc') { - this.store.view.sort({ - valueAccessor: (entity: object) => this.getValue(entity, column), + this.store.stateView.sort({ + valueAccessor: (record: EntityRecord) => this.getValue(record, column), direction, nullsFirst: this.sortNullsFirst }); } else { - this.store.view.sort(undefined); + this.store.stateView.sort(undefined); } } /** * When an entity is clicked, emit an event - * @param entity Entity + * @param record Record * @internal */ - onRowClick(entity: object) { - this.entityClick.emit(entity); + onRowClick(record: EntityRecord) { + this.entityClick.emit(record.entity); } /** * When an entity is selected, select it in the store and emit an event. Even if * "many" is set to true, this method always select a single, exclusive row. Selecting * multiple rows should be achieved by using the checkboxes. - * @param entity Entity + * @param record Record * @internal */ - onRowSelect(entity: object) { + onRowSelect(record: EntityRecord) { if (this.selection === false) { return; } - // Selecting a + const entity = record.entity; this.store.state.update(entity, {selected: true}, true); this.entitySelectChange.emit({added: [entity]}); } @@ -244,7 +232,10 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { this.store.state.updateAll({selected: toggle}); if (toggle === true) { - this.entitySelectChange.emit({added: [this.store.view.all()]}); + const entities = this.store.stateView + .all() + .map((record: EntityRecord) => record.entity); + this.entitySelectChange.emit({added: [entities]}); } } @@ -252,12 +243,13 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { * When an entity is toggled, select or unselect it in the store. On select, * emit an event. * @param toggle Select or unselect - * @param entity Entity + * @param record Record * @internal */ - onToggleRow(toggle: boolean, entity: object) { + onToggleRow(toggle: boolean, record: EntityRecord) { if (this.selection === false) { return; } + const entity = record.entity; const exclusive = toggle === true && !this.selectMany; this.store.state.update(entity, {selected: toggle}, exclusive); if (toggle === true) { @@ -294,28 +286,29 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { /** * Whether a row is should be selected based on the underlying entity state - * @param entity Entity + * @param record Record * @returns True if a row should be selected * @internal */ - rowIsSelected(entity: object): boolean { - const state = this.store.state.get(entity); + rowIsSelected(record: EntityRecord): boolean { + const state = record.state; return state.selected ? state.selected : false; } /** * Method to access an entity's values - * @param entity Entity + * @param record Record * @param column Column * @returns Any value * @internal */ - getValue(entity: object, column: EntityTableColumn): any { + getValue(record: EntityRecord, column: EntityTableColumn): any { + const entity = record.entity; if (column.valueAccessor !== undefined) { - return column.valueAccessor(entity); + return column.valueAccessor(entity, record); } if (this.template.valueAccessor !== undefined) { - return this.template.valueAccessor(entity, column.name); + return this.template.valueAccessor(entity, column.name, record); } return this.store.getProperty(entity, column.name); } @@ -359,36 +352,38 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { /** * Return a row ngClass - * @param entity Entity + * @param record Record * @returns ngClass * @internal */ - getRowClass(entity: object): {[key: string]: boolean} { + getRowClass(record: EntityRecord): {[key: string]: boolean} { + const entity = record.entity; const func = this.template.rowClassFunc; if (func instanceof Function) { - return func(entity); + return func(entity, record); } return {}; } /** * Return a row ngClass - * @param entity Entity + * @param record Record * @param column Column * @returns ngClass * @internal */ - getCellClass(entity: object, column: EntityTableColumn): {[key: string]: boolean} { + getCellClass(record: EntityRecord, column: EntityTableColumn): {[key: string]: boolean} { + const entity = record.entity; const cls = {}; const tableFunc = this.template.cellClassFunc; if (tableFunc instanceof Function) { - Object.assign(cls, tableFunc(entity, column)); + Object.assign(cls, tableFunc(entity, column, record)); } const columnFunc = column.cellClassFunc; if (columnFunc instanceof Function) { - Object.assign(cls, columnFunc(entity)); + Object.assign(cls, columnFunc(entity, record)); } return cls; @@ -397,12 +392,15 @@ export class EntityTableComponent implements OnInit, OnDestroy, OnChanges { /** * When a button is clicked * @param func Function - * @param entity Entity + * @param record Record * @internal */ - onButtonClick(clickFunc: (entity: object) => void, entity: object) { + onButtonClick( + clickFunc: (entity: object, record?: EntityRecord) => void, + record: EntityRecord + ) { if (typeof clickFunc === 'function') { - clickFunc(entity); + clickFunc(record.entity, record); } } diff --git a/packages/common/src/lib/entity/shared/entity.interfaces.ts b/packages/common/src/lib/entity/shared/entity.interfaces.ts index 887d4b9e22..b62c9d72f6 100644 --- a/packages/common/src/lib/entity/shared/entity.interfaces.ts +++ b/packages/common/src/lib/entity/shared/entity.interfaces.ts @@ -15,6 +15,8 @@ export interface EntityState { export interface EntityRecord { entity: E; state: S; + revision: number; + ref: string; } export interface EntityStoreOptions { @@ -27,6 +29,8 @@ export interface EntityStateManagerOptions { store?: EntityStore; } +export interface EntityStoreStrategyOptions {} + export interface EntityTransactionOptions { getKey?: (entity: object) => EntityKey; } @@ -70,14 +74,14 @@ export interface EntityTableTemplate { selectMany?: boolean; sort?: boolean; fixedHeader?: boolean; - valueAccessor?: (entity: object, property: string) => any; + valueAccessor?: (entity: object, property: string, record: EntityRecord) => any; headerClassFunc?: () => { [key: string]: boolean; }; - rowClassFunc?: (entity: object) => { + rowClassFunc?: (entity: object, record: EntityRecord) => { [key: string]: boolean; }; - cellClassFunc?: (entity: object, column: EntityTableColumn) => { + cellClassFunc?: (entity: object, column: EntityTableColumn, record: EntityRecord) => { [key: string]: boolean; }; } @@ -86,17 +90,17 @@ export interface EntityTableColumn { name: string; title: string; renderer?: EntityTableColumnRenderer; - valueAccessor?: (entity: object) => any; + valueAccessor?: (entity: object, record: EntityRecord) => any; visible?: boolean; sort?: boolean; - cellClassFunc?: (entity: object) => { + cellClassFunc?: (entity: object, record: EntityRecord) => { [key: string]: boolean; }; } export interface EntityTableButton { icon: string; - click: (entity: object) => void; + click: (entity: object, record: EntityRecord) => void; color?: 'primary' | 'accent' | 'warn'; style?: 'mat-mini-fab' | 'mat-icon-button'; } diff --git a/packages/common/src/lib/entity/shared/index.ts b/packages/common/src/lib/entity/shared/index.ts index 374cea84a6..2613ca8984 100644 --- a/packages/common/src/lib/entity/shared/index.ts +++ b/packages/common/src/lib/entity/shared/index.ts @@ -6,3 +6,5 @@ export * from './state'; export * from './watcher'; export * from './transaction'; export * from './view'; + +export * from './strategies'; diff --git a/packages/common/src/lib/entity/shared/state.ts b/packages/common/src/lib/entity/shared/state.ts index 2e04dca70b..355afc3052 100644 --- a/packages/common/src/lib/entity/shared/state.ts +++ b/packages/common/src/lib/entity/shared/state.ts @@ -168,10 +168,19 @@ export class EntityStateManager { const state = this.index.get(key) || {} as S; + if (keys.indexOf(key) >= 0) { this.index.set(key, Object.assign({}, state, changes)); } else { - this.index.set(key, Object.assign({}, state, reverseChanges)); + // Update only if the reverse changes would modify + // a key already present in the current state + const shouldUpdate = Object.keys(reverseChanges).some((changeKey: string) => { + return state[changeKey] !== undefined && + state[changeKey] !== reverseChanges[changeKey]; + }); + if (shouldUpdate === true) { + this.index.set(key, Object.assign({}, state, reverseChanges)); + } } }); diff --git a/packages/common/src/lib/entity/shared/store.ts b/packages/common/src/lib/entity/shared/store.ts index 47e4879a46..e2f564c6fd 100644 --- a/packages/common/src/lib/entity/shared/store.ts +++ b/packages/common/src/lib/entity/shared/store.ts @@ -4,13 +4,14 @@ import { EntityStateManager } from './state'; import { EntityView } from './view'; import { EntityKey, EntityState, EntityRecord, EntityStoreOptions } from './entity.interfaces'; import { getEntityId, getEntityProperty } from './entity.utils'; +import { EntityStoreStrategy } from './strategies/strategy'; /** * An entity store class holds any number of entities * as well as their state. It can be observed, filtered and sorted and * provides methods to insert, update or delete entities. */ -export class EntityStore { +export class EntityStore { /** * Observable of the raw entities @@ -66,18 +67,18 @@ export class EntityStore get pristine(): boolean { return this._pristine; } private _pristine: boolean = true; + /** + * Strategies + */ + private strategies: EntityStoreStrategy[] = []; + constructor(entities: E[], options: EntityStoreOptions = {}) { this.getKey = options.getKey ? options.getKey : getEntityId; this.getProperty = options.getProperty ? options.getProperty : getEntityProperty; - this.state = new EntityStateManager({store: this}); - this.view = new EntityView(this.entities$); - this.stateView = new EntityView>(this.view.all$()).join({ - source: this.state.change$, - reduce: (entity: E): EntityRecord => { - return {entity, state: this.state.get(entity)}; - } - }); + this.state = this.createStateManager(); + this.view = this.createDataView(); + this.stateView = this.createStateView(); this.view.lift(); this.stateView.lift(); @@ -201,6 +202,76 @@ export class EntityStore this.next(); } + /** + * Add a strategy to this store + * @param strategy Entity store strategy + * @returns Entity store + */ + addStrategy(strategy: EntityStoreStrategy, activate: boolean = false): EntityStore { + const existingStrategy = this.strategies.find((_strategy: EntityStoreStrategy) => { + return strategy.constructor === _strategy.constructor; + }); + if (existingStrategy !== undefined) { + throw new Error('A strategy of this type already exists on that EntityStore.'); + } + + this.strategies.push(strategy); + strategy.bindStore(this); + + if (activate === true) { + strategy.activate(); + } + + return this; + } + + /** + * Remove a strategy from this store + * @param strategy Entity store strategy + * @returns Entity store + */ + removeStrategy(strategy: EntityStoreStrategy): EntityStore { + const index = this.strategies.indexOf(strategy); + if (index >= 0) { + this.strategies.splice(index, 1); + strategy.unbindStore(this); + } + return this; + } + + /** + * Return strategies of a given type + * @param type Entity store strategy class + * @returns Strategies + */ + getStrategyOfType(type: typeof EntityStoreStrategy): EntityStoreStrategy { + return this.strategies.find((strategy: EntityStoreStrategy) => { + return strategy instanceof type; + }); + } + + /** + * Activate strategies of a given type + * @param type Entity store strategy class + */ + activateStrategyOfType(type: typeof EntityStoreStrategy) { + const strategy = this.getStrategyOfType(type); + if (strategy !== undefined) { + strategy.activate(); + } + } + + /** + * Deactivate strategies of a given type + * @param type Entity store strategy class + */ + deactivateStrategyOfType(type: typeof EntityStoreStrategy) { + const strategy = this.getStrategyOfType(type); + if (strategy !== undefined) { + strategy.deactivate(); + } + } + /** * Generate a complete index of all the entities * @param entities Entities @@ -229,4 +300,59 @@ export class EntityStore this.empty$.next(empty); } + /** + * Create the entity state manager + * @returns EntityStateManager + */ + private createStateManager() { + return new EntityStateManager({store: this}); + } + + /** + * Create the data view + * @returns EntityView + */ + private createDataView() { + return new EntityView(this.entities$); + } + + /** + * Create the state view + * @returns EntityView> + */ + private createStateView() { + return new EntityView>(this.view.all$()) + .join({ + source: this.state.change$, + reduce: (entity: E): EntityRecord => { + const key = this.getKey(entity); + const state = this.state.get(entity); + const currentRecord = this.stateView.get(key); + + if ( + currentRecord !== undefined && + currentRecord.entity === entity && + this.statesAreTheSame(currentRecord.state, state) + ) { + return currentRecord; + } + + const revision = currentRecord ? currentRecord.revision + 1 : 1; + const ref = `${key}-${revision}`; + return {entity, state, revision, ref}; + } + }) + .createIndex((record: EntityRecord) => this.getKey(record.entity)); + } + + private statesAreTheSame(currentState: S, newState: S): boolean { + if (currentState === newState) { + return true; + } + + const currentStateIsEmpty = Object.keys(currentState).length === 0; + const newStateIsEmpty = Object.keys(newState).length === 0; + return currentStateIsEmpty && newStateIsEmpty; + } + } diff --git a/packages/common/src/lib/entity/shared/strategies/filter-selection.ts b/packages/common/src/lib/entity/shared/strategies/filter-selection.ts new file mode 100644 index 0000000000..879827d59e --- /dev/null +++ b/packages/common/src/lib/entity/shared/strategies/filter-selection.ts @@ -0,0 +1,94 @@ +import { EntityRecord } from '../entity.interfaces'; +import { EntityStore } from '../store'; +import { EntityStoreStrategy } from './strategy'; + +/** + * When active, this strategy filters a store's stateView to return + * selected entities only. + */ +export class EntityStoreFilterSelectionStrategy extends EntityStoreStrategy { + + /** + * Store / filter ids map + */ + private filters: Map = new Map(); + + /** + * Bind this strategy to a store and start filtering it + * @param store Entity store + */ + bindStore(store: EntityStore) { + super.bindStore(store); + if (this.active === true) { + this.filterStore(store); + } + } + + /** + * Unbind this strategy from a store and stop filtering it + * @param store Entity store + */ + unbindStore(store: EntityStore) { + super.unbindStore(store); + if (this.active === true) { + this.unfilterStore(store); + } + } + + /** + * Start filtering all stores + * @internal + */ + protected doActivate() { + this.filterAll(); + } + + /** + * Stop filtering all stores + * @internal + */ + protected doDeactivate() { + this.unfilterAll(); + } + + /** + * Filter all stores + */ + private filterAll() { + this.stores.forEach((store: EntityStore) => this.filterStore(store)); + } + + /** + * Unfilter all stores + */ + private unfilterAll() { + this.stores.forEach((store: EntityStore) => this.unfilterStore(store)); + } + + /** + * Filter a store and add it to the filters map + */ + private filterStore(store: EntityStore) { + if (this.filters.has(store)) { + return; + } + + const filter = (record: EntityRecord) => { + return record.state.selected === true; + }; + this.filters.set(store, store.stateView.addFilter(filter)); + } + + /** + * Unfilter a store and delete it from the filters map + */ + private unfilterStore(store: EntityStore) { + const filterId = this.filters.get(store); + if (filterId === undefined) { + return; + } + + store.stateView.removeFilter(filterId); + this.filters.delete(store); + } +} diff --git a/packages/common/src/lib/entity/shared/strategies/index.ts b/packages/common/src/lib/entity/shared/strategies/index.ts new file mode 100644 index 0000000000..534dcb1f71 --- /dev/null +++ b/packages/common/src/lib/entity/shared/strategies/index.ts @@ -0,0 +1,2 @@ +export * from './strategy'; +export * from './filter-selection'; diff --git a/packages/geo/src/lib/feature/shared/strategies/strategy.ts b/packages/common/src/lib/entity/shared/strategies/strategy.ts similarity index 58% rename from packages/geo/src/lib/feature/shared/strategies/strategy.ts rename to packages/common/src/lib/entity/shared/strategies/strategy.ts index c957ec7b34..fa8d7d0c59 100644 --- a/packages/geo/src/lib/feature/shared/strategies/strategy.ts +++ b/packages/common/src/lib/entity/shared/strategies/strategy.ts @@ -1,37 +1,35 @@ -import { FeatureStoreStrategyOptions } from '../feature.interfaces'; -import { FeatureStore } from '../store'; +import { BehaviorSubject } from 'rxjs'; + +import { EntityStoreStrategyOptions } from '../entity.interfaces'; +import { EntityStore } from '../store'; /** - * Strategies or responsible of synchronizing a feature store and a layer. - * A strategy can be shared among multiple stores. Sharing a strategy - * is a good idea when multiple strategies would have on cancelling effect - * on each other. + * Entity store strategies. They can do pretty much anything during a store's + * lifetime. For example, they may act as triggers when something happens. + * Sharing a strategy is a good idea when multiple strategies would have + * on cancelling effect on each other. * * At creation, strategy is inactive and needs to be manually activated. */ -export class FeatureStoreStrategy { +export class EntityStoreStrategy { /** * Feature store * @internal */ - protected stores: FeatureStore[] = []; + protected stores: EntityStore[] = []; /** * Whether this strategy is active * @internal */ - protected active = false; + get active(): boolean { return this.active$.value; } + readonly active$: BehaviorSubject = new BehaviorSubject(false); - constructor(protected options: FeatureStoreStrategyOptions = {}) { + constructor(protected options: EntityStoreStrategyOptions = {}) { this.options = options; } - /** - * Whether this strategy is active - */ - isActive(): boolean { return this.active; } - /** * Activate the strategy. If it's already active, it'll be deactivated * and activated again. @@ -40,7 +38,7 @@ export class FeatureStoreStrategy { if (this.active === true) { this.doDeactivate(); } - this.active = true; + this.active$.next(true); this.doActivate(); } @@ -49,7 +47,7 @@ export class FeatureStoreStrategy { * and activated again. */ deactivate() { - this.active = false; + this.active$.next(false); this.doDeactivate(); } @@ -57,7 +55,7 @@ export class FeatureStoreStrategy { * Bind this strategy to a store * @param store Feature store */ - bindStore(store: FeatureStore) { + bindStore(store: EntityStore) { if (this.stores.indexOf(store) < 0) { this.stores.push(store); } @@ -67,7 +65,7 @@ export class FeatureStoreStrategy { * Unbind this strategy from store * @param store Feature store */ - unbindStore(store: FeatureStore) { + unbindStore(store: EntityStore) { const index = this.stores.indexOf(store); if (index >= 0) { this.stores.splice(index, 1); diff --git a/packages/common/src/lib/entity/shared/view.ts b/packages/common/src/lib/entity/shared/view.ts index 9956f01e3d..12f235ce1b 100644 --- a/packages/common/src/lib/entity/shared/view.ts +++ b/packages/common/src/lib/entity/shared/view.ts @@ -1,8 +1,9 @@ import { BehaviorSubject, Observable, Subscription, combineLatest } from 'rxjs'; import { debounceTime, map, skip } from 'rxjs/operators'; -import { ObjectUtils } from '@igo2/utils'; +import { ObjectUtils, uuid } from '@igo2/utils'; import { + EntityKey, EntityFilterClause, EntitySortClause, EntityJoinClause @@ -20,6 +21,11 @@ export class EntityView { */ readonly values$ = new BehaviorSubject([]); + /** + * Subscription to the source (and joined sources) values + */ + private values$$: Subscription; + /** * Whether this view has been lifted */ @@ -35,15 +41,26 @@ export class EntityView { */ private filter$ = new BehaviorSubject(undefined); + /** + * Observable of filter clauses + */ + private filters$: BehaviorSubject = new BehaviorSubject([]); + + /** + * Filters index + */ + private filterIndex: Map = new Map(); + /** * Observable of a sort clause */ private sort$ = new BehaviorSubject(undefined); /** - * Subscription to the source (and joined sources) values + * Method for indexing */ - private values$$: Subscription; + get getKey(): (V) => EntityKey { return this.getKey$.value; } + private getKey$: BehaviorSubject<(V) => EntityKey> = new BehaviorSubject(undefined); /** * Number of entities @@ -57,8 +74,26 @@ export class EntityView { readonly empty$ = new BehaviorSubject(true); get empty(): boolean { return this.empty$.value; } + /** + * Store index + */ + get index(): Map { return this._index; } + private _index: Map; + constructor(private source$: BehaviorSubject) {} + /** + * Get a value from the view by key + * @param key Key + * @returns Value + */ + get(key: EntityKey): V { + if (this._index === undefined) { + throw new Error('This view has no index, therefore, this method is unavailable.'); + } + return this.index.get(key); + } + /** * Get all the values * @returns Array of values @@ -122,6 +157,17 @@ export class EntityView { this.clear(); } + /** + * Create an index + * @param getKey Method to get a value's id + * @returns The view + */ + createIndex(getKey: (E) => EntityKey): EntityView { + this._index = new Map(); + this.getKey$.next(getKey); + return this; + } + /** * Join another source to the stream (chainable) * @param clause Join clause @@ -145,6 +191,26 @@ export class EntityView { return this; } + /** + * @param clause Filter clause + * @returns The filter id + */ + addFilter(clause: EntityFilterClause): string { + const id = uuid(); + this.filterIndex.set(id, clause); + this.filters$.next(Array.from(this.filterIndex.values())); + return id; + } + + /** + * Remove a filter by id + * @param clause Filter clause + */ + removeFilter(id: string) { + this.filterIndex.delete(id); + this.filters$.next(Array.from(this.filterIndex.values())); + } + /** * Sort values (chainable) * @param clauseSort clause @@ -162,12 +228,21 @@ export class EntityView { lift() { this.lifted = true; const source$ = this.joins.length > 0 ? this.liftJoinedSource() : this.liftSource(); - this.values$$ = combineLatest(source$, this.filter$, this.sort$) - .pipe(skip(1), debounceTime(25)) - .subscribe((bunch: [V[], EntityFilterClause, EntitySortClause]) => { - const [_values, filter, sort] = bunch; - const values = this.processValues(_values, filter, sort); - this.setValues(values); + const observables$ = [ + source$, + this.filters$, + this.filter$, + this.sort$, + this.getKey$ + ]; + + this.values$$ = combineLatest(observables$) + .pipe(skip(1), debounceTime(5)) + .subscribe((bunch: [V[], EntityFilterClause[], EntityFilterClause, EntitySortClause, (V) => EntityKey]) => { + const [_values, filters, filter, sort, getKey] = bunch; + const values = this.processValues(_values, filters, filter, sort); + const generateIndex = getKey !== undefined; + this.setValues(values, generateIndex); }); } @@ -220,13 +295,16 @@ export class EntityView { /** * Filter and sort values before streaming them * @param values Values + * @param filters Filter clauses * @param filter Filter clause * @param sort Sort clause * @returns Filtered and sorted values */ - private processValues(values: V[], filter: EntityFilterClause, sort: EntitySortClause): V[] { + private processValues( + values: V[], filters: EntityFilterClause[], filter: EntityFilterClause, sort: EntitySortClause + ): V[] { values = values.slice(0); - values = this.filterValues(values, filter); + values = this.filterValues(values, filters.concat([filter])); values = this.sortValues(values, sort); return values; } @@ -234,12 +312,18 @@ export class EntityView { /** * Filter values * @param values Values - * @param filter Filter clause + * @param filters Filter clauses * @returns Filtered values */ - private filterValues(values: V[], clause: EntityFilterClause): V[] { - if (clause === undefined) { return values; } - return values.filter((value: V) => clause(value)); + private filterValues(values: V[], clauses: EntityFilterClause[]): V[] { + if (clauses.length === 0) { return values; } + + return values + .filter((value: V) => { + return clauses + .filter((clause: EntityFilterClause) => clause !== undefined) + .every((clause: EntityFilterClause) => clause(value)); + }); } /** @@ -260,11 +344,31 @@ export class EntityView { }); } - private setValues(values: V[]) { + /** + * Set value and optionally generate an index + * @param values Values + * @param generateIndex boolean + */ + private setValues(values: V[], generateIndex: boolean) { + if (generateIndex === true) { + this._index = this.generateIndex(values); + } + this.values$.next(values); + const count = values.length; const empty = count === 0; this.count$.next(count); this.empty$.next(empty); } + + /** + * Generate a complete index of all the values + * @param entities Entities + * @returns Index + */ + private generateIndex(values: V[]): Map { + const entries = values.map((value: V) => [this.getKey(value), value]); + return new Map(entries as [EntityKey, V][]); + } } diff --git a/packages/common/src/lib/workspace/shared/workspace.ts b/packages/common/src/lib/workspace/shared/workspace.ts index 7af7d5d9ff..531fea9845 100644 --- a/packages/common/src/lib/workspace/shared/workspace.ts +++ b/packages/common/src/lib/workspace/shared/workspace.ts @@ -1,5 +1,4 @@ import { Subscription, BehaviorSubject, Subject } from 'rxjs'; -import { debounceTime } from 'rxjs/operators'; import { ActionStore } from '../../action'; import { Widget } from '../../widget'; @@ -34,11 +33,6 @@ export class Workspace { */ private entities$$: Subscription; - /** - * Whether this workspace is active - */ - private active: boolean = false; - /** * State change that trigger an update of the actions availability */ @@ -87,9 +81,11 @@ export class Workspace { constructor(protected options: WorkspaceOptions) {} /** - * Whether this workspace is active + * Whether this strategy is active + * @internal */ - isActive(): boolean { return this.active; } + get active(): boolean { return this.active$.value; } + readonly active$: BehaviorSubject = new BehaviorSubject(false); /** * Activate the workspace. By doing that, the workspace will observe @@ -100,7 +96,7 @@ export class Workspace { if (this.active === true) { this.deactivate(); } - this.active = true; + this.active$.next(true); if (this.entityStore !== undefined) { this.entities$$ = this.entityStore.stateView.all$() @@ -114,7 +110,7 @@ export class Workspace { * Deactivate the workspace. Unsubcribe to the selected entity. */ deactivate() { - this.active = false; + this.active$.next(false); this.deactivateWidget(); if (this.entities$$ !== undefined) { diff --git a/packages/geo/src/lib/feature/shared/feature.interfaces.ts b/packages/geo/src/lib/feature/shared/feature.interfaces.ts index ccbde14015..6cd13b6fdc 100644 --- a/packages/geo/src/lib/feature/shared/feature.interfaces.ts +++ b/packages/geo/src/lib/feature/shared/feature.interfaces.ts @@ -2,7 +2,7 @@ import { FormGroup } from '@angular/forms'; import { GeoJsonGeometryTypes } from 'geojson'; -import { EntityKey, EntityStoreOptions } from '@igo2/common'; +import { EntityKey, EntityStoreOptions, EntityStoreStrategyOptions } from '@igo2/common'; import { VectorLayer } from '../../layer'; import { IgoMap } from '../../map'; @@ -41,7 +41,7 @@ export interface FeatureStoreOptions extends EntityStoreOptions { layer?: VectorLayer; } -export interface FeatureStoreStrategyOptions { +export interface FeatureStoreStrategyOptions extends EntityStoreStrategyOptions { // When the store moves features into view, the view extent, which is also the features extent, // is scaled by those factors, effectively resulting in a decentered view or a more zoomed in/out view. // These factors are applied to the top, right, bottom and left directions, in that order. diff --git a/packages/geo/src/lib/feature/shared/store.ts b/packages/geo/src/lib/feature/shared/store.ts index 4d89c027a6..3525de3782 100644 --- a/packages/geo/src/lib/feature/shared/store.ts +++ b/packages/geo/src/lib/feature/shared/store.ts @@ -13,7 +13,6 @@ import { IgoMap } from '../../map'; import { FeatureMotion } from './feature.enums'; import { Feature, FeatureStoreOptions } from './feature.interfaces'; import { computeOlFeaturesDiff, featureFromOl, featureToOl, moveToOlFeatures } from './feature.utils'; -import { FeatureStoreStrategy } from './strategies/strategy'; /** * The class is a specialized version of an EntityStore that stores @@ -22,12 +21,6 @@ import { FeatureStoreStrategy } from './strategies/strategy'; */ export class FeatureStore extends EntityStore { - /** - * Feature store strategies responsible of synchronizing the store - * and the layer - */ - strategies: FeatureStoreStrategy[] = []; - /** * Vector layer to display the features on */ @@ -60,76 +53,6 @@ export class FeatureStore extends EntityStore { return this; } - /** - * Add a strategy to this store - * @param strategy Feature store strategy - * @returns Feature store - */ - addStrategy(strategy: FeatureStoreStrategy, activate: boolean = false): FeatureStore { - const existingStrategy = this.strategies.find((_strategy: FeatureStoreStrategy) => { - return strategy.constructor === _strategy.constructor; - }); - if (existingStrategy !== undefined) { - throw new Error('A strategy of this type already exists on that FeatureStore.'); - } - - this.strategies.push(strategy); - strategy.bindStore(this); - - if (activate === true) { - strategy.activate(); - } - - return this; - } - - /** - * Remove a strategy from this store - * @param strategy Feature store strategy - * @returns Feature store - */ - removeStrategy(strategy: FeatureStoreStrategy): FeatureStore { - const index = this.strategies.indexOf(strategy); - if (index >= 0) { - this.strategies.splice(index, 1); - strategy.unbindStore(this); - } - return this; - } - - /** - * Return strategies of a given type - * @param type Feature store strategy class - * @returns Strategies - */ - getStrategyOfType(type: typeof FeatureStoreStrategy): FeatureStoreStrategy { - return this.strategies.find((strategy: FeatureStoreStrategy) => { - return strategy instanceof type; - }); - } - - /** - * Activate strategies of a given type - * @param type Feature store strategy class - */ - activateStrategyOfType(type: typeof FeatureStoreStrategy) { - const strategy = this.getStrategyOfType(type); - if (strategy !== undefined) { - strategy.activate(); - } - } - - /** - * Deactivate strategies of a given type - * @param type Feature store strategy class - */ - deactivateStrategyOfType(type: typeof FeatureStoreStrategy) { - const strategy = this.getStrategyOfType(type); - if (strategy !== undefined) { - strategy.deactivate(); - } - } - /** * Set the layer's features and perform a motion to make them visible. Strategies * make extensive use of that method. diff --git a/packages/geo/src/lib/feature/shared/strategies/index.ts b/packages/geo/src/lib/feature/shared/strategies/index.ts index 871c770d17..229fe518b6 100644 --- a/packages/geo/src/lib/feature/shared/strategies/index.ts +++ b/packages/geo/src/lib/feature/shared/strategies/index.ts @@ -1,4 +1,3 @@ export * from './loading'; export * from './loading-layer'; export * from './selection'; -export * from './strategy'; 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 6e7aab87d7..8a0f83412a 100644 --- a/packages/geo/src/lib/feature/shared/strategies/loading-layer.ts +++ b/packages/geo/src/lib/feature/shared/strategies/loading-layer.ts @@ -1,9 +1,10 @@ import { unByKey } from 'ol/Observable'; import { OlEvent } from 'ol/events/Event'; +import { EntityStoreStrategy } from '@igo2/common'; + import { FeatureStore } from '../store'; import { FeatureStoreLoadingLayerStrategyOptions } from '../feature.interfaces'; -import { FeatureStoreStrategy } from './strategy'; /** * This strategy loads a layer's features into it's store counterpart. @@ -13,7 +14,7 @@ import { FeatureStoreStrategy } from './strategy'; * Important: In it's current state, this strategy is to meant to be combined * with a standard Loading strategy and it would probably cause recursion issues. */ -export class FeatureStoreLoadingLayerStrategy extends FeatureStoreStrategy { +export class FeatureStoreLoadingLayerStrategy extends EntityStoreStrategy { /** * Subscription to the store's OL source changes @@ -30,7 +31,7 @@ export class FeatureStoreLoadingLayerStrategy extends FeatureStoreStrategy { */ bindStore(store: FeatureStore) { super.bindStore(store); - if (this.isActive() === true) { + if (this.active === true) { this.watchStore(store); } } @@ -41,7 +42,7 @@ export class FeatureStoreLoadingLayerStrategy extends FeatureStoreStrategy { */ unbindStore(store: FeatureStore) { super.unbindStore(store); - if (this.isActive() === true) { + if (this.active === true) { this.unwatchStore(store); } } diff --git a/packages/geo/src/lib/feature/shared/strategies/loading.ts b/packages/geo/src/lib/feature/shared/strategies/loading.ts index e3b5f28bc6..59a5ed5307 100644 --- a/packages/geo/src/lib/feature/shared/strategies/loading.ts +++ b/packages/geo/src/lib/feature/shared/strategies/loading.ts @@ -1,9 +1,10 @@ import { Subscription } from 'rxjs'; +import { EntityStoreStrategy } from '@igo2/common'; + import { FeatureMotion } from '../feature.enums'; import { Feature, FeatureStoreLoadingStrategyOptions } from '../feature.interfaces'; import { FeatureStore } from '../store'; -import { FeatureStoreStrategy } from './strategy'; /** * This strategy loads a store's features into it's layer counterpart. @@ -13,7 +14,7 @@ import { FeatureStoreStrategy } from './strategy'; * Important: This strategy observes filtered entities, not raw entities. This * is not configurable yet. */ -export class FeatureStoreLoadingStrategy extends FeatureStoreStrategy { +export class FeatureStoreLoadingStrategy extends EntityStoreStrategy { /** * Subscription to the store's features @@ -30,7 +31,7 @@ export class FeatureStoreLoadingStrategy extends FeatureStoreStrategy { */ bindStore(store: FeatureStore) { super.bindStore(store); - if (this.isActive() === true) { + if (this.active === true) { this.watchStore(store); } } @@ -41,7 +42,7 @@ export class FeatureStoreLoadingStrategy extends FeatureStoreStrategy { */ unbindStore(store: FeatureStore) { super.unbindStore(store); - if (this.isActive() === true) { + if (this.active === true) { this.unwatchStore(store); } } diff --git a/packages/geo/src/lib/feature/shared/strategies/selection.ts b/packages/geo/src/lib/feature/shared/strategies/selection.ts index a2dc070a1e..1bae53bae0 100644 --- a/packages/geo/src/lib/feature/shared/strategies/selection.ts +++ b/packages/geo/src/lib/feature/shared/strategies/selection.ts @@ -8,7 +8,7 @@ import { unByKey } from 'ol/Observable'; import { Subscription, combineLatest } from 'rxjs'; import { map, debounceTime, skip } from 'rxjs/operators'; -import { EntityKey, EntityRecord } from '@igo2/common'; +import { EntityKey, EntityRecord, EntityStoreStrategy } from '@igo2/common'; import { FeatureDataSource } from '../../../datasource'; import { VectorLayer } from '../../../layer'; @@ -16,7 +16,6 @@ import { IgoMap, ctrlKeyDown } from '../../../map'; import { Feature, FeatureStoreSelectionStrategyOptions } from '../feature.interfaces'; import { FeatureStore } from '../store'; -import { FeatureStoreStrategy } from './strategy'; import { FeatureMotion } from '../feature.enums'; class OlDragSelectInteraction extends OlDragBoxInteraction { @@ -35,7 +34,7 @@ class OlDragSelectInteraction extends OlDragBoxInteraction { * would trigger the strategy of each layer and they would cancel * each other as well as move the map view around needlessly. */ -export class FeatureStoreSelectionStrategy extends FeatureStoreStrategy { +export class FeatureStoreSelectionStrategy extends EntityStoreStrategy { /** * Listener to the map click event that allows selecting a feature @@ -76,7 +75,7 @@ export class FeatureStoreSelectionStrategy extends FeatureStoreStrategy { */ bindStore(store: FeatureStore) { super.bindStore(store); - if (this.isActive() === true) { + if (this.active === true) { // Force reactivation this.activate(); } @@ -89,7 +88,7 @@ export class FeatureStoreSelectionStrategy extends FeatureStoreStrategy { */ unbindStore(store: FeatureStore) { super.unbindStore(store); - if (this.isActive() === true) { + if (this.active === true) { // Force reactivation this.activate(); } @@ -153,7 +152,7 @@ export class FeatureStoreSelectionStrategy extends FeatureStoreStrategy { }); this.stores$$ = combineLatest(...stores$) .pipe( - debounceTime(25), + debounceTime(5), skip(1), // Skip intial selection map((features: Array) => features.reduce((a, b) => a.concat(b))) ).subscribe((features: Feature[]) => this.onSelectFromStore(features));