본문 바로가기

📢 들어가며

이번 포스팅은 지난 포스팅에서 이어진다.
지난 포스팅에선 CRUD API를 구현해보았다.

이번 포스팅에선 구현했던 API를 적용하고
OpenLayers의 overlay 기능을 활용해 지도의 아이콘에 hover 기능을 추가할 것이다.

모든 소스코드는 깃헙에서 확인할 수 있다.

🍜 API 기능 모듈화

지도에 맛집 리뷰들을 출력시켜볼 것이다.
이를 위해 지난 포스팅에서 구현했던 /api/review/getReviews API를 활용하려한다.

그런데 활용하려니, 저번에 구현했던 try, catch 부터 스피너용 processingCount 까지 증가하는 코드를 반복적으로 구현해야하는게 보기 좋지 않은 것 같았다.
그래서 나는 이 부분을 모듈화해주기로 했다.

 

frontend/src/common 경로에 Api.js 라는 파일을 하나 파 주고 아래와 같이 구현했다.

export async function process(that, func) {
    that.processingCount++;
    try {
        return await func();
    } catch (err) {
        console.log(err.message);
        await that.$bvModal.msgBoxOk(err.message, {
            hideHeader: true,
            okTitle: '확인',
            noFade: false,
            size: 'sm',
            buttonSize: 'sm',
            okVariant: 'danger',
            headerClass: 'p-2 border-bottom-0',
            footerClass: 'p-2 border-top-0',
        })
        return await Promise.reject(err);
    } finally {
        that.processingCount--;
    }

}

frontend/src/common/Api.js

        async saveReview () {
            this.processingCount++;
            try {
                await axios.post('/api/review/saveReview', {
                    title: this.title,
                    address: this.address,
                    grade: this.grade,
                    review: this.review
                });
                await this.$bvModal.msgBoxOk('저장 완료되었습니다.', {
                    hideHeader: true,
                    okTitle: '확인',
                    noFade: false,
                    size: 'sm',
                    buttonSize: 'sm',
                    okVariant: 'success',
                    headerClass: 'p-2 border-bottom-0',
                    footerClass: 'p-2 border-top-0',
                });
            } catch (e) {
                console.log(e.message);
                await this.$bvModal.msgBoxOk(e.message, {
                    hideHeader: true,
                    okTitle: '확인',
                    noFade: false,
                    size: 'sm',
                    buttonSize: 'sm',
                    okVariant: 'danger',
                    headerClass: 'p-2 border-bottom-0',
                    footerClass: 'p-2 border-top-0',
                });
                return await Promise.reject(e);
            } finally {
                this.processingCount--;
            }
        }

frontend/src/components/SideBar.vue

 

위 코드는 지난 포스팅에서 구현한 saveReview 메소드이다.
반복적으로 사용되는 processingCount 증감 부분과, catch 내용을 Api.js에서 모듈화 시켜줬다.
모듈화된 코드를 활용하면 saveReview는 아래와 같이 고쳐줄 수 있다.

import { process } from '@/common/Api.js';

        saveReview () {
            process(this, async () => {
                await axios.post('/api/review/saveReview', {
                    title: this.title,
                    address: this.address,
                    grade: this.grade,
                    review: this.review
                });
                await this.$bvModal.msgBoxOk('저장 완료되었습니다.', {
                    hideHeader: true,
                    okTitle: '확인',
                    noFade: false,
                    size: 'sm',
                    buttonSize: 'sm',
                    okVariant: 'success',
                    headerClass: 'p-2 border-bottom-0',
                    footerClass: 'p-2 border-top-0',
                });
            })
        }

frontend/src/components/SideBar.vue

훨씬 깔끔해졌다! 🎉

bootStrap 모달 옵션 부분도 너무 길어서 모듈화 시켜주자.
저장, 삭제, 수정 시 완료 모달이 뜰 테니 모듈화 시켜주면 좋을 것같다.

frontend/src/common 경로에 Dialog.js 파일을 하나 파고 아래와 같이 작성한다.

export function confirm(that, message) {
    return that.$bvModal.msgBoxConfirm(message, {
        hideHeader: true,
        okTitle: '확인',
        cancelTitle: '취소',
        noFade: false,
        size: 'sm',
        buttonSize: 'sm',
        okVariant: 'warning',
        cancelVariant: 'secondary',
        headerClass: 'p-2 border-bottom-0',
        footerClass: 'p-2 border-top-0',
    });
}

