1限マップを作ってみた

初めまして、信州大学で情報工学の修士学生をしているhinoです。

皆さんは1限マップというものを知っていますか？
このような感じで学校の1限に間に合うように最寄り駅に到着できる範囲を表示したマップです。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">東工大の一限に間に合う範囲の地図を作ってみました<br>8:40までに大岡山に着ける場所が対象です <a href="https://t.co/DbZYjFjQNf">pic.twitter.com/DbZYjFjQNf</a></p>&mdash; いうん (@IUnS_252255) <a href="https://twitter.com/IUnS_252255/status/1309481186285158403?ref_src=twsrc%5Etfw">September 25, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

所属しているサークルの学祭展示物で1限マップを紹介しようという事になったので、通っている大学にあるいくつかのキャンパスの1限マップをgeopandasを用いて作成してみました。

## データの取得
1限マップを作成するには大きく分けて以下の3つのデータが必要になります。
1. 駅データ
2. 時刻表データ
3. 地図データ

### 1. 駅データ
HeartRails Express（ https://express.heartrails.com ） のAPIを利用して駅データを取得しました。
北陸新幹線が延伸したこともしっかり対応されており、無料で利用できるので大変助かりました。

### 2. 時刻表データ
Yahoo乗換案内で1件1件調べて対応しました。
ある程度目星をつけて駅の範囲は絞ってものの、調べる量が多くて大変でした...笑

なお、検索条件は以下の通りです。
- 共通事項: 新幹線、特急、高速バス、路線バスを検索条件とする
- 長野工学キャンパス: 長野駅8:40着（駅から徒歩20分）
- 上田キャンパス: 上田駅8:40着（駅から徒歩20分）
- 松本キャンパス: 大学西門8:50着（バス停からすぐだが、構内が他キャンパスより広いため）

駅データと時刻表データは以下のように整理しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/470b33f6-6e8a-4de3-86d3-cb05bc9af55f.png)



### 3. 地図データ
都道府県境のポリゴンデータは中々ないものの、こういう便利なものがあったので活用させていただきました。
https://japonyol.net/editor/article/47-prefectures-geojson.html

## 駅データの整理
各条件に合う出発時間を0:00から起算して何分かかったかで30分刻みでランク分けしました。
ex. 08:00発なら480分なので、 480 / 30 = 16

```Python
import pandas as pd

# Step 1: データを読み込んでDataFrameを作成する
df = pd.read_csv('各キャンパスごとの時刻表データ')

# Step 2: 時刻列をDatetime型に変換する
df['time'] = pd.to_datetime(df['時刻'])

# Step 3: 時刻を分数に直す
df['minutes'] = df['time'].dt.hour * 60 + df['time'].dt.minute

# Step 4: 30ごとにランク分けして新しい列に値を入れる
df['rank'] = df['minutes'] // 30

# csvファイル名を指定して出力
df.to_csv('csvファイル名', index=False)
```

出力結果の例はこのようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/0943e0b2-0df6-d3f4-8bf1-886932294614.png)

