ui/colorLegendWidget.js

var d3 = require('../svg/svgRenderer').d3;
var domWidget = require('./domWidget');
var inherit = require('../inherit');
var registerWidget = require('../registry').registerWidget;
var util = require('../util');
var uniqueID = require('../svg/uniqueID');

require('./colorLegendWidget.styl');

/**
 * @typedef {object} geo.gui.colorLegendWidget.category
 * @property {string} name The text label of the legend.
 * @property {string} type The type of the legend, either discrete or continuous.
 * @property {string} scale The scale of the legend. For discrete type,
 * linear, log, sqrt, pow, ordinal, and quantile is supported.
 * For continuous type, linear, log, sqrt, and pow is supported.
 * @property {number[]|string[]} domain Only for ordinal scale legend, string
 * values are acceptable. For ordinal legend, the number in the domain array
 * should be the same number of colors. For quantile scale legend, the domain
 * should be an array of all values. For other scales, the domain needs to be
 * an array of two number for marking the upper bound and lower bound.
 * This domain property will be used with d3 scale object internally.
 * @property {geo.geoColor[]} colors The colors of the legend.
 * All valid svg color can be used. For discrete type, multiple values
 * are accepted. For continuous type, an array of two values is supported.
 * @property {number} [base] The base of log when log scale is used.
 * default to 10.
 * @property {number} [exponent] The exponent of power when power scale is used.
 * default to 1.
 * @property {boolean} [endAxisLabelOnly] Only show left most and right most
 * axis label
 * default to null.
 */

/**
 * A UI widget that enables display discrete colors or two-color continuous
 *  transition legend.
 *
 * @class
 * @alias geo.gui.colorLegendWidget
 * @extends geo.gui.domWidget
 * @param {object} [arg] Widget options.
 * @param {geo.gui.widget.position} [arg.position] Position setting relatively to the map
 * container.
 * @param {geo.gui.colorLegendWidget.category[]} [arg.categories] An array
 * of category definitions for the initial color legends
 * @param {number} [arg.width=300] The width of the widget in pixels.
 * @param {number} [arg.ticks=6] The maximum number of ticks on the axis of a legend, default is 6.
 * @returns {geo.gui.colorLegendWidget}
 */
