跳转至

比例尺 — 数据世界到像素世界的翻译器

这一章讲什么: 数据可视化本质上是按比例表示数据:把任意数值映射到位置、长度、颜色。 d3-scale 模块里的每个函数都是一台翻译器,本章把全部翻译器分类讲透: 连续的(线性、幂、对数、时间)、插值的(输出颜色)、离散的(切桶、类别映射)。 原书给了一套可交互的对比页(Compare/ 文件夹),同一批数据换不同比例尺渲染,差异一目了然。

1. 顶层全景:一张选型地图

全部比例尺可沿着三个问题定位:

  输入是连续的还是离散的?
  ├─ 连续输入
  │   ├─ 输出也要连续(位置/长度)→ 连续比例尺
  │   │   ├─ 比例关系重要 → scaleLinear(默认选择)
  │   │   ├─ 数量级跨度大 → scaleLog(不能有 0)
  │   │   ├─ 要有 0 又要对数感 → scaleSymlog
  │   │   ├─ 要强调小数值差异 → scalePow / scaleSqrt
  │   │   └─ 输入是日期 → scaleTime / scaleUtc
  │   └─ 输出是颜色 → 插值比例尺
  │       ├─ 单向变化 → scaleSequential
  │       └─ 正负两极 + 中点 → scaleDiverging
  └─ 输入是类别
      ├─ 输出位置(柱状/散点)→ scaleBand / scalePoint
      ├─ 输出颜色等样式 → scaleOrdinal
      └─ 想把连续值切成几档 → scaleQuantize / scaleQuantile / scaleThreshold

  图说:先分输入,再分输出,最后按数据的数学性质挑非线性变体。

2. 连续比例尺:domain 进、range 出

所有连续比例尺共享一套心智模型:domain(定义域)= 数据的取值范围,range(值域)= 屏幕上的输出范围, 调用比例尺函数就是做一次带插值的映射1。

  const scaleA = d3.scaleLinear().domain([0,1000]).range([100,500]);
  scaleA(0)     // 100   —— domain 下限 → range 下限
  scaleA(250)   // 200   —— 四分之一处
  scaleA(1000)  // 500

  const scaleB = d3.scaleLinear().domain([0,1000]).range([500,100]);  // range 反装
  scaleB(250)   // 400   —— 斜率变负:y 轴向上的手法(第 08 章)

  图说:range 两端一换,函数就反向——不需要任何专门配置。

配置方法(连续比例尺通用,全部可链式)2:

方法 干什么 备注
domain(数组) / range(数组) 设输入/输出范围 可传多段(见下)
rangeRound(数组) range 取整 防抗锯齿模糊
invert(值) 反向翻译:像素 → 数据 交互取数必备
nice() 把 domain 撑成整数端点 轴刻度更整齐
clamp(bool) 超界输入钳到端点 默认 false
interpolate(工厂) 换插值器(如 HSL 空间) 颜色专用
unknown(值) undefined/NaN 输入的输出 默认 undefined
exponent/base/constant 幂/对数/对称对数的参数 各归各的尺

2.1 线性比例尺与分段

scaleLinear 就是 f(x) = ax + b,保比例差。domain 和 range 都可以分段: 每对相邻端点之间独立插值3:

  const scale = d3.scaleLinear().domain([-10,0,10]).range([0,900,1000]);

  图说:负半段映射到 0–900(宽),正半段挤进 900–1000(窄)——
  想把某段数据放大看的标准手法,斜率每段不同。

clamp 与 invert 是两个高频工具,原书给了具体的数4:

  scale(64)    // 3700   —— 超界就外推,不会报错
  scale.clamp(true)
  scale(64)    // 1000   —— 钳到端点
  scale(-10)   // 0

  scale.invert(300)   // -4   —— 用户点在 300 像素处 → 数据值 -4
  图说:invert 是交互图的地基——点击/悬停的坐标全靠它译回数据值。

2.2 非线性连续尺

