首页
/ D3 时间尺度详解:scaleTime 与 scaleUtc 的构造、刻度生成与域名规整

D3 时间尺度详解:scaleTime 与 scaleUtc 的构造、刻度生成与域名规整

2026-09-06 15:02:45作者:蔡怀权

时间尺度(Time Scales)是 D3 将时间维度映射到视觉编码的核心组件。本文以官方文档 时间尺度 为主线,系统讲解 scaleTime / scaleUtc 的构造与默认值、ticks 的日历间隔自动选择机制、tickFormat 的多尺度格式化策略,以及 nice 的域名规整方法,并结合本仓库 d3 包(v7.9.0,依赖 d3-scale@^4.0.2d3-time@^3.1.0d3-time-format@^4.1.0)与示例组件 ExampleBlankChart.vue 补充实战上下文。读完本文,你将能够独立搭建一条带合理刻度和标签的时间轴,并理解 UTC 与本地时间两种尺度在行为上的差异。

一、什么是时间尺度:面向时间域的线性尺度变体

时间尺度是线性尺度的一个变体,区别在于它拥有时间域(temporal domain)

  • 域名中的值会被强制转换为 ECMAScript Date 对象,而不是数字;
  • invert 的返回值同样是日期对象;
  • 刻度(ticks)基于 d3-time 的日历间隔生成,省去了为时间域手动生成轴刻度的繁琐工作。

线性尺度文档 可以看出,时间尺度继承其完整的连续尺度契约:domainrangeinvertclampinterpolate 等方法的行为语义与线性尺度一致,只是输入输出换成了时间语义。在 d3 主包中,入口文件 依次再导出 d3-scaled3-timed3-time-format 三个模块,这正是时间尺度“连续变换 + 日历间隔 + 日期格式化”三层能力的组合来源。

二、d3.scaleTime(domain, range):本地时间尺度

d3.scaleTime 使用指定的 domainrange 构造一个新的时间尺度,默认采用默认插值器,且钳制(clamping)处于关闭状态

const x = d3.scaleTime([new Date(2000, 0, 1), new Date(2000, 0, 2)], [0, 960]);
x(new Date(2000, 0, 1, 5)); // 200
x(new Date(2000, 0, 1, 16)); // 640
x.invert(200); // Sat Jan 01 2000 05:00:00 GMT-0800 (PST)
x.invert(640); // Sat Jan 01 2000 16:00:00 GMT-0800 (PST)

上例中域跨度为 24 小时、值域跨度为 960 像素,因此每 5 小时对应 200 像素。注意 invert 的返回值受运行环境的时区影响(示例输出为 PST),这正是本地时间尺度的特性所在。

默认值约定:

  • 若未指定 domain,默认为本地时间的 [2000-01-01, 2000-01-02]
  • 若未指定 range,默认为 [0, 1]

三、d3.scaleUtc(domain, range):可预测性更强的 UTC 尺度

d3.scaleUtcscaleTime 等价,但返回的时间尺度运行在 Coordinated Universal Time(协调世界时) 下,而非本地时间:

const x = d3.scaleUtc([new Date("2000-01-01"), new Date("2000-01-02")], [0, 960]);
x(new Date("2000-01-01T05:00Z")); // 200
x(new Date("2000-01-01T16:00Z")); // 640
x.invert(200); // 2000-01-01T05:00Z
x.invert(640); // 2000-01-01T16:00Z

同样默认 domain[2000-01-01, 2000-01-02](UTC)range[0, 1]

官方建议:尽可能优先使用 UTC 尺度,因为它行为更可预测——每天始终是 24 小时,且结果不依赖于浏览器所在时区。

这一建议在 d3-time 文档 中有充分的原理支撑:本地时间下,受夏令时影响,一天可能是 23 到 25 小时,一周可能是 167 到 169 小时;直接做毫秒减法计算天数甚至会得到 30.958333333333332 这样的结果。而 d3-time 明确说明它只支持本地时区和 UTC 两种时间基准,UTC 路径下每个间隔长度恒定,因此 scaleUtcticksnice 输出在任意机器上都一致,便于复现和调试。

四、time.ticks(count):基于日历间隔的自动刻度

*time*.ticks(*count*) 返回尺度域中具有代表性的日期数组:

const x = d3.scaleTime();
x.ticks(10);
// [Sat Jan 01 2000 00:00:00 GMT-0800 (PST),
//  Sat Jan 01 2000 03:00:00 GMT-0800 (PST),
//  Sat Jan 01 2000 06:00:00 GMT-0800 (PST),
//  Sat Jan 01 2000 09:00:00 GMT-0800 (PST),
//  Sat Jan 01 2000 12:00:00 GMT-0800 (PST),
//  Sat Jan 01 2000 15:00:00 GMT-0800 (PST),
//  Sat Jan 01 2000 18:00:00 GMT-0800 (PST),
//  Sat Jan 01 2000 21:00:00 GMT-0800 (PST),
//  Sun Jan 02 2000 00:00:00 GMT-0800 (PST)]

