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为例,其具体命令是:
{
  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; }; }
                    
                





                
            
        
浙公网安备 33010602011771号