Source: dial.js

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

/**
 * Class representing an SVG Dial widget.
 * @class
 */
class Dial extends Widget {

  /**
   * @constructor
   * @param {object} container - DOM container for the widget.
   * @param {object=} o - options.
   * @param {number=0} o.minVal - Minimum value constraint.
   * @param {number=127} o.maxVal - Maximum value constraint.
   * @param {number=1} o.step - Interval of the steps in which the dial changes value. 
   * @param {string="#000"} o.needleColor - Dial needle color.
   * @param {string="#f40"} o.activeColor - Dial active color.
   * @param {number=0.2} o.arcThicknessAspect - The aspect of the arc thickness. 
   */
  constructor(container, o) {
    super(container, o);
    return this;
  }

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

  /**
   * Returns the dial value.
   * @public @override
   * @returns {number} - Value of the dial.
   */
  getVal() {
    return this.state.val;
  }

  /**
   * Sets the dial value.
   * Same as setVal(), but will not trigger observer callbacks.
   * @public @override
   * @param {number} newVal - The new value.
   */
  setInternalVal(newVal) {
    this.setInternalState({ val: newVal });
  }

  /**
   * Sets the dial value.
   * Same as setInternalVal(), but will trigger observer callbacks.
   * @public @override
   * @param {number} newVal - The new value.
   */
  setVal(newVal) {
    this.setState({ val: newVal });
  }

  /**
   * Sets the options. 
   * @public @override
   * @param {object} o - Options.
   */
  setOptions(o) {
    super.setOptions(o);
    this.o.stepPrecision = MathUtil.getPrecision(this.o.step);
  }

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

  /**
   * Initializes the options.
   * @override
   * @private
   */
  _initOptions(o) {
    // set the defaults
    this.o = {
      minVal: 0,
      maxVal: 127,
      step: 1,
      needleColor: "#414141",
      activeColor: "#f40",
      arcThicknessAspect: 0.2,
      mouseSensitivity: 1.2
    };

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

    // set the precision based on the step interval
    this.o.stepPrecision =  MathUtil.getPrecision(this.o.step);
  }

  /**
   * Initializes state constraints.
   * @override
   * @private
   */
  _initStateConstraints() {
    const _this = this;

    this.stateConstraints = new ConstraintSpec({
      val: new Constraint({
        min: _this.o.minVal,
        max: _this.o.maxVal,
        transform: num => MathUtil.quantize(num, _this.o.step, _this.o.stepPrecision)
      })
    });
  }

  /**
   * Initializes state.
   * @override
   * @private
   */
  _initState() {
    this.state = {
      val: 0
    };
  }

