Source: map/layer/ServiceLayer/ServiceLayer.js

import { Layer } from "../Layer/Layer.js";
import { FilterBuilder } from "../../filters/FilterBuilder.js";
import { ServiceLayerSchema } from "./ServiceLayer.schema.js";
import { getUniqueValuesFromWPS } from "../../utils/getUniqueValuesFromWPS.js";

export { ServiceLayer }

/**
 * Abstract Service Layer Implementation
 * 
 * @author rhess <robin.hess@awi.de>
 * @author sjaswal <shahzeib.jaswal@awi.de>
 * @memberof vef.map.layer
 */
class ServiceLayer extends Layer {

    /**
     * Set all properties for the layer based on the options object.
     * 
     * @param {object} config 
     * @param {object} cache 
     * @param {string} id 
     */
    constructor(config, cache, id) {
        // calling parent constructor
        super(config, cache, id);

        // init default values and define getters and setters
        this.setSchema_(ServiceLayerSchema.getSchema());

        // init getters and setters for configs required in this.config
        // to prevent saving to "cache" or "default"
        this.addConfig_({
            queryParams: {},
            staticFilter: []
        });

        this.filter = config.filter || [];

        this.timeColumns_ = ["time", "datetime", "date_time", "date_time_start", "date_time_end", "begin_date", "end_date", "dateTimeStart", "dateTimeEnd", "beginDate", "endDate", "DATA_DATETIME"];
        this.getometryColumns_ = ["geometry", "geom", "the_geom", "geom_multi"];

        // current messages for possible info about unapplied filters
        this.filterMessages_ = [];
    }

    get capabilitiesUrl() {
        return this.serviceUrl + `?REQUEST=GetCapabilities&SERVICE=${this.type.toUpperCase()}&VERSION=${this.serviceVersion}`;
    }

    /**
     * Get all active messages
     * 
     * @returns {object[]} messages
     */
    getMessages() {
        // implementation of layer-specific messages needs to be done in child classes
        return [...this.messages_, ...this.filterMessages_];
    }

    /**
     * Get Layer name for making requests in case names differ for each projection
     */
    getRequestName() {
        if (typeof this.name == "string") {
            return this.name;
        } else if (typeof this.name == "object") {
            if (this.crs_ in this.name) {
                return this.name[this.crs_];
            } else if ("default" in this.name) {
                return this.name["default"];
            }
        }
        return "";
    }

    /**
     * Iterates through the array of timestamps and returns the
     * one closest to the given value
     * 
     * @param {string} value 
     * @param {number[]} timestamps 
     * @param {string[]} isoTimestamps 
     */
    getClosestTimestamp_(value, timestamps, isoTimestamps) {
        value = new Date(value).getTime();
        let closestIndex = 0;
        let closestDistance = Math.abs(timestamps[0] - value);
        for (let i = 0; i < timestamps.length; ++i) {
            const distance = Math.abs(timestamps[i] - value);
            if (distance < closestDistance) {
                closestDistance = distance;
                closestIndex = i;
            }
        }
        return isoTimestamps[closestIndex];
    }

