写于 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 不现实。我把采集拆成三条路,可以并存:

  1. 手写 JSON:示范级人物(李白、杜甫、苏轼、项羽……)值得抠;
  2. 整理台 + 本机大模型:把传记贴进去,抽成结构化草稿,再入库;
  3. 队列自动跑queue.json 里排队,脚本调本机 LM Studio,写 JSON、导入 SQLite。

参考源尽量用能公开抓摘要的:快懂百科、搜狗百科搜索卡片、CBDB、维基摘要。正史和通鉴留给人工。头像优先 Wikimedia Commons 的历史绣像、石刻,下不到就前端画一个朱砂圆印——缺图比错图好。

自动采集的提示词很苛刻:只输出一个 JSON,字段和主数据对齐。队列空了会从候选名录补货。这套东西不走云、不耗编辑器额度,晚上开着 LM Studio 就能慢慢填库。

质量上必须认账:早期全库迁徙中位数大约 6 条,九成只有年份。地图上表现为一生几跳。所以后来单开了一条「名人重采」:三十多人按维基年表 + CBDB 任职 + 作品系年重抽,目标每人十几到四十个有据点。默认自动采集先不动,避免八百人一起被冲掉。

第三阶段:JSON 变成草稿,SQLite 变成主库

人一多,全量 JSON 打进前端包就难受。查询也不方便——「东汉这一段在这片经纬度里走过哪些人」用文件扫很别扭。

于是本地权威源改成 SQLite:

用途
people人物主档
migrations迁徙路径点
works诗文与事迹
collect_queue待整理队列
fts_allFTS5 全文

JSON 还在,角色变成草稿和 git 友好的备份。db:importpeople/*.json 灌进库;开发时 Hono 在 :5221 提供查询和 upsert。前端整理台可以直接把抽好的人写回去。

这一步很舒服,也埋了一个生产事故:开发时前端走 /api/people/full,部署到静态站以后这个 API 不存在,人物列表变成空数组,地图上空无一人。热修是把全量数据重新打进 JS,可用性回来了,首屏优化也没了。

这才逼出后面的静态分层——后面架构一节会细写。

第四阶段:让「走」看起来像走

插值的第一版是:两次迁徙记录之间,整段时间做线性插值。问题是记录常隔几年甚至几十年。于是李白会在成都和江陵之间爬两年——迁徙日期的语义是「迁至 / 居于」,不是「这两年都在路上」。

改法是按路程和时代交通速度算旅途天数:

年份日速上限
1912 年以前40 km/日60 天
1912–1949200 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                  │
                    └─────────────────────────────────────────┘

两句话:

  1. 本机是唯一完整库。
  2. 生产地图是静态文件。

前端:一张地图,一个 store

界面很克制。中央是腾讯地图;底下时间轴;右侧人名与详情,可收成窄轨;左上角年号提示。搜索可以点侧栏,也可以在地图上直接打拼音。

状态集中在 Zustand,不往 localStorage 塞全量人物——早期这么干过,会卡死首屏。store 里真正重要的是:

  • people:当前可见的人物运行时对象;
  • currentSerial:时间轴位置;
  • selectedId / focusedIds:选中与「只看这些人」;
  • dataSourcesqlite 还是 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.jsonVIP 完整稿,首页冒泡更丰满
详情people/{id}.json按需单人全文:note、works、avatar
清单manifest.json版本与人数,用来做缓存破坏

运行时组装规则:

人物 = catalog 行 + paths 迁徙(note 为空)+ 已加载的 detail 覆盖

hydrate 顺序:

  1. 开发环境先试 /api/people/full,成功则整库进 store,整理台可写;
  2. 失败(生产必然失败)则并行拉 catalog + paths + featured;
  3. 选中、聚焦、需要冒泡时,再 GET people/{id}.json 合并;
  4. 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)

做这个项目的快感,很大一部分不在框架,而在一种错觉变得可操作:历史不再是一排专章年表,而是一群坐标随时间滑动。拖到建安十三年,赤壁附近会挤上一些名字;拖到天宝年间,长安、成都、江陵上同时有人——有的你熟,有的你只在别人的传记里见过一笔。书里分开写的人,在那一年其实住在同一张地图上。

精确性仍然有限。地图会骗人,模型会编,古地名会对错。我能做的是把「有据」和「猜测」分开,把「能静态活下来」的架构先做好,再慢慢把轨迹加密。

若你也想在地图上看见某个人的一生,可以直接打开 古人行迹:拖到他活着的那一年,同代人就在旁边。