编程语言研究书库

坐标轴 — 给数据一幅刻度尺

坐标轴 — 给数据一幅刻度尺

这一章讲什么: 点、线、柱只是数据,坐标轴是读者把像素翻译回数值的那把尺子。 d3-axis 模块用「生成器函数 + call()」的模式造轴:你先给它一把比例尺,它把刻度和文字画进容器。 后半章把两根轴拼成直角坐标系、极坐标网格,最后封装成一行调用的可复用模块。 章末原书自己点破:轴和比例尺是焊死的——没有比例尺就没有轴,所以下一章全是比例尺1。

1. 顶层全景:一个轴 = 一段 path + 一排 tick

d3-axis 生成的 SVG 结构固定,先把它看清,后面所有配置都是在改这段代码里的零件2:

  <g fill="none" font-size="10" font-family="sans-serif" text-anchor="middle">
    <path class="domain" d="M0.5,6V0.5H250.5V6"></path>     ← 贯穿轴:一条 path
    <g class="tick" transform="translate(0.5,0)">            ← 每个刻度一个 <g>
      <line y2="6"></line>                                    ← 刻度短线(默认 6px)
      <text y="9" dy="0.71em">0.0</text>                      ← 刻度文字(默认再隔 3px)
    </g>
    <!-- …更多 tick… -->
  </g>

  图说:轴 = 1 条 domain path + N 个 tick 组(每组一线一文)。
  外侧那两道短竖线不是 <line>,是 domain path 两端翘起的一小段。

记住三个零件的名字——domain(主轴)、tick(刻度组)、tick 里的 line/text—— 配置方法管数值,CSS 类选择器管外观,两者分工明确。

2. 核心原理

2.1 创建:生成器函数 + call()

四个工厂函数,各管一个朝向(刻度在轴线的哪一侧)3:

函数朝向典型用途
d3.axisTop(scale)刻度朝上图表顶部
d3.axisBottom(scale)刻度朝下图表底部,x 轴标配
d3.axisLeft(scale)刻度朝左图表左侧,y 轴标配
d3.axisRight(scale)刻度朝右图表右侧

主线例子:给 domain [0,1]、range [0,400] 的线性比例尺配一根底部轴——

  const scale  = d3.scaleLinear().range([0, 400]);
  const axisB  = d3.axisBottom(scale);      // ← 生成器函数,此刻还没画任何东西

  const svg = d3.select("body").append("svg").attr("width", 500).attr("height", 50);
  svg.append("g").call(axisB);              // ← call 把生成器"点着",SVG 落进 <g>

  图说:axisB 是个函数;call(axisB) 等价于 axisB(<g>),但 call 不拆链(第 05 章)。

两个必须知道的默认行为:

第一,轴永远画在原点 (0,0)。 所以第一次调用后,第一个刻度的文字常被视口边缘裁掉一半—— 移动轴 = 给容器 <g> 挂 transform,而不是改轴本身4:

  svg.append("g").attr("id", "bottom-axis")
     .attr("transform", "translate(10,10)")   // 右挪 10、下移 10
     .call(axisB);

第二,改完配置要重新 call。 所有配置方法只改生成器,渲染永远发生在 call 那一刻; axisB.tickSize(25) 之后不 call,页面上什么都不会发生5。

2.2 刻度配置:tickSize / tickPadding / ticks / tickValues

配置方法一览(全部返回轴本身、可链式)6:

方法管什么默认
tickSizeInner(n)内侧刻度线长(同时顶开文字)6
tickSizeOuter(n)两端那截「外刻度」(domain 的弯钩)6
tickSize(n)内外一起设6
tickPadding(n)刻度线与文字的间距3
ticks(count, 格式?)建议刻度数 + 可选的格式说明符自动
tickValues(数组)钉死刻度用哪几个值(必须落在 domain 内)自动
tickFormat(函数)逐个格式化刻度文字自动
scale(新比例尺)换绑比例尺(缩放时动态更新用)——

