Kitware / UPennContrast

UPenn ?
https://upenn-contrast.netlify.com/
Apache License 2.0
8 stars 6 forks source link

Performance issue on loading large number of GeoJS annotations #819

Open arjunrajlab opened 1 day ago

arjunrajlab commented 1 day ago

When we have many tens of thousands of annotations (usually around 20-40K), the interface will "lock up" during the loading. After digging into the timing, I think the issue is in the addAnnotation function in GeoJS. It seems that when the total number of annotations in the layer is small, then it's really fast, like 0.01ms. But when the number of annotations is very large, then it will (towards the end) be something like 0.8ms. I think this is because adding a large number of annotations is O(n^2) or worse due to the way addAnnotation is written. Is there some way to either fix the issue (perhaps use a map or has) or make an addAnnotations that would allow for a bulk addition of multiple annotations without the costly lookups? Or both? I'm not sure there's a way to fix this in NimbusImage itself.

arjunrajlab commented 11 hours ago

@manthey Here's a proposed fix from Claude but I haven't been able to test it:

var inherit = require('./inherit');
var featureLayer = require('./featureLayer');
var geo_annotation = require('./annotation');
var geo_event = require('./event');
var registry = require('./registry');
var transform = require('./transform');
var $ = require('jquery');
var Mousetrap = require('mousetrap');
var textFeature = require('./textFeature');

/**
 * Object specification for an annotation layer.
 *
 * @typedef {geo.layer.spec} geo.annotationLayer.spec
 * @extends {geo.layer.spec}
 * @property {number} [dblClickTime=300] The delay in milliseconds that is
 *   treated as a double-click when working with annotations.
 * @property {number} [adjacentPointProximity=5] The minimum distance in
 *   display coordinates (pixels) between two adjacent points when creating a
 *   polygon or line.  A value of 0 requires an exact match.
 * @property {number} [continuousPointProximity=5] The minimum distance in
 *   display coordinates (pixels) between two adjacent points when dragging
 *   to create an annotation.  `false` disables continuous drawing mode.
 * @property {number} [continuousPointCollinearity=1.0deg] The minimum angle
 *   between a series of three points when dragging to not interpret them as
 *   collinear.  Only applies if `continuousPointProximity` is not `false`.
 * @property {number} [continuousCloseProximity=10] The minimum distance in
 *   display coordinates (pixels) to close a polygon or end drawing a line when
 *   dragging to create an annotation.  `false` never closes at the end of a
 *   drag.  `true` is effectively infinite.
 * @property {number} [finalPointProximity=10] The maximum distance in display
 *   coordinates (pixels) between the starting point and the mouse coordinates
 *   to signal closing a polygon.  A value of 0 requires an exact match.  A
 *   negative value disables closing a polygon by clicking on the start point.
 * @property {boolean} [showLabels=true] Truthy to show feature labels that are
 *   allowed by the associated feature to be shown.
 * @property {boolean} [clickToEdit=false] Truthy to allow clicking an
 *   annotation to place it in edit mode.
 * @property {geo.textFeature.styleSpec} [defaultLabelStyle] Default styles for
 *   labels.
 */

/**
 * @typedef {object} geo.annotationLayer.labelRecord
 * @property {string} text The text of the label
 * @property {geo.geoPosition} position The position of the label in map gcs
 *    coordinates.
 * @property {geo.textFeature.styleSpec} [style] A {@link geo.textFeature}
 *    style object.
 */

/**
 * @typedef {geo.util.polyop.spec} geo.util.polyop.annotationLayerSpec
 * @extends {geo.util.polyop.spec}
 * @property {string} [keepAnnotations='exact'] Determine which annotations are
 *   present after a boolean operation.  `'exact'` replaces modified
 *   annotations with the results; unmodified annotations are left as is.
 *   `'all'` replaces all annotations, so unchanged annotations may be
 *   converted to polygonAnnotations.  `'none'` discards all existing
 *   annotations and only keeps modified results.
 */

/**
 * Layer to handle direct interactions with different features.  Annotations
 * (features) can be created by calling mode(<name of feature>) or cancelled
 * with mode(null).  There is also an "edit" mode which is used when modifying
 * an annotation.
 *
 * @class
 * @alias geo.annotationLayer
 * @extends geo.featureLayer
 * @param {geo.annotationLayer.spec} [arg] Specification for the new layer.
 * @returns {geo.annotationLayer}
 * @fires geo.event.annotation.state
 * @fires geo.event.annotation.coordinates
 * @fires geo.event.annotation.edit_action
 * @fires geo.event.annotation.select_edit_handle
 */
