제품

Kibana 6.2에서 Vega를 사용한 Sankey 시각화

Kibana에서 사용자 지정 Vega 그래프를 구축하는 시리즈를 이어가며, 오늘의 주제는 네트워크 트래픽 패턴을 보여주는 간단한 2단계 Sankey 그래프입니다. (지난번에는 Kibana의 사용자 지정 Vega 시각화에 대해 논의했습니다.) Sankey 다이어그램은 선의 폭을 흐름 양에 비례하여 표시하는 흐름 다이어그램의 한 유형입니다. 샘플 데이터의 각 항목에는 소스 및 대상 국가 코드가 포함되어 있습니다. 이 그래프에는 all-to-all(기본값) 모드와 사용자가 소스 또는 대상 국가를 선택하여 관련 트래픽만 표시할 수 있는 모드, 이렇게 두 가지 모드가 있습니다.

모든 국가 간 트래픽

선택한 국가의 트래픽

필수 구성 요소

  • 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시간으로 설정한 다음, 무작위로 생성된 데이터를 관찰하여 모든 것이 작동하는지 테스트하십시오.

데이터

이 예제에서는 geo.src를 사용하겠습니다. (첫 번째 스택으로) 및 geo.dest (두 번째 스택으로) makelogs 유틸리티에서 생성된 무작위 데이터의 필드입니다. 이 필드들은 트래픽의 소스와 대상을 나타냅니다. 이 쿼리는 Elasticsearch 복합 집계를 사용하여 두 필드별로 집계합니다. 각 조합에 대한 문서 수를 계산하고 상위 10개를 반환합니다.

