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:
@@ -1,2 +1,7 @@
|
|||||||
# 백엔드 API 주소 (dognation_BT)
|
# 백엔드 API 주소 (dognation_BT)
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
VITE_API_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# 네이버 지도 (네이버 클라우드 플랫폼 > Maps 에서 발급)
|
||||||
|
VITE_NAVER_MAP_CLIENT_ID=your_ncp_key_id
|
||||||
|
# 신규 키는 ncpKeyId(기본), 구형 키면 ncpClientId 로 변경
|
||||||
|
# VITE_NAVER_MAP_AUTH_PARAM=ncpKeyId
|
||||||
|
|||||||
Generated
+18
@@ -21,6 +21,7 @@
|
|||||||
"@capacitor/cli": "^8.3.4",
|
"@capacitor/cli": "^8.3.4",
|
||||||
"@capacitor/ios": "^8.3.4",
|
"@capacitor/ios": "^8.3.4",
|
||||||
"@quasar/vite-plugin": "^1.9.0",
|
"@quasar/vite-plugin": "^1.9.0",
|
||||||
|
"@types/navermaps": "^3.7.5",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"sass-embedded": "^1.83.0",
|
"sass-embedded": "^1.83.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
@@ -1525,6 +1526,23 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/navermaps": {
|
||||||
|
"version": "3.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/navermaps/-/navermaps-3.9.2.tgz",
|
||||||
|
"integrity": "sha512-pPO38MFRRY+2wka0VdG3U9RlwLEVBwJVYy50KtQzwxNdJVVfas8Qlv4USx3wxvly7GNX9Cew2qfjdKEmLoQPKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.1.1",
|
"version": "26.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@capacitor/android": "^8.3.4",
|
"@capacitor/android": "^8.3.4",
|
||||||
"@capacitor/cli": "^8.3.4",
|
"@capacitor/cli": "^8.3.4",
|
||||||
"@capacitor/ios": "^8.3.4",
|
"@capacitor/ios": "^8.3.4",
|
||||||
|
"@types/navermaps": "^3.7.5",
|
||||||
"@quasar/vite-plugin": "^1.9.0",
|
"@quasar/vite-plugin": "^1.9.0",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"sass-embedded": "^1.83.0",
|
"sass-embedded": "^1.83.0",
|
||||||
|
|||||||
@@ -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>
|
||||||
Vendored
+7
@@ -1,4 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="navermaps" />
|
||||||
|
|
||||||
declare module '*.vue' {
|
declare module '*.vue' {
|
||||||
import type { DefineComponent } from 'vue'
|
import type { DefineComponent } from 'vue'
|
||||||
@@ -8,8 +9,14 @@ declare module '*.vue' {
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL: string
|
readonly VITE_API_BASE_URL: string
|
||||||
|
readonly VITE_NAVER_MAP_CLIENT_ID: string
|
||||||
|
readonly VITE_NAVER_MAP_AUTH_PARAM?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
naver: typeof naver
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// 네이버 지도 JS SDK 동적 로더 (싱글턴)
|
||||||
|
// 키는 네이버 클라우드 플랫폼(Maps)에서 발급. .env 의 VITE_NAVER_MAP_CLIENT_ID 로 주입.
|
||||||
|
let loadPromise: Promise<typeof naver> | null = null
|
||||||
|
|
||||||
|
export function loadNaverMaps(): Promise<typeof naver> {
|
||||||
|
if (typeof window !== 'undefined' && window.naver?.maps) {
|
||||||
|
return Promise.resolve(window.naver)
|
||||||
|
}
|
||||||
|
if (loadPromise) {
|
||||||
|
return loadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = import.meta.env.VITE_NAVER_MAP_CLIENT_ID
|
||||||
|
// 신규 키는 ncpKeyId, 구형 키는 ncpClientId. 필요 시 env 로 전환.
|
||||||
|
const authParam = import.meta.env.VITE_NAVER_MAP_AUTH_PARAM || 'ncpKeyId'
|
||||||
|
|
||||||
|
loadPromise = new Promise<typeof naver>((resolve, reject) => {
|
||||||
|
if (!clientId) {
|
||||||
|
reject(new Error('VITE_NAVER_MAP_CLIENT_ID 가 설정되지 않았습니다.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = `https://openapi.map.naver.com/openapi/v3/maps.js?${authParam}=${clientId}`
|
||||||
|
script.async = true
|
||||||
|
script.onload = () => resolve(window.naver)
|
||||||
|
script.onerror = () => reject(new Error('네이버 지도 SDK 를 불러오지 못했습니다.'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
return loadPromise
|
||||||
|
}
|
||||||
+15
-17
@@ -1,13 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page class="q-pa-md">
|
<q-page class="q-pa-md">
|
||||||
<!-- 지도 영역 (추후 네이버/카카오 지도 SDK 연동 자리) -->
|
<!-- 네이버 지도: 스팟 마커 표시, 마커 클릭 시 해당 스팟 선택 -->
|
||||||
<q-card flat bordered class="map-placeholder flex flex-center q-mb-md">
|
<q-card flat bordered class="q-mb-md overflow-hidden">
|
||||||
<div class="text-center text-grey-6">
|
<NaverMap :spots="spots" :selected-id="currentSpot?.id ?? null" @select="onSelectSpot" />
|
||||||
<q-icon name="map" size="48px" color="secondary" />
|
|
||||||
<div class="q-mt-sm text-caption">
|
|
||||||
{{ currentSpot ? currentSpot.name : '스팟 불러오는 중…' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<!-- 로딩 / 에러 -->
|
<!-- 로딩 / 에러 -->
|
||||||
@@ -80,10 +75,12 @@ import { spotsApi, type Mate, type Review, type Spot } from '@/api/spots'
|
|||||||
import { matchesApi } from '@/api/matches'
|
import { matchesApi } from '@/api/matches'
|
||||||
import { ApiError } from '@/api/http'
|
import { ApiError } from '@/api/http'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import NaverMap from '@/components/NaverMap.vue'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
|
||||||
|
const spots = ref<Spot[]>([])
|
||||||
const currentSpot = ref<Spot | null>(null)
|
const currentSpot = ref<Spot | null>(null)
|
||||||
const reviews = ref<Review[]>([])
|
const reviews = ref<Review[]>([])
|
||||||
const mates = ref<Mate[]>([])
|
const mates = ref<Mate[]>([])
|
||||||
@@ -97,8 +94,8 @@ onMounted(load)
|
|||||||
async function load() {
|
async function load() {
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
try {
|
try {
|
||||||
const spots = await spotsApi.list()
|
spots.value = await spotsApi.list()
|
||||||
currentSpot.value = spots[0] ?? null
|
currentSpot.value = spots.value[0] ?? null
|
||||||
if (currentSpot.value) {
|
if (currentSpot.value) {
|
||||||
await refreshSpot(currentSpot.value.id)
|
await refreshSpot(currentSpot.value.id)
|
||||||
}
|
}
|
||||||
@@ -108,6 +105,14 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSelectSpot(spotId: number) {
|
||||||
|
const spot = spots.value.find((s) => s.id === spotId)
|
||||||
|
if (!spot) return
|
||||||
|
currentSpot.value = spot
|
||||||
|
checkedIn.value = false
|
||||||
|
await refreshSpot(spotId)
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshSpot(spotId: number) {
|
async function refreshSpot(spotId: number) {
|
||||||
const [r, m] = await Promise.all([spotsApi.reviews(spotId), spotsApi.mates(spotId)])
|
const [r, m] = await Promise.all([spotsApi.reviews(spotId), spotsApi.mates(spotId)])
|
||||||
reviews.value = r
|
reviews.value = r
|
||||||
@@ -147,10 +152,3 @@ function notifyError(e: unknown) {
|
|||||||
$q.notify({ color: 'negative', message, icon: 'error', position: 'top' })
|
$q.notify({ color: 'negative', message, icon: 'error', position: 'top' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.map-placeholder {
|
|
||||||
height: 240px;
|
|
||||||
background: linear-gradient(160deg, #eaf5fe 0%, #d7ecfb 100%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user