Source: ui/color/ColorSlider.js

import { UiElement } from "../UiElement.js";
import { generateCssGradient, copyColorScale, stretchColorScale, invertColorScale } from "./Utils.js";
import "./ColorSlider.css";

export { ColorSlider }

/**
 * color scale slider for changing the values of a color scale.
 * Used internally by ColorScalePicker
 * 
 * @author rhess <robin.hess@awi.de>
 * @memberof vef.ui.color
 */
class ColorSlider extends UiElement {

    /**
     * @param {HTMLElement | string} target 
     * @param {object[]} colorScale initial color scale
     * 
     * events:
     *   slide: on dragging handles with mouse
     *   stop: stop dragging handles with mouse
     *   select: click/select handle with mouse
     *   deselect: no handle is selected anymore
     */
    constructor(target, colorScale) {
        super(target, {
            'slide': [],
            'stop': [],
            'select': [],
            'deselect': [],
            'update': []
        });

        this.handles_ = [];
        this.min_ = 0;
        this.max_ = 1;
        this.slider_ = document.createElement("div");
        this.selectedHandle_ = null;

        // create the element
        const element = this.getElement();
        element.classList.add("color-slider-container");
        this.slider_.classList.add("color-slider");
        element.appendChild(this.slider_);

        this.setColorScale(colorScale || [
            {
                value: 0,
                opacity: 1,
                color: "#000000"
            },
            {
                value: 1,
                opacity: 1,
                color: "#ffffff"
            }
        ]);

        // update slider scale when the window gets resized
        const resizeObserver = new ResizeObserver(() => this.update());
        resizeObserver.observe(this.slider_);
    }

    /**
     * stretch values linear from min to max
     * 
     * @param {number} min 
     * @param {number} max 
     */
    stretchToScale(min, max) {
        stretchColorScale(this.handles_, min, max);
        if (this.selectedHandle_) this.selectHandle(this.selectedHandle_);

        this.update(true);
    }

    /**
     * Invert the color scale value order
     * 
     * @param {number} min 
     * @param {number} max 
     */
    invertScale() {
        invertColorScale(this.handles_);
        this.handles_.sort((a, b) => a.value - b.value);
        if (this.selectedHandle_) this.selectHandle(this.selectedHandle_);

        this.update(true);
    }

    /**
     * get the currently selected handle object
     * 
     * @returns {object}
     */
    getSelectedHandle() {
        return this.selectedHandle_;
    }

    /**
     * Get a handle objec by index
     * 
     * @param {number} index 
     * @returns {object}
     */
    getHandle(index) {
        return this.handles_[index];
    }

    /**
     * Get the amount of items/handles
     * 
     * @returns {number}
     */
    getLength() {
        return this.handles_.length;
    }

    /**
     * Select a handle object and trigger the "select" event
     * 
     * @param {object} handle 
     */
    selectHandle(handle) {
        // move handle to front
        for (let i in this.handles_) {
            this.handles_[i].element.style["box-shadow"] = "unset";
        }
        this.slider_.appendChild(handle.element);
        handle.element.style["box-shadow"] = "0 0 0 1px" + handle.color;
        this.selectedHandle_ = handle;

        this.fire("select", this);
    }

    /**
     * set the ColorSlider to deselected and trigger
     * the "deselect" event
     */
    deselectHandle() {
        // move handle to front
        for (let i in this.handles_) {
            this.handles_[i].element.style["box-shadow"] = "unset";
        }
        this.selectedHandle_ = null;

        this.fire("deselect", this);
    }

    /**
     * get the current color scale as an array
     * 
     * @returns [object[]]
     */
    getColorScale() {
        return copyColorScale(this.handles_);
    }

    /**
     * Set the ColorSlider Values from an existing
     * color scale array
     * 
     * @param {object[]} colorScale 
     */
    setColorScale(colorScale) {
        this.clear();
        if (colorScale.length > 0) {
            this.min_ = colorScale[0].value;
            this.max_ = colorScale[0].value;
            for (let i = 0; i < colorScale.length; ++i) {
                this.addHandle(colorScale[i]);
            }
        }
    }