export function ok(that, message) {
    return that.$bvModal.msgBoxOk(message, {
        hideHeader: true,
        okTitle: '확인',
        noFade: false,
        size: 'sm',
        buttonSize: 'sm',
        okVariant: 'success',
        headerClass: 'p-2 border-bottom-0',
        footerClass: 'p-2 border-top-0',
    });
}

frontend/src/common/Dialog.js

okconfirm 메소드를 하나 파줬다.
ok 메소드는 saveReview에서 구현했던 것과 동일하다. 이제 message 매개변수만 넘기면 한 줄로 모달을 구현할 수 있게된다.
confirm 은 삭제처럼 한 번 더 유저에서 확인용 팝업을 띄우고 싶은 상황에 사용하고자 구현했다.
ok 와 다른 점은 취소 버튼이 추가되었다는 점이다.

 

모듈화된 코드를 사용하여 saveReview 를 수정해줬다.

import { process } from '@/common/Api.js';
import { ok } from '@/common/Dialog.js';

// ...
        saveReview () {
            process(this, async () => {
                await axios.post('/api/review/saveReview', {
                    title: this.title,
                    address: this.address,
                    grade: this.grade,
                    review: this.review
                });
                await ok(this, '저장 완료되었습니다.');
            })
        }

완전 깔끔해졌다! 🎉
이제 반복적인 코드 사용이 줄게 될것이다 ㅎㅎ

🍜 Vuex 추가

이제 본격적으로 리뷰를 지도에 뿌려주려니, 고민거리가 생겼다.
지금 당장은 지도에만 리뷰를 출력시키려고 하는 것이지만,
나중엔 SideBar 부분에 리뷰 목록을 불러오게 구현할 수도 있을 것이고 (아직 구체적으로 설계해보진 않았지만)
지도에 출력된 리뷰 정보(아이콘)를 클릭하여 상세 정보를 사이드에서 볼 것 같았다. (아래에서 구현함)
아무튼 이래저래 사이드바와 지도 사이의 데이터 교환이 굉장히 많이 일어날 것 같았다.

<template>
  <div id="app">
    <MainMap/>
    <SideBar class="side-bar"/>
  </div>
</template>

frontend/src/App.vue

 

App.vue 코드를 보면 알 수 있듯이,
사이드 바와 지도가 서로 형제 컴포넌트이기 때문에 $eventBus에 의존해야하는데,
이것으로만 데이터를 주고 받으려니 코드가 더러워질 것 같았다.

그리고 무엇보다 새로운 걸 써보고 싶은 마음에 ㅎㅎ Vuex 를 도입하기로 했다.
Vuex는 예전에 포스팅하면서 공부해본 적이 있다.
Vuex란? 개념과 예제
☝️☝️☝️
이 전 포스팅을 더듬어가며 Vuex 를 추가해보았다.

🍥 Vuex 설치

Vue CLI를 활용하고 있기 때문에 vue add 명령어로 Vuex를 설치해줬다.

vue add vuex

설치가 다 되고 나면 package.json에 vuex@vue/cli-plugin-vuex가 추가된 것을 확인할 수 있다.

설치하고 보니, 따로 추가해주지 않았는데도 frontend/src/store/index.js 가 추가되었다.
이 파일에 대한 설명은 Vuex 포스팅에서 다룬 적 있다. 참고!

🍥 Store 작성

store 가 필요한 부분은 지금 상황에선 일단 크게 두 개 일 것 같다.

  • 사이드 바와 지도 컴포넌트 사이의 리뷰 정보 공유
  • 사이드 바와 지도 컴포넌트 사이의 현재 선택된 위도, 경도 공유

위 상황을 고려하여 frontend/src/store/index.js 를 아래와 같이 작성해줬다.

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        reviews: undefined,
        curLon: undefined,
        curLat: undefined,
        curReviewId: undefined,
        curTitle: undefined,
        curAddress: undefined,
        curGrade: undefined,
        curReview: undefined,
    },
    mutations: {
        setReviews: (state, reviews) => {
            state.reviews = reviews;
        },
        setLonLat: (state, { lon, lat }) => {
            state.curLon = lon;
            state.curLat = lat;
        }
        setCurReviewId: (state, id) => {
            state.curReviewId = id;
        },
        setCurTitle: (state, title) => {
            state.curTitle = title;
        },
        setCurAddress: (state, address) => {
            state.curAddress = address;
        },
        setCurGrade: (state, grade) => {
            state.curGrade = grade;
        },
        setCurReview: (state, review) => {
            state.curReview = review;
        },
    },
    actions: {},
    modules: {}
})