var colorLegendWidget = function (arg) {
  'use strict';
  if (!(this instanceof colorLegendWidget)) {
    return new colorLegendWidget(arg);
  }

  domWidget.call(this, arg);

  var m_this = this,
      m_categories = [],
      m_width = arg.width || 300,
      m_ticks = arg.ticks || 6,
      s_init = this._init;
  // get the widget container ready
  this._init = function () {
    s_init();
    var canvas = m_this.canvas();
    d3.select(canvas)
      .attr('class', 'color-legend-container');

    m_this.popup = d3.select(canvas).append('div')
      .attr('class', 'color-legend-popup');

    if (arg.categories) {
      m_this.categories(arg.categories);
    }
  };

  /**
   * Clear the DOM container and create legends.
   */
  this._draw = function () {
    d3.select(m_this.canvas()).selectAll('div.geojs-color-legends').remove();

    if (!m_categories.length) {
      d3.select(m_this.canvas()).style('display', 'none');
      return;
    } else {
      d3.select(m_this.canvas()).style('display', 'block');
    }

    var container = d3.select(m_this.canvas())
      .append('div')
      .attr('class', 'geojs-color-legends');

    var width = m_width;
    var margin = 20;

    m_categories.forEach(function (category, index) {
      var legendContainer = container
        .append('div')
        .attr('class', 'geojs-color-legend');

      legendContainer
        .append('div')
        .attr('class', 'geojs-title')
        .text(category.name);

      var legendSvg = legendContainer
        .append('svg')
        .attr('class', 'svg')
        .attr('width', width)
        .attr('height', '40px')
        .attr('viewBox', -margin + ' 0 ' + width + ' 40');
      if (category.type === 'discrete') {
        m_this._drawDiscrete(legendSvg, width - 2 * margin, category);
      } else if (category.type === 'continuous') {
        m_this._drawContinous(legendSvg, width - 2 * margin, category);
      }
    });

  };

  /**
   * Get or set categories.
   * @param {geo.gui.colorLegendWidget.category[]} [categories] If `undefined`,
   * return the current legend categories array. If an array is provided,
   * remove current legends and recreate with the new categories.
   * @returns {geo.gui.colorLegendWidget.category[]|this}
   * The current list of categories or the current class instance.
   */
  this.categories = function (categories) {
    if (categories === undefined) {
      return m_categories;
    }
    m_categories = m_this._prepareCategories(categories);
    m_this._draw();
    return m_this;
  };

  /**
   * Add additional categories.
   * @param {geo.gui.colorLegendWidget.category[]} categories Append additional
   * legend categories to the end the of the current list of legends.
   * @returns {this} The current class instance.
   */
  this.addCategories = function (categories) {
    m_categories = m_categories.concat(m_this._prepareCategories(categories));
    m_this._draw();
    return m_this;
  };

  /**
   * Remove categories.
   *
   * @param {geo.gui.colorLegendWidget.category[]} categories If a category
   * object exists in the current legend categories, that category will be
   * removed.
   * @returns {this} The current class instance.
   */
  this.removeCategories = function (categories) {
    m_categories = m_categories.filter(function (category) {
      return categories.indexOf(category) === -1;
    });
    m_this._draw();
    return m_this;
  };

  /**
   * This function normalize color input string with the utility function. It modifies the original object.
   * @param {geo.gui.colorLegendWidget.category[]} categories The categories
   * @returns {geo.gui.colorLegendWidget.category[]} prepared categories
   */
  this._prepareCategories = function (categories) {
    categories.forEach(function (category) {
      category.color = category.colors.map(function (color) {
        return util.convertColorToHex(color, true);
      });
    });
    return categories;
  };

  /**
   * Draw an individual discrete type legend.
   * @param {Element} svg svg element that the legend will be drawn
   * @param {number} width width of the svg element in pixel
   * @param {geo.gui.colorLegendWidget.category} category The discrete type legend category
   */
  this._drawDiscrete = function (svg, width, category) {
    /**
     * Render the d3 axis object based on the axis d3 Scale.
     * @param {object} axisScale d3 scale object
     * @returns {object} d3 axis object
     */
    function createAxis(axisScale) {
      var skip = Math.ceil(axisScale.domain().length / m_ticks);
      var values = axisScale.domain().filter(function (d, i) { return i % skip === 0; });
      return d3.axisBottom(axisScale)
        .tickFormat(d3.format('.2s'))
        .tickValues(values);
    }

    if (['linear', 'log', 'sqrt', 'pow', 'quantile', 'ordinal'].indexOf(category.scale) === -1) {
      throw new Error('unsupported scale');
    }
    var valueRange, valueScale, colorScale, axisScale, axis, steps, ticks;
    if (category.scale === 'ordinal') {
      colorScale = d3.scaleOrdinal()
        .domain(category.domain)
        .range(category.colors);
      m_this._renderDiscreteColors(
        svg, category.domain, colorScale, width, function (d) { return d; });

      axisScale = d3.scaleBand()
        .domain(category.domain)
        .rangeRound([0, width]);
      var skip = Math.ceil(category.domain.length / m_ticks);
      var values = category.domain.filter(function (d, i) { return i % skip === 0; });
      axis = d3.axisBottom(axisScale).tickValues(values);
    } else if (category.scale === 'quantile') {
      valueRange = [0, category.colors.length];
      steps = util.range(0, category.colors.length - 1);
      valueScale = d3.scaleQuantile().domain(category.domain).range(steps);
      colorScale = d3.scaleQuantize().domain(valueRange).range(category.colors);
      m_this._renderDiscreteColors(svg, steps, colorScale, width, function (d) {
        return valueScale.invertExtent(d).join(' - ');
      });

      var axisDomain = [valueScale.invertExtent(0)[0]];
      axisDomain = axisDomain.concat(steps.map(
        function (step) { return valueScale.invertExtent(step)[1]; }));

      axisScale = d3.scalePoint()
        .domain(axisDomain)
        .range([0, width]);
      axis = createAxis(axisScale);
    } else if (['linear', 'log', 'sqrt', 'pow'].indexOf(category.scale) !== -1) {
      valueRange = [0, category.colors.length];
      valueScale = d3['scale' + category.scale.charAt(0).toUpperCase() + category.scale.slice(1)]()
        .domain(category.domain).range(valueRange).nice();
      colorScale = d3.scaleQuantize().domain(valueRange).range(category.colors);
      steps = util.range(0, category.colors.length - 1);
      var precision = Math.max.apply(null, category.domain
        .map(function (number) { return getPrecision(number); }));
      m_this._renderDiscreteColors(svg, steps, colorScale, width, function (d) {
        return m_this._popupFormatter(valueScale.invert(d), precision)
          + ' - ' + m_this._popupFormatter(valueScale.invert(d + 1), precision);
      });

      ticks = steps.slice();
      ticks.push(category.colors.length);
      axisScale = d3.scalePoint()
        .domain(ticks.map(function (tick) {
          return valueScale.invert(tick);
        }))
        .range([0, width]);
      axis = createAxis(axisScale);
    }
    if (category.endAxisLabelOnly) {
      axis.tickValues([axisScale.domain()[0], axisScale.domain()[axisScale.domain().length - 1]]);
    }
    m_this._renderAxis(svg, axis);
  };

  /**
   * Render colors for discrete type with d3.
   * @param {Element} svg svg element that the legend will be drawn
   * @param {number[]} steps discrete input scale domain for d3 scale
   * @param {object} colorScale d3 scale for transform input into color
   * @param {number} width width of the svg element in pixel
   * @param {function} getValue function that transforms raw domain into desired discrete range
   */
  this._renderDiscreteColors = function (svg, steps, colorScale, width, getValue) {
    svg.selectAll('rect')
      .data(steps)
      .enter()
      .append('rect')
      .attr('width', width / steps.length)
      .attr('height', '20px')
      .attr('fill', function (d) {
        return colorScale(d);
      })
      .attr('transform', function (d, i) {
        return 'translate(' + i * width / steps.length + ' ,0)';
      })
      .on('mousemove', function (evt, d) {
        m_this._showPopup(evt, getValue(d));
      })
      .on('mouseout', m_this._hidePopup);
  };

  /**
   * Draw an individual continuous type legend.
   * @param {Element} svg svg element that the legend will be drawn
   * @param {number} width width of the svg element in pixel
   * @param {geo.gui.colorLegendWidget.category} category The continuous type legend category
   */
  this._drawContinous = function (svg, width, category) {
    var axisScale, axis, i;
    if (['linear', 'log', 'sqrt', 'pow'].indexOf(category.scale) === -1) {
      throw new Error('unsupported scale');
    }
    var range = [0];
    for (i = 1; i < category.domain.length - 1; i++) {
      range.push((width / (category.domain.length - 1) * i));
    }
    range.push(width);
    axisScale = d3['scale' + category.scale.charAt(0).toUpperCase() + category.scale.slice(1)]().domain(category.domain).range(range).nice();
    if (category.scale === 'log' && category.base) {
      axisScale.base(category.base);
    }
    if (category.scale === 'pow' && category.exponent) {
      axisScale.exponent(category.exponent);
    }
    var id = uniqueID();
    var precision = Math.max.apply(null, category.domain
      .map(function (number) { return getPrecision(number); }));

    var gradient = svg
      .append('defs')
      .append('linearGradient')
      .attr('id', 'gradient' + id);
    gradient.append('stop')
      .attr('offset', '0%')
      .attr('stop-color', category.colors[0]);
    for (i = 1; i < category.colors.length - 1; i++) {
      gradient.append('stop')
        .attr('offset', (100 / (category.colors.length - 1) * i).toFixed(6) + '%')
        .attr('stop-color', category.colors[i]);
    }
    gradient.append('stop')
      .attr('offset', '100%')
      .attr('stop-color', category.colors[category.colors.length - 1]);
    svg.append('rect')
      .attr('fill', 'url(#gradient' + id + ')')
      .attr('width', width)
      .attr('height', '20px')
      .on('mousemove', function (event) {
        var value = axisScale.invert(d3.pointer(event)[0]);
        var text = m_this._popupFormatter(value, precision);
        m_this._showPopup(event, text);
      })
      .on('mouseout', m_this._hidePopup);

    axis = d3.axisBottom(axisScale)
      .ticks(m_ticks, '.2s');
    if (category.endAxisLabelOnly) {
      axis.tickValues([category.domain[0], category.domain[category.domain.length - 1]]);
    }
    m_this._renderAxis(svg, axis);
  };

  /**
   * Actually render the axis with d3.
   * @param {Element} svg svg element that the axis will be drawn
   * @param {object} axis d3 axis object
   */
  this._renderAxis = function (svg, axis) {
    svg.append('g')
      .attr('class', 'axis x')
      .attr('transform', 'translate(0, 20)')
      /*
      .call(function (g) {
        g.call(axis);
      });
      */
      .call(axis);
  };

  /**
   * Formatter of number that tries to maximize the precision
   * while making the output shorter.
   * @param {number} number to be formatted
   * @param {number} precision maximum number of decimal places that are kept
   * @returns {string} formatted string output
   */
  this._popupFormatter = function (number, precision) {
    number = parseFloat(number.toFixed(8));
    precision = Math.min(precision, getPrecision(number));
    precision = Math.min(precision, Math.max(3, 7 - Math.trunc(number).toString().length));
    return d3.format('.' + precision + 'f')(number);
  };

  /**
   * Show the popup based on current mouse event.
   * @param {d3.event} event The triggering d3 event.
   * @param {string} text content to be shown in the popup
   */
  this._showPopup = function (event, text) {
    // The cursor location relative to the container
    var offset = d3.pointer(event, m_this.canvas());
    m_this.popup
      .text(text);
    var containerWidth = m_this.canvas().clientWidth;
    var popupWidth = m_this.popup.node().clientWidth;
    m_this.popup
      // If the popup will be longer or almost longer than the container
      .style('left', offset[0] - (offset[0] + popupWidth - containerWidth > -10 ? popupWidth : 0) + 'px')
      .style('top', (offset[1] - 22) + 'px')
      .transition()
      .duration(200)
      .style('opacity', 1);
  };

  /**
   * Hide the popup.
   */
  this._hidePopup = function () {
    m_this.popup.transition()
      .duration(200)
      .style('opacity', 0);
  };

  return this;
};

/**
 * Get the number of decimals of a number.
 *
 * @private
 * @param {number} number the number input
 * @returns {number} the number of decimal
 */
function getPrecision(number) {
  if (!isFinite(number)) return 0;
  var e = 1, p = 0;
  while (Math.round(number * e) / e !== number) {
    if (!isFinite(number * e)) { return 0; }
    e *= 10;
    p++;
  }
  return p;
}

inherit(colorLegendWidget, domWidget);

registerWidget('dom', 'colorLegend', colorLegendWidget);
module.exports = colorLegendWidget;