🚂

【PHP】入力された住所から最寄り駅までの駅徒歩を計算する

に公開

課題

「任意の住所」と「周辺の駅」の各距離や各駅徒歩をかんたんに弾き出すのがむずかしく、PHPアプリケーションに組み込めるようにしたい。

デモ

デモ準備中、全処理をまとめたコード・リポジトリを整理でき次第公開します。お手数ですが、早くほしいひとはソーシャルメディアとかで突いてください。

環境

ウェブサーバー:AlmaLinux9 PHP8.2 BCMath拡張(小数点のために使います)
ローカル:macOS Sequoia Homebrew

経緯度は小数点の計算が細かいので、BCMath拡張はたぶん重要です。PHP8.4からは速いんですけど、うちの8.2環境だと遅くてちょっとよくないです。

https://wiki.php.net/rfc/add_bcdivmod_to_bcmath

経緯度

経緯度の国際表示規格(ISO_6709)で、ほんとうの順番は「緯度 latitude(Y軸)」→「経度 longitude(X軸)」ですが、日本語の「経緯度」はママイキで説明しつつ、ソースコード上ではISOにのっとってlat→lngにします。

数値列(タプル)では緯度を経度の先に用いる
Latitude comes before longitude

https://ja.wikipedia.org/wiki/ISO_6709
https://en.wikipedia.org/wiki/ISO_6709

バッドノウハウかもしれませんが、経緯度の数字をぱっとみでどっちがわからなくなったとき、1桁長いほうが「long」なので「longitude」とおぼえました。

コンセプト

入力住所から経緯度を取得し、事前に準備した全駅の経緯度と2地点距離計測をしまくって、小さい順にソートして第一位をとり、法的に駅徒歩換算する

※入力住所は正規化ツールで正規化可能な程度では正確なものとする
※法令内では道路距離だが今回は楕円の最短距離で算出している
※駅の始点は1個目の頂点に固定している(幾何重心をとっていない)
※ほんとうに毎回全駅と比較すると計算量が膨大になるので同県の駅に絞っている

概要

手続き的には、以下の流れになる。

・「Geolonia 住所データ(v2)」をビルドして住所の正規化用の辞書を準備
https://github.com/geolonia/japanese-addresses-v2/

・国土交通省のデータダウンロードサイトから鉄道データを取得
https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N02-2024.html

・鉄道データの経緯度から逆ジオコーディング(後述)で都道府県を取得

・てきとうなインターフェイスから入力された住所をGeolonia住所データ辞書で正規化し、住所の経緯度を取得
https://github.com/geolonia/normalize-japanese-addresses

・GIS系のライブラリをもちいて2地点の経緯度の差分から距離(メートル単位)を算出
https://github.com/mjaschen/phpgeo

・不動産の公正競争規約の表示規約の計算式で徒歩時間(分単位)算出

https://www.caa.go.jp/policies/policy/representation/fair_labeling/fair_competition_code

ひとつずつ解説していきます。

「Geolonia 住所データ(v2)」をビルドして住所の正規化用の辞書を準備

リポジトリのREADMEのとおりにビルドしていきます。めちゃくちゃ時間かかります。私はPHPプロジェクトのなかに入れているのでサブモジュールで入れて、シェルスクリプトで使いやすいところに複製しています。

git clone git@github.com:geolonia/japanese-addresses-v2.git
cd japanese-addresses-v2
npm install
npm run run:all

https://github.com/geolonia/japanese-addresses-v2/

国土交通省のデータダウンロードサイトから鉄道データを取得

駅のオープンデータというと「駅データ.jp」が有名どころかもしれませんが、住所に不備(というか中途半端?)がけっこう目立って経緯度との相性がわるかったので利用せず、かわりに国土交通省のデータにします。

「国土数値情報ダウンロードサイト(国土交通省)」は住所がそもそも入っていませんが、駅の範囲の経緯度が詳細にもらえるので、逆ジオコーディングで住所を検索できるので問題ないです。

https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N02-2024.html

2024年度のデータは、駅情報が「N02-24_Station.geojson」、路線情報が「N02-24_RailroadSection.geojson」に格納されているので、これをPHP側でjson_decode()メソッドで配列化して利用します。

もらえる経緯度は駅の補間点の経緯度(LineStringの各頂点)です。いまのコードは始点(1個目の頂点)をすぐに使いまわしていますが、必要なら幾何学的重心(centroid)を計算してもいいと思います。今回のGISライブラリでは幾何重心が計算できないので、単に全点を囲むバウンディングボックスの中心を計算するだけで事足りるでしょう。経緯度からポリゴンを定義したあとにgetBounds()メソッドですぐに計算できます。

https://ja.wikipedia.org/wiki/幾何中心

データ構造とかは上記のURLの仕様をご確認ください。

$json = @file_get_content("./N02-24_Station.geojson"); // ダウンロード先の好きなパスにしてください
$data = json_decode($json);
$features = $data["features"];

