产品

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 相连的部分。

Sankey 组件

我们需要有几个数据表来绘制上述图表:

  • Node 此表不会直接用于绘图,但将用作其他表的数据源。我们可以将上图中的 Node 可视化为黑匣子——这里我们有 12 个节点。每个Node由其代表的文档计数、堆叠 ID、国家/地区代码以及在堆栈中的位置(y0y1)组成。请注意,此表没有任何“屏幕”坐标,只有源数据。即使是堆栈位置也是以文档计数来表示的。第一个 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"}
    }
  }
}

有用的链接:

问题与评论

如需讨论此博客文章,请使用我们的论坛