ui/scaleWidget.js

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

require('./scaleWidget.styl');

/**
 * Scale widget specification.
 *
 * @typedef {object} geo.gui.scaleWidget.spec
 * @property {number} [scale=1] A scale applied to the map gcs units to convert
 *   to the scale units.
 * @property {number} [maxWidth=200] The maximum width of the scale in pixels.
 *   For horizontal scales (orientation is `top` or `bottom`) this is the
 *   maximum length of the scale bar.  For vertical scales, this is the width
 *   available for the scale text.
 * @property {number} [maxHeight] The maximum height of the scale in pixels.
 *   For vertical scales (orientation is `left` or `right`) this is the
 *   maximum length of the scale bar.  For horizontal scales, this is the
 *   height available for the scale text.  Default is 200 for vertical scales,
 *   20 for horizontal scales.
 * @property {string} [orientation='bottom'] One of `left`, `right`, `top`, or
 *   `bottom`.  The scale text is placed in that location relative to the scale
 *   bar.
 * @property {number} [strokeWidth=2] The width of the ticks and scale bar in
 *   pixels.
 * @property {number} [tickLength=10] The length of the end ticks in pixels.
 * @property {string|geo.gui.scaleWidget.unit[]} [units='si'] One of either
 *   `'si'` or `'miles'` or an array of units in ascending order.  See the
 *   `UnitsTable` for examples.
 * @property {function} [distance] The function used to compute the length of
 *   the scale bar.  This defaults to `transform.sphericalDistance` for all
 *   maps except those with a gcs of `'+proj=longlat +axis=enu'`, where
 *   `math.sqrt(util.distance2dSquared(pt1, pt2))` is used instead.
 */

/**
 * Scale widget unit specification.
 *
 * @typedef {object} geo.gui.scaleWidget.unit
 * @property {string} unit Display name for the unit.
 * @property {number} scale Scale for 1 unit in the current system.
 * @property {number} [minimum=1] Minimum value where this applies after
 *   scaling.  This can be used to handle singular and plural words (e.g.,
 *   `[{units: 'meter', scale: 1}, {units: 'meters', scale: 1, minimum: 1.5}]`)
 * @property {number} [basis=10] The basis for the multiples value.
 * @property {object[]} [multiples] A list of objects in ascending value order
 *   that determine what round values are displayed.
 * @property {number} multiples.multiple The value that is selected for display.
 * @property {number} multiples.digit The number of significant digits in
 *   `multiple`.
 */

