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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
28
components/ContentType/DestinationPage/Map/utils.ts
Normal file
28
components/ContentType/DestinationPage/Map/utils.ts
Normal 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
|
||||
}
|
||||
53
hooks/maps/use-map-viewport.ts
Normal file
53
hooks/maps/use-map-viewport.ts
Normal 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)
|
||||
}
|
||||
42
hooks/maps/use-supercluster.ts
Normal file
42
hooks/maps/use-supercluster.ts
Normal 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
29
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user