2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

国土数値情報の将来推計人口データを全国版GeoJSONにまとめて可視化する

2
Posted at

はじめに

以前、国土数値情報の「1kmメッシュ別将来推計人口データ」を使って、首都圏の将来推計人口を Leaflet で可視化するビューアを作成しました。

前回は、埼玉県、千葉県、東京都、神奈川県の4ファイルを読み込み、地図上で年度ごとの人口や2025年比を確認できるようにしました。

今回はその続きとして、全国の都道府県データを1つの GeoJSON にまとめ、全国版の将来推計人口ビューアとして扱えるようにしてみます。

ただし、全国版にすると feature 数が一気に増えます。

そのため、単純に GeoJSON を結合するだけでなく、以下もあわせて対応します。

  • ZIPファイルを直接読み込む
  • 複数の GeoJSON を1つに結合する
  • ビューアで使う属性だけを残して軽量化する
  • 全国版 GeoJSON を Leaflet で表示する
  • feature 数が多い場合のスライダー操作を改善する

将来推計人口データの構成

今回使用するのは、国土数値情報で公開されている「1kmメッシュ別将来推計人口データ(R6国政局推計)」です。

データは都道府県ごとに ZIP ファイルとして提供されています。

今回の作業ディレクトリでは、以下のように ZIP ファイルを配置しました。

mesh-population-viewer/
├─ index.html
├─ data/
│  └─ japan_population_min.geojson
└─ scripts/
   ├─ merge_population_geojson.py
   ├─ 1km_mesh_2024_GEOJSON/
   │  ├─ 1km_mesh_2024_01_GEOJSON.zip
   │  ├─ 1km_mesh_2024_02_GEOJSON.zip
   │  ├─ ...
   │  └─ 1km_mesh_2024_47_GEOJSON.zip
   └─ output/
      ├─ japan_population.geojson
      └─ japan_population_min.geojson

ZIP ファイルの中には GeoJSON が含まれています。

前回はあらかじめ展開した GeoJSON を data/ に置いて読み込みましたが、今回は Python スクリプト側で ZIP を直接読み込むようにしました。

全国版GeoJSONを作成する

全国版 GeoJSON を作成するために、scripts/merge_population_geojson.py を用意しました。

処理の流れは以下です。

  1. scripts/1km_mesh_2024_GEOJSON/ にある ZIP ファイルを探す
  2. 各 ZIP の中から GeoJSON ファイルを読み込む
  3. すべての feature を1つの FeatureCollection にまとめる
  4. 全属性版と軽量版の2種類を出力する

ZIPファイルを直接読み込む

ZIP ファイルは zipfile で開きます。

ZIP 内のファイル名を確認し、拡張子が .geojson のものを読み込んでいます。

def load_geojson_from_zip(zip_path):
    """
    ZIP内のGeoJSONを読み込む
    """

    with zipfile.ZipFile(zip_path) as zf:

        geojson_name = next(
            name
            for name in zf.namelist()
            if name.lower().endswith(".geojson")
        )

        with zf.open(geojson_name) as fp:
            return json.load(fp)

これにより、手元で47都道府県分の ZIP を展開してから処理する必要がなくなります。

GeoJSONを結合する

都道府県ごとの GeoJSON は、それぞれ FeatureCollection になっています。

そのため、各ファイルの features を取り出して、1つの配列に追加していきます。

all_features = []
min_features = []

zip_files = sorted(
    INPUT_DIR.glob("*.zip")
)

for zip_file in zip_files:

    print(f"読込中: {zip_file.name}")

    geojson = load_geojson_from_zip(zip_file)

    features = geojson.get("features", [])

    all_features.extend(features)

    min_features.extend(
        create_min_feature(feature)
        for feature in features
    )

最後に、まとめた feature 配列を FeatureCollection として出力します。

full_geojson = {
    "type": "FeatureCollection",
    "features": all_features,
}

必要な属性だけ残す

国土数値情報のデータには、総人口以外にも男女別、年齢階級別など多くの属性が含まれています。

今回のビューアで使うのは、以下だけです。

  • メッシュID
  • 2025年から2070年までの総人口

そこで、軽量版では以下の属性だけを残しました。

