プロダクト

Kibana 6.2におけるVegaを使用したサンキー可視化

KibanaでカスタムVegaグラフを構築するシリーズの続きとして、本日はネットワークトラフィックのパターンを表示するためのシンプルな2レベルのサンキーグラフを扱います。(前回は、KibanaでのカスタムVega可視化について解説しました。)サンキーダイアグラムは、線の幅がフローの量に比例して表示されるフロー図の一種です。サンプルデータの各エントリには、ソースと目的地の国コードが含まれています。このグラフには、all-to-all(デフォルト)と、ユーザーがソースまたは目的地の国を選択して関連するトラフィックのみを表示できるモードの2つがあります。

すべての国間のトラフィック

選択した国からのトラフィック

前提条件

  • 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(2番目のスタックとして)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 個のオブジェクト
    ]
  }
}

可視化戦略

サンキーグラフは、スタックに整理されたNodeと、それらのNodeを接続するエッジで構成されていると考えることができます。このグラフには、ソースとデスティネーションという2つのスタックしかありません。2つのスタックは、画面の両側に1つずつ、垂直バーとして描画されます。各スタックには、国が積み重なるように配置されています。各国の構成要素はNodeであり、ソース国のNodeがデスティネーション国のNodeと接続されています。

Sankey components

上記のグラフを描画するには、いくつかのデータテーブルが必要です:

  • Node このテーブルは直接描画には使用されませんが、他のテーブルのデータソースとして使用されます。上の図では、Nodeをブラックボックスとして可視化できます。ここには12個のNodeがあります。各Nodeは、それが表すドキュメントカウント、スタック ID、国コード、およびスタック内の位置(y0 から y1)で構成されます。このテーブルには「画面」座標はなく、ソースデータのみが含まれていることに注意してください。スタックの位置でさえ、ドキュメントカウントの観点から表現されます。1番目のNodeは 0..n1-1、2番目は n1..n2-1 となります。また、スタックが2つあるため、1番目、2番目、...のNodeも各スタックに1つずつ、計2つ存在することになります。
  • エッジ 線(この場合は6本)のリストで、それぞれが左側と右側のNodeを接続しています。線には(x, y)座標のペア、線の太さ(strikeWidth)、および線の色(ソースNodeと同じ)が必要です。このテーブルは、Nodeテーブルからすべての stack=="stk1" 行を取得し、宛先スタックに対応するNodeを検索することで生成されます。linkpath変換は、曲線を描画するSVGパス文字列を生成します。
  • groups 各グループは、同じスタックの同じ国に属するすべてのNodeを結合します。このグラフには6つのグループがあり、各サイドに3つずつ配置されています。各グループには、スタック ID(stk1 または stk2)、開始および終了の Y 値(Nodeテーブルと同様の y0 および y1)、および国コードが必要です。この表もNode表から生成され、スタック ID と国コードでグループ化し、Nodeと同様にスタックされます。両方の表を同じ値で並べ替えるため、y 値は Node 表と一致します。

テーブルに加えて、私は3つのスケールを使用して、「データ座標空間」から「スクリーン座標空間」へ投影しています。「x」スケールは、画面上のstk1スタックとstk2スタックの位置を決定します。「y」スケールは、グラフの高さを最も高いスタック内の全Nodeの合計高さにマップします(今回のケースでは、両方のスタックは同じ高さです)。最後に、「color」スケールが各国の色を割り当てます。

最後のステップは、3つのマークを使用してデータテーブルをビジュアルに変換することです。エッジには path マーク、スタックには rect マーク、国ラベルには text マークを使用します。

インタラクション

この時点で期待通りのグラフが表示されますが、ユーザーがグラフをどのように操作できるかを定義したい場合もよくあります。これはシグナルを使用して行われます。シグナルとは、イベントに基づいて値が変化し、さまざまな式で使用できる動的な変数です。

ユーザーが送信元スタックの国グループをクリックすると、グラフはクリックされた国以外のすべてのデータを非表示にする必要があります。同様に、送信先側をクリックしたときにも機能するはずです。これは、groupSelector シグナルを定義することで実行されます。groupMark がクリックされると、シグナルは送信元または送信先のいずれかとして国コードに設定されます。Nodeテーブルはシグナル値に基づいて自動的にフィルタリングされ、他のデータが存在しないかのようにグラフが再描画されます。戻るには、中央にテキストマーク付きの四角いボックスを表示します。ボックスをクリックするとシグナルがリセットされ、Nodeテーブルが全リストの状態に復元されます。

もう1つのインタラクションは、スタック内の国に対する「マウスオーバー」です。groupMark マウスオーバーイベント時に、groupSelector と同様に groupHover シグナルが設定されます。このシグナルは、エッジラインの透明度を制御するために使用されます。

デバッグと探索

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ドキュメントをご覧ください。このコードでは、JSONをより読みやすくしたHJSONを使用している点にご注意ください。

