500字节画出一张世界地图——比你这条微信还短

500字节画出一张世界地图——比你这条微信还短

SVG数据压缩可视化算法

数据源:HN · HN

500字节能干什么?

一条微信文字消息,大约500字节。一张手机拍的最小尺寸缩略图,也得几KB起步。但最近,开发者 Iwo Kadziela 做了一件反直觉的事:他用不到500字节的数据,画出了一张可辨认的世界地图。

注意,是可辨认——你能看清七大洲的轮廓,分得出非洲和南美。不是那种少到只剩两个圆圈的”抽象艺术”。

笔者看到这个项目的第一反应是:这怎么可能?一张地图需要描述大陆边缘的每一个弯曲、每一个半岛、每一个海湾。即便只保留最粗略的轮廓,SVG 路径坐标数也轻松破千。500字节?连塞坐标都不够。

然后笔者读完了原作者的博客文章——发现整个故事的关键转折,恰恰在于”SVG试过了,失败了”。

反派登场:坐标数 vs 字节限额

这里有一个根本性的张力:地图的视觉精度,和你能用来描述它的字节数,本质上是对手。

一张真实的世界地图,核心是海岸线——那些弯曲、凹入、凸出,而不是色块填充。简化地图数据,通常的思路是用 Douglas-Peucker 算法:遍历路径上的点,找到距离基线最远的那个点,如果距离超过了容差阈值就保留,否则丢弃中间所有点。反复递归,直到整条路径被精简到最少的关键点集。

这个算法在”去掉95%的点但肉眼看不出来”的场景下效果很好。问题是,当你把容差调到足够大——大到只剩几十个坐标点时——地图就不再像地图了。非洲变成了三角形,南美洲成了一个椭圆,东南亚群岛直接消失了。

原作者正是从 SVG 路径出发的。他用 AI 编码助手 Codex 尝试了多种 SVG 方案,结果一致:一旦路径简化到500字节以内,形状已经面目全非。

SVG 路径有一个冷酷的代价规则:每一个坐标都要为它的精确性付费。 一个典型的 SVG 路径命令,比如 M123.4 567.8 L234.5 678.9,光是坐标数字就要消耗十几到二十几个字符。一个大陆的轮廓线,少则几十个点、多则几百个。算上 SVG 标签开销(<path d="..." />),500字节只能容纳大约20-30个坐标点——这对世界地图来说远远不够。

工程判断:Douglas-Peucker 在”适度简化”区间表现优秀,但500字节的极端约束把简化比推到了算法能力的边界之外。路径简化在这里碰到的已经不是精度问题:表达方式本身出了问题

转机:放弃 SVG,拥抱 ASCII

Codex 最终回到了一个看起来更”原始”的方案:ASCII 字符画。

做法直接得令人意外:用一个字符矩阵表示地球表面,* 代表陆地,空格代表海洋。长这样(缩略示意):

        *****
       *******
      *********
     ***********
      *********
       *******
        *****

这个方案的优势在哪?不是更精确——ASCII 网格的分辨率远低于 SVG 坐标。优势在于:每个人眼觉得”重复”的东西,压缩算法也觉得重复。

原作者做了三个关键优化:

第一刀,砍掉海洋。 最初的 ASCII 地图用 . 表示水、* 表示陆地。原作者意识到,识别地图时人们只看大陆块在哪里,水是什么符号根本无关。删掉所有海洋符号后,整张图变成了稀疏的陆地斑块——但它节省了大量无用字符。

第二刀,裁剪画布。 原始地图左右两侧有大片空白海洋。把画布裁到刚好包住所有大陆,进一步缩小了字符矩阵。

第三刀,选择填充而非轮廓。 这是一个关键决策。原文作者尝试过只保留大陆轮廓线(细线条勾勒形状),理论上信息密度更高。但实际效果却更差——为什么?因为轮廓线在字符矩阵里表现为稀疏分布的 *,之间隔大量空格。而填充的大陆块产生了长串连续的 *。压缩算法不擅长处理”一个字符、一个空格、再一个字符”的稀疏模式,但它极擅长处理”连续100个 *”这样的重复序列。

这三刀下去,ASCII 地图的原始大小是8523字节——作为纯文本,不算小。但好戏在后头。

核心引擎:deflate 为什么能让8523字节变成445字节

