此页面需要javascript支持,请在浏览器中启用javascript

d3-selection 入门篇(1),制作响应式 Github ContributionCalendar

d3
ContributionCalendar
d3-selection
reactive
共1493个字,阅读时间 7 分钟
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://icebreaker.top/articles/2022/1/22-d3-selection

icebreaker

d3-selection 入门篇(1), 制作响应式 Github ContributionCalendar

前言

前端同学们对 d3 肯定是相当的熟悉了,在我们制作复杂的,重交互的图表时,常常会用到它。同时我们在使用它的时候,往往会结合 vue/react 这类前端框架,往 d3-selection 里传送 VNode/ReactNode,从而达成水乳交融,灵活多变的效果。

但是 d3 整个家族是非常庞大的,光官方的子依赖包就有 30 个。这导致一开始暴露给我们的 api 就过多了,新手很难上手。于是,本篇文章就只介绍 d3 30 个子包 中的 d3-selection,本文所有的实现只依赖此包,使用 d3 的版本为最新的 7.3.0

写一个简单的 Demo

学习 d3 最快的方式就是多写多想,接下来我们来实现一下 Github ContributionCalendar:

Github ContributionCalendar

相信大家对这个肯定是非常熟悉了,它是用 svg 画的一个年度日历,包含 2 个坐标轴 (月份,星期几),鼠标移上去会显示一个 popper 上面会展示那一天 git 提交的次数。

popper

开始撰写

流程式写法

流程式写法最简单易懂,因为它是顺着svg构造一步一步来的。

首先观察图形就是一个 svg,带了一堆的 recttext。那么我们就能写出下列代码: (代码太长做阉割处理,只展示核心部分,完整代码见附录)

// 一列 7 个正方形
const colItemCount = 7
// 创建一个 svg ,里面一个 g
const wrapper = d3
  .select(boxRef.value as HTMLDivElement)
  .append('svg')
  .attr('width', 722)
  .attr('height', 112)
  .append('g')
  .attr('transform', 'translate(10, 20)')

let colIndex = -1
let g: d3.Selection<SVGGElement, unknown, null, undefined>
// 一年有 365 个日出,我送你 365 个祝福
for (let i = 0; i < 365; i++) {
  // y 轴的索引
  const yIdx = i % colItemCount
  if (yIdx === 0) {
    // 当第一个的时候,指针指到第一列,添加一个 g 对 rect 进行包裹
    colIndex++
    g = wrapper
      .append('g')
      .attr('transform', `translate(${colIndex * 14}, 0)`)
  }
  // 正方形
  g!
    .append('rect')
    .attr('width', 10)
    .attr('height', 10)
    .attr('x', 14)
    // y轴偏移
    .attr('y', yIdx * 13)
    .attr('rx', 2)
    .attr('ry', 2)
    .attr('data-level', i % 4)
    // 添加 class , attr('class','ContributionCalendar-day')也行
    .classed('ContributionCalendar-day', true)
    .each(function () {
      // 鼠标移上去显示 Popper
      addPopper(this as SVGRectElement)
    })
}
// 添加 x 轴
addXAxis()
// 添加 y 轴
addYAxis()

这样一个简单的 Github ContributionCalendar 就完成了。

里面已经包含了 d3-selection 中大量的 api,同时也暴露出很多的问题:

  1. 假如这是一个 render 方法,当我们改变其中的值后,再次调用重新渲染,它会再添加一个 svg 并进行一些 dom 操作。显然此时把一些 selection 缓存起来是比较好的选择。
  2. function 虽然有拆分,但是变量之间的耦合度太高。

数据驱动式写法

d3-selection 中除了一些选择赋值这些基础的 dom 操作 API,另一个极其重要的就是要掌握 Joining Data,即:

  • selection.data([data[, key]])
  • selection.join(enter[, update][, exit])

data 在很多场景下极其有用,比如我们处理自引用树状结构时,就要用它结合 d3-hierarchy 一起使用。

join 本身就是 enterexit,在回调部分通过数据的变化对选择集合进行归类,并执行自定义回调。

datum 则可以从现有的数据集合中抽取更改链式调用的数据源。

接下来我们就可以对刚才的代码进行改造:

准备数据源

首先这个图形看上去就像一个二维数组,它有像 countleveldateindexid 这样的属性,分别用来表示,提交的次数,像素块的亮度,提交的时间,像素块的索引,渲染的唯一 key。 于是我们就能预先准备这样一笔数据了:

type MatrixItem = {
  id: string
  index: number
  level: number
  count: number
  date: string
}
let matrix: MatrixItem[][]
// then fill the matrix
matrix = fillMatrix(seed)

接着让这个二维数组,来接管我们 svg 的渲染:

// render 时接管 svg 的宽度
const width = Math.floor(matrix.length + 1) * 14 + 24
svg.attr('width', width).attr('height', height)

wrapper
  .selectAll('g')
  // 注入数据
  .data(data)
  // 二维数组 第一层,选中 MatrixItem[] , enter -> g
  .join('g')
  .attr('transform', function (d, i, g) {
    return `translate(${(i + 1) * 14}, 0)`
  })
  .selectAll('rect')
  // 二维数组 第二层,选中 MatrixItem
  .data((d) => d)
  .join(
    // enter
    (enter) => {
      // 添加小方块
      return enter
        .append('rect')
        .attr('width', 10)
        .attr('height', 10)
        .attr('x', 0)
        .attr('y', function (d, i) {
          return i * 13
        })
        .attr('rx', 2)
        .attr('ry', 2)
        .attr('data-count', function (d, i) {
          return d.count
        })
        .attr('data-date', function (d, i) {
          return d.date
        })
        .classed('ContributionCalendar-day', true)
        .each(function (d, i, g) {
          addPopper(this as SVGRectElement)
        })
        .attr('data-level', function (d, i) {
          return d.level
        })
    },
    // update 时 level更改,进行变色
    (update) =>
      update.attr('data-level', function (d, i) {
        d.level = (d.level + seed) % 4
        return d.level
      }),
    // exit 时,移除 dom
    (exit) => exit.remove()
  )

效果如图所示:

reactive

需要好好理解的是 join 这个概念,这里需要贴一段原文自行体会一下:

D3’s data join lets you specify exactly what happens to the DOM as data changes. This makes it fast—you can minimize DOM operations—and expressive—you can animate enter, update and exit separately. Yet power comes at a cost: the data join’s generality makes it hard to learn and easy to forget.

就个人理解而言,这个 join 的本质就是一个 diff。每次在执行的时候,它会和现存的 node 集合进行比较,根据绑定的情况,把它们归为三类:

update = new Selection(update, parents);
update._enter = enter;
update._exit = exit;
return update;

来交给后续的 join(enter/exit) ,执行它们的回调方法。值得注意的是,data 是否声明 key,走的绑定方法(bind)是 2 个不同的分支:(bindIndex,bindKey)。后者会创建一个 nodeByKeyValueMap<Key,Node> 结构来缓存原先的数据映射。

就这样 Selecting ElementsModifying ElementsJoining Data 的部分,通过这个示例已经很明白了。

出于篇幅限制,下一篇文章,我才会讲解一下极其重要的 Handling EventsControl FlowLocal VariablesNamespaces 等等内容。

其中 Handling Events 也是很重要,经常结合其他包使用,比如 d3-zoom

最后说一句,笔者随缘更新....

附录

@d3/selection-join

Thinking With Joins

selection.data source

selection.join source

Demo folder