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 對象。