龙空技术网

D3.js实战教程:12 网络可视化

启辰8 59

前言:

此刻朋友们对“js台词”大体比较珍视,同学们都想要了解一些“js台词”的相关资讯。那么小编也在网摘上网罗了一些关于“js台词””的相关知识,希望我们能喜欢,大家快快来学习一下吧!

本章涵盖操作节点和链接数据集。绘制邻接矩阵。创建弧形图。使用 d3 力布局运行模拟。施加定位、碰撞、定心、多体和连杆力。

网络分析和网络可视化是社交网络和大数据时代的标准配置。它们特别有趣,因为它们专注于事物之间的关系,并且比在更常见的数据可视化中看到的传统平面数据更准确地表示系统。虽然在第11章讨论的分层可视化中,一个节点可以有许多子节点,但只有一个父节点,但网络提供了多对多连接的可能性。

一般来说,在处理网络时,您将连接的事物(如人)称为节点,将它们之间的连接(例如在Twitter上关注某人)称为边缘链接。网络也可以被称为,因为它们在数学中就是这样称呼的。它们可以表示许多不同的数据结构,例如运输网络和链接的开放数据。

网络不仅仅是一种数据格式:它们是对数据的透视。处理网络数据时,通常会尝试发现和显示网络或部分网络的模式,而不是网络中的单个节点。通常,您会发现典型的信息可视化技术旨在展示网络结构而不是单个节点。

在本章中,我们将介绍四种常见的网络可视化形式:邻接矩阵、弧图、蜂群图和网络图,如图 12.1 所示。我们将使用 D3 的力导向布局创建最后两个可视化效果。

图 12.1 邻接矩阵、弧形图、蜂群图和网络图是可视化网络数据的四种常用方法。蜂群图侧重于节点,而邻接矩阵使用网格来表示这些节点之间的链接。弧形图和网络图同时显示节点及其连接。

12.1 准备网络数据

在本章中,我们将根据莎士比亚戏剧《罗密欧与朱丽叶》的数据构建一个项目。您可以在 看到此项目,并从本书的Github存储库()下载代码文件。

您可以在文件 romeo_and_juliet.json 中找到主数据集。在此 JSON 文件中,每个对象都是剧中的一句台词,并附有执行此台词的角色名称、行为和场景编号。表 12.1 显示了此数据集中的线条样本,特别是来自第 1 幕场景 3 和 5 的线条。

表12.1 剧目《罗密欧与朱丽叶》台词样本

动作-场景-线

选手

线

1.3.1

凯普莱特夫人

护士,我女儿在哪里?把她叫到我身边。

1.3.2

护士

现在,在我十二岁的处女头上,

1.3.3

护士

我叫她来。什么,羊肉!什么,瓢虫!

1.3.4

护士

上帝保佑!这个女孩在哪里?什么,朱丽叶!

1.3.5

朱丽叶·凯普莱特

现在怎么样!谁打电话?

1.5.115

罗密欧·蒙塔古

再给我一次我的罪。

1.5.116

朱丽叶·凯普莱特

你亲吻书。

1.5.117

护士

夫人,妈很想和你说一句话。

1.5.118

罗密欧·蒙塔古

她的母亲是什么?

要构建网络可视化,我们需要从原始数据集创建两个单独的数据文件:一个包含节点,另一个包含边。在我们的项目中,节点将是剧中的角色。每当两个角色在剧中共享至少一个场景时,他们之间就会有一个边缘或链接。此边缘的权重将是它们一起出现的场景数。

如果我们从表 12.1 中获取示例数据集,则节点数据文件可能类似于表 12.2。它包含有关每个节点的任何相关信息。在我们的例子中,这些是角色的名字,他们所属的房子,他们的描述,以及他们在剧中表演的台词数量。

表12.2 示例数据集节点列表

名字

房子

描述

行数

凯普莱特夫人

凯普莱特之家

凯普莱特家族的女族长。

115

护士

凯普莱特之家

朱丽叶的私人侍从和知己。

281

朱丽叶·凯普莱特

凯普莱特之家

该剧女主角凯普莱特的13岁女儿。

544

罗密欧·蒙塔古

蒙塔古之家

蒙塔古之子,是该剧的男主。

614

完整的节点列表已经在本章的代码文件中提供 nodes.json .我们将在构建项目时使用此信息。

我们需要的第二个文件是边缘列表。边缘列表有两个主列,分别名为列和目标列。顾名思义,它们代表每个链接的源和目标。图 12.2 显示了两种类型的网络边缘:有向和无向。有向边具有从源节点到目标节点的有意义的方向。箭头可以表示此方向。例如,你可能在Twitter上关注Lady Gaga,但Lady Gaga不一定关注你。在无向网络中,连接在两个方向上都有效。对于我们的罗密欧与朱丽叶项目,边缘是无向的。

图 12.2 网络边有源和目标。在有向网络中,连接的方向是有意义的,通常由箭头表示。在无向网络中,关系没有特定的方向。

表 12.3 显示了边缘列表如何查找示例数据集的示例。在这个小样本中,凯普莱特夫人和护士只共享一个场景(1.3),而护士和朱丽叶一起出现在两个场景中(1.3和1.5)。由于示例中的边缘是无向的,因此只要哪个字符出现在表中的同一行上,与源或目标相关联并不重要。然后我们使用权重属性来指示角色共享的场景数量。此权重稍后将影响两个节点在网络图中的接近程度。

表12.3 示例数据集的链接列表

目标

重量

凯普莱特夫人

护士

1

护士

朱丽叶·凯普莱特

2

