UNPKG

hslayers-ng

Version:
779 lines (773 loc) 56.5 kB
import * as i0 from '@angular/core'; import { inject, Injectable, EventEmitter, input, Output, Component, model, output, computed, signal, ChangeDetectionStrategy, forwardRef, Injector, viewChild, DestroyRef, Input } from '@angular/core'; import { TranslatePipe } from '@ngx-translate/core'; import * as i1 from '@ng-bootstrap/ng-bootstrap'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { HttpClient } from '@angular/common/http'; import { of, debounceTime, map as map$1, take, tap, switchMap, concat, filter, startWith, share, catchError as catchError$1 } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; import { HsLanguageService } from 'hslayers-ng/services/language'; import { HsToastService } from 'hslayers-ng/common/toast'; import { HsProxyService, listAttributes } from 'hslayers-ng/services/utils'; import { getWfsUrl, getDefinition, getName, getWorkspace } from 'hslayers-ng/common/extensions'; import { NgClass, AsyncPipe } from '@angular/common'; import * as i1$1 from '@angular/forms'; import { FormsModule, ReactiveFormsModule, NG_VALUE_ACCESSOR, FormControl } from '@angular/forms'; import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { HsLayoutService } from 'hslayers-ng/services/layout'; import { HsStylerPartBaseComponent } from 'hslayers-ng/services/styler'; import { HsEventBusService } from 'hslayers-ng/services/event-bus'; class HsFiltersService { constructor() { this.http = inject(HttpClient); this.hsToastService = inject(HsToastService); this.hsLanguageService = inject(HsLanguageService); this.hsProxyService = inject(HsProxyService); this.layerAttributes = []; } /** * Sets the selected layer for filtering operations * @param layer The layer to be set as selected */ setSelectedLayer(layer) { this.selectedLayer = layer; } /** * Sets the attributes for the selected layer * @param attributes Array of WFS feature attributes */ setLayerAttributes(attributes) { this.layerAttributes = attributes; } /** * Adds a new filter to the collection based on the specified type * @param type The type of filter to add (AND, OR, COMPARE, NOT) * @param append Whether to append the new filter or replace existing ones * @param collection The collection to add the filter to */ add(type, append, collection) { let filter; switch (type) { case 'AND': filter = [ '&&', ['==', undefined, '<value>'], ['==', undefined, '<value>'], ]; break; case 'OR': filter = [ '||', ['==', undefined, '<value>'], ['==', undefined, '<value>'], ]; break; case 'COMPARE': filter = ['==', undefined, '<value>']; break; case 'NOT': filter = ['!', ['==', undefined, '<value>']]; break; default: } if (append) { collection.push(filter); } else { collection.length = 0; collection.push(...filter); } } /** * Converts logical operators to human-readable format * @param logOp The logical operator to convert * @returns The human-readable version of the operator */ humanReadableLogOp(logOp) { return { '&&': 'AND', '||': 'OR', '!': 'NOT' }[logOp]; } /** * Checks if the given filter is a logical operator * @param filters The filter array to check * @returns True if the filter is a logical operator, false otherwise */ isLogOp(filters) { return filters?.length > 0 && ['&&', '||', '!'].includes(filters[0]); } /** * Checks if a filter can be deleted */ canDeleteFilter(parent) { if (!Array.isArray(parent)) { return false; } return ((['||', '&&'].includes(parent[0]) && parent.length > 3) || (parent[0] === '!' && parent.length > 2)); } /** * Displays a warning message when a filter cannot be deleted. * @param parent The parent filter array */ showCannotDeleteFilterWarning(parent) { const readableOp = this.humanReadableLogOp(parent[0]); const message = this.hsLanguageService.getTranslation('FILTERS.cannotDeleteFilterToastMessage', { operator: readableOp, count: readableOp === 'NOT' ? 1 : 2, }); this.hsToastService.createToastPopupMessage('STYLER.removeFilter', message, { type: 'warning', serviceCalledFrom: 'HsFiltersService', }); } /** * Removes a filter from the parent filter array * @param parent The parent filter array * @param filter The filter to remove * @returns True if the filter was successfully removed, false otherwise */ removeFilter(parent, filter) { if (Array.isArray(parent) && this.canDeleteFilter(parent)) { const index = parent.findIndex((item) => item === filter); if (index !== -1) { parent.splice(index, 1); return true; } } this.showCannotDeleteFilterWarning(parent); return false; } /** * Fetches attribute values for a given attribute name * @param attributeName The name of the attribute to fetch values for * @returns An Observable of the WfsFeatureAttribute with fetched values */ getAttributeWithValues(attributeName) { const attribute = this.layerAttributes.find((attr) => attr.name === attributeName); if (!attribute) { return of(null); } if (attribute.values || attribute.range) { return of(attribute); } return this.fetchAttributeValues(attribute).pipe(map((updatedAttr) => { const index = this.layerAttributes.findIndex((attr) => attr.name === attributeName); this.layerAttributes[index] = updatedAttr; return updatedAttr; }), catchError(() => of(attribute))); } /** * Fetches attribute values from the WFS service * @param attribute The attribute to fetch values for * @returns An Observable of the updated WfsFeatureAttribute */ fetchAttributeValues(attribute) { if (!this.selectedLayer?.layer) { return of(attribute); } const url = this.buildWfsUrl(this.selectedLayer.layer, attribute.name); return this.http.get(url, { observe: 'response', responseType: 'text' }).pipe(map((response) => { const contentType = response.headers.get('content-type'); let parsedResponse; /** * NOTE: Spec says it might be served in other formats other than GML but * havent found any example so keeping just in case */ if (contentType && contentType.includes('application/json')) { parsedResponse = JSON.parse(response.body); } else { // Assume XML if not JSON parsedResponse = response.body; } const values = this.extractValuesFromResponse(parsedResponse, attribute); if (attribute.isNumeric) { return { ...attribute, range: { min: Math.min(...values), max: Math.max(...values), }, }; } return { ...attribute, values, }; }), catchError(() => of(attribute))); } /** * Builds the WFS URL for fetching attribute values * @param layer The layer to build the URL for * @param attributeName The name of the attribute * @returns The constructed WFS URL */ buildWfsUrl(layer, attributeName) { const baseUrl = getWfsUrl(layer) || getDefinition(layer).url; const params = [ 'service=WFS', 'version=2.0.0', 'request=GetPropertyValue', `typename=${getName(layer)}`, `valueReference=${attributeName}`, 'outputFormat=application/json', ].join('&'); return this.hsProxyService.proxify(`${baseUrl}?${params.toString()}`); } /** * Returns an array of unique, sorted attribute values * @param values The array of values to sort * @returns An array of unique, sorted values */ getSortedUniqueValues(values) { return [...new Set(values)].sort((a, b) => { if (typeof a === 'string' && typeof b === 'string') { return a.localeCompare(b); } return a - b; }); } /** * Extracts attribute values from the WFS response * @param response The WFS response (can be JSON or XML) * @param attribute The WFS feature attribute * @returns An array of unique, sorted attribute values */ extractValuesFromResponse(response, attribute) { let values = []; if (typeof response === 'string') { // XML response const parser = new DOMParser(); const xmlDoc = parser.parseFromString(response, 'text/xml'); /** * Extract values from the 'namespace:attributeName' elements * namespace is extracted from * a) normal WFS layers : the layerName eg.'filip:layer' * b) Layman layers: workspace */ const namespace = getWorkspace(this.selectedLayer.layer) || getName(this.selectedLayer.layer).split(':')[0]; const elementName = `${namespace}:${attribute.name}`; const valueElements = xmlDoc.getElementsByTagName(elementName); values = Array.from(valueElements).map((el) => attribute.isNumeric ? +el.textContent : el.textContent.trim()); } else if (response.features && Array.isArray(response.features)) { // JSON response values = response.features.map((feature) => feature.properties[attribute.name]); } // Return unique, sorted values return this.getSortedUniqueValues(values); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsFiltersService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsFiltersService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsFiltersService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); class HsAddFilterButtonComponent { constructor() { this.clicks = new EventEmitter(); this.rule = input(...(ngDevMode ? [undefined, { debugName: "rule" }] : [])); this.hsFiltersService = inject(HsFiltersService); this.logicalOperators = ['AND', 'OR', 'NOT']; this.comparisonOperator = 'COMPARE'; this.filterOptions = [ this.comparisonOperator, ...this.logicalOperators, ]; } ngOnChanges({ rule }) { /** * Set correct active tab on rule change */ if (rule !== undefined) { if (rule.currentValue.filter?.[0]) { const readableType = this.hsFiltersService.humanReadableLogOp(rule.currentValue.filter[0]); this.setActiveTab(readableType || this.comparisonOperator); } else { this.activeTab = undefined; } } } emitClick(type) { this.clicks.emit({ type }); this.setActiveTab(type); } setActiveTab(type) { const rule = this.rule(); if (!type && rule?.filter && Array.isArray(rule.filter) && rule.filter.length > 0) { const readableType = this.hsFiltersService.humanReadableLogOp(rule.filter[0]); this.activeTab = readableType || this.comparisonOperator; } else { this.activeTab = type; } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsAddFilterButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.2.3", type: HsAddFilterButtonComponent, isStandalone: true, selector: "hs-add-filter-button", inputs: { rule: { classPropertyName: "rule", publicName: "rule", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { clicks: "clicks" }, usesOnChanges: true, ngImport: i0, template: "@if (!rule()) {\n <div class=\"p-1\">\n <div ngbDropdown class=\"d-inline-block\" #menu=\"ngbDropdown\">\n <button class=\"btn btn-outline-primary btn-sm\" [title]=\"'STYLER.addFilter' | translate\" ngbDropdownToggle>\n <span class=\"fa-solid fa-plus\"></span>\n </button>\n <div ngbDropdownMenu>\n @for (filter of filterOptions; track filter) {\n <button ngbDropdownItem (click)=\"emitClick(filter); menu.close()\">\n {{ 'STYLER.' + filter | translate }}\n </button>\n }\n </div>\n </div>\n </div>\n} @else {\n <div class=\"btn-group w-100 py-2 px-1\">\n @for (filter of filterOptions; track filter) {\n <button class=\"btn btn-sm btn-outline-primary hs-add-filter-tab w-25 rounded-0\"\n [class.text-bg-primary]=\"filter === activeTab\" (click)=\"emitClick(filter)\">\n {{ 'STYLER.' + filter | translate }}\n </button>\n }\n </div>\n}\n\n", dependencies: [{ kind: "ngmodule", type: NgbDropdownModule }, { kind: "directive", type: i1.NgbDropdown, selector: "[ngbDropdown]", inputs: ["autoClose", "dropdownClass", "open", "placement", "popperOptions", "container", "display"], outputs: ["openChange"], exportAs: ["ngbDropdown"] }, { kind: "directive", type: i1.NgbDropdownToggle, selector: "[ngbDropdownToggle]" }, { kind: "directive", type: i1.NgbDropdownMenu, selector: "[ngbDropdownMenu]" }, { kind: "directive", type: i1.NgbDropdownItem, selector: "[ngbDropdownItem]", inputs: ["tabindex", "disabled"] }, { kind: "directive", type: i1.NgbDropdownButtonItem, selector: "button[ngbDropdownItem]" }, { kind: "pipe", type: TranslatePipe, name: "translate" }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsAddFilterButtonComponent, decorators: [{ type: Component, args: [{ selector: 'hs-add-filter-button', imports: [TranslatePipe, NgbDropdownModule], template: "@if (!rule()) {\n <div class=\"p-1\">\n <div ngbDropdown class=\"d-inline-block\" #menu=\"ngbDropdown\">\n <button class=\"btn btn-outline-primary btn-sm\" [title]=\"'STYLER.addFilter' | translate\" ngbDropdownToggle>\n <span class=\"fa-solid fa-plus\"></span>\n </button>\n <div ngbDropdownMenu>\n @for (filter of filterOptions; track filter) {\n <button ngbDropdownItem (click)=\"emitClick(filter); menu.close()\">\n {{ 'STYLER.' + filter | translate }}\n </button>\n }\n </div>\n </div>\n </div>\n} @else {\n <div class=\"btn-group w-100 py-2 px-1\">\n @for (filter of filterOptions; track filter) {\n <button class=\"btn btn-sm btn-outline-primary hs-add-filter-tab w-25 rounded-0\"\n [class.text-bg-primary]=\"filter === activeTab\" (click)=\"emitClick(filter)\">\n {{ 'STYLER.' + filter | translate }}\n </button>\n }\n </div>\n}\n\n" }] }], propDecorators: { clicks: [{ type: Output }] } }); class FilterRangeInputComponent { constructor() { this.min = input(...(ngDevMode ? [undefined, { debugName: "min" }] : [])); this.max = input(...(ngDevMode ? [undefined, { debugName: "max" }] : [])); this.value = model(...(ngDevMode ? [undefined, { debugName: "value" }] : [])); this.change = output(); this.bubblePosition = computed(() => { const newVal = Number(((this.value() - this.min()) * 100) / (this.max() - this.min())); return `calc(${newVal}% + (${12 - newVal * 0.15}px))`; }, ...(ngDevMode ? [{ debugName: "bubblePosition" }] : [])); this.expandedSignal = signal(false, ...(ngDevMode ? [{ debugName: "expandedSignal" }] : [])); this.expanded = this.expandedSignal.asReadonly(); toObservable(this.bubblePosition) .pipe(debounceTime(250), takeUntilDestroyed()) .subscribe(() => { this.change.emit(this.value()); }); } expandRange() { this.expandedSignal.set(true); } contractRange() { this.expandedSignal.set(false); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: FilterRangeInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "20.2.3", type: FilterRangeInputComponent, isStandalone: true, selector: "hs-filter-range-input", inputs: { min: { classPropertyName: "min", publicName: "min", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", change: "change" }, ngImport: i0, template: ` <div class="filter-range-input-container position-relative h-100" [ngClass]="{'expanded': expanded()}" > <div class="value-bubble text-bg-primary" [style.left]="bubblePosition()"> {{ value() }} </div> <input #rangeInput type="range" class="form-range border px-1 h-100" [(ngModel)]="value" [min]="min()" [max]="max()" (mousedown)="expandRange()" (mouseup)="contractRange()" (mouseleave)="contractRange()" /> </div> `, isInline: true, styles: [".filter-range-input-container{transition:min-width .5s ease-in-out}.value-bubble{position:absolute;padding:2px 6px;border-radius:10px;font-size:12px;top:-10px;transform:translate(-50%)}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.RangeValueAccessor, selector: "input[type=range][formControlName],input[type=range][formControl],input[type=range][ngModel]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: FilterRangeInputComponent, decorators: [{ type: Component, args: [{ selector: 'hs-filter-range-input', imports: [FormsModule, NgClass], template: ` <div class="filter-range-input-container position-relative h-100" [ngClass]="{'expanded': expanded()}" > <div class="value-bubble text-bg-primary" [style.left]="bubblePosition()"> {{ value() }} </div> <input #rangeInput type="range" class="form-range border px-1 h-100" [(ngModel)]="value" [min]="min()" [max]="max()" (mousedown)="expandRange()" (mouseup)="contractRange()" (mouseleave)="contractRange()" /> </div> `, changeDetection: ChangeDetectionStrategy.OnPush, styles: [".filter-range-input-container{transition:min-width .5s ease-in-out}.value-bubble{position:absolute;padding:2px 6px;border-radius:10px;font-size:12px;top:-10px;transform:translate(-50%)}\n"] }] }], ctorParameters: () => [] }); class HsAttributeSelectorComponent { constructor() { this.attributes = input.required(...(ngDevMode ? [{ debugName: "attributes" }] : [])); this.value = null; this.disabled = false; this.onChange = () => { }; this.onTouched = () => { }; } writeValue(value) { this.value = value; } registerOnChange(fn) { this.onChange = fn; } registerOnTouched(fn) { this.onTouched = fn; } setDisabledState(isDisabled) { this.disabled = isDisabled; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsAttributeSelectorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.2.3", type: HsAttributeSelectorComponent, isStandalone: true, selector: "hs-filters-attribute-selector", inputs: { attributes: { classPropertyName: "attributes", publicName: "attributes", isSignal: true, isRequired: true, transformFunction: null } }, providers: [ { provide: NG_VALUE_ACCESSOR, // eslint-disable-next-line no-use-before-define useExisting: forwardRef(() => HsAttributeSelectorComponent), multi: true, }, ], ngImport: i0, template: ` <select class="form-control form-select hs-comparison-filter-attribute h-100 border-end-0" [(ngModel)]="value" (ngModelChange)="onChange($event)" (blur)="onTouched()" [disabled]="disabled" > <option [ngValue]="null" [disabled]="true" hidden> {{ 'FILTERS.pickAnAttribute' | translate }} </option> @for (attr of attributes(); track attr) { <option [ngValue]="attr">{{ attr }}</option> } </select> `, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "pipe", type: TranslatePipe, name: "translate" }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsAttributeSelectorComponent, decorators: [{ type: Component, args: [{ selector: 'hs-filters-attribute-selector', imports: [FormsModule, ReactiveFormsModule, TranslatePipe], providers: [ { provide: NG_VALUE_ACCESSOR, // eslint-disable-next-line no-use-before-define useExisting: forwardRef(() => HsAttributeSelectorComponent), multi: true, }, ], template: ` <select class="form-control form-select hs-comparison-filter-attribute h-100 border-end-0" [(ngModel)]="value" (ngModelChange)="onChange($event)" (blur)="onTouched()" [disabled]="disabled" > <option [ngValue]="null" [disabled]="true" hidden> {{ 'FILTERS.pickAnAttribute' | translate }} </option> @for (attr of attributes(); track attr) { <option [ngValue]="attr">{{ attr }}</option> } </select> `, }] }] }); class HsComparisonFilterComponent extends HsStylerPartBaseComponent { constructor() { super(); this.injector = inject(Injector); this.filterRangeInput = viewChild(FilterRangeInputComponent, ...(ngDevMode ? [{ debugName: "filterRangeInput" }] : [])); this.expanded = computed(() => this.filterRangeInput()?.expanded() ?? false, ...(ngDevMode ? [{ debugName: "expanded" }] : [])); this.OPERATORS = { default: [ { value: '==', alias: '=' }, { value: '!=', alias: '≠' }, ], custom: [ { value: '==', alias: '= ∅' }, { value: '!=', alias: '≠ ∅' }, ], stringBased: [{ value: '*=', alias: '≈' }], numeric: [ { value: '<', alias: '<' }, { value: '<=', alias: '≤' }, { value: '>', alias: '>' }, { value: '>=', alias: '≥' }, ], }; this.customOperatorSelected = signal(false, ...(ngDevMode ? [{ debugName: "customOperatorSelected" }] : [])); this.features = []; this.currentAttribute = signal(null, ...(ngDevMode ? [{ debugName: "currentAttribute" }] : [])); this.hsFiltersService = inject(HsFiltersService); this.hsLayoutService = inject(HsLayoutService); this.destroyRef = inject(DestroyRef); this.loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : [])); this.selectedOperator = signal(null, ...(ngDevMode ? [{ debugName: "selectedOperator" }] : [])); this.valueSource = signal('value', ...(ngDevMode ? [{ debugName: "valueSource" }] : [])); this.isWfsFilter = toSignal(this.hsLayoutService.mainpanel$.pipe(map$1((panel) => panel === 'wfsFilter'))); this.updateFeatures(); } emitChange() { /** * Sync local and remote filter values */ if (this.valueSource() === 'property') { /** * Form necessary for geostlyer parser to properly encode fitler into SLD */ this.filter[2] = { name: 'property', args: [this.extractValue(this._filter[2])], }; this.filter[1] = { name: 'property', args: [this.extractValue(this._filter[1])], }; } else { this.filter[1] = this._filter[1]; this.filter[2] = this._filter[2]; } this.changes.emit(); } /** * Extracts the value from the FilterWithArgs object * @param value The value to extract * @returns The extracted value */ extractValue(value) { if (value && typeof value === 'object' && 'args' in value) { this.valueSource.set('property'); return value.args[0]; } return value; } /** * Parses filter values to extract the attribute name from the FilterWithArgs object * in order to keep local state simple: string based */ parseFilterValues(filter) { const localFilter = [ filter[0], this.extractValue(filter[1]), this.extractValue(filter[2]), ]; return localFilter; } /** * In case user toggles between layer with comparison filters set up * component is not recreated, only its inputs are changed. * In that case we need to update features and reinitialize the filter. */ ngOnChanges({ filter, parent, }) { if (filter && filter.previousValue) { this.updateFeatures(); this.initializeFilter(); } } /** * Handles operator selection changes */ onOperatorChange(e) { const operatorAlias = e.target.value; const isCustom = this.OPERATORS.custom.map((op) => op.alias).includes(operatorAlias); this.customOperatorSelected.set(isCustom); const operators = Object.values(this.OPERATORS).flat(); const foundOperator = operators.find((op) => op.alias === operatorAlias && (isCustom ? op.alias.includes('∅') : !op.alias.includes('∅'))); if (foundOperator) { this.filter[0] = foundOperator.value; } if (isCustom) { this._filter[2] = undefined; this.valueSource.set('value'); } this.emitChange(); } ngOnInit() { /** * Populate local state with copy of filter values */ const f = JSON.parse(JSON.stringify(this.filter)); this._filter = this.parseFilterValues(f); this.initializeFilter(); } /** * Set up the filter with initial values and streams * Is used both on init and on filter change (in case previous value is present) */ initializeFilter() { this.initOperator(); // Initialize attribute control with the current filter value or null this.attributeControl = new FormControl(this._filter[1] ?? null); /** * Stream to get initial attribute values. * Serves basically as startWith but with observable * NOTE: take(1) to allow switch to subsequentAttributes in concat */ const initialAttribute$ = this.getAttributeWithValues(this.attributeControl.value).pipe(take(1)); // Stream to handle subsequent attribute changes const subsequentAttributes$ = this.attributeControl.valueChanges.pipe(tap(() => this.loading.set(true)), switchMap((attrName) => { this._filter[1] = attrName; return this.getAttributeWithValues(attrName); })); // Combine initial and subsequent attribute streams const currentAttribute$ = concat(initialAttribute$, subsequentAttributes$).pipe(tap((attr) => { this.currentAttribute.set(attr); this.loading.set(false); })); this.operators = currentAttribute$.pipe(filter((attr) => attr !== null), tap((attr) => { this.currentAttribute.set(attr); this.loading.set(false); }), map$1((attr) => { const d = this.isWfsFilter() ? this.OPERATORS.default : [...this.OPERATORS.default, ...this.OPERATORS.custom]; if (attr?.isNumeric) { if (this._filter[2] === '<value>') { this._filter[2] = attr.range?.min || 0; } return [...d, ...this.OPERATORS.numeric]; } return [...d, ...this.OPERATORS.stringBased]; }), startWith(this.OPERATORS.default), share(), catchError$1((error) => { console.error('Error fetching attribute values:', error); this.loading.set(false); return of(this.OPERATORS.default); }), takeUntilDestroyed(this.destroyRef)); } /** * Init operator value and adjusts it if necessary to match the custom operator mappings * geostyler parses '= ∅' as '==', so in case value is undefined we need to adjust the operator */ initOperator() { const operatorValue = this._filter[0]; const isCustom = ['==', '!='].includes(operatorValue) && (this._filter[2] === undefined || this._filter[2] === null); this.customOperatorSelected.set(isCustom); if (isCustom) { this.valueSource.set('value'); } const operators = Object.values(this.OPERATORS).flat(); const operatorAlias = operators.find((op) => op.value === operatorValue && (isCustom ? op.alias.includes('∅') : !op.alias.includes('∅')))?.alias; this.selectedOperator.set(operatorAlias); } /** * Retrieves attribute with values based on the current filter type (WFS or local) * @param attrName The name of the attribute to retrieve * @returns An Observable of WfsFeatureAttribute */ getAttributeWithValues(attrName) { return this.isWfsFilter() ? this.hsFiltersService.getAttributeWithValues(attrName) : this.getLocalAttributesWithValues(attrName); } /** * Returns an array of values for the given attribute from the existing features. * @param attrName The name of the attribute to retrieve values from. * @returns An array of values for the given attribute. */ getValidValuesFromExistingFeatures(attrName) { return this.hsFiltersService.getSortedUniqueValues(this.features.reduce((acc, feature) => { const value = feature.get(attrName); // Keep all non-null/undefined values and filter NaN for numbers if (value !== null && value !== undefined && (typeof value !== 'number' || !isNaN(value))) { acc.push(value); } return acc; }, [])); } /** * Creates WFSFeatureAttribute object from the existing feature values * @param attrName The name of the attribute to retrieve. * @returns An observable of WfsFeatureAttribute. */ getLocalAttributesWithValues(attrName) { const values = this.getValidValuesFromExistingFeatures(attrName); const isNumeric = !isNaN(Number(values[0])); return of({ name: attrName, type: 'unknown', isNumeric, range: isNumeric ? { min: Math.min(...values), max: Math.max(...values), } : undefined, values, }); } /** * Updates the features and attributes based on the selected layer */ updateFeatures() { const layer = this.hsFiltersService.selectedLayer?.layer; if (layer) { const src = layer.getSource(); this.features = src.getFeatures(); /** * If WFS layer is used, use the attributes from the layer descriptor, * otherwise (in styler) use the attributes from the features. */ this.attributes = this.isWfsFilter() ? this.hsFiltersService.layerAttributes.map((a) => a.name) : listAttributes(this.features, false, this.hsFiltersService.attributesExcludedFromList); } } /** * Removes the current filter from its parent or deletes the rule filter */ remove() { if (this.parent) { this.hsFiltersService.removeFilter(this.parent, this.filter); } else { this.deleteRuleFilter(); } this.emitChange(); } /** * Toggles the value source between value and property * @param source The source to toggle to */ toggleValueSource(source) { this.valueSource.set(source); this.emitChange(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsComparisonFilterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.2.3", type: HsComparisonFilterComponent, isStandalone: true, selector: "hs-comparison-filter", inputs: { filter: "filter", parent: "parent" }, viewQueries: [{ propertyName: "filterRangeInput", first: true, predicate: FilterRangeInputComponent, descendants: true, isSignal: true }], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: "<div class=\"comparison-filter-container d-flex flex-row form-group p-1 position-relative mb-0\"\n [ngClass]=\"{'expanded': expanded()}\">\n <hs-filters-attribute-selector [attributes]=\"attributes\" [formControl]=\"attributeControl\"\n (ngModelChange)=\"emitChange()\">\n </hs-filters-attribute-selector>\n\n <select class=\" form-control form-select hs-comparison-filter-operator\" style=\"width: min-content\"\n [ngModel]=\"selectedOperator()\" (change)=\"onOperatorChange($event)\" name=\"hs-sld-filter-comparison-sign\"\n [disabled]=\"!attributeControl.value\">\n @for (op of operators | async; track op.alias) {\n <option [value]=\"op.alias\">\n {{'COMMON.' + op.alias | translate}}\n </option>\n }\n </select>\n <div class=\"comparison-filter-value\">\n @if(customOperatorSelected()){\n <input class=\"form-select h-100 rounded-0\" disabled value='NULL' />\n }\n @else if(currentAttribute()?.range){\n @if(valueSource() === 'value'){\n\n <hs-filter-range-input [min]=\"currentAttribute()?.range.min\" [max]=\"currentAttribute()?.range.max\"\n [(value)]=\"_filter[2]\" (change)=\"emitChange()\">\n </hs-filter-range-input>\n } @else if(valueSource() === 'property') {\n <hs-filters-attribute-selector [attributes]=\"attributes\" [(ngModel)]=\"_filter[2]\" (ngModelChange)=\"emitChange()\">\n </hs-filters-attribute-selector>\n }\n }\n @else {\n @if(_filter[0] === '*=') {\n <input class=\" form-select h-100\" [(ngModel)]=\"_filter[2]\" (change)=\"emitChange()\"\n [disabled]=\"!attributeControl.value\" [attr.list]=\"'customValues'\" />\n <datalist id=\"customValues\">\n @for (value of currentAttribute()?.values; track value) {\n <option [value]=\"value\">\n }\n </datalist>\n } @else {\n <select class=\"form-control form-select h-100\" [(ngModel)]=\"_filter[2]\" (change)=\"emitChange()\"\n [disabled]=\"!attributeControl.value\">\n @for (value of currentAttribute()?.values; track value) {\n <option [ngValue]=\"value\">{{value}}</option>\n }\n </select>\n }\n }\n </div>\n <div>\n <button class=\"btn btn-outline-danger btn-sm rounded-0\" style=\"height: 100%\" (click)=\"remove()\">\n <span class=\"fa-solid fa-trash\"></span>\n </button>\n </div>\n\n @if (loading()) {\n <div\n class=\"loading-overlay position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center\">\n <div class=\"text-center bg-secondary-subtle d-flex p-1 px-3 rounded-5 text-center\">\n <span class=\"hs-loader hs-loader-dark\"></span>\n <div class=\"small text-muted\">{{'FILTERS.loadingAttributeValues' | translate}}...</div>\n </div>\n </div>\n }\n</div>\n@if(currentAttribute()?.range && !isWfsFilter() && !customOperatorSelected()){\n<div class=\"comparison-filter-value-source btn-group w-100 p-1 d-flex justify-content-end\">\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary p-1 border-0 rounded-0 w-25 flex-grow-0\"\n [class.active]=\"valueSource() === 'value'\" (click)=\"toggleValueSource('value')\">\n {{'COMMON.value' | translate}}\n </button>\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary p-1 border-0 rounded-0 w-25 flex-grow-0\"\n [class.active]=\"valueSource() === 'property'\" (click)=\"toggleValueSource('property')\">\n {{'COMMON.property' | translate}}\n </button>\n</div>\n}\n", styles: [":host{display:block;padding-bottom:.75rem}.comparison-filter-container{position:relative;min-height:50px}.comparison-filter-value{min-width:33%;transition:min-width .5s ease-in-out;flex-grow:1}.comparison-filter-container.expanded .hs-comparison-filter-operator{display:none}.comparison-filter-container.expanded .comparison-filter-value{min-width:70%}.loading-overlay{position:absolute;top:0;left:0;right:0;bottom:0;background-color:#f8f8f8b3;display:flex;justify-content:center;align-items:center;z-index:1000}.comparison-filter-value-source button{font-size:.7rem}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: FilterRangeInputComponent, selector: "hs-filter-range-input", inputs: ["min", "max", "value"], outputs: ["valueChange", "change"] }, { kind: "component", type: HsAttributeSelectorComponent, selector: "hs-filters-attribute-selector", inputs: ["attributes"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }, { kind: "pipe", type: AsyncPipe, name: "async" }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsComparisonFilterComponent, decorators: [{ type: Component, args: [{ imports: [ NgClass, ReactiveFormsModule, FormsModule, TranslatePipe, AsyncPipe, FilterRangeInputComponent, HsAttributeSelectorComponent, ], selector: 'hs-comparison-filter', template: "<div class=\"comparison-filter-container d-flex flex-row form-group p-1 position-relative mb-0\"\n [ngClass]=\"{'expanded': expanded()}\">\n <hs-filters-attribute-selector [attributes]=\"attributes\" [formControl]=\"attributeControl\"\n (ngModelChange)=\"emitChange()\">\n </hs-filters-attribute-selector>\n\n <select class=\" form-control form-select hs-comparison-filter-operator\" style=\"width: min-content\"\n [ngModel]=\"selectedOperator()\" (change)=\"onOperatorChange($event)\" name=\"hs-sld-filter-comparison-sign\"\n [disabled]=\"!attributeControl.value\">\n @for (op of operators | async; track op.alias) {\n <option [value]=\"op.alias\">\n {{'COMMON.' + op.alias | translate}}\n </option>\n }\n </select>\n <div class=\"comparison-filter-value\">\n @if(customOperatorSelected()){\n <input class=\"form-select h-100 rounded-0\" disabled value='NULL' />\n }\n @else if(currentAttribute()?.range){\n @if(valueSource() === 'value'){\n\n <hs-filter-range-input [min]=\"currentAttribute()?.range.min\" [max]=\"currentAttribute()?.range.max\"\n [(value)]=\"_filter[2]\" (change)=\"emitChange()\">\n </hs-filter-range-input>\n } @else if(valueSource() === 'property') {\n <hs-filters-attribute-selector [attributes]=\"attributes\" [(ngModel)]=\"_filter[2]\" (ngModelChange)=\"emitChange()\">\n </hs-filters-attribute-selector>\n }\n }\n @else {\n @if(_filter[0] === '*=') {\n <input class=\" form-select h-100\" [(ngModel)]=\"_filter[2]\" (change)=\"emitChange()\"\n [disabled]=\"!attributeControl.value\" [attr.list]=\"'customValues'\" />\n <datalist id=\"customValues\">\n @for (value of currentAttribute()?.values; track value) {\n <option [value]=\"value\">\n }\n </datalist>\n } @else {\n <select class=\"form-control form-select h-100\" [(ngModel)]=\"_filter[2]\" (change)=\"emitChange()\"\n [disabled]=\"!attributeControl.value\">\n @for (value of currentAttribute()?.values; track value) {\n <option [ngValue]=\"value\">{{value}}</option>\n }\n </select>\n }\n }\n </div>\n <div>\n <button class=\"btn btn-outline-danger btn-sm rounded-0\" style=\"height: 100%\" (click)=\"remove()\">\n <span class=\"fa-solid fa-trash\"></span>\n </button>\n </div>\n\n @if (loading()) {\n <div\n class=\"loading-overlay position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center\">\n <div class=\"text-center bg-secondary-subtle d-flex p-1 px-3 rounded-5 text-center\">\n <span class=\"hs-loader hs-loader-dark\"></span>\n <div class=\"small text-muted\">{{'FILTERS.loadingAttributeValues' | translate}}...</div>\n </div>\n </div>\n }\n</div>\n@if(currentAttribute()?.range && !isWfsFilter() && !customOperatorSelected()){\n<div class=\"comparison-filter-value-source btn-group w-100 p-1 d-flex justify-content-end\">\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary p-1 border-0 rounded-0 w-25 flex-grow-0\"\n [class.active]=\"valueSource() === 'value'\" (click)=\"toggleValueSource('value')\">\n {{'COMMON.value' | translate}}\n </button>\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary p-1 border-0 rounded-0 w-25 flex-grow-0\"\n [class.active]=\"valueSource() === 'property'\" (click)=\"toggleValueSource('property')\">\n {{'COMMON.property' | translate}}\n </button>\n</div>\n}\n", styles: [":host{display:block;padding-bottom:.75rem}.comparison-filter-container{position:relative;min-height:50px}.comparison-filter-value{min-width:33%;transition:min-width .5s ease-in-out;flex-grow:1}.comparison-filter-container.expanded .hs-comparison-filter-operator{display:none}.comparison-filter-container.expanded .comparison-filter-value{min-width:70%}.loading-overlay{position:absolute;top:0;left:0;right:0;bottom:0;background-color:#f8f8f8b3;display:flex;justify-content:center;align-items:center;z-index:1000}.comparison-filter-value-source button{font-size:.7rem}\n"] }] }], ctorParameters: () => [], propDecorators: { filter: [{ type: Input }], parent: [{ type: Input }] } }); class HsFilterComponent extends HsStylerPartBaseComponent { constructor() { super(); this.hsFiltersService = inject(HsFiltersService); } remove() { if (this.parent) { this.hsFiltersService.removeFilter(this.parent, this.filter); } this.emitChange(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsFilterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.2.3", type: HsFilterComponent, isStandalone: true, selector: "hs-filter", inputs: { filter: "filter", parent: "parent" }, usesInheritance: true, ngImport: i0, template: "<div>\n @if (hsFiltersService.isLogOp(filter)) {\n <div class=\"card m-2\"><!-- TODO: Remove function call from template -->\n <div class=\"d-flex flex-row card-header p-0\">\n <div class=\"p-2\">{{hsFiltersService.humanReadableLogOp(filter[0])}}</div>\n <!-- TODO: Remove function call from template -->\n <hs-add-filter-button (clicks)=\"hsFiltersService.add($event.type, true, filter)\"></hs-add-filter-button>\n <button class=\"btn btn-outline-danger btn-sm m-1\" [title]=\"'STYLER.removeFilter' | translate\"\n (click)=\"remove()\">\n <span class=\"fa-solid fa-trash\"></span>\n </button>\n </div>\n @for (item of filter; track item; let i = $index) {\n <div class=\"d-flex flex-row\">\n @if (i>0) {\n <hs-filter [filter]=\"item\" [parent]=\"filter\" (changes)=\"emitChange()\"></hs-filter>\n }\n </div>\n }\n </div>\n }\n @if (filter.length > 1 && !(hsFiltersService.isLogOp(filter))) {\n <hs-comparison-filter [filter]=\"filter\" (changes)=\"emitChange()\" [parent]=\"parent\"></hs-comparison-filter>\n }<!-- TODO: Remove function call from template -->\n</div>\n", styles: [":host{flex:1 1 auto}\n"], dependencies: [{ kind: "component", type: HsFilterComponent, selector: "hs-filter", inputs: ["filter", "parent"] }, { kind: "component", type: HsComparisonFilterComponent, selector: "hs-comparison-filter", inputs: ["filter", "parent"] }, { kind: "component", type: HsAddFilterButtonComponent, selector: "hs-add-filter-button", inputs: ["rule"], outputs: ["clicks"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HsFilterComponent, decorators: [{ type: Component, args: [{ selector: 'hs-filter', imports: [ HsComparisonFilterComponent, HsAddFilterButtonComponent, TranslatePipe, ], template: "<div>\n @if (hsFiltersService.isLogOp(filter)) {\n <div class=\"card m-2\"><!-- TODO: Remove function call from template -->\n <div class=\"d-flex flex-row card-header p-0\">\n <div class=\"p-2\">{{hsFiltersService.humanReadableLogOp(filter[0])}}</div>\n <!-- TODO