JS-D3库

基本地址

<script src="https://d3js.org/d3.v6.min.js"></script>

Github: https://github.com/d3/d3

介绍

D3的主要功能是基于数据来变化DOM文档,比如根据输入的信息来快速生成/变更一个html中的table。或者用同样的数据生成可交互的SVG。D3能够支持HTML,SVG,CSS这些文档的相关操作,D3可能不是非常灵活,但是其速度很快,能够支持大规模数据集,并且能够自定义交互行为和动画。此外,D3通过module的方式允许代码重用。

学习D3最好通过实际例子入手,也可以直接拿这些例子改成自己的数据来可视化,这样能加快进度。

Observable平台

可以在Observable上面学习D3,能够迅速看到代码运行后的结果。

https://observablehq.com/

Observable不是Javascript,Observable允许用户使用D3,Three,Tensorflow等库,但是:

  • Observable中,每个cell中的脚本是无关的,一个cell中语法错误,其他的cells还是会运行。
  • local variable只有在定义它们的那个cell中才是可见的。
    • 比如有一个cell,只包含{var local = 1;}这个local对其他cells就不可见。
  • named cell对应的变量只能赋值一次,比如第一个cell foo=2,那么其他Cell就只能读foo这个值,不能写。也就是说,每个named cell可以视作是某个函数的声明,而不是一条赋值语句。
  • cells是按照数据互相依赖的拓扑序来运行的,因此可以随便打乱每个cell的顺序。不需要像Js一样从上到下写代码。当修改了一个cell之后,点击运行,只有这个cell依赖的cells的会重跑。
  • 由于cells是按照拓扑序来运行的,因此不允许circular definition,即循环定义。
  • implicitly await Promise
  • 如果一个cells是generator,那么所有引用它的cells都能看到的是最近yielded的值
  • 复杂的statements要用{}抱起来,然后return或者yield。
    •   
      {
        let sum = 0;
        for (let i = 0; i < 10; ++i) {
          sum += i;
        }
        return sum;
      

        

  • cell有一个特别的viewof单元,可以让cell可见。
  • mutable operator
  • 有一个标准库,能够很轻易地完成一些功能
    • md`Hello, I’m *Markdown*!`
  • 能够从其他notebook中import any named cell
  • require是AMD(Asynchronous Module Definition)格式的,而不是CommonJS风格的
    • d3 = require("d3@6")
  • 如果一个cell在不断运行,没法释放资源,可以使用invalidation promise(作为页面刷新党,感觉刷新是不是可以有类似功能)
    •   { invalidation.then(() => console.log("I was invalidated.")); }

 

Observable+d3:

  • 引入数据: values = FileAttachment("values@1.json").json()
  • import d3: d3 = require("d3@6")
  • 引入其他人的cell: import {chart as chart1} with {values as data} from "@d3/histogram"
  • 画图: chart1
  • 改变图的高度:
    •   height = 200
    •   import {chart as chart2} with {values as data, height} from "@d3/histogram"
  • 生成随机数: values3 = Float64Array.from({length: 2000}, d3.randomNormal(mu, 2))
  • 规定x轴范围: x = d3.scaleLinear([-10, 10], [margin.left, width - margin.right])

 

Data

例如,在cell上上传一个附件temperature.csv,然后通过d3来parse

d3.csvParse(await FileAttachment("temperature.csv").text())

  

d3默认不去推测类型,所以从文件中读取得到的都只是字符串的格式,要使其自动推测格式,只需加上d3.autoType选项

d3.csvParse(await FileAttachment("temperature.csv").text(), d3.autoType)

  

计算数据的范围:

d3.extent(data, d => d.date)

  

从数据中提取子属性

temperatures = data.map(d => d.temperature)

 

Scales

 对于一个如下所示的数据:

fruits = [
  {name: "🍊", count: 21},
  {name: "🍇", count: 13},
  {name: "🍏", count: 8},
  {name: "🍌", count: 5},
  {name: "🍐", count: 3},
  {name: "🍋", count: 2},
  {name: "🍎", count: 1},
  {name: "🍉", count: 1}
]

  

我们要将其用如下条形图展示,那么就要把count属性映射到x轴,把name属性映射到y轴

 

 映射函数分别如下:

这里x使用了linear scale,因为count是个标量,而且条形图长度要与count成正比

x = d3.scaleLinear()
    .domain([0, d3.max(fruits, d => d.count)])
    .range([margin.left, width - margin.right])
    .interpolate(d3.interpolateRound)

  

这里y使用了band scale,因为name对应名词,而且每条bar都要求很粗。

例子中的scale都使用了method chaining的方式来进行设置。

y = d3.scaleBand()
    .domain(fruits.map(d => d.name))
    .range([margin.top, height - margin.bottom])
    .padding(0.1)
    .round(true)

  

每个scale都有两个属性: domain和range,这一对属性将domain中的数据映射到range,比如说对于linear scale,domain为[0, 21], range为[margin.left, width - margin.right],那么d3就会把0映射到margin.left处,依次等距类推。

上面的例子中,可以用x.domain()或者x.range()来读取这对属性。而x(21)=640,也即width - margin.right,x(0) = 30,也即margin.left。

这里的margin用来给坐标轴标号等留位置。

文中用了一个有趣的条形图的一部分来向我们展示x的值:

一个按钮用来输入count这个值。

viewof count = {
  const form = html`<form style="font: 12px var(--sans-serif); display: flex; align-items: center; min-height: 33px;">
  <label style="display: block;">
    <input name="input" type="range" min="0" max="21" value="12" step="1" style="width: 180px;">
    count = <output name="output"></output>
  </label>
</form>`;
  form.oninput = () => form.output.value = form.value = form.input.valueAsNumber;
  form.oninput();
  return form;
}

  

一根bar用来展示具体值:

html`<svg viewBox="0 0 ${width} 33" style="max-width: ${width}px; font: 10px sans-serif; display: block;">
  <rect fill="steelblue" x="${x(0)}" width="${x(count) - x(0)}" height="33"></rect>
  <text fill="white" text-anchor="end" x="${x(count)}" dx="-6" dy="21">${count}</text>
</svg>`

  

 

 为了画条形图:

1. 创建一个G element

  

<g fill="steelblue">
    ${fruits.map(d => svg`<rect y="${y(d.name)}" x="${x(0)}" width="${x(d.count) - x(0)}" height="${y.bandwidth()}"></rect>`)}
  </g>

  

2. 然后把G element传给d3.select。

d3.select(svg`<g transform="translate(0,${margin.top})">`)

3. 使用select.call将axis渲染到G element中

d3.select(svg`<g transform="translate(0,${margin.top})">`)
    .call(d3.axisTop(x))

  

4.  the domain path for a minimalist style

d3.select(svg`<g transform="translate(0,${margin.top})">`)
    .call(d3.axisTop(x))
    .call(g => g.select(".domain").remove())

  

5. 使用selection.node来获取渲染后的G element以便嵌入到其他元素中

  ${d3.select(svg`<g transform="translate(0,${margin.top})">`)
    .call(d3.axisTop(x))
    .call(g => g.select(".domain").remove())
    .node()}

  

 

 

 

Scale还能做其他的事,比如将值映射为颜色,例如,下面这个离子将数值映射为蓝色的深浅:

color = d3.scaleSequential()
    .domain([0, d3.max(fruits, d => d.count)])
    .interpolator(d3.interpolateBlues)

  

应用到bar上,这里还考虑到了蓝色深浅标签颜色也要不同:

html`<svg viewBox="0 ${margin.top} ${width} ${height - margin.top}" style="max-width: ${width}px; font: 10px sans-serif;">
  <g>
    ${fruits.map(d => svg`<rect fill="${color(d.count)}" y="${y(d.name)}" x="${x(0)}" width="${x(d.count) - x(0)}" height="${y.bandwidth()}"></rect>`)}
  </g>
  <g text-anchor="end" transform="translate(-6,${y.bandwidth() / 2})">
    ${fruits.map(d => svg`<text fill="${d3.lab(color(d.count)).l < 60 ? "white" : "black"}" y="${y(d.name)}" x="${x(d.count)}" dy="0.35em">${d.count}</text>`)}
  </g>
  ${d3.select(svg`<g transform="translate(${margin.left},0)">`)
    .call(d3.axisLeft(y))
    .call(g => g.select(".domain").remove())
    .node()}
</svg>`

  

 Shapes

D3提供了一些特定的vocabulary,用来产生多种形状。借用这些,用户可以画圆形,矩形,线或者任何其他任何形状。一条path的形状是由SVG Path data language规定的,其底层很类似于pen plotter的语法。例如:

Mx,y 移动到[x,y]这个点

Lx,y 画线到[x,y]这个点

hx在x这里画一条直线

vy在y这里画一条竖线

z关闭当前的subpath

以line chart为例,其具体命令是:

"M40,177.39579502267992L40.44303097345133,176.9415839156553L40.88606194690265,177.1308385435822L41.32909292035398,177.77139266887337..."
 
{
  let path = `M${x(data[0].date)},${y(data[0].close)}`;
  for (let i = 1; i < data.length; ++i) {
    path += `L${x(data[i].date)},${y(data[i].close)}`;
  }
  return path;
}

  

不过d3有更方便的封装函数: d3.line。d3.line会生成一个默认的line generator。调用这个generator的x方法和y方法能够得到对应的坐标。

line = d3.line()
    .x(d => x(d.date))
    .y(d => y(d.close))

  

接着,将他嵌入到名为path的元素中的d属性中。这里为了避免两个线段连接处的尖峰误导判断,教程中设置stoke-miterlimit=1。

html`<svg viewBox="0 0 ${width} ${height}">
  <path d="${line(data)}" fill="none" stroke="steelblue" stroke-width="1.5" stroke-miterlimit="1"></path>
  ${d3.select(svg`<g>`).call(xAxis).node()}//已经定义好的xAis
  ${d3.select(svg`<g>`).call(yAxis).node()}
</svg>`

  

 

 

接下来以area为例,area需要定义三个值: x, y0(baseline),y1(topline)。

area = d3.area()
    .x(d => x(d.date))
    .y0(y(0))
    .y1(d => y(d.close))

  

html`<svg viewBox="0 0 ${width} ${height}">
  <path fill="steelblue" d="${area(data)}"></path>
  ${d3.select(svg`<g>`).call(xAxis).node()}
  ${d3.select(svg`<g>`).call(yAxis).node()}
</svg>`

  

xAxis = g => g
    .attr("transform", `translate(0,${height - margin.bottom})`)
    .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0))

  

