지도 마커 클러스터링 적용 (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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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">) {
|
||||
const map = useMap();
|
||||
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 { 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(
|
||||
(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 (
|
||||
<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 isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
||||
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||
|
||||
Reference in New Issue
Block a user