가끔은 그런날이 있다.
'어... 아까 과속 단속 카메라 앞에서 빠르게 지나간 것 같은데?'
그런데 과속단속 카메라 위치를 보려고 해도 잘 찾아지지 않았다.
그래서 한번 만들어보기로 했다.
내가 주로 가는 곳이 경기도였기에, 경기 공공 데이터를 써보기로 했다.
들어가보니 이미 카카오 맵 API로 구현한 예제가 있었다.
만들면 이런 느낌이 날 것 같았다.
일단 데이터에서 보니 단속 카메라가 단순히 과속 뿐만 아니라 여러가지였다.
종류는 01:속도, 02:신호, 03:통행위반, 04:불법주정차, 99:기타 이렇게 총 5가지.
이제 타입스크립트라서 반환 타입을 지정해줘야 하는데...
출력값이 총 24개네? 쓸데없이 개많다.
일단 json 데이터를 하나 다운받은 뒤 뤼튼에다가 던져서 타입을 출력했다.
역시 AI가 개꿀이다.
interface cameraDataType {
MNLSS_TEFCM_INFO: string;
SIDO_NM: string;
SIGUN_NM: string;
ROAD_KIND_NM: string;
ROUTE_MANAGE_NO_INFO: string | null; // 빈 값이 있을 수 있으므로 null 허용
ROUTE_NM: string;
ROUTE_INFO: string;
REFINE_ROADNM_ADDR: string;
REFINE_LOTNO_ADDR: string;
REFINE_WGS84_LAT: string;
REFINE_WGS84_LOGT: string;
INSTL_PLC: string;
REGLT_DIV_NM: string;
LIMITN_SPEED_INFO: string;
PRTCAREA_DIV: string;
INSTL_YY: string;
MNGINST_NM: string;
MNGINST_TELNO: string;
DATA_STD_DE: string;
}
이렇게 타입을 지정하고 json 파일을 읽어와 지도에 표시할 것이다.
단속 카메라가 우후죽순으로 생기지는 않으니 그냥 구현해보는 용으로는 괜찮을 것이다.
이제 카카오 맵 API 이용을 위한 기본적인 설정을 할 차례이다.
카카오 디벨로퍼로 들어간다.
그리고 내 어플리케이션으로 들어간다.
자신의 어플리케이션이 없다면 하나 만들어주자.
그리고 플랫폼을 등록해준다.
기본적으로 여기에 등록된 사이트 도매인 이외에는 API 사용이 제한된다.
그리고 하단의 카카오맵에 들어가 활성화 설정을 해준다.
마지막으로 나의 API키를 확인해주자.
이제 이 키를 복사해서 나의 .env 에다가 넣어주면 된다.
어차피 노출이 되는, nextpublic으로 설정해야 하는 키이기에 그냥 자바스크립트에 때려박아도 상관없다.
빨리 만들어보고 싶어서 github에서 찾은 카카오맵 라이브러리를 써보기로 했다.
JaeSeoKim님의 깃허브를 링크 걸어둔다.
먼저 라이브러리를 설치해보자.
yarn add react-kakao-maps-sdk
해당 라이브러리 안에 여러가지 타입들을 지원해준다.
tsconfig.json에서 아래와 같이 설정해주자.
{
...,
"compilerOptions": {
...,
"types": [
...,
"kakao.maps.d.ts"
]
}
}
혹시 typeRoots가 있다면 이게 먹히지 않는다.
이럴때는 아래와 같이 설정해주면 된다.
{
"compilerOptions": {
...
"typeRoots": [
"./node_modules/kakao.maps.d.ts/@types"
]
...
}
}
기본적이 준비가 끝났으니 카카오맵을 호출하기 위한 컴포넌트를 만들어보자.
스크립트가 모두 로드되기 전에 Map이 호출되면 오류가 난다.
그래서 onReady를 통해 스크립 상태를 확인하고, 로딩이 끝난 뒤에 지도를 표시하도록 했다.
"use client";
import { Map, MapTypeControl, ZoomControl } from "react-kakao-maps-sdk";
import { useEffect, useState } from "react";
import { cameraCustomOverlay } from "@/types/allTypes";
import Script from "next/script";
export default function KakaoMapPage({
data,
}: {
data: cameraCustomOverlay[];
}) {
const [scriptLoad, setScriptLoad] = useState<boolean>(false);
return (
<div className="items-center">
<Script
src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_API_KEY}&autoload=false&libraries=clusterer`}
onReady = {() => setScriptLoad(true)}
/>
{scriptLoad ? (
<Map // 지도를 표시할 Container
style={{ height: "calc(100svh - 100px)" }}
className="w-full"
id="map"
center={{ lat: 37.44749167, lng: 127.1477194 }}
level={3} // 지도의 확대 레벨
>
<MapTypeControl position={"TOPRIGHT"} />
<ZoomControl position={"RIGHT"} />
</Map>
) : (
<div></div>
)}
</div>
);
}
이제 이걸 Page에서 호출하거나, 자체를 Page에다가 갖다 집어넣어도 아래와 같이 실행이 된다.
일단 지도를 띄우긴 했으니 반은 성공인 셈이다.
이제 카메라 데이터에서 경도, 위도, 그리고 속도제한 데이터를 뽑아와 지도에 표시할 차례이다.
SDK에서 마커 표시방법은 상당히 간단하다.
그냥 표시하고 싶은 위치의 경도, 위도를 집어넣으면 끝.
<MapMarker position={{ lat: 37, lng: 128 }} />
대충 위도, 경도를 집어넣었더니 충주의 한 야산에 핀이 꽂혀 있다.
그리고 태그 안에다가 ReactNode를 집어넣으면 글자도 표시해준다.
<MapMarker position={{ lat: 37, lng: 128 }}>
<div>어.. 여기가</div>
<div>위도 37도 맞는교?</div>
</MapMarker>
사실 별로 이쁘진 않다.
그래서 커스텀 오버레이도 지원한다.
커스텀 오버레이는 custumOverlayMap을 호출해 온 뒤에 쓰면 된다.
<MapMarker position={{ lat: 37, lng: 128 }}></MapMarker>
<CustomOverlayMap position={{ lat: 37, lng: 128 }} yAnchor={2}>
<div>
<div className="rounded-lg p-2 border border-red-500 bg-violet-600 text-white">
<div>어.. 여기가</div>
<div>위도 37도 맞는교?</div>
</div>
</div>
</CustomOverlayMap>
중요한 점은, MapMarker태그 바깥쪽에 써야 하다는 것.
MapMarker 내부에서 선언하면 흰 박스가 계속 남아있게 된다.
이제 이걸 함수로 만들어주자.
함수에 들어갈 데이터에 맞게 cameraCustomOverlay
라는 타입도 만들어 주었다.
interface cameraCustomOverlay {
lat: number;
lng: number;
title: string;
limit: number;
}
const EventMarkerContainer = ({ data }: { data: cameraCustomOverlay }) => {
const [isVisible, setIsVisible] = useState(false);
const position = { lat: data.lat, lng: data.lng };
return (
<>
<MapMarker
position={position} // 마커를 표시할 위치
onClick={() => setIsVisible(!isVisible)}
/>
{isVisible ? (
<CustomOverlayMap
position={position}
yAnchor={1.5}
key={`overlay-${data.lat}-${data.lng}`}
>
<div>
<div className="bg-gray-50 text-black border-gray-500 rounded-lg border-2 relative">
<div className="bg-slate-500 text-white p-2">카메라 정보</div>
<div className="absolute top-0 right-0 p-2">
<XCircleIcon
className="size-6 text-white"
onClick={() => setIsVisible(!isVisible)}
/>
</div>
<div className="p-2">
<div className="break-words">{data.title}</div>
<div>제한속도 : {data.limit}</div>
</div>
</div>
</div>
</CustomOverlayMap>
) : null}
</>
);
};
마우스 오버 이벤트로 했더니 핸드폰에서 실행이 안됐다.
그래서 useState를 이용해 오버레이를 열고 닫을 수 있도록 만들었다.
json을 가공한 뒤 map으로 태그를 넣으면 끝이다.
json을 읽고 파싱한뒤, 주정차 단속은 필터로 제거한다.
그리고 내게 필요한 정보인 경위도, 설치장소, 속도제한를 컴포넌트로 넘겨주었다.
import KakaoMapPage from "@/components/KakaoMapPage";
import fs from "fs";
import { cameraJsonPath } from "@/data/filePaths";
import { cameraDataType } from "@/types/allTypes";
export default function Page() {
const dataJson = fs.readFileSync(cameraJsonPath, "utf-8");
const allData: cameraDataType[] = JSON.parse(dataJson);
const filterdMarkerData = allData.filter(
(data) => !data.REGLT_DIV_NM.includes("4")
);
const markerData = filterdMarkerData.map((data) => {
return {
lat: Number(data.REFINE_WGS84_LAT),
lng: Number(data.REFINE_WGS84_LOGT),
title: data.INSTL_PLC,
limit: Number(data.LIMITN_SPEED_INFO),
};
});
return (
<div className="items-center">
<KakaoMapPage data={markerData} />
</div>
);
}
이제 컴포넌트에서는 해당 데이터를 이용해 마커를 표시한다.
export default function KakaoMapPage({
data,
}: {
data: cameraCustomOverlay[];
}) {
...
{scriptLoad ? (
<Map // 지도를 표시할 Container
style={{ height: "calc(100svh - 100px)" }}
className="w-full"
id="map"
center={{lat: 37.44749167,lng: 127.1477194}}
level={3} // 지도의 확대 레벨
>
{data.map((camera, idx) => (
<EventMarkerContainer // 마커를 생성합니다
key={idx}
data={camera}
/>
))}
<MapTypeControl position={"TOPRIGHT"} />
<ZoomControl position={"RIGHT"} />
</Map>
) : (
<div></div>
...
);
}
이렇게 하면 마커가 표시되고, 클릭할 때 커스텀 오버레이들이 뜨게 된다.
실행해보니 잘 된다.
navigator를 이용하면 사용자의 GPS 정보를 가져올 수 있다.
useState로 기본 위치를 지정해두고, useEffect에서 navigator로 사용자의 위치정보를 가져와 지도의 위치를 재할당한다.
...
const [mapCenter, setMapCenter] = useState({
lat: 37.44749167,
lng: 127.1477194, // 기본위치 경기도 성남
});
useEffect(() => {
const { geolocation } = navigator;
geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
setMapCenter({
lat: latitude,
lng: longitude, // 사용자의 위치로 재할당
});
},
(err) => console.log(err),
{ enableHighAccuracy: true, timeout: 5000, maximumAge: 0 }
);
}, []);
...
cctv 데이터가 너무 많은 관계로 지도가 너무 버벅거렸다.
이제 이 문제를 클러스터로 해결해보자.
<MarkerClusterer averageCenter={true} minLevel={6}>
{data.map((camera, idx) => (
<EventMarkerContainer // 마커를 생성합니다
key={idx}
data={camera}
/>
))}
</MarkerClusterer>
SDK에서는 단순하게 MarkerClusterer 태그로 감싸주기만 하면 된다.
그런데 여기서 문제점은 CustomOverlayMap 태그 또한 마커 태그와 똑같이 인식되면서 클릭에 대한 반응이 조금 달라진다.
만일 여기서 클러스터와 같이 마커, 커스텀오버레이를 쓰고 싶다면 아래와 같이 태그를 분리해 주어야 한다.
<MarkerClusterer averageCenter={true} minLevel={6}>
{data.map((camera, idx) => (
<MapMarker // 마커를 생성합니다
key={idx}
position={{ lat: camera.lat, lng: camera.lng }}
/>
))}
</MarkerClusterer>;
{
data.map((camera, idx) => (
<CustomOverlayMap key={idx} position={{ lat: camera.lat, lng: camera.lng }}>
내가 원하는 내용
</CustomOverlayMap>
));
}
이제 여기서 각각의 MapMarker와 CustomOverlayMap이 useState를 공유하게 만들어 주어야 한다.
이는 key를 이용하면 된다.
// 먼저 useState로 활성화시킬 마커의 키를 지정해준다.
const [activeMarkerId, setActiveMarkerId] = useState<null|number>(null);
...
<MarkerClusterer averageCenter={true} minLevel={6}>
{data.map((camera, idx) => (
<MapMarker // 마커를 생성합니다
key={idx}
position={{ lat: camera.lat, lng: camera.lng }}
onClick={()=> {
setActiveMarkerId(activeMarkerId === idx ? null : idx);
// 마커를 클릭하면 마커의 idx를 활성화 키로 지정함.
// 만약 기존의 키가 마커의 키와 같다면 키를 해제함.
}}
/>
))}
</MarkerClusterer>
{data.map((camera, idx) => (
// 활성화된 키와 일치할 경우만 커스텀 오버레이를 보여줌.
activeMarkerId === idx && (
<CustomOverlayMap
key={idx}
yAnchor={1.5}
position={{ lat: camera.lat, lng: camera.lng }}
>
<div>
<div className="bg-gray-50 text-black border-gray-500 rounded-lg border-2 relative">
<div className="bg-slate-500 text-white p-2">카메라 정보</div>
<div className="absolute top-0 right-0 p-2">
<XCircleIcon className="size-6 text-white"
onClick={() => setActiveMarkerId(null)}
/>
</div>
<div className="p-2">
<div className="break-words">{camera.title}</div>
<div>제한속도 : {camera.limit}</div>
</div>
</div>
</div>
</CustomOverlayMap>
)
))}
지구과학 선생 치고 이 정도면 너무 깊이 들어온 것 같다.
이제 한번 마지막으로 실행해보자.
이틀 카카오맵 API SDK를 한번 파보았다.
안되는거 찾느라 골머리였는데 결국에는 해결하니 기분이 좋다.
시간이 된다면 API 자체도 한번 해봐야겠지만, 그럴 시간이 부족한 것 같다.
나는 아마 이까지인 듯...
그래도 새로운걸 배울 수 있어 즐거운 시간이었다.