Source: map/layer/MeasurementsLayer/MeasurementsLayer.js

import { cloneDeep } from "lodash";

import { Layer } from "../Layer/Layer.js";
import { DataFrame } from "../../../data/DataFrame.js"
import { getColorFromScale, copyColorScale } from "../../../ui/color/Utils.js";
import { GeoJSONLayerProxy } from "../GeoJSONLayer/GeoJSONLayer.proxy.js";
import { GeoJSONLayerProxyLeaflet } from "../GeoJSONLayer/GeoJSONLayer.proxy.leaflet.js";
import { MeasurementsLayerSchema } from "./MeasurementsLayer.schema.js";
import { filterDataFrame } from "../../../data/utils.js";

export { MeasurementsLayer }

/**
 * Layer for displaying Measurements from a DataFrame Object
 * 
 * @author rhess <robin.hess@awi.de>
 * @memberof vef.map.layer
 */
class MeasurementsLayer 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) {
        // remove the dataframe to prevent exporting it when saving a viewer
        let dataFrame = config?.dataFrame || cache?.dataFrame || null
        if (config?.dataFrame) delete config.dataFrame;
        if (cache?.dataFrame) delete cache.dataFrame;

        // calling parent constructor
        super(config, cache, id);

        this.setSchema_(MeasurementsLayerSchema.getSchema());

        // remove availableCrs to always use default
        if ("availableCrs" in this.config) delete this.config.availableCrs;
        if ("availableCrs" in this.cache) delete this.cache.availableCrs;

        // override defaults based on other properties
        if (!this.defaults.title) this.defaults.title = this.columnData;

        this.dataFrameOriginal = null;
        this.dataFrame = null;

        // private properties
        this.clickedFeatures_ = [];
        this.activeGeoJSON_ = null;

        if (dataFrame) this.setDataFrame(dataFrame);

