frontend

광역지하철 역, 노선도 지도에 올려보기

하리하링웹 2025. 4. 20. 15:34

1. 데이터 구하기

한국의 지하철 역사 정보는 공공데이터 포털에서 받을 수 있다. 좌표까지 포함된 데이터를 받은 뒤 geojson형태로 직접 가공해준다. Point 형태의 geojson예시는 아래와 같다.

{
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [126.807969, 37.319791]
      },
      "properties": {
        "name": "초지",
        "name:ko": "초지",
        "name:en": "Choji",
        "name:ja": "チョジ",
        "name:zh": "草芝",
        "public_transport": "station",
        "railway": "station",
        "subway": "yes",
        "line": "수인분당선",
      },
    },

 

properties에는 딱히 정해진 양식이 없으니 본인이 사용할 데이터를 추가로 넣어도 된다.

 

인터넷상에 공개되어 있는 구현 방식에는 역과 역 사이를 properties값을 사용해 잇는 방식으로 지하철 노선도를 구현하는 방식정도만 설명되어 있는데 노선 자체가 애초에 직선이 아니기에 이렇게 하면 실제 프로덕트에서는 사용할 수 없게된다.

 

따라서 의미있는 노선 데이터를 직접 구해줘야한다.

 

https://download.geofabrik.de/asia/south-korea.html

 

Geofabrik Download Server

 

download.geofabrik.de

위 사이트를 사용하면 한국 지리 정보를 pbf 형태로 얻을 수 있다.

 

이 pbf파일에는 한국의 모든 정보가 담겨있기에 용량이 지나치게 크다. 따라서 railway 값이 rail, subway인 데이터만 필터링하여 pbf 파일을 다시 만들어 주자.

osmium tags-filter south-korea-latest.osm.pbf \\
  nwr railway=rail railway=subway \\
  -o rail-and-subway.osm.pbf

 

이후 결과물을 사용하여 geojson 형태로 변환해주면 된다.

osmium export filtered-south-korea-lstest.osm.pbf -o osm-korea-subway.geojson

 

파일을 확인해보면 아래와 같이 정상적인 geojson이 생성된 것을 확인할 수 있을것이다.