yAxis = g => g
    .attr("transform", `translate(${margin.left},0)`)
    .call(d3.axisLeft(y).ticks(height / 40))
    .call(g => g.select(".domain").remove())

  

 

 当然也可以修改baseline即y0,使之不为0,画出一个条带。

 

 当然,也可以一次画多条线:

html`<svg viewBox="0 0 ${width} ${height}">
  <path d="${areaBand(data)}" fill="#ddd"></path>
  <g fill="none" stroke-width="1.5" stroke-miterlimit="1">
    <path d="${lineMiddle(data)}" stroke="#00f"></path>
    <path d="${line(data)}" stroke="#000"></path>
  </g>
  ${d3.select(svg`<g>`).call(xAxis).node()}
  ${d3.select(svg`<g>`).call(yAxis).node()}
</svg>`

  

 

 

html`<svg viewBox="0 0 ${width} ${height}">
  <path d="${areaBand(data)}" fill="#ddd"></path>
  <g fill="none" stroke-width="1.5" stroke-miterlimit="1">
    <path d="${areaBand.lineY0()(data)}" stroke="#00f"></path>
    <path d="${areaBand.lineY1()(data)}" stroke="#f00"></path>
  </g>
  ${d3.select(svg`<g>`).call(xAxis).node()}
  ${d3.select(svg`<g>`).call(yAxis).node()}
</svg>`

  

 

