Leaflet.jsで路線図を描く

地図描画のためのJavaScriptライブラリ[Leaflet](https://leafletjs.com/)を使って鉄道路線図を描くということをやってみた。道路のナビ情報や徒歩のGPSデータを地図にするアプリは色々あるが、鉄道路線を、駅間を直線で結ぶだけじゃなくて線路に沿ってちゃんと線を引くアプリが意外に無さそうだったので、作ってみようという野望への一歩。

とりあえず京急逗子線だけを対象にしているが、下記のように発着駅を指定するとその間の路線図を描くWebアプリが出来た：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2468443/c51b0712-797e-4c5d-8479-b2a49a280de3.png)

路線位置情報はグラフDBに格納しておいて、描画時にそのDBからデータを呼び出している。グラフDBへの格納の部分は別記事にまとめてあるので参照してください：
[鉄道駅LOD GeoJSONデータをNeo4jに入れる](https://qiita.com/sangoro/items/e485e53ef3ee8b1b7521)

# 環境
- Python 3.13.2
- Webアプリ実装：Flask
- 地図生成：Leaflet
- グラフDBクライアント：Neo4j Python driver

# 準備

Neo4j Auraを起動し、[鉄道駅LOD GeoJSONデータをNeo4jに入れる](https://qiita.com/sangoro/items/e485e53ef3ee8b1b7521)のスクリプト（register.py）で路線位置情報を登録しておく。

# コード

Webクライアント：
```main.py
from flask import Flask, render_template, request, send_from_directory
import os
import graphdb

app=Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def root():
    # 駅名リストを取得
    # station_nameが空でないPointノードを取得
    query = f'MATCH (p:Point) WHERE p.station_name IS NOT NULL AND p.station_name <> "" RETURN p.station_name;'
    with graphdb.Driver.get_session() as session:
        result = session.run(query)
        stations = [record['p.station_name'] for record in result]

    if request.method == 'POST':
        # フォームから発着駅名を取得
        from_station = request.form.get('from_station')
        to_station = request.form.get('to_station')
    else:
        # GETリクエストの場合は、デフォルトの駅名を設定
        from_station = stations[0]
        to_station = stations[-1]

    query = f'MATCH l=(p:Point {{station_name:"{from_station}"}})-[r:Connected*]-(q:Point {{station_name:"{to_station}"}}) RETURN l;'

    features = []
    multiple_lines = []
    lines = []
    bbox = None

    with graphdb.Driver.get_session() as session:
        result = session.run(query)
        for record in result:
            path = record['l']
            # pathから各ノードの情報を取得
            for node in path.nodes:
                if 'station_name' in node:
                    if len(lines) > 0:
                        multiple_lines.append(lines)
                        lines = []
                    features.append({
                        "properties": {
                            "name": node['station_name']
                        },
                        "type": "Feature",
                        "geometry": {
                            "type": "Point",
                            "coordinates": [node['longitude'], node['latitude']]
                        },
                    })
                else:
                    lines.append([node['longitude'], node['latitude']])
                
                # bboxの計算
                if bbox is None:
                    bbox = [node['longitude'], node['latitude'], node['longitude'], node['latitude']]
                else:
                    bbox[0] = min(bbox[0], node['longitude'])
                    bbox[1] = min(bbox[1], node['latitude'])
                    bbox[2] = max(bbox[2], node['longitude'])
                    bbox[3] = max(bbox[3], node['latitude'])
    features.append({
        "type": "Feature",
        "geometry": {
            "type": "MultiLineString",
            "coordinates": multiple_lines
        },
    })
    geojson = {
        "type": "FeatureCollection",
        "features": features
    }

    return render_template(
        'index.html', 
        stations=stations, 
        from_station=from_station, 
        to_station=to_station, 
        geojson=geojson,
        min_lng=bbox[0],
        min_lat=bbox[1],
        max_lng=bbox[2],
        max_lat=bbox[3],)

@app.route('/favicon.ico')
def favicon():
    return send_from_directory(os.path.join(app.root_path, 'static/img'), 'favicon.ico', )

if __name__ == '__main__': 
    app.run(host="localhost", port=8080, debug=True)
```

画面定義：
```index.html
<!doctype html>
<html>
<head>
<title>tetsulogger</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{ url_for('static', filename='css/tetsulogger.css') }}" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
crossorigin=""></script>
</head>
<body>
    <!-- 駅名のプルダウンを生成 -->
    <form method="POST" action="/">
        発：
        <select name="from_station">
            {% for station in stations %}
                {% if station == from_station %}
                    <option value="{{ station }}" selected>{{ station }}</option>
                {% else %}
                    <option value="{{ station }}">{{ station }}</option>
                {% endif %}
            {% endfor %}
        </select>
        着：
        <select name="to_station">
            {% for station in stations %}
                {% if station == to_station %}
                    <option value="{{ station }}" selected>{{ station }}</option>
                {% else %}
                    <option value="{{ station }}">{{ station }}</option>
                {% endif %}
            {% endfor %}
        </select>
        <input type="submit" value="Submit">
    </form>

    <!-- 地図を表示するためのdiv要素 -->
    <div id="map"></div>
    <script>
    const map = L.map('map', {});
    
    var bounds = L.latLngBounds([[{{min_lat}}, {{min_lng}}], [{{max_lat}}, {{max_lng}}]]);
    map.fitBounds(bounds);

    var geojson = {{geojson|tojson}};
    L.geoJSON(geojson, {}).addTo(map);

    const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
        maxZoom: 18,
        attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
    }).addTo(map);
    </script>
</body>
</html>
```
```tetsulogger.css
body {
    padding: 0;
    margin: 0;
    }
    html, body, #map {
        width: 500px;
        height: 500px;
    }
```

# おわりに
呑み鉄メインみたいなゆるーい乗り鉄をやっていこうと思っていて、そのための記録アプリを探していたのだが、どうもしっくりくるものがなかった。なので自分で作ってしまおうかと思っている。この記事の内容は、その実現に向けた最初の一歩という感じ。共感いただける方がいたら、連携できそうなことがあれば教えてください。