坐标轴 — 给数据一幅刻度尺
坐标轴 — 给数据一幅刻度尺
这一章讲什么: 点、线、柱只是数据,坐标轴是读者把像素翻译回数值的那把尺子。 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. 可带走的
- 轴 = 生成器函数(
d3.axisBottom(scale)等四种)+ 容器上.call(axis)渲染; - 轴默认画在 (0,0)——移动靠容器
<g>的 transform; - 零件三个名字:domain(主轴)、tick(刻度组)、tick 里的 line/text;
- 改任何配置后必须重新 call 才生效;配 transition 就是动画更新;
ticks(n, 格式)的 n 是建议;tickValues(数组)才是钉死;- 外刻度是 path 的一部分,改不了 padding,通常
tickSizeOuter(0)移除; currentColor:一行style("color", …)改全轴的颜色;- y 轴反向 = 把比例尺 range 的大值写在前头;
- 网格 = tickSize 拉长 + 容器平移 + domain 归位三步;
- 径向轴 = 同一生成器 join 进 n 个旋转容器;
d3.pointRadial把 (角度,半径) 换算成 (x,y); - chart-utils.js 把笛卡尔/径向轴打包成链式一行调用,后续章节复用。
8. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 轴的 SVG 结构与四个工厂 | Axes | text/15-ch08-chapter-8-axes.txt:26(搜「Creating axes」) · text/15-ch08-chapter-8-axes.txt:44(搜「axisTop(scale)」) |
| call() 渲染与原点问题 | Axes | text/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)」) |
| 配置方法表 | Axes | text/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」 | Axes | text/15-ch08-chapter-8-axes.txt:183(搜「axisB.tickSize(25)」) · text/15-ch08-chapter-8-axes.txt:184(搜「must call the axis function again」) |
| 内外刻度与 padding | Axes | text/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/时间轴 | Axes | text/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()」) |
| 动态换比例尺 | Axes | text/15-ch08-chapter-8-axes.txt:302(搜「scale.domain([0,100])」) · text/15-ch08-chapter-8-axes.txt:312(搜「scaleSqrt」) |
| 生成代码解剖与 currentColor | Axes | text/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")」) |
| 轴 + 散点实战 | Axes | text/15-ch08-chapter-8-axes.txt:455(搜「width = 450」) · text/15-ch08-chapter-8-axes.txt:487(搜「translate(${[0, height - margin]})」) |
| 笛卡尔系与 y 轴反向 | Axes | text/15-ch08-chapter-8-axes.txt:537(搜「height=500, width=800」) · text/15-ch08-chapter-8-axes.txt:540(搜「height – margin.y, margin.y」) |
| 网格三步法 | Axes | text/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」) |
| 四象限与原点交叉 | Axes | text/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」) |
| 径向轴 | Axes | text/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」) |
| pointRadial | Axes | text/15-ch08-chapter-8-axes.txt:811(搜「pointRadial」) · text/15-ch08-chapter-8-axes.txt:815(搜「Math.PI/180」) |
| chart-utils 模块 | Axes | text/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()」) |
| 章小结(轴比例尺焊死) | Axes | text/15-ch08-chapter-8-axes.txt:873(搜「Summary」) · text/15-ch08-chapter-8-axes.txt:883(搜「tightly coupled with scales」) |
Footnotes
-
出处:「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.」 ↩ -
出处:「Axes」第 347–358 段(
text/15-ch08-chapter-8-axes.txt:348,搜「<path class="domain"」)。生成的 SVG 结构照原书代码转写。 ↩ -
出处:「Axes」第 44–56 段(
text/15-ch08-chapter-8-axes.txt:44,搜「axisTop(scale)」)。四个工厂函数见原书 Table 8.1。 ↩ -
出处:「Axes」第 76–92 段(
text/15-ch08-chapter-8-axes.txt:76,搜「partially clipped」;:86,搜「origin (0,0)」;:91,搜「translate(10,10)」)。 ↩ -
出处:「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.」 ↩ -
出处:「Axes」第 124–154 段(
text/15-ch08-chapter-8-axes.txt:126,搜「tickSizeInner(number)」;:138,搜「tickPadding(number)」;:141,搜「tickValues(array)」)。方法表照原书 Table 8.2 转写。 ↩ -
出处:「Axes」第 268–272 段(
text/15-ch08-chapter-8-axes.txt:270,搜「axisB.ticks(5, '%')」;:272,搜「six were displayed」)。 ↩ -
出处:「Axes」第 276–281 段(
text/15-ch08-chapter-8-axes.txt:279,搜「tickValues([0, .1」)。 ↩ -
出处:「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 -
出处:「Axes」第 156–158 段(
text/15-ch08-chapter-8-axes.txt:156,搜「method chaining」)。 ↩ -
出处:「Axes」第 321–338 段(
text/15-ch08-chapter-8-axes.txt:324,搜「scaleTime()」;:327,搜「"%b %Y"」;:333,搜「categorical values」)。 ↩ -
出处:「Axes」第 371–378 段(
text/15-ch08-chapter-8-axes.txt:372,搜「currentColor」;:378,搜「style("color", "red")」)。 ↩ ↩2 -
出处:「Axes」第 362–369 段(
text/15-ch08-chapter-8-axes.txt:367,搜「font-family: "serif"」;:362,搜「overridden with CSS」)。 ↩ -
出处:「Axes」第 403–414 段(
text/15-ch08-chapter-8-axes.txt:408,搜「rotate(45)」)。 ↩ -
出处:「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.」 ↩ -
出处:「Axes」第 527–554 段(
text/15-ch08-chapter-8-axes.txt:528,搜「turn the axisLeft() upside-down」;:540,搜「height – margin.y, margin.y」)。 ↩ -
出处:「Axes」第 560–583 段(
text/15-ch08-chapter-8-axes.txt:572,搜「x values」;:578,搜「rotate(90)」)。 ↩ -
出处:「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」)。 ↩ -
出处:「Axes」第 682–721 段(
text/15-ch08-chapter-8-axes.txt:688,搜「[-1,1]」;:694,搜「filter(v => v === 0)」;:717,搜「0.0 label is duplicated」)。 ↩ -
出处:「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 -
出处:「Axes」第 793–824 段(
text/15-ch08-chapter-8-axes.txt:811,搜「pointRadial」;:815,搜「Math.PI/180」)。 ↩ -
出处:「Axes」第 831–872 段(
text/15-ch08-chapter-8-axes.txt:836,搜「chart-utils.js」;:843,搜「cartesianAxes()」;:866,搜「[xAxis, yAxis] = showAxes()」)。 ↩