はじめに
前記事の続きです。
GTFSデータ調べの最中ですが、今回は結構面白い見える化コーディングだったのでアウトプットします。
実行環境
- Mac Ventura 13.1
- Livebook app
Livebookはデスクトップアプリを使ってます。
因みになのですが、Livebook app でもコマンドラインのように --home オプションで好きなディレクトリで開けたらいいなと思い、Livebook.app の中のシェルスクリプトファイルに
export HOME="開きたいlivebook用ディレクトリパス"
と無理矢理追加書き込みして使ってます。いいのかどうかはさておき、密かに便利だなーと思っています。
バス路線座標データの読み込み
早速、佐賀県が公開してくださってるデータをありがたく使わせていただきます。
まだ、使うGTPSデータとやらは手に入っておりません。
今回は佐賀県GTPSデータの中のshape.txtを読み込みます。
どうも全路線の座標データが入ってるようなのです。
これを使えば「Elixir Livebook で佐賀県のGTSFで全バス路線を地図に描きたいのだ」が出来そうじゃないですか?
最終的にこんなになってしまったセッティング。
Mix.install([
{:explorer, "~> 0.5"},
{:kino, "~> 0.8"},
{:csv, "~> 3.0"},
{:kino_maplibre, "~> 0.1.7"},
# {:req, "~> 0.3.3"},
{:random_colour, "~> 0.1.0"}
])
前回と同じエリアス設定
alias Explorer.DataFrame, as: Df
alias Explorer.Series, as: Sr
alias MapLibre, as: Ml
require Explorer.DataFrame
早速shape.txtを取り込んでみます。
shape_df =
Path.absname("./gtfs-sample/saga-current/shapes.txt")
|> Df.from_csv!()
shape_df
|> Kino.DataTable.new()
路線ごとのデータフレーム配列
生データを見るとshape_idが路線名みたいに見えます。shape_idでまとめると路線ポイントの座標が取得出来るようです。
路線ポイントの順番がshape_pt_sequenceで、連続値とは限らない昇順なのだそうです。
因みに日本で奨励しているGTFSはGTFS-JPだとのことらしいです。
まあよく知らないことは置いといて、まず何路線あるのか様子をみたいです。
shape_id_lists =
shape_df
|> Df.distinct([:shape_id])
|> Df.pull(:shape_id)
|> Sr.to_list()
なんと492本あるようです。本当に全路線かもしれない。
よくわからないので、路線ごとにデータフレームして配列を作ってみたいと思います。
routes_df =
shape_id_lists
|> Enum.map(fn shape ->
shape_df
|> Df.filter(shape_id == ^shape)
|> Df.arrange_with(& &1[:shape_pt_sequence])
end)
元データは綺麗に昇順で並んでいるようですが、念の為にソートはかけたいです。
で、この後どうしたら良いのでしょうか。さっぱりわかりません。
1路線だけ取り出して見える化
手っ取り早く路線が見たいので、データフレームの中から一個だけ抽出してみます。
route_df =
routes_df
|> hd()
lon_list =
route_df[:shape_pt_lon]
|> Sr.to_list()
lat_list =
route_df[:shape_pt_lat]
|> Sr.to_list()
lon_lat_list =
lon_list
|> Enum.zip(lat_list)
ここで %Geo.LineString といういかにも路線図に似合いそうなものを見つけたので、試してみましょう。
shape_geojson = %Geo.LineString{
coordinates: lon_lat_list
}
センター座標を前記事と同じように取ります。
lon = Enum.map(shape_geojson.coordinates, &elem(&1, 0))
lat = Enum.map(shape_geojson.coordinates, &elem(&1, 1))
center = {
(Enum.min(lon) + Enum.max(lon)) / 2,
(Enum.min(lat) + Enum.max(lat)) / 2
}
描画します。
Ml.new(
center: center,
zoom: 15,
# style: "https://api.maptiler.com/maps/streets/style.json?key=get_your_own_OpIi9ZULNHzrESv6T2vL"
style: :street #これでよかったみたい。reqパッケージ要らなくなった。
)
|> Ml.add_geo_source("route", shape_geojson)
|> Ml.add_layer(
id: "route",
source: "route",
type: :line,
paint: [line_color: "#aa00ff", line_width: 4]
)
どうやら%Geo.LineString、いけるようです。
全路線に拡張
こんな感じで全部をGeoJSON化してみたいと思います。
routes_geojson = %Geo.GeometryCollection{
geometries:
routes_df
|> Enum.map(fn route ->
lon = route[:shape_pt_lon] |> Sr.to_list()
lat = route[:shape_pt_lat] |> Sr.to_list()
lon_lat = lon |> Enum.zip(lat)
%Geo.LineString{coordinates: lon_lat}
end)
}
センター座標をGeoJSONから算出したい
とここで %Geo.GeometryCollection を見ているうちに、「GeoJSON -->🐱-->センター座標」がしたいと思い立ちました。
%Geo.LineStringの配列の中の緯度経度配列を、緯度は緯度、経度は経度でグサーっと縦に串刺しにして集約すれば、総数で割った平均値がセンター座標になるイメージ。
面白そうじゃないですか?
# ルートごとの緯度経度のsumを作る
lon_lat_sum =
routes_geojson.geometries
|> Enum.map(fn line_string ->
line_string.coordinates
|> Enum.reduce(fn {lon, lat}, {acc_lon, acc_lat} ->
{lon + acc_lon, lat + acc_lat}
end)
end)
# 総数を取得する
count = shape_df[:shape_pt_lon] |> Sr.to_list() |> Enum.count()
lon_lat_sum
|> Enum.reduce(fn {lon, lat}, {acc_lon, acc_lat} ->
{lon + acc_lon, lat + acc_lat}
end)
|> then(&{elem(&1, 0) / count, elem(&1, 1) / count})
なんとなく、ぽいのが出てきたので、パイプラインひと繋がりにします。
小数点も元データが多くて7桁っぽいのでここは合わせた方が良いのでしょうか。
count = shape_df[:shape_pt_lon] |> Sr.to_list() |> Enum.count()
all_center =
routes_geojson.geometries
|> Enum.map(fn line_string ->
line_string.coordinates
|> Enum.reduce(fn {lon, lat}, {acc_lon, acc_lat} ->
{lon + acc_lon, lat + acc_lat}
end)
end)
|> Enum.reduce(fn {lon, lat}, {acc_lon, acc_lat} ->
{lon + acc_lon, lat + acc_lat}
end)
|> then(&{ Float.round(elem(&1, 0) / count, 7), Float.round(elem(&1, 1) / count, 7)})
一つの流れになって動いてしまいましたが、もうすでに自分の理解の限界を超えてしまっている気がします。
ここで、気がつきました。Flat化すれば全然シンプルになるはず。
all_center =
routes_geojson.geometries
|> Enum.map(fn line_string -> line_string.coordinates end)
|> List.flatten()
|> Enum.reduce(fn {lon, lat}, {acc_lon, acc_lat} ->
{lon + acc_lon, lat + acc_lat}
end)
|> then(& {Float.round(elem(&1, 0) / count, 7), Float.round(elem(&1, 1) / count, 7)} )
イメージが収束して限界値から少し離れた気がします。
全路線の描画
Ml.new(
center: all_center,
zoom: 9,
# style: "https://api.maptiler.com/maps/streets/style.json?key=get_your_own_OpIi9ZULNHzrESv6T2vL"
style: :street
)
|> Ml.add_geo_source("routes", routes_geojson)
|> Ml.add_layer(
id: "route",
source: "routes",
type: :line,
paint: [line_color: "#aa00ff"]
)
いい感じがします。けど、カラフルな路線図が見たくなりました。
路線ごとの色分け描画
とはいえ全く見当がつきません。一体どうやれば色分けできるのでしょうか。
途方にくれたので、とりあえずデータを両手に持てる数に絞ってゴニョることにしました。
route0 = routes_geojson.geometries |> Enum.at(30)
route1 = routes_geojson.geometries |> Enum.at(50)
map =
Ml.new(
center: all_center,
zoom: 9
)
map
|> Ml.add_geo_source("route0", route0)
|> Ml.add_layer(
id: "route0",
source: "route0",
type: :line,
paint: [line_color: "#ff0000"]
)
|> Ml.add_geo_source("route1", route1)
|> Ml.add_layer(
id: "route1",
source: "route1",
type: :line,
paint: [line_color: "#00ffff"]
)
どうやらどんどんデータを足していくことで、個々に描画出来るようです。
このイメージは Enum.reduce による集約? 畳み込み? 的な実装で出来るかもしれない?!
本当になんとなくの思い付きだけで言っています。
因みにぼくの畳み込みのイメージはこんな感じです。
口では「次元降下させることかな」なんて残念ななんちゃって発言するかもしれませんが、頭の中はこんなのです。
map =
Ml.new(
center: all_center,
zoom: 9,
# style: "https://api.maptiler.com/maps/streets/style.json?key=get_your_own_OpIi9ZULNHzrESv6T2vL"
style: :street
)
draw_routes = fn routes ->
routes
|> Enum.with_index()
|> Enum.reduce(map, fn {line_string, index}, acc ->
id = Integer.to_string(index)
acc
|> Ml.add_geo_source(id, line_string)
|> Ml.add_layer(
id: id,
source: id,
type: :line,
paint: [line_color: RandomColour.generate(), line_width: 5]
)
end)
end
draw_routes.(routes_geojson.geometries)
なんと!! 描画できてしまったみたいです。
驚いたことに、あんな幼稚園児みたいな畳み込みイメージでも機能したようなのです!
Elixir Livebook 凄すぎます。
因みにidのところはユニークな文字列にしたくて、見かけた記事からアイディアを頂戴しました。
アルケミストのお方々はこんなときどうやるのでしょうか。
@the_haigoさんに教えていただいたユニーク文字列の取得を取り入れてみました。
さっぱりわからなかったので調べてみました。
Erlangからのcrypto系からのASCII文字列取得って感じですね(なんてぼんやりしたイメージなのだろう)。
勉強になります! (2023-02-24)
@the_haigoさんのコメントで、with_indexでとっていくのが由緒正しい感じがしたので、こっちにコードを変えました。
すごいアルケミストから教えてもらえて嬉しい。 (2023-02-24)
@the_haigoさんのコメントで教えてもらったSmart Cell、:style にURL要らなかったみたいなので修正。 (2023-02-24)
まとめ
やっぱり、GTFSとお友達になるためにElixir Livebookを使うのは楽しい。
実はまだ要件に辿り着いていません、がLivebookと共に歩けば必ず辿り着く気がします。