Skip to content

Commit

Permalink
Implement clustering of venues (#2)
Browse files Browse the repository at this point in the history
* Added supercluster lib, initializing variables for handling clustering

* Setting mapRef to the loaded instance of GoogleMap

* Extract zoom and center to state variables, added functions to handle bounds and zoom changes

* Slowly adding clustering logic

* Clusters being calculated, yay

* Render clusters

* Dynamically change supercluster radius

* Merge remote

* Delete route on closing InfoWindow
  • Loading branch information
ikorotkaya authored Oct 4, 2023
1 parent 28842f2 commit 8a1808b
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 48 deletions.
19 changes: 19 additions & 0 deletions design_notes/6_clustering_venues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Clustering venues

Venues are dynamically clustered on the map with a cluster radius depending on the zoom level.

To calculate clusters the [supercluster](https://github.com/mapbox/supercluster) packaged is used.

Clustering mechanism is inspired by this blog post: https://bitsbydenis.medium.com/react-google-maps-marker-clustering-34219f22fed8

Here's a sample app behind this post: https://github.com/deni1688/carsharing-map/blob/master/src/App.tsx

## Misc

### Missing GeoJSON type

https://github.com/mapbox/supercluster#typescript

~~~
npm install @types/supercluster --save-dev
~~~
3 changes: 3 additions & 0 deletions design_notes/7_refs_in_react.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 7. Read about refs in React

https://react.dev/learn/referencing-values-with-refs
27 changes: 17 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@googlemaps/markerclusterer": "^2.5.0",
"@react-google-maps/api": "^2.19.2",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
Expand All @@ -15,6 +14,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"supercluster": "^8.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"zustand": "^4.4.1"
Expand Down Expand Up @@ -46,6 +46,7 @@
},
"devDependencies": {
"@types/google.maps": "^3.54.3",
"@types/supercluster": "^7.1.1",
"tailwindcss": "^3.3.3"
}
}
183 changes: 146 additions & 37 deletions src/components/GoogleMap.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,57 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useRef } from "react";
import {
GoogleMap,
LoadScript,
MarkerF,
InfoWindowF,
DirectionsRenderer
DirectionsRenderer
} from "@react-google-maps/api";
import Supercluster, { ClusterFeature, PointFeature } from "supercluster";

import carMarker from "images/car-marker.png";
import pinIcon from "images/pin-icon.svg";
import clusterPinIcon from "images/cluster-pin-icon.svg";
import pinActiveIcon from "images/pin-active-icon.svg";

import { GoogleMapsComponentProps } from "types";
import { Venue, GoogleMapsComponentProps } from "types";
import VenuePopUp from "./VenuePopUp";

import { useStore } from "store";

const googleMapOptions = {
streetViewControl: false,
mapTypeControl: false,
fullscreenControl: false,
maxZoom: 16,
minZoom: 6
};

const MIN_CLUSTER_POINTS = 3;

type Map = google.maps.Map & { zoom: number };

