0
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?

学校区ポリゴン内の物件を抽出し、地図に表示してみる バックエンド GeoPandas編

Posted at

はじめに

この記事では、特定の小学校区ポリゴン内に含まれる物件データを抽出し、ポリゴンと物件データをレスポンスするサーバープログラムの例をご紹介します。この記事を通して、GeoPandasの空間演算を用いてポリゴン内に含まれる地物データを抽出する方法が理解いただけると思います。

またサーバープログラムの動作を確認する地図アプリの例も合わせてご紹介します。

具体例として、不動産関連のユースケースを想定した学校区に含まれる自社の物件データの抽出を取り上げていますが、不動産関連や学校区に限りません。町丁目内の顧客データを抽出する場合など、他のケースにも置き換えることができます。

学区地図アプリ 完成イメージ

以下が地図アプリの完成イメージです。地図をクリックした地点の小学校区とその小学校区に含まれる物件データを表示しています。
※ 使用している物件データは、架空のものになります。

points_in_gakku_polygons2.gif

処理詳細
  1.  地図上をクリックし、サーバーへのリクエストを行います
  2.  サーバーは、事前にCSV形式の物件データを読み込んでいます
  3.  サーバーは、TerraMap API からの小学校区データを取得します
  4.  サーバーは、小学校区ポリゴン内の物件データを抽出します
  5.  サーバーは、小学校区データと抽出した物件データをレスポンスします
  6.  学区内の物件データは、右側テーブルに表示させます
  7.  小学校区ポリゴン、小学校位置、学区内の物件位置を地図に表示させます
  8.  地図上の物件位置にカーソルを合わせることで、名称と住所をポップアップ表示させます

使用技術一覧

カテゴリ 採用技術 / データソース
地図ライブラリ MapLibre GL JS
背景地図 OpenStreetMap(ベクトルタイル)
空間演算 GeoPandas の GeoDataFrame
小学校区データ TerraMap API の「小学校区・中学校区データ」
※ 詳細は初めて扱った時の記事をご参照下さい。
物件データ CSV形式(lat, lng, name, address)1

物件データと空間演算について

この記事では物件のデータベース(RDMS)を扱っていません。大量のデータはデータベースに保持し使用することが多いと思いますが、今回は予めデータベースから絞られて出力された小規模なデータを想定しております。データベースから取得する部分は疑似的にCSVファイルを読み込む形にしています。

GeoPandasによる空間演算もサーバーサイドのプログラムで実行させております。これはデータベースで空間演算を行わない場合や行うことが難しい場合を想定しております。

サーバーサイドの実装(Python)

TerraMap API から取得した小学校区データと、小学校区ポリゴン内の物件データをレスポンスする仲介サーバーをPythonで作成しました。

全ソースに続き、ポイントとなる箇所の説明を加えておきます。

ファイル構成
root/
 ├── server.py
 └── bukken_sample.csv
server.py
# 標準ライブラリ
import json

# サードパーティライブラリ
from flask import Flask, request, jsonify, Response
from flask_cors import CORS
from functools import lru_cache
from shapely.geometry import Point, shape
from waitress import serve
import pandas as pd
import geopandas as gpd
import requests

# グローバル変数
gdf = None  # 物件データのGeoDataFrame

# 物件データを読み込み、GeoDataFrameに変換する関数
# キャッシュを使用し、一度だけ読み込むようにする
@lru_cache(maxsize=1)
def load_data():
    # サンプルCSVの文字コードがCP932なので、CP932を指定して読み込む
    df = pd.read_csv("./bukken_sample.csv", encoding='cp932', usecols=["lat","lng","name","address"])
    # 緯度経度からPointジオメトリを作成し、GeoDataFrameに変換
    geometry = [Point(xy) for xy in zip(df["lng"], df["lat"])]
    return gpd.GeoDataFrame(df, geometry=geometry, crs="EPSG:4326")

# Flaskアプリケーションの設定
app = Flask(__name__)
CORS(app)  # CORS設定  全てのオリジンを許可(開発用)

