Kibana 6.2 中使用 Vega 进行的 Sankey 可视化
继续我们关于在 Kibana 中构建自定义 Vega 图表的系列文章,今天的主题是一个简单的两级 Sankey 图表,用于展示网络流量模式。(上次,我们讨论了 Kibana 中的自定义 Vega 可视化。)Sankey 图是一种流程图,其线条宽度与流量大小成正比。示例数据中的每个条目都包含来源国和目标国代码。该图表将有两种模式:全对全(默认),此外它还允许用户选择来源国或目标国,并仅显示相关的网络流量。


准备工作
- 下载 Elasticsearch 6.2 或更高版本
- 解压并运行
bin/elasticsearch - 下载 Kibana 6.2 或更高版本(相同版本与 Elasticsearch 相同)
- 解压缩并运行
bin/kibana - 使用 makelogs 实用程序 生成示例数据。使用
npm install -g makelogs进行安装,并使用makelogs运行。您可能需要使用-c 100k参数生成更大的数据集。这假设您已经安装了 NPM。请勿在生产集群上执行此操作! - 导航至 http://localhost:5601/
- 单击 Management 选项卡(左侧最后一个图标),创建新的索引模式:在索引模式中输入
logstash-*,单击 Next step,为时间过滤器选择@timestamp,然后单击 Create index pattern。 - 通过使用 Discover 选项卡、将右上角的时间筛选器设置为最近 1 小时并 Observe 随机生成的数据,来测试一切是否正常工作。
数据
在此示例中,我将使用 geo.src(作为第一个堆栈)和 geo.dest(作为第二个堆栈)来自 makelogs 实用程序 生成的随机数据的字段。这些字段代表流量的源和目标。此查询将使用 Elasticsearch 复合聚合 对这两个字段进行聚合,计算每种组合的文档数量,并返回前 10 个。
GET Logstash-*/_搜索
{
"size":0,
"aggs": {
"table": {
"composite": {
"size": 10,
"sources": [
{"stk1": {"terms": {"字段": "geo.src"}}},
{"stk2": {"terms": {"字段": "geo.dest"}}}
]
}
}
}
}
尝试在 开发工具 (Dev Tools) 选项卡中运行此查询——您应该会看到类似如下内容:
{
// ... 跳过一些元数据信息 ...
"aggregations": {
"table": {
"buckets": [
{
"key": {
"stk1": "AE",
"stk2": "AR"
},
"doc_count": 1
},
{
"key": {
"stk1": "AE",
"stk2": "BD"
},
"doc_count": 2
},
// ……还有 8 个对象
]
}
}
可视化策略
我们可以将桑基图表 (Sankey 图表) 看作是组织成堆栈的 Node,以及连接这些 Node 的边。对于此图表,我们只有两个堆栈:源和目标。这两个堆栈被绘制为垂直条,屏幕两侧各一个。每个堆栈都有一个叠一个的国家。每个国家都由 Node 组成——源国家中与目标国家中的 Node 相连的部分。

我们需要有几个数据表来绘制上述图表:
- Node 此表不会直接用于绘图,但将用作其他表的数据源。我们可以将上图中的 Node 可视化为黑匣子——这里我们有 12 个节点。每个Node由其代表的文档计数、堆叠 ID、国家/地区代码以及在堆栈中的位置(
y0到y1)组成。请注意,此表没有任何“屏幕”坐标,只有源数据。即使是堆栈位置也是以文档计数来表示的。第一个 Node 将为0..n1-1,第二个节点将为n1..n2-1,依此类推。此外,由于我们有两个堆栈,因此会有两个第一、第二……Node,每个堆栈对应一个。 - edges(边)一系列线条(本例中为 6 条),每条线条连接左侧和右侧的一个 Node。该线条需要一对 (x, y) 坐标、线条粗细 (strikeWidth) 和线条颜色(与源Node相同)。此表是通过从 Node 表中获取所有
stack=="stk1"行,并查找目标堆栈对应的 Node 而生成的。linkpath 转换会生成描述曲线的 SVG 路径字符串。 - groups 每个组都结合了同一国家/地区、同一堆栈的所有 Node。在此图表中,我们有 6 个组,每侧 3 个。每个组都需要有一个堆栈 ID(stk1 或 stk2)、起始和结束 Y 值(y0 和 y1,类似于 Node 表)以及国家/地区代码。此表也是根据 Node 表生成的,按堆栈 ID 和国家/地区代码进行分组,并以类似于 Node 的方式进行堆叠。Y 值将与 Node 表对齐,因为我们是根据相同的值对两个表进行排序的。
除了表格之外,我还使用三个扩展将 "数据坐标空间" 投影到 "屏幕坐标空间" 中。“x” 扩展决定了 stk1 和 stk2 堆栈在屏幕上的位置。“y” 扩展将图表高度地图到最高堆栈中所有 Node 的总高度(在本例中,两个堆栈具有相同的高度)。最后,“color” 扩展为每个国家/地区分配一种颜色。
最后一步是使用三个标记将数据表转换为可视化效果:用于边的 path 标记、用于堆叠的 rect 标记以及用于国家/地区标签的 text 标记。
交互
此时您将看到预期的图表,但通常您可能需要定义用户如何与该图表进行交互。这是通过信号(即根据事件改变其值并可在各种表达式中使用的动态变量)来实现的。
当用户在源堆栈中单击某个国家/地区组时,图表需要隐藏所有非所单击国家/地区的数据。同样,在单击目标侧时也应实现此功能。这可以通过定义 groupSelector 信号来实现。当单击 groupMark 时,信号会被设置为作为源或目标的国家/地区代码。Node 表会根据信号值自动进行筛选,并且图表会重新绘制,就好像不存在其他数据一样。为了返回,我在中间显示一个带有文本标记的矩形框。单击该框会重置信号,从而将 Node 表恢复为完整列表。
另一种交互是堆叠中各国家/地区的“鼠标悬停”。在 groupMark 鼠标悬停事件中,groupHover 信号的设置方式与 groupSelector 类似。该信号用于控制边线的透明度。
故障排查与探索
理解 Vega 代码有时会具有挑战性。幸运的是,您可以随时使用浏览器调试工具访问 Vega 的内部状态。在 Firefox 和 Chrome 中,请使用 Control+Shift+I (Windows, Linux) 或 Command+Option+I (Mac)。使用控制台查看表格内容,例如:console.table(VEGA_DEBUG.view.数据('edges'))或使用 VEGA_DEBUG.view.signal('height') 查看信号状态。您甚至可以使用 VEGA_DEBUG.view._runtime.scales.y 查看扩展内部值。
Vega 代码
请参阅代码注释以了解每一行的作用,或阅读 Vega 文档。请注意,此代码使用 HJSON,这是一种更具可读性的 JSON 格式。
{
$schema: https://vega.github.io/schema/vega/v3.0.json
数据: [
{
// 基于当前选定的时间范围和过滤器字符串查询 ES
名称:rawData
URL:{
%context%:true
%timefield%:@timestamp
索引:logstash-*
正文:{
大小:0
aggs: {
表:{
复合:{
大小:10,000
sources: [
{
stk1: {
词:{field: "geo.src"}
}
}
{
stk2: {
词:{field: "geo.dest"}
}
}
]
}
}
}
}
}
// 从结果中,仅提取我们感兴趣的数据
格式:{property: "aggregations.table.buckets"}
// 将 key.stk1 转换为 stk1,以便在下方进行更简单的访问
转换:[
{type: "formula", expr: "datum.key.stk1", as: "stk1"}
{type: "formula", expr: "datum.key.stk2", as: "stk2"}
{type: "formula", expr: "datum.doc_count", as: "size"}
]
}
{
名称:Node
源:rawData
转换:[
// 当选中某个国家/地区时,筛除不相关的数据
{
类型:筛选
expr: !groupSelector || groupSelector.stk1 == datum.stk1 || groupSelector.stk2 == datum.stk2
}
// 设置用于后续查找的新秘钥 - 标识每个 Node
{type: "formula", expr: "datum.stk1+datum.stk2", as: "key"}
// 不再是每一行表格行,而是创建两行新行,
// 一个用于源(stack=stk1),另一个用于目标Node(stack=stk2)。
// 存储在 stk1 和 stk2 字段中的国家/地区代码被放入 grpId 字段中。
{
类型:倍
字段:["stk1", "stk2"]
as: ["stack", "grpId"]
}
// 创建一个排序键,stk1 和 stk2 堆栈的排序键各不相同。
// 空格分隔符可确保在某些极端情况下实现正确的排序顺序。
{
类型:公式
expr: datum.stack == 'stk1' ?datum.stk1+''+datum.stk2 : datum.stk2+''+datum.stk1
as: sortField
}
// 计算用于将 Node 一个接一个堆叠的 y0 和 y1 位置,
// 针对每个堆栈独立进行,并确保它们处于正确的顺序,
// 从顶部开始按字母顺序排列(在 Y 轴上反转)
{
类型:堆栈
groupby: ["stack"]
排序:{field: "sortField", order: "descending"}
字段:size
}
// 计算每个 Node 的垂直中心点,用于绘制边
{type: "formula", expr: "(datum.y0+datum.y1)/2", as: "yc"}
]
}
{
名称:groups
来源:Node
转换:[
// 将所有 Node 合并为国家/地区组,汇总文档计数
{
类型:聚合
groupby: ["stack", "grpId"]
字段: ["size"]
ops:["sum"]
as: ["total"]
}
// 重新计算堆叠的 y0,y1 值
{
类型:堆栈
groupby: ["stack"]
排序:{field: "grpId", order: "descending"}
字段:total
}
// 将 y0 和 y1 值投影到屏幕坐标
// 在此处执行一次,而不是在标记中多次执行
{type: "formula", expr: "scale('y', datum.y0)", as: "scaledY0"}
{type: "formula", expr: "scale('y', datum.y1)", as: "scaledY1"}
// 用于指示标签是否应位于堆叠右侧的布尔标志
{type: "formula", expr: "datum.stack == 'stk1'", as: "rightLabel"}
// 使用“y”扩展计算该国家的流量百分比
// 域上边界,代表总流量
{
类型:公式
expr: datum.total/domain('y')[1]
作为:百分比
}
]
}
{
// 这是一个包含所有 'stk2' 栈 Node 的临时查找表
名称:destinationNodes
来源:Node
转换:[
{type: "filter", expr: "datum.stack == 'stk2'"}
]
}
{
名称:edges
来源:Node
转换:[
// 我们仅需要来自左侧堆栈的 Node
{type: "filter", expr: "datum.stack == 'stk1'"}
// 从右侧堆叠中查找对应的 Node,将其保留为“target”
{
类型:查找
from: destinationNodes
键:键
字段: ["key"]
as: ["target"]
}
// 计算 Node 对的 stk1 和 stk2 堆栈之间的 SVG 链接路径
{
类型:链接路径
orient: 水平
形状:对角线
sourceY:{expr: "scale('y', datum.yc)"}
sourceX:{expr: "scale('x', 'stk1') + bandwidth('x')"}
targetY: {expr: "scale('y', datum.target.yc)"}
targetX:{expr: "scale('x', 'stk2')"}
}
// 计算线条粗细的一个小技巧。
// 该值需要与 Node 高度相同,但需进行扩展
// 由于屏幕的 Y 轴原因,根据屏幕高度调整大小会得出反向值
// 坐标从上到下,而图表的 Y=0
// 位于底部。因此,从屏幕高度中减去缩放后的文档计数
//(这是 "y" 扩展的 "下" 界)为我们提供了正确的值
{
类型:公式
expr: range('y')[0]-扩展('y', datum.size)
as: strokeWidth
}
// 工具提示需要单个链接占总流量的百分比
{
类型:公式
expr: datum.size/domain('y')[1]
作为:百分比
}
]
}
]
扩展: [
{
// 计算水平堆叠定位
name: x
类型:band
范围:宽度
domain: ["stk1", "stk2"]
paddingOuter: 0.05
paddingInner: 0.95
}
{
// 此扩展比例最高可达所有 Node 中最高的 y1 值
name: y
类型:线性
范围:高度
域:{data: "nodes", field: "y1"}
}
{
// 使用 rawData 以确保点击时颜色保持不变。
name: 颜色
类型:序数
范围:类别
域:{data: "rawData", fields: ["stk1", "stk2"]}
}
{
// 此 扩展 用于将内部 ID (stk1, stk2) 映射到堆叠名称
名称:stackNames
类型:序数
range: ["Source", "Destination"]
domain: ["stk1", "stk2"]
}
]
轴:[
{
// x 轴应使用自定义标签格式来打印正确的堆栈名称
方向:底部
扩展:x
编码:{
标签:{
更新:{
文本:{scale: "stackNames", field: "value"}
}
}
}
}
{orient: "left", scale: "y"}
]
标记:[
{
// 绘制堆栈之间的连接线
类型:路径
名称:edgeMark
来自:{data: "edges"}
// 这可以防止路径因 strokeWidth 过大而导致的一些自动调整大小问题
clip: true
编码:{
更新:{
// 默认使用左侧Node的颜色,显示流量时除外
// 仅来自一个国家/地区,在这种情况下使用目标颜色。
stroke: [
{
test: groupSelector && groupSelector.stack=='stk1'
扩展:颜色
字段:stk2
}
{scale: "color", field: "stk1"}
]
描边宽度:{field: "strokeWidth"}
路径:{field: "path"}
// 当显示所有流量并悬停在某个国家/地区上方时,
// 突出显示来自该国家的流量。
strokeOpacity:{
signal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2)?0.9:0.3
}
// 确保悬停选中的边缘显示在最上方
zindex: {
signal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2)? 1 : 0
}
// 格式工具提示字符串
工具提示:{
signal: datum.stk1 + ' → ' + datum.stk2 + ' ' + format(datum.size,',.0f') + ' (' + format(datum.percentage,'.1%') + ')'
}
}
// 单行鼠标悬停高亮显示
悬停:{
strokeOpacity: {value: 1}
}
}
}
{
// 绘制堆栈组(国家/地区)
类型:rect
名称:groupMark
来自:{data: "groups"}
编码:{
输入:{
填充:{scale: "color", field: "grpId"}
宽度:{scale: "x", band: 1}
}
更新:{
x:{scale: "x", field: "stack"}
y:{field: "scaledY0"}
y2: {field: "scaledY1"}
fillOpacity: {value: 0.6}
工具提示:{
signal: datum.grpId + ' ' + format(datum.total,',.0f') + ' (' + format(datum.percentage,'.1%') + ')'
}
}
悬停:{
fillOpacity: {value: 1}
}
}
}
{
// 在堆叠内侧绘制国家代码标签
类型:text
来自:{data: "groups"}
// 不要处理标签的事件 - 否则线条鼠标悬停效果会不整洁
interactive: false
编码:{
更新:{
// 取决于它是哪个堆栈,将 x 定位并留出一些内边距
x:{
signal: 扩展('x', datum.stack)+ (datum.rightLabel ?bandwidth('x') + 8 : -8)
}
// 组中间
yc: {signal: "(datum.scaledY0 + datum.scaledY1)/2"}
对齐:{signal: "datum.rightLabel ? 'left' : 'right'"}
基线:{value: "middle"}
fontWeight: {value: "bold"}
// 仅在组高度足够大时显示文本标签
文本:{signal: "abs(datum.scaledY0-datum.scaledY1) > 13 ? datum.grpId : ''"}
}
}
}
{
// 创建一个“显示全部”按钮。仅在选择国家/地区时显示。
类型:组
数据: [
// 我们需要确保仅当 groupSelector 信号为 true 时才显示该按钮。
// 每个标记的绘制次数与底层数据中的元素数量相同。
// 这意味着如果值列表为空,则不会进行绘制。
// 在这里,我创建了一个包含一个空对象的数据源,并对该列表进行了筛选
// 基于信号值。此操作只能在组中完成。
{
name: dataForShowAll
值:[{}]
转换:[{type: "filter", expr: "groupSelector"}]
}
]
// 设置按钮大小和位置
编码:{
输入:{
xc:{signal: "width/2"}
y:{value: 30}
宽度:{value: 80}
高度:{value: 30}
}
}
标记:[
{
// 此组显示为一个带有圆角的按钮。
类型:组
// 标记名称允许信号捕获
名称:groupReset
// 仅当 dataForShowAll 包含值时才显示按钮。
来自:{data: "dataForShowAll"}
编码:{
输入:{
圆角半径:{value: 6}
填充:{value: "#f5f5f5"}
描边:{value: "#c1c1c1"}
描边宽度:{value: 2}
// 使用父组的大小
高度:{
字段:{group: "height"}
}
宽度:{
字段:{group: "width"}
}
}
更新:{
// 组默认是透明的
不透明度:{value: 1}
}
悬停:{
不透明度:{value: 0.7}
}
}
标记:[
{
类型:text
// 如果为 true,它将防止在文本上方时点击按钮。
interactive: false
编码:{
输入:{
// 将括号组中的文本居中
xc: {
字段:{group: "width"}
倍数:0.5
}
yc: {
字段:{group: "height"}
倍数:0.5
偏移量:2
}
对齐:{value: "center"}
基线:{value: "middle"}
fontWeight: {value: "bold"}
文本:{value: "Show All"}
}
}
}
]
}
]
}
]
信号:[
{
// 用于突出显示往返于同一国家的流量
名称:groupHover
值:{}
on: [
{
事件:@groupMark:mouseover
更新:“{stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}”
}
{events: "mouseout", update: "{}"}
]
}
// 用于仅筛选与所选国家/地区相关的数据
{
name: groupSelector
value: false
on: [
{
// 单击 groupMark 会将此信号设置为筛选值
事件:@groupMark:click!
更新:“{stack:datum.stack, stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}”
}
{
// 单击“show all”(显示全部)按钮,或在任意位置双击即可重置
events: [
{type: "click", markname: "groupReset"}
{type: "dblclick"}
]
update: "false"
}
]
}
]
}
高亮组件
要突出显示堆栈的组件并生成上述说明图像,请将 url 部分替换为这些硬编码值,并在所有其他标记之后添加一个额外的标记。
值:{
聚合:{ table: { 分桶:[
{key: {stk1:"aa", stk2:"cc"}, doc_count: 7}
{key: {stk1:"aa", stk2:"bb"}, doc_count: 4}
{key: {stk1:"bb", stk2:"aa"}, doc_count: 8}
{key: {stk1:"bb", stk2:"bb"}, doc_count: 6}
{key: {stk1:"bb", stk2:"cc"}, doc_count: 3}
{key: {stk1:"cc", stk2:"aa"}, doc_count: 9}
]}}}
{
类型:rect
来自:{data: "nodes"}
编码:{
输入:{
描边:{value: "#000"}
描边宽度:{value: 2}
宽度:{scale: "x", band: 1}
x:{scale: "x", field: "stack"}
y:{field: "y0", scale: "y"}
y2: {field: "y1", scale: "y"}
}
}
}
有用的链接:
问题与评论
如需讨论此博客文章,请使用我们的论坛