{"type":"FeatureCollection","features":[
{"type":"Feature","geometry":{"type":"Point","coordinates":[129.0379837,35.1055604]},"properties":{"railway":"buffer_stop"}},
{"type":"Feature","geometry":{"type":"Point","coordinates":[126.9711907,37.5460003]},"properties":{"railway":"switch"}},
{"type":"Feature","geometry":{"type":"Point","coordinates":[126.967798,37.5321855]},"properties":{"railway":"switch"}},
{"type":"Feature","geometry":{"type":"Point","coordinates":[127.4116427,36.3437794]},"properties":{"railway":"level_crossing"}},
{"type":"Feature","geometry":{"type":"Point","coordinates":[127.4204891,36.3445944]},"properties":{"railway":"level_crossing"}},
{"type":"Feature","geometry":{"type":"Point","coordinates":[127.4244894,36.3433467]},"properties":{"railway":"level_crossing"}},
{"type":"Feature","geometry":{"type":"Point","coordinates":[126.7457423,37.8884132]},"properties":{"railway":"railway_crossing"}},
{"type":"Feature","geometry":{"type":"Point","coordinates":[126.7122098,37.8962698]},"properties":{"railway":"railway_crossing"}},
....

https://geojson.io/#map=2/0/20

위 링크에 geojson 파일을 드래그하여 넣어보면 시각적으로도 확인할 수 있다.

 

 

 

2. 데이터 가공

이제 아래의 두 가지 데이터를 가지고 있다.

  1. 한국의 지하철 역사 정보가 담긴 geojson 파일
  2. 한국의 철도 노선 정보 + 역사 정보가 담긴 geojson 파일

2번 항목에서 역사 정보는 필요가 없기에 한 번 가공해줘야 하는 필요성이 있다. geometry 타입이 LineString인 항목만 남기는 과정을 거쳐준다.

jq '{
  type: "FeatureCollection",
  features: [.features[] | select(.geometry.type == "LineString")]
}' osm-korea-subway.geojson > korea-subway-lines.geojson

 

결과물을 다시 확인해보면 아래 이미지처럼 노선 정보만 잘 필터링 되어 있는것을 확인할 수 있다.

 

 

지도에 너무 많은 정보가 올라가도 필요가 없기에 중요한 노선 정보들만 남기고 가지치기를 진행해 줘야 한다.

 

나의 경우에는 name을 기반으로 하드코딩하여 데이터를 필터링 하였다. 코드는 아래와 같다 편의성을 위해 geojson은 json 확장자로 이름을 변경하였다.

import fs from 'fs';
import path from 'path';
import inputData from './korea-subway-lines.json';

const newObj = {
  type: 'FeatureCollection',
  features: [],
};

const data = inputData;

function main() {
  data.features.forEach((feature) => {
    const { properties, geometry } = feature;

    if (geometry.type === 'Point') {
      newObj.features.push(feature);
      return;
    }

    if (
      (geometry.type === 'LineString' &&
        properties.public_transport === 'station') ||
      properties['name:ko'] === '경부고속선' ||
      properties.name === '경부고속선' ||
      properties['name:ko'] === '경북본선' ||
      properties.name === '경북본선' ||
      properties['name:ko'] === '호남고속선' ||
      properties.name === '호남고속선' ||
      properties['name:ko'] === '영동본선' ||
      properties.name === '영동본선' ||
      properties['name:ko'] === '동해선' ||
      properties.name === '동해선' ||
      properties['name:ko'] === '대구선' ||
      properties.name === '대구선' ||
      properties['name:ko'] === '경부선' ||
      properties.name === '경부선' ||
      properties['name:ko'] === '경전선' ||
      properties.name === '경전선' ||
      properties['name:ko'] === '전라선' ||
      properties.name === '전라선' ||
      properties['name:ko'] === '호남선' ||
      properties.name === '호남선' ||
      properties['name:ko'] === '충북선' ||
      properties.name === '충북선' ||
      properties['name:ko'] === '정선선' ||
      properties.name === '정선선' ||
      properties['name:ko'] === '북평선' ||
      properties.name === '북평선' ||
      properties['name:ko'] === '평택선' ||
      properties.name === '평택선' ||
      properties['name:ko'] === '광주선' ||
      properties.name === '광주선' ||
      properties['name:ko'] === '경부본선' ||
      properties.name === '경부본선' ||
      properties['name:ko'] === '중부내륙선' ||
      properties.name === '중부내륙선' ||
      properties['name:ko'] === '함백선' ||
      properties.name === '함백선' ||
      properties['name:ko'] === '대불선' ||
      properties.name === '대불선' ||
      properties['name:ko'] === '철도종합시험선로' ||
      properties.name === '철도종합시험선로' ||
      properties['name:ko'] === '오송선' ||
      properties.name === '오송선' ||
      properties['name:ko'] === '군산항선' ||
      properties.name === '군산항선' ||
      properties['name:ko'] === '북전주선' ||
      properties.name === '북전주선' ||
      properties['name:ko'] === '장성화물선' ||
      properties.name === '장성화물선' ||
      properties['name:ko'] === '광양제철선' ||
      properties.name === '광양제철선' ||
      properties['name:ko'] === '여천선' ||
      properties.name === '여천선' ||
      properties['name:ko'] === '사천선' ||
      properties.name === '사천선' ||
      properties['name:ko'] === '구 경전선' ||
      properties.name === '구 경전선' ||
      properties['name:ko'] === '진해선' ||
      properties.name === '진해선' ||
      properties['name:ko'] === '부산신항선' ||
      properties.name === '부산신항선' ||
      properties['name:ko'] === '동해본선' ||
      properties.name === '동해본선' ||
      properties['name:ko'] === '삼척선' ||
      properties.name === '삼척선' ||
      properties['name:ko'] === '솔안터널' ||
      properties.name === '솔안터널' ||
      properties['name:ko'] === '묵호항선' ||
      properties.name === '묵호항선' ||
      properties['name:ko'] === '울산신항선' ||
      properties.name === '울산신항선' ||
      properties['name:ko'] === '온산선' ||
      properties.name === '온산선' ||
      properties['name:ko'] === '신항남선' ||
      properties.name === '신항남선' ||
      properties['name:ko'] === '행암선' ||
      properties.name === '행암선' ||
      properties['name:ko'] === '광양항선' ||
      properties.name === '광양항선' ||
      properties['name:ko'] === '전경삼각선' ||
      properties.name === '전경삼각선' ||
      properties['name:ko'] === '신광양항선' ||
      properties.name === '신광양항선' ||
      properties['name:ko'] === '남선화물선' ||
      properties.name === '남선화물선' ||
      properties['name:ko'] === '강경선' ||
      properties.name === '강경선' ||
      properties['name:ko'] === '옥구선' ||
      properties.name === '옥구선' ||
      properties['name:ko'] === '신항북선' ||
      properties.name === '신항북선' ||
      properties['name:ko'] === '덕산선' ||
      properties.name === '덕산선' ||
      properties['name:ko'] === '양산화물선' ||
      properties.name === '양산화물선' ||
      properties['name:ko'] === '미전선' ||
      properties.name === '미전선' ||
      properties['name:ko'] === '울산항선' ||
      properties.name === '울산항선' ||
      properties['name:ko'] === '건천연결선' ||
      properties.name === '건천연결선' ||
      properties['name:ko'] === 'K-2인입선' ||
      properties.name === 'K-2인입선' ||
      properties['name:ko'] === '신동화물선' ||
      properties.name === '신동화물선' ||
      properties['name:ko'] === '괴동선' ||
      properties.name === '괴동선' ||
      properties['name:ko'] === '건천연결선' ||
      properties.name === '건천연결선' ||
      properties['name:ko'] === '동해북부선' ||
      properties.name === '동해북부선' ||
      properties['name:ko'] === '태백본선' ||
      properties.name === '태백본선' ||
      properties['name:ko'] === '하이원추추파크 강삭철도' ||
      properties.name === '하이원추추파크 강삭철도' ||
      properties['name:ko'] === '문경선' ||
      properties.name === '문경선' ||
      properties['name:ko'] === '경북선' ||
      properties.name === '경북선' ||
      properties['name:ko'] === '익산삼각선' ||
      properties.name === '익산삼각선' ||
      properties['name:ko'] === '오송정비기지선' ||
      properties.name === '오송정비기지선' ||
      properties['name:ko'] === '부강화물선' ||
      properties.name === '부강화물선' ||
      properties['name:ko'] === '대전철도차량정비단 인입선' ||
      properties.name === '대전철도차량정비단 인입선' ||
      properties['name:ko'] === '군산화물선' ||
      properties.name === '군산화물선' ||
      properties['name:ko'] === '오송정비기지선' ||
      properties.name === '오송정비기지선' ||
      properties['name:ko'] === '송정리 비행장선' ||
      properties.name === '송정리 비행장선' ||
      properties['name:ko'] === '경량전철시험선' ||
      properties.name === '경량전철시험선' ||
      properties['name:ko'] === '대구북연결선' ||
      properties.name === '대구북연결선' ||
      properties['name:ko'] === '약목보수기지선' ||
      properties.name === '약목보수기지선' ||
      properties['name:ko'] === '영일만항선' ||
      properties.name === '영일만항선' ||
      properties['name:ko'] === '우암선' ||
      properties.name === '우암선' ||
      properties['name:ko'] === '영동선' ||
      properties.name === '영동선' ||
      properties['name:ko'] === '태백선' ||
      properties.name === '태백선' ||
      properties['name:ko'] === '용유차량기지선' ||
      properties.name === '용유차량기지선' ||
      properties['name:ko'] === '송탄선' ||
      properties.name === '송탄선' ||
      properties['name:ko'] === '북송정삼각선' ||
      properties.name === '북송정삼각선' ||
      properties['name:ko'] === '남부화물기지선' ||
      properties.name === '남부화물기지선' ||
      properties['name:ko'] === '가야선' ||
      properties.name === '가야선' ||
      properties['name:ko'] === '동해남부선' ||
      properties.name === '동해남부선' ||
      properties['name:ko'] === '광명주박기지선' ||
      properties.name === '광명주박기지선' ||
      properties['name:ko'] === '4비선' ||
      properties.name === '4비선' ||
      properties['name:ko'] === '평내기지선' ||
      properties.name === '평내기지선' ||
      properties['name:ko'] === '3군지사선' ||
      properties.name === '3군지사선' ||
      properties['name:ko'] === '경부제2선' ||
      properties.name === '경부제2선' ||
      properties['name:ko'] === '수색직결선' ||
      properties.name === '수색직결선' ||
      properties['name:ko'] === '창동출고선' ||
      properties.name === '창동출고선' ||
      properties['name:ko'] === '노원입고선' ||
      properties.name === '노원입고선' ||
      properties['name:ko'] === '망우선' ||
      properties.name === '망우선' ||
      properties['name:ko'] === '수색객차출발선' ||
      properties.name === '수색객차출발선' ||
      properties['name:ko'] === '문산기지선' ||
      properties.name === '문산기지선' ||
      properties['name:ko'] === '숙성기지선' ||
      properties.name === '숙성기지선' ||
      properties['name:ko'] === '세류역 인상선' ||
      properties.name === '세류역 인상선' ||
      properties['name:ko'] === '천안직결선' ||
      properties.name === '천안직결선' ||
      properties['name:ko'] === '피난선' ||
      properties.name === '피난선' ||
      properties['name:ko'] === '용유차량삼각선' ||
      properties.name === '용유차량삼각선' ||
      properties['name:ko'] === '태백삼각선' ||
      properties.name === '태백삼각선' ||
      properties['name:ko'] === '방향전환선' ||
      properties.name === '방향전환선' ||
      properties['name:ko'] === '부발기지선' ||
      properties.name === '부발기지선' ||
      properties['name:ko'] === '영동정비기지선' ||
      properties.name === '영동정비기지선' ||
      properties['name:ko'] === '영천삼각선' ||
      properties.name === '영천삼각선' ||
      properties['name:ko'] === '제천조차장선' ||
      properties.name === '제천조차장선' ||
      properties['name:ko'] === '금강산청년선' ||
      properties.name === '금강산청년선' ||
      properties['name:ko'] === '용문기지선' ||
      properties.name === '용문기지선' ||
      properties['name:ko'] === '검수고 8번선' ||
      properties.name === '검수고 8번선' ||
      properties['name:ko'] === '검수고 7번선' ||
      properties.name === '검수고 7번선' ||
      properties['name:ko'] === '검수고 6번선' ||
      properties.name === '검수고 6번선' ||
      properties['name:ko'] === '검수고 5번선' ||
      properties.name === '검수고 5번선' ||
      properties['name:ko'] === '검수고 4번선' ||
      properties.name === '검수고 4번선' ||
      properties['name:ko'] === '검수고 3번선' ||
      properties.name === '검수고 3번선' ||
      properties['name:ko'] === '검수고 2번선' ||
      properties.name === '검수고 2번선' ||
      properties['name:ko'] === '검수고 1번선' ||
      properties.name === '검수고 1번선' ||
      properties['name:ko'] === '신창회차선' ||
      properties.name === '신창회차선' ||
      properties['name:ko'] === '안산연결선' ||
      properties.name === '안산연결선' ||
      properties['name:ko'] === '길동삼각선' ||
      properties.name === '길동삼각선' ||
      properties['name:ko'] === '길동삼각선' ||
      properties.name === '길동삼각선' ||
      properties['name:ko'] === 'X8' ||
      properties.name === 'X8' ||
      properties['name:ko'] === 'X7' ||
      properties.name === 'X7' ||
      properties['name:ko'] === 'X1' ||
      properties.name === 'X1' ||
      properties['name:ko'] === 'X2' ||
      properties.name === 'X2' ||
      properties['name:ko'] === 'X4' ||
      properties.name === 'X4' ||
      properties['name:ko'] === 'X5' ||
      properties.name === 'X5' ||
      properties['name:ko'] === 'Y2' ||
      properties.name === 'Y2' ||
      properties['name:ko'] === 'Y1' ||
      properties.name === 'Y1' ||
      properties['name:ko'] === 'Y4' ||
      properties.name === 'Y4' ||
      properties['name:ko'] === 'Y5' ||
      properties.name === 'Y5' ||
      properties['name:ko'] === 'M2' ||
      properties.name === 'M2' ||
      properties['name:ko'] === 'Y3' ||
      properties.name === 'Y3' ||
      properties['name:ko'] === 'A6' ||
      properties.name === 'A6' ||
      properties['name:ko'] === 'A3' ||
      properties.name === 'A3' ||
      properties['name:ko'] === 'A7' ||
      properties.name === 'A7' ||
      properties['name:ko'] === 'X3' ||
      properties.name === 'X3' ||
      properties['name:ko'] === 'P1' ||
      properties.name === 'P1' ||
      properties['name:ko'] === 'P2' ||
      properties.name === 'P2' ||
      properties['name:ko'] === 'P3' ||
      properties.name === 'P3' ||
      properties['name:ko'] === 'P4' ||
      properties.name === 'P4' ||
      properties['name:ko'] === 'P5' ||
      properties.name === 'P5' ||
      properties['name:ko'] === 'X6' ||
      properties.name === 'X6' ||
      properties['name:ko'] === 'M1' ||
      properties.name === 'M1' ||
      properties['name:ko'] === '수서평택고속선' ||
      properties.name === '수서평택고속선' ||
      properties['name:ko'] === '평택삼각선' ||
      properties.name === '평택삼각선' ||
      properties['name:ko'] === '수도권광역급행철도에이선' ||
      properties.name === '수도권광역급행철도에이선' ||
      properties['name:ko'] === '태권도원 모노레일 승강장' ||
      properties.name === '태권도원 모노레일 승강장' ||
      properties['name:ko'] === '평택직결선' ||
      properties.name === '평택직결선' ||
      properties['name:ko'] === '부전선' ||
      properties.name === '부전선' ||
      properties['name:ko'] === '(구)경전선' ||
      properties.name === '(구)경전선' ||
      properties['name:ko'] === '섬진강 기차마을 관광철도' ||
      properties.name === '섬진강 기차마을 관광철도' ||
      properties['name:ko'] === '교외선' ||
      properties.name === '교외선' ||
      properties['name:ko'] === '9호선' ||
      properties.name === '9호선' ||
      properties['name:ko'] === '고양기지선' ||
      properties.name === '고양기지선' ||
      properties['name:ko'] === '분당기지선' ||
      properties.name === '분당기지선' ||
      properties['name:ko'] === '9호선' ||
      properties.name === '9호선' ||
      properties['name:ko'] === '9호선' ||
      properties.name === '9호선' ||
      properties['name:ko'] === '9호선' ||
      properties.name === '9호선' ||
      (properties['name:ko'] as string)?.includes('케이블카') ||
      (properties.name as string)?.includes('케이블카') ||
      properties.amenity === 'bus_station' ||
      properties.name === '당진제철소 내부 철도' ||
      properties.amenity === 'parking' ||
      properties.amenity === 'ferry_terminal' ||
      properties.attraction === 'train' ||
      properties.railway === 'monorail' ||
      !properties.name
    ) {
      return;
    }

    newObj.features.push(feature);
  });

  fs.writeFileSync(
    path.join(__dirname, 'filtered-korea-subway-lines.json'),
    JSON.stringify(newObj, null, 2),
    'utf-8',
  );
}

main();

 

결과물을 확인해보면 유의미한 노선 정보만 보여지는 것을 확인할 수 있다.

 

 

 

3. UI 개선을 위한 데이터 가공

얼추 된 것 처럼 보이지만 여기에는 문제가 있다. 수도권 부분의 지하철을 확대해보자

 

이는 신논현역의 지도이다. 지하철 노선이 두 개로 보여지는 것을 확인할 수 있다. 이는 실제로 지하철의 rail 개수가 좌, 우 총 2개이기에 발생하는 문제이다. 즉 이러한 모든 정보를 한 개의 라인만 남기고 제거하는 과정을 거쳐야한다.

여기서 내가 생각한 방법은 2가지 정도이다.

  1. 10m 이내에 properties가 모두 같은 레일 정보가 여러개 있을 경우 더 긴 레일을 남기고 삭제
    1. 예를들어 A,B의 A가 더 긴 레일이 있으면 A레일의 모든 점에서 B라인까지의 거리를 확인한 뒤 조건에 맞으면 삭제
    2. 이후 직접 데이터 처리 (끊어진 노선 연결, 처리 안된 노선 삭제 등)
  2. 각 연결된 노선마다 id를 부여한 뒤에 위와 같이 2개의 라인이 있는 경우 한쪽 id와 일치하는 모든 라인을 삭제

나의 경우에는 1번은 예외 케이스가 너무 많을 것 같았기에 2번을 채택하였다.

 

예를들어 위 이미지와 같은 경우에는 A노선, B노선이 명확하게 나뉘어져 있기에 B 라인의 아이디를 필터링 하는 작업을 거쳐주면 자동으로 한 개의 라인만 남게된다.

 

예외 처리, 직접 삭제 등을 많이 하긴 했지만 3시간 내로 완료한 걸 보면 나름 효율적으로 처리했다고 생각한다.

 

이제 가공이 완료된 데이터를 확인해보자.

 

 

노선이 훨씬 깔끔하게 보이는 것을 확인할 수 있다.

 

이제 이를 pmtiles 형태로 바꿔 프로덕트에 올려보면 아래와 같이 보여지는 것을 확인할 수 있다.

 

pmtiles 형태로 변환하는 코드는 아래와 같다. 편의상 min zoom 레벨은 0으로 설정하였다.

 

tippecanoe -o korea-subway-lines.pmtiles -l subway -Z0 -z15 filtered-korea-subway-lines.json

 

 

다음으로 설정을 통해 각 노선에 색을 입혀보자

{
      "id": "subway",
      "type": "line",
      "source": "korea-subway",
      "source-layer": "subway",
      "paint": {
        "line-color": [
          "match",
          ["get", "name"],
          [
            "경원본선",
            "서울 지하철 1호선",
            "경부제1본선",
            "경부제2본선",
            "경부제3본선",
            "경인선",
            "경인제1본선",
            "경인제2본선",
            "경인제3본선",
            "장항선",
            "병점기지선"
          ],
          "#0052A4",
          [
            "서울 지하철 2호선",
            "서울 지하철 2호선 성수지선",
            "서울 지하철 2호선-신정지선"
          ],
          "#00A84D",
          ["서울 지하철 3호선", "일산선"],
          "#EF7C1C",
          ["서울 지하철 4호선", "진접선", "과천선", "안산선"],
          "#00A5DE",
          ["수도권 전철 5호선", "서울 지하철 5호선", "하남선"],
          "#996CAC",
          "서울 지하철 6호선",
          "#CD7C2F",
          ["서울 지하철 7호선", "온수 회차선"],
          "#747F00",
          ["서울 지하철 8호선", "별내선"],
          "#E6186C",
          ["서울 지하철 9호선", "서울지하철 9호선"],
          "#BB8336",
          ["신분당선", "수도권 전철 신분당선"],
          "#D4003B",
          ["분당선", "수인선"],
          "#F5A200",
          [
            "경의본선",
            "용산선",
            "중앙본선",
            "경춘선",
            "망우선",
            "중앙선",
            "경강선"
          ],
          "#77C4A3",
          ["서울 경전철 우이신설선", "1-2호선 연결선로"],
          "#B0CE18",
          "의정부경전철",
          "#FDA600",
          "인천국제공항선",
          "#0090D2",
          "인천 도시철도 1호선",
          "#7CA8D5",
          "인천 도시철도 2호선",
          "#ED8B00",
          "서해선",
          "#81A914",
          "서울 경전철 신림선",
          "#6789CA",
          "김포 도시철도",
          "#A17800",
          "용인경전철",
          "#509F22",
          "대전 도시철도 1호선",
          "#007448",
          ["대구 도시철도 1호선", "대구 도시철도 1호선 안심-하양 복선전철"],
          "#D93F5C",
          ["대구 도시철도 2호선", "대구 2호선"],
          "#00AA80",
          "대구 도시철도 3호선",
          "#FFB100",
          "광주 도시철도 1호선",
          "#009088",
          "부산 도시철도 1호선",
          "#F06A00",
          "부산 도시철도 2호선",
          "#81BF48",
          "부산 도시철도 3호선",
          "#BB8C00",
          "부산 도시철도 4호선",
          "#217DCB",
          "부산-김해 경전철",
          "#8652A1",
          "#888888"
        ],
        "line-width": 1.5
      }
    },

 

각 노선별 색상은 아래 주소를 참고하였다.

https://ko.wikipedia.org/wiki/틀:한국_철도_노선색

 

틀:한국 철도 노선색 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. #CCCCCC 설명 대한민국과 조선민주주의인민공화국을 포함한 한반도 내 철도 운영 주체의 사색과 철도 노선색을 모아 놓은 틀입니다. 주의: {{철도역 정보}}의 배

ko.wikipedia.org

 

 

line-width를 1로 설정하면 아래와 같이 보여진다.

 

다음으로는 앞에서 만들었던 지하철 역사 정보를 올릴 차례이다. 방법 자체는 간단하다.

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [127.047455, 37.709914]
      },
      "properties": {
        "name": "망월사",
        "name:ko": "망월사",
        "name:en": "Mangwolsa",
        "name:ja": "マンウォルサ",
        "name:zh": "望月寺",
        "public_transport": "station",
        "railway": "station",
        "subway": "yes",
        "line": "1호선",
      },
    },

 

