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

物件データの学区と学校までの徒歩ルートを地図表示してみる

1
Posted at

はじめに

今回は、物件データに対する小学校区・中学校区ポリゴンと各学校までの徒歩ルートを表示する地図アプリを作成してみました。

徒歩ルート検索については、OSRMのサーバーを構築してみました。小学校区・中学校区データの取得については、TarraMap API を仲介する学区サーバーを構築してみました。

地図アプリ 完成イメージ

地図アプリの完成イメージは、以下の動画のようになります。

表示している物件データをクリックすることで、学区サーバーへのリクエストを開始します。レスポンスされた学区ポリゴンと学校位置を地図上に表示し、つづいて物件位置と学校位置とでルート検索を行っています。このプロセスを小学校区と中学校区で2回繰り返しています。

※ 物件データに関しては、架空のものになります。

school_routing.gif

使用技術一覧

カテゴリ 採用技術 / データソース
地図ライブラリ MapLibre GL JS
背景地図 OpenStreetMap(ベクトルタイル)
物件データ GeoJSON形式
ルート検索
サーバー
Dockerイメージ osrm/osrm-backend の利用
(OSRMの徒歩プロファイルを使用)
学区サーバー TarraMap API の「小学校区・中学校区データ」を
仲介する Node.js サーバー

学校区データの詳細に関しましては、初めて扱った時の記事をご参照下さい。
小学校区・中学校区データについて

使用した技術的要素は上記になりますが、2つのサーバー構築とフロントエンドの実装に関して説明していきます。

ルート検索サーバー(OSRM)

ルート検索には、OSRM(Open Source Routing Machine)を利用しました。OSRMにはデモ用のAPIサーバーがあるようですが、ドライブルート以外が要求されるときはDockerでAPIサーバーを構築する方が良さそうです。

サーバー構築および起動

OSRMのバックエンドに特化したDockerイメージ、osrm/osrm-backend を活用し、以下のような手順で徒歩ルート検索サーバー構築を行いました。

紹介する各コマンドは、Windowsのコマンドプロンプトでの実行例になります。

1)PBFファイルのダウンロード
Geofabrik Download Server から必要な地域(今回は日本の関東地域)の最新版OpenStreetMapデータ(PBFファイル)をダウンロードします。curlコマンドを使ったダウンロード例は以下になりますが、サイトから地域と形式を選んでダウンロードすることもできます。

curl -L -O https://download.geofabrik.de/asia/japan/kanto-latest.osm.pbf

2)PBFファイルの加工処理
OSRM向けにPBFファイルを加工します。
Dockerが使える状態にして、PBFファイルの保存先で以下の処理を行います。

最終的には30個近くのファイルになり、5GB近くの容量も必要になるのでご注意ください。

docker run -t -v "%cd%:/data" osrm/osrm-backend osrm-extract -p /opt/foot.lua /data/kanto-latest.osm.pbf
docker run -t -v "%cd%:/data" osrm/osrm-backend osrm-partition /data/kanto-latest.osrm
docker run -t -v "%cd%:/data" osrm/osrm-backend osrm-customize /data/kanto-latest.osrm
コマンド 概要
osrm-extract 指定したプロファイル(今回の場合は /opt/foot.lua=徒歩)に合わせて、道路のネットワークデータを抽出・作成する
osrm-partition 経路検索を高速化するために、エリアを分割する
osrm-customize 移動時間や距離の重み付けを適用する

3)サーバー起動

docker run -t -i -p 5000:5000 -v "%cd%:/data" osrm/osrm-backend osrm-routed --algorithm mld /data/kanto-latest.osrm

リクエスト・レスポンス例

リクエストURLには、2点の経度と緯度を入力させます。

http://127.0.0.1:5000/route/v1/foot/139.685910,35.677971;139.683351,35.674421?overview=full&geometries=geojson

下記のレスポンス例は一部抜粋になりますが、ルートライン座標[ geometry ]、
距離[ distance ] (m)、所要時間[ duration ] (sec) を取得することができます。

