hslayers-ng
Version:
HSLayers-NG mapping library
779 lines (773 loc) • 56.5 kB
JavaScript
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