feat: 지도 카카오로 전환 + 확대/축소·현재위치 (네이버 코드 유지)
CI / build (push) Failing after 11m24s

홈 지도를 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:
sb
2026-07-11 13:26:42 +09:00
parent b5f394a7bc
commit 0e69218710
8 changed files with 332 additions and 4 deletions
+193
View File
@@ -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>
+2
View File
@@ -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
}
+41
View File
@@ -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
}
+3 -3
View File
@@ -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()
+67
View File
@@ -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
}