比例尺 变换 什么时候用
scalePow().exponent(k) f = axᵏ+b 想弯曲线性关系;平方强调大值,开方强调小值
scaleSqrt() = scalePow().exponent(0.5) 面积↔半径的换算(面积比 = 半径比²)
scaleLog().base(k) f = a·log(x)+b 数量级跨度大;domain 不能有 0
scaleSymlog().constant(k) 中间线性、两侧对称对数 要有 0、有负数、又要压数量级
scaleTime / scaleUtc 线性,domain 是 Date 日期;Utc 版不受本地时区影响

原书的主线演示:同一列 −10…10 的数,画在 1000 像素轴上——

  scalePow().exponent(2):   平方后再映射 → 大值被拉开,点列呈 U 形曲线
  scaleSqrt():              开方后再映射 → 小值被拉开
  scaleLog().domain([1,20]): 1~10 占去左半边,11~100 挤在右半边——
                            每个等距刻度代表 ×10(默认 base 10)
  base(2):                  等距刻度代表 ×2
  scaleSymlog():            (-1,1) 之间是直线,往外是对数曲线,零附近平滑过渡

  图说:非线性尺不扭曲数据,只扭曲「距离」的分配——
  把像素预算花在数据密集或你关心的区间。

对数的另一条死线:domain 严格正或严格负,不能含 0(log(0) 无定义)。 既要对数感又要 0 和负数,才轮到 symlog——它由三段函数拼成:中间 (−k, k) 线性, 两侧各自对称对数,constant(k) 调线性区宽度(默认 1)5。

2.3 时间比例尺

scaleTime 是 domain 为 Date 对象的线性尺——内部按毫秒插值,刻度自动生成日历区间 (跨年就出年份,单日就出小时)6:

  const scale = d3.scaleTime()
    .domain([new Date(2017,9,1), new Date(2018,9,1)])
    .range([0,1000]);

  // 三个日期点 [10/1, 11/26, 次年 3/15] 映射到 ~117, ~233, ~550 像素附近
  // 单日 domain(0:00–23:59)→ 刻度自动变「日 + 小时」
  // scaleUtc 同数据、同位置,刻度按 UTC 摆——时差让轴整体平移

3. 插值比例尺:输出是颜色

顺序比例尺(scaleSequential) = 连续尺 + 插值器:把 domain 线性压到 [0,1], 交给插值器产出输出(通常是颜色)。默认 range 就是 [0,1],你只配 domain7:

  const scale = d3.scaleSequential(d3.interpolateRainbow).domain([0,600]);

  d3.range(0,601,5).forEach(d => /* 每 5 单位一根 4px 色条,填 scale(d) */)
  // → 一条 600 像素的彩虹

  图说:rainbow 插值器吃 t∈[0,1] 出颜色;scale 负责把 0–600 归一化。

非线性变体(scaleSequentialPow(d3.interpolateRainbow).exponent(3))把颜色预算重分配: 指数 3 让前半段几乎跑完整个光谱,蓝色独吞后半——数据分布不均时让颜色用得更公平8。

发散比例尺(scaleDiverging) = domain 三段的顺序尺:[负极, 中点, 正极], 中点映射到插值器的 0.5。配 d3-scale-chromatic 的发散配色(如 interpolateBrBG 棕-绿)9:

  const scale = d3.scaleDiverging(d3.interpolateBrBG).domain([-300, 0, 300]);
  // 负值 → 棕色系,0 → 中性色,正值 → 绿色系
  // 中点不必是 0:温度距平常取多年均值,domain 变成 [-3, 0.8, 3] 之类

发散 vs 顺序的选择依据原书一句话:要强调正负两极,用发散;单向渐变,用顺序10。

4. 离散比例尺:切桶与对位

离散比例尺分两类:分区(连续输入切成几桶)和序数(离散输入对位到输出)。

4.1 三个切桶尺:quantize / quantile / threshold

