@@ -5,33 +5,29 @@ import Supercluster from "supercluster";
import type { Restaurant } from "@/lib/api" ;
import { getCuisineIcon } from "@/lib/cuisine-icons" ;
import Icon from "@/components/Icon" ;
import type { MapBounds , FlyTo , MapViewProps } from "@/components/MapView.types" ;
import type { MapBounds , MapViewProps } from "@/components/MapView.types" ;
// ---- naver maps v3 타입 최소 정의 (full 타입은 @types/navermaps 없음) ----
declare global {
interface Window {
naver ? : {
maps : NaverMaps ;
} ;
naver ? : { maps : NaverMaps } ;
}
}
type NaverMaps = {
LatLng : new ( lat : number , lng : number ) = > unknown ;
Map : new ( el : HTMLElement , opts : Record < string , unknown > ) = > NaverMapInstance ;
Event : { addListener : ( target : unknown , type : string , fn : ( . . . args : unknown [ ] ) = > void ) = > unknown } ;
Position? : unknown ;
MapTypeControlStyle? : unknown ;
ZoomControlStyle? : unknown ;
Size : new ( w : number , h : number ) = > unknown ;
} ;
type NaverMapInstance = {
setCenter : ( latlng : unknown ) = > void ;
setZoom : ( zoom : number , useEffect? : boolean ) = > void ;
getCenter : ( ) = > { lat : ( ) = > number ; lng : ( ) = > number ; x : number ; y : number } ;
getCenter : ( ) = > { lat : ( ) = > number ; lng : ( ) = > number } ;
getZoom : ( ) = > number ;
getBounds : ( ) = > { getNE : ( ) = > { lat : ( ) = > number ; lng : ( ) = > number } ; getSW : ( ) = > { lat : ( ) = > number ; lng : ( ) = > number } } ;
getProjection : ( ) = > { fromCoordToOffset : ( latlng : unknown ) = > { x : number ; y : number } } ;
panTo : ( latlng : unknown , opts? : Record < string , unknown > ) = > void ;
destroy ? : ( ) = > void ;
refresh : ( noEffect? : boolean ) = > void ;
setSize ? : ( size : unknown ) = > void ;
} ;
const NAVER_CLIENT_ID = process . env . NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "" ;
@@ -41,10 +37,7 @@ function useNaverMaps(): { ready: boolean; error: string | null } {
const [ error , setError ] = useState < string | null > ( null ) ;
useEffect ( ( ) = > {
if ( ! NAVER_CLIENT_ID ) {
setError ( "NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정" ) ;
return ;
}
if ( ! NAVER_CLIENT_ID ) { setError ( "NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정" ) ; return ; }
if ( window . naver ? . maps ) { setReady ( true ) ; return ; }
const existing = document . querySelector < HTMLScriptElement > ( ` script[data-naver-maps] ` ) ;
if ( existing ) {
@@ -52,7 +45,7 @@ function useNaverMaps(): { ready: boolean; error: string | null } {
return ;
}
const s = document . createElement ( "script" ) ;
s . src = ` https://oapi.map.naver.com/openapi/v3/maps.js?ncpClient Id= ${ NAVER_CLIENT_ID } ` ;
s . src = ` https://oapi.map.naver.com/openapi/v3/maps.js?ncpKey Id= ${ NAVER_CLIENT_ID } ` ;
s . async = true ;
s . dataset . naverMaps = "1" ;
s . onload = ( ) = > setReady ( true ) ;
@@ -109,11 +102,13 @@ export default function NaverMapView({
const mapRef = useRef < NaverMapInstance | null > ( null ) ;
const [ bounds , setBounds ] = useState < MapBounds | null > ( null ) ;
const [ zoom , setZoom ] = useState ( 13 ) ;
const [ initError , setInitError ] = useState < string | null > ( null ) ;
const { getClusters , getExpansionZoom } = useSupercluster ( restaurants ) ;
// 1) 지도 인스턴스 1회 생성
// 지도 1회 생성 — divRef 항상 마운트되므로 ready 시점에 안정적으로 잡힘
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 ;
@@ -127,20 +122,40 @@ export default function NaverMapView({
zoomControl : false ,
} ) ;
mapRef . current = m ;
// 컨테이너 크기 변경(예: flex layout 안에서 첫 마운트 시 0× 0 → 실제 크기) → refresh
const ro = new ResizeObserver ( ( ) = > {
try { m . refresh ( true ) ; } catch { /* noop */ }
} ) ;
ro . observe ( divRef . current ) ;
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 ) ;
}
} ;
// 컨테이너 크기가 정해진 다음 sync (rAF로 다음 프레임)
requestAnimationFrame ( ( ) = > {
try { m . refresh ( true ) ; } catch { /* noop */ }
sync ( ) ;
} ) ;
n . Event . addListener ( m , "bounds_changed" , sync ) ;
n . Event . addListener ( m , "zoom_changed" , sync ) ;
} catch ( e ) {
const msg = e instanceof Error ? e.message : String ( e ) ;
console . error ( "[NaverMap] init failed" , e ) ;
setInitError ( msg ) ;
}
} , [ ready , flyTo , selected , onBoundsChanged ] ) ;
// 2) flyTo 변경 반영
// flyTo 변경 반영
useEffect ( ( ) = > {
const m = mapRef . current ;
if ( ! m || ! flyTo || ! window . naver ? . maps ) return ;
@@ -153,22 +168,38 @@ export default function NaverMapView({
return getClusters ( bounds , zoom ) ;
} , [ bounds , zoom , getClusters ] ) ;
// 3) 좌표 → 화면 픽셀 변환 (네이버 projection)
const toScreen = useCallback ( ( lat : number , lng : number ) = > {
const m = mapRef . current ;
if ( ! m || ! window . naver ? . maps ) return null ;
const p = m . getProjection ( ) . fromCoordToOffset ( new window . naver . maps . LatLng ( lat , lng ) ) ;
return p ;
try {
return m . getProjection ( ) . fromCoordToOffset ( new window . naver . maps . LatLng ( lat , lng ) ) ;
} catch {
return null ;
}
} , [ ] ) ;
if ( error ) return < div className = "w-full h-full flex items-center justify-center text-xs text-red-500" > { error } — 새 로 고 침 후 에 도 같 으 면 Google Map으로 폴 백 됩 니 다 . < / div > ;
if ( ! ready ) return < div className = "w-full h-full flex items-center justify-center text-xs text-gray-400" > 네 이 버 지 도 로 딩 중 … < / div > ;
return (
< div className = "relative w-full h-full" >
< div ref = { divRef } className = "absolute inset-0" />
{ /* 지도 컨테이너 — 항상 마운트, 명시적 크기 보장 * /}
< div
ref = { divRef }
className = "absolute inset-0"
style = { { width : "100%" , height : "100%" , backgroundColor : "#e5e7eb" } }
/ >
{ /* 마커 overlay (좌표→픽셀 변환 + absolute positioned div) */ }
{ /* 로딩/에러 overlay */ }
{ ( error || initError ) && (
< div className = "absolute inset-0 flex items-center justify-center text-xs text-red-600 bg-white/80 pointer-events-none" >
{ error ? ? initError } — 새 로 고 침 또 는 GoogleMap로 fallback
< / div >
) }
{ ! ready && ! error && (
< div className = "absolute inset-0 flex items-center justify-center text-xs text-gray-500 bg-white/80 pointer-events-none" >
네 이 버 지 도 로 딩 중 …
< / div >
) }
{ /* 마커 overlay */ }
{ clusters . map ( ( feature ) = > {
const [ lng , lat ] = feature . geometry . coordinates ;
const pt = toScreen ( lat , lng ) ;
@@ -210,7 +241,6 @@ export default function NaverMapView({
) ;
} ) }
{ /* 내 위치 버튼 */ }
{ onMyLocation && (
< button
onClick = { onMyLocation }