frontend/src/store/index.js

statereviews 와 SideBar의 Input에 해당하는 내용(curTitle, curReview 등)을 선언하고,
mutation에 각 state 값을 채워넣는 메소드를 작성해줬다.

 

이제 commit을 사용해 이 메소드들을 활용해보자!

🍜 위도, 경도 컬럼 추가

본격적으로 Vuex를 활용해 기능을 구현할 것이다.
그런데... 아뿔싸ㅠ

지난 포스팅에서 테이블을 생성할 때 주소명만 저장하고 위도, 경도 정보를 저장할 컬럼을 만들어주지 않았다 ㅋㅋㅋ
지도에 뿌려줄 위치를 찾지 못하게 되니 큰일이다.
얼른 컬럼을 추가해주자.
컬럼을 추가해주는 김에, address 컬럼의 타입크기도 살짝 늘려주자
(일부 지역의 주소가 너무 길어 에러가 뜨는 현상을 발견했다.)

alter table tbl_review add column lon double not null;
alter table tbl_review add column lat double not null;
alter table tbl_review modify address varchar(100);

추가해준 컬럼에 맞춰 DTO도 수정해줬다.

public class ReviewDTO {
    // ...
    Double lon;
    Double lat;

    // ...

    public Double getLon() {
        return lon;
    }

    public void setLon(Double lon) {
        this.lon = lon;
    }

    public Double getLat() {
        return lat;
    }

    public void setLat(Double lat) {
        this.lat = lat;
    }
}

src/main/java/com/map/restaurant/good/dto/ReviewDTO.java

API 부분도 수정해주자.

// ...
methods: {
    // ...
    saveReview () {
        process(this, async () => {
            await axios.post('/api/review/saveReview', {
                title: this.title,
                address: this.address,
                grade: this.grade,
                review: this.review,
                lon: this.$store.state.curLon, // 추가
                lat: this.$store.state.curLat // 추가
            });
            await ok(this, '저장 완료되었습니다.');
        })
    }
}
// ...

frontend/src/components/SideBar.vue

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.map.restaurant.good.dao.ReviewDAO">
    <insert id="saveReview">
        insert into tbl_review (id, title, address, review, grade, lon, lat)
        values (#{id}, #{title}, #{address}, #{review}, #{grade}, #{lon}, #{lat})
        on duplicate key
        update
            title = #{title},
            address = #{address},
            review = #{review},
            grade = #{grade},
            lon = #{lon},
            lat = #{lat}
    </insert>

    <select id="getReviews" resultType="hashMap">
        select *
        from tbl_review
    </select>

    <delete id="deleteReview">
        delete
        from tbl_review
        where id = #{id}
    </delete>
</mapper>

src/main/resources/mappers/ReviewMapper.xml

🍜 리뷰 불러오기

이제 저장된 리뷰들을 지도에 뿌려보자.
DB에서 맛집의 위도, 경도정보를 가져와 그 위치에 아이콘을 그려줄 것이다.
지난 시간에 구현한 /getReviews API를 사용하자.


불러온 리뷰들은 statereviews에 저장할 것이다.
비동기 api 를 불러오는 과정이기 때문에 Vuex 의 actions 부분에 api 호출 메소드를 작성하자.

import { process } from '@/common/Api.js';

// ...

    actions: {
        async setReviews({commit}, that) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReviews');
                await commit('setReviews', result.data);
            })
        }
    }

frontend/src/store/index.js

불러온 api 결과물은 commit으로 위에서 mutations 에 정의한 setReviews 를 불러와 set해줬다.

    mounted() {
        // ...
        this.olMap = new OlMap({
            // ...
        });
        await this.$store.dispatch('setReviews', this);
        // ...
    }

frontend/src/components/MainMap.vue

지도가 그려지고 난 뒤에 리뷰가 뿌려져야하니,
지도 선언 바로 다음에 dispatchsetReviews 를 호출해오자.


이제, 지도에 아이콘으로 불러온 리뷰들을 출력할 것이다.
무료 아이콘 사이트에서 적당한 스팟 아이콘을 다운로드해왔다.
사실 썩 맘에 들진 않는데 (색이 마음에 안듬...) 나중에 바꿔주던가 해야겠다 ㅋㅋ

다운 받은 이미지 파일을 frontend/src/assets/images/spot.png로 저장한다. (public 에 저장해줘도 상관 없다.)

다운로드한 spot 아이콘