# TerraMap APIの小学校区ポリゴン+物件を取得するAPIエンドポイント
@app.route("/get-merged-gakku-polygon", methods=["POST"])
def get_merged_gakku_polygon():
    data = request.get_json()
    lat = data.get("lat")
    lng = data.get("lng")

    if not lat or not lng:
        return jsonify({"error": "lat and lng are required"}), 400

    try:
        response = requests.post(
            # TerraMap APIのエリア取得エンドポイント
            "https://tmapi.mapmarketing.jp/api/area",
            json={
                "layer_id": "10101",    # 小学校区レイヤーを指定
                "area_type": "coordinate",
                "coordinates": [[lng, lat]],
                "output": "polygon,point",
            },
            headers={
                "Content-Type": "application/json",
                "X-API-KEY": "YOUR_TERRAMAP_API_KEY",
            },
            timeout=10
        )
        response.raise_for_status()
        geojson = response.json()

        # 物件データを読み込む
        global gdf

        # 学区ポリゴン内の物件を取得
        polygon_geom = shape(geojson['features'][0]['geometry'])
        filteredPoints = gdf[gdf.geometry.within(polygon_geom)]

        # GeoJSON形式のレスポンスを作成
        # 学区ポリゴン
        polygon_feature = geojson['features'][0]
        # 抽出された物件ポイント
        point_features = []
        for idx, row in filteredPoints.iterrows():
            point_features.append({
                "type": "Feature",
                "geometry": {
                    "type": "Point",
                    "coordinates": [row["lng"], row["lat"]]
                },
                "properties": {
                    "name": row["name"],
                    "address": row["address"]
                }
            })

        # 学区ポリゴンと物件ポイントを結合
        merged_geojson = {
            "type": "FeatureCollection",
            "features": [polygon_feature] + point_features
        }

        return Response(json.dumps(merged_geojson, ensure_ascii=False), mimetype='application/json')
    except Exception as e:
        print("get-merged-gakku-polygon error:", str(e))
        return jsonify({"error": "Failed to get merged-gakku-polygon"}), 500

if __name__ == "__main__":
    # サーバー起動時に一度だけデータを読み込む
    gdf = load_data()
    print("Data loaded successfully.")

    # サーバー起動メッセージ
    print("Starting server on http://0.0.0.0:3000")
    print("Access the server via http://127.0.0.1:3000")

    # 本番環境向きのWSGIサーバーを起動
    serve(app, host='0.0.0.0', port=3000)
bukken_sample.csv (一部抜粋)
lat,lng,name,address
35.65937,139.70639,マンション渋谷,東京都渋谷区渋谷
35.65543,139.70083,桜丘町ハウス,東京都渋谷区桜丘町
35.65394,139.69626,コーポ南平台町,東京都渋谷区南平台町
35.65822,139.698,道玄坂ヒルズ,東京都渋谷区道玄坂
35.65746,139.69461,マンション円山町,東京都渋谷区円山町
35.65672,139.69229,神泉町アパート,東京都渋谷区神泉町
35.66064,139.69182,レジデンス松濤,東京都渋谷区松濤
...

TerraMap APIから小学校区データを取得する

TerraMap APIを利用するには専用のAPIキーが必要になり、サーバープログラムでの使用を推奨しています。コード内では YOUR_TERRAMAP_API_KEY 部分にAPIキーを入力します。

/get-merged-gakku-polygon のエンドポイントは、地図アプリからの座標情報を受け取り小学校区ポリゴンと物件データを返すように作成しています。処理のはじめにTerraMap APIのインターフェース仕様に従ったリクエストを実行しています。

小学校区データ取得部分
# TerraMap APIの小学校区ポリゴン+物件を取得するAPIエンドポイント
@app.route("/get-merged-gakku-polygon", methods=["POST"])
def get_merged_gakku_polygon():
    data = request.get_json()
    lat = data.get("lat")
    lng = data.get("lng")

    if not lat or not lng:
        return jsonify({"error": "lat and lng are required"}), 400

    try:
        response = requests.post(
            # TerraMap APIのエリア取得エンドポイント
            "https://tmapi.mapmarketing.jp/api/area",
            json={
                "layer_id": "10101",    # 小学校区レイヤーを指定
                "area_type": "coordinate",
                "coordinates": [[lng, lat]],
                "output": "polygon,point",
            },
            headers={
                "Content-Type": "application/json",
                "X-API-KEY": "YOUR_TERRAMAP_API_KEY",
            },
            timeout=10
        )
        response.raise_for_status()
        geojson = response.json()