export default function GoogleMapsComponent({
userLocation,
onMarkerDragEnd,
venues,
}: GoogleMapsComponentProps) {

// const [center, setCenter] = useState<LatLng>(userLocation);
const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
const [mapHeight, setMapHeight] = useState(0);

const highlightedVenueId = useStore((state) => state.highlightedVenueId);
const highlightVenue = useStore((state) => state.setHighlightedVenueId)
const selectedVenueId = useStore((state) => state.selectedVenueId);
const selectVenue = useStore((state) => state.setSelectedVenueId);

const [directions, setDirections] = useState<google.maps.DirectionsResult | null>(null);

const mapRef = useRef<Map>();
const [zoom, setZoom] = useState<number>(12);
const [bounds, setBounds] = useState<GeoJSON.BBox>([0, 0, 0, 0]);
const [clusters, setClusters] = useState<ClusterFeature<any>[]>([]);
const [supercluster, setSupercluster] = useState<Supercluster<any>>(new Supercluster({ radius: 75, maxZoom: googleMapOptions.maxZoom, minPoints: MIN_CLUSTER_POINTS }));

const updateMapHeight = () => {
const header = document.getElementById("header");
const footer = document.getElementById("footer");
Expand Down Expand Up @@ -58,11 +80,56 @@ export default function GoogleMapsComponent({
height: mapHeight + "px",
};

const handleGoogleMapsLoad = () => {
const handleGoogleMapsLoad = (map: google.maps.Map) => {
setIsGoogleMapsLoaded(true);
};
mapRef.current = map as Map;
};

const [directions, setDirections] = useState<google.maps.DirectionsResult | null>(null);
const handleClusterClick = ({ id, lat, lng }: { id: number, lat: number, lng: number }) => {
const expansionZoom = Math.min(supercluster.getClusterExpansionZoom(id), 20);
mapRef.current?.setZoom(expansionZoom);
mapRef.current?.panTo({ lat, lng });
}

const handleInfoWindowCloseClick = () => {
selectVenue(null);
setDirections(null);
}

const getLabel = (pointCount: number): google.maps.MarkerLabel => {
return {
text: pointCount.toString(),
color: "#FFF",
fontWeight: "bold"
};
}

const handleBoundsChanged= () => {
if (mapRef.current) {
const bounds = mapRef.current.getBounds()?.toJSON();

setBounds([
bounds?.west || 0, // eslint-disable-line @typescript-eslint/strict-boolean-expressions
bounds?.south || 0, // eslint-disable-line @typescript-eslint/strict-boolean-expressions
bounds?.east || 0, // eslint-disable-line @typescript-eslint/strict-boolean-expressions
bounds?.north || 0 // eslint-disable-line @typescript-eslint/strict-boolean-expressions
]);
}
}

const handleZoomChanged = () => {
if (mapRef.current) {
setZoom(mapRef.current?.zoom);
}
}

const formatDataToGeoJsonPoints = (venues: Venue[]): GeoJSON.Feature<GeoJSON.Point>[] => {
return venues.map((venue) => ({
type: "Feature",
geometry: { type: "Point", coordinates: [venue.coordinates.lng, venue.coordinates.lat] },
properties: { cluster: false, venue }
}));
}

useEffect(() => {
if (selectedVenueId !== null) {
Expand All @@ -89,6 +156,24 @@ export default function GoogleMapsComponent({
}
}, [userLocation, selectedVenueId, venues]);

useEffect(() => {
const radius = 100 * googleMapOptions.maxZoom / zoom;

setSupercluster(new Supercluster({
radius: radius,
maxZoom: googleMapOptions.maxZoom,
minPoints: MIN_CLUSTER_POINTS
}));
}, [zoom]);

useEffect(() => {
if (mapRef.current) {
supercluster.load(formatDataToGeoJsonPoints(venues) as PointFeature<GeoJSON.Feature<GeoJSON.Point>>[]);

setClusters(supercluster.getClusters(bounds, zoom));
}
}, [venues, bounds, zoom]);

const routeDistance = directions?.routes[0]?.legs[0]?.distance?.text;
const routeDuration = directions?.routes[0]?.legs[0]?.duration?.text;

Expand All @@ -99,9 +184,15 @@ export default function GoogleMapsComponent({
googleMapsApiKey={process.env.REACT_APP_GOOGLE_MAPS_API_KEY !== undefined
? process.env.REACT_APP_GOOGLE_MAPS_API_KEY
: ""}
onLoad={handleGoogleMapsLoad}
>
<GoogleMap mapContainerStyle={containerStyle} center={userLocation} zoom={12}>
<GoogleMap
onLoad={handleGoogleMapsLoad}
onBoundsChanged={handleBoundsChanged}
onZoomChanged={handleZoomChanged}
mapContainerStyle={containerStyle}
center={userLocation}
options={googleMapOptions}
zoom={zoom}>
{directions && (
<DirectionsRenderer
directions={directions}
Expand Down Expand Up @@ -132,35 +223,53 @@ export default function GoogleMapsComponent({
/>
)}
{isGoogleMapsLoaded &&
venues.map((venue, index) => (
<MarkerF
key={index}
position={venue.coordinates}
title={venue.name}
onClick={() => selectVenue(venue.id)}
onMouseOver={() => highlightVenue(venue.id)}
onMouseOut={() => highlightVenue(null)}
options={{
icon: {
url:
highlightedVenueId === venue.id || selectedVenueId === venue.id
? pinActiveIcon
: pinIcon,
scaledSize: new window.google.maps.Size(32, 48),
},
}}
>
{selectedVenueId === venue.id && (
<InfoWindowF
position={venue.coordinates}
onCloseClick={() => selectVenue(null)}
options={{ disableAutoPan: true }}
clusters.map(({ id, geometry, properties }) => {
const [lng, lat] = geometry.coordinates;
const { cluster, point_count } = properties;

return cluster // eslint-disable-line @typescript-eslint/strict-boolean-expressions
? <MarkerF
key={`cluster-${id}`}
onClick={() => handleClusterClick({ id: id as number, lat, lng })}
position={{ lat, lng }}
options={{
icon: {
url: clusterPinIcon,
scaledSize: new window.google.maps.Size(48, 48)
},
}}
label={getLabel(point_count)} />
: <MarkerF
key={id}
position={properties.venue.coordinates}
title={properties.venue.name}
onClick={() => selectVenue(properties.venue.id)}
onMouseOver={() => highlightVenue(properties.venue.id)}
onMouseOut={() => highlightVenue(null)}
options={{
icon: {
url:
highlightedVenueId === properties.venue.id || selectedVenueId === properties.venue.id
? pinActiveIcon
: pinIcon,
scaledSize: new window.google.maps.Size(32, 48),
},
}}
>
<VenuePopUp venue={venue} routeDistance={routeDistance} routeDuration={routeDuration} />
</InfoWindowF>
)}
</MarkerF>
))}
{selectedVenueId === properties.venue.id && (
<InfoWindowF
position={properties.venue.coordinates}
onCloseClick={() => handleInfoWindowCloseClick()}
options={{
disableAutoPan: false
}}
>
<VenuePopUp venue={properties.venue} routeDistance={routeDistance} routeDuration={routeDuration} />
</InfoWindowF>
)}
</MarkerF>;
})
}
</GoogleMap>
</LoadScript>
</div>
Expand Down
10 changes: 10 additions & 0 deletions src/images/cluster-pin-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 8a1808b

Please sign in to comment.