    /**
     * Adjusts time filter values according to the dimension config
     * 
     * @param {object} filter filter values
     * 
     * @private
     */
    adjustTimeValues_(filter, dimension) {
        if (!dimension) return;
        // only execute if any of the used properties of "dimension" is set
        if (!dimension.range && !dimension.resolution && !dimension.defaultTime && !dimension.values && !dimension.operators) return;

        for (let i = 0; i < filter.values.length; ++i) {
            const values = filter.values[i];
            const originalValues = values.join(", ");

            // override the values if a range is given and set it to a single day
            if (dimension.range == "day") {
                for (let j = 1; j < values.length; ++j) {
                    if (values[0] == "bt") {
                        const day = values[2].substring(0, 10);
                        values[1] = day + "T00:00:00Z";
                        values[2] = day + "T23:59:59Z";
                    }
                }
            }

            // apply timestamp resolution and defaultTime settings
            if (dimension.resolution || dimension.defaultTime) {
                for (let j = 1; j < values.length; ++j) {
                    if (dimension.resolution == "date") {
                        values[j] = values[j].substring(0, 10);
                    } else if (dimension.defaultTime) {
                        values[j] = values[j].substring(0, 10) + "T" + dimension.defaultTime;
                    }
                }
            }

            if (Array.isArray(dimension.values) && (dimension.values.length > 0)) {
                // if values are set, a single value is matched from the list. dimension.mode is ignored
                const timeValues = [];
                for (let i = 0; i < dimension.values.length; ++i) {
                    if (dimension.values[i].length == 24) {
                        timeValues.push(new Date(dimension.values[i]).getTime());
                    }
                }
                filter.values[i] = ["eq", this.getClosestTimestamp_(values[1], timeValues, dimension.values)];
            } else if (Array.isArray(dimension.operators) && (dimension.operators.length > 0)) {
                if (!dimension.operators.includes(values[0])) {
                    const operator = dimension.operators[0];
                    if (operator == "bt") {
                        const begin = values[1].substring(0, 10) + "T00:00:00Z";
                        const end = values[1].substring(0, 10) + "T23:59:59Z";
                        filter.values[i] = [operator, begin, end];
                    } else {
                        const index = (values[0] == "bt") ? 2 : 1;
                        filter.values[i] = [operator, values[index]];
                    }
                }
            }

            let newValues = values.join(", ")
            if (originalValues != newValues) {
                switch (values[0]) {
                    case "bt":
                        newValues = "range from <b>" + values[1] + "</b> to <b>" + values[2] + "</b>";
                        break;
                    case "eq":
                        newValues = "single timestamp <b>" + values[0] + "</b>";
                        break;
                    case "lt":
                        newValues = "less than <b>" + values[0] + "</b>";
                        break;
                    case "gt":
                        newValues = "greater than <b>" + values[0] + "</b>";
                        break;
                    case "lteq":
                        newValues = "less than or equal to <b>" + values[0] + "</b>";
                        break;
                    case "gteq":
                        newValues = "greater than or equal to <b>" + values[0] + "</b>";
                        break;
                }

                this.filterMessages_.push({
                    level: 0,
                    content: "Time filter adjusted to:<br/>" + newValues + "."
                });
            }

        }

        return filter;
    }

    adjustTimeColumn_(filter) {
        let columns = this.timeColumns_;

        // Combine dimensions and attributeFields into a single array of keys and filter them as fallback columns containing "date" or "time"
        columns.push(
            ...Object.keys({ ...this.dimensions, ...this.attributeFields }).filter(key => /date|time/i.test(key))
        );

        for (let i = 0; i < columns.length; ++i) {
            if (columns[i] in this.dimensions) {
                filter.column = columns[i];
                return this.dimensions[filter.column];
            } else if (columns[i] in this.attributeFields) {
                filter.column = columns[i];
                return this.attributeFields[filter.column];
            }
        }

        this.filterMessages_.push({
            level: 0,
            content: "No valid time column found for the filter."
        });
    }

    /**
     * Automatically detect the geometry column if a column name is not set
     * 
     * @private
     * @param {*} f filter 
     */
    adjustGeometryColumn_(f) {
        f.column = this.getometryColumns_.find(col => col in this.attributeFields)
            || Object.keys(this.attributeFields).find(key => key.toLowerCase().includes("geom"));

        if (!f.column) {
            this.filterMessages_.push({
                level: 0,
                content: "No valid geometry column found for the filter."
            });
        }
    }

    applyColumnMapping(filter) {
        const mapping = this?.mapping?.filter?.mapping?.properties;
        if (!mapping) return;
        const column = filter.column;
        if (column in mapping) {
            filter.column = mapping[column];
            this.filterMessages_.push({
                level: 0,
                content: `The filtered column <b>${column}</b> was mapped to the layers internal column <b>${filter.column}</b>`
            });
        }
    }

    /**
     * Handles the error message for attribute filtering.
     * @private
     * @param {String} attributeName 
     * @param {String|null} reasonId 
     */
    showAttributeErrorMessage_(attributeName, reasonId = null) {

        const baseMessage = `Cannot be filtered by attribute <b>${attributeName}</b>`;
        const reasons = {
            'reason#0': 'This layer is excluded in the filter configuration.',
            'reason#1': 'It is disabled in the layer configuration.'
        };

        const content = [baseMessage, reasonId ? ': ' + reasons[reasonId] : '.'].join('')

        this.filterMessages_.push({
            'level': 0,
            'content': content
        });

        if (this.throwFilterErrors) {
            throw Error(content);
        }
    }