역사 정보는 위와 같은 구조일텐데 features 배열 내부를 복사하여 라인 정보가 있는 geojson에 추가로 붙여넣어주기만 하면 된다. 이후 이를 pmtiles 형태로 바꿔준 뒤 지도 설정쪽에 아래 설정을 추가해준다.

{
      "id": "stations",
      "type": "circle",
      "source": "korea-subway",
      "source-layer": "subway",
      "filter": ["==", "$type", "Point"],
      "paint": {
        "circle-radius": 3.5,
        "circle-color": "#FFFFFF",
        "circle-stroke-width": 1.7,
        "circle-stroke-color": [
          "match",
          ["get", "line"],
          "1호선",
          "#0052A4",
          "2호선",
          "#00A84D",
          "3호선",
          "#EF7C1C",
          "4호선",
          "#00A5DE",
          "5호선",
          "#996CAC",
          "6호선",
          "#CD7C2F",
          "7호선",
          "#747F00",
          "8호선",
          "#E6186C",
          "9호선",
          "#BB8336",
          "신분당선",
          "#D4003B",
          "수인분당선",
          "#F5A200",
          "서해선",
          "#81A914",
          "공항철도",
          "#0090D2",
          "인천1호선",
          "#7CA8D5",
          "인천2호선",
          "#ED8B00",
          "김포골드라인",
          "#A17800",
          "용인에버라인",
          "#509F22",
          "GTXA",
          "#9A6292",
          ["경의중앙선", "경강선", "경춘선"],
          "#77C4A3",
          "우이신설선",
          "#B0CE18",
          "의정부경전철",
          "#FDA600",
          "부산1호선",
          "#F06A00",
          "부산2호선",
          "#81BF48",
          "부산3호선",
          "#BB8C00",
          "부산4호선",
          "#217DCB",
          "부산김해경전철",
          "#8652A1",
          "대전1호선",
          "#007448",
          "광주1호선",
          "#009088",
          "대구1호선",
          "#D93F5C",
          "대구2호선",
          "#00AA80",
          "대구3호선",
          "#FFB100",
          "#888888"
        ]
      }
    },
    {
      "id": "station-labels",
      "type": "symbol",
      "source": "korea-subway",
      "source-layer": "subway",
      "filter": ["==", "$type", "Point"],
      "layout": {
        "text-field": ["get", "name"],
        "text-font": ["Noto Sans Medium"],
        "text-justify": "center",
        "text-offset": [0, 0.6],
        "text-anchor": "top",
        "text-size": 11.5
      },
      "paint": {
        "text-color": "#333333",
        "text-halo-color": "#FFFFFF",
        "text-halo-width": 0.5
      },
      "maxzoom": 15
    },
    {
      "id": "station-labels-detail",
      "type": "symbol",
      "source": "korea-subway",
      "source-layer": "subway",
      "filter": ["==", "$type", "Point"],
      "layout": {
        "text-field": [
          "format",
          ["get", "name"],
          { "font-scale": 1 },
          "\n",
          ["get", "line"],
          { "font-scale": 0.9 }
        ],
        "text-font": ["Noto Sans Medium"],
        "text-justify": "center",
        "text-offset": [0, 1.8],
        "text-size": 11
      },
      "paint": {
        "text-color": "#333333",
        "text-halo-color": "#FFFFFF",
        "text-halo-width": 0.5
      },
      "minzoom": 15,
      "maxzoom": 23
    }

 