主线例子:同一批数,三种桶法,四个颜色11:

  data = [1,9,14,17,20,  29,31,45,49,  51,56,62,  80,89,99]

  ① scaleQuantize(等宽切)
     .domain([0,100]).range(['blue','red','green','orange'])
     scale.thresholds()   // [25, 50, 75]
     切法:0–25 蓝,25–50 红,50–75 绿,≥75 橙 —— 每段等宽,与数据分布无关
     → 1–20 蓝(5 个),29–49 红(4 个),51–62 绿(3 个),80–99 橙(3 个)

  ② scaleQuantile(等数切)
     .domain(sample).range(同四色),sample = [1,3,4, 8,9,20, 30,36,41, 45,48,96]
     scale.quantiles()    // [7, 25, 42]
     切法:按样本的分位数定界——每段约 3 个点,桶宽不等
     → 同样的 data,各档点数被强行拉平

  ③ scaleThreshold(手切)
     .domain([10,20,80]).range(同四色)
     切法:<10 蓝,10–20 红,20–80 绿,≥80 橙 —— 阈值自己定,业务口径说了算

  图说:等宽(quantize)不看数据分布;等数(quantile)完全由样本分位数定;
  阈值(threshold)把业务规则(如 HDI 等级 0.550/0.700/0.800)直接写进 domain。

三个尺都支持 invertExtent(输出值) 反查区间:scale.invertExtent("red") 返回 [25, 50]12。 原书 exercise 9.4–9.6 用同一批 HDI 数据把三种尺各练一遍——对比着学,差异记得最牢。

4.2 序数比例尺:类别对位

scaleOrdinal:domain 与 range 一一对应;domain 比 range 长时,range 循环13:

  const colorScale = d3.scaleOrdinal()
    .domain(d3.range(0,501,25))               // 21 个值
    .range(['black','blue','red','green']);   // 4 个颜色 → 循环 5 轮多

  colorScale.unknown('gray')   // 不在 domain 里的输入,不再自动加进 domain,而是给灰色

unknown() 是个行为开关:默认情况下序数尺遇到没见过的输入会把它加进 domain; 设了 unknown 之后变成「陌生值一律给这个后备值」14。

4.3 band 与 point:位置专用序数尺

柱状图和分类散点的位置,不用 scaleOrdinal 硬凑,有两个专职尺:

band:把 range 切成 N 条等宽带,每带对应一个类别。核心方法15:

  const scale = d3.scaleBand().domain(d3.range(0,100,10)).range([0,800]);

  scale(30)           // 第 4 条带的起点 x
  scale.bandwidth()   // 带的宽度 → 直接当柱宽
  scale.step()        // 相邻带起点间距(bandwidth + paddingInner)
  .padding(v)         // 内、外边距一起设(占 step 的比例)
  .paddingInner(v)    // 带间空隙
  .paddingOuter(v)    // 两端空隙
  .align(0~1)         // 图在边距里的对齐(0.5 = 居中,默认)

  图说:柱宽、柱间距、两端留白全由 band 尺统一发配——第 03 章柱状图的 yscale 就是它。

point:把 range 切成 N 个等距点(没有带宽),散点的分类 x 轴标配:

  const scale = d3.scalePoint().domain(d3.range(0,100,10)).range([0,800]);
  .attr("cx", scale)          // 10 个点均匀落在 0–800
  .padding(v) 以 step 为单位把点群往里收;.align() 调对齐;.step() 取点距

5. 在线教程:散点图(第一部分)

GDP 人均(国际元,对数跨度)× HDI 的散点:加载联合国 CSV → 定尺寸与边距 → 配比例尺(这里用对数 x 轴!)→ 画点 → 加轴和标签 → 回调比例尺到最优。 第二部分接在第 10 章后加颜色、悬浮提示与交互16。

6. 作者的判断与证据

判断 性质 依据
「数量级跨度大用对数,有 0 用 symlog」 选型法则 log 不能含 0 的数学限制 + symlog 三段结构5
「发散尺强调两极,顺序尺单向渐变」 选型法则 domain 三段结构与插值器 0.5 中点的对应关系10
「量化等宽、分位等数、阈值按业务」 三者分界的判据 同一份数据三桶法的对照演示11

