publiclab/mapknitter

View on GitHub
app/assets/javascripts/mapknitter/Map.js

Summary

Maintainability
F
1 wk
Test Coverage
MapKnitter.Map = MapKnitter.Class.extend({

  initialize: function (options) {
    this._zoom = options.zoom || 0;
    this._latlng = L.latLng(options.latlng);
    this.map_id = options.map_id || 0;
    this.readOnly = options.readOnly;
    this.logged_in = options.logged_in;
    this.anonymous = options.anonymous;

    window.mapknitter = this;

    L.Icon.Default.imagePath = '/assets/leaflet/dist/images/';

    this._map = L.map('knitter-map-pane', {
      zoomControl: false,
    }).setView(this._latlng, this._zoom);

    // make globally accessible map namespace for knitter.js
    map = this._map;

    images = []; bounds = [];

    /* Set up basemap and drawing toolbars. */
    this.setupMap();

    this.setupCollection();

    map._initialBounds = map.getBounds();

    /* Load warpables data via AJAX request. */
    this._warpablesUrl = options.warpablesUrl;

    /** this took me a bit to notice - this below code is all one big chunk run
     * only after a map refresh. Events need to be setup before this 
     */

    this.withWarpables(function (warpables) {
      $.each(warpables, function (i, warpable) {

        var wn = warpable.nodes;

        // only already-placed images:
        if (wn.length > 0) {
          var downloadEl = $('.img-download-' + warpable.id),
              imgEl = $('#full-img-' + warpable.id);

          // this 'download' section can likely be dropped as Leaflet.DistortableImage now provides for such download itself
          downloadEl.click(function () {
            downloadEl.html('<i class="fa fa-circle-o-notch fa-spin"></i>');

            imgEl[0].onload = function () {
              var height = imgEl.height(),
                  width = imgEl.width(),
                  nw = map.latLngToContainerPoint(wn[0]),
                  ne = map.latLngToContainerPoint(wn[1]),
                  se = map.latLngToContainerPoint(wn[2]),
                  sw = map.latLngToContainerPoint(wn[3]),
                  offsetX = nw.x,
                  offsetY = nw.y,
                  displayedWidth = $('#warpable-img-' + warpable.id).width(),
                  ratio = width / displayedWidth;

              nw.x -= offsetX;
              ne.x -= offsetX;
              se.x -= offsetX;
              sw.x -= offsetX;

              nw.y -= offsetY;
              ne.y -= offsetY;
              se.y -= offsetY;
              sw.y -= offsetY;

              warpWebGl(
                'full-img-' + warpable.id,
                [0, 0, width, 0, width, height, 0, height],
                [nw.x, nw.y, ne.x, ne.y, se.x, se.y, sw.x, sw.y],
                true // trigger download
              );

              downloadEl.html('<i class="fa fa-download"></i>');
            };

            imgEl[0].src = $('.img-download-' + warpable.id).attr('data-image');
          });

          var corners = [
            L.latLng(wn[0].lat, wn[0].lon),
            L.latLng(wn[1].lat, wn[1].lon),
            L.latLng(wn[3].lat, wn[3].lon),
            L.latLng(wn[2].lat, wn[2].lon)
          ];

          var img = L.distortableImageOverlay(warpable.srcmedium, {
            corners: corners,
            mode: 'lock',
            actions: [
              L.DragAction,
              L.DistortAction,
              L.FreeRotateAction,
              L.ScaleAction,
              L.RotateAction,
              L.LockAction,
              L.OpacityAction,
              L.BorderAction,
              L.RestoreAction,
              L.StackAction,
              L.DeleteAction
            ]
          });

          map._imgGroup.addLayer(img);

          /**
           * TODO: toolbar may still appear outside of frame. Create a getter for toolbar corners in LDI and then include them in this calculation
           */
          bounds = bounds.concat(corners);
          var newImgBounds = L.latLngBounds(corners);

          if (!map._initialBounds.contains(newImgBounds) && !map._initialBounds.equals(newImgBounds)) {
            map._initialBounds.extend(newImgBounds);
            map.flyToBounds(map._initialBounds);
          }

          images.push(img);
          img.warpable_id = warpable.id;

          if (!mapknitter.readOnly) {
            L.DomEvent.on(img._image, 'load', function(e) {
              mapknitter.setupEvents(L.Util.extend(e, {layer: img}));
              mapknitter.setupToolbar(L.Util.extend(e, {layer: img}));
            });
          }
        }
      });
    });

    function getBounds() {
      console.log('Center map on images')
      if (map._imgGroup.getBounds().isValid()) map.fitBounds(map._imgGroup.getBounds());
      else setTimeout(getBounds, 1000);
    }
    getBounds();    
  },

  _enter: function() {
    map._imgGroup.editing.disable();
  },

  _out: function() {
    map._imgGroup.editing.enable();
  },

  setupEvents: function (e) {
    var img = e.layer;

    /**
     * TODO: the edit event is fire on handleDragEnd from LDI. This needs to be documented.
     * and maybe change to 'handledragend' or something to be very explicit. this handle 
     * is necessary beyond click / mouseup because you can distort the image without clicking
     * on it.
     */
    L.DomEvent.on(img, {
      edit: mapknitter.saveImage,
    }, img);

    L.DomEvent.on(img._image, {
      click: mapknitter.selectImage,
      mouseup: mapknitter.saveImageIfChanged,
      touchend: mapknitter.saveImageIfChanged
    }, img);

    // deselect is not a real event / can we just use mouseup instead
    // img.on('deselect', mapknitter.saveImageIfChanged, img);
  },

  setupToolbar: function (e) {
    var img = e.layer,
        edit = img.editing;

    // overriding the upstream Delete action so that it makes database updates in MapKnitter
    L.DomEvent.on(img._image, 'load', function() {
      var newTool = mapknitter.customDeleteAction();

      if (edit.hasTool(L.DeleteAction)) {
        edit.replaceTool(L.DeleteAction, newTool);
      } else {
        edit.addTool(newTool);
      }

      if (!edit._selected) { edit._deselect(); }

      img.on('delete', mapknitter.deleteImage, img);
    });
  },

  /* Add a new, unplaced, but already uploaded image to the map.
   * <lat> and <lng> are optional. */
  addImage: function(url,id,lat,lng,angle,altitude) {
    var img = L.distortableImageOverlay(url);

    img.geocoding = {
      lat: lat,
      lng: lng,
      altitude: altitude,
      angle: angle
    };

    images.push(img);
    img.warpable_id = id;

    map._imgGroup.addLayer(img);
  },

  setupGeocode: function (e) {
    var img = e.layer,
        geo = img.geocoding;

    L.DomEvent.on(img._image, 'load', function () {

      /* use geodata */
      if (geo && geo.lat) {
        /* move the image to this newly discovered location */
        var center = img.getCenter();
        var latBy = geo.lat - center.lat;
        var lngBy = geo.lng - center.lng;

        for (var i = 0; i < 4; i++) {
          img._corners[i].lat += latBy;
          img._corners[i].lng += lngBy;
        }

        img.rotateBy(geo.angle);

        /* Attempt to convert altitude to scale factor based on Leaflet zoom;
          * for correction based on altitude we need the original dimensions of the image.
          * This may work only at sea level unless we factor in ground level.
          * We may also need to get camera field of view to get this even closer.
          * We could also fall back to the scale of the last-placed image.
          */
        if (geo.altitude && geo.altitude != 0) {
          // var width = img._image.width, height = img._image.height
          //scale = ( (act_height/img_height) * (act_width/img_width) ) / geo.altitude;
          //img.scaleBy(scale);

          var elevator = new google.maps.ElevationService();
          var lat = mapknitter._map.getCenter().lat;
          var lng = mapknitter._map.getCenter().lng;

          elevator.getElevationForLocations({
            'locations': [{lat: lat, lng: lng}]
          }, function (results, status) {
            console.log("Photo taken from " + geo.altitude + " meters above sea level");
            console.log("Ground is " + results[0].elevation + " meters above sea level");
            console.log("Photo taken from " + (geo.altitude - results[0].elevation) + " meters");
            var a = geo.altitude - results[0].elevation,
                fov = 50,
                A = fov * (Math.PI / 180),
                width = 2 * (a / Math.tan(A)),
                currentWidth =
                  img.getCorner(2).distanceTo(img.getCorner(1)) +
                  img.getCorner(1).distanceTo(img.getCorner(2)) / 2;

            console.log("Photo should be " + width + " meters wide");
            img.scaleBy(width / currentWidth);
          });
        }

        img.fire('update');
        /* pan the map there too */
        mapknitter._map.fitBounds(L.latLngBounds(img.getCorners()));
        img._reset();
      }

      return img;
    });
  },

  geocodeImageFromId: function (dom_id, id, url) {
    mapknitter.geocodeImage(
      $(dom_id)[0],
      function (lat, lng, id, angle, altitude) {
        /* Display button to place this image with GPS tags. */
        $('.add-image-gps-' + id).attr('data-lat', lat);
        $('.add-image-gps-' + id).attr('data-lng', lng);
        if (angle) $('.add-image-gps-' + id).attr('data-angle', angle);
        if (altitude) $('.add-image-gps-' + id).attr('data-altitude', altitude);
        $('.add-image-gps-' + id).show();
        $('.add-image-gps-' + id).on('click', function () {
          $('.add-image-' + id).hide();
          $('#uploadModal').modal('hide');
          window.mapknitter._map.setZoom(18);
          window.mapknitter._map.setView(
            [$(this).attr('data-lat'),
            $(this).attr('data-lng')]);
          var angle = $(this).attr('data-angle') || 0;
          var altitude = $(this).attr('data-altitude') || 0;
          img = window.mapknitter.addImage(url,
            id,
            $(this).attr('data-lat'),
            $(this).attr('data-lng'),
            angle,
            altitude);
          $('#warpable-' + id + ' a').hide();
        })
      },
      id
    )
  },

  /*
   * Accepts an image element, and executes given function with
   * params as: function(lat,lng) {}
   * Adapting from:
    https://github.com/publiclab/mapknitter/blob/6e88c7725d3c013f402526289e806b8be4fcc23c/public/cartagen/cartagen.js#L9378
  */
  geocodeImage: function (img, fn, id) {
    EXIF.getData(img, function () {
      var GPS = EXIF.getAllTags(img);

      /* If the lat/lng is available. */
      if (typeof GPS["GPSLatitude"] !== 'undefined' && typeof GPS["GPSLongitude"] !== 'undefined') {

        // sadly, encoded in [degrees,minutes,seconds]
        var lat = (GPS["GPSLatitude"][0]) +
          (GPS["GPSLatitude"][1] / 60) +
          (GPS["GPSLatitude"][2] / 3600);
        var lng = (GPS["GPSLongitude"][0]) +
          (GPS["GPSLongitude"][1] / 60) +
          (GPS["GPSLongitude"][2] / 3600);

        if (GPS["GPSLatitudeRef"] != "N") lat = lat * -1
        if (GPS["GPSLongitudeRef"] == "W") lng = lng * -1
      }

      // Attempt to use GPS compass heading; will require
      // some trig to calc corner points, which you can find below:

      var angle = 0;
      // "T" refers to "True north", so -90.
      if (GPS["GPSImgDirectionRef"] == "T")
        angle = (Math.PI / 180) * (GPS.GPSImgDirection["numerator"] / GPS.GPSImgDirection["denominator"] - 90);
      // "M" refers to "Magnetic north"
      else if (GPS["GPSImgDirectionRef"] == "M")
        angle = (Math.PI / 180) * (GPS.GPSImgDirection["numerator"] / GPS.GPSImgDirection["denominator"] - 90);
      else
        console.log("No compass data found");

      console.log("Orientation:", GPS["Orientation"])

      /* If there is orientation data -- i.e. landscape/portrait etc */
      if (GPS["Orientation"] == 6) { //CCW
        angle += (Math.PI / 180) * -90
      } else if (GPS["Orientation"] == 8) { //CW
        angle += (Math.PI / 180) * 90
      } else if (GPS["Orientation"] == 3) { //180
        angle += (Math.PI / 180) * 180
      }

      /* If there is altitude data */
      if (typeof GPS["GPSAltitude"] !== 'undefined' && typeof GPS["GPSAltitudeRef"] !== 'undefined') {
        // Attempt to use GPS altitude:
        // (may eventually need to find EXIF field of view for correction)
        if (typeof GPS.GPSAltitude !== 'undefined' &&
          typeof GPS.GPSAltitudeRef !== 'undefined') {
          altitude = (GPS.GPSAltitude["numerator"] / GPS.GPSAltitude["denominator"] + GPS.GPSAltitudeRef);
        } else {
          altitude = 0; // none
        }
      }

      /* only execute callback if lat (and by
       * implication lng) exists */
      if (lat) fn(lat, lng, id, angle, altitude);
    });
  },

  selectImage: function (e) {
    var img = this;
    // var img = e.layer;
    // save state, watch for changes by tracking stringified corner positions:
    img._corner_state = JSON.stringify(img._corners);
    /* Need to re-enable editing on each select because we disable it when clicking the sidebar */
    img.editing.enable.bind(img.editing)();
    /* If it's locked, allow event to propagate on to map below */   // sb: why? commenting out below line. 
    if (this.editing._mode !== "lock") { e.stopPropagation(); }
  },

    /* Called by the concurrent_editing.js channel's 'received' function (app/assets/javascripts/channels/concurrent_editing.js).
     * It recieves a list of updated warpables,i.e. list of images with updated corner points. The aim of writing this function
     * is to reposition the updated images onto the map on every connected browser (via the ActionCable). */

  synchronizeData: function(warpables) {
    var layers = [];
    map.eachLayer(function(l) {layers.push(l)});
    layers = layers.filter(image => !!image._url);
    warpables.forEach(function(warpable) {
      var corners = [];
      warpable.nodes.forEach(function(node) {
        corners.push(L.latLng(node.lat, node.lon));
      });

      var x = corners[2];
      var y = corners [3];
      corners [2] = y;
      corners [3] = x;

      var layer = layers.filter(l => l._url === warpable.srcmedium)[0];

      if (!layer) {
        window.mapknitter.synchronizeNewAddedImage(warpable);
      } else {
        layer.setCorners(corners);
        var index = layers.indexOf(layer);
        if (index > -1) {
          layers.splice(index, 1);
        }
      }
    });

    // remove images if deleted from any user's browser
    layers.forEach(function(layer) {
      var edit = layer.editing;
      edit._removeToolbar();
      edit.disable();
      // remove from Leaflet map:
      map.removeLayer(layer);
      // remove from sidebar too:
      $('#warpable-' + layer.warpable_id).remove();
    });
  },

  synchronizeNewAddedImage: function(warpable) {
    var wn = warpable.nodes;
    var bounds = [];

    // only already-placed images:
    if (wn.length > 0) {
      var downloadEl = $('.img-download-' + warpable.id),
          imgEl = $('#full-img-' + warpable.id);

      // this 'download' section can likely be dropped as Leaflet.DistortableImage now provides for such download itself
      downloadEl.click(function () {
        downloadEl.html('<i class="fa fa-circle-o-notch fa-spin"></i>');

        imgEl[0].onload = function () {
          var height = imgEl.height(),
              width = imgEl.width(),
              nw = map.latLngToContainerPoint(wn[0]),
              ne = map.latLngToContainerPoint(wn[1]),
              se = map.latLngToContainerPoint(wn[2]),
              sw = map.latLngToContainerPoint(wn[3]),
              offsetX = nw.x,
              offsetY = nw.y,
              displayedWidth = $('#warpable-img-' + warpable.id).width(),
              ratio = width / displayedWidth;

          nw.x -= offsetX;
          ne.x -= offsetX;
          se.x -= offsetX;
          sw.x -= offsetX;

          nw.y -= offsetY;
          ne.y -= offsetY;
          se.y -= offsetY;
          sw.y -= offsetY;

          warpWebGl(
            'full-img-' + warpable.id,
            [0, 0, width, 0, width, height, 0, height],
            [nw.x, nw.y, ne.x, ne.y, se.x, se.y, sw.x, sw.y],
            true // trigger download
          );

          downloadEl.html('<i class="fa fa-download"></i>');
        };

        imgEl[0].src = $('.img-download-' + warpable.id).attr('data-image');
      });

      var corners = [
        L.latLng(wn[0].lat, wn[0].lon),
        L.latLng(wn[1].lat, wn[1].lon),
        L.latLng(wn[3].lat, wn[3].lon),
        L.latLng(wn[2].lat, wn[2].lon),
      ];

      var img = L.distortableImageOverlay(warpable.srcmedium, {
        corners: corners,
        mode: 'lock',
      });

      var imgGroup = L.distortableCollection().addTo(map);

      imgGroup.addLayer(img);

      /**
       * TODO: toolbar may still appear outside of frame. Create a getter for toolbar corners in LDI and then include them in this calculation
       */
      bounds = bounds.concat(corners);
      var newImgBounds = L.latLngBounds(corners);

      if (!map._initialBounds.contains(newImgBounds) && !map._initialBounds.equals(newImgBounds)) {
        map._initialBounds.extend(newImgBounds);
        mapknitter._map.flyToBounds(map._initialBounds);
      }

      images.push(img);
      img.warpable_id = warpable.id;

      if (!mapknitter.readOnly) {
        L.DomEvent.on(img.getElement(), {
          click: mapknitter.selectImage,
          load: mapknitter.setupToolbar
        }, img);

        L.DomEvent.on(imgGroup, 'layeradd', mapknitter.setupEvents, img);
      }

      img.editing.disable();
    }
  },

  saveImageIfChanged: function () {
    var img = this,
        edit = img.editing;
    // check if image state has changed at all before saving!
    if (edit._mode !== 'lock' && img._corner_state !== JSON.stringify(img._corners)) {
      window.mapknitter.saveImage.bind(img)();
    }
  },

  saveImage: function () {
    var img = this;
    img._corner_state = JSON.stringify(img._corners); // reset change state string:
    $.ajax('/images/' + img.warpable_id, { // send save request
      type: 'PATCH',
      data: {
        warpable_id: img.warpable_id,
        locked: (img.editing._mode == 'lock'),
        points:
          img.getCorner(0).lng + ',' + img.getCorner(0).lat + ':' +
          img.getCorner(1).lng + ',' + img.getCorner(1).lat + ':' +
          img.getCorner(3).lng + ',' + img.getCorner(3).lat + ':' +
          img.getCorner(2).lng + ',' + img.getCorner(2).lat,
      },
      beforeSend: function (e) {
        $('.mk-save').removeClass('fa-check-circle fa-times-circle fa-green fa-red').addClass('fa-spinner fa-spin')
      },
      success: function(data) {
        App.concurrent_editing.speak(data);
      },
      complete: function (e) {
        $('.mk-save').removeClass('fa-spinner fa-spin').addClass('fa-check-circle fa-green')
      },
      error: function (e) {
        $('.mk-save').removeClass('fa-spinner fa-spin').addClass('fa-times-circle fa-red')
      }
    });
  },

  // /maps/newbie/warpables/42, but we'll try /warpables/42
  // as it should also be a valid restful route
  deleteImage: function () {
    var img = this;
    if (confirm('Are you sure you want to delete this image? You cannot undo this.')) {
      $.ajax('/images/' + img.warpable_id, {
        dataType: 'json',
        type: 'DELETE',
        beforeSend: function() {
          $('.mk-save').removeClass('fa-check-circle fa-times-circle fa-green fa-red').addClass('fa-spinner fa-spin');
        },
        success: function(data) {
          App.concurrent_editing.speak(data);
          map._imgGroup.removeLayer(img);
          // remove from sidebar too:
          $('#warpable-' + img.warpable_id).remove();
        },
        complete: function() {
          $('.mk-save').removeClass('fa-spinner fa-spin').addClass('fa-check-circle fa-green');
        },
        error: function(request) {
          alert(request.responseText);
          $('.mk-save').removeClass('fa-spinner fa-spin').addClass('fa-times-circle fa-red');
        }
      });
    }
  },

  getMap: function () {
    return this._map;
  },

  /* Fetch JSON list of warpable images */
  withWarpables: function (callback) {
    if (this._warpables) {
      if (callback) { callback(this._warpables); }
    } else {
      jQuery.getJSON(this._warpablesUrl, function (warpablesData) {
        this._warpables = warpablesData;
        if (callback) { callback(this._warpables); }
      });
    }
  },

  /* withWarpable(id, "medium", function(img) { ... }) */
  withWarpable: function (id, size, callback) {
    this.withWarpables(function (warpables) {
      var url = warpables[id][size],
          img = jQuery("<img/>").attr("src", url).attr("data-warpable-id", id);

      callback(img);
    });
  },

  addKml: function () {
    var url = prompt("Enter a KML URL");
    var kml = omnivore.kml(url)
      .on('ready', function () {
        console.log(kml);
        map.fitBounds(kml.getBounds());
        $.each(kml._layers, function (i, marker) {
          marker.bindPopup('<p><img width="100%;" src="' + marker.feature.properties.__imgUrl + '" /></p><p width="100%;">' + marker.feature.properties.__data + "</p>");
        });
      }).addTo(map);
  },

  setupMap: function () {
    var map = this._map;
    map.addGoogleMutant();

    L.control.zoom({position: 'topright'}).addTo(map);
    L.control.scale().addTo(map);
  },

  setupCollection: function() {

    map._imgGroup = L.distortableCollection({
      editable: !mapknitter.readOnly,
      exportOpts: {
        // exportUrl: 'http://34.74.118.242/api/v2/export/', // to swap to JS exporter
        // exportStartUrl: 'http://34.74.118.242/api/v2/export/', // to swap to JS exporter
        fetchStatusUrl: fetchStatusUrl
      }
    }).addTo(map);

    // customize the function that starts up the export
    function fetchStatusUrl(opts) {
      console.log('fetch status json', opts);

      var scale = 0;
      opts.collection.forEach(function(img) {
        scale += img.cm_per_pixel;
      });
      // average of scales of each image
      scale = parseInt(scale/opts.collection.length);

      // sort by order of appearance in DOM, as Leaflet uses to sort display order:
      // https://github.com/Leaflet/Leaflet/blob/37d2fd15ad6518c254fae3e033177e96c48b5012/src/dom/DomUtil.js#L95-L102
      var domCollection = $('img.collected').toArray();
      opts.collection.sort(function(a, b) {
        return(
          Number(domCollection.indexOf(map._imgGroup._layers[a.id]._image)) -
          Number(domCollection.indexOf(map._imgGroup._layers[b.id]._image))
        );
      });

      $.ajax({
        url: 'https://export.mapknitter.org/export',
        crossDomain: true,
        type: 'POST',
        data: {
          collection: JSON.stringify(opts.collection),
          scale: prompt("Choose a scale in 'centimeters per pixel' (where a smaller 50cm pixel is higher resolution - comparable to Google Maps - or a larger 200cm pixel is lower resolution):", scale) || opts.scale,
          upload: true,
        },
        success: handleStatusResponse
      });
      // show exports
      $('.export-tab').click();
      $('.exports-tab').click();
      window.location.hash = "#cloud-exports";
    }

    // receives the URL of status.json, and starts running the updater to repeatedly fetch from status.json;
    // this may be overridden to integrate with any UI
    function handleStatusResponse(status_url, opts) {
      // alert("Export has begun: leave this window open to be notified of completion."); // https://github.com/publiclab/Leaflet.DistortableImage/issues/522
      // this is for the JS exporter:
      // var statusUrl = data.split('please visit, ')[1];

      // save the location of the status URL
      $.ajax({
        url: "/export",
        type: 'POST',
        data: {
          status_url: 'https://export.mapknitter.org' + status_url,
          map_id: mapknitter.map_id
        }
      }).done(function (data) {
        console.log('saved status.json URL to MapKnitter', data);
      });

      // hand it off to https://github.com/publiclab/mapknitter/blob/main/app/views/maps/_cloud_exports.html.erb
      addExport('https://export.mapknitter.org' + status_url);

      // repeatedly fetch the status.json (now may be unnecessary after above addExport() usage)
      var updateInterval = setInterval(function intervalUpdater() {
        $.ajax('https://export.mapknitter.org/' + status_url + '?' + Date.now(), { // bust cache with timestamp
          type: 'GET',
          crossDomain: true,
        }).done(function(data) {
          // update the progress bar or spinner
          // opts.updater(data);
          data = JSON.parse(data);
          if (data && data.status == "complete") {
            alert("Export completed at " + data.cm_per_pixel + " cm/px. JPG available at https://mapknitter-exports-warps.storage.googleapis.com" + data.jpg.split('public/warps')[1] + " -- Please refresh page to view completed exports.");
            clearInterval(updateInterval);
          }
          });
      }, 3000); // frequency of updating

      opts.resolve(); // stop the spinner
    }

    var sidebar = document.querySelector('body > div.sidebar');

    if (!mapknitter.readOnly) {
      // Deselect images if you click on the sidebar, otherwise hotkeys still fire as you type.
      L.DomEvent.on(sidebar, {
        click: mapknitter._enter,
        mouseleave: mapknitter._out,
      });

      L.DomEvent.on(map._imgGroup, 'layeradd', function (e) {
        mapknitter.setupEvents(e);
        mapknitter.setupToolbar(e);
        mapknitter.setupGeocode(e);
      });
    }
  },

  /** ========== custom toolbar actions =========== */ /* TODO: find a better place for these */

  /** The upstream delete action also triggers a confirmation window, this one won't */
  customDeleteAction: function () {
    var action = L.EditAction.extend({
      initialize: function (map, overlay, options) {
        var use = 'delete_forever';

        options = options || {};
        options.toolbarIcon = {
          svg: true,
          html: use,
          tooltip: 'Delete Image'
        };

        L.EditAction.prototype.initialize.call(this, map, overlay, options);
      },

      addHooks: function () {
        var ov = this._overlay;

        if (ov.editing._mode !== 'lock') {
          ov.fire('delete');
        }
      }
    });

    return action;
  }

});