이후 확인해보면 아래와 같이 보여지는 것을 확인할 수 있다.

 

 

뭔가 이상하다. 정보가 보이기는 하는데 몇 개만 보여지는 문제가 발생한다.

 

지도를 좀 더 확대해보면 분명 데이터는 잘 올라가 있는 것을 확인할 수 있다.

이는 tippecanoe를 사용하여 pmtiles를 만들 때 자동으로 어느 줌 레벨에서 어떤 정보가 보일지를 정해주기 때문이다. 우리는 그런걸 원치 않기때문에 geojson에 어떤 줌레벨에서 보여지게 할 것인지 직접 정보를 넣어주면 된다.

 {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [126.765844, 37.338212]
      },
      "properties": {
        "name": "신길온천",
        "name:ko": "신길온천",
        "name:en": "Neunggil",
        "name:ja": "シンギル・オンチョン",
        "name:zh": "新吉温泉",
        "public_transport": "station",
        "railway": "station",
        "subway": "yes",
        "line": "수인분당선",
        "isNeerPoint": true
      },
      "tippecanoe": {
        "maxzoom": 15,
        "minzoom": 0
      }
    },

 

geojson의 Point에 위 json처럼 tippecanoe property를 추가해주면 pmtiles 생성 시 알아서 줌레벨 0부터 15까지 보이도록 생성해준다. 역사 정보는 일반적으로 12부터 보여지는 것이 적당하나 여기서는 개발 편의를 위해 0으로 설정하였다.