7. 边界与局限

  • scaleRadial(半径平方尺)在函数表里一带而过,本章没有展开示例17。
  • nice()、rangeRound 只给了定义,组合使用的时机(如配合 axis)留给了散点教程。
  • 比例尺的性能(大数组逐点映射)与缓存没有讨论。
  • 本章例图全是「数轴 + 圆点」的抽象形式,真实图表中的比例尺集成要看教程和第 11 章起。

判断(我们的,不是书里的): 这一章的函数表看着吓人(20+ 个生成器),但它们全是 同一个抽象的三个参数的排列组合:输入类型(连续/离散)× 变换(线性/幂/对数/对称对数)× 输出类型(连续/离散/插值器)。掌握了这个三维分解,记不住函数名也能推出来该用哪个。 如果错,会错在: 个别生成器不完全服从这个分解(如 scaleRadial 的平方输出、 scaleIdentity 的恒等特例),把它当完备分类法会漏掉边角——但对选型而言,这个分解够用了。

8. 可带走的

  1. 比例尺 = 函数:scale(数据值) 返回像素/颜色/类别;domain 与 range 是两次声明;
  2. 连续尺三问:保比例?跨数量级?有零有负?——依次指向 linear → log → symlog;
  3. range 反装 = 函数反向(y 轴向上的标准手法);
  4. clamp 钳超界、invert 反翻译(交互取数)、nice 撑整数端点、unknown 兜 NaN;
  5. 日期用 scaleTime(本地)/scaleUtc(零时区),刻度自动按跨度换日历单位;
  6. 颜色输出走插值尺:单向 scaleSequential、两极带中点 scaleDiverging,配 d3-scale-chromatic 的插值器;
  7. 指数 >1 把颜色预算压向前半段——数据密集区颜色更分得开;
  8. 切桶三兄弟:quantize 等宽、quantile 等数(由样本分位数定界)、threshold 手切业务阈值;
  9. invertExtent 反查桶区间;thresholds()/quantiles() 看切点;
  10. 类别对位:ordinal 循环 range、band 出带宽(柱宽)、point 出等距点(散点 x);
  11. band/point 的 padding 都以 step 为单位,align 管整体对齐。

9. 原文地图

