Source: map/filters/ui/RangeFilter.js

import { SliderHeader } from "../../../ui/slider/SliderHeader.js";
import { Slider } from "../../../ui/slider/Slider.js";
import { FilterUi } from "./FilterUi.js";
import { roundNumber } from "../../../utils/utils.js";
import { FilterSettings } from "../FilterSettings.js";
import { EditableTable } from "../../../ui/table/EditableTable.js";
import { Dropdown } from "../../../ui/Dropdown.js";

export { RangeFilter };

/**
 * A class that defines the Ui for a Slider-based Range Filter
 * 
 * @author rhess <robin.hess@awi.de>
 * @memberof vef.map.filters.ui
 */
class RangeFilter extends FilterUi {

    /**
     * @param {HTMLElement | string} target
     * @param {object} options filter specific options
     * @param {LayerManager} layers Used for included/excluded layers
     */
    constructor(target, options, layers) {

        // apply default options
        options = Object.assign({
            title: "Filter By Range",
            allowShowNullValues: true,
            showNullValues: true,
            columns: [{
                title: "Depth",
                column: "depth",
                decimals: 0,
                begin: 0,
                end: 1000,
            }],
            selectedIndex: 0,
            operators: [
                "-",
                "=",
                ">",
                "≥",
                "<",
                "≤"
            ],
            initialOperator: "-",
            initialBegin: 0,
            initialEnd: 1000,
        }, options || {});

        super(target, options, layers);

        this.slider = null;
        this.columnDropdown = null;
        this.settingsClass_ = RangeFilterSettings;
        this.currentValue_ = null
        this.operator_ = options.initialOperator || options.operators[0];

        const container = this.getContentContainer();
        container.classList.add("range-filter");
        container.style.paddingBottom = "5px";

        this.initSlider_();
        this.setTitle(this.options_.title);
        this.initShowNullButton_();
    }

    get column() {
        return this.options_.columns?.[this.options_.selectedIndex]?.column || "";
    }

    get decimals() {
        return parseFloat(this.options_.columns?.[this.options_.selectedIndex]?.decimals || 0);
    }

    get begin() {
        return parseFloat(this.options_.columns?.[this.options_.selectedIndex]?.begin || 0);
    }

    get end() {
        return parseFloat(this.options_.columns?.[this.options_.selectedIndex]?.end || 100);
    }

    /**
     * Init a slider header and its events including
     * validation of the input
     * 
     * @param {Slider} slider
     * @param {string} type
     * @param {string[]} operators
     * @returns {SliderHeader} header
     */
    initHeader_(slider) {
        const header = new SliderHeader(null, {
            title: "VALUES",
            operators: this.options_.operators,
            defaultOperator: this.operator_,
            useInput: true,
            inputPattern: String.raw`^[\-+]?[0-9]*(?:[.,][0-9]+)?$`
        });

        const validCharacters = "-+0123456789.,";
        const removeInvalidCharacters = (str) => {
            for (let i = str.length - 1; i >= 0; --i) {
                if (!validCharacters.includes(str[i])) str = str.replace(str[i], "");
            }
            return str;
        }

        // validation and auto-complete minus character or end
        header.on("input", (e) => {
            let value = removeInvalidCharacters(e.value);
            if (value.includes(",")) value = value.replaceAll(",", ".");
            if (value.startsWith(".")) value = "0" + value;

            // remove additional decimal seperators
            const parts = value.split(".");
            if (parts.length > 2) {
                value = "";
                let addedSeperator = false;
                for (let i = 0; i < parts.length; ++i) {
                    value += parts[i];
                    if (!addedSeperator) {
                        value += ".";
                        addedSeperator = true;
                    }
                }
            }

            header.setValue(e.type, value);
        });

        // operator selection
        header.on("select", operator => {
            this.operator_ = operator;
            this.initSlider_();
            this.fire("change", this);
        });

        // apply the header value to the slider
        header.on("change", value => this.parseHeaderValue_(value));

        // insert before slider
        slider.getElement().insertAdjacentElement("beforebegin", header.getElement())

        return header;
    }

