map.js

var $ = require('jquery');
var inherit = require('./inherit');
var sceneObject = require('./sceneObject');

/**
 * Map specification.
 *
 * @typedef {object} geo.map.spec
 * @property {string} node DOM selector for the map container.
 * @property {string|geo.transform} [gcs='EPSG:3857'] The main coordinate
 *   system of the map (this is often web Mercator).
 * @property {string|geo.transform} [ingcs='EPSG:4326'] The default coordinate
 *   system of interface calls (this is often latitude and longitude).
 * @property {number} [unitsPerPixel=156543] GCS to pixel unit scaling at zoom
 *   0 (i.e., meters per pixel or degrees per pixel).  The actual default is
 *   `maxBounds.right - maxBounds.left` converted to `gcs` and then divided by
 *   `256`.
 * @property {object} [maxBounds] The maximum visible map bounds.
 * @property {number} [maxBounds.left=-180] The left bound.
 * @property {number} [maxBounds.right=180] The right bound.
 * @property {number} [maxBounds.bottom=-85.06] The bottom bound.  The default
 *   is actually the `left` value transformed to the map's `gcs` coordinate
 *   system.
 * @property {number} [maxBounds.top=85.06] The top bound.  The default is
 *   actually the `right` value transformed to the map's `gcs` coordinate
 *   system.
 * @property {number} [maxBounds.gcs=ingcs] The coordinate system for the
 *   bounds.
 * @property {number} [zoom=4] Initial zoom.
 * @property {object} [center] Initial map center.
 * @property {number} center.x=0
 * @property {number} center.y=0
 * @property {number} [rotation=0] Initial clockwise rotation in radians.
 * @property {number} [width] The map width (default node width).
 * @property {number} [height] The map height (default node height).
 * @property {number} [min=0] Minimum zoom level (though fitting to the
 *   viewport may make it so this is smaller than the smallest possible value).
 * @property {number} [max=16] Maximum zoom level.
 * @property {boolean} [discreteZoom=false] If `true`, only allow integer zoom
 *   levels.  `false` for any zoom level.
 * @property {boolean|function} [allowRotation=true] `false` prevents rotation,
 *   `true` allows any rotation.  If a function, the function is called with a
 *   rotation (angle in radians) and returns a valid rotation.  This can be
 *   used to constrain the rotation to a range or specific values.
 * @property {geo.camera} [camera] The camera to control the view.
 * @property {geo.mapInteractor} [interactor] The UI event handler.  If
 *   `undefined`, a default interactor is created and used.  If `null`, no
 *   interactor is attached to the map.
 * @property {array} [animationQueue] An array used to synchronize animations.
 *   If specified, this should be an empty array or the same array as passed to
 *   other map instances.
 * @property {boolean} [autoResize=true] Adjust map size on window resize.
 * @property {boolean} [clampBoundsX=false] Prevent panning outside of the
 *   maximum bounds in the horizontal direction.
 * @property {boolean} [clampBoundsY=true] Prevent panning outside of the
 *   maximum bounds in the vertical direction.
 * @property {boolean} [clampZoom=true] Prevent zooming out so that the map
 *   area is smaller than the window.
 * @property {boolean|string} [autoshareRenderer] If specified, pass this value
 *   to layers when they are created.  See
 *   {@link geo.layer.spec#autoshareRenderer} for valid values.
 */

/**
 * Specification used with `map.create`.
 *
 * @typedef {geo.map.spec} geo.map.createSpec
 * @extends geo.map.spec
 * @property {object[]} [data=[]] The default data array to apply to each
 *      feature if none exists.
 * @property {geo.layer.spec[]} [layers=[]] Layers to create.
 */

/**
 * Creates a new map object.
 *
 * @class
 * @alias geo.map
 * @extends geo.sceneObject
 *
 * @param {geo.map.spec} arg Map specification
 * @returns {geo.map}
 */