이제 이 아이콘을 지도에 그리는 메소드를 작성할 것이다.
지난 포스팅에서 클릭했을 때 Feature 를 추가하는 방법과 동일하게 구현할 것인데,
클릭시 Feature 가 그려지는 레이어와는 또 다른 레이어를 추가할 것이다.

같은 레이어에 리뷰 Feature 를 추가해줘도 상관 없긴 하지만,
클릭시 발생하는 아이콘과 리뷰 목록 아이콘 둘 다 지웠다 그려지는 경우가 많다.
지워질 때 어떤 아이콘인지 반복을 돌며 찾아내서 지우는 것 보단
레이어 자체를 비우고 다시 그리는 게 더 좋을 것 같았다.

    data() {
        return {
            // ...
            iconsSource: undefined
        }
    },
    methods: {
        drawFeatures() {
            if (this.iconsSource)
                this.iconsSource.clear();

            this.iconsSource = new OlVectorSource(EPSG_3857);
            const iconsLayer = new OlVectorLayer({
                source: this.iconsSource
            });
            const style = new OlStyle({
                image: new OlIcon({
                    scale: 0.8,
                    src: require('../assets/images/spot.png')
                })
            });
            const features = this.reviews.map(review => {
                const point = this.coordi4326To3857([review.lon, review.lat]);
                const feature = new OlFeature({
                    geometry: new OlPoint(point)
                });
                feature.set('title', review.title);
                feature.set('grade', review.grade);
                feature.set('address', review.address);
                feature.set('review', review.review);
                feature.set('reviewId', review.id);
                feature.setStyle(style);

                return feature;
            })
            this.iconsSource.addFeatures(features);

            this.olMap.addLayer(iconsLayer);
        }
    }

frontend/src/components/MainMap.vue

 

iconSource 를 전역 변수로 선언해 그려진 내용을 기억하게 만들고 clear() 할 수 있게 했다.
clear()는 OpenLayers 에서 제공하는 것으로 source 의 모든 요소를 비운다.

 

지난 번에 구현했던 지도 클릭시 Feature 를 그리는 방법과 동일하게 구현했다.
다른 점은 addFeature 가 아니고 addFeatures(복수형)을 사용했다는 점이다.

 

생성한 feature엔 리뷰 정보들을 set해준다.
그래야 리뷰 아이콘을 클릭했을 때, 사이드바에 해당 정보를 넘겨줄 수 있다.

 

마지막엔 OpenLayers의 addLayer 를 사용하여 생성한 리뷰 아이콘용 레이어를 지도에 추가!

지도에 출력된 리뷰 아이콘

지도에 아이콘이 제대로 출력되는 걸 확인할 수 있었다! 🎉

🍜 리뷰 아이콘 클릭 시 내용 보여주기

지도에 보이는 리뷰 아이콘을 클릭했을 때, 좌측 사이드바에 해당 내용이 보여지도록 할 것이다.
아이콘 클릭 이벤트는 지도 화면을 클릭했을 때 현재 위치에 아이콘을 하나 찍는 용도로 지난 포스팅에서 구현했었다.
그 이벤트 내부에 Feature 를 클릭했을 때 다르게 동작하는 로직을 추가해줄 것이다.

mutations: {
    setReview: (state, review) => {
        state.curReviewId = review ? review.id : review;
        state.curLat = review ? review.lat : review;
        state.curLon = review ? review.lon : review;
        state.curTitle = review ? review.title : review;
        state.curGrade = review ? review.grade : review;
        state.curAddress = review ? review.address : review;
        state.curReview = review ? review.review : review;
    }
}

frontend/src/store/index.js

// ...
    this.olMap.on('click', async (e) => {
            this.vectorSource.clear();
            geocoder.getSource().clear();
            const [lon, lat] = toLonLat(e.coordinate)
            const addressInfo = await that.getAddress(lon, lat)

            this.$store.commit('setReview', undefined);
            this.$store.commit('setCurAddress', that.getUiAddress(addressInfo.data.display_name));
            that.$store.commit('setLonLat', {lon, lat});

            const point = that.coordi4326To3857([lon, lat]);
            const feature = new OlFeature({
                geometry: new OlPoint(point)
            })
            feature.setStyle(new OlStyle({
                image: new OlIcon({
                    scale: 0.7,
                    src: '//cdn.rawgit.com/jonataswalker/map-utils/master/images/marker.png'
                })
            }))

            const existFeature = that.olMap.forEachFeatureAtPixel(e.pixel, feature => {
                this.$store.commit('setCurTitle', feature.get('title'));
                this.$store.commit('setCurAddress', feature.get('address'));
                this.$store.commit('setCurGrade', feature.get('grade'));
                this.$store.commit('setCurReview', feature.get('review'));
                this.$store.commit('setCurReviewId', feature.get('reviewId'));
                this.$store.commit('setInputState', true);
                return true;
            })

            if (!existFeature)
                this.vectorSource.addFeature(feature);
        })