主题 原书章 原文位置
本章开头与手写 scale 对照 Scales text/16-ch09-chapter-9-scales.txt:23(搜「What are scales?」) · text/16-ch09-chapter-9-scales.txt:31(搜「createLinearScale」)
连续尺函数表 Scales text/16-ch09-chapter-9-scales.txt:64(搜「Continuous scales」) · text/16-ch09-chapter-9-scales.txt:73(搜「scaleLinear」)
domain/range 与反装 Scales text/16-ch09-chapter-9-scales.txt:110(搜「scaleA = d3.scaleLinear()」) · text/16-ch09-chapter-9-scales.txt:115(搜「returns 200」) · text/16-ch09-chapter-9-scales.txt:120(搜「[500,100]」)
配置方法表 Scales text/16-ch09-chapter-9-scales.txt:137(搜「domain(array)」) · text/16-ch09-chapter-9-scales.txt:146(搜「invert(value)」) · text/16-ch09-chapter-9-scales.txt:152(搜「clamp(boolean)」)
多段线性尺 Scales text/16-ch09-chapter-9-scales.txt:216(搜「domain([-10,0,10])」)
clamp/invert 实跑 Scales text/16-ch09-chapter-9-scales.txt:228(搜「returns 3700」) · text/16-ch09-chapter-9-scales.txt:243(搜「invert(300)」)
幂/根/对数/symlog Scales text/16-ch09-chapter-9-scales.txt:255(搜「exponent(2)」) · text/16-ch09-chapter-9-scales.txt:264(搜「scaleSqrt()」) · text/16-ch09-chapter-9-scales.txt:284(搜「scaleLog().domain([1,20]」) · text/16-ch09-chapter-9-scales.txt:294(搜「base(2)」) · text/16-ch09-chapter-9-scales.txt:315(搜「scaleSymlog」) · text/16-ch09-chapter-9-scales.txt:324(搜「constant(10)」)
时间比例尺 Scales text/16-ch09-chapter-9-scales.txt:338(搜「new Date(2017,9,1)」) · text/16-ch09-chapter-9-scales.txt:361(搜「2018,9,15,6,30,0」) · text/16-ch09-chapter-9-scales.txt:374(搜「scaleUtc()」)
顺序与发散插值尺 Scales text/16-ch09-chapter-9-scales.txt:417(搜「scaleSequential」) · text/16-ch09-chapter-9-scales.txt:440(搜「interpolateRainbow」) · text/16-ch09-chapter-9-scales.txt:456(搜「exponent(3)」) · text/16-ch09-chapter-9-scales.txt:470(搜「diverging scale」) · text/16-ch09-chapter-9-scales.txt:502(搜「interpolateBrBG」)
离散尺总表与切桶三兄弟 Scales text/16-ch09-chapter-9-scales.txt:522(搜「Discrete scales」) · text/16-ch09-chapter-9-scales.txt:591(搜「[0,100]」) · text/16-ch09-chapter-9-scales.txt:599(搜「[ 25.00, 50.00, 75.00 ]」) · text/16-ch09-chapter-9-scales.txt:635(搜「sample = [1,3,4」) · text/16-ch09-chapter-9-scales.txt:642(搜「[ 7.00, 25.00, 42.00 ]」) · text/16-ch09-chapter-9-scales.txt:661(搜「thresholds = [10, 20, 80]」) · text/16-ch09-chapter-9-scales.txt:622(搜「invertExtent("red")」)
ordinal 与 unknown Scales text/16-ch09-chapter-9-scales.txt:686(搜「d3.range(0,501,25)」) · text/16-ch09-chapter-9-scales.txt:713(搜「unknown('gray')」)
band 与 point Scales text/16-ch09-chapter-9-scales.txt:727(搜「Returns the distance between」) · text/16-ch09-chapter-9-scales.txt:760(搜「scaleBand().domain(data)」) · text/16-ch09-chapter-9-scales.txt:799(搜「scalePoint().domain(data)」)
散点教程第一部分 Scales text/16-ch09-chapter-9-scales.txt:828(搜「scatterplot」)
章小结 Scales text/16-ch09-chapter-9-scales.txt:849(搜「Summary」)

10. 练习题的三个选型场景(加厚:把选型地图用熟)

原书练习 9.1–9.3 是三道「选错比例尺就翻车」的对照题,正好把本章选型地图用一遍:

  1. 点挤成一团(练习 9.1):两幅散点图,一幅点挤在左下角(数量级跨度大), 一幅挤在一条线附近(乘法关系)。前者换 scaleLog(第 09 章 §2.2 的数量级判据), 后者换 scaleSqrt(平方关系配开方尺,曲线拉直)。判据:先看点的「挤法」—— 沿轴挤=换非线性,沿对角线挤=换幂次。
  2. 行星三围对比(练习 9.2):直径、体积、密度三个量纲放一张图,体积差了九个数量级—— 体积轴必须 log;密度在窄区间线性即可。一个坐标系里允许混用尺型,每根轴独立选型。
  3. 气温距平 1880–2024(练习 9.3):x 是年份(时间尺),y 是距平值(有负有正)—— 上色时 domain 取 [-3, 0.8, 3] 这类非零中点的三段域,把「正常年份」压进窄色带—— 这是第 10 章发散配色的前置课:中点选在数据分布的「平常处」,而不是默认的 0。

顺带两个本章没展开、出门会撞见的边角:

  • d3.scaleIdentity():恒等尺([0,1]→[0,1]),画「像素即数据」的参考图时用;
  • scaleRadial():输出是输入的平方——半径编码面积时的专用尺(与 scaleSqrt 方向相反, 一个是「面积进半径出」,一个是「值进面积出」),用反了图的面积感就全错。

  1. 出处:「Scales」第 23–35 段(text/16-ch09-chapter-9-scales.txt:23,搜「What are scales?」;:31,搜「createLinearScale」)。原书先手写 f(x)=ax+b 的生成器,再指出 d3.scaleLinear() 干同样的事。 ↩

  2. 出处:「Scales」第 130–171 段(text/16-ch09-chapter-9-scales.txt:137,搜「domain(array)」;:146,搜「invert(value)」;:152,搜「clamp(boolean)」)。方法表照原书 Table 9.2 转写。 ↩

  3. 出处:「Scales」第 211–221 段(text/16-ch09-chapter-9-scales.txt:216,搜「domain([-10,0,10])」;:217,搜「[0,900,1000]」)。 ↩

  4. 出处:「Scales」第 225–247 段(text/16-ch09-chapter-9-scales.txt:228,搜「returns 3700」;:233,搜「returns 1000」;:243,搜「invert(300)」)。「点击坐标译回数据值」的交互场景见第 245–246 段。 ↩

  5. 出处:「Scales」第 280–328 段(text/16-ch09-chapter-9-scales.txt:284,搜「scaleLog().domain([1,20]」;:308,搜「only support values greater than zero」;:309,搜「combination of three functions」;:324,搜「constant(10)」)。 ↩↩

  6. 出处:「Scales」第 332–384 段(text/16-ch09-chapter-9-scales.txt:338,搜「new Date(2017,9,1)」;:357,搜「depending on the domain」;:361,搜「2018,9,15,6,30,0」;:374,搜「scaleUtc()」)。 ↩

  7. 出处:「Scales」第 411–451 段(text/16-ch09-chapter-9-scales.txt:417,搜「scaleSequential」;:440,搜「interpolateRainbow」;:448,搜「default range is [0,1]」)。 ↩

  8. 出处:「Scales」第 453–463 段(text/16-ch09-chapter-9-scales.txt:456,搜「exponent(3)」;:459,搜「increases the diversity of colors near the start」)。 ↩

  9. 出处:「Scales」第 469–514 段(text/16-ch09-chapter-9-scales.txt:470,搜「diverging scale」;:502,搜「interpolateBrBG」;:508,搜「central value is the neutral value」)。 ↩

  10. 出处:「Scales」第 466–467、518–520 段(text/16-ch09-chapter-9-scales.txt:466,搜「highlight extreme positive and negative values」;:518,搜「highlight their sequential aspects」)。 ↩↩

  11. 出处:「Scales」第 582–676 段(text/16-ch09-chapter-9-scales.txt:591,搜「[0,100]」;:599,搜「[ 25.00, 50.00, 75.00 ]」;:604,搜「data = [1,9,14,17,20」;:635,搜「sample = [1,3,4」;:642,搜「[ 7.00, 25.00, 42.00 ]」;:661,搜「thresholds = [10, 20, 80]」)。 ↩↩

  12. 出处:「Scales」第 619–622 段(text/16-ch09-chapter-9-scales.txt:622,搜「invertExtent("red")」)。 ↩

  13. 出处:「Scales」第 678–705 段(text/16-ch09-chapter-9-scales.txt:682,搜「the range will repeat」;:686,搜「d3.range(0,501,25)」)。 ↩

  14. 出处:「Scales」第 710–716 段(text/16-ch09-chapter-9-scales.txt:713,搜「unknown('gray')」)。 ↩

  15. 出处:「Scales」第 720–770 段(text/16-ch09-chapter-9-scales.txt:727,搜「Returns the distance between」;:760,搜「scaleBand().domain(data)」;:768,搜「attr("x", scale)」)。 ↩

  16. 出处:「Scales」第 828–847 段(text/16-ch09-chapter-9-scales.txt:830,搜「Gross Domestic Product」;:838,搜「6-log-scale」)。 ↩

  17. 出处:「Scales」第 82–84 段(text/16-ch09-chapter-9-scales.txt:82,搜「scaleRadial」)。全章无 scaleRadial 的独立示例。 ↩