《Getting Started with D3》填坑之旅(三):第二章

cover

Chapter 2. The Enter Selection(輸入選擇)

示例1:創建地鐵線路狀態公告欄

閒言少敘,來看本書的第一個坑,還是大坑:重要概念 enter selection(輸入選擇)。

示例的場景很簡單:根據清洗好的 JSON 數據源,羅列出紐約各個地鐵線的狀態信息,比如是否正常運行、是否計劃運營等等;然後對不同的狀態設置相應的 CSS 樣式,效果圖如下:

示例效果圖

代碼也挺簡單的,順帶提到了一個 D3 慣用的【方法鏈式】編碼風格(小瀑布式寫法?(cascade)):

<!DOCTYPE html>
<html>
    <head><meta charset="utf-8"></head>
    <body>
<script src="js/d3.js"></script>
<script>
    function draw(data) {
        "use strict";

        d3.select('body')
            .append('ul')
            .selectAll('li')
            .data(data)
            .enter()
            .append('li')
                // populate content
                .text(d => `${d.name}: ${d.status}`)
                // different styles
                .style('font-weight', d => 
                    d.status == 'GOOD SERVICE' ? 'normal' : 'bold')
    }
    // v5.15.1:
    d3.json("data/service_status.json").then(draw);
</script>
    </body>
</html>

真正的坑,出現在作者對這段代碼的講解上。原諒我只憑書中的描述,難以領會作者的深意。後來才發現,必須訪問書中提示欄(如下圖所示)給出的 鏈接文章,並且再結合那篇文章末尾的補遺位置給出的 示例鏈接,才能完全搞明白 Mike 大神說的啥。
warning and caution

感興趣的親們可以去看看原文。要徹底搞懂本章標題中的 Enter Selection 爲何物,必須結合一個重要配圖:

3 stages in D3

鏈接文章說,D3 在繪製頁面元素時,只需要告知其數據元素(狀態信息)與作圖元素(li)間的對應關係即可,這在 D3 中稱爲【數據連接】(data join)。結合第 L12-15 行代碼:

.selectAll('li')
.data(data)
.enter()
.append('li')

接下來是真正的關鍵信息:

  1. .selectAll('li') 返回一個【空選中】(empty selection),這個【空選中】會與一個新的數據數組 連接 在一起,得到三個新 選中,對應上圖那三個狀態:enter(輸入)、update(更新)、exit(退出)。鑑於剛纔的【空選中】沒有任何頁面元素,updateexit 選中也是一個【空選中】;而左邊那個 enter 選中,則包含了一組 佔位符,每個 佔位符 與後續即將渲染的各個數據元素一一對應。根據小冊子中的解釋,此時這些佔位符都是空的,沒有綁定任何數據;雖然是空的,但可以通過【空選中】的 .data() 方法接收數據,即後面的第二行代碼;
  2. .data(data) 返回上圖中的 update 選中,此時 enterexit 選中阻斷了 update 選中的後續操作;
  3. .enter()update 選中的方法,返回一個 enter 狀態的選中(即本章標題中的 輸入選擇)。此時,各佔位上已經有了帶渲染的數據;
  4. .append('li') 是在 enter 選中上的方法,作用是將所有待渲染的作圖元素 li,按數據源中的元素順序,逐一追加渲染到頁面的 ul 容器元素內。

再配合後面兩句設置,纔有了最終的頁面效果。

數據連接的設計初衷

代碼倒是解釋完了,新的問題又出來了:

爲什麼要繞這麼大一圈呢?直接在 .data() 方法中搞定一切、或者直接給一個封裝好的方法不好嗎?

這篇由 D3 創始人親自執筆的文章給出的解釋是:

**The beauty of the data join is that it generalizes. **

翻譯過來就是:數據連接的終極奧義,在於它是泛化的、通用的、普適的。(說了當沒說)

結合後面的幾個例子,才知道這樣的設計不僅適合類似示例這樣只考慮 enter 選中的小兒科型應用場景,也適合動態的、實時的數據可視化展示——

例1:動態增刪元素

var circle = svg.selectAll("circle")
  .data(data);

circle.exit().remove();

circle.enter().append("circle")
    .attr("r", 2.5)
  .merge(circle)
    .attr("cx", d => d.x)
    .attr("cy", d => d.y);

這段代碼會重新選中頁面 svg 元素內的所有 circle 元素。當新的數據集少於當前數據集,多出的部分會進入 exit 狀態,進而被最終刪除;反之,如果新數據集大於當前數據集,則新多出的部分會進入 enter 狀態,進而渲染到頁面;如果前後兩個數據集一樣大,則只執行數據對應位置的更新,不存在頁面元素的增刪環節。