※ Python以外の言語でのTerraMap APIへのリクエスト例については、以下のスタートアップガイドをご参照下さい。

物件データの読み込みと抽出

物件データの読み込みと抽出については、pandasとDataFrame、GeoPandasとGeoDataFrameの理解が必要ですが、それぞれ以下のような位置付けになります。

項目 内容
pandas Pythonのデータ解析ライブラリ。表形式データを効率的に扱うことができる。
DataFrame pandasの中心的なデータ構造。行と列からなる二次元のラベル付きデータ。
GeoPandas pandasを拡張した地理空間データ処理ライブラリ。シェープファイルやGeoJSONなどを扱うことができる。
GeoDataFrame GeoPandasの中心的なデータ構造。DataFrameに加えて、幾何情報(Point, Polygonなど)を持つ。

CSVファイルの読み込み

pandasのread_csv関数によりCSVデータのDataFrameへの変換を行っています。さらにDataFrameの緯度・経度からPointジオメトリを作成し、GeoPandasのGeoDataFrameへと変換しています。

CSVファイル読み込み部分
# グローバル変数
gdf = None  # 物件データのGeoDataFrame

# 物件データを読み込み、GeoDataFrameに変換する関数
# キャッシュを使用し、一度だけ読み込むようにする
@lru_cache(maxsize=1)
def load_data():
    # サンプルCSVの文字コードがCP932なので、CP932を指定して読み込む
    df = pd.read_csv("./bukken_sample.csv", encoding='cp932', usecols=["lat","lng","name","address"])
    # 緯度経度からPointジオメトリを作成し、GeoDataFrameに変換
    geometry = [Point(xy) for xy in zip(df["lng"], df["lat"])]
    return gpd.GeoDataFrame(df, geometry=geometry, crs="EPSG:4326")

### 中略 ###########

if __name__ == "__main__":
    # サーバー起動時に一度だけデータを読み込む
    gdf = load_data()
    print("Data loaded successfully.")

学区ポリゴン内の物件データを抽出

小学校区データ geojson と物件データ gdf のジオメトリ同士の空間演算を実行し、gdf.geometry.within(polygon_geom) == True の条件で物件データを絞り込んでいます。

サーバープログラムはGeoJSON形式でレスポンスするため、物件のポイントデータもFeatureへと変換しています。

物件データ抽出部分
        # 物件データを読み込む
        global gdf

        # 学区ポリゴン内の物件を取得
        polygon_geom = shape(geojson['features'][0]['geometry'])
        filteredPoints = gdf[gdf.geometry.within(polygon_geom)]

        # GeoJSON形式のレスポンスを作成
        # 学区ポリゴン
        polygon_feature = geojson['features'][0]
        # 抽出された物件ポイント
        point_features = []
        for idx, row in filteredPoints.iterrows():
            point_features.append({
                "type": "Feature",
                "geometry": {
                    "type": "Point",
                    "coordinates": [row["lng"], row["lat"]]
                },
                "properties": {
                    "name": row["name"],
                    "address": row["address"]
                }
            })

サーバーの起動

Pythonやpipが使える前提ですが、サーバーを起動するには以下の処理が必要になります。

ライブラリのインストール

pip install flask flask-cors pandas geopandas shapely requests waitress

サーバー起動

python server.py

フロントエンドの実装