    /**
     * Add a ne handle (color-item)
     * to the ColorSlider
     * 
     * options = {
     *   color: "#000000",
     *   value: 0,
     *   opacity: 1
     * }
     * 
     * @param {object} options 
     */
    addHandle(options) {
        const handle = {
            value: options.value || this.max_,
            color: options.color || "#000000",
            opacity: options.opacity || 1,
            element: document.createElement("div")
        };

        handle.element.classList.add("handle");
        this.slider_.appendChild(handle.element);

        let down, width, startPosition, valueExtent;
        const mousemove = (event) => {
            if (!down) return;

            let position = event.clientX - startPosition;
            if (event.clientX < startPosition) {
                position = 0;
            } else if (event.clientX > startPosition + width) {
                position = width;
            }

            handle.value = this.min_ + (valueExtent * (position / width));
            handle.element.style.left = position + 'px';
            this.updateGradient_();
            this.fire('slide', this);
        };

        const mousedown = (event) => {
            event.preventDefault();

            const sliderRect = this.slider_.getBoundingClientRect();
            const handleRect = handle.element.getBoundingClientRect();

            down = true;
            width = this.slider_.offsetWidth;
            startPosition = sliderRect.x;
            valueExtent = this.max_ - this.min_;

            document.addEventListener('mouseup', mouseup);
            document.addEventListener('mousemove', mousemove);

            this.selectHandle(handle);
        };

        const mouseup = () => {
            down = false;
            document.removeEventListener('mousemove', mousemove);
            document.removeEventListener('mouseup', mouseup);
            this.updateGradient_();
            this.fire('stop', this);
        };

        handle.element.addEventListener('mousedown', mousedown);

        this.handles_.push(handle);
        this.update();

        return handle;
    }

    /**
     * Remove a handle object from the ColorSlider
     * 
     * @param {object} handle 
     */
    removeHandle(handle) {
        if (this.selectedHandle_ == handle) this.deselectHandle();

        this.handles_.splice(this.handles_.indexOf(handle), 1);

        handle.element.remove();

        handle.value = null;
        handle.color = null;
        handle.opacity = null;
        handle.element = null

        this.updateGradient_();
    }

    /**
     * Remove al handles/color-ítems
     */
    clear() {
        // remove in reverse order
        for (let i = this.handles_.length - 1; i >= 0; --i) {
            this.removeHandle(this.handles_[i]);
        }
        this.updateGradient_();
    }

    /**
     * re-render the color gradient using css
     * 
     * @private
     */
    updateGradient_() {
        this.handles_.sort((a, b) => a.value - b.value);
        const gradient = (this.handles_.length > 0) ? generateCssGradient(this.handles_, this.min_, this.max_) : "";
        this.slider_.style.background = gradient;
    }

    /**
     * Update the positions of the handles based on their values and
     * re-render the color gradient
     * 
     * @param {boolean} autoMinMax stretch min/max to value extent
     */
    update(autoMinMax) {
        if (this.handles_.length == 0) return;

        let min = this.handles_[0].value;
        let max = this.min_;

        for (let i = 0; i < this.handles_.length; ++i) {
            if (this.handles_[i].value > max) max = this.handles_[i].value;
            if (this.handles_[i].value < min) min = this.handles_[i].value;
        }

        this.min_ = (autoMinMax || (min < this.min_)) ? min : this.min_;
        this.max_ = (autoMinMax || (max > this.max_)) ? max : this.max_;

        const rangeSize = this.slider_.offsetWidth;
        const valueRange = this.max_ - this.min_;

        const valueToPosition = (handle) => {
            const position = rangeSize * ((handle.value - this.min_) / valueRange);
            handle.element.style.left = position + "px";
        };

        for (let i = 0; i < this.handles_.length; ++i) {
            valueToPosition(this.handles_[i]);
            this.handles_[i].element.style.background = this.handles_[i].color;
        }

        this.updateGradient_();
        this.fire("update", this);
    }

}