@@ -3,8 +3,8 @@
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
import Supercluster from "supercluster" ;
import Supercluster from "supercluster" ;
import type { Restaurant } from "@/lib/api" ;
import type { Restaurant } from "@/lib/api" ;
import { getCuisineIcon } from "@/lib/cuisine-icons" ;
import Icon from "@/components/Icon" ;
import Icon from "@/components/Icon" ;
import { getCuisineIcon } from "@/lib/cuisine-icons" ;
import type { MapBounds , MapViewProps } from "@/components/MapView.types" ;
import type { MapBounds , MapViewProps } from "@/components/MapView.types" ;
declare global {
declare global {
@@ -12,26 +12,63 @@ declare global {
naver ? : { maps : NaverMaps } ;
naver ? : { maps : NaverMaps } ;
}
}
}
}
type LatLng = { lat : ( ) = > number ; lng : ( ) = > number } ;
type NaverMaps = {
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 ;
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 ;
Size : new ( w : number , h : number ) = > unknown ;
Point : new ( x : number , y : number ) = > unknown ;
} ;
} ;
type NaverMapInstance = {
type NaverMapInstance = {
setCenter : ( latlng : unknown ) = > void ;
setCenter : ( latlng : unknown ) = > void ;
setZoom : ( zoom : number , useEffect? : boolean ) = > void ;
setZoom : ( zoom : number , useEffect? : boolean ) = > void ;
getCenter : ( ) = > { lat : ( ) = > number ; lng : ( ) = > number } ;
getZoom : ( ) = > number ;
getZoom : ( ) = > number ;
getBounds : ( ) = > { getNE : ( ) = > { lat : ( ) = > number ; lng : ( ) = > number } ; getSW : ( ) = > { lat : ( ) = > number ; lng : ( ) = > number } } ;
getBounds : ( ) = > { getNE : ( ) = > LatLng ; getSW : ( ) = > LatLng } ;
getProjection : ( ) = > { fromCoordToOffset : ( latlng : unknown ) = > { x : number ; y : number } } ;
panTo : ( latlng : unknown , opts? : Record < string , unknown > ) = > void ;
panTo : ( latlng : unknown , opts? : Record < string , unknown > ) = > void ;
refresh : ( noEffect? : boolean ) = > 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 || "" ;
const NAVER_CLIENT_ID = process . env . NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "" ;
// Channel color palette — GoogleMapView와 동일
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 < string > ( ) ;
restaurants . forEach ( ( r ) = > r . channels ? . forEach ( ( ch ) = > channels . add ( ch ) ) ) ;
const map : Record < string , typeof CHANNEL_COLORS [ 0 ] > = { } ;
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 } {
function useNaverMaps ( ) : { ready : boolean ; error : string | null } {
const [ ready , setReady ] = useState ( typeof window !== "undefined" && ! ! window . naver ? . maps ) ;
const [ ready , setReady ] = useState ( typeof window !== "undefined" && ! ! window . naver ? . maps ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
@@ -89,6 +126,35 @@ function getClusterSize(count: number): number {
return 54 ;
return 54 ;
}
}
// 단일 마커 — 식당명 박스 + 화살표 핀 (GoogleMapView와 동일 디자인)
function markerIconHtml (
name : string ,
cuisineIcon : string ,
c : typeof CHANNEL_COLORS [ 0 ] ,
opts : { isSelected : boolean ; isClosed : boolean }
) : string {
const { isSelected , isClosed } = opts ;
const bg = isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c . bg ;
const text = isSelected ? "#fff" : isClosed ? "#9ca3af" : c . text ;
const border = isSelected ? "2px solid #1d4ed8" : ` 1.5px solid ${ c . border } ` ;
const shadow = isSelected ? "0 2px 8px rgba(37,99,235,0.4)" : ` 0 1px 4px ${ c . border } 40 ` ;
const arrowColor = isSelected ? "#1d4ed8" : c . arrow ;
const opacity = isClosed ? 0.5 : 1 ;
const deco = isClosed ? "line-through" : "none" ;
return `
<div style="display:flex;flex-direction:column;align-items:center;transition:transform .2s ease;transform:scale( ${ isSelected ? 1.15 : 1 } );opacity: ${ opacity } ;">
<div style="padding:4px 8px;background: ${ bg } ;color: ${ text } ;font-size:12px;font-weight:600;border-radius:6px;border: ${ border } ;box-shadow: ${ shadow } ;white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis;text-decoration: ${ deco } ;">
<span class="material-symbols-rounded" style="font-size:14px;width:14px;height:14px;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;margin-right:3px;vertical-align:middle;color:#E8720C;"> ${ escapeHtml ( cuisineIcon ) } </span> ${ escapeHtml ( name ) }
</div>
<div style="width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid ${ arrowColor } ;margin-top:-1px;"></div>
</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 ( {
export default function NaverMapView ( {
restaurants ,
restaurants ,
selected ,
selected ,
@@ -96,16 +162,20 @@ export default function NaverMapView({
onBoundsChanged ,
onBoundsChanged ,
flyTo ,
flyTo ,
onMyLocation ,
onMyLocation ,
activeChannel ,
} : MapViewProps ) {
} : MapViewProps ) {
const channelColors = useMemo ( ( ) = > getChannelColorMap ( restaurants ) , [ restaurants ] ) ;
const { ready , error } = useNaverMaps ( ) ;
const { ready , error } = useNaverMaps ( ) ;
const divRef = useRef < HTMLDivElement | null > ( null ) ;
const divRef = useRef < HTMLDivElement | null > ( null ) ;
const mapRef = useRef < NaverMapInstance | 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 [ bounds , setBounds ] = useState < MapBounds | null > ( null ) ;
const [ zoom , setZoom ] = useState ( 13 ) ;
const [ zoom , setZoom ] = useState ( 13 ) ;
const [ initError , setInitError ] = useState < string | null > ( null ) ;
const [ initError , setInitError ] = useState < string | null > ( null ) ;
const { getClusters , getExpansionZoom } = useSupercluster ( restaurants ) ;
const { getClusters , getExpansionZoom } = useSupercluster ( restaurants ) ;
// 지도 1회 생성 — divRef 항상 마운트되므로 ready 시점에 안정적으로 잡힘
// 지도 1회 생성
useEffect ( ( ) = > {
useEffect ( ( ) = > {
if ( ! ready || ! divRef . current || mapRef . current ) return ;
if ( ! ready || ! divRef . current || mapRef . current ) return ;
try {
try {
@@ -122,13 +192,20 @@ export default function NaverMapView({
zoomControl : false ,
zoomControl : false ,
} ) ;
} ) ;
mapRef . current = m ;
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 ( ( ) = > {
const ro = new ResizeObserver ( ( ) = > {
try { m . refresh ( true ) ; } catch { /* noop */ }
try { m . refresh ( true ) ; } catch { /* noop */ }
} ) ;
} ) ;
ro . observe ( divRef . current ) ;
ro . observe ( divRef . current ) ;
// bounds_changed가 줌/팬 끝나는 시점에 한 번만 emit (SDK가 throttle)
const sync = ( ) = > {
const sync = ( ) = > {
try {
try {
const b = m . getBounds ( ) ;
const b = m . getBounds ( ) ;
@@ -141,13 +218,12 @@ export default function NaverMapView({
console . warn ( "[NaverMap] sync failed" , e ) ;
console . warn ( "[NaverMap] sync failed" , e ) ;
}
}
} ;
} ;
// 컨테이너 크기가 정해진 다음 sync (rAF로 다음 프레임)
requestAnimationFrame ( ( ) = > {
requestAnimationFrame ( ( ) = > {
try { m . refresh ( true ) ; } catch { /* noop */ }
try { m . refresh ( true ) ; } catch { /* noop */ }
sync ( ) ;
sync ( ) ;
} ) ;
} ) ;
n . Event . addListener ( m , "bounds_changed" , sync ) ;
// idle = 줌/팬 끝났을 때 한 번 (bounds_changed보다 적게 발화 → 성능)
n . Event . addListener ( m , "zoom_changed " , sync ) ;
n . Event . addListener ( m , "idle " , sync ) ;
} catch ( e ) {
} catch ( e ) {
const msg = e instanceof Error ? e.message : String ( e ) ;
const msg = e instanceof Error ? e.message : String ( e ) ;
console . error ( "[NaverMap] init failed" , e ) ;
console . error ( "[NaverMap] init failed" , e ) ;
@@ -163,34 +239,88 @@ export default function NaverMapView({
if ( flyTo . zoom ) m . setZoom ( flyTo . zoom , true ) ;
if ( flyTo . zoom ) m . setZoom ( flyTo . zoom , true ) ;
} , [ flyTo ] ) ;
} , [ flyTo ] ) ;
// 클러스터 계산 (bounds/zoom 변경 시)
const clusters = useMemo ( ( ) = > {
const clusters = useMemo ( ( ) = > {
if ( ! bounds ) return [ ] ;
if ( ! bounds ) return [ ] ;
return getClusters ( bounds , zoom ) ;
return getClusters ( bounds , zoom ) ;
} , [ bounds , zoom , getClusters ] ) ;
} , [ bounds , zoom , getClusters ] ) ;
const toScreen = useCallback ( ( lat : number , lng : number ) = > {
// 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성
useEffect ( ( ) = > {
const m = mapRef . current ;
const m = mapRef . current ;
if ( ! m || ! window . naver ? . maps ) return null ;
const naver = window . naver ? . maps ;
try {
if ( ! m || ! naver ) return ;
return m . getProjection ( ) . fromCoordToOffset ( new window . naver . maps . LatLng ( lat , lng ) ) ;
} catch {
// 기존 마커 제거
return null ;
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 isSelected = selected ? . id === r . id ;
const isClosed = r . business_status === "CLOSED_PERMANENTLY" || r . business_status === "CLOSED_TEMPORARILY" ;
const cuisineIcon = getCuisineIcon ( r . cuisine_type ) ;
const marker = new naver . Marker ( {
position : new naver . LatLng ( lat , lng ) ,
map : m ,
title : r.name ,
zIndex : isSelected ? 1000 : 1 ,
icon : {
content : markerIconHtml ( r . name , cuisineIcon , chColor ? ? CHANNEL_COLORS [ 0 ] , { isSelected , isClosed } ) ,
// 박스 폭 가변 — 화살표 끝(하단 중앙)이 좌표에 위치하도록 추정 anchor
// approxWidth = textLen * 7 + 30 (icon+padding), height = box 24 + arrow 6 = 30
anchor : new naver . Point ( Math . min ( r . name . length * 4 + 18 , 64 ) , 30 ) ,
} ,
} ) ;
naver . Event . addListener ( marker , "click" , ( ) = > {
onSelectRestaurant ? . ( r ) ;
} ) ;
markersRef . current . push ( marker ) ;
}
}
}
} , [ clusters , getExpansionZoom , onSelectRestaurant , channelColors , activeChannel , selected ] ) ;
// 컴포넌트 unmount 시 마커 정리
useEffect ( ( ) = > {
return ( ) = > {
for ( const mk of markersRef . current ) mk . setMap ( null ) ;
markersRef . current = [ ] ;
infoWindowRef . current ? . close ( ) ;
} ;
} , [ ] ) ;
} , [ ] ) ;
return (
return (
< div className = "relative w-full h-full" >
< div className = "relative w-full h-full" >
{ /* 지도 컨테이너 — 항상 마운트, 명시적 크기 보장 */ }
< div
< div
ref = { divRef }
ref = { divRef }
className = "absolute inset-0"
className = "absolute inset-0"
style = { { width : "100%" , height : "100%" , backgroundColor : "#e5e7eb" } }
style = { { width : "100%" , height : "100%" , backgroundColor : "#e5e7eb" } }
/ >
/ >
{ /* 로딩/에러 overlay */ }
{ ( error || initError ) && (
{ ( error || initError ) && (
< div className = "absolute inset-0 flex items-center justify-center text-xs text-red-600 bg-white/80 pointer-events-none" >
< 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 >
< / div >
) }
) }
{ ! ready && ! error && (
{ ! ready && ! error && (
@@ -198,54 +328,11 @@ export default function NaverMapView({
네 이 버 지 도 로 딩 중 …
네 이 버 지 도 로 딩 중 …
< / div >
< / 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 && (
{ onMyLocation && (
< button
< button
onClick = { onMyLocation }
onClick = { onMyLocation }
aria-label = "내 위치"
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 } / >
< Icon name = "my-location" size = { 22 } / >
< / button >
< / button >
@@ -253,3 +340,7 @@ export default function NaverMapView({
< / div >
< / div >
) ;
) ;
}
}
function escapeHtml ( s : string ) : string {
return s . replace ( /[&<>"']/g , ( ch ) = > ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ ch ] as string ) ) ;
}