// ...

methods: {
    // ...
    getUiAddress(str) {
        return str.split(', ').reverse().join(' ');
    },
}

frontend/src/components/MainMap.vue

vectorSource 역시 전역 변수로 변경해줬다.
mounted 밖에서도 clear() 시킬 일이 있기 때문이다.
(예를 들면 리뷰를 저장하고 새 리뷰 목록을 불러 왔을 때)

 

setReview 라는 mutations 메소드를 하나 파고 현재 선택된 리뷰 정보 전체를 set하는 메소드를 만든다.
그리고 this.$store.commit('setReview', undefined);로 현재 store 에 저장된 내용을 초기화 시킨다.
(지도에 그려진 리뷰 아이콘을 클릭했을 때 store 에 현재 리뷰 정보가 담기게 될 것이다.
클릭 이 후에 다시 아이콘이 없는 지도 아무 부분이나 클릭한다면 선택된 리뷰가 없는 것이니 초기화 시켜주는 것)

 

기존의 that.setUiAddress(addressInfo.data.display_name);을 지우고
getUiAddress 메소드를 새로 하나 판다.

 

forEachFeatureAtPixel()은 현재 클릭된 Feature에 대한 이벤트를 구현할 수 있도록 한다.
클릭된 feature 의 정보를 store 에 commit 으로 저장시켰다.

 

그리고 현재 클릭된 feature 가 없다면,
즉, 지도의 빈 곳을 클릭한 상태라면 vectorSource의 feature 인 '현재 클릭된 곳 표시용 아이콘'을 addFeauture 한다.

이제 지도의 아이콘을 클릭했을 때 현재 선택된 리뷰의 정보를 store 에 담게 했으니,
이를 사이드바에 불러오게 할 것이다.

data () {
        return {
            // ...
            title: undefined,
            address: undefined,
            grade: undefined,
            review: undefined,
        }
    },

frontend/src/components/SideBar.vue

지난 포스팅에서 사이드 바의 input 값을 넣어줄 data()를 선언했었다.
이는 사이드 바 내부에서만 주고 받는 데이터였기 때문에,
지도와 공유하기 위해선 생성해준 store 의 state 로 변경해줘야한다.

import { mapState } from 'vuex'

// ...
    computed: {
        ...mapState({
            reviewId: state => state.curReviewId,
            curAddress: state => state.curAddress,
            curGrade: state => state.curGrade,
            curReview: state => state.curReview,
            curTitle: state => state.curTitle
        }),
        address: {
            get() {
                return this.curAddress;
            },
            set(newVal) {
                this.$store.commit('setCurAddress', newVal);
            }
        },
        grade: {
            get() {
                return this.curGrade
            },
            set(newVal) {
                this.$store.commit('setCurGrade', newVal);
            }
        },
        review: {
            get() {
                return this.curReview
            },
            set(newVal) {
                this.$store.commit('setCurReview', newVal);
            }
        },
        title: {
            get() {
                return this.curTitle
            },
            set(newVal) {
                this.$store.commit('setCurTitle', newVal);
            }
        }
    },

frontend/src/components/SideBar.vue

data()에 선언된 내용을 지우고, 위와 같이 입력해준다.
mapStateVuex 포스팅에서 공부한적 있다.
state를 여러개 가져올 때 쓰는 것!

 

사이드 바의 input 들은 store에 저장된 내용을 출력하기도 하지만
유저가 직접 입력해주는 경우도 있다.(추가/수정)
때문에 사용자의 입력값을 store 에 저장해주는 get(), set() 도 선언한다.

 

아이콘 클릭시 사이드 바에 리뷰 내용이 출력된다!


성공적으로 사이드바에 데이터가 불려오는 것을 확인할 수 있었다! 🎉

🍜 리뷰 수정, 삭제 구현하기

지도 아이콘 클릭으로 불려온 사이드 바 데이터들을 수정할 수 있게 해보자.

사이드 바에 출력된 데이터 들은 처음엔 수정(입력)할 수 없게 하고, (저장 버튼도 숨기기! 읽기 전용!)
'수정하기' 버튼을 클릭했을 때 저장버튼도 출력되고 수정(입력)도 가능하게 할 것이다.