$properties = $feature["properties"];
$type_id = $properties["N02_001"] ?? null;
$business_id = $properties["N02_002"] ?? null;
$railroad_name = $properties["N02_003"] ?? null;
$company_name = $properties["N02_004"] ?? null;
$station_name = $properties["N02_005"] ?? null;
$station_code = $properties["N02_006"] ?? null;
$station_code_sorted_from_north = $properties["N02_007"] ?? null; // 北から順番に並べた駅コード
$group_code = $properties["N02_008"] ?? null; // 300m圏内の駅コード

$station_fullname = $railroad_name . " " . $station_name;

$geometry = $feature["geometry"];
$coordinates = $geometry["coordinates"];
$station_lat = $coordinates[0][1] ?? null; // 緯度の始点
$station_lon = $coordinates[0][0] ?? null; // 経度の始点

路線側のデータとは、$railroad_name(構造名でいうところの"N02_003")で結合できるので、必要な場合は適宜やってください。

最低限、駅名と経緯度をDBとかに保存すれば次に進めます。

鉄道データの経緯度から逆ジオコーディングで都道府県を取得

経緯度から住所をとるために、いわゆる「逆ジオコーディング」というものをします。といっても、今回は都道府県がわかればいいので、国土地理院にAPIで済ませます。

エンドポイントはhttps://mreversegeocoder.gsi.go.jp/reverse-geocoder/LonLatToAddress?lat={$lat}&lon={$lon}なので、先程の鉄道データの経緯度をあてはめて地名データを返してもらいます。

https://vldb.gsi.go.jp/sokuchi/surveycalc/api_help.html
https://github.com/gsi-cyberjapan/gsimaps

/**
 * 国土地理院のAPIを利用して緯度経度から住所を取得(逆ジオコーディング)
 * @param float $lat
 * @param float $lon
 * @return ?array
 */
function reverseGeocoding(float $lat, float $lon): ?array
{
    sleep(2); // 10秒に10回の制限
    $url = "https://mreversegeocoder.gsi.go.jp/reverse-geocoder/LonLatToAddress?lat={$lat}&lon={$lon}";
    $response_json = @file_get_content($url);

    if ($response_json === false) return null;
    $data = json_decode($response_json);

    if ($data && isset($data["results"])) return $data["results"];
    return null;
}

返ってきたペイロードは、lv01Nmというキー名のほうに地名が入っており、muniCdというキー名のほうに旧地理院の住所マスターと対応しているCodeが格納されています。対応表は以下のJSから取得します。すこし使いづらいので、私はローカルでシェルスクリプトをはしらせてJSON形式に変換しました。

https://maps.gsi.go.jp/js/muni.js

ここはめちゃくちゃmacOSでの処理で申し訳ないです。muni.jsがShift_JISということもあって文字コードの変換が大変なので、nkfをつかいます。brew install nkfをしてから使います。Windows OSなどのかたは適宜うまくやってください。

#!/bin/bash

#
# このスクリプトは、国土地理院の市区町村マスターデータ(muni.js)を取得し、
# JSON形式に変換して、指定されたディレクトリにファイルとして保存します。
# brew install nkf
#

# エラーが発生した場合にスクリプトを終了します。
set -euo pipefail

# --- nkfコマンドの存在チェック ---
if ! command -v nkf &> /dev/null; then
    echo "エラー: このスクリプトの実行には 'nkf' コマンドが必要です。" >&2
    echo "Homebrewを使い、以下のコマンドでインストールしてください:" >&2
    echo "brew install nkf" >&2
    exit 1
fi

# --- 設定 ---
readonly URL="https://maps.gsi.go.jp/js/muni.js"
readonly OUTPUT_FILE_NAME="data.json"
# --- ここまで ---

# --- 引数処理 ---
readonly OUTPUT_DIR="お好きな出力ディレクトリをここに入力"

# --- メイン処理 ---
mkdir -p "${OUTPUT_DIR}"
readonly OUTPUT_FILE_PATH="${OUTPUT_DIR}/${OUTPUT_FILE_NAME}"

echo "市区町村データの取得と変換を開始します (nkfを使用)..."
echo "出力先: ${OUTPUT_FILE_PATH}"

# curlでデータを取得し、nkfで文字コードをUTF-8に変換、
# awkでJavaScriptのコードをJSON形式に整形します。
# nkf -w は入力文字コードを自動判別してUTF-8に変換する非常に強力なオプションです。
curl -fsSL "${URL}" \
| nkf -w \
| awk -F "[\"']" '
  BEGIN {
    print "{"
    is_first_line = 1
  }
  /GSI.MUNI_ARRAY/ {
    if (is_first_line == 0) {
      print ","
    }
    is_first_line = 0

    split($4, val, ",")
    printf "  \"%s\": {\n", $2
    printf "    \"prefCode\": \"%s\",\n", val[1]
    printf "    \"prefName\": \"%s\",\n", val[2]
    printf "    \"cityCode\": \"%s\",\n", val[3]
    printf "    \"cityName\": \"%s\"\n", val[4]
    printf "  }"
  }
  END {
    print "\n}"
  }
