"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { APIProvider, Map, AdvancedMarker, 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"; const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 }; const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; // Channel color palette const CHANNEL_COLORS = [ { bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default) { bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue { bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green { bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink { bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple { bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red { bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal { bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow ]; function getChannelColorMap(restaurants: Restaurant[]) { const channels = new Set(); restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch))); const map: Record = {}; let i = 0; for (const ch of channels) { map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length]; i++; } return map; } import type { MapBounds, FlyTo, MapViewProps } from "@/components/MapView.types"; type RestaurantProps = { restaurant: Restaurant }; type RestaurantFeature = Supercluster.PointFeature; function useSupercluster(restaurants: Restaurant[]) { // #278 — indexRef 제거 (set만 되고 read 없는 dead code) 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); 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); // #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code) const clusters = useMemo(() => { if (!bounds) return []; return getClusters(bounds, zoom); }, [bounds, zoom, getClusters]); const handleMarkerClick = useCallback( (r: Restaurant) => { setInfoTarget(r); onSelectRestaurant?.(r); }, [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; map.panTo({ lat: flyTo.lat, lng: flyTo.lng }); if (flyTo.zoom) map.setZoom(flyTo.zoom); }, [map, flyTo]); // Pan and zoom to selected restaurant useEffect(() => { if (!map || !selected) return; map.panTo({ lat: selected.latitude, lng: selected.longitude }); map.setZoom(16); setInfoTarget(selected); }, [map, selected]); return ( <> {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]; const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0]; const c = chColor || CHANNEL_COLORS[0]; return ( handleMarkerClick(r)} zIndex={isSelected ? 1000 : 1} >
{getCuisineIcon(r.cuisine_type)} {r.name}
); })} {infoTarget && ( setInfoTarget(null)} >

{getCuisineIcon(infoTarget.cuisine_type)}{infoTarget.name}

{infoTarget.business_status === "CLOSED_PERMANENTLY" && ( 폐업 )} {infoTarget.business_status === "CLOSED_TEMPORARILY" && ( 임시휴업 )}
{infoTarget.rating && (

{infoTarget.rating} {infoTarget.rating_count && ( ({infoTarget.rating_count.toLocaleString()}) )}

)} {infoTarget.cuisine_type && (

{infoTarget.cuisine_type}

)} {infoTarget.address && (

{infoTarget.address}

)} {infoTarget.price_range && (

{infoTarget.price_range}

)} {infoTarget.phone && (

{infoTarget.phone}

)}
)} ); } export default function GoogleMapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) { const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]); const channelNames = useMemo(() => { const names = Object.keys(channelColors); if (activeChannel) return names.filter((n) => n === activeChannel); return names; }, [channelColors, activeChannel]); const boundsTimerRef = useRef | null>(null); const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => { if (!onBoundsChanged) return; if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current); boundsTimerRef.current = setTimeout(() => { const { north, south, east, west } = ev.detail.bounds; onBoundsChanged({ north, south, east, west }); }, 150); }, [onBoundsChanged]); // #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지) useEffect(() => { return () => { if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current); }; }, []); return ( {onMyLocation && ( )} {channelNames.length > 0 && (
{channelNames.map((ch) => (
))}
)}
); }