지도 마커 클러스터링 적용 (supercluster)
- 줌 16 이하에서 근접 마커를 숫자 원형 클러스터로 묶음 - 클러스터 클릭 시 해당 영역으로 자동 줌인 - 개별 마커 스타일/채널 색상 유지 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -9,10 +9,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
"@vis.gl/react-google-maps": "^1.7.1",
|
"@vis.gl/react-google-maps": "^1.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -1544,6 +1546,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/google.maps": {
|
||||||
"version": "3.58.1",
|
"version": "3.58.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
|
||||||
@@ -1595,6 +1603,15 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.56.1",
|
"version": "8.56.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||||
@@ -4555,6 +4572,12 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"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": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
"@vis.gl/react-google-maps": "^1.7.1",
|
"@vis.gl/react-google-maps": "^1.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
InfoWindow,
|
InfoWindow,
|
||||||
useMap,
|
useMap,
|
||||||
} from "@vis.gl/react-google-maps";
|
} from "@vis.gl/react-google-maps";
|
||||||
|
import Supercluster from "supercluster";
|
||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
@@ -62,10 +63,83 @@ interface MapViewProps {
|
|||||||
activeChannel?: string;
|
activeChannel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RestaurantProps = { restaurant: Restaurant };
|
||||||
|
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||||
|
|
||||||
|
function useSupercluster(restaurants: Restaurant[]) {
|
||||||
|
const indexRef = useRef<Supercluster<{ restaurant: Restaurant }> | 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<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
|
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
||||||
|
const [zoom, setZoom] = useState(13);
|
||||||
|
const [bounds, setBounds] = useState<MapBounds | null>(null);
|
||||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
|
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||||
|
|
||||||
|
// Build a lookup for restaurants by id
|
||||||
|
const restaurantMap = useMemo(() => {
|
||||||
|
const m: Record<string, Restaurant> = {};
|
||||||
|
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(
|
const handleMarkerClick = useCallback(
|
||||||
(r: Restaurant) => {
|
(r: Restaurant) => {
|
||||||
@@ -75,6 +149,41 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
[onSelectRestaurant]
|
[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)
|
// Fly to a specific location (region filter)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !flyTo) return;
|
if (!map || !flyTo) return;
|
||||||
@@ -92,7 +201,46 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
|
|
||||||
return (
|
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 (
|
||||||
|
<AdvancedMarker
|
||||||
|
key={`cluster-${cluster_id}`}
|
||||||
|
position={{ lat, lng }}
|
||||||
|
onClick={() => handleClusterClick(cluster_id, lng, lat)}
|
||||||
|
zIndex={100}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
|
||||||
|
border: "3px solid #fff",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: size > 42 ? 15 : 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "transform 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{point_count}
|
||||||
|
</div>
|
||||||
|
</AdvancedMarker>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual marker
|
||||||
|
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
|
||||||
const isSelected = selected?.id === r.id;
|
const isSelected = selected?.id === r.id;
|
||||||
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
||||||
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user