/**
 * For a unit table, the records are ordered smallest scale to largest scale.
 * The smallest unit can be repeated to have different rounding behavior for
 * values less than 1 and values greater than or equal to 1.
 *
 * @typedef {object} geo.gui.scaleWidget.unitTableRecord
 * @property {string} unit The display name of the unit.
 * @property {number} scale The size of the unit in base unit.
 * @property {number} [basis=10] The number of units in the next greater unit
 *    if not a power of 10.
 * @property {object[]} [multiples] A list of multiplier values to round to
 *    when rounding is used.  The list should probably include a multiple of 1.
 *    Default is 1, 1.5, 2, 3, 5, 8.
 * @property {number} multiples.multiple A factor to round to.
 * @property {number} multiples.digit The number of digits to preserve when
 *    rounding.
 */

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

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

  var m_this = this,
      s_exit = this._exit,
      m_options = Object.assign({}, {
        scale: 1,
        maxWidth: 200,
        maxHeight: arg.orientation === 'left' || arg.orientation === 'right' ? 200 : 20,
        orientation: 'bottom',
        strokeWidth: 2,
        tickLength: 10,
        units: 'si',
        distance: function (pt1, pt2, gcs) {
          if (gcs === '+proj=longlat +axis=enu') {
            return Math.sqrt(util.distance2dSquared(pt1, pt2));
          }
          /* We can use either the spherical distance or the Vincenty distance
           * here in much the same way.
          return transform.vincentyDistance(pt1, pt2, gcs).distance;
           */
          return transform.sphericalDistance(pt1, pt2, gcs);
        }
      }, arg);

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

    d3.select(m_this.canvas()).attr('width', m_options.maxWidth).attr('height', m_options.maxHeight);
    // Update the scale on pan
    m_this.geoOn(geo_event.pan, m_this._update);
    m_this._render();
    return m_this;
  };

  /**
   * Clean up after the widget.
   */
  this._exit = function () {
    m_this.geoOff(geo_event.pan, m_this._update);
    s_exit();
  };

  /**
   * Return true if the scale is vertically oriented.
   *
   * @returns {boolean} `true` if the scale is vertical, `false` if horizontal.
   */
  this._vertical = function () {
    return m_options.orientation === 'left' || m_options.orientation === 'right';
  };

  /**
   * Given a maximum value, return a value that is no larger than it but at a
   * round number of a set of units.
   *
   * @param {number} maxValue The maximum value to return.  The returned value
   *    will never be smaller than 3/5 of this value.
   * @param {number} pixels A number that is scaled by the ratio of the
   *    returned value to `maxValue`.
   * @param {string|geo.gui.scaleWidget.unit[]} [units] The units to use.  If
   *    not specified, the instance's option units value is used.
   * @returns {object} An object with `html`, `value`, and `pixels` values
   *    representing the calculated value.
   */
  this._scaleValue = function (maxValue, pixels, units) {
    units = (scaleWidget.unitsTable[units] || units ||
             scaleWidget.unitsTable[m_options.units] || m_options.units);
    var multiples = [
      {multiple: 1, digits: 1},
      {multiple: 1.5, digits: 2},
      {multiple: 2, digits: 1},
      {multiple: 3, digits: 1},
      {multiple: 5, digits: 1},
      {multiple: 8, digits: 1}];
    var unit = units[0],
        multiple, power, value;
    units.forEach(function (unitEntry) {
      if (maxValue >= unitEntry.scale * (unitEntry.minimum || 1)) {
        unit = unitEntry;
      }
    });
    power = Math.floor(Math.log(maxValue / unit.scale) / Math.log(unit.basis || 10));
    multiples = unit.multiples || multiples;
    multiples.forEach(function (mul) {
      var mulValue = unit.scale * mul.multiple * Math.pow(10, power);
      if (mulValue <= maxValue) {
        multiple = mul;
        value = mulValue;
      }
    });
    return {
      html: (multiple.multiple * Math.pow(10, power)).toFixed(
        Math.max(0, -power + multiple.digits - 1)) + ' ' + unit.unit,
      value: value,
      pixels: value / maxValue * pixels,
      power: power,
      multiple: multiple,
      unitRecord: unit,
      originalValue: maxValue,
      originalPixels: pixels
    };
  };

  /**
   * Create and draw the scale based on the current display distance at the
   * location of the scale.
   */
  this._render = function () {
    var svg = d3.select(m_this.canvas()),
        map = m_this.layer().map(),
        width = m_options.maxWidth,
        height = m_options.maxHeight,
        sw = m_options.strokeWidth,
        sw2 = sw * 0.5,
        tl = m_options.tickLength,
        vert = m_this._vertical(),
        pixels, pt1, pt2, dist, value, pts;

    pixels = (vert ? m_options.maxHeight : m_options.maxWidth) - sw;
    /* Calculate the distance that the maximum length scale bar can occupy at
     * the location that the scale bar will be drawn. */
    pt1 = $(svg.node()).offset();
    pt1 = {
      x: pt1.left + (m_options.orientation === 'left' ? width - sw2 : sw2),
      y: pt1.top + (m_options.orientation === 'top' ? height - sw2 : sw2)
    };
    pt2 = {x: pt1.x + (vert ? 0 : pixels), y: pt1.y + (vert ? pixels : 0)};
    dist = m_options.distance(map.displayToGcs(pt1, null), map.displayToGcs(pt2, null), map.gcs()) * m_options.scale;
    if (dist <= 0 || !isFinite(dist)) {
      console.warn('The distance calculated for the scale is invalid: ' + dist);
      return;
    }
    value = m_this._scaleValue(dist, pixels);
    if (vert) {
      height = value.pixels + sw;
    } else {
      width = value.pixels + sw;
    }
    svg.attr('width', width).attr('height', height);
    if (svg.select('polyline').empty()) {
      svg.append('polyline').classed('geojs-scale-widget-bar', true).attr('fill', 'none').attr('stroke-width', sw);
    }
    if (svg.select('text').empty()) {
      svg.append('text').classed('geojs-scale-widget-text', true);
    }
    switch (m_options.orientation) {
      case 'bottom':
        pts = [[sw2, tl], [sw2, sw2], [width - sw2, sw2], [width - sw2, tl]];
        svg.select('text')
          .attr('x', width / 2)
          .attr('y', sw * 2)
          .attr('text-anchor', 'middle')
          .attr('dominant-baseline', 'hanging');
        break;
      case 'top':
        pts = [[sw2, height - tl], [sw2, height - sw2], [width - sw2, height - sw2], [width - sw2, height - tl]];
        svg.select('text')
          .attr('x', width / 2)
          .attr('y', height - sw * 2)
          .attr('text-anchor', 'middle')
          .attr('dominant-baseline', 'alphabetic');
        break;
      case 'left':
        pts = [[width - tl, sw2], [width - sw2, sw2], [width - sw2, height - sw2], [width - tl, height - sw2]];
        svg.select('text')
          .attr('x', width - sw * 2)
          .attr('y', height / 2)
          .attr('text-anchor', 'end')
          .attr('dominant-baseline', 'middle');
        break;
      case 'right':
        pts = [[tl, sw2], [sw2, sw2], [sw2, height - sw2], [tl, height - sw2]];
        svg.select('text')
          .attr('x', sw * 2)
          .attr('y', height / 2)
          .attr('text-anchor', 'start')
          .attr('dominant-baseline', 'middle');
        break;
    }
    svg.select('polyline').attr('points', pts.map(function (pt) { return pt.join(','); }).join(' '));
    svg.select('text').html(value.html);
  };

  /**
   * Update the widget upon panning.
   */
  this._update = function () {
    m_this._render();
  };

  /**
   * Get or set options.
   *
   * @param {string|object} [arg1] If `undefined`, return the options object.
   *    If a string, either set or return the option of that name.  If an
   *    object, update the options with the object's values.
   * @param {object} [arg2] If `arg1` is a string and this is defined, set
   *    the option to this value.
   * @returns {object|this} If options are set, return the annotation,
   *    otherwise return the requested option or the set of options.
   */
  this.options = function (arg1, arg2) {
    if (arg1 === undefined) {
      var result = Object.assign({}, m_options);
      result.position = m_this.position(undefined, true);
      return result;
    }
    if (typeof arg1 === 'string' && arg2 === undefined) {
      return arg1 === 'position' ? m_this.position(undefined, true) : m_options[arg1];
    }
    if (arg2 === undefined) {
      m_options = util.deepMerge(m_options, arg1);
    } else {
      m_options[arg1] = arg2;
    }
    if (arg1.position || arg1 === 'position') {
      m_this.position(arg1.position || arg2);
    }
    m_this._render();
    return m_this;
  };
};