var map = function (arg) {
  'use strict';
  if (!(this instanceof map)) {
    return new map(arg);
  }
  arg = arg || {};

  if (arg.node === undefined || arg.node === null) {
    console.warn('map creation requires a node');
    return this;
  }

  sceneObject.call(this, arg);

  var camera = require('./camera');
  var transform = require('./transform');
  var util = require('./util');
  var registry = require('./registry');
  var geo_event = require('./event');
  var mapInteractor = require('./mapInteractor');
  var uiLayer = require('./ui/uiLayer');

  /**
   * Private member variables
   * @private
   */
  var m_this = this,
      s_exit = this._exit,
      // See https://en.wikipedia.org/wiki/Web_Mercator
      // phiMax = 180 / Math.PI * (2 * Math.atan(Math.exp(Math.PI)) - Math.PI / 2),
      m_node = $(arg.node),
      m_width = arg.width || m_node.width() || 512,
      m_height = arg.height || m_node.height() || 512,
      m_gcs = arg.gcs === undefined ? 'EPSG:3857' : arg.gcs,
      m_ingcs = arg.ingcs === undefined ? 'EPSG:4326' : arg.ingcs,
      m_center = {x: 0, y: 0},
      m_zoom = arg.zoom === undefined ? 4 : arg.zoom,
      m_rotation = arg.rotation ? arg.rotation : 0,
      m_fileReader = null,
      m_interactor = null,
      m_validZoomRange = {min: 0, max: 16, origMin: 0},
      m_transition = null,
      m_queuedTransition = null,
      m_discreteZoom = arg.discreteZoom ? true : false,
      m_allowRotation = (
        typeof arg.allowRotation === 'function' ?
          arg.allowRotation : (arg.allowRotation === undefined ?
            true : !!arg.allowRotation)),
      m_maxBounds = arg.maxBounds || {},
      m_camera = arg.camera || camera(),
      m_unitsPerPixel,
      m_clampBoundsX,
      m_clampBoundsY,
      m_clampZoom,
      m_animationQueue = arg.animationQueue || [],
      m_autoResize = arg.autoResize === undefined ? true : arg.autoResize,
      m_autoshareRenderer = arg.autoshareRenderer,
      m_origin;

  /* Compute the maximum bounds on our map projection.  By default, x ranges
   * from [-180, 180] in the interface projection, and y matches the x range in
   * the map (not the interface) projection.  For images, this might be
   * [0, width] and [0, height] instead. */
  var mcx = ((m_maxBounds.left || 0) + (m_maxBounds.right || 0)) / 2,
      mcy = ((m_maxBounds.bottom || 0) + (m_maxBounds.top || 0)) / 2;
  m_maxBounds.left = transform.transformCoordinates(m_maxBounds.gcs || m_ingcs, m_gcs, {
    x: m_maxBounds.left !== undefined ? m_maxBounds.left : -180, y: mcy
  }).x;
  m_maxBounds.right = transform.transformCoordinates(m_maxBounds.gcs || m_ingcs, m_gcs, {
    x: m_maxBounds.right !== undefined ? m_maxBounds.right : 180, y: mcy
  }).x;
  m_maxBounds.top = (m_maxBounds.top !== undefined ?
    transform.transformCoordinates(m_maxBounds.gcs || m_ingcs, m_gcs, {
      x: mcx, y: m_maxBounds.top}).y : m_maxBounds.right);
  m_maxBounds.bottom = (m_maxBounds.bottom !== undefined ?
    transform.transformCoordinates(m_maxBounds.gcs || m_ingcs, m_gcs, {
      x: mcx, y: m_maxBounds.bottom}).y : m_maxBounds.left);
  m_unitsPerPixel = (arg.unitsPerPixel || (
    m_maxBounds.right - m_maxBounds.left) / 256);

  m_camera.viewport = {
    width: m_width,
    height: m_height,
    left: m_node.offset().left,
    top: m_node.offset().top
  };
  arg.center = util.normalizeCoordinates(arg.center);
  m_clampBoundsX = arg.clampBoundsX === undefined ? false : arg.clampBoundsX;
  m_clampBoundsY = arg.clampBoundsY === undefined ? true : arg.clampBoundsY;
  m_clampZoom = arg.clampZoom === undefined ? true : arg.clampZoom;

  /**
   * Get/set the number of world space units per display pixel at the given
   * zoom level.
   *
   * @param {number} [zoom=0] The target zoom level.
   * @param {number?} [unit] If present, set the `unitsPerPixel` at the
   *   specified zoom level.  Otherwise return the current value.
   * @returns {number|this}
   */
  this.unitsPerPixel = function (zoom, unit) {
    zoom = zoom || 0;
    if (unit) {
      // get the units at level 0
      m_unitsPerPixel = Math.pow(2, zoom) * unit;

      // redraw all the things
      m_this.draw();
      return m_this;
    }
    return Math.pow(2, -zoom) * m_unitsPerPixel;
  };

  /**
   * Get/set the animation queue.  Two maps can share a single animation queue
   * to ensure synchronized animations.  When setting, the animation queue will
   * merge values from the existing queue into the new queue.
   *
   * @param {array} [queue] The animation queue to use.
   * @returns {array|this} The current animation queue or the current map.
   */
  this.animationQueue = function (queue) {
    if (queue === undefined) {
      return m_animationQueue;
    }
    if (queue !== m_animationQueue) {
      if (m_animationQueue.length) {
        /* If the specified queue already has data in, don't copy the 0th
         * element of the existing queue, since the 0th element is always the
         * actual requestAnimationFrame reference.  In this case, cancel the
         * existing requestAnimationFrame.  By using a property of window,
         * tests can override this if needed. */
        if (queue.length && queue[0] !== m_animationQueue[0]) {
          window['cancelAnimationFrame'](m_animationQueue[0]); // eslint-disable-line dot-notation
        }
        for (var i = queue.length ? 1 : 0; i < m_animationQueue.length; i += 1) {
          queue.push(m_animationQueue[i]);
        }
      }
      m_animationQueue = queue;
    }
    return m_this;
  };

  /**
   * Get/set the autoResize flag.
   *
   * @param {boolean} [autoResize] Truthy to automatically resize the map when
   *    the size of the browser window changes.
   * @returns {boolean|this} The current state of autoResize or the current map.
   */
  this.autoResize = function (autoResize) {
    if (autoResize === undefined) {
      return m_autoResize;
    }
    if (autoResize !== m_autoResize) {
      $(window).off('resize', resizeSelf);
      m_autoResize = autoResize;
      if (m_autoResize) {
        $(window).on('resize', resizeSelf);
      }
    }
    return m_this;
  };

  /**
   * Get/set the `clampBoundsX` setting.  If changed, adjust the bounds of the
   * map as needed.
   *
   * @param {boolean} [clamp] The new clamp value.
   * @returns {boolean|this}
   */
  this.clampBoundsX = function (clamp) {
    if (clamp === undefined) {
      return m_clampBoundsX;
    }
    if (clamp !== m_clampBoundsX) {
      m_clampBoundsX = !!clamp;
      m_this.pan({x: 0, y: 0});
    }
    return m_this;
  };

  /**
   * Get/set the `clampBoundsY` setting.  If changed, adjust the bounds of the
   * map as needed.
   *
   * @param {boolean} [clamp] The new clamp value.
   * @returns {boolean|this}
   */
  this.clampBoundsY = function (clamp) {
    if (clamp === undefined) {
      return m_clampBoundsY;
    }
    if (clamp !== m_clampBoundsY) {
      m_clampBoundsY = !!clamp;
      m_this.pan({x: 0, y: 0});
    }
    return m_this;
  };

  /**
   * Get/set the `clampZoom` setting.  If changed, adjust the bounds of the map
   * as needed.
   *
   * @param {boolean} [clamp] The new clamp value.
   * @returns {boolean|this}
   */
  this.clampZoom = function (clamp) {
    if (clamp === undefined) {
      return m_clampZoom;
    }
    if (clamp !== m_clampZoom) {
      m_clampZoom = !!clamp;
      reset_minimum_zoom();
      m_this.zoom(m_zoom);
    }
    return m_this;
  };

  /**
   * Get/set the `allowRotation` setting.  If changed, adjust the map as
   * needed.
   *
   * @param {boolean|function} [allowRotation] The new `allowRotation` value.
   *    `false` prevents rotation, `true` allows any rotation.  If a function,
   *    the function is called with a rotation (angle in radians) and returns a
   *    valid rotation (this can be used to constrain the rotation to a range
   *    or to specific values).
   * @returns {boolean|function|this}
   */
  this.allowRotation = function (allowRotation) {
    if (allowRotation === undefined) {
      return m_allowRotation;
    }
    if (typeof allowRotation !== 'function') {
      allowRotation = !!allowRotation;
    }
    if (allowRotation !== m_allowRotation) {
      m_allowRotation = allowRotation;
      m_this.rotation(m_rotation);
    }
    return m_this;
  };

  /**
   * Get the map's world coordinate origin in gcs coordinates.
   *
   * @returns {geo.geoPosition}
   */
  this.origin = function () {
    return Object.assign({}, m_origin);
  };

  /**
   * Get the camera.
   *
   * @returns {geo.camera}
   */
  this.camera = function () {
    return m_camera;
  };

  /**
   * Get or set the map gcs.  This is the coordinate system used in drawing the
   * map.
   *
   * @param {string} [arg] If `undefined`, return the current gcs.  Otherwise,
   *    a new value for the gcs.
   * @returns {string|this} A string used by {@link geo.transform}.
   */
  this.gcs = function (arg) {
    if (arg === undefined) {
      return m_gcs;
    }
    if (arg !== m_gcs) {
      var oldCenter = m_this.center(undefined, undefined);
      m_gcs = arg;
      reset_minimum_zoom();
      var newZoom = m_this._fix_zoom(m_zoom);
      if (newZoom !== m_zoom) {
        m_this.zoom(newZoom);
      }
      m_this.center(oldCenter, undefined);
    }
    return m_this;
  };

  /**
   * Get or set the map interface gcs.  This is the coordinate system used when
   * getting or setting map bounds, center, and other values.
   *
   * @param {string} [arg] If `undefined`, returtn the current interface gcs.
   *    Otherwise, a new value for the interface gcs.
   * @returns {string|this} A string used by {@link geo.transform}.
   */
  this.ingcs = function (arg) {
    if (arg === undefined) {
      return m_ingcs;
    }
    m_ingcs = arg;
    return m_this;
  };

  /**
   * Get root DOM node of the map.
   *
   * @returns {object}
   */
  this.node = function () {
    return m_node;
  };

  /**
   * Get/Set zoom level of the map.
   *
   * @param {number} [val] If `undefined`, return the current zoom level.
   *    Otherwise, the new zoom level to set.
   * @param {object} [origin] If present, specifies the center of the zoom;
   *    otherwise the map's display center is used.
   * @param {geo.geoPosition} origin.geo The gcs coordinates of the zoom
   *    center.
   * @param {geo.screenPosition} origin.map The display coordinates of the zoom
   *    center.
   * @param {boolean} [ignoreDiscreteZoom] If `true`, ignore the discreteZoom
   *    option when determining the new view.
   * @param {boolean} [ignoreClampBounds] If `true`, ignore the clampBounds
   *    option when determining the new view.
   * @returns {number|this}
   * @fires geo.event.zoom
   * @fires geo.event.pan
   */
  this.zoom = function (val, origin, ignoreDiscreteZoom, ignoreClampBounds) {
    if (val === undefined) {
      return m_zoom;
    }
    var evt, bounds;
    /* If we are zooming around a point, ignore the clamp bounds */
    var aroundPoint = (origin && (origin.mapgcs || origin.geo) && origin.map);
    if (aroundPoint) {
      ignoreClampBounds = true;
    }

    /* The ignoreDiscreteZoom flag is intended to allow non-integer zoom values
     * during animation. */
    val = m_this._fix_zoom(val, ignoreDiscreteZoom);
    if (val === m_zoom) {
      return m_this;
    }

    m_zoom = val;

    bounds = m_this.boundsFromZoomAndCenter(
      val, m_center, m_rotation, null, ignoreDiscreteZoom, ignoreClampBounds);
    m_this.modified();

    camera_bounds(bounds, m_rotation);
    evt = {
      zoomLevel: m_zoom,
      screenPosition: origin ? origin.map : undefined
    };
    m_this.geoTrigger(geo_event.zoom, evt);

    if (aroundPoint) {
      var shifted = m_this.gcsToDisplay(origin.mapgcs || origin.geo,
                                        origin.mapgcs ? null : undefined);
      m_this.pan({x: origin.map.x - shifted.x, y: origin.map.y - shifted.y},
                 ignoreDiscreteZoom, true);
    } else {
      m_this.pan({x: 0, y: 0}, ignoreDiscreteZoom, ignoreClampBounds);
    }
    return m_this;
  };

  /**
   * Pan the map by a number of display pixels.
   *
   * @param {object} delta Amount to pan in display pixels.
   * @param {number} delta.x Horizontal distance on the display.
   * @param {number} delta.y Vertical distance on the display.
   * @param {boolean} [ignoreDiscreteZoom] If `true`, ignore the `discreteZoom`
   *    option when determining the new view.
   * @param {boolean|'limited'} [ignoreClampBounds] If `true` ignore the
   *    `clampBoundsX` and `clampBoundsY` options when determining the new
   *    view.  When `'limited'`, the `clampBoundsX` and `clampBoundsY` options
   *    are selectively enforced so that the map will not end up more out of
   *    bounds than its current state.
   * @returns {this}
   * @fires geo.event.pan
   */
  this.pan = function (delta, ignoreDiscreteZoom, ignoreClampBounds) {
    var evt = {
      screenDelta: delta
    };

    if (delta.x || delta.y) {
      var unit = m_this.unitsPerPixel(m_zoom);

      var sinr = Math.sin(m_rotation), cosr = Math.cos(m_rotation);
      m_camera.pan({
        x: (delta.x * cosr - (-delta.y) * sinr) * unit,
        y: (delta.x * sinr + (-delta.y) * cosr) * unit
      });
    }
    /* If m_clampBoundsX or m_clampBoundsY is true, clamp the pan */
    var bounds = m_camera.bounds;
    bounds = fix_bounds(
      bounds, m_rotation, ignoreClampBounds === 'limited' ? {
        x: delta.x, y: delta.y, unit: unit} : undefined,
      ignoreClampBounds === true);
    if (bounds !== m_camera.bounds) {
      var panPos = m_this.gcsToDisplay({
        x: m_camera.bounds.left, y: m_camera.bounds.top}, null);
      bounds = m_this.boundsFromZoomAndCenter(m_zoom, {
        x: (bounds.left + bounds.right) / 2,
        y: (bounds.top + bounds.bottom) / 2
      }, m_rotation, null, ignoreDiscreteZoom, true);
      camera_bounds(bounds, m_rotation);
      var clampPos = m_this.gcsToDisplay({
        x: m_camera.bounds.left, y: m_camera.bounds.top}, null);
      evt.screenDelta.x += clampPos.x - panPos.x;
      evt.screenDelta.y += clampPos.y - panPos.y;
    }

    m_center = m_camera.displayToWorld({
      x: m_width / 2,
      y: m_height / 2
    });

    m_this.geoTrigger(geo_event.pan, evt);

    m_this.modified();
    return m_this;
  };

  /**
   * Get/set the map rotation.  The rotation is performed around the current
   * view center.  Rotation mostly ignores `clampBoundsX`, as the behavior
   * feels peculiar otherwise.
   *
   * @param {number} [rotation] Absolute angle in radians (positive is
   *    clockwise).
   * @param {object} [origin] If specified, rotate about this origin.
   * @param {geo.geoPosition} origin.geo The gcs coordinates of the
   *    rotation center.
   * @param {geo.screenPosition} origin.map The display coordinates of the
   *    rotation center.
   * @param {boolean} [ignoreRotationFunc] If `true`, don't constrain the
   *    rotation.
   * @returns {number|this}
   * @fires geo.event.rotate
   * @fires geo.event.pan
   */
  this.rotation = function (rotation, origin, ignoreRotationFunc) {
    if (rotation === undefined) {
      return m_rotation;
    }
    var aroundPoint = (origin && origin.geo && origin.map);

    rotation = fix_rotation(rotation, ignoreRotationFunc);
    if (rotation === m_rotation) {
      return m_this;
    }
    m_rotation = rotation;

    var bounds = m_this.boundsFromZoomAndCenter(
      m_zoom, m_center, m_rotation, null, ignoreRotationFunc, true);
    m_this.modified();

    camera_bounds(bounds, m_rotation);

    var evt = {
      rotation: m_rotation,
      screenPosition: origin ? origin.map : undefined
    };

    m_this.geoTrigger(geo_event.rotate, evt);

    if (aroundPoint) {
      var shifted = m_this.gcsToDisplay(origin.geo);
      m_this.pan({x: origin.map.x - shifted.x, y: origin.map.y - shifted.y},
                 undefined, true);
    } else {
      m_this.pan({x: 0, y: 0}, undefined, true);
    }
    /* Changing the rotation can change our minimum zoom */
    reset_minimum_zoom();
    m_this.zoom(m_zoom, undefined, ignoreRotationFunc);
    return m_this;
  };

  /**
   * Get or set the center of the map in the given geographic coordinates.
   *
   * @param {geo.geoPosition} coordinates If specified, the new center of the
   *    map.
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.  If setting the
   *    center, it is converted from this gcs to the map projection.  The
   *    returned center is converted from the map projection to this gcs.
   * @param {boolean} [ignoreDiscreteZoom] If `true`, ignore the `discreteZoom`
   *    option when determining the new view.
   * @param {boolean|'limited'} [ignoreClampBounds] If `true` ignore the
   *    `clampBoundsX` and `clampBoundsY` options when determining the new
   *    view.  When `'limited'`, the `clampBoundsX` and `clampBoundsY` options
   *    are selectively enforced so that the map will not end up more out of
   *    bounds than its current state.
   * @returns {geo.geoPosition|this}
   * @fires geo.event.pan
   */
  this.center = function (coordinates, gcs, ignoreDiscreteZoom, ignoreClampBounds) {
    var center;
    if (coordinates === undefined) {
      center = Object.assign({}, m_this.worldToGcs(m_center, gcs));
      return center;
    }

    // get the screen coordinates of the new center
    center = m_this.gcsToWorld(coordinates, gcs);

    camera_bounds(m_this.boundsFromZoomAndCenter(
      m_zoom, center, m_rotation, null, ignoreDiscreteZoom,
      ignoreClampBounds), m_rotation);
    m_this.modified();
    // trigger a pan event
    m_this.geoTrigger(geo_event.pan, {
      screenDelta: {x: 0, y: 0}
    });
    return m_this;
  };

  /**
   * Add a layer to the map.
   *
   * @param {string} layerName The type of layer to add to the map.
   * @param {object} arg Parameters for the new layer.
   * @returns {geo.layer}
   * @fires geo.event.layerAdd
   */
  this.createLayer = function (layerName, arg) {
    arg = arg || {};
    if (m_this.autoshareRenderer() !== undefined) {
      arg = Object.assign({autoshareRenderer: m_this.autoshareRenderer()}, arg);
    }
    var newLayer = registry.createLayer(
      layerName, m_this, arg);

    if (newLayer) {
      m_this.addChild(newLayer);
      m_this.children().forEach(function (c) {
        if (c instanceof uiLayer) {
          c.moveToTop();
        }
      });
      newLayer._update();
      m_this.modified();

      m_this.geoTrigger(geo_event.layerAdd, {
        target: m_this,
        layer: newLayer
      });
    }

    return newLayer;
  };

  /**
   * Remove a layer from the map.
   *
   * @param {geo.layer?} layer Layer to remove from the map.
   * @returns {geo.layer}
   * @fires geo.event.layerRemove
   */
  this.deleteLayer = function (layer) {

    if (layer !== null && layer !== undefined) {
      layer._exit();
      m_this.removeChild(layer);

      m_this.modified();

      m_this.geoTrigger(geo_event.layerRemove, {
        target: m_this,
        layer: layer
      });
    }

    // Return deleted layer (similar to createLayer) as in the future
    // we may provide extension of this method to support deletion of
    // layer using id or some sort.
    return layer;
  };

  /**
   * Get or set the size of the map.
   *
   * @param {geo.screenSize} [arg] Size in pixels.
   * @returns {geo.screenSize|this} The size in pixels or the map object.
   * @fires geo.event.resize
   */
  this.size = function (arg) {
    if (arg === undefined) {
      return {
        width: m_width,
        height: m_height
      };
    }
    // store the original center and restore it after the resize
    var oldCenter = m_this.center();
    m_width = arg.width || m_width;
    m_height = arg.height || m_height;

    reset_minimum_zoom();
    var newZoom = m_this._fix_zoom(m_zoom);
    if (newZoom !== m_zoom) {
      m_this.zoom(newZoom);
    }
    m_this.camera().viewport = {
      width: m_width,
      height: m_height,
      left: m_node.offset().left,
      top: m_node.offset().top
    };
    m_this.center(oldCenter);

    m_this.geoTrigger(geo_event.resize, {
      target: m_this,
      width: m_width,
      height: m_height
    });

    m_this.modified();
    return m_this;
  };

  /**
   * Get the rotated size of the map.  This is the width and height of the
   * non-rotated area necessary to enclose the rotated area in pixels.
   *
   * @returns {geo.screenSize} The size that fits the rotated map.
   */
  this.rotatedSize = function () {
    if (!this.rotation()) {
      return {
        width: m_width,
        height: m_height
      };
    }
    var bds = rotate_bounds_center(
      {x: 0, y: 0}, {width: m_width, height: m_height}, m_this.rotation());
    return {
      width: Math.abs(bds.right - bds.left),
      height: Math.abs(bds.top - bds.bottom)
    };
  };

  /**
   * Convert from gcs coordinates to map world coordinates.
   *
   * @param {geo.geoPosition|geo.geoPosition[]} c The input coordinate to
   *    convert.
   * @param {string|geo.transform|null} [gcs] Input gcs.  `undefined` to use
   *    the interface gcs, `null` to use the map gcs, or any other transform.
   * @returns {geo.worldPosition|geo.worldPosition[]} World space coordinates.
   */
  this.gcsToWorld = function (c, gcs) {
    if (Array.isArray(c)) {
      return c.map(function (pt) { return m_this.gcsToWorld(pt, gcs); });
    }
    gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
    if (gcs !== m_gcs) {
      c = transform.transformCoordinates(gcs, m_gcs, c);
    }
    if (m_origin.x || m_origin.y || m_origin.z) {
      c = transform.affineForward(
        {origin: m_origin},
        [c]
      )[0];
    } else if (!('z' in c)) {
      c = {x: c.x, y: c.y, z: 0};
    }
    return c;
  };

  /**
   * Convert from map world coordinates to gcs coordinates.
   *
   * @param {geo.worldPosition|geo.worldPosition[]} c The input coordinate to
   *    convert.
   * @param {string|geo.transform|null} [gcs] output gcs.  `undefined` to use
   *    the interface gcs, `null` to use the map gcs, or any other transform.
   * @returns {geo.geoPosition|geo.geoPosition[]} GCS space coordinates.
   */
  this.worldToGcs = function (c, gcs) {
    if (Array.isArray(c)) {
      return c.map(function (pt) { return m_this.worldToGcs(pt, gcs); });
    }
    if (m_origin.x || m_origin.y || m_origin.z) {
      c = transform.affineInverse(
        {origin: m_origin},
        [c]
      )[0];
    } else if (!('z' in c)) {
      c = {x: c.x, y: c.y, z: 0};
    }
    gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
    if (gcs !== m_gcs) {
      c = transform.transformCoordinates(m_gcs, gcs, c);
    }
    return c;
  };

  /**
   * Convert from gcs coordinates to display coordinates.  This is identical to
   * calling `gcsToWorld` and then `worldToDisplay`.
   *
   * @param {geo.geoPosition|geo.geoPosition[]} c The input coordinate to
   *    convert.
   * @param {string|geo.transform|null} [gcs] Input gcs.  `undefined` to use
   *    the interface gcs, `null` to use the map gcs, or any other transform.
   * @returns {geo.screenPosition|geo.screenPosition[]} Display space
   *    coordinates.
   */
  this.gcsToDisplay = function (c, gcs) {
    c = m_this.gcsToWorld(c, gcs);
    return m_this.worldToDisplay(c);
  };

  /**
   * Convert from world coordinates to display coordinates using the attached
   * camera.
   *
   * @param {geo.worldPosition|geo.worldPosition[]} c The input coordinate to
   *    convert.
   * @returns {geo.screenPosition|geo.screenPosition[]} Display space
   *    coordinates.
   */
  this.worldToDisplay = function (c) {
    if (Array.isArray(c)) {
      return c.map(function (pt) { return m_camera.worldToDisplay(pt); });
    }
    return m_camera.worldToDisplay(c);
  };

  /**
   * Convert from display to gcs coordinates.  This is identical to calling
   * `displayToWorld` and then `worldToGcs`.
   *
   * @param {geo.screenPosition|geo.screenPosition[]} c The input display
   *    coordinate to convert.
   * @param {string|geo.transform|null} [gcs] Output gcs.  `undefined` to use
   *    the interface gcs, `null` to use the map gcs, or any other transform.
   * @returns {geo.geoPosition|geo.geoPosition[]} GCS space coordinates.
   */
  this.displayToGcs = function (c, gcs) {
    c = m_this.displayToWorld(c); // done via camera
    return m_this.worldToGcs(c, gcs);
  };

  /**
   * Convert from display coordinates to world coordinates using the attached
   * camera.
   *
   * @param {geo.screenPosition|geo.screenPosition[]} c The input coordinate to
   *    convert.
   * @returns {geo.worldPosition|geo.worldPosition[]} World space coordinates.
   */
  this.displayToWorld = function (c) {
    if (Array.isArray(c)) {
      return c.map(function (pt) { return m_camera.displayToWorld(pt); });
    }
    return m_camera.displayToWorld(c);
  };

  /**
   * Redraw the map and all its layers.
   *
   * @returns {this} The map object.
   * @fires geo.event.draw
   * @fires geo.event.drawEnd
   */
  this.draw = function () {
    var i, layers = m_this.children();

    m_this.geoTrigger(geo_event.draw, {
      target: m_this
    });

    m_this._update();

    for (i = 0; i < layers.length; i += 1) {
      layers[i].draw();
    }

    m_this.geoTrigger(geo_event.drawEnd, {
      target: m_this
    });

    return m_this;
  };

  /**
   * Get, set, or create and set a file reader to a layer in the map to be used
   * as a drop target.
   *
   * @param {string|object} [readerOrName] `undefined` to get the current
   *    reader, an instance of a file reader to set the reader, or a name to
   *    create a file reader.
   * @param {object} [opts] options Parameters for creating a file reader when
   *    the reader is specified by name.  If this includes `layer`, use that
   *    layer, otherwise create a layer using these options.
   * @returns {geo.fileReader|this}
   */
  this.fileReader = function (readerOrName, opts) {
    if (readerOrName === undefined) {
      return m_fileReader;
    }
    if (typeof readerOrName === 'string') {
      opts = opts || {};
      if (!opts.layer) {
        opts.layer = m_this.createLayer('feature', Object.assign({}, opts));
      }
      opts.renderer = opts.layer.renderer().api();
      m_fileReader = registry.createFileReader(readerOrName, opts);
    } else {
      m_fileReader = readerOrName;
    }
    return m_this;
  };

  /**
   * Trigger an event when the browser is hidden or unhidden.
   *
   * See {@link geo.map.trackBrowserHidden}.
   */
  function handleBrowserHidden() {
    var hidden;

    if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
      hidden = 'hidden';
    } else if (typeof document.msHidden !== 'undefined') {
      hidden = 'msHidden';
    } else if (typeof document.webkitHidden !== 'undefined') {
      hidden = 'webkitHidden';
    }
    m_this.geoTrigger(document[hidden] ? geo_event.hidden : geo_event.unhidden);
  }

  /**
   * Track when the browser tab is hidden or unhidden.
   *
   * Based on
   * https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
   * as accessed on 2019-10-24.
   *
   * @param {boolean} [enable] If `false`, remove the event listener.
   */
  function trackBrowserHidden(enable) {
    var visibilityChange;

    if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
      visibilityChange = 'visibilitychange';
    } else if (typeof document.msHidden !== 'undefined') {
      visibilityChange = 'msvisibilitychange';
    } else if (typeof document.webkitHidden !== 'undefined') {
      visibilityChange = 'webkitvisibilitychange';
    }
    document.removeEventListener(visibilityChange, handleBrowserHidden);
    if (enable !== false) {
      document.addEventListener(visibilityChange, handleBrowserHidden);
    }
  }

  /**
   * Initialize the map.
   *
   * @param {object} [arg] Optional arguments.
   * @returns {this} The map object.
   */
  this._init = function (arg) {

    if (m_node === undefined || m_node === null) {
      throw new Error('Map require DIV node');
    }

    if (m_node.data('data-geojs-map') && $.isFunction(m_node.data('data-geojs-map').exit)) {
      m_node.data('data-geojs-map').exit();
    }
    m_node.addClass('geojs-map');
    m_node.data('data-geojs-map', m_this);

    trackBrowserHidden();
    return m_this;
  };

  /**
   * Update map.  This updates all layers of the map.
   *
   * @param {object} [request] Optional information about the source of this
   *    update request.  This could be an event, for instance.  It is passed
   *    to individual layer's `_update` function.
   * @returns {this} The map object.
   */
  this._update = function (request) {
    var i, layers = m_this.children();
    for (i = 0; i < layers.length; i += 1) {
      layers[i]._update(request);
    }
    return m_this;
  };

  /**
   * Exit this map.  This removes all layers, destroys current interactor, and
   * empties the associated DOM node.
   */
  this.exit = function () {
    trackBrowserHidden(false);
    var i, layers = m_this.children();
    for (i = layers.length - 1; i >= 0; i -= 1) {
      layers[i]._exit();
      m_this.removeChild(layers[i]);
    }
    if (m_this.interactor()) {
      m_this.interactor().destroy();
      m_this.interactor(null);
    }
    // if the animation queue was shared, this clears it
    m_animationQueue = [];
    m_this.node().data('data-geojs-map', null);
    m_this.node().off('.geo');
    /* make sure the map node has nothing left in it */
    m_this.node().empty();
    $(window).off('resize', resizeSelf);
    s_exit();
  };

  /**
   * Get or set the map interactor.
   *
   * @param {geo.mapInteractor} [arg] If specified, the map interactor to set.
   * @returns {geo.mapInteractor|this} The current map interactor or the map
   *    object.
   */
  this.interactor = function (arg) {
    if (arg === undefined) {
      return m_interactor;
    }
    if (m_interactor && m_interactor !== arg) {
      m_interactor.destroy();
    }
    m_interactor = arg;

    // this makes it possible to set a null interactor
    // i.e. map.interactor(null);
    if (m_interactor) {
      /* If we set a map interactor, make sure we have a tabindex */
      if (!m_node.attr('tabindex')) {
        m_node.attr('tabindex', 0);
      }
      m_interactor.map(m_this);
    }
    return m_this;
  };

  /**
   * Get or set the min/max zoom range.
   *
   * @param {object} [arg] The zoom range.
   * @param {number} [arg.min] The minimum zoom level.
   * @param {number} [arg.max] The maximum zoom level.
   * @param {boolean} [noRefresh] If `true`, don't update the map if the zoom
   *    level has changed.
   * @returns {object|this} The current zoom range or the map object.  The
   *    `min` value is the minimum value that the map can go to based on the
   *    current dimensions and settings, the `origMin` value is the value that
   *    was specified via this function or when the map was created.
   */
  this.zoomRange = function (arg, noRefresh) {
    if (arg === undefined) {
      return Object.assign({}, m_validZoomRange);
    }
    if (arg.max !== undefined) {
      m_validZoomRange.max = arg.max;
    }
    if (arg.min !== undefined) {
      m_validZoomRange.min = m_validZoomRange.origMin = arg.min;
    }
    reset_minimum_zoom();
    if (!noRefresh) {
      m_this.zoom(m_zoom);
    }
    return m_this;
  };

  /**
   * Get the current transition or start an animated zoom/pan/rotate.  If a
   * second transition is requested while a transition is already in progress,
   * a new transition is created that is functionally from wherever the map has
   * moved to (possibly partway through the first transition) going to the end
   * point of the new transition.
   *
   * @param {object} [opts] Options for a transition, or `undefined` to get the
   *    current transition.
   * @param {geo.geoPosition} [opts.center] A new map center.
   * @param {number} [opts.zoom] A new map zoom level.
   * @param {geo.geoPosition} [opts.zoomOrigin] An origin to use when zooming
   *    to a new zoom level.
   * @param {number} [opts.rotation] A new map rotation.
   * @param {number} [opts.duration=1000] Transition duration in milliseconds.
   * @param {function} [opts.ease] Easing function for the transition.  This is
   *    in the style of a d3 easing function.
   * @param {function} [opts.interp] Function to use when interpolating
   *    between values.  This gets passed two arrays, the start and end values
   *    for [`x`, `y`, `z` or `zoom`, `rotation`], and returns a function that,
   *    when passed a time value returns an array of the interpolated [`x`,
   *    `y`, `z` or `zoom`, `rotation`] values.
   * @param {boolean} [opts.zCoord] If `true`, convert zoom values to z values
   *    for interpolation.
   * @param {function} [opts.done] If specified, call this function when a
   *    transition completes.  The function is called with an object that
   *    contains `cancel`: a boolean if the transition was canceled, `source`:
   *    a value based on what canceled a transition, `transition`: the current
   *    transition that just completed, `next`: a boolean if another transition
   *    follows immediately.
   * @param {boolean} [opts.endClamp=true] If `false`, the last center change
   *    will not clamp to the bounds and zoom values.
   * @param {string|geo.transform|null} [gcs] Input gcs.  `undefined` to use
   *    the interface gcs, `null` to use the map gcs, or any other transform.
   *    Applies only to `opts.center` and to converting zoom values to height,
   *    if specified.
   * @param {number} [animTime] The animation frame time (from a
   *    `window.requestAnimationFrame` callback).  Used if a new transition is
   *    requested because the current transition has completed to keep things
   *    synchronized.
   * @returns {geo.map}
   * @fires geo.event.transitionstart
   * @fires geo.event.transitionend
   * @fires geo.event.transitioncancel
   */
  this.transition = function (opts, gcs, animTime) {

    if (opts === undefined) {
      return m_transition;
    }

    if (m_transition) {
      /* The queued transition needs to combine the current transition's
       * endpoint, any other queued transition, and the new transition to be
       * complete. */
      var transitionEnd = util.deepMerge({}, m_transition.end);
      if (transitionEnd.center && m_gcs !== m_ingcs) {
        transitionEnd.center = transform.transformCoordinates(
          m_gcs, m_ingcs, transitionEnd.center);
      }
      m_queuedTransition = Object.assign(
        {}, transitionEnd || {}, m_queuedTransition || {}, opts);
      return m_this;
    }

    /* Basic linear interpolation between two values. */
    function interp1(p0, p1, t) {
      return p0 + (p1 - p0) * t;
    }
    /**
     * Generate an interpolation function that interpolates all array entries.
     *
     * @param {array} p0 An array of numbers to interpolate from.
     * @param {array} p1 An array of numbers to interpolate to.
     * @returns {function} A function that, given `t`, returns an array of
     *      interpolated values.
     * @private
     */
    function defaultInterp(p0, p1) {
      return function (t) {
        var result = [];
        $.each(p0, function (idx) {
          result.push(interp1(p0[idx], p1[idx], t));
        });
        return result;
      };
    }

    var units = m_this.unitsPerPixel(0);

    // Transform zoom level into z-coordinate and inverse.
    function zoom2z(z) {
      return Math.pow(2, -(z + 1)) * units * m_height;
    }
    function z2zoom(z) {
      return -Math.log2(z / units / m_height) - 1;
    }

    var defaultOpts = {
      center: undefined,
      zoom: m_this.zoom(),
      rotation: m_this.rotation(),
      duration: 1000,
      ease: function (t) {
        return t;
      },
      interp: defaultInterp,
      done: null,
      zCoord: true
    };

    if (opts.center) {
      gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
      opts = util.deepMerge({}, opts);
      opts.center = util.normalizeCoordinates(opts.center);
      if (gcs !== m_gcs) {
        opts.center = transform.transformCoordinates(gcs, m_gcs, opts.center);
      }
    }
    opts = util.deepMerge({}, defaultOpts, opts);

    m_transition = {
      start: {
        center: m_this.center(undefined, null),
        zoom: m_this.zoom(),
        rotation: m_this.rotation()
      },
      end: {
        center: opts.center,
        zoom: m_this._fix_zoom(opts.zoom),
        rotation: fix_rotation(opts.rotation, undefined, true)
      },
      ease: opts.ease,
      zCoord: opts.zCoord,
      done: opts.done,
      duration: opts.duration,
      zoomOrigin: opts.zoomOrigin,
      endClamp: opts.endClamp
    };

    m_transition.interp = opts.interp([
      m_transition.start.center.x,
      m_transition.start.center.y,
      opts.zCoord ? zoom2z(m_transition.start.zoom) : m_transition.start.zoom,
      m_transition.start.rotation
    ], [
      m_transition.end.center ? m_transition.end.center.x : m_transition.start.center.x,
      m_transition.end.center ? m_transition.end.center.y : m_transition.start.center.y,
      opts.zCoord ? zoom2z(m_transition.end.zoom) : m_transition.end.zoom,
      m_transition.end.rotation
    ]);

    /**
     * Process an animation from during a transition.
     *
     * @param {number} time The animation frame time.  Used to ensure multiple
     *      transitions are smooth.
     * @private
     */
    function anim(time) {
      var done = m_transition.done,
          next = m_queuedTransition;
      if (m_transition.cancel === true) {
        /* Finish cancelling a transition. */
        m_this.geoTrigger(geo_event.transitioncancel, opts);
        if (done) {
          done({
            cancel: true,
            source: m_transition.cancelSource,
            transition: m_transition
          });
        }
        m_transition = null;
        /* There will only be a queuedTransition if it was created after this
         * transition was cancelled */
        if (m_queuedTransition) {
          next = m_queuedTransition;
          m_queuedTransition = null;
          m_this.transition(next, undefined, time);
        }
        return;
      }

      if (!m_transition.start.time) {
        m_transition.start.time = time;
        m_transition.end.time = time + opts.duration;
      }
      m_transition.time = time - m_transition.start.time;
      if (time >= m_transition.end.time || next) {
        if (!next) {
          if (m_transition.end.center) {
            var needZoom = m_zoom !== m_this._fix_zoom(m_transition.end.zoom);
            var noEndClamp = needZoom || opts.endClamp === false;
            m_this.center(m_transition.end.center, null, noEndClamp, noEndClamp);
          }
          m_this.zoom(m_transition.end.zoom, m_transition.zoomOrigin, opts.endClamp === false, opts.endClamp === false);
          m_this.rotation(fix_rotation(m_transition.end.rotation));
        }

        m_this.geoTrigger(geo_event.transitionend, opts);

        if (done) {
          done({next: !!next});
        }

        m_transition = null;
        if (m_queuedTransition) {
          next = m_queuedTransition;
          m_queuedTransition = null;
          m_this.transition(next, undefined, time);
        }

        return;
      }

      var z = m_transition.ease(
        (time - m_transition.start.time) / opts.duration
      );

      var p = m_transition.interp(z);
      if (m_transition.zCoord) {
        p[2] = z2zoom(p[2]);
      }
      if (m_this._fix_zoom(p[2], true) === m_zoom) {
        m_this.center({
          x: p[0],
          y: p[1]
        }, null, true, true);
      } else {
        m_center = m_this.gcsToWorld({x: p[0], y: p[1]}, null);
        m_this.zoom(p[2], m_transition.zoomOrigin, true, true);
      }
      m_this.rotation(p[3], undefined, true);

      m_this.scheduleAnimationFrame(anim);
    }

    m_this.geoTrigger(geo_event.transitionstart, opts);

    if (geo_event.cancelNavigation) {
      m_transition = null;
      m_this.geoTrigger(geo_event.transitionend, opts);
      return m_this;
    } else if (geo_event.cancelAnimation) {
      // run the navigation synchronously
      opts.duration = 0;
      anim(0);
    } else if (animTime) {
      anim(animTime);
    } else {
      m_this.scheduleAnimationFrame(anim);
    }
    return m_this;
  };

  /**
   * Cancel any existing transition.  The transition will send a cancel event
   * at the next animation frame, but no further activity occurs.
   *
   * @param {string} [source] Optional cause of the cancel.  This can be any
   *    value, but something like `(method name).(action)` is recommended to
   *    allow other functions to determine the source and cause of the
   *    transition being canceled.
   * @returns {boolean} `true` if a transition was in progress.
   * @fires geo.event.transitioncancel
   */
  this.transitionCancel = function (source) {
    if (m_transition && (m_transition.cancel !== true || m_queuedTransition)) {
      m_transition.cancel = true;
      m_transition.cancelSource = source || m_transition.cancelSource || '';
      m_queuedTransition = null;
      return true;
    }
    return false;
  };

  /**
   * Get/set the locations of the current map edges.  When set, the left-top
   * and right-bottom corners are transformed to the map's gcs and then used
   * to set the bounds.
   *
   * @param {geo.geoBounds} [bds] The requested map bounds.
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.  If setting the
   *    bounds, they are converted from this gcs to the map projection.  The
   *    returned bounds are converted from the map projection to this gcs.
   * @returns {geo.geoBounds} The actual new map bounds.
   */
  this.bounds = function (bds, gcs) {
    var nav;

    gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
    if (bds !== undefined) {
      if (gcs !== m_gcs) {
        var trans = transform.transformCoordinates(gcs, m_gcs, [{
          x: bds.left, y: bds.top}, {x: bds.right, y: bds.bottom}]);
        bds = {
          left: trans[0].x,
          top: trans[0].y,
          right: trans[1].x,
          bottom: trans[1].y
        };
      }
      bds = fix_bounds(bds, m_rotation);
      nav = m_this.zoomAndCenterFromBounds(bds, m_rotation, null);

      // This might have consequences in terms of bounds/zoom clamping.
      // What behavior do we expect from this method in that case?
      m_this.zoom(nav.zoom);
      m_this.center(nav.center, null);
    }

    return m_this.boundsFromZoomAndCenter(m_zoom, m_center, m_rotation, gcs,
                                          true);
  };

  /**
   * Get/set the maximum view area of the map.  If the map wraps, this is the
   * unwrapped area.
   *
   * @param {geo.geoBounds} [bounds] The map bounds.
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.  If setting the
   *    bounds, they are converted from this gcs to the map projection.  The
   *    returned bounds are converted from the map projection to this gcs.
   * @returns {geo.geoBounds|this} The map maximum bounds or the map object.
   */
  this.maxBounds = function (bounds, gcs) {
    gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
    if (bounds === undefined) {
      return {
        left: transform.transformCoordinates(m_gcs, gcs, {
          x: m_maxBounds.left, y: 0}).x,
        right: transform.transformCoordinates(m_gcs, gcs, {
          x: m_maxBounds.right, y: 0}).x,
        bottom: transform.transformCoordinates(m_gcs, gcs, {
          x: 0, y: m_maxBounds.bottom}).y,
        top: transform.transformCoordinates(m_gcs, gcs, {
          x: 0, y: m_maxBounds.top}).y
      };
    }
    var cx = ((bounds.left || 0) + (bounds.right || 0)) / 2,
        cy = ((bounds.bottom || 0) + (bounds.top || 0)) / 2;
    if (bounds.left !== undefined) {
      m_maxBounds.left = transform.transformCoordinates(gcs, m_gcs, {
        x: bounds.left, y: cy}).x;
    }
    if (bounds.right !== undefined) {
      m_maxBounds.right = transform.transformCoordinates(gcs, m_gcs, {
        x: bounds.right, y: cy}).x;
    }
    if (bounds.bottom !== undefined) {
      m_maxBounds.bottom = transform.transformCoordinates(gcs, m_gcs, {
        x: cx, y: bounds.bottom}).y;
    }
    if (bounds.top !== undefined) {
      m_maxBounds.top = transform.transformCoordinates(gcs, m_gcs, {
        x: cx, y: bounds.top}).y;
    }
    reset_minimum_zoom();
    m_this.zoom(m_zoom);
    m_this.pan({x: 0, y: 0});
    return m_this;
  };

  /**
   * Get the corners of the map.  Since the map can be rotated, this is
   * necessarily not the same as the overall bounds, which is the orthogonal
   * bounding box.
   *
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.  If setting the
   *    bounds, they are converted from this gcs to the map projection.  The
   *    returned bounds are converted from the map projection to this gcs.
   * @returns {geo.geoPosition[]} The corners of the map in the order
   *    upper-left, upper-right, lower-right, lower-left.
   */
  this.corners = function (gcs) {
    return [
      m_this.displayToGcs({x: 0, y: 0}, gcs),
      m_this.displayToGcs({x: m_width, y: 0}, gcs),
      m_this.displayToGcs({x: m_width, y: m_height}, gcs),
      m_this.displayToGcs({x: 0, y: m_height}, gcs)
    ];
  };

  /**
   * Get the center zoom level necessary to display the given bounds.
   *
   * @param {geo.geoBounds} bounds The requested map bounds.  `right` must be
   *    greater than `left` and `bottom` must be greater than `top` in the
   *    map's gcs (after conversion from the provided gcs).
   * @param {number} rotation Rotation in clockwise radians.
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.
   * @returns {geo.zoomAndCenter}
   */
  this.zoomAndCenterFromBounds = function (bounds, rotation, gcs) {
    var center, zoom;

    gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
    if (gcs !== m_gcs) {
      var trans = transform.transformCoordinates(gcs, m_gcs, [{
        x: bounds.left, y: bounds.top}, {x: bounds.right, y: bounds.bottom}]);
      bounds = {
        left: trans[0].x,
        top: trans[0].y,
        right: trans[1].x,
        bottom: trans[1].y
      };
    }
    if (bounds.left >= bounds.right || bounds.bottom >= bounds.top) {
      throw new Error('Invalid bounds provided');
    }

    // calculate the zoom to fit the bounds
    zoom = m_this._fix_zoom(calculate_zoom(bounds, rotation));

    // clamp bounds if necessary
    bounds = fix_bounds(bounds, rotation);

    /* This relies on having the map projection coordinates be uniform
     * regardless of location.  If not, the center will not be correct. */
    // calculate new center
    center = {
      x: (bounds.left + bounds.right) / 2 - m_origin.x,
      y: (bounds.top + bounds.bottom) / 2 - m_origin.y
    };
    if (gcs !== m_gcs) {
      center = transform.transformCoordinates(m_gcs, gcs, center);
    }
    return {
      zoom: zoom,
      center: center
    };
  };

  /**
   * Get the bounds that will be displayed with the given zoom and center.
   *
   * Note: the bounds may not have the requested zoom and center due to map
   * restrictions.
   *
   * @param {number} zoom The requested zoom level.
   * @param {geo.geoPosition} center The requested center.
   * @param {number} rotation The requested rotation in clockwise radians.
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.
   * @param {boolean} [ignoreDiscreteZoom] If `true`, ignore the `discreteZoom`
   *    option when determining the new view.
   * @param {boolean} [ignoreClampBounds] If `true` and `clampBoundsX` or
   *    `clampBoundsY` is set, allow the bounds to be less clamped.
   *    The map's `maxBounds` can be shifted so that they lie no further than
   *    the center of the bounds (rather than being forced to be at the edge).
   * @returns {geo.geoBounds}
   */
  this.boundsFromZoomAndCenter = function (zoom, center, rotation, gcs, ignoreDiscreteZoom, ignoreClampBounds) {
    var width, height, halfw, halfh, bounds, units;

    gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
    // preprocess the arguments
    zoom = m_this._fix_zoom(zoom, ignoreDiscreteZoom);
    units = m_this.unitsPerPixel(zoom);
    center = m_this.gcsToWorld(center, null);

    // get half the width and height in world coordinates
    width = m_width * units;
    height = m_height * units;
    halfw = width / 2;
    halfh = height / 2;

    // calculate the bounds.  This is only valid if the map projection has
    // uniform units in each direction.  If not, then worldToGcs should be
    // used.

    if (rotation) {
      center.x += m_origin.x;
      center.y += m_origin.y;
      bounds = rotate_bounds_center(
        center, {width: width, height: height}, rotation);
      // correct the bounds when clamping is enabled
      bounds.width = width;
      bounds.height = height;
      bounds = fix_bounds(bounds, rotation, undefined, ignoreClampBounds);
    } else {
      bounds = {
        left: center.x - halfw + m_origin.x,
        right: center.x + halfw + m_origin.x,
        bottom: center.y - halfh + m_origin.y,
        top: center.y + halfh + m_origin.y
      };
      // correct the bounds when clamping is enabled
      bounds = fix_bounds(bounds, 0, undefined, ignoreClampBounds);
    }
    if (gcs !== m_gcs) {
      var bds = transform.transformCoordinates(
        m_gcs, gcs,
        [[bounds.left, bounds.top], [bounds.right, bounds.bottom]]);
      bounds = {
        left: bds[0][0], top: bds[0][1], right: bds[1][0], bottom: bds[1][1]
      };
    }
    /* Add the original width and height of the viewport before rotation. */
    bounds.width = width;
    bounds.height = height;
    return bounds;
  };

  /**
   * Get/set the discrete zoom flag.  If `true`, the map will snap to integer
   * zoom levels.
   *
   * @param {boolean} [discreteZoom] If specified, the new discrete zoom flag.
   * @returns {boolean|this} The current discrete zoom flag or the map object.
   */
  this.discreteZoom = function (discreteZoom) {
    if (discreteZoom === undefined) {
      return m_discreteZoom;
    }
    discreteZoom = discreteZoom ? true : false;
    if (m_discreteZoom !== discreteZoom) {
      m_discreteZoom = discreteZoom;
      if (m_discreteZoom) {
        m_this.zoom(Math.round(m_this.zoom()));
      }
      if (m_this.interactor()) {
        m_this.interactor().options({discreteZoom: m_discreteZoom});
      }
    }
    return m_this;
  };

  /**
   * Get the layers contained in the map.
   * Alias of {@link geo.sceneObject#children}.
   * @method
   */
  this.layers = this.children;

  /**
   * Compare two layers by zIndex.  If the zIndex is the same, the order in the
   * parent element is used.  If the two layers don't have the same parent (for
   * instance, one layer isn't attached to the map), layers in the map are
   * sorted below detached layers.
   *
   * @param {geo.layer} a First layer to compare.
   * @param {geo.layer} b Second layer to compare.
   * @returns {number} Positive if `a` is above `b`.
   */
  function layerZIndexSort(a, b) {
    var az = a.zIndex(), bz = b.zIndex();
    if (az !== bz) {
      return az - bz;
    }
    var an = a.node()[0],
        bn = b.node()[0],
        ap = an && an.parentNode,
        bp = bn && bn.parentNode;
    if (ap && bp && ap === bp) {
      var nodes = Array.from(ap.children),
          ai = nodes.indexOf(an),
          bi = nodes.indexOf(bn);
      if (ai >= 0 && bi >= 0) {
        return ai - bi;
      }
    }
    return ap ? -1 : bp ? 1 : 0;
  }

  /**
   * Get the layers contained in the map sorted by zIndex.  If two layers have
   * the same zIndex, they are returned in creation order.
   *
   * @returns {geo.layer[]}
   */
  this.sortedLayers = function () {
    return m_this.children().sort(layerZIndexSort);
  };

  /**
   * Get a sorted list of {@link geo.sceneObject} including all children.  The
   * list always includes specified objects.  Children immediately follow their
   * parents.  Siblings may be separated by children of preceding siblings.
   *
   * @param {geo.sceneObject[]} [objects] A list of objects for which the
   *    a combined list of dependents is generated.  If not specified, the
   *    sorted list of layers is used.
   * @returns {geo.sceneObject[]} A list of object and dependents.
   */
  this.listSceneObjects = function (objects) {
    var objectList = [];
    objects = objects || m_this.sortedLayers();
    objects.forEach(function (object) {
      if (objectList.indexOf(object) < 0) {
        objectList.push(object);
        if (object.children) {
          var children = object.children();
          if (children.length) {
            objectList = objectList.concat(m_this.listSceneObjects(children));
          }
        }
      }
    });
    return objectList;
  };

  /**
   * Update the attribution notice displayed on the bottom right corner of
   * the map.  The content of this notice is managed by individual layers.
   * This method queries all of the visible layers and joins the individual
   * attribution notices into a single element.  By default, this method
   * is called on each of the following events:
   *
   *   * {@link geo.event.layerAdd}
   *   * {@link geo.event.layerRemove}
   *
   * In addition, layers should call this method when their own attribution
   * notices have changed.  Users, in general, should not need to call this.
   *
   * @returns {this} Chainable.
   */
  this.updateAttribution = function () {
    // clear any existing attribution content
    m_this.node().find('.geo-attribution').remove();

    // generate a new attribution node
    var $a = $('<div/>')
      .addClass('geo-attribution')
      .on('mousedown', function (evt) {
        evt.stopPropagation();
      });

    // append content from each layer
    m_this.children().forEach(function (layer) {
      var content = layer.attribution();
      if (content) {
        $('<span/>')
          .addClass('geo-attribution-layer')
          .html(content)
          .appendTo($a);
      }
    });

    /* Only add the element if there is at least one attribution */
    if ($('span', $a).length) {
      $a.appendTo(m_this.node());
    }
    return m_this;
  };

  /**
   * Get a screen-shot of all or some of the canvas layers of map.  Note that
   * webGL layers are rerendered, even if
   *   `window.overrideContextAttributes.preserveDrawingBuffer = true;`
   * is set before creating the map object.  Chrome, at least, may not keep the
   * drawing buffers if the tab loses focus (and returning focus won't
   * necessarily rerender).
   *
   * @param {geo.layer|geo.layer[]|false|object} [layers] Either a layer, a
   *    list of layers, falsy to get all layers, or an object that contains
   *    optional values of `layers`, `type`, `encoderOptions`, and additional
   *    values listed in the `opts` parameter (this last form allows a single
   *    argument for the function).
   * @param {string} [type='image/png'] See {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL
   *    canvas.toDataURL}.  Use `'canvas'` to return the canvas element (this
   *    can be used to get the results as a blob, which can be faster for some
   *    operations but is not supported as widely).
   * @param {number} [encoderOptions] See {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL
   *    canvas.toDataURL}.
   * @param {object} [opts] Additional screenshot options.
   * @param {false|string|CanvasRenderingContext2D.fillStyle}
   *    [opts.background='white'] If `false` or `null`, don't prefill the
   *    background.  Otherwise, a css color or
   *    `CanvasRenderingContext2D.fillStyle` to fill the initial canvas.  This
   *    could match the background of the browser page, for instance.
   * @param {boolean|'idle'} [opts.wait=false] If `'idle'`, wait for the map to
   *    be idle and one additional animation frame to occur.  If truthy, wait
   *    for an animation frame to occur.  Otherwise, take the screenshot as
   *    soon as possible.
   * @param {boolean|null} [opts.attribution=null] If `null` or unspecified,
   *    include the attribution only if all layers are used.  If false, never
   *    include the attribution.  If `true`, always include it.
   * @param {HTMLElement[]|string[]} [opts.html] A list of additional HTML
   *    elements, selectors, or jQuery elements to render on top of the map.
   *    These are rendered in order, with the last one topmost.
   * @returns {jQueryDeferred} A jQuery Deferred object.  The done function
   *    receives either a data URL or an `HTMLCanvasElement` with the result.
   * @fires geo.event.screenshot.ready
   */
  this.screenshot = function (layers, type, encoderOptions, opts) {
    var defer;

    if (layers && !Array.isArray(layers) && !layers.renderer) {
      type = type || layers.type;
      encoderOptions = encoderOptions || layers.encoderOptions;
      opts = opts || layers;
      layers = layers.layers;
    }
    opts = opts || {};
    /* if asked to wait, return a Deferred that will do so, calling the
     * screenshot function without waiting once it is done. */
    if (opts.wait) {
      var optsWithoutWait = Object.assign({}, opts, {wait: false});
      defer = $.Deferred();

      var waitForRAF = function () {
        window.requestAnimationFrame(function () {
          defer.resolve();
        });
      };

      if (opts.wait === 'idle') {
        m_this.onIdle(waitForRAF);
      } else {
        waitForRAF();
      }
      return defer.then(function () {
        return m_this.screenshot(layers, type, encoderOptions, optsWithoutWait);
      });
    }
    defer = $.when();
    // ensure layers is a list of all the layers we want to include
    if (!layers) {
      layers = m_this.layers();
      if (opts.attribution === null || opts.attribution === undefined) {
        opts.attribution = true;
      }
    } else if (!Array.isArray(layers)) {
      layers = [layers];
    }
    // filter to only the included layers
    layers = layers.filter(function (l) {
      return m_this.layers().indexOf(l) >= 0 &&
             l.opacity() > 0 && (!l.visible || l.visible());
    });
    // sort layers by z-index
    layers = layers.sort(layerZIndexSort);
    // create a new canvas element
    var result = document.createElement('canvas');
    result.width = m_width;
    result.height = m_height;
    var context = result.getContext('2d');
    // optionally start with a white or custom background
    if (opts.background !== false && opts.background !== null) {
      var background = opts.background;
      if (opts.background === undefined) {
        /* If we are using the map's current background, start with white as a
         * fallback, then fill with the backgrounds of all parents and the map
         * node.  Since each may be partially transparent, this is required to
         * match the web page's color.  It won't use background patterns. */
        context.fillStyle = 'white';
        context.fillRect(0, 0, result.width, result.height);
        m_this.node().parents().get().reverse().forEach(function (elem) {
          background = window.getComputedStyle(elem).backgroundColor;
          if (background && background !== 'transparent') {
            context.fillStyle = background;
            context.fillRect(0, 0, result.width, result.height);
          }
        });
        background = window.getComputedStyle(m_this.node()[0]).backgroundColor;
      }
      if (background && background !== 'transparent') {
        context.fillStyle = background;
        context.fillRect(0, 0, result.width, result.height);
      }
    }
    // for each layer, copy to our new canvas.
    layers.forEach(function (layer) {
      var opacity = layer.opacity();
      layer.node().children('canvas').each(function () {
        var canvasElem = $(this);
        defer = defer.then(function () {
          if (layer.renderer() && layer.renderer().api() === 'webgl') {
            layer.renderer()._renderFrame();
          }
          drawLayerImageToContext(context, opacity, canvasElem, canvasElem[0], layer.node().css('mix-blend-mode'));
        });
      });
      if ((layer.node().children().not('canvas').length || !layer.node().children().length) && (!layer.renderer() || layer.renderer().api() !== 'webgl')) {
        defer = defer.then(function () {
          return util.htmlToImage(layer.node(), 1).done(function (img) {
            drawLayerImageToContext(context, 1, $([]), img, layer.node().css('mix-blend-mode'));
          });
        });
      }
    });
    if (opts.attribution) {
      m_this.node().find('.geo-attribution').each(function () {
        var attrElem = $(this);
        defer = defer.then(function () {
          return util.htmlToImage(attrElem, 1).done(function (img) {
            drawLayerImageToContext(context, 1, $([]), img);
          });
        });
      });
    }
    if (opts.html) {
      $(opts.html).each(function () {
        var attrElem = $(this);
        defer = defer.then(function () {
          return util.htmlToImage(attrElem, 1).done(function (img) {
            drawLayerImageToContext(context, 1, $([]), img, attrElem.css('mix-blend-mode'));
          });
        });
      });
    }
    defer = defer.then(function () {
      var canvas = result;
      if (type !== 'canvas') {
        try {
          result = result.toDataURL(type, encoderOptions);
        } catch (err) {
          console.warn('Failed to convert screenshot to output', err);
          var failure = $.Deferred();
          failure.reject();
          return failure;
        }
      }
      m_this.geoTrigger(geo_event.screenshot.ready, {
        canvas: canvas,
        screenshot: result
      });
      return result;
    });
    return defer;
  };

  /**
   * Instead of each function using `window.requestAnimationFrame`, schedule
   * all such frames through this function.  This allows the callbacks to be
   * reordered or removed as needed and reduces overhead in Chrome a small
   * amount.  Also, if the animation queue is shared between map instances, the
   * callbacks will be called in a single time slice, providing better
   * synchronization.
   *
   * @param {function} callback Function to call during the animation frame.
   *    It is called with an animation epoch, exactly as
   *    `requestAnimationFrame`.
   * @param {boolean|'remove'} [action=false] Falsy to only add the callback if
   *    it is not already scheduled.  `'remove'` to remove the callback (use
   *    this instead of `cancelAnimationFrame`).  Any other truthy value moves
   *    the callback to the end of the list.
   * @returns {number} An integer as returned by
   *    `window.requestAnimationFrame`.
   */
  this.scheduleAnimationFrame = function (callback, action) {
    if (!m_animationQueue.length) {
      /* By referring to requestAnimationFrame as a property of window, versus
       * explicitly using window.requestAnimationFrame, we prevent the
       * stripping of 'window' off of the reference and allow our tests to
       * override this if needed. */
      m_animationQueue.push(window['requestAnimationFrame'](processAnimationFrame)); // eslint-disable-line dot-notation
    }
    var pos = m_animationQueue.indexOf(callback, 1);
    if (pos >= 0) {
      if (!action) {
        return;
      }
      m_animationQueue.splice(pos, 1);
      if (action === 'remove') {
        return;
      }
    }
    m_animationQueue.push(callback);
    return m_animationQueue[0];
  };

  /**
   * Return the nearest valid zoom level to the requested zoom.
   * @param {number} zoom A zoom level to adjust to current settings
   * @param {boolean} [ignoreDiscreteZoom] If `true`, ignore the `discreteZoom`
   *    option when determining the new view.
   * @returns {number} The zoom level clamped to the allowed zoom range and
   *    with other settings applied.
   * @private
   */
  this._fix_zoom = function (zoom, ignoreDiscreteZoom) {
    zoom = Math.round(zoom * 1e6) / 1e6;
    zoom = Math.max(
      Math.min(
        m_validZoomRange.max,
        zoom
      ),
      m_validZoomRange.min
    );
    if (m_discreteZoom && !ignoreDiscreteZoom) {
      zoom = Math.round(zoom);
      if (zoom < m_validZoomRange.min) {
        zoom = Math.ceil(m_validZoomRange.min);
      }
    }
    return zoom;
  };

  /**
   * Get or set the setting of autoshareRenderer.
   *
   * @param {boolean|string|null} [arg] If specified, the new value for
   *   autoshareRender that gets passed to created layers.  `null` will clear
   *   the value.
   * @returns {boolean|string|this}
   */
  this.autoshareRenderer = function (arg) {
    if (arg === undefined) {
      return m_autoshareRenderer;
    }
    m_autoshareRenderer = arg === null ? undefined : arg;
    return m_this;
  };

  /* Report the current version on the map object. */
  this._version = require('./version');
  /* Link to the main library */
  this._geo = require('./index');

  /**
   * Draw a layer image to a canvas context.  The layer's opacity and transform
   * are applied.  This is used as part of making a screenshot.
   *
   * @param {CanvasRenderingContext2D} context The 2d canvas context to draw
   *    into.
   * @param {number} opacity The opacity in the range [0, 1].
   * @param {object} elem A jQuery element that might have a transform.
   * @param {HTMLImageElement} img The image or canvas to draw to the canvas.
   * @param {string} [mixBlendMode] the mix-blend-mode used to add this layer.
   * @private
   */
  function drawLayerImageToContext(context, opacity, elem, img, mixBlendMode) {
    context.globalAlpha = opacity;
    if (mixBlendMode) {
      context.globalCompositeOperation = mixBlendMode;
    }
    var transform = elem.css('transform');
    // if the canvas is being transformed, apply the same transformation
    if (transform && transform.substr(0, 7) === 'matrix(') {
      context.setTransform.apply(context, transform.substr(7, transform.length - 8).split(',').map(parseFloat));
    } else {
      context.setTransform(1, 0, 0, 1, 0, 0);
    }
    context.drawImage(img, 0, 0);
    context.globalCompositeOperation = 'source-over';
  }

  /**
   * Service the callback during an animation frame.  This uses splice to modify
   * the `animationQueue` to allow multiple map instances to share the queue.
   * @private
   */
  function processAnimationFrame() {
    var queue = m_animationQueue.splice(0, m_animationQueue.length);

    /* The first entry is the reference to the window.requestAnimationFrame. */
    for (var i = 1; i < queue.length; i += 1) {
      try {
        queue[i].apply(m_this, arguments);
      } catch (err) {
        console.error(err);
      }
    }
  }

  /*
   * The following are some private methods for interacting with the camera.
   * In order to hide the complexity of dealing with map aspect ratios,
   * clamping behavior, resetting zoom levels on resize, etc. from the
   * layers, the map handles camera movements directly.  This requires
   * passing all camera movement events through the map initially.  The
   * map uses these methods to fix up the events according to the constraints
   * of the display and passes the event to the layers.
   */
  /**
   * Calculate the scaling factor to fit the given map bounds into the viewport
   * with the correct aspect ratio.
   *
   * @param {geo.geoBounds} bounds A desired bounds.
   * @returns {object} Multiplicative aspect ratio correction with x and y
   *    values.
   * @private
   */
  function camera_scaling(bounds) {
    var width = bounds.right - bounds.left,
        height = bounds.top - bounds.bottom,
        ar_bds = Math.abs(width / height),
        ar_vp = m_width / m_height,
        sclx, scly;

    if (ar_bds > ar_vp) {
      // fit left and right
      sclx = 1;

      // grow top and bottom
      scly = ar_bds / ar_vp;
    } else {
      // fit top and bottom
      scly = 1;

      // grow left and right
      sclx = ar_vp / ar_bds;
    }
    return {x: sclx, y: scly};
  }

  /**
   * Adjust a set of bounds based on a rotation.  If a rotation exists, the
   * returned bounds are typically larger than the source bounds.
   *
   * @param {geo.geoBounds} bounds Bounds to adjust.
   * @param {number} rotation Angle in radians (positive is clockwise).
   * @returns {geo.geoBounds}
   * @private
   */
  function rotate_bounds(bounds, rotation) {
    if (rotation) {
      var center = {
        x: (bounds.left + bounds.right) / 2,
        y: (bounds.top + bounds.bottom) / 2
      };
      var size = {
        width: Math.abs(bounds.left - bounds.right),
        height: Math.abs(bounds.top - bounds.bottom)
      };
      bounds = rotate_bounds_center(center, size, rotation);
    }
    return bounds;
  }

  /**
   * Generate a set of bounds based on a center point, a width and height, and
   * a rotation.
   *
   * @param {geo.geoPosition} center
   * @param {object} size Size of the screen in map gcs.
   * @param {number} size.width
   * @param {number} size.height
   * @param {number} rotation Angle in radians (positive is clockwise).
   * @returns {geo.geoBounds}
   * @private
   */
  function rotate_bounds_center(center, size, rotation) {
    // calculate the half width and height
    var width = size.width / 2, height = size.height / 2;
    var sinr = Math.sin(rotation), cosr = Math.cos(rotation);
    var ul = {}, ur = {}, ll = {}, lr = {};
    ul.x = center.x + (-width) * cosr - (-height) * sinr;
    ul.y = center.y + (-width) * sinr + (-height) * cosr;
    ur.x = center.x + width * cosr - (-height) * sinr;
    ur.y = center.y + width * sinr + (-height) * cosr;
    ll.x = center.x + (-width) * cosr - height * sinr;
    ll.y = center.y + (-width) * sinr + height * cosr;
    lr.x = center.x + width * cosr - height * sinr;
    lr.y = center.y + width * sinr + height * cosr;
    return {
      left: Math.min(ul.x, ur.x, ll.x, lr.x),
      right: Math.max(ul.x, ur.x, ll.x, lr.x),
      bottom: Math.min(ul.y, ur.y, ll.y, lr.y),
      top: Math.max(ul.y, ur.y, ll.y, lr.y)
    };
  }

  /**
   * Calculate the minimum zoom level to fit the given bounds inside the view
   * port using the view port size, the given bounds, and the number of units
   * per pixel.  The method sets the valid zoom bounds as well as the current
   * zoom level to be within that range.
   *
   * @param {geo.geoBounds} bounds Bounds to fit to the screen.
   * @param {number} [rotation] Rotation in radians.  If unspecified, use the
   *    current map rotation.
   * @returns {number} The necessary zoom level.
   * @private
   */
  function calculate_zoom(bounds, rotation) {
    if (rotation === undefined) {
      rotation = m_rotation;
    }
    bounds = rotate_bounds(bounds, rotation);
    // compare the aspect ratios of the viewport and bounds
    var scl = camera_scaling(bounds), z;

    if (scl.y > scl.x) {
      // left to right matches exactly
      // center map vertically and have blank borders on the
      // top and bottom (or repeat tiles)
      z = -Math.log2(
        Math.abs(bounds.right - bounds.left) * scl.x /
        (m_width * m_unitsPerPixel)
      );
    } else {
      // top to bottom matches exactly, blank border on the
      // left and right (or repeat tiles)
      z = -Math.log2(
        Math.abs(bounds.top - bounds.bottom) * scl.y /
        (m_height * m_unitsPerPixel)
      );
    }
    return z;
  }

  /**
   * Reset the minimum zoom level given the current window size.
   * @private
   */
  function reset_minimum_zoom() {
    if (m_clampZoom) {
      m_validZoomRange.min = Math.max(
        m_validZoomRange.origMin, calculate_zoom(m_maxBounds));
    } else {
      m_validZoomRange.min = m_validZoomRange.origMin;
    }
  }

  /**
   * Return a valid rotation angle.
   *
   * @param {number} rotation Proposed rotation.
   * @param {boolean} [ignoreRotationFunc] If truthy and rotations are allowed,
   *    allow any rotation.  Otherwise, the rotation is passed through the
   *    `allowRotation` function.
   * @param {boolean} [noRangeLimit] If falsy, ensure that the rotation is in
   *    the range [0, 2*PI).  If it is very close to zero, it is snapped to
   *    zero.  If true, the rotation can have any value.
   * @returns {number} the validated rotation
   * @private
   */
  function fix_rotation(rotation, ignoreRotationFunc, noRangeLimit) {
    if (!m_allowRotation) {
      return 0;
    }
    if (!ignoreRotationFunc && typeof m_allowRotation === 'function') {
      rotation = m_allowRotation(rotation);
    }
    /* Ensure that the rotation is in the range [0, 2pi) */
    if (!noRangeLimit) {
      var range = Math.PI * 2;
      rotation = (rotation % range) + (rotation >= 0 ? 0 : range);
      if (Math.min(Math.abs(rotation), Math.abs(rotation - range)) < 0.00001) {
        rotation = 0;
      }
    }
    return rotation;
  }

  /**
   * Return the nearest valid bounds maintaining the width and height.  Does
   * nothing if `clampBoundsX` and `clampBoundsY` are false.  If a delta is
   * specified, will only clamp if the out-of-bounds condition would be worse.
   * If `ignoreClampBounds` is true, clamping is applied only to prevent more
   * than half the image from being off screen.
   *
   * @param {geo.geoBounds} bounds The new bounds to apply in map gcs
   *    coordinates.
   * @param {number} [rotation] The angle of rotation in radians.  May be falsy
   *    to have no rotation.
   * @param {object} [delta] If present, the shift in position in screen
   *    coordinates.  Bounds will only be adjusted if the bounds would be
   *    more out of position after the shift.
   * @param {number} delta.x
   * @param {number} delta.y
   * @param {number} delta.unit Units per pixel at the current zoom level.
   * @param {boolean} [ignoreClampBounds] If `true` and `clampBoundsX` or
   *    `clampBoundsY` are set, allow the bounds to be less clamped.
   *    Specifically, the map's `maxBounds` can be shifted so that they lie no
   *    further than the center of the bounds (rather than being forced to be
   *    at the edge).
   * @returns {geo.geoBounds} The adjusted bounds.  This may be the same object
   *    passed in `bounds`.
   * @private
   */
  function fix_bounds(bounds, rotation, delta, ignoreClampBounds) {
    if (!m_clampBoundsX && !m_clampBoundsY) {
      return bounds;
    }
    var dx, dy, maxBounds = m_maxBounds;
    if (rotation) {
      maxBounds = Object.assign({}, m_maxBounds);
      /* When rotated, expand the maximum bounds so that they will allow the
       * corners to be visible.  We know the rotated bounding box, plus the
       * original maximum bounds.  To fit the corners of the maximum bounds, we
       * can expand the total bounds by the same factor that the rotated
       * bounding box is expanded from the non-rotated bounding box (for a
       * small rotation, this is sin(rotation) * (original bounding box height)
       * in the width).  This feels like appropriate behaviour with one of the
       * two bounds clamped.  With both, it seems mildly peculiar. */
      var bw = Math.abs(bounds.right - bounds.left),
          bh = Math.abs(bounds.top - bounds.bottom),
          absinr = Math.abs(Math.sin(rotation)),
          abcosr = Math.abs(Math.cos(rotation)),
          ow, oh;
      if (bounds.width && bounds.height) {
        ow = bounds.width;
        oh = bounds.height;
      } else if (Math.abs(absinr - abcosr) < 0.0005) {
        /* If we are close to a 45 degree rotation, it is ill-determined to
         * compute the original (pre-rotation) bounds width and height.  In
         * this case, assume that we are using the map's aspect ratio. */
        if (m_width && m_height) {
          var aspect = Math.abs(m_width / m_height);
          var fac = Math.pow(1 + Math.pow(aspect, 2), 0.5);
          ow = Math.max(bw, bh) / fac;
          oh = ow * aspect;
        } else {
          /* Fallback if we don't have width or height */
          ow = bw * abcosr;
          oh = bh * absinr;
        }
      } else {
        /* Compute the pre-rotation (original) bounds width and height */
        ow = (abcosr * bw - absinr * bh) / (abcosr * abcosr - absinr * absinr);
        oh = (abcosr * bh - absinr * bw) / (abcosr * abcosr - absinr * absinr);
      }
      /* Our maximum bounds are expanded based on the projected length of a
       * tilted side of the original bounding box in the rotated bounding box.
       * To handle all rotations, take the minimum difference in width or
       * height. */
      var bdx = bw - Math.max(abcosr * ow, absinr * oh),
          bdy = bh - Math.max(abcosr * oh, absinr * ow);
      maxBounds.left -= bdx;
      maxBounds.right += bdx;
      maxBounds.top += bdy;
      maxBounds.bottom -= bdy;
    }
    if (ignoreClampBounds) {
      maxBounds = {
        left: maxBounds.left - (bounds.right - bounds.left) / 2,
        right: maxBounds.right + (bounds.right - bounds.left) / 2,
        top: maxBounds.top - (bounds.bottom - bounds.top) / 2,
        bottom: maxBounds.bottom + (bounds.bottom - bounds.top) / 2
      };
    }
    if (m_clampBoundsX) {
      if (bounds.right - bounds.left > maxBounds.right - maxBounds.left) {
        dx = maxBounds.left - ((bounds.right - bounds.left - (
          maxBounds.right - maxBounds.left)) / 2) - bounds.left;
      } else if (bounds.left < maxBounds.left) {
        dx = maxBounds.left - bounds.left;
      } else if (bounds.right > maxBounds.right) {
        dx = maxBounds.right - bounds.right;
      }
      if (dx && (!delta || delta.x * dx > 0)) {
        if (delta && Math.abs(dx) > Math.abs(delta.x * delta.unit)) {
          dx = Math.abs(delta.x * delta.unit) * dx / Math.abs(dx);
        }
        bounds = {
          left: bounds.left += dx,
          right: bounds.right += dx,
          top: bounds.top,
          bottom: bounds.bottom
        };
      }
    }
    if (m_clampBoundsY) {
      if (bounds.top - bounds.bottom > maxBounds.top - maxBounds.bottom) {
        dy = maxBounds.bottom - ((bounds.top - bounds.bottom - (
          maxBounds.top - maxBounds.bottom)) / 2) - bounds.bottom;
      } else if (bounds.top > maxBounds.top) {
        dy = maxBounds.top - bounds.top;
      } else if (bounds.bottom < maxBounds.bottom) {
        dy = maxBounds.bottom - bounds.bottom;
      }
      if (dy && (!delta || -delta.y * dy > 0)) {
        if (delta && Math.abs(dy) > Math.abs(delta.y * delta.unit)) {
          dy = Math.abs(delta.y * delta.unit) * dy / Math.abs(dy);
        }
        bounds = {
          top: bounds.top += dy,
          bottom: bounds.bottom += dy,
          left: bounds.left,
          right: bounds.right
        };
      }
    }
    return bounds;
  }

  /**
   * Call the camera bounds method with the given bounds, but correct for the
   * viewport aspect ratio.
   *
   * @param {geo.geoBounds} bounds The bounds for the camera.  If a rotation
   *    is specified, the bounds need to also contain the map gcs width and
   *    height.
   * @param {number} [rotation] The map rotation in radians.
   * @private
   */
  function camera_bounds(bounds, rotation) {
    m_camera.rotation = rotation || 0;
    /* When dealing with rotation, use the original width and height of the
     * bounds, as the rotation will have expanded them. */
    if (bounds.width && bounds.height && rotation) {
      var cx = (bounds.left + bounds.right) / 2,
          cy = (bounds.top + bounds.bottom) / 2;
      m_camera.viewFromCenterSizeRotation({x: cx, y: cy}, bounds, rotation);
    } else {
      m_camera.bounds = bounds;
    }
    /* Update the center to what was set. */
    m_center = {
      x: (m_camera.bounds.left + m_camera.bounds.right) / 2,
      y: (m_camera.bounds.top + m_camera.bounds.bottom) / 2
    };
  }

  /**
   * Resize the map based on the size of the associated DOM node.
   * @private
   */
  function resizeSelf() {
    m_this.size({width: m_node.width(), height: m_node.height()});
  }

  /*
   * All the methods are now defined.  From here, we are initializing all
   * internal variables and event handlers.
   */

  this._init(arg);

  // set up drag/drop handling
  this.node().on('dragover.geo', function (e) {
    var evt = e.originalEvent;

    if (m_this.fileReader()) {
      evt.stopPropagation();
      evt.preventDefault();
      evt.dataTransfer.dropEffect = 'copy';
    }
  })
  .on('drop.geo', function (e) {
    var evt = e.originalEvent, reader = m_this.fileReader(),
        i, file;

    function done() {
      m_this.draw();
    }

    if (reader) {
      evt.stopPropagation();
      evt.preventDefault();

      for (i = 0; i < evt.dataTransfer.files.length; i += 1) {
        file = evt.dataTransfer.files[i];
        if (reader.canRead(file)) {
          reader.read(file, done); // to do: trigger event on done
        }
      }
    }
  });

  /*
   * The map coordinates for the default world map, where c = half
   * circumference at equator in meters, o = origin:
   *   (-c, c) + o                   (c, c) + o
   *            (center.x, center.y) + o            <-- center of viewport
   *   (-c, -c) + o                  (c, -c) + o
   */
  // Set the world origin
  m_origin = {x: 0, y: 0};

  // Fix the zoom level (minimum and initial)
  this.zoomRange(arg, true);
  m_zoom = this._fix_zoom(m_zoom);
  m_rotation = fix_rotation(m_rotation);
  // Now update to the correct center and zoom level
  this.center(Object.assign({}, arg.center || m_center), undefined);

  if (arg.interactor !== null) {
    this.interactor(arg.interactor || mapInteractor({discreteZoom: m_discreteZoom}));
  }

  if (m_autoResize) {
    $(window).on('resize', resizeSelf);
  }

  // attach attribution updates to layer events
  m_this.geoOn([
    geo_event.layerAdd,
    geo_event.layerRemove
  ], m_this.updateAttribution);

  return this;
};

/**
 * Create a map from an object.  Any errors in the creation
 * of the map will result in returning `null`.
 *
 * @param {geo.map.createSpec} spec The map creation specification.
 * @returns {geo.map|null}
 */
map.create = function (spec) {
  'use strict';

  var _map = map(spec),
      layer = require('./layer');

  /* If the spec is bad, we still end up with an object, but it won't have a
   * zoom function */
  if (!_map || !_map.zoom) {
    console.warn('Could not create map.');
    return null;
  }

  spec.data = spec.data || [];
  spec.layers = spec.layers || [];

  spec.layers.forEach(function (l) {
    l.data = l.data || spec.data;
    l.layer = layer.create(_map, l);
  });

  return _map;
};

inherit(map, sceneObject);
module.exports = map;