feat: 네이버 지도 연동 (스팟 마커)

- 네이버 지도 JS SDK 동적 로더(싱글턴) + @types/navermaps 타입
- NaverMap 컴포넌트: 스팟 실좌표 마커, 클릭 시 스팟 선택/정보창, 선택 시 중심 이동
- HomePage 지도 자리를 네이버 지도로 교체, 마커 선택 → 해당 스팟 한줄평/메이트 로드
- 키(VITE_NAVER_MAP_CLIENT_ID) 미설정 시 폴백 처리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 08:20:19 +09:00
parent bbafb3ed4d
commit f089ba2dfe
7 changed files with 205 additions and 17 deletions
+129
View File
@@ -0,0 +1,129 @@
<template>
<div class="map-wrap">
<div ref="mapEl" class="map-canvas"></div>
<!-- SDK 미로드/ 미설정 폴백 -->
<div v-if="errorMsg" class="map-fallback flex flex-center column text-grey-6">
<q-icon name="map" size="40px" color="secondary" />
<div class="q-mt-sm text-caption text-center">{{ errorMsg }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { loadNaverMaps } from '@/lib/naverMap'
import type { Spot } from '@/api/spots'
const props = defineProps<{
spots: Spot[]
selectedId: number | null
}>()
const emit = defineEmits<{ (e: 'select', id: number): void }>()
// 기본 중심: 서울시청
const DEFAULT_CENTER = { lat: 37.5666, lng: 126.9784 }
const mapEl = ref<HTMLElement | null>(null)
const errorMsg = ref('')
let map: naver.maps.Map | null = null
let infoWindow: naver.maps.InfoWindow | null = null
const markers = new Map<number, naver.maps.Marker>()
onMounted(async () => {
try {
const nv = await loadNaverMaps()
if (!mapEl.value) return
const center = firstCenter()
map = new nv.maps.Map(mapEl.value, {
center: new nv.maps.LatLng(center.lat, center.lng),
zoom: 14,
})
infoWindow = new nv.maps.InfoWindow({ content: '', borderWidth: 0, disableAnchor: true })
renderMarkers(nv)
} catch (e) {
errorMsg.value = `${(e as Error).message} 지도 키(VITE_NAVER_MAP_CLIENT_ID)를 설정하세요.`
}
})
onBeforeUnmount(() => {
markers.forEach((m) => m.setMap(null))
markers.clear()
map?.destroy()
})
// 스팟 목록이 갱신되면 마커 다시 그림
watch(
() => props.spots,
() => {
if (map && window.naver?.maps) renderMarkers(window.naver)
},
)
// 외부에서 선택된 스팟이 바뀌면 지도 중심 이동 + 정보창
watch(
() => props.selectedId,
(id) => {
if (!map || id == null) return
const spot = props.spots.find((s) => s.id === id)
const marker = markers.get(id)
if (spot?.latitude != null && spot?.longitude != null) {
map.panTo(new window.naver.maps.LatLng(spot.latitude, spot.longitude))
}
if (marker) openInfo(spot!, marker)
},
)
function firstCenter() {
const withCoord = props.spots.find((s) => s.latitude != null && s.longitude != null)
return withCoord ? { lat: withCoord.latitude!, lng: withCoord.longitude! } : DEFAULT_CENTER
}
function renderMarkers(nv: typeof naver) {
markers.forEach((m) => m.setMap(null))
markers.clear()
for (const spot of props.spots) {
if (spot.latitude == null || spot.longitude == null) continue
const marker = new nv.maps.Marker({
position: new nv.maps.LatLng(spot.latitude, spot.longitude),
map: map!,
title: spot.name,
})
nv.maps.Event.addListener(marker, 'click', () => {
openInfo(spot, marker)
emit('select', spot.id)
})
markers.set(spot.id, marker)
}
}
function openInfo(spot: Spot, marker: naver.maps.Marker) {
if (!infoWindow || !map) return
infoWindow.setContent(
`<div style="padding:8px 12px;font-size:13px;font-weight:600;color:#3E92CC;white-space:nowrap;">📍 ${spot.name}</div>`,
)
infoWindow.open(map, marker)
}
</script>
<style scoped>
.map-wrap {
position: relative;
height: 240px;
border-radius: 8px;
overflow: hidden;
}
.map-canvas {
width: 100%;
height: 100%;
}
.map-fallback {
position: absolute;
inset: 0;
background: linear-gradient(160deg, #eaf5fe 0%, #d7ecfb 100%);
padding: 16px;
}
</style>