d3-geo, TopoJSON, canvas를 이용한 맵 차트 그리기

TOAST UI Map Chart 4를 새롭게 준비하면서 조사했던 지도 그리는 방법에 대해 간단하게 공유하려고 합니다.

왜 이조합인가?

기존 Chart3에서는 svg를 사용해 path 자체를 가져와 직접 입력해주는 형태로 차트를 그려주고 있었습니다. 지도데이터를 그릴 때 제일 먼저 생각나는 것은 캔버스가 아닌 svg를 사용하는 것입니다. 여러 부분에서 캔버스보다 다루기 쉬운 것은 사실이지만 TOAST UI Chart4의 반응형 로직을 그대로 활용하고 싶다는 생각에서 캔버스를 사용해 테스트를 진행했습니다. 또한, amChart에서 제공하는 데이터를 직접 사용하다 보니 행정 구역상의 변경이 있거나 amchart에 존재하지 않는 지도일 경우 사용이 어려운 문제가 있었습니다.

아무래도 path 자체를 갖고 있는 지도 데이터를 갖고 있는게 아닌 그리는 방법을 갖고 있다보니위도, 경도를 입력해 마커를 표시하기에 무리가 있으며 이후에 새로운 기능을 확장하기에 무리가 있었습니다.

그럼 표준화된 지도 데이터 방식이 뭐가 있을까요?

GeoJSON과 TopoJSON

저도 이 분야는 익숙지 않아 관련 라이브러리를 먼저 조사했습니다. 기존에 사용하고 있던 amChart 부터 많이 사용하는 D3, leaflet, Mapbox, Google map 같은 서비스들을 살펴보면 지도를 그리는 데 사용되는 데이터로 GeoJSON 그리고 TopoJSON을 공통으로 확인할 수 있습니다.

GeoJSON

GeoJSON은 위치 정보를 갖는 점을 기반으로 지형을 표현하기 위해 설계된 개방형 공개 표준 형식입니다. 이름에서도 알 수 있듯이 JSON 형태의 파일 형태입니다. 다른 지리 정보 시스템(GIS) 처럼 공식 표준 조직에서 운영되지 않고 국제 인터넷 표준화 기구 산하 워킹그룹에서 유지되지만, XML을 기반으로 한 GPX사실상 표준처럼 사용됩니다.

GeoJSON은 FeatureCollection을 갖는 객체에 Feature 집합을 갖는 JSON 형태로 나타납니다. 아래 예시는 자바해의 말레이제도의 가상 GeoJSON 표기를 나타낸 내용입니다.

Feature 집합은 여러 geometry 타입과 좌표, 속성값을 갖습니다. 사용할 수 있는 간단한 geometry를 위키에서 참조해보면 다음과 같습니다. GeoJSON 좌표는 [경도, 위도]를 지원합니다.

GeoJSON 각각의 Geometry의 타입과 매칭되는 결과는 다음과 같습니다.

TopoJSON

GeoJSON은 이제 어떤 느낌인지 조금 이해가 가기 시작하는데 새로운 JSON 형태가 등장합니다. 바로 TopoJSON입니다. TopoJSON은 GeoJSON의 확장입니다. 여기서 말하는 Topology는 GIS 혹은 두 공유 경계선(또는 연결선 사이의 경계점)을 단순화한 뒤 arc로 나타내는 방식입니다. 이로 인해 형태는 좀 더 단순해지지만, 더 작은 파일을 갖게 됩니다. (데이터에 따라 다르겠지만 약 10배 정도 가벼워진다고 합니다.)

TopoJSON의 간단한 예시를 살펴보겠습니다. GeoJSON과 비슷하게 느껴질 수 있지만 TopoJSON은 Topology타입을 가지며 objects에 geometry값들이 담겨 있습니다. 그 외 arcbbox등의 경곗값과 데이터 직렬화를 위한 transform값이 담겨 있을 수 있습니다.

