diff --git a/package-lock.json b/package-lock.json index f53d57ed..af976a81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.10.6", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/react-fontawesome": "^0.2.0", + "@googlemaps/markerclusterer": "^2.5.3", "@mui/icons-material": "^5.11.16", "@mui/lab": "^5.0.0-alpha.165", "@mui/material": "^5.12.0", @@ -2924,9 +2925,10 @@ } }, "node_modules/@googlemaps/markerclusterer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.3.2.tgz", - "integrity": "sha512-zb9OQP8XscZp2Npt1uQUYnGKu1miuq4DPP28JyDuFd6HV17HCEcjV9MtBi4muG/iVRXXvuHW9bRCnHbao9ITfw==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz", + "integrity": "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==", + "license": "Apache-2.0", "dependencies": { "fast-deep-equal": "^3.1.3", "supercluster": "^8.0.1" @@ -3850,6 +3852,16 @@ "react-dom": "^16.8 || ^17 || ^18" } }, + "node_modules/@react-google-maps/api/node_modules/@googlemaps/markerclusterer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.3.2.tgz", + "integrity": "sha512-zb9OQP8XscZp2Npt1uQUYnGKu1miuq4DPP28JyDuFd6HV17HCEcjV9MtBi4muG/iVRXXvuHW9bRCnHbao9ITfw==", + "license": "Apache-2.0", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "supercluster": "^8.0.1" + } + }, "node_modules/@react-google-maps/infobox": { "version": "2.19.2", "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.19.2.tgz", diff --git a/package.json b/package.json index 643b7d7d..cd1b85ed 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@emotion/styled": "^11.10.6", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/react-fontawesome": "^0.2.0", + "@googlemaps/markerclusterer": "^2.5.3", "@mui/icons-material": "^5.11.16", "@mui/lab": "^5.0.0-alpha.165", "@mui/material": "^5.12.0", diff --git a/src/components/Map/index.tsx b/src/components/Map/index.tsx index 221e65e3..ecec6fd1 100644 --- a/src/components/Map/index.tsx +++ b/src/components/Map/index.tsx @@ -1,6 +1,12 @@ -import * as React from 'react'; -import { GoogleMap, LoadScript, Marker } from '@react-google-maps/api'; -import { Box } from '@mui/material'; +import React from 'react'; +import { GoogleMap, LoadScript, InfoWindow } from '@react-google-maps/api'; +import { Box, Stack } from '@mui/material'; +import { + MarkerClusterer, + Cluster, + ClusterStats, + DefaultRenderer, +} from '@googlemaps/markerclusterer'; export interface UIMarker { id: string | number; @@ -62,7 +68,7 @@ const getMapBounds = (markers: UIMarker[]) => { return bounds; }; -// Fit map to its bounds after the api is loaded +// Fit map to its bounds after the API is loaded const onGoogleMapsApiLoad = (map: google.maps.Map, markers: UIMarker[]) => { if (!markers.length) { map.setCenter(DEFAULT_LATLNG); @@ -81,7 +87,7 @@ const onGoogleMapsApiLoad = (map: google.maps.Map, markers: UIMarker[]) => { // If we run `setZoom` right after `fitBounds` the map won't refresh. With this we first wait for the map to be idle (from fitBounds), and then set the zoom level. const listener = google.maps.event.addListenerOnce(map, 'idle', () => { - // Don't allow to zoom closer than the defailt detail zoom level on initial load. + // Don't allow to zoom closer than the default detail zoom level on initial load. const currentZoom = map.getZoom(); if (currentZoom != null && currentZoom > DETAIL_ZOOM_LEVEL) { map.setZoom(DETAIL_ZOOM_LEVEL); @@ -118,7 +124,7 @@ const BaseMap = ({ apiKey, mapClick, }: MapProps) => { - const markers = React.useMemo( + const markersData = React.useMemo( () => data .map((entry) => { @@ -139,12 +145,128 @@ const BaseMap = ({ return null; } - return marker; + return marker as UIMarker; }) - .filter((x) => !!x) as UIMarker[], + .filter((x): x is UIMarker => !!x), [data, dataMap, getIcon, onItemClick], ); + const mapRef = React.useRef(null); + const markerClustererRef = React.useRef(null); + const markersRef = React.useRef([]); + + const [infoWindowData, setInfoWindowData] = React.useState<{ + position: google.maps.LatLng | google.maps.LatLngLiteral; + content: React.ReactNode; + } | null>(null); + + const onMapLoad = React.useCallback( + (map: google.maps.Map) => { + onGoogleMapsApiLoad(map, markersData); + mapRef.current = map; + + const googleMarkers = markersData.map((marker) => { + const googleMarker = new google.maps.Marker({ + position: { lat: marker.lat, lng: marker.lng }, + title: marker.title, + icon: marker.icon, + }); + + if (marker.click) { + googleMarker.addListener('click', marker.click); + } + + return googleMarker; + }); + + markersRef.current = googleMarkers; + + markerClustererRef.current = new MarkerClusterer({ + markers: googleMarkers, + map, + renderer: { + render( + cluster: Cluster, + stats: ClusterStats, + map: google.maps.Map, + ): google.maps.Marker { + const defaultRenderer = new DefaultRenderer(); + const marker = defaultRenderer.render( + cluster, + stats, + map, + ) as google.maps.Marker; + marker.addListener('mouseover', () => { + setInfoWindowData({ + position: cluster.position, + content: ( + + {cluster.markers?.map((marker: any) => { + const onClick = markersData.find( + (m) => m.title === marker.title, + )?.click; + return ( + + + {marker.title} + + ); + })} + + ), + }); + }); + + return marker; + }, + }, + }); + }, + [markersData, onItemClick], + ); + + React.useEffect(() => { + if (mapRef.current && markerClustererRef.current) { + markerClustererRef.current.clearMarkers(); + + const newGoogleMarkers = markersData.map((marker) => { + const googleMarker = new google.maps.Marker({ + position: { lat: marker.lat, lng: marker.lng }, + title: marker.title, + icon: marker.icon, + }); + + if (marker.click) { + googleMarker.addListener('click', marker.click); + } + + return googleMarker; + }); + + markersRef.current = newGoogleMarkers; + + markerClustererRef.current.addMarkers(newGoogleMarkers); + } + }, [markersData]); + + React.useEffect(() => { + return () => { + if (markerClustererRef.current) { + markerClustererRef.current.clearMarkers(); + } + markersRef.current.forEach((marker) => marker.setMap(null)); + }; + }, []); + + if (!data.length || !markersData.length) { + return null; + } + return ( {apiKey && ( @@ -156,22 +278,17 @@ const BaseMap = ({ opacity: 1, }} options={defaultMapOptions} - onLoad={(map) => onGoogleMapsApiLoad(map, markers)} + onLoad={onMapLoad} onClick={mapClick} > - {markers.map((marker) => ( - 1} - onClick={marker.click} - title={marker.title} - icon={marker.icon} - /> - ))} + {infoWindowData && ( + setInfoWindowData(null)} + > + {infoWindowData.content} + + )} )}