## データの表示
geopandasとmatplotlibを用いてデータの表示を行いました。
最も早いに出発する駅と最も遅い時間に出発する駅を吹き出しで目立たせるようにしました。
```Python
import geopandas as gpd
from shapely.geometry import Point
import japanize_matplotlib
import matplotlib.pyplot as plt
import pandas as pd
from matplotlib.lines import Line2D

def plot_map_with_extreme_stations(csv_path, geojson_path, center_lat, center_lon, column_name, radius_km, map_title, 
                                   min_x_offset, min_y_offset, max_x_offset, max_y_offset, legend_location, markersize):
    """
    指定された範囲で地図を表示し、CSVから取得したポイントデータを重ねる関数。
    一番minutesが小さい駅と大きい駅のtimeと駅名をマップにプロット。
    
    Parameters:
    - csv_path: CSVファイルのパス
    - geojson_path: GeoJSONファイルのパス
    - center_lat: 中心点の緯度
    - center_lon: 中心点の経度
    - radius_km: 表示範囲の半径（km単位）
    - map_title: 地図のタイトル
    - min_x_offset: 最小時間駅のx方向のオフセット
    - min_y_offset: 最小時間駅のy方向のオフセット
    - max_x_offset: 最大時間駅のx方向のオフセット
    - max_y_offset: 最大時間駅のy方向のオフセット
    - legend_location: 凡例の位置を指定するための数値
    - markersize: CSVのポイントのマーカーサイズ
    """
    # 凡例位置の対応辞書
    loc_map = {
        1: 'upper right',
        2: 'upper left',
        3: 'lower left',
        4: 'lower right',
        5: 'right',
        6: 'center left',
        7: 'center right',
        8: 'lower center',
        9: 'upper center',
        10: 'center'
    }

    # 中心点の座標
    center = Point(center_lon, center_lat)

    # GeoDataFrameの作成（WGS84座標系: EPSG:4326）
    gdf_center = gpd.GeoDataFrame(geometry=[center], crs="EPSG:4326")

    # 適切な投影法に変換（距離を扱う場合: EPSG:3857）
    gdf_center = gdf_center.to_crs(epsg=3857)

    # GeoJSONデータの読み込み
    gdf_prefecture = gpd.read_file(geojson_path)

    # 日本地図の座標系を変更（EPSG:3857）
    gdf_prefecture = gdf_prefecture.to_crs(epsg=3857)

    # CSVファイルの読み込み
    df = pd.read_csv(csv_path)

    # 緯度と経度からジオメトリを作成
    geometry = [Point(xy) for xy in zip(df['経度'], df['緯度'])]
    gdf_points = gpd.GeoDataFrame(df, geometry=geometry, crs="EPSG:4326")

    # 適切な投影法に変換（EPSG:3857）
    gdf_points = gdf_points.to_crs(epsg=3857)

    # 中心点の座標（3857系）を取得
    center_x, center_y = gdf_center.geometry.x[0], gdf_center.geometry.y[0]

    # カラーマップの作成（rankごとの離散的な色）
    rank_colors = {
        9: 'purple',
        10: 'blue',
        11: 'cyan',
        12: 'green',
        13: 'yellow',
        14: 'orange',
        15: 'red',
        16: 'brown',
        17: 'black'
    }

    labels = {
        9: '04:30~04:59',
        10: '05:00~05:29',
        11: '05:30~05:59',
        12: '06:00~06:29',
        13: '06:30~06:59',
        14: '07:00~07:29',
        15: '07:30~07:59',
        16: '08:00~08:29',
        17: '08:30~08:59'
    }

    # rankに基づいて色を割り当て
    gdf_points['color'] = gdf_points['rank'].map(rank_colors)

    # 一番minutesが小さい駅と大きい駅のデータを取得
    min_station = gdf_points.loc[gdf_points['minutes'].idxmin()]
    max_station = gdf_points.loc[gdf_points['minutes'].idxmax()]

    # time列から時刻部分（HH:MM）を抽出
    min_time = pd.to_datetime(min_station['time']).strftime('%H:%M')
    max_time = pd.to_datetime(max_station['time']).strftime('%H:%M')

    # 図の描画
    fig, ax = plt.subplots(figsize=(15, 15))

    # 都道府県単位の地図を描画
    gdf_prefecture.plot(ax=ax, edgecolor='black', facecolor='lightgrey')

    # CSVから作成したポイントデータを重ねる（離散的な色で表示）
    for rank, color in rank_colors.items():
        gdf_points[gdf_points['rank'] == rank].plot(
            ax=ax,
            color=color,
            markersize=markersize,  # マーカーサイズを引数から設定
            label=labels[rank]
        )

    # 一番minutesが小さい駅をプロット（吹き出し付き）
    ax.annotate(
        f"{min_station['駅名']} ({min_time})", 
        xy=(min_station.geometry.x, min_station.geometry.y),
        xytext=(min_station.geometry.x + min_x_offset, min_station.geometry.y + min_y_offset),  # 動的オフセット
        arrowprops=dict(facecolor='magenta', arrowstyle='->', lw=1.5),
        fontsize=14,  # フォントサイズを調整
        color='black',
        bbox=dict(facecolor='white', alpha=0.7, boxstyle='round,pad=0.5')  # 吹き出し背景
    )

    # 一番minutesが大きい駅をプロット（吹き出し付き）
    ax.annotate(
        f"{max_station['駅名']} ({max_time})", 
        xy=(max_station.geometry.x, max_station.geometry.y),
        xytext=(max_station.geometry.x + max_x_offset, max_station.geometry.y + max_y_offset),  # 動的オフセット
        arrowprops=dict(facecolor='cyan', arrowstyle='->', lw=1.5),
        fontsize=14,  # フォントサイズを調整
        color='black',
        bbox=dict(facecolor='white', alpha=0.7, boxstyle='round,pad=0.5')  # 吹き出し背景
    )

    # x軸とy軸の表示範囲を設定（指定された半径の範囲）
    radius_m = radius_km * 1000  # kmからmに変換
    ax.set_xlim(center_x - radius_m, center_x + radius_m)
    ax.set_ylim(center_y - radius_m, center_y + radius_m)

    # 軸を非表示にする
    ax.axis('off')

    # 凡例の設定（選択した位置に配置）
    legend_elements = [
        Line2D([0], [0], marker='o', color='w', label=labels[rank], markerfacecolor=color, markersize=10)
        for rank, color in rank_colors.items()
    ]
    ax.legend(handles=legend_elements, title='時間帯', fontsize=12, title_fontsize=15, loc=loc_map.get(legend_location, 'lower right'))

    # 日本語タイトルの設定
    plt.title(map_title, fontsize=30)  # 動的なタイトル設定

    # 図の表示
    plt.show()
```

