Skip to content

Commit 4e195a3

Browse files
Andrea RosciJSReds
authored andcommitted
Implement map clustering with tooltips to handle overlapping markers
Change-type: minor
1 parent ec77ccb commit 4e195a3

File tree

3 files changed

+155
-25
lines changed

3 files changed

+155
-25
lines changed

package-lock.json

Lines changed: 15 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@emotion/styled": "^11.10.6",
1616
"@fortawesome/free-solid-svg-icons": "^6.5.1",
1717
"@fortawesome/react-fontawesome": "^0.2.0",
18+
"@googlemaps/markerclusterer": "^2.5.3",
1819
"@mui/icons-material": "^5.11.16",
1920
"@mui/lab": "^5.0.0-alpha.165",
2021
"@mui/material": "^5.12.0",

src/components/Map/index.tsx

Lines changed: 139 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import * as React from 'react';
2-
import { GoogleMap, LoadScript, Marker } from '@react-google-maps/api';
3-
import { Box } from '@mui/material';
1+
import React from 'react';
2+
import { GoogleMap, LoadScript, InfoWindow } from '@react-google-maps/api';
3+
import { Box, Stack } from '@mui/material';
4+
import {
5+
MarkerClusterer,
6+
Cluster,
7+
ClusterStats,
8+
DefaultRenderer,
9+
} from '@googlemaps/markerclusterer';
410