压缩前:8523字节。压缩后:445字节。压缩比接近95%。

这不是魔法,是 deflate 算法对 ASCII 地图这种数据天然适配的结果。

deflate 是 ZIP 和 gzip 使用的压缩算法,它由两阶段组成:

第一阶段:LZ77(字典压缩)。 算法从左到右扫描数据,寻找之前出现过的重复序列。找到后,用”往回看 N 个字节,复制 M 个字节”的指令代替原始数据。一行的 **** 和下一行的 **** 是重复的,一块大陆内部的连续 * 更是重复的。ASCII 世界地图里,大陆块的每一行都和前一行高度相似——LZ77 对这种”相邻行之间的冗余”处理效率极高。

第二阶段:Huffman 编码(熵编码)。 LZ77 产生的输出里,某些符号(比如”重复长度=1”、“往回距离很小”)出现的频率远高于其他符号。Huffman 编码给高频符号分配短码(2-3比特)、低频符号分配长码,进一步压缩。

这里有一个值得展开的细节:填充大陆 vs 轮廓线的压缩差异。填充大陆产生的是大块连续 *,LZ77 用一个指令就能描述”往后复制整行”。轮廓线则是 * 和空格的交替,LZ77 找不到足够长的重复序列,只能退回到逐字符编码,压缩率大幅下降。

原作者在文章中写得直白:“We tested removing the filled interiors and keeping only continent outlines, but that actually compressed worse.”——填充反而比轮廓压缩得更好,这在直觉上是反过来的,但理解压缩算法的内部机制后,它合情合理。

工程判断:选择压缩友好的数据表示,比直接削减数据本身更有效。 填充大陆的数据量更大(8523字节 vs 轮廓线的可能只有3000字节),但压缩后的结果更小。这意味着在极端字节约束下,优化目标不应该是”让原始数据变小”,而是”让压缩后的数据变小”——这两个目标有时是矛盾的。

浏览器怎么解压

最终的实现极其精简。HTML 文件本体不到1KB,核心逻辑只有一行 fetch 调用:

fetch('data:;base64,...')
  .then(r => r.body.pipeThrough(new DecompressionStream('deflate-raw')))
  .then(s => new Response(s).text())
  .then(t => b.innerHTML = '<pre>' + t)

工作流程:Base64 解码 → 浏览器内置的 DecompressionStream API(deflate-raw 模式)解压 → 把解压后的 ASCII 文本塞进一个 <pre> 标签 → 用 font-size: 0.65vw 让字符足够小,使地图在任意屏幕上完整可见。

值得留意的是,这里用的是浏览器原生 API。DecompressionStream 在 Chrome 80+、Firefox 113+、Safari 16.4+ 上都可以使用,不需要额外引入任何 JS 库,零依赖——这本身也为总字节限额争取了空间。

从500字节里学到的

笔者从这个实验里提炼出三条思考,它们和日常工作中遇到的优化问题其实相通:

一是表达方式决定压缩上限。 如果你用 SVG 路径坐标表示地图,无论怎么简化,每个坐标都在单独消耗字节。而 ASCII 字符矩阵天然具备行间冗余——压缩算法的设计正是为了捕获这种冗余。选择哪种表达方式,比用什么压缩参数更根本。

二是”原始数据越小越好”是直觉陷阱。 填充大陆的原始大小比轮廓线大,但压缩后更小。这个反直觉的结果说明:你需要理解压缩算法的工作方式,才能设计出真正适合压缩的数据结构。只看原始大小做决策,可能走向错误的方向。

三是约束本身可以成为创造力引擎。 500字节不是一个实用需求——现实中没有人需要把地图塞进一条微信消息的长度。但这个荒谬的约束迫使作者放弃了”把 SVG 画得更精简”的常规思路,转而追问”压缩算法到底喜欢吃什么”。限制越紧,越能暴露惯性思维的盲区。

压缩过的世界地图,仍然是世界地图。

但不是因为数据被削减了——数据的组织方式恰好和压缩算法的胃口对上了。500字节不是画出来的,是”骗”出来的:骗过压缩算法的代价函数,让它以为自己在高效存储重复数据,而实际上存储的是一张能认出来的世界。

参考链接:

本文技术分析基于原作者 Iwo Kadziela 的博客文章、GitHub 仓库源码以及 HN 社区讨论。