KEEP_PROPERTIES = {
    "MESH_ID",
    "PTN_2025",
    "PTN_2030",
    "PTN_2035",
    "PTN_2040",
    "PTN_2045",
    "PTN_2050",
    "PTN_2055",
    "PTN_2060",
    "PTN_2065",
    "PTN_2070",
}

feature ごとに、必要な属性だけを持つ新しい feature を作成します。

def create_min_feature(feature):
    """
    必要な属性のみ残したFeatureを生成
    """

    properties = feature.get("properties", {})

    return {
        "type": "Feature",
        "geometry": feature["geometry"],
        "properties": {
            key: properties.get(key)
            for key in KEEP_PROPERTIES
            if key in properties
        },
    }

これで、ビューアに不要な属性を削除できます。

出力結果を確認する

スクリプトを実行すると、以下の2ファイルを出力します。

scripts/output/
├─ japan_population.geojson
└─ japan_population_min.geojson

手元の環境では、出力結果は以下のようになりました。

japan_population.geojson      1.2GB
japan_population_min.geojson   76MB

軽量化しても 76MB ありますが、全属性版と比べるとかなり小さくなりました。

feature 数は以下です。

177,791 features

軽量版の feature には、ビューアで使用する属性だけが残っています。

{
  "properties": {
    "MESH_ID": "...",
    "PTN_2025": "...",
    "PTN_2030": "...",
    "PTN_2035": "...",
    "PTN_2040": "...",
    "PTN_2045": "...",
    "PTN_2050": "...",
    "PTN_2055": "...",
    "PTN_2060": "...",
    "PTN_2065": "...",
    "PTN_2070": "..."
  }
}

この軽量版を data/japan_population_min.geojson として配置し、ビューアから読み込むようにしました。

const files = ["./data/japan_population_min.geojson"];

全国版を表示したときの課題

ここまでで、全国版 GeoJSON は作成できました。

ただし、実際に Leaflet で表示してみると、前回の首都圏版では問題にならなかった課題が出てきました。

それは、年度を切り替えるスライダーの操作が重くなることです。

前回の実装では、スライダーを動かすたびに以下の処理をしていました。

function renderLayer(year) {
  if (geojsonLayer) {
    map.removeLayer(geojsonLayer);
  }

  geojsonLayer = L.geoJSON(geojsonData, {
    renderer: L.canvas(),
    style: (feature) => {
      // 年度に応じて色を返す
    },
    onEachFeature: (feature, layer) => {
      // ポップアップを設定する
    },
  }).addTo(map);
}

つまり、年度を切り替えるたびに、既存レイヤーを削除し、177,791 feature 分の GeoJSON レイヤーを作り直していました。

首都圏4都県では許容できても、全国版ではこの処理が重くなります。

スライダーの input イベントはドラッグ中に連続して発火するため、操作するたびに大きな再描画が積み重なってしまいます。

スライダー操作を改善する

今回の改善では、データ形式は変えません。

Vector Tile や PMTiles に変換すればより本格的に改善できますが、今回は GeoJSON のまま、index.html 側の実装だけを見直しました。

対応したことは主に3つです。

  • GeoJSON レイヤーを初回だけ作成する
  • 年度変更時は既存レイヤーの style だけ更新する
  • スライダー操作中の再描画を debounce する

レイヤーを初回だけ作成する

まず、現在の表示年を状態として持つようにします。

let displayMode = "population";
let currentYear = Number(yearSlider.value);
let renderTimer = null;

次に、現在の表示年と表示モードに応じて style を返す関数を用意します。

function getFeatureStyle(feature) {
  let fillColor;

  if (displayMode === "population") {
    const population = getPopulation(feature, currentYear);
    fillColor = getPopulationColor(population);
  } else {
    const ratio = getRatio(feature, currentYear);
    fillColor = getRatioColor(ratio);
  }

  return {
    color: "#666",
    weight: 0.3,
    fillColor,
    fillOpacity: 0.7,
  };
}

GeoJSON レイヤーは初回だけ作成します。

function createLayer() {
  geojsonLayer = L.geoJSON(geojsonData, {
    renderer: L.canvas(),
    style: getFeatureStyle,
    onEachFeature: (feature, layer) => {
      layer.bindPopup(() => getPopupContent(feature));
    },
  }).addTo(map);
}

