江山时序源码拆解:数据从哪来,26MB 疆域怎么压成 2MB
DeanChensj/jiangshan-map,中文名「江山时序」,一个从战国(公元前 403 年)到今天的中国历史交互地图:拖时间轴看 18 个朝代的疆域涨落,点任何古城看它两千年改名换姓的沿革,搜一个人(苏轼、玄奘、王阳明)把他一生的行迹画在变迁的疆域上。2026 年 7 月建仓,4496 star、516 fork、320 个 commit,GPL-3.0,作者 Dean Chen。
我上一版写这篇时,基本是按文件名把仓库走读了一遍——说了有哪些脚本、各管什么,却没把任何一段真正的实现代码摆出来。那是偷懒。这次重写,我从 jsDelivr 把 build_atlas.js、audit_topology.js、sync_version.js 和 668KB 的 app.js 拉下来逐段读,下面引用的代码都来自仓库原文,只做删节、不做改写。
一句话结论没变:直接拿来用(教学、写作、做内容配图)完全值得,深链和 embed 设计得很好;但二次开发前必须先搞清许可红线——郡县边界那层是哈佛 CHGIS 的,学术用途、不得再分发,连从它的仓库转载都不行。 变化的是深度:这次把"数据从哪来"落到每个 GeoJSON 字段,把"怎么压"落到 varint 的位运算,把"怎么画"落到投影矩阵和贝塞尔圆角。
本文提纲
- 数据从哪来:三层来源,一条许可红线
- 一张快照的解剖:GeoJSON 里到底存了什么
- 构建:26 MB 源文件怎么变成 2 MB
- VLQ 增量编码:一个环怎么变成一串字母
- 运行时:惰性 getter 与首屏 boot seed
- 渲染:自研 Mercator 与 SVG 路径
- 标注:分层优先级与贪心防碰撞
- 时间轴、URL 状态与 embed 模式
- 质量围栏:拓扑审计棘轮与克制的自动修边
- 缓存:内容哈希,而不是文件名
- 知识层:纪年正统与人物行迹
- AI 原生设计:MCP server、Agent Skill 与 llms.txt
- 怎么用:本地跑起来,以及改数据的完整循环
- 横向比较
- 失效边界
- 成本
数据从哪来:三层来源,一条许可红线
仓库 data/atlas/README.md 把来源交代得比大多数学术项目还诚实,而且明确承认"原始导入脚本不在仓库中,以上来源依据项目早期文档整理"。这句话很关键——它意味着数据溯源是事后重建的,不是从第一天就归档的。三层几何,来源完全不同:
MERMAID_BLOCK_0
郡/州/府级边界(prefectures) 来自 CHGIS V6(哈佛费正清中心 + 复旦中国历史地理研究所)和 Robert M. Hartwell 中国历史 GIS。这是全项目最权威、也最烫手的一层:原始许可是学术、非商业、不得再分发,明确不适用 GPL-3.0。README 的原话是"请勿从本仓库转载,如需郡州府边界,请从 CHGIS / Harvard Dataverse 官方渠道获取",并给出了标准引用格式(CHGIS Version 6,2016)。
政权轮廓(polities) 来自 aourednik/historical-basemaps(GPL-3.0),这是能合法再分发的部分。一级政区(regions:刺史部/道/路/省) 依据史籍手工绘制,README 直说"早期版本来源未记录",而且"多数与郡县层不共享边界"——这句话解释了为什么后面那套拓扑审计只审政权、不审政区。
在此之上是全部几何被简化到 0.01° 精度、参照谭其骧《中国历史地图集》逐张核对,但 README 写死了法律分寸:"本仓库不包含谭图的任何图像或数据",谭图只作参照。每个数据文件头部还嵌着一条会跟着文件流出的许可声明:
{
"type": "FeatureCollection",
"license": "Approximate historical boundaries for illustration, not surveyed borders. When reusing, credit Jiangshan Atlas (https://github.com/DeanChensj/jiangshan-map) and the sources in data/atlas/README.md. Prefecture-level (郡/州/府) boundaries derive from CHGIS V6 and the Hartwell China Historical GIS: academic, non-commercial use only; do not redistribute; not licensed under GPL-3.0.",
这段 license 不是给人看的装饰。build_atlas.js 会在每次构建时把 LICENSE_NOTICE 常量重新戳进每一个 GeoJSON,注释写得很明白:Stamped into every snapshot file so the terms travel with files copied out of the repo. 数据一旦被复制出去,条款跟着走。
人文数据是另一条管线:纪年表、君主世系、诗人行迹、城池沿革,对照《资治通鉴》和各朝正史手工整理。作者博客里有两个真实纠错案例——放大地图才发现辽国都城上京被画在辽国疆域之外,只好回头补北疆;横跨欧亚的蒙古帝国在罗布泊破了个窟窿,查了半天才补上。历史 GIS 的数据质量问题不是学术假设,是这种项目每天踩的坑。
一张快照的解剖:GeoJSON 里到底存了什么
打开 data/atlas/donghan/s_donghan_d.geojson,结构不是标准的"一个 FeatureCollection 对应一张图",而是多了一个 snapshot 成员,把整张地图的元信息和要素清单装在一起:
{
"type": "FeatureCollection",
"snapshot": {
"caption": "200—207 年 · 官渡之战与曹操统一北方",
"note": "200 年曹操于官渡之战以少胜多击败袁绍主力……",
"prefRef": "s_donghan",
"regionRef": "s_donghan",
"polities": [
"曹操(北方)",
"s_donghan_d1/公孙氏(辽东)",
"s_donghan_d2/马腾·韩遂(关陇)",
"s_donghan_d2/张鲁(汉中)",
"s_donghan_b2/南匈奴(内附)"
]
},
"features": [
{
"type": "Feature",
"id": "曹操(北方)",
"properties": {
"title": "曹操(北方)",
"tint": "#4a6fa5",
"anchor": [114.8, 35.8],
"role": "main"
},
"geometry": { "type": "MultiPolygon", "coordinates": [[[[110.39, 35.88], "..."]]] }
}
]
}
这里藏着三套引用规则,决定了整个项目的可维护性:
- 裸 id(
"曹操(北方)")指向本文件内的 feature。 快照键/id("s_donghan_d1/公孙氏(辽东)")指向另一个快照文件里的 feature,id 本身不允许包含/(构建时会校验)。prefRef/regionRef指向郡县层和政区层所在的快照。
于是"同一个政权在相邻快照间通常不变"这件事有了低成本解法:要素只在最早用到它的文件里定义一次,后续快照只写引用。 改齐国一条边界,所有引用它的快照全部跟着变;想在某阶段单独改,就复制成新 id,切断共享。
properties 里四个字段各有用途:title 是显示名,tint 是填色,anchor 是标注锚点(经纬度),role 决定它在审计和渲染里的地位。role 的取值在 audit_topology.js 里被明确定义:
const CORE_ROLES = new Set(['main', 'rival', 'protectorate']);
main(主角政权)、rival(对手)、protectorate(藩属)是会被审计边界严丝合缝的核心;neighbor 是"周边模糊地带",画得虚、允许对不上,直接豁免。用数据字段把"哪条边界必须精确"这个判断显式化了,而不是靠人记。
构建:26 MB 源文件怎么变成 2 MB
先把账摆出来。仓库的 197 个 GeoJSON 源文件合计 26.47 MB,再加上 index.json 的清单,编译产物是两个 bundle:
| 产物 | 大小 | 内容 |
|---|---|---|
js/atlas_boot.js |
72 KB | 首屏 3 张快照(neighbors、s_zhanguo_a、s_qin_a) |
js/atlas_snapshots.js |
2.09 MB | 全部 197 张快照,懒加载 |
data/atlas/index.json |
3.6 KB | boot 清单 + order(全部 197 个键的规范顺序) |
26.47 MB → 2.09 MB,约 12.7 倍;首屏只要 72 KB,是源数据的 0.27%。这不是靠 gzip 魔法,是三层处理叠出来的:字段去冗余、整环去重、坐标增量编码。
第一层是规范化。normalizeGeometry 干三件事:删掉连续重复点、闭合每个环、丢掉顶点少于 3 个的塌缩环(外环塌缩时连它包着的洞一起丢)。同时 toFixedPoint 强制每条坐标最多两位小数,多一位就直接报错:
const SCALE = 100;
function toFixedPoint(value, where) {
const scaled = Math.round(value * SCALE);
if (!Number.isFinite(value) || scaled / SCALE !== value) {
throw new Error(`${where}: coordinate ${value} must be a finite number with at most two decimals`);
}
return scaled;
}
注意这里用 scaled / SCALE !== value 判精度,不是四舍五入后放行。数据里出现 110.392 会直接让构建失败——源头治精度,而不是渲染时再处理。这也是那套"0.01° 精度"承诺能被机器验证的原因。
第二层是引用解析。所有 polities/prefectures/regions 数组里的字符串,都要在这两个 pass 里变成整数下标:
// Pass 1: register every feature under "<snapshotKey>/<id>".
json.features.forEach((f, i) => {
const ref = `${key}/${f && f.id}`;
// ... 校验 type / id / properties,normalizeGeometry
featureIndex.set(ref, features.length);
features.push(f.geometry ? { ...f.properties, shape: f.geometry } : { ...f.properties });
});
// Pass 2: resolve snapshot feature lists to indices.
const resolve = (ref, key, rel) => {
const qualified = typeof ref === 'string' && ref.includes('/') ? ref : `${key}/${ref}`;
const idx = featureIndex.get(qualified);
if (idx === undefined) {
errors.push(`${rel}: unknown feature "${ref}"`);
return -1;
}
referenced.add(qualified);
return idx;
};
构建还会顺手查两类"数据卫生"问题:同一快照键被两个文件定义、order 里漏了某个文件、以及定义了却没有任何快照引用的孤儿要素(feature "X" is not referenced by any snapshot)。这类校验在人工编辑 197 个文件的规模下是刚需。
第三层才是编码。两个 bundle 共用一套运行时解码器,文件头就是解码字母表和反查表:
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-';
const R = new Uint8Array(128);
for (let i = 0; i < 64; i++) R[A.charCodeAt(i)] = i;
64 个字符 = 6 bit,但每个字符只承载 5 bit 有效数据,最高位(第 5 位,值 32)当续位用——这就是标准的变长整数(varint)+ 续位编码,只是把 base64 换成了这套字母表。
"拓扑去重"这个说法需要精确一点。 bundle 的注释写的是 Lossless Topology-Deduplicated,实现上做的是整环去重 + 整要素去重,不是顶点级拆分:
const ringId = (ring, where) => {
const str = encodeRing(ring, where);
let id = ringIds.get(str);
if (id === undefined) {
id = rings.length;
rings.push(str);
ringIds.set(str, id);
}
return id;
};
一个环编码成字符串后作为 map 的 key,完全相同的环只存一份。要素同理,用 JSON.stringify(raw) 做指纹:
const json = JSON.stringify(raw);
let id = featIds.get(json);
if (id === undefined) {
id = feats.length;
feats.push(raw);
featIds.set(json, id);
}
为什么整环去重就够?因为跨快照引用已经把"同一块疆域"消解成了同一个要素对象。辽宋之间那条共享边界,本来就是同一个多边形的边,不存在两条独立顶点链需要合并。整环去重额外处理的是另一类情况:同一张图里两个政权用了完全相同的环(飞地、共管区、抄同一份底图时的重复),以及相邻快照里被完整复用的形状。
编号也是规范化的:按 index.json 的 order 走,要素和环都按首次出现顺序编号。这带来一个很有用的性质——构建是确定性的,同样的源一定产出字节相同的 bundle,于是 --check 可以只做字符串比较:
outputs[FULL_PATH] = encodeBundle(doc, { keys: index.order });
outputs[BOOT_PATH] = encodeBundle(doc, { keys: index.boot, isBoot: true });
CI 里跑 node scripts/build_atlas.js --check,任何一个源文件格式不 Canonical、或者改了源忘了重建 bundle,都会失败。生成物入仓 + 字节级比对,在没有 npm 生态的项目里是最便宜的一致性保障。
VLQ 增量编码:一个环怎么变成一串字母
这是全文我最想让人看清的一段。encodeRing 把每个环的坐标转成定点数,然后编码相邻点的差值,不是绝对坐标:
function encodeVarint(delta) {
let v = delta < 0 ? (~delta << 1) | 1 : delta << 1;
let out = '';
while (v >= 32) {
out += ALPHABET[(v & 31) | 32];
v >>>= 5;
}
return out + ALPHABET[v];
}
function encodeRing(ring, where) {
let out = '';
let px = 0;
let py = 0;
for (const pt of ring) {
const x = toFixedPoint(pt[0], where);
const y = toFixedPoint(pt[1], where);
out += encodeVarint(x - px) + encodeVarint(y - py);
px = x;
py = y;
}
return out;
}
拆开看三个设计:
一、定点数。 坐标先乘以 100(SCALE),0.01° 精度变成整数。30.25° → 3025,没有浮点尾数,解码端除以 100 就还原。
二、ZigZag。 delta < 0 ? (~delta << 1) | 1 : delta << 1 把有符号整数映射成无符号:0→0,-1→1,1→2,-2→3。这样小数(绝对值小)无论正负都落在小的编码值上,负数的符号位不会占满整条编码。
三、续位分组。 每 5 bit 一组,最高位(| 32)表示"后面还有"。一个 ±100 以内的差值通常 1 个字符搞定;军团级的大跳(跨经度 10°,即 1000 个单位)也就 2-3 个字符。
效果在真实的 atlas_boot.js 里能看到。第一个环编码后是一长串这样的字符:
2pMq9GMWQUGYIYcCaEaGaGaGaGYKWMYIWOUQGYKWIWAYEYWkBmBUYIYIWIYCaCgBmCaLYJWLWRaDYFYAYCYCYBaAaJcJYJaLYJcDcDcFaKYKYIYIWOYOkCn...
解码器是它的镜像,并且按需解码:
function ring(idx) {
let r = rc[idx];
if (r) return r;
const s = D.r[idx];
r = [];
let i = 0, n = s.length, x = 0, y = 0;
while (i < n) {
let b, sh = 0, v = 0;
do { b = R[s.charCodeAt(i++)]; v |= (b & 31) << sh; sh += 5; } while (b >= 32);
x += (v & 1) ? ~(v >>> 1) : (v >>> 1);
sh = 0; v = 0;
do { b = R[s.charCodeAt(i++)]; v |= (b & 31) << sh; sh += 5; } while (b >= 32);
y += (v & 1) ? ~(v >>> 1) : (v >>> 1);
r.push([x / 100, y / 100]);
}
return (rc[idx] = r);
}
rc 是环缓存数组,解一次就常驻。为什么要缓存数组而不是直接解成 SVG path?因为同一批坐标要用于三件事:画多边形、点选命中测试(pointInShape)、以及标注锚点的投影。解一次、多回复用,是这里唯一合理的取舍。
运行时:惰性 getter 与首屏 boot seed
更妙的是要素层也懒。bundle 里存的 shape 不是坐标,而是环编号:
raw.shape = { _p: value.coordinates.map((r) => ringId(r, label)) };
运行时用 Object.defineProperty 挂一个 getter,只有在真正访问 .coordinates 时才解码,解完立刻把 getter 替换成值:
if (sh._p) {
const ids = sh._p;
raw.shape = { type: 'Polygon' };
Object.defineProperty(raw.shape, 'coordinates', {
enumerable: true,
configurable: true,
get() {
const v = ids.map(ring);
Object.defineProperty(this, 'coordinates', { value: v, writable: true, enumerable: true, configurable: true });
return v;
}
});
}
_p 是 Polygon(环编号数组),_m 是 MultiPolygon(二维环编号数组)。因为要素在 197 张快照间共享同一个对象,一个要素的几何在整场浏览里只会被解码一次——从战国拖到民国再拖回来,第二次是直接命中缓存。
首屏机制则靠"合并而非覆盖"。boot bundle 的收尾逻辑是:
const out = window.ATLAS_SNAPSHOTS || {};
for (const [k, s] of Object.entries(D.d)) {
if (out[k]) continue;
// ... 逐快照装配 features
}
window.ATLAS_SNAPSHOTS = out;
全量 bundle 稍后加载时,if (out[k]) continue; 保证 boot 已经装好的 3 张不会被重复覆盖,最后打上一个 window.ATLAS_FULL_SNAPSHOTS_LOADED = true 的哨兵。于是首屏可以立刻画战国初年,剩下的 194 张在后台补上,没有"加载中"白屏,也没有覆盖冲突。
这套设计的代价是构建脚本必须自己实现编码器、解码器、缓存和 getter——build_atlas.js 只有 18.8 KB,但它替代的是一个通用的 protobuf / 二进制序列化栈。对零依赖项目,这是很划算的一笔交易。
渲染:自研 Mercator 与 SVG 路径
技术栈是反直觉的:零运行时依赖、零 npm 包、零地图库。没有 Leaflet、MapLibre、D3,投影、缩放、命中测试、标注布局全在 app.js(668 KB)里手写。
投影类只有 40 行,但每一行都有用:
class MercatorProjector {
constructor(width, height, bounds) {
this.width = width;
this.height = height;
this.bounds = bounds;
const spanLng = bounds.east - bounds.west;
const spanLat = (this._latToMerc(bounds.north) - this._latToMerc(bounds.south)) * RAD_TO_DEG;
this.scale = Math.min(width / spanLng, height / spanLat);
this.padX = (width - spanLng * this.scale) * 0.5;
this.padY = (height - spanLat * this.scale) * 0.5;
this.northMerc = this._latToMerc(bounds.north);
}
_latToMerc(lat) {
return Math.log(Math.tan(Math.PI * 0.25 + (lat * DEG_TO_RAD) * 0.5));
}
toScreen(lng, lat) {
const x = (lng - this.bounds.west) * this.scale + this.padX;
const y = (this.northMerc - this._latToMerc(lat)) * RAD_TO_DEG * this.scale + this.padY;
return [x, y];
}
}
三个细节值得说。一是 Math.min 统一缩放:横纵用同一个 scale,绝不拉伸——历史地图一旦各向异性,黄河的弯和海岸线就会互相打架。多出来的空间用 padX/padY 居中。二是把纬度先过 Mercator 再算跨度,否则高纬度会被算窄,中国地图北端(漠北、外兴安岭一带)会显得挤。三是提供逆变换 toGeo,鼠标点一下,屏幕坐标能反解回经纬度,命中测试和"点击此处查沿革"都靠它:
toGeo(x, y) {
const lng = (x - this.padX) / this.scale + this.bounds.west;
const merc = this.northMerc - (y - this.padY) / (this.scale * RAD_TO_DEG);
const lat = (2 * Math.atan(Math.exp(merc)) - Math.PI * 0.5) * RAD_TO_DEG;
return [lng, lat];
}
greatCircleKm 则是标准 haversine(常数 12742 是地球直径公里数),用在"这段行军多少里"的解说和相机取景范围估算上。
投影出点之后,一个环直接拼成 SVG path 的 M/L/Z 序列,保留一位小数——在 1000×700 的画布上,0.1px 已经低于视觉分辨力,多留位数只是浪费字节。但直线连出来的多边形有个问题:0.01° 简化之后,短边很多,放大看是一圈锯齿。于是有了 smoothRingToSvgPath:
for (let i = 0; i < n; i++) {
const p0 = pts[i];
const p1 = pts[(i + 1) % n];
const dx = p1[0] - p0[0];
const dy = p1[1] - p0[1];
const len = Math.hypot(dx, dy);
if (len >= 1.6) {
const t = Math.min(0.28, 4.2 / len);
sx[i] = p0[0] + dx * t;
sy[i] = p0[1] + dy * t;
ex[i] = p1[0] - dx * t;
ey[i] = p1[1] - dy * t;
curved[i] = 1;
} else {
// 太短的边不圆角,直接保留端点
}
}
每段边向内收缩 t,留出圆角空间;t = min(0.28, 4.2/len) 保证收缩比例不超过 28%,且绝对收缩量不超过 4.2px——长边圆一点,短边几乎不动。然后用二次贝塞尔 Q 在顶点处连接两条收缩后的边:
if (curved[i] && curved[next]) {
out += `Q${vx.toFixed(1)} ${vy.toFixed(1)} ${sx[next].toFixed(1)} ${sy[next].toFixed(1)}`;
}
vx/vy 就是原顶点,作为控制点。效果是:拓扑完全不变(顶点还在原处,只是视觉上被圆过去),但放大到省级细节时,海岸线和国界不再像刀切的。
为什么不用 D3?因为 D3 的 geoPath 在这里只解决了 30 行代码的问题,却要带来一个依赖树、一次版本升级、以及一套和项目自己缓存的几何对象不兼容的数据约定。地图库真正贵的地方不是投影,是瓦片、交互、图层管理——而这个项目的需求恰好把这三样都绕开了:疆域是矢量多边形、没有底图瓦片、图层只有"政权/郡县/政区/河流/标注"五层。
标注:分层优先级与贪心防碰撞
地图上最容易被低估的工程是标注。198 张快照、每张几十个政权名,加上古城、关隘、山脉、河流,直接 text 全画上去就是一团黑。app.js 的做法是分层 + 贪心 + 允许隐藏。
每个标注节点先算一个候选盒(文字宽高 + padding),按优先级顺序遍历,逐个尝试偏移量,落进 occupied 数组就算占位成功;所有候选位都被占,就加 is-crowded-hidden 隐藏:
occupied.push(candBox);
placed = true;
break;
// ...
if (!placed) {
node.dataset.placed = '1';
node.classList.add('is-crowded-hidden');
}
三级细节值得抄:一是 data-placed 锁。一旦某次缩放里决定"这个标注放这儿",后续重绘不再重算,避免拖时间轴时标注乱跳(视觉抖动比标注重叠更烦人)。二是缩小回退时释放锁,代码注释写得很清楚——dropping the lock lets the label re-seat itself the moment the user zooms back in, instead of staying hidden。三是低优先级图层滑动避让:山脉和界河名有自己的一串偏移候选,先沿山脊/河道切向滑,滑不开再隐藏:
const hydroOffsets = [
[0, 0], [14, 0], [-14, 0], [26, 0], [-26, 0],
[0, -7], [0, 7], [16, -6], [-16, 6], [38, 0], [-38, 0],
];
这里没有任何物理模拟、没有力导向、没有 Web Worker。200 个盒子做 AABB 相交测试,在现代浏览器上是零点几毫秒的事。用确定性算法换"每次打开地图长得都一样",对一张要被人引用、截图、做课件的图,这比"优雅的自适应布局"重要得多。
时间轴、URL 状态与 embed 模式
时间轴本身是数据驱动的:map_data.js 里 18 个时期的阶段规则决定某一年该用哪张快照,lore_data.js 提供该年的年号、君主、事件。真正值得拆的是状态怎么进出 URL。
_urlStateParams 只写"可复原"的状态,而且注释把取舍写明白了——一次性参数用完即弃,因为它们不再描述屏幕:
_urlStateParams() {
const prev = new URLSearchParams(location.search);
const p = new URLSearchParams();
p.set('year', String(this.year));
const person = this.activePerson;
const personKeys = person && Array.isArray(person.profiles) ? person.profiles.map((pr) => String(pr.key || '')) : [];
if (personKeys.length && personKeys.every((k) => k && !k.startsWith('adhoc_'))) {
p.set('person', personKeys.join(','));
if (this.activePersonEventIdx >= 0) p.set('stop', String(this.activePersonEventIdx));
} else {
if (this.activePolity && this.activePolity.curated && this.activePolity.key) {
p.set('polity', this.activePolity.key);
}
if (Array.isArray(this.activeTrace) && this.activeTrace.length === 2) {
p.set('trace', this.activeTrace.map((v) => Number(v).toFixed(3)).join(','));
}
}
if (prev.has('terrain')) p.set('terrain', '1');
if (this.locale === 'en') p.set('lang', 'en');
else if (this.locale === 'zh-tw') p.set('lang', 'zh-tw');
return p;
}
四个决策:year 永远写进 URL(这是地图的"当前帧");人物和政权互斥但都能复原(person + stop 记录看到第几个事件);trace 只保留两位以上精度的两点轨迹(手动刷出的高亮范围);lang 只写非默认语言,中文用户拿到的是最短 URL。还有一个细节:adhoc_ 开头的人物 key 不写进 URL——那是搜索临时构造的对象,刷新后无法复原,写进去只会得到一条死链。
embed 模式是同一套参数的另一副面孔:
_parseEmbedParams(q) {
if (!q || q.get('embed') !== '1') return null;
const num = (k) => {
if (!q.has(k) || q.get(k) === '') return null;
const n = Math.round(Number(String(q.get(k)).split(',')[0]));
return Number.isFinite(n) ? n : null;
};
return {
from: num('from'), to: num('to'),
lock: q.get('lock') === '1',
autoplay: q.get('autoplay') === '1',
year: num('year'),
};
}
from/to 限定时间轴范围,lock=1 锁死某一年(配合 year),autoplay=1 自动播放。实现上最漂亮的一点是不散点判断,而是收窄全局边界:
_applyEmbedYearRange() {
const lo = this.minYear, hi = this.maxYear;
const clamp = (y) => Math.max(lo, Math.min(hi, y));
let { from, to } = this.embed;
if (this.embed.lock && this.embed.year !== null) {
from = this.embed.year; to = this.embed.year;
}
from = clamp(from === null ? lo : from);
to = clamp(to === null ? hi : to);
if (from > to) [from, to] = [to, from];
this.minYear = from;
this.maxYear = to;
}
注释说得很直白:Narrow the global year bounds; every scrub, step, autoplay and deep link already clamps to them. 只要改 minYear/maxYear,拖动、单步、自动播放、深链四条路径全部自动受限,不用在四个 handler 里各写一遍边界检查。这是典型的"把约束放在数据源头而不是每个消费点"的做法,也是嵌入模式能做得这么薄的原因。
另外,iframe 默认盖了一层 embed-shield 遮罩,提示"点击以操作地图",点一下才交出拖拽和滚轮控制权。这是被无数嵌入地图坑过的经验:不这么做,宿主页面往下滚,滚到地图就被 iframe 吃掉滚动事件。
质量围栏:拓扑审计棘轮与克制的自动修边
相邻政权共享边界一旦顶点不齐,放大就是缝隙或重叠。手画 197 张快照,靠人眼保证顶点链一致是不可能的。这个项目的回答是一套自建 QA 机器,也是我认为全仓库工程含金量最高的部分。
审计不比较顶点,而是采样:取一个政权所有顶点和所有边中点,看它们落在另一个政权里还是外面。
const CORE_ROLES = new Set(['main', 'rival', 'protectorate']);
const GAP_DEG = 0.04; // ~4 km
const ON_BORDER_DEG = 1e-9;
function compareShapes(a, b) {
const result = { overlap: 0, gap: 0 };
const sample = (x, y, isVertex) => {
if (isVertex && b.vertices.has(pointKey(x, y))) return; // 顶点完全重合,跳过
const d = borderDistance(b, x, y);
if (d <= ON_BORDER_DEG) return; // 正好在边界上
if (contains(b, x, y)) result.overlap++; // 落在对方内部 → 重叠
else if (d !== Infinity) result.gap++; // 4km 内但没贴着 → 缝隙
};
for (const ring of a.rings) {
for (let i = 0; i < ring.length - 1; i++) {
const [x, y] = ring[i];
const [nx, ny] = ring[i + 1];
sample(x, y, true);
sample((x + nx) / 2, (y + ny) / 2, false);
}
}
return result;
}
四层设计都在这个函数里:顶点完全重合直接豁免(共享边界至少顶点要对上);边中点也采样(只对齐了角点、边却穿进对方的细长 sliver 也能抓到);缝隙阈值 0.04° ≈ 4 km(容纳 0.01° 取整漂移,又不至于把真实缓冲带算成错误);ON_BORDER_DEG = 1e-9 处理"恰好落在边上"的数值噪声。
判"在不在多边形内"用奇偶规则(even-odd)逐环翻转,天然支持洞和多部件;距离查询用 0.25° 网格把线段分桶,只查点周围 3×3 个格子。197 张快照 × 几十对政权的两两比较,靠这两层索引压到可跑。
然后是棘轮。基线文件 topology_baseline.json 记录每张快照被接受的 overlap/gap 数;--check 逐项比对:
const was = baseline[key] || { overlap: 0, gap: 0 };
const now = current[key] || { overlap: 0, gap: 0 };
for (const metric of ['overlap', 'gap']) {
if (now[metric] > was[metric]) worse.push(`${key}: ${metric} ${was[metric]} → ${now[metric]}`);
else if (now[metric] < was[metric]) better.push(`${key}: ${metric} ${was[metric]} → ${now[metric]}`);
}
最值得抄的是 --update 的拒绝逻辑:
if (worse.length && !firstRun && !args.includes('--allow-increase')) {
for (const w of worse) console.error(`✗ ${w}`);
console.error('Refusing to raise the baseline; fix the borders or pass --allow-increase.');
return 1;
}
基线只能降,想升必须显式加 --allow-increase。 而且 --check 对"变好"也报错——better (baseline is stale),要求你更新基线并提交。这个设计很反直觉但非常对:如果只拦变差,基线会慢慢变成一张过时的免责单;要求变好也同步更新,基线才始终等于"当前已知最优"。修一个少一个,账本始终可信。
修复层是 scripts/repair_topology.js(24 KB),它的克制比能力更值得说。默认容差 DEFAULT_TOLERANCE = 0.03(约 3 km),把低优先级政权的边界顶点吸附到高优先级(main > rival > protectorate)的边上,吸附点优先落在已有顶点(VERTEX_SNAP_DEG = 0.015),整条吸附链最多 60 个点(MAX_CHAIN_VERTICES = 60)。但每一处改动都要过四道否决:
- 面积变化超过 3%(
MAX_AREA_CHANGE = 0.03)→ 拒绝; - 形成超过 30 点、面积超过 0.01 deg²(约 100 km²)的捏点环 → 当成真实领土,不改;
- 用 1 km 栅格(
RASTER_DEG = 0.01)做形态差分,腐蚀 3 格后改动仍不消失,说明这块变化宽度超过约 6 km → 冻结; - 会连带让其他共享该要素的快照变差 → 跳过并打印。
三公里以内自动修,六公里以上交给人,中间地带按安全规则判。自动化的价值不在于多能干,而在于知道什么时候不干——这个脚本把"不确定就上报"写成了代码,而不是写在贡献指南里靠自觉。
验收层 scripts/validate_atlas.js 是 451 KB 的验证套件,比 build_atlas.js 大 24 倍:语法、哈希、schema、i18n、关键 UI 路径、拓扑审计、子进程冒烟测试全在里面。本地迭代跑 --fast 约 6 秒,提 PR 前跑全量。6 秒的反馈循环是这套机制能活下来的前提——如果一次校验要两分钟,作者早就绕过它了。
MERMAID_BLOCK_1
缓存:内容哈希,而不是文件名
零依赖项目没有 webpack 的 contenthash,于是自己写了 sync_version.js。核心只有三行:
function shortSha256(filePath) {
const buf = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8);
}
但它解决的问题比看上去复杂。静态资源可以直接写在 HTML 的 ?v= 查询串里,而懒加载的 bundle 根本不在 HTML 里出现——是 app.js 运行时自己 import()/插 script 拉进来的,没地方挂查询串。解法是把它们的哈希塞进 #atlas-app-script 的 data 属性:
const expectedAppTag =
`<script id="atlas-app-script" src="js/app.js?v=${hashes['js/app.js']}"` +
` data-v-snapshots="${hashes['js/atlas_snapshots.js']}"` +
` data-v-i18n="${hashes['js/i18n_dict.js']}"></script>`;
app.js 读 dataset.vSnapshots / dataset.vI18n 再拼懒加载 URL,于是"改数据不刷全站缓存"成立。还有一条容易被忽略的断言:
const hardcodedAppVersions = controllerSrc.match(/\?v=[0-9a-zA-Z_.-]+/g) || [];
assert.strictEqual(
hardcodedAppVersions.length, 0,
`Controller scripts ... must not hardcode ?v= query strings ...; read hashes from #atlas-app-script data-v-* attributes instead`
);
控制器脚本里禁止写死 ?v=。 否则 app.js 里会残留一份手工维护的旧版本号,和 HTML 里的哈希打架,缓存永远失效在最难查的地方。这条断言把"单一真相源"从约定变成了 CI 会拦的硬规则。
知识层:纪年正统与人物行迹
几何之外,内容数据有两个设计决策值得单独说。
纪年正统观。 时间轴显示哪家的年号,是历史地图的隐含意识形态问题。项目的选择写在 lore_data.js 结构里:主线遵循《资治通鉴》的 conventions——三国取曹魏、十六国取东晋、南北朝取南朝、五代取中原五代、宋辽金夏并列取宋;同时蜀汉、孙吴、北朝、辽、金、西夏、忽必烈之前的蒙古,放进平行的 REGIONAL_ERA_TRACKS,事件按发生地所属政权的年号标注。用数据结构把"谁的纪年是主线"这个史学争议显式化,比在页脚写一行免责声明体面,也让读者能看见这个选择本身。
人物行迹。 张骞、玄奘、李白、苏轼、王阳明的完整行迹作为结构化数据存在 lore_data.js(802 KB):每个人有多个 profile(不同史料或不同阶段的版本,用 key 区分),事件带年份、地点、史料原文和诗引。上一节那个 person=key1,key2&stop=N 的 URL 参数,就是这套结构的直接映射——stop 不是"第 N 年",是第 N 个事件,因为一个人可能在同一年停留多地。播放时每前进一步,时间轴年份、在位君主、疆域形态同步变化。
map_data.js(575 KB)则承担 18 个时期的元数据、阶段规则、古城坐标、河流关隘和里程碑;i18n_dict.js(403 KB)+ zh_hant.js(32 KB)是中英繁三语词典。这四个文件都是手写维护的源,不经过构建——文本数据用 JSON/JS 直存,几何数据才值得自己造压缩格式,这个分界线划得很清楚。
AI 原生设计:MCP server、Agent Skill 与 llms.txt
这个项目对 AI 的态度不是"用 AI 写的",而是把"被 AI 使用"做成了功能面:
llms.txt(13 KB):给 LLM 的机器可读说明,讲清站点结构、深链参数怎么拼;scripts/mcp_server.js(11 KB):MCP server,agent 可通过标准协议查询地图;skills/jiangshan-atlas/:一个 Agent Skill(SKILL.md + 零依赖 Python CLI),装进 agent 后用自然语言操作地图。
作者举的例子很传神:问"雪拥蓝关马不前",agent 定位到韩愈被贬潮州、立马蓝关的公元 819 年,在图上标出蓝关;问"问汝平生功业,黄州惠州儋州",它把苏轼一贬再贬的路线串起来画出来。古诗地名 → 历史坐标 → 地图深链,这条链路对一个静态站点来说很超前——它默认未来的读者里有相当一部分不是人。
配合前文那套 URL 状态和 embed 参数(year/person/stop/polity/trace/from/to/lock/autoplay),它的每一条内容都能被引用、被嵌、被 agent 直接生成。topics/ 目录下还有 35 个按朝代和战役预构建的静态落地页,给搜索引擎和 LLM 各准备了一份入口。
怎么用:本地跑起来,以及改数据的完整循环
只想看/截图,不需要安装任何东西:
git clone https://github.com/DeanChensj/jiangshan-map.git
cd jiangshan-map
python3 -m http.server 8000
# 打开 http://localhost:8000/
仓库没有 dependency,npm install 无事可做;自带 Dockerfile 和 PWA manifest,能装到手机桌面。线上版在 https://deanchen.me/jiangshan-map/ ,支持中英繁三语和 ?lang=en 直切。
想改数据,循环是固定的四步:
# 1. 改 data/atlas/<朝代>/<快照>.geojson(或 index.json 的 order)
node scripts/build_atlas.js # 规范化源文件 + 重建两个 bundle + 刷新 index.html 哈希
node scripts/audit_topology.js # 先看最差的 15 张快照
node scripts/audit_topology.js --details s_beisong_a # 看具体是哪两个政权对不上
node scripts/repair_topology.js beisong --tolerance 0.02 # 自动修 3km 内的浅缝隙
node scripts/audit_topology.js --update # 只能调低基线;调高会被拒绝
node scripts/validate_atlas.js --fast # 约 6 秒,提 PR 前去掉 --fast 跑全量
想导出数据,用 scripts/export_geojson.js。注意它默认剔除郡县层,要加 --with-prefectures 才带——这是许可红线在工具层面的落地,不是可选项。
想嵌入自己的页面,直接 iframe:
https://deanchen.me/jiangshan-map/?embed=1&year=208&lock=1
embed=1 出精简 UI,lock=1 锁死年份,from=960&to=1279 把时间轴压到宋,autoplay=1 自动播放。宿主页面不用写任何 JS。
横向比较
跟 CHGIS / 谭其骧《中国历史地图集》比。 那是学术权威和纸质经典,精确但不可交互、检索门槛高。江山时序明确定位自己是"示意性复原"而非测绘成果,用交互性和可读性换精确性——它是把学术成果翻译成大众可用形态的转译层,不试图替代原典,也因此在许可上守住了边界(不复制谭图数据,只作核对参照)。
跟 aourednik/historical-basemaps 比。 后者提供全球历史政权的原始多边形(也是本项目政权层的来源),但没有产品、没有知识层、没有质量机制。江山时序的差异不在数据独占(数据本来就是公开的),而在整理、核对、纠错的累计工作量——那个只降不升的基线就是工作量的账本。
跟用 Leaflet/MapLibre 拼的历史地图 demo 比。 那些项目能更快出第一版,但会继承地图库的坐标系、瓦片假设和交互模型;江山时序用 40 行投影换来了完全可控的渲染和零依赖部署,代价是自己写标注布局和命中测试。对一个要活很多年的静态站,这笔交易划算。
失效边界
一、许可红线必须再说一遍。 郡县边界源自 CHGIS V6 和 Hartwell,学术、非商业、不得再分发,不适用 GPL-3.0。fork 自己跑没问题,把郡县层导出去商用或再发布就是侵权。export_geojson.js 默认剔除、每个文件嵌 license、构建时重新戳一遍——三重提示,别绕过。政权轮廓层是 GPL-3.0 传染的,拿去做闭源产品同样不行。
二、"近似边界"就是近似边界。 README 反复强调这是示意性复原:0.01° 精度、手工调整、游牧政权和羁縻地区本来就是动态过渡带。讲历史故事、配文章插图、做课程演示都对;拿它做严肃学术论证、或在敏感边界问题上引用它当依据,都是错误用法。
三、零依赖是双刃剑。 对使用者是福音(克隆即用),对贡献者是门槛:改渲染逻辑要直接读 668 KB 的 app.js,加功能没有 npm 生态可用。可贡献面主要在数据层(地理纠错、翻译、人物数据),贡献指南的重点也确实全在数据规范上。
四、质量机制依赖"有人跑"。 棘轮只有在 CI 里真的执行才有意义;--check 是脚本,不是平台。fork 之后如果把 CI 关了,基线就退化成一纸空文。这套设计的有效性,一半来自脚本,一半来自"每次提交都跑"的纪律。
五、单人维护的可持续性。 320 个 commit、四个月、大半在深夜和周末。12 个 open issue 的消化速度取决于一个人的历史地理知识余量。好在跨快照引用 + 严格 schema + 自动验证把外部贡献门槛压得足够低——这可能是它为长期存活做的最重要设计。
成本
- 钱:免费,GPL-3.0(注意上面的许可例外)。线上版直接用。
- 机器:纯静态站,任何静态托管都行。首屏 72 KB,2 MB 全量懒加载,普通网络无压力;没有服务端、没有数据库、没有瓦片服务。
- 二次开发的时间成本:先想清楚动哪一层。改文案和人物数据,照 CONTRIBUTING 的 JSON 规范填,一小时能上手;改疆域几何,得先理解跨快照引用、跑通 build → audit → repair → validate 四步、引用谭图册数图幅、等棘轮放行——那是认真投入一个历史地理 mini 论文的开始。
- 维护成本:几何源 26 MB 全在 git 里,每次改边界都会产生二进制式的大 diff。这是"数据即源码"的代价,也是它可审计的原因。
参考文档与链接
- DeanChensj/jiangshan-map — 本文拆解的原仓库,GPL-3.0
- data/atlas/README.md — 数据来源与许可的官方声明
- CONTRIBUTING.md — 仓库结构、数据规范、审计流程
- scripts/build_atlas.js — 本文 VLQ 编码、去重、惰性 getter 的出处
- scripts/audit_topology.js — 采样审计与基线棘轮
- 从历史课本里的插图,到 GitHub 1k star — 作者自述的项目故事
- CHGIS V6 — 哈佛费正清中心 + 复旦史地所,郡县层来源
- aourednik/historical-basemaps — 政权轮廓层来源,GPL-3.0
- 在线版 — 直接可玩,支持中英繁三语
你拖时间轴的第一个年份会停在哪儿?我去的是前 260 年的长平。评论区聊聊你最想让哪一年的地图出现在屏幕上。
作者: itech001
来源: 公众号:AI人工智能时代(the-ai-era)
网站: https://www.theaiera.top/
关注每日最新AI新闻和技术博客,主页有更多的文章的AI 技术参考:https://www.theaiera.top
本文首发于 AI 人工智能时代,转载请注明出处。

浙公网安备 33010602011771号