DuckDB-Wasm で GTFS(公共交通時刻表)をブラウザ分析する
公共交通の標準フォーマット GTFS(stops / trips / stop_times の CSV 群)を DuckDB-Wasm に読み込み、発車本数・次の発車・駅間所要時間をブラウザ内 SQL で集計するパターン。registerFileText での仮想ファイル登録、24 時超え時刻('25:30:00')を TIME にキャストできない罠、window 関数での駅間計算まで、架空 2 路線の触れる demo 付きで整理する実装メモ。
検証日: 2026-07-26
使用バージョン:
@duckdb/duckdb-wasm@1.33.1-dev45.0対象: オープンデータの時刻表を「サーバーもローカル環境構築もなしで」触ってみたい人。CSV 群を SQL で JOIN したい場面全般
GTFS(General Transit Feed Specification — 世界中の公共交通機関が時刻表・停留所を公開するのに使う標準フォーマット)は、実体が zip に入った複数の CSV なので、DuckDB-Wasm と極めて相性がいい。この記事では GTFS の主要 4 ファイルをブラウザ内の DuckDB に読み込み、「駅別の発車本数」「次の発車」「駅間所要時間」を SQL だけで出す。動く demo 付き(データは架空 2 路線)。
DuckDB-Wasm 自体の導入(bundle 選択・COOP/COEP・メモリ上限)は前の記事で扱ったので、この記事は「実データ形式を食わせる」側に集中する。
触って試す
プリセットの 3 クエリはそのまま実行でき、SQL は自由に書き換えられる。データは架空の 2 路線(本線 8 駅・支線 4 駅、朝 6:00〜9:00 のダイヤ約 350 発車)だが、列構成は本物の GTFS と同一なので、クエリは実データにそのまま流用できる。
1. GTFS の中身 — 4 テーブルの関係
GTFS zip の中で分析によく使うのは次の 4 ファイル。リレーショナルな構造がそのまま SQL の JOIN に対応する:
| ファイル | 中身 | 主キー |
|---|---|---|
stops.txt | 停留所・駅(名前、緯度経度) | stop_id |
routes.txt | 路線(名前、種別 = 鉄道/バス等) | route_id |
trips.txt | 便(「7:00 発の下り」のような 1 運行) | trip_id |
stop_times.txt | 便がどの停留所に何時に着くか | trip_id + stop_sequence |
関係は routes 1—n trips 1—n stop_times n—1 stops。**行数の主役は stop_times**で、都市圏の実フィードでは数十万〜数百万行になる — ブラウザ内で列指向エンジンの DuckDB を使う意味はここにある。
2. 最小サンプル — CSV 文字列を仮想ファイルとして登録する
DuckDB-Wasm には「ブラウザのメモリ上の文字列/バッファをファイルとして見せる」API がある。次のコードは CSV 文字列を登録してテーブル化する:
import * as duckdb from "@duckdb/duckdb-wasm";
// (bundle 選択〜 instantiate は導入記事の通り)
const conn = await db.connect();
// 文字列を仮想ファイル 'stops.txt' として登録
await db.registerFileText("stops.txt", stopsCsv);
// GTFS の時刻は '25:30:00' がありうるので、まず全列 VARCHAR で読む(§4)
await conn.query(`
CREATE TABLE stops AS
SELECT * FROM read_csv_auto('stops.txt', all_varchar=true);
`);
ファイルの入手経路ごとに使う API が変わる:
- 文字列(fetch 済み・埋め込み) →
db.registerFileText(name, text) - URL から直接 →
db.registerFileURL(name, url, duckdb.DuckDBDataProtocol.HTTP, false)(CORS 許可が必要) - ユーザがドロップした File →
db.registerFileHandle(name, file, duckdb.DuckDBDataProtocol.BROWSER_FILEREADER, true)
zip の展開は DuckDB ではできないので、実フィードは fetch 後に zip.js 等で展開してから registerFileText する。
3. 定番クエリ 3 つ
(1) 駅別の発車本数 — JOIN + GROUP BY の素直な形:
SELECT s.stop_name, count(*) AS departures
FROM stop_times st JOIN stops s USING (stop_id)
GROUP BY s.stop_name
ORDER BY departures DESC;
(2) ある駅の「次の 5 本」 — GTFS の時刻は文字列だが、HH:MM:SS 固定幅なので文字列比較がそのまま時刻順になる:
SELECT st.departure_time, r.route_short_name
FROM stop_times st
JOIN trips t USING (trip_id)
JOIN routes r USING (route_id)
JOIN stops s USING (stop_id)
WHERE s.stop_name = '中央' AND st.departure_time >= '07:30:00'
ORDER BY st.departure_time
LIMIT 5;
(3) 駅間の所要時間 — 「同じ便の次の停車」は window 関数 lead()(現在行から見て次の行の値を返す)で取る。時刻はまず分に変換しておく(理由は §4):
WITH st AS (
SELECT trip_id, stop_id, stop_sequence::INT AS seq,
CAST(split_part(departure_time, ':', 1) AS INT) * 60
+ CAST(split_part(departure_time, ':', 2) AS INT) AS dep_min
FROM stop_times
), hop AS (
SELECT trip_id, stop_id,
lead(stop_id) OVER w AS next_stop,
lead(dep_min) OVER w - dep_min AS minutes
FROM st
WINDOW w AS (PARTITION BY trip_id ORDER BY seq)
)
SELECT a.stop_name AS from_stop, b.stop_name AS to_stop, avg(minutes) AS avg_minutes
FROM hop JOIN stops a ON hop.stop_id = a.stop_id
JOIN stops b ON hop.next_stop = b.stop_id
GROUP BY 1, 2;
PARTITION BY trip_id で便ごとに区切り、ORDER BY seq(停車順)で並べる — この 2 つが GTFS 分析の window 関数の型。
4. GTFS 特有の罠: 24 時を超える時刻
GTFS の時刻列は「サービス日基準」で、終電が日をまたぐと 25:30:00 のような表記になる(仕様で明示的に許可されている)。これを read_csv_auto の型推論に任せると 2 通りの事故が起きる:
- 深夜便を含むフィード →
25:30:00が TIME としてパースできず 読み込み自体が失敗 - 深夜便を含まない部分だけ試して TIME 型になった → 後から実フィードを入れた時だけ落ちる
対処は「まず all_varchar=true で全部文字列として読む」に倒すこと。時刻順の比較・並べ替えは固定幅文字列のままで正しく動く(§3-(2))。分単位の計算が必要な箇所だけ、§3-(3) の split_part 方式で自前変換する — '25:30:00' もそのまま 1530 分になり、日またぎの減算も破綻しない。
5. 実データの入手先
架空データで型を掴んだら、実フィードに差し替える:
- gtfs-data.jp — 日本の GTFS-JP フィードのカタログ。API(
api.gtfs-data.jp/v2/feeds)でフィード一覧を取得でき、多くが登録不要でダウンロードできる(大半はバス事業者) - 公共交通オープンデータセンター(ODPT) — 首都圏の鉄道・地下鉄・バス。開発者登録(無料)が必要
- Mobility Database — 世界のフィードカタログ
ライセンスはフィードごとに異なる(CC BY が多い)。可視化や分析結果を公開する場合は各フィードの出典表記に従う。
つまずいたポイント
Invalid Input Error: Could not parse string "25:30:00": §4 の 24 時超え時刻。all_varchar=trueで読み、時刻計算はsplit_partで自前変換Binder Error: No function matches '-(TIME, TIME)': TIME 同士の減算がサポート外(DuckDB-Wasm 1.33 系で確認)。::TIMEに頼らず分に変換してから引く(§3-(3) の形)stop_sequenceの並びが 1, 10, 11, 2, …: 全列 VARCHAR で読んでいるので文字列ソートになる。ORDER BY stop_sequence::INTと明示キャストする- JOIN 結果が 0 行:
stop_idの前後空白。フィードによっては CSV に BOM や空白が混ざる。read_csv_autoは BOM は処理するが、空白はtrim(stop_id)で正規化してから JOIN する registerFileURLでFailed to fetch: 配信元が CORS 未対応。ブラウザから直接読めるのは CORS ヘッダ付きの配信元だけなので、ダメなら一度 fetch できる場所(自サイトの public/ 等)に置く- BigInt が JSON.stringify で落ちる:
count(*)の結果は BigInt で返る。表示前にNumber(v)するかtoString()する(demo はString(v)で統一)
向くケース / 向かないケース
| 判定 | |
|---|---|
| フィードの中身を対話的に調べる・可視化の前処理 | ◎ 環境構築ゼロで JOIN・window 関数まで使える |
| 「動く時刻表ビューア」をサーバーレスで公開 | ◎ 静的ホスティング + フィード zip だけで成立 |
| 全国規模・複数フィードの横断バッチ集計 | △ ローカルの DuckDB(CLI/Python)の方が速くて安い |
| リアルタイム位置情報(GTFS-RT) | ✖ GTFS-RT は Protocol Buffers。別途デコーダが必要 |
関連書籍
Python と JavaScriptではじめるデータビジュアライゼーション | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。