D3.js 核心概念——数据处理与分析(十二)时间边距计算器之三

语言: CN / TW / HK

Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情


系列文章可以查看《数据可视化》专栏


定制时距器

有时候需要基于 D3 所提供的各种类型的 interval 进行简单的定制,可以使用 interval.filter(test) 方法,该方法返回(定制过的)interval

方法 interval.filter(test) 的入参是一个测试函数,该函数会接收 Date 对象作为参数,最后只有该测试函数的返回值为 truthy 时,该 Date 对象才可以作为定制后的 interval 的「潜在的可采集的时间点」,类似于数组的 arr.filter() 方法,对元素进行筛选。

例如使用 d3.timeDay.range(start, end) 在时间范围中每隔 1 天进行时间点采集,最后生成包含一系列 Date 对象的数组。如果先对 d3.timeDay 这个 interval 进行定制,使其只能采集以 1 为结尾的日期

js // 只能采集以 1 结尾的日期,如每个月的 1st、11th、21th、31th 才能进行采集 d3.timeDay.filter(d => (d.getDate() - 1) % 10 === 0)

d3-interval-filter.png

⚠️ interval.filter() 返回的定制过的 interval 没有 interval.count() 方法

💡 对于需要按照特定间隔/步长来采集时间的需求,D3 提供了一个更简单的方法 interval.every(step),它相当于 interval.filter()语法糖(但不需要设置复杂的 test 测试函数,直接指定步长 step 即可),最后返回的是一个(定制过的)interval

js d3.timeDay.every(2).range(new Date(2015, 0, 1), new Date(2015, 0, 7)); // [2015-01-01T00:00, 2015-01-03T00:00, 2015-01-05T00:00]

虽然 interval.every()interval.range() 类似,但是两者的作用和适用场景是不同的。

  • interval.every() 返回的是一个定制过的 interval,需要进一步调用 interval 的其他方法(对时间进行修约的方法)才会返回 Date 对象;而 interval.range() 返回的是一个包含一系列 Date 对象的数组
  • interval.every(step) 其步长是针对 interval 所在时间尺度的父级而言的,例如对于 d3.timeMinute.every(15) 由于步长是 15,即每 15 分钟采集一次时间点,所以最后返回的 interval 其「潜在的可采集的时间点」是 :00:15:30 依次类推,这个采集的起始点是固定的,由于时间尺度是分钟,则其父级为小时,所以从父级时间尺度的下界,即 0 分 0 秒开始;而 interval.range(start, end, step) 其步长是针对 interval 所在的时间尺度本身而言的,即采集的起始点有 start 控制,从大于(或等于) start 时间点,且是 interval 所在时间尺度的下界开始,每隔特定的步长 step 进行采集。

```js console.log("--- within month ---"); const startDateWithinMonth = new Date("2022-05-02T10:12:12Z"); const endDateWithinMonth = new Date("2022-05-10T14:12:12Z");

// 使用未经修改的 interval // 以天为间隔时间尺度 // 采集时间点的步长为 2,即每隔一天采集一次 const rangeArrWithinMonth = d3.utcDay.range( startDateWithinMonth, endDateWithinMonth, 2 );

// 先对 interval 进行定制 // 以天为间隔时间尺度,则父级时间尺度为月 // 设置步长为 2 // 也是将采集时间点步长设置为 2,即每隔一天采集一次 const filterInterval = d3.utcDay.every(2); // 然后使用定制过的 interval 进行时间采集 const filterArrWithinMonth = filterInterval.range( startDateWithinMonth, endDateWithinMonth );

// 当时间范围的起始点和结束点在一个月内,两者的结果有时候是一样的(可以看以下关于 interval.every() 的工作原理的解释) console.log("within month range: ", rangeArrWithinMonth); console.log("within month filter range: ", filterArrWithinMonth);

/ * --- outside month --- /

console.log("--- outside month ---"); // 对于时间范围跨越月份,情况就不一样 // 由于 interval.every(step) 设置的步长是先对父级的时间尺度而言的 // 可以将 d3.timeDay.every(2) 理解为从每个月(父级时间尺度)的 1 号开始,每隔一天进行时间采集,即每个月份的 1号、3号、5号……27号、29号、31号(如果该月份有 31号)这些采样的日子都已经是固定的 // 而 d3.timeDay.range(start, end, 2) 采样是由 start 参数决定的从哪一天开始采集 const startDateOutsideMonth = new Date("2022-05-28T10:12:12Z"); const endDateOutsideMonth = new Date("2022-06-06T14:12:12Z");

const rangeArrOutsideMonth = d3.utcDay.range( startDateOutsideMonth, endDateOutsideMonth, 2 );

const filterArrOutsideMonth = filterInterval.range( startDateOutsideMonth, endDateOutsideMonth );

console.log("outside month range: ", rangeArrOutsideMonth); console.log("outside month filter range: ", filterArrOutsideMonth); ```

以下是控制台输出的结果,具体的代码演示可以查看这个 Codepen

d3-interval-every.png

所以通过 interval.every(step) 设置采集的间距,再使用定制过的时距器 interval.range() 获得的一系列 Date 对象,它们之间时间间距可能会不均匀

