From f9a03052b1cbee00b3ff98412ff694135101d0af Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Fri, 14 Feb 2025 06:31:01 +0000 Subject: [PATCH] Merged in feat/SW-1616-clustering (pull request #1330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../DestinationPage/Map/DynamicMap/index.tsx | 7 +- .../ClusterMarker/clusterMarker.module.css | 20 +++++ .../Map/MapContent/ClusterMarker/index.tsx | 41 +++++++++ .../Map/MapContent/Marker/index.tsx | 45 ++++++++++ .../DestinationPage/Map/MapContent/index.tsx | 84 ++++++++++++++----- .../Map/MapContent/mapContent.module.css | 0 .../ContentType/DestinationPage/Map/index.tsx | 5 +- .../ContentType/DestinationPage/Map/utils.ts | 28 +++++++ hooks/maps/use-map-viewport.ts | 53 ++++++++++++ hooks/maps/use-supercluster.ts | 42 ++++++++++ package-lock.json | 29 +++++++ package.json | 3 + types/components/maps/destinationMarkers.ts | 7 ++ 13 files changed, 342 insertions(+), 22 deletions(-) create mode 100644 components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/clusterMarker.module.css create mode 100644 components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/index.tsx create mode 100644 components/ContentType/DestinationPage/Map/MapContent/Marker/index.tsx delete mode 100644 components/ContentType/DestinationPage/Map/MapContent/mapContent.module.css create mode 100644 components/ContentType/DestinationPage/Map/utils.ts create mode 100644 hooks/maps/use-map-viewport.ts create mode 100644 hooks/maps/use-supercluster.ts 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]