写于 2026 年 8 月。这是一个个人项目的阶段性记录,不是产品发布稿。
读史时我常有一种错位感:传记写得很细,人却像钉在纸上。孔子周游列国、李白出蜀、苏轼贬谪、郑和下西洋——这些句子人人会背,可一旦问「他那年到底站在哪」,脑子里往往只剩一个模糊的方向。更别提旁边还有谁:书本习惯围着某几个名人转,同时代的其他人,以及他们当时各自在哪,几乎从不当真事来看。
我想做一个能拖动的时间轴:拖到某一年,地图上只留下当时还在世的人,并且落在各自那年的位置上;点开某个人,轨迹、诗文、事迹按日期冒出来。不是又一个百科词条列表,而是把「时间 × 地点 × 事件」当成第一等数据,也把「这一年的同代人」摊开在同一张图上。
于是有了 古人行迹。
现在库里大约 860 人,从先秦到近现代。生产环境是纯静态站点,挂在个人站的子目录里,不跑数据库。打开页面就能拖时间轴、看彩带轨迹、搜人名。这篇文章记下为什么做、怎么做成现在这样、以及几处反复改过的架构取舍。
一、最初的想法
1. 历史人物是「动」的
人物传记的默认形态是年表:某年生,某年中进士,某年贬某地,某年卒。读起来像清单。地图应用的默认形态则是「把点钉上去」——出生地一个针,做官地一个针,卒地一个针。两者都丢掉了中间那段:人是怎么从 A 走到 B 的,走的时候路上还发生了什么。
我想要的交互很简单:
- 时间轴从公元前拖到近代,地图上的人随生卒出现、消失;
- 在世者按迁徙记录移动,轨迹留下彩带;
- 到了某年某地若有诗、有战、有政,就冒一个气泡。
如果只能记住一件事,就是这句话:迁徙记录驱动地图,作品/事迹驱动冒泡,生卒驱动存亡。 其他都是围绕这三件事长出来的。
2. 同时代的人,以及他们当时在哪
另一件事,是课本和评传几乎从不让你看见的。
读杜甫,你会记住安史之乱、成都草堂、夔州、湖南;读李白,你会记住酒、长安、夜郎。两人在诗里互相提起,于是我们知道他们「同时代」。可同一年里,王维在不在长安?颜真卿在哪做官?安禄山军在河北还是已经过了洛阳?这些名字若不是主角,就会从视野里掉出去。不是史料没有,是阅读方式把他们切成了互不相干的专章。
时间轴要做的,正是把这种切法反过来:先选定一个年份,再问「谁还活着,人在何处」。 地图上出现的不该是「杜甫专题」,而该是天宝十四年这一刀切下去后,还在世的那一群人——有的在京城,有的在贬所,有的在路上。点开其中任何一个,才进入他的一生;拖走时间,这群人换一批,位置也跟着变。
所以库里不能只有那几个家喻户晓的名字。八百人里大多数不会被写成示范级游踪,但他们必须在。否则「同时代」只是几个 VIP 互相看见,和翻书没太大差别。名人细、路人粗,粗的那一层不是凑数,是为了让某一刻的中国不是空的。
3. 数据精度必须诚实
历史地理天然不精确。很多条目只有年份,没有月日;很多地点是古地名,现代对应只能估;很多「路过三峡」并没有史料写他在哪一天停过。
所以从第一天起,日期就带精度:年 / 月 / 日。缺月就落到该年正月,但精度字段还在,展示时不会假装「精确到日」。迁徙点后来又拆成两类:
- 有据(attested):材料里出现过的驻留,写入人物数据;
- 途经(via):出蜀沿江、入京函谷这类走廊上的必经点,播放时插入,不入库。
途经点置信度故意压得很低。关「必经」开关,地图就退回有据点之间的直线。我不想让模型给李白编一套路、给杜甫再编一套不一致的路。
4. 业余维护,慢慢收集
这是下班以后自己玩的项目,没有团队,也没有更新日程。人是一点一点加的,愿意接受「名人细、路人粗」——八百人不可能人人示范级。缺的以后再补,错的以后再改。
二、实现过程:从一个 Demo 长成一条流水线
项目从 2026 年 7 月下旬搭起来,到 8 月底大约一个月。中间没有一次「推倒重来」,但数据层换了三次形态。按时间说比较清楚。
第一阶段:能播就行
最早的形态非常直白:
- Vite + React + TypeScript;
- 腾讯地图 GL 画底图、折线、标记;
- Zustand 里放「当前日期、是否播放、选中谁」;
- 人物写在 JSON 里,几个样例(李白、杜甫、苏轼)就够验证交互。
时间不用 Date,因为要覆盖公元前。每个历史日期压成一个可排序的序列日(近似儒略日),时间轴拖的是这个整数。缺月缺日用默认值补齐,精度单独保存。
地图上的位置是插值算出来的:已知若干迁徙点,给定当前序列日,求出此人此刻的经纬度、是否在移动、朝向。彩带是「走到当前为止」的折线,不会先画到终点再折回来。
这一阶段解决的是「像不像那么回事」。像了之后,立刻碰到两个问题:人太少,以及人在路上爬得太慢。
第二阶段:人从哪来
手写 860 份人物 JSON 不现实。我把采集拆成三条路,可以并存:
- 手写 JSON:示范级人物(李白、杜甫、苏轼、项羽……)值得抠;
- 整理台 + 本机大模型:把传记贴进去,抽成结构化草稿,再入库;
- 队列自动跑:
queue.json里排队,脚本调本机 LM Studio,写 JSON、导入 SQLite。
参考源尽量用能公开抓摘要的:快懂百科、搜狗百科搜索卡片、CBDB、维基摘要。正史和通鉴留给人工。头像优先 Wikimedia Commons 的历史绣像、石刻,下不到就前端画一个朱砂圆印——缺图比错图好。
自动采集的提示词很苛刻:只输出一个 JSON,字段和主数据对齐。队列空了会从候选名录补货。这套东西不走云、不耗编辑器额度,晚上开着 LM Studio 就能慢慢填库。
质量上必须认账:早期全库迁徙中位数大约 6 条,九成只有年份。地图上表现为一生几跳。所以后来单开了一条「名人重采」:三十多人按维基年表 + CBDB 任职 + 作品系年重抽,目标每人十几到四十个有据点。默认自动采集先不动,避免八百人一起被冲掉。
第三阶段:JSON 变成草稿,SQLite 变成主库
人一多,全量 JSON 打进前端包就难受。查询也不方便——「东汉这一段在这片经纬度里走过哪些人」用文件扫很别扭。
于是本地权威源改成 SQLite:
| 表 | 用途 |
|---|---|
people | 人物主档 |
migrations | 迁徙路径点 |
works | 诗文与事迹 |
collect_queue | 待整理队列 |
fts_all | FTS5 全文 |
JSON 还在,角色变成草稿和 git 友好的备份。db:import 从 people/*.json 灌进库;开发时 Hono 在 :5221 提供查询和 upsert。前端整理台可以直接把抽好的人写回去。
这一步很舒服,也埋了一个生产事故:开发时前端走 /api/people/full,部署到静态站以后这个 API 不存在,人物列表变成空数组,地图上空无一人。热修是把全量数据重新打进 JS,可用性回来了,首屏优化也没了。
这才逼出后面的静态分层——后面架构一节会细写。
第四阶段:让「走」看起来像走
插值的第一版是:两次迁徙记录之间,整段时间做线性插值。问题是记录常隔几年甚至几十年。于是李白会在成都和江陵之间爬两年——迁徙日期的语义是「迁至 / 居于」,不是「这两年都在路上」。
改法是按路程和时代交通速度算旅途天数:
| 年份 | 日速 | 上限 |
|---|---|---|
| 1912 年以前 | 40 km/日 | 60 天 |
| 1912–1949 | 200 km/日 | 14 天 |
| 1949 以后 | 800 km/日 | 7 天 |
到达记录日才开始走;走完停在目的地,直到下一次迁徙。同地小于 1 公里不播动画。这不是历史交通仿真,只是让播放节奏不要荒谬。
路径形状另说。两点直线会穿过秦岭、横渡没有渡口的河。精确贴路网成本太高,我做了折中:共享走廊。第一期八条,比如出蜀沿江、入京函谷、下扬州运河、岭南贬谪。两站若落在同一条走廊上,播放时插入中间必经点,沿折线插值。走廊按年代过滤,人物 JSON 里不写这些点。
到这里,产品形态基本稳定:本机生产数据,静态站消费数据。
三、架构设计
总览
系统其实是两条轨道,不要拧成一条。
┌─ 本机权威 ─────────────────────────────┐
百科/CBDB/维基 ──►│ JSON 草稿 → SQLite (guren.db) │
LM Studio 抽取 ──►│ 整理台 / auto-collect / enrich-vip │
手写示范稿 ──►│ │
└──────────────┬──────────────────────────┘
│ npm run build
│ 导出静态分层
▼
┌─ 生产静态站 ────────────────────────────┐
│ dist/ → 个人站 /projects/guren-way/ │
│ 地图只读 catalog / paths / people/{id} │
│ 无 SQLite,无人物 API │
└─────────────────────────────────────────┘两句话:
- 本机是唯一完整库。
- 生产地图是静态文件。
前端:一张地图,一个 store
界面很克制。中央是腾讯地图;底下时间轴;右侧人名与详情,可收成窄轨;左上角年号提示。搜索可以点侧栏,也可以在地图上直接打拼音。
状态集中在 Zustand,不往 localStorage 塞全量人物——早期这么干过,会卡死首屏。store 里真正重要的是:
people:当前可见的人物运行时对象;currentSerial:时间轴位置;selectedId/focusedIds:选中与「只看这些人」;dataSource:sqlite还是static,决定要不要按需拉详情;- 图层开关:轨迹、气泡、必经点。
地图组件自己持有腾讯地图实例和折线/标记图层,按 store 派生出「这一刻每个人在哪」。人物页着陆时会聚焦到此人一生、拉近到迁徙范围、时间轴缩成这段寿命。Esc 退回全图。
整理台是懒加载面板,不进首屏包。
时间与地理:纯函数,尽量可测
这部分我有意不和 React 缠在一起。
date.ts:历史日期规范化、序列日互转、年龄;geo.ts:球面距离、方位、旅途天数、沿折线插值、locatePersonAt;corridors.ts:两站之间匹配哪条走廊、插入哪些途经点;places.ts:古地名 → 现代名与坐标的兜底表;era.ts:年号第 N 年 ↔ 公元年。
播放逻辑可以单测,不需要起地图。后来加拼音搜索、静态导出,也是同一习惯:先抽纯函数和测试,再接线。一个月里功能堆得快,全靠这个才没把地图组件变成上帝对象。
人物数据的三层
这是生产架构里最关键的一块。
一次加载 860 人的完整稿(迁徙 note、诗文摘句、头像元数据)既重又不必要——默认地图只需要「谁、何时、在哪」。点开某人才需要诗和事迹。于是构建时从 SQLite 导出五类文件:
| 层 | 文件 | 首屏是否加载 | 内容 |
|---|---|---|---|
| 名录 | catalog.json(约 200KB) | 是 | id、姓名、字号、朝代、生卒、籍贯 |
| 轨迹 | paths.json(约 276KB) | 是 | 每人精简迁徙:年 + 坐标 + attested/via |
| 精选 | featured.json | 是 | VIP 完整稿,首页冒泡更丰满 |
| 详情 | people/{id}.json | 按需 | 单人全文:note、works、avatar |
| 清单 | manifest.json | 是 | 版本与人数,用来做缓存破坏 |
运行时组装规则:
人物 = catalog 行 + paths 迁徙(note 为空)+ 已加载的 detail 覆盖hydrate 顺序:
- 开发环境先试
/api/people/full,成功则整库进 store,整理台可写; - 失败(生产必然失败)则并行拉 catalog + paths + featured;
- 选中、聚焦、需要冒泡时,再
GET people/{id}.json合并; - catalog 或 paths 损坏必须报错,禁止把空数组当成「已加载」。
最后一条是那次空白地图事故换来的。API 挂了可以降级,但不许静默变成无人。
构建流水线是:
tsc → vite build → 导出 dist/data客户端主包不再静态 import 八百多个 JSON。
本地 SQLite 仍在。它服务的是整理和查询,不是线上读路径。规模再往上(十万级路径点)再考虑 Postgres + PostGIS;当前 WAL 模式足够。
采集与发布:人和机器的分工
queue.json / 候选名录
│
▼
auto-collect(本机 LM)──► people/<id>.json ──► db:import
│
├── enrich-vip:名人加密有据点
├── fetch-avatar:Commons → public/avatars + 回写 JSON
└── 手修示范稿(李白出蜀、项羽起兵这类)
│
▼
data/guren.db
│
▼
export-static → dist/ → 同步个人站有据点只在材料里「有年有地」才记。诗里写到、人未必到过的地名不当行踪。没有月日就不编月日。走廊途经点永远是运行时的,避免每人一份互相打架的「标准路线」。
四、几次反复确认过的取舍
播放不像仿真,但必须不像爬行。 日速和旅途上限都是拍的。目的只是让「迁至」这个语义在动画里成立。真要做驿道、运河、季风,那是另一个项目。
名人细、路人粗。 自动采集给了覆盖面,示范级重采给了可看性。用户点开孔子、李白、苏轼,应该看到密的;点开一个只有籍贯的人,至少还有一个锚点,而不是空白。覆盖面本身就是功能:没有足够多的「路人」,拖到某一年就看不见同代人。
静态站优先于「正确的服务端架构」。 人物查询用 SQLite 更优雅,但个人站子目录托管更省事。分层 JSON 是在这个约束下兼顾完整轨迹和首屏体积的办法。以后人到数千、paths.json 膨胀,再按时间窗分片不迟。
权威源在 git 够得到的地方。 JSON 能 diff,SQLite 能查,静态层能缓存。生产环境只吃打包结果,不直接碰主库。
五、还没做、也不急着做的
- 走廊只有八条,远称不上路网;
- 全库大多数人仍然偏稀,只有 VIP 经得起细看;
- 静态层还没有 IndexedDB,二访没有「秒开」;
- 没有沿河、沿驿道的真正贴合;
- 史料冲突没有版本和出处页,只有字段上的
source/confidence。
这些我都知道。古人行迹目前的完成定义是:在个人站点上,拖动时间轴,能看见某一刻还在世的人落在各自的位置上;点开其中几个名字,轨迹和诗文对得上。同代人能同时出现,比单人传记更接近我当初想看的东西。
六、技术栈(方便对照)
| 层 | 选择 |
|---|---|
| 地图前端 | Vite · React 19 · TypeScript · Zustand · 腾讯地图 GL · React Router |
| 本机数据 | SQLite(better-sqlite3)· Hono API |
| 采集 | 本机 LM Studio(OpenAI 兼容)· CBDB / 百科摘要 |
| 生产 | 静态 dist/(分层 JSON) |
做这个项目的快感,很大一部分不在框架,而在一种错觉变得可操作:历史不再是一排专章年表,而是一群坐标随时间滑动。拖到建安十三年,赤壁附近会挤上一些名字;拖到天宝年间,长安、成都、江陵上同时有人——有的你熟,有的你只在别人的传记里见过一笔。书里分开写的人,在那一年其实住在同一张地图上。
精确性仍然有限。地图会骗人,模型会编,古地名会对错。我能做的是把「有据」和「猜测」分开,把「能静态活下来」的架构先做好,再慢慢把轨迹加密。
若你也想在地图上看见某个人的一生,可以直接打开 古人行迹:拖到他活着的那一年,同代人就在旁边。