    /**
     * Gets the filter URL params based on the
     * Filter config and the layer's dimensions
     * @private
     * @param {object[]} config
     */
    createFilterParams_(config) {
        this.filterMessages_ = [];

        if (!Array.isArray(config)) config = [];

        // merge the static filters and copy config so the original is unchanged
        config = JSON.parse(JSON.stringify(this.staticFilter.concat(config)));

        const params = {};
        const attributeFilters = [];

        for (let f of config) {
            // apply filter column mapping
            this.applyColumnMapping(f);

            // find and assign the time dimension/attribute
            if (f?.type?.toLowerCase() == "time") {
                // only adjust the time column if it is not already set
                if (!f.column) {
                    // mapping of possible time column names if a filter.column is not set
                    const timeColumn = this.adjustTimeColumn_(f);
                    // adjust the time values according to the dimension config
                    this.adjustTimeValues_(f, timeColumn);
                } else {
                    // adjust the time values according to the dimension config
                    this.adjustTimeValues_(f, this.dimensions[f.column] || this.attributeFields[f.column]);
                }
                if (!f.column) continue;
                // Automatically detect the geometry column and a column name is not set
            } else if (!f.column && (f?.type?.toLowerCase() == "geometry")) {
                this.adjustGeometryColumn_(f);
                if (!f.column) continue;
            }

            if (f.column in this.dimensions) {
                const dimension = this.dimensions[f.column];
                // adjust the colum name according to the mapping
                f.column = dimension.name || f.column;
                if (!dimension.disabled) {
                    if (dimension.type == "attribute_field") {
                        // fallback for older configs, where a dimensions is not absolutely a WMS dimension filter 
                        attributeFilters.push(f);
                    } else {
                        Object.assign(params, FilterBuilder.getFilterParams([f], this.serviceSoftware, this.type, "dimension", this.name));
                    }
                } else {
                    this.showAttributeErrorMessage_(f.column, 'reason#1')
                }
            } else if (f.column in this.attributeFields) {
                const field = this.attributeFields[f.column];
                if (!field.disabled) {
                    attributeFilters.push(f);
                } else {
                    this.showAttributeErrorMessage_(f.column, 'reason#1')
                }
            } else if (!f.column && Array.isArray(f.values)) {
                // syntax [operator, column, values, ...] not supported for type "dimension"
                const values = [];
                for (let i = 0; i < f.values.length; ++i) {
                    const col = f.values[i][1];
                    if ((col in this.dimensions) && (this.dimensions[col].type == "attribute_field")) {

                        if (!this.dimensions[col].disabled) {
                            // adjust the colum name according to the mapping
                            f.values[i][1] = this.dimensions[col].name || col;
                            values.push(f.values[i]);
                        } else {
                            this.showAttributeErrorMessage_(col, 'reason#1')
                        }
                    } else if (col in this.attributeFields) {
                        if (!this.attributeFields[col].disabled) {
                            values.push(f.values[i]);
                        } else {
                            this.showAttributeErrorMessage_(col, 'reason#1')
                        }
                    } else {
                        this.showAttributeErrorMessage_(col)
                    }
                }
                if (values.length) attributeFilters.push({
                    type: f.type,
                    column: null,
                    values: values
                });
            } else {
                this.showAttributeErrorMessage_(f.column)
            }

        }

        Object.assign(params, FilterBuilder.getFilterParams(attributeFilters, this.serviceSoftware, this.type, this.filterType, this.name));

        this.fire("layer_message", this.getMessages())

        // merge the params with static parameters
        return params;
    }

    async getUniqueValues(column) {
        if (!this.config.attributeCache) this.config.attributeCache = {};
        if (!this.updatedUniqueValuesAtRuntime_) this.updatedUniqueValuesAtRuntime_ = [];

        const fetchValues_ = async () => {
            const values = await getUniqueValuesFromWPS(this.serviceUrl, this.getRequestName(), column);
            this.config.attributeCache[column] = values
            this.updatedUniqueValuesAtRuntime_.push(column);
            return values;
        };

        const getCachedValues_ = () => {
            if (this.config.attributeCache[column]) {
                return this.config.attributeCache[column];
            } else {
                return [];
            };
        };

        try {
            if (this.updatedUniqueValuesAtRuntime_.includes(column)) {
                return getCachedValues_();
            } else {
                return await fetchValues_();
            }
        } catch (error) {
            console.error("Error fetching unique values from WPS:", error);
            return getCachedValues_();
        }
    }
}