Source: multislider.js

import Widget from "ui/core/widget";
import Constraint from "util/constraint";
import ConstraintSpec from "util/constraint-def";

/**
 * Class representing a Multislider widget.
 * @class
 * @implements {Widget}
 */
class Multislider extends Widget {

  /**
   * @constructor
   * @param {object} container - DOM container for the widget.
   * @param {object} [o] - Options.
   * @param {number} [o.numSliders=10] - Number of sliders.
   * @param {number} [o.minVal=0] - Minimum slider value.
   * @param {number} [o.maxVal=127] - Maximum slider value.
   * @param {array<string>} [o.sliderColors=["#000"]] - Slider colors, specified as an array of color values.
   *                                                    e.g. to get a black-white-black-white zebra pattern, specify
   *                                                    ['black', 'white']
   * @param {string} [o.backgroundColor="#fff"] - Background color.
   */
  constructor(container, o) {
    super(container, o);
    return this;
  }

  /* ===========================================================================
  *  INITIALIZATION METHODS
  */

  /**
   * Initialize the options
   * @override
   * @protected
   */
  _initOptions(o) {
    // set the defaults
    this.o = {
      numSliders: 10,
      minVal: 0,
      maxVal: 127,
      sliderColors: ["#f40", "#f50"],
      backgroundColor: "#fff",
      mouseSensitivity: 1.2
    };

    // override defaults with provided options
    super._initOptions(o);
  }

  /**
   * Initialize state constraints
   * @override
   * @protected
   */
  _initStateConstraints() {
    const _this = this;

    this.stateConstraints = new ConstraintSpec({
      sliderVals: [new Constraint({ 
        min: _this.o.minVal, 
        max: _this.o.maxVal,
        transform: (num) => num.toFixed(0) 
      })]
    });
  }

  /**
   * Initialize state
   * @override
   * @protected
   */
  _initState() {
    this.state = {
      sliderVals: []
    };
  }

  /**
   * Initialize the svg elements
   * @override
   * @protected
   */
  _initSvgEls() {
    const _this = this;

    this.svgEls = {
      panel: document.createElementNS(this.SVG_NS, "rect"),
      sliders: [],
      sliderPanels: []
    };

    this._appendSvgEls();
    this._update();
  }

  /**
   * Initialize mouse and touch event handlers
   * @override
   * @protected
   */
  _initHandlers() {
    const _this = this;

    this.handlers = {
      touch: function touch(ev) {
        ev.preventDefault();

        let y = _this._getHeight() - _this._getRelativeY(ev.clientY);

        _this._setSliderVal(ev.target, y);

        for (let i = 0; i < _this.svgEls.sliderPanels.length; ++i) {
          _this.svgEls.sliderPanels[i].addEventListener("mousemove", _this.handlers.move);
          _this.svgEls.sliderPanels[i].addEventListener("touchmove", _this.handlers.move);

        }

        document.addEventListener("mouseup", _this.handlers.release);
        document.addEventListener("touchend", _this.handlers.release);
      },

      move: function move(ev) {
        ev.preventDefault();
              
        let y = _this._getHeight() - _this._getRelativeY(ev.clientY);
        _this._setSliderVal(ev.target, y);
      },

      release: function release(ev) {
        ev.preventDefault();

        for (let i = 0; i < _this.svgEls.sliderPanels.length; ++i) {
          _this.svgEls.sliderPanels[i].removeEventListener("mousemove", _this.handlers.move);
          _this.svgEls.sliderPanels[i].removeEventListener("touchmove", _this.handlers.move);
        }
      }
    };

    for (let i = 0; i < this.svgEls.sliderPanels.length; ++i) {
      this.svgEls.sliderPanels[i].addEventListener("mousedown", this.handlers.touch);
      this.svgEls.sliderPanels[i].addEventListener("touchstart", this.handlers.touch);
    }
  }

  /**
   * Update (redraw) component based on state.
   * @override
   * @protected
   */
  _update() {
    const _this = this;

    _this._updateEls();
  
    for (let i = 0; i < this.o.numSliders; ++i) {
      let sliderPos = _this._calcSliderPos(i);

      this.svgEls.sliders[i].setAttribute("x", sliderPos.x);
      this.svgEls.sliders[i].setAttribute("y", sliderPos.y);
      this.svgEls.sliders[i].setAttribute("width", _this._calcSliderWidth());
      this.svgEls.sliders[i].setAttribute("height", _this._calcSliderHeight(i));
      this.svgEls.sliders[i].setAttribute("fill", this.o.sliderColors[i % this.o.sliderColors.length]);

      this.svgEls.sliderPanels[i].setAttribute("x", sliderPos.x);
      this.svgEls.sliderPanels[i].setAttribute("y", 0);
      this.svgEls.sliderPanels[i].setAttribute("width", _this._calcSliderWidth());
      this.svgEls.sliderPanels[i].setAttribute("height", _this._getHeight());
      this.svgEls.sliderPanels[i].setAttribute("fill", "transparent");
    }

    // set background panel color
    this.svgEls.panel.setAttribute("x", 0);
    this.svgEls.panel.setAttribute("y", 0);
    this.svgEls.panel.setAttribute("width", _this._getWidth());
    this.svgEls.panel.setAttribute("height", _this._getHeight());
    this.svgEls.panel.setAttribute("fill", this.o.backgroundColor);
  }

