이 글은 앞서 설명한 벡터 타일맵 만들기 1,2 편에 이어지는 3편이다.
1부 : 폴리곤 데이터 벡터 타일링
2부 : 라인, 포인트 데이터 벡터 타일링
앞에서 행정동 경계 폴리곤, 전국 건물 폴리곤, 교통사고 데이터 포인트의 3개 벡터 타일을 만들었다.
이 글에서 Deck.gl의 모든 내용을 설명하지는 않는다. 벡터타일을 불러들이는 mvtLayer만 설명하기로 한다.
다만, deck.gl의 기본 템플릿은 매우 단순하므로 그리 어렵지 않게 준비할 수 있다.
deck.gl의 기본 템플릿은 아래 글에서 이미 설명한 바 있다. 여기에 mvtLayer만 얹으면 된다.
행정경계 시각화
벡터 타일맵 데이터들은 웹페이지 코딩시 ./로 접근할 수 있는 폴더에 넣어 놓으면 준비 완료다. next.js라면 public 폴더 밑에 adm폴더를 만들고 그 하위에 줌 레벨 번호를 의미하는 1, 2, 3, 4, 5... 14 폴더가 놓이면 된다.
아래 파일은 타입스크립트 .tsx 기준으로 작성되었다.
import { MVTLayer } from "@deck.gl/geo-layers";
import type { Feature, Geometry } from "geojson";
import { _TerrainExtension as TerrainExtension } from "@deck.gl/extensions";
type PropertiesType = {
name?: string;
rank: number;
layerName: string;
class: string;
sidocd?: string;
injury_dgree_2_dc?: string;
ROAD_BT?: number;
injury_dgree_2_code?: number;
GRO_FLO_CO?: number;
BDTYP_CD?: string;
};
const ENABLE_TERRAIN_EXTENSION = false;
const mvtLayer = new MVTLayer<PropertiesType>({
id: "MVTLayer",
data: ["./adm/{z}/{x}/{y}.mvt"],
onError: (error) => {},
minZoom: 1,
maxZoom: 14,
loadOptions: {
//레이어가 밑에서 부터 깔리는 순서는 1. 여기서 명시하는 순서.
//즉, 가장 첫번째가 가장 밑에 깔린다.
//2. 그 다음에는 featureId로 미리 properties에 넣어놓은 순서
//그 다음에는 uniqueIdProperty로 지정된다.
mvt: {
layers: ["emd_line", "sgg_line", "sido_line", "emd"],
},
},
getFillColor: (f: Feature<Geometry, PropertiesType>) => {
//console.log(f);
switch (f.properties.layerName) {
case "emd":
return [150, 150, 255, 100];
case "sgg":
return [120, 150, 180, 0];
case "sido":
return [218, 218, 218, 0];
default:
return [240, 0, 0, 0];
}
},
getLineWidth: (f: Feature<Geometry, PropertiesType>) => {
switch (f.properties.layerName) {
case "emd_line":
return 10;
case "sgg_line":
return 30;
case "sido_line":
return 100;
default:
return 3;
}
},
getLineColor: (f: Feature<Geometry, PropertiesType>) => {
switch (f.properties.layerName) {
case "emd_line":
return [200, 150, 100];
case "sgg_line":
return [180, 180, 180];
case "sido_line":
return [50, 50, 50];
default:
return [100, 100, 100];
}
},
lineWidthUnits: "meters",
lineWidthMinPixels: 1,
stroked: true,
pickable: true,
autoHighlight: true,
highlightColor: [255, 180, 70, 80],
uniqueIdProperty: "emdcd",
opacity: 0.1,
filled: true,
extensions: ENABLE_TERRAIN_EXTENSION
? [
new TerrainExtension({
terrainDrawMode: "draped",
}),
]
: [],
});
내용은 조금 길어 보이지만 사실 별 것 없다.
읍면동, 시군구, 시도에 따라서 색상과 선굵기를 달리하느라 좀 길어졌을 뿐이고, 핵심은 몇 줄 안 된다.
우선 new MVTLayer로 deck layer를 만든다.
data에는 방금 복사해 둔 폴더를 지정한다. {z}/{x}/{y}.mvt로 두면 deckgl이 알아서 폴더 구조로 파싱해서 읽는다.
data : ["주소"], 이렇게 배열로 지정된 이유는, mirror된 동일 복제본을 여러 경로에 두면, 외부 인터넷망의 소스에서 가져올 경우 deckgl이 알아서 여기저기 요청해서 빨리 받아준다고 한다.
loadOptions 에서는 여러가지 내용을 지정할 수 있다. 여기서는 어떤 레이어를 읽을지를 지정했는데, 배열에 명시된 emd_line, sgg_line, sido_line, emd의 순서로 차례차례 밑에부터 그려지게 된다. mvt 레이어에 담겨 있지만 여기서 명시하지 않은 sgg, sido (폴리곤 데이터)의 두 가지는 읽지 않기로 했다.
지도 시각화에서는 같은 평면에 여러 레이어가 겹칠 경우가 많으므로 이 순서가 꽤 중요한데, 예를 들자면 river 레이어 위에 bridge 레이어가 그려져야 하기 때문이다. 주석에 명시된대로, 특별한 loadOptions이 없으면 모든 레이어를 읽어들이는데, featureId 순서로 정렬된다. 즉, 같은 레이어에서도 깔리는 순서를 지정할 수 있다는 말이다. 그도 없으면 여기 속성에서 uniqueIdProperty로 명시된 속성값을 기준으로 정렬된다.
말 나온 김에 먼저 설명하자면 uniqueIdProperty는 pickable : true 로 두었을 때 필요한 옵션이다. 한 객체가 타일링 되면서 2개 이상으로 분할 될 수 있는데, 만약 uniqueIdProperty에서 지정한 속성이 같다면 아무리 세분화된 객체라고 해도 하나에 마우스를 올리면 모두 선택된 것으로 인지한다.
mvt에는 point, line, polygon 모두 섞여 있는데, 내부적으로는 GeojsonLayer를 호출해서 subLayer 형식으로 렌더링하게 된다.이 과정에서 getFillColor는 point과 polygon에 적용되는 옵션이고, getLineWidth와 getLineColor는 line과 polygon의 테두리를 그릴 때 적용된다.
extensions 에서는 newTerrainExtension 을 설정했다.
extensions: ENABLE_TERRAIN_EXTENSION
? [
new TerrainExtension({
terrainDrawMode: "draped",
}),
]
: [],
만약 동시에 그려지는 다른 레이어에 terrainLayer가 있을 경우 해당 terrain의 레벨 값 기준으로 이 레이어를 변형시켜 그려준다.
terrainDrawMode는 draped과 offset 둘 중 하나를 적을 수 있는데, draped 같은 경우 지형 표면에 맞춰서 납작하게 그려준다. offset은 폴리곤에 elevation을 설정했을 때 유효한데, 뒤에서 설명하겠다.
나머지 옵션은 다른 레이어들과 공통적이므로 특별히 설명하지 않겠다.
이렇게 옵션을 설정하면 아래 그림과 같이 보인다.
읍면동, 시군구, 시도 경계가 차례로 쌓이고, 읍면동 기준으로 pickable한 레이어가 만들어졌다. 레이어를 불러들일 때 마지막에 emd 폴리곤을 별도로 설정하지 않으면 onHover나 onClick이 제대로 작동하지 않는다.
읍면동, 시군구, 시도 경계는 geojson 기준으로 모두 합쳐 45MB정도가 된다. 물론 복잡한 경계는 용량이 더 커지겠지만, 여기서 변환한 데이터 기준으로 그렇다는 말이다. 따라서 어떤 시각화에서 이 3개의 경계를 모두 불러들이면 아무래도 초기 로딩에 시간이 좀 걸린다.
한편 1~14단계의 타일링 데이터는 모두 합쳐 90MB정도 된다. 그렇지만 90MB를 모두 읽는 것이 아니고 화면에 보이는 줌 레벨과 타일만 읽기 때문에 초기 로딩도 빠르고 전체적으로 전송되는 용량도 실질적으로 그리 많지 않다.
terrainLayer를 넣고 다시 그리면 아래처럼 보인다. 시군구 경계가 지형을 따라 그 위에 그려졌다.
지형 시각화
잠깐 terrainLayer를 설명하고 넘어가겠다.
import {
TerrainLayer,
TerrainLayerProps,
} from "@deck.gl/geo-layers";
const heightMultiplier = 1;
const ELEVATION_DECODER_AMAZON: TerrainLayerProps["elevationDecoder"] = {
rScaler: 256 * heightMultiplier,
gScaler: 1 * heightMultiplier,
bScaler: (1 / 256) * heightMultiplier,
offset: -32768 * heightMultiplier,
};
const terrain = new TerrainLayer<TerrainLayerProps>({
id: "terrain",
minZoom: 0,
maxZoom: 15,
elevationDecoder: ELEVATION_DECODER_AMAZON,
elevationData: "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png",
texture: "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}",
wireframe: false,
operation: "terrain+draw",
color: [255, 255, 255],
});
지형은 간단하다. 문제는 데이터인데, 맵박스나 다른 곳들은 대부분 종량제로 과금한다. 맵박스의 경우 혼자 작업하기에는 충분한 양인 한 달 기준 750,000개 타일을 제공하지만, 여러사람이 접속하는 서비스에서는 감당 못할 정도로 접속량이 늘어날 수도 있다. 다행히도 무상으로 엑세스 가능한 데이터가 있다. 과거 Mapzen의 데이터를 아마존에서 서비스한다.
아래 링크에 이에 대한 설명이 있다.
여기에서 명시된 주소 중 terrarium 포맷의 png 파일이 terrainLayer에서 받아들이는 형식이다.
본래 높이 값이 png 파일의 r,g,b 값들로 분할 인코딩 되어 있는데, 이 값을 디코딩 하는 공식이 명시되어 있다.
(red * 256 + green + blue / 256) - 32768
따라서 이 값에 맞게 디코딩 객체를 입력해주면 된다. ELEVATION_DECODER_AMAZON 에 입력된 객체가 그 형식이다.
heightMultiplier는 별도의 변수로 두어, 필요에 따라 지형을 강조하는 등의 추가 작업을 할 수 있다.
texture에는 보통 satellite 데이터를 넣는다. 역시 xyz 타일 형식을 넣으면 되는데, 역시 구글에서 제공하는 개방 데이터 주소를 넣어놓았다. 많이 느리긴 하지만 그래도 현재 시점에서는 무상으로 이용할 수 있다는 장점이 있다. 우리나라에 한정한다면 브이월드 같은 경우에 api를 신청하면 과금 없이 받을 수 있다.
포인트 데이터 시각화
포인트 데이터 역시 mvtLayer라서 앞서 행정경계와 같은 형식으로 그릴 수 있는데, 여기서는 포인트와 텍스트를 동시에 표현하기 위해 renderSubLayer로 그렸다.
const mvtLayer_point = new MVTLayer<PropertiesType>({
id: "MVTLayer_point",
data: "./point/{z}/{x}/{y}.mvt",
minZoom: 14,
maxZoom: 14,
loadOptions: {
mvt: {
layers: ["accident"],
},
},
renderSubLayers: (props) => {
const { tile, data, ...rest } = props;
return [
new GeoJsonLayer<PropertiesType>({
...rest,
id: `text-${props.id}-geojson`,
data,
stroked: false,
filled: true,
getFillColor: (f: Feature<Geometry, PropertiesType>) => {
//console.log(f);
switch (f.properties.injury_dgree_2_dc) {
case "사망":
return [250, 50, 25];
case "중상":
return [250, 200, 50];
case "경상":
return [100, 200, 50];
case "부상신고":
return [50, 100, 200];
default:
return [100, 100, 100];
}
},
getPointRadius: 5,
pointType: "circle",
pointRadiusMinPixels: 2,
pointRadiusMaxPixels: 10,
pointRadiusUnits: "meters",
opacity: 0.3,
extensions: ENABLE_TERRAIN_EXTENSION
? [
new TerrainExtension({
terrainDrawMode: "draped",
}),
]
: [],
}),
new GeoJsonLayer<PropertiesType>({
...rest,
id: `point-${props.id}-geojson`,
data,
pointType: "text", //이게 핵심
getText: (f: Feature<Geometry, PropertiesType>) => {
return f.properties.injury_dgree_2_dc;
},
getTextSize: 15,
textCharacterSet: "auto",
textFontFamily: "Malgun Gothic",
getTextPixelOffset: [0, -18],
textBackground: false, //background 사각형 표시 여부
//sdf가 적용되어 있을 때만.
//아래 세 가지를 잘 조정해야 텍스트가 잘 보인다.
textFontSettings: { sdf: true, smoothing: 0.3 },
textOutlineColor: [0, 0, 0],
textOutlineWidth: 10,
textFontWeight: 400,
textSizeMinPixels: 10,
textSizeMaxPixels: 20,
getTextColor: [200, 200, 200],
pointRadiusUnits: "pixels",
extensions: ENABLE_TERRAIN_EXTENSION
? [
new TerrainExtension({
terrainDrawMode: "draped",
}),
]
: [],
}),
];
},
});
renderSubLayer는 tileLayer를 상속하는 클래스에서 사용할 수 있는 값인데, return 하는 레이어에 두 개의 GeojsonLayer를 둘 수 있다. 같은 데이터로 하나는 포인트 데이터의 위치를 점으로 나타내고, 다른 하나는 속성값을 텍스트로 표현하기 위해 두 개로 분할해서 그렸다.
두 번째 subLayer를 보면 pointType이 "text"로 되어 있다. 텍스트를 쓰기 위해서는 이게 필수다.
여러가지 텍스트 설정 값은 deck.gl document에 상세히 나와 있다. 한글을 표현하기 위해 textCharacterSet를 "auto"로 두고, point보다 약간 위쪽에 위치시키기 위해 getTextPixelOffset 값을 줬다.
글자 주변에 outline을 표현하려면 textFontSettings 에서 sdf: true를 줘야 한다. sdf(signed distance field)는 텍스트를 표현할 때 이미지로 텍스쳐를 만들지 않고, 폰트 형상 경계선으로부터의 거리값을 둔 데이터를 바탕으로 텍스트를 그리는 방식을 일컫는다. 테두리나 외곽선을 부드럽게 표현할 수 있는 장점이 있는ㄷ네, 텍스쳐 형식보다 계산 비용이 많이 드는게 단점이다. 그런데 deck.gl에서 드로잉되는 퍼포먼스를 보면 느리지 않다. 아마도 sdf 방식으로 계산한 결과를 다시 텍스쳐로 만드는 것 같다.
시각화 결과는 아래와 같다.
사고 수 만큼 무지막지하게 많은 텍스트들이 화면을 뒤덮지만, 그리 느리지 않다. 겹치는 텍스트나 포인트들은 CollisionFilterExtension() 을 이용해서 제어할 수 있다. 단, 이 익스텐션은 지형 높이에 따라 객체들을 재배치해주는 TerrainExtension()와 충돌하고 뻗어버리므로 참고하자.
건물 데이터 시각화
건물 데이터는 아래와 같다. 앞의 설명과 겹치는 내용은 생략한다.
import { Color } from "@deck.gl/core";
const mvtLayer4 = new MVTLayer<PropertiesType>({
id: "MVTLayer-building",
data: ["./buildingwhole/{z}/{x}/{y}.mvt"],
minZoom: 1,
maxZoom: 14,
loadOptions: {
mvt: {
layers: ["building"],
},
},
getFillColor: (f: Feature<Geometry, PropertiesType>) => {
const defaultColor: Color = [158, 158, 156];
if (f.properties.BDTYP_CD) {
const bdType = f.properties.BDTYP_CD.substring(0, 2);
switch (bdType) {
case "01": {
switch (f.properties.BDTYP_CD) {
case "01001": //단독주택
return [205, 172, 127];
case "01003": //다가구주택
return [216, 75, 83];
default: //단독기타
return defaultColor;
}
}
case "02": {
switch (f.properties.BDTYP_CD) {
case "02001": //아파트
return [141, 176, 216];
case "02003": //다세대
return [216, 75, 83];
case "02002": //연립
return [248, 165, 43];
default:
return defaultColor;
}
}
case "03": //근생
return [66, 183, 194];
case "04": //근생
return [66, 183, 194];
case "08": //교육
return [49, 143, 88];
case "10": //업무
return [56, 89, 181];
case "13": //공장
return [68, 101, 171];
default:
return defaultColor;
}
} else {
return defaultColor;
}
},
extruded: true,
getElevation: (f: Feature<Geometry, PropertiesType>) => {
return f.properties.GRO_FLO_CO ? f.properties.GRO_FLO_CO * 4 : 3;
},
extensions: ENABLE_TERRAIN_EXTENSION
? [
new TerrainExtension({
terrainDrawMode: "offset",
}),
]
: [],
pickable: true,
autoHighlight: true,
highlightColor: [255, 180, 70],
uniqueIdProperty: "BD_MGT_SN",
opacity: 1,
});
미리 준비해 둔 속성 값을 이용해서 색상을 부여했다. 마우스 오버할 경우 색상이 변하는 기준은 원래 데이터의 고유값에 해당하는 "BD_MGT_SN"으로 지정했다. 지상층 수 값인 GRO_FLO_CO에 층고 4미터를 곱해서 높이를 표현했다.
TerrainExtension의 terrainDrawMode는 "offset"으로 두었다. extruded : true로 둘 경우에 폴리곤의 기저 레벨을 지형에 맞춰서 출발시킬 수 있다. 다만, 각 점들이 개별적으로 지형에 반응하므로 경사지에 놓인 건물은 건물의 윗면도 기울어지게 된다. 그래도 값싼 비용으로 지형에 맞춰서 표현할 수 있다는게 어디인가 싶다.
만약 같은 건물에는 같은 지면 레벨 값을 부여하고 싶다면 미리 속성에 값을 부여하고 dataTransform 옵션을 사용하면 된다.
const mvtLayer4 = new MVTLayer<PropertiesType>({
id: "MVTLayer-building",
data: ["./buildingwhole/{z}/{x}/{y}.mvt"],
...
dataTransform: (tile: any) => {
//console.log(tile); //tile값을 찍어보면 왜 아래와 같이 다루는지 이해할 수 있다.
const pos = tile.polygons.positions;
const height = tile.polygons.numericProps.HEIGHT.value;
pos.size = 3;
const newValue = new Float32Array((pos.value.length / 2) * 3);
for (let i = 0; i < pos.value.length; i += 2) {
const index = (i / 2) * 3;
newValue[index] = pos.value[i];
newValue[index + 1] = pos.value[i + 1];
newValue[index + 2] = height[i / 2];
}
pos.value = newValue;
return tile;
},
...
});
properties에 HEIGHT라는 이름으로 각 건물마다 높이 값이 들어있다면 위와 같은 콜백함수로 데이터를 로드한 직후 변형할 수 있다. 물론 이 때는 TerrainExtension을 사용하지 않아야 한다.
dataTransform에는 데이터를 로드해서 삼각형으로 테셀레이션 한 결과값이 들어온다. 그리고 여기서 리턴하는 값이 렌더링에 이용되는 것 같다. 일종의 hooking 기능을 하는 함수라고 보면 된다.
따라서 2차원 좌표로 된 삼각형 테셀레이션 값을 3차원 좌표 체계로 바꾸면서 (pos.size = 3), z좌표에 미리 지정한 값을 넣어주면 된다.
결과는 아래와 같다.
녹색 건물들을 보면 지형에 따라 건물들이 기울어져 있다.
이로써 타일링과 deck.gl 구현에 대한 설명을 모두 마친다.
아래 링크에서 지형과 행정경계, 그리고 지형 위에 구현한 전국의 모든 건물들 700만동을 볼 수 있다.
github에 올렸는데, 우연히도 adm과 building이 980MB정도 된다. github page의 제한이 1GB인데 운이 좋게도 딱 맞았다.
참고로 페이지 브라우징이 느린건 mvt 타일 때문이 아니라 구글 위성 타일 이미지 때문이다.
휴대폰에서도 잘 돌아간다. 다만, 여기저기 돌아다니다 보면 몇십MB 이상을 받게 될 수 있으니 데이터 무제한이 아니라면 전송량을 신경써야 한다.
덧붙이는 글
좀 더 최근의 기술에 관심이 있다면 pmtiles 를 찾아보면 된다.
지도 타일이 우리나라 전 국토 14레벨 기준 10만개 파일이 넘어가는데, pmtiles는 단 하나의 파일(!)로 서비스해준다. 서버 관리 측면에서는 눈이 번쩍 뜨이는 솔루션일 수 있다. http range request 방식을 이용해서 마치 큰 동영상 파일에 여기저기 엑세스 할 수 있는 것처럼 x,y,z 요청을 미리 정해진 인덱스의 범위 쿼리로 바꾸어 응답해주는 기술이다.
자체적으로 데이터도 압축해서 전체 용량도 다수의 mvt 대비 절반 정도로 줄어든다.
홈페이지 설명을 보면 이런저런 장점으로 인해 클라우스 서비스 이용시 비용도 대폭 줄어든다고 하니, 한 번 쯤 검토해볼만하다.
벡터 타일과 래스터 타일 모두 지원하는데, 래스터의 경우 용량이 크게 줄어드는것 같지는 않다.
기존의 mvt 타일 폴더들을 convert 하는 툴도 제공하므로, 이미 운영중인 서비스 리소스들을 대상으로 테스트 해 볼 수도 있다. gdal이나 mapbox의 유용한 컨버팅 라이브러리인 티피카누(tippicanoe)에서도 변환을 지원한다.
pmtiles 에서 제공하는 툴을 사용하면 명령어 한 줄로 pmtiles 들이 있는 폴더를 서버로 서비스할 수 있는데, 이 서버 툴의 역할은 클라이언트에서 /{z}/{x}/{y}.mvt 로 동일하게 요청하면 자체적으로 범위 쿼리로 해석하여 필요한 파일들을 추출 후 응답해주는 것이다. 즉, 클라이언트 입장에서는 서버의 파일 구조가 바뀌었는지 알 수 없다는 것.
100GB(100메가 아님)의 osm 파일을 pmtiles 뷰어 사이트로 드래그&드롭하면 1초만에 브라우징이 시작된다. 파일이 얼마나 크든, 범위 요청을 통해 필요한 부분만 읽어나가기 때문이다.
물론 mbtiles도 단일 파일이라는 점에서 pmtiles와 같다고 볼 수도 있지만, mbtiles은 서비스를 위해 sqlite를 이용해서 중계해주는 서버가 필요하다. 반면 pmtiles는 http range request를 지원하는 AWS S3에서 곧바로 서비스가 가능하다. 더 놀라운건 github page 에서도 된다는 점! github page역시 http range request를 지원하는 것 같다. (문서로 아무리 찾아봐도 그런 스펙이 없지만, pmtiles 홈페이지에서 된다고 해서 직접 간단히 샘플을 복&붙해서 올려보니 잘 돌아간다)
끝.
'Function' 카테고리의 다른 글
OD 시각화 5 : deck.gl.TripsLayer 의 커스터마이징 (0) | 2024.10.30 |
---|---|
deck.gl로 만든 웹 지도의 hillshade 표현 (0) | 2024.10.28 |
벡터 타일 2 : 웹에서 대용량 공간 데이터 시각화하기 (0) | 2024.08.11 |
벡터 타일 1 : 웹에서 대용량 공간 데이터 시각화하기 (0) | 2024.08.11 |
벡터필드 : 웹에서 해류데이터 표현하기 (1) | 2024.02.24 |