지도 마커 클러스터링 적용 (supercluster)
- 줌 16 이하에서 근접 마커를 숫자 원형 클러스터로 묶음 - 클러스터 클릭 시 해당 영역으로 자동 줌인 - 개별 마커 스타일/채널 색상 유지 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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