    initColumnSelection_() {
        const options = this.options_;
        if (this.columnDropdown) this.columnDropdown.dispose();
        if (options.columns.length <= 1) {
            return;
        }
        if (options.selectedIndex >= options.columns.length) options.selectedIndex = 0;

        this.columnDropdown = new Dropdown(null, {
            placeholder: "select a column...",
            deselectLabel: null,
            showDeselectItem: false,
            search: false,
        });

        const items = {}
        for (let i = 0; i < options.columns.length; ++i) {
            items[options.columns[i].title] = i;
        }

        this.columnDropdown.setItems(items);
        this.columnDropdown.select(options.columns[options.selectedIndex].title);

        this.columnDropdown.on("select", e => {
            options.selectedIndex = e.item;
            options.initialBegin = this.begin;
            options.initialEnd = this.end;
            this.currentValue_ = null;
            this.reloadOptions_();
        });

        const element = this.columnDropdown.getElement();
        element.style.marginBottom = "10px";
        this.content_.insertAdjacentElement("afterbegin", element);
    }

    /**
     * internal method for initializing the slider.
     * the slider gets re-initialized everytime the
     * operator changes.
     * 
     * @private
     */
    initSlider_() {
        const options = this.options_;

        this.initColumnSelection_();

        // adjust operator
        if (!options.operators.includes(this.operator_)) {
            if (options.operators.length == 0) options.operators = ["-"];
            this.operator_ = options.operators[0];
        }

        // remove slider
        if (this.slider_) this.slider_.dispose();
        this.slider_ = null;

        const sliderOptions = {
            min: this.begin,
            max: this.end
        }

        const previousLeft = (Number.isFinite(this.currentValue_) || (this.currentValue_ == null)) ? this.currentValue_ : this.currentValue_.left;
        const previousRight = (Number.isFinite(this.currentValue_) || (this.currentValue_ == null)) ? null : this.currentValue_.right;

        if ("-" == this.operator_) {
            sliderOptions.handles = 2;
            sliderOptions.value = {
                left: previousLeft || options.initialBegin || this.begin,
                right: previousRight || options.initialEnd || this.end
            }
        } else {
            sliderOptions.handles = 1;
            sliderOptions.value = previousLeft || options.initialBegin || this.begin;
        }

        const container = document.createElement("div");
        const slider = new Slider(container, sliderOptions);
        const header = this.initHeader_(slider)

        const updateValue = val => {
            let values = val;

            if (Number.isFinite(val)) {
                values = roundNumber(val, this.decimals)
            } else {
                values = {
                    left: roundNumber(val.left, this.decimals),
                    right: roundNumber(val.right, this.decimals),
                };
            }

            header.setValues({
                begin: (Number.isFinite(values)) ? values : values.left,
                end: (Number.isFinite(values)) ? values : values.right
            });

            this.currentValue_ = values;
        };

        const dispose = () => {
            header.dispose();
            slider.dispose();
            container.remove();
        };

        const item = {
            container: container,
            header: header,
            slider: slider,
            dispose: dispose
        };

        slider.on("change", (value) => {
            updateValue(value);
        });
        slider.on("stop", (value) => {
            updateValue(value);
            this.fire("change", this);
        });
        slider.on("arrow_right", handleName => this.increment(handleName));
        slider.on("arrow_left", handleName => this.decrement(handleName));
        header.on("arrow_up", inputName => this.increment((inputName == "begin") ? "left" : "right"));
        header.on("arrow_down", inputName => this.decrement((inputName == "begin") ? "left" : "right"));

        updateValue(slider.getValue());
        this.slider_ = item;
        this.getContentContainer().appendChild(container);
    }

    reloadOptions_() {
        this.setTitle(this.options_.title);
        this.toggleToolVisibility("btn-power", this.options_.deactivatable);
        this.toggleToolVisibility("btn-show-null", this.options_.allowShowNullValues);
        this.initSlider_();
        this.fire("change", this);
    }

    increment(handle) {
        const value = this.slider_.slider.getValue();
        if (Number.isFinite(value)) {
            this.slider_.slider.setValue(value + 1);
        } else {
            ++value[handle];
            this.slider_.slider.setValue(value);
        }
        this.fire("change", this);
    }

    decrement(handle) {
        const value = this.slider_.slider.getValue();
        if (Number.isFinite(value)) {
            this.slider_.slider.setValue(value - 1);
        } else {
            --value[handle];
            this.slider_.slider.setValue(value);
        }
        this.fire("change", this);
    }

    /**
     * @private
     * @param {object} value 
     */
    parseHeaderValue_(value) {
        const slider = this.slider_.slider;
        const val = parseFloat(value.value);

        if (slider.options_.handles == 2) {
            const sliderValue = slider.getValue();
            if (value.type == "begin") {
                sliderValue.left = val;
            } else {
                sliderValue.right = val;
            }
            slider.setValue(sliderValue);
        } else {
            slider.setValue(val);
        }

        slider.stop_();
    }