GET logstash-*/_search
{
  "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 그래프는 스택으로 구성된 노드와 노드를 연결하는 에지로 이루어져 있다고 생각할 수 있습니다. 이 그래프에는 소스와 대상이라는 두 개의 스택만 있습니다. 두 스택은 화면 양쪽에 하나씩 수직 막대로 그려집니다. 각 스택에는 국가들이 서로 겹쳐 쌓여 있습니다. 각 국가는 노드로 구성되며, 이는 대상 국가의 노드와 연결된 소스 국가의 일부입니다.

Sankey components

위 그래프를 그리려면 여러 개의 데이터 테이블이 필요합니다:

  • Node 이 테이블은 직접 그리는 데 사용되지 않지만 다른 테이블의 데이터 소스로 사용됩니다. 위 그림에서 노드를 블랙박스로 시각화할 수 있습니다. 여기에는 12개의 노드가 있습니다. 각 노드는 노드가 나타내는 문서 수, 스택 ID, 국가 코드 및 스택 내 위치(y0~y1)로 구성됩니다. 이 테이블에는 '화면' 좌표가 없으며 소스 데이터만 있다는 점에 유의하십시오. 스택 위치조차도 문서 수로 표현됩니다. 첫 번째 노드는 0..n1-1, 두 번째 노드는 n1..n2-1 등으로 진행됩니다. 또한 두 개의 스택이 있으므로 각 스택에 대해 하나씩, 첫 번째, 두 번째, ... 노드가 두 개씩 존재하게 됩니다.
  • 에지 선의 목록(이 경우 6개)이며, 각 선은 왼쪽과 오른쪽의 Node를 연결합니다. 선에는 (x, y) 좌표 쌍, 선 두께(strikeWidth), 선 색상(소스 노드와 동일)이 필요합니다. 이 테이블은 Node 테이블에서 모든 stack=="stk1" 행을 가져오고 대상 스택에 해당하는 Node를 조회하여 생성됩니다. linkpath 변환은 곡선을 설명하는 SVG 경로 스트링을 생성합니다.
  • groups 각 그룹은 동일한 스택에 대해 동일한 국가의 모든 노드를 결합합니다. 이 그래프에는 6개의 그룹이 있으며, 양쪽에 3개씩 있습니다. 각 그룹은 스택 ID(stk1 또는 stk2), 시작 및 종료 Y 값(Node 테이블과 유사한 y0 및 y1), 국가 코드를 가져야 합니다. 이 테이블 또한 Node 테이블에서 생성되며, 스택 ID와 국가 코드로 그룹화하고 Node와 유사하게 스택을 쌓습니다. 두 테이블을 동일한 값으로 정렬하므로 y 값은 Node 테이블과 정렬됩니다.

테이블 외에도, 저는 "데이터 좌표 공간"에서 "화면 좌표 공간"으로 투영하기 위해 세 가지 스케일을 사용합니다. "x" 스케일은 화면에서 stk1 및 stk2 스택의 위치를 결정합니다. "y" 확장 그래프 높이를 가장 높은 스택에 있는 모든 Node의 총 높이로 맵핑합니다(이 경우 두 스택의 높이는 동일합니다). 마지막으로, "색상" 스케일은 각 국가에 색상을 할당합니다.

마지막 단계는 세 가지 마크를 사용하여 데이터 테이블을 시각 자료로 변환하는 것입니다. 엣지용 path 마크, 스택용 rect 마크, 국가 레이블용 텍스트 마크를 사용합니다.

상호 작용

이 시점에서 예상한 그래프를 볼 수 있지만, 종종 사용자가 그래프와 상호 작용하는 방식을 정의해야 할 수도 있습니다. 이는 신호(signals)를 통해 수행됩니다. 신호는 이벤트에 따라 값이 변경되고 다양한 표현식에서 사용할 수 있는 동적 변수입니다.

사용자가 소스 스택에서 국가 그룹을 클릭하면, 그래프는 클릭된 국가에서 온 것이 아닌 모든 데이터를 숨겨야 합니다. 마찬가지로, 대상 측을 클릭할 때도 작동해야 합니다. 이는 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 설명서를 읽어 보십시오. 이 코드는 더 읽기 쉬운 JSON 형식인 HJSON을 사용한다는 점을 참고하십시오.

{
  $schema: https://vega.github.io/schema/vega/v3.0.json
  데이터: [
    {
      // 현재 선택된 시간 범위 및 필터 스트링을 기반으로 ES 쿼리
      name: rawData
      URL: {
        %context%: true
        %timefield%: @timestamp
        인덱스: logstash-*
        body: {
          size: 0
          aggs: {
            표: {
              복합: {
                size: 10000
                출처: [
                  {
                    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
      source: 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 필드에 배치됩니다.
        {
          type: fold
          필드: ["stk1", "stk2"]
          as: ["stack", "grpId"]
        }
        // stk1 및 stk2 스택에 대해 서로 다른 정렬 키(sortkey)를 생성합니다.
        // 스페이스 구분 기호는 일부 코너 케이스에서 올바른 정렬 순서를 보장합니다.
        {
          유형: 공식
          expr: datum.stack == 'stk1' ? datum.stk1+' '+datum.stk2 : datum.stk2+' '+datum.stk1
          as: sortField
        }
        // 노드를 서로 겹쳐서 쌓기 위한 y0 및 y1 위치 계산,
        // 각 스택에 대해 독립적으로 수행하고, 적절한 순서로 되어 있는지 확인하며,
        // 상단부터 알파벳순(y축에서 역순)
        {
          type: 스택
          groupby: ["stack"]
          정렬: {field: "sortField", order: "descending"}
          필드: 크기
        }
        // 각 노드의 수직 중심점을 계산하여 에지를 그리는 데 사용
        {type: "formula", expr: "(datum.y0+datum.y1)/2", as: "yc"}
      ]
    }
    {
      이름: 그룹
      소스: Node
      변환: [
        // 모든 노드를 국가 그룹으로 결합하고 문서 개수 합산
        {
          유형: 집계
          groupby: ["stack", "grpId"]
          필드: ["size"]
          ops: ["sum"]
          as: ["total"]
        }
        // 스태킹 y0, y1 값을 다시 계산합니다
        {
          type: 스택
          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]
          as: 백분율
        }
      ]
    }
    {
      // 이는 모든 'stk2' 스택 노드가 포함된 임시 조회 테이블입니다
      이름: destinationNodes
      소스: Node
      변환: [
        {type: "filter", expr: "datum.stack == 'stk2'"}
      ]
    }
    {
      name: edges
      소스: Node
      변환: [
        // 왼쪽 스택의 노드만 필요합니다
        {type: "filter", expr: "datum.stack == 'stk1'"}
        // 오른쪽 스택에서 해당 노드를 찾아 "target"으로 유지
        {
          유형: lookup
          from: destinationNodes
          키: 키
          필드: ["key"]
          as: ["target"]
        }
        // Node 쌍에 대한 stk1 및 stk2 스택 간의 SVG 링크 경로 계산
        {
          유형: linkpath
          방향: 수평
          도형: 대각선
          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')"}
        }
        // 선의 두께를 계산하는 작은 요령입니다.
        // 값은 노드의 높이와 동일해야 하지만, 확장
        // 화면의 Y축 때문에 화면 높이에 맞춘 크기는 반전된 값을 제공합니다
        // 좌표는 위에서 아래로 이동하지만, 그래프의 Y=0은
        //은(는) 하단에 있습니다. 따라서 화면 높이에서 확장된 문서 수를 뺍니다
        // (이는 "y" 배율의 "하한"값임) 올바른 값을 제공합니다
        {
          유형: 공식
          expr: range('y')[0]-확장하다('y', datum.size)
          as: strokeWidth
        }
        // 도구 설명은 전체 트래픽 중 개별 링크의 비율이 필요합니다
        {
          유형: 공식
          expr: datum.size/domain('y')[1]
          as: 백분율
        }
      ]
    }
  ]
  확장하다: [
    {
      // 수평 스택 위치를 계산합니다
      이름: x
      유형: band
      범위: width
      도메인: ["stk1", "stk2"]
      paddingOuter: 0.05
      paddingInner: 0.95
    }
    {
      // 이 스케일은 모든 노드의 가장 높은 y1 값까지 확장됩니다
      name: y
      유형: 선형
      범위: height
      도메인: {data: "nodes", field: "y1"}
    }
    {
      // 클릭 시 색상이 동일하게 유지되도록 rawData를 사용합니다.
      이름: 색상
      유형: 서수
      범위: 카테고리
      도메인: {data: "rawData", fields: ["stk1", "stk2"]}
    }
    {
      // 이 확장(scale)은 내부 ID(stk1, stk2)를 스택 이름에 매핑하는 데 사용됩니다
      이름: stackNames
      유형: 서수
      range: ["Source", "Destination"]
      도메인: ["stk1", "stk2"]
    }
  ]
  axes: [
    {
      // x축은 적절한 스택 이름을 출력하기 위해 사용자 지정 라벨 형식을 사용해야 합니다
      방향: 아래
      확장하다: x
      인코딩: {
        레이블: {
          업데이트: {
            텍스트: {scale: "stackNames", field: "value"}
          }
        }
      }
    }
    {orient: "left", scale: "y"}
  ]
  marks: [
    {
      // 스택 간 연결선 그리기
      유형: 경로
      이름: edgeMark
      출처: {data: "edges"}
      // 이는 경로에 대한 큰 strokeWidth로 인해 발생하는 일부 자동 크기 조정 문제를 방지합니다
      clip: true
      인코딩: {
        업데이트: {
          // 기본적으로 트래픽을 표시하는 경우를 제외하고 왼쪽 노드의 색상을 사용합니다
          // 단 하나의 국가에서 온 경우, 대상 색상을 사용하십시오.
          획: [
            {
              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
          }
          // 도구 설명 스트링 형식
          tooltip: {
            신호: datum.stk1 + ' → ' + datum.stk2 + '    ' + format(datum.size, ", ',.0f') + '   (' + format(datum.percentage, '.1%') + ')'
          }
        }
        // 단일 행의 간단한 마우스 오버 강조 표시
        hover: {
          strokeOpacity: {value: 1}
        }
      }
    }
    {
      // 스택 그룹(국가) 그리기
      유형: rect
      이름: groupMark
      출처: {data: "groups"}
      인코딩: {
        입력: {
          채우기: {scale: "color", field: "grpId"}
          width: {scale: "x", band: 1}
        }
        업데이트: {
          x: {scale: "x", field: "stack"}
          y: {field: "scaledY0"}
          y2: {field: "scaledY1"}
          fillOpacity: {value: 0.6}
          tooltip: {
            signal: datum.grpId + '   ' + format(datum.total, ',.0f') + '   (' + format(datum.percentage, '.1%') + ')'
          }
        }
        hover: {
          fillOpacity: {value: 1}
        }
      }
    }
    {
      // 스택 안쪽에 국가 코드 레이블 그리기
      유형: 텍스트
      출처: {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일 때만 버튼이 표시되도록 해야 합니다.
        // 각 마크는 백업 데이터에 있는 요소의 수만큼 그려집니다.
        // 즉, 값 목록이 비어 있으면 그려지지 않습니다.
        // 여기서 빈 객체 하나로 데이터 소스를 생성하고 해당 목록을 필터링합니다
        // 신호 값 기준. 이는 그룹 내에서만 수행할 수 있습니다.
        {
          이름: dataForShowAll
          값: [{}]
          변환: [{type: "filter", expr: "groupSelector"}]
        }
      ]
      // 버튼 크기 및 위치 설정
      인코딩: {
        입력: {
          xc: {signal: "width/2"}
          y: {value: 30}
          width: {value: 80}
          height: {value: 30}
        }
      }
      marks: [
        {
          // 이 그룹은 둥근 모서리를 가진 버튼으로 표시됩니다.
          유형: 그룹
          // 마크 이름은 신호 캡처를 허용합니다
          이름: groupReset
          // dataForShowAll에 값이 있는 경우에만 버튼을 표시합니다.
          출처: {data: "dataForShowAll"}
          인코딩: {
            입력: {
              cornerRadius: {value: 6}
              채우기: {value: "#f5f5f5"}
              stroke: {value: "#c1c1c1"}
              strokeWidth: {value: 2}
              // 부모 그룹의 크기 사용
              height: {
                필드: {group: "height"}
              }
              width: {
                필드: {group: "width"}
              }
            }
            업데이트: {
              // 그룹은 기본적으로 투명합니다
              opacity: {value: 1}
            }
            hover: {
              opacity: {value: 0.7}
            }
          }
          marks: [
            {
              유형: 텍스트
              // 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"}
                }
              }
            }
          ]
        }
      ]
    }
  ]
  신호: [
    {
      // 동일한 국가로 오가는 트래픽을 강조하는 데 사용
      이름: groupHover
      값: {}
      on: [
        {
          events: @groupMark:mouseover
          update: "{stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}"
        }
        {events: "mouseout", update: "{}"}
      ]
    }
    // 선택한 국가와 관련된 데이터만 필터링하는 데 사용됩니다
    {
      이름: groupSelector
      값: false
      on: [
        {
          // groupMark를 클릭하면 이 신호가 필터 값으로 설정됩니다
          이벤트: @groupMark:click!
          update: "{stack:datum.stack, stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}"
        }
        {
          // "모두 보기" 버튼을 클릭하거나 아무 곳이나 더블 클릭하면 재설정됩니다
          events: [
            {type: "click", markname: "groupReset"}
            {type: "dblclick"}
          ]
          update: "false"
        }
      ]
    }
  ]
}

구성 요소 강조

스택의 구성 요소를 강조 표시하고 위의 설명 이미지를 생성하려면 url 섹션을 이러한 하드코딩된 값으로 대체하고 다른 모든 표시 뒤에 추가 표시를 추가하십시오.

값: {
  집계: { 테이블: { 버킷: [
    {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}
      width: {scale: "x", band: 1}
      x: {scale: "x", field: "stack"}
      y: {field: "y0", scale: "y"}
      y2: {field: "y1", scale: "y"}
    }
  }
}

유용한 링크:

질문 및 의견

이 블로그 게시물에 대해 논의하려면 당사 포럼을 이용해 주십시오.