"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Supercluster from "supercluster"; import type { Restaurant } from "@/lib/api"; import Icon from "@/components/Icon"; import type { MapBounds, MapViewProps } from "@/components/MapView.types"; declare global { interface Window { naver?: { maps: NaverMaps }; } } type LatLng = { lat: () => number; lng: () => number }; type NaverMaps = { LatLng: new (lat: number, lng: number) => LatLng; Map: new (el: HTMLElement, opts: Record) => NaverMapInstance; Marker: new (opts: Record) => NaverMarker; InfoWindow: new (opts: Record) => NaverInfoWindow; Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown; removeListener: (handler: unknown) => void }; Size: new (w: number, h: number) => unknown; Point: new (x: number, y: number) => unknown; }; type NaverMapInstance = { setCenter: (latlng: unknown) => void; setZoom: (zoom: number, useEffect?: boolean) => void; getZoom: () => number; getBounds: () => { getNE: () => LatLng; getSW: () => LatLng }; panTo: (latlng: unknown, opts?: Record) => void; refresh: (noEffect?: boolean) => void; }; type NaverMarker = { setMap: (map: NaverMapInstance | null) => void; setIcon: (icon: Record) => void; setPosition: (latlng: unknown) => void; getPosition: () => LatLng; }; type NaverInfoWindow = { open: (map: NaverMapInstance, marker: NaverMarker) => void; close: () => void; setContent: (content: string) => void; getMap: () => NaverMapInstance | null; }; const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || ""; // Channel color palette — GoogleMapView와 동일 const CHANNEL_COLORS = [ { border: "#f59e0b" }, // amber (default) { border: "#3b82f6" }, // blue { border: "#22c55e" }, // green { border: "#ec4899" }, // pink { border: "#a855f7" }, // purple { border: "#ef4444" }, // red { border: "#14b8a6" }, // teal { border: "#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; } function useNaverMaps(): { ready: boolean; error: string | null } { const [ready, setReady] = useState(typeof window !== "undefined" && !!window.naver?.maps); const [error, setError] = useState(null); useEffect(() => { if (!NAVER_CLIENT_ID) { setError("NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정"); return; } if (window.naver?.maps) { setReady(true); return; } const existing = document.querySelector(`script[data-naver-maps]`); if (existing) { existing.addEventListener("load", () => setReady(true), { once: true }); return; } const s = document.createElement("script"); s.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${NAVER_CLIENT_ID}`; s.async = true; s.dataset.naverMaps = "1"; s.onload = () => setReady(true); s.onerror = () => setError("naver maps v3 스크립트 로드 실패"); document.head.appendChild(s); }, []); return { ready, error }; } type RestaurantProps = { restaurant: Restaurant }; type RestaurantFeature = Supercluster.PointFeature; function useSupercluster(restaurants: Restaurant[]) { 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) => index.getClusters([bounds.west, bounds.south, bounds.east, bounds.north], Math.floor(zoom)) , [index]); const getExpansionZoom = useCallback((clusterId: number) => { try { return index.getClusterExpansionZoom(clusterId); } catch { return 17; } }, [index]); return { getClusters, getExpansionZoom }; } function getClusterSize(count: number): number { if (count < 10) return 36; if (count < 50) return 42; if (count < 100) return 48; return 54; } // 단일 마커 — 채널 색상별 function markerIconHtml(color: string): string { return `
`; } // SVG data URL — 클러스터(숫자) function clusterIconHtml(count: number, size: number): string { return `
${count}
`; } export default function NaverMapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel, }: MapViewProps) { const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]); const { ready, error } = useNaverMaps(); const divRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); const infoWindowRef = useRef(null); const [bounds, setBounds] = useState(null); const [zoom, setZoom] = useState(13); const [initError, setInitError] = useState(null); const { getClusters, getExpansionZoom } = useSupercluster(restaurants); // 지도 1회 생성 useEffect(() => { if (!ready || !divRef.current || mapRef.current) return; try { const n = window.naver!.maps; const initLat = flyTo?.lat ?? selected?.latitude ?? 37.5665; const initLng = flyTo?.lng ?? selected?.longitude ?? 126.978; const initZoom = flyTo?.zoom ?? 13; const m = new n.Map(divRef.current, { center: new n.LatLng(initLat, initLng), zoom: initZoom, logoControl: false, mapDataControl: false, scaleControl: false, zoomControl: false, }); mapRef.current = m; infoWindowRef.current = new n.InfoWindow({ borderWidth: 0, anchorSize: new n.Size(10, 10), pixelOffset: new n.Point(0, -8), backgroundColor: "transparent", disableAnchor: false, }); const ro = new ResizeObserver(() => { try { m.refresh(true); } catch { /* noop */ } }); ro.observe(divRef.current); // bounds_changed가 줌/팬 끝나는 시점에 한 번만 emit (SDK가 throttle) const sync = () => { try { const b = m.getBounds(); const ne = b.getNE(), sw = b.getSW(); const nb: MapBounds = { north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() }; setBounds(nb); setZoom(m.getZoom()); onBoundsChanged?.(nb); } catch (e) { console.warn("[NaverMap] sync failed", e); } }; requestAnimationFrame(() => { try { m.refresh(true); } catch { /* noop */ } sync(); }); // idle = 줌/팬 끝났을 때 한 번 (bounds_changed보다 적게 발화 → 성능) n.Event.addListener(m, "idle", sync); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("[NaverMap] init failed", e); setInitError(msg); } }, [ready, flyTo, selected, onBoundsChanged]); // flyTo 변경 반영 useEffect(() => { const m = mapRef.current; if (!m || !flyTo || !window.naver?.maps) return; m.panTo(new window.naver.maps.LatLng(flyTo.lat, flyTo.lng)); if (flyTo.zoom) m.setZoom(flyTo.zoom, true); }, [flyTo]); // 클러스터 계산 (bounds/zoom 변경 시) const clusters = useMemo(() => { if (!bounds) return []; return getClusters(bounds, zoom); }, [bounds, zoom, getClusters]); // 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성 useEffect(() => { const m = mapRef.current; const naver = window.naver?.maps; if (!m || !naver) return; // 기존 마커 제거 for (const mk of markersRef.current) mk.setMap(null); markersRef.current = []; for (const feature of clusters) { 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); const marker = new naver.Marker({ position: new naver.LatLng(lat, lng), map: m, icon: { content: clusterIconHtml(point_count, size), anchor: new naver.Point(size / 2, size / 2), }, }); naver.Event.addListener(marker, "click", () => { const z = Math.min(getExpansionZoom(cluster_id), 18); m.panTo(new naver.LatLng(lat, lng)); m.setZoom(z, true); }); markersRef.current.push(marker); } else { const r = (feature.properties as RestaurantProps).restaurant; const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0]; const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0]; const marker = new naver.Marker({ position: new naver.LatLng(lat, lng), map: m, title: r.name, icon: { content: markerIconHtml(chColor?.border ?? "#f59e0b"), anchor: new naver.Point(14, 14), }, }); naver.Event.addListener(marker, "click", () => { const iw = infoWindowRef.current; if (iw && m) { iw.setContent( `
${escapeHtml(r.name)}
` ); iw.open(m, marker); } onSelectRestaurant?.(r); }); markersRef.current.push(marker); } } }, [clusters, getExpansionZoom, onSelectRestaurant, channelColors, activeChannel]); // 컴포넌트 unmount 시 마커 정리 useEffect(() => { return () => { for (const mk of markersRef.current) mk.setMap(null); markersRef.current = []; infoWindowRef.current?.close(); }; }, []); return (
{(error || initError) && (
{error ?? initError}
)} {!ready && !error && (
네이버 지도 로딩 중…
)} {onMyLocation && ( )}
); } function escapeHtml(s: string): string { return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch] as string)); }