본문 바로가기

데이터와 함께 탱고를/데이터 시각화

[지도 데이터 시각화] Part 5. Mapboxgl 살펴보기

Mapbox 는 WebGL 기술을 이용해, 브라우저에 지도 데이터를 렌더링해주는 해주는 자바스크립트 라이브러리입니다. (물론, 모바일용, Unity 용으로도 있습니다.)

Mapbox 의 주 기능은 무엇보다 '이쁘게' 그려준다는 겁니다.
API 기능 설계가 대부분 이 '스타일' 에 아주 잘 맞춰져있습니다.

또, 어떤 라이브러리보다, API Reference 가 직관적이고 쉽습니다.
공식 Documentattion 을 보시면, 깔끔하게 API 가 정리되어있는 것을 보실 수 있고,
누구나 쉽게 사용할 수 있도록 Examples 도 훌륭하게 정리되어 있습니다.

Mapbox GL js API Refernce
https://docs.mapbox.com/mapbox-gl-js/api/

Mapbox GL js Examples
https://docs.mapbox.com/mapbox-gl-js/examples/

원래는 js 라이브러리 였지만, 파이썬에서도 사용할 수 있도록 파이썬 라이브러리도 만들어 놓았습니다.

쥬피터에서의 interactive map

어떤 맵이든, interactive 한 기능을 지닌다면 대부분 js 로 구성된 것이라고 보면 됩니다. 즉 코딩은 파이썬으로 하지만, 패키지 내 엔진을 거쳐 html, js 파일로 출력시키는거죠. 이는 map 뿐만이 아니라 모든 쥬피터 위젯들도 마찬가지 입니다. 이를 이용하면, 데이터를 다루는 어떤 js 라이브러리도 파이썬에서 코딩할 수 있게끔 할 수 있습니다.

그럼 이제 하나하나 시작해보겠습니다.

1. 설치 & 세팅

다음 명령어로 설치하고 임포트 합니다.

pip install mapboxgl
import mapboxgl
mapboxgl.__version__

mapboxgl 을 사용하기 위해서는 Mapbox access token 이 필요합니다.
https://www.mapbox.com/ 에 접속하셔서, 계정을 만드시고, account 에 들어가시면 자신의 token 값에 대해 알 수 있습니다.

API 내에 token 값을 이 값을 주셔도 되고,
매번 값을 주기 귀찮은 경우, 환경변수 MAPBOX_ACCESS_TOKEN 에 token 값을 설정하신 다음,
token = os.getenv('MAPBOX_ACCESS_TOKEN') 으로 받아올 수 있습니다.

윈도우, 맥OS 에서의 환경변수 설정은 구글링하시면 쉽게 나옵니다.

2. 샘플 하나 해보기

folium 에서는 map 이라는 표현을 사용했다면, mapboxgl 에서는 viz 라는 표현을 사용합니다.
mapboxgl 에서 지원하는 모든 레이어를 다 불러오려면 아래와 같이 코딩해주면 됩니다.

from mapboxgl.viz import *

이전 포스팅 folium 에서와 동일한 Choropleth 맵을 그려보겠습니다.
코드 스타일이 folium 과 어떻게 다른지 느끼실 수 있을 겁니다.

샘플 데이터는 서울시 행정동별 고령인구 지도 데이터를 사용합니다.
파일 내부에 데이터가 아래와 같이 저장되어 있습니다.

geo_data = 'data/older_seoul.geojson'