ファイル構成
root/
 ├── index.html
 ├── map.js
 └── style.css
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>小学校区ポリゴン内にある物件データを抽出</title>
    <script src="https://unpkg.com/maplibre-gl@5.8.0/dist/maplibre-gl.js"></script>
    <link
      href="https://unpkg.com/maplibre-gl@5.8.0/dist/maplibre-gl.css"
      rel="stylesheet"
    />
    <script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
    <link 
      href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css"
      rel="stylesheet"
    >
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="container">
      <div id="map"></div>
      <div id="info" style="width: 33.33%; height: 100%; overflow: auto; padding: 10px;">
        <h2>小学校区内・物件データ</h2>
        <p>地図上をクリックすると、その地点の小学校区内にある物件データが表示されます</p>
        <div id="data-count"></div><br />
        <div id="data-table"></div>
      </div>
    </div>
    <script src="map.js"></script>
  </body>
</html>
map.js
// 背景地図の表示
const map = new maplibregl.Map({
  container: "map",
  style: "https://tile.openstreetmap.jp/styles/osm-bright/style.json",
  center: [139.69152, 35.66444],
  zoom: 14,
});

const bukkenPopup = new maplibregl.Popup({
  closeButton: false,
  closeOnClick: false,
});
let bukkenGrid = null;
let bukkenCurrentCoordinates = undefined;
let schoolMarker = null;

// レイヤー、ソース、マーカーを削除
function initializeLayerAndSource() {
  if (map.getLayer("polygon")) {
    map.removeLayer("polygon");
  }
  if (map.getLayer("outline")) {
    map.removeLayer("outline");
  }
  if (map.getLayer("points")) {
    map.removeLayer("points");
  }
  if (map.getSource("gakku_geojson")) {
    map.removeSource("gakku_geojson");
  }
  if (schoolMarker) {
    schoolMarker.remove();
  }
}

