홈 지도를 Kakao Maps JS SDK 로 교체. ZoomControl(확대/축소)과 현재위치 버튼(@capacitor/geolocation → 파란 점 표시) 추가. naverMap.ts / NaverMap.vue 는 미사용으로 보존. 시뮬레이터에서 렌더·현재위치 확인. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+6
-1
@@ -1,7 +1,12 @@
|
||||
# 백엔드 API 주소 (dognation_BT)
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
|
||||
# 네이버 지도 (네이버 클라우드 플랫폼 > Maps 에서 발급)
|
||||
# 지도: 현재 카카오 지도 사용 (홈 화면). 네이버 키는 참고용으로 남겨둠.
|
||||
# 카카오 지도 (Kakao Developers > 내 애플리케이션 > 앱 키 > JavaScript 키)
|
||||
# 콘솔 > 플랫폼 > Web 에 서비스 도메인 등록 필요 (개발: http://localhost:9000)
|
||||
VITE_KAKAO_MAP_KEY=your_kakao_javascript_key
|
||||
|
||||
# 네이버 지도 (네이버 클라우드 플랫폼 > Maps) — 현재 미사용, 코드는 유지
|
||||
VITE_NAVER_MAP_CLIENT_ID=your_ncp_key_id
|
||||
# 신규 키는 ncpKeyId(기본), 구형 키면 ncpClientId 로 변경
|
||||
# VITE_NAVER_MAP_AUTH_PARAM=ncpKeyId
|
||||
|
||||
Generated
+19
@@ -11,6 +11,7 @@
|
||||
"@capacitor-community/apple-sign-in": "^7.1.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@quasar/extras": "^1.16.12",
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"pinia": "^2.3.0",
|
||||
@@ -158,6 +159,18 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/geolocation": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.0.tgz",
|
||||
"integrity": "sha512-N29QcoIPmme0xSxRkm7+3hjoHp6mBAOarxecvtCCZKyOBeKiJsFUq981cezg2XWBa6fhCXJMCCjQPngKK/dIag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor/synapse": "^1.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.1.tgz",
|
||||
@@ -168,6 +181,12 @@
|
||||
"@capacitor/core": "^8.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/synapse": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@capacitor-community/apple-sign-in": "^7.1.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@quasar/extras": "^1.16.12",
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"pinia": "^2.3.0",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="map-wrap">
|
||||
<div ref="mapEl" class="map-canvas"></div>
|
||||
|
||||
<!-- 현재위치로 이동 -->
|
||||
<q-btn
|
||||
v-if="!errorMsg"
|
||||
round
|
||||
dense
|
||||
color="white"
|
||||
text-color="primary"
|
||||
icon="my_location"
|
||||
class="locate-btn"
|
||||
:loading="locating"
|
||||
@click="locate"
|
||||
/>
|
||||
|
||||
<!-- 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 { useQuasar } from 'quasar'
|
||||
import { Geolocation } from '@capacitor/geolocation'
|
||||
import { loadKakaoMaps } from '@/lib/kakaoMap'
|
||||
import type { Spot } from '@/api/spots'
|
||||
|
||||
const props = defineProps<{
|
||||
spots: Spot[]
|
||||
selectedId: number | null
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'select', id: number): void }>()
|
||||
|
||||
const $q = useQuasar()
|
||||
|
||||
// 기본 중심: 서울시청
|
||||
const DEFAULT_CENTER = { lat: 37.5666, lng: 126.9784 }
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
const errorMsg = ref('')
|
||||
const locating = ref(false)
|
||||
|
||||
let map: kakao.maps.Map | null = null
|
||||
let infoWindow: kakao.maps.InfoWindow | null = null
|
||||
let myCircle: kakao.maps.Circle | null = null
|
||||
const markers = new Map<number, kakao.maps.Marker>()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const kk = await loadKakaoMaps()
|
||||
if (!mapEl.value) return
|
||||
|
||||
const center = firstCenter()
|
||||
map = new kk.maps.Map(mapEl.value, {
|
||||
center: new kk.maps.LatLng(center.lat, center.lng),
|
||||
level: 5, // 네이버 zoom 14 ≈ 카카오 level 5
|
||||
})
|
||||
// 확대/축소 +/- 컨트롤 (핀치 줌도 함께 동작)
|
||||
map.addControl(new kk.maps.ZoomControl(), kk.maps.ControlPosition.RIGHT)
|
||||
infoWindow = new kk.maps.InfoWindow({ removable: false })
|
||||
renderMarkers(kk)
|
||||
} catch (e) {
|
||||
errorMsg.value = `${(e as Error).message} 지도 키(VITE_KAKAO_MAP_KEY)를 설정하세요.`
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
markers.forEach((m) => m.setMap(null))
|
||||
markers.clear()
|
||||
myCircle?.setMap(null)
|
||||
})
|
||||
|
||||
/** 현재 위치로 지도 이동 + 파란 점 표시. (iOS 위치 권한 필요) */
|
||||
async function locate() {
|
||||
if (!map) return
|
||||
locating.value = true
|
||||
try {
|
||||
const pos = await Geolocation.getCurrentPosition({ enableHighAccuracy: true, timeout: 10000 })
|
||||
const kk = window.kakao
|
||||
const here = new kk.maps.LatLng(pos.coords.latitude, pos.coords.longitude)
|
||||
map.setLevel(4)
|
||||
map.panTo(here)
|
||||
if (!myCircle) {
|
||||
myCircle = new kk.maps.Circle({
|
||||
center: here,
|
||||
radius: 30,
|
||||
strokeWeight: 2,
|
||||
strokeColor: '#3E92CC',
|
||||
strokeOpacity: 0.9,
|
||||
fillColor: '#3E92CC',
|
||||
fillOpacity: 0.35,
|
||||
})
|
||||
myCircle.setMap(map)
|
||||
} else {
|
||||
myCircle.setPosition(here)
|
||||
}
|
||||
} catch {
|
||||
$q.notify({
|
||||
color: 'warning',
|
||||
message: '현재 위치를 가져오지 못했어요. 위치 권한을 확인하세요.',
|
||||
icon: 'location_off',
|
||||
position: 'top',
|
||||
})
|
||||
} finally {
|
||||
locating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 스팟 목록이 갱신되면 마커 다시 그림
|
||||
watch(
|
||||
() => props.spots,
|
||||
() => {
|
||||
if (map && window.kakao?.maps) renderMarkers(window.kakao)
|
||||
},
|
||||
)
|
||||
|
||||
// 외부에서 선택된 스팟이 바뀌면 지도 중심 이동 + 정보창
|
||||
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.kakao.maps.LatLng(spot.latitude, spot.longitude))
|
||||
}
|
||||
if (spot && 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(kk: typeof kakao) {
|
||||
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 kk.maps.Marker({
|
||||
position: new kk.maps.LatLng(spot.latitude, spot.longitude),
|
||||
map: map!,
|
||||
title: spot.name,
|
||||
})
|
||||
kk.maps.event.addListener(marker, 'click', () => {
|
||||
openInfo(spot, marker)
|
||||
emit('select', spot.id)
|
||||
})
|
||||
markers.set(spot.id, marker)
|
||||
}
|
||||
}
|
||||
|
||||
function openInfo(spot: Spot, marker: kakao.maps.Marker) {
|
||||
if (!infoWindow || !map) return
|
||||
infoWindow.setContent(
|
||||
`<div style="padding:6px 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%;
|
||||
}
|
||||
.locate-btn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.map-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(160deg, #eaf5fe 0%, #d7ecfb 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
Vendored
+2
@@ -11,6 +11,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_NAVER_MAP_CLIENT_ID: string
|
||||
readonly VITE_NAVER_MAP_AUTH_PARAM?: string
|
||||
readonly VITE_KAKAO_MAP_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
@@ -19,4 +20,5 @@ interface ImportMeta {
|
||||
|
||||
interface Window {
|
||||
naver: typeof naver
|
||||
kakao: typeof kakao
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// 카카오 지도 JS SDK 동적 로더 (싱글턴)
|
||||
// JavaScript 키는 Kakao Developers > 내 애플리케이션 > 앱 키 > JavaScript 키.
|
||||
// .env 의 VITE_KAKAO_MAP_KEY 로 주입. 콘솔 > 플랫폼 > Web 에 서비스 도메인 등록 필요
|
||||
// (개발: http://localhost:9000 / 라이브리로드 IP, 배포: 실제 도메인·capacitor 스킴).
|
||||
let loadPromise: Promise<typeof kakao> | null = null
|
||||
|
||||
export function loadKakaoMaps(): Promise<typeof kakao> {
|
||||
if (typeof window !== 'undefined' && window.kakao?.maps) {
|
||||
return Promise.resolve(window.kakao)
|
||||
}
|
||||
if (loadPromise) {
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
const appKey = import.meta.env.VITE_KAKAO_MAP_KEY
|
||||
|
||||
loadPromise = new Promise<typeof kakao>((resolve, reject) => {
|
||||
if (!appKey) {
|
||||
reject(new Error('VITE_KAKAO_MAP_KEY 가 설정되지 않았습니다.'))
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
// autoload=false → 스크립트 로드 후 kakao.maps.load 로 명시적으로 초기화한다.
|
||||
script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${appKey}&autoload=false`
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
// 키/도메인이 유효하지 않으면 load 콜백이 호출되지 않을 수 있어 타임아웃으로 방어.
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('카카오 지도 초기화 실패 · 키/도메인 등록을 확인하세요.')),
|
||||
8000,
|
||||
)
|
||||
window.kakao.maps.load(() => {
|
||||
clearTimeout(timer)
|
||||
resolve(window.kakao)
|
||||
})
|
||||
}
|
||||
script.onerror = () => reject(new Error('카카오 지도 SDK 를 불러오지 못했습니다.'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
return loadPromise
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<!-- 네이버 지도: 스팟 마커 표시, 마커 클릭 시 해당 스팟 선택 -->
|
||||
<!-- 카카오 지도: 스팟 마커 표시, 마커 클릭 시 해당 스팟 선택 -->
|
||||
<q-card flat bordered class="q-mb-md overflow-hidden">
|
||||
<NaverMap :spots="spots" :selected-id="currentSpot?.id ?? null" @select="onSelectSpot" />
|
||||
<KakaoMap :spots="spots" :selected-id="currentSpot?.id ?? null" @select="onSelectSpot" />
|
||||
</q-card>
|
||||
|
||||
<!-- 로딩 / 에러 -->
|
||||
@@ -99,7 +99,7 @@ import { matchesApi } from '@/api/matches'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDogsStore } from '@/stores/dogs'
|
||||
import NaverMap from '@/components/NaverMap.vue'
|
||||
import KakaoMap from '@/components/KakaoMap.vue'
|
||||
import ChatDialog from '@/components/ChatDialog.vue'
|
||||
|
||||
const $q = useQuasar()
|
||||
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
// 카카오 지도 JS SDK 최소 타입 선언 (프로젝트에서 사용하는 API 서브셋).
|
||||
// 공식 @types 가 없어 필요한 부분만 선언한다.
|
||||
declare namespace kakao.maps {
|
||||
class LatLng {
|
||||
constructor(lat: number, lng: number)
|
||||
getLat(): number
|
||||
getLng(): number
|
||||
}
|
||||
|
||||
class Map {
|
||||
constructor(container: HTMLElement, options: { center: LatLng; level?: number })
|
||||
panTo(latlng: LatLng): void
|
||||
setCenter(latlng: LatLng): void
|
||||
setLevel(level: number): void
|
||||
getLevel(): number
|
||||
addControl(control: ZoomControl, position: ControlPosition): void
|
||||
}
|
||||
|
||||
// 지도 확대/축소 +/- 위젯
|
||||
class ZoomControl {}
|
||||
|
||||
enum ControlPosition {
|
||||
TOP,
|
||||
TOPLEFT,
|
||||
TOPRIGHT,
|
||||
LEFT,
|
||||
RIGHT,
|
||||
BOTTOMLEFT,
|
||||
BOTTOM,
|
||||
BOTTOMRIGHT,
|
||||
}
|
||||
|
||||
// 현재위치 표시용 원(파란 점)
|
||||
class Circle {
|
||||
constructor(options: {
|
||||
center: LatLng
|
||||
radius: number
|
||||
strokeWeight?: number
|
||||
strokeColor?: string
|
||||
strokeOpacity?: number
|
||||
fillColor?: string
|
||||
fillOpacity?: number
|
||||
})
|
||||
setMap(map: Map | null): void
|
||||
setPosition(latlng: LatLng): void
|
||||
}
|
||||
|
||||
class Marker {
|
||||
constructor(options: { position: LatLng; map?: Map; title?: string })
|
||||
setMap(map: Map | null): void
|
||||
getPosition(): LatLng
|
||||
}
|
||||
|
||||
class InfoWindow {
|
||||
constructor(options: { content?: string; removable?: boolean })
|
||||
setContent(content: string): void
|
||||
open(map: Map, marker?: Marker): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
namespace event {
|
||||
function addListener(target: unknown, type: string, handler: () => void): void
|
||||
}
|
||||
|
||||
// autoload=false 로 로드한 뒤 SDK 초기화 콜백.
|
||||
function load(callback: () => void): void
|
||||
}
|
||||
Reference in New Issue
Block a user