{
  "code": "Ok",
  "routes": [
    {
      "geometry": {
        "coordinates": [
          [
            139.686047,
            35.677987
          ],
          [
            139.686069,
            35.677866
          ],

          // .... 座標を省略しています
          
          [
            139.68314,
            35.674508
          ]
        ],
        "type": "LineString"
      },
      "legs": [
        {
          "steps": [],
          "distance": 614.8,
          "duration": 444.4,
          "summary": "",
          "weight": 444.4
        }
      ],
      "distance": 614.8,
      "duration": 444.4,
      "weight_name": "duration",
      "weight": 444.4
    }
  ],
  "waypoints": [
  
    // .... ウェイポイント情報を省略しています

  ]
}

参考情報

学区サーバー(Node.js)

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

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

server.js
const axios = require("axios");
const cors = require("cors");
const express = require("express");

const app = express();

// CORS設定  全てのオリジンを許可(開発用)
app.use(cors());

// JSONのパーサーを使用
app.use(express.json());

// TerraMap APIの学校区ポリゴン取得エンドポイント
app.post("/get-gakku-polygon", async (req, res) => {
  // リクエストボディから緯度,経度,学校区分を取得
  const { lat, lng, school_type } = req.body;

  if (!lat || !lng) {
    return res.status(400).json({
      error: "lat and lng are required",
    });
  }

  // school_typeをlayer_id にマッピングする
  const schoolParam = school_type
    ? school_type.toString().toLowerCase()
    : "elementary";
  const schoolMap = {
    elementary: "10101", // 小学校区のレイヤーID
    middle: "10102", // 中学校区のレイヤーID
  };

  if (!schoolMap[schoolParam]) {
    return res.status(400).json({
      error:
        "Invalid school_type parameter. Allowed values: 'elementary', 'middle'.",
    });
  }

  const layerId = schoolMap[schoolParam];

  try {
    const response = await axios.post(
      "https://tmapi.mapmarketing.jp/api/area",
      {
        layer_id: layerId, // 学校区の指定
        area_type: "coordinate", // 座標指定を選択
        coordinates: [[lng, lat]],
        output: "polygon,point", // ポリゴンとポイント情報(学校名)を取得
      },
      {
        headers: {
          "Content-Type": "application/json",
          "X-API-KEY": "YOUR_TERRAMAP_API_KEY",
        },
      }
    );

    res.json(response.data);
  } catch (error) {
    console.error("get-gakku-polygons error", error.message);

    res.status(500).json({
      error: "Failed to get gakku polygons",
    });
  }
});

// サーバー起動
app.listen(3000, () => {
  console.log("学区サーバーがポート3000で起動中です");
});

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

学区サーバーの起動

学区サーバーを起動するには以下の処理が必要になります。

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

npm install axios cors express

サーバー起動

node server.js

フロントエンドの実装

ファイル構成
root/
 ├── index.html
 ├── map.js
 ├── style.css
 └── bukken.geojson

JavaScript

実装のメインであるJavaScriptコードは以下のようになりました。コード内のコメントで補足しております。

drawGakkuPolygon() で小学校と中学校を区別している点や、7つのレイヤーを適切な順番に表示している点が少し複雑になっていますが、他は順番に処理しているプログラムかと思います。

map.js
let schoolMarkers = []; // 学校のマーカーを管理
let bukkenPopup = null; // 物件のポップアップを管理

// 地図の初期化
const map = new maplibregl.Map({
  container: "map",
  style: "https://tile.openstreetmap.jp/styles/osm-bright/style.json",
  center: [139.686144361, 35.6810950557],
  zoom: 13,
});
map.addControl(new maplibregl.NavigationControl());

map.on("load", async () => {
  // 物件ポイントの描画
  await drawBukkenPoints();
});

// 物件ポイントの描画処理
async function drawBukkenPoints() {
  const response = await fetch("./bukken.geojson");
  const data = await response.json();

  // 物件位置をマーカーで表示
  map.addSource("bukken", {
    type: "geojson",
    data: data,
  });
  map.addLayer({
    id: "bukken-layer",
    type: "circle",
    source: "bukken",
    paint: {
      "circle-radius": 5,
      "circle-color": "#2e8b57",
      "circle-stroke-width": 2,
      "circle-stroke-color": "#fff",
    },
  });
}