var annotationLayer = function (arg) {
  'use strict';
  if (!(this instanceof annotationLayer)) {
    return new annotationLayer(arg);
  }
  featureLayer.call(this, arg);

  var mapInteractor = require('./mapInteractor');
  var timestamp = require('./timestamp');
  var util = require('./util');

  var m_this = this,
      s_init = this._init,
      s_exit = this._exit,
      s_draw = this.draw,
      s_update = this._update,
      m_buildTime = timestamp(),
      m_options,
      m_mode = null,
      m_annotations = [],
      m_annotationsById = new Map(),
      m_features = [],
      m_labelFeature,
      m_labelLayer,
      m_keyHandler;

  var geojsonStyleProperties = {
    closed: {dataType: 'boolean', keys: ['closed', 'close']},
    fill: {dataType: 'boolean', keys: ['fill']},
    fillColor: {dataType: 'color', keys: ['fillColor', 'fill-color', 'marker-color', 'fill']},
    fillOpacity: {dataType: 'opacity', keys: ['fillOpacity', 'fill-opacity']},
    lineCap: {dataType: 'text', keys: ['lineCap', 'line-cap']},
    lineJoin: {dataType: 'text', keys: ['lineJoin', 'line-join']},
    radius: {dataType: 'positive', keys: ['radius']},
    scaled: {dataType: 'booleanOrNumber', keys: ['scaled']},
    stroke: {dataType: 'boolean', keys: ['stroke']},
    strokeColor: {dataType: 'color', keys: ['strokeColor', 'stroke-color', 'stroke']},
    strokeOffset: {dataType: 'number', keys: ['strokeOffset', 'stroke-offset']},
    strokeOpacity: {dataType: 'opacity', keys: ['strokeOpacity', 'stroke-opacity']},
    strokeWidth: {dataType: 'positive', keys: ['strokeWidth', 'stroke-width']}
  };
  var textFeatureDataTypes = {
    offset: 'coordinate2',
    rotateWithMap: 'boolean',
    rotation: 'angle',
    scaleWithMap: 'boolean',
    scale: 'booleanOrNumber',
    shadowBlur: 'numberOrBlank',
    shadowOffset: 'coordinate2',
    strokeWidth: 'numberOrBlank',
    visible: 'boolean'
  };
  textFeature.usedStyles.forEach(function (key) {
    geojsonStyleProperties[key] = {
      option: 'labelStyle',
      dataType: textFeatureDataTypes[key] || 'text',
      keys: [
        key,
        'label' + key.charAt(0).toUpperCase() + key.slice(1),
        key.replace(/([A-Z])/g, '-$1').toLowerCase(),
        'label-' + key.replace(/([A-Z])/g, '-$1').toLowerCase()]
    };
  });

  m_options = $.extend(true, {}, {
    dblClickTime: 300,
    adjacentPointProximity: 5,  // in pixels, 0 is exact
    // in pixels; set to continuousPointProximity to false to disable
    // continuous drawing modes.
    continuousPointProximity: 5,
    // in radians, minimum angle between continuous points to interpret them as
    // being collinear
    continuousPointCollinearity: 1.0 * Math.PI / 180,
    continuousCloseProximity: 10, // in pixels, 0 is exact
    finalPointProximity: 10,  // in pixels, 0 is exact
    showLabels: true,
    clickToEdit: false
  }, arg);

  /**
   * Process an action event.  If we are in rectangle-creation mode, this
   * creates a rectangle.
   *
   * @param {geo.event} evt The selection event.
   * @fires geo.event.annotation.edit_action
   */
  this._processAction = function (evt) {
    var update;
    if (evt.state && evt.state.actionRecord &&
        evt.state.actionRecord.owner === geo_annotation.actionOwner &&
        m_this.currentAnnotation) {
      switch (m_this.mode()) {
        case m_this.modes.edit:
          update = m_this.currentAnnotation.processEditAction(evt);
          if (m_this.currentAnnotation &&
              m_this.currentAnnotation._editHandle &&
              m_this.currentAnnotation._editHandle.handle) {
            m_this.geoTrigger(geo_event.annotation.edit_action, {
              annotation: m_this.currentAnnotation,
              handle: m_this.currentAnnotation._editHandle ? m_this.currentAnnotation._editHandle.handle : undefined,
              action: evt.event
            });
            if (evt.event === geo_event.actionup) {
              m_this._selectEditHandle(
                {data: m_this.currentAnnotation._editHandle.handle},
                m_this.currentAnnotation._editHandle.handle.selected);
            }
          }
          break;
        case m_this.modes.cursor:
          m_this.currentAnnotation._cursorHandleMousemove(evt.mouse);
          update = m_this.currentAnnotation.processAction(evt);
          m_this.geoTrigger(geo_event.annotation.cursor_action, {
            annotation: m_this.currentAnnotation,
            operation: m_this.currentBooleanOperation(),
            evt: evt
          });
          break;
        default:
          update = m_this.currentAnnotation.processAction(evt);
          break;
      }
    }
    m_this._updateFromEvent(update);
  };

  /**
   * Check if there is a current boolean operation.
   *
   * @returns {string?} Either undefined for no current boolean operation or
   *   the name of the operation.
   */
  this.currentBooleanOperation = function () {
    let op;
    if (m_this._currentBooleanClass) {
      op = m_this._currentBooleanClass.split('-')[1];
    }
    return op;
  };

  /**
   * Check if the map is currently in a mode where we are adding an annotation
   * with a boolean operation.  If so, remove the current annotation from the
   * layer, then apply it via the boolean operation.
   */
  this._handleBooleanOperation = function () {
    const op = m_this.currentBooleanOperation();
    if (!op || !m_this.currentAnnotation || !m_this.currentAnnotation.toPolygonList) {
      return;
    }
    const newAnnot = m_this.currentAnnotation;
    m_this.removeAnnotation(m_this.currentAnnotation, false);
    if (m_this.annotations().length || (op !== 'difference' && op !== 'intersect')) {
      const evt = {
        annotation: newAnnot,
        operation: op
      };
      m_this.geoTrigger(geo_event.annotation.boolean, evt);
      if (evt.cancel !== false) {
        util.polyops[op](m_this, newAnnot.toPolygonList(), {correspond: {}, keepAnnotations: 'exact', style: m_this});
      }
    }
  };

  /**
   * Handle updating the current annotation based on an update state.
   *
   * @param {string|undefined} update Truthy to update.  `'done'` if the
   *    annotation was completed and the mode should return to `null`.
   *    `'remove'` to remove the current annotation and set the mode to `null`.
   *    Falsy to do nothing.
   */
  this._updateFromEvent = function (update) {
    switch (update) {
      case 'remove':
        m_this.removeAnnotation(m_this.currentAnnotation, false);
        m_this.mode(null);
        break;
      case 'done':
        m_this._handleBooleanOperation();
        m_this.mode(null);
        break;
    }
    if (update) {
      m_this.modified();
      m_this.draw();
    }
  };

  /**
   * Check the state of the modifier keys and apply them if appropriate.
   *
   * @param {geo.event} evt The mouse move or click event.
   */
  this._handleMouseMoveModifiers = function (evt) {
    if (m_this.mode() !== m_this.modes.edit && m_this.currentAnnotation.options('allowBooleanOperations') && (m_this.currentAnnotation._coordinates().length < 2 || m_this.mode() === m_this.modes.cursor)) {
      if (evt.modifiers) {
        const mod = (evt.modifiers.shift ? 's' : '') + (evt.modifiers.ctrl ? 'c' : '') + (evt.modifiers.meta || evt.modifiers.alt ? 'a' : '');
        if (m_this._currentBooleanClass === m_this._booleanClasses[mod]) {
          return;
        }
        m_this._currentBooleanClass = m_this._booleanClasses[mod];
        const mapNode = m_this.map().node();
        Object.values(m_this._booleanClasses).forEach((c) => {
          mapNode.toggleClass(c, m_this._booleanClasses[mod] === c);
        });
      }
    }
  };

  /**
   * Handle mouse movement.  If there is a current annotation, the movement
   * event is sent to it.
   *
   * @param {geo.event} evt The mouse move event.
   */
  this._handleMouseMove = function (evt) {
    if (m_this.mode() && m_this.currentAnnotation) {
      m_this._handleMouseMoveModifiers(evt);
      var update = m_this.currentAnnotation.mouseMove(evt);
      if (update) {
        m_this.modified();
        m_this.draw();
      }
    }
  };

  /**
   * Select or deselect an edit handle.
   *
   * @param {geo.event} evt The mouse move event.
   * @param {boolean} enable Truthy to select the handle, falsy to deselect it.
   * @returns {this}
   */
  this._selectEditHandle = function (evt, enable) {
    if (!evt.data || !evt.data.editHandle) {
      return;
    }
    $.each(m_features[geo_annotation._editHandleFeatureLevel], function (type, feature) {
      feature.feature.modified();
    });
    m_this.currentAnnotation.selectEditHandle(evt.data, enable);
    m_this.draw();
    m_this.map().node().toggleClass('annotation-input', !!enable);
    m_this.map().interactor().removeAction(
      undefined, undefined, geo_annotation.actionOwner);
    if (enable) {
      var actions = m_this.currentAnnotation.actions(geo_annotation.state.edit);
      $.each(actions, function (idx, action) {
        m_this.map().interactor().addAction(action);
      });
    }
    return m_this;
  };

  /**
   * Handle mouse on events.  If there is no current annotation and
   * clickToEdit is enabled, any hovered annotation is highlighted.
   * event is sent to it.
   *
   * @param {geo.event} evt The mouse move event.
   */
  this._handleMouseOn = function (evt) {
    if (!evt.data || !evt.data.annotation) {
      return;
    }
    if (m_this.mode() === m_this.modes.edit && m_this.currentAnnotation) {
      m_this._selectEditHandle(evt, true);
      return;
    }
    if (m_this.mode() || m_this.currentAnnotation || !m_this.options('clickToEdit')) {
      return;
    }
    evt.data.annotation.state(geo_annotation.state.highlight);
    m_this.modified();
    m_this.draw();
  };

  /**
   * Handle mouse off events.  If the specific annotation is in the highlight
   * state, move it back to the done state.
   *
   * @param {geo.event} evt The mouse move event.
   */
  this._handleMouseOff = function (evt) {
    if (!evt.data || !evt.data.annotation) {
      return;
    }
    if (m_this.mode() === m_this.modes.edit && evt.data.editHandle && evt.data.selected) {
      m_this._selectEditHandle(evt, false);
      return;
    }
    if (evt.data.annotation.state() === geo_annotation.state.highlight) {
      evt.data.annotation.state(geo_annotation.state.done);
      m_this.modified();
      m_this.draw();
    }
  };

  /**
   * Handle mouse clicks.  If there is a current annotation, the click event is
   * sent to it.
   *
   * @param {geo.event} evt The mouse click event.
   */
  this._handleMouseClick = function (evt) {
    var retrigger = false, update;
    if (m_this.mode() === m_this.modes.edit) {
      if (m_this.map().interactor().hasAction(undefined, undefined, geo_annotation.actionOwner)) {
        update = m_this.currentAnnotation.mouseClickEdit(evt);
        m_this._updateFromEvent(update);
        return;
      }
      m_this.mode(null);
      m_this.draw();
      $.each(m_features, function (idx, featureLevel) {
        $.each(featureLevel, function (type, feature) {
          feature.feature._clearSelectedFeatures();
        });
      });
      retrigger = true;
    } else if (m_this.mode() && m_this.currentAnnotation) {
      m_this._handleMouseMoveModifiers(evt);
      update = m_this.currentAnnotation.mouseClick(evt);
      m_this._updateFromEvent(update);
      retrigger = !m_this.mode();
      if (m_this.mode() === m_this.modes.cursor) {
        m_this.geoTrigger(geo_event.annotation.cursor_click, {
          annotation: m_this.currentAnnotation,
          operation: m_this.currentBooleanOperation(),
          evt: evt
        });
      }
    } else if (!m_this.mode() && !m_this.currentAnnotation && m_this.options('clickToEdit')) {
      var highlighted = m_this.annotations().filter(function (ann) {
        return ann.state() === geo_annotation.state.highlight;
      });
      if (highlighted.length !== 1) {
        return;
      }
      m_this.mode(m_this.modes.edit, highlighted[0]);
      m_this.draw();
      retrigger = true;
    }
    if (retrigger) {
      // retrigger mouse move to ensure the correct events are attached
      m_this.map().interactor().retriggerMouseMove();
    }
  };

  /**
   * Set or get 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) {
      return m_options;
    }
    if (typeof arg1 === 'string' && arg2 === undefined) {
      return m_options[arg1];
    }
    if (arg2 === undefined) {
      m_options = $.extend(true, m_options, arg1);
    } else {
      m_options[arg1] = arg2;
    }
    m_this.modified();
    return m_this;
  };

  /**
   * Calculate the display distance for two coordinate in the current map.
   *
   * @param {geo.geoPosition|geo.screenPosition} coord1 The first coordinates.
   * @param {string|geo.transform|null} gcs1 `undefined` to use the interface
   *    gcs, `null` to use the map gcs, `'display'` if the coordinates are
   *    already in display coordinates, or any other transform.
   * @param {geo.geoPosition|geo.screenPosition} coord2 the second coordinates.
   * @param {string|geo.transform|null} [gcs2] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, `'display'` if the coordinates are
   *    already in display coordinates, or any other transform.
   * @returns {number} the Euclidean distance between the two coordinates.
   */
  this.displayDistance = function (coord1, gcs1, coord2, gcs2) {
    var map = m_this.map();
    if (gcs1 !== 'display') {
      gcs1 = (gcs1 === null ? map.gcs() : (
        gcs1 === undefined ? map.ingcs() : gcs1));
      coord1 = map.gcsToDisplay(coord1, gcs1);
    }
    if (gcs2 !== 'display') {
      gcs2 = (gcs2 === null ? map.gcs() : (
        gcs2 === undefined ? map.ingcs() : gcs2));
      coord2 = map.gcsToDisplay(coord2, gcs2);
    }
    var dist = Math.sqrt(Math.pow(coord1.x - coord2.x, 2) +
                         Math.pow(coord1.y - coord2.y, 2));
    return dist;
  };

  /**
   * Add an annotation to the layer.  The annotation could be in any state.
   *
   * @param {geo.annotation} annotation The annotation to add.
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.
   * @param {boolean} [update] If `false`, don't update the layer after adding
   *    the annotation.
   * @returns {this} The current layer.
   * @fires geo.event.annotation.add_before
   * @fires geo.event.annotation.add
   */
  this.addAnnotation = function (annotation, gcs, update) {
    if (!m_annotationsById.has(annotation.id())) {
      while (m_annotationsById.has(annotation.id())) {
        annotation.newId();
      }
      m_this.geoTrigger(geo_event.annotation.add_before, {
        annotation: annotation
      });
      m_annotations.push(annotation);
      m_annotationsById.set(annotation.id(), annotation);
      annotation.layer(m_this);
      var map = m_this.map();
      gcs = (gcs === null ? map.gcs() : (
        gcs === undefined ? map.ingcs() : gcs));
      if (gcs !== map.gcs()) {
        annotation._convertCoordinates(gcs, map.gcs());
      }
      if (update !== false) {
        m_this.modified();
        m_this.draw();
      }
      m_this.geoTrigger(geo_event.annotation.add, {
        annotation: annotation
      });
    }
    return m_this;
  };

  /**
   * Add multiple annotations at once to the layer.
   *
   * @param {geo.annotation[]} annotations Array of annotations to add.
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.
   * @returns {this} The current layer.
   */
  this.addAnnotations = function (annotations, gcs) {
    if (!Array.isArray(annotations) || !annotations.length) {
      return m_this;
    }

    var map = m_this.map();
    gcs = (gcs === null ? map.gcs() : (
      gcs === undefined ? map.ingcs() : gcs));

    annotations.forEach(function (annotation) {
      if (!m_annotationsById.has(annotation.id())) {
        while (m_annotationsById.has(annotation.id())) {
          annotation.newId();
        }
        m_this.geoTrigger(geo_event.annotation.add_before, {
          annotation: annotation
        });
        m_annotations.push(annotation);
        m_annotationsById.set(annotation.id(), annotation);
        annotation.layer(m_this);

        if (gcs !== map.gcs()) {
          annotation._convertCoordinates(gcs, map.gcs());
        }

        m_this.geoTrigger(geo_event.annotation.add, {
          annotation: annotation
        });
      }
    });

    m_this.modified();
    m_this.draw();

    return m_this;
  };

  /**
   * Remove an annotation from the layer.
   *
   * @param {geo.annotation} annotation The annotation to remove.
   * @param {boolean} [update] If `false`, don't update the layer after removing
   *    the annotation.
   * @returns {boolean} `true` if an annotation was removed.
   * @fires geo.event.annotation.remove
   */
  this.removeAnnotation = function (annotation, update) {
    var pos = $.inArray(annotation, m_annotations);
    if (pos >= 0) {
      if (annotation === m_this.currentAnnotation) {
        m_this.currentAnnotation = null;
      }
      annotation._exit();
      m_annotations.splice(pos, 1);
      m_annotationsById.delete(annotation.id());
      if (update !== false) {
        m_this.modified();
        m_this.draw();
      }
      m_this.geoTrigger(geo_event.annotation.remove, {
        annotation: annotation
      });
    }
    return pos >= 0;
  };

  /**
   * Remove all annotations from the layer.
   *
   * @param {boolean} [skipCreating] If truthy, don't remove annotations that
   *    are in the create state.
   * @param {boolean} [update] If `false`, don't update the layer after
   *    removing the annotation.
   * @returns {number} The number of annotations that were removed.
   */
  this.removeAllAnnotations = function (skipCreating, update) {
    var removed = 0, annotation, pos = 0;
    while (pos < m_annotations.length) {
      annotation = m_annotations[pos];
      if (skipCreating && annotation.state() === geo_annotation.state.create) {
        pos += 1;
        continue;
      }
      m_this.removeAnnotation(annotation, false);
      removed += 1;
    }
    m_annotationsById.clear(); // Clear the Map
    if (removed && update !== false) {
      m_this.modified();
      m_this.draw();
    }
    return removed;
  };

  /**
   * Get the list of annotations on the layer.
   *
   * @returns {geo.annotation[]} An array of annotations.
   */
  this.annotations = function () {
    return m_annotations.slice();
  };

  /**
   * Get an annotation by its id.
   *
   * @param {number} id The annotation ID.
   * @returns {geo.annotation} The selected annotation or `undefined` if none
   *    matches the id.
   */
  this.annotationById = function (id) {
    if (id !== undefined && id !== null) {
      id = +id;  /* Cast to int */
    }
    var annotations = m_annotations.filter(function (annotation) {
      return annotation.id() === id;
    });
    if (annotations.length) {
      return annotations[0];
    }
  };

  /* A list of special modes */
  this.modes = {
    edit: 'edit',
    cursor: 'cursor'
  };

  /* Keys are short-hand for preferred event modifiers.  Values are classes to
   * apply to the map node. */
  this._booleanClasses = {
    s: 'annotation-union',
    sc: 'annotation-intersect',
    c: 'annotation-difference',
    sa: 'annotation-xor'
  };

  /**
   * Get or set the current mode.
   *
   * @param {string|null} [arg] `undefined` to get the current mode, `null` to
   *    stop creating/editing, `this.modes.edit` (`'edit'`) plus an annotation
   *    to switch to edit mode, `this.modes.cursor` plus an annotation to
   *    switch to using the annotation as a cursor, or the name of the type of
   *    annotation to create.  Available annotations can listed via
   *    {@link geo.listAnnotations}.
   * @param {geo.annotation} [editAnnotation] If `arg === this.modes.edit` or
   *    `arg === this.modes.cursor`, this is the annotation that should be
   *    edited or used.
   * @param {object} [options] Additional options to pass when creating an
   *    annotation.
   * @returns {string|null|this} The current mode or the layer.
   * @fires geo.event.annotation.mode
   */
  this.mode = function (arg, editAnnotation, options) {
    if (arg === undefined) {
      return m_mode;
    }
    if (arg !== m_mode ||
        ((arg === m_this.modes.edit || arg === m_this.modes.cursor) && editAnnotation !== m_this.editAnnotation) ||
        (arg !== m_this.modes.edit && arg !== m_this.modes.cursor && arg && !m_this.currentAnnotation)
    ) {
      var createAnnotation, actions,
          mapNode = m_this.map().node(), oldMode = m_mode;
      m_mode = arg;
      mapNode.toggleClass('annotation-input', !!(m_mode && m_mode !== m_this.modes.edit && m_mode !== m_this.modes.cursor));
      if (!m_mode || m_mode === m_this.modes.edit) {
        Object.values(m_this._booleanClasses).forEach((c) => mapNode.toggleClass(c, false));
        m_this._currentBooleanClass = undefined;
      }
      if (!m_keyHandler) {
        m_keyHandler = Mousetrap(mapNode[0]);
      }
      if (m_mode) {
        m_keyHandler.bind('esc', function () { m_this.mode(null); });
      } else {
        m_keyHandler.unbind('esc');
      }
      if (m_this.currentAnnotation) {
        switch (m_this.currentAnnotation.state()) {
          case geo_annotation.state.create:
            m_this.removeAnnotation(m_this.currentAnnotation);
            break;
          case geo_annotation.state.edit:
            m_this.currentAnnotation.state(geo_annotation.state.done);
            m_this.modified();
            m_this.draw();
            break;
          case geo_annotation.state.cursor:
            m_this.currentAnnotation.state(geo_annotation.state.done);
            m_this.modified();
            m_this.draw();
            m_this.map().node().toggleClass('annotation-cursor', false);
            break;
        }
        m_this.currentAnnotation = null;
      }
      if (m_mode === m_this.modes.edit) {
        m_this.currentAnnotation = editAnnotation;
        m_this.currentAnnotation.state(geo_annotation.state.edit);
        m_this.modified();
      } else if (m_mode === m_this.modes.cursor) {
        m_this.currentAnnotation = editAnnotation;
        m_this.currentAnnotation.state(geo_annotation.state.cursor);
        m_this.modified();
        m_this.map().node().toggleClass('annotation-cursor', true);
        actions = m_this.currentAnnotation.actions(geo_annotation.state.cursor);
      } else if (registry.registries.annotations[m_mode]) {
        createAnnotation = registry.registries.annotations[m_mode].func;
      }
      m_this.map().interactor().removeAction(
        undefined, undefined, geo_annotation.actionOwner);
      if (createAnnotation) {
        options = $.extend({}, options || {}, {
          state: geo_annotation.state.create,
          layer: m_this
        });
        m_this.currentAnnotation = createAnnotation(options);
        m_this.addAnnotation(m_this.currentAnnotation, null);
        actions = m_this.currentAnnotation.actions(geo_annotation.state.create);
      }
      if (actions) {
        $.each(actions, function (idx, action) {
          m_this.map().interactor().addAction(action);
        });
      }
      m_this.geoTrigger(geo_event.annotation.mode, {
        mode: m_mode, oldMode: oldMode});
      if (oldMode === m_this.modes.edit || oldMode === m_this.modes.cursor) {
        m_this.modified();
      }
    }
    return m_this;
  };

  /**
   * Return the current set of annotations as a geojson object.  Alternately,
   * add a set of annotations from a geojson object.
   *
   * @param {string|object|File} [geojson] If present, add annotations based on
   *    the given geojson object.  If `undefined`, return the current
   *    annotations as geojson.  This may be a JSON string, a javascript
   *    object, or a File object.
   * @param {boolean|string} [clear] If `true`, when adding annotations, first
   *    remove all existing objects.  If `'update'`, update existing
   *    annotations and remove annotations that no longer exist.  If falsy,
   *    update existing annotations and leave annotations that have not
   *    changed.
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.
   * @param {boolean} [includeCrs] If truthy, include the coordinate system in
   *    the output.
   * @returns {object|number|undefined} If `geojson` was undefined, the current
   *    annotations is a javascript object that can be converted to geojson
   *    using JSON.stringify.  If `geojson` is specified, either the number of
   *    annotations now present upon success, or `undefined` if the value in
   *    `geojson` was not able to be parsed.
   */
  this.geojson = function (geojson, clear, gcs, includeCrs) {
    if (geojson !== undefined) {
      var reader = registry.createFileReader('geojsonReader', {
        layer: m_this,
        lineStyle: require('./annotation/lineAnnotation').defaults.style,
        pointStyle: require('./annotation/pointAnnotation').defaults.style,
        polygonStyle: require('./annotation/polygonAnnotation').defaults.style
      });
      if (!reader.canRead(geojson)) {
        return;
      }
      if (clear === true) {
        m_this.removeAllAnnotations(true, false);
      }
      if (clear === 'update') {
        $.each(m_this.annotations(), function (idx, annotation) {
          annotation.options('updated', false);
        });
      }
      reader.read(geojson, function (features) {
        $.each(features.slice(), function (feature_idx, feature) {
          m_this._geojsonFeatureToAnnotation(feature, gcs);
          m_this.deleteFeature(feature);
        });
      });
      if (clear === 'update') {
        $.each(m_this.annotations(), function (idx, annotation) {
          if (annotation.options('updated') === false &&
              annotation.state() === geo_annotation.state.done) {
            m_this.removeAnnotation(annotation, false);
          }
        });
      }
      m_this.modified();
      m_this.draw();
      return m_annotations.length;
    }
    geojson = null;
    var features = [];
    $.each(m_annotations, function (annotation_idx, annotation) {
      var obj = annotation.geojson(gcs, includeCrs);
      if (obj) {
        features.push(obj);
      }
    });
    if (features.length) {
      geojson = {
        type: 'FeatureCollection',
        features: features
      };
    }
    return geojson;
  };

  /**
   * Convert a feature as parsed by the geojson reader into one or more
   * annotations.
   *
   * @param {geo.feature} feature The feature to convert.
   * @param {string|geo.transform|null} [gcs] `undefined` to use the interface
   *    gcs, `null` to use the map gcs, or any other transform.
   * @fires geo.event.annotation.update
   */
  this._geojsonFeatureToAnnotation = function (feature, gcs) {
    var dataList = feature.data(),
        annotationList = registry.listAnnotations(),
        map = m_this.map();
    gcs = (gcs === null ? map.gcs() : (
      gcs === undefined ? map.ingcs() : gcs));
    $.each(dataList, function (data_idx, data) {
      var type = (data.properties || {}).annotationType || feature.featureType,
          options = $.extend({}, data.properties || {}),
          position, datagcs, i, existing;
      if ($.inArray(type, annotationList) < 0) {
        return;
      }
      options.style = options.style || {};
      options.labelStyle = options.labelStyle || {};
      delete options.annotationType;
      // the geoJSON reader can emit line, polygon, point, and marker
      switch (feature.featureType) {
        case 'line':
          position = feature.line()(data, data_idx);
          if (!position || position.length < 2) {
            return;
          }
          // make a copy of the position array to avoid mutating the original.
          position = position.slice();
          break;
        case 'polygon':
          position = feature.polygon()(data, data_idx);
          if (!position || !position.outer || position.outer.length < 3) {
            return;
          }
          // make a copy of the position array to avoid mutating the original.
          position = {
            outer: position.outer.slice(),
            inner: (position.inner || []).map((h) => h.slice())
          };
          if (position.outer[position.outer.length - 1][0] === position.outer[0][0] &&
              position.outer[position.outer.length - 1][1] === position.outer[0][1]) {
            position.outer.splice(position.outer.length - 1, 1);
            if (position.outer.length < 3) {
              return;
            }
          }
          position.inner.forEach((h) => {
            if (h.length > 3 && h[h.length - 1][0] === h[0][0] && h[h.length - 1][1] === h[0][1]) {
              h.splice(h.length - 1, 1);
            }
          });
          if (!position.inner || !position.inner.length) {
            position = position.outer;
          }
          break;
        case 'marker':
          position = data.geometry.coordinates[0].slice(0, 4);
          break;
        case 'point':
          position = [feature.position()(data, data_idx)];
          break;
      }
      datagcs = ((data.crs && data.crs.type === 'name' && data.crs.properties &&
                  data.crs.properties.type === 'proj4' &&
                  data.crs.properties.name) ? data.crs.properties.name : gcs);
      [position.outer || position].concat(position.inner || []).forEach((poslist) => {
        for (i = 0; i < poslist.length; i += 1) {
          poslist[i] = util.normalizeCoordinates(poslist[i]);
        }
        if (datagcs !== map.gcs()) {
          const transposlist = transform.transformCoordinates(datagcs, map.gcs(), poslist);
          for (i = 0; i < poslist.length; i += 1) {
            poslist[i] = transposlist[i];
          }
        }
      });
      options.coordinates = position;
      /* For each style listed in the geojsonStyleProperties object, check if
       * is given under any of the variety of keys as a valid instance of the
       * required data type.  If not, use the property from the feature. */
      $.each(geojsonStyleProperties, function (key, prop) {
        var value;
        $.each(prop.keys, function (idx, altkey) {
          if (value === undefined) {
            value = m_this.validateAttribute(options[altkey], prop.dataType);
          }
        });
        if (value === undefined) {
          value = m_this.validateAttribute(
            feature.style.get(key)(data, data_idx), prop.dataType);
        }
        if (value !== undefined) {
          options[prop.option || 'style'][key] = value;
        }
      });
      /* Delete property keys we have used */
      $.each(geojsonStyleProperties, function (key, prop) {
        $.each(prop.keys, function (idx, altkey) {
          delete options[altkey];
        });
      });
      if (options.annotationId !== undefined) {
        existing = m_this.annotationById(options.annotationId);
        if (existing) {
          delete options.annotationId;
        }
      }
      if (existing && existing.type() === type && existing.state() === geo_annotation.state.done && existing.options('updated') === false) {
        /* We could change the state of the existing annotation if it differs
         * from done. */
        delete options.state;
        delete options.layer;
        options.updated = true;
        existing.options(options);
        m_this.geoTrigger(geo_event.annotation.update, {
          annotation: existing
        });
      } else {
        options.state = geo_annotation.state.done;
        options.layer = m_this;
        options.updated = 'new';
        m_this.addAnnotation(registry.createAnnotation(type, options), null);
      }
    });
  };

  /**
   * Validate a value for an attribute based on a specified data type.  This
   * returns a sanitized value or `undefined` if the value was invalid.  Data
   * types include:
   * - `color`: a css string, `#rrggbb` hex string, `#rgb` hex string, number,
   *   or object with r, g, b properties in the range of [0-1].
   * - `opacity`: a floating point number in the range [0, 1].
   * - `positive`: a floating point number greater than zero.
   * - `boolean`: a string whose lowercase value is `'false'`, `'off'`, or
   *   `'no'`, and falsy values are false, all else is true.  `null` and
   *   `undefined` are still considered invalid values.
   * - `booleanOrNumber`: a string whose lowercase value is `'false'`, `'off'`,
   *   `'no'`, `'true'`, `'on'`, or `'yes'`, falsy values that aren't 0, and
   *   `true` are handled as booleans.  Otherwise, a floating point number that
   *   isn't NaN or an infinity.
   * - `coordinate2`: either an object with x and y properties that are
   *   numbers, or a string of the form <x>[,]<y> with optional whitespace, or
   *   a JSON encoded object with x and y values, or a JSON encoded list of at
   *   leasst two numbers.
   * - `number`: a floating point number that isn't NaN or an infinity.
   * - `angle`: a number that represents radians.  If followed by one of `deg`,
   *   `grad`, or `turn`, it is converted to radians.  An empty string is also
   *   allowed.
   * - `text`: any text string.
   * @param {number|string|object|boolean} value The value to validate.
   * @param {string} dataType The data type for validation.
   * @returns {number|string|object|boolean|undefined} The sanitized value or
   *    `undefined`.
   */
  this.validateAttribute = function (value, dataType) {
    var parts;

    if (value === undefined || value === null) {
      return;
    }
    switch (dataType) {
      case 'angle':
        if (value === '') {
          break;
        }
        parts = /^\s*([-.0-9eE]+)\s*(deg|rad|grad|turn)?\s*$/.exec(('' + value).toLowerCase());
        if (!parts || !isFinite(parts[1])) {
          return;
        }
        var factor = (parts[2] === 'grad' ? Math.PI / 200 :
          (parts[2] === 'deg' ? Math.PI / 180 :
            (parts[2] === 'turn' ? 2 * Math.PI : 1)));
        value = +parts[1] * factor;
        break;
      case 'boolean':
        value = !!value && ['false', 'no', 'off'].indexOf(('' + value).toLowerCase()) < 0;
        break;
      case 'booleanOrNumber':
        if ((!value && value !== 0 && value !== '') || ['true', 'false', 'off', 'on', 'no', 'yes'].indexOf(('' + value).toLowerCase()) >= 0) {
          value = !!value && ['false', 'no', 'off'].indexOf(('' + value).toLowerCase()) < 0;
        } else {
          if (!util.isNonNullFinite(value)) {
            return;
          }
          value = +value;
        }
        break;
      case 'coordinate2':
        if (value === '') {
          break;
        }
        if (value && util.isNonNullFinite(value.x) && util.isNonNullFinite(value.y)) {
          value.x = +value.x;
          value.y = +value.y;
          break;
        }
        try { value = JSON.parse(value); } catch (err) { }
        if (value && util.isNonNullFinite(value.x) && util.isNonNullFinite(value.y)) {
          value.x = +value.x;
          value.y = +value.y;
          break;
        }
        if (Array.isArray(value) && util.isNonNullFinite(value[0]) && util.isNonNullFinite(value[1])) {
          value = {x: +value[0], y: +value[1]};
          break;
        }
        parts = /^\s*([-.0-9eE]+)(?:\s+|\s*,)\s*([-.0-9eE]+)\s*$/.exec('' + value);
        if (!parts || !isFinite(parts[1]) || !isFinite(parts[2])) {
          return;
        }
        value = {x: +parts[1], y: +parts[2]};
        break;
      case 'color':
        value = util.convertColor(value);
        if (value === undefined || value.r === undefined) {
          return;
        }
        break;
      case 'number':
        if (!util.isNonNullFinite(value)) {
          return;
        }
        value = +value;
        break;
      case 'numberOrBlank':
        if (value === '') {
          break;
        }
        if (!util.isNonNullFinite(value)) {
          return;
        }
        value = +value;
        break;
      case 'opacity':
        if (value === '') {
          return;
        }
        value = +value;
        if (isNaN(value) || value < 0 || value > 1) {
          return;
        }
        break;
      case 'positive':
        value = +value;
        if (!isFinite(value) || value <= 0) {
          return;
        }
        break;
      case 'text':
        value = '' + value;
        break;
    }
    return value;
  };

  /**
   * Update layer.
   *
   * @returns {this} The current layer.
   */
  this._update = function () {
    if (m_this.timestamp() > m_buildTime.timestamp()) {
      var labels = m_this.options('showLabels') ? [] : null,
          editable = m_this.options('clickToEdit') || m_this.mode() === m_this.modes.edit;
      /* Internally, we have a set of feature levels (to provide z-index
       * support), each of which can have data from multiple annotations.  We
       * clear the data on each of these features, then build it up from each
       * annotation.  Eventually, it may be necessary to optimize this and
       * only update the features that are changed.
       */
      $.each(m_features, function (idx, featureLevel) {
        $.each(featureLevel, function (type, feature) {
          feature.data = [];
          delete feature.feature.scaleOnZoom;
        });
      });
      $.each(m_annotations, function (annotation_idx, annotation) {
        var features = annotation.features();
        if (labels) {
          var annotationLabel = annotation.labelRecord();
          if (annotationLabel) {
            labels.push(annotationLabel);
          }
        }
        $.each(features, function (idx, featureLevel) {
          if (m_features[idx] === undefined) {
            m_features[idx] = {};
          }
          $.each(featureLevel, function (type, featureSpec) {
            /* Create features as needed */
            if (!m_features[idx][type]) {
              var feature = m_this.createFeature(type, {
                gcs: m_this.map().gcs(),
                selectionAPI: editable
              });
              if (!feature) {
                /* We can't create the desired feature, probably because of the
                 * selected renderer.  Issue one warning only. */
                var key = 'error_feature_' + type;
                if (!m_this[key]) {
                  console.warn('Cannot create a ' + type + ' feature for ' +
                               'annotations.');
                  m_this[key] = true;
                }
                return;
              }
              if (editable) {
                feature.geoOn(geo_event.feature.mouseon, m_this._handleMouseOn);
                feature.geoOn(geo_event.feature.mouseoff, m_this._handleMouseOff);
              }

              /* Since each annotation can have separate styles, the styles are
               * combined together with a meta-style function.  Any style that
               * could be used should be in this list.  Color styles may be
               * restricted to {r, g, b} objects for efficiency, but this
               * hasn't been tested.
               */
              var style = {};
              $.each([
                'closed', 'fill', 'fillColor', 'fillOpacity', 'line',
                'lineCap', 'lineJoin', 'polygon', 'position', 'radius',
                'radiusIncludesStroke', 'rotateWithMap', 'rotation',
                'scaleWithZoom', 'stroke', 'strokeColor', 'strokeOffset',
                'strokeOpacity', 'strokeWidth', 'symbolValue', 'uniformPolygon'
              ], function (keyidx, key) {
                var origFunc;
                if (feature.style()[key] !== undefined) {
                  origFunc = feature.style.get(key);
                }
                style[key] = function (d, i, d2, i2) {
                  var style = (
                    (d && d.style) ? d.style : (d && d[2] && d[2].style) ?
                      d[2].style : d2.style);
                  var result = style ? style[key] : d;
                  if (util.isFunction(result)) {
                    result = result(d, i, d2, i2);
                  }
                  if (result === undefined && origFunc) {
                    result = origFunc(d, i, d2, i2);
                  }
                  return result;
                };
              });
              feature.style(style);
              m_features[idx][type] = {
                feature: feature,
                style: style,
                data: []
              };
            } else {
              feature = m_features[idx][type].feature;
              // update whether we check for selections on existing features
              if (feature.selectionAPI() !== !!editable) {
                feature.selectionAPI(editable);
                if (editable) {
                  feature.geoOn(geo_event.feature.mouseon, m_this._handleMouseOn);
                  feature.geoOn(geo_event.feature.mouseoff, m_this._handleMouseOff);
                } else {
                  feature.geoOff(geo_event.feature.mouseon, m_this._handleMouseOn);
                  feature.geoOff(geo_event.feature.mouseoff, m_this._handleMouseOff);
                }
              }
            }
            /* Collect the data for each feature */
            var dataEntry = featureSpec.data || featureSpec;
            if (!Array.isArray(dataEntry)) {
              dataEntry = [dataEntry];
            }
            dataEntry.forEach(function (dataElement) {
              dataElement.annotation = annotation;
              m_features[idx][type].data.push(dataElement);
              if (featureSpec.scaleOnZoom) {
                m_features[idx][type].feature.scaleOnZoom = true;
              }
            });
          });
        });
      });
      /* Update the data for each feature */
      $.each(m_features, function (idx, featureLevel) {
        $.each(featureLevel, function (type, feature) {
          feature.feature.data(feature.data);
        });
      });
      m_this._updateLabels(labels);
      m_buildTime.modified();
    }
    s_update.call(m_this, arguments);
    return m_this;
  };

  /**
   * Show or hide annotation labels.  Create or destroy a child layer or a
   * feature as needed.
   *
   * @param {object[]|null} labels The list of labels to display of `null` for
   *    no labels.
   * @returns {this} The class instance.
   * @fires geo.event.annotation.layerAdd
   */
  this._updateLabels = function (labels) {
    if (!labels || !labels.length) {
      m_this._removeLabelFeature();
      return m_this;
    }
    if (!m_labelFeature) {
      if (!(registry.registries.features[m_this.rendererName()] || {}).text) {
        var renderer = registry.rendererForFeatures(['text']);
        m_labelLayer = registry.createLayer('feature', m_this.map(), {renderer: renderer});
        m_this.addChild(m_labelLayer);
        m_labelLayer._update();
        m_this.geoTrigger(geo_event.layerAdd, {
          target: m_this,
          layer: m_labelLayer
        });
      }
      var style = {};
      textFeature.usedStyles.forEach(function (key) {
        style[key] = function (d, i) {
          if (d.style && d.style[key] !== undefined) {
            return d.style[key];
          }
          return (m_this.options('defaultLabelStyle') || {})[key];
        };
      });
      m_labelFeature = (m_labelLayer || m_this).createFeature('text', {
        style: style,
        gcs: m_this.map().gcs(),
        position: function (d) {
          return d.position;
        }
      });
    }
    m_labelFeature.data(labels);
    return m_this;
  };

  /**
   * Check if any features are marked that they need to be updated when a zoom
   * occurs.  If so, mark that feature as modified.
   */
  this._handleZoom = function () {
    var i, features = m_this.features();
    for (i = 0; i < features.length; i += 1) {
      if (features[i].scaleOnZoom) {
        features[i].modified();
      }
    }
  };

  /**
   * Remove the label feature if it exists.
   *
   * @returns {this} The current layer.
   * @fires geo.event.annotation.layerRemove
   */
  this._removeLabelFeature = function () {
    if (m_labelLayer) {
      m_labelLayer._exit();
      m_this.removeChild(m_labelLayer);
      m_this.geoTrigger(geo_event.layerRemove, {
        target: m_this,
        layer: m_labelLayer
      });
      m_labelLayer = m_labelFeature = null;
    }
    if (m_labelFeature) {
      m_this.removeFeature(m_labelFeature);
      m_labelFeature = null;
    }
    return m_this;
  };

  /**
   * Update if necessary and draw the layer.
   *
   * @returns {this} The current layer.
   */
  this.draw = function () {
    m_this._update();
    s_draw.call(m_this);
    return m_this;
  };

  /**
   * Return any annotation that has area as a polygon list: an array of
   * polygons, each of which is an array of polylines, each of which is an
   * array of points, each of which is a 2-tuple of numbers.
   *
   * @param {geo.util.polyop.spec} [opts] Ignored.
   * @returns {geo.polygonList} A list of polygons.
   */
  this.toPolygonList = function (opts) {
    const poly = [];
    opts = opts || {};
    const indices = [];
    if (!opts.annotationIndices) {
      opts.annotationIndices = {};
    }
    opts.annotationIndices[m_this.id()] = indices;
    m_annotations.forEach((annotation, idx) => {
      if (annotation.toPolygonList) {
        annotation.toPolygonList(opts).forEach((p) => poly.push(p));
        indices.push(idx);
      }
    });
    return poly;
  };

  /**
   * Replace appropriate annotations with a list of polygons.
   *
   * @param {geo.polygonList} poly A list of polygons.
   * @param {geo.util.polyop.annotationLayerSpec} [opts] This contains
   *   annotationIndices and correspondence used to track annotations.
   * @returns {this}
   */
  this.fromPolygonList = function (poly, opts) {
    if (!poly || !poly.length) {
      return m_this;
    }
    /* One of 'all', 'exact', 'none'; default is 'exact' */
    const keep = opts.keepAnnotations;
    const keepIds = {};
    const reusedIds = {};
    let correspond, exact, annot;
    const indices = (opts.annotationIndices || {})[m_this.id()];
    if (indices && opts.correspond && opts.correspond.poly1 && indices.length === opts.correspond.poly1.length) {
      correspond = opts.correspond.poly1;
      exact = opts.correspond.exact1;
      annot = m_this.annotations();
    }
    if (keep !== 'all' && keep !== 'none' && annot) {
      annot.forEach((oldAnnot, idx) => {
        if (indices.indexOf(idx) < 0) {
          keepIds[oldAnnot.id()] = true;
        }
      });
    }
    const polyAnnot = [];
    poly.forEach((p, idx) => {
      p = p.map((h) => h.map((pt) => ({x: pt[0], y: pt[1]})));
      const result = {
        vertices: p.length === 1 ? p[0] : {outer: p[0], inner: p.slice(1)}
      };
      if (correspond) {
        for (let i = 0; i < correspond.length; i += 1) {
          if (correspond[i] && correspond[i].indexOf(idx) >= 0) {
            const orig = annot[indices[i]];
            if (keep !== 'all' && keep !== 'none' && exact && exact[i] && exact[i].indexOf(idx) >= 0) {
              keepIds[orig.id()] = true;
              return;
            }
            if (keep !== 'all' && reusedIds[orig.id()] === undefined) {
              result.annotationId = orig.id();
              reusedIds[orig.id()] = true;
            }
            ['name', 'description', 'label'].forEach((k) => {
              if (orig[k](undefined, true)) {
                result[k] = orig[k](undefined, true);
              }
            });
            Object.entries(orig.options()).forEach(([key, value]) => {
              if (['showLabel', 'style'].indexOf(key) >= 0 || key.endsWith('Style')) {
                result[key] = value;
              }
            });
            break;
          }
        }
      }
      polyAnnot.push(result);
    });
    let update = false;
    // delete extant annotations
    if (keep !== 'all' && annot) {
      annot
        .filter((oldAnnot) => keepIds[oldAnnot.id()] === undefined)
        .forEach((oldAnnot) => {
          m_this.removeAnnotation(oldAnnot, false);
          update = true;
        });
    }
    // add new annotations
    polyAnnot.forEach((p) => {
      m_this.addAnnotation(registry.createAnnotation('polygon', p), m_this.map().gcs(), false);
      update = true;
    });
    if (update) {
      m_this.modified();
      m_this.draw();
    }
    return m_this;
  };

  /**
   * Initialize.
   *
   * @returns {this} The current layer.
   */
  this._init = function () {
    // Call super class init
    s_init.call(m_this);

    if (!m_this.map().interactor()) {
      m_this.map().interactor(mapInteractor({actions: []}));
    }
    m_this.geoOn(geo_event.actionselection, m_this._processAction);
    m_this.geoOn(geo_event.actionmove, m_this._processAction);
    m_this.geoOn(geo_event.actionup, m_this._processAction);

    m_this.geoOn(geo_event.mouseclick, m_this._handleMouseClick);
    m_this.geoOn(geo_event.mousemove, m_this._handleMouseMove);

    m_this.geoOn(geo_event.zoom, m_this._handleZoom);

    return m_this;
  };

  /**
   * Free all resources.
   *
   * @returns {this} The current layer.
   */
  this._exit = function () {
    if (m_keyHandler) {
      m_keyHandler.reset();
    }
    m_this._removeLabelFeature();
    // Call super class exit
    s_exit.call(m_this);
    m_annotations = [];
    m_annotationsById.clear();
    m_features = [];
    return m_this;
  };

  return m_this;
};

inherit(annotationLayer, featureLayer);
registry.registerLayer('annotation', annotationLayer);
module.exports = annotationLayer;