几个从原书 console 实验里捞出来的要点:

  • ticks 的第一个参数只是建议。 axisB.ticks(5, '%') 建议 5 根,画出来 6 根—— 自动算法挑的是「好看」的数,不是你的数7。
  • tickValues 是指令不是建议:axisB.tickValues([0,.1,.2,.5,.8,.9,1]) 就精确画这七根8。 但值必须合法:负数不能进平方根比例尺的轴,0 不能进对数比例尺的轴。
  • 外刻度是个特殊零件:它不是 <line>,是 domain path 两端的一部分, 所以 tickPadding 不影响它、想改它得动 path——多数时候直接 tickSizeOuter(0) 取消它, 靠内刻度充当端点9。
  • 配置方法渲染后也能再调:「改了再 call」即可,配合 transition 就是动画更新10。

2.3 换比例尺与动态更新

scale() 方法换绑,重新 call 即重画。动态改 domain 的例子——

  scale.domain([0,100]);                          // 换个 domain
  horz.transition().duration(1000).call(axisB);   // 1 秒动画到新刻度

  axisB.scale(d3.scaleSqrt().range([0,350]))      // 整体换成平方根比例尺
  horz.transition().duration(1000).call(axisB)    // 刻度立刻非线性

时间比例尺的轴自动出时间刻度,格式用日期模板:axisB.ticks(6, "%b %Y") 出「Jan 2024」式标签; 分类比例尺(序数)的轴直接把类别名当刻度文字11。

2.4 样式:currentColor 一把钥匙

d3-axis 生成的 <line>、<path> 的 stroke 和 <text> 的 fill 全都写成 currentColor——这个 CSS 关键字的值就是 color 属性的值。所以一行改色12:

  axisB 不直接管样式,但容器是选择器:
  d3.select(".x-axis").style("color", "red")    // 轴线+刻度线+文字全红

  /* 样式表里更常见的写法 */
  .domain { stroke-width: 2 }        /* 加粗主轴 */
  .tick line { opacity: 0 }          /* 只藏刻度线(藏不掉外刻度,那要 tickSizeOuter(0)) */
  .domain { display: none }          /* 或 d3.select(".domain").remove() 连根拔掉 */

文字的字体也可以整组覆盖:轴把 font-size:10、font-family:sans-serif 写在容器 <g> 上, CSS 对 .tick 类的规则优先于 XML 属性,改之即生效13。刻度过密放不下时, 可以对 .tick text 挂 transform: translate(38) rotate(45) 斜排14。

2.5 实战:轴 + 散点

主线例子合拢:450×100 视口、25 像素边距,5–19 个随机点,一根轴——

  const width=450, height=100, margin=25;
  const scale = d3.scaleLinear().range([margin, width - margin*2]);
  const axisB = d3.axisBottom(scale);

  const dataSize = d3.randomInt(5,20)();
  const data = d3.range(dataSize).map(() => d3.randomInt(1,100)());
  scale.domain([0, d3.max(data)]);                    // 数据来了才配 domain

  svg.selectAll("circle").data(data).join("circle")
     .attr("cx", d => scale(d)).attr("cy", height/2)
     .attr("r", 3).style("fill", "steelblue");

  svg.append("g").attr("transform", `translate(${[0, height - margin]})`).call(axisB);
  // 轴容器的 x 位置由比例尺 range 保证对齐;translate 只负责把它挪到底部

注意这条对齐原理:轴的刻度位置完全由比例尺的 range 决定, 容器 transform 只管把整根轴搬到画布的上/下/左/右,不影响刻度与数据点的对位15。

3. 复合轴:笛卡尔系、网格与四象限

3.1 拼一个直角坐标系

直角系 = axisBottom(底部)+ axisLeft(左侧)。唯一的「坑」是方向: 屏幕 y 轴向下,数学 y 轴向上——把 y 比例尺的 range 反过来写(大值在前),轴就正过来了16:

  const height=500, width=800, margin = {x: 50, y: 50};

  const scaleX = d3.scaleLinear().range([margin.x, width - margin.x]);
  const scaleY = d3.scaleLinear().range([height - margin.y, margin.y]);  // ← 反装

  const axisX = d3.axisBottom(scaleX), axisY = d3.axisLeft(scaleY);

  const xG = svg.append("g").attr("class", "x-axis")
      .attr("transform", `translate(0,${height - margin.y})`).call(axisX);
  const yG = svg.append("g").attr("class", "y-axis")
      .attr("transform", `translate(${margin.x},0)`).call(axisY);

