diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bab6b08..0bcb9df 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,12 @@ "version": "0.1.0", "dependencies": { "@react-oauth/google": "^0.13.4", + "@types/supercluster": "^7.1.3", "@vis.gl/react-google-maps": "^1.7.1", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "supercluster": "^8.0.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1544,6 +1546,12 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT" + }, "node_modules/@types/google.maps": { "version": "3.58.1", "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", @@ -1595,6 +1603,15 @@ "@types/react": "^19.2.0" } }, + "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==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -4555,6 +4572,12 @@ "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==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6086,6 +6109,15 @@ } } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6076e57..257f253 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,10 +10,12 @@ }, "dependencies": { "@react-oauth/google": "^0.13.4", + "@types/supercluster": "^7.1.3", "@vis.gl/react-google-maps": "^1.7.1", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "supercluster": "^8.0.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index 088aa91..1af0438 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -8,6 +8,7 @@ import { InfoWindow, useMap, } from "@vis.gl/react-google-maps"; +import Supercluster from "supercluster"; import type { Restaurant } from "@/lib/api"; import { getCuisineIcon } from "@/lib/cuisine-icons"; import Icon from "@/components/Icon"; @@ -62,10 +63,83 @@ interface MapViewProps { activeChannel?: string; } +type RestaurantProps = { restaurant: Restaurant }; +type RestaurantFeature = Supercluster.PointFeature; + +function useSupercluster(restaurants: Restaurant[]) { + const indexRef = useRef | null>(null); + + const points: RestaurantFeature[] = useMemo( + () => + restaurants.map((r) => ({ + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] }, + properties: { restaurant: r }, + })), + [restaurants] + ); + + const index = useMemo(() => { + const sc = new Supercluster<{ restaurant: Restaurant }>({ + radius: 60, + maxZoom: 16, + minPoints: 2, + }); + sc.load(points); + indexRef.current = sc; + return sc; + }, [points]); + + const getClusters = useCallback( + (bounds: MapBounds, zoom: number) => { + return index.getClusters( + [bounds.west, bounds.south, bounds.east, bounds.north], + Math.floor(zoom) + ); + }, + [index] + ); + + const getExpansionZoom = useCallback( + (clusterId: number): number => { + try { + return index.getClusterExpansionZoom(clusterId); + } catch { + return 17; + } + }, + [index] + ); + + return { getClusters, getExpansionZoom, index }; +} + +function getClusterSize(count: number): number { + if (count < 10) return 36; + if (count < 50) return 42; + if (count < 100) return 48; + return 54; +} + function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit) { const map = useMap(); const [infoTarget, setInfoTarget] = useState(null); + const [zoom, setZoom] = useState(13); + const [bounds, setBounds] = useState(null); const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]); + const { getClusters, getExpansionZoom } = useSupercluster(restaurants); + + // Build a lookup for restaurants by id + const restaurantMap = useMemo(() => { + const m: Record = {}; + restaurants.forEach((r) => { m[r.id] = r; }); + return m; + }, [restaurants]); + + const clusters = useMemo(() => { + if (!bounds) return []; + return getClusters(bounds, zoom); + }, [bounds, zoom, getClusters]); const handleMarkerClick = useCallback( (r: Restaurant) => { @@ -75,6 +149,41 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh [onSelectRestaurant] ); + const handleClusterClick = useCallback( + (clusterId: number, lng: number, lat: number) => { + if (!map) return; + const expansionZoom = Math.min(getExpansionZoom(clusterId), 18); + map.panTo({ lat, lng }); + map.setZoom(expansionZoom); + }, + [map, getExpansionZoom] + ); + + // Track camera changes for clustering + useEffect(() => { + if (!map) return; + const listener = map.addListener("idle", () => { + const b = map.getBounds(); + const z = map.getZoom(); + if (b && z != null) { + const ne = b.getNorthEast(); + const sw = b.getSouthWest(); + setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() }); + setZoom(z); + } + }); + // Trigger initial bounds + const b = map.getBounds(); + const z = map.getZoom(); + if (b && z != null) { + const ne = b.getNorthEast(); + const sw = b.getSouthWest(); + setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() }); + setZoom(z); + } + return () => google.maps.event.removeListener(listener); + }, [map]); + // Fly to a specific location (region filter) useEffect(() => { if (!map || !flyTo) return; @@ -92,7 +201,46 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh return ( <> - {restaurants.map((r) => { + {clusters.map((feature) => { + const [lng, lat] = feature.geometry.coordinates; + const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster; + + if (isCluster) { + const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties; + const size = getClusterSize(point_count); + return ( + handleClusterClick(cluster_id, lng, lat)} + zIndex={100} + > +
42 ? 15 : 13, + fontWeight: 700, + cursor: "pointer", + transition: "transform 0.2s ease", + }} + > + {point_count} +
+
+ ); + } + + // Individual marker + const r = (feature.properties as { restaurant: Restaurant }).restaurant; const isSelected = selected?.id === r.id; const isClosed = r.business_status === "CLOSED_PERMANENTLY"; const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];