Merged in feat/SW-1616-clustering (pull request #1330)

feat(SW-1616): Added clustering on destination country/city pages

* feat(SW-1616): Added clustering on destination country/city pages


Approved-by: Fredrik Thorsson
Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-02-14 06:31:01 +00:00
parent 7fac673fbc
commit f9a03052b1
13 changed files with 342 additions and 22 deletions

View File

@@ -61,10 +61,13 @@ export default function DynamicMap({
}
const mapOptions: MapProps = {
defaultCenter: markers[0].coordinates, // Default center will be overridden by the bounds
defaultCenter: markers[0]?.coordinates || {
lat: 59.3293,
lng: 18.0686,
}, // Default center will be overridden by the bounds
minZoom: 3,
maxZoom: 18,
defaultZoom: 8,
defaultZoom: 5,
disableDefaultUI: true,
clickableIcons: false,
gestureHandling: "greedy",

View File

@@ -0,0 +1,20 @@
.clusterMarker {
display: flex;
justify-content: center;
align-items: center;
width: 42px !important;
height: 42px !important;
background-color: var(--Base-Text-High-contrast);
border: 4px solid var(--Base-Surface-Primary-light-Normal);
color: var(--Base-Text-Inverted);
border-radius: var(--Corner-radius-Rounded);
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s;
}
.count {
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Subtitle-2-fontSize);
font-weight: var(--typography-Subtitle-2-fontWeight);
}

View File

@@ -0,0 +1,41 @@
"use client"
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import { useCallback } from "react"
import styles from "./clusterMarker.module.css"
interface ClusterMarkerProps {
position: google.maps.LatLngLiteral
size: number
sizeAsText: string
onMarkerClick?: (position: google.maps.LatLngLiteral) => void
}
export default function ClusterMarker({
position,
size,
sizeAsText,
onMarkerClick,
}: ClusterMarkerProps) {
const handleClick = useCallback(() => {
if (onMarkerClick) {
onMarkerClick(position)
}
}, [onMarkerClick, position])
return (
<AdvancedMarker
position={position}
zIndex={size}
onClick={handleClick}
className={styles.clusterMarker}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
>
<span className={styles.count}>{sizeAsText}</span>
</AdvancedMarker>
)
}

View File

@@ -0,0 +1,45 @@
"use client"
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
useAdvancedMarkerRef,
} from "@vis.gl/react-google-maps"
import { useCallback } from "react"
import HotelMarkerByType from "@/components/Maps/Markers"
import type { MarkerProperties } from "@/types/components/maps/destinationMarkers"
interface MarkerProps {
position: google.maps.LatLngLiteral
properties: MarkerProperties
onMarkerClick?: (
position: google.maps.LatLngLiteral,
properties: MarkerProperties
) => void
}
export default function Marker({
position,
properties,
onMarkerClick,
}: MarkerProps) {
const [markerRef, marker] = useAdvancedMarkerRef()
const handleClick = useCallback(() => {
if (onMarkerClick) {
onMarkerClick(position, properties)
}
}, [onMarkerClick, position, properties])
return (
<AdvancedMarker
ref={markerRef}
position={position}
onClick={handleClick}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
>
<HotelMarkerByType hotelId={properties.id} hotelType={properties.type} />
</AdvancedMarker>
)
}

View File

@@ -1,29 +1,75 @@
"use client"
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import { useMap } from "@vis.gl/react-google-maps"
import HotelMarkerByType from "@/components/Maps/Markers"
import { useSupercluster } from "@/hooks/maps/use-supercluster"
import styles from "./mapContent.module.css"
import ClusterMarker from "./ClusterMarker"
import Marker from "./Marker"
import type { DestinationMarker } from "@/types/components/maps/destinationMarkers"
import type { ClusterProperties } from "supercluster"
import type {
MarkerGeojson,
MarkerProperties,
} from "@/types/components/maps/destinationMarkers"
interface MapContentProps {
markers: DestinationMarker[]
geojson: MarkerGeojson
}
export default function MapContent({ markers }: MapContentProps) {
return markers.map((item) => (
<AdvancedMarker
key={item.name}
className={styles.advancedMarker}
position={item.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.BOTTOM_CENTER}
>
<HotelMarkerByType hotelId={item.id} hotelType={item.type} />
</AdvancedMarker>
))
// Important this is outside the component to avoid re-creating the object on each render
// which is making the useSupercluster hook fail
const CLUSTER_OPTIONS = {
extent: 256,
radius: 80,
maxZoom: 14,
}
export default function MapContent({ geojson }: MapContentProps) {
const map = useMap()
const { clusters } = useSupercluster<MarkerProperties>(
geojson,
CLUSTER_OPTIONS
)
function handleClusterClick(position: google.maps.LatLngLiteral) {
const currentZoom = map && map.getZoom()
if (currentZoom) {
map.panTo(position)
map.setZoom(currentZoom + 2)
}
}
function handleMarkerClick(
position: google.maps.LatLngLiteral,
properties: MarkerProperties
) {
console.log("Marker clicked", position, properties)
}
return clusters.map((feature) => {
const [lng, lat] = feature.geometry.coordinates
const clusterProperties = feature.properties as ClusterProperties
const markerProperties = feature.properties as MarkerProperties
const isCluster = clusterProperties.cluster
return isCluster ? (
<ClusterMarker
key={feature.id}
position={{ lat, lng }}
size={clusterProperties.point_count}
sizeAsText={String(clusterProperties.point_count_abbreviated)}
onMarkerClick={handleClusterClick}
/>
) : (
<Marker
key={feature.id}
position={{ lat, lng }}
properties={markerProperties}
onMarkerClick={handleMarkerClick}
/>
)
})
}

View File

@@ -16,6 +16,7 @@ import { debounce } from "@/utils/debounce"
import DynamicMap from "./DynamicMap"
import MapContent from "./MapContent"
import MapProvider from "./MapProvider"
import { mapMarkerDataToGeoJson } from "./utils"
import styles from "./map.module.css"
@@ -57,6 +58,8 @@ export default function Map({
}))
.filter((item): item is DestinationMarker => !!item.coordinates)
const geoJson = mapMarkerDataToGeoJson(markers)
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
const handleMapHeight = useCallback(() => {
const topPosition = rootDiv.current?.getBoundingClientRect().top ?? 0
@@ -119,7 +122,7 @@ export default function Map({
>
<aside className={styles.sidebar}>{children}</aside>
<DynamicMap markers={markers} mapId={mapId} onClose={handleClose}>
<MapContent markers={markers} />
<MapContent geojson={geoJson} />
</DynamicMap>
</Dialog>
</Modal>

View File

@@ -0,0 +1,28 @@
import type {
DestinationMarker,
MarkerFeature,
MarkerGeojson,
} from "@/types/components/maps/destinationMarkers"
export function mapMarkerDataToGeoJson(markers: DestinationMarker[]) {
const features = markers.map<MarkerFeature>(
({ coordinates, ...properties }) => {
return {
type: "Feature",
id: properties.id,
geometry: {
type: "Point",
coordinates: [coordinates.lng, coordinates.lat],
},
properties,
}
}
)
const geoJson: MarkerGeojson = {
type: "FeatureCollection",
features,
}
return geoJson
}

View File

@@ -0,0 +1,53 @@
// Hook to use supercluster with @visgl/react-google-map.
// Implemented according to https://github.com/visgl/react-google-maps/tree/main/examples/custom-marker-clustering
import { useMap } from "@vis.gl/react-google-maps"
import { useEffect, useState } from "react"
import type { BBox } from "geojson"
type MapViewportOptions = {
padding?: number
}
export function useMapViewport({ padding = 0 }: MapViewportOptions = {}) {
const map = useMap()
const [bbox, setBbox] = useState<BBox>([-180, -90, 180, 90])
const [zoom, setZoom] = useState(0)
// observe the map to get current bounds
useEffect(() => {
if (!map) return
const listener = map.addListener("bounds_changed", () => {
const bounds = map.getBounds()
const zoom = map.getZoom()
const projection = map.getProjection()
if (!bounds || !zoom || !projection) return
const sw = bounds.getSouthWest()
const ne = bounds.getNorthEast()
const paddingDegrees = degreesPerPixel(zoom) * padding
const n = Math.min(90, ne.lat() + paddingDegrees)
const s = Math.max(-90, sw.lat() - paddingDegrees)
const w = sw.lng() - paddingDegrees
const e = ne.lng() + paddingDegrees
setBbox([w, s, e, n])
setZoom(zoom)
})
return () => listener.remove()
}, [map, padding])
return { bbox, zoom }
}
function degreesPerPixel(zoomLevel: number) {
// 360° divided by the number of pixels at the zoom-level
return 360 / (Math.pow(2, zoomLevel) * 256)
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useMemo, useReducer } from "react"
import Supercluster, {type ClusterProperties } from "supercluster"
import { useMapViewport } from "./use-map-viewport"
import type { FeatureCollection, GeoJsonProperties, Point } from "geojson"
export function useSupercluster<T extends GeoJsonProperties>(
geojson: FeatureCollection<Point, T>,
superclusterOptions: Supercluster.Options<T, ClusterProperties>
) {
// create the clusterer and keep it
const clusterer = useMemo(() => {
return new Supercluster(superclusterOptions)
}, [superclusterOptions])
// version-number for the data loaded into the clusterer
// (this is needed to trigger updating the clusters when data was changed)
const [version, dataWasUpdated] = useReducer((x: number) => x + 1, 0)
// when data changes, load it into the clusterer
useEffect(() => {
clusterer.load(geojson.features)
dataWasUpdated()
}, [clusterer, geojson])
// get bounding-box and zoomlevel from the map
const { bbox, zoom } = useMapViewport({ padding: 100 })
// retrieve the clusters within the current viewport
const clusters = useMemo(() => {
// don't try to read clusters before data was loaded into the clusterer (version===0),
// otherwise getClusters will crash
if (!clusterer || version === 0) return []
return clusterer.getClusters(bbox, zoom)
}, [version, clusterer, bbox, zoom])
return {
clusters,
}
}

29
package-lock.json generated
View File

@@ -30,6 +30,8 @@
"@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-rc.467",
"@tsparticles/confetti": "^3.5.0",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"@vercel/otel": "^1.9.1",
"@vis.gl/react-google-maps": "^1.2.0",
"class-variance-authority": "^0.7.0",
@@ -61,6 +63,7 @@
"react-to-print": "^3.0.2",
"server-only": "^0.0.1",
"sonner": "^1.7.0",
"supercluster": "^8.0.1",
"superjson": "^2.2.1",
"usehooks-ts": "3.1.0",
"uuid": "^11.0.5",
@@ -8368,6 +8371,11 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
},
"node_modules/@types/google.maps": {
"version": "3.58.0",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.0.tgz",
@@ -8609,6 +8617,14 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"dev": true
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/tedious": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
@@ -16142,6 +16158,11 @@
"node": ">=4.0"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="
},
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@@ -21116,6 +21137,14 @@
"node": ">= 8"
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/superjson": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.1.tgz",

View File

@@ -45,6 +45,8 @@
"@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-rc.467",
"@tsparticles/confetti": "^3.5.0",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"@vercel/otel": "^1.9.1",
"@vis.gl/react-google-maps": "^1.2.0",
"class-variance-authority": "^0.7.0",
@@ -76,6 +78,7 @@
"react-to-print": "^3.0.2",
"server-only": "^0.0.1",
"sonner": "^1.7.0",
"supercluster": "^8.0.1",
"superjson": "^2.2.1",
"usehooks-ts": "3.1.0",
"uuid": "^11.0.5",

View File

@@ -1,6 +1,13 @@
import type { FeatureCollection, Point } from "geojson"
export interface DestinationMarker {
id: string
type: string
name: string
coordinates: google.maps.LatLngLiteral
}
export type MarkerProperties = Omit<DestinationMarker, "coordinates">
export type MarkerGeojson = FeatureCollection<Point, MarkerProperties>
export type MarkerFeature = MarkerGeojson["features"][number]