diff --git a/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx b/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx
index 711b63b03..5aecfef20 100644
--- a/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx
+++ b/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx
@@ -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",
diff --git a/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/clusterMarker.module.css b/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/clusterMarker.module.css
new file mode 100644
index 000000000..7271a5a8d
--- /dev/null
+++ b/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/clusterMarker.module.css
@@ -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);
+}
diff --git a/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/index.tsx b/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/index.tsx
new file mode 100644
index 000000000..ae114f87c
--- /dev/null
+++ b/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/index.tsx
@@ -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 (
+
+ {sizeAsText}
+
+ )
+}
diff --git a/components/ContentType/DestinationPage/Map/MapContent/Marker/index.tsx b/components/ContentType/DestinationPage/Map/MapContent/Marker/index.tsx
new file mode 100644
index 000000000..e4caac171
--- /dev/null
+++ b/components/ContentType/DestinationPage/Map/MapContent/Marker/index.tsx
@@ -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 (
+
+
+
+ )
+}
diff --git a/components/ContentType/DestinationPage/Map/MapContent/index.tsx b/components/ContentType/DestinationPage/Map/MapContent/index.tsx
index e867565c1..f56702ad1 100644
--- a/components/ContentType/DestinationPage/Map/MapContent/index.tsx
+++ b/components/ContentType/DestinationPage/Map/MapContent/index.tsx
@@ -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) => (
-
-
-
- ))
+// 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(
+ 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 ? (
+
+ ) : (
+
+ )
+ })
}
diff --git a/components/ContentType/DestinationPage/Map/MapContent/mapContent.module.css b/components/ContentType/DestinationPage/Map/MapContent/mapContent.module.css
deleted file mode 100644
index e69de29bb..000000000
diff --git a/components/ContentType/DestinationPage/Map/index.tsx b/components/ContentType/DestinationPage/Map/index.tsx
index f8fe57641..08a6c860f 100644
--- a/components/ContentType/DestinationPage/Map/index.tsx
+++ b/components/ContentType/DestinationPage/Map/index.tsx
@@ -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({
>
-
+
diff --git a/components/ContentType/DestinationPage/Map/utils.ts b/components/ContentType/DestinationPage/Map/utils.ts
new file mode 100644
index 000000000..c7bbc45ca
--- /dev/null
+++ b/components/ContentType/DestinationPage/Map/utils.ts
@@ -0,0 +1,28 @@
+import type {
+ DestinationMarker,
+ MarkerFeature,
+ MarkerGeojson,
+} from "@/types/components/maps/destinationMarkers"
+
+export function mapMarkerDataToGeoJson(markers: DestinationMarker[]) {
+ const features = markers.map(
+ ({ 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
+}
diff --git a/hooks/maps/use-map-viewport.ts b/hooks/maps/use-map-viewport.ts
new file mode 100644
index 000000000..4c77ebbb2
--- /dev/null
+++ b/hooks/maps/use-map-viewport.ts
@@ -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([-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)
+}
diff --git a/hooks/maps/use-supercluster.ts b/hooks/maps/use-supercluster.ts
new file mode 100644
index 000000000..249bbad00
--- /dev/null
+++ b/hooks/maps/use-supercluster.ts
@@ -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(
+ geojson: FeatureCollection,
+ superclusterOptions: Supercluster.Options
+) {
+ // 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,
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 0d4de3f91..b49db9d7b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index b9650bc99..d80694fad 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/types/components/maps/destinationMarkers.ts b/types/components/maps/destinationMarkers.ts
index 279d29d97..aa37debb5 100644
--- a/types/components/maps/destinationMarkers.ts
+++ b/types/components/maps/destinationMarkers.ts
@@ -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
+
+export type MarkerGeojson = FeatureCollection
+export type MarkerFeature = MarkerGeojson["features"][number]