ポイントは、年度切り替え時に L.geoJSON() を再実行しないことです。

styleだけ更新する

年度や表示モードが変わったときは、既存レイヤーに対して setStyle() を呼びます。

function updateLayerStyles() {
  if (!geojsonLayer) return;
  geojsonLayer.setStyle(getFeatureStyle);
}

これにより、geometry や layer 自体の再生成を避けられます。

また、ポップアップも固定文字列を先に作るのではなく、クリックされたタイミングで現在年をもとに生成するようにしました。

layer.bindPopup(() => getPopupContent(feature));

これで、年度を切り替えるたびに全 feature 分のポップアップ HTML を作り直す必要がなくなります。

スライダー操作をdebounceする

スライダーの input イベントは、ドラッグ中に何度も発火します。

そこで、ラベルはすぐに更新しつつ、地図の再描画は少し遅らせます。

function scheduleLayerUpdate() {
  if (renderTimer) {
    clearTimeout(renderTimer);
  }

  renderTimer = setTimeout(() => {
    renderTimer = null;
    updateLayerStyles();
  }, 150);
}

スライダー操作時は、選択中の年を更新し、描画更新を予約します。

yearSlider.addEventListener("input", () => {
  currentYear = Number(yearSlider.value);
  yearLabel.textContent = currentYear;

  if (geojsonData) {
    scheduleLayerUpdate();
  }
});

さらに、スライダー操作が完了したタイミングでは、確定した年で即時に反映します。

yearSlider.addEventListener("change", () => {
  currentYear = Number(yearSlider.value);
  yearLabel.textContent = currentYear;

  if (renderTimer) {
    clearTimeout(renderTimer);
    renderTimer = null;
  }

  updateLayerStyles();
});

この変更により、スライダーを動かすたびに巨大な GeoJSON レイヤーを作り直す処理を避けられるようになりました。

改善後の表示

全国版 GeoJSON を読み込むため、初回表示にはそれなりに時間がかかります。

ただし、一度読み込んだ後の年度切り替えはかなり改善されました。

6a7f8df6-0a28-47ab-aebf-0d73a9b416b7.png

人口モードでは、指定した年度の人口規模を色で確認できます。

12aec20c-1361-434a-8a2b-534dc3343723.png

2025年比モードでは、2025年を基準にした増減傾向を確認できます。

おわりに

今回は、国土数値情報の将来推計人口データを全国版 GeoJSON として結合し、Leaflet で表示してみました。

全国版にすると、feature 数は 177,791 件になり、軽量化後の GeoJSON でも 76MB ありました。

そのため、前回のようにスライダー操作ごとに GeoJSON レイヤーを再生成する実装では、操作が重くなります。

今回は以下の改善を入れることで、GeoJSON のままでもある程度快適に操作できるようになりました。

  • 必要な属性だけを残して GeoJSON を軽量化する
  • Leaflet の GeoJSON レイヤーは初回だけ作成する
  • 年度切り替え時は setStyle() で style だけ更新する
  • スライダー操作中の再描画を debounce する
  • ポップアップ内容はクリック時に生成する

もちろん、全国規模の大量ポリゴンを本格的に扱うのであれば、Vector Tile や PMTiles などを検討するのがよさそうです。

ただ、今回はあくまで GeoJSON のまま扱う範囲で、どこが重くなるのか、どの実装を変えると効くのかを確認できました。

小さな実装の違いでも、feature 数が増えると体感に大きく影響することがわかりました。