另一个非常基础的形状是arc,arc有四个属性需要定义: innerRadius,outerRadius, startangle, endAngle。

下面的arc接受一对参数[startAngle, endAngle],将前者设置为startAngle,后者设置为endAngle。

arc = d3.arc()
    .innerRadius(210)
    .outerRadius(310)
    .startAngle(([startAngle, endAngle]) => startAngle)
    .endAngle(([startAngle, endAngle]) => endAngle)

  

html`<svg viewBox="-320 -320 640 640" style="max-width: 640px;">
  ${Array.from({length: n}, (_, i) => svg`<path stroke="black" fill="${d3.interpolateRainbow(i / n)}" d="${arc([i / n * 2 * Math.PI, (i + 1) / n * 2 * Math.PI])}"></path>`)}
</svg>`

  

 

 d3还定义了一个简单的自动计算arc角度的pie图函数:d3.pie。

pieArcData = d3.pie()
    .value(d => d.count)
  (fruits)

  

 

 

arcPie = d3.arc()
    .innerRadius(210)
    .outerRadius(310)
    .padRadius(300)
    .padAngle(2 / 300)
    .cornerRadius(8)

  

html`<svg viewBox="-320 -320 640 640" style="max-width: 640px;" text-anchor="middle" font-family="sans-serif">
  ${pieArcData.map(d => svg`
    <path fill="steelblue" d="${arcPie(d)}"></path>
    <text fill="white" transform="translate(${arcPie.centroid(d).join(",")})">
      <tspan x="0" font-size="24">${d.data.name}</tspan>
      <tspan x="0" font-size="12" dy="1.3em">${d.value.toLocaleString("en")}</tspan>
    </text>
  `)}
</svg>`

  

 Animation

