名古屋駅から乗ったお客さんがどの駅で降りているのか地図上で可視化してみた


## はじめに

この記事は、FOSS4G TOKAI 2023のLT大会で発表した「みんな名古屋駅からどこに降りているのか？大都市交通センサスを使って見てみる」で実装したソースコードを解説する記事です。

![スクリーンショット 2023-09-04 23.34.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93706/6f6468af-9738-620f-8549-ed0549166346.png)

このLTは、国土交通省が公開している大都市交通センサスのデータを使って、名古屋駅で乗車したお客様がどこの駅で降りているのか、上記の画像のように地図上で立体的に可視化してみようというものを、デモを交えながら発表しました。

[実際にデモサイトも公開しています。](https://where-to-get-off-from-nagoya-station-webgis-8lhk2t6ct-k15015kk.vercel.app/) 地図上に伸びているグラフは、各駅において名古屋駅から来た人数を表しており、グラフにカーソルを合わせると詳細な降車人数が表示されます。

可視化してみるまでの背景や、大都市交通センサスの詳細については [LT会の公開資料](https://speakerdeck.com/kyotonagoya/foss4g-tokai-2023-lt-minnaming-gu-wu-yi-karadokonijiang-riteirunoka-da-du-shi-jiao-tong-sensasuwoshi-tutejian-temiru) をご覧ください。

## 処理の流れ

大都市交通センサスのデータを使って地図上に可視化するまでの流れは、以下の画像のような流れとなります。

![FOSS4GTOKAI2023.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93706/0ac9d942-4826-b037-0d66-ccf09fe2a446.png)

## 駅ごとの出場人数を集計

最初に、大都市交通センサスの1次ODデータから対象となるデータを抽出します。1次ODデータは、以下のようにデータが格納されています。具体的には、各ICカード (Suica, PASMOなど) ごとに、どの駅で入場してどの駅で出場した人が何人いるかというデータが格納されています。

```csv
圏域,カード種別,【入場】圏域,【入場】事業者名,【入場】路線名,【入場】駅名,【入場】都道府県,【入場】市町村区,【入場】時間帯,【出場】圏域,【出場】事業者名,【出場】路線名,【出場】駅名,【出場】都道府県,【出場】市町村区,所要時間（５分単位）,人数
1.首都圏,Suica,1.首都圏,東日本旅客鉄道,総武線各駅停車,西船橋,千葉県,船橋市,15,1.首都圏,東日本旅客鉄道,京浜東北・根岸線,川口,埼玉県,川口市,70,1
1.首都圏,Suica,1.首都圏,東日本旅客鉄道,京浜東北・根岸線,田町,東京都,港区,19,1.首都圏,東日本旅客鉄道,京葉線（１）,新浦安,千葉県,浦安市,35,1
2.中京圏,manaca,2.中京圏,名古屋市交通局,東山線,栄,愛知県,名古屋市中区,19,2.中京圏,名古屋市交通局,東山線,新栄町,愛知県,名古屋市東区,5,10
```

この生データから入場駅が名古屋駅のものを抽出します。以下にPythonのソースコードを示します。最初にPythonでcsvファイルから `pandas.DataFrame` に変換します。その後、運行会社・路線名・駅名をもとに入場駅の抽出をします。

```python
import pandas as pd

# ヘッダーを定義
header = [
    "圏域",
    "カード種別",
    "【入場】圏域",
    "【入場】事業者名",
    "【入場】路線名",
    "【入場】駅名",
    "【入場】都道府県",
    "【入場】市町村区",
    "【入場】時間帯",
    "【出場】圏域",
    "【出場】事業者名",
    "【出場】路線名",
    "【出場】駅名",
    "【出場】都道府県",
    "【出場】市町村区",
    "所要時間（５分単位）",
    "人数"
]

# csvをDataFrameとして読み込む
# ヘッダーのないファイルでは、ヘッダーの指定が必要
df = pd.read_csv('./1st_od_02.csv', header=None, names=header)

company_name = "名古屋市交通局"
line_name = "東山線"
station_name = "名古屋"

# 入場駅をもとにデータを抽出
extract_frame = df[
  (df['【入場】事業者名'] == company_name) & 
  (df['【入場】路線名'] == line_name) &
  (df['【入場】駅名'] == station_name)
]

# データを出力
extract_frame.to_csv('output/extract.csv', index=False)
```

このPythonコードを実行することで、東山線の名古屋駅を入場したデータが抽出できます。

```csv
圏域,カード種別,【入場】圏域,【入場】事業者名,【入場】路線名,【入場】駅名,【入場】都道府県,【入場】市町村区,【入場】時間帯,【出場】圏域,【出場】事業者名,【出場】路線名,【出場】駅名,【出場】都道府県,【出場】市町村区,所要時間（５分単位）,人数
2.中京圏,ICOCA,2.中京圏,名古屋市交通局,東山線,名古屋,愛知県,名古屋市中村区,14,2.中京圏,名古屋市交通局,桜通線,鶴里,愛知県,名古屋市南区,35,1
2.中京圏,manaca,2.中京圏,名古屋市交通局,東山線,名古屋,愛知県,名古屋市中村区,14,2.中京圏,名古屋市交通局,東山線,新栄町,愛知県,名古屋市東区,10,14
2.中京圏,pasmo,2.中京圏,名古屋市交通局,東山線,名古屋,愛知県,名古屋市中村区,16,2.中京圏,名古屋市交通局,桜通線,高岳,愛知県,名古屋市東区,15,2
```

次に抽出したデータから、出場した駅ごとに人数をカウントします。元の1次ODファイルはICカードごとの集計となっているため、出場駅などをグループ化して出場駅ごとに人数を出します。

```python
# csvをDataFrameとして読み込む
df = pd.read_csv('output/extract.csv', header=0)

sum_frame = frame.loc[:,['【出場】駅名','【出場】事業者名','【出場】路線名','人数']].groupby(['【出場】駅名','【出場】事業者名','【出場】路線名']).sum()

# 抽出データをcsvファイルに書き出し
sum_frame.to_csv('output/exit_count.csv', index=True)
```

このコードを実行することで、（名古屋駅から乗った人の）駅ごとの出場人数を集計できます。

```csv
【出場】駅名,【出場】事業者名,【出場】路線名,人数
いりなか,名古屋市交通局,鶴舞線,29
ナゴヤドーム前矢田,名古屋市交通局,名城線,26
一社,名古屋市交通局,東山線,159
```

## GeoJSON と集計人数を結合

地図上に表示するために、駅の GeoJSON に出場人数を結合します。

[国土数値情報の鉄道時系列データ](https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N05-v1_3.html) から駅の地理データを利用します。QGIS で現存する駅かつ対象路線の駅を抽出して `.geojson` 形式で出力します。

その後、 JavaScript で駅の地理データと出場人数の集計データを結合します。GeoJSON の `properties` に出場人数を追加します。

```js
import * as fs from 'fs';
import { parse } from 'csv-parse/sync';

// 各駅の降車人数ファイルを読み込み
const count_data = fs.readFileSync(`./exit_count.csv`);
const records = parse(count_data);

// 駅のgeojsonを読み込み
const station_geojson = fs.readFileSync(`./station.geojson`);
let station = JSON.parse(station_geojson.toString());
let features = station.features

// 駅の位置情報に各駅の乗車人数を返す
let count_features = features.flatMap((feature) => {

    // 対象となる駅の集計データを取得
    const counts = records.filter( record => record[0] === feature.properties.N05_011);

    // 駅の集計データが無い場合
    if (counts.length === 0) {
        return [];
    }
    
    // 人数をpropetiesに追加
    feature.properties.count = parseInt(counts[0][3]);

    return feature
});

// 駅の重複を削除
const filter_count_features = count_features.filter((x, i, features) => {
    return features.findIndex(feature => feature.properties.N05_011 === x.properties.N05_011) === i;
});

// feturesを更新
station.features = filter_count_features;

// 書き出し
const output_geojson = JSON.stringify(station, null, '  ');
fs.writeFileSync(`station_count.geojson`, output_geojson);

```

上記のソースコードを実行して書き出した GeoJSON データを見ると `properties` に `count` (出場人数) が追加されています。

```json
{
  "type": "Feature",
  "properties": {
    "N05_001": "3",
    "N05_002": "1号線東山線",
    "N05_003": "名古屋市",
    "N05_004": "1969",
    "N05_005b": "1969",
    "N05_005e": "9999",
    "N05_006": "EB03_17301021",
    "N05_007": null,
    "N05_008": null,
    "N05_009": null,
    "N05_011": "本郷",
    "count": 1184
  },
  "geometry": {
    "type": "Point",
    "coordinates": [
      137.01359,
      35.175325
    ]
  }
},
```

## Next.js + deck.glで表示をする

これらのデータを基にして Next.js(TypeScript) + deck.gl を使ってWebアプリで出場人数を可視化します。

### ソースコード

今回はサンプルページを１つのファイルで記述しています（本当はコンポーネントで分けたいほうがよいのですが…）。以下にソースコードを示します。地図のタイルのURLが定義されている場所や、ディレクトリ構造の詳細については [GitHubのリポジトリをご覧ください](https://github.com/k15015kk/where-to-get-off-from-nagoya-station-webgis)

```src/pages/sample/index.tsx
import { load } from '@loaders.gl/core';
import { JSONLoader } from '@loaders.gl/json';
import { useEffect, useState, useMemo } from 'react';
import { GridCellLayer, GeoJsonLayer } from '@deck.gl/layers/typed';
import { MapboxOverlay, MapboxOverlayProps } from '@deck.gl/mapbox/typed';
import Map, { NavigationControl, FullscreenControl, useControl } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';

// 地図の一番最初に表示する場所を設定
const INITIAL_VIEW_STATE: MapViewState = {
  longitude: 136.881637,
  latitude: 35.170694,
  zoom: 11,
  pitch: 60,
  bearing: 0
};

// 読み込みデータの定義
const files: FileData[] = [
  {
    id: 4,
    company: "名古屋市交通局",
    line_file: './geojson/nagoya_subway_line.geojson',
    station_file: './geojson/nagoya_subway_station_count.geojson',
  }
];

// DeckGL Overlay
function DeckGLOverlay(props: MapboxOverlayProps & {
  interleaved?: boolean;
}) {
  const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay(props));
  overlay.setProps(props);
  return null;
}

// RGBのRを決める関数
function calcRedColorNumber(ratio: number): number {
  if (ratio <= 0.25) {
    return 0;
  } else if (ratio <= 0.5) {
    const diff = ratio - 0.25;
    return Math.ceil(255 * diff * 4);
  } else {
    return 255;
  }
}

// RGBのGを決める関数
function calcGreenColorNumber(ratio: number): number {
  if (ratio <= 0.25) {
    return Math.ceil(255 * ratio * 4);
  } else if (ratio <= 0.5) {
    return 255;
  } else {
    const diff = 1.0 - ratio;
    return Math.ceil(255 * diff * 2);
  }
}

// RGBのBを決める関数
function calcBlueColorNumber(ratio: number): number {
  if (ratio <= 0.25) {
    return Math.ceil(255 - (255 * ratio * 4));
  } else {
    return 0
  }
}

export default function Home() {

  // Properties
  const maxCount = 5000;

  // State
  const [lineData, setLineData] = useState<object>();
  const [stationData, setStationData] = useState<any[]>([]);

  const [selected, setSelected] = useState<FileData>(files[0]);

  // Effect
  useEffect(() => {
    // 線形データを取得
    async function loadLineData() {
      const line = await load(
        selected.line_file,
        JSONLoader
      );

      setLineData(line);
    }

    // 駅データを取得
    async function loadStationData() {
      const station = await load(
        selected.station_file,
        JSONLoader
      );

      setStationData(station.features);
    }

    loadLineData();
    loadStationData();
  }, [selected]);

  // Layer
  const lineGeoJsonLayer = new GeoJsonLayer({
    id: "line-geojson-layer",
    data: lineData,
    stroked: true,
    fill: true,
    lineWidthScale: 40,
    lineWidthUnits: 'meters',
    lineWidthMinPixels: 2,
    getLineColor: [64, 64, 64, 255],
    getLineWidth: 8,
  });

  const customerGridCellLayer = new GridCellLayer({
    id: 'customer-count-grid-cell-layer',
    data: stationData,
    cellSize: 300,
    extruded: true,
    pickable: true,
    elevationScale: 4,
    getPosition: d => d.geometry.coordinates,
    getElevation: d => d.properties.count,
    getFillColor: (d => {
      return [
        calcRedColorNumber(d.properties.count / maxCount),
        calcGreenColorNumber(d.properties.count / maxCount),
        calcBlueColorNumber(d.properties.count / maxCount),
        255
      ]
    })
  });

  // View
  return (
    <>
      <div className='absolute w-full h-full'>
        <div className='relative z-10 mx-4 my-2 bg-white-opacity p-2 w-32 rounded'>
          <p className='relative text-black w-auto'>
            数値は人数
          </p>
          <div className='relative my-1 flex'>
            <div className='relative m-1 w-4 h-4 bg-graph-red border border-black'></div>
            <p className='relative m-1 leading-4'>5000以上</p>
          </div>
          <div className='relative my-1 flex'>
            <div className='relative m-1 w-4 h-4 bg-graph-orange border border-black'></div>
            <p className='relative m-1 leading-4'>3750</p>
          </div>
          <div className='relative my-1 flex'>
            <div className='relative m-1 w-4 h-4 bg-graph-yellow border border-black'></div>
            <p className='relative m-1 leading-4'>2500</p>
          </div>
          <div className='relative my-1 flex'>
            <div className='relative m-1 w-4 h-4 bg-graph-green border border-black'></div>
            <p className='relative m-1 leading-4'>1250</p>
          </div>
          <div className='relative my-1 flex'>
            <div className='relative m-1 w-4 h-4 bg-graph-blue border border-black'></div>
            <p className='relative m-1 leading-4'>0</p>
          </div>
        </div>
        <h1 className='absolute px-2 py-1 mx-4 bg-black text-white rounded opacity-40 right-0 bottom-8 w-auto z-10'>
          名古屋駅からどの駅で降りているのか？
        </h1>
      </div>
      <div className='absolute w-screen h-screen'>

        <Map
          mapStyle={process.env.NEXT_PUBLIC_MAP_URL}
          initialViewState={INITIAL_VIEW_STATE}
          maplibreLogo
        >
          <DeckGLOverlay
            layers={[lineGeoJsonLayer, customerGridCellLayer]}
            getTooltip={({ object }) => object && `${object.properties.N05_011}\n${object.properties.count}`}
          />
          <NavigationControl />
          <FullscreenControl />
        </Map>
      </div>
    </>
  )
}

```

このページで保持する情報として、線形情報である `lineData` と、駅の地点と降りている人の数をまとめた `stationData` を `useState` で定義しています。

`useEffect` の中で、線形ファイルを読み込む `loadLineData` と、駅情報を読み込む `loadStationData` という関数を実行しています。この関数は、それぞれローカルで保存している GeoJSON ファイルを [loaders.gl](https://loaders.gl/) を使って読み込んでいます。読み込んだ後は、先程の `setLineData` と `setStationData` を使ってデータを保持しています。

deck.gl のレイヤーとして `GeoJsonLayer` で路線の線形を表示しています。また `GridCellLayer` を使って駅で降りている人の数を棒グラフのように地図上にプロットしています。

グラフの色については、5000人を赤色として、3750人を橙色、2500人を黄色、1250人を緑色、0人を青色となるように色の調整をしています。RGBの各値については `calcRedColorNumber` `calcGreenColorNumber` `calcBlueColorNumber` という関数で、それぞれ計算しています。

`NEXT_PUBLIC_MAP_URL` は `.env` の中に定義しています。今回は [MapTilerの JP MIERUNE Gray](https://cloud.maptiler.com/maps/jp-mierune-gray/) の Vector Style のURLを定義しています。

### 実際にローカルサーバで動かしてみる

このソースコードで実際に Next.js のローカルサーバを実行すると、以下のように地図上のグラフが表示されます。

![Next.js動作画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93706/35010568-f3b9-cabd-078f-c74643374e1c.png)

`getTooltip` を設定しているので、棒グラフにカーソルをあてると各駅の降りている人数がツールチップとして表示されます。名古屋駅で乗ってきた2万人近くの方は、栄で降りていることがわかります (降りた方の数は2日間の数値です) 。

![棒グラフにカーソルをあてたときの動き](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93706/2a163dc3-7118-f41e-7499-fb7f2fb935d1.png)

## おわりに

今回は、大都市交通センサスのデータを加工して、名古屋駅から乗ってくる人がどの駅で降りているのか地図上に可視化してみました。

このように、WebGISで簡単に地図上のグラフ化ができるので、是非試してみたください。