用 D3 手动制作图表,就像你真的知道你在做什么一样

Avatar of Burke Holland
Burke Holland on

DigitalOcean 为你旅程的每个阶段提供云产品。 立即开始使用 $200 免费积分!

图表!除了社会学之外,我最不喜欢的科目。 但是在进入这个行业之前,你必须制作一个图表。 我不知道人们对图表有什么执念,但显然,没有一个柱状图来显示 Maggie 上个月的销售额,我们就无法建立文明,所以我们必须想尽一切办法制作图表。

是的,我知道这不是你展示这些数据的最佳方式。 我想在这里强调一点。

为了让你为即将到来的 “OMG 我必须制作一个图表” 的存在危机做好准备,这种危机就像死亡一样,我们喜欢假装它永远不会发生,我要向你展示如何使用 D3.js 手动制作自己的散点图。 本文侧重于代码方面,你第一次看到完成的代码,可能会触发你的“战斗或逃跑”反应。 但是,如果你能读完这篇文章,我想你会对自己的 D3 理解程度感到惊讶,你会对自己能够制作其他你不想制作的图表充满信心。

不过,在我们开始之前,重要的是讨论一下为什么你想要自己动手制作图表。

构建与购买

当你确实需要制作图表时,你很可能会使用一些“开箱即用”的东西。 你绝对不会自己手动制作图表。 就好像你永远不会拿着锤子敲打自己的拇指一样,因为那很痛苦,而且还有更有效的方式使用锤子。 图表是相当复杂的 UI 元素。 这不像你只是在 div 中居中对齐一些文本。 像 Chart.jsKendo UI 这样的库提供了预制图表,你可以直接用它们展示你的数据。 开发人员花费了数千小时来完善这些图表,你永远不会自己构建一个这样的图表。

或者你会吗?

图表库非常棒,但是它们确实会对你施加一定程度的限制……有时候它们甚至会让简单的操作变得更难。 正如彼得·帕克的祖父在他那过于夸张的蜘蛛侠死亡场景中所说,“伟大的图表库带来了巨大的灵活性权衡。”

托比永远不应该成为蜘蛛侠。 跟我干!

当我与同事 Jasmine Greenaway 决定我们可以使用图表来找出谁是 @horse_js 时,我遇到了这种情况。 如果你还没有成为 @horse_js 的粉丝,它是一个 Twitter 模仿账号,会引用人们的断章取义。 它非常棒。

我们提取了 @horse_js 在过去两年的所有推文。 我们将它们存储在 Cosmos DB 数据库中,然后创建了一个 Azure Function 端点 来公开这些数据。

然后,我们心中充满了沉重的感觉,意识到我们需要一个图表。 我们希望能够看到数据随时间的变化情况。 我们认为,通过直观地查看数据的时间序列分析,我们可以识别出一些模式或获得关于该 Twitter 账户的一些见解。 事实上,我们做到了。

我们绘制了 @horse_js 在过去两年中发布的所有推文。 当我们在散点图上查看这些数据时,它看起来像这样

查看 CodePen 上 Burke Holland (@burkeholland) 的 wYxYNd

巧合的是,这是我们将在本文中构建的内容。

每条推文都显示在 x 轴上的日期和 y 轴上的时间。 我以为这用图表库很容易实现,但我尝试的所有库都无法真正处理在 x 轴上使用日期,在 y 轴上使用时间的情况。 我也找不到任何人在网上发布的示例。 我是不是要开创新的领域? 我是不是数据可视化的先驱?

可能是的。 绝对是的。

所以,让我们看看如何使用 D3 来构建这个令人惊叹的散点图。

开始使用 D3

关于 D3 的一件事是:它看起来非常糟糕。 我只是想说出来,这样我们就可以停止假装 D3 代码看起来很有趣。 它不是。 说出来并不丢人。 现在我们已经邀请了这只房间里的“大象”参加茶话会,请允许我暗示,尽管 D3 代码看起来很糟糕,但实际上它并不糟糕。 只是代码量很大。

要开始,我们需要 D3。 我在这些示例中使用的是 D3 5 的 CDN 包含。 我也使用 Moment 来处理日期,我们稍后会讲到。

https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js
https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.22.2/moment.min.js

D3 与 SVG 配合使用。 这就是它所做的事情。 它基本上将 SVG 与数据结合起来,并提供一些方便的预制可视化机制,比如轴。 或者说是 Axees? Axises? 无论“轴”的复数形式是什么。 但是现在,只要知道它就像 SVG 的 jQuery 就可以了。

