江山时序源码拆解:数据从哪来,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], "..."]]] }
    }
  ]
}

这里藏着三套引用规则,决定了整个项目的可维护性:

  1. 裸 id("曹操(北方)")指向本文件内的 feature。
  2. 快照键/id("s_donghan_d1/公孙氏(辽东)")指向另一个快照文件里的 feature,id 本身不允许包含 /(构建时会校验)。
  3. 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。这是"数据即源码"的代价,也是它可审计的原因。

参考文档与链接

你拖时间轴的第一个年份会停在哪儿?我去的是前 260 年的长平。评论区聊聊你最想让哪一年的地图出现在屏幕上。


作者: itech001
来源: 公众号:AI人工智能时代(the-ai-era)
网站: https://www.theaiera.top/
关注每日最新AI新闻和技术博客,主页有更多的文章的AI 技术参考:https://www.theaiera.top

本文首发于 AI 人工智能时代,转载请注明出处。

posted @ 2026-11-07 10:03  iTech  阅读(4)  评论(0)    收藏  举报