ysoseri.us

技术内容 / 从一张世界纸面,到可持续更新的国家地图包

从一张世界纸面,到可持续更新的国家地图包

从一张世界纸面,到可持续更新的国家地图包

Travel 的起点不是“做一个地图组件”,而是做一张真正被使用过的旅行地图:世界轮廓必须准确,纸面要有氧化、折痕、纤维和磨损,图钉像真实物件一样插在纸上;纯文字记录落在便利贴上,带图片或视频的记录落在相纸上。访客看到的应该先是一张有时间痕迹的纸,其次才是地理数据和 Web 交互。

这个目标带来了一个很具体的矛盾:常见地图平台足够精确,却会把 Web Mercator、在线道路瓦片和工具型视觉一起带进来;一张生成式羊皮纸足够有质感,却无法保证海岸线和行政边界正确。Travel 因此走上了一条更费工、但职责更清楚的路线:地理形状来自真实数据,纸张质感来自真实扫描,二者在离线制图阶段合成,浏览器只负责浏览和承载实时内容。

第一版设计:一张可以一路放大的世界地图

最初设想是一张有限边界的世界羊皮纸。它可以像无边画布一样拖动和缩放,但纸张本身有撕裂边缘。洲际尺度只看世界轮廓和图钉,国家尺度看省、州边界,继续放大到市级后才显示便利贴与相纸。每个记录的位置由 WGS84 经纬度确定,不另存一套容易失真的图片像素坐标。

Leaflet 很适合快速接入标准在线瓦片,但 Travel 不需要地图平台的底图,也不想被 Web Mercator 限制。OpenLayers 能定义有限 extent、自定义 tile grid 和投影转换,也能把图钉、地名、纸片和阅读浮窗拆成独立图层。因此首版选择 OpenLayers,并以 Natural Earth 1 作为世界纸面的投影。相较于 Web Mercator,它在世界总览中不过度放大高纬地区,也更接近一张完整铺开的纸。

地理数据分层来自 Natural Earth、geoBoundaries,以及中国原型阶段使用的 GADM 4.1。世界总览使用较轻的海岸线和国界;国家视野再读取更细的行政数据。AI 不负责画地图,因为它无法稳定复现真实海岸线、国界和行政区身份;地图平台 API 也不负责渲染,因为地点搜索和地图绘制是两件事。高德与 Google Places 只在管理者选址时把文字地点解析成候选经纬度,最终仍由本站的 Region Catalog 完成点入面判断并保存稳定 region_id

把真实纸张和行政边界合成在一起

纸面底材来自 Caleb Kimbrough 的真实羊皮纸扫描。离线 renderer 把扫描纹理映射到统一纸张坐标,再绘制陆地填色、海岸线和行政边界。世界样张为 8192×4096,中国和新西兰局部样张为 4096×3072。墨线没有交给浏览器中的 Rough.js:renderer 用 Canvas 多次复描形成主笔画和轻微偏移,用低透明模糊层模拟墨水渗入纸纤维,最后以 multiply 合成。这样抖动、笔压和渗色会成为制图资产的一部分,而不是滚轮缩放时反复计算的运行时效果。

为了避免瓦片接缝,原设计要求所有随机量由全局坐标和固定 seed 派生;行政路径先在带缓冲区的 metatile 中渲染,切片时再裁掉 buffer。氧化、污渍和折痕固定在同一套 UV 坐标中,缩放不会换位置;微观纤维则按尺度增加,但不能重新随机生成。纸张上曾出现过可辨认的椭圆环纹,这种能看出算法中心、却无法用纸张物理解释的效果被删除了。

16K 超分实验:只借用高频,不替换原纸

8K 世界图在高倍视野会很快暴露像素,因此我们用 Colab 中的 RealESRGAN_x2plus 做过 16K 纸面实验。关键不是把超分结果直接上线,而是限制 AI 的职责。

首先把不含陆地色和行政墨线的 8192×4096 纯纸面分块处理,并保留 overlap,得到 16384×8192 的候选图。然后以原图的 Lanczos 2× 结果作为颜色和低频结构基线,只从 AI 候选中提取高频残差,以可调强度叠加。透明撕边的 alpha 单独做确定性重采样,不让模型重新发明纸边。于是氧化、折痕、污渍和整体色调仍由原始扫描决定,AI 只补它相对擅长的纤维与细小颗粒。

