[Vue.js] Vue Kakao 지도 구현 03 - Overlay 팝업 구현

Vue.js에서 Kakao 지도에 Overlay 팝업을 구현합니다.

[Vue.js] Vue Kakao 지도 구현 03 - Overlay 팝업 구현

프로젝트 구조

프로젝트구조
[ROOT] +- public | +- index.html +- src +- components/map | +- overlay | +- index.js (+) | | +- KakaoMap.vue (E) | +- marker-handler.js | +- service | +- api.js | +- App.vue (E) +- main.js
  • overlay/index.js - Overlay를 담당하는 클래스
  • App.vue - overaly에 사용될 element를 KakaoMap 안에 끼워넣음
  • KakaoMap.vue - overlay용 element를 slot으로 정의함

Section1 에서는 전체 소스코드를 첨부합니다.

상세 설명을 보려면 Section 2 로 이동합니다.

1. Source Code

src/components/map/overlay/index.js (+)

Overlay 팝업을 담당하는 클래스(새로 추가)

.../map/overlay/index.js
const kakao = window.kakao; class KakaoOverlay { constructor(vueMap, content) { this.vueMap = vueMap; this.content = content; // init instance this.instance = new kakao.maps.CustomOverlay({ map: null, clickable: false, content: content, position: null, xAnchor: 0.5, yAnchor: 1, zIndex: 3, }); } showAt(lat, lng) { // (lat, lng )에 보여줌 console.log("overlay show!!", lat, lng); this.instance.setMap(this.vueMap.mapInstance); const pos = new kakao.maps.LatLng(lat, lng); this.instance.setPosition(pos); } hide() { this.instance.setMap(null); } } export default KakaoOverlay;

src/App.vue

overlay에 사용될 element를 정의함

src/App.vue
<template> <div> <h3>kakao map demo(center, level)</h3> <div class="controll"> <button @click="zoom(-1)"> <span class="material-icons"> zoom_in </span> </button> <button @click="zoom(1)"> <span class="material-icons"> zoom_out </span> </button> </div> <div class="map-area"> <div class="harbors"> <div class="harbor" v-for="hbr in harbors" :key="hbr.seq" @click="showOnMap(hbr)" :class="{ active: hbr === activeHarbor }" > <h4>{{ hbr.place }}</h4> </div> </div> <KakaoMap ref="kmap" class="kmap" :options="mapOption"> <div class="overlay-popup" ref="harborOverlay" slot="overlay"> <div v-if="overlayHarber"> <h3>{{ overlayHarber.place }}</h3> <div class="addr">{{ overlayHarber.addr }}</div> <a class="close" href="#" @click.prevent="closeOverlay()" ><span class="material-icons"> close </span></a > </div> </div> </KakaoMap> </div> </div> </template> <script> import KakaoMap from "./components/map/KakaoMap.vue"; import api from "./service/api"; import MarkerHandler from "./components/map/marker-handler"; import KakaoOverlay from "./components/map/overlay"; export default { components: { KakaoMap, }, data() { return { mapOption: { center: { lat: 33.450701, lng: 126.570667, }, level: 8, }, harbors: [], // empty markers: null, // marker handler! activeHarbor: null, // selected harbor! overlay: null, // overlay 인스턴스 overlayHarber: null, // overlay에 보여줄 항구 }; }, mounted() { const vueKakaoMap = this.$refs.kmap; this.markers = new MarkerHandler(vueKakaoMap, { markerClicked: (harbor) => { console.log("[clicked ]", harbor); // this.activeHarbor = harbor; this.showOnMap(harbor); // 마커 클릭시 this.overlayHarber = harbor; this.overlay.showAt(harbor.lat, harbor.lng); }, }); this.overlay = new KakaoOverlay(vueKakaoMap, this.$refs.harborOverlay); api.harbor.all((res) => { console.log("[항구목록]", res.harbors); this.harbors = res.harbors; // create markers this.markers.add(this.harbors, (harbor) => { return { lat: harbor.lat, lng: harbor.lng }; }); }); }, methods: { zoom(delta) { // delta : 1 or -1 // console.log("[delta]", delta); const level = Math.max(3, this.mapOption.level + delta); // min level 3 this.mapOption.level = level; // console.log(this.mapOption.level); }, showOnMap(harbor) { this.activeHarbor = harbor; // console.log("[center]", harbor); this.mapOption.center = { lat: harbor.lat, lng: harbor.lng, }; }, closeOverlay() { this.overlay.hide(); }, }, }; </script> <style lang="scss"> button { border: 1px solid transparent; padding: 6px; background-color: #efefefdd; border-radius: 6px; &:hover { background-color: #ddd; border-color: #ddd; cursor: pointer; } &:active { background-color: #aaa; border-color: #aaa; } } .map-area { display: flex; .harbors { .harbor { padding: 10px; border: 1px solid transparent; &:hover { background-color: aliceblue; border-color: #6a9dff; cursor: pointer; } &:active { background-color: rgb(166, 197, 224); border-color: #4471c5; } &.active { background-color: rgb(253, 229, 150); border-color: rgb(211, 173, 3); } h4 { margin: 0; } } } .kmap { flex: 1 1 auto; .overlay-popup { background-color: #ffffffcc; box-shadow: 0 0 8px #0000004d, 0 0 1px 2px #00000022; max-width: 200px; min-width: 160px; position: absolute; bottom: 44px; left: 50%; transform: translateX(-50%); h3 { margin: 0; padding: 8px; padding-right: 24px; background-color: #ed4215; color: white; font-weight: 400; font-size: 16px; } .addr { padding: 8px; white-space: break-spaces; } .close { color: black; position: absolute; top: 0; left: 100%; transform: translate(-50%, -50%); background-color: white; border-radius: 100%; line-height: 0; padding: 6px; box-shadow: 0 0 6px #0000004d; } } } } </style>

src/components/map/KakaoMap.vue

  • named slot(<slot name="overlay"/>)을 정의
  • 중심 좌표 이동시 setCenter 대신 panTo메소드 사용
KakaoMap.vue
<template> <div ref="map"> <slot name="overlay"></slot> </div> </template> <script> let kakao = window.kakao; export default { props: ["options"], data() { return { mapInstance: null, }; }, mounted() { kakao = kakao || window.kakao; console.log(this.$refs.map); // should be not null var container = this.$refs.map; const { center, level } = this.options; this.mapInstance = new kakao.maps.Map(container, { center: new kakao.maps.LatLng(center.lat, center.lng), level, }); }, watch: { "options.level"(cur, prev) { console.log(`[LEVEL CHANGED] ${prev} => ${cur}`); // for testing this.mapInstance.setLevel(cur); }, "options.center"(cur) { this.mapInstance.panTo(new kakao.maps.LatLng(cur.lat, cur.lng)); // 부드럽게 이동 }, }, }; </script> <style> .kmap { height: 600px; } </style>

2. 설명

Kakao 지도 api에서 제공하는 CustomOverlay를 vue.js 기반 프로젝트에서 구현할때 해결해야할 부분이 있습니다.

아래 그림은 overlay 사용 예제 페이지의 모습인데

대응하는 코드는 아래와 같습니다.

JAVASCRIPT
// 커스텀 오버레이에 표출될 내용으로 HTML 문자열이나 document element가 가능합니다 var content = '<div class="customoverlay">' + ' <a href="https://map.kakao.com/link/map/11394059" target="_blank">' + ' <span class="title">구의야구공원</span>' + ' </a>' + '</div>';

위처럼 문자열로 overlay html을 조립하는 식은 vue.js와 같은 spa 프로젝트에서는 지양해야할 부분입니다.

여기서는 vue.js 에서 제공하는 <slot/>을 이용해서 overlay 화면을 렌더링하도록 합니다.

<slot /> 사용하기

overlay에서 사용할 html 을 KakaoMap 컴포넌트 안에 아래와 같이 끼워넣습니다.

App.vue
<template> <div> .... <div class="map-area"> .... <KakaoMap ref="kmap" class="kmap" :options="mapOption"> <div class="overlay-popup" ref="harborOverlay" slot="overlay"> <div> <h3>항구 이름 위치</h3> <div class="addr">항구 주소 위치</div> <a href="#">닫기</a> </div> </div> </KakaoMap> </div> </div> </template>
  • 항구 정보를 보여주기 위해서 제목(h3)과 주소(.addr) 를 준비함.
  • 닫기 버튼을 눌러 overlay를 닫는 기능도 추가함.
  • ref - overlay 생성시 전달하기 위해서 ref로 element를 참조함
  • slot="overlay" - KakaoMap 컴포넌트 안에 "overlay" 이름이 지정된 slot에 끼워넣겠음.

이제 KakaoMap.vue 안에서 대응하는 slot을 정의합니다.

KakaoMap.vue
<template> <div ref="map"> <slot name="overlay"></slot> </div> </template>

애플리케이션이 올바로 시작되면 slot 위치는 아래와 같이 App.vue에서 끼워넣었던 overlay의 element로 치환됩니다.

KakaoMap.vue
<template> <div ref="map"> <div class="overlay-popup"> <div> <h3>항구 이름 위치</h3> <div class="addr">항구 주소 위치</div> <a href="#">닫기</a> </div> </div> </div> </template>

<div ref="map"> 은 Kakao 지도 라이브러리가 지도 관련 이미지들을 끼워넣는 placeholder입니다. overlay의 element가 placehoder 의 자식 element로 존재하지만 overlay창을 띄울때 다른 곳으로 이동하게 됩니다.

overlay의 element가 다른 곳으로 이동해도 App.vue에서 닫기 버튼에 지정할 클릭 이벤트는 여전히 작동합니다.

KakaoOverlay 클래스 생성

공식 문서를 보면 overlay를 띄우는 코드는 아래와 같이 제공됩니다.

CustomOverlay 사용법
var customOverlay = new kakao.maps.CustomOverlay({ map: ..., content: ...., position: new kakao.maps.LatLng(33.450701, 126.570667), xAnchor: 0.5, yAnchor: 1, zIndex: 3 });

클래스 KakaoOverlay는 구체적인 overlay 생성 과정을 은닉하고, 부가 기능을 추가하는 곳입니다.

.../map/overlay/index.js
const kakao = window.kakao; class KakaoOverlay { constructor(vueMap, content) { this.vueMap = vueMap; this.content = content; // init instance this.instance = new kakao.maps.CustomOverlay({ ... }); } showAt(lat, lng) { // 주어진 위치에 overlay를 렌더링함 } hide() { // 아래와 같이 null을 지정하면 overlay가 닫힘 this.instance.setMap(null); } } export default KakaoOverlay;
  • Vue 파일이 아닌 일반 js 파일
  • vueMap - KakaoMap.vue 파일의 인스턴스(VueComponent)
  • content - overlay의 화면 내용. 공식 문서에서는 대부분 html을 직접 조립하는 예제들을 보여주지만 이 위치에 dom element를 전달할 수도 있습니다. 여기서는 App.vue에서 끼워넣었던 overlay element를 전달합니다.

KakaoOverlay 클래스는 App.vue에서 아래와 같이 사용합니다.

App.vue
<template> </template> <script> ... // (1) 임포트 import KakaoOverlay from "./components/map/overlay"; export default { data() { return { ... overlay: null, // (2) overlay 인스턴스 참조용 } }, mounted() { const vueKakaoMap = this.$refs.kmap; this.markers = .... // (3) 지도 컴포넌트와 overlay element를 전달 this.overlay = new KakaoOverlay(vueKakaoMap, this.$refs.harborOverlay); .... }, } </script>
  • (1) KakaoOverlay 클래스를 import
  • (2) overlay 인스턴스 참조할 변수
  • (3) 지도 컴포넌트(KakaoMap.vue의 인스턴스)와 overlay의 content로 사용할 element 참조를 전달해서 생성자를 호출

이제 KakaoOverlay 클래스에서는 아래와 같이 실제 overlay를 준비합니다.

.../map/overlay/index.js
const kakao = window.kakao; class KakaoOverlay { constructor(vueMap, content) { this.vueMap = vueMap; this.content = content; // init instance this.instance = new kakao.maps.CustomOverlay({ map: null, clickable: false, content: content, xAnchor: 0.5, yAnchor: 1, zIndex: 3, }); } .... } export default KakaoOverlay;
  • Kakao 지도 라이브러리에서 제공하는 CustomOverlay 클래스를 사용할때 map property 에 null을 지정해서 아직 화면에 띄우지는 않음.
  • xAnchor: 0.5 - 좌표가 overlay 화면의 좌우 너비의 가운데 위치하도록 함
  • yAnchor: 1 - 좌표가 overlay 화면의 높이에서 야래에 위치하도록 함
  • zIndex: 이 값을 조절해서 특정 overlay가 다른 overlay 위에 올라오도록 할 수도 있습니다.

실제로 좌표가 움직이지는 않습니다(움직이는건 overlay)

TXT
(xAhcnor: 0.5, yAnchor: 1) BBBBB BBBBB BBBBB + (xAhcnor: 0.5, yAnchor: 0) + BBBBB BBBBB BBBBB (xAhcnor: 0, yAnchor: 0) + BBBBB BBBBB BBBBB (xAhcnor: 0.5, yAnchor: 0.5) BBBBB BB+BB BBBBB

마커 클릭

이제 App.vue에서 마커를 클릭할때 항구의 위치값을 전달해서 overlay를 띄웁니다.

App.vue
export default { mounted() { .... this.markers = new MarkerHandler(vueKakaoMap, { markerClicked: (harbor) => { .... this.showOnMap(harbor); // 마커 클릭시 overlay를 보여줌 this.overlay.showAt(harbor.lat, harbor.lng); }, }); this.overlay = ... .... },
  • showAt(lat, lng) 를 호출해서 overlay 를 띄움

KakaoOverlay 클래스에 showAt 메소드를 구현합니다.

.../map/overlay/index.js
const kakao = window.kakao; class KakaoOverlay { constructor(vueMap, content) { this.vueMap = vueMap; this.content = content; this.instance = .... .... } showAt(lat, lng) { this.instance.setMap(this.vueMap.mapInstance); const pos = new kakao.maps.LatLng(lat, lng); this.instance.setPosition(pos); } }
  • this.instance - kakao.mpas.CustomOverlay 로 생성했던 Overlay 객체
  • this.vueMap.mapInstance - kakao.maps.Map 으로 생성했던 지도 객체
  • setMap, setPosition 으로 위치 지정 후 지도에 렌더링함

Overly Design

App.vue에서 overlay를 아래와 같이 디자인 합니다.

App.vue
<style lang="scss"> .... .map-area { .kmap { .... .overlay-popup { background-color: #ffffffcc; box-shadow: 0 0 8px #0000004d, 0 0 1px 2px #00000022; max-width: 200px; min-width: 160px; h3 { margin: 0; padding: 8px; padding-right: 24px; background-color: #ed4215; color: white; font-weight: 400; font-size: 16px; } .addr { padding: 8px; white-space: break-spaces; } } } } </style>

마커를 클릭하면 아래와 같이 좌표 위에 overlay가 나타납니다.

overlay는 원하는 위치에 렌더링 되었으나 마커와 겹쳐서 보기에 좋지 않습니다. .overlay-popup에 위치를 수정하는 스타일을 추가합니다.

App.vue
<style lang="scss"> .... .map-area { .kmap { .... .overlay-popup { .... position: absolute; bottom: 44px; left: 50%; transform: translateX(-50%); h3 { ... } .addr { ... } } } } </style>
  • bottom: 44px - 기본 마커의 높이만큼 간격을 줘서 겹치지 않게 함
  • left, transform - 가운데 정렬

close 버튼 디자인은 동영상 참조 https://youtu.be/2wEKmcdVWmA

Overlay 내용 채우기

마커를 클릭하면 overlay에 항구 정보를 보여주고 싶습니다.

overlay에 보여줄 항구를 나타낼 변수를 정의하고 overay element에 항구이름과 주소를 바인딩합니다.

App.vue
<template> .... <KakaoMap ...> <div class="overlay-popup" ...> <div v-if="overlayHarber"><!-- (3) 렌더링 --> <h3>{{ overlayHarber.place }}</h3> <div class="addr">{{ overlayHarber.addr }}</div> <a class="close" href="#" @click.prevent="closeOverlay()" ><span class="material-icons"> close </span></a > </div> </div> </KakaoMap> </template> <script> export default { data() { return { ... overlay: null, overlayHarber: null, // (1) overlay에 보여줄 항구 } }, mounted() { .... this.markers = new MarkerHandler(vueKakaoMap, { markerClicked: (harbor) => { ... // 마커 클릭시 this.overlayHarber = harbor; // (2) 클릭한 항구 바인딩 this.overlay.showAt(harbor.lat, harbor.lng); }, }); .... }, methods: { ... closeOverlay() { this.overlay.hide(); // (4) 오버레이 닫음 } } } </script>
  • (1, 2) 마커 클릭시 대응하는 항구를 변수에 바인딩함 (this.overlayHarber = harbor)
  • (3) 템플릿에서 overlayHarbor를 overlay element에 렌더링함
  • (4) close 버튼 클릭시 overlay를 닫음

마지막으로 KakaoOverlay 클래스에 메소드 hide()를 구현합니다.

.../map/overlay/index.js
const kakao = window.kakao; class KakaoOverlay { ... showAt(lat, lng) { ... } hide() { this.instance.setMap(null); } }