' > "${OUTPUT_FILE_PATH}"

echo "✅ 処理が完了しました。'${OUTPUT_FILE_PATH}' を作成しました。"

変換後のデータは、muniCdコードの数字がキー名になって、都道府県名はprefNameに入れてあるので、それを取得します。

/**
 * 国土地理院の市区町村マスターデータ(https://maps.gsi.go.jp/js/muni.js)から都道府県名を取得
 * @param string $muniCd
 * @return ?string
 */
function getPrefByMuniCd(string $muniCd): ?string
{
    $json = @file_get_content("./data.json"); // 文字コード変換&JSON化したあとのマスターデータ
    if (empty($json)) return null;

    $data = json_decode($json);
    if (empty($data)) return null;

    return $data[$muniCd]["prefName"] ?? null;
}

文字列でもいいですが、私は「ISO 3166-2:JP規格」の都道府県コードと変換して取り回しています。

https://ja.wikipedia.org/wiki/ISO_3166-2:JP

てきとうなインターフェイスから入力された住所をGeolonia住所データ辞書で正規化し、住所の経緯度を取得

Geoloniaが日本の住所の正規化をめちゃくちゃがんばってくれていて、もとがTypeScriptのコードなので、全メソッドをPHPに変換する作業をします。ここに掲載すると煩雑なのでデモリポジトリに入れておきます。

正規化に利用する辞書は冒頭でビルドした「Gelonia 住所データ(v2)」です。使いやすいところに蔵置しておいてください。

https://github.com/geolonia/normalize-japanese-addresses

よっぽどエグい住所でなければ正規化に成功するので、$normalizedAddressData["point"]["lat"]...["lng"]に経緯度が格納されて返却されるので、これを計算に出します。

GIS系のライブラリをもちいて2地点の経緯度の差分から距離(メートル単位)を算出

「mjaschen/phpgeo」は、GIS(Geographic Information System)系といっても計算サポート用のライブラリです。

https://github.com/mjaschen/phpgeo

Composerで取り込ませてもらいます。最適なバージョンはREADMEをよくご覧になってください。

composer require mjaschen/phpgeo

使いかたも提示されているためここでは割愛しますが(デモリポジトリには入れていますが)、たとえばこんな感じで計算します。

/**
 * ヴィンセンティ法による高精度な距離計算(メートル)。
 *
 * @param float $lat1 開始地点の緯度
 * @param float $lng1 開始地点の経度
 * @param float $lat2 終了地点の緯度
 * @param float $lng2 終了地点の経度
 * @return float 距離(m)
 */
function vincenty(float $lat1, float $lng1, float $lat2, float $lng2): float
{
    $coordinate1 = new Coordinate($lat1, $lng1);
    $coordinate2 = new Coordinate($lat2, $lng2);

    return $this->vincenty->getDistance($coordinate1, $coordinate2);
}

計算リソース的にヴィンセンティ法じゃないほうがいいのかもしれませんが、厳密さも捨てがたかったため採用しました。もっとシンプルな計算法でもいいと思います。

ここまでですでに獲得している「A: 入力された住所の経緯度」「B: 駅の経緯度」を引数にとることで距離を計算してもらえて、メートルで返されます。

不動産の公正競争規約の表示規約の計算式で徒歩時間(分単位)算出

消費者庁が景品表示法の関連で出している「不動産の公正競争規約」のなかに、駅徒歩の簡易的な計算方法があるので、ソースコードに落とし込みます。

https://www.caa.go.jp/policies/policy/representation/fair_labeling/fair_competition_code

表示規約施行規則
第5章 表示基準
    第1節 物件の内容・取引条件等に係る表示基準
        第9条
            (1)...
            ...
            (9) 徒歩による所要時間は、道路距離80メートルにつき1分間を要するものとして算出した数値を表示すること。この場合において、1分未満の端数が生じたときは、1分として算出すること。

ヴィンセンティ法は楕円の最短距離なので道路距離ではないです、が、ceil()とかで丸めておけば景品表示法で消費者庁に怒られることはないと思います。要件によってはもっと厳密にやってもいいかもしれません。

https://www.php.net/manual/ja/function.ceil.php

/**
 * 徒歩時間を計算(分)。
 *
 * @param float $distanceInMeters 距離(m)
 * @param int   $speedMetersPerMinute 分速(m)。デフォルトは一般的な歩行速度の80m/分。
 * @return int 徒歩時間(分)
 */
public function calculateWalkingTime(float $distanceInMeters, int $speedMetersPerMinute = 80): int
{
    if ($distanceInMeters < 0 || $speedMetersPerMinute <= 0) {
        return 0;
    }
    return (int) ceil($distanceInMeters / $speedMetersPerMinute);
}

まとめ

ありそうでなかったのでつくりましたが、車輪の再発明じゃないことを祈ります。とくにPHPだとぜんぜんそういう処理群にたどり着けませんでした。

Discussion