diff --git a/src/components/EnhancedMap/TaskFeatureLayer/TaskFeatureLayer.js b/src/components/EnhancedMap/TaskFeatureLayer/TaskFeatureLayer.js index de6a2e164..ffd1dbdc2 100644 --- a/src/components/EnhancedMap/TaskFeatureLayer/TaskFeatureLayer.js +++ b/src/components/EnhancedMap/TaskFeatureLayer/TaskFeatureLayer.js @@ -1,12 +1,14 @@ -import { useState, useEffect } from 'react' +import React, { useState, useEffect } from 'react' import ReactDOM from 'react-dom' import { GeoJSON, useMap } from 'react-leaflet' import L from 'leaflet' import { injectIntl } from 'react-intl' import { featureCollection } from '@turf/helpers' import _isFunction from 'lodash/isFunction' +import _get from 'lodash/get' import _uniqueId from 'lodash/uniqueId' -import AsSimpleStyleableFeature from '../../../interactions/TaskFeature/AsSimpleStyleableFeature' +import AsSimpleStyleableFeature + from '../../../interactions/TaskFeature/AsSimpleStyleableFeature' import PropertyList from '../PropertyList/PropertyList' import resolveConfig from 'tailwindcss/resolveConfig' import tailwindConfig from '../../../tailwind.config.js' @@ -47,48 +49,94 @@ const TaskFeatureLayer = props => { const { features, mrLayerId, animator, externalInteractive } = props const layerLabel = props.intl.formatMessage(layerMessages.showTaskFeaturesLabel) + const pane = _get(props, 'leaflet.pane') useEffect(() => { - setLayer( + const newLayer = ( { - return L.marker(latLng) + return L.marker(latLng, {mrLayerLabel: layerLabel, mrLayerId: mrLayerId}) }} onEachFeature={(feature, layer) => { - const styleableFeature = _isFunction(feature.styleLeafletLayer) ? feature : AsSimpleStyleableFeature(feature) - - if (!externalInteractive) { - layer.bindPopup(() => propertyList(feature.properties)) - } else { - layer.on('click', ({ latlng }) => { - const popup = L.popup({ - offset: [1, -20], - maxHeight: 300, - }) - .setLatLng(latlng) - .setContent(propertyList(feature.properties)) + const styleableFeature = + _isFunction(feature.styleLeafletLayer) ? + feature : + AsSimpleStyleableFeature(feature) + + if (externalInteractive) { + layer.on('click', (e) => { + if (!layer._map) return; // Check if layer is still on the map - popup.openOn(map) - }) + L.popup({}, layer) + .setLatLng(e.latlng) + .setContent(propertyList(feature.properties)) + .openOn(map); - layer.on('mr-external-interaction:start-preview', () => { styleableFeature.pushLeafletLayerSimpleStyles( layer, - Object.assign(styleableFeature.markerSimplestyles(layer), HIGHLIGHT_SIMPLESTYLE), - 'mr-external-interaction:start-preview' - ) - }) - - layer.on('mr-external-interaction:end-preview', () => { - styleableFeature.popLeafletLayerSimpleStyles(layer, 'mr-external-interaction:start-preview') - }) + { + ...styleableFeature.markerSimplestyles(layer), + ...HIGHLIGHT_SIMPLESTYLE, + }, + 'mr-external-interaction:popup-open' + ); + + const previewHandler = () => { + if (layer._map) { + styleableFeature.pushLeafletLayerSimpleStyles( + layer, + { + ...styleableFeature.markerSimplestyles(layer), + ...HIGHLIGHT_SIMPLESTYLE, + }, + 'mr-external-interaction:popup-open' + ) + } + }; + + layer.on('mr-external-interaction:start-preview', previewHandler); + + const popupCloseHandler = () => { + if (layer._map) { + styleableFeature.popLeafletLayerSimpleStyles(layer, 'mr-external-interaction:popup-open'); + styleableFeature.popLeafletLayerSimpleStyles(layer, 'mr-external-interaction:start-preview'); + } + map.off('popupclose', popupCloseHandler); + layer.off('mr-external-interaction:start-preview', previewHandler); + }; + + map.on('popupclose', popupCloseHandler); + }); + + if(feature.geometry.type !== 'Point' && feature.geometry.type !== 'GeometryCollection'){ + layer.on('mouseover', () => { + if (!layer._map) return; // Check if layer is still on the map + + styleableFeature.pushLeafletLayerSimpleStyles( + layer, + { + ...styleableFeature.markerSimplestyles(layer), + ...HIGHLIGHT_SIMPLESTYLE, + }, + 'mr-external-interaction:start-preview' + ) + + const mouseoutHandler = () => { + if (layer._map) { + styleableFeature.popLeafletLayerSimpleStyles(layer, 'mr-external-interaction:start-preview') + } + layer.off('mouseout', mouseoutHandler); + }; + + layer.on('mouseout', mouseoutHandler); + }); + } } - // Animate features when added to map (if requested) if (animator) { const oldOnAdd = layer.onAdd layer.onAdd = map => { @@ -101,7 +149,9 @@ const TaskFeatureLayer = props => { }} /> ) - }, [features.length]) + + setLayer(newLayer) + }, [features, mrLayerId, pane, animator, externalInteractive, layerLabel, map]) return layer } diff --git a/src/components/TaskPane/TaskMap/Messages.js b/src/components/TaskPane/TaskMap/Messages.js new file mode 100644 index 000000000..a170e565d --- /dev/null +++ b/src/components/TaskPane/TaskMap/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/TaskPane/TaskMap/TaskMap.js b/src/components/TaskPane/TaskMap/TaskMap.js index 7b5b28bc7..98e0e5783 100644 --- a/src/components/TaskPane/TaskMap/TaskMap.js +++ b/src/components/TaskPane/TaskMap/TaskMap.js @@ -1,4 +1,5 @@ -import { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' +import ReactDOM from 'react-dom' import PropTypes from 'prop-types' import classNames from 'classnames' import { ZoomControl, LayerGroup, Pane, MapContainer, useMap, useMapEvents, AttributionControl } from 'react-leaflet' @@ -6,7 +7,6 @@ import { featureCollection } from '@turf/helpers' import { coordAll } from '@turf/meta' import { point } from '@turf/helpers' import _isObject from 'lodash/isObject' -import _uniqueId from 'lodash/uniqueId' import L from 'leaflet' import _get from 'lodash/get' import _isFinite from 'lodash/isFinite' @@ -18,6 +18,7 @@ import _compact from 'lodash/compact' import _flatten from 'lodash/flatten' import _isEmpty from 'lodash/isEmpty' import _clone from 'lodash/clone' +import _omit from 'lodash/omit' import { buildLayerSources, DEFAULT_OVERLAY_ORDER } from '../../../services/VisibleLayer/LayerSources' import DirectionalIndicationMarker @@ -53,6 +54,12 @@ import { supportedSimplestyles } from '../../../interactions/TaskFeature/AsSimpleStyleableFeature' import BusySpinner from '../../BusySpinner/BusySpinner' import './TaskMap.scss' +import booleanDisjoint from '@turf/boolean-disjoint' +import AsIdentifiableFeature from '../../../interactions/TaskFeature/AsIdentifiableFeature' +import messages from './Messages' +import { IntlProvider } from 'react-intl' +import PropertyList from '../../EnhancedMap/PropertyList/PropertyList' +import { orderedFeatureLayers, animateFeatures, getClickPolygon, isClickOnMarker } from './helperFunctions' const shortcutGroup = 'layers' @@ -63,7 +70,7 @@ const shortcutGroup = 'layers' * * @author [Neil Rotstan](https://github.com/nrotstan) */ -export const TaskMapContainer = (props) => { +export const TaskMapContent = (props) => { const map = useMap() const [showTaskFeatures, setShowTaskFeatures] = useState(true) const [showOSMData, setShowOSMData] = useState(false) @@ -115,7 +122,7 @@ export const TaskMapContainer = (props) => { }, }) - /** Process keyboard shortcuts for the layers */ + /** Process keyboard shortcuts for the layers */ const handleKeyboardShortcuts = event => { // Ignore if shortcut group is not active if (_isEmpty(props.activeKeyboardShortcuts[shortcutGroup])) { @@ -146,12 +153,112 @@ export const TaskMapContainer = (props) => { } } + const popupLayerSelectionList = (layers, latlng) => { + const contentElement = document.createElement('div') + ReactDOM.render( +
+

{props.intl.formatMessage(messages.layerSelectionHeader)}

+
    + {layers.map(([description, layerInfo], index) => { + return ( + + + + ); + })} +
+
, + contentElement + ); + + L.popup({ + closeOnEscapeKey: false, // Otherwise our links won't get a onMouseLeave event + }).setLatLng(latlng).setContent(contentElement).openOn(map) + } + + const handleMapClick = (e) => { + const clickBounds = getClickPolygon(e, map) + const candidateLayers = new Map() + map.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 && isClickOnMarker(clickBounds, featureLayer, map)) || + !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 ? ( + +
{layerDescription}
+
{featureName}
+
+ ) : layerDescription + + intraLayerMatches.push({ + mrLayerId: featureLayer.options.mrLayerId, + description: layerDescription, + label: layerLabel, + geometry: featureGeojson, + layer: featureLayer, + }) + } + } + }) + + if (intraLayerMatches.length > 0) { + orderedFeatureLayers(intraLayerMatches).forEach(match => { + candidateLayers.set(match.description, match) + }) + } + } + }) + + if (candidateLayers.size === 1) { + candidateLayers.values().next().value.layer.fire('mr-external-interaction', { + map: map, + latlng: e.latlng, + }) + } + else if (candidateLayers.size > 1) { + let layers = [...candidateLayers.entries()] + if (props.overlayOrder && props.overlayOrder.length > 0) { + layers = _sortBy(layers, layerEntry => { + const position = props.overlayOrder.indexOf(layerEntry[1].mrLayerId) + return position === -1 ? Number.MAX_SAFE_INTEGER : position + }) + } + popupLayerSelectionList(layers, e.latlng) + } + } + /** * Invoked by LayerToggle when the user wishes to toggle visibility of * task features on or off. */ const toggleTaskFeatureVisibility = () => { - setShowTaskFeatures(!showTaskFeatures) + setShowTaskFeatures(prevState => !prevState); } /** @@ -277,25 +384,27 @@ export const TaskMapContainer = (props) => { loadMapillaryIfNeeded(); loadOpenStreetCamIfNeeded(); generateDirectionalityMarkers(); + animator.setAnimationFunction(animateFeatures) + + map.on('click', handleMapClick) return () => { props.deactivateKeyboardShortcutGroup(shortcutGroup, handleKeyboardShortcuts); + animator.reset() + map.off('click', handleMapClick) }; }, []); useEffect(() => { - loadMapillaryIfNeeded(); - loadOpenStreetCamIfNeeded(); - }, [props]); - - useEffect(() => { + deactivateOSMDataLayer() generateDirectionalityMarkers(); - }, [props.task.geometries]); + }, [props.task.id, props.task.geometries]); useEffect(() => { - deactivateOSMDataLayer() - generateDirectionalityMarkers(); - }, [props.task.id]); + loadMapillaryIfNeeded(); + loadOpenStreetCamIfNeeded(); + animator.setAnimationFunction(animateFeatures) + }, [props]); useEffect(() => { if (features.length !== 0) { @@ -304,40 +413,9 @@ export const TaskMapContainer = (props) => { ); map.fitBounds(layerGroup.getBounds().pad(0.2)); } + map.closePopup() }, [props.taskBundle, props.taskId]); - const mapillaryImageMarkers = () => { - return { - id: "mapillary", - component: ( - setMapillaryViewerImage(imageKey)} - imageAlt="Mapillary" - /> - ), - } - } - - const openStreetCamImageMarkers = () => ({ - id: "openstreetcam", - component: ( - setOpenStreetCamViewerImage(imageKey)} - imageAlt="OpenStreetCam" - /> - ), - }) - const generateDirectionalityMarkers = () => { const markers = [] const allFeatures = features @@ -381,6 +459,38 @@ export const TaskMapContainer = (props) => { }) } + const mapillaryImageMarkers = () => { + return { + id: "mapillary", + component: ( + setMapillaryViewerImage(imageKey)} + imageAlt="Mapillary" + /> + ), + } + } + + const openStreetCamImageMarkers = () => ({ + id: "openstreetcam", + component: ( + setOpenStreetCamViewerImage(imageKey)} + imageAlt="OpenStreetCam" + /> + ), + }) + const applyStyling = taskFeatures => { // If the challenge has conditional styles, apply those const conditionalStyles = _get(props, 'challenge.taskStyles') @@ -395,6 +505,26 @@ export const TaskMapContainer = (props) => { return taskFeatures } + const sortOverlayLayers = (layers) => { + let overlayOrder = props.getUserAppSetting(props.user, 'mapOverlayOrder') + if (_isEmpty(overlayOrder)) { + overlayOrder = DEFAULT_OVERLAY_ORDER + } + + // Sort the overlays according to the user's preferences. We then reverse + // that order because the layer rendered on the map last will be on top + if (overlayOrder && overlayOrder.length > 0) { + return _sortBy(layers, layer => { + const position = overlayOrder.indexOf(layer.id) + return position === -1 ? Number.MAX_SAFE_INTEGER : position + }).reverse() + } + + return layers + } + + const maxZoom = _get(props.task, "parent.maxZoom", MAX_ZOOM) + const renderMapillaryViewer = () => { return ( { /> ) } - const maxZoom = _get(props.task, "parent.maxZoom", MAX_ZOOM) - const renderId = _uniqueId() - let overlayOrder = props.getUserAppSetting(props.user, 'mapOverlayOrder') - if (_isEmpty(overlayOrder)) { - overlayOrder = DEFAULT_OVERLAY_ORDER - } - animator.reset() - - if (!props.task || !_isObject(props.task.parent)) { - return + const taskFeatureLayer = useMemo(() => { + return { + id: "task-features", + component: ( + + ) } + }, [props.taskBundle, props.taskId]); - let overlayLayers = buildLayerSources( + const overlayLayers = () => { + let layers = buildLayerSources( props.visibleOverlays, _get(props, 'user.settings.customBasemaps'), (layerId, index, layerSource) => ({ id: layerId, @@ -426,30 +560,19 @@ export const TaskMapContainer = (props) => { ) if (showTaskFeatures) { - overlayLayers.push({ - id: "task-features", - component: ( - - ) - }) + layers.push(taskFeatureLayer); } if (props.showMapillaryLayer) { - overlayLayers.push(mapillaryImageMarkers()) + layers.push(mapillaryImageMarkers()) } if (props.showOpenStreetCamLayer) { - overlayLayers.push(openStreetCamImageMarkers()) + layers.push(openStreetCamImageMarkers()) } if (showOSMData && osmData) { - overlayLayers.push({ + layers.push({ id: "osm-data", component: ( { } if (showTaskFeatures && !_isEmpty(directionalityIndicators)) { - overlayLayers.push(directionalityIndicators) - } - - // Sort the overlays according to the user's preferences. We then reverse - // that order because the layer rendered on the map last will be on top - if (overlayOrder && overlayOrder.length > 0) { - overlayLayers = _sortBy(overlayLayers, layer => { - const position = overlayOrder.indexOf(layer.id) - return position === -1 ? Number.MAX_SAFE_INTEGER : position - }).reverse() + layers.push(directionalityIndicators) } - // Note: we need to also pass maxZoom to the tile layer (in addition to the - // map), or else leaflet won't autoscale if the zoom goes beyond the - // capabilities of the layer. + return sortOverlayLayers(layers) + } - return ( -
- - - - - {_map(overlayLayers, (layer, index) => ( - - {layer.component} - - ))} - - {mapillaryViewerImage && renderMapillaryViewer()} - - {openStreetCamViewerImage && - setOpenStreetCamViewerImage(null)} - /> - } -
- ) + if (!props.task || !_isObject(props.task.parent)) { + return } + return ( +
+ + + + + {overlayLayers().map((layer, index) => ( + + {layer.component} + + ))} + {mapillaryViewerImage && renderMapillaryViewer()} + {openStreetCamViewerImage && setOpenStreetCamViewerImage(null)} />} +
+ ) +} + const TaskMap = (props) => { const ResizeMap = () => { const map = useMap(); @@ -543,8 +646,8 @@ const TaskMap = (props) => { { > - + ); diff --git a/src/components/TaskPane/TaskMap/helperFunctions.js b/src/components/TaskPane/TaskMap/helperFunctions.js new file mode 100644 index 000000000..da5f240c2 --- /dev/null +++ b/src/components/TaskPane/TaskMap/helperFunctions.js @@ -0,0 +1,110 @@ +import booleanContains from '@turf/boolean-contains' +import booleanDisjoint from '@turf/boolean-disjoint' +import bboxPolygon from '@turf/bbox-polygon' +import { Point } from 'leaflet' +import _isArray from 'lodash/isArray' +import _filter from 'lodash/filter' +import _reduce from 'lodash/reduce' +import _sortBy from 'lodash/sortBy' + +const PIXEL_MARGIN = 10 + +export const animateFeatures = () => { + const paths = document.querySelectorAll('.task-map .leaflet-pane path.leaflet-interactive') + if (paths.length > 0) { + for (let path of paths) { + pathComplete(path).then(pathLength => { + path.style.strokeDasharray = `${pathLength} ${pathLength}` + path.style.strokeDashoffset = pathLength + + path.addEventListener("transitionend", () => { + path.style.strokeDasharray = 'none' + }) + + path.getBoundingClientRect() + path.style.transition = 'stroke-dashoffset 1s ease-in-out' + path.style.strokeDashoffset = '0' + path.style.opacity = '1' + }) + } + } + + const markers = document.querySelectorAll('.task-map .leaflet-marker-pane') + if (markers) { + for (let marker of markers) { + marker.classList.remove('animated') + setTimeout(() => marker.classList.add('animated'), 100) + } + } +} + +export const isClickOnMarker = (clickBounds, marker, map) => { + const icon = marker.getIcon() + const iconOptions = Object.assign({}, Object.getPrototypeOf(icon).options, icon.options) + const markerPoint = map.containerPointToLayerPoint( + map.latLngToContainerPoint(marker.getLatLng()) + ) + + if (!_isArray(iconOptions.iconAnchor) || !_isArray(iconOptions.iconSize)) { + return false + } + + const nw = map.layerPointToLatLng(new Point( + markerPoint.x - iconOptions.iconAnchor[0], + markerPoint.y - iconOptions.iconAnchor[1] + )) + const se = map.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(clickBounds, markerPolygon) +} + +export const getClickPolygon = (clickEvent, map) => { + const center = clickEvent.layerPoint + const nw = map.layerPointToLatLng(new Point(center.x - PIXEL_MARGIN, center.y - PIXEL_MARGIN)) + const se = map.layerPointToLatLng(new Point(center.x + PIXEL_MARGIN, center.y + PIXEL_MARGIN)) + return bboxPolygon([nw.lng, se.lat, se.lng, nw.lat]) +} + +export const pathComplete = (path, priorLength, subsequentCheck = false) => { + return new Promise(resolve => { + const currentLength = path.getTotalLength() + if (subsequentCheck && currentLength === priorLength) { + resolve(currentLength) + return + } + + setTimeout(() => { + pathComplete(path, currentLength, true).then(length => resolve(length)) + }, 100) + }) +} + +export const orderedFeatureLayers = (layers) => { + if (!layers || layers.length < 2) { + return layers + } + + const geometryOrder = ['Point', 'MultiPoint', 'LineString', 'MultiLineString'] + const orderedLayers = _sortBy( + _filter(layers, l => geometryOrder.indexOf(l.geometry.type) !== -1), + l => geometryOrder.indexOf(l.geometry.type) + ) + + 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) +}