💡 可以使用 interval.every(step) 从父级时间尺度设置采集时间的间距,同时也可以在调用 interval.range(start, end, step) 时从 interval 所属的时间尺度,再设置采集时间的步长。可以理解为先在父级时间尺度约束固定的采集时间点,这样 interval 就成为一个具有离散的时间点的数组,然后在 interval.range(start, end, step) 里设置的步长就是在这个数组里再挑选元素

```js const start = new Date("2022-05-02T10:12:12Z"); const end = new Date("2022-05-10T14:12:12Z");

const filterInterval = d3.utcDay.every(2);

const rangeArr = filterInterval.range(start, end);

const rangeArrWithStep = filterInterval.range(start, end, 2);

console.log('range array: ', rangeArr); console.log('range array with step: ', rangeArrWithStep) ```

控制台输出的结果如下

d3-interval-every-and-range.png

⚠️ 和 interval.filter() 方法一样,interval.every() 返回的定制过的 interval 没有 interval.count() 方法

如果希望深度定制 interval 可以使用方法 d3.timeInterval(floor, offset[, count[, field]]) 可以对修约行为、日期偏移行为以及采集时间点的行为,最后该方法返回经过深度定制的 interval:

第一个参数 floor 是一个函数,它会接收一个 Date 对象,其作用是对时间进行向下修约,返回在该时间尺度下的下边界值(一个 Date 对象)(设置了 floor 函数后,相应的方法 interval([date])interval.ceil(date)interval.round(date) 的行为也确定了)

第二个参数 offset 是一个函数,它会接收一个 Date 对象和偏移步长 step(应该是整数),其作用是对时间偏移,偏移量是 step,单位是当前的时间尺度,返回偏移后的 Date 对象

第三个(可选)参数 count 是一个函数,它会接收两个参数(它们已经修约到相应时间尺度的下边界),分别表示时间范围的起始点 start(不包含)和结束点 end(包括),其作用是计算在 (start, end] 时间范围内以相应时间尺度来计算,有多少个间隔。⚠️ 如果该参数未设置,则最后返回的深度定制的 interval 没有 interval.count()interval.every() 方法

第四个(可选)参数 field 是一个函数,它会接收一个 Date 对象(已经修约到相应时间尺度的下边界),然后返回 Date 对象特定字段的值,例如 D3 默认提供的 d3.timeDay 时距器,其 field 函数就是 date => date.getDate() - 1 返回 Date 对象在月份中的天数。该方法定义了 interval.every() 的行为,因此在对 Date 修约并返回 Date 对象的特定字段值的时候,应该考虑其父级时间尺度的限制。⚠️ 如果该参数未设置,则默认返回在当前时间尺度下(以 UTC 方式计算)自 1970年1月1日以来的间隔数

```js // 创建一个自定义时距器 // 它的向下修约行为是直接去掉秒,即将 Date 对象的秒字段设置为 0 // 它的偏移行为的时间尺度是分钟级别的 const customInterval = d3.timeInterval( (date) => { date.setSeconds(0, 0); }, (date, step) => { date.setMinutes(date.getMinutes() + step); } );

const date = new Date("2022-02-06T13:12:12.123Z"); console.log("original date: ", date); // original date: 2022-02-06T13:12:12.123Z

const floorTime = customInterval(date); const offsetTime = customInterval.offset(date, 2);

console.log("floor date: ", floorTime); // floor date: 2022-02-06T13:12:00.000Z console.log("offset date: ", offsetTime); // offset date: 2022-02-06T13:14:12.123Z ```

💡 具体代码可以在这个 Codepen 查看

时间轴刻度

D3 为时间轴刻度生成提供了简便的方法

方法 d3.timeTicks(start, stop, count)d3.utcTicks(start, stop, count) 可以在给定的时间范围内(开始点和结束点均包含),基于所需生成的刻度数量 count 进行调整,生成一系列保证可读性的时间对象 💡 和 d3.ticks 方法类似

基于 startend 的距离,会自动从以下的多种时距器中,挑选出时间尺度适合的 interval(

  • 1 second
  • 5 seconds
  • 15 seconds
  • 30 seconds
  • 1 minute
  • 5 minutes
  • 15 minutes
  • 30 minutes
  • 1 hour
  • 3 hours
  • 6 hours
  • 12 hours
  • 1 day
  • 2 days
  • 1 week
  • 1 month
  • 3 months
  • 1 year

💡 以上方法内部使用相应的时距器的方法 interval.range 生成一系列 Date 对象作为时间轴刻度,如果希望知道内部使用的是哪一种 interval,可以通过相应的方法 d3.timeTickInterval(start, stop, count)d3.utcTickInterval(start, stop, count) 获取得到

💡 对于 startend 的间隔较小(毫秒)和较大(多年)该方法也支持,刻度值生成遵循 d3.ticks 方法的规则

```js start = new Date(Date.UTC(1970, 2, 1)) stop = new Date(Date.UTC(1996, 2, 19)) count = 4

d3.utcTicks(start, stop, count) // [1975-01-01, 1980-01-01, 1985-01-01, 1990-01-01, 1995-01-01] ```

💡 如果参数 count 不是一个数值,而直接就是一个 interval,则直接使用该时距器的 interval.range() 方法采集 Date 对象。