关键特性:

  1. 返回的刻度值(大体)等间距、取值合理(例如每天的午夜时刻);
  2. 刻度保证落在域的取值范围(extent)之内
  3. count 未指定时默认为 10,且只是一个提示值——根据域的不同,尺度可能返回更多或更少的刻度。

自动刻度会从以下日历间隔中选取:

粒度 候选间隔
1 秒、5 秒、15 秒、30 秒
1 分、5 分、15 分、30 分
1 时、3 时、6 时、12 时
1 天、2 天
1 周
1 月、3 月
1 年

这套间隔清单与 d3.utcTicks 的文档完全一致:产生与 count 最接近个数的间隔即被采用,此外还会按 d3.ticks 的规则考虑毫秒级(小范围)与多年级(大范围)的倍数。这也解释了上面示例为何在 24 小时域上选择了 3 小时间隔。

若希望显式控制间隔,可以传入一个时间间隔代替 count;配合 interval.every 可以抽取子集。例如以 15 分钟为间隔生成刻度:

const x = d3.scaleUtc().domain([new Date("2000-01-01T00:00Z"), new Date("2000-01-01T02:00Z")]);
x.ticks(d3.utcMinute.every(15));
// [2000-01-01T00:00Z,
//  2000-01-01T00:15Z,
//  2000-01-01T00:30Z,
//  2000-01-01T00:45Z,
//  2000-01-01T01:00Z,
//  2000-01-01T01:15Z,
//  2000-01-01T01:30Z,
//  2000-01-01T01:45Z,
//  2000-01-01T02:00Z]

注意:在某些情况下(例如按天取间隔时),指定 step 可能导致刻度间距不规则,因为时间间隔本身的长度是变化的(d3.timeDay 的父间隔是 d3.timeMonth,interval 编号会在每月重置,这一点在 interval.every 的文档 中有明确说明)。

五、time.tickFormat(count, specifier):多尺度时间格式化

*time*.tickFormat(*count*, *specifier*) 返回一个适合显示刻度值的时间格式化函数:

const x = d3.scaleUtc().domain([new Date("2000-01-01T00:00Z"), new Date("2000-01-01T02:00Z")]);
const T = x.ticks(); // [2000-01-01T00:00Z, 2000-01-01T00:15Z, 2000-01-01T00:30Z, …]
const f = x.tickFormat();
T.map(f); // ["2000", "12:15", "12:30", "12:45", "01 AM", "01:15", "01:30", "01:45", "02 AM"]

参数行为:

  • count 目前会被忽略,仅为与 linear.tickFormat 等其他尺度保持接口一致而接受;
  • 若指定了格式 specifier,则等价于 d3-time-format 的 format(如 d3.utcFormat("%b %d"));
  • 若未指定 specifier,返回默认的多尺度时间格式,它根据具体日期自动选择人类可读的表示:
场景 默认格式 示例输出
年边界 %Y 2011
月边界 %B February
周边界 %b %d Feb 06
日边界 %a %d Mon 07
时边界 %I %p 01 AM
分边界 %I:%M 01:23
秒边界 :%S :45
其他时刻(毫秒) .%L .012

这种默认策略虽然看起来不寻常,却有一个明显好处:同时提供局部与全局上下文。例如把一组刻度格式化为 [11 PM, Mon 07, 01 AM],能同时传达小时、日期与星期信息;而如果只输出小时 [11 PM, 12 AM, 01 AM],读者就无法判断日期是否发生了跨越。d3-time-format 文档 中给出的 multiFormat 示例函数正是这一默认策略的手工等价实现——它利用 d3.utcSecond(date) < date 这类“向下取整后与原值比较”的判定逐级选择格式,想自定义条件化时间格式时可参照该实现。

六、time.nice(count):把域名规整到“整点”边界

*time*.nice(*count*) 将域名向外扩展,使其起止于整齐的时间值:

const x = d3.scaleUtc().domain([new Date("2000-01-01T12:34Z"), new Date("2000-01-01T12:59Z")]).nice();
x.domain(); // [2000-01-01T12:30Z, 2000-01-01T13:00Z]