// 学校データの初期化処理
async function initializeLayerAndSource() {
  const layers = [
    "elementary_school_polygon",
    "elementary_school_outline",
    "elementary_school_route_line",
    "middle_school_polygon",
    "middle_school_outline",
    "middle_school_route_line",
  ];
  const sources = [
    "elementary_school_geojson",
    "elementary_school_route",
    "middle_school_geojson",
    "middle_school_route",
  ];

  for (let layer of layers) {
    if (map.getLayer(layer)) {
      map.removeLayer(layer);
    }
  }
  for (let source of sources) {
    if (map.getSource(source)) {
      map.removeSource(source);
    }
  }

  for (let i = schoolMarkers.length - 1; i >= 0; i--) {
    schoolMarkers[i].remove();
  }
  schoolMarkers = [];
}

/**
 * 学区ポリゴン、徒歩ルートの描画処理
 * @param {number} lat - 物件の緯度
 * @param {number} lng - 物件の経度
 * @param {'middle'|'elementary'} schoolType - 学校区分('middle': 中学校, 'elementary': 小学校)
 * @param {string} [beforeLayerId] - ポリゴンレイヤー追加の基準となる既存レイヤーID。ポリゴンレイヤーはこのレイヤーの下に描画されます。
 * @returns {Promise<void>} 描画処理が完了した際に解消されるPromise
 */
async function drawGakkuPolygon(lat, lng, schoolType, beforeLayerId) {
  return new Promise((resolve, reject) => {
    // クリックした位置の学区データを取得
    fetch(`http://127.0.0.1:3000/get-gakku-polygon`, {
      // サーバーURLは適宜変更してください
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ lat: lat, lng: lng, school_type: schoolType }),
    })
      .then((response) => response.json())
      .then((data) => {
        // GeoJSONのソースを追加
        map.addSource(`${schoolType}_school_geojson`, {
          type: "geojson",
          data: data,
        });

        // schoolTypeでスタイル(色・オフセット)を変更するためのフラグ
        const isElementary = schoolType === "elementary";

        lineStyle = {
          paint: {
            "line-color": isElementary ? "#f93333ff" : "#006affff",
            "line-width": 3,
            "line-opacity": 0.6,
            "line-dasharray": [5, 1],
            // 小学校区は境界線から2px内側にずらして描画
            "line-offset": isElementary ? 2 : 0,
          },
        };

        // 学校区ポリゴンの枠線を設定します
        map.addLayer(
          {
            id: `${schoolType}_school_outline`,
            type: "line",
            source: `${schoolType}_school_geojson`,
            ...lineStyle,
          },
          beforeLayerId // 引数で指定したレイヤーの下に配置
        );

        // 学校区ポリゴンの背景色を設定します
        map.addLayer(
          {
            id: `${schoolType}_school_polygon`,
            type: "fill",
            source: `${schoolType}_school_geojson`,
            layout: {},
            paint: {
              "fill-color":
                isElementary ? "#f93333ff" : "#006affff",
              "fill-opacity": 0.3,
            },
          },
          `${schoolType}_school_outline` // ポリゴンの枠線の下に配置
        );

        // 学校の位置を取得    TerraMap APIデータ特有のデータ構造に対応
        const schoolPosition = {
          lat: data.features[0].properties.point_coordinates[1],
          lng: data.features[0].properties.point_coordinates[0],
        };

        // 徒歩ルート情報の取得
        const url = `http://127.0.0.1:5000/route/v1/foot/${lng},${lat};${schoolPosition.lng},${schoolPosition.lat}?overview=full&geometries=geojson`;

        fetch(url)
          .then((response) => response.json())
          .then((json) => {
            const route = json.routes[0].geometry;
            const dist = json.routes[0].distance;
            const time = Math.round(json.routes[0].duration / 60);

            map.addSource(`${schoolType}_school_route`, {
              type: "geojson",
              data: {
                type: "Feature",
                geometry: route,
              },
            });

            // 徒歩ルートの描画
            map.addLayer(
              {
                id: `${schoolType}_school_route_line`,
                type: "line",
                source: `${schoolType}_school_route`,
                layout: {
                  "line-join": "round",
                  "line-cap": "round",
                },
                paint: {
                  "line-color": isElementary ? "#f93333ff" : "#006affff",
                  "line-width": 5,
                  // 小学校区はオフセットを変更
                  "line-offset": isElementary ? 2 : 0,
                },
              },
              "bukken-layer" // 最上位の物件レイヤーの下に配置
            );

            // 学校のマーカーを追加(dist, timeが取得できた後に実行)
            const marker = new maplibregl.Marker({
              color: isElementary ? "#f93333ff" : "#006affff",
              // 小・中学校の同一位置に備え、オフセットを設定
              offset: isElementary ? [7, 3] : [0, 0],
            })
              .setLngLat([schoolPosition.lng, schoolPosition.lat])
              .setPopup(
                new maplibregl.Popup({
                  // 小・中学校の同一位置に備え、オフセットを設定
                  // 文字の重なりに備え、縦方向には大きくずらす
                  offset: isElementary ? [7, -10] : [0, -20],
                  closeButton: false,
                  closeOnClick: false,
                }).setHTML(
                  "<p class='popup-text'>" +
                    data.features[0].properties.points[0][0] + // 学校名:TerraMap APIデータ特有のデータ構造に対応
                    "<br>" +
                    dist +
                    "m, 徒歩" +
                    time +
                    "分</p>"
                )
              )
              .addTo(map);
            // マーカーのポップアップを表示
            marker.togglePopup();
            schoolMarkers.push(marker);

            // Promiseを解決
            resolve();
          })
          .catch((error) => {
            console.error("ルート取得エラー:", error);
            reject(error);
          });
      })
      .catch((error) => {
        console.error("学区データ取得エラー:", error);
        reject(error);
      });
  });
}