map.on("load", () => {
  // クリックイベント
  map.on("click", (e) => {
    const lat = e.lngLat.lat;
    const lng = e.lngLat.lng;

    // クリックした位置の小学校区データ+物件ポイントデータを取得
    fetch(`http://127.0.0.1:3000/get-merged-gakku-polygon`, {
      // サーバーURLは適宜変更してください
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ lat: lat, lng: lng }),
    })
      .then((response) => response.json())
      .then((data) => {
        // 表示物の初期化
        initializeLayerAndSource();

        if (!data || !data.features || data.features.length === 0) {
          console.log("No features found in the response.");
          document.getElementById("data-table").innerHTML = "";
          document.getElementById("data-count").textContent =
            "該当物件数 --- 0 件";
          return;
        }

        // GeoJSONのソースを追加
        map.addSource(`gakku_geojson`, {
          type: "geojson",
          data: data,
        });

        // 小学校区ポリゴンの背景色を設定します
        map.addLayer({
          id: `polygon`,
          type: "fill",
          source: `gakku_geojson`,
          layout: {},
          paint: {
            "fill-color": "#1c1c1c",
            "fill-opacity": 0.5,
          },
          filter: ["==", "$type", "Polygon"], // 小学校区ポリゴンデータのみ表示
        });

        // 小学校区ポリゴンの枠線を設定します
        map.addLayer({
          id: `outline`,
          type: "line",
          source: `gakku_geojson`,
          layout: {},
          paint: {
            "line-color": "#1c1c1c",
            "line-width": 2,
          },
          filter: ["==", "$type", "Polygon"], // 小学校区ポリゴンデータのみ表示
        });

        // 小学校の位置を取得    TerraMap APIデータ特有のデータ構造に対応
        const schoolPosition = {
          lat: data.features[0].properties.point_coordinates[1],
          lng: data.features[0].properties.point_coordinates[0],
        };
        // 小学校のマーカーを追加
        schoolMarker = new maplibregl.Marker()
          .setLngLat([schoolPosition.lng, schoolPosition.lat])
          .setPopup(
            new maplibregl.Popup({
              offset: -35,
              closeButton: false,
              closeOnClick: false,
            }).setHTML(
              "<p class='popup-text'>" +
                data.features[0].properties.points[0][0] + // 学校名:TerraMap APIデータ特有のデータ構造に対応
                "</p>"
            )
          )
          .addTo(map);
        // マーカーのポップアップを表示
        schoolMarker.togglePopup();

        // 物件ポイントデータを表示
        map.addLayer({
          id: "points",
          type: "circle",
          source: "gakku_geojson",
          paint: {
            "circle-radius": 6,
            "circle-color": "#22ff98ff",
            "circle-stroke-width": 2,
            "circle-stroke-color": "#FFFFFF",
          },
          filter: ["==", "$type", "Point"], // 物件ポイントデータのみ表示
        });

        // 物件テーブル表示
        const bukkenFeatures = data.features
          ? data.features.filter((f) => f.geometry.type === "Point")
          : [];
        if (bukkenFeatures.length > 0) {
          document.getElementById("data-table").innerHTML = "";
          const tableData = bukkenFeatures.map((point) => [
            point.properties.name,
            point.properties.address,
          ]);

          if (!bukkenGrid) {
            bukkenGrid = new gridjs.Grid({
              columns: [
                { id: "name", name: "名称" },
                { id: "address", name: "住所" },
              ],
              data: tableData,
            }).render(document.getElementById("data-table"));
          } else {
            bukkenGrid
              .updateConfig({
                data: tableData,
              })
              .forceRender();
          }

          document.getElementById("data-count").textContent = `${
            data.features[0].properties.points[0][0] // 学校名:TerraMap APIデータ特有のデータ構造に対応
          }区内 --- ${bukkenFeatures.length.toLocaleString()} 件`;
        } else {
          document.getElementById("data-table").innerHTML = "";
          document.getElementById("data-count").textContent =
            "該当物件数 --- 0 件";
        }
      })
      .catch((error) => {
        console.log(error);
      });
  });

  // 物件の MouseMoveイベント・ポップアップ表示
  map.on("mousemove", "points", (e) => {
    map.getCanvas().style.cursor = "pointer";
    const featureCoordinates = e.features[0].geometry.coordinates.toString();
    if (bukkenCurrentCoordinates !== featureCoordinates) {
      bukkenCurrentCoordinates = featureCoordinates;

      const coordinates = e.features[0].geometry.coordinates.slice();
      const name = e.features[0].properties.name;
      const address = e.features[0].properties.address;

      bukkenPopup
        .setLngLat(coordinates)
        .setHTML(`<p class='popup-text'>名称: ${name}<br/>住所: ${address}</p>`)
        .addTo(map);
    }
  });

  // 物件の MouseLeaveイベント・ポップアップ削除
  map.on("mouseleave", "points", () => {
    map.getCanvas().style.cursor = "";
    bukkenCurrentCoordinates = undefined;
    bukkenPopup.remove();
  });
});
style.css
.container {
  display: flex;
  width: 100%;
  height: 95vh;
}

#map {
  width: 66.67%;
  height: 100%;
}

.maplibregl-popup-tip {
  display: none;
}

.maplibregl-popup-content {
  background-color: rgba(255, 255, 255, 0.5);
  box-shadow: none;
  padding: 0;
}

.popup-text {
  color: black;
  font-size: 10px;
  margin: 5px;
}

背景地図の表示

まず基本となる MapLibre GL JS の取り込みと、背景地図の表示は以下のようになります。

html
    <script src="https://unpkg.com/maplibre-gl@5.8.0/dist/maplibre-gl.js"></script>
    <link
      href="https://unpkg.com/maplibre-gl@5.8.0/dist/maplibre-gl.css"
      rel="stylesheet"
    />
javascript
// 背景地図の表示
const map = new maplibregl.Map({
  container: "map",
  style: "https://tile.openstreetmap.jp/styles/osm-bright/style.json",
  center: [139.69152, 35.66444],
  zoom: 14,
});

学区ポリゴンとマーカーの表示

小学校区データを表示させる部分を抜粋します。
地図のクリックイベントで、サーバーから小学校区データおよび物件データを取得し、その中から小学校区ポリゴンと小学校のマーカーを表示させています。

javascript
let schoolMarker = null;

// 中略 ////////////////////////////////////