行为要点(与 linear.nice 的语义对齐):

  • 该方法通常会修改尺度的域名,且一般只向外扩展到最近的整值;
  • 可选的刻度 count 参数可控制用于扩展边界的步长,保证返回的ticks恰好覆盖整个域名;也可以直接指定一个时间间隔来显式设定刻度,若指定了 interval,还可以再给一个 step 来跳过部分刻度。例如 time.nice(d3.utcSecond.every(10)) 会把域名扩展到整十秒(0、10、20 秒……);
  • 当域名由数据计算得出(例如用 extent)而边界不规则时,nicing 尤其有用。例如域名 [2009-07-13T00:02, 2009-07-13T23:48] 规整后为 [2009-07-13, 2009-07-14]
  • 如果域名超过两个值,nicing 只影响第一个和最后一个值。

需要记住的限制:nice 只修改当前的域名,之后用 domain 重新设置域名不会自动重新规整,需要再次调用 nice

七、实战:用 scaleUtc 搭建完整的时间轴骨架

仓库文档站中的示例组件 ExampleBlankChart.vue 展示了时间尺度与 d3-axis 的标准组合,也是官方推荐的坐标系搭建骨架:

// 声明 x(水平位置)尺度:UTC 时间尺度
const x = d3.scaleUtc()
    .domain([new Date("2023-01-01"), new Date("2024-01-01")])
    .range([marginLeft, width - marginRight]);

// 声明 y(垂直位置)尺度:线性尺度
const y = d3.scaleLinear()
    .domain([0, 100])
    .range([height - marginBottom, marginTop]);

// 创建 SVG 容器
const svg = d3.create("svg")
    .attr("width", width)
    .attr("height", height);

// 添加 x 轴:axisBottom 会自动调用 x.ticks() 与 x.tickFormat()
svg.append("g")
    .attr("transform", `translate(0,${height - marginBottom})`)
    .call(d3.axisBottom(x));

从这个示例可以归纳出时间尺度的典型工作流:

  1. scaleUtc(或 scaleTime)声明域和值域,domain 直接接收 Date 对象;
  2. 交给 d3.axisBottom(x) / d3.axisLeft(y) 等轴生成器,轴的刻度位置来自 *time*.ticks,刻度标签来自 *time*.tickFormat——前文介绍的自动间隔选择与多尺度格式化在此“零配置”生效;
  3. 若数据域边界不规则,先调用 x.nice() 规整,再绑定轴;
  4. 需要交互反查(例如根据鼠标位置还原时间)时使用 x.invert,返回值是 Date 对象。

八、常见陷阱与最佳实践

  1. 优先 UTC:除非业务必须展示本地时区(如用户所在城市的本地营业时段),否则用 scaleUtc 规避夏令时导致的 23/25 小时日、刻度不均与跨环境结果不一致问题;
  2. new Date 构造函数重载陷阱new Date(2000, 0, 1) 是本地时间,而 new Date("2000-01-01") 按 ISO 字符串解析为 UTC 零点。混用两种写法是时间尺度 bug 的高发来源,建议全项目统一一种约定;
  3. count 只是提示ticks(10) 不保证恰好返回 10 个刻度,tickFormat(count) 中的 count 当前也被忽略,不要据此断言刻度数量;
  4. step 间隔的不规则性:对 timeDay 这类父间隔为月的日历间隔,every(step) 的锚点会随月重置,跨月拼接多个 range 时可能出现间距不一致,官方建议用 interval.every 而非 range 的 step 参数来保证区间一致性;
  5. nicing 是手动操作:每次重新 domain 之后都需要重新 nice
  6. 格式化扩展:默认多尺度格式覆盖了常见需求,需要固定格式(如纯 %H:%M)时用 specifier 直接落到 d3-time-format;需要复杂条件化格式时参照其 multiFormat 手工实现。

九、小结

API 作用 关键参数语义
d3.scaleTime(domain, range) 构造本地时间尺度 默认域 [2000-01-01, 2000-01-02] 本地时间,默认值域 [0, 1]
d3.scaleUtc(domain, range) 构造 UTC 时间尺度 默认域 [2000-01-01, 2000-01-02] UTC,默认值域 [0, 1]
*time*.ticks(count) 生成日历间隔刻度 count 默认 10,仅是提示;可传时间间隔 + interval.every(step)
*time*.tickFormat(count, specifier) 返回刻度格式化函数 count 当前被忽略;specifier 缺省时启用多尺度默认格式
*time*.nice(count) 规整域名边界 可传 count 或时间间隔(可再配 step);只改首尾值

时间尺度的价值在于把“时间”这件不规则的事(变长的月、跳变的夏令时、跨时区的读者)封装进与线性尺度一致的连续映射契约中,并内置了与日历对齐的刻度与格式系统。结合本文的 API 语义、d3-time 的间隔机制d3-time-format 的格式说明 以及 d3-scale 总览API 索引,你可以在此基础上构建出任意粒度的时间轴、时间刷选与时间交互功能。

登录后查看全文
热门项目推荐
相关项目推荐