# 파일을 파이썬에서 읽습니다.
import json
with open(geo_data) as f:
    data = json.loads(f.read())
{'type': 'FeatureCollection',
 'crs': {'type': 'name',
 'properties': {'name': 'urn:ogc:def:crs:OGC:1.3:CRS84'}},
 'features': [{
   'type': 'Feature',
   'properties': {
    '시': '서울특별시',
    '구': '종로구',
    '동': '사직동',
    '행정동코드': 11110530,
    '인구': 9700,
    '남자': 4375,
    '여자': 5325},
   'geometry': {'type': 'MultiPolygon',
    'coordinates': [[[[126.97688884274817, 37.575650779448786],
       [126.9770344988775, 37.569194530054546],
       [126.97597472821249, 37.569336299425764],
       ....
     ]]]
   }, {
   'type': 'Feature',
   'properties': {
    '시': '서울특별시',
    '구': '종로구',
    '동': '삼청동',
    '행정동코드': 11110540,
    '인구': 3013,
    '남자': 1443,
    '여자': 1570},
   'geometry': {'type': 'MultiPolygon',
    'coordinates': [[[[126.98268938649305, 37.5950655194224],
       [126.98337258456999, 37.59435192551688],
       [126.98386809792802, 37.59385046812643],
       ....
     ]]]
   }]
}

MultiPolygon 단위로, 시군구 이름, 인구, 남자, 여자 데이터를 가지고 있는 일반적인 geojson 데이터 입니다.

이를 인구에 따라 각 데이터가 다른 색을 가지도록 ChoroplethViz 로 시각화 해보겠습니다.

import os
from mapboxgl.utils import create_color_stops

# 환경변수에서 자신의 mapbox token 을 가져옵니다.
token = os.getenv('MAPBOX_ACCESS_TOKEN')

# 서울시 중심부의 경도, 위도 입니다. 
center = [126.986, 37.565]

# 시각화 할 값에 따른 색상의 범주를 지정해줍니다.
color_breaks = [0, 10000, 20000, 30000, 40000, 50000]
color_stops = create_color_stops(color_breaks, colors='BuPu')
# color_stops 에는 다음과 같은 값이 들어갑니다.
# color_stops = [
#     [0, 'rgb(237,248,251)'],
#     [10000, 'rgb(191,211,230)'],
#     [20000, 'rgb(158,188,218)'],
#     [30000, 'rgb(140,150,198)'],
#     [40000, 'rgb(136,86,167)'],
#     [50000, 'rgb(129,15,124)']
# ]

# ChoroplethViz 를 그립니다.
viz = ChoroplethViz(
    access_token=token,
    data=data,
    color_property='인구',
    color_stops=color_stops,
    center=center,
    zoom=10)

# 맵을 출력합니다.
viz.show()

기본적인 파라미터만 줘도 꽤 이쁘게 시각화가 됐습니다.
각 데이터(폴리곤)에 마우스를 올리시면 해당 데이터의 속성(필드) 값이 뜨는 것을 볼 수 있습니다.

3. 동작 살펴보기

그럼 이제 어떻게 동작하는지 찬찬히 살펴보겠습니다.

먼저, folium 과는 다르게 mapboxgl 은 오로지 1개의 geojson 데이터만을 필요로 합니다.
즉, geojson 안에 데이터 별 속성(변수) 값과 지도 데이터 둘 다 있어야 합니다.
위에서 사용한 ChoroplethViz 를 간략히 살펴보면 다음과 같이 구성되어 있습니다.

ChoroplethViz(
    token = "자신의 mapbox 계정 api token",
    data = "데이터 (.geojson)",
    color_property = "데이터 내 속성에서 시각화할 색의 기준이 될 속성",
    color_stops = "시각화할 색상과 각 색상의 범주 값",
    center = "지도의 중심점",
    zoom = "지도의 줌 레벨"
    *args,
    **kwargs,
)

추가적인 설명을 하면,

  • token 은 항상 pk. ... 로 시작합니다. 각자의 계정에서 확인해보세요.
  • data 에는 geojson 파일 이름이 아니라 미리 파일을 읽어온 값을 주어야합니다. 즉 메모리에 올라간 (인메모리) 값입니다.
  • color_stops 은 위 예처럼 create_color_stops() 으로 만드는게 일반적입니다.
  • center 는 (위도, 경도)가 아니라 (경도, 위도) 순입니다. 이게 folium 이랑 다릅니다. mapboxgl 에서는 모든 위치 데이터는 (경도, 위도) 순으로 되어 있습니다.

이 외에도 다음과 같은 파라미터들을 추가로 줄 수 있습니다.
(물론 이거 말고도 더 있습니다. 공식 도큐먼트나 ChoroplethViz? 를 통해서 확인해보세요.

color_default = "매칭이 안된 데이터들의 기본 색상 (default grey)",
color_function_type = "color_stops 의 색상을 어떤 순서로 나타낼지 형태 (default interpolate)",
line_color = "선 색상 (default white)",
line_stroke = "선 유형 (default solid)",
line_width = "선 굵기 (default 1)",
line_opacity = "선 투명도 (default 1)",
height_property = "높이를 줄 속성 (3D extrusion)"
height_stops = "높이를 줄 값의 범위 (m 단위)",
height_default = "기본 높이 (m 단위)"
highlight_color = "마우스 오버(hover)시 나타낼 색상 (default black)",

folium 과 비교하면 매우 디테일하게 스타일링할 수 있습니다.
여기서는 인구 속성으로 색 뿐만 아니라 높이까지 지정하여 3d map 을 만들어보겠습니다.

ChoroplethViz 객체를 생성할 때 파라미터에 넣어도 되지만, 객체를 만든 이후 객체의 속성으로 파라미터 내용을 추가해도 됩니다.
위에서 viz 라는 객체를 이미 만들었으므로, viz 의 속성으로 추가한 뒤 다시 렌더링 해보겠습니다.

from mapboxgl.utils import create_numeric_stops

# 맵을 -15도 만큼 좌우 회전하고, 45도 만큼 상하 회전합니다.
viz.bearing = -15
viz.pitch = 45

# 각 데이터에 '인구'를 기준으로 height 값을 줍니다.
viz.height_property = '인구'

# 높이의 값을 '인구' 에 따라 0 ~ 50000 사이의 값을 0 ~ 3000 사이의 값으로 매핑합니다.
numeric_stops = create_numeric_stops([0, 10000, 20000, 30000, 40000, 50000], 0, 3000)
# numeric_stops 에는 다음과 같은 값이 들어갑니다.
# numeric_stops = [
#     [0, 0.0],
#     [10000, 500.0],
#     [20000, 1000.0],
#     [30000, 1500.0],
#     [40000, 2000.0],
#     [50000, 2500.0]
# ]

viz.height_stops = numeric_stops
viz.height_function_type = 'interpolate'

# render again
viz.show()

훨씬 입체적으로 시각화 되었습니다.

만약 전체 데이터가 아니라, 특정한 데이터만 다른 색상으로 시각화 하고싶으면 다음과 같이 하면 됩니다.
이 때 꼭 color_function_type 파라미터 값으로 'match' 을 주어야 합니다.

match_color_stops = [
    ['양재1동', 'rgb(46,204,113)'], 
    ['세곡동', 'rgb(231,76,60)'], 
    ['역삼1동', 'rgb(142,68,173)']
]

token = os.getenv('MAPBOX_ACCESS_TOKEN')
center = [126.986, 37.565]

viz = ChoroplethViz(
    data,
    access_token=token,
    color_function_type='match',
    color_property='동',
    color_stops=match_color_stops, 
    color_default='rgba(52,73,94,0.5)',
    center=center,
    bearing=-15,
    pitch=45,
    zoom=10)
viz.show()

4. csv -> geojson 으로 만들기

mapboxgl 은 .geojson 데이터만 시각화 한다고 했습니다. (굳이 말하자면 .tile 도 가능합니다)
근데 만약 내가 .geojson 파일은 없고, 위도, 경도만 담고있는 .csv 파일만 있다면 어떻게 해야할까요?
사실 대부분의 웹에 공개된 데이터는 .csv 형태인 경우가 많기 때문에 충분히 이런 상황이 발생 할만합니다.

이를 위해 mapboxgl.utils 에서는 .csv 파일을 .geojson 으로 컨버팅해주는 함수가 존재합니다.
매우 유용하므로 여기서 잠깐 예제를 통해 소개하고 넘어가곘습니다.

먼저 pandas 로 서울시내 공중화장실 데이터를 .csv 파일을 불러오겠습니다.

import pandas as pd

df = pd.read_csv('data/toilet_seoul.csv')
df.head()

위도 경도를 담고있는 .csv 파일입니다. 이제 이를 mapboxgl.utils 에 있는 df_to_geojson() 을 통해 geojson 형태로 만들어보겠습니다.

from mapboxgl.utils import df_to_geojson

geo_data = df_to_geojson(
    df=df,
    lat='위도',
    lon='경도',
    # filename = "data/toilet_seoul.geojson"
)

type(geo_data)

이렇게 하면 geo_data 는 이제 위 데이터프레임을 geojson 형태로 갖게 됩니다.
만약 변수가 아니라 파일로 뽑아내고 싶으시면 위에서 주석처리한 부분을 푸시면 됩니다.

5. 다른 레이어들 살펴보기

5.1. Point 단위

CircleViz

샘플로, 간단하게 서울시내 공중화장실 데이터를 지도에 Point 들을 찍어보겠습니다.

geo_data = 'data/toilet_seoul_sample.geojson'

import json
with open(geo_data) as f:
    data = json.loads(f.read())

위 파일을 열어보면 다음과 같이 데이터가 저장되어 있습니다.

{'type': 'FeatureCollection',
 'features': [{'type': 'Feature',
   'geometry': {'type': 'Point', 'coordinates': [127.158647, 37.501401]},
   'properties': {'구명': '송파구', '법정동명': '마천동', '이용량': 248}},
  {'type': 'Feature',
   'geometry': {'type': 'Point', 'coordinates': [127.073728, 37.644937]},
   'properties': {'구명': '노원구', '법정동명': '하계동', '이용량': 252}},
  {'type': 'Feature',
   'geometry': {'type': 'Point', 'coordinates': [127.077637, 37.640335]},
   'properties': {'구명': '노원구', '법정동명': '하계동', '이용량': 192}},
  {'type': 'Feature',
   'geometry': {'type': 'Point', 'coordinates': [127.020449, 37.645724]},
   'properties': {'구명': '강북구', '법정동명': '수유동', '이용량': 409}},
  {'type': 'Feature',
   'geometry': {'type': 'Point', 'coordinates': [127.031187, 37.659783]},
   'properties': {'구명': '도봉구', '법정동명': '방학동', '이용량': 333}},
  {'type': 'Feature',
   'geometry': {'type': 'Point', 'coordinates': [127.123028, 37.502339]},
   'properties': {'구명': '송파구', '법정동명': '가락동', '이용량': 333}},
   ...
]}

Point 단위로 구명, 법정동명, 이용량을 가지고 있는 일반적인 geojson 데이터 입니다.
(이용량은 실제 값이 아니고, 샘플용으로 제가 랜덤으로 준 값입니다.)

import os

token = os.getenv('MAPBOX_ACCESS_TOKEN')
center = [126.986, 37.565]

viz = CircleViz(
    data,
    access_token=token,
    center=center,
    zoom=10)

viz.show()

이용량에 따라 색깔을 달리하려면 다음과 같이 속성 값을 주면 됩니다.
(사실 위에서 한 Choropleth 샘플과 똑같습니다.)

viz.color_property = '이용량'
viz.color_stops = create_color_stops([0, 100, 200, 300, 400, 500], colors='BuPu')

viz.show()

GraduatedCircleViz

GraduatedCircleViz 는 Point 의 색상과 크기 둘 모두 데이터에 따라 다르게 시각화가 가능합니다.

import os
from mapboxgl.utils import (
    create_color_stops,
    create_radius_stops
)

token = os.getenv('MAPBOX_ACCESS_TOKEN')
center = [126.986, 37.565]
viz = GraduatedCircleViz(
    data,
    access_token=token,
    color_property='이용량',
    color_stops=create_color_stops([0, 100, 200, 300, 400, 500], colors='BuPu'),
    radius_property="이용량",
    radius_stops=create_radius_stops([0, 100, 200, 300, 400, 500], 0, 8),
    center=center,
    zoom=10)

# radius_stops 는 다음과 같은 값입니다.
# radius_stops = [
#     [0, 0.0], 
#     [100, 1.67], 
#     [200, 3.33], 
#     [300, 5.0], 
#     [400, 6.67], 
#     [500, 8.33]
# ]

viz.show()

HeatmapViz

HeatmapViz 는 데이터의 속성 값을 기준으로 Heatmap 을 시각화합니다.
아래 예시를 통해 보겠습니다.

import os
from mapboxgl.utils import (
    create_numeric_stops,
    create_color_stops
)

token = os.getenv('MAPBOX_ACCESS_TOKEN')
center = [126.986, 37.565]
viz = HeatmapViz(
    data,
    access_token=token,
    weight_property="이용량",
    weight_stops=create_numeric_stops([100, 200, 300, 400, 500], 0, 1),
    color_stops=create_color_stops([0.1, 0.3, 0.5, 0.7, 0.9], colors='BuPu'),
    radius_stops=create_numeric_stops([8, 10, 12, 14, 16], 3, 12),
    center=center,
    zoom=10
)

viz.show()

추가적인 설명을 하면 다음과 같습니다.

  • weight_property 에 시각화할 데이터의 속성을 줍니다.
  • weight_stops 에 이 weight_property 의 값을 0-1 사이의 값으로 매핑하여 줍니다.
    위 예에서는 0-500 의 값이 0-1 사이의 값으로 매핑됩니다.
  • color_stops 파라미터는 매핑된 이 0-1 값의 범위에 따른 색상 값을 나타냅니다.
  • radius_stopsintensity_stops 파라미터는 zoom 레벨에 따른 원의 크기와 강도를 정하는데 쓰입니다.
    위 예에서는 줌 레벨이 8-16 일 때 각각 줌 레벨에 따른 원의 크기가 3-12 사이의 값으로 매핑됩니다.

ClusteredCircleViz

각 포인트들을 일정한 Cluster 로 묶어 시각화합니다.

import os
from mapboxgl.utils import (
    create_color_stops,
    create_radius_stops
)

token = os.getenv('MAPBOX_ACCESS_TOKEN')
center = [126.986, 37.565]
viz = ClusteredCircleViz(
    data,
    access_token=token,
    color_stops=create_color_stops([100, 200, 300, 400, 500, 1000], colors='BuPu'),
    radius_stops=create_radius_stops([0, 100, 200, 300, 400, 500, 1000], 0, 30),
    cluster_radius=60,
    center=center,
    zoom=10
)

viz.show()

5.2. Path 단위

Path 단위의 레이어들은 geometry 정보가 LineString 단위의 데이터들입니다.

먼저, 샘플 데이터로 사용할 서울시 특정 지역의 도로 데이터를 가져오겠습니다.

import json

with open('data/dongjak_road.geojson') as f:
    data = json.loads(f.read())

이 데이터는 대략 다음과 같이 생겼습니다.

{'type': 'FeatureCollection',
 'crs': {'type': 'name',
  'properties': {'name': 'urn:ogc:def:crs:OGC:1.3:CRS84'}},
 'features': [
  {'type': 'Feature',
   'properties': {'도로명': '만양로8길', '도로길이': 53, '도로폭': 7},
   'geometry': {'type': 'LineString',
    'coordinates': [[126.94777636499998, 37.50835655899999],
     [126.94782061399997, 37.50831506600002],
     [126.94786056500004, 37.50822010600001],
     [126.94791028600002, 37.50814785199998],
     [126.94795418900003, 37.508099371000014],
     [126.94805295399999, 37.508003151000025],
     [126.94809408200001, 37.507960638999975]]}},
  {'type': 'Feature',
   'properties': {'도로명': '동작대로39가길', '도로길이': 450, '도로폭': 5},
   'geometry': {'type': 'LineString',
    'coordinates': [[126.98097487099994, 37.493643061],
     [126.98093601699998, 37.49368160099999],
     [126.98089868099999, 37.493756701999985],
     [126.98072013800004, 37.493820518],
     [126.98048858200002, 37.493856463999975],
     [126.98042777299997, 37.49387360600002],
     ....

LinestringViz

LinestringViz 는 LineString 타입의 데이터들을 시각화 합니다.
여기서는 도로 폭 값에 따라 색상과 선 굵기를 다르게 시각화 해보겠습니다.

import os
from mapboxgl.utils import (
    create_color_stops,
    create_numeric_stops
)

token = os.getenv('MAPBOX_ACCESS_TOKEN')
center = [126.950, 37.495]

viz = LinestringViz(
    data,
    color_property='도로폭',
    color_stops=create_color_stops([10, 15, 20, 25, 30, 35, 40, 45, 50], colors='BuPu'),
    line_width_property='도로폭',
    line_width_stops=create_numeric_stops([10, 15, 20, 25, 30, 35, 40, 45, 50], 1, 4),
    center=center,
    zoom=12
)

viz.show()

이제 추가적인 설명없이도, 쉽게 파라미터 해석이 될거라고 생각하여 더 이상의 추가설명은 생략하겠습니다.

5.3. 그 외

이 외에 Image 레이어나 오른쪽 하단의 Legend 스타일링 등 더 세분화된 내용이 있기는 하지만, 일반적으로 사용할 일은 잘 없을 듯하여 여기서 더 이상 소개하지는 않겠습니다. 위에서 다룬 레이어가 사실상 mapboxgl 에서 제공해주는 대부분이라고 생각합니다.

위에서 다룬 내용 외 더 세세하게 알고싶으시다면,
공식 github 에서 살펴보시길 추천드립니다.

mapboxgl-jupyter github
https://github.com/mapbox/mapboxgl-jupyter

6. 맵 스타일링

Viz

mapboxgl 의 모든 레이어들은 mapboxgl.viz 안에 있습니다.
여기에 속한 레이어들은 모두 다음과 같은 속성(파라미터)들을 공통으로 가지고 있습니다.

View 관련 파라미터

zoom = 0
pitch = 0
bearing = 0
legend = True
opacity = 1
center = (0, 0)
width = '100%'
height = '500px'
style = 'mapbox://styles/mapbox/light-v10?optimize=true'
access_token = None,
...

뒤에 ...으로 표시한 이유는, 이 외에도 더 있다는 말입니다.
여기서는 주로 사용하는 속성들만 표시했습니다.

어떤 속성이 있는지 더 알고 싶으시면 아래 링크를 참고하세요.

클래스 Viz의 속성 알아보기
https://github.com/mapbox/mapboxgl-jupyter/blob/master/mapboxgl/viz.py#L75

Basemap

위 속성 중 style 은 basemap 의 스타일을 지정해줍니다.
스타일은 다음과 같이 미리 정해진 것들이 있습니다.

- mapbox://styles/mapbox/streets-v11
- mapbox://styles/mapbox/outdoors-v11
- mapbox://styles/mapbox/light-v10
- mapbox://styles/mapbox/dark-v10
- mapbox://styles/mapbox/satellite-v9
- mapbox://styles/mapbox/satellite-streets-v11
- mapbox://styles/mapbox/navigation-preview-day-v4
- mapbox://styles/mapbox/navigation-preview-night-v4
- mapbox://styles/mapbox/navigation-guidance-day-v4
- mapbox://styles/mapbox/navigation-guidance-night-v4

예를 들어, mapbox://styles/mapbox/outdoors-v11 로 값을 주면 다음과 같이 바뀝니다.

viz = ChoroplethViz(
    None,
    style='mapbox://styles/mapbox/outdoors-v11',
    center=center,
    zoom=11,
)
viz.show()

위에 정의된 스타일 말고도, Mapbox Studio 에서 본인이 만든 스타일을 가져올 수도 있습니다.
Mapbox 가 스타일에 얼마나 신경쓰는지 제대로 알 수 있는 부분입니다.

7. 마무리

mapboxgl 은 folium 과 비교하면 훨씬 세련된 디자인과 스타일을 보여줍니다.
직접 커스터마이징 할 수 있는 부분도 많습니다.
어딘가에서 지도 데이터 관련 발표를 해야할 때, 사람들의 이목과 흥미를 끌기 위해서는 mapboxgl 은 꼭 필요해 보입니다.

그런데 이런 mapboxgl 도 단점이 하나 있습니다.
바로 레이어를 층층이 쌓을 수 없다는 것인데요.
예를 들어, Line과 Point 를 동시에 표현하고 싶다면... mapboxgl 에서는 방법이 없습니다.
(제가 못찾은 걸수도 있습니다. 혹시 아시는 분은 댓글로 알려주세요...)

folium 의 경우, add_to() 를 통해 레이어를 겹겹히 쌓을 수 있었습니다만... mapboxgl 에서는 그런 기능이 안보입니다.

다음 포스팅에서는 지금까지의 지도 데이터 시각화 방법 중 가장 최근의 트랜디한 라이브러리 pydeck 을 살펴보겠습니다.
pydeck 은 folium 에서의 다량의 데이터 렌더링 문제와 mapboxgl 의 레이어 쌓는 문제를 모두 해결하고 있습니다.
그리고 무엇보다, 오픈소스 커밋이 몇 일전에 바뀔 정도로 따끈따끈한 라이브러리입니다.

파이썬으로 지도 시각화를 하면서 가장 소개하고싶던 라이브러리라, 저도 기대가 됩니다.
그럼 다음 포스팅에서 뵙겠습니다.