コロナ禍の人流オープンデータ（1kmメッシュ）を地図とヒストグラムで表示してみた

## はじめに
「[全国の人流オープンデータ](https://www.geospatial.jp/ckan/dataset/mlit-1km-fromto)」（国土交通省）とはコロナウィルス対策調査の一環で公開された2019年～2021年のデータになります。

この記事では、山手線駅周辺の人流オープンデータ（1kmメッシュ・平日昼に限定）をMapbox地図上に表示させ、2019年1月～2021年12月の36ヵ月間でどんな変化がみられるかを調べてみました。

山手線駅の位置情報、1kmメッシュポリゴン、人流データ（滞在人口データ）は、すべてGeoJSONファイルから読み込むようにしています。

:::note info
[山手線駅周辺の人流オープンデータ（1kmメッシュ）をMapbox地図で表示してみる](https://qiita.com/y-mto/items/544507eaed1de6b878a2)

前回は上記の記事を書かせてもらいました。今回の記事では、対象とする人流データを変更し、地図アプリにヒストグラムの追加や駅ごとの集計といった機能拡張を行っています。
:::

## 完成イメージ
実際に地図アプリで調べてみたイメージは以下のようになります。

**月ごとの変化**
スライダーで年月を指定しています。ヒストグラムには滞在人口の合計が示されています。

![stations_jinryu_histogram1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2893989/7d3d0662-173b-46ae-83bb-1691f7cf192c.gif)

**駅ごとの集計**
駅マークをクリックすることで、駅周辺（半径500m円に交差）のメッシュに限定させたヒストグラムをみることができます。

![stations_jinryu_histogram2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2893989/4b2b6232-d85f-433c-82b9-9c1794058cd9.gif)

## データについて
前回と同様ですが、山手線駅データは [鉄道駅LOD GeoJSON ダウンローダー](https://uedayou.net/jrslod-geojson-downloader/) から取得し、「全国の人流オープンデータ（1kmメッシュ）」は [TerraMap API](https://www.mapmarketing.co.jp/terramap-api?grid=qiita&utm_source=qiita.com&utm_medium=referral&utm_campaign=qiita_stations_jinryu_histogram_523e91d4cafdcc9ac006_01) からのレスポンスデータを利用しました。

1kmメッシュデータは、各駅を中心とした半径500m円に交差するもので限定させました。

今回の人流データは、平日の昼（11～14時）に限定させましたが、その代わりに3年間の全データを属性に持たせました。使用したGeoJSONファイルの一部抜粋したものは以下になります。
<details><summary>jinryu-opendata.geojson</summary>

```json
{
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "properties": {
                "area": {
                    "area": 1.047139255783081
                },
                "data": [
                    {
                        "is_authorized": true,
                        "stat_item_id": 10088,
                        "stat_id": "036001900",
                        "value": "16246"
                    },
                    {
                        "is_authorized": true,
                        "stat_item_id": 10097,
                        "stat_id": "036001900",
                        "value": "15905"
                    },

                    // .... 36ヵ月間分の滞在人口データを省略しています
                ],
                "point_coordinates": [
                    139.71875,
                    35.6125
                ],
                "geocode": "53393537"
            },
            "geometry": {
                "type": "MultiPolygon",
                "coordinates": [
                    [
                        [
                            [
                                139.725,
                                35.608333333
                            ],
                            [
                                139.7125,
                                35.608333333
                            ],
                            [
                                139.7125,
                                35.616666667
                            ],
                            [
                                139.725,
                                35.616666667
                            ],
                            [
                                139.725,
                                35.608333333
                            ]
                        ]
                    ]
                ]
            }
        },

        // .... featureを省略しています
    ]
}
```
</details>


## ソース
```none:ファイル構成
root/
 ├── index.html
 ├── map.js
 ├── style.css
 ├── stations.geojson
 └── jinryu-opendata.geojson
```
### JavaScript
`YOUR_MAPBOX_ACCESS_TOKEN` にはご自身のアクセストークンが必要になります。
```map.js
mapboxgl.accessToken = "YOUR_MAPBOX_ACCESS_TOKEN";

const dateSlider = document.getElementById("date-slider");
const dateLabel = document.getElementById("date-label");
let polygonGeoJson = null;
let selectedDataIndex = 0;
let targetFeatures = null;
let targetStation = "";
let histogramChart = null;

const map = new mapboxgl.Map({
  container: "map",
  style: "mapbox://styles/mapbox/dark-v11",
  center: [139.700124, 35.681236],
  zoom: 10.5,
});

map.on("load", async function () {
  // 駅マーカーの表示
  await drawMarkers();
  // メッシュデータの表示
  await drawPolygons();
  // ヒストグラム新規作成
  createHistogram();

  // 変更イベントを監視
  dateSlider.addEventListener("input", updateSelectedDataIndex);
});

// 年月IDの更新、適用処理
function updateSelectedDataIndex() {
  selectedDataIndex = Number(dateSlider.value);
  const year = Math.floor(selectedDataIndex / 12) + 2019;
  const month = (selectedDataIndex % 12) + 1;
  dateLabel.textContent = `${year}年${month}月`;

  // ポリゴンの色を更新
  map.setPaintProperty(
    "polygon",
    "fill-color",
    getFillColor()
  );
  // ヒストグラム更新
  updateHistogram()
}

// データのインデックス・値に応じた塗り潰し色設定を取得
function getFillColor() {
  return [
    "interpolate",
    ["linear"],
    ["to-number", ["get", "value", ["at", selectedDataIndex, ["get", "data"]]]],
    3000,
    "#2b83ba",
    6000,
    "#abdda4",
    10000,
    "#ffffbf",
    50000,
    "#fdae61",
    100000,
    "#d7191c",
    200000,
    "#800000",
  ];
}

// 駅マーカーの表示
async function drawMarkers() {
  try {
    const response = await fetch("stations.geojson");
    const geojson = await response.json();

    geojson.features.forEach((feature) => {
      const type = feature.geometry.type;

      if (type == "Point") {
        const coordinates = feature.geometry.coordinates;
        const marker = new mapboxgl.Marker({
          color: "#314ccd",
        })
          .setLngLat(coordinates)
          .addTo(map);
        // clickイベントの定義
        marker.getElement().addEventListener("click", () => {
          // 駅から半径500m円の空間検索
          const center = turf.point([coordinates[0], coordinates[1]]);
          const radius = 0.5;
          const circle = turf.circle(center, radius);

          // 地図上に既存の円を消去
          if (map.getSource("circle")) {
            map.removeLayer("circle");
            map.removeSource("circle");
          }
          // 円を地図上に追加
          map.addSource("circle", {
            type: "geojson",
            data: circle,
          });
          map.addLayer({
            id: "circle",
            type: "fill",
            source: "circle",
            layout: {},
            paint: {
              "fill-color": "#808080",
              "fill-opacity": 0.5,
            },
          });

          // 円と交差するフィーチャ、ジオコードを取得
          targetFeatures = polygonGeoJson.features.filter((f) =>
            turf.booleanIntersects(f, circle)
          );
          const geocodes = targetFeatures.map(feature => feature.properties.geocode);

          // 該当ポリゴンの透過を解除
          const match = [
            "match",
            ["get", "geocode"],
            geocodes,
            1,
            0.5,
          ];
          map.setPaintProperty("polygon", "fill-opacity", match);

          // 駅ごとのヒストグラム作成
          targetStation = feature.properties.name;
          updateHistogram()
        });
      }
    });
    return;
  } catch (error) {
    console.error("Error drawing Markers:", error);
  }
}

// メッシュデータの表示
async function drawPolygons() {
  try {
    const response = await fetch("jinryu-opendata.geojson");
    polygonGeoJson = await response.json();
    targetFeatures = polygonGeoJson.features;

    map.addSource("jinryu_polygons", {
      type: "geojson",
      data: polygonGeoJson,
    });

    // ポリゴンの背景色を設定します
    map.addLayer({
      id: "polygon",
      type: "fill",
      source: "jinryu_polygons",
      layout: {},
      paint: {
        "fill-color": getFillColor(),
        "fill-opacity": 0.5,
      },
    });

    // メッシュ以外をクリックしたときに駅選択を解除
    map.on("click", (e) => {
      const features = map.queryRenderedFeatures(e.point, {
        layers: ["polygon"],
      });

      if (features.length === 0) {
        // 地図上に既存の円を消去
        if (map.getSource("circle")) {
          map.removeLayer("circle");
          map.removeSource("circle");
        }
        map.setPaintProperty("polygon", "fill-opacity", 0.5);

        targetFeatures = polygonGeoJson.features;
        targetStation = "";
        updateHistogram()
      }
    });

    // ポリゴンラインを設定します
    map.addLayer({
      id: "outline",
      type: "line",
      source: "jinryu_polygons",
      layout: {},
      paint: {
        "line-color": "#bcbccc",
        "line-width": 2,
      },
    });
  } catch (error) {
    console.error("Error drawing Polygons:", error);
  }
}

// フィーチャの集計処理
function calculateSum(features) {
  const sum = new Array(36).fill(0);
  features.forEach((feature) => {
    if (feature.properties && Array.isArray(feature.properties.data)) {
      feature.properties.data.forEach((obj, index) => {
        sum[index] += Number(obj.value);
      });
    }
  });
  return sum;
}

function createHistogram() {
  // 集計
  const sum = calculateSum(targetFeatures);

  // ラベル
  const years = Array.from({ length: 3 }, (_, i) => 2019 + i);
  const months = Array.from({ length: 12 }, (_, i) => i + 1);
  const labels = years.flatMap((year) =>
    months.map((month) => `${year}年${month}月`)
  );

  // Chart.jsで描画
  histogramChart = new Chart(document.getElementById("histogram"), {
    type: "bar",
    data: {
      labels: labels,
      datasets: [
        {
          label: "滞在人口合計",
          data: sum,
          backgroundColor: sum.map((value, index) => {
            return index === 0
              ? "rgba(147, 112, 219, 0.6)"
              : "rgba(75, 192, 192, 0.6)";
          }),
          borderColor: sum.map((value, index) => {
            return index === 0
              ? "rgba(147, 112, 219, 1)"
              : "rgba(75, 192, 192, 1)";
          }),
          borderWidth: 1,
        },
      ],
    },
    options: {
      plugins: {
        title: {
          display: true,
          text: "滞在人口合計・全メッシュ",
        },
        legend: {
          display: false,
        },
        datalabels: {
          display: function (context) {
            return context.dataIndex === 0;
          },
          anchor: "center",
          align: "center",
          color: "blue",
          formatter: (value) => value.toLocaleString(),
        },
      },
      scales: {
        x: { title: { display: false, text: "時系列" } },
        y: {
          title: { display: false, text: "滞在人口合計" },
          beginAtZero: true,
        },
      },
    },
    plugins: [ChartDataLabels],
  });
}

function updateHistogram() {
  // 集計
  const sum = calculateSum(targetFeatures);
  histogramChart.data.datasets[0].data = sum;

  // タイトル更新
  let title = "滞在人口合計";
  if (targetFeatures.length === 71) {
    title += "・全メッシュ";
  } else if (targetStation != "") {
    title += "・" + targetStation;
  }
  histogramChart.options.plugins.title.text = title;

  // 選択されている年月に対する更新
  // 滞在人口値の表示
  histogramChart.options.plugins.datalabels.display = (context) => {
    return context.dataIndex === selectedDataIndex;
  }
  // ヒストグラムの色
  histogramChart.data.datasets[0].backgroundColor = sum.map((value, index) => {
    return index === selectedDataIndex ? "rgba(147, 112, 219, 0.6)" : "rgba(75, 192, 192, 0.6)";
  });
  histogramChart.data.datasets[0].borderColor = sum.map((value, index) => {
    return index === selectedDataIndex ? "rgba(147, 112, 219, 1)" : "rgba(75, 192, 192, 1)";
  });

  histogramChart.update();
}
```
### HTML, CSS
<details><summary>index.html</summary>

```html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8" />
  <title>山手線駅周辺の人流オープンデータ（平日・昼）表示</title>
  <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
  <link href="https://api.mapbox.com/mapbox-gl-js/v3.10.0/mapbox-gl.css" rel="stylesheet" />
  <script src="https://api.mapbox.com/mapbox-gl-js/v3.10.0/mapbox-gl.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels"></script>
  <script src="https://unpkg.com/@turf/turf@7.2.0/turf.min.js"></script>
  <link rel="stylesheet" href="style.css" />

  <!-- Mapbox Assembly -->
  <link href="https://api.mapbox.com/mapbox-assembly/v1.3.0/assembly.min.css" rel="stylesheet" />
  <script src="https://api.mapbox.com/mapbox-assembly/v1.3.0/assembly.js"></script>
</head>

<body>
  <div id="container">
    <div id="map"></div>
    <!-- 年月スライダー-->
    <div class="absolute fl my24 mx24 py18 px12 bg-gray-faint round" style="opacity: 0.8">
      <h4 class="txt-l txt-bold mb6"> 人流オープンデータ</h4>
      <div style="padding-bottom: 10px;">平日・昼(11～14時)</div>
      <form id="params">
        <h4 class="txt-m txt-bold mb6">年月指定:</h4>
        <input type="range" id="date-slider" min="0" max="35" value="0" step="1" list="values" />
        <datalist id="values">
          <option value="0" label="2019"></option>
          <option value="12" label="2020"></option>
          <option value="24" label="2021"></option>
          <option value="35" label=""></option>
        </datalist>
        <div id="date-label" style="padding-top: 10px;">2019年1月</div>
      </form>
    </div>
    <!-- 凡例 -->
    <div class="absolute fl my24 mx24 py12 px12 bg-gray-faint round" style="opacity: 0.8; right: 0px">
      <h4 class="txt-m txt-bold mb6">滞在人口</h4>
      <div class="legend">
        <div class="legend-item">
          <span class="legend-color" style="background-color: #2b83ba"></span>
          <span class="legend-label">3,000</span>
        </div>
        <div class="legend-item">
          <span class="legend-color" style="background-color: #abdda4"></span>
          <span class="legend-label">6,000</span>
        </div>
        <div class="legend-item">
          <span class="legend-color" style="background-color: #ffffbf"></span>
          <span class="legend-label">10,000</span>
        </div>
        <div class="legend-item">
          <span class="legend-color" style="background-color: #fdae61"></span>
          <span class="legend-label">50,000</span>
        </div>
        <div class="legend-item">
          <span class="legend-color" style="background-color: #d7191c"></span>
          <span class="legend-label">100,000</span>
        </div>
        <div class="legend-item">
          <span class="legend-color" style="background-color: #800000"></span>
          <span class="legend-label">200,000</span>
        </div>
      </div>
    </div>
    <!-- ヒストグラム -->
    <div id="histogram-wrap">
      <canvas id="histogram"></canvas>
    </div>
  </div>
  <script src="map.js"></script>
</body>

</html>
```
</details>

<details><summary>style.css</summary>

```css
body {
  margin: 0;
  padding: 0;
}

#container {
  display: grid;
  grid-template-rows: 60vh 40vh;
  height: 100vh;
  margin: 0;
}

#map {
  grid-row: 1 / 2;
  width: 100%;
  height: 100%;
}

#histogram-wrap {
  grid-row: 2 / 3;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 10px;
  height: 100%;
}

#histogram-wrap canvas {
  max-width: 90%;
  max-height: 90%;
}

/* 凡例のスタイル */
.legend {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.legend-item {
  display: flex;
  align-items: center;
  gap: 8px;
}

.legend-color {
  width: 20px;
  height: 20px;
  border: 1px solid #ccc;
}

.legend-label {
  font-size: 12px;
}

/* 年月スライダー関係 */
datalist {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  writing-mode: horizontal-tb;
  width: 200px;
}

input[type="range"] {
  width: 200px;
  margin: 0;
}
```

</details>

## さいごに
折角なので、表示結果からの考察も加えておきます。

東京都においては、2020年4～5月に第1回緊急事態宣言があり、2021年には計3回の緊急事態宣言が続きました。ヒストグラムには第1回緊急事態宣言のインパクトが顕著に現れているかと思います。

![jinryu-histogram1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2893989/1a0a324d-d8c4-4695-8510-db6675bb6577.png)

新宿と池袋、東京駅と品川はどれも滞在人口の多い大きい駅ですが、後者の方が滞在人口の減少率が高いことはヒストグラムからも読み取れます。駅の条件は様々と思いますが、新幹線が通っているか否かも影響があるかもしれません。
![jinryu-histogram-shinjuku.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2893989/f6552ced-6395-4b9e-aae2-35af40579e0c.png)
![jinryu-histogram-ikebukuro.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2893989/441b2394-b9eb-443c-85d1-9af1502babfb.png)
![jinryu-histogram-tokyo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2893989/834b1de6-eee9-4a48-8de3-ccb03139df00.png)
![jinryu-histogram-shinagawa.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2893989/ab20d4cf-5398-459c-a052-29d435323f9a.png)

最後まで読んでいただきまして、ありがとうございました。