ui/sliderWidget.js

var svgWidget = require('./svgWidget');
var inherit = require('../inherit');
var registerWidget = require('../registry').registerWidget;

/**
 * @typedef {geo.gui.widget.spec} geo.gui.sliderWidget.spec
 * @extends {geo.gui.widget.spec}
 * @property {number} [width=20] The width of the slider in pixels.
 * @property {number} [height=160] The height of the slider in pixels.  The
 *   actual bar is `height - 3 * width`.
 */

/**
 * Create a new instance of class sliderWidget.
 *
 * @class
 * @alias geo.gui.sliderWidget
 * @extends geo.gui.svgWidget
 * @param {geo.gui.sliderWidget.spec} arg Options for the widget.
 * @returns {geo.gui.sliderWidget}
 */
var sliderWidget = function (arg) {
  'use strict';
  if (!(this instanceof sliderWidget)) {
    return new sliderWidget(arg);
  }
  svgWidget.call(this, arg);

  var d3 = require('../svg/svgRenderer').d3;
  var geo_event = require('../event');

  var m_this = this,
      s_exit = this._exit,
      m_xscale,
      m_yscale,
      m_plus,
      m_minus,
      m_nub,
      m_width = arg.width || 20, // Size of the widget in pixels
      m_height = arg.height || 160,  // slider height + 3 * width
      m_nubSize = arg.width ? arg.width * 0.5 : 10,
      m_plusIcon,
      m_minusIcon,
      m_group,
      m_lowContrast,
      m_highlightDur = 100;

  /* http://icomoon.io */
  /* CC BY 3.0 http://creativecommons.org/licenses/by/3.0/ */
  m_plusIcon = 'M512 81.92c-237.568 0-430.080 192.614-430.080 430.080 0 237.568 192.563 430.080 430.080 430.080s430.080-192.563 430.080-430.080c0-237.517-192.563-430.080-430.080-430.080zM564.326 564.326v206.182h-104.653v-206.182h-206.234v-104.653h206.182v-206.234h104.704v206.182h206.182v104.704h-206.182z';
  m_minusIcon = 'M512 81.92c-237.568 0-430.080 192.614-430.080 430.080 0 237.568 192.563 430.080 430.080 430.080s430.080-192.563 430.080-430.080c0-237.517-192.563-430.080-430.080-430.080zM770.56 459.674v104.704h-517.12v-104.704h517.12z';

  // Define off-white gray colors for low contrast ui (unselected).
  m_lowContrast = {
    white: '#f4f4f4',
    black: '#505050'
  };

  /**
   * Add an icon from a path string.  Returns a d3 group element.
   *
   * @param {string} icon svg path string.
   * @param {d3Selection} base where to append the element.
   * @param {number} cx Center x-coordinate.
   * @param {number} cy Center y-coordinate.
   * @param {number} size Icon size in pixels.
   * @returns {d3GroupElement}
   */
  function put_icon(icon, base, cx, cy, size) {
    var g = base.append('g');

    // the scale factor
    var s = size / 1024;

    g.append('g')
      .append('g')
        .attr(
          'transform',
          'translate(' + cx + ',' + cy + ') scale(' + s + ') translate(-512,-512)'
        )
      .append('path')
        .attr('d', icon)
        .attr('class', 'geo-glyphicon');

    return g;
  }

  /**
   * Return the size of the widget.
   *
   * @returns {geo.screenSize}
   */
  this.size = function () {
    return {width: m_width, height: m_height};
  };

  /**
   * Initialize the slider widget.
   *
   * @returns {this}
   */
  this._init = function () {
    m_this._createCanvas();
    m_this._appendCanvasToParent();

    m_this.reposition();

    var svg = d3.select(m_this.canvas()),
        map = m_this.layer().map();

    svg.attr('width', m_width).attr('height', m_height);

    // create d3 scales for positioning
    // TODO: make customizable and responsive
    m_xscale = d3.scaleLinear().domain([-4, 4]).range([0, m_width]);
    m_yscale = d3.scaleLinear().domain([0, 1]).range([m_width * 1.5, m_height - m_width * 1.5]);

    // Create the main group element
    svg = svg.append('g').classed('geo-ui-slider', true);
    m_group = svg;

    // Create + zoom button
    m_plus = svg.append('g');
    m_plus.append('circle')
      .datum({
        fill: 'white',
        stroke: null
      })
      .classed('geo-zoom-in', true)
      .attr('cx', m_xscale(0))
      .attr('cy', m_yscale(0.0) - m_width + 2)
      .attr('r', (m_width - 2) / 2)
      .style('cursor', 'pointer')
      .on('click', function () {
        var z = map.zoom();
        map.transition({
          zoom: z + 1,
          ease: d3.easeCubicInOut,
          duration: 500
        });
      })
      .on('mousedown', function (evt) {
        evt.stopPropagation();
      });

    put_icon(
      m_plusIcon,
      m_plus,
      m_xscale(0),
      m_yscale(0) - m_width + 2,
      m_width + 4
    ).style('cursor', 'pointer')
      .style('pointer-events', 'none')
      .select('path')
      .datum({
        fill: 'black',
        stroke: null
      });

    // Create the - zoom button
    m_minus = svg.append('g');
    m_minus.append('circle')
      .datum({
        fill: 'white',
        stroke: null
      })
      .classed('geo-zoom-out', true)
      .attr('cx', m_xscale(0))
      .attr('cy', m_yscale(1.0) + m_width - 2)
      .attr('r', (m_width - 2) / 2)
      .style('cursor', 'pointer')
      .on('click', function () {
        var z = map.zoom();
        map.transition({
          zoom: z - 1,
          ease: d3.easeCubicInOut,
          duration: 500
        });
      })
      .on('mousedown', function (evt) {
        evt.stopPropagation();
      });

    put_icon(
      m_minusIcon,
      m_minus,
      m_xscale(0),
      m_yscale(1) + m_width - 2,
      m_width + 4
    ).style('cursor', 'pointer')
      .style('pointer-events', 'none')
      .select('path')
      .datum({
        fill: 'black',
        stroke: null
      });

    /**
     * Respond to a mouse event on the widget.
     *
     * @param {d3Event} evt The event on the widget.
     * @param {boolean} [trans] Truthy for an animated transition.
     */
    function respond(evt, trans) {
      var z = m_yscale.invert(d3.pointer(event, svg.node())[1]),
          zrange = map.zoomRange();
      z = (1 - z) * (zrange.max - zrange.min) + zrange.min;
      if (trans) {
        map.transition({
          zoom: z,
          ease: d3.easeCubicInOut,
          duration: 500,
          done: m_this._update()
        });
      } else {
        map.zoom(z);
        m_this._update();
      }
      evt.stopPropagation();
    }

    // Create the track
    svg.append('rect')
      .datum({
        fill: 'white',
        stroke: 'black'
      })
      .classed('geo-zoom-track', true)
      .attr('x', m_xscale(0) - m_width / 6)
      .attr('y', m_yscale(0))
      .attr('rx', m_width / 10)
      .attr('ry', m_width / 10)
      .attr('width', m_width / 3)
      .attr('height', m_height - m_width * 3)
      .style('cursor', 'pointer')
      .on('click', function (evt) {
        respond(evt, true);
      });

    // Create the nub
    m_nub = svg.append('rect')
      .datum({
        fill: 'black',
        stroke: null
      })
      .classed('geo-zoom-nub', true)
      .attr('x', m_xscale(-4))
      .attr('y', m_yscale(0.5) - m_nubSize / 2)
      .attr('rx', 3)
      .attr('ry', 3)
      .attr('width', m_width)
      .attr('height', m_nubSize)
      .style('cursor', 'pointer')
      .on('mousedown', function (evt) {
        d3.select(document).on('mousemove.geo.slider', function () {
          respond(evt);
        });
        d3.select(document).on('mouseup.geo.slider', function () {
          respond(evt);
          d3.select(document).on('.geo.slider', null);
        });
        evt.stopPropagation();
      });

    /**
     * When the mouse is over the widget, change the style.
     */
    function mouseOver() {
      d3.select(this).attr('filter', 'url(#geo-highlight)');
      m_group.selectAll('rect,path,circle').transition()
        .duration(m_highlightDur)
        .style('fill', function (d) {
          return d.fill || null;
        })
        .style('stroke', function (d) {
          return d.stroke || null;
        });
    }

    /**
     * When the mouse is no longer over the widget, change the style.
     */
    function mouseOut() {
      d3.select(this).attr('filter', null);
      m_group.selectAll('circle,rect,path').transition()
        .duration(m_highlightDur)
        .style('fill', function (d) {
          return m_lowContrast[d.fill] || null;
        })
        .style('stroke', function (d) {
          return m_lowContrast[d.stroke] || null;
        });
    }

    m_group.selectAll('*')
      .on('mouseover', mouseOver)
      .on('mouseout', mouseOut);

    // Update the nub position on zoom
    m_this.geoOn(geo_event.zoom, m_this._update);

    mouseOut();
    m_this._update();
    return m_this;
  };

  /**
   * Removes the slider element from the map and unbinds all handlers.
   */
  this._exit = function () {
    m_this.geoOff(geo_event.zoom, m_this._update);
    m_group.remove();
    s_exit();
  };

  /**
   * Update the slider widget state in response to map changes.  I.e., zoom
   * range changes.
   *
   * @param {object} [obj] An object that can specify a zoom value.
   * @param {number} [obj.zoom] The new zoom value to show on the slider.
   */
  this._update = function (obj) {
    var map = m_this.layer().map(),
        zoomRange = map.zoomRange(),
        zoom = map.zoom(),
        zoomScale = d3.scaleLinear();

    obj = obj || {};
    zoom = obj.value || zoom;
    zoomScale.domain([zoomRange.min, zoomRange.max])
      .range([1, 0])
      .clamp(true);

    m_nub.attr('y', m_yscale(zoomScale(zoom)) - m_nubSize / 2);
  };
};

inherit(sliderWidget, svgWidget);

registerWidget('dom', 'slider', sliderWidget);
module.exports = sliderWidget;