@@ -3,7 +3,6 @@
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
import Supercluster from "supercluster" ;
import type { Restaurant } from "@/lib/api" ;
import { getCuisineIcon } from "@/lib/cuisine-icons" ;
import Icon from "@/components/Icon" ;
import type { MapBounds , MapViewProps } from "@/components/MapView.types" ;
@@ -12,22 +11,35 @@ declare global {
naver ? : { maps : NaverMaps } ;
}
}
type LatLng = { lat : ( ) = > number ; lng : ( ) = > number } ;
type NaverMaps = {
LatLng : new ( lat : number , lng : number ) = > unknown ;
LatLng : new ( lat : number , lng : number ) = > LatLng ;
Map : new ( el : HTMLElement , opts : Record < string , unknown > ) = > NaverMapInstance ;
Event : { addListener : ( target : unknown , type : string , fn : ( . . . args : unknown[ ] ) = > void ) = > unknown } ;
Marker : new ( opts : Record < string , unknown> ) = > NaverMarker ;
InfoWindow : new ( opts : Record < string , unknown > ) = > 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 ;
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 } } ;
getBounds : ( ) = > { getNE : ( ) = > LatLng ; getSW : ( ) = > LatLng } ;
panTo : ( latlng : unknown , opts? : Record < string , unknown > ) = > void ;
refresh : ( noEffect? : boolean ) = > void ;
setSize ? : ( size : unknown ) = > void ;
} ;
type NaverMarker = {
setMap : ( map : NaverMapInstance | null ) = > void ;
setIcon : ( icon : Record < string , unknown > ) = > 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 || "" ;
@@ -89,6 +101,15 @@ function getClusterSize(count: number): number {
return 54 ;
}
// SVG data URL — 단일 마커(주황 핀)
function markerIconHtml ( ) : string {
return ` <div style="width:28px;height:28px;border-radius:9999px;background:#f59e0b;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,.25);"></div> ` ;
}
// SVG data URL — 클러스터(숫자)
function clusterIconHtml ( count : number , size : number ) : string {
return ` <div style="width: ${ size } px;height: ${ size } px;border-radius:9999px;background:rgba(245,158,11,.92);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size: ${ size > 44 ? 14 : 12 } px;border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);"> ${ count } </div> ` ;
}
export default function NaverMapView ( {
restaurants ,
selected ,
@@ -100,12 +121,14 @@ export default function NaverMapView({
const { ready , error } = useNaverMaps ( ) ;
const divRef = useRef < HTMLDivElement | null > ( null ) ;
const mapRef = useRef < NaverMapInstance | null > ( null ) ;
const markersRef = useRef < NaverMarker [ ] > ( [ ] ) ;
const infoWindowRef = useRef < NaverInfoWindow | 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회 생성 — divRef 항상 마운트되므로 ready 시점에 안정적으로 잡힘
// 지도 1회 생성
useEffect ( ( ) = > {
if ( ! ready || ! divRef . current || mapRef . current ) return ;
try {
@@ -122,13 +145,20 @@ export default function NaverMapView({
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 ,
} ) ;
// 컨테이너 크기 변경(예: flex layout 안에서 첫 마운트 시 0× 0 → 실제 크기) → refresh
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 ( ) ;
@@ -141,13 +171,12 @@ export default function NaverMapView({
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 ) ;
// 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 ) ;
@@ -163,34 +192,87 @@ export default function NaverMapView({
if ( flyTo . zoom ) m . setZoom ( flyTo . zoom , true ) ;
} , [ flyTo ] ) ;
// 클러스터 계산 (bounds/zoom 변경 시)
const clusters = useMemo ( ( ) = > {
if ( ! bounds ) return [ ] ;
return getClusters ( bounds , zoom ) ;
} , [ bounds , zoom , getClusters ] ) ;
const toScreen = useCallback ( ( lat : number , lng : number ) = > {
// 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성
useEffect ( ( ) = > {
const m = mapRef . current ;
if ( ! m || ! window . naver ? . maps ) return null ;
try {
return m . getProjection ( ) . fromCoordToOffset ( new window . naver . maps . LatLng ( lat , lng ) ) ;
} catch {
return null ;
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 marker = new naver . Marker ( {
position : new naver . LatLng ( lat , lng ) ,
map : m ,
title : r.name ,
icon : {
content : markerIconHtml ( ) ,
anchor : new naver . Point ( 14 , 14 ) ,
} ,
} ) ;
naver . Event . addListener ( marker , "click" , ( ) = > {
const iw = infoWindowRef . current ;
if ( iw && m ) {
iw . setContent (
` <div style="background:#fff;border-radius:8px;padding:8px 12px;box-shadow:0 4px 12px rgba(0,0,0,.18);font-size:13px;font-weight:600;color:#111;white-space:nowrap;"> ${ escapeHtml ( r . name ) } </div> `
) ;
iw . open ( m , marker ) ;
}
onSelectRestaurant ? . ( r ) ;
} ) ;
markersRef . current . push ( marker ) ;
}
}
} , [ clusters , getExpansionZoom , onSelectRestaurant ] ) ;
// 컴포넌트 unmount 시 마커 정리
useEffect ( ( ) = > {
return ( ) = > {
for ( const mk of markersRef . current ) mk . setMap ( null ) ;
markersRef . current = [ ] ;
infoWindowRef . current ? . close ( ) ;
} ;
} , [ ] ) ;
return (
< div className = "relative w-full h-full" >
{ /* 지도 컨테이너 — 항상 마운트, 명시적 크기 보장 */ }
< div
ref = { divRef }
className = "absolute inset-0"
style = { { width : "100%" , height : "100%" , backgroundColor : "#e5e7eb" } }
/ >
{ /* 로딩/에러 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
{ error ? ? initError }
< / div >
) }
{ ! ready && ! error && (
@@ -198,54 +280,11 @@ export default function NaverMapView({
네 이 버 지 도 로 딩 중 …
< / div >
) }
{ /* 마커 overlay */ }
{ clusters . map ( ( feature ) = > {
const [ lng , lat ] = feature . geometry . coordinates ;
const pt = toScreen ( lat , lng ) ;
if ( ! pt ) return null ;
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 (
< button
key = { ` c- ${ cluster_id } ` }
onClick = { ( ) = > {
const z = Math . min ( getExpansionZoom ( cluster_id ) , 18 ) ;
const m = mapRef . current ;
if ( ! m || ! window . naver ? . maps ) return ;
m . panTo ( new window . naver . maps . LatLng ( lat , lng ) ) ;
m . setZoom ( z , true ) ;
} }
className = "absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-500/90 text-white font-semibold shadow-lg ring-2 ring-white"
style = { { left : pt.x , top : pt.y , width : size , height : size , fontSize : size > 44 ? 14 : 12 } }
>
{ point_count }
< / button >
) ;
}
const r = ( feature . properties as RestaurantProps ) . restaurant ;
const cuisineIcon = getCuisineIcon ( r . cuisine_type ) ;
const isSel = selected ? . id === r . id ;
return (
< button
key = { r . id }
onClick = { ( ) = > onSelectRestaurant ? . ( r ) }
className = { ` absolute -translate-x-1/2 -translate-y-full rounded-full shadow-md ring-2 ring-white transition-transform ${ isSel ? "scale-125 z-10" : "" } ` }
style = { { left : pt.x , top : pt.y , width : 32 , height : 32 , background : "#f59e0b" , color : "#78350f" } }
title = { r . name }
>
< Icon name = { cuisineIcon } size = { 18 } / >
< / button >
) ;
} ) }
{ onMyLocation && (
< button
onClick = { onMyLocation }
aria-label = "내 위치"
className = "absolute right-3 bottom-3 size-11 rounded-full bg-white shadow-lg flex items-center justify-center"
className = "absolute right-3 bottom-3 size-11 rounded-full bg-white shadow-lg flex items-center justify-center z-10 "
>
< Icon name = "my-location" size = { 22 } / >
< / button >
@@ -253,3 +292,7 @@ export default function NaverMapView({
< / div >
) ;
}
function escapeHtml ( s : string ) : string {
return s . replace ( /[&<>"']/g , ( ch ) = > ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ ch ] as string ) ) ;
}