inherit(scaleWidget, svgWidget);

/**
 * The unitsTable has predefined unit sets for a base unit of one meter.  Each
 * entry is an array that must be in ascending order.  Use unicode in strings,
 * not html entities.  It makes it more reusable.
 * @name unitsTable
 * @property unitsTable {object} The key names are the names of unit systems,
 *    such as `si`.
 * @property unitsTable.unit {geo.gui.scaleWidget.unitTableRecord[]} A list of
 *    units within the unit system from smallest to largest.
 * @memberof geo.gui.scaleWidget
 */
scaleWidget.unitsTable = {
  si: [
    {unit: 'nm', scale: 1e-9},
    {unit: '\u03BCm', scale: 1e-6},
    {unit: 'mm', scale: 0.001},
    {unit: 'm', scale: 1},
    {unit: 'km', scale: 1000}
  ],
  miles: [
    {unit: 'in', scale: 0.0254}, // applies to < 1 in
    {
      /* By specifying inches a second time, the first entry will apply to
       * values less than 1 inch, and those will be rounded by powers of 10
       * using the default rules.  This entry will round values differently,
       * so one will see 1, 1.5, 2, 3, 6, 9 rather than the default which would
       * be 1, 1.5, 2, 3, 5, 8, 10. */
      unit: 'in',
      scale: 0.0254,
      basis: 12,
      multiples: [
        {multiple: 1, digits: 1},
        {multiple: 1.5, digits: 2},
        {multiple: 2, digits: 1},
        {multiple: 3, digits: 1},
        {multiple: 6, digits: 1},
        {multiple: 9, digits: 1}
      ]
    },
    {unit: 'ft', scale: 0.3048},
    {unit: 'mi', scale: 1609.344}
  ],
  decmiles: [ // decimal miles
    {unit: 'mi', scale: 1609.344}
  ]
};