        // framework specific map implementations
        this.layerProxies_.leaflet = GeoJSONLayerProxyLeaflet;
        this.activeLayer_ = new GeoJSONLayerProxy();
        this.previousActiveLayer_ = null;
    }

    /**
     * create a map layer based on the service options
     * 
     * @param {string} type e.g. "leaflet"
     * @private
     */
    createMapLayer_(type, options) {
        const proxy = super.createMapLayer_(type, this);
        if (proxy) {
            proxy.on("layerproxy_click", (e) => this.fire("layer_click", e));
            proxy.on("layerproxy_mouseover", (e) => this.fire("layer_mouseover", e));
            proxy.on("layerproxy_mouseout", (e) => this.fire("layer_mouseout", e));
        }
        return proxy;
    }

    setDataFrame(dataFrame, groupByColumn) {
        if (groupByColumn) this.groupByColumn = groupByColumn;
        if (dataFrame instanceof DataFrame) {
            this.dataFrameOriginal = dataFrame;

            if (this.groupByColumn) {
                if (typeof this.groupByColumn === 'string') {
                    this.dataFrame = filterDataFrame(dataFrame, this.groupByColumn);
                } else if (typeof this.groupByColumn === 'function') {
                    this.dataFrame = dataFrame.filter(this.groupByColumn);
                }
            } else {
                this.dataFrame = dataFrame;
            }
        } else {
            throw new Error("invalid dataFrame");
        }
    }

    applyFilter_() {
        let dataFrame = this.dataFrame;

        if (this.filter) {
            const cols = dataFrame.columnMap;
            const defaultTimeColumn = cols["Date/Time"] || cols["date/time"] || cols["datetime"] || cols["date_time"] || cols["time"];

            dataFrame = dataFrame.filter((i, row) => {
                for (let i = 0; i < this.filter.length; ++i) {
                    let type = this.filter[i].type.toLowerCase();

                    // Use the filter.column if defined, else default time column name if no column is found and the filter.type is "time"
                    const column = this.filter[i].column 
                        ? cols[this.filter[i].column] 
                        : (type === "time" ? defaultTimeColumn : null);

                    if (column === null || column === undefined) continue;

                    if (Number.isFinite(column)) {
                        for (let j = 0; j < this.filter[i].values.length; ++j) {
                            const values = this.filter[i].values[j];
                            const dataFrameVal = ((type == "time")) ? new Date(row[column]).getTime() : row[column];
                            const filterVal1 = ((type == "time")) ? new Date(values[1]).getTime() : values[1];

                            // checks are inverted, to return false if they don't match
                            switch (values[0].toLowerCase()) {
                                case "bt":
                                    const filterVal2 = ((type == "time")) ? new Date(values[2]).getTime() : values[2];
                                    if ((dataFrameVal < filterVal1) || (dataFrameVal > filterVal2)) return false;
                                    break;
                                case "eq":
                                    if (dataFrameVal != filterVal1) return false;
                                    break;
                                case "lt":
                                    if (dataFrameVal > filterVal1) return false;
                                    break;
                                case "gt":
                                    if (dataFrameVal < filterVal1) return false;
                                    break;
                            }
                        }
                    }
                }

                return true;
            });
        }

        return new Promise(resolve => resolve(dataFrame));
    }

    /**
     * helper method for finding a column index containing a certain set of strings
     * 
     * @param {DataFrame} dataFrame to search for columns
     * @param {string | string[]} names single column name or an array of names
     */
    getColumnIndex(dataFrame, names) {
        if (!Array.isArray(names)) names = [names];
        // first iteration for exact matches
        for (let name of names) {
            if (name && (name in dataFrame.columnMap)) return dataFrame.columnMap[name];
        }
        // second iteration for checking containing strings
        for (let col in dataFrame.columnMap) {
            for (let name of names) {
                if (name && (col.toLowerCase().includes(name.toLowerCase()))) return dataFrame.columnMap[col];
            }
        }
        return null;
    }

    /**
     * helper method for finding a column name from an index
     * 
     * @param {DataFrame} dataFrame to search for columns
     * @param {number} index
     */
    getColumnName(dataFrame, index) {
        for (let col in dataFrame.columnMap) {
            if (dataFrame.columnMap[col] == index) return col;
        }
        return null;
    }

    /**
     * Helper method to update the mapLayer according to
     * the current state. Might be necessary after switching the map type
     * 
     * @private
     */
    updateMapLayer_() {
        this.applyFilter();
    }

    async createGeoJSON_() {
        this.activeGeoJSON_ = null;

        this.fire("layer_loading");
        let dataFrame = await this.applyFilter_();
        this.fire("layer_loaded");

        const geoJSON = {
            type: "FeatureCollection",
            features: []
        };

        // using fixed positions for lat/lon
        if (!this.columnLatitude || !this.columnLongitude) {
            if (!this.position) {
                console.warn("invalid lat/lon for fixed position");
                return;
            }

            let position = cloneDeep(this.position);
            if (!Array.isArray(position)) position = [position];

            position.forEach(pos => {
                geoJSON.features.push({
                    type: "Feature",
                    geometry: pos
                });
            });

            this.activeGeoJSON_ = geoJSON;
            return;
        }

        const cols = dataFrame.columnMap;
        const rows = dataFrame.rows;
        const colIndex = this.getColumnIndex(dataFrame, this.columnData);

        if (typeof colIndex != "number") {
            console.warn("invalid columnData");
            return;
        }

        const lat = this.getColumnIndex(dataFrame, [this.columnLatitude, "Latitude", "latitude", "lat"]);
        const lng = this.getColumnIndex(dataFrame, [this.columnLongitude, "Longitude", "longitude", "lng"]);

        if ((typeof lat != "number") || (typeof lng != "number")) {
            console.warn("invalid lat/lon");
            return;
        }

        for (let i = 0; i < rows.length; ++i) {
            const row = rows[i];
            if (!Number.isFinite(row[lat]) || !Number.isFinite(row[lng])) continue;

            const feature = {
                type: "Feature",
                geometry: {
                    type: "Point",
                    coordinates: [row[lng], row[lat]]
                },
                properties: {
                    color: this.getColorOptions_(row[colIndex]).color
                },
                _data: row
            }

            for (let key in cols) {
                feature.properties[key] = rows[i][cols[key]];
            }

            geoJSON.features.push(feature);
        }

        this.activeGeoJSON_ = geoJSON;
    }

    /**
     * Set on the layer. Uses generic
     * filter syntax and transforms it internally
     * 
     * @override
     */
    applyFilter() {
        this.createGeoJSON_().then(() => {
            if (this.activeGeoJSON_) this.activeLayer_.setData(this.activeGeoJSON_);
        });
    }

    /**
     * get the color options for a single marker
     * @private
     */
    getColorOptions_(value) {
        if (Number.isFinite(value) && this.colorScale && (this.colorScale.length > 0)) {
            const color = getColorFromScale(this.colorScale, value);
            return {
                color: color.color,
                opacity: this.opacity,
                fillOpacity: this.opacity * this.opacityRatio,
                stroke: false
            }
        } else {
            return {
                color: '#3388ff',
                opacity: this.opacity,
                fillOpacity: this.opacity * this.opacityRatio,
                stroke: false
            }
        }
    }

    /**
     * Set the colorscale for the layer
     * 
     * @param colorScale 
     */
    setColorScale(colorScale) {
        this.colorScale = colorScale;

        if (this.activeGeoJSON_) {
            this.activeGeoJSON_.features.forEach(feature => {
                const value = (this.columnData in feature.properties) ? feature.properties[this.columnData] : null;
                const style = this.getColorOptions_(value);
                feature.properties.color = style.color;
            });
            this.activeLayer_.updateStyle();
        }
    }

    /**
     * Get the active colorscale for the layer
     */
    getColorScale() {
        return (this.colorScale) ? copyColorScale(this.colorScale) : null;
    }

    getOptions() {
        const options = super.getOptions();
        options.type = "measurement";
        return options;
    }

    /**
     * Get Info of the last clicked features
     * @returns {Promise} feature info
     */
    getFeatureInfo() {
        return new Promise(resolve => {
            resolve(this.activeLayer_.getClickedFeatures());
        });
    }

    /**
     * Set the transparency between 0 and 1
     * @override
     * @param {number} opacity
     */
    setOpacity(opacity) {
        this.opacity = opacity;
        this.activeLayer_.updateStyle();
    }

    /**
     * Get the bounding box as an object
     * 
     * @returns {object} { min: {x, y}, max: {x, y}, crs: "CRS:84" }
     */
    getBounds() {
        const bounds = this.activeLayer_.getBounds();
        return (bounds) ? bounds : super.getBounds();
    }

}