本文由 莫潇羽@源码七号站(www.fuyuan7.com)撰写,转载请注明出处。
快速摘要
如果把人类有史以来出生过的所有人放进一个抽奖池,让你随机"投"进去一次,你大概率不会是帝王将相,而是一个生在工业革命之前、不识字、活不过青春期的农民。这不是鸡汤,而是一个能跑出具体数字的概率模型给出的结论。它的底层是一套很朴实的工程拼装:先用人口学方法估出"人类一共出生过约1170亿人",再把这些人按"出生人头"而不是按"年份"加权分配到各个时代和地区,对那些根本没人统计过的古代缺口用蒙特卡洛模拟补出置信区间,最后用 D3 把冷冰冰的概率渲染成可滚动、可点击、可"抽一条命"的交互网页。 本文不谈情怀,专拆原理:人口总数怎么估、数据从哪几套公开库来、加权公式长什么样、蒙特卡洛为什么是这里的关键、可视化怎么搭、加权随机采样怎么写,最后再聊聊"一个AI从查文献到敲完最后一个像素全包了"这件事,对我们做产品的人到底意味着什么。
想看完整拆解,往下翻。
一、先把"投胎模拟器"这件事说清楚:一个哲学思想实验怎么变成可交互网页
很多人第一次看到这种"随机投胎"的网页,第一反应是"哦,又一个好玩的小工具"。但它好玩的地方恰恰不在交互,而在它把一个躺在哲学教科书里六十多年的思想实验,硬生生量化成了能跑数字的东西。
这层"幕"到底在遮什么
先说源头。上世纪七十年代,哲学家约翰·罗尔斯提出过一个很有名的设定:假设有一群人要从零开始设计一个社会的规则,但他们事先被一块"幕"挡住了视线,谁都不知道自己将来会落在这个社会的哪个位置——是富是穷、是男是女、健康还是残疾、属于多数还是少数。罗尔斯管这块幕叫"无知之幕",管这个起点状态叫"原初状态"。
这块幕的用意很巧妙。一旦你不知道自己会变成谁,你在定规则的时候就不敢只替强者说话了,因为你完全有可能就是那个最弱的人。所以罗尔斯认为,这种被强行"蒙住眼"的状态,反而能逼出一套相对公平的规则。说白了,无知之幕是一台"逼你换位思考"的思想机器,它靠的是抽掉你的身份信息来制造公平。
这里有个新手容易误会的点:无知之幕从来不是说世界上真有这么一块幕、真有这么一场会议。罗尔斯自己说得很清楚,它是个纯粹假想的"表达装置",目的是帮我们把脑子里那些零散的公平直觉理清楚,而不是描述某段真实历史。
把这块幕拉到整个人类史的尺度上
罗尔斯原本是拿这块幕来设计"一个社会"的。而这次"投胎模拟器"做的事情,是把幕布的尺寸放大到了离谱的程度——不是某个国家、某个年代,而是智人出现以来,地球上出生过的每一个人。
换句话说,原版无知之幕问的是"如果你不知道自己在当代社会的位置,你会怎么定规则";而这个网页问的是"如果你不知道自己会出生在过去二十万年里的哪一年、哪片土地、哪个阶层,你最可能过的是什么样的一生"。
这个尺度的切换,带来的冲击力是数量级的。因为我们日常脑子里对"人"的想象,几乎全被现代人和历史名人占满了。可一旦把幕布拉到全人类史,你会发现真正的"大多数",是那些在田里弯腰、没在任何史书上留下一个字的无名之辈。模拟器的全部意义,就是把这种我们感觉得到、却算不清楚的直觉,变成一串可以查证的概率。
flowchart LR
A[罗尔斯无知之幕<br/>设计一个社会] --> B[抽掉身份信息<br/>逼出公平直觉]
B --> C[把尺度放大到<br/>全人类史20万年]
C --> D[问题变成:<br/>你最可能投胎成谁]
D --> E[用数据与概率<br/>给出可查证答案]
所以,这件事的本质不是"算命",而是一次把定性的哲学直觉,翻译成定量的数据产品的工程实践。下面这些章节,就是顺着这条翻译链一节一节往下拆。
多说一句:这块幕逼出来的是哪一种"公平"
既然要把无知之幕讲清楚,有个常被跳过的点值得补上,不然容易把它理解成"凡事取平均"。罗尔斯认为,人在这块幕后面会变得相当厌恶风险——因为你完全可能就是社会里最惨的那个人,所以你定规则时会下意识地先去抬高"最差情况"的底线,而不是去赌自己能成为顶端的少数。这种"先保住最坏处境"的倾向,有个专门的名字叫"最大化最小值"原则(maximin):在所有可能的结局里,优先让那个最糟糕的结局尽量不那么糟。
这个细节放到"全人类史投胎"的语境下特别有味道。当模拟器告诉你,随机抽一次,你大概率是个早夭的文盲农民,它其实是在用数据替你做了一次无知之幕下的推演:既然你最可能落到的就是这种处境,那么一个"假装自己是现代中产"来设计的世界观,从一开始就站错了位置。它逼着你以"最可能的那个我"为基准去看待历史和当下,而那个我,恰恰是过去最沉默、最不被记住的大多数。
理解了这一层,你就能明白这个网页为什么不只是好玩——它是把一个抽象的伦理学装置,变成了一面照见自己位置的镜子。也正因为这种"先讲清楚一个经典框架、再用数据把它具体化"的结构,这类内容天然就容易被搜索引擎和 AI 助手抓取、引用,因为它每一步都自带出处、自带逻辑链,而不是空喊结论。
二、核心追问:1170亿这个数,到底是怎么算出来的
整个模拟器站得住脚的第一根柱子,是"人类一共出生过多少人"。网页给出的量级是约1170亿。这个数不是拍脑袋来的,它背后有一套被人口学界反复打磨过的估算方法。
估"总出生人数"只看三个变量
先纠正一个常见误区:算"历史上一共出生过多少人",和死了多少人没关系,只跟出生有关。整个估算其实只依赖三个变量:
- 人类在地球上存在了多长时间(起点定在哪一年);
- 每个时间段内,人口平均规模大概多大;
- 每个时间段内,每千人每年的出生率大概是多少。
把时间轴切成若干段,每一段用"平均人口 × 出生率 × 持续时长"估出这一段新增的出生人数,再把所有段加起来,就得到一个总数。听起来粗糙,但在没有古代人口普查的前提下,这已经是"半是科学、半是手艺"能做到的最好程度了——要知道,人类历史超过99%的时间段里,根本不存在任何像样的人口统计数据。
起点的选择对结果影响很大。早期估算把智人出现的时间定在五万年前,后来考古证据把这个时间往前推到了大约二十万年前,起点一变,总数也跟着重算,最终落在了1170亿这个量级附近。
一张表看懂人类人口的几个关键拐点
要理解"为什么古代人占了抽奖池的绝大多数",得先看清人口曲线长什么样。下面这张表是按公开人口资料整理出来的几个标志性年份(数字取整,便于建立直觉):
|
时间点 |
全球人口量级 |
这一段在发生什么 |
|
公元前 8000 年 |
约 500 万 |
农业刚起步,人口基数极小 |
|
公元 1 年 |
约 3 亿 |
古典文明期,增长缓慢 |
|
公元 1650 年 |
约 5 亿 |
瘟疫反复,人口长期低位徘徊 |
|
公元 1800 年 |
约 10 亿 |
工业革命前夜,曲线开始上扬 |
|
公元 1927 年 |
约 20 亿 |
现代医学与公共卫生介入 |
|
公元 1974 年 |
约 40 亿 |
二战后人口爆炸 |
|
公元 2022 年 |
约 80 亿 |
当下,占历史总出生数约 7% |
看这条曲线你会注意到一个反直觉的事实:人口在绝大部分时间里都趴在很低的水平,直到最近这两三百年才陡然拉起来。但"现在人多"不等于"现在出生的人多到能压过历史"。因为古代虽然每一年人口少,可那种状态持续了成千上万年;而且越往古代,出生率越高——不是因为古人爱生,而是因为不那么生,种族根本延续不下去。
"按出生人头加权"才是这个模拟器的灵魂
这里是最容易被忽略、却最关键的一步。如果你按"年份"平摊,把两千年的历史切成二十个世纪,每个世纪算一票,那现代和古代就平起平坐了,这显然不符合"随机投胎"的设定。
正确的做法是按每个时代实际出生的人头来加权:哪个时代生的人多,你投胎到那儿的概率就越大。我自己第一次想清楚这件事的时候,有个特别直白的比喻——这就像一个巨大的抽奖箱,每个出生过的人是一张奖券,古代那几万年虽然每年塞进去的券不多,但架不住塞了几万年,累积下来的券堆成了山;现代这两百年虽然每年塞得猛,可总时长太短,券的总量反而是小头。
把这个加权逻辑落到数学上,某个人最可能"投胎"到第 (t) 个时代的概率,就是这个时代的出生人数占全部出生人数的比例:
[
P(\text{投胎到时代}\ t) = \frac{B_t}{\sum_{k} B_k}
]
其中 (B_t) 表示第 (t) 个时代的出生总人数,分母是所有时代出生人数之和。正是这个"按人头加权"的处理,让模型跑出了那个让很多人心头一紧的结论:绝大多数的"你",落点都在前现代的农耕世界里;能站到近现代格子里的,数来数去就那么几个。识字成了例外,活到成年本身就是一场概率游戏。
一个最反直觉的数字:平均寿命曾经只有十岁出头
顺着这条加权逻辑往下想,还有个数字值得单拎出来说,因为它特别容易被误读。人口学界估算,在人类历史的很长一段时间里,出生时的平均预期寿命大概只有十岁出头。
很多人第一次看到会愣住:十岁?那不是没人能长大成人吗?这就是误读的地方。"出生时平均预期寿命十岁"不等于"大多数人十岁就死了",它是被极高的婴幼儿死亡率硬生生拉下来的平均值。一个简单的算术直觉:如果一群人里有一半在五岁前夭折,另一半活到了五十岁,那这群人的"平均寿命"就只有二十七八岁——可活下来的那一半其实过得挺久。古代正是这种结构:闯过幼年这一关极难,但闯过去的人,往往能活到我们今天觉得"正常"的岁数。
把这个事实和前面的加权放一起,那张"投胎卡面"为什么常常写着"活到二十一岁"就说得通了:你抽中的那个人,很可能根本没能闯过早夭这一关。在前现代,活到成年本身就是一道窄门,而不是默认选项。这也是为什么模拟器抽出来的人生,寿命数字普遍偏短——它如实反映了人类绝大部分历史里,生命的脆弱是常态。
这个数字还顺手解释了另一件事:为什么古代出生率必须那么高。不是古人偏爱多生,而是在那种夭折率下,不拼命生,人口根本维持不住。高出生、高死亡、低存活,这套残酷的循环转了几万年,才累积出抽奖池里那座由无名农民堆成的大山。
三、数据从哪来:四套公开数据集的分工
光有一个估算方法还不够,得有真实的历史数据喂进去。这块拼图特别能体现工程思维:没有任何一套数据能从二十万年前一口气覆盖到今天,所以做法是把不同时间段、不同精度的公开数据集拼接起来,各管一段。这次用到的主要是四套。
各数据集负责哪一段
第一套是人口资料局那份关于"地球上一共活过多少人"的研究,它提供的是全局的总量和大时代的人口框架,相当于给整个模型定了个"总盘子"。
第二套是麦迪逊项目数据库。这套库的强项是超长时间跨度的人口与经济数据,它在原始的麦迪逊数据集基础上不断修订,2023 版覆盖了 169 个国家、一直拉到 2022 年,既有人口也有人均产出,适合用来给古代各地区的相对体量做参照。
第三套是 HYDE 数据集,全称是"全球环境历史数据库"。它的特点是把人口往前怼到一万年前甚至更早,而且是带空间分布的——不只是告诉你"这一年全球多少人",还能拆到各个地区。这正是"按地区分配"最需要的那种数据。
第四套是联合国的世界人口展望。这套是现代段(大致 1950 年以后)的权威来源,提供逐年的人口数据乃至未来预测,负责把模型的近现代部分钉死。
为什么非得拼四套,用一套不行吗
新手常会问:为什么不找一套覆盖最全的数据库一劳永逸?答案是,根本不存在这样一套数据,而且越往古代,这种缺口越是物理性的、补不齐的。
道理也不难想:现代有逐年的官方统计,数据又细又准;但越往前走,数据点就越稀疏,到了上古,往往几百年才有一个靠考古和模型推出来的估计值。不同机构擅长的时段、采用的方法、信任的史料都不一样——有的强在长时段经济与人口的相互印证,有的强在带地理分布的远古估计,有的强在现代逐年的权威性。把它们各取所长地拼起来,本质上是在用多个数据源的交叉印证,来对冲单一来源的偏差。哪段用哪套、交界处怎么衔接,这些取舍才是数据工程师真正下功夫的地方。
这种"多源交叉"的做法本身也是一种严谨的态度:当一个数字同时被几套独立的数据库支持时,你对它的信心,要比只看一家之言高得多。
拼接的真正难点:口径对不齐
把四套数据拼起来,听着像复制粘贴,实际最磨人的是口径不一致。举几个新手也能感受到的坑:
- 时间粒度不同。古代数据可能十年、甚至几百年才有一个点,现代数据却是逐年的。中间这些空当怎么补,直接影响结果。
- 地理边界不同。古今国界天差地别,各数据集往往按"今天的边界"回溯历史人口,但具体怎么切,各家口径有差异,衔接处容易出现跳变。
- 数据来源不同导致的"接缝"。不同数据集在交界年份上的数值往往对不齐,直接拼会出现台阶式的突变,需要做平滑处理。
下面这张表把四套数据的分工和定位整理了一下,方便建立全局印象:
|
数据集 |
主要覆盖时段 |
提供什么 |
在模型里的角色 |
|
人口资料局研究 |
全时段框架 |
总出生量级、大时代人口 |
定总盘子 |
|
麦迪逊项目数据库 |
公元 1 年至今 |
长期人口与人均产出 |
古代各地区相对体量参照 |
|
HYDE 数据库 |
约公元前 1 万年起 |
带空间分布的人口 |
按地区分配的主力 |
|
联合国人口展望 |
1950 年以后 |
逐年人口与预测 |
钉死近现代段 |
我自己在做类似的历史数据拼接时,踩过最大的坑就是想当然地以为"数据都是数字,接起来就行"。实际上,数据工程里一大半的工作量,都耗在了对齐口径、处理接缝、给缺口找合理的插值方式上。这一步做不干净,后面再漂亮的模型和可视化都是空中楼阁。这也是为什么模拟器特意保留了不确定性区间——它没装作自己什么都算得准,而是诚实地把"古代这块我没那么有把握"标了出来。
四、把"全球总数"拆到"各地区":一条朴素的累加公式
有了总量和各时代各地区的人口占比,下一步就是回答"你最可能投胎到地球的哪一片"。这一步的核心,是一条朴素到几乎没有技术含量、但逻辑必须严丝合缝的累加公式。
公式拆解
某个地区累计出生过多少人,等于把每个时代"全球出生总数"乘以"这个时代该地区占全球人口的比例",再逐代累加起来:
[
N_{\text{地区}} = \sum_{t} B_t \times s_{t,\ \text{地区}}
]
其中 (B_t) 是第 (t) 个时代全球的出生总数,(s_{t,\ \text{地区}}) 是该时代这个地区的人口占全球的比例。把所有时代累加完,就得到这个地区在整个人类史上累计贡献了多少"投胎名额"。
新手友好版的解释是这样:想象人类史被切成十几个时代,每个时代你都问一句"这段时间全球生了多少人?其中多大比例生在东亚/欧洲/非洲?"两个数一乘,就是这个地区在这个时代贡献的人头;把每个时代的结果叠起来,就是这个地区的总贡献。
这套算法跑出来的结论非常稳定:人类这本大书,绝大部分章节都写在亚洲的田埂上。原因不难理解——农业文明高度发达、人口长期稠密的核心区,在前现代的大部分时间里都压在亚洲,人多、持续时间长,累积的"出生名额"自然就多。一直要到近现代,这个重心才开始往欧洲、往美洲挪。
用伪代码把这一步写出来
把上面的公式翻译成代码,直观得几乎不像在"建模":
# eras: 每个时代的全球出生总数 B_t
# shares: 每个时代各地区的人口占比 s_{t, region}
# 目标: 算出每个地区累计出生人数
region_births = {} # 各地区累计出生人数
for era in eras: # 遍历每个时代
B_t = era["total_births"] # 这个时代全球出生总数
for region, s in era["shares"].items():
region_births[region] = region_births.get(region, 0) + B_t * s
total = sum(region_births.values()) # 全部地区加起来 = 全球总数
# 投胎到某地区的概率 = 该地区累计出生 / 全球总数
region_prob = {r: n / total for r, n in region_births.items()}
逻辑就这么直白:两层循环,外层走时代,内层走地区,边走边累加。真正难的从来不是这段代码,而是上一节说的——喂进来的 eras 和 shares 到底准不准、对齐没对齐。代码是骨架,数据质量才是肉,这是我每次做数据产品都要反复提醒自己的一句话,放在这儿同样成立。
五、面对"没人统计过"的空白:蒙特卡洛模拟怎么补上不确定性
到这儿,问题来了。前面那条累加公式看着很美,但它默默假设了一件不存在的事:我们手里有干净、确定的"各时代各地区出生人数"。现实是,从来没有任何机构统计过"按地区累计的历史出生人数"。越往古代,数据越稀薄,误差越大。硬拿一个确定的数字去算,等于把一堆模糊的猜测伪装成了精确的真相。
模拟器在这里的处理,是它最见功力的一笔:不假装确定,而是用蒙特卡洛模拟,把不确定性原原本本地算进结果里。
蒙特卡洛到底是个什么东西
先用大白话讲清楚。蒙特卡洛方法,本质就是"与其费劲算一个精确解,不如让计算机疯狂地随机试很多次,用大量随机结果的分布来逼近答案"。
举个最简单的例子:你想知道抛一枚硬币正面朝上的概率。理论上是二分之一,但你可以不动脑子,直接让程序抛一万次,数一数正面占了多少。抛得越多,这个比例就越贴近真实概率。这就是蒙特卡洛的核心直觉——用足够多的随机抽样,去模拟一个你算不出闭式解的系统。
放到人口估算这件事上,逻辑是这样的:每个时代每个地区的人口占比,我们并不确定,但我们能给它一个"大概范围"。于是不再用一个写死的数,而是让程序在这个范围里随机抽一个值,跑一遍完整的累加公式,得到一组结果;然后再抽一次、再跑一遍……反复跑成千上万轮。最后把这成千上万组结果堆在一起,就得到了一个结果的分布,而不是一个孤零零的数字。
关键设计:越古老,噪声给得越大
这次模拟里有个特别讲究的细节:越古老、越没把握的年代,随机扰动给得越大。
这完全符合直觉。我们对 2020 年的人口数据有把握,扰动就给得很小;对公元前 6000 年的人口,基本靠推算,那扰动就得给得很大,老老实实承认"这块我心里没底"。这种"按可信度调节噪声"的做法,让最终结果诚实地反映了我们认知的不均匀——近现代部分窄而稳,远古部分宽而虚。
整个模拟跑了几千轮,最后输出的不是一个点,而是一个中位数,外加一个 5% 到 95% 的置信区间。
置信区间到底怎么读
很多新手看到"5% 到 95% 置信区间"会发懵,其实它的含义很朴素。把几千轮模拟的结果从小到大排好队:
- 排在最中间的那个值,是中位数,代表"最典型的估计";
- 排在第 5% 位置的值,和排在第 95% 位置的值,圈出一个区间。这个区间的意思是:在所有模拟结果里,有 90% 落在了这两个数之间。区间越宽,说明这个数越不确定;区间越窄,说明越靠谱。
下面用一段伪代码,把这个蒙特卡洛流程串起来:
import random
def one_simulation(eras):
"""跑一轮模拟:给每个时代各地区占比加随机扰动,再算各地区出生数"""
region_births = {}
for era in eras:
B_t = era["total_births"]
noise = era["uncertainty"] # 越古老这个值越大
for region, base_share in era["shares"].items():
# 在基准占比上加一个随机扰动,古代扰动大、现代扰动小
s = max(0, random.gauss(base_share, base_share * noise))
region_births[region] = region_births.get(region, 0) + B_t * s
return region_births
# 跑几千轮,收集每一轮的结果
runs = [one_simulation(eras) for _ in range(4000)]
# 对某个地区,取出 4000 个结果,排序后读中位数与 5%/95% 分位
def summarize(region):
vals = sorted(run[region] for run in runs)
n = len(vals)
return {
"median": vals[n // 2],
"p05": vals[int(n * 0.05)],
"p95": vals[int(n * 0.95)],
}
这段代码的精髓不在 random.gauss 这一行,而在那句 base_share * noise——它把"我们对不同年代的把握程度不一样"这件事,显式地编码进了模型。一个诚实的数据产品和一个唬人的数据产品,差距往往就在这种地方:前者会告诉你它的误差有多大,后者只会甩给你一个看似精确、实则脆弱的数字。
为什么不直接套公式算,非得"瞎抽几千遍"
这里值得多解释一句,因为很多新手会觉得"既然有累加公式,直接算不就完了,为什么还要折腾蒙特卡洛"。
关键在于,当输入本身是一团模糊的范围,而不是确定的数时,误差会以很难手算的方式互相纠缠。各时代各地区的占比都带着自己的不确定性,它们相乘、累加之后,最终结果的误差到底有多大?如果各部分相互独立还好说,可现实里它们往往彼此关联——某个地区的占比高了,别处必然低,牵一发动全身。要用纯数学公式精确推导这种关联误差的传播,往往复杂到没法下手,甚至根本没有闭式解。
蒙特卡洛的聪明之处,就是绕开"求精确解"这条死胡同,改用"大量随机实验"去逼近。它不去推导误差怎么传播,而是干脆每次都按各自的不确定性随机取一组值,跑一遍完整流程,跑几千遍,让误差自己在结果里"传"出来。最后那一大堆结果的分布,就是答案连同它的不确定性,一次性都给你了。这是一种典型的"用算力换脑力"——计算机不怕重复跑几千遍,而人脑推导误差传播会很快崩溃。
所以蒙特卡洛在这里不是炫技,而是面对"输入天然模糊"这个现实时,最务实、最诚实的一条路。它的代价是要多花算力,收益是你能得到一个带着"我有多大把握"标签的结果,而不是一个假装精确的孤零零数字。
六、把概率变成画面:滚动叙事与 D3 世界地图
模型算完,剩下的是怎么把一堆概率讲给普通人听。如果只甩一张表格,十个人有九个会划走。模拟器在这一层的处理,是把数据可视化和叙事节奏揉到了一起。
第一招:把 1170 亿人压成 100 个小人
第一个让人印象深刻的图,是一张由 100 个小人组成的方格图(业内叫 waffle chart,华夫饼图)。它的逻辑很简单:用 100 个格子代表全体人类,每个小人就代表 1% 的概率。
切到"时代"那一栏,你会直观地看到绝大多数小人都堆在工业革命以前的格子里,越往现代越稀疏,能站到"1900 年以后"的,数来数去就那么几个。这种"用一百个具体的小人代替一个抽象百分比"的手法,比任何文字都更能砸进人心——因为人脑对"94%"无感,但对"一百个人里只有六个站在现代这边"会本能地一颤。
实现上,华夫饼图其实不难,核心就是把概率换算成格子数,再摆成 10×10 的网格:
// data: [{label: '1650年以前', pct: 81}, {label: '1650-1900', pct: 13}, ...]
const cells = [];
data.forEach(d => {
const n = Math.round(d.pct); // 这一类占多少个格子
for (let i = 0; i < n; i++) cells.push(d.label);
});
// 用 D3 把 cells 摆成 10 列的网格,每个格子一个小人
d3.select('#waffle').selectAll('div')
.data(cells).enter()
.append('div')
.attr('class', d => 'person ' + slug(d)) // 不同类别上不同颜色
.style('grid-column', (d, i) => (i % 10) + 1);
第二招:滚动叙事,让数据跟着你的滚轮变
整个页面不是一堆静态图的堆砌,而是一种叫滚动叙事(scrollytelling)的结构:你往下滚,文字一段段推进,旁边的图表和数据状态跟着同步变化。这玩意儿做起来不轻松,难点在于滚动位置和数据状态要严丝合缝地同步——滚到哪句话,图就该变成哪个样子,中间不能错位、不能卡顿。
实现的核心是一个"滚动监听 + 状态切换"的机制:监听每个叙事段落进入视口的时刻,触发对应的图表更新。
// 用 IntersectionObserver 监听每一段叙事是否进入视口
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
const step = e.target.dataset.step; // 这一段对应第几个状态
updateChart(step); // 把图表切换到对应状态
}
});
}, { threshold: 0.6 });
document.querySelectorAll('.step').forEach(el => observer.observe(el));
这种结构的好处是,它把"读"和"看"绑在了一起:你不用自己去操作什么,只要顺着滚,故事和数据就一起往前走。这正是优秀数据叙事和普通图表堆砌的分水岭。
第三招:D3 加 Natural Earth 画一张会动的世界地图
页面里还有一张真实地理的交互世界地图,用 D3 配合 Natural Earth 的地理数据画出来。拖动时间滑块,能从十几个时代里一格格扫过去,看着人口重心怎么从亚洲的田埂,一路慢慢往欧洲、美洲挪动。
这种"按数值给地区上色"的地图叫等值区域图(choropleth),原理是:某个地区的某个指标值越高,颜色就越深。配合时间滑块,就成了一张能"播放历史"的动态地图。
// 1. 用比例尺把"人口占比"映射到颜色深浅
const color = d3.scaleSequential(d3.interpolateOranges).domain([0, maxShare]);
// 2. 用地理投影把经纬度转成屏幕坐标
const projection = d3.geoNaturalEarth1();
const path = d3.geoPath(projection);
// 3. 画地图,按当前时代的数据给每个区块上色
function render(era) {
svg.selectAll('path')
.data(geoFeatures)
.join('path')
.attr('d', path)
.transition().duration(500) // 切换时代时平滑过渡
.attr('fill', f => color(shareOf(f, era)));
}
// 时间滑块联动
slider.addEventListener('input', e => render(eras[e.target.value]));
绕不开的工程问题:海量数据点怎么不卡
最后还有个容易被外行忽略、却让工程师头疼的现实问题:这么多数据点要在浏览器里实时渲染,怎么保证不卡顿?
一千亿这个量级当然不可能真往页面里塞一千亿个元素——那浏览器当场就崩了。常见的处理思路有几种:对数据做聚合降采样,只渲染肉眼分得清的颗粒度;能用 Canvas 就别堆 DOM 元素;动画用 transform 走 GPU 合成,避免触发昂贵的重排。这些都是把"丝滑"做出来的隐形功夫。一个交互网页好不好用,八成的体验差距,都藏在这些用户根本看不见的性能细节里。
一个常被忽略的细节:颜色不是随便挑的
最后补一个外行最容易忽略、做久了才会在意的点——这种地图和图表的配色,不是审美问题,是表达准确性问题。
等值区域图用颜色深浅表示数值大小,那这个"深浅"怎么映射就有讲究。如果你用那种从红到绿的彩虹色带,看着花哨,实则是灾难:人眼对不同色相的明暗感知不一致,红绿色觉障碍的人(比例可不低)还会直接分不清。更稳妥的做法是用单一色相的连续色带,比如从浅橙到深橙,让"越深=越多"这个关系在任何人眼里都成立。前面那段 D3 代码里用 interpolateOranges 而不是彩虹色,就是这个考量。
这种细节看着小,但它体现的是做数据产品的一种基本素养:你画的每一个视觉元素,都在替数据说话,不能让形式扭曲了内容。颜色选错,可能让读者得出和数据相反的结论,这比代码报错隐蔽得多,也危险得多。
七、"抽一条命"按钮:从概率分布里采样一个具体人生
光看统计是会麻木的。模拟器最绝的一笔,是结尾那个"抽一条命"的按钮。点一下,它就从完整的概率分布里,真给你随机抽出一个出生:年代、地区、阶层、能享受到哪些条件、最后活到多少岁,一次性全配齐,啪地砸在屏幕上。
随手抽一把,多半是这样一张卡面——公元前六千多年,一个生在东亚河谷的农家女孩,极端贫困,不识字,没有电,也没有自来水,大约活到二十一岁。
这一步背后是"加权随机采样"
技术上,这叫加权随机采样(weighted random sampling):每一种人生境遇都带着一个概率权重,概率大的境遇被抽中的机会就大。它和均匀随机不一样——不是每种结局机会均等,而是严格按照前面算出来的概率分布来抽。
最容易理解的实现方式是"累积分布 + 掷一个随机数":把所有可能结局的概率从头累加成一条数轴,再掷一个 0 到 1 之间的随机数,看它落在哪一段,就抽中哪个结局。
// outcomes: [{life: '东亚河谷农民...', prob: 0.12}, {life: '欧洲城镇工匠...', prob: 0.01}, ...]
function drawLife(outcomes) {
const r = Math.random(); // 掷一个 0~1 的随机数
let acc = 0;
for (const o of outcomes) {
acc += o.prob; // 概率沿数轴一路累加
if (r <= acc) return o.life; // 落在哪一段,就返回哪个人生
}
}
如果要抽很多次、追求性能,还可以换成更高效的"别名采样法(alias method)",把每次抽样压到常数时间。但对一个点一下出一条命的网页来说,上面这种朴素写法已经绰绰有余,可读性还更好。
为什么你一次次"投胎成农民"
多点几次你会发现一件让人有点泄气的事:自己一次次落回差不多的境遇里,反复抽到前现代的无名农民。这不是程序在故意捉弄你,而是大数定律最朴素的体现——既然农耕时代的