## 長野工学キャンパスの場合
長野駅が最寄りということもあり、新幹線のおかげで広範囲から1限に間に合うように通学できることがわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/51b26fd7-69a0-86f1-7e26-a0b9fb1d406b.png)

## 上田キャンパスの場合
長野キャンパスより静岡県や福島県方面からの通学が難しくなるようです。
これは、上田駅に停車する新幹線が限られていることに起因すると思われます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/40d680e3-b33e-3777-09f9-f17bdbef7c15.png)

### 2024/11/03 追記
上田キャンパスは今年度から08:50スタートになった都合上、08:40着だと難しいようです。（徒歩20分を想定）
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">とても重箱隅突な話ですが、上田キャンパスでは今年度より100分授業となった関係で1限が8:50からとなっており、上田駅から歩く場合8:20ぐらいまでに着いた方がいいかもしれません<br>またバスを使う場合8:17上田発8:25小林脳外科前着のバスがあり、これに乗れば1限は間に合いそうです。参考までに <a href="https://t.co/YRChSPZhgO">https://t.co/YRChSPZhgO</a> <a href="https://t.co/QCKKI41iOv">pic.twitter.com/QCKKI41iOv</a></p>&mdash; カエルム (@caelumfib) <a href="https://twitter.com/caelumfib/status/1848046507083321831?ref_src=twsrc%5Etfw">October 20, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

08:30までに上田駅に到着する場合の1限マップはこうなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/30117beb-4d0e-2e92-5920-d88aa2fb1994.png)

08:40までに到着する場合と比較するとこうなります。
特に長野県内で1限に間に合う範囲がたったの10分の差で顕著に狭まったのがわかります。
<div style="display: flex; justify-content: space-between;">
    <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/40d680e3-b33e-3777-09f9-f17bdbef7c15.png" alt="Image 1" width="45%">
    <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/30117beb-4d0e-2e92-5920-d88aa2fb1994.png" alt="Image 2" width="45%">
</div>


## 松本キャンパスの場合
一気に範囲が狭まりました。
新幹線が通っていないと範囲は大きく狭まるようです。

参考までに松本には信州まつもと空港がありますが、最も早く到着する便でも8:50着なので1限には間に合わないです。

https://www.matsumoto-airport.co.jp/schedule

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/2866e35a-a1db-8cfc-212a-e9e5faa92dab.png)