/**
 * The areaUnitsTable has predefined unit sets for a base unit of one square
 * meter.  Each entry is an array that must be in ascending order.  This table
 * can be passed to formatUnit.
 * @name areaUnitsTable
 * @property areaUnitsTable {object} The key names are the names of unit
 *    systems, such as `si`.
 * @property areaUnitsTable.unit {geo.gui.scaleWidget.unitTableRecord[]} A list
 *    of units within the unit system from smallest to largest.
 * @memberof geo.gui.scaleWidget
 */
scaleWidget.areaUnitsTable = {
  si: [
    {unit: 'nm\xB2', scale: 1e-18},
    {unit: '\u03BCm\xB2', scale: 1e-12},
    {unit: 'mm\xB2', scale: 1e-6},
    {unit: 'm\xB2', scale: 1},
    {unit: 'km\xB2', scale: 1e6}
  ],
  hectares: [
    {unit: 'ha', scale: 1e4}
  ],
  decmiles: [ // decimal square miles
    {unit: 'mi\xB2', scale: 1609.344 * 1609.344}
  ],
  miles: [
    {unit: 'in\xB2', scale: 0.0254 * 0.0254},
    {unit: 'ft\xB2', scale: 0.3048 * 0.3048},
    {unit: 'mi\xB2', scale: 1609.344 * 1609.344}
  ],
  acres: [
    {unit: 'pl', scale: 0.3048 * 0.3048 * 16.5 * 16.5},
    {unit: 'rd', scale: 1609.344 * 1609.344 / 640 / 4},
    {unit: 'ac', scale: 1609.344 * 1609.344 / 640}
  ]
};

/**
 * Format a unit with a specified number of significant figures.  Given a value
 * in base units, such as meters, this will return a string with appropriate
 * units.  For instance, `formatUnit(0.345)` will return `345 mm`.
 *
 * @param {number} val The value.  A length or area in base units.  With the
 *    default unit table, this is in meters.  With the `areaUnitsTable`, this
 *    is square meters.
 * @param {string|object[]} [unit='si'] The name of the unit system or a unit
 *    table.
 * @param {object} [table=unitTable] The table of the unit system.  Ignored if
 *    `unit` is a unit table.
 * @param {number} [digits=3] The minimum number of significant figures.
 * @returns {string} A formatted string or `undefined`.
 */
scaleWidget.formatUnit = function (val, unit, table, digits) {
  if (val === undefined || val === null) {
    return;
  }
  if (!Array.isArray(unit)) {
    table = table || scaleWidget.unitsTable;
    if (!table || !table[unit || 'si']) {
      return;
    }
    unit = table[unit || 'si'];
  }
  let pos;
  for (pos = 0; pos < unit.length - 1; pos += 1) {
    if (val < unit[pos + 1].scale) {
      break;
    }
  }
  unit = unit[pos];
  val /= unit.scale;
  digits = Math.max(0, -Math.ceil(Math.log10(val)) + (digits === undefined || digits < 0 ? 3 : digits));
  if (digits > 10) {
    return;
  }
  let result = val.toFixed(digits);
  if (digits) {
    while (result.substr(result.length - 1) === '0') {
      result = result.substr(0, result.length - 1);
    }
    if (result.substr(result.length - 1) === '.') {
      result = result.substr(0, result.length - 1);
    }
  }
  return result + ' ' + unit.unit;
};

registerWidget('dom', 'scale', scaleWidget);
module.exports = scaleWidget;