TOAST UI Map Chart는 복잡한 지도 데이터를 다루는 대신 단순하고 가벼운 차트를 개발해야 하는 방향을 갖고 있었습니다. 또한, 기하학적으로 변형을 주지 않을 예정이였기 대문에 GeoJSON 대신 TopoJSON을 사용해 지도를 그릴 수 있다면 ‘좀 더 가벼운 데이터를 가지고 있을 수 있겠다’ 생각했고 테스트를 하기 시작했습니다. (실제로 축척이나 얼마나 구체적으로 표현하냐에 따라 달라지겠지만 세계 지도의 경우 GeoJSON이 10~20MB가 나가기도 했습니다. 하지만 TopoJSON의 경우 500B~1KB 정도 였습니다.)

d3-geo

GeoJSON, TopoJSON을 이용해 지도를 그리는데에는 d3-geo를 사용했습니다. d3-geo 없이 지도를 그리려면 투영(projection)에 따라 좌표값이 변할 수 있어 적절한 계산을 별도로 수행해야 했고 polygon을 그리거나 topology 차이를 계산하는 등 여러 수학적인 계산이 필요하게 됩니다.

일일히 스펙을 학습하고 수학 공부를 해서 맵 차트를 그리기에는 무리가 있었고 이미 잘 만들어져 있고 널리 사용되고 있는 d3-geo를 사용하기로 마음먹었습니다. (사실상 TopoJSON이나 GeoJSON을 통해 지도를 생성하고 싶은 경우에 d3-geo를 사용하는 것은 국룰에 가까웠습니다)