罗密欧·蒙塔古

朱丽叶·凯普莱特

1

本章的代码文件 edges.json 中已经提供了完整的边缘列表。它包含在至少一个场景中一起出现的每对角色。权重属性指示它们并排出现的场景数。在文件edges.json中,这个数字代表两个角色在整个剧中共享的场景总数。我们将在以下部分中使用此信息来构建可视化效果。

12.2 创建邻接矩阵

您可能会惊讶于最有效的网络可视化之一根本没有连接线。相反,邻接矩阵使用网格来表示节点之间的连接。邻接矩阵的原理很简单:沿 x 轴放置节点,然后沿 y 轴再次放置它们。如果 x 轴上的节点连接到 y 轴上的节点,则会填充相应的网格正方形。否则,将其留空,如图 12.3 所示。因为我们的网络也是加权的,所以我们可以使用饱和度或不透明度来指定权重,浅色表示较弱的连接,深色表示较强的连接。

图 12.3 在邻接矩阵中,节点沿 x 轴放置,然后再次沿 y 轴放置。如果连接了两个节点,则填充相应的网格正方形。我们可以通过色彩饱和度或不透明度来传达这种联系的强度。

图 12.4 显示了我们将在本节中构建的邻接矩阵。请注意矩阵是如何对称的。这是因为我们的网络链接是无定向的。如果罗密欧与朱丽叶分享一个场景,那么朱丽叶也与罗密欧分享一个场景!由于字符无法连接到自身,因此沿矩阵对角线的空格为空白。

图12.4 罗密欧与朱丽叶的邻接矩阵。字符或节点沿 x 轴和 y 轴列出,而它们共享的场景数(边缘)则由填充的网格方块传达。正方形越暗,两个角色共享的场景就越多。无向网络的邻接矩阵是对称的,但它们也可以可视化有向网络,为此它们变得不对称。

使用 D3.js 构建邻接矩阵相对简单,因为此可视化仅由矩形和文本元素组成。在本节中,我们将解释如何操作网络数据(节点和链接数据文件)来实现此图表。我们还将使其具有交互性以提高其可读性。

若要开始本练习,请在代码编辑器中从 chapter_12/12.2-Adjacency_matrix 代码文件中打开 start 文件夹。这个项目是用 ES6 JavaScript 模块构建的,我们已经为你添加了相关的 D3 依赖项。您可以在 package.json 中看到它们。要安装这些依赖项,请在终端中运行命令 npm install。如第 11 章所述,您将需要一个捆绑器才能在本地运行项目。如果您已安装 Parcel,则可以在终端中输入命令 parcel src/index.html,该项目将在 上可用。否则,请回到第11章的开头以获取说明。

我们已经将文件nodes.json和edges.json加载到项目中。在 main.js 中,它们在常量节点和边下可用。仍然在main.js中,取消注释以下函数调用:

drawMatrix(nodes, edges);

然后,打开文件矩阵.js ,其中声明了此函数。由于 D3 没有邻接矩阵的布局,因此我们需要自己执行一些计算。在示例 12.1 中,我们首先按每个字符的行数降序对节点进行排序。这是我们要在矩阵上呈现它们的顺序。

我们的边数组由无向链接组成。我们在每个共享至少一个场景的角色对之间只有一个链接。但在邻接矩阵中,链接在两个方向上表示:从水平列出的节点到垂直列出的节点,反之亦然。为了提取所有必需的链接,我们遍历边缘数组。对于每个边,我们创建两个链接:一个从源到目标,一个从目标到源。我们将这些边缘的信息存储在一个名为 edgeHash 的对象中,该对象位于与其源名称和目标名称对应的键下。

最后,我们通过在节点中循环两次来创建所有可能的源-目标连接。我们计算相应网格正方形的左上角的位置,其中包含索引和正方形的尺寸,并将其保存在键 x 和 y .通过这样做,我们创建了自己的布局。然后,如果 edgeHash 包含相应的链接,我们将项目推送到矩阵数组。完成后,矩阵数组包含将在可视化中填充的每个正方形的一个项目。

示例 12.1 准备数据矩阵(矩阵.js)