// 物件に対するクリックイベント処理
map.on("click", "bukken-layer", async (e) => {
  // 既存のポップアップを閉じる
  if (bukkenPopup) {
    bukkenPopup.remove();
    bukkenPopup = null;
  }

  // 物件名のポップアップ表示
  const coordinates = e.features[0].geometry.coordinates.slice();
  const properties = e.features[0].properties;
  const description = `<p class='popup-text'>${properties.name}</p>`;

  bukkenPopup = new maplibregl.Popup({
    offset: -35,
    closeButton: false,
    closeOnClick: false,
  })
    .setLngLat(coordinates)
    .setHTML(description)
    .addTo(map);

  // 物件の緯度、経度
  const lat = coordinates[1];
  const lng = coordinates[0];
  await initializeLayerAndSource();

  // レイヤー順を調整して学校データを表示
  // 物件 -> 小学校ルート -> 中学校ルート
  // -> 小学校区ライン -> 小学校区ポリゴン -> 中学校区ライン -> 中学校区ポリゴン
  // ※ 比較的広い中学校区の方を下位のレイヤーとして、先に処理していく
  await drawGakkuPolygon(lat, lng, "middle", "bukken-layer");
  await drawGakkuPolygon(lat, lng, "elementary", "middle_school_route_line");
});

その他

その他のソースファイルは以下のようになりました。

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>物件における学校区と徒歩ルートを表示</title>
    <link
      href="https://unpkg.com/maplibre-gl@3.6.1/dist/maplibre-gl.css"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div id="map"></div>
    <script src="https://unpkg.com/maplibre-gl@3.6.1/dist/maplibre-gl.js"></script>
    <script src="map.js"></script>
  </body>
</html>
style.css
#map {
  height: 95vh;
  width: 95vw;
}

.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;
}
bukken.geojson (一部抜粋)
{
  "type": "FeatureCollection",
  "name": "bukken",
  "crs": {
    "type": "name",
    "properties": {
      "name": "urn:ogc:def:crs:OGC:1.3:CRS84"
    }
  },
  "features": [
    {
      "type": "Feature",
      "properties": {
        "name": "西新宿ガーデン",
        "address": "東京都新宿区西新宿"
      },
      "geometry": {
        "type": "Point",
        "coordinates": [
          139.69165,
          35.69077
        ]
      }
    },
    {
      "type": "Feature",
      "properties": {
        "name": "ハイツ初台",
        "address": "東京都渋谷区初台"
      },
      "geometry": {
        "type": "Point",
        "coordinates": [
          139.68591,
          35.67797
        ]
      }
    },

    // .... 物件データは省略しています
    
  ]
}
1
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
1
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?