  /**
   * Initializes the svg elements.
   * @override
   * @private
   */
  _initSvgEls() {
    const _this = this;

    this.svgEls = {
      bgArc: document.createElementNS(this.SVG_NS, "path"),
      activeArc: document.createElementNS(this.SVG_NS, "path"),
      needle: document.createElementNS(this.SVG_NS, "line")
    };

    // draw the background arc
    this.svgEls.bgArc.setAttribute("d",
      _this._calcSvgArcPath(
        _this._calcNeedleCenter().x,
        _this._calcNeedleCenter().y,
        _this._calcDialRadius(),
        0.67 * Math.PI,
        2.35 * Math.PI
    ));
    this.svgEls.bgArc.setAttribute("stroke-width", _this._calcArcStrokeWidth());
    this.svgEls.bgArc.setAttribute("stroke", _this.o.needleColor);
    this.svgEls.bgArc.setAttribute("fill", "transparent");
    this.svgEls.bgArc.setAttribute("stroke-linecap", "round");

    // draw the active arc
    this.svgEls.activeArc.setAttribute("stroke-width", _this._calcArcStrokeWidth());
    this.svgEls.activeArc.setAttribute("stroke", _this.o.activeColor);
    this.svgEls.activeArc.setAttribute("fill", "transparent");
    this.svgEls.activeArc.setAttribute("stroke-linecap", "round");

    // draw the needle
    this.svgEls.needle.setAttribute("x1", _this._calcNeedleCenter().x);
    this.svgEls.needle.setAttribute("y1", _this._calcNeedleCenter().y);
    this.svgEls.needle.setAttribute("x2", _this._calcNeedleEnd().x);
    this.svgEls.needle.setAttribute("y2", _this._calcNeedleEnd().y);
    this.svgEls.needle.setAttribute("stroke-width", _this._calcNeedleWidth());
    this.svgEls.needle.setAttribute("stroke", _this.o.needleColor);
    this.svgEls.needle.setAttribute("z-index", "1000");
    this.svgEls.needle.setAttribute("stroke-linecap", "round");

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

  /**
   * Initializes mouse and touch event handlers.
   * @override
   * @private
   */
   _initHandlers() {
      const _this = this;

      let y0 = 0;
      let yD = 0;
      let newVal = _this.getState().val;

      this.handlers = {

       touch: function(ev) {
          y0 = ev.clientY;

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

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

          yD = y0 - ev.clientY;
          y0 = ev.clientY;

          newVal = _this.state.val + (yD * _this.o.mouseSensitivity * _this._calcMovePrecision());
          newVal = Math.max(newVal, _this.o.minVal);
          newVal = Math.min(newVal, _this.o.maxVal);

          _this.setState({
            val: newVal
          });
        },

        release: function() {
          document.removeEventListener("mousemove", _this.handlers.move);
          document.removeEventListener("touchmove", _this.handlers.move);
        }
      };

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

  /**
   * Updates (redraws) components based on state.
   * @override
   * @private
   */
   _update() {
     // change the needle angle
     this.svgEls.needle.setAttribute("x1", this._calcNeedleCenter().x);
     this.svgEls.needle.setAttribute("y1", this._calcNeedleCenter().y);
     this.svgEls.needle.setAttribute("x2", this._calcNeedleEnd().x);
     this.svgEls.needle.setAttribute("y2", this._calcNeedleEnd().y);

     // change the active arc length
     this.svgEls.activeArc.setAttribute("d",
       this._calcSvgArcPath(
         this._calcNeedleCenter().x,
         this._calcNeedleCenter().y,
         this._calcDialRadius(),
         0.65 * Math.PI,
         this._calcNeedleAngle() - 0.5 * Math.PI
     ));

     // if the value is at min, change the color to match needle color
     // - otherwise the active part will be visible beneath the needle
     if (this.state.val === this.o.minVal) {
       this.svgEls.activeArc.setAttribute("stroke", this.o.needleColor);
     } else {
       this.svgEls.activeArc.setAttribute("stroke", this.o.activeColor);
     }
   }

  /* ==============================================================================================
  *  INTERNAL FUNCTIONALITY METHODS
  */

   /** 
    * Calcultes the stroke width for the background and active arcs.
    * @private
    * @returns {number} - Arc stroke width;
    */
   _calcArcStrokeWidth() {
     return this._calcDialRadius() * this.o.arcThicknessAspect;
   }

   /** 
    * Calculates the dial radius.
    * @private
    * @returns {number} - Radius of the dial.
    */
   _calcDialRadius() {
     let radius = (Math.min(this._getWidth(), this._getHeight()) / 2) * 0.89;
     radius = Math.trunc(radius);
     return radius;
   }

   /** 
    * Calculates the needle angle for a given state val.
    * @private
    * @returns {number} - Angle of the needle.
    */
   _calcNeedleAngle() {
     const _this = this;

     return (
              // protect against divide by 0:
              ((this.o.maxVal - _this.o.minVal) !== 0) ?
                  (  
                    (_this.state.val - _this.o.minVal) / (_this.o.maxVal - _this.o.minVal) * (1.7 * Math.PI) + 
                    (1.15 * Math.PI)
                  )  
                : ( 0.5 * (1.7 * Math.PI) + (1.15 * Math.PI) )
            );
   }

   /** 
    * Calculates the center of the needle.
    * @private
    * @returns {object} - {x, y} object representing the needle center coordinates.
    */
   _calcNeedleCenter() {
     const _this = this;
     return {
       x: Math.trunc(_this._getWidth() / 2),
       y: Math.trunc(_this._getHeight() / 2)
     };
   }

   /** 
    * Calculates the position of end of the needle
    * @private
    * @returns {object} - {x, y} object representing the end of the needle. 
    */
   _calcNeedleEnd() {
     const _this = this;
     return {
       x: _this._calcNeedleCenter().x + (Math.sin(_this._calcNeedleAngle()) * _this._calcDialRadius()),
       y: _this._calcNeedleCenter().y - (Math.cos(_this._calcNeedleAngle()) * _this._calcDialRadius())
     };
   }

   /** 
    * Calculates the needle width.
    * @private
    * @returns {number} - The width of the needle in px.
    */
   _calcNeedleWidth() {
     return this._calcDialRadius() / 5;
   }

   /** 
    * Calculates the path for an svg arc based on cx, cy, r, startAngle, endAngle.
    * The input parameters are the way arcs are represented in HTML canvas.
    * @private
    * @param {number} cx - Center X.
    * @param {number} cy - Center Y.
    * @param {number} r - Radius.
    * @param {number} startAngle - Start angle in radians.
    * @param {number} endAngle - End angle in radians.
    * @returns {string} - A string to be used for the arc path by an SVG arc object.
    */
   _calcSvgArcPath(cx, cy, r, startAngle, endAngle) {
     let x1 = cx + (r * Math.cos(startAngle));
     let y1 = cy + (r * Math.sin(startAngle));
     let x2 = cx + (r * Math.cos(endAngle));
     let y2 = cy + (r * Math.sin(endAngle));
     let largeArc = (endAngle - startAngle) < Math.PI ? 0 : 1;
     let sweep = (endAngle - startAngle) < Math.PI ? 1 : 1;

     return ["M", x1, y1, "A", r, r, 0, largeArc, sweep, x2, y2].join(" ");
   }

   /**
    * Calculates the precision with which the state value changes when moved.
    */
   _calcMovePrecision() {
     let precision = (this.o.maxVal - this.o.minVal) / 127;
     return precision;
   }
}

export default Dial;