Markers clusterization, mobile version of map

This commit is contained in:
2025-07-24 17:27:14 +05:00
parent 6a415ec165
commit 1497d6ede3
14 changed files with 463 additions and 89 deletions
+5 -2
View File
@@ -16,7 +16,7 @@ import GoogleMap from "./GoogleMap";
import MarasiDriveNeighboursSliderTablet from "./MarasiDriveNeighboursSliderTablet";
import CustomScrollBar from "./ui/ScrollBar";
import BrochureButton from "./ui/BrochureButton";
import { marasiDriveMapData } from "../data/mapMarasiDrive";
import { MarasiDriveMapData } from "../data/marasiDriveMapData";
function AboutMarasiDrive() {
const target = useRef<HTMLDivElement>(null);
@@ -239,7 +239,10 @@ function AboutMarasiDrive() {
<MarasiDriveMapCard {...card} key={card.title} />
))}
<div className="col-start-3 col-span-full row-start-1 row-span-full rounded-[1.667vw] overflow-hidden">
<GoogleMap mapCenter={marasiDriveMapData.mapCenter} />
<GoogleMap
mapCenter={MarasiDriveMapData.defaultCenter}
markers={MarasiDriveMapData.markers}
/>
</div>
</div>
<MarasiDriveMapMobile />
+50 -70
View File
@@ -1,88 +1,68 @@
/* eslint-disable no-loss-of-precision */
import { Map } from "@vis.gl/react-google-maps";
import MapMarker from "./ui/MapMarker";
import IGMapPoi from "../types/IGMapPoi";
import MarasiMarker from "../../public/images/map/markers/marasi-drive.png";
import MarasiPopup from "../../public/images/map/markers/popups/marasi-drive.png";
import MarinaMarker from "../../public/images/map/markers/dubai-marina.png";
import MarinaPopup from "../../public/images/map/markers/popups/dubai-marina.png";
import ShopIcon from "../../public/images/map/markers/points/shop-point.svg";
import GoogleMapMarkers from "./GoogleMapMarkers";
import { Map, useMap } from "@vis.gl/react-google-maps";
import { useEffect, useState } from "react";
import GoogleMapFilterButtons from "./GoogleMapFilterButtons";
interface IGMapProps {
mapCenter: google.maps.LatLngLiteral;
defaultZoom?: number;
markers?: IGMapPoi[];
minZoom?: number;
disableUI?: boolean;
mobile?: boolean;
mobileActive?: boolean;
}
export default function GoogleMap({
mapCenter,
markers,
defaultZoom = 14,
minZoom = 10,
disableUI = true,
mobile = false,
mobileActive = false,
}: IGMapProps) {
const [mapMarkersFilter, setMapMarkersFilter] = useState("All");
const [isInteractale, setIsInteractale] = useState(!mobile);
const map = useMap();
useEffect(() => {
if (!map) return;
setIsInteractale(!mobile || (mobile && mobileActive));
if (!mobileActive && mobile) {
map.panTo(mapCenter);
map.setZoom(defaultZoom);
}
}, [mobile, mobileActive, map]);
useEffect(() => {
if (!mobileActive) setMapMarkersFilter("All");
}, [mobileActive]);
return (
<Map
mapId={"30ff24bdc133c941ee0d0608"}
defaultCenter={mapCenter}
defaultZoom={defaultZoom}
disableDefaultUI={disableUI}
minZoom={minZoom}
gestureHandling={"greedy"}
<div
className={`size-[100%] ${
isInteractale ? "pointer-events-auto" : "pointer-events-none"
}`}
>
<MapMarkers />
</Map>
);
}
function MapMarkers() {
const marasiDriveMarker = (
<div className="flex items-end hover:cursor-pointer">
<img src={MarasiMarker} alt="" />
<img className="mb-3" src={MarasiPopup} alt="" />
</div>
);
const DubaiMarinaMarker = (
<div className="flex items-end hover:cursor-pointer">
<img src={MarinaMarker} alt="" />
<img className="mb-3" src={MarinaPopup} alt="" />
</div>
);
const shopMarker = (
<div>
<img src={ShopIcon} alt="" />
</div>
);
const templateMarkers: IGMapPoi[] = [
{
location: { lat: 25.181504160790247, lng: 55.27565159760525 },
customMarker: marasiDriveMarker,
},
{
location: { lat: 25.069466431595334, lng: 55.128736429300375 },
customMarker: DubaiMarinaMarker,
},
{
location: { lat: 25.193476007744233, lng: 55.274782084720286 },
customMarker: shopMarker,
},
{
location: { lat: 25.193476007744233, lng: 55.244782084720286 },
customMarker: shopMarker,
},
];
return (
<>
{templateMarkers.map((poi: IGMapPoi, index: number) => (
<MapMarker key={String(index)} location={poi.location}>
{poi.customMarker}
</MapMarker>
))}
</>
<Map
mapId={"30ff24bdc133c941ee0d0608"}
defaultCenter={mapCenter}
defaultZoom={defaultZoom}
disableDefaultUI={true}
minZoom={minZoom}
gestureHandling={!mobile || mobileActive ? "greedy" : "none"}
>
{markers && (
<GoogleMapMarkers data={markers} filter={mapMarkersFilter} />
)}
<GoogleMapFilterButtons
mobile={mobile}
mobileActive={mobileActive}
currentFilter={mapMarkersFilter}
onChange={setMapMarkersFilter}
/>
</Map>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from "react";
import AllIcon from "./icons/map/AllIcon";
import EntertainmentIcon from "./icons/map/EntertainmentIcon";
import HotelIcon from "./icons/map/HotelIcon";
import MallsIcon from "./icons/map/MallsIcon";
import OtherIcon from "./icons/map/OtherIcon";
import Button from "./ui/Button";
export default function GoogleMapFilterButtons({
mobile,
mobileActive,
currentFilter,
onChange,
}: {
mobile: boolean;
mobileActive: boolean;
currentFilter: string;
onChange: (newFilter: string) => void;
}) {
const filters = ["All", "Hotels", "Malls", "Entertainment", "Other"];
const [expanded, setExpanded] = useState(false);
const buttonsVisisble = (expanded && mobile && mobileActive) || !mobile;
return (
<div
className={`flex gap-[0.556vw] absolute items-center bottom-[1.111vw] left-1/2 -translate-x-1/2 ${
mobile && "flex-col"
}`}
>
{buttonsVisisble &&
filters.map((key) => (
<Button
key={key}
onClick={() => onChange(key)}
variant={key === currentFilter ? "cta" : "secondary"}
>
<div className="size-5">
{key === "All" && <AllIcon />}
{key === "Hotels" && <HotelIcon />}
{key === "Malls" && <MallsIcon />}
{key === "Entertainment" && <EntertainmentIcon />}
{key === "Other" && <OtherIcon />}
</div>
{key}
</Button>
))}
{mobile && mobileActive && (
<Button
onClick={() => setExpanded(!expanded)}
variant={expanded ? "cta" : "secondary"}
className="my-4"
>
{!expanded && (
<div>
<AllIcon />
</div>
)}
{!expanded ? <span>Select Category</span> : <span>Apply</span>}
</Button>
)}
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useMap } from "@vis.gl/react-google-maps";
import { useEffect, useRef, useState } from "react";
import IGMapPoi from "../types/IGMapPoi";
import MapMarker from "./ui/MapMarker";
import {
MarkerClusterer,
DefaultRenderer,
Cluster,
Marker,
} from "@googlemaps/markerclusterer";
import { AnimatePresence, motion } from "motion/react";
class CustomMarkerRenderer extends DefaultRenderer {
render({
count,
position,
}: Cluster): google.maps.marker.AdvancedMarkerElement {
const svgElement = document.createElement("div");
svgElement.innerHTML = `
<svg fill="black" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="50" height="50">
<circle cx="120" cy="120" opacity="1" r="70" />
</svg>
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; font-size: 12px; font-weight: bold;">
${count}
</div>`;
// Create the AdvancedMarkerElement
const marker = new google.maps.marker.AdvancedMarkerElement({
position,
content: svgElement, // Use the custom SVG element as content
zIndex: Math.max(1000, count),
});
return marker;
}
}
export default function GoogleMapMarkers({
data,
filter,
}: {
data: IGMapPoi[];
filter: string;
}) {
const map = useMap();
const [markers, setMarkers] = useState<{ [key: string]: Marker }>({});
const clusterer = useRef<MarkerClusterer | null>(null);
useEffect(() => {
if (!map) return;
if (!clusterer.current) {
clusterer.current = new MarkerClusterer({
map: map,
renderer: new CustomMarkerRenderer(),
});
}
}, [map]);
useEffect(() => {
clusterer.current?.clearMarkers();
clusterer.current?.addMarkers(Object.values(markers));
}, [markers]);
const setMarkerRef = (marker: Marker | null, key: string) => {
if (marker && markers[key]) return;
if (!marker && !markers[key]) return;
setMarkers((prev) => {
if (marker) {
return { ...prev, [key]: marker };
} else {
const newMarkers = { ...prev };
delete newMarkers[key];
return newMarkers;
}
});
};
return (
<AnimatePresence>
{data.map(
(poi: IGMapPoi, index: number) =>
(filter === poi.type || filter === "All") && (
<MapMarker
key={index}
markerKey={index}
poi={poi}
setMarkerRef={setMarkerRef}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{poi.customMarker}
</motion.div>
</MapMarker>
)
)}
</AnimatePresence>
);
}
+46 -7
View File
@@ -1,18 +1,52 @@
import { useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { marasiDriveMapCards } from "../data/aboutMarasiDrive";
import CustomScrollBar from "./ui/ScrollBar";
import { MarasiDriveMapData } from "../data/marasiDriveMapData";
import GoogleMap from "./GoogleMap";
import Button from "./ui/Button";
import FullScreenIcon from "./icons/FullScreenIcon";
import CloseIcon from "./icons/CloseIcon";
function MarasiDriveMapMobile() {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<HTMLDivElement>(null);
const [mapActive, setMapActive] = useState(false);
useEffect(() => {
if (mapActive) {
mapRef.current?.scrollIntoView();
window.scrollBy({
top: -40,
left: 0,
behavior: "smooth",
});
}
}, [mapActive]);
return (
<div className="min-2xl:hidden relative flex flex-col">
<div className="max-md:aspect-[328/544] md:max-2xl:aspect[1/1] max-2xl:mb-4">
<img
src="/images/about-complex/marasi-drive/map/map.png"
alt=""
className="object-cover size-full rounded-[6.667vw] md:max-2xl:rounded-[3.125vw]"
<div
ref={mapRef}
className={`relative max-md:aspect-[328/328] scroll-mt-10 md:max-2xl:aspect[1/1] transition-all max-2xl:mb-4 overflow-clip rounded-[4.444vw] ${
mapActive ? "h-[80vh]" : "h-[100vw]"
}`}
>
<GoogleMap
mapCenter={MarasiDriveMapData.defaultCenter}
markers={MarasiDriveMapData.markers}
mobileActive={mapActive}
mobile={true}
/>
<Button
className={`absolute size-[11.111vw] right-[1.111vw] ${
mapActive ? "top-[1.111vw]" : "bottom-[1.111vw]"
}`}
onClick={() => setMapActive(!mapActive)}
>
<div className="size-[5.556vw]">
{mapActive ? <CloseIcon /> : <FullScreenIcon />}
</div>
</Button>
</div>
<div
ref={containerRef}
@@ -36,7 +70,12 @@ function MarasiDriveMapMobile() {
</div>
))}
</div>
<CustomScrollBar containerRef={containerRef} inlinePadding={16} trackStyle="min-2xl:hidden max-2xl:translate-y-5" thumbStyle="min-2xl:hidden"/>
<CustomScrollBar
containerRef={containerRef}
inlinePadding={16}
trackStyle="min-2xl:hidden max-2xl:translate-y-5"
thumbStyle="min-2xl:hidden"
/>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { SVGProps } from "react";
const AllIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width={21}
height={20}
viewBox="0 0 21 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M6.517 13.97h.028v.024zm0-7.952h.029v.023zm7.971 7.976h.029v.024zm-.029-7.976h.029v.023z"
stroke="currentColor"
strokeWidth={3}
strokeLinejoin="round"
/>
</svg>
);
export default AllIcon;
@@ -0,0 +1,19 @@
import { SVGProps } from "react";
const EntertainmentIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width={21}
height={20}
viewBox="0 0 21 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M7.49 6.07a.1.1 0 0 1 .186 0l1.188 3.01a.1.1 0 0 0 .056.057l3.01 1.187a.1.1 0 0 1 0 .186l-3.01 1.188a.1.1 0 0 0-.056.056l-1.188 3.01a.1.1 0 0 1-.186 0l-1.187-3.01a.1.1 0 0 0-.056-.056L3.236 10.51a.1.1 0 0 1 0-.186l3.01-1.187a.1.1 0 0 0 .057-.056zm7.13 4.882a.05.05 0 0 1 .093 0l.77 1.954a.05.05 0 0 0 .029.028l1.953.77a.05.05 0 0 1 0 .093l-1.953.77a.05.05 0 0 0-.028.029l-.77 1.953a.05.05 0 0 1-.094 0l-.77-1.953a.05.05 0 0 0-.028-.028l-1.954-.77a.05.05 0 0 1 0-.094l1.954-.77a.05.05 0 0 0 .028-.028zm-1.25-8.334a.05.05 0 0 1 .093 0l.77 1.954a.05.05 0 0 0 .029.028l1.953.77a.05.05 0 0 1 0 .093l-1.953.77a.05.05 0 0 0-.028.029l-.77 1.953a.05.05 0 0 1-.094 0l-.77-1.953a.05.05 0 0 0-.028-.028l-1.954-.77a.05.05 0 0 1 0-.094l1.954-.77a.05.05 0 0 0 .028-.028z"
stroke="currentColor"
strokeWidth={1.5}
/>
</svg>
);
export default EntertainmentIcon;
+19
View File
@@ -0,0 +1,19 @@
import { SVGProps } from "react";
const HotelIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width={21}
height={20}
viewBox="0 0 21 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M15.834 10.247h.333a1 1 0 0 1 1 1v2.19a1 1 0 0 1-1 1H4.834a1 1 0 0 1-1-1v-2.19a1 1 0 0 1 1-1h.333m10.667 0V6.882c0-.579-.448-1.048-1-1.048H10.5m5.334 4.413H10.5m-5.333 0V6.882c0-.579.448-1.048 1-1.048H10.5m-5.333 4.413H10.5m-5.833 4.19v1.397m11.667-1.397v1.397M10.5 10.247V5.834"
stroke="currentColor"
strokeWidth={1.5}
/>
</svg>
);
export default HotelIcon;
+20
View File
@@ -0,0 +1,20 @@
import { SVGProps } from "react";
const MallsIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width={21}
height={20}
viewBox="0 0 21 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M8 6.406H6.5a1 1 0 0 0-1 1v7.428a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V7.406a1 1 0 0 0-1-1H13m-5 0v-.572a2.5 2.5 0 0 1 2.5-2.5v0a2.5 2.5 0 0 1 2.5 2.5v.572m-5 0h5"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="square"
/>
</svg>
);
export default MallsIcon;
+21
View File
@@ -0,0 +1,21 @@
import { SVGProps } from "react";
const OtherIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width={21}
height={20}
viewBox="0 0 21 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M10.5 4.166v11.667M5.45 7.086l10.104 5.834m-10.1 0 10.103-5.833"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default OtherIcon;
+26 -5
View File
@@ -1,18 +1,39 @@
import { AdvancedMarker, Pin } from "@vis.gl/react-google-maps";
import { AdvancedMarker, Pin, useMap } from "@vis.gl/react-google-maps";
import type { Marker } from "@googlemaps/markerclusterer";
import IGMapPoi from "../../types/IGMapPoi";
interface IGMapMarker {
key: string;
location: google.maps.LatLngLiteral;
markerKey: number;
poi: IGMapPoi;
setMarkerRef: (marker: Marker | null, key: string) => void | undefined;
children: React.ReactNode;
}
export default function MapMarker({ key, location, children }: IGMapMarker) {
export default function MapMarker({
markerKey,
poi,
setMarkerRef,
children,
}: IGMapMarker) {
const map = useMap();
return (
<AdvancedMarker key={key} position={location}>
<AdvancedMarker
onClick={() => {
map?.panTo(poi.location);
map?.setZoom(15);
}}
key={markerKey}
position={poi.location}
ref={(marker) =>
!poi.ignoreClusterization && setMarkerRef(marker, markerKey.toString())
}
>
<Pin
background={"transparent"}
glyphColor={"transparent"}
borderColor={"transparent"}
scale={poi.clickableAreaScale || 1}
>
{children}
</Pin>
-5
View File
@@ -1,5 +0,0 @@
export const marasiDriveMapData = {
mapCenter: { lat: 25.183476007744233, lng: 55.274782084720286 }
}
+66
View File
@@ -0,0 +1,66 @@
/* eslint-disable no-loss-of-precision */
import MarasiMarker from "../../public/images/map/markers/marasi-drive.png";
import MarinaMarker from "../../public/images/map/markers/dubai-marina.png";
import ShopIcon from "../../public/images/map/markers/points/shop-point.svg";
import IGMapPoi from "../types/IGMapPoi";
const marasiDriveMarker = (
<div className="flex items-end hover:cursor-pointer">
<img src={MarasiMarker} alt="" />
</div>
);
const DubaiMarinaMarker = (
<div className="flex items-end hover:cursor-pointer">
<img src={MarinaMarker} alt="" />
</div>
);
const shopMarker = (
<div>
<img src={ShopIcon} alt="" />
</div>
);
export const MarasiDriveMapData: {
defaultCenter: { lat: number; lng: number };
markers: IGMapPoi[];
} = {
defaultCenter: { lat: 25.183476007744233, lng: 55.274782084720286 },
markers: [
{
location: { lat: 25.223476007744233, lng: 55.274782084720286 },
type: "Malls",
customMarker: shopMarker,
},
{
location: { lat: 25.123476007744233, lng: 55.258782084720286 },
type: "Malls",
customMarker: shopMarker,
},
{
location: { lat: 25.143476007744233, lng: 55.224782084720286 },
type: "Malls",
customMarker: shopMarker,
},
{
location: { lat: 25.223476007744233, lng: 55.278782084720286 },
type: "Malls",
customMarker: shopMarker,
},
{
location: { lat: 25.181371868546396, lng: 55.27515332907251 },
type: "Hotels",
ignoreClusterization: true,
customMarker: marasiDriveMarker,
clickableAreaScale: 3,
},
{
location: { lat: 25.069466431595334, lng: 55.128736429300375 },
type: "Hotels",
ignoreClusterization: true,
customMarker: DubaiMarinaMarker,
clickableAreaScale: 3,
},
],
};
+5
View File
@@ -1,4 +1,9 @@
export default interface IGMapPoi {
location: google.maps.LatLngLiteral;
ignoreClusterization?:boolean;
type:"All" | "Hotels" | "Malls" | "Entertainment"| "Other";
// Scale multiplyer for Pin (MapMarker.tsx) component. (To match visible marker size).
clickableAreaScale?:number;
customMarker?: React.ReactNode;
}