읽기 전용으로 만드는 state(isDisabledInput)를 하나 구현해주자.
사이드 바 내 전역 변수가 아니라 store의 state 로 저장해 주는 이유는,
아래 항목들을 고려했기 때문이다.

  1. 지도 아이콘을 클릭 했을 때 isDisabledInput 가 true가 되어야한다.
  2. 아이콘이 존재하지 않는 지도 영역을 클릭했을 떄 isDisabledInput가 false 가 되어야한다.
  3. 수정하기 버튼을 눌렀을 때 isDisabledInput가 false 가 되어야한다.
  4. 수정 후 저장하기 버튼을 눌렀을 때 isDisabledInput가 true 가 되어야한다.
  5. 선택한 항목을 삭제 한 후(새 리뷰 목록을 불러올 때) isDisabledInput가 false 가 되어야한다.
// ...
state: {
        // ...
        isDisabledInput: undefined
    },
    mutations: {
        setInputState: (state, bool) => {
            state.isDisabledInput = bool;
        },
    }
// ...

frontend/src/store/index.js


위 항목 번호를 따라 진행해보겠다.
1번, 2번 항목을 구현하기 위해 MainMap.vue의 클릭 이벤트 부분에 아래와 같이 구현한다.

        this.olMap.on('click', async (e) => {
            this.vectorSource.clear();
            geocoder.getSource().clear();
            const [lon, lat] = toLonLat(e.coordinate)
            const addressInfo = await that.getAddress(lon, lat)

            this.$store.commit('setReview', undefined);
            this.$store.commit('setInputState', false); // 추가!
            this.$store.commit('setCurAddress', that.getUiAddress(addressInfo.data.display_name));
            that.$store.commit('setLonLat', {lon, lat});

            const point = that.coordi4326To3857([lon, lat]);
            const feature = new OlFeature({
                geometry: new OlPoint(point)
            })
            feature.setStyle(new OlStyle({
                image: new OlIcon({
                    scale: 0.7,
                    src: '//cdn.rawgit.com/jonataswalker/map-utils/master/images/marker.png'
                })
            }))

            const existFeature = that.olMap.forEachFeatureAtPixel(e.pixel, feature => {
                this.$store.commit('setCurTitle', feature.get('title'));
                this.$store.commit('setCurAddress', feature.get('address'));
                this.$store.commit('setCurGrade', feature.get('grade'));
                this.$store.commit('setCurReview', feature.get('review'));
                this.$store.commit('setCurReviewId', feature.get('reviewId'));
                this.$store.commit('setInputState', true); // 추가!
                return true;
            })

            if (!existFeature)
                this.vectorSource.addFeature(feature);
        })

frontend/src/components/MainMap.vue

 

1번 항목을 위해 forEachFeatureAtPixel 내부에 this.$store.commit('setInputState', true);를 추가해줬고,
2번 항목을 위해 forEachFeatureAtPixel 외부에 this.$store.commit('setInputState', false);를 추가해줬다.

                <div class="bottom-btn-area">
                    <BButton
                        class="save-btn"
                        @click="saveReview"
                        v-if="!isDisabledInput"
                    >
                        저장
                    </BButton>
                    <BButton
                        class="mr-2"
                        variant="success"
                        @click="$store.commit('setInputState', false)"
                        v-if="isDisabledInput"
                    >
                        수정하기
                    </BButton>
                    <BButton
                        variant="danger"
                        @click="removeReview"
                        v-if="isDisabledInput"
                    >
                        삭제하기
                    </BButton>
                </div>

frontend/src/components/SideBar.vue

 

3, 4, 5번 항목을 위해 사이드 바에 버튼을 추가해줬다.
각 항목은 isDisabledInput에 따라 v-if 로 가려지게 했다.
3번 항목을 위해 수정하기를 누르면 this.$store.commit('setInputState', false)가 되게 했다.

 

수정하기, 삭제하기, 저장 버튼이 적절하게 나타났다 사라진다

적절하게 버튼이 생겼다 사라지는 걸 확인할 수 있었다!🎉

이제 removeReviewsaveReview(수정)를 구현해보자.

        saveReview () {
            process(this, async () => {
                await axios.post('/api/review/saveReview', {
                    id: this.reviewId, // 추가!
                    title: this.title,
                    address: this.address,
                    grade: this.grade,
                    review: this.review,
                    lon: this.$store.state.curLon,
                    lat: this.$store.state.curLat
                });
                await ok(this, '저장 완료되었습니다.');

                await this.$store.dispatch('setReviews', this); // 추가!
                this.$store.commit('setInputState', true); // 추가!
            })
        }