动画可以被认为是在隔一段时间向用户展示一张图片。也就是说,我们可以认为动画是图片的序列,这个序列中的每个元素都是一个参数为时间t的函数。

如下面的例子,stroke-dasharray属性定义的图形将具有动画效果(其他图形保持不变)。这里,连续动画一般是通过离散的关键帧定义的,其他中间帧则是通过插值(或者tweening)生成的。stroke-dasharray本身可以生成虚线,它的第一个参数是实线段的长度即可见的长度,第二个参数是虚线段的长度即不可见的长度,通过调整这两个属性,我们就能够实现动画效果。

replay, html`<svg viewBox="0 0 ${width} ${height}">
  ${d3.select(svg`<path d="${line(data)}" fill="none" stroke="steelblue" stroke-width="1.5" stroke-miterlimit="1" stroke-dasharray="0,1"></path>`).call(reveal).node()}
  ${d3.select(svg`<g>`).call(xAxis).node()}
  ${d3.select(svg`<g>`).call(yAxis).node()}
</svg>`

  

这里我们使用了d3.interpolate进行插值。

reveal = path => path.transition()
    .duration(5000)
    .ease(d3.easeLinear)
    .attrTween("stroke-dasharray", function() {
      const length = this.getTotalLength();
      return d3.interpolate(`0,${length}`, `${length},${length}`);
    })

  

interpolate也能应用在许多其他方面。

 

 当定义一个transition的时候,可以通过transition.attrTween来调用已经声明好的interpolator,也可以使用transition.attr或者transition.style来让d3决定具体interpolator。

此外,还能通过Observable的数据流来画图,每当t更改时,就重新创建图形,但是这样的效率更低。

 

Joins

d3 selection帮助用户快速增量型地动态更新图。d3并不是要求用户直接定义最终DOM的状态,而是要求用户定义将当前状态转化为期望的状态所需要的更改,比如插入,更新或者删除。

例如,以下代码展示了26个字母。