验证也不能只靠肉眼。16K 候选会缩回 8K,在有效纸面 alpha mask 内计算 MAE、PSNR、SSIM、低频色差与 alpha 误差,并生成局部对比图。如果回缩结果不能保持原始纸面的宏观结构,它就不能进入制图链路。这一实验验证了“高频残差而非整图替换”的方向,但没有消除高倍纹理重复、边缘接缝和真实拖拽观感的后续验收。

连续缩放方案:Raster、MVT 与 PMTiles

在架构转型之前,我们曾为“世界一路放大”设计一套分层方案:z0-z4 保留完整羊皮纸 raster;在 z4.3-z4.7 之间交叉淡化到“16K 纯纸面 + 矢量行政墨线”,而不是在整数 zoom 突然切换;z6-z10 再叠加中观纤维、细纤维和极细颗粒三档透明细节瓦片。旧图层必须一直显示到新纸面和矢量线都 ready,再通过 opacity 交叉淡化,避免白底或半套资源。

行政层也不能让浏览器直接下载全球 GeoJSON。设想中的离线工具链会先拓扑化共享边界,为每条边生成稳定 edge_id,沿最高精度母线按规范弧长生成确定性笔触,再从同一母线派生嵌套 LOD。原因是普通 Douglas-Peucker 简化会在不同 zoom 改变顶点集合:即使 feature ID 不变,运行时重新抖动仍会让墨线在缩放时跳变。

数据需先从 WGS84 转到本站 Natural Earth 1 平面坐标,再按自定义 tile matrix 裁切、量化并编码成 MVT。PMTiles 只作为支持 Range Request 的单文件瓦片容器,由 OpenLayers 自定义 loader 读取。tippecanoe 默认输出 Web Mercator MVT,不能直接承担这套自定义投影工具链;简单地先用 ogr2ogr 改投影也不够,因为 tile matrix、裁切和客户端解码仍必须使用同一坐标定义。

纸面微观层最初还评估过 WebGL multi-scale virtual texture 与 Wang Tiles。它们能进一步压低重复,但要自行维护 shader、纹理驻留和接缝规则。首发更实际的方案是标准 OpenLayers TileLayer 加多个不同周期和偏移的无缝细节图集,并承认它在极高倍慢速拖动时仍可能出现周期。这是需要实测的材料上限,不应提前用复杂引擎掩盖。

为什么全球高缩放方案失败了

首版上线后的问题不是单个参数失调,而是运行时承担了错误的范围。从世界首屏滚轮放大时,完整 raster、纯纸面、行政线和 DOM 纸片有各自的阈值:约 +2 会出现一层更深但没有线条的纸,+3 纸色再次变化,+6 才出现细行政边界,便利贴和相纸直到约 +11 才进入视野,最高到 +14。用户看到的不是连续放大一张地图,而是多套资源轮流暴露。

性能开销也被全球范围放大。为了只查看中国或新西兰的一座城市,浏览器仍要为全球高层级准备行政几何、纸面细节、图钉投影和 DOM 覆盖物;滚轮期间图层切换、图钉定位和纸片尺寸同时更新。县级或同等地方层级的边界在世界上下文里大部分永远不会被访问,却仍扩大下载、解码、内存和绘制成本。继续调 +5+6+11 只能改变问题出现的时机,不能改变这套结构的资源上限。

一些交互故障也来自同一模型:图钉和底图分别换算坐标会在 zoom 变化时漂移;透明位图边界造成不同颜色图钉的热区不一致;锚定图钉的编辑浮窗会被视口边缘挤出。它们后来分别通过单一投影函数、统一热区和固定左侧编辑抽屉修正,但真正决定性能上限的仍是“一个世界场景同时负责索引和地方细节”。

最终转型:固定世界总览,按国家加载地图包

最终方案废弃了世界地图连续深入国家细节的结构。世界模式现在只是一张固定、快速打开的羊皮纸索引:它显示所有真实图钉和可用国家的轻量轮廓,图钉不直接交互,地图也不拖动或缩放。访客尝试滚轮、双击或触控板缩放时,界面明确要求选择国家;左上角的国家选择器执行同一个命令。当前 ready 的国家是中国和新西兰,这个列表来自服务端数据,不是写死在前端的永久白名单。