所以,我们需要的第一个东西是用于工作的 SVG 元素。

<svg id="chart"></svg>

好的。 现在我们可以开始使用 D3 来实现数据可视化。 我们要做的第一件事是将我们的散点图变成一个类。 我们希望这个东西尽可能通用,以便我们可以用其他数据集重复使用它。 我们将从一个构造函数开始,它接受两个参数。 第一个将是我们即将操作的元素的类或 ID(在本例中为 #chart),第二个是一个对象,它允许我们传递任何可能在图表之间变化的参数(例如数据、宽度等)。

class ScatterPlot {
  constructor(el, options) {
  }
}

图表代码本身将放在一个 render 函数中,该函数还需要传递我们正在使用的数据集。

class ScatterPlot {
  constructor(el, options) {
    this.render(options.data);
  }
  
  render(data) {
  }
}

我们在 render 方法中要做的第一件事是为我们的图表设置一些大小值和边距。

class ScatterPlot {
  constructor(el, options) {
    this.data = options.data || [];
    this.width = options.width || 500;
    this.height = options.height || 400;
  
    this.render();
  }
  
  render() {
    let margin = { top: 20, right: 20, bottom: 50, left: 60 };
    let height = this.height || 400;
    let width = (this.height || 400) - margin.top - margin.bottom;
    let data = this.data;
  }
}

我之前提到过 D3 就像 SVG 的 jQuery,我认为这个比喻很贴切。 所以你可以明白我的意思,让我们用 D3 绘制一个简单的 SVG 图形。

首先,你需要选择 SVG 将要使用的 DOM 元素。 完成后,你可以开始添加元素并设置它们的属性。 D3 与 jQuery 一样,都是基于链式调用的概念构建的,所以你调用的每个函数都会返回你调用它的元素的实例。 这样,你可以一直添加元素和属性,直到天荒地老。

例如,假设我们要绘制一个正方形。 使用 D3,我们可以绘制一个矩形(在 SVG 中,它是一个 rect),并在绘制过程中添加必要的属性。

查看 CodePen 上 Burke Holland (@burkeholland) 的 zmdpJZ

现在。 此时你可能会说,“但我不知道 SVG。” 好吧,我也不知道。 但是我知道如何使用 Google,而且网上有很多关于如何在 SVG 中做任何事情的文章。

那么,我们如何从一个矩形变成一个图表呢? 这就是 D3 超越 “用于绘制的 jQuery” 的地方。

首先,让我们创建一个图表。 我们在标记中从一个空的 SVG 元素开始。 我们使用 D3 选择这个空的 svg 元素(名为 #chart),并定义它的宽度和高度以及边距。

// create the chart
this.chart = d3.select(this.el)
  .attr('width', width + margin.right + margin.left)
  .attr('height', height + margin.top + margin.bottom);

它看起来像这样

查看 CodePen 上 Burke Holland (@burkeholland) 的 EdpOqy

太棒了! 什么都没有。 如果你打开开发者工具,你会看到那里确实有些东西。 它只是一个空的“东西”。 就好像我的灵魂一样。

这就是你的图表! 让我们开始往里面添加一些数据。 为此,我们需要定义我们的 x 轴和 y 轴。

在 D3 中,这很简单。 你调用 axisBottom 方法。 在这里,我还使用正确的日期格式来格式化刻度标记。

let xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat('%b-%y'));

我还将一个 “x” 参数传递给 axisBottom 方法。 那是什么? 那就是所谓的比例。

D3 比例

D3 有一个叫做 **比例** 的东西。 比例只是告诉 D3 将数据放在哪里的一种方式,D3 有很多不同类型的比例。 最常见的类型是线性比例,比如从 1 到 10 的数据比例。 它还包含一个专门用于时间序列数据的比例,这正是我们这个图表所需的。 我们可以使用 scaleTime 方法来定义 x 轴的“比例”。

// define the x-axis
let minDateValue = d3.min(data, d => {
  return new Date(moment(d.created_at).format('MM-DD-YYYY'));
});

let maxDateValue = d3.max(data, d => {
  return new Date(moment(d.created_at).format('MM-DD-YYYY'));
});

let x = d3.scaleTime()
  .domain([minDateValue, maxDateValue])
  .range([0, width]);

let xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat('%b-%y'));

