《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:绘制露天广场的日平均流量

(更新中)

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