이를 올린 뒤 다시 확인해 보면 잘 적용 되는 것을 확인할 수 있다.

 

 

아직 문제가 있는데, 지도를 확대해보면 아래 이미지처럼 역과 지하철 노선이 떨어져 있는 문제가 발생하게 된다. 이는 실제로 역의 좌표와 노선의 좌표가 다르기 때문에 발생하는 문제인데 보기 불편하니 직접 수정해주도록 하자.

 

 

import fs from 'fs';
import path from 'path';
import inputData from './korea-subway-lines.json';

function toRadians(degrees) {
  return (degrees * Math.PI) / 180;
}

function haversineDistance([lon1, lat1], [lon2, lat2]) {
  const R = 6371000; // 지구 반지름
  const dLat = toRadians(lat2 - lat1);
  const dLon = toRadians(lon2 - lon1);
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRadians(lat1)) *
      Math.cos(toRadians(lat2)) *
      Math.sin(dLon / 2) ** 2;
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  return R * c;
}

function projectPointToLine(point, line) {
  const [px, py] = point;
  let closestPoint = null;
  let minDist = Infinity;

  for (let i = 0; i < line.length - 1; i++) {
    const [x1, y1] = line[i];
    const [x2, y2] = line[i + 1];

    const dx = x2 - x1;
    const dy = y2 - y1;
    const lengthSq = dx * dx + dy * dy;

    let t = ((px - x1) * dx + (py - y1) * dy) / lengthSq;
    t = Math.max(0, Math.min(1, t));

    const projX = x1 + t * dx;
    const projY = y1 + t * dy;

    const dist = haversineDistance([px, py], [projX, projY]);

    if (dist < minDist) {
      minDist = dist;
      closestPoint = [projX, projY];
    }
  }

  return { closestPoint, distance: minDist };
}