D3 比例使用了一些术语,这些术语有点吓人。 这里有两个主要的概念需要理解:域和范围。

  • **域:** 数据集中可能值的范围。 在我的例子中,我从数组中获取最小日期,以及数组中的最大日期。 数据集中的所有其他值都在这两个端点之间,所以这些 “端点” 定义了我的域。
  • 范围:要显示数据集中数据的范围。换句话说,您希望数据有多分散?在本例中,我们希望将数据限制在图表宽度内,因此我们仅将width作为第二个参数传递。如果我们传递一个像10000这样的值,我们的数据将超过 10,000 像素宽。如果我们根本不传递任何值,它将在图表的左侧将所有数据绘制在彼此之上……就像下面的图像一样。

y轴的构建方式相同。只是针对它,我们将对数据进行时间格式化,而不是日期格式化。

// define y axis
let minTimeValue = new Date().setHours(0, 0, 0, 0);
let maxTimeValue = new Date().setHours(23, 59, 59, 999);

let y = d3.scaleTime()
  .domain([minTimeValue, maxTimeValue])
  .nice(d3.timeDay)
  .range([height, 0]);

let yAxis = d3.axisLeft(y).ticks(24).tickFormat(d3.timeFormat('%H:%M'));

y轴上的额外nice方法调用告诉y轴以一种漂亮的方式格式化此时间刻度。如果我们不包含它,它将不会为左侧最上面的刻度添加标签,因为它只到晚上 11:59:59,而不是一直到午夜。这只是一个怪癖,但我们这里不做垃圾。我们需要在所有刻度上添加标签。

现在,我们准备将轴绘制到图表上。请记住,我们的图表上有一些边距。为了正确地定位图表内部的项目,我们将创建一个分组 (g) 元素并设置其宽度和高度。然后,我们可以在该容器中绘制所有元素。

let main = this.chart.append('g')
  .attr('transform', `translate(${margin.left}, ${margin.top})`)
  .attr('width', width)
  .attr('height', height)
  .attr('class', 'main');

我们正在绘制容器,考虑边距并设置其宽度和高度。是的。我知道。这很乏味。但这就是在浏览器中布局事物的状态。您上次尝试将内容在 div 中水平和垂直居中是什么时候?是的,在 Flexbox 和 CSS Grid 之前,这并不那么好。

现在,我们可以绘制x

main.chart.append('g')
  .attr('transform', `translate(0, ${height})`)
  .attr('class', 'main axis date')
  .call(xAxis);

我们创建一个容器元素,然后“调用”我们之前定义的xAxis。D3 从左上角开始绘制事物,因此我们使用transform属性将x轴从顶部偏移,使其显示在底部。如果我们没有这样做,我们的图表将看起来像这样……

通过指定转换,我们将其推到底部。现在是y

main.append('g')
  .attr('class', 'main axis date')
  .call(yAxis);

让我们看看到目前为止的所有代码,然后我们将看看它在屏幕上输出什么。

class ScatterPlot {
  constructor(el, options) {
    this.el = el;

    if (options) {
      this.data = options.data || [];
      this.tooltip = options.tooltip;
      this.pointClass = options.pointClass || '';
      
      
      this.data = options.data || [];
      this.width = options.width || 500;
      this.height = options.height || 400;

      this.render();
    }
  }

  render() {    
    let margin = { top: 20, right: 15, bottom: 60, left: 60 };
    let height = this.height || 400;
    let width = (this.width || 500) - margin.right - margin.left;
    let data = this.data;    

    // create the chart
    let chart = d3.select(this.el)
      .attr('width', width + margin.right + margin.left)
      .attr('height', height + margin.top + margin.bottom);
    
    // define the x-axis
    let minDateValue = d3.min(data, d => {
      return new Date(moment(d.created_at).format('MM-DD-YYYY'));
    });

    let maxDateValue = d3.max(data, d => {
      return new Date(moment(d.created_at).format('MM-DD-YYYY'));
    });

    let x = d3.scaleTime()
      .domain([minDateValue, maxDateValue])
      .range([0, width]);
      
    let xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat('%b-%y'));
    
    // define y axis
    let minTimeValue = new Date().setHours(0, 0, 0, 0);
    let maxTimeValue = new Date().setHours(23, 59, 59, 999);
    
    let y = d3.scaleTime()
      .domain([minTimeValue, maxTimeValue])
      .nice(d3.timeDay)
      .range([height, 0]);

    let yAxis = d3.axisLeft(y).ticks(24).tickFormat(d3.timeFormat('%H:%M'));  
    
