Repository: osmlab/maproulette3
Pull request #1411 (2020-10-12 21:49:03)

Feature-selection menu on task completion map
* When multiple feature layers are rendered on the task completion map,
offer a menu on click with a list of candidate features that could have
been clicked (if more than one)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc9635aa..ab761d9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,12 +7,6 @@ The format is based on
 This project adheres to
 [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
-
-## [v3.6.13] - 2020-10-12
-### Fixed
-- Comment mention dropdown interfering with adding newlines to comment
-
-
 ## [v3.6.12] - 2020-10-07
 ### Added
 - Updated translations and new Vietnamese locale (thank you translators!)
diff --git a/package.json b/package.json
index 9a0ed2c9..0e645abc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "maproulette3",
-  "version": "3.6.13",
+  "version": "3.6.12",
   "private": true,
   "dependencies": {
     "@apollo/client": "^3.1.1",
@@ -14,6 +14,7 @@
     "@turf/bbox": "^6.0.1",
     "@turf/bbox-polygon": "^6.0.1",
     "@turf/bearing": "^6.0.1",
+    "@turf/boolean-contains": "^6.0.1",
     "@turf/boolean-disjoint": "^6.0.2",
     "@turf/center": "^6.0.1",
     "@turf/centroid": "^6.0.2",
@@ -38,7 +39,7 @@
     "handlebars": "^4.7.6",
     "leaflet": "^1.6.0",
     "leaflet-lasso": "^2.1.0",
-    "leaflet-vectoricon": "https://github.com/nrotstan/Leaflet.VectorIcon.git#with-transforms",
+    "leaflet-vectoricon": "https://github.com/nrotstan/Leaflet.VectorIcon.git#honor-existing-margin-styles",
     "leaflet.markercluster": "^1.4.1",
     "mapillary-js": "^2.20.0",
     "node-sass": "^4.11.0",
diff --git a/src/components/AutosuggestTextBox/AutosuggestMentionTextArea.js b/src/components/AutosuggestTextBox/AutosuggestMentionTextArea.js
index 2d617678..9999ac6f 100644
--- a/src/components/AutosuggestTextBox/AutosuggestMentionTextArea.js
+++ b/src/components/AutosuggestTextBox/AutosuggestMentionTextArea.js
@@ -60,14 +60,10 @@ export default class AutosuggestMentionTextArea extends Component {
     }
   }
 
-  handleKeyDown = (e, dropdown) => {
-    if (dropdown.isDropdownVisible) {
-      if (e.key === "ArrowUp" ||
-          e.key === "ArrowDown" ||
-          e.key === "ArrowRight" ||
-          e.key === "ArrowLeft") {
-        e.preventDefault()
-      }
+  handleKeyDown = e => {
+    if (e.key === "Enter") {
+      // Don't let enter key potentially submit a form
+      e.preventDefault()
     }
   }
 
@@ -188,7 +184,7 @@ export default class AutosuggestMentionTextArea extends Component {
                     this.props.inputClassName,
                     "mr-flex-grow mr-w-full mr-h-full mr-outline-none"
                   )}
-                  onKeyDown={(e) => this.handleKeyDown(e, dropdown)}
+                  onKeyDown={this.handleKeyDown}
                   onFocus={(e) => this.setState({textBoxActive: true})}
                   onBlur={(e) => this.setState({textBoxActive: false})}
                   placeholder={this.props.placeholder}
diff --git a/src/components/EnhancedMap/EnhancedMap.js b/src/components/EnhancedMap/EnhancedMap.js
index 2a5c0a33..19ec2133 100644
--- a/src/components/EnhancedMap/EnhancedMap.js
+++ b/src/components/EnhancedMap/EnhancedMap.js
@@ -1,10 +1,27 @@
+import React  from 'react'
+import ReactDOM from 'react-dom'
 import PropTypes from 'prop-types'
-import { Map } from 'react-leaflet'
-import { LatLngBounds, LatLng, latLng } from 'leaflet'
+import { injectIntl } from 'react-intl'
+import { Map as ReactLeafletMap } from 'react-leaflet'
+import { LatLngBounds, LatLng, Point, latLng } from 'leaflet'
+import L from 'leaflet'
+import bboxPolygon from '@turf/bbox-polygon'
+import booleanDisjoint from '@turf/boolean-disjoint'
+import booleanContains from '@turf/boolean-contains'
 import _get from 'lodash/get'
+import _each from 'lodash/each'
 import _isEmpty from 'lodash/isEmpty'
 import _isEqual from 'lodash/isEqual'
 import _isFinite from 'lodash/isFinite'
+import _isArray from 'lodash/isArray'
+import _sortBy from 'lodash/sortBy'
+import _filter from 'lodash/filter'
+import _reduce from 'lodash/reduce'
+import AsIdentifiableFeature
+       from '../../interactions/TaskFeature/AsIdentifiableFeature'
+import messages from './Messages'
+
+const PIXEL_MARGIN = 10 // number of pixels on each side of a click to consider
 
 /**
  * EnhancedMap is an extension of the react-leaflet Map that provides
@@ -19,7 +36,7 @@ import _isFinite from 'lodash/isFinite'
  *
  * @author [Neil Rotstan](https://github.com/nrotstan)
  */
-export default class EnhancedMap extends Map {
+export class EnhancedMap extends ReactLeafletMap {
   animationHandle = null
   mapBoundsFitToLayer = false
   mapMoved = false
@@ -133,6 +150,47 @@ export default class EnhancedMap extends Map {
     }, 0)
   }
 
+
+  /**
+   * Return bounding polygon centered around clicked layer point, with
+   * PIXEL_MARGIN on each side of the point
+   */
+  getClickPolygon = clickEvent => {
+    const center = clickEvent.layerPoint
+    const nw = this.leafletElement.layerPointToLatLng(new Point(center.x - PIXEL_MARGIN, center.y - PIXEL_MARGIN))
+    const se = this.leafletElement.layerPointToLatLng(new Point(center.x + PIXEL_MARGIN, center.y + PIXEL_MARGIN))
+    return bboxPolygon([nw.lng, se.lat, se.lng, nw.lat])
+  }
+
+  /**
+   * Determines if a click was within a marker's icon, which could potentially
+   * extend far beyond our PIXEL_MARGIN from the marker's represened point
+   */
+  isClickOnMarker = (clickPolygon, marker) => {
+    const icon = marker.getIcon()
+    const iconOptions = Object.assign({}, Object.getPrototypeOf(icon).options, icon.options)
+    const markerPoint = this.leafletElement.containerPointToLayerPoint(
+      this.leafletElement.latLngToContainerPoint(marker.getLatLng())
+    )
+
+    // We need an iconAnchor and iconSize to continue
+    if (!_isArray(iconOptions.iconAnchor) || !_isArray(iconOptions.iconSize)) {
+      return false
+    }
+
+    const nw = this.leafletElement.layerPointToLatLng(new Point(
+      markerPoint.x - iconOptions.iconAnchor[0],
+      markerPoint.y - iconOptions.iconAnchor[1]
+    ))
+    const se = this.leafletElement.layerPointToLatLng(new Point(
+      markerPoint.x + (iconOptions.iconSize[0] - iconOptions.iconAnchor[0]),
+      markerPoint.y + (iconOptions.iconSize[1] - iconOptions.iconAnchor[1])
+    ))
+    const markerPolygon = bboxPolygon([nw.lng, se.lat, se.lng, nw.lat])
+
+    return !booleanDisjoint(clickPolygon, markerPolygon)
+  }
+
   componentDidMount() {
     super.componentDidMount()
 
@@ -167,6 +225,142 @@ export default class EnhancedMap extends Map {
     if (this.props.initialBounds && _isFinite(this.props.initialBounds.getNorth())) {
       this.leafletElement.fitBounds(this.props.initialBounds)
     }
+
+    if (this.props.externalInteractive) {
+      this.leafletElement.on('click', e => {
+        const clickBounds = this.getClickPolygon(e)
+        const candidateLayers = new Map()
+        this.leafletElement.eachLayer(layer => {
+          if (!_isEmpty(layer._layers)) {
+            // multiple features in a layer could match. Detect them and then
+            // put them into an intuitive order
+            const intraLayerMatches = []
+            _each(layer._layers, featureLayer => {
+              if (featureLayer.toGeoJSON) {
+                const featureGeojson = featureLayer.toGeoJSON()
+                // Look for an overlap between the click and the feature. However, since marker
+                // layers are represented by an icon (which could extend far beyond the feature
+                // plus our usual pixel margin), check for a click on the marker itself as well
+                if ((featureLayer.getIcon && this.isClickOnMarker(clickBounds, featureLayer)) ||
+                    !booleanDisjoint(clickBounds, featureGeojson)) {
+                  const featureId = AsIdentifiableFeature(featureGeojson).normalizedTypeAndId()
+                  const featureName = _get(featureGeojson, 'properties.name')
+                  let layerDescription =
+                    (featureLayer.options.mrLayerLabel || '') + (featureId ? `: ${featureId}` : '')
+                  if (!layerDescription) {
+                    // worst case, fall back to a layer id (ours, preferably, or leaflet's)
+                    layerDescription = `Layer ${featureLayer.mrLayerId || featureLayer._leaflet_id}`
+                  }
+
+                  const layerLabel = featureName ? (
+                    <React.Fragment>
+                      <div>{layerDescription}</div>
+                      <div className="mr-text-grey-light mr-text-xs">{featureName}</div>
+                    </React.Fragment>
+                  ) : layerDescription
+
+                  intraLayerMatches.push({
+                    mrLayerId: featureLayer.options.mrLayerId,
+                    description: layerDescription,
+                    label: layerLabel,
+                    geometry: featureGeojson,
+                    layer: featureLayer,
+                  })
+                }
+              }
+            })
+
+            if (intraLayerMatches.length > 0) {
+              this.orderedFeatureLayers(intraLayerMatches).forEach(match => {
+                candidateLayers.set(match.description, match)
+              })
+            }
+          }
+        })
+
+        if (candidateLayers.size === 1) {
+          candidateLayers.values().next().value.layer.fire('mr-external-interaction', {
+            map: this.leafletElement,
+            latlng: e.latlng,
+          })
+        }
+        else if (candidateLayers.size > 1) {
+          let layers = [...candidateLayers.entries()]
+          if (this.props.overlayOrder && this.props.overlayOrder.length > 0) {
+            layers = _sortBy(layers, layerEntry => {
+              const position = this.props.overlayOrder.indexOf(layerEntry[1].mrLayerId)
+              return position === -1 ? Number.MAX_SAFE_INTEGER : position
+            })
+          }
+
+          this.popupLayerSelectionList(layers, e.latlng)
+        }
+      })
+    }
+  }
+
+  /**
+   * Simple sorting of multiple related feature layers (such as all in a single map
+   * layer) by geometry type into order of: points, lineStrings, surrounded polygons,
+   * surrounding polygons. Each layer should be an object with a `geometry` field
+   */
+  orderedFeatureLayers(layers) {
+    if (!layers || layers.length < 2) {
+      return layers // nothing to do
+    }
+
+    // We'll process polygons separately
+    const geometryOrder = ['Point', 'MultiPoint', 'LineString', 'MultiLineString']
+    const orderedLayers = _sortBy(
+      _filter(layers, l => geometryOrder.indexOf(l.geometry.type) !== -1), // no polygons yet
+      l => geometryOrder.indexOf(l.geometry.type)
+    )
+
+    // Now order any polygons by number of enclosing polygons, so enclosed will come
+    // before enclosing
+    const polygonLayers = _filter(layers, l => l.geometry.type === 'Polygon' || l.geometry.type === 'MultiPolygon')
+    const orderedPolygons = polygonLayers.length < 2 ? polygonLayers : _sortBy(
+      polygonLayers,
+      l => _reduce(
+        polygonLayers,
+        (count, other) => {
+          return booleanContains(other.geometry, l.geometry) ? count + 1 : count
+        },
+        0
+      )
+    ).reverse()
+
+    return orderedLayers.concat(orderedPolygons)
+  }
+
+  popupLayerSelectionList = (layers, latlng) => {
+    const contentElement = document.createElement('div')
+    ReactDOM.render(
+      <div className="mr-text-base mr-px-4 mr-links-blue-light">
+        <h3>{this.props.intl.formatMessage(messages.layerSelectionHeader)}</h3>
+        <ol>
+          {layers.map(([description, layerInfo]) => {
+            return (
+              <li key={description} className="mr-my-4">
+                {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+                <a
+                  onClick={() => layerInfo.layer.fire('mr-external-interaction', {
+                    map: this.leafletElement,
+                    latlng: latlng,
+                    onBack: () => this.popupLayerSelectionList(layers, latlng),
+                  })}
+                >
+                  {layerInfo.label || description}
+                </a>
+              </li>
+            )
+          })}
+        </ol>
+      </div>,
+      contentElement
+    )
+
+    L.popup().setLatLng(latlng).setContent(contentElement).openOn(this.leafletElement)
   }
 
   componentDidUpdate(prevProps, prevState) {
@@ -228,3 +422,5 @@ EnhancedMap.defaultProps = {
   justFitFeatures: false,
   animateFeatures: false,
 }
+
+export default injectIntl(EnhancedMap)
diff --git a/src/components/EnhancedMap/ImageMarkerLayer/ImageMarkerLayer.js b/src/components/EnhancedMap/ImageMarkerLayer/ImageMarkerLayer.js
index 3dba10e9..78f501b8 100644
--- a/src/components/EnhancedMap/ImageMarkerLayer/ImageMarkerLayer.js
+++ b/src/components/EnhancedMap/ImageMarkerLayer/ImageMarkerLayer.js
@@ -24,19 +24,22 @@ const colors = resolveConfig(tailwindConfig).theme.colors
 const ImageMarkerLayer = props => {
   const [imageMarkers, setImageMarkers] = useState([])
 
+  const {images, markerColor, imageAlt, imageClicked, icon, mrLayerId, mrLayerLabel, style} = props
   useEffect(() => {
     setImageMarkers(
       buildImageMarkers(
-        props.images,
-        props.icon ? props.icon : circleIcon(props.markerColor),
-        props.imageClicked,
-        props.imageAlt,
+        images,
+        icon ? icon : circleIcon(markerColor),
+        imageClicked,
+        imageAlt,
+        mrLayerId,
+        mrLayerLabel
       )
     )
-  }, [props.images, props.markerColor, props.imageAlt, props.imageClicked, props.icon])
+  }, [images, markerColor, imageAlt, imageClicked, icon, mrLayerId, mrLayerLabel])
 
   return (
-    <LayerGroup style={props.style}>
+    <LayerGroup style={style}>
       {imageMarkers}
     </LayerGroup>
   )
@@ -55,7 +58,7 @@ ImageMarkerLayer.propTypes = {
   markerColor: PropTypes.string,
 }
 
-const buildImageMarkers = (images, icon, imageClicked, imageAlt) => {
+const buildImageMarkers = (images, icon, imageClicked, imageAlt, layerId, layerLabel) => {
   if (!images || images.length === 0) {
     return []
   }
@@ -63,6 +66,8 @@ const buildImageMarkers = (images, icon, imageClicked, imageAlt) => {
   return _map(images, imageInfo =>
     <Marker
       key={imageInfo.key}
+      mrLayerId={layerId}
+      mrLayerLabel={layerLabel}
       position={[imageInfo.lat, imageInfo.lon]}
       icon={icon}
       onMouseover={({target}) => target.openPopup()}
diff --git a/src/components/EnhancedMap/LayerToggle/LayerToggle.js b/src/components/EnhancedMap/LayerToggle/LayerToggle.js
index cb5ca5fd..6eb140d2 100644
--- a/src/components/EnhancedMap/LayerToggle/LayerToggle.js
+++ b/src/components/EnhancedMap/LayerToggle/LayerToggle.js
@@ -111,7 +111,7 @@ export class LayerToggle extends Component {
                       {...provided.draggableProps}
                     >
                       <div className="mr-relative mr-my-2 mr-flex mr-justify-between">
-                        <div>{overlays[rubric.source.index].component}</div>
+                        <div className="mr-text-sm">{overlays[rubric.source.index].component}</div>
                         {canReorderLayers &&
                          <div {...provided.dragHandleProps}>
                            <SvgSymbol
diff --git a/src/components/EnhancedMap/Messages.js b/src/components/EnhancedMap/Messages.js
new file mode 100644
index 00000000..a170e565
--- /dev/null
+++ b/src/components/EnhancedMap/Messages.js
@@ -0,0 +1,11 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with EnhancedMap
+ */
+export default defineMessages({
+  layerSelectionHeader: {
+    id: "Map.layerSelectionList.header",
+    defaultMessage: "Select Desired Feature"
+  },
+})
diff --git a/src/components/EnhancedMap/OSMDataLayer/OSMDataLayer.js b/src/components/EnhancedMap/OSMDataLayer/OSMDataLayer.js
index f9deda70..9a7fa613 100644
--- a/src/components/EnhancedMap/OSMDataLayer/OSMDataLayer.js
+++ b/src/components/EnhancedMap/OSMDataLayer/OSMDataLayer.js
@@ -1,12 +1,15 @@
 import React from 'react'
 import ReactDOM from 'react-dom'
 import { Path, withLeaflet } from 'react-leaflet'
+import { injectIntl } from 'react-intl'
 import L from 'leaflet'
 import _isEqual from 'lodash/isEqual'
 import _get from 'lodash/get'
+import _omit from 'lodash/omit'
 import PropertyList from '../PropertyList/PropertyList'
 import resolveConfig from 'tailwindcss/resolveConfig'
 import tailwindConfig from '../../../tailwind.config.js'
+import layerMessages from '../LayerToggle/Messages'
 
 const colors = resolveConfig(tailwindConfig).theme.colors
 
@@ -16,19 +19,24 @@ const colors = resolveConfig(tailwindConfig).theme.colors
 export class OSMDataLayer extends Path {
   lastZoom = null
 
-  popupContent(layer) {
+  popupContent(layer, onBack) {
+    const properties = layer.feature.properties
     const header = (
       <a target="_blank"
          rel="noopener noreferrer"
-         href={`https://www.openstreetmap.org/${layer.feature.type}/${layer.feature.id}`}
+         href={`https://www.openstreetmap.org/${properties.type}/${properties.id}`}
       >
-        {layer.feature.type} {layer.feature.id}
+        {properties.type} {properties.id}
       </a>
     )
 
     const contentElement = document.createElement('div')
     ReactDOM.render(
-      <PropertyList header={header} featureProperties={layer.feature.tags} />,
+      <PropertyList
+        header={header}
+        featureProperties={_omit(layer.feature.properties, ['id', 'type'])}
+        onBack={onBack}
+      />,
       contentElement
     )
     return contentElement
@@ -41,7 +49,10 @@ export class OSMDataLayer extends Path {
 
   createLeafletElement(props) {
     this.lastZoom = props.zoom
-    return this.generateLayer(props)
+    return this.generateLayer({
+      mrLayerLabel: props.intl.formatMessage(layerMessages.showOSMDataLabel),
+      ...props,
+    })
   }
 
   updateLeafletElement(fromProps, toProps) {
@@ -82,15 +93,34 @@ export class OSMDataLayer extends Path {
     })
 
     layerGroup.eachLayer(layer => {
+      layer.options.mrLayerLabel = props.mrLayerLabel
       layer.options.fill = false
       layer.options.pane = layerGroup.options.pane
+      layer.originalToGeoJSON = layer.toGeoJSON
+      layer.toGeoJSON = precision => {
+        const geojson = layer.originalToGeoJSON(precision)
+        return {
+          ...geojson.geometry,
+          properties: layer.feature.properties,
+        }
+      }
+
+      if (props.externalInteractive) {
+        layer.on('mr-external-interaction', ({map, latlng, onBack}) => {
+          L.popup({}, layer).setLatLng(latlng).setContent(this.popupContent(layer, onBack)).openOn(map)
+        })
+      }
     })
-    layerGroup.bindPopup(this.popupContent)
+
+    if (!props.externalInteractive) {
+      layerGroup.bindPopup(this.popupContent)
+    }
+
     return layerGroup
   }
 }
 
-export default withLeaflet(OSMDataLayer)
+export default withLeaflet(injectIntl(OSMDataLayer))
 
 // The below code (with a couple minor linter fixes) comes from the
 // [leaflet-osm](https://github.com/openstreetmap/leaflet-osm) project's
@@ -159,7 +189,13 @@ L.OSM.DataLayer = L.FeatureGroup.extend({
 
       if (layer) {
         layer.addTo(this);
-        layer.feature = feature;
+        layer.feature = {
+          properties: {
+            id: feature.id,
+            type: feature.type,
+            ...feature.tags,
+          }
+        }
       }
     }
   },
diff --git a/src/components/EnhancedMap/PropertyList/PropertyList.js b/src/components/EnhancedMap/PropertyList/PropertyList.js
index 7051eea7..188b6b30 100644
--- a/src/components/EnhancedMap/PropertyList/PropertyList.js
+++ b/src/components/EnhancedMap/PropertyList/PropertyList.js
@@ -6,6 +6,7 @@ import _map from 'lodash/map'
 import _isEmpty from 'lodash/isEmpty'
 import _isObject from 'lodash/isObject'
 import _truncate from 'lodash/truncate'
+import SvgSymbol from '../../SvgSymbol/SvgSymbol'
 import messages from './Messages'
 
 /**
@@ -16,13 +17,26 @@ import messages from './Messages'
 const PropertyList = props => {
   // Default is lightMode -- only do darkMode if the value is present and false
   const darkMode = props.lightMode === false
-
   const tagInfo = process.env.REACT_APP_TAGINFO_SERVER_URL
+  const header = (
+    <h3 className="mr-flex mr-items-center">
+      {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+      {props.onBack && <a onClick={props.onBack} className="mr-mr-4">
+          <SvgSymbol
+            sym="icon-cheveron-left"
+            viewBox="0 0 20 20"
+            className="mr-fill-current mr-w-6 mr-h-6"
+          />
+        </a>
+      }
+      {props.header ? props.header : <FormattedMessage {...messages.title} />}
+    </h3>
+  )
 
   if (_isEmpty(props.featureProperties)) {
     return (
       <div className="feature-properties empty">
-        <h3>{props.header ? props.header : <FormattedMessage {...messages.title} />}</h3>
+        {header}
         <span className="mr-ml-5"><FormattedMessage {...messages.noProperties} /></span>
       </div>
     )
@@ -53,7 +67,6 @@ const PropertyList = props => {
     )
   }))
 
-  const header = <h3>{props.header ? props.header : <FormattedMessage {...messages.title} />}</h3>
   return (
     <div className="feature-properties mr-ml-2">
       {!props.hideHeader && header}
diff --git a/src/components/EnhancedMap/TaskFeatureLayer/TaskFeatureLayer.js b/src/components/EnhancedMap/TaskFeatureLayer/TaskFeatureLayer.js
index 8902e351..8b2e654f 100644
--- a/src/components/EnhancedMap/TaskFeatureLayer/TaskFeatureLayer.js
+++ b/src/components/EnhancedMap/TaskFeatureLayer/TaskFeatureLayer.js
@@ -2,12 +2,14 @@ import React, { useState, useEffect } from 'react'
 import ReactDOM from 'react-dom'
 import { GeoJSON, withLeaflet } from 'react-leaflet'
 import L from 'leaflet'
+import { injectIntl } from 'react-intl'
 import _isFunction from 'lodash/isFunction'
 import _get from 'lodash/get'
 import _uniqueId from 'lodash/uniqueId'
 import AsSimpleStyleableFeature
        from '../../../interactions/TaskFeature/AsSimpleStyleableFeature'
 import PropertyList from '../PropertyList/PropertyList'
+import layerMessages from '../LayerToggle/Messages'
 
 /**
  * TaskFeatureLayer renders a react-leaflet map layer representing the given
@@ -17,16 +19,17 @@ import PropertyList from '../PropertyList/PropertyList'
 const TaskFeatureLayer = props => {
   const [layer, setLayer] = useState(null)
 
-  const propertyList = featureProperties => {
+  const propertyList = (featureProperties, onBack) => {
     const contentElement = document.createElement('div')
     ReactDOM.render(
-      <PropertyList featureProperties={featureProperties} />,
+      <PropertyList featureProperties={featureProperties} onBack={onBack} />,
       contentElement
     )
     return contentElement
   }
 
-  const { features, mrLayerId, animator } = props
+  const { features, mrLayerId, animator, externalInteractive } = props
+  const layerLabel = props.intl.formatMessage(layerMessages.showTaskFeaturesLabel)
   const pane = _get(props, 'leaflet.pane')
 
   useEffect(() => {
@@ -34,12 +37,21 @@ const TaskFeatureLayer = props => {
       <GeoJSON
         key={_uniqueId()}
         mrLayerId={mrLayerId}
+        mrLayerLabel={layerLabel}
         data={features}
         pointToLayer={(point, latLng) => {
-          return L.marker(latLng, { pane })
+          const marker = L.marker(latLng, {pane, mrLayerLabel: layerLabel, mrLayerId: mrLayerId})
+          return marker
         }}
         onEachFeature={(feature, layer) => {
-          layer.bindPopup(() => propertyList(feature.properties))
+          if (!externalInteractive) {
+            layer.bindPopup(() => propertyList(feature.properties))
+          }
+          else {
+            layer.on('mr-external-interaction', ({map, latlng, onBack}) => {
+              L.popup({}, layer).setLatLng(latlng).setContent(propertyList(feature.properties, onBack)).openOn(map)
+            })
+          }
 
           // Animate features when added to map (if requested)
           if (animator) {
@@ -60,9 +72,9 @@ const TaskFeatureLayer = props => {
         }}
       />
     )
-  }, [features, mrLayerId, pane, animator])
+  }, [features, mrLayerId, pane, animator, externalInteractive, layerLabel])
 
   return layer
 }
 
-export default withLeaflet(TaskFeatureLayer)
+export default withLeaflet(injectIntl(TaskFeatureLayer))
diff --git a/src/components/TaskPane/TaskMap/TaskMap.js b/src/components/TaskPane/TaskMap/TaskMap.js
index 896cd8dd..de1df1bb 100644
--- a/src/components/TaskPane/TaskMap/TaskMap.js
+++ b/src/components/TaskPane/TaskMap/TaskMap.js
@@ -371,6 +371,8 @@ export class TaskMap extends Component {
     component: (
       <ImageMarkerLayer
         key="mapillary"
+        mrLayerId="mapillary"
+        mrLayerLabel="Mapillary"
         images={this.props.mapillaryImages}
         markerColor="#39AF64"
         imageClicked={imageKey => this.setState({"mapillaryViewerImage": imageKey})}
@@ -384,6 +386,8 @@ export class TaskMap extends Component {
     component: (
       <ImageMarkerLayer
         key="openstreetcam"
+        mrLayerId="openstreetcam"
+        mrLayerLabel="OpenStreetCam"
         images={this.props.openStreetCamImages}
         markerColor="#C851E0"
         imageClicked={imageKey => this.setState({"openStreetCamViewerImage": imageKey})}
@@ -490,7 +494,7 @@ export class TaskMap extends Component {
       this.props.visibleOverlays, _get(this.props, 'user.settings.customBasemaps'),
       (layerId, index, layerSource) => ({
         id: layerId,
-        component: <SourcedTileLayer key={layerId} source={layerSource} />,
+        component: <SourcedTileLayer key={layerId} source={layerSource} mrLayerId={layerId} />,
       })
     )
 
@@ -503,6 +507,7 @@ export class TaskMap extends Component {
             mrLayerId="task-features"
             features={this.applyStyling(this.taskFeatures())}
             animator={this.animator}
+            externalInteractive
           />
         )
       })
@@ -522,10 +527,12 @@ export class TaskMap extends Component {
         component: (
           <OSMDataLayer
             key="osm-data"
+            mrLayerId="osm-data"
             xmlData={this.state.osmData}
             zoom={_isFinite(this.state.latestZoom) ? this.state.latestZoom : zoom}
             showOSMElements={this.state.showOSMElements}
             animator={this.animator}
+            externalInteractive
           />
         ),
       })
@@ -573,12 +580,13 @@ export class TaskMap extends Component {
           minZoom={minZoom}
           maxZoom={maxZoom}
           worldCopyJump={true}
-          features={this.applyStyling(this.taskFeatures())}
           fitToLayer={this.state.skipFit ? null : 'task-features'}
           fitBoundsOnlyAsNecessary
           animator={this.animator}
           onBoundsChange={this.updateTaskBounds}
           conditionalStyles={_get(this.props, 'challenge.taskStyles')}
+          externalInteractive
+          overlayOrder={overlayOrder}
         >
           <ZoomControl position='topright' />
           <FitBoundsControl />
diff --git a/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js b/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js
index 078431af..125c9e56 100644
--- a/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js
+++ b/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js
@@ -5,13 +5,9 @@ import L from 'leaflet'
 import 'leaflet-vectoricon'
 import { ZoomControl, Marker, Tooltip } from 'react-leaflet'
 import MarkerClusterGroup from 'react-leaflet-markercluster'
-import { point, featureCollection } from '@turf/helpers'
-import bbox from '@turf/bbox'
-import bboxPolygon from '@turf/bbox-polygon'
 import _get from 'lodash/get'
 import _map from 'lodash/map'
 import _cloneDeep from 'lodash/cloneDeep'
-import { latLng } from 'leaflet'
 import { buildLayerSources } from '../../../services/VisibleLayer/LayerSources'
 import { TaskStatusColors, messagesByStatus } from '../../../services/Task/TaskStatus/TaskStatus'
 import { messagesByPriority } from '../../../services/Task/TaskPriority/TaskPriority'
@@ -49,21 +45,28 @@ const starIconSvg = L.vectorIcon({
   },
 })
 
-const markerIconSvg = (priority, styleOptions={}) => L.vectorIcon({
-  viewBox: '0 0 20 20',
-  svgHeight: 45 - (priority * 10),
-  svgWidth: 40 - (priority * 10),
-  type: 'path',
-  shape: { // zondicons "location" icon
-    d: "M10 20S3 10.87 3 7a7 7 0 1 1 14 0c0 3.87-7 13-7 13zm0-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"
-  },
-  style: Object.assign({
-    fill: colors['blue-leaflet'],
-    stroke: colors['grey-leaflet'],
-    strokeWidth: 0.5,
-  }, styleOptions),
-  iconAnchor: [5, 15], // render tip of SVG near marker location
-})
+const markerIconSvg = (priority, styleOptions={}) => {
+  const prioritizedWidth = 40 - (priority * 10)
+  const prioritizedHeight = 45 - (priority * 10)
+  return L.vectorIcon({
+    viewBox: '0 0 20 20',
+    svgWidth: prioritizedWidth,
+    svgHeight: prioritizedHeight,
+    type: 'path',
+    shape: { // zondicons "location" icon
+      d: "M10 20S3 10.87 3 7a7 7 0 1 1 14 0c0 3.87-7 13-7 13zm0-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"
+    },
+    style: Object.assign({
+      fill: colors['blue-leaflet'],
+      stroke: colors['grey-leaflet'],
+      strokeWidth: 0.5,
+      marginTop: '0px',
+      marginLeft: '0px',
+    }, styleOptions),
+    iconSize: [prioritizedWidth, prioritizedHeight],
+    iconAnchor: [prioritizedWidth / 2, prioritizedHeight], // tip of marker
+  })
+}
 
 /**
  * TaskNearbyMap allows the user to select a task that is geographically nearby
@@ -97,17 +100,7 @@ export class TaskNearbyMap extends Component {
       return null
     }
 
-    // Compute bounds of task markers to be displayed, including a marker for
-    // the current task to make sure it'll be shown on the map as well
     const currentCenterpoint = AsMappableTask(this.props.task).calculateCenterPoint()
-    const bounding = bboxPolygon(
-      bbox(featureCollection(
-        [point([currentCenterpoint.lng, currentCenterpoint.lat])].concat(
-          _map(this.props.taskMarkers,
-              marker => point([marker.position[1], marker.position[0]]))
-        )
-      ))
-    )
 
     const hasTaskMarkers = _get(this.props, 'taskMarkers.length', 0) > 0
     let coloredMarkers = null
@@ -168,11 +161,8 @@ export class TaskNearbyMap extends Component {
         <LayerToggle {...this.props} />
         <EnhancedMap
           onClick={this.props.clearNextTask}
-          center={latLng(0, 45)} zoom={3} minZoom={2} maxZoom={19}
-          setInitialBounds={false}
+          center={currentCenterpoint} zoom={18} minZoom={2} maxZoom={19}
           zoomControl={false} animate={true} worldCopyJump={true}
-          features={bounding}
-          justFitFeatures
         >
           <ZoomControl position='topright' />
           <VisibleTileLayer {...this.props} zIndex={1} />
diff --git a/src/interactions/TaskCluster/AsMappableCluster.js b/src/interactions/TaskCluster/AsMappableCluster.js
index cbd9ba19..a3307cec 100644
--- a/src/interactions/TaskCluster/AsMappableCluster.js
+++ b/src/interactions/TaskCluster/AsMappableCluster.js
@@ -29,8 +29,11 @@ const statusIcons = _fromPairs(_map(TaskStatusColors, (color, status) => [
      fill: color,
      stroke: colors['grey-leaflet'],
      strokeWidth: 0.5,
+     marginTop: '0px',
+     marginLeft: '0px',
    },
-   iconAnchor: [5, 15], // render tip of SVG near marker location
+   iconSize: [20, 20],
+   iconAnchor: [10, 20], // tip of marker
  })
 ]))
 
@@ -108,7 +111,8 @@ export class AsMappableCluster {
         icon.options.svgHeight = 40
         icon.options.svgWidth = 40
         icon.options.style.fill = colors.yellow
-        icon.options.iconAnchor = [5, 25] // adjust position of marker tip for larger size
+        icon.options.iconSize = [40, 40]
+        icon.options.iconAnchor = [20, 40]
       }
       else if (isSelected) {
         icon = _cloneDeep(selectedTaskStatusIcons[markerData.taskStatus])
diff --git a/src/interactions/TaskFeature/AsIdentifiableFeature.js b/src/interactions/TaskFeature/AsIdentifiableFeature.js
index 9ba35eba..2e706255 100644
--- a/src/interactions/TaskFeature/AsIdentifiableFeature.js
+++ b/src/interactions/TaskFeature/AsIdentifiableFeature.js
@@ -55,6 +55,57 @@ export class AsIdentifiableFeature {
     return (match && match.length > 1) ? match[1] : null
   }
 
+  /**
+   * Extracts the OSM element from the raw feature id (falling back to a `type`
+   * property if none is found in the id) and returns 'node', 'way', or
+   * 'relation', or null if no type is detected
+   */
+  osmType() {
+    const typeRe = /^(node|way|relation|n|r|w)/
+
+    let featureType = this.rawFeatureId()
+    if (!typeRe.test(featureType)) {
+      featureType = this.type // this.type should really be a GeoJSON type, but just in case
+      if (!typeRe.test(featureType)) {
+        featureType = this.properties.type
+      }
+    }
+
+    const match = typeRe.exec(featureType)
+    if (match) {
+      switch (match[0]) {
+        case 'node':
+        case 'n':
+          return 'node'
+        case 'way':
+        case 'w':
+          return 'way'
+        case 'relation':
+        case 'r':
+          return 'relation'
+        default:
+          return null
+      }
+    }
+
+    return null
+  }
+
+  /**
+   * Returns the type and id in the form of `type id`, or just id if no type
+   * can be found. Returns null if no id can be found
+   */
+  normalizedTypeAndId() {
+    const osmId = this.osmId()
+    if (!osmId) {
+      // We must have at least an id
+      return null
+    }
+
+    const osmType = this.osmType()
+    return (osmType ? `${osmType} ` : '') + osmId
+  }
+
   isValidId(id) {
     if (_isUndefined(id)) {
       return false
diff --git a/src/interactions/TaskFeature/AsSimpleStyleableFeature.js b/src/interactions/TaskFeature/AsSimpleStyleableFeature.js
index 015f26b2..95ed1f48 100644
--- a/src/interactions/TaskFeature/AsSimpleStyleableFeature.js
+++ b/src/interactions/TaskFeature/AsSimpleStyleableFeature.js
@@ -103,8 +103,11 @@ export class AsSimpleStyleableFeature {
         fill: colors['blue-leaflet'],
         stroke: colors['grey-leaflet'],
         strokeWidth: 0.5,
+        marginTop: '0px',
+        marginLeft: '0px',
       },
-      iconAnchor: [5, 15], // render tip of SVG near marker location
+      iconSize: [40, 40],
+      iconAnchor: [20, 40], // tip of marker
     }
 
     let useCustomMarker = false
@@ -121,10 +124,14 @@ export class AsSimpleStyleableFeature {
             case 'small':
               customMarker.svgHeight = 20
               customMarker.svgWidth = 20
+              customMarker.iconAnchor = [10, 20]
+              customMarker.iconSize = [20, 20]
               break
             case 'large':
               customMarker.svgHeight = 60
               customMarker.svgWidth = 60
+              customMarker.iconAnchor = [30, 60]
+              customMarker.iconSize = [60, 60]
               break
             default:
               // medium is already the default size
diff --git a/src/styles/utilities/typography.css b/src/styles/utilities/typography.css
index bda63e36..c47e20e7 100644
--- a/src/styles/utilities/typography.css
+++ b/src/styles/utilities/typography.css
@@ -73,6 +73,15 @@ h4,
   }
 }
 
+.mr-links-blue-light a {
+  @apply mr-text-blue-light;
+
+  &:hover,
+  &.active {
+    @apply mr-text-blue-leaflet;
+  }
+}
+
 .mr-link-list a {
   @apply mr-block mr-mb-2;
 }
diff --git a/yarn.lock b/yarn.lock
index b707d6a1..0e7f1e7e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2314,6 +2314,17 @@
     "@turf/helpers" "^5.1.5"
     "@turf/invariant" "^5.1.5"
 
+"@turf/boolean-contains@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-6.0.1.tgz#c3c583215fc5bda47ede51cf52d735ffdc1006a5"
+  integrity sha512-usAexEdWu7dV43paowGSFEM0PljexnlOuj09HF/VDZwO3FKelwUovF2ymetYatuG7KcIYcexeNEkQ5qQnGExlw==
+  dependencies:
+    "@turf/bbox" "6.x"
+    "@turf/boolean-point-in-polygon" "6.x"
+    "@turf/boolean-point-on-line" "6.x"
+    "@turf/helpers" "6.x"
+    "@turf/invariant" "6.x"
+
 "@turf/boolean-disjoint@^6.0.2":
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/@turf/boolean-disjoint/-/boolean-disjoint-6.0.2.tgz#5d6d0f4b4b8d5fe03004d74d978044c7529b294f"
@@ -2331,6 +2342,14 @@
     "@turf/helpers" "6.x"
     "@turf/invariant" "6.x"
 
+"@turf/boolean-point-on-line@6.x":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-6.0.1.tgz#d943c242a5fdcde03f8ad0221750fd1aacf06223"
+  integrity sha512-Vl724Tzh4CF/13kgblOAQnMcHagromCP1EfyJ9G/8SxpSoTYeY2G6FmmcpbW51GqKxC7xgM9+Pck50dun7oYkg==
+  dependencies:
+    "@turf/helpers" "6.x"
+    "@turf/invariant" "6.x"
+
 "@turf/center@^6.0.1":
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/@turf/center/-/center-6.0.1.tgz#40a17d0a170df5bba09ad93e133b904d8eb14601"
@@ -8156,9 +8175,9 @@ leaflet-lasso@^2.1.0:
   dependencies:
     "@terraformer/spatial" "^2.0.7"
 
-"leaflet-vectoricon@https://github.com/nrotstan/Leaflet.VectorIcon.git#with-transforms":
+"leaflet-vectoricon@https://github.com/nrotstan/Leaflet.VectorIcon.git#honor-existing-margin-styles":
   version "0.2.0"
-  resolved "https://github.com/nrotstan/Leaflet.VectorIcon.git#beaa664f96e05c115e8e50d7128be6bfaca9d055"
+  resolved "https://github.com/nrotstan/Leaflet.VectorIcon.git#a163491e71b1b03dc3bb3d123f3bde79e519297f"
 
 leaflet.markercluster@^1.4.1:
   version "1.4.1"


Test patch:
diff --git a/src/interactions/TaskFeature/AsIdentifiableFeature.test.js b/src/interactions/TaskFeature/AsIdentifiableFeature.test.js
index 367391b8..d57b0eb2 100644
--- a/src/interactions/TaskFeature/AsIdentifiableFeature.test.js
+++ b/src/interactions/TaskFeature/AsIdentifiableFeature.test.js
@@ -57,3 +57,92 @@ describe('osmId', () => {
     expect(AsIdentifiableFeature(basicFeature).osmId()).toEqual('1042007773')
   })
 })
+
+describe('osmType', () => {
+  test("returns the OSM element type from the `osmid` field if it exists", () => {
+    basicFeature.osmid = 'node/123'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toEqual('node')
+
+    basicFeature.osmid = 'way/123'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toEqual('way')
+
+    basicFeature.osmid = 'relation/123'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toEqual('relation')
+  })
+
+  test("normalizes the OSM element type", () => {
+    basicFeature.osmid = 'n/123'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toEqual('node')
+
+    basicFeature.osmid = 'w/123'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toEqual('way')
+
+    basicFeature.osmid = 'r/123'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toEqual('relation')
+  })
+
+  test("falls back to the `type` field if no type in the id", () => {
+    basicFeature.osmid = '123'
+
+    basicFeature.type = 'node'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toEqual('node')
+
+    basicFeature.type = 'way'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toEqual('way')
+
+    basicFeature.type = 'relation'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toEqual('relation')
+  })
+
+  test("returns null if no element type can be found", () => {
+    basicFeature.osmid = '123'
+    expect(AsIdentifiableFeature(basicFeature).osmType()).toBeNull()
+  })
+})
+
+describe('normalizedTypeAndId', () => {
+  test("returns the type and id as a string", () => {
+    basicFeature.osmid = 'node/123'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('node 123')
+
+    basicFeature.osmid = 'way/123'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('way 123')
+
+    basicFeature.osmid = 'relation/123'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('relation 123')
+  })
+
+  test("normalizes the OSM element type", () => {
+    basicFeature.osmid = 'n/123'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('node 123')
+
+    basicFeature.osmid = 'w/123'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('way 123')
+
+    basicFeature.osmid = 'r/123'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('relation 123')
+  })
+
+  test("falls back to the `type` field if no type in the id", () => {
+    basicFeature.osmid = '123'
+
+    basicFeature.type = 'node'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('node 123')
+
+    basicFeature.type = 'way'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('way 123')
+
+    basicFeature.type = 'relation'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('relation 123')
+  })
+
+  test("returns just the id if no type is detected", () => {
+    basicFeature.osmid = '123'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toEqual('123')
+  })
+
+  test("returns null if no id is found", () => {
+    basicFeature.osmid = 'xyz'
+    expect(AsIdentifiableFeature(basicFeature).normalizedTypeAndId()).toBeNull()
+  })
+})