function snapPointToNearestLine(featureCollection, maxDistance = 30) {
  const pointFeatures = featureCollection.features.filter(
    (f) => f.geometry.type === 'Point',
  );
  const lineFeatures = featureCollection.features.filter(
    (f) => f.geometry.type === 'LineString',
  );

  for (const pointFeature of pointFeatures) {
    const pointCoords = pointFeature.geometry.coordinates;
    const lineNameKeyword = pointFeature.properties.line;

    const candidates = [];

    for (const lineFeature of lineFeatures) {
      const { closestPoint, distance } = projectPointToLine(
        pointCoords,
        lineFeature.geometry.coordinates,
      );

      if (distance <= maxDistance) {
        candidates.push({
          feature: lineFeature,
          distance,
          closestPoint,
        });
      }
    }

    if (candidates.length === 0) {
      continue;
    }

    let selected;
    const matching = candidates.filter((c) =>
      c.feature.properties.name?.includes(lineNameKeyword),
    );

    if (matching.length > 0) {
      selected = matching.reduce((a, b) => (a.distance < b.distance ? a : b));
    } else {
      selected = candidates.reduce((a, b) => (a.distance < b.distance ? a : b));
    }

    pointFeature.geometry.coordinates = selected.closestPoint;
    if (!pointFeature.tippecanoe) pointFeature.tippecanoe = {};
    pointFeature.tippecanoe.minzoom = 0;
  }

  return featureCollection;
}