    // define our content area
    let main = chart.append('g')
      .attr('transform', `translate(${margin.left}, ${margin.top})`)
      .attr('width', width)
      .attr('height', height)
      .attr('class', 'main');
    
    // draw x axis
    main.append('g')
      .attr('transform', `translate(0, ${height})`)
      .attr('class', 'main axis date')
      .call(xAxis);
    
    // draw y axis
    main.append('g')
      .attr('class', 'main axis date')
      .call(yAxis);
  }
}

查看 Pen oaeybM by Burke Holland (@burkeholland) on CodePen.

我们得到一个图表!给你的朋友打电话!给你的父母打电话!没有什么不可能的!

​​轴标签

现在让我们添加一些图表标签。到目前为止,您可能已经发现,在涉及 D3 时,您几乎都在手动完成所有操作。添加轴标签也不例外。我们要做的就是添加一个 svg text​ 元素,设置其值并定位它。仅此而已。
​​
​​对于x​轴,我们可以使用translate​添加文本标签并定位它。我们将它的x​位置设置为图表的中间(宽度 / 2)。然后我们减去左侧边距,以确保我们恰好位于图表下方居中。我还使用axis-label​的 CSS 类,它具有text-anchor: middle​,以确保我们的文本来自文本元素的中心。
​​

​​​​// text label for the x axis
​​chart.append("text")             
​​  .attr("transform",
​​        "translate(" + ((width/2) + margin.left) + " ," + 
​​                       (height + margin.top + margin.bottom) + ")")
​​  .attr('class', 'axis-label')
​​  .text("Date Of Tweet");

​​
​​y​轴的概念相同 - 一个我们手动定位的 text​ 元素。这个是使用绝对x​y​属性定位的。这是因为我们的 transform​ 用于旋转标签,因此我们使用x​y​属性来定位它。
​​
​​请记住:旋转元素后,x 和 y 会随其旋转。这意味着,当 text​ 元素像这里一样侧向时,y​ 现在将其向左和向右推动,而 x​ 将其向上和向下推动。困惑了吗?没关系,您有许多志同道合的人。
​​

​​// text label for the y-axis
​​chart.append("text")
​​  .attr("transform", "rotate(-90)")
​​  .attr("y", 10)
​​  .attr("x",0 - ((height / 2) + (margin.top + margin.bottom))
​​  .attr('class', 'axis-label')
​​  .text("Time of Tweet - CST (-6)");

​​
​​

查看 Pen oaeybM by Burke Holland (@burkeholland) on CodePen.

​​现在,就像我说的 - 代码非常多。这是不可否认的。但它并不复杂。它就像乐高:乐高积木很简单,但你可以用它们建造非常复杂的东西。我的意思是,这是一个高度复杂、相互关联的砖块系统。

​​现在我们已经有了图表,是时候绘制数据了。
​​

绘制数据点

这相当简单。像往常一样,我们创建一个分组来放置所有圆圈。然后我们循环遍历数据集中的每个项目并绘制一个 SVG 圆圈。我们必须根据当前数据项的日期和时间值来设置每个圆圈的位置 (cxcy)。最后,我们设置它的半径 (r),它控制圆圈的大小。

let circles = main.append('g');

data.forEach(item => {
  circles.append('svg:circle')
  .attr('class', this.pointClass)
  .attr('cx', d => {
    return x(new Date(item.created_at));
  })
  .attr('cy', d => {
    let today = new Date();
    let time = new Date(item.created_at);
    return y(today.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds()));
  })
  .attr('r', 5);
});

当我们设置cxcy值时,我们使用之前定义的刻度 (xy)。我们将此刻度传递给当前数据项的日期或时间值,刻度将返回该项在图表上的正确位置。

而且,我的好朋友,我们得到一个带有真实数据的真实图表。

查看 Pen VEzdrR by Burke Holland (@burkeholland) on CodePen.

最后,让我们在这个图表中添加一些动画。D3 有一些很好的缓动函数,我们可以在此处使用。我们要做的是在每个圆圈上定义一个过渡。基本上,在transition方法之后的所有内容都将进行动画处理。由于 D3 从左上角开始绘制所有内容,因此我们可以先设置x位置,然后对y进行动画处理。结果是这些点看起来像是落到位的。我们可以使用 D3 的巧妙easeBounce缓动函数,使这些点在落下时反弹。