export const drawMatrix = (nodes, edges) => {   nodes.sort((a, b) => b.totalLines - a.totalLines);  #A   const edgeHash = {};                            #B  edges.forEach(edge => {                         #B                                                  #B    const link1 = {                               #B      source: edge.source,                        #B      target: edge.target,                        #B      weight: edge.weight                         #B    };                                            #B    const id1 = `${edge.source}-${edge.target}`;  #B    edgeHash[id1] = link1;                        #B                                                  #B    const link2 = {                               #B      source: edge.target,                        #B      target: edge.source,                        #B      weight: edge.weight                         #B    };                                            #B    const id2 = `${edge.target}-${edge.source}`;  #B    edgeHash[id2] = link2;                        #B                                                  #B  });                                             #B   const matrix = [];                             const squareWidth = 16;                       const padding = 2;                            nodes.forEach((charA, i) => {                #C    nodes.forEach((charB, j) => {              #C      if (charA !== charB) {                   #C        const id = `${charA.id}-${charB.id}`;  #C        const item = {                         #C          id: id,                              #C          source: charA.id,                    #C          target: charB.id,                    #C          x: i * (squareWidth + padding),      #D          y: j * (squareWidth + padding)       #D        };                                              if (edgeHash[id]) {                      #E          item["weight"] = edgeHash[id].weight;  #E          matrix.push(item)                      #E        }                                        #E      }    });  }); }; 
练习:绘制邻接矩阵

现在我们已经预处理了数据,您已经掌握了绘制邻接矩阵所需的所有知识,如图 12.4 和托管项目 () 中的邻接矩阵。

1. 继续在矩阵.js 中工作,在函数 drawMatrix() 中。

2. 根据节点数量以及每个方块的大小和填充来计算图表的尺寸。

3. 在 div 中附加一个 SVG 元素,其中包含索引中已存在的矩阵 id.html .

4. 为矩阵数组中的每个项目附加一个矩形元素。使用 x 和 y 键设置方块的位置。将其填充属性设置为颜色“#364652”,并使其填充不透明度与链接的权重成正比(权重越大,不透明度越高)。

5. 在矩阵左侧为每个节点附加一个标签。

6. 在矩阵顶部为每个节点附加一个标签。使用变换属性旋转文本元素。

如果您在任何时候遇到困难或想将您的解决方案与我们的解决方案进行比较,您可以在附录 D 的 D.12 节和本章代码文件的文件夹 12.1-Adjacency_matrix / 末尾找到它。但是,像往常一样,我们鼓励您尝试自己完成它。您的解决方案可能与我们的略有不同,没关系!

完成后,邻接矩阵应类似于托管项目中的邻接矩阵。为了帮助用户解释正方形的不透明度,我们将创建一个图例。在示例 12.2 中,我们首先声明一个名为 weights 的数组,其中包含一个介于 1 和 7 之间的整数列表。这些对应于两个角色共享的可能权重或场景数量。

我们使用此权重数组来创建可能的权重的无序列表。每个列表项包含一个负责显示颜色的 div 和另一个负责显示标签的 div。我们已经在可视化中为这个图例编写了样式.css 。请确保使用与代码段中相同的类名,以使这些样式有效。

12.2 为正方形的不透明度添加图例(矩阵.js)

import { select, selectAll } from "d3-selection";import { max, range } from "d3-array";import { scaleLinear } from "d3-scale"; export const drawMatrix = (nodes, edges) => {   ...   const weights = range(1, maxWeight + 1);  #A   const legend = select(".matrix-legend")   #B    .append("ul")                           #B    .selectAll(".legend-color-item")        #B    .data(weights)                          #B    .join("li")                             #B      .attr("class", "legend-color-item");  #B  legend                                        #C    .append("div")                              #C      .attr("class", "legend-color")            #C      .style("background-color", "#364652")     #C      .style("opacity", d => opacityScale(d));  #C  legend                                    #D    .append("div")                          #D      .attr("class", "legend-color-label")  #D      .text(d => d);                        #D }); 

如果没有突出显示正方形的行和列的内容,邻接矩阵可能很难阅读。通过添加这样的交互性,如清单 12.3 所示,我们使可视化更易于阅读。

12.3 鼠标悬停时突出显示相关节点(矩阵.js)

...import { transition } from "d3-transition"; export const drawMatrix = (nodes, edges) => {   ...   selectAll(".grid-quare")    .on("mouseenter", (e, d) => {  #A       const t = transition()        .duration(150);       selectAll(".label-left")                                          #B        .transition(t)                                                  #B          .style("opacity", label => label.id === d.source ? 1 : 0.1);  #B                                                                        #B      selectAll(".label-top")                                           #B        .transition(t)                                                  #B          .style("opacity", label => label.id === d.target ? 1 : 0.1);  #B       const charA = nodes.find(char => char.id === d.source).name;  #C      const charB = nodes.find(char => char.id === d.target).name;  #C      select(".matrix-tooltip-charA").text(charA);                  #C      select(".matrix-tooltip-charB").text(charB);                  #C      select(".matrix-tooltip-scenes").text(d.weight);              #C      select(".matrix-tooltip").classed("hidden", false);           #C     })    .on("mouseleave", (e, d) => {  #D       selectAll(".label-top, .label-left")  #E        .style("opacity", 1);               #E       select(".matrix-tooltip").classed("hidden", true);  #F     }); });

您可以在图 12.5 中看到,将光标移动到矩阵中的正方形上如何突出显示相应的节点,并显示一个工具提示,其中包含角色共享的场景数量。

图 12.5 当光标位于邻接矩阵中的正方形上时,将突出显示相应字符的节点标签,并显示包含他们共享的场景数的工具提示(局部视图)。

12.3 绘制弧图

以图形方式表示网络的另一种方法是使用弧形图。弧图沿一条线排列节点,并将链接绘制为该线上方或下方的弧形。邻接矩阵侧重于边缘动态,而弧图侧重于节点动态,允许您查看哪些节点是隔离的,哪些节点具有许多连接。图 12.6 显示节点 A 连接到节点 B 和 C,节点 B 连接到节点 A 和 C,节点 C 连接到节点 A 和 B,节点 D 是隔离的。

图 12.6 在弧图上,节点沿一条线定位,链接显示为节点之间的弧。在此示例中,节点 A 连接到节点 B 和 C,节点 B 连接到节点 A 和 C,节点 C 连接到节点 A 和 B,节点 D 是隔离的。

在本节中,我们将构建如图 12.7 所示的弧图。字符节点由圆圈表示,并沿水平轴定位。节点的颜色代表它们所属的家族(黄色代表维罗纳统治家族,红色代表凯普莱特家族,绿色代表蒙塔古家族,紫色代表其他角色。圆圈的大小与他们在整个剧中说的台词数量成正比。在共享至少一个场景的角色之间绘制链接。共享场景的数量越多,链接越粗。

图12.7 弧形图,显示了《罗密欧与朱丽叶》中人物之间的联系。链接越粗,两个角色共享的场景就越多。

要开始使用,请转到main.js并取消注释函数调用drawArcDiagram(节点,边)。然后打开 arc.js .,我们将在函数 drawArcDiagram() 中工作。我们已经声明了图表的维度。

正如我们对邻接矩阵所做的那样,我们必须在绘制弧图之前预处理节点和边缘数据。在示例 12.4 中,我们创建了节点和边数组的深层副本。此副本将允许我们附加与弧图相关的信息,而不会影响原始数组。

我们通过用点刻度设置每个节点的水平位置来构建伪布局。我们还使边缘源和目标指向整个节点对象,而不仅仅是节点 ID。这个技巧将使我们能够稍后轻松访问边缘的开始和结束位置。

示例 12.4 预处理弧图数据 (arc.js)

import { scalePoint } from "d3-scale";import { houses } from "./helper"; export const drawArcDiagram = (nodes, edges) => {   ...   const nodeHash = {};  const arcNodes = JSON.parse(JSON.stringify(nodes));  #A  arcNodes.sort((a, b) => houses.find(h => h.house === a.house).order -  #B        ➥houses.find(h => h.house === b.house).order);                   #B   const xScale = scalePoint()               #C    .domain(arcNodes.map(node => node.id))  #C    .range([0, innerWidth]);                #C   arcNodes.forEach((node, i) => {      nodeHash[node.id] = node;      #D    node["x"] = xScale(node.id);   #E  });                                 const arcEdges = JSON.parse(JSON.stringify(edges));  #F  arcEdges.forEach(edge => {    edge.source = nodeHash[edge.source];  #G    edge.target = nodeHash[edge.target];  #G  }); };

完成数据预处理步骤后,我们可以附加组成弧形图的形状。为了绘制弧线,我们使用路径元素。在清单 12.5 中,我们将弧的源节点、计算的中间点和目标节点的坐标传递给 D3 的线生成器,以获取路径的 d 属性。我们还根据每个弧的粗细设置其笔触宽度属性。最后,我们调用已在比例中声明的色阶.js以设置字符节点的颜色。

清单 12.5 追加圆弧和节点 (arc.js)

...import { line, curveCardinal } from "d3-shape";import { max } from "d3-array";import { getRadius, colorScale } from "./scales"; export const drawArcDiagram = (nodes, edges) => {   ...   const svg = select("#arc")                                           #A    .append("svg")                                                     #A      .attr("viewBox", `0 0 ${width} ${height}`)                       #A    .append("g")                                                       #A      .attr("transform", `translate(${margin.left}, ${margin.top})`);  #A   const getArc = d => {                                     #B    const arcGenerator = line().curve(curveCardinal);       #B    const midX = (d.source.x + d.target.x) / 2;             #B    const midY = -Math.abs((d.source.x - d.target.x) / 6);  #B                                                            #B    const path = arcGenerator([                             #B      [d.source.x, 0],                                      #B      [midX, midY],                                         #B      [d.target.x, 0]                                       #B    ]);                                                     #B                                                            #B    return path;                                            #B  };                                                        #B   svg    .selectAll(".arc-link")                 #C    .data(arcEdges)                         #C    .join("path")                           #C      .attr("class", "arc-link")            #C      .attr("d", d => getArc(d))            #C      .attr("fill", "none")                 #C      .attr("stroke", "#364652")            #C      .attr("stroke-width", d => d.weight)  #C      .attr("stroke-opacity", 0.1)          #C      .attr("stroke-linecap", "round");     #C   const maxLines = max(arcNodes, d => d.totalLinesNumber);               #D  svg                                                                    #D    .selectAll(".arc-node")                                              #D    .data(arcNodes.sort((a, b) => b.totalLinesNumber-a.totalLinesNumber))#D    .join("circle")                                                      #D      .attr("class", "arc-node")                                         #D      .attr("cx", d => d.x)                                              #D      .attr("cy", 0)                                                     #D      .attr("r", d => getRadius(maxLines, d.totalLinesNumber))           #D      .attr("fill", d => colorScale(d.house))                            #D      .attr("stroke", d => "#FAFBFF")                                    #D      .attr("stroke-width", 2);                                          #D   svg                                                                #E    .selectAll(".arc-label")                                         #E    .data(arcNodes)                                                  #E    .join("text")                                                    #E      .attr("class", "arc-label")                                    #E      .attr("text-anchor", "end")                                    #E      .attr("dominant-baseline", "middle")                           #E      .attr("transform", d => `translate(${d.x}, 70), rotate(-70)`)  #E      .text(d => d.name)                                             #E      .style("font-size", "14px");                                   #E };

完成此步骤后,您的弧图将类似于托管项目 () 中的弧图。有了这样一个抽象的图表,交互性不再是可选的,以帮助确定谁连接到谁。例如,当鼠标位于节点上时,我们只能显示与该节点共享链接的字符以及相应的边缘。这就是我们在清单 12.6 中所做的。

示例 12.6 添加交互(弧.js)

...import { transition } from "d3-transition"; export const drawArcDiagram = (nodes, edges) => {   ...   selectAll(".arc-node")    .on("mouseenter", (e, d) => {  #A       const t = transition()        .duration(150);       const isLinked = char => {                                      #B        return arcEdges.find(edge =>                                  #B          (edge.source.id === d.id && edge.target.id === char.id) ||  #B          (edge.source.id === char.id && edge.target.id === d.id))    #B          ? true                                                      #B          : false;                                                    #B      };                                                              #B       selectAll(".arc-link")                                             #C         .transition(t)                                                   #C         .attr("stroke-opacity", link =>                                  #C           link.source.id === d.id || link.target.id === d.id             #C           ? 0.1                                                          #C           : 0);                                                          #C                                                                          #C       selectAll(".arc-node")                                             #C         .transition(t)                                                   #C         .attr("fill-opacity", char => char.id === d.id || isLinked(char) #C           ? 1                                                            #C           : 0 )                                                          #C         .attr("stroke-opacity", char =>                                  #C           char.id === d.id || isLinked(char)                             #C             ? 1                                                            #C           : 0 );                                                         #C                                                                          #C       selectAll(".arc-label")                                            #C         .transition(t)                                                   #C         .style("opacity", char => char.id === d.id || isLinked(char)     #C           ? 1                                                            #C           : 0 )                                                          #C         .style("font-weight", char => char.id === d.id                   #C           ? 700                                                          #C           : 400);                                                        #C      })    .on("mouseleave", (e, d) => {  #D       selectAll(".arc-link")           #E        .attr("stroke-opacity", 0.1);  #E                                       #E      selectAll(".arc-node")           #E        .attr("fill-opacity", 1)       #E        .attr("stroke-opacity", 1);    #E                                       #E      selectAll(".arc-label")          #E        .style("opacity", 1)           #E        .style("font-weight", 400);    #E     }); });

在图 12.8 中,我们看到了交互如何揭示节点的动态。我们可以得出结论,罗密欧与剧中几乎每个角色都至少共享一个场景,而约翰修士只与劳伦斯修士共享一个场景。

图 12.8 交互有助于揭示节点的动态。我们观察到,罗密欧几乎与剧中的每个角色共享场景,而约翰修士仅与劳伦斯修士共享舞台。

12.4 玩力

强制布局的名称继承自它确定网络最佳图形表示的方法。它会动态更新其元素的位置以找到最合适的元素。与前面讨论的 D3 布局不同,强制布局是实时执行的,而不是作为渲染前的预处理步骤。

力布局背后的原理是定位、碰撞、定心、多体和连杆力之间的相互作用。通过混合和匹配这些力,我们可以将节点吸引到特定位置,确保它们不重叠,使节点相互排斥等。在本节中,我们将研究这些不同的力,看看它们如何影响可视化。我们还将构建蜂群图和网络图。

12.4.1 运行力模拟

为了运行 D3 模拟,我们从模块 d3-force () 调用方法 d3.forceSimulation()。根据施加到模拟中的力,D3将两种类型的信息附加到每个节点:它们的下一个位置(x和y)和下一个速度(vx和vy)。每次 D3 完成名为 tick 的模拟迭代时,我们都会调用一个自定义函数,在该函数中,我们根据 D3 附加的数据更改圆的位置。我们执行此循环,直到模拟收敛,这意味着节点根据约束力找到了最佳位置。

图 12.9 在运行力布局模拟时,D3 会进行一系列实时刻度或迭代,根据绑定力计算每个节点的位置和速度。然后,我们可以在即时报价的回调函数中更新节点的位置。一旦找到每个节点的最佳位置,模拟就会停止。

要设置您的第一个模拟,请转到 main.js 并取消注释函数调用 drawBeeswarm(节点)。然后,打开蜂群.js .我们将在函数 drawBeeswarm() 中工作,我们已经在其中声明了图表的维度。

在清单 12.7 中,我们将一个 SVG 容器附加到 DOM 中,并为每个节点附加一个圆圈元素。请注意,我们不需要设置圆圈的 cx 和 cy 属性。我们将在模拟期间处理此问题。

然后我们声明一个名为 updateNetwork() 的函数,我们在其中选择所有节点并根据模拟附加的 x 和 y 属性设置它们的位置。为了运行模拟,我们调用 D3 方法 forceSimulation()。我们设置 nodes() 访问器函数并在每次报价后调用 updateNetwork()。

示例 12.7 运行力模拟 (beeswarm.js)

import { select, selectAll } from "d3-selection";import { forceSimulation } from "d3-force";import { colorScale } from "./scales"; export const drawBeeswarm = (nodes) => {  ...  const svg = select("#beeswarm")                                #A    .append("svg")                                               #A      .attr("viewBox", `0 0 ${width} ${height}`)                 #A    .append("g")                                                 #A      .attr("transform", `translate(${width/2}, ${height/2})`);  #A   svg                                          #B    .selectAll(".beeswarm-circle")             #B    .data(nodes)                               #B    .join("circle")                            #B      .attr("class", "beeswarm-circle")        #B      .attr("r", 8)                            #B      .attr("fill", d => colorScale(d.house))  #B      .attr("stroke", "#FAFBFF")               #B      .attr("stroke-width", 1);                #B   const updateNetwork = () => {    #C    selectAll(".beeswarm-circle")  #C      .attr("cx", d => d.x)        #C      .attr("cy", d => d.y);       #C  };                               #C   const simulation = forceSimulation()  #D    .nodes(nodes)                       #D    .on("tick", updateNetwork);         #D };

如果保存此代码并在浏览器中查看,您将看到节点很好地聚集在 SVG 容器的中间,如图 12.10 所示。在清单 12.7 中,我们为此目的将内部图表转换为 SVG 容器的中心。在以下小节中,我们将对模拟施加不同的力,并查看它们如何影响节点的位置。

图 12.10 如果我们不对模拟施加任何力,则节点形成一个没有重叠的集群。

定位力

我们将讨论的第一个力是定位力 d3.forceX() 和 d3.forceY() 。顾名思义,forceX() 使节点向特定的水平位置移动,而 forceY() 对垂直位置执行相同的操作。图 12.11 显示,应用 forceX(0) 将节点的 x 位置设置为零,并让它们沿 y 轴松散地找到一个位置。forceY(0) 恰恰相反;它将节点的 y 位置设置为零,并沿 x 轴松散地放置它们。如果我们同时应用 forceX(0) 和 forceY(0),则所有节点在 (0, 0) 处相互堆叠。

图 12.11 d3.forceX() 使节点向特定的水平位置移动,而 d3.forceY() 对垂直位置执行相同的操作。

我们还可以通过 strength() 访问器函数控制力的强度。如果未指定,则力的强度默认为 1。假设我们同时施加 x 和 y 定位力,同时将 x 力的强度降低到 0.01。在这种情况下,对坐标 (0,0) 的吸引力在垂直方向上将比在水平方向上更强。图 12.12 显示,这会导致节点松散地放置在 x 轴上。如果我们做相反的事情,将 y 力施加的强度设置为 0.01,则节点对 y 位置的吸引力为零。请注意,我们可以将强度访问器函数与任何 D3 力一起使用。

图 12.12 我们可以使用 strength() 访问器函数控制施加力的强度。默认情况下,此强度设置为 1。如果我们同时施加 x 和 y 力,但削弱 x 力,则垂直方向对坐标 (0,0) 的吸引力将比水平方向更强。如果我们反其道而行之,水平方向的吸引力将比垂直方向更强。

还有另一个定位力,d3.forceRadial(),我们不会在这里介绍,但这是你想要用来在圆周上定位节点的力。

我们还可以使用定位力来创建集群。例如,在图 12.13 中,我们调用 D3 点刻度,它根据角色的房子返回节点的水平或垂直位置。通过将此值传递给定位力,我们为每个房屋创建一个不同的聚类或分组。

图 12.13 我们可以使用定位力来创建集群。通过将点刻度返回的值传递给 forceX() 或 forceY(),我们为每个房子创建一个不同的组。

碰撞力

为了防止节点重叠,我们可以使用碰撞力 d3.forceCollide() 。此力采用给定的半径来设置节点之间的最小距离。在图 12.14 所示的示例中,每个节点的半径为 10px,传递给 radius() 访问器函数的值为 12。这意味着碰撞力将确保两个节点的中心之间始终至少有 12px,从而创建 2px 的填充。

如果我们不设置任何定位力,节点自然会组合成圆形。如果我们在碰撞力之外施加力X()或forceY(),我们会得到一个类似蜂群的形状。

图 12.14 碰撞力 d3.forceCollide() 防止节点重叠。通过设置其 radius() 访问器函数,我们在每个节点的中心之间施加最小距离。没有定位力,节点自然聚集成一个圆圈。如果我们将碰撞力与定位力相结合,我们将获得类似于蜂群图的扁平形状。

让我们花点时间探索一下我们创建蜂群可视化的新功能。蜂群图在显示分布的同时保持单个点很受欢迎。出于本演示的目的,我们将创建 300 个节点,并使用方法 d3.randomNormal() 将它们的值属性设置为服从正态分布的随机数,也称为高斯分布。

const sampleArray = range(300);sampleNodes = [];sampleArray.forEach(() => {  const randomNumberGenerator = randomNormal();  sampleNodes.push({ value: randomNumberGenerator() * 10 });});

假设我们将这些节点附加到 SVG 容器中,并使用 forceX() 运行力模拟,将节点定位到与其值(最后一个代码片段中设置的值属性)相对应的水平位置。在这种情况下,forceY() 将节点向垂直中心移动,碰撞力确保没有重叠。我们最终得到了一个蜂群图!

forceSimulation()  .force("x", forceX(d => xScale(d.value)) )  .force("y", forceY(0) )  .force("collide", forceCollide().radius(7) )  .nodes(sampleNodes)  .on("tick", updateNetwork);

在图 12.15 上,我们观察到我们确实得到了正态分布。尽管每个节点的水平位置应该非常接近与其值属性对应的位置,但很高兴知道 d3 会尝试找到平衡模拟中施加的所有力的位置。最终的水平位置可能与 value 属性不完全对应。

图 12.15 表示正态分布值的蜂群图。

定心力

定心力 d3.forceCenter() 将整个节点集群移动到特定位置。虽然 x 和 y 定位力单独移动粒子并可能使粒子系统变平,但中心力会平移整个粒子系统,从而保持其原始形状。在图 12.16 中,我们首先对一组节点施加碰撞力。默认情况下,节点将以坐标 (0,0) 为中心。如果我们使用 d3.forceCenter() 施加定心力,并设置我们希望系统使用 x() 和 y() 访问器函数移动的坐标,则节点将完全移动到定义的坐标。

图 12.16 中心力将整个粒子系统或节点向传递给 x() 和 y() 访问器函数的坐标移动。

多体力

到目前为止,我们只讨论了吸引节点走向某个位置的力。多体力 d3.forceManyBody() 的不同之处在于它影响节点之间的交互方式。负多体力模拟排斥力,而正多体力模拟吸引力,如图12.17所示。

图 12.17 多体力影响节点之间的相互作用。负的多体力会产生排斥力,而正的多体力则模仿吸引力。

为了创建罗密欧与朱丽叶角色的蜂群般的可视化,如托管项目 () 中显示的可视化,我们根据相关角色所说的行数缩放节点的半径。为此,我们调用函数 getRadius() ,已经在 scales 中可用.js 。我们还将每个节点的半径存储在绑定数据中。我们稍后将使用它来设置节点之间的碰撞力。请注意我们如何制作节点数组的深层副本并在模拟中使用它。这将防止我们在从相同的数据(蜂群和网络图)运行两个力模拟时遇到麻烦。

const beeswarmNodes = JSON.parse(JSON.stringify(nodes)); const maxLines = max(nodes, d => d.totalLinesNumber)svg  .selectAll(".beeswarm-circle")  .data(beeswarmNodes)  .join("circle")    .attr("class", "beeswarm-circle")    .attr("r", d => {      d["radius"] = getRadius(maxLines, d.totalLinesNumber);      return d.radius;    })    .attr("fill", d => colorScale(d.house))    .attr("stroke", "#FAFBFF")    .attr("stroke-width", 1);

然后,我们对模拟施加 y 定位力,将节点吸引到 SVG 容器的垂直中心。我们还使用碰撞力来避免重叠。最后,我们将 radius 访问器函数设置为我们附加到节点数据的 radius 属性加上 2px。

const simulation = forceSimulation()  .force("y", forceY(0) )  .force("collide", forceCollide().radius(d => d.radius + 2) )  .nodes(beeswarmNodes)  .on("tick", updateNetwork);

最终的蜂群图如图 12.18 所示。

图12.18 《罗密欧与朱丽叶》中人物的蜂群状图表。角色的颜色对应于他们的房子,他们的大小对应于他们在剧中的台词数量。

d3-force模块中可用的最后一个力是链接力d3.forceLink(),它在连接的节点之间施加力。两个节点连接得越强,链接力将它们拉在一起就越近。通过链接力,结合前面讨论的其他力,我们可以创建一个网络图。

仍然使用我们的罗密欧与朱丽叶数据集,我们将创建一个网络图,其中链接表示两个角色共享的场景数量。我们可以转到main.js并取消注释函数调用drawNetwork(节点,边缘)。然后,我们打开文件网络.js并开始在函数 drawNetwork() 中工作。

在下面的代码片段中,我们使用 id() 访问器函数让 D3 知道如何识别源节点和目标节点。请注意,用于标识节点的 id 必须是唯一的。我们还使用 strength() 访问器函数来指定链接在节点上施加的力与其权重属性(两个角色共享的场景数)成正比。

示例 12.8 中的代码类似于上一节中用于创建类似蜂群图表的代码。主要区别在于,除了节点圆之外,我们还为每个边附加了一个线元素。然后,在每次报价后调用的函数 updateNetwork() 不仅更新节点的位置,还更新链接的位置。

我们将链接力与将可视化移动到 SVG 容器中心的中心力、在节点之间产生排斥力以使它们之间保持一定距离的强负多体力以及避免重叠的碰撞力相结合。

示例 12.8 创建网络图 (network.js)

import { select, selectAll } from "d3-selection";import { max } from "d3-array";import { forceSimulation, forceCollide, forceCenter, forceManyBody,          forceLink } from "d3-force";import { colorScale, getRadius } from "./scales"; export const drawNetwork = (nodes, edges) => {   const width = 850;  const height = 600;   const svg = select("#network")    .append("svg")      .attr("viewBox", `0 0 ${width} ${height}`)    .append("g")      .attr("transform", `translate(${width/2}, ${height/2})`);   svg                                        #A    .selectAll(".network-link")              #A    .data(edges)                             #A    .join("line")                            #A      .attr("class", "network-link")         #A      .attr("stroke", "#364652")             #A      .attr("stroke-opacity", 0.1)           #A      .attr("stroke-width", d => d.weight);  #A   const maxLines = max(nodes, d => d.totalLinesNumber)  svg    .selectAll(".network-node")    .data(nodes)    .join("circle")      .attr("class", "network-node")      .attr("r", d => {        d["radius"] = getRadius(maxLines, d.totalLinesNumber);        return d.radius;      })      .attr("fill", d => colorScale(d.house))      .attr("stroke", "#FAFBFF")      .attr("stroke-width", 1);   const updateNetwork = () => {    selectAll(".network-link")       #B      .attr("x1", d => d.source.x)   #B      .attr("y1", d => d.source.y)   #B      .attr("x2", d => d.target.x)   #B      .attr("y2", d => d.target.y);  #B     selectAll(".network-node")      .attr("cx", d => d.x)      .attr("cy", d => d.y);  };   const simulation = forceSimulation()    .force("charge", forceManyBody().strength(-1000))                    #C    .force("collide", forceCollide().radius(d => d.radius + 2) )         #C    .force("center", forceCenter().x(0).y(0))                            #C    .force("link", forceLink().id(d => d.id).strength(d => d.weight/10)) #C    .nodes(nodes)    .on("tick", updateNetwork);   simulation        #D    .force("link")  #D    .links(edges);  #D };
自定义边界力

如果您在浏览器中查看网络,您会注意到代表合唱的节点无处可见。这是因为这个节点没有链接将其附加到另一个节点(合唱有自己的场景,不与其他角色共享舞台),并且多体力施加强烈的排斥力。如果使用检查器工具查找合唱节点,则会看到它在 SVG 容器外部结束。我们将创建自定义边界力以将其带回内部。因为 D3 力只是回调函数,所以没有什么能阻止我们编写自己的函数!

在下面的代码片段中,我们编写了一个名为“bounding”的自定义力。在回调函数中,我们遍历每个节点,如果节点当前在 SVG 容器之外,我们更改其速度( vx 或 vy ),以便将其重定向到 SVG 容器。

const simulation = forceSimulation()  ...  .force("bounding", () => {    nodes.forEach(node => {      if (node.x < -width/2 + node.radius) {        node.vx = 1;      }      if (node.y < -height/2 + node.radius) {        node.vy = 1;      }      if (node.x > width/2 - node.radius) {        node.vx = -1;      }      if (node.y > height/2 - node.radius) {        node.vy = -1;      }    });  })

图 12.19 显示了最终的网络可视化。主要角色(较大的圆圈)彼此靠近,因为他们共享多个场景。次要字符(较小的圆圈)位于可视化效果的外围,因为将它们附加到其他字符的链接较弱。

图 12.19 罗密欧与朱丽叶角色的网络可视化。每个圆圈代表一个角色,它们的大小与他们在剧中的台词数量成正比。节点之间的链接表示它们共享的场景数。两个节点之间的距离越近,它们在剧中共享的场景就越多。

为了完成此项目,我们将添加一个交互以突出显示鼠标悬停时的关系。该技术类似于之前用于使弧图具有交互性的技术。当鼠标位于节点上时,我们将所有节点和链接的不透明度设置为零,但与所选节点相关的节点和链接除外。我们还在可视化效果右侧的工具提示中显示角色的名称和描述。

12.9 向网络图添加交互性(network.js)

export const drawNetwork = (nodes, edges) => {   ...   selectAll(".network-node")    .on("mouseenter", (e, d) => {       const t = transition()        .duration(150);       const isLinked = char => {        return edges.find(edge =>          (edge.source.id === d.id && edge.target.id === char.id) ||          (edge.source.id === char.id && edge.target.id === d.id))          ? true          : false;      };       selectAll(".network-link")        .transition(t)        .attr("stroke-opacity", link =>           link.source.id === d.id || link.target.id === d.id ? 0.1 : 0);       selectAll(".network-node")        .transition(t)        .attr("fill-opacity", char =>           char.id === d.id || isLinked(char) ? 1 : 0 );       select(".network-character")    #A        .text(d.name);                #A      select(".network-description")  #A        .text(d.description);         #A      select(".network-sidebar")      #A        .classed("hidden", false);    #A     })    .on("mouseleave", () => {       selectAll(".network-link")        .attr("stroke-opacity", 0.1);       selectAll(".network-node")        .attr("fill-opacity", 1);       select(".network-sidebar")   #B        .classed("hidden", true);  #B     }); });

当鼠标位于节点上时,仅显示与该节点共享至少一个场景的角色,如图 12.20 所示。

图 12.20 当光标位于节点上时,仅显示共享至少一个场景的字符及其相关链接。

停止和重新启动部队布局

力布局旨在“冷却”,并在网络布局足够好以至于节点不再移动到新位置后最终停止。当布局像这样停止时,如果您希望它再次动画化,则需要重新启动它。此外,如果您对强制设置进行了任何更改,或者想要添加或删除部分网络,则需要停止并重新启动它。

您可以使用 simulation.stop() 停止模拟。当与网页上其他位置的组件进行交互或网络样式发生更改时,最好停止网络,然后在交互结束后重新启动网络。

要开始或重新启动布局动画,请使用 simulation.restart() 。首次创建模拟时不必启动模拟;它会自动启动。

最后,如果你想一步地向前移动布局,你可以使用 simulation.tick() 。如果您不需要花哨的动画,您还可以预先计算图表。例如,您可以运行 simulation.tick(120) 在布局可视化之前执行 120 次迭代。模拟网络而不以图形方式对其进行动画处理要快得多,并且可以使用 D3 过渡将节点移动到其最终预计算位置的动画。

优化

部队布局是高度资源密集型的。这就是为什么它冷却并停止按设计运行的原因。而且,如果您有一个使用强制布局运行的大型网络,则可以对用户的计算机征税,直到它实际上无法使用为止。优化的第一个技巧是限制网络中节点和边的数量。一般的经验法则是不超过 1000 个节点。尽管如此,该限制曾经是 100,并且随着浏览器性能的提高而变得更高,因此请使用分析并了解您的受众可能使用的浏览器的最低性能。

但是如果你必须呈现更多的节点,并且想要提高性能,你可以在计算每个节点的排斥电荷时,使用 forceManyBody.chargeDistance() 来设置最大距离。此设置越低,部队布局的结构化程度就越低,但运行速度越快。由于网络变化很大,因此您必须尝试不同的 chargeDistance() 值才能找到最适合您的网络的值。

最后,如果与用户没有预期的交互,请考虑将强制布局计算委托给 Web 工作人员或使用画布呈现图形的某些部分。我们将在第 15 章讨论如何使用 canvas。

12.5 小结有许多方法可以表示网络,例如使用邻接矩阵、弧形图和力导向图。确保使用适合您的网络结构和受众的方法。要构建网络可视化,我们需要从原始数据集创建两个单独的数据文件:一个包含节点,另一个包含边。邻接矩阵侧重于节点之间的关系,而弧形图则强调节点的动态。力布局 d3.forceSimulation() 动态更新其元素的位置以找到最佳拟合。为了控制力布局的布局,我们使用定位、碰撞、居中、多体和连杆力的组合。定位力使节点向特定位置移动。它们还可用于创建节点群集。我们使用碰撞力来设置节点之间的最小距离并避免重叠。定心力将整个节点簇移动到特定位置。多体力影响节点之间的相互作用。负多体力模拟排斥力,而正多体力模拟吸引力。链接力在连接的节点之间施加力。两个节点连接得越强,链接力将它们拉在一起就越近。我们还可以编写自定义力,例如,防止节点扩散到特定区域之外。强制布局是高度资源密集型的,每当它可能对用户的计算机造成负担时,您都需要使用优化技术。

标签: #js台词 #d3 力导向图