const newData = snapPointToNearestLine(inputData, 30);

fs.writeFileSync(
  path.join(__dirname, 'source-korea-subway-processed.json'),
  JSON.stringify(newData, null, 2),
  'utf8',
);

 

위 코드는 아래의 조건을 거쳐 노선 위에 역사 정보를 올리는 동작을 하는 코드이다.

  • 30m 이상 떨어진 라인은 무시
  • 30m 내 라인이 여러 개인 경우:
    • point.properties.line 값이 line.properties.name에 포함되면 해당 라인 선택
    • 포함되는 라인이 없다면, 가장 가까운 라인 선택
  • 30m 내 라인이 한 개면 그 라인 선택
  • 30m 내 라인이 없으면 좌표 변경 없음

이를 사용하여 데이터를 가공한 뒤 다시 확인해보면 아래처럼 보여지는 것을 확인할 수 있다.

 

 

아직 마지막 문제가 남아있다.

 

이런식으로 환승 구간에서 줌 레벨이 충분히 낮은데도 불구하고 역이 2개가 보여지는 것이다. 이는 상당히 보기 불편하니 이런 경우 줌 레벨 12~14까지는 한 개의 역사 정보만 보여주고 15부터 둘 다 보여주는 방식으로 데이터를 변경해보자.

 

예를들면 위와 같은 경우에는 아래처럼 변경하면 된다.