chart1 = {
  const svg = d3.create("svg")
      .attr("viewBox", [0, 0, width, 33])
      .attr("font-family", "sans-serif")
      .attr("font-size", 10)
      .style("display", "block");

  svg.selectAll("text")
    .data(alphabet)
    .join("text")
      .attr("x", (d, i) => i * 17)
      .attr("y", 17)
      .attr("dy", "0.35em")
      .text(d => d);

  return svg.node();
}

  

如果想要动态改变这个SVG,最好就使用d3.select把需要更新的元素单独拿出来更新:

randomLetters = {
  while (true) {
    yield d3.shuffle(alphabet.slice())
      .slice(Math.floor(Math.random() * 10) + 5)
      .sort(d3.ascending);
    await Promises.delay(3000);
  }
}

  

chart2 = {
  const svg = d3.create("svg")
      .attr("viewBox", [0, 0, width, 33])
      .attr("font-family", "sans-serif")
      .attr("font-size", 10)
      .style("display", "block");

  let text = svg.selectAll("text");

  return Object.assign(svg.node(), {
    update(letters) {
      text = text
        .data(letters)
        .join("text")
          .attr("x", (d, i) => i * 17)
          .attr("y", 17)
          .attr("dy", "0.35em")
          .text(d => d);
    }
  });
}

  

chart2.update(randomLetters)

  

在调用selection.data时,d3会计算三个集合enter, update和exit,针对这三个集合中的数据,能够定义不同的行为。这里使用selection.join,自动将enter部分和update部分合在一起并排序返回,由接下来的函数决定如何绘制。

当然,update对应的元素本身不需要重新绘制了,所以上面的函数还可以进一步化简:

chart3 = {
  const svg = d3.create("svg")
      .attr("viewBox", [0, 0, width, 33])
      .attr("font-family", "sans-serif")
      .attr("font-size", 10)
      .style("display", "block");

  let text = svg.selectAll("text");

  return Object.assign(svg.node(), {
    update(letters) {
      text = text
        .data(letters, d => d)
        .join(
          enter => enter.append("text")
            .attr("y", 17)
            .attr("dy", "0.35em")
            .text(d => d),
          update => update,
          exit => exit.remove()
        )
          .attr("x", (d, i) => i * 17);
    }
  });
}

  

此外,还可以进一步为enter和exit加上动画:

chart4 = {
  const svg = d3.create("svg")
      .attr("viewBox", [0, 0, width, 33])
      .attr("font-family", "sans-serif")
      .attr("font-size", 10)
      .style("display", "block");

  let text = svg.selectAll("text");

  return Object.assign(svg.node(), {
    update(letters) {
      const t = svg.transition().duration(750);

      text = text
        .data(letters, d => d)
        .join(
          enter => enter.append("text")
            .attr("y", -7)
            .attr("dy", "0.35em")
            .attr("x", (d, i) => i * 17)
            .text(d => d),
          update => update,
          exit => exit
            .call(text => text.transition(t).remove()
              .attr("y", 41))
        )
          .call(text => text.transition(t)
            .attr("y", 17)
            .attr("x", (d, i) => i * 17));
    }
  });
}

 

交互

Ben Shneiderman关于交互的格言如下:

1. 首先提供概述

2. 缩放+过滤

3. details on demand

第一个例子通过d3.pairs允许用户将鼠标移到某个位置停下的时候看到具体信息。

html`<svg viewBox="0 0 ${width} ${height}">
  <path d="${line(data)}" fill="none" stroke="steelblue" stroke-width="1.5" stroke-miterlimit="1"></path>
  <g fill="none" pointer-events="all">
    ${d3.pairs(data, (d, b) => svg`<rect x="${x(d.date)}" height="${height}" width="${x(b.date) - x(d.date)}">
      <title>${formatDate(d.date)}
${formatClose(d.close)}</title>
    </rect>`)}
  </g>
  ${d3.select(svg`<g>`).call(xAxis).node()}
  ${d3.select(svg`<g>`).call(yAxis).node()}
</svg>`

  