{
  $schema: https://vega.github.io/schema/vega/v3.0.json
  データ: [
    {
      // 現在選択されている時間範囲とフィルター文字列に基づいてESをクエリします
      name: rawData
      URL:{
        %context%: true
        %timefield%: @timestamp
        インデックス: logstash-*
        本文: {
          size:0
          aggs: {
            table:{
              composite: {
                サイズ:10000
                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"}
        // 各テーブル行の代わりに、2つの新しい行を作成します。
        // 1つはソース用(stack=stk1)、もう1つは宛先Node用(stack=stk2)。
        // stk1 および stk2 フィールドに格納された国コードは、grpId フィールドに配置されます。
        {
          タイプ: 倍
          フィールド: ["stk1", "stk2"]
          as: ["stack", "grpId"]
        }
        // ソートキーを作成します。stk1スタックとstk2スタックで異なります。
        // スペース区切り文字は、いくつかのコーナーケースにおいて適切な並び順を保証します。
        {
          type:式
          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
      source: Node
      変換: [
        // すべてのNodeを国グループにまとめ、ドキュメント数を合計する
        {
          type: アグリゲーション
          groupby: ["stack", "grpId"]
          フィールド: ["size"]
          ops: ["sum"]
          as: ["total"]
        }
        // スタッキングのy0、y1値を再計算する
        {
          タイプ:スタック
          groupby: ["stack"]
          並べ替え:{field: "grpId", order: "descending"}
          フィールド:合計
        }
        // y0およびy1の値を画面座標に投影します
        // ここで複数回実行するのではなく、1 度だけ実行します
        {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"スケールを使用して、この国のトラフィックの割合を計算
        // ドメイン上のバウンド。これは合計トラフィックを表します
        {
          type:式
          expr: datum.total/domain('y')[1]
          as: 割合
        }
      ]
    }
    {
      // これはすべての'stk2'スタックNodeを含む一時的なルックアップテーブルです
      name: destinationNodes
      source: Node
      変換: [
        {type: "filter", expr: "datum.stack == 'stk2'"}
      ]
    }
    {
      名前:edges
      source: Node
      変換: [
        // 左側のスタックのNodeのみが必要です
        {type: "filter", expr: "datum.stack == 'stk1'"}
        // 右側のスタックから対応する Node を見つけ、「target」として保持する
        {
          タイプ:ルックアップ
          送信元:destinationNodes
          key: key
          フィールド: ["key"]
          as: ["target"]
        }
        // Nodeペアのstk1スタックとstk2スタック間のSVGリンクパスを計算
        {
          type: linkpath
          orient: horizontal
          シェイプ:対角線
          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」スケールの「下限」です)が正しい値をもたらします
        {
          type:式
          expr: range('y')[0]-スケール('y', datum.size)
          as: strokeWidth
        }
        // ツールヒントには、全トラフィックに対する個々のリンクの割合が必要です
        {
          type:式
          expr: datum.size/domain('y')[1]
          as: 割合
        }
      ]
    }
  ]
  スケール: [
    {
      // 水平スタック配置を計算します
      name: x
      type: band
      範囲:幅
      domain: ["stk1", "stk2"]
      paddingOuter: 0.05
      paddingInner: 0.95
    }
    {
      // このスケールは、すべてのNodeの最も高いy1値まで上がります
      名前:y
      type: 線形
      範囲:高さ
      ドメイン: {data: "nodes", field: "y1"}
    }
    {
      // クリック時に色が変化しないようにrawDataを使用します。
      name: 色
      タイプ:序数
      範囲: カテゴリー
      ドメイン: {data: "rawData", fields: ["stk1", "stk2"]}
    }
    {
      // このスケールは、内部ID(stk1、stk2)をスタック名にMapsするために使用されます
      name: stackNames
      タイプ:序数
      range: ["Source", "Destination"]
      domain: ["stk1", "stk2"]
    }
  ]
  軸:[
    {
      // x軸は、適切なスタック名を表示するためにカスタムラベルフォーマットを使用する必要があります
      orient: bottom
      スケール: x
      エンコード: {
        ラベル: {
          update: {
            テキスト: {scale: "stackNames", field: "value"}
          }
        }
      }
    }
    {orient: "left", scale: "y"}
  ]
  marks: [
    {
      // スタック間の接続線を描画する
      タイプ:パス
      name: edgeMark
      差出人:{data: "edges"}
      // これは、パスのstrokeWidthが大きい場合に発生する自動サイズ調整のいくつかの問題を防御します
      clip: true
      エンコード: {
        update: {
          // デフォルトでは左側のNodeの色を使用しますが、トラフィックを表示している場合は除きます
          // 1つの国のみからの場合、その場合はデスティネーションカラーを使用します。
          stroke: [
            {
              test: groupSelector && groupSelector.stack=='stk1'
              スケール:カラー
              フィールド: stk2
            }
            {scale: "color", field: "stk1"}
          ]
          strokeWidth: {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%') + ')'
          }
        }
        // 単一ラインのシンプルなマウスオーバーハイライト
        hover: {
          strokeOpacity: {value: 1}
        }
      }
    }
    {
      // スタックグループ(国)を描画
      タイプ:rect
      名前:groupMark
      差出人:{data: "groups"}
      エンコード: {
        入力:{
          塗りつぶし: {scale: "color", field: "grpId"}
          幅: {scale: "x", band: 1}
        }
        update: {
          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%') + ')'
          }
        }
        hover: {
          fillOpacity: {value: 1}
        }
      }
    }
    {
      // スタックの内側に国コードラベルを描画する
      type: text
      差出人:{data: "groups"}
      // ラベルのイベントは処理しない(そうしないと、ラインのマウスオーバー表示が不鮮明になるため)
      interactive: false
      エンコード: {
        update: {
          // どのスタックかによって、パディングを加えて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の場合にのみボタンを表示するようにする必要があります。
        // 各マークは、基盤となるデータの要素数と同じ回数だけ描画されます。
        // つまり、値リストが空の場合、描画されません。
        // ここでは、空のオブジェクトを1つ含むデータソースを作成し、そのリストをフィルタリングします
        // シグナル値に基づく。これはグループ内でのみ実行可能です。
        {
          name: dataForShowAll
          values: [{}]
          変換:[{type: "filter", expr: "groupSelector"}]
        }
      ]
      // ボタンのサイズと位置を設定
      エンコード: {
        入力:{
          xc: {signal: "width/2"}
          y:{value: 30}
          幅: {value: 80}
          高さ:{value: 30}
        }
      }
      marks: [
        {
          // このグループは、角が丸いボタンとして表示されます。
          タイプ:グループ
          // mark nameはシグナルのキャプチャを可能にします
          名前:groupReset
          // dataForShowAllに値がある場合のみボタンを表示します。
          差出人:{data: "dataForShowAll"}
          エンコード: {
            入力:{
              cornerRadius: {value: 6}
              塗りつぶし: {value: "#f5f5f5"}
              stroke: {value: "#c1c1c1"}
              strokeWidth: {value: 2}
              // 親グループのサイズを使用
              高さ: {
                フィールド: {group: "height"}
              }
              width: {
                フィールド: {group: "width"}
              }
            }
            update: {
              // グループはデフォルトで透明です
              不透明度:{value: 1}
            }
            hover: {
              不透明度:{value: 0.7}
            }
          }
          marks: [
            {
              type: text
              // trueの場合、テキスト上にカーソルがあるときにボタンのクリックを防御します。
              interactive: false
              エンコード: {
                入力:{
                  // カッコ内のテキストを中央揃えにする
                  xc: {
                    フィールド: {group: "width"}
                    mult: 0.5
                  }
                  yc: {
                    フィールド: {group: "height"}
                    mult: 0.5
                    オフセット:2
                  }
                  アラインメント: {value: "center"}
                  ベースライン: {value: "middle"}
                  fontWeight: {value: "bold"}
                  テキスト: {value: "Show All"}
                }
              }
            }
          ]
        }
      ]
    }
  ]
  signals: [
    {
      // 同じ国への、または同じ国からのトラフィックを強調するために使用
      name: groupHover
      値: {}
      オン: [
        {
          イベント: @groupMark:mouseover
          update: 「{stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}」
        }
        {events: "mouseout", update: "{}"}
      ]
    }
    // 選択した国に関連するデータのみをフィルタリングするために使用
    {
      name: groupSelector
      value: false
      オン: [
        {
          // groupMarkをクリックすると、このシグナルがフィルター値に設定されます
          イベント:@groupMark:click!
          update: 「{stack:datum.stack, stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}」
        }
        {
          // [show all]ボタンをクリックするか、任意の場所をダブルクリックするとリセットされます
          イベント: [
            {type: "click", markname: "groupReset"}
            {type: "dblclick"}
          ]
          update: "false"
        }
      ]
    }
  ]
}

コンポーネントのハイライト

スタックのコンポーネントを強調表示して上記の解説画像を作成するには、urlセクションをこれらのハードコードされた値に置き換え、他のすべてのマークの後にさらにマークを追加してください。

values: {
  アグリゲーション: { 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"}
  エンコード: {
    入力:{
      stroke: {value: "#000"}
      strokeWidth: {value: 2}
      幅: {scale: "x", band: 1}
      x: {scale: "x", field: "stack"}
      y:{field: "y0", scale: "y"}
      y2:{field: "y1", scale: "y"}
    }
  }
}

便利なリンク:

質問とコメント

このブログ記事については、フォーラムをご利用ください。