轴标签(「x values」「y values」)就是往轴容器里再 append 一个 <text>, y 轴那个记得 rotate(90)17。

3.2 网格:把刻度线拉长

网格 = 让刻度线贯穿整个绘图区。手法分三步(原书承认「not trivial」但「一想就通」)18:

  ① 拉长:axisX.tickSize(height - margin.y*2 + 10)   // 线长 = 图高 + 10(补跨过轴那段)
     axisY.tickSize(width  - margin.x*2 + 10)

  ② 挪容器:把整根轴搬到对侧,让长线往图内生长
     x-axis → translate(0, margin.y)          // 从底部挪到顶部,线向下长
     y-axis → translate(width - margin.x, 0)  // 从左侧挪到右侧,线向左长

  ③ 把 domain 挪回去(它不该跟着搬家):
     d3.select(".x-axis .domain").attr("transform", `translate(0,${height - margin.y*2})`)
     d3.select(".y-axis .domain").attr("transform", `translate(${-width + margin.x*2},0)`)

  图说:先借 tickSize 把短线变长,再整体平移让长线伸进图里,最后把轴线归位。
  网格线样式走 CSS:.x-axis .tick line { stroke-dasharray: 5 5 } 出虚线。

四象限(含负数)更简单:domain 带上负数,重 call 轴即可; 想把两根轴交叉到原点,就再改容器的 transform。两个装饰性细节: 过零点的刻度线可以 filter(v => v === 0) 单独拉长当象限分界线; 原点重叠的 "0.0" 标签会重复,删掉一个、挪开另一个19。

3.3 径向轴:十二根轴转出来的极坐标

极坐标没有现成函数,用一个轴生成器 × 旋转 n 次拼出来20:

  const width = 600, height = 600, margin = 20;
  const chart = svg.append("g").attr("transform", `translate(${[width/2,height/2]})`);

  const scaleRadius = d3.scaleLinear().range([0, width/2 - margin]);
  const axis = d3.axisBottom(scaleRadius).ticks(5).tickSizeOuter(0);

  const n = 12;                                  // 12 根轴,每 30° 一根
  const steps = d3.range(0, n);                  // [0..11]
  chart.selectAll("g.axis")
    .data(steps)
    .join("g").attr("class", "axis").classed("blank",(d,i) => i != 0)
      .attr("transform", (d,i) => `rotate(${i * 360/n})`)   // 每根转 30°
      .call(axis);                                          // 轴生成器在旋转后的容器里重放

  /* 只留第一根的文字,其余藏掉 */
  .blank text, .tick:first-of-type {display: none}

  图说:同一个生成器被 call 了 12 次,每次在一个旋转过的 <g> 里——
  数据绑定 + join 那套(第 05 章)在这里用来批量"种"轴。

数据点是极坐标 (angle, radius),SVG 要的是 (x, y):d3.pointRadial(弧度, 像素半径) 做换算,角度记得从度转弧度(angle * Math.PI/180)21。

4. 可复用模块:chart-utils.js

本章的笛卡尔/径向代码被打包成 js/chart-utils.js,之后章节的例子大量复用:

  import * as utils from '../chart-utils.js';

  utils.cartesianAxes().container(chart).xScale(scaleX).yScale(scaleY)();
  utils.radialAxes() .container(chart).aScale(scaleAngle).rScale(scaleRadius)();

  // 调用返回值是轴生成器数组,还能继续配:
  const [xAxis, yAxis] = showAxes();
  xAxis.tickFormatting(d3.format('%'));
  d3.select('.x-axis').call(xAxis);          // 改完重 call,老规矩

