2026-07-11 08:20:19 +09:00
|
|
|
<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'
|
2026-07-11 09:22:03 +09:00
|
|
|
import { loadNaverMaps, onNaverAuthFailure } from '@/lib/naverMap'
|
2026-07-11 08:20:19 +09:00
|
|
|
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 () => {
|
2026-07-11 09:22:03 +09:00
|
|
|
onNaverAuthFailure(() => {
|
|
|
|
|
errorMsg.value = '지도 인증 실패 · NCP 키가 유효한지, 서비스 URL(localhost:9000)이 등록됐는지 확인하세요.'
|
|
|
|
|
})
|
2026-07-11 08:20:19 +09:00
|
|
|
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,
|
2026-07-12 13:13:39 +09:00
|
|
|
// 확대/축소 +/- 컨트롤 (핀치 줌도 함께 동작)
|
|
|
|
|
zoomControl: true,
|
|
|
|
|
zoomControlOptions: { position: nv.maps.Position.RIGHT_CENTER },
|
2026-07-11 08:20:19 +09:00
|
|
|
})
|
|
|
|
|
infoWindow = new nv.maps.InfoWindow({ content: '', borderWidth: 0, disableAnchor: true })
|
2026-07-12 13:13:39 +09:00
|
|
|
// 지도 아무 곳이나 탭하면 가장 가까운 스팟 선택 (마커가 겹쳐도 선택 가능)
|
|
|
|
|
nv.maps.Event.addListener(map, 'click', (e: naver.maps.PointerEvent) => {
|
|
|
|
|
if (!e?.coord) return
|
|
|
|
|
const coord = e.coord as naver.maps.LatLng
|
|
|
|
|
const spot = nearestSpot(coord.lat(), coord.lng())
|
|
|
|
|
if (spot) emit('select', spot.id)
|
|
|
|
|
})
|
2026-07-11 08:20:19 +09:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 13:13:39 +09:00
|
|
|
/** 클릭 좌표에서 가장 가까운 스팟(위경도 제곱거리 최소). */
|
|
|
|
|
function nearestSpot(lat: number, lng: number): Spot | null {
|
|
|
|
|
let best: Spot | null = null
|
|
|
|
|
let bestDist = Infinity
|
|
|
|
|
for (const s of props.spots) {
|
|
|
|
|
if (s.latitude == null || s.longitude == null) continue
|
|
|
|
|
const dLat = s.latitude - lat
|
|
|
|
|
const dLng = s.longitude - lng
|
|
|
|
|
const dist = dLat * dLat + dLng * dLng
|
|
|
|
|
if (dist < bestDist) {
|
|
|
|
|
bestDist = dist
|
|
|
|
|
best = s
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 08:20:19 +09:00
|
|
|
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>
|