
如果你平时只在小程序里看过东京地铁图第一次在 Hacker News 上刷到 “Show HN: Tokyo Trains” 这类作品时很容易被精致的列车动效吸引住。但做过交通可视化的人会立刻问出另一个问题它的数据从哪里来不同运营商的线路怎么拼成一张图换乘路径又是怎么算出来的这篇文章不替某个具体 Demo 站台而是从“实现一个 Tokyo Trains 风格应用”的角度把这类项目拆开讲清楚东京轨交数据为什么难处理、静态数据与实时数据怎么分工、后端查询与换乘规划怎么写、前端地图和纯地铁图如何选择以及从原型到生产会踩到哪些坑。如果你正准备做地图可视化、交通数据应用或者对 GTFS 数据流感兴趣这篇文章可以给你一条完整可落地的路径。## 1. 这篇文章真正要解决的问题 先给一个明确的判断Tokyo Trains 这类应用真正的技术难点不在前端而在数据。 表面上看你需要的功能并不复杂一张地图上画几条彩色的线点一下站台显示下一班车几点到。但放到东京这个场景里事情立刻变味了。东京的轨道交通不是一家公司运营的而是由 JR、东京地铁、都营地铁、多家私铁和新干线共同组成。每家公司有自己的数据格式、线路颜色、票价规则、车辆类型和进站语音。甚至同一个汉字站名在不同公司的数据里可能写成不同的编码。 所以“做一个东京列车信息应用”这个需求放到工程上会拆成几个真实问题 1. 数据从哪里拿拿到之后怎么清洗 2. 站点、线路、班次这些实体应该怎么建模 3. 用户查“从东京站到新宿站怎么走”时换乘路径算法要写得多重 4. 前端地图和纯地铁图什么时候用哪种方案 5. 实时数据如果拿不到怎么用静态数据做原型 这篇文章会沿着这几条线展开。读完你可以做到三件事 - 理解东京轨交应用背后的数据模型与查询思路 - 用公开的静态数据搭出最小可运行的地图查询应用 - 避开站名编码、时区、线路几何、换乘计算这几个高频坑。 什么读者最适合读如果你正在做地图可视化、LBS 应用、城市数据产品或者只是想搞懂 GTFS 数据到底怎么用这篇文章值得收藏如果你只是想找一个已经写好的仓库直接跑起来那么这篇文章更偏“实现思路”需要你结合自己的技术栈去做翻译。2. 东京轨交可视化应用难点在数据整合2.1 多运营商带来的数据碎片化东京轨道交通的第一个特点是“一张网多张皮”。山手线属于 JR 东日本银座线属于东京地铁大江户线属于都营地铁小田急线、京王线、东急线又分属不同私铁。它们共同组成了一张可换乘的网络但在经营层面完全是分开的。这意味着同一个地理位置可能被多个运营商标记为不同的站点 ID换乘站的站名经常重合但内部步行路径不在轨道数据里线路颜色不是一套统一的规范而是各公司自定义的视觉体系票价规则各公司独立跨公司换乘的计价逻辑非常复杂。如果你要做的是一个只展示“某一条线路”的小应用数据碎片化问题不大但如果要做成 Tokyo Trains 那种可查询、可规划路线的应用你必须在数据层先把“多运营商”这个概念消化掉。2.2 静态数据与实时数据是两套体系很多人第一次接触交通数据时会默认“只要拿到实时 API 就万事大吉”。实际上东京轨交应用通常需要同时处理两类数据数据类型内容更新频率典型来源静态数据站点坐标、线路走向、列车时刻表、运营日期按季度或年度更新GTFS-JP、运营商 Open Data实时数据当前位置、延误信息、进站倒计时秒级或分钟级运营商官方 API、车站信息屏、GPS 上报在项目初期优先做静态数据。原因很直接静态数据覆盖全网络来源稳定可以一次性导入数据库实时数据则高度依赖运营商授权每家企业的 API 格式和更新频率都不一样做完全部接入可能要花掉几个月。从工程角度讲你应该先把“静态数据查询”做成一个稳定的底座再在底座上逐步叠加实时能力。很多项目失败不是因为未来规划得不够好而是因为在第一天就试图把实时数据全部接完结果被各家的接口差异拖垮了。2.3 产品形态决定了技术取舍Tokyo Trains 这类产品通常有两种形态它们的侧重点完全不同信息查询型用户需要知道“下一班车几点”“从 A 到 B 怎么换乘”。核心是数据准确性和路线规划质量。视觉展示型用户被列车流动、线路颜色、站名动画吸引。核心是前端叙事能力但对数据精度要求相对低。两种形态并不冲突但开发顺序必须错开。我的建议是先做信息查询型把数据链路打通然后再做视觉展示。反过来做很容易变成“地图很好看但一问数据就露馅”。## 3. 技术选型与整体架构设计 ### 3.1 推荐的分层架构 在动手写代码之前先想清楚整个系统的分层 text 前端展示层地图 / 纯地铁图 / 信息面板 ↓ REST API 或 WebSocket 后端服务层查询路由、换乘规划、缓存 ↓ SQL / ORM 数据持久层站点、线路、班次、运行日期 ↓ 定时任务 数据接入层GTFS 解析、开放数据导入这个分层不是为了显得正规而是为了隔离变化。数据源格式会变前端框架会换但数据库里的“站点—线路—班次”模型相对稳定。把数据接入做成独立的定时任务即使某个运营商的格式变了也只需要改接入层的一个解析器。3.2 技术栈怎么选下面是一套适合中型原型项目的技术栈建议层次推荐选项理由数据库PostgreSQL PostGIS支持地理空间查询路线几何数据能直接存为线类型后端Python / FastAPI生态丰富处理 GTFS 的 py 库多开发速度快前端地图MapLibre GL 或 Leaflet矢量切片渲染流畅GeoJSON 支持好纯地铁图SVG 自绘或开源地铁图库不依赖真实地理坐标视觉更接近官方线路图部署Docker Compose数据库、后端、前端一键启动版本选择以你本机当前稳定版为准不要盲目追求最新。Backend 如果团队更熟 Node.js也可以换成 NestJS 或 Express核心逻辑不受影响前端如果更熟 React也可以把示例代码迁移过去。这里真正需要注意的是数据库建议直接上 PostGIS。原因很简单线路在地图上不是直线而是折线或多段线站点也不是普通字符串而是经纬度坐标。普通数据库虽然能存数字但做“附近站点查询”时只能在应用层用半正弦公式计算距离数据量大了之后性能和代码复杂度都会失控。## 4. 数据准备与建模从 GTFS 到数据库 ### 4.1 数据来源怎么找 东京的轨交开放数据最常用的入口是 GTFS-JP也就是日本基于通用交通数据规范提供的静态数据包。你可以把它理解成一张全网络的“课表”哪条线路、在哪天运行、每个站几点到几点走。 需要注意不是所有运营商都在同一个数据包里。项目初期选两三家代表性运营商比如 JR 东日本和东京地铁把“多源数据导入同一套表结构”的流程先跑通。不要一开始就追求覆盖全东京否则你会陷入无休止的字段映射工作。 获取之后建议先人工打开几个文件看字段不要直接写导入代码。GTFS 最大的坑是字段缺失和编码不一致先看数据再写解析器能省掉很多调试时间。 ### 4.2 站点、线路、班次的核心表结构 基于 GTFS 的核心实体我们至少需要四张表线路表、站点表、班次表、时刻表。 sql -- 文件路径db/schema.sql CREATE EXTENSION IF NOT EXISTS postgis; CREATE TABLE routes ( route_id TEXT PRIMARY KEY, route_short_name TEXT, route_long_name TEXT, route_type INT, route_color TEXT ); CREATE TABLE stops ( stop_id TEXT PRIMARY KEY, stop_name TEXT, stop_lat DOUBLE PRECISION, stop_lon DOUBLE PRECISION, geom GEOMETRY(Point, 4326) ); CREATE TABLE trips ( trip_id TEXT PRIMARY KEY, route_id TEXT REFERENCES routes(route_id), service_id TEXT, trip_headsign TEXT ); CREATE TABLE stop_times ( trip_id TEXT REFERENCES trips(trip_id), arrival_time TEXT, departure_time TEXT, stop_id TEXT REFERENCES stops(stop_id), stop_sequence INT, PRIMARY KEY (trip_id, stop_sequence) ); CREATE INDEX idx_stop_times_stop ON stop_times (stop_id); CREATE INDEX idx_stops_geom ON stops USING GIST (geom);这里解释几个容易误解的字段route_type线路类型。GTFS 规范里 0 是电车1 是地铁2 是城际铁路3 是公交。不同国家可能有扩展导入前最好先统计一下出现的值。service_id运营日期。arrival_time和departure_time在 GTFS 里是文本类型而且可能出现超过 24:00 的值后面导入时要注意。stop_sequence是当天班次内站点的顺序这个字段在做换乘规划时非常重要。同一条线路上所有 trips 的stop_sequence共同定义了相邻站点关系。4.3 数据导入脚本拿到 GTFS 原始 CSV 文件后写一个 Python 导入脚本。示例里用psycopg2批量写入避免逐条 insert 的性能问题。# 文件路径scripts/import_gtfs.py import csv from pathlib import Path import psycopg2 from psycopg2.extras import execute_values GTFS_DIR Path(data/gtfs) DB_DSN postgresql://localhost/tokyo_trains def read_csv(filename): with open(GTFS_DIR / filename, encodingutf-8-sig) as f: return list(csv.DictReader(f)) def import_stops(conn): rows read_csv(stops.txt) records [ (row[stop_id], row[stop_name], float(row[stop_lat]), float(row[stop_lon])) for row in rows if row.get(stop_lat) and row.get(stop_lon) ] with conn.cursor() as cur: execute_values( cur, INSERT INTO stops (stop_id, stop_name, stop_lat, stop_lon) VALUES %s ON CONFLICT (stop_id) DO NOTHING , records, page_size1000, ) cur.execute( UPDATE stops SET geom ST_SetSRID(ST_MakePoint(stop_lon, stop_lat), 4326) WHERE geom IS NULL ) conn.commit() def import_routes(conn): rows read_csv(routes.txt) records [ (row[route_id], row.get(route_short_name, ), row.get(route_long_name, ), int(row.get(route_type, 0)), row.get(route_color, )) for row in rows ] with conn.cursor() as cur: execute_values( cur, INSERT INTO routes (route_id, route_short_name, route_long_name, route_type, route_color) VALUES %s ON CONFLICT (route_id) DO NOTHING , records, page_size1000, ) conn.commit() def main(): conn psycopg2.connect(DB_DSN) import_routes(conn) import_stops(conn) conn.close() print(GTFS import finished) if __name__ __main__: main()这段代码做了两件关键的事用utf-8-sig读取文件避开日文汉字和 BOM 导致的编码异常先用经纬度字段写入普通列再统一回填geom避免在批量插入时反复拼几何对象。4.4 导入时的编码与时区问题东京轨交数据最常见的坑有三个这里单独说。第一是编码。GTFS 文件可能是 UTF-8也可能带 BOM。如果在 Windows 上处理日文数据还可能出现 Shift-JIS 编码的文件。最稳妥的办法是用 Python 读取时尝试 UTF-8失败后回退到 Shift-JIS。第二是时间。GTFS 的arrival_time是文本可能出现 “24:30:00” 这种超过 24 小时的写法。如果要算“下一班车”不能直接当 time 类型存而是建议转成当天从 00:00 起算的分钟数整数比如 24:30 存成 1470。第三是站点名匹配。同一个车站在 JR 数据里可能叫 “東京”在 Metro 数据里可能叫 “東京メトロ”。要做到中文界面下统一显示需要额外维护一张站名别名表把运营商原始名映射到展示名。这个表可以在导入阶段生成不用在查询阶段做翻译。## 5. 后端 API 与核心功能实现 ### 5.1 接口设计概览 数据导入完成后后端服务提供三个基础接口就够支撑最小原型 - 获取线路列表用于前端显示图层或线路选择器 - 获取站点详细信息用于地图点击后展示 - 获取指定站点的下一班车用于信息面板。 写接口时先不要追求大规模性能优化保持业务逻辑清晰更重要。用一个 FastAPI 文件先跑通全流程后面再拆分路由。 ### 5.2 线路与站点查询 python # 文件路径api/main.py from fastapi import FastAPI, HTTPException, Query from typing import Optional import psycopg2 from psycopg2.extras import RealDictCursor app FastAPI(titleTokyo Trains API) DB_DSN postgresql://localhost/tokyo_trains def get_conn(): return psycopg2.connect(DB_DSN, cursor_factoryRealDictCursor) app.get(/routes) def list_routes(route_type: Optional[int] None): sql SELECT route_id, route_short_name, route_long_name, route_color FROM routes params [] if route_type is not None: sql WHERE route_type %s params.append(route_type) sql ORDER BY route_short_name with get_conn() as conn, conn.cursor() as cur: cur.execute(sql, params) return cur.fetchall() app.get(/stops/{stop_id}/next_trains) def next_trains(stop_id: str, limit: int Query(5, ge1, le20)): sql SELECT t.trip_headsign, st.arrival_time, r.route_short_name FROM stop_times st JOIN trips t ON t.trip_id st.trip_id JOIN routes r ON r.route_id t.route_id WHERE st.stop_id %s AND st.arrival_time_min ? ORDER BY st.arrival_time_min LIMIT %s with get_conn() as conn, conn.cursor() as cur: cur.execute(sql, (stop_id, current_time_minutes(), limit)) return cur.fetchall()这个示例省略了arrival_time_min的转化细节但逻辑上你应该在导入时就把文本时间转换成当天分钟数这样查询“下一班”只需要做一次整数比较简单又高效。5.3 简化版换乘规划线路规划是 Tokyo Trains 类应用的另一块核心。完整的票价与换乘优化通常需要引入专门的路线规划引擎但如果你只是做一个演示原型完全可以用图上的广度优先搜索实现“最少换乘次数”的路径。思路是把站点看作图的节点只要某一条 trip 里相邻两站存在stop_sequence或相邻就在这两个节点之间加一条边。然后从起点站开始 BFS直到找到终点站。# 文件路径api/router.py节选 from collections import defaultdict, deque def build_graph(conn): 根据相邻站点关系构建无向图stop_id - set(neighbor_stop_id) graph defaultdict(set) with conn.cursor() as cur: cur.execute( SELECT a.stop_id, b.stop_id FROM stop_times a JOIN stop_times b ON a.trip_id b.trip_id AND a.stop_sequence 1 b.stop_sequence ) for a, b in cur.fetchall(): graph[a].add(b) graph[b].add(a) return graph def find_transfer_path(graph, start, goal): 返回从 start 到 goal 的站点列表不可达时返回 None。 if start goal: return [start] queue deque([(start, [start])]) visited {start} while queue: stop, path queue.popleft() for neighbor in graph[stop]: if neighbor goal: return path [goal] if neighbor not in visited: visited.add(neighbor) queue.append((neighbor, path [neighbor])) return None这个算法的优点是实现简单能保证找到换乘次数最少的路径缺点是它不区分“同一线路直达”和“换个公司换乘”的成本差异。如果要考虑“换乘步行 500 米”这种体验层面的代价就需要把站点之间的换乘步行距离加入边权然后改用 Dijkstra 算法并在权重里把换乘惩罚设置成额外成本。对最小原型来说BFS 已经足够跑通演示。真正需要上生产环境时建议优先调研现成的多模式规划引擎而不是自己写一套完整优化器。## 6. 前端地图可视化与交互实现 ### 6.1 地图模式用 MapLibre 展示站点 前端最直接的地图模式是把后端查询到的站点和线路渲染成 GeoJSON叠加到 MapLibre GL 上。这里用城市背景图层作为底图把站点显示成圆点点击后触发下一班车信息查询。 javascript // 文件路径web/src/map.js import maplibregl from maplibre-gl; import maplibre-gl/dist/maplibre-gl.css; const map new maplibregl.Map({ container: map, style: https://demotiles.maplibre.org/style.json, center: [139.767, 35.681], zoom: 10 }); async function loadStations() { const response await fetch(http://localhost:8000/stations); const stations await response.json(); map.addSource(stations, { type: geojson, data: stations }); map.addLayer({ id: station-points, type: circle, source: stations, paint: { circle-radius: 5, circle-color: #1E88E5, circle-stroke-width: 1, circle-stroke-color: #FFFFFF } }); map.on(click, station-points, (e) { const stopId e.features[0].properties.stop_id; fetch(http://localhost:8000/stops/${stopId}/next_trains) .then(r r.json()) .then(trains console.table(trains)); }); } loadStations();这里的stations接口返回的必须是 GeoJSON FeatureCollection每个 Feature 的properties里带有stop_id和stop_name。前端不关心数据内部怎么算出来的它只消费一个稳定的 GeoJSON 结构。6.2 纯地铁图模式更适合视觉展示地图模式适合“真实地理信息”场景但 Tokyo Trains 这类偏展示的产品更常见的视觉方案是纯地铁图。它刻意忽略真实地理距离用等距网格表达站间关系风格接近东京地铁官方线路图。纯地铁图有两种实现路线使用开源地铁图生成库能根据站点邻接表自动布局手工维护一份 SVG 模板把站点坐标静态画上去。前者适合数据量大的动态网络后者适合风格高度定制的作品。如果目标是先让数据链路跑通建议先做地图模式如果目标是做出漂亮的“线网图”需要把 SVG 当作一等公民把站点、线路、标签分层管理并且允许设计人员手工调整坐标。交互方面两种模式都建议保留“点击站点看下一班车”的功能。信息面板不要做得太重先展示三列线路名、终点方向、预计到达时间。后面再逐步加入延误状态车辆位置等实时信息。## 7. 运行效果与验证方法 ### 7.1 启动后端 假设代码目录结构如下 text tokyo-trains/ ├── db/schema.sql ├── scripts/import_gtfs.py ├── api/main.py ├── api/router.py └── web/src/map.js先导入数据再启动 FastAPI# 进入项目目录创建数据库 createdb tokyo_trains psql -d tokyo_trains -f db/schema.sql # 安装 Python 依赖 pip install fastapi uvicorn psycopg2-binary # 运行导入脚本 python scripts/import_gtfs.py # 启动后端服务 uvicorn api.main:app --reload --port 8000启动成功后浏览器访问http://localhost:8000/docs可以看到 Swagger 文档这是一个快速验证 API 是否正常的手段。7.2 启动前端cd web npm install npm run dev访问http://localhost:5173地图加载后应该能看到站点点位。点击任意站点浏览器控制台会打印出该站的下一班车列表。7.3 用 curl 验证数据正确性# 查询所有地铁线路 curl http://localhost:8000/routes?route_type1 # 查询某个站点的下一班车 curl http://localhost:8000/stops/{stop_id}/next_trains?limit5判断成功的标准很简单线路列表返回的route_color能正确区分不同公司下一班车返回的记录里arrival_time全部晚于当前时间同一个站点的多条记录能按时间顺序排列。如果返回为空先看数据库里stop_times表有没有数据再看查询条件是不是把arrival_time_min和当前时间比较错了。## 8. 常见问题与排查思路 | 问题现象 | 可能原因 | 排查方式 | 解决方案 | | --- | --- | --- | --- | | 导入脚本报 UnicodeDecodeError | GTFS 文件不是 UTF-8或带 BOM | 用 Python 打开文件并打印前几行 | 读取时尝试 utf-8-sig失败回退 Shift-JIS | | 地图上站点位置明显偏移 | 经纬度字段和几何字段不一致 | 对比 stops 表的 stop_lat、stop_lon 和 geom | 统一在导入后执行 UPDATE ... SET geom ST_SetSRID(ST_MakePoint(stop_lon, stop_lat), 4326) | | 下一班车查不到数据 | arrival_time 存成了超过 24:00 的文本比较逻辑出错 | 手动查询 stop_times 表检查最大时间值 | 导入时把文本时间转换为当天分钟数整数 | | 同一条线路显示成多条独立线段 | 缺少线路几何聚合前端按 trip 分段绘制 | 检查数据库 routes 与 stop_times 关联是否正常 | 在后端按 route_id 聚合站点序列生成 GeoJSON LineString | | 站点点击无反应 | 前端 GeoJSON 属性名与代码不一致 | 打开浏览器 Network 面板查看接口返回 | 统一 stop_id 属性名并在代码里打日志确认事件绑定 | | 换乘路径绕远或提示不可达 | BFS 图构建不完整某些站点没有相邻边 | 单独查询某个站点的邻居数量 | 检查 stop_sequence 是否连续补全缺失的 trip 数据 | 排查交通数据应用问题时最重要的原则是先数据后代码。不要一上来就怀疑前端渲染先查数据库里的数据形态对不对再查接口返回最后再看前端图层。9. 工程化建议与最佳实践9.1 数据更新与版本管理静态数据不是永久不变的。线路调整、站台更名、时刻表季度更新都会改变原始数据。建议把每次下载的 GTFS 压缩包按版本号存储并在数据库里记录数据版本号。这样排查问题时你能立刻确认“当前线上数据是哪一版”。如果团队有多个人协作最好用脚本统一管理数据更新任务不要靠手动下载再导入的方式。用 CI 定时任务每周检查一次上游数据是否有新版本有新版本时自动跑一遍导入流程。9.2 缓存与性能站点列表和线路几何属于低变更数据可以在后端加一层内存缓存优先缓存整张线路图。下一班车查询属于高频查询但数据量大时索引设计比缓存更重要。stop_times表的查询条件集中在stop_id和arrival_time_min这两个字段必须建联合索引否则数据量上去后接口会明显变慢。地图切片也是一样的道理。如果站点数量几千个前端一次性加载 GeoJSON 还能接受如果扩展到全东京几万个站点一定要做格式压缩或矢量切片。9.3 多语言与站名匹配东京轨交应用面向的读者可能是中文、日文、英文用户。站名展示不能只存一个字段建议设计成“原始名 展示名”两列。更细的做法是维护一张stop_name_i18n表按语言区分展示文本。CREATE TABLE stop_name_i18n ( stop_id TEXT REFERENCES stops(stop_id), lang TEXT, name TEXT, PRIMARY KEY (stop_id, lang) );中文界面下调用接口时传入langzh后端根据当前语言返回对应站名。如果某些站没有对应翻译就回退到日文原名不要返回空字符串。9.4 安全与合规如果接入了第三方 APIAPI Key 必须放在后端不能写进前端代码。否则任何访问页面的人都可能从浏览器的网络面板里拿到你的 Key这是最常见的泄露方式之一。如果涉及实时列车位置必须先确认运营商的数据使用条款。不同企业对数据用途、缓存时间、转发范围的要求差别很大。即使某家运营商开放下载也可能禁止把数据合并后二次分发。稳妥的方法是只保留本地演示数据不对外提供未经授权的数据转发接口。9.5 生产环境注意事项数据库连接不要写死使用环境变量或配置中心管理导入任务要支持失败重试和事务回滚路线规划的 BFS 图要在服务启动时构建不要放到请求路径里每次重建前端地图样式和地理数据分离方便替换不同底图上线前至少准备一天完整的测试数据覆盖工作日和周末两种服务模式。## 10. 总结与后续学习方向 Tokyo Trains 这类项目的核心价值是把一张复杂的城市交通网络整理成可查询、可展示的数据产品。这篇内容围绕实现路径展开真正重要的经验可以浓缩成三句话 1. 先解决数据再做交互。数据格式不稳前端做得再好看也只是空中楼阁 2. 用静态数据跑通原型。实时数据接入应该在晚些阶段按运营商逐个解决 3. 换乘规划从简单算法起步。先让路线可达再逐步优化换乘体验。 如果你想继续深入可以从这几个方向下手 - 把 GTFS 时间转换和时间表日期的逻辑梳理成独立工具库 - 研究 Dijkstra 算法与换乘惩罚函数优化路线规划的体验 - 接入某一两个运营商的实时数据观察静态查询与实时位置的落差 - 给前端增加语言切换处理中日英三语站名映射。 动手之前先准备一份覆盖 3 到 5 家运营商的小型数据包亲手跑通从导入到前端展示的完整链路。数据问题只有在真实数据上踩过一遍才真正算数。