container / xScale / yScale 链式配置,末尾多一对圆括号执行—— 这是 D3 社区「可复用图表」模式的最小形态(第 09 章的比例尺链式配置同款写法)22。

5. 作者的判断与证据

判断性质依据
「外刻度没用就设 0」经验规则外刻度不是线、padding 不管它,原书直接建议移除9
「径向轴别超过 12 根」经验之谈30° 一根的密度是「excessive visual clutter」的分界线20
「配置不够就用 CSS」分层原则currentColor 设计与「CSS 覆盖 XML 属性」的优先级被当作特性讲解12

6. 边界与局限

  • 本章比例尺只用线性版和时间/序数的一瞥,完整比例尺家族是第 09 章。
  • 网格的「三步法」是手工方案;更系统的网格线(独立于轴)一般用 d3.line 或专门的 grid 插件,书里没展开。
  • 轴的动画更新(transition + call)给了演示,但多轴联动的图表重排(resize 响应式)要到后面章节的例子才出现。
  • chart-utils.js 是教学件,不是发布级的库——错误处理、多图表实例隔离都没有。

判断(我们的,不是书里的): d3-axis 的设计模式值得单独一记: 「配置与渲染分离」——生成器函数攒配置,call 才渲染,渲染器永远可重放。 同一个 axisB 可以 call 进任意多个容器(径向轴 12 根轴就是这么来的), 也可以随时改配置后重 call。这套「immutable 配置 + 显式重渲染」与 React 的 「props 变化触发重渲染」异曲同工,但早了十年,而且不含 diff——重放就是全量重画, 轴这种小 SVG 全量重画完全无感。 如果错,会错在: 对带复杂自定义刻度的巨型轴(数千刻度),全量重放可能有性能问题—— 届时需要手动管理 tick 的 enter/update/exit,而 axis 生成器不给你这个粒度。

7. 可带走的

  1. 轴 = 生成器函数(d3.axisBottom(scale) 等四种)+ 容器上 .call(axis) 渲染;
  2. 轴默认画在 (0,0)——移动靠容器 <g> 的 transform;
  3. 零件三个名字:domain(主轴)、tick(刻度组)、tick 里的 line/text;
  4. 改任何配置后必须重新 call 才生效;配 transition 就是动画更新;
  5. ticks(n, 格式) 的 n 是建议;tickValues(数组) 才是钉死;
  6. 外刻度是 path 的一部分,改不了 padding,通常 tickSizeOuter(0) 移除;
  7. currentColor:一行 style("color", …) 改全轴的颜色;
  8. y 轴反向 = 把比例尺 range 的大值写在前头;
  9. 网格 = tickSize 拉长 + 容器平移 + domain 归位三步;
  10. 径向轴 = 同一生成器 join 进 n 个旋转容器;d3.pointRadial 把 (角度,半径) 换算成 (x,y);
  11. chart-utils.js 把笛卡尔/径向轴打包成链式一行调用,后续章节复用。

8. 原文地图