data.forEach(item => {
  circles.append('svg:circle')
  .attr('class', this.pointClass)
  .attr('cx', d => {
    return x(new Date(item.created_at));
  })
  .transition()
  .duration(Math.floor(Math.random() * (3000-2000) + 1000))
  .ease(d3.easeBounce)
  .attr('cy', d => {
      let today = new Date();
      let time = new Date(item.created_at);
      return y(today.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds()));
  })
  .attr('r', 5);

好的,再一遍,现在大家都一起……

class ScatterPlot {
  constructor(el, options) {
    this.el = el;      
    this.data = options.data || [];
    this.width = options.width || 960;
    this.height = options.height || 500;

    this.render();
  }

  render() {    
    let margin = { top: 20, right: 20, bottom: 50, left: 60 };
    let height = this.height - margin.bottom - margin.top;
    let width = this.width - margin.right - margin.left;
    let data = this.data;    

    // create the chart
    let chart = d3.select(this.el)
      .attr('width', width + margin.right + margin.left)
      .attr('height', height + margin.top + margin.bottom);
    
    // define the x-axis
    let minDateValue = d3.min(data, d => {
      return new Date(moment(d.created_at).format('MM-DD-YYYY'));
    });

    let maxDateValue = d3.max(data, d => {
      return new Date(moment(d.created_at).format('MM-DD-YYYY'));
    });

    let x = d3.scaleTime()
      .domain([minDateValue, maxDateValue])
      .range([0, width]);
      
    let xAxis = d3.axisBottom(x).tickFormat(d3.timeFormat('%b-%y'));
    
    // define y axis
    let minTimeValue = new Date().setHours(0, 0, 0, 0);
    let maxTimeValue = new Date().setHours(23, 59, 59, 999);
    
    let y = d3.scaleTime()
      .domain([minTimeValue, maxTimeValue])
      .nice(d3.timeDay)
      .range([height, 0]);

    let yAxis = d3.axisLeft(y).ticks(24).tickFormat(d3.timeFormat('%H:%M'));    
    
    // define our content area
    let main = chart.append('g')
      .attr('transform', `translate(${margin.left}, ${margin.top})`)
      .attr('width', width)
      .attr('height', height)
      .attr('class', 'main');
    
    // draw x axis
    main.append('g')
      .attr('transform', `translate(0, ${height})`)
      .attr('class', 'main axis date')
      .call(xAxis);
    
    // draw y axis
    main.append('g')
      .attr('class', 'main axis date')
      .call(yAxis);  

    // text label for the y axis
​​    chart.append("text")
​​      .attr("transform", "rotate(-90)")
​​      .attr("y", 10)
​​      .attr("x",0 - ((height / 2) + margin.top + margin.bottom)
​​      .attr('class', 'axis-label')
​​      .text("Time of Tweet - CST (-6)");      
​​      
​​    // draw the data points
    let circles = main.append('g');
      
    data.forEach(item => {
      circles.append('svg:circle')
        .attr('class', this.pointClass)
        .attr('cx', d => {
          return x(new Date(item.created_at));
        })
        .transition()
        .duration(Math.floor(Math.random() * (3000-2000) + 1000))
        .ease(d3.easeBounce)
        .attr('cy', d => {
            let today = new Date();
            let time = new Date(item.created_at);
            return y(today.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds()));
        })
        .attr('r', 5);
    });
  }
}

我们现在可以调用一些数据并渲染此图表……

// get the data
let data = fetch('https://s3-us-west-2.amazonaws.com/s.cdpn.io/4548/time-series.json').then(d => d.json()).then(data => {

  // massage the data a bit to get it in the right format
  let horseData = data.map(item => {
    return item.horse;
  })

  // create the chart
  let chart = new ScatterPlot('#chart', {
    data: horseData,
    width: 960
  });
});

这里就是整个内容,包括调用 Azure 函数从 Cosmos DB 返回数据。这是一个海量数据,因此请耐心等待,因为我们将占用您所有带宽。

查看 Pen GYvGep by Burke Holland (@burkeholland) on CodePen.

如果您走到这一步,我……好吧,我印象深刻。D3 并不是一件容易上手的东西。它看起来根本不像很有趣。但是,这里没有砸到任何拇指,我们现在完全控制了这个图表。我们可以随心所欲地处理它。

查看一些 D3 的其他资源,祝您的图表一切顺利。您可以做到!或者您做不到。无论哪种方式,都必须有人制作一个图表,它很可能就是您。

用于您的数据和 API

更多关于 D3