例2:提升頁面渲染性能

此外,從數據連接的角度考慮問題,還能使編碼風格將更趨於聲明式:只需要對 enterupdateexit 這三個狀態實現交互、過渡、動畫等各類可視化效果即可,省去了具體的 if 分支或 for 循環細節。比如,可以在 enter 階段而非 update 階段給 circle 元素的半徑賦一個常量值,達到最小化 DOM 操作的效果,極大地提升頁面渲染的性能。

circle.enter().append("circle")
    .attr("r", 0)

例3:針對狀態設置過渡

// Expand-in:
circle.enter()
  .append("circle")
    .attr("r", 0)
  .transition()
    .attr("r", 2.5);

// Shrink-out:
circle.exit()
  .transition()
    .attr("r", 0)
    .remove();

例子倒是給出了不少,但對我這樣的重度強迫症已棄療者來說,百聞不如一見——沒有見到實際的效果,總有點小遺憾。誰曾想,在文末的附錄補遺的位置,看到了大神八年前留下的文末彩蛋(要不要這麼心有靈犀啊😂😂😂)

Addendum

不看不知道,一看嚇一跳,這是一個帶實時編輯預覽功能的 D3 可視化實驗室,用 GitHub 帳號就可以登錄,叉取自己感興趣的示例文章。8 年前的這篇 通用更新模式 已經在 2019 年 1 月 23 日被 Mike Bostock 大神更新到了 新文章 上,看得我那叫個過癮啊。這不就是 D3.js 版的 字符跳動效果 的手把手教程嗎。代碼的簡潔、工整、高效讓人折服!不知道國內是否有這樣的模擬環境,母語爲英語的崽兒們簡直太幸福了。。。

靜態效果:

在這裏插入圖片描述

{
  const svg = d3.create("svg")
      .attr("width", width)
      .attr("height", 33)
      .attr("viewBox", `0 -20 ${width} 33`);

  svg.selectAll("text")
    .data(randomLetters())
    .join("text")
      .attr("x", (d, i) => i * 16)
      .text(d => d);

  return svg.node();
}

每 2.5 秒動態更新一次:

dynamic1

{
  const svg = d3.create("svg")
      .attr("width", width)
      .attr("height", 33)
      .attr("viewBox", `0 -20 ${width} 33`);

  while (true) {
    svg.selectAll("text")
      .data(randomLetters())
      .join("text")
        .attr("x", (d, i) => i * 16)
        .text(d => d);

    yield svg.node();
    await Promises.tick(2500);
  }
}

.join() 還可以接收三個回調函數,分別對應 enterupdateexit 三個選中狀態,例如:

動態字符跳動效果:

character animation

{
  function randomLetters() {
    return d3.shuffle("abcdefghijklmnopqrstuvwxyz".split(""))
      .slice(0, Math.floor(6 + Math.random() * 20))
      .sort();
  }
  
  const svg = d3.create("svg")
      .attr("width", width)
      .attr("height", 33)
      .attr("viewBox", `0 -20 ${width} 33`);

  while (true) {
    const t = svg.transition()
        .duration(750);

    svg.selectAll("text")
      .data(randomLetters(), d => d)
      .join(
        enter => enter.append("text")
            .attr("fill", "green")
            .attr("x", (d, i) => i * 16)
            .attr("y", -30)
            .text(d => d)
          .call(enter => enter.transition(t)
            .attr("y", 0)),
        update => update
            .attr("fill", "black")
            .attr("y", 0)
          .call(update => update.transition(t)
            .attr("x", (d, i) => i * 16)),
        exit => exit
            .attr("fill", "brown")
          .call(exit => exit.transition(t)
            .attr("y", 30)
            .remove())
      );

    yield svg.node();
    await Promises.tick(2500);
  }
}

最後一個比較有意思的地方,是隨機英文字母序列的實現,用到了 d3.shuffle(array)

function randomLetters() {
  return d3.shuffle("abcdefghijklmnopqrstuvwxyz".split(""))
    .slice(0, Math.floor(6 + Math.random() * 20))
    .sort();
}

也可以用 ES6 的 Array.from() 實現:

const randomLetters = () => d3.shuffle(Array.from({length: 26}, (e, i) => 
    String.fromCharCode(i + 97)))
  .slice(0, Math.floor(6 + Math.random() * 20))
  .sort();

示例2:繪製露天廣場的日平均流量

(更新中)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章