主题原书章原文位置
轴的 SVG 结构与四个工厂Axestext/15-ch08-chapter-8-axes.txt:26(搜「Creating axes」) · text/15-ch08-chapter-8-axes.txt:44(搜「axisTop(scale)」)
call() 渲染与原点问题Axestext/15-ch08-chapter-8-axes.txt:62(搜「scaleLinear().range([0,400])」) · text/15-ch08-chapter-8-axes.txt:71(搜「call(axisB)」) · text/15-ch08-chapter-8-axes.txt:86(搜「origin (0,0)」)
配置方法表Axestext/15-ch08-chapter-8-axes.txt:126(搜「tickSizeInner(number)」) · text/15-ch08-chapter-8-axes.txt:141(搜「tickValues(array)」) · text/15-ch08-chapter-8-axes.txt:144(搜「ticks(count,format)」)
tickSize 实验与「要重 call」Axestext/15-ch08-chapter-8-axes.txt:183(搜「axisB.tickSize(25)」) · text/15-ch08-chapter-8-axes.txt:184(搜「must call the axis function again」)
内外刻度与 paddingAxestext/15-ch08-chapter-8-axes.txt:203(搜「tickSizeOuter(25)」) · text/15-ch08-chapter-8-axes.txt:208(搜「no methods to adjust outer tick padding」)
ticks/tickValues/时间轴Axestext/15-ch08-chapter-8-axes.txt:255(搜「axisB.ticks(5)」) · text/15-ch08-chapter-8-axes.txt:272(搜「six were displayed」) · text/15-ch08-chapter-8-axes.txt:279(搜「tickValues([0, .1」) · text/15-ch08-chapter-8-axes.txt:324(搜「scaleTime()」)
动态换比例尺Axestext/15-ch08-chapter-8-axes.txt:302(搜「scale.domain([0,100])」) · text/15-ch08-chapter-8-axes.txt:312(搜「scaleSqrt」)
生成代码解剖与 currentColorAxestext/15-ch08-chapter-8-axes.txt:348(搜「<path class="domain"」) · text/15-ch08-chapter-8-axes.txt:372(搜「currentColor」) · text/15-ch08-chapter-8-axes.txt:378(搜「style("color", "red")」)
轴 + 散点实战Axestext/15-ch08-chapter-8-axes.txt:455(搜「width = 450」) · text/15-ch08-chapter-8-axes.txt:487(搜「translate(${[0, height - margin]})」)
笛卡尔系与 y 轴反向Axestext/15-ch08-chapter-8-axes.txt:537(搜「height=500, width=800」) · text/15-ch08-chapter-8-axes.txt:540(搜「height – margin.y, margin.y」)
网格三步法Axestext/15-ch08-chapter-8-axes.txt:652(搜「tickSize(height – margin.y*2」) · text/15-ch08-chapter-8-axes.txt:659(搜「translate(0,${margin.y})」) · text/15-ch08-chapter-8-axes.txt:668(搜「.y-axis .domain」)
四象限与原点交叉Axestext/15-ch08-chapter-8-axes.txt:688(搜「[-1,1]」) · text/15-ch08-chapter-8-axes.txt:694(搜「filter(v => v === 0)」) · text/15-ch08-chapter-8-axes.txt:714(搜「duplicated」)
径向轴Axestext/15-ch08-chapter-8-axes.txt:746(搜「width = 600, height = 600」) · text/15-ch08-chapter-8-axes.txt:769(搜「chart.selectAll("g.axis")」) · text/15-ch08-chapter-8-axes.txt:778(搜「blank text」)
pointRadialAxestext/15-ch08-chapter-8-axes.txt:811(搜「pointRadial」) · text/15-ch08-chapter-8-axes.txt:815(搜「Math.PI/180」)
chart-utils 模块Axestext/15-ch08-chapter-8-axes.txt:836(搜「chart-utils.js」) · text/15-ch08-chapter-8-axes.txt:843(搜「cartesianAxes()」) · text/15-ch08-chapter-8-axes.txt:866(搜「[xAxis, yAxis] = showAxes()」)
章小结(轴比例尺焊死)Axestext/15-ch08-chapter-8-axes.txt:873(搜「Summary」) · text/15-ch08-chapter-8-axes.txt:883(搜「tightly coupled with scales」)

Footnotes

  1. 出处:「Axes」第 883–885 段(text/15-ch08-chapter-8-axes.txt:883,搜「tightly coupled with scales」)。原文:「Axes are tightly coupled with scales: you can't create an axis generator without a scale.」 ↩

  2. 出处:「Axes」第 347–358 段(text/15-ch08-chapter-8-axes.txt:348,搜「<path class="domain"」)。生成的 SVG 结构照原书代码转写。 ↩

  3. 出处:「Axes」第 44–56 段(text/15-ch08-chapter-8-axes.txt:44,搜「axisTop(scale)」)。四个工厂函数见原书 Table 8.1。 ↩

  4. 出处:「Axes」第 76–92 段(text/15-ch08-chapter-8-axes.txt:76,搜「partially clipped」;:86,搜「origin (0,0)」;:91,搜「translate(10,10)」)。 ↩

  5. 出处:「Axes」第 183–187 段(text/15-ch08-chapter-8-axes.txt:183,搜「axisB.tickSize(25)」;:184,搜「Nothing happens」)。原文:「Whenever you update the axis or its scale, you must call the axis function again from your selection to render it.」 ↩

  6. 出处:「Axes」第 124–154 段(text/15-ch08-chapter-8-axes.txt:126,搜「tickSizeInner(number)」;:138,搜「tickPadding(number)」;:141,搜「tickValues(array)」)。方法表照原书 Table 8.2 转写。 ↩

  7. 出处:「Axes」第 268–272 段(text/15-ch08-chapter-8-axes.txt:270,搜「axisB.ticks(5, '%')」;:272,搜「six were displayed」)。 ↩

  8. 出处:「Axes」第 276–281 段(text/15-ch08-chapter-8-axes.txt:279,搜「tickValues([0, .1」)。 ↩

  9. 出处:「Axes」第 197–227 段(text/15-ch08-chapter-8-axes.txt:198,搜「tickSizeInner() affects padding」;:208,搜「no methods to adjust outer tick padding」;:227,搜「tickSizeOuter(0)」)。 ↩ ↩2

  10. 出处:「Axes」第 156–158 段(text/15-ch08-chapter-8-axes.txt:156,搜「method chaining」)。 ↩

  11. 出处:「Axes」第 321–338 段(text/15-ch08-chapter-8-axes.txt:324,搜「scaleTime()」;:327,搜「"%b %Y"」;:333,搜「categorical values」)。 ↩

  12. 出处:「Axes」第 371–378 段(text/15-ch08-chapter-8-axes.txt:372,搜「currentColor」;:378,搜「style("color", "red")」)。 ↩ ↩2

  13. 出处:「Axes」第 362–369 段(text/15-ch08-chapter-8-axes.txt:367,搜「font-family: "serif"」;:362,搜「overridden with CSS」)。 ↩

  14. 出处:「Axes」第 403–414 段(text/15-ch08-chapter-8-axes.txt:408,搜「rotate(45)」)。 ↩

  15. 出处:「Axes」第 483–488 段(text/15-ch08-chapter-8-axes.txt:452,搜「positioned correctly」;:487,搜「translate(${[0, height - margin]})」)。原文:「The container's position is set by the scale's range, which guarantees that it is aligned with the data.」 ↩

  16. 出处:「Axes」第 527–554 段(text/15-ch08-chapter-8-axes.txt:528,搜「turn the axisLeft() upside-down」;:540,搜「height – margin.y, margin.y」)。 ↩

  17. 出处:「Axes」第 560–583 段(text/15-ch08-chapter-8-axes.txt:572,搜「x values」;:578,搜「rotate(90)」)。 ↩

  18. 出处:「Axes」第 628–675 段(text/15-ch08-chapter-8-axes.txt:631,搜「not trivial」;:652,搜「tickSize(height – margin.y*2」;:658,搜「translate(0,${margin.y})」;:668,搜「.y-axis .domain」)。 ↩

  19. 出处:「Axes」第 682–721 段(text/15-ch08-chapter-8-axes.txt:688,搜「[-1,1]」;:694,搜「filter(v => v === 0)」;:717,搜「0.0 label is duplicated」)。 ↩

  20. 出处:「Axes」第 732–788 段(text/15-ch08-chapter-8-axes.txt:746,搜「width = 600, height = 600」;:758,搜「30 degrees」;:769,搜「chart.selectAll("g.axis")」;:778,搜「blank text」)。 ↩ ↩2

  21. 出处:「Axes」第 793–824 段(text/15-ch08-chapter-8-axes.txt:811,搜「pointRadial」;:815,搜「Math.PI/180」)。 ↩

  22. 出处:「Axes」第 831–872 段(text/15-ch08-chapter-8-axes.txt:836,搜「chart-utils.js」;:843,搜「cartesianAxes()」;:866,搜「[xAxis, yAxis] = showAxes()」)。 ↩

On this page