diff --git a/client/src/components/Leaflet.js b/client/src/components/Leaflet.js index 5e4c23c802..5070aaf080 100644 --- a/client/src/components/Leaflet.js +++ b/client/src/components/Leaflet.js @@ -1,4 +1,5 @@ -import { Control, CRS, Icon, Map, Marker, TileLayer } from "leaflet" +import { Control, CRS, DivIcon, Icon, Map, Marker, TileLayer } from "leaflet" +import AppContext from "components/AppContext" import "leaflet-defaulticon-compatibility" import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css" import { @@ -14,8 +15,12 @@ import "leaflet.markercluster/dist/MarkerCluster.css" import "leaflet.markercluster/dist/MarkerCluster.Default.css" import "leaflet/dist/leaflet.css" import { Location } from "models" +import GeoLocation from "pages/locations/GeoLocation" import PropTypes from "prop-types" import React, { useCallback, useEffect, useRef, useState } from "react" +import ReactDOM from "react-dom" +import MARKER_FLAG_BLUE_2X from "resources/leaflet/marker-flag-blue-2x.png" +import MARKER_FLAG_BLUE from "resources/leaflet/marker-flag-blue.png" import MARKER_ICON_2X from "resources/leaflet/marker-icon-2x.png" import MARKER_ICON from "resources/leaflet/marker-icon.png" import MARKER_SHADOW from "resources/leaflet/marker-shadow.png" @@ -64,6 +69,14 @@ const icon = new Icon({ shadowSize: [41, 41] }) +const locationIcon = new Icon({ + iconUrl: MARKER_FLAG_BLUE, + iconRetinaUrl: MARKER_FLAG_BLUE_2X, + iconSize: [64, 64], + iconAnchor: [18, 62], + popupAnchor: [2, -58] +}) + const addLayers = (map, layerControl) => { let defaultLayer = null Settings.imagery.baseLayers.forEach(layerConfig => { @@ -85,11 +98,12 @@ const addLayers = (map, layerControl) => { } } -const Leaflet = ({ +const BaseLeaflet = ({ width, height, marginBottom, markers, + allLocations, mapId: initialMapId, onMapClick }) => { @@ -108,6 +122,7 @@ const Leaflet = ({ const [map, setMap] = useState(null) const [markerLayer, setMarkerLayer] = useState(null) + const [layerControl, setLayerControl] = useState(null) const [doInitializeMarkerLayer, setDoInitializeMarkerLayer] = useState(false) const prevMarkersRef = useRef() @@ -121,7 +136,8 @@ const Leaflet = ({ icon: icon, draggable: m.draggable || false, autoPan: m.autoPan || false, - id: m.id + id: m.id, + zIndexOffset: 1000 }) if (m.name) { marker.bindPopup(m.name) @@ -166,6 +182,7 @@ const Leaflet = ({ const layerControl = new Control.Layers({}, {}, { collapsed: false }) layerControl.addTo(newMap) addLayers(newMap, layerControl) + setLayerControl(layerControl) setMap(newMap) @@ -238,20 +255,82 @@ const Leaflet = ({ widthPropUnchanged ]) + useEffect(() => { + if (!map || !layerControl || !allLocations?.length) { + return + } + const allMarkers = allLocations + .filter(loc => Location.hasCoordinates(loc)) + .map(location => { + const popupContent = document.createElement("div") + popupContent.setAttribute("style", "width: 300px;text-align: center") + + return new Marker([location.lat, location.lng], { + icon: locationIcon, + draggable: false, + autoPan: false, + id: location.uuid + }) + .bindTooltip(location.name, { + direction: "top", + permanent: true, + offset: [0, -58] + }) + .bindPopup(popupContent) + .on("popupopen", e => { + // TODO LinkTo component will be utilized here to provide routing + ReactDOM.render( + <> + {location.name} @{" "} + + , + e.popup.getContent() + ) + }) + }) + + const locationsLayer = new MarkerClusterGroup({ + iconCreateFunction: function(cluster) { + return new DivIcon({ + className: "all-locations-marker-cluster-icon-container", + html: ` + +
${cluster.getChildCount()}
+ ` + }) + } + }).addLayers(allMarkers) + + layerControl.addOverlay(locationsLayer, "All Locations") + locationsLayer.addTo(map) // make "All Locations" selected by default + + return () => { + layerControl.removeLayer(locationsLayer) + map.removeLayer(locationsLayer) + } + }, [map, layerControl, allLocations]) + return
} -Leaflet.propTypes = { +BaseLeaflet.propTypes = { width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), marginBottom: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), markers: PropTypes.array, + allLocations: PropTypes.arrayOf(PropTypes.object).isRequired, mapId: PropTypes.string, // pass this when you have more than one map on a page onMapClick: PropTypes.func } -Leaflet.defaultProps = { +BaseLeaflet.defaultProps = { width: "100%", height: "500px", marginBottom: "18px" } +const Leaflet = props => ( + + {context => } + +) + export default Leaflet diff --git a/client/src/index.css b/client/src/index.css index eb748b3cba..b308b4efbf 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1098,3 +1098,36 @@ header.searchPagination { height: 100% !important; } } + +.all-locations-marker-cluster-icon-container { + border: 1px solid #3272cb; + position: relative !important; + border-radius: 6px; + background: white; +} + +.all-locations-marker-cluster-icon-container:hover { + z-index: 9999 !important; +} + +.all-locations-marker-cluster-icon-container .alm-cluster-icon { + transform-origin: bottom center; + transform: translate(-14px, -54px); + /* Prevents hover on parent container. If the mouse is on the image, parent container is not hovered while + other childs and parent itself remains hoverable. Image is much larger than the container and it overflows parent. + If hover is not prevented on the image then parent receives "z-index: 9999" which makes any marker beneath unclickable. */ + pointer-events: none; +} + +.all-locations-marker-cluster-icon-container .alm-cluster-text { + width: 34px; + height: 26px; + position: absolute; + bottom: 0; + left: 0; + transform: translate(3px, -36px); + font-size: 14px; + padding: 3px 0 0 5px; + color: white; + font-weight: bold; +} diff --git a/client/src/pages/App.js b/client/src/pages/App.js index fc969cdedf..8e50d72d16 100644 --- a/client/src/pages/App.js +++ b/client/src/pages/App.js @@ -124,6 +124,15 @@ const GQL_GET_APP_DATA = gql` shortName } } + + locationList(query: { pageSize: 0 }) { + list { + uuid + name + lat + lng + } + } } ` @@ -161,7 +170,8 @@ const App = ({ pageDispatchers, pageProps }) => { appSettings: appState.settings, currentUser: appState.currentUser, loadAppData: refetch, - notifications: appState.notifications + notifications: appState.notifications, + allLocations: appState.allLocations }} > { const currentUser = new Person(data.me) const notifications = getNotifications(currentUser) + const allLocations = data?.locationList?.list || [] + return { currentUser, settings, advisorOrganizations, principalOrganizations, - notifications + notifications, + allLocations } } } diff --git a/client/src/pages/locations/Form.js b/client/src/pages/locations/Form.js index 12b33d8629..73dc0940a3 100644 --- a/client/src/pages/locations/Form.js +++ b/client/src/pages/locations/Form.js @@ -33,7 +33,7 @@ const GQL_UPDATE_LOCATION = gql` ` const LocationForm = ({ edit, title, initialValues }) => { - const { currentUser } = useContext(AppContext) + const { currentUser, loadAppData } = useContext(AppContext) const history = useHistory() const [error, setError] = useState(null) const canEditName = @@ -245,6 +245,8 @@ const LocationForm = ({ edit, title, initialValues }) => { ? response[operation].uuid : initialValues.uuid }) + // After successful submit, reload all locations data + loadAppData() // After successful submit, reset the form in order to make sure the dirty // prop is also reset (otherwise we would get a blocking navigation warning) form.resetForm() diff --git a/client/src/resources/leaflet/marker-flag-blue-2x.png b/client/src/resources/leaflet/marker-flag-blue-2x.png new file mode 100644 index 0000000000..f6355df79a Binary files /dev/null and b/client/src/resources/leaflet/marker-flag-blue-2x.png differ diff --git a/client/src/resources/leaflet/marker-flag-blue.png b/client/src/resources/leaflet/marker-flag-blue.png new file mode 100644 index 0000000000..6ed3e1f6ac Binary files /dev/null and b/client/src/resources/leaflet/marker-flag-blue.png differ