511
export interface UIMarker {
612
id: string | number;
@@ -62,7 +68,7 @@ const getMapBounds = (markers: UIMarker[]) => {
6268
return bounds;
6369
};
6470

65-
// Fit map to its bounds after the api is loaded
71+
// Fit map to its bounds after the API is loaded
6672
const onGoogleMapsApiLoad = (map: google.maps.Map, markers: UIMarker[]) => {
6773
if (!markers.length) {
6874
map.setCenter(DEFAULT_LATLNG);
@@ -81,7 +87,7 @@ const onGoogleMapsApiLoad = (map: google.maps.Map, markers: UIMarker[]) => {
8187

8288
// 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.
8389
const listener = google.maps.event.addListenerOnce(map, 'idle', () => {
84-
// Don't allow to zoom closer than the defailt detail zoom level on initial load.
90+
// Don't allow to zoom closer than the default detail zoom level on initial load.
8591
const currentZoom = map.getZoom();
8692
if (currentZoom != null && currentZoom > DETAIL_ZOOM_LEVEL) {
8793
map.setZoom(DETAIL_ZOOM_LEVEL);
@@ -118,7 +124,7 @@ const BaseMap = <T extends any>({
118124
apiKey,
119125
mapClick,
120126
}: MapProps<T>) => {
121-
const markers = React.useMemo(
127+
const markersData = React.useMemo(
122128
() =>
123129
data
124130
.map((entry) => {
@@ -139,12 +145,128 @@ const BaseMap = <T extends any>({
139145
return null;
140146
}
141147

142-
return marker;
148+
return marker as UIMarker;
143149
})
144-
.filter((x) => !!x) as UIMarker[],
150+
.filter((x): x is UIMarker => !!x),
145151
[data, dataMap, getIcon, onItemClick],
146152
);
147153

154+
const mapRef = React.useRef<google.maps.Map | null>(null);
155+
const markerClustererRef = React.useRef<MarkerClusterer | null>(null);
156+
const markersRef = React.useRef<google.maps.Marker[]>([]);
157+
158+
const [infoWindowData, setInfoWindowData] = React.useState<{
159+
position: google.maps.LatLng | google.maps.LatLngLiteral;
160+
content: React.ReactNode;
161+
} | null>(null);
162+
163+
const onMapLoad = React.useCallback(
164+
(map: google.maps.Map) => {
165+
onGoogleMapsApiLoad(map, markersData);
166+
mapRef.current = map;
167+
168+
const googleMarkers = markersData.map((marker) => {
169+
const googleMarker = new google.maps.Marker({
170+
position: { lat: marker.lat, lng: marker.lng },
171+
title: marker.title,
172+
icon: marker.icon,
173+
});
174+
175+
if (marker.click) {
176+
googleMarker.addListener('click', marker.click);
177+
}
178+
179+
return googleMarker;
180+
});
181+
182+
markersRef.current = googleMarkers;
183+
184+
markerClustererRef.current = new MarkerClusterer({
185+
markers: googleMarkers,
186+
map,
187+
renderer: {
188+
render(
189+
cluster: Cluster,
190+
stats: ClusterStats,
191+
map: google.maps.Map,
192+
): google.maps.Marker {
193+
const defaultRenderer = new DefaultRenderer();
194+
const marker = defaultRenderer.render(
195+
cluster,
196+
stats,
197+
map,
198+
) as google.maps.Marker;
199+
marker.addListener('mouseover', () => {
200+
setInfoWindowData({
201+
position: cluster.position,
202+
content: (
203+
<Box p="2">
204+
{cluster.markers?.map((marker: any) => {
205+
const onClick = markersData.find(
206+
(m) => m.title === marker.title,
207+
)?.click;
208+
return (
209+
<Stack
210+
direction="row"
211+
alignItems="center"
212+
sx={{ cursor: onClick ? 'pointer' : 'inherit' }}
213+
onClick={onClick}
214+
>
215+
<img src={marker.icon} />
216+
{marker.title}
217+
</Stack>
218+
);
219+
})}
220+
</Box>
221+
),
222+
});
223+
});
224+
225+
return marker;
226+
},
227+
},
228+
});
229+
},
230+
[markersData, onItemClick],
231+
);
232+
233+
React.useEffect(() => {
234+
if (mapRef.current && markerClustererRef.current) {
235+
markerClustererRef.current.clearMarkers();
236+
237+
const newGoogleMarkers = markersData.map((marker) => {
238+
const googleMarker = new google.maps.Marker({
239+
position: { lat: marker.lat, lng: marker.lng },
240+
title: marker.title,
241+
icon: marker.icon,
242+
});
243+
244+
if (marker.click) {
245+
googleMarker.addListener('click', marker.click);
246+
}
247+
248+
return googleMarker;
249+
});
250+
251+
markersRef.current = newGoogleMarkers;
252+
253+
markerClustererRef.current.addMarkers(newGoogleMarkers);
254+
}
255+
}, [markersData]);
256+
257+
React.useEffect(() => {
258+
return () => {
259+
if (markerClustererRef.current) {
260+
markerClustererRef.current.clearMarkers();
261+
}
262+
markersRef.current.forEach((marker) => marker.setMap(null));
263+
};
264+
}, []);
265+
266+
if (!data.length || !markersData.length) {
267+
return null;
268+
}
269+
148270
return (
149271
<Box height="100%" className={className}>
150272
{apiKey && (
@@ -156,22 +278,17 @@ const BaseMap = <T extends any>({
156278
opacity: 1,
157279
}}
158280
options={defaultMapOptions}
159-
onLoad={(map) => onGoogleMapsApiLoad(map, markers)}
281+
onLoad={onMapLoad}
160282
onClick={mapClick}
161283
>
162-
{markers.map((marker) => (
163-
<Marker
164-
key={marker.id}
165-
position={{
166-
lat: isNaN(marker.lat) ? 0 : marker.lat,
167-
lng: isNaN(marker.lng) ? 0 : marker.lng,
168-
}}
169-
clickable={markers.length > 1}
170-
onClick={marker.click}
171-
title={marker.title}
172-
icon={marker.icon}
173-
/>
174-
))}
284+
{infoWindowData && (
285+
<InfoWindow
286+
position={infoWindowData.position}
287+
onCloseClick={() => setInfoWindowData(null)}
288+
>
289+
{infoWindowData.content}
290+
</InfoWindow>
291+
)}
175292
</GoogleMap>
176293
</LoadScript>
177294
)}

0 commit comments

Comments
 (0)