frontend/src/components/SideBar.vue

    mutations: {
    // ...
        setReviews: (state, reviews) => {
            if (state.reviews &&
                reviews &&
                state.reviews.length !== reviews.length) {
                const ids = state.reviews.map(re => re.id);
                const curReview = reviews.find(review => !ids.includes(review.id));
                if (curReview)
                    state.curReviewId = curReview.id;
            }
            state.reviews = reviews;
            state.isDisabledInput = false;

            const review = reviews.find(review =>
                review.id === state.curReviewId
            );

            setReview(state, review);
        },
        setReview: (state, review) => {
            setReview(state, review);
        },
       }

//...
function setReview(state, review) {
    state.curReviewId = review ? review.id : review;
    state.curLat = review ? review.lat : review;
    state.curLon = review ? review.lon : review;
    state.curTitle = review ? review.title : review;
    state.curGrade = review ? review.grade : review;
    state.curAddress = review ? review.address : review;
    state.curReview = review ? review.review : review;
}

frontend/src/store/index.js

 

기존에 구현되어있던 saveReview 에 저장 후 리뷰를 불러오는 로직과
4번 항목인 this.$store.commit('setInputState', true); 를 추가해줬다.

 

추가/수정 후 리뷰를 불러왔을 때 현재 추가/수정한 리뷰를 보여줘야할 것 같아서,
mutations의 setReviews를 수정해줬다.

if (state.reviews &&
    reviews &&
    state.reviews.length !== reviews.length)

store에 저장된 reviews가 존재하고(이미 리뷰가 불러와져있는 경우)
인자로 reviews 를 받아왔을 때(새로 불러온 리뷰),
인자의 reviews의 길이와 state.reviews의 길이가 같은지 확인한다.


다르다면, 새로운 항목이 추가되거나 삭제된 경우이다.
추가되었다면 해당 리뷰를 사이드바에 보여줘야하니, findid를 찾아 리뷰를 set해준다.


각 리뷰 항목을 set하는 과정은 setReview와 중복되는 부분이 있는 것 같아서,
setReview라는 function 을 하나 파줬다.


만약 setReview에 받아온 review인자가 없다면(undefined) 리뷰항목들을 초기화 시켜주게 될것이다.
이는 리뷰가 삭제되었을 때의 상황이다.

import { confirm, ok } from '@/common/Dialog.js';

//...
    methods: {
    // ...
        removeReview() {
            process(this, async () => {
                const isConfirmed = await confirm(this, `'${this.title}' 리뷰를 삭제하시겠습니까?`);
                if (! isConfirmed) return;

                await axios.delete('/api/review/deleteReview', {
                    data: {
                        id: this.reviewId
                    }
                });

                await ok(this, '삭제되었습니다.');

                await this.$store.dispatch('setReviews', this);
            })
        }
    }

frontend/src/components/SideBar.vue

리뷰 삭제는 삭제 전에 한번 더 확인하는 게 좋을 것 같아서, 앞전에 구현했었던 Dialog.jsconfirm()을 활용한다.
삭제하고 난 위에 await this.$store.dispatch('setReviews', this);


수정, 삭제한 것이 지도에도 반영이 되어야하기 때문에,
아래 내용을 MainMap.vue 에 추가한다.

    computed: {
        reviews() {
            return this.$store.state.reviews;
        }
    },
    watch: {
        async reviews() {
            if (this.vectorSource)
                this.vectorSource.clear();
            this.drawFeatures();
        }
    },

frontend/src/components/MainMap.vue

새로운 리뷰 목록이 불러와졌을 때,
vecotSource, 즉, 현재 선택된 지도 영역에 아이콘을 표시하는 vectorSourceclear 시켜준다.
그리고 drawFeatures 로 새로 불러온 리뷰 목록을 지도에 출력!

🍜 지도 아이콘 hover 기능 구현

지도 아이콘을 클릭했을 때 사이드바에서 리뷰 정보를 확인할 수 있지만,
일일이 누르고 API가 동작하는 것까지 기다리기엔 조금 답답한 것 같아서
지도 아이콘에 hover 기능을 추가해 간단한 정보를 지도에서 바로 확인할 수있게 할 것이다.

<template>
    <div class="main-map" ref="map">
        <div
            class="overlay-tooltip"
            ref="overlay"
            v-show="isShowOverlay"
        >
            <div class="overlay-content">
                {{ selectedOverlayText }}
                <BFormRating class="rating" v-model="selectedOverlayRating" readonly />
            </div>
        </div>
    </div>