{
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          127.01377773956666,
          37.49302333433919
        ]
      },
      "properties": {
        "name": "교대",
        "name:ko": "교대",
        "name:en": "Seoul Nat`l Univ. of Education",
        "name:ja": "キョデ ",
        "name:zh": "首尔教育大学",
        "public_transport": "station",
        "railway": "station",
        "subway": "yes",
        "line": "3호선",
        "isNeerPoint": true
      },
      "tippecanoe": {
        "maxzoom": 15,
        "minzoom": 15
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          127.01465884681642,
          37.49398884013909
        ]
      },
      "properties": {
        "name": "교대",
        "name:ko": "교대",
        "name:en": "Seoul Nat`l Univ. of Education",
        "name:ja": "キョデ",
        "name:zh": "首尔教育大学",
        "public_transport": "station",
        "railway": "station",
        "subway": "yes",
        "line": "2호선",
        "isNeerPoint": true
      },
      "tippecanoe": {
        "maxzoom": 15,
        "minzoom": 12
      }
    }

 

코드는 아래와 같다

import fs from 'fs';
import path from 'path';
import inputData from './source-korea-subway-processed.json';

// === 기본 유틸리티 함수 =================================
function toRadians(degrees) {
  return (degrees * Math.PI) / 180;
}

function haversineDistance([lon1, lat1], [lon2, lat2]) {
  const R = 6371000; // 지구 반지름 (미터)
  const dLat = toRadians(lat2 - lat1);
  const dLon = toRadians(lon2 - lon1);
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRadians(lat1)) *
      Math.cos(toRadians(lat2)) *
      Math.sin(dLon / 2) ** 2;
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  return R * c;
}

// === 중복 역 줌 레벨 조정 함수 =========================
// 50미터 내에 이름이 같은 역이 두 개 이상인 경우 그룹화하여,
// 한 개는 minzoom 0 (낮은 줌)에서 보이고 나머지는 minzoom 15 (높은 줌)에서만 보이도록 설정
function adjustZoomForDuplicateStations(
  featureCollection,
  duplicateRadius = 50,
) {
  // 모든 Point feature 추출
  const pointFeatures = featureCollection.features.filter(
    (f) => f.geometry.type === 'Point',
  );
  const processed = new Set();

  for (let i = 0; i < pointFeatures.length; i++) {
    const stationA = pointFeatures[i];
    if (processed.has(stationA)) continue;

    const cluster = [stationA];
    processed.add(stationA);

    for (let j = i + 1; j < pointFeatures.length; j++) {
      const stationB = pointFeatures[j];
      // 이름이 동일한 경우에만 그룹으로 고려
      if (stationA.properties.name === stationB.properties.name) {
        const d = haversineDistance(
          stationA.geometry.coordinates,
          stationB.geometry.coordinates,
        );

        if (d <= duplicateRadius) {
          cluster.push(stationB);
          processed.add(stationB);
        }
      }
    }

    if (cluster.length > 1) {
      // 그룹에 2개 이상의 역이 있는 경우
      // 첫 번째 역은 그대로 낮은 줌(0~15)에서 보이도록 하고,
      // 나머지는 높은 줌(15 이상)에서만 보이도록 설정
      if (!cluster[0].tippecanoe) {
        cluster[0].tippecanoe = {};
      }
      cluster[0].tippecanoe.minzoom = 0;

      for (let k = 1; k < cluster.length; k++) {
        if (!cluster[k].tippecanoe) {
          cluster[k].tippecanoe = {};
        }
        cluster[k].tippecanoe.minzoom = 15;
      }
    } else {
      // 그룹에 해당하는 역이 단 하나인 경우 기본값 유지 (0~15)
      if (!cluster[0].tippecanoe) {
        cluster[0].tippecanoe = {};
      }
      cluster[0].tippecanoe.minzoom = 0;
    }
  }
  return featureCollection;
}

// === 메인 처리 ============================================
const adjustedData = adjustZoomForDuplicateStations(inputData, 500);

fs.writeFileSync(
  path.join(__dirname, 'source-korea-subway-processed.json'),
  JSON.stringify(adjustedData, null, 2),
  'utf8',
);

console.log('처리 완료: source-korea-subway-processed.json');
  • 모든 Point feature 중 같은 name 값을 갖고, 좌표 간의 거리가 500m 이내인 경우를 그룹화.
  • 그룹에 한 개 이상의 역이 있는 경우, 그룹의 첫 번째 역은 그대로 0~15에서 보이고, 나머지 역은 tippecanoe.minzoom을 15로 설정해 줌 15 이상에서만 보이도록 변경.
  • 이름이 같은 역이 50m 이내에 없으면 기존 설정(0~15)을 유지.

이제 이를 pmtiles로 변환해 올리면 정상 동작하는 것을 확인할 수 있다.

 

[낮은 줌레벨]

 

[높은 줌레벨]

 

 

이제 먼저 보여주고 싶은 역의 줌 레벨을 조정하거나 역사의 위치를 마음에 드는 곳으로 옮기기, min-zoom level을 12로 바꾸는 등 목적에 따라 약간의 후처리 작업을 진행하면 작업이 완료된다.

 

[최종 결과 이미지]