  /**
   * Updates the SVG elements and state containers. 
   * Adds or removes a number of SVG elements and state containers to match the current number of sliders.
   * @private
   */
  _updateEls() {
    let numSliders = this.o.numSliders;

    // add SVG elements representing sliders to match current number of sliders
    for (let i = this.state.sliderVals.length; i < numSliders; ++i) {
      this.state.sliderVals.push(this.o.minVal);
      this._addSvgSlider();
    }

    // remove SVG elements representing sliders to match current number of sliders
    for (let i = this.state.sliderVals.length; i > numSliders; --i) {
      this.state.sliderVals.pop();
      this._removeSvgSlider();
    }
  }

  /* ===========================================================================
  *  PUBLIC API
  */

  /**
   * Get public representation of the state.
   * @abstract
   * @public
   * @returns {array} - An array of slider values.
   */
  getVal() {
    return this.getState().sliderVals;
  }

  /**
   * Set the current state in a format specific to each widget.
   * Same as setVal(), but will not cause an observer callback trigger.
   * @abstract @public
   * @param {array} newSliderVals - An array representing the new slider values
   */
  setInternalVal(newSliderVals) {
    let newState = {
      sliderVals: newSliderVals
    };
    this.setInternalState(newState);
  }

  /**
   * Set the current state in a format specific to each widget.
   * Same as setInternalVal(), but will cause an observer callback trigger.
   * @abstract @public
   * @param {array} newSliderVals - An array representing the new slider values
   */
  setVal(newSliderVals) {
    let newState = {
      sliderVals: newSliderVals
    };
    this.setState(newState);
  }

  /* ===========================================================================
  *  HELPER METHODS
  */

  /**
   * Adds an svg element representing a slider.
   * @private 
   */
  _addSvgSlider() {
    let _this = this;

    let newSlider = document.createElementNS(this.SVG_NS, "rect");
    let newSliderPanel = document.createElementNS(this.SVG_NS, "rect");
    this.svg.appendChild(newSlider);
    this.svg.appendChild(newSliderPanel);
    this.svgEls.sliders.push(newSlider);
    this.svgEls.sliderPanels.push(newSliderPanel);

    newSliderPanel.addEventListener("mousedown", _this.handlers.touch);
    newSliderPanel.addEventListener("touchstart", _this.handlers.touch);
  }

  /**
   * Remove an SVG slider element.
   * @private 
   */
  _removeSvgSlider() {
    let targetSlider = this.svgEls.sliders.pop();
    let targetSliderPanel = this.svgEls.sliderPanels.pop();
    this.svg.removeChild(targetSliderPanel);
    this.svg.removeChild(targetSlider);
    targetSlider = null;
    targetSliderPanel = null;
  }

  /**
   * Calculate the width of each slider.
   * Each slider's width is width of multislider / number of sliders.
   * @private
   * @returns {number} - Width of each slider in px. 
   */
  _calcSliderWidth() {
    return this._getWidth() / this.o.numSliders;
  }

  /**
   * Calculate the position for a given slider.
   * @private
   * @param {number} idx - Index of the slider (left to right).
   * @returns {object} - Object representing the {x, y} position.
   */
  _calcSliderPos(idx) {
    const _this = this;

    return {
      x: _this._calcSliderWidth() * idx, 
      y: _this._getHeight() - _this._calcSliderHeight(idx)
    };
  }

  /**
   * Calculate the slider height.
   * @private
   * @param {number} idx - Index of the slider.
   * @returns {number} - Height of the slider in px.
   */
  _calcSliderHeight(idx) {
    return (this.state.sliderVals[idx] / (this.o.maxVal - this.o.minVal)) * this._getHeight(); 
  }

  /**
   * Set value for a slider based on y position of a click event.
   * @param {object} targetSliderPanel - The panel that registered the event. 
   * @param {number} y - Y-position of the event. 
   */
  _setSliderVal(targetSliderPanel, y) {
    const _this = this;
    
    let targetIdx = this.svgEls.sliderPanels.findIndex(sliderPanel => sliderPanel === targetSliderPanel);
    let newVal = (y / (this._getHeight())) * (this.o.maxVal - this.o.minVal) + this.o.minVal;

    let newState = {
      sliderVals: _this.state.sliderVals.map((val, idx) => {
        return (idx === targetIdx) ? newVal : val;
      })
    };

    this.setState(newState);
  }
}

export default Multislider;