选择国家后,浏览器只加载该国的 manifest 和地图资源。每个国家有独立 bounds、基准分辨率、行政层级、初始视野和纸片尺度,国家模式以相对 +0 开始,+5 显示便利贴与相纸,最高到 +14。世界模式和国家模式都只把 WGS84 经纬度当作位置真相,屏幕像素始终由当前投影即时计算;国家地图上的图钉也是同一批 D1 数据,不维护第二份位置,因此两种视图可以同步而不产生人工校准漂移。

当前首版国家包有意保持简单:一个 4096×3072 的 map.webp 加一个 manifest,行政墨线在离线阶段烘焙,先验证国家边界、相机和按需加载模型。中国包使用 geoBoundaries ADM0 与 GADM 4.1 ADM2 地级数据;ADM1 只保留给 Region Catalog,不再叠进 raster,避免不同来源的省界与地级线出现双层偏差。新西兰包使用 geoBoundaries 的 ADM0、ADM1 与 ADM2。未来若高倍清晰度需要 MVT、PMTiles 或真正的 raster 金字塔,只需替换国家包内部资产,不必再次改变图钉数据和浏览状态机。

自动制图任务如何接入新国家

新增国家不是后台里的一个“新增国家”按钮,而是新建真实图钉的自然结果。地点候选确定 ISO-3166 alpha-2 国家代码后,Worker 先保存图钉,再在 ctx.waitUntil 中幂等调用国家构建逻辑。D1 的 countries 保存 pending / building / ready / failed 状态,country_build_jobs 记录任务和重试;部分唯一索引保证每个国家同时最多只有一个活动任务。

Worker 通过 GitHub repository_dispatch 触发 build-country.yml。Workflow 校验两位大写国家代码,以国家代码做并发分组,安装应用与 renderer 依赖,然后完成以下流程:

  1. 从 Natural Earth 取得国家身份和范围,从 geoBoundaries 获取 ADM0/ADM1/ADM2;中国的 ADM2 原型单独读取 GADM 4.1。
  2. 生成国家专用渲染配置,并用同一纸张扫描、墨线参数和 Natural Earth 1 投影输出地图。
  3. 写出带 SHA-256、bounds、行政层级、baseZoommaxZoomkeepsakesFromZoom 的 manifest。
  4. 将版本资产上传到 v3/countries/{CODE}/{version}/,使用一年 immutable 缓存;最后更新只有 60 秒缓存的国家 manifest 指针。
  5. Workflow 用共享密钥对回调正文做 SHA-256 HMAC,Travel Worker 验签后再从 R2 读取 manifest,核对国家、版本和 ready 状态。
  6. 只有校验全部通过,D1 才把国家原子切换为 ready;失败则保留错误和任务状态,不暴露半成品包。

图钉写入与制图任务因此互不阻塞。内容先成为真实数据,国家包只是可重新生成的派生资产。世界首屏不请求任何国家高倍资源,进入中国也不会下载新西兰或未来其他国家的行政数据。

当前收益与仍需验证的边界

这次转型把两个本来不同的问题拆开了:世界总览负责整体感和发现,国家地图负责地方尺度的阅读与编辑;D1 负责实时内容,R2 国家包负责可替换制图结果;浏览器处理当前国家,GitHub Actions 处理离线重活。它没有牺牲羊皮纸的视觉基线,却把下载、解码和绘制成本限制在用户真正进入的范围内。

仍有几项值得继续检验:GADM 和其他行政数据的长期许可与替换策略;国家包从单张 raster 升级为瓦片或 MVT 后的线条跨级一致性;高倍纸纤维的周期可见性;极端节点数量下 OpenLayers overlay 与 DOM 纸片的预算;以及失败构建、旧 manifest 和缓存回滚在真实网络条件下的表现。

公开实现与源代码:

https://github.com/Ysoseri1224/travel

如果你做过离线制图、Natural Earth 1 自定义投影、拓扑化 MVT、PMTiles、纸张多尺度纹理、OpenLayers 或 R2 版本化资产,欢迎审查这次转型前后的技术边界并提出改进建议。仓库提供的是当前代码和可公开实现;上面的设计推理、失败现象与取舍已在本文中完整说明。