우선, 카카오 지도 javascript API는 지도와 함께 사용할 수 있는 라이브러리를 지원한다고 한다.
이 기능은 추가로 불러와서 사용할 수 있도록 되어있다.
카카오가 제공하고 있는 라이브러리는 3가지
Clusterer, services, drawing
Clusterer - 마커를 클러스터링 할 수 있는 라이브러리
(Clustering, 군집화 - 부분그룹으로 나누는 것, 지도 상에서 결과가 많아 다 보여줄 수 없을 때 묶어주는 것)
Services - 장소 검색과 '주소-좌표' 반환을 할 수 있는 라이브러리
(내가 이번 프로젝트에 사용해보았다..!)
Drawing - 지도 위에 마커와 그래픽스 객체를 쉽게 그릴 수 있게 그리기 모드 지원 라이브러리
(기본 services 라이브러리에서 제공하는 지도 생성 방법으로 충분해서 사용안함)
<!DOCTYPE html> <html lang="en"> <head> <script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=APIKEY&libraries=LIBRARY"></script> </head> ...
우선 API를 로딩하는 위의 스크립트 태그는 HTML파일안의 head, body 등 어떠한 위치에 넣어도 상관없으나 반드시 실행 코드보다 먼저 선언되어야 하니까 index.html에 libraries에 services를 넣어준다. head태그 내 즐비한 meta태그아래 넣어주자..!
시작은 지도가 들어갈 div부터!
1. 컴포넌트의 return, JSX부분에 지도가 들어갈 div를 만들고 크기를 지정해준다!
// 지도를 생성할 MapContainer.js import React, { useEffect } from 'react'; const MapContainer = () => { return ( <Grid className="map_wrap"> <Grid id="map" absolute="relative" overflow="hidden" width="auto" height="100vh" /> </Grid> ); } export default MapContainer; * 재사용성을 위해 div태그 관련 elements인 Grid를 만들어 사용
2. 최초 렌더링 시 지도를 보여주기 위해 의존성 배열은 비워둔 React Hook useEffect에 넣어보자!!
카카오API - 지도를 띄우는 코드 작성const container = document.getElementById('map'); const options = { center: new kakao.maps.LatLng(33.450701, 126.570667), level: 3 }; const map = new kakao.maps.Map(container, options);
위처럼만 하면 지도가 나오겠지만 아님..! 하지만, 여기서 주의할 점! 그대로 따라하는데 안되서 힘들었음…ㅠㅠㅠㅠ
함수형 컴포넌트에서는 const {kakao } = window; 혹은 컴포넌트 최상단에 global kakao를 선언을 해야한다!!
/*global kakao */ import React from "react"; const MapContainer = (props) => { ...
그렇지 않으면 지도 객체나 장소 객체 만들때 kakao가 선언되어 있지 않다고 오류를 던진다!!!!!
script를 index.html에 추가해서 존재하긴 하지만
리액트가 알아듣지 못하기 때문에 선언, 명시해줘야한다!
3. 지도를 그려줄 MapContainer에 키워드로 장소검색하기를 적용할 거다!
const ps = new kakao.maps.services.Places();
ps라는 장소 검색 객체를 생성한 후 ps와 검색 결과 완료 시 호출되는 함수들을 React.useEffect에 넣어준다.
// MapContainer.js React.useEffect(() => { // 지도 생성 const container = document.getElementById("map"); const options = { center: new kakao.maps.LatLng( latitude ? latitude : 33.450701, longitude ? longitude : 126.570667 ), level: 3, }; const map = new kakao.maps.Map(container, options); // 장소 검색 객체 생성 후 키워드로 장소검색(keywordSearch) const ps = new kakao.maps.services.Places(); ps.keywordSearch(props?.searchPlace, placesSearchCB); // input으로 입력한 키워드로 검색완료 시 실행되는 콜백함수 function placesSearchCB(data, status, pagination) { if (data.length === 0) { return customAlert.sweetOK( "앗 검색 결과가 없어요", "식당 + 지역으로 검색해주세요.", "검색이 불가능한 경우 직접 입력 가능해요." ); } if (status === kakao.maps.services.Status.OK) { let bounds = new kakao.maps.LatLngBounds(); // 검색 결과가 있으면(OK) displayMarker로 마커 표시 for (let i = 0; i < data.length; i++) { displayMarker(data[i]); bounds.extend(new kakao.maps.LatLng(data[i].y, data[i].x)); } // 최초 지도를 그릴 때 지정했던 위도, 경도에서 검색 결과의 위도, 경도로 변경 후 지도 범위 재설정 map.setBounds(bounds); } if (status === kakao.maps.services.Status.ERROR) { return customAlert.sweetOK( "검색 실패", "검색 중에 오류가 발생했어요.", "잠시 후 다시 시도해주세요." ); } } },[]);
해당 검색 결과 장소의 위,경도를 기반으로 한 위치를 마커로 표시하기 위해 displayMarker함수 추가
우선 click, mouseover등 이벤트는 신경쓰지말고 필요하면 활용하자!React.useEffect(() => { // 마커위에 띄울 infowindow let infowindow = new kakao.maps.InfoWindow({ zIndex: 1 }); ... function displayMarker(place) { // marker객체 생성 let marker = new kakao.maps.Marker({ map: map, position: new kakao.maps.LatLng(place.y, place.x), }); // 검색 결과가 정확한 경우, 인포윈도우 노출 infowindow.setContent( '<div style="padding:0.5rem;font-size:1rem;width:max-content;">' + place.place_name + "</div>" ); infowindow.open(map, marker); // 마커영역에 마우스를 올리면 장소명, 주소를 포함한 인포윈도우 노출 kakao.maps.event.addListener(marker, "mouseover", function () { infowindow.setContent( '<div style="padding:0.5rem;font-size:1rem;width:max-content;">' + place.place_name + "</div>" ); infowindow.open(map, marker); }); // 마커 영역에 마우스를 내리면 장소명, 주소를 포함한 인포윈도우 제거 kakao.maps.event.addListener(marker, "mouseout", function () { infowindow.close(); }); kakao.maps.event.addListener(map, "click", function () { infowindow.close(); }); // 마커 클릭 시 해당 장소의 이름, 도로명 주소 설정 여부를 묻고 결정 후 modal 닫기 kakao.maps.event.addListener(marker, "click", function () { customAlert .sweetPromise( "해당 주소로 선택하시겠어요?", place.place_name, place.road_address_name ) .then((res) => { if (res === true) { dispatch(locateActions.setShopAddress(place.place_name)); dispatch(locateActions.setPlaceUrl(place.place_url)); props?.close(); props?.placeNull(); } return; }); }); }
4. 그렇다면 이제, 검색을 하기 위한 ShopAddress 컴포넌트를 만들자!
- state는 2개를 만들어 관리할거다!
- ShopAddress 컴포넌트에 우선 input을 하나 만들고 검색 input창의 값을 저장할 useState인 inputText를 만든다.
- 그 값을 담아 지도를 생성하는 하위 컴포넌트인 MapContainer 컴포넌트로 보내줄 place도 만들어둔다.
- 검색하고 나면 다른 값을 입력할 수 있게 input창을 비워 초기화 시킬거다!(setInputText("");)
import React from "react"; // AddressGrid는 헤더 import { AddressGrid, MapContainer } from "."; import { Grid, Image } from "../elements"; const ShopAddress = (props) => { const [inputText, setInputText] = React.useState(""); const [place, setPlace] = React.useState(""); const onChange = (e) => { setInputText(e.target.value); }; const onKeyPress = (e) => { if (e.key === "Enter") { handleSubmit(e); } }; // setPlace로 place값 지정하고 MapContainer 컴포넌트로 넘겨줌 const handleSubmit = (e) => { setPlace(inputText); }; // 검색 - 식당 설정 후 지도 초기화 const placeNull = () => { setInputText(""); setPlace(""); }; return ( <React.Fragment> <AddressGrid is_shop close={props?.close}> <Grid is_flex2 bg="#fff" padding="1.8rem 2rem 1.6rem 2rem" margin="0 0 0 0.1rem" <Grid width="fit-content" height="5rem" radius="1.2rem" border="0.1rem solid #EBE9E9" padding="1rem" bg="#FFFFFF" is_flex2 <input type="text" id="keyword" placeholder="식당 + 지역으로 검색 돼요." value={inputText} onChange={onChange} onKeyPress={onKeyPress} style={{ border: "none", fontSize: "1.6rem", width: "27rem", height: "5rem", background: "none", outline: "none", }} /> <Image size="2.4" src={isWebpSupported() ? webp.searchWebp : png.search} margin="0 0 0.4rem" _onClick={handleSubmit} /> </Grid> </Grid> <MapContainer searchPlace={place} close={props?.close} placeNull={placeNull} /> </AddressGrid> </React.Fragment> ); };
- onChange가 발생할 때마다(입력할 때 마다) useState인 setInputText로 값을 변경해주고 검색 버튼을(submit하면) 누르면 inputText를 setPlace로 place값을 변경해주고 setInputText로 input값을 초기화 해주고 props로 MapContainer에 값 넘겨주기!
5. 지도를 보여줄 컴포넌트인 MapContainer에 props로 place를 넘겨주고 카카오 맵의 키워드 검색 함수인 keywordSearch(searchPlace, placesSearchCB)에 사용한다!
(props로 받은 place를 MapContainer에서는 searchPlace로 받아온다)// MapContainer.js React.useEffect(() => { // 지도 생성 const container = document.getElementById("map"); const options = { center: new kakao.maps.LatLng( latitude ? latitude : 33.450701, longitude ? longitude : 126.570667 ), level: 3, }; const map = new kakao.maps.Map(container, options); // 장소 검색 객체 생성 후 키워드로 장소검색(keywordSearch) const ps = new kakao.maps.services.Places(); ps.keywordSearch(props?.searchPlace, placesSearchCB);
useEffect의 의존성 배열이 []로 비어있어 렌더링이 안될 것이다.
그렇다고 []를 없애면 MapContainder가 ShopAddress컴포넌트의 하위에
위치하고 있어 inputText 상태가 바뀔 때마다 계속 렌더링 될 것이다.(useState는 state가 바뀌면 재렌더링한다!)
- ShopAddress에서 값을 변경해서 searchPlace라는 이름으로 Props를 넘겨주는 시점은 검색 버튼을 눌렀을 때이다!
- 그러면 searchPlace가 변경될 거고 변경된 searchPlace를
감지할 수 있도록 useEffect의 의존성배열에 searchPlace를 넣어주면 input에 검색어를 입력하고 검색 버튼을 누를 때만 실행되는 것이다!
희희...하단 부의 의존성 배열 []에 props로 받아온 searchPlace를 넣어주자!해결!!!!!!!!
7. 자 이제 useEffect deps에 searchPlace를 넣어주고 나니 잘 나온다!
- 근데 뭔가 심심하다.
- 클릭했을 때나 마우스를 갖다대었을 때, 마우스를 다른 마커로 옮겼을 때도 인포윈도우가 나와줬으면 좋겠는데!
그렇다면 8번을 참고하자!
8. 먼저 useEffect 안에서
let info window = new kakao.maps.InfoWindow({zIndex:1})만들어두고 인포윈도우 객체를 만들자!(이벤트 만들때!!)
- 기본적으로 input에 입력한 값과 검색 결과가 일치하면 마커와 그 위에 식당 이름이 적힌 인포윈도우를 띄워주고
- 확인 후 다른 마커를 선택하려고 마우스를 내리면 mouseout 이벤트
- 다른 마커를 클릭하면 인포윈도우가 생성되도록 click 이벤트
- 마우스를 갖다댔을 때 인포윈도우가 나타나는 mouseover 이벤트 생성
그럼 깔끔하게 지도가 잘 나오는 것을 볼 수 있다..!MapContainer.js
// MapContainer.js /*global kakao */ // 게시글 작성 및 수정 시 지도로 식당 찾기 import React from "react"; import { useDispatch, useSelector } from "react-redux"; import { actionCreators as locateActions } from "../redux/modules/loc"; import { Grid } from "../elements"; import { customAlert } from "./Sweet"; import logger from "../shared/Console"; const MapContainer = (props) => { const dispatch = useDispatch(); // 게시글 - 만날 장소의 위도, 경도 const latitude = useSelector((state) => state.user.user.latitude); const longitude = useSelector((state) => state.user.user.longitude); React.useEffect(() => { // 마커위에 띄울 infowindow let infowindow = new kakao.maps.InfoWindow({ zIndex: 1 }); // 지도를 그릴 container (JSX - <Grid id="map">) // 지도의 중심좌표(center)와 확대 레벨(level)설정( 값이 없으면 기본 좌표 지정 ) const container = document.getElementById("map"); const options = { center: new kakao.maps.LatLng( latitude ? latitude : 33.450701, longitude ? longitude : 126.570667 ), level: 3, }; // 지도 생성 const map = new kakao.maps.Map(container, options); // 장소 검색 객체 생성 후 키워드로 장소검색 const ps = new kakao.maps.services.Places(); ps.keywordSearch(props?.searchPlace, placesSearchCB); // input으로 입력한 키워드로 검색완료 시 실행되는 콜백함수 function placesSearchCB(data, status, pagination) { if (data.length === 0) { return customAlert.sweetOK( "앗 검색 결과가 없어요", "식당 + 지역으로 검색해주세요.", "검색이 불가능한 경우 직접 입력 가능해요." ); } if (status === kakao.maps.services.Status.OK) { let bounds = new kakao.maps.LatLngBounds(); // 검색 결과가 있으면(OK) displayMarker로 마커 표시 for (let i = 0; i < data.length; i++) { displayMarker(data[i]); bounds.extend(new kakao.maps.LatLng(data[i].y, data[i].x)); } // 최초 지도를 그릴 때 지정했던 위도, 경도에서 검색 결과의 위도, 경도로 변경 후 지도 범위 재설정 map.setBounds(bounds); } if (status === kakao.maps.services.Status.ERROR) { return customAlert.sweetOK( "검색 실패", "검색 중에 오류가 발생했어요.", "잠시 후 다시 시도해주세요." ); } } // 검색 결과로 나온 place를 기준으로 마커를 표시 function displayMarker(place) { let marker = new kakao.maps.Marker({ map: map, position: new kakao.maps.LatLng(place.y, place.x), }); // 검색 결과가 정확한 경우, 인포윈도우 노출 infowindow.setContent( '<div style="padding:0.5rem;font-size:1rem;width:max-content;">' + place.place_name + "</div>" ); infowindow.open(map, marker); // 마커영역에 마우스를 올리면 장소명, 주소를 포함한 인포윈도우 노출 kakao.maps.event.addListener(marker, "mouseover", function () { infowindow.setContent( '<div style="padding:0.5rem;font-size:1rem;width:max-content;">' + place.place_name + "</div>" ); infowindow.open(map, marker); }); // 마커 영역에 마우스를 내리면 장소명, 주소를 포함한 인포윈도우 제거 kakao.maps.event.addListener(marker, "mouseout", function () { infowindow.close(); }); kakao.maps.event.addListener(map, "click", function () { infowindow.close(); }); // 마커 클릭 시 해당 장소의 이름, 도로명 주소 설정 여부를 묻고 결정 후 modal 닫기 kakao.maps.event.addListener(marker, "click", function () { customAlert .sweetPromise( "해당 주소로 선택하시겠어요?", place.place_name, place.road_address_name ) .then((res) => { if (res === true) { dispatch(locateActions.setShopAddress(place.place_name)); dispatch(locateActions.setPlaceUrl(place.place_url)); props?.close(); props?.placeNull(); } return; }); }); } }, [props?.searchPlace]); return ( <Grid className="map_wrap"> <Grid id="map" absolute="relative" overflow="hidden" width="auto" height="100vh" /> </Grid> ); }; export default MapContainer;
ShopAddress.js
// ShopAddress.js // 게시글 작성 및 수정 시 지도에서 키워드로 식당 찾기 import React from "react"; import { AddressGrid, MapContainer } from "."; import { Grid, Image } from "../elements"; // 이미지 import { png } from "../styles/img/index"; import { webp } from "../styles/img/webp/index"; import { isWebpSupported } from "react-image-webp/dist/utils"; const ShopAddress = (props) => { const [inputText, setInputText] = React.useState(""); const [place, setPlace] = React.useState(""); const onChange = (e) => { setInputText(e.target.value); }; const onKeyPress = (e) => { if (e.key === "Enter") { handleSubmit(e); } }; // setPlace로 place값 지정하고 MapContainer 컴포넌트로 넘겨줌 const handleSubmit = (e) => { e.preventDefault(); setPlace(inputText); }; // 검색 - 식당 설정 후 지도 초기화 const placeNull = () => { setInputText(""); setPlace(""); }; return ( <React.Fragment> <AddressGrid is_shop close={props?.close}> <Grid is_flex2 bg="#fff" padding="1.8rem 2rem 1.6rem 2rem" margin="0 0 0 0.1rem" <Grid width="fit-content" height="5rem" radius="1.2rem" border="0.1rem solid #EBE9E9" padding="1rem" bg="#FFFFFF" is_flex2 <input type="text" id="keyword" placeholder="식당 + 지역으로 검색 돼요." value={inputText} onChange={onChange} onKeyPress={onKeyPress} style={{ border: "none", fontSize: "1.6rem", width: "27rem", height: "5rem", background: "none", outline: "none", }} /> <Image size="2.4" src={isWebpSupported() ? webp.searchWebp : png.search} margin="0 0 0.4rem" _onClick={handleSubmit} /> </Grid> </Grid> <MapContainer searchPlace={place} close={props?.close} placeNull={placeNull} /> </AddressGrid> </React.Fragment> ); }; export default ShopAddress;