</template>


// ...
  data() {
      return {
          //...
          overlay: undefined,
          isShowOverlay: false,
          selectedOverlayText: undefined,
          selectedOverlayRating: undefined,
      }
  }

//...
<style lang="scss" scoped>
// ...
    .overlay-tooltip {
        border-radius: 5px;
        background-color: rgba(0, 0, 0, 0.5);
        padding: 5px 10px;
        color: white;
        text-align: center;

        > .overlay-content::after {

            content: "";
            position: absolute;

            top: 100%;
            left: 50%;
            margin-left: -5px;

            border-width: 5px;
            border-style: solid;
            border-color: rgba(0, 0, 0, 0.5) transparent transparent transparent;


        }

        ::v-deep.rating {
            font-size: 15px;
            background-color: transparent;
            border: none;
            padding: 0;
            margin: 0;
            color: #ffdd00;
            height: unset;
        }
    }
</style>

frontend/src/components/MainMap.vue

OpenLayers 는 Overlay 라는 기능을 제공한다.
Overlay 는 직역하면 '위에 깔다'라는 뜻이다.
말그대로 지도 위에 팝업이나 위젯따위를 그릴 때 많이 사용된다.

나는 지도 위 아이콘을 hover 했을 때 맛집의 제목가 별점이 보였으면 좋겠어서,
위와 같이 구현해줬다.


selectedOverlayText 는 맛집 제목이고,
selectedOverlayRating 는 별점이다.
그리고 isShowOverlay로 hover 될때마다 출력되게 할 것이다.


오버레이는 말풍선 모양처럼 보이게 스타일을 줬다.

import Overlay from 'ol/Overlay.js';
// ...
    mounted: {
        // ...
        this.olMap.on('pointermove', (e) => {
            that.olMap.getTargetElement().style.cursor = '';
            that.isShowOverlay = false;
            that.olMap.removeOverlay(that.overlay);

            that.olMap.forEachFeatureAtPixel(e.pixel, feature => {
                if (feature.get('title') !== undefined) {
                    that.isShowOverlay = true;
                    that.selectedOverlayText = feature.get('title');
                    that.selectedOverlayRating = feature.get('grade');

                    const overlay = that.$refs.overlay;

                    that.overlay = new Overlay({
                        element: overlay,
                        position: feature.getGeometry().getCoordinates(),
                        positioning: 'bottom-center',
                        offset: [0, -10]
                    })
                    that.olMap.addOverlay(that.overlay);
                    that.olMap.getTargetElement().style.cursor = 'pointer';
                }
            })
        })
    }

frontend/src/components/MainMap.vue

pointermove 부분을 위와 같이 수정한다.

that.olMap.getTargetElement().style.cursor = '';
that.olMap.getTargetElement().style.cursor = 'pointer';

위 코드는 아이콘 위에 커서를 올렸을 때
클릭이 가능하다는 느낌을 주기위해
손가락 포인터 커서 모양으로 바꿔주는 코드이다.

아이콘에 마우스가 올려졌을 때만 오버레이가 보여야하기때문에,
forEachFeatureAtPixel 외부엔 removeOverlay로 오버레이를 지워주고,
isShowOverlayfalse 로 변경하여 가린다.

 

forEachFeatureAtPixel 내부에서 feature의 title이 존재한다면,
selectedOverlayText, selectedOverlayRating를 해당 feature 의 정보로 채워넣는다.

 

new Overlay 로 오버레이를 생성한다.
positioningoffset으로 feature 위에 표시될 위치를 잡는다.

 

hover 시 오버레이 말풍선이 출력되는 모습

hover 시 오버레이 말풍선이 적절히 출력되는 것을 확인할 수 있었다!🎉


이번 시간엔 Vuex 를 적용시키고
OpenLayers 의 오버레이 기능을 추가해보았다.

수정된 부분이 많아 포스팅에서 다소 빠뜨린 부분이 있을 수 있다.
전체 코드는 깃헙을 참고!

다음 포스팅에선 첨부파일로 이미지를 저장해보겠다.

댓글, 하트, 피드백은 언제나 환영입니다🥰

Seize the day!

Spring MVC | Spring Boot | Spring Security | Mysql | Oracle | PostgreSQL | Vue.js | Nuxt.js | React.js | TypeScript | JSP | Frontend | Backend | Full Stack | 자기계발 | 미라클 모닝 | 일상