还可以使用voroni overlay来展示最近的数据点的tooltip。

但这样非常慢,因此可以自定义一个tooltip,再通过d3.pair改变这个tooltip的属性。

{
  const tooltip = new Tooltip();
  return html`<svg viewBox="0 0 ${width} ${height}">
  <path d="${line(data)}" fill="none" stroke="steelblue" stroke-width="1.5" stroke-miterlimit="1"></path>
  ${d3.select(svg`<g>`).call(xAxis).node()}
  ${d3.select(svg`<g>`).call(yAxis).node()}
  <g fill="none" pointer-events="all">
    ${d3.pairs(data, (a, b) => Object.assign(svg`<rect x="${x(a.date)}" height="${height}" width="${x(b.date) - x(a.date)}"></rect>`, {
    onmouseover: () => tooltip.show(a),
    onmouseout: () => tooltip.hide()
  }))}
  </g>
  ${tooltip.node}
</svg>`;
}

  

注意这里因为SVG不支持z-order,所以一定要最后来画tooltip

class Tooltip {
  constructor() {
    this._date = svg`<text y="-22"></text>`;
    this._close = svg`<text y="-12"></text>`;
    this.node = svg`<g pointer-events="none" display="none" font-family="sans-serif" font-size="10" text-anchor="middle">
  <rect x="-27" width="54" y="-30" height="20" fill="white"></rect>
  ${this._date}
  ${this._close}
  <circle r="2.5"></circle>
</g>`;
  }
  show(d) {
    this.node.removeAttribute("display");
    this.node.setAttribute("transform", `translate(${x(d.date)},${y(d.close)})`);
    this._date.textContent = formatDate(d.date);
    this._close.textContent = formatClose(d.close);
  }
  hide() {
    this.node.setAttribute("display", "none");
  }
}

  

这里再提供了一种d3 style来更改属性。

{
  const tooltip = new Tooltip();

  const svg = d3.create("svg")
      .attr("viewBox", [0, 0, width, height]);

  svg.append("path")
      .attr("fill", "none")
      .attr("stroke", "steelblue")
      .attr("stroke-width", 1.5)
      .attr("stroke-miterlimit", 1)
      .attr("d", line(data));

  svg.append("g")
      .call(xAxis);

  svg.append("g")
      .call(yAxis);

  svg.append("g")
      .attr("fill", "none")
      .attr("pointer-events", "all")
    .selectAll("rect")
    .data(d3.pairs(data))
    .join("rect")
      .attr("x", ([a, b]) => x(a.date))
      .attr("height", height)
      .attr("width", ([a, b]) => x(b.date) - x(a.date))
      .on("mouseover", (event, [a]) => tooltip.show(a))
      .on("mouseout", () => tooltip.hide());

  svg.append(() => tooltip.node);

  return svg.node();
}

  

这样做还是昂贵的,因为需要为每个可以鼠标悬停的地方分别建立一个元素来监视。所以,我们也可以不建立这些元素,而是反向推算对应的数据。

{
  const tooltip = new Tooltip();
  return Object.assign(html`<svg viewBox="0 0 ${width} ${height}">
  <path d="${line(data)}" fill="none" stroke="steelblue" stroke-width="1.5" stroke-miterlimit="1"></path>
  ${d3.select(svg`<g>`).call(xAxis).node()}
  ${d3.select(svg`<g>`).call(yAxis).node()}
  ${tooltip.node}
</svg>`, {
    onmousemove: event => tooltip.show(bisect(data, x.invert(event.offsetX))), 
    onmouseleave: () => tooltip.hide()
  });
}

  

这里使用二分方法检查对应的数据。

bisect = {
  const bisectDate = d3.bisector(d => d.date).left;
  return (data, date) => {
    const i = bisectDate(data, date, 1);
    const a = data[i - 1], b = data[i];
    return date - a.date > b.date - date ? b : a;
  };
}

 

posted @ 2020-11-06 20:23  雪溯  阅读(1399)  评论(0编辑  收藏  举报