创建和弦图
使用 d3创建和弦图有很多层,对应于 d3 将数据操作与数据可视化仔细分离。如果您不仅要创建和弦图,还要顺利更新它,您需要清楚地了解程序的每个部分做什么以及它们如何交互。
首先,数据操作方面。 d3 Chord Layout 工具获取有关不同组之间交互的数据,并创建一组数据对象,其中包含原始数据但也分配了角度测量值。这样,它类似于pie 布局工具,但有一些重要的区别与和弦布局的复杂性增加有关。
与其他 d3 布局工具一样,您通过调用函数 ( ) 创建和弦布局对象d3.layout.chord()
,然后在布局对象上调用其他方法来更改默认设置。然而,与饼图布局工具和大多数其他布局不同,弦布局对象不是一个将您的数据作为输入并输出具有布局属性(角度)设置的数据对象的计算数组的函数。
相反,您的数据是布局的另一个设置,您使用.matrix()
方法定义,并存储在布局对象中。数据必须存储在对象中,因为有两个不同的具有布局属性的数据对象数组,一个用于和弦(不同组之间的连接),另一个用于组本身。布局对象存储数据这一事实在处理更新时很重要,因为如果您仍然需要旧数据进行转换,您必须小心不要用新数据覆盖旧数据。
var chordLayout = d3.layout.chord() //create layout object
.sortChords( d3.ascending ) //set a property
.padding( 0.01 ); //property-setting methods can be chained
chordLayout.matrix( data ); //set the data matrix
.groups()
在设置数据矩阵后,通过调用和弦布局来访问组数据对象。每个组相当于数据矩阵中的一行(即数组数组中的每个子数组)。已为组数据对象分配了代表圆的一部分的开始角度和结束角度值。这很像一个饼图,不同之处在于每个组(以及整个圆圈)的值是通过对整行(子数组)的值求和来计算的。组数据对象还具有表示它们在原始矩阵中的索引的属性(很重要,因为它们可能按不同的顺序排序)和它们的总值。
.chords()
在设置数据矩阵后,通过调用和弦布局来访问和弦数据对象。每个弦代表数据矩阵中的两个值,相当于两组之间的两种可能关系。例如,在@latortue09 的示例中,关系是社区之间的自行车旅行,因此表示社区 A 和社区 B 之间的行程的和弦表示从 A 到 B 的行程次数以及从 B 到 A 的次数。如果社区 A在a
您的数据矩阵的行中并且 Neighborhood B 在 row 中b
,那么这些值应该分别位于data[a][b]
和data[b][a]
。(当然,有时您绘制的关系不会有这种类型的方向,在这种情况下,您的数据矩阵应该是对称的,这意味着这两个值应该相等。)
每个和弦数据对象都有两个属性,source
和target
,每个属性都是它自己的数据对象。源数据对象和目标数据对象都具有相同的结构,其中包含有关从一组到另一组的单向关系的信息,包括组的原始索引和该关系的值,以及表示一个部分的开始和结束角度组的圆的部分。
源/目标命名有点令人困惑,因为正如我上面提到的,和弦对象代表两个组之间关系的两个方向。具有较大值的方向决定了调用哪个组,调用哪个source
组target
。因此,如果从邻域 A 到邻域 B 有 200 次行程,但从 B 到 A 有 500 次行程,则该source
和弦对象target
的圆圈。对于组与自身之间的关系(在此示例中,在同一社区开始和结束的行程),源对象和目标对象是相同的。
和弦数据对象数组的最后一个重要方面是它只包含两个组之间存在关系的对象。如果 Neighborhood A 和 Neighborhood B 之间在任一方向上都没有行程,则这些组将没有和弦数据对象。当从一个数据集更新到另一个数据集时,这一点变得很重要。
第二,数据可视化方面。 和弦布局工具创建数据对象数组,将数据矩阵中的信息转换为圆的角度。但它没有画出任何东西。要创建和弦图的标准 SVG 表示,您可以使用d3 选择来创建连接到布局数据对象数组的元素。因为和弦图中有两种不同的布局数据对象数组,一种用于和弦,另一种用于组,因此有两种不同的 d3 选择。
在最简单的情况下,两个选择都将包含<path>
元素(并且两种类型的路径将按类进行区分)。连接到和弦图组的数据数组的<path>
s 成为围绕圆圈外部的弧,而<path>
连接到和弦本身的数据的 s 成为整个圆圈的带。
a 的形状<path>
由其"d"
(路径数据或方向)属性确定。D3 有多种路径数据生成器,它们是获取数据对象并创建可用于路径"d"
属性的字符串的函数。每个路径生成器都是通过调用 d3 方法创建的,并且可以通过调用它自己的方法来修改每个路径生成器。
标准和弦图中的组是使用d3.svg.arc()
路径数据生成器绘制的。此弧生成器与饼图和甜甜圈图使用的相同。毕竟,如果你从和弦图中删除和弦,你基本上只是有一个由群弧组成的圆环图。默认的弧生成器期望通过startAngle
和endAngle
属性传递数据对象;和弦布局创建的组数据对象使用此默认值。弧发生器还需要知道弧的内半径和外半径。这些可以指定为数据的函数或常量;对于和弦图,它们将是常数,对于每个弧都是相同的。
var arcFunction = d3.svg.arc() //create the arc path generator
//with default angle accessors
.innerRadius( radius )
.outerRadius( radius + bandWidth);
//set constant radius values
var groupPaths = d3.selectAll("path.group")
.data( chordLayout.groups() );
//join the selection to the appropriate data object array
//from the chord layout
groupPaths.enter().append("path") //create paths if this isn't an update
.attr("class", "group"); //set the class
/* also set any other attributes that are independent of the data */
groupPaths.attr("fill", groupColourFunction )
//set attributes that are functions of the data
.attr("d", arcFunction ); //create the shape
//d3 will pass the data object for each path to the arcFunction
//which will create the string for the path "d" attribute
和弦图中的和弦具有这种图表特有的形状。它们的形状是使用d3.svg.chord()
路径数据生成器定义的。默认的和弦生成器需要由和弦布局对象创建的表单数据,唯一需要指定的是圆的半径(通常与弧组的内半径相同)。
var chordFunction = d3.svg.chord() //create the chord path generator
//with default accessors
.radius( radius ); //set constant radius
var chordPaths = d3.selectAll("path.chord")
.data( chordLayout.chords() );
//join the selection to the appropriate data object array
//from the chord layout
chordPaths.enter().append("path") //create paths if this isn't an update
.attr("class", "chord"); //set the class
/* also set any other attributes that are independent of the data */
chordPaths.attr("fill", chordColourFunction )
//set attributes that are functions of the data
.attr("d", chordFunction ); //create the shape
//d3 will pass the data object for each path to the chordFunction
//which will create the string for the path "d" attribute
这是简单的情况,只有<path>
元素。如果您还希望将文本标签与您的组或和弦相关联,那么您的数据将连接到<g>
元素,以及标签的<path>
元素和<text>
元素(以及任何其他元素,如头发颜色示例中的刻度线)是继承其数据对象的子级。更新图表时,您需要更新所有受数据影响的子组件。
更新和弦图
考虑到所有这些信息,您应该如何创建可以用新数据更新的和弦图?
首先,为了尽量减少代码总量,我通常建议将您的更新方法作为初始化方法的两倍。是的,对于在更新中永远不会改变的事物,您仍然需要一些初始化步骤,但是对于实际绘制基于数据的形状,您应该只需要一个函数,无论这是更新还是新的可视化。
对于此示例,初始化步骤将包括创建<svg>
和居中<g>
元素,以及读取有关不同社区的信息数组。然后初始化方法将调用带有默认数据矩阵的更新方法。切换到不同数据矩阵的按钮将调用相同的方法。
/*** Initialize the visualization ***/
var g = d3.select("#chart_placeholder").append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("id", "circle")
.attr("transform",
"translate(" + width / 2 + "," + height / 2 + ")");
//the entire graphic will be drawn within this <g> element,
//so all coordinates will be relative to the center of the circle
g.append("circle")
.attr("r", outerRadius);
d3.csv("data/neighborhoods.csv", function(error, neighborhoodData) {
if (error) {alert("Error reading file: ", error.statusText); return; }
neighborhoods = neighborhoodData;
//store in variable accessible by other functions
updateChords(dataset);
//call the update method with the default dataset url
} ); //end of d3.csv function
/* example of an update trigger */
d3.select("#MenOnlyButton").on("click", function() {
updateChords( "/data/men_trips.json" );
disableButton(this);
});
我只是将一个数据 url 传递给更新函数,这意味着该函数的第一行将是一个数据解析函数调用。生成的数据矩阵用作新数据布局对象的矩阵。我们需要一个新的布局对象,以便为过渡功能保留旧布局的副本。(如果您不打算转换更改,您可以只matrix
在同一布局上调用该方法来创建新布局。)为了最大限度地减少代码重复,我使用一个函数来创建新布局对象并设置其所有选项:
/* Create OR update a chord layout from a data matrix */
function updateChords( datasetURL ) {
d3.json(datasetURL, function(error, matrix) {
if (error) {alert("Error reading file: ", error.statusText); return; }
/* Compute chord layout. */
layout = getDefaultLayout(); //create a new layout object
layout.matrix(matrix);
/* main part of update method goes here */
}); //end of d3.json
}
然后是更新或创建绘图功能的主要部分:您需要将所有方法链分解为四个部分,用于数据连接、输入、退出和更新。这样,您可以在更新期间使用与处理可视化的原始创建相同的代码来处理创建新元素(例如,在先前数据集中没有关系的组的新和弦)。
一是数据加入链。 一个用于组,一个用于和弦。
为了通过转换保持对象的恒定性——并减少你必须在更新时设置的图形属性的数量——你需要在你的数据连接中设置一个关键函数。默认情况下,d3 仅根据它们在页面/数组中的顺序将数据与选择中的元素匹配。因为我们的和弦布局的.chords()
数组不包括和弦,如果该数据集中存在零关系,则更新轮次之间和弦的顺序可能不一致。该.groups()
数组也可以重新排序为与原始数据矩阵不匹配的顺序,因此我们还添加了一个关键函数以确保安全。在这两种情况下,关键功能都基于.index
和弦布局存储在数据对象中的属性。
/* Create/update "group" elements */
var groupG = g.selectAll("g.group")
.data(layout.groups(), function (d) {
return d.index;
//use a key function in case the
//groups are sorted differently between updates
});
/* Create/update the chord paths */
var chordPaths = g.selectAll("path.chord")
.data(layout.chords(), chordKey );
//specify a key function to match chords
//between updates
/* Elsewhere, chordKey is defined as: */
function chordKey(data) {
return (data.source.index < data.target.index) ?
data.source.index + "-" + data.target.index:
data.target.index + "-" + data.source.index;
//create a key that will represent the relationship
//between these two groups *regardless*
//of which group is called 'source' and which 'target'
}
请注意,和弦是<path>
元素,但组是<g>
元素,其中将包含 a<path>
和 a <text>
。
在此步骤中创建的变量是数据连接选择;它们将包含与选择器匹配并匹配数据值的所有现有元素(如果有),并且它们将包含用于与现有元素不匹配的任何数据值的空指针。他们也有访问这些链的.enter()
和方法。.exit()
二是进入链。 对于与元素不匹配的所有数据对象(如果这是第一次绘制可视化,则为所有数据对象),我们需要创建元素及其子组件。此时,您还希望为所有元素(无论数据如何)设置任何属性,或者基于您在键函数中使用的数据值设置任何属性,因此不会在更新时更改。
var newGroups = groupG.enter().append("g")
.attr("class", "group");
//the enter selection is stored in a variable so we can
//enter the <path>, <text>, and <title> elements as well
//Create the title tooltip for the new groups
newGroups.append("title");
//create the arc paths and set the constant attributes
//(those based on the group index, not on the value)
newGroups.append("path")
.attr("id", function (d) {
return "group" + d.index;
//using d.index and not i to maintain consistency
//even if groups are sorted
})
.style("fill", function (d) {
return neighborhoods[d.index].color;
});
//create the group labels
newGroups.append("svg:text")
.attr("dy", ".35em")
.attr("color", "#fff")
.text(function (d) {
return neighborhoods[d.index].name;
});
//create the new chord paths
var newChords = chordPaths.enter()
.append("path")
.attr("class", "chord");
// Add title tooltip for each new chord.
newChords.append("title");
请注意,组弧的填充颜色是在输入时设置的,而不是和弦的填充颜色。这是因为和弦颜色会根据哪个组(和弦连接的两个组)被称为“源”和哪个是“目标”而改变,即取决于关系的哪个方向更强(有更多的行程)。
第三,更新链。 当您将元素附加到.enter()
选择时,该新元素将替换原始数据连接选择中的空占位符。之后,如果您操纵原始选择,则设置将应用于新元素和更新元素。因此,您可以在此处设置任何依赖于数据的属性。
//Update the (tooltip) title text based on the data
groupG.select("title")
.text(function(d, i) {
return numberWithCommas(d.value)
+ " trips started in "
+ neighborhoods[i].name;
});
//update the paths to match the layout
groupG.select("path")
.transition()
.duration(1500)
.attr("opacity", 0.5) //optional, just to observe the transition
.attrTween("d", arcTween( last_layout ) )
.transition().duration(10).attr("opacity", 1) //reset opacity
;
//position group labels to match layout
groupG.select("text")
.transition()
.duration(1500)
.attr("transform", function(d) {
d.angle = (d.startAngle + d.endAngle) / 2;
//store the midpoint angle in the data object
return "rotate(" + (d.angle * 180 / Math.PI - 90) + ")" +
" translate(" + (innerRadius + 26) + ")" +
(d.angle > Math.PI ? " rotate(180)" : " rotate(0)");
//include the rotate zero so that transforms can be interpolated
})
.attr("text-anchor", function (d) {
return d.angle > Math.PI ? "end" : "begin";
});
// Update all chord title texts
chordPaths.select("title")
.text(function(d) {
if (neighborhoods[d.target.index].name !==
neighborhoods[d.source.index].name) {
return [numberWithCommas(d.source.value),
" trips from ",
neighborhoods[d.source.index].name,
" to ",
neighborhoods[d.target.index].name,
"\n",
numberWithCommas(d.target.value),
" trips from ",
neighborhoods[d.target.index].name,
" to ",
neighborhoods[d.source.index].name
].join("");
//joining an array of many strings is faster than
//repeated calls to the '+' operator,
//and makes for neater code!
}
else { //source and target are the same
return numberWithCommas(d.source.value)
+ " trips started and ended in "
+ neighborhoods[d.source.index].name;
}
});
//update the path shape
chordPaths.transition()
.duration(1500)
.attr("opacity", 0.5) //optional, just to observe the transition
.style("fill", function (d) {
return neighborhoods[d.source.index].color;
})
.attrTween("d", chordTween(last_layout))
.transition().duration(10).attr("opacity", 1) //reset opacity
;
//add the mouseover/fade out behaviour to the groups
//this is reset on every update, so it will use the latest
//chordPaths selection
groupG.on("mouseover", function(d) {
chordPaths.classed("fade", function (p) {
//returns true if *neither* the source or target of the chord
//matches the group that has been moused-over
return ((p.source.index != d.index) && (p.target.index != d.index));
});
});
//the "unfade" is handled with CSS :hover class on g#circle
//you could also do it using a mouseout event on the g#circle
这些更改是使用d3 转换完成的,以创建从一个图表到另一个图表的平滑转换。对于路径形状的更改,使用自定义函数在保持整体形状的同时进行过渡。更多关于以下内容。
第四,exit() 链。 如果之前图表中的任何元素在新数据中不再有匹配项 - 例如,如果由于该数据中这两个组之间没有关系(例如,这两个社区之间没有旅行)而导致和弦不存在set - 然后您必须从可视化中删除该元素。您可以立即删除它们,以便它们消失以便为过渡数据腾出空间,或者您可以使用过渡将它们移出然后删除。(调用.remove()
转换选择将在转换完成时删除元素。)
您可以创建自定义过渡以使形状缩小为空,但我只是使用淡出到零不透明度:
//handle exiting groups, if any, and all their sub-components:
groupG.exit()
.transition()
.duration(1500)
.attr("opacity", 0)
.remove(); //remove after transitions are complete
//handle exiting paths:
chordPaths.exit().transition()
.duration(1500)
.attr("opacity", 0)
.remove();
关于自定义补间函数:
如果您只是使用默认补间从一种路径形状切换到另一种路径形状,结果可能看起来有点奇怪。尝试从“仅限男性”切换到“仅限女性”,您会看到和弦与圆的边缘断开连接。如果圆弧位置发生了更显着的变化,您会看到它们穿过圆圈到达新位置,而不是绕着圆环滑动。
这是因为从一种路径形状到另一种路径形状的默认过渡仅匹配路径上的点,并将每个点以直线从一个过渡到另一个。它适用于任何类型的形状而无需任何额外代码,但它不一定在整个过渡过程中保持该形状。
自定义补间函数可让您定义路径在过渡的每一步应如何成形。我已经在这里和这里写了关于补间函数的评论,所以我不会重复它。但简短的描述是,您传递给的补间函数.attrTween(attribute, tween)
必须是每个元素调用一次的函数,并且本身必须返回一个函数,该函数将在转换的每个“滴答”处调用以返回该点的属性值在过渡中。
为了获得路径形状的平滑过渡,我们使用两个路径数据生成器函数——弧生成器和弦生成器——在过渡的每一步创建路径数据。这样,圆弧将始终看起来像圆弧,而和弦将始终看起来像和弦。正在过渡的部分是开始和结束角度值。给定两个描述相同类型形状但具有不同角度值的不同数据对象,您可以使用d3.interpolateObject(a,b)
该函数创建一个函数,该函数将在过渡的每个阶段为您提供一个具有适当过渡角度属性的对象。因此,如果您有来自旧布局的数据对象和来自新布局的匹配数据对象,您可以平滑地将圆弧或弦从一个位置移动到另一个位置。
但是,如果您没有旧数据对象,您应该怎么做?要么是因为这个和弦在旧布局中没有匹配,要么是因为这是第一次绘制可视化并且没有旧布局。如果将空对象作为第一个参数传递给d3.interpolateObject
,则转换后的对象将始终是最终值。结合其他过渡,例如不透明度,这是可以接受的。但是,我决定进行过渡,使其以零宽度形状开始 - 即起始角度与结束角度匹配的形状 - 然后扩展为最终形状:
function chordTween(oldLayout) {
//this function will be called once per update cycle
//Create a key:value version of the old layout's chords array
//so we can easily find the matching chord
//(which may not have a matching index)
var oldChords = {};
if (oldLayout) {
oldLayout.chords().forEach( function(chordData) {
oldChords[ chordKey(chordData) ] = chordData;
});
}
return function (d, i) {
//this function will be called for each active chord
var tween;
var old = oldChords[ chordKey(d) ];
if (old) {
//old is not undefined, i.e.
//there is a matching old chord value
//check whether source and target have been switched:
if (d.source.index != old.source.index ){
//swap source and target to match the new data
old = {
source: old.target,
target: old.source
};
}
tween = d3.interpolate(old, d);
}
else {
//create a zero-width chord object
var emptyChord = {
source: { startAngle: d.source.startAngle,
endAngle: d.source.startAngle},
target: { startAngle: d.target.startAngle,
endAngle: d.target.startAngle}
};
tween = d3.interpolate( emptyChord, d );
}
return function (t) {
//this function calculates the intermediary shapes
return path(tween(t));
};
};
}
(查看 fiddle 的 arc tween 代码,稍微简单一些)
现场版:http: //jsfiddle.net/KjrGF/12/