参考:コード全体

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>将来推計人口ビューア</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
    <style>
      html,
      body {
        margin: 0;
        padding: 0;
        height: 100%;
        font-family: sans-serif;
      }
      #container {
        display: flex;
        flex-direction: column;
        height: 100%;
      }
      #toolbar {
        padding: 12px;
        background: #ffffff;
        border-bottom: 1px solid #cccccc;
        display: flex;
        flex-wrap: wrap;
        gap: 16px;
        align-items: center;
      }
      #map {
        flex: 1;
      }
      #yearLabel {
        font-size: 1.2rem;
        font-weight: bold;
      }
      input[type="range"] {
        width: 300px;
      }
      .legend {
        background: white;
        padding: 10px;
        line-height: 1.5;
        border-radius: 4px;
        box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
      }
      .legend i {
        display: inline-block;
        width: 18px;
        height: 18px;
        margin-right: 8px;
        opacity: 0.8;
        vertical-align: middle;
      }
    </style>
  </head>
  <body>
    <div id="container">
      <div id="toolbar">
        <div>
          表示年:
          <span id="yearLabel">2025</span>
        </div>
        <input type="range" id="yearSlider" min="2025" max="2070" step="5" value="2025" />
        <label>
          <input type="radio" name="mode" value="population" checked />
          人口
        </label>
        <label>
          <input type="radio" name="mode" value="ratio" />
          2025年比
        </label>
      </div>
      <div id="map"></div>
    </div>
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
    <script>
      // 地図を初期化し、首都圏が収まるよう表示位置を設定する
      const map = L.map("map");
      map.setView([35.681236, 139.767125], 9);
      L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
        attribution: "&copy; OpenStreetMap contributors",
      }).addTo(map);

      const yearLabel = document.getElementById("yearLabel");
      const yearSlider = document.getElementById("yearSlider");

      // 現在の表示モード("population": 人口 / "ratio": 2025年比)
      let displayMode = "population";
      let currentYear = Number(yearSlider.value);
      let renderTimer = null;

      // 読み込んだGeoJSONデータとLeafletレイヤーを保持する変数
      let geojsonData = null;
      let geojsonLayer = null;

      // 都道府県ごとに分かれたGeoJSONファイルのパス一覧
      const files = ["./data/japan_population_min.geojson"];

      // GeoJSONの属性名は「PTN_西暦年」の形式(例: PTN_2025)になっている
      function getPopulation(feature, year) {
        return Number(feature.properties[`PTN_${year}`]) || 0;
      }

      // 2025年の人口を基準として、指定年の人口比率(%)を算出する
      function getRatio(feature, year) {
        const base = Number(feature.properties["PTN_2025"]) || 0;
        const current = Number(feature.properties[`PTN_${year}`]) || 0;
        if (base === 0) return 0;
        return (current / base) * 100;
      }

      // 人口の多さを赤系の色で表現する(人口が多いほど濃い赤)
      function getPopulationColor(value) {
        if (value >= 5000) return "#800026";
        if (value >= 3000) return "#BD0026";
        if (value >= 1000) return "#E31A1C";
        if (value >= 500) return "#FC4E2A";
        if (value >= 100) return "#FD8D3C";
        return "#FFEDA0";
      }

      // 人口増加を赤、ほぼ横ばいを白、人口減少を青で表現する
      function getRatioColor(value) {
        if (value >= 130) return "#b30000";
        if (value >= 120) return "#cb181d";
        if (value >= 105) return "#fc9272";
        if (value >= 95) return "#f7f7f7";
        if (value >= 80) return "#c6dbef";
        if (value >= 70) return "#6baed6";
        if (value >= 60) return "#3182bd";
        return "#08306b";
      }

      // 現在の表示年・表示モードに応じたFeatureのスタイルを返す
      function getFeatureStyle(feature) {
        let fillColor;
        if (displayMode === "population") {
          const population = getPopulation(feature, currentYear);
          fillColor = getPopulationColor(population);
        } else {
          const ratio = getRatio(feature, currentYear);
          fillColor = getRatioColor(ratio);
        }
        return {
          color: "#666",
          weight: 0.3,
          fillColor,
          fillOpacity: 0.7,
        };
      }

      // メッシュクリック時に人口・増減率を表示するポップアップ内容を生成する
      function getPopupContent(feature) {
        const population = getPopulation(feature, currentYear);
        const ratio = getRatio(feature, currentYear);
        const changeRate = ratio - 100;
        return `
                <table>
                    <tr>
                        <th align="left">メッシュID</th>
                        <td>${feature.properties.MESH_ID}</td>
                    </tr>
                    <tr>
                        <th align="left">年</th>
                        <td>${currentYear}</td>
                    </tr>
                    <tr>
                        <th align="left">人口</th>
                        <td>${population.toLocaleString()} 人</td>
                    </tr>
                    <tr>
                        <th align="left">2025年比</th>
                        <td>${ratio.toFixed(1)} %</td>
                    </tr>
                    <tr>
                        <th align="left">増減率</th>
                        <td>${changeRate > 0 ? "+" : ""}${changeRate.toFixed(1)} %</td>
                    </tr>
                </table>
            `;
      }

      // GeoJSONレイヤーは初回だけ作成し、年度切り替え時の再生成を避ける
      function createLayer() {
        geojsonLayer = L.geoJSON(geojsonData, {
          renderer: L.canvas(),
          style: getFeatureStyle,
          onEachFeature: (feature, layer) => {
            layer.bindPopup(() => getPopupContent(feature));
          },
        }).addTo(map);
      }

      // 既存レイヤーのスタイルだけを更新する
      function updateLayerStyles() {
        if (!geojsonLayer) return;
        geojsonLayer.setStyle(getFeatureStyle);
      }

      // スライダー操作中の連続描画を抑制する
      function scheduleLayerUpdate() {
        if (renderTimer) {
          clearTimeout(renderTimer);
        }
        renderTimer = setTimeout(() => {
          renderTimer = null;
          updateLayerStyles();
        }, 150);
      }

      // 凡例コントロールを地図右下に追加する
      const legend = L.control({ position: "bottomright" });
      legend.onAdd = function () {
        const div = L.DomUtil.create("div", "legend");
        updateLegend(div);
        return div;
      };
      legend.addTo(map);

      // 表示モードに応じて凡例の内容を切り替える
      function updateLegend(element) {
        if (displayMode === "population") {
          element.innerHTML = `
            <strong>人口</strong><br>
            <i style="background:#800026;border:1px solid #ccc"></i>5000以上<br>
            <i style="background:#BD0026;border:1px solid #ccc"></i>3000以上<br>
            <i style="background:#E31A1C;border:1px solid #ccc"></i>1000以上<br>
            <i style="background:#FC4E2A;border:1px solid #ccc"></i>500以上<br>
            <i style="background:#FD8D3C;border:1px solid #ccc"></i>100以上<br>
            <i style="background:#FFEDA0;border:1px solid #ccc"></i>100未満
        `;
        } else {
          element.innerHTML = `
            <strong>2025年比</strong><br>
            <i style="background:#b30000;border:1px solid #ccc"></i>130%以上<br>
            <i style="background:#cb181d;border:1px solid #ccc"></i>120~130%<br>
            <i style="background:#fc9272;border:1px solid #ccc"></i>105~120%<br>
            <i style="background:#f7f7f7;border:1px solid #ccc"></i>95~105%<br>
            <i style="background:#c6dbef;border:1px solid #ccc"></i>80~95%<br>
            <i style="background:#6baed6;border:1px solid #ccc"></i>70~80%<br>
            <i style="background:#3182bd;border:1px solid #ccc"></i>60~70%<br>
            <i style="background:#08306b;border:1px solid #ccc"></i>60%未満
        `;
        }
      }

      // 複数のGeoJSONを並列で取得し、全件完了後に1つのFeatureCollectionとしてまとめて描画する
      Promise.all(files.map((file) => fetch(file).then((response) => response.json())))
        .then((results) => {
          geojsonData = {
            type: "FeatureCollection",
            features: results.flatMap((item) => item.features),
          };
          createLayer();
        })
        .catch((error) => {
          console.error(error);
          alert("GeoJSONの読み込みに失敗しました");
        });

      // スライダー操作で表示年度を切り替える
      yearSlider.addEventListener("input", () => {
        currentYear = Number(yearSlider.value);
        yearLabel.textContent = currentYear;
        if (geojsonData) {
          scheduleLayerUpdate();
        }
      });

      // スライダー操作完了時は選択年の表示に確定する
      yearSlider.addEventListener("change", () => {
        currentYear = Number(yearSlider.value);
        yearLabel.textContent = currentYear;
        if (renderTimer) {
          clearTimeout(renderTimer);
          renderTimer = null;
        }
        updateLayerStyles();
      });

      // ラジオボタン切り替えで表示モードを変更し、凡例とレイヤーを更新する
      document.querySelectorAll('input[name="mode"]').forEach((radio) => {
        radio.addEventListener("change", (event) => {
          displayMode = event.target.value;
          const legendElement = document.querySelector(".legend");
          updateLegend(legendElement);
          updateLayerStyles();
        });
      });
    </script>
  </body>
</html>
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?