d3-geo는 전체 번들크기가 크지 않았으며 트리 쉐이킹을 지원했기 때문에 큰 부담없이 사용할 수 있었습니다.(참고: https://bundlephobia.com/result?p=d3-geo@2.0.1)

예제 만들어 보기

그럼 직접 예제 코드를 살펴보며 간단한 API를 살펴보겠습니다. 예제 코드는 테스트 용도로 작성되어서 비효율적으로 작성한 부분이 있으니 양해부탁드립니다.

한국 TopoJSON은 테스트용으로 Popong에서 만들어주신 데이터를 사용했습니다.

위에서 소개했던 기술을 이용해 세계지도와 한국 지도를 생성해보고, 세계 지도에는 위도와 경도를 이용해 마커를 추가해보겠습니다.

0. topojson-client으로 TopoJSON 변환하기

제일 먼저 TopoJSON을 데이터를 가져와 d3-geo 에서 사용할 수 있도록 GeoJSON으로 변환하는 과정을 거칩니다. 이 과정에는 topojson-client를 사용했습니다. feature메서드를 통해 TopoJSON 데이터를 변환해 GeoJSON으로 반환해줍니다.

1. 투영(Projection) 지정하기

지구는 둥급니다. (아니다! 지구는 평평하다 라고 하시는 분이 있으실 수 있지만..😥) 투영을 통해 구형 다각형을 평면 다각형으로 변환하는 과정을 거쳐 화면으로 나타내줍니다. d3-geo는 여러가지 투영법을 제공하는데요.

등등 여기에 나와 있지 않은 더 많은 투영법을 제공합니다.

예제에서는 메르카토르 도법을 이용해 세계 지도를 그려보겠습니다. geoMercator를 통해 투영법을 생성한 뒤 해당 투영법에 추가 작업을 수행하게 됩니다.

코드를 살펴보다 보면 projection에 scale이나 translate를 지정해 주는 부분이나 bounds메서드를 통해 계산을 진행하는 것을 볼 수 있습니다. 여기서 scale은 지도를 그릴 때 사용하는 축척을 의미합니다.

한국 지도를 나타낼때 처럼 특정 지역의 그릴때는 globe가 outline이 되는 것이 아닌 한국 자체가 outline이 되어야 하기 때문에 해당 GeoJSON feature를 전달하는 방식으로 나타낼 수 있습니다.

path.bounds()는 GeoJSON 객체에 대해 투영된 평면 경계 상자를 반환합니다. [[x0, y0], [x1, y1]] 2차원 배열로 표시되며 각각 최소, 최대 좌표를 나타냅니다. computeBounds() 메서드는 캔버스에 크기에 맞게 투영이 조절되게 하기 위한 여러가지 정보를 계산해 전달해줍니다.

이로써, 이 지도에서 사용될 투영을 지정하고 캔버스의 크기에 맞게 축척과 중심점을 지정해줬습니다.

이제 이 투영법이 적용된 path generator를 만들겠습니다. d3-geo에서 제공하는 geoPath를 통해 쉽게 생성할 수 있습니다. 첫 번째 매개변수로 투영을 입력할 경우 해당 투영이 적용됩니다.

이제 지도를 그릴 준비는 모두 마무리 되었습니다. 지도 데이터를 이용해서 본격적으로 지도를 나타내 보겠습니다.

2. 지도 그리기

작성한 render()함수를 보면 mapData(GeoJSON의 features)를 기준으로 순회를 돌며 object를 기준으로 getNewMapAreaCanvas()함수를 호출합니다.

getNewMapAreaCanvas()에서는 제일 먼저 생성해 둔 geoPath를 이용해 캔버스를 생성하고 각 객체들의 bounds를 계산하고 크기만큼 크기를 조절해줍니다. 이후 동일하게 translate를 통해 위치를 이동시킨뒤 geoPath.context()를 이용해 차트를 그립니다.

context의 경우 인자가 null인 경우 svg path가 반환되며 canvas의 context를 넘겨주는 경우 다음과 순서를 진행하며 path를 그려줍니다.

끝입니다. 사실 해준건 사용할 projection을 지정해주고 축척과 약간의 이동 뿐인데 d3-geo가 그림을 그리는데 큰 도움을 주게 됩니다. world 데이터를 기준으로 생성된 지도를 보면 다음과 같습니다.

3. 이벤트 탐지하기

마우스를 지도 영역에 올리거나 클릭을 했을 때 마우스 좌표가 어느 영역에 올라가 있는지 탐지하려면 어떻게 해야할까요? 이 또한 d3-geo가 깔끔하게 판단해 줍니다. 예제에서는 데이터를 클릭했을 경우 해당하는 object가 존재할 때 properties를 나타내도록 구현되어있습니다. 이 showCountryNameInRange() 함수에서 봐야하는 두 가지 함수가 있는데 projection.invert()getContains()입니다.

invert()는 인자로 받은 픽셀 단위 마우스 포지션을 투영해서 [경도, 위도]로 변경해 반환합니다. 이 값을 이용해 getContains()를 통해 이 object에 포함되어 있는지를 알아낼 수 있습니다.

4. 마커 만들기

위도 경도값을 이용해 지도에 마킹을 진행해 보도록 하겠습니다. 기존에 생성해 둔 투영을 그대로 이용해 [경도, 위도] 값을 전달하면 이 투영에 맞춰 [x, y]의 픽셀값이 어디인지 나타나게 됩니다. (간단하쥬?)

예시로 모스크바의 위치를 세계지도에 표시해보도록 하겠습니다.

실제 모스크바의 빨간 마커가 보이는 부분입니다.

위도, 경도 값을 입력받아 projection을 통해 좌표로 변환 뒤 arc를 이용해 원을 그렸습니다. 결과는 다음과 같습니다.

마무리

지금까지 GeoJSON, TopoJSON에 대해 간단히 살펴보고 d3-geo를 활용해 canvas 위에 지도를 그려봤습니다.여기서 소개한 API 외에 좌표 계산에 도움이 되는 여러가지 메서드들이 추가로 많이 존재합니다. 다음과 같은 기술을 사용하면서 좀 더 표준에 가까운 데이터를 기반으로 차트를 그릴 수 있게 되었고 위도, 경도 기반의 마커를 그리는 등 여러 확장할 수 있는 가능성을 갖게 되었습니다. (사용자가 그리고 싶은 TopoJSON을 입력하는 경우 차트를 더 쉽게 그릴 수 있도록 하는 부분도 고려중입니다.)

사실 d3-geo와 TopoJSON을 입력해 지도를 그리는 것 자체도 많은 것을 학습해야 하고 복잡합니다. TOAST UI Map Chart는 이후 업데이트에서 이런 복잡한 부분을 좀 더 단순하게 만들어 사용자가 쉽게 지도 위에서 놀 수 있도록 자리를 마련하려고 합니다. 아직은 시작 단계이지만 기대해주시고 기다려주시면 좋은 차트로 보답하겠습니다.

참고

개인용 블로그로 사용하고 있습니다. 좋은 개발자가 꿈입니다. > https://www.notion.so/Han-Jung-c43f4bcd2b3f4b3d85b93aee41c5e098

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store