map.on("load", () => {
  // クリックイベント
  map.on("click", (e) => {
    const lat = e.lngLat.lat;
    const lng = e.lngLat.lng;

    // クリックした位置の小学校区データ+物件ポイントデータを取得
    fetch(`http://127.0.0.1:3000/get-merged-gakku-polygon`, {
      // サーバーURLは適宜変更してください
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ lat: lat, lng: lng }),
    })
      .then((response) => response.json())
      .then((data) => {

        // 中略 ////////////////////////////////////

        // GeoJSONのソースを追加
        map.addSource(`gakku_geojson`, {
          type: "geojson",
          data: data,
        });

        // 小学校区ポリゴンの背景色を設定します
        map.addLayer({
          id: `polygon`,
          type: "fill",
          source: `gakku_geojson`,
          layout: {},
          paint: {
            "fill-color": "#1c1c1c",
            "fill-opacity": 0.5,
          },
          filter: ["==", "$type", "Polygon"], // 小学校区ポリゴンデータのみ表示
        });

        // 小学校区ポリゴンの枠線を設定します
        map.addLayer({
          id: `outline`,
          type: "line",
          source: `gakku_geojson`,
          layout: {},
          paint: {
            "line-color": "#1c1c1c",
            "line-width": 2,
          },
          filter: ["==", "$type", "Polygon"], // 小学校区ポリゴンデータのみ表示
        });

        // 小学校の位置を取得    TerraMap APIデータ特有のデータ構造に対応
        const schoolPosition = {
          lat: data.features[0].properties.point_coordinates[1],
          lng: data.features[0].properties.point_coordinates[0],
        };
        // 小学校のマーカーを追加
        schoolMarker = new maplibregl.Marker()
          .setLngLat([schoolPosition.lng, schoolPosition.lat])
          .setPopup(
            new maplibregl.Popup({
              offset: -35,
              closeButton: false,
              closeOnClick: false,
            }).setHTML(
              "<p class='popup-text'>" +
                data.features[0].properties.points[0][0] + // 学校名:TerraMap APIデータ特有のデータ構造に対応
                "</p>"
            )
          )
          .addTo(map);
        // マーカーのポップアップを表示
        schoolMarker.togglePopup();

        // 中略 ////////////////////////////////////
        
      })
      .catch((error) => {
        console.log(error);
      });
  });

  // 中略 ////////////////////////////////////

});

物件データの表示

サーバーからのレスポンスに含まれる物件データを、地図内とテーブルに表示する部分を抜粋します。いずれもクリックイベント内に記述しています。

物件の円レイヤーを追加

複数も考えられる物件データは、マーカーではなく円のレイヤーとして追加しました。

javascript
//// クリックイベント内の一部分 ////

        // 物件ポイントデータを表示
        map.addLayer({
          id: "points",
          type: "circle",
          source: "gakku_geojson",
          paint: {
            "circle-radius": 6,
            "circle-color": "#22ff98ff",
            "circle-stroke-width": 2,
            "circle-stroke-color": "#FFFFFF",
          },
          filter: ["==", "$type", "Point"], // 物件ポイントデータのみ表示
        });

物件データのテーブル表示

Grid.js の Grid を活用して、比較的短いコードで済ませています。

javascript
let bukkenGrid = null;

// 中略 ////////////////////////////////////

//// クリックイベント内の一部分 ////

        // 物件テーブル表示
        const bukkenFeatures = data.features
          ? data.features.filter((f) => f.geometry.type === "Point")
          : [];
        if (bukkenFeatures.length > 0) {
          document.getElementById("data-table").innerHTML = "";
          const tableData = bukkenFeatures.map((point) => [
            point.properties.name,
            point.properties.address,
          ]);

          if (!bukkenGrid) {
            bukkenGrid = new gridjs.Grid({
              columns: [
                { id: "name", name: "名称" },
                { id: "address", name: "住所" },
              ],
              data: tableData,
            }).render(document.getElementById("data-table"));
          } else {
            bukkenGrid
              .updateConfig({
                data: tableData,
              })
              .forceRender();
          }

参考情報

  1. 緯度・経度から始まるヘッダー付きのCSVファイルです。この記事では属性を名称と住所に絞っています。

0
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
0
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?