    /**
     * Get the filter object to pass it on to the Layers.
     * 
     * @override
     * @returns {object} filter object
     */
    getActiveFilter() {
        let values = null;
        switch (this.operator_) {
            case ">":
                values = [["gt", this.currentValue_]];
                break;
            case "≥":
                values = [["gteq", this.currentValue_]];
                break;
            case "<":
                values = [["lt", this.currentValue_]];
                break;
            case "≤":
                values = [["lteq", this.currentValue_]];
                break;
            case "=":
                values = [["eq", this.currentValue_]];
                break;
            case "-":
                values = [["bt", this.currentValue_.left, this.currentValue_.right]];
                break;
        }

        if (!values) return {};
        if ((values.length > 0) && this.options_.showNullValues) values.push(["null"])

        return {
            type: "attribute",
            column: this.column,
            values: values,
            excludedLayers: this.getExcludedLayers()
        };
    }

    /**
     * Get the filter's options-object
     */
    getOptions() {
        const activeFilterValues = this.getActiveFilter().values[0];
        const operator = this.operator_;
        const options = super.getOptions();

        options.initialOperator = operator;

        // assign active values
        if (operator == "-") {
            options.initialBegin = activeFilterValues[1];
            options.initialEnd = activeFilterValues[2];
        } else {
            options.initialBegin = activeFilterValues[1];
            options.initialEnd = null;
        }

        return options;
    }
}

/**
 * A class that defines the Ui for a Slider-based Range Filter
 * 
 * @memberof vef.map.filters.ui
 * @private
 */
class RangeFilterSettings extends FilterSettings {

    htmlExtension = `
        <div class="table-container"></div>
        <div>
            <label class="input-label" style="width: 80px;">Operators:</label>
            <label title="equals" style="margin-right:20px;"><input type="checkbox" name="operator-equals"/> =</label>
            <label title="range" style="margin-right:20px;"><input type="checkbox" name="operator-range"/> -</label>
            <label title="greater than" style="margin-right:20px;"><input type="checkbox" name="operator-gt"/> &gt;</label>
            <label title="greater thanor equal to" style="margin-right:20px;"><input type="checkbox" name="operator-gteq"/> ≥</label>
            <label title="less than"><input type="checkbox" name="operator-lt"/> &lt;</label>
            <label title="less than or equal to"><input type="checkbox" name="operator-lteq"/> ≤</label>
        </div>
        <div><label><input type="checkbox" name="allow-show-null-values"/> Show "include NULL values" Button</label></div>
    `;

    /**
     * @param {FilterUi} filter
     */
    constructor(filter) {
        super(filter);

        this.query("form").insertAdjacentHTML("beforeend", this.htmlExtension);

        this.table = new EditableTable(this.query(".table-container"), [
            {
                key: "title",
                name: "Title"
            },
            {
                key: "column",
                name: "Column"
            },
            {
                key: "decimals",
                name: "Decimals"
            },
            {
                key: "begin",
                name: "Begin"
            },
            {
                key: "end",
                name: "End"
            }
        ], filter.options_.columns);

    }

    /**
     * Get the currently configured filter options
     */
    getFilterOptions() {
        const options = super.getFilterOptions();
        options.columns = this.table.getValues();
        options.allowShowNullValues = this.query("form input[name='allow-show-null-values']").checked;

        const operators = [];
        if (this.query("form input[name='operator-equals']").checked) operators.push("=");
        if (this.query("form input[name='operator-range']").checked) operators.push("-");
        if (this.query("form input[name='operator-gt']").checked) operators.push(">");
        if (this.query("form input[name='operator-gteq']").checked) operators.push("≥");
        if (this.query("form input[name='operator-lt']").checked) operators.push("<");
        if (this.query("form input[name='operator-lteq']").checked) operators.push("≤");
        options.operators = operators;

        return options;
    }

    /**
     * Update Ui based on the filters current options
     */
    updateFilterOptions() {
        super.updateFilterOptions();
        const options = this.filter.options_;

        this.table.clearTable();
        this.table.Row(options.columns);

        this.query("form input[name='allow-show-null-values']").checked = options.allowShowNullValues;
        this.query("form input[name='operator-equals']").checked = options.operators.includes("=");
        this.query("form input[name='operator-range']").checked = options.operators.includes("-");
        this.query("form input[name='operator-gt']").checked = options.operators.includes(">");
        this.query("form input[name='operator-gteq']").checked = options.operators.includes("≥");
        this.query("form input[name='operator-lt']").checked = options.operators.includes("<");
        this.query("form input[name='operator-lteq']").checked = options.operators.includes("≤");
    }

}