Produto

Visualização Sankey com Vega no Kibana 6.2

Dando continuidade à série sobre a criação de gráficos personalizados do Vega no Kibana, o tópico de hoje é um gráfico de Sankey simples de dois níveis para mostrar padrões de tráfego de rede. (Na última vez, discutimos visualização personalizada do Vega no Kibana.) Um diagrama de Sankey é um tipo de diagrama de fluxo com a largura das linhas mostrada proporcionalmente à quantidade de fluxo. Cada entrada nos dados de amostra tem o código do país de origem e de destino. O gráfico terá dois modos: all-to-all (padrão), além de permitir que os usuários selecionem o país de origem ou de destino e mostrem apenas o tráfego relacionado.

Tráfego entre todos os países

Tráfego de um país selecionado

Pré-requisitos

  • Baixe Elasticsearch 6.2 ou posterior
  • Descompacte e execute bin/elasticsearch
  • Baixar Kibana 6.2 ou posterior(mesmo versão como Elasticsearch)
  • Descompacte e execute bin/kibana
  • Use o utilitário makelogs para gerar dados de exemplo. Instale-o com npm install -g makelogs, execute com makelogs. Talvez você queira gerar um conjunto de dados maior com um parâmetro -c 100k. Isso pressupõe que você já tenha o NPM. Não faça isso em um cluster de produção!
  • Navegue até http://localhost:5601/
  • Clique na guia Management (último ícone à esquerda), crie um novo padrão de indexação: insira logstash-* para o padrão de indexação, clique em Next step, escolha @timestamp para o filtro de tempo e clique em Create index pattern.
  • Teste se tudo funciona usando a aba Discover, definindo o filtro de tempo no canto superior direito para a última 1 hora e observando dados gerados aleatoriamente.

dados

Para este exemplo, usarei geo.src (como o primeiro stack) e geo.dest (como o segundo stack) campos dos dados aleatórios gerados pelo utilitário makelogs. Os campos representam a origem e o destino do tráfego. Esta consulta faria a agregação por ambos os campos usando a agregação composta do Elasticsearch, contando o número de documentos para cada combinação e retornando os 10 primeiros.

GET logstash-*/_search
{
  "size":0,
  "aggs": {
    "table": {
      "composite": {
        "size": 10,
        "sources": [
          {"stk1": {"terms": {"campo": "geo.src"}}},
          {"stk2": {"terms": {"campo": "geo.dest"}}}
        ]
      }
    }
  }
}

Tente executar esta consulta na guia Dev Tools - você deverá ver algo como:

{
  // ... ignorando algumas metainformações ...
  "aggregations": {
    "table": {
      "buckets": [
        {
          "key": {
            "stk1": "AE",
            "stk2": "AR"
          },
          "doc_count": 1
        },
        {
          "key": {
            "stk1": "AE",
            "stk2": "BD"
          },
          "doc_count": 2
        },
        // ... mais 8 objetos
    ]
  }
}

Estratégia de visualização

Podemos pensar no gráfico Sankey como tendo Node organizados em stacks, e arestas conectando os Node. Para este gráfico, temos apenas duas stacks: a de origem e a de destino. As duas stacks são desenhadas como barras verticais, uma de cada lado da tela. Cada stack tem países empilhados uns sobre os outros. Cada país consiste em Node - partes do país de origem que estão conectadas aos Node no país de destino.

Componentes Sankey

Precisamos ter várias tabelas de dados para desenhar o gráfico acima:

  • Node Esta tabela não será usada para desenho diretamente, mas será usada como fonte de dados para as outras tabelas. Podemos visualizar os Node como caixas pretas na imagem acima - temos 12 deles aqui. Cada Node consiste na contagem de documentos que ele representa, no ID da stack, no código do país e na posição dentro da stack (y0 a y1). Observe que esta tabela não possui nenhuma coordenada de "tela", apenas os dados de origem. Até mesmo a posição na stack é expressa em termos de contagens de documentos. O primeiro Node iria de 0..n1-1, o segundo seria n1..n2-1, etc. Além disso, como temos duas stacks, haveria dois dos primeiros, segundos, ... Node, um para cada stack.
  • arestas Uma lista de linhas (6 neste caso), cada uma conectando um Node nos lados esquerdo e direito. A linha precisará de um par de coordenadas (x, y), espessura da linha (strikeWidth) e cor da linha (a mesma do Node). Esta tabela é gerada pegando todas as linhas stack=="stk1" da tabela de Node e procurando o Node correspondente para o stack de destino. Uma transformação linkpath gera a string de caminho SVG que descreve a linha curva.
  • grupos Cada grupo combina todos os Node do mesmo país para a mesma stack. Neste gráfico temos 6 grupos, 3 de cada lado. Cada grupo precisa ter um ID de stack (stk1 ou stk2), o valor Y inicial e final (y0 e y1, similar à tabela de Node) e o código do país. Esta tabela também é gerada a partir da tabela de Node, agrupando-a por ID de stack e código do país, e empilhando-a de forma similar aos Node. Os valores y se alinharão com a tabela de Node porque classificamos ambas as tabelas pelos mesmos valores.

Além das tabelas, uso três escalas para projetar do "espaço de coordenadas de dados" para o "espaço de coordenadas da tela". O redimensionar "x" determina a localização das pilhas stk1 e stk2 na tela. A escala "y" mapeia a altura do gráfico para a altura total de todos os Node na pilha mais alta (no nosso caso, ambas as pilhas têm a mesma altura). Por fim, o redimensionar "color" atribui uma cor a cada país.

A última etapa é converter tabelas de dados em visuais usando três marcas: a marca path para arestas, a marca rect para as pilhas e a marca de texto para rótulos de país.

Interações

Neste ponto, você verá o gráfico esperado, mas muitas vezes você pode querer definir como os usuários podem interagir com o gráfico. Isso é feito com sinais — variáveis dinâmicas que alteram seu valor com base em eventos e podem ser usadas em várias expressões.

Quando um usuário clica em um grupo de países na stack de origem, o gráfico precisa ocultar todos os dados que não sejam do país clicado. Da mesma maneira, deve funcionar ao clicar no lado de destino. Isso é feito definindo um sinal groupSelector. Quando groupMark é clicado, o sinal é definido como o código do país como origem ou destino. A tabela de Node é filtrada automaticamente com base no valor do sinal, e o gráfico é redesenhado como se não existissem outros dados. Para voltar, mostro uma caixa retangular no meio com uma marca de texto. Clicar na caixa redefine o sinal, restaurando assim a tabela de Node para a lista completa.

Outra interação é o “mouseover” em países nas stacks. No evento mouseover de groupMark, o sinal groupHover é definido de forma semelhante ao groupSelector. O sinal é usado para controlar a transparência das linhas das arestas.

Depuração e exploração

Entender o código do Vega pode ser desafiador às vezes. Felizmente, você pode acessar o estado interno do Vega a qualquer momento usando as ferramentas de depuração do navegador. No Firefox e no Chrome, use Control+Shift+I (Windows, Linux) ou Command+Option+I (Mac). Use o console para ver o conteúdo da tabela, por exemplo, console.table(VEGA_DEBUG.view.dados('edges'))

[[ ## completed ## ] ] ou o estado de um sinal com VEGA_DEBUG.view.signal('height'). Você pode até visualizar valores internos de escala com VEGA_DEBUG.view._runtime.scales.y.

Código Vega

Veja os comentários do código para uma explicação do que cada linha faz ou leia a Documentação do Vega. Observe que este código usa HJSON, uma forma mais legível de JSON.

{
  $schema: https://vega.github.io/schema/vega/v3.0.json
  dados: [
    {
      // consultar o ES com base no intervalo de tempo selecionado atualmente e na string de filtro
      name: rawData
      url: {
        %context%: verdadeiro
        %timefield%: @timestamp
        índice: logstash-*
        body: {
          size: 0
          aggs: {
            tabela: {
              composto: {
                tamanho: 10.000
                sources: [
                  {
                    stk1: {
                      termos: {field: "geo.src"}
                    }
                  }
                  {
                    stk2: {
                      termos: {field: "geo.dest"}
                    }
                  }
                ]
              }
            }
          }
        }
      }
      // Do resultado, pegue apenas os dados nos quais estamos interessados
      formato: {property: "aggregations.table.buckets"}
      // Converter key.stk1 -> stk1 para acesso mais simples abaixo
      transformação: [
        {type: "formula", expr: "datum.key.stk1", as: "stk1"}
        {type: "formula", expr: "datum.key.stk2", as: "stk2"}
        {type: "formula", expr: "datum.doc_count", as: "size"}
      ]
    }
    {
      nome: Node
      fonte: rawData
      transformação: [
        // quando um país for selecionado, filtre dados não relacionados
        {
          tipo: filtro
          expr: !groupSelector || groupSelector.stk1 == datum.stk1 || groupSelector.stk2 == datum.stk2
        }
        // Definir nova chave para pesquisas posteriores - identifica cada Node
        {type: "formula", expr: "datum.stk1+datum.stk2", as: "key"}
        // em vez de cada linha da tabela, crie duas novas linhas,
        // um para a origem (stack=stk1) e um para o Node de destino (stack=stk2).
        // O código do país armazenado nos campos stk1 e stk2 é colocado no campo grpId.
        {
          tipo: dobra
          campos: ["stk1", "stk2"]
          as: ["stack", "grpId"]
        }
        // Criar uma sortkey, diferente para as stacks stk1 e stk2.
        // O separador de espaço garante a ordem de classificação correta em alguns casos específicos.
        {
          tipo: fórmula
          expr: datum.stack == 'stk1' ? datum.stk1+' '+datum.stk2 : datum.stk2+' '+datum.stk1
          como: sortField
        }
        // Calcule as posições y0 e y1 para empilhar os Node um sobre o outro,
        // independentemente para cada stack, e garantindo que estejam na ordem correta,
        // em ordem alfabética a partir do topo (invertido no eixo y)
        {
          tipo: stack
          groupby: ["stack"]
          sort: {field: "sortField", order: "descending"}
          campo: tamanho
        }
        // calcula o ponto central vertical para cada Node, usado para desenhar arestas
        {type: "formula", expr: "(datum.y0+datum.y1)/2", as: "yc"}
      ]
    }
    {
      nome: grupos
      origem: Node
      transformação: [
        // combinar todos os Node em grupos de países, somando as contagens de documentos
        {
          type: aggregate
          groupby: ["stack", "grpId"]
          campos: ["size"]
          ops: ["sum"]
          como: ["total"]
        }
        // recalcular os valores de empilhamento y0,y1
        {
          tipo: stack
          groupby: ["stack"]
          sort: {field: "grpId", order: "descending"}
          campo: total
        }
        // projetar valores y0 e y1 para coordenadas de tela
        // fazendo isso uma vez aqui em vez de fazer várias vezes nas marcas
        {type: "formula", expr: "scale('y', datum.y0)", as: "scaledY0"}
        {type: "formula", expr: "scale('y', datum.y1)", as: "scaledY1"}
        // flag booleana se o rótulo deve estar à direita da pilha
        {type: "formula", expr: "datum.stack == 'stk1'", as: "rightLabel"}
        // Calcular a porcentagem de tráfego para este país usando "y" redimensionar
        // limite superior do domínio, que representa o tráfego total
        {
          tipo: fórmula
          expr: datum.total/domain('y')[1]
          como: porcentagem
        }
      ]
    }
    {
      // Esta é uma tabela de consulta temporária com todos os Node de pilha 'stk2'
      name: destinationNodes
      origem: Node
      transformação: [
        {type: "filter", expr: "datum.stack == 'stk2'"}
      ]
    }
    {
      nome: bordas
      origem: Node
      transformação: [
        // queremos apenas Node da pilha esquerda
        {type: "filter", expr: "datum.stack == 'stk1'"}
        // encontre o node correspondente da pilha da direita, mantenha-o como "target"
        {
          type: lookup
          from: destinationNodes
          chave: chave
          campos: ["key"]
          as: ["target"]
        }
        // calcular o caminho do link SVG entre as pilhas stk1 e stk2 para o par de Node
        {
          tipo: linkpath
          orient: horizontal
          forma: diagonal
          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')"}
        }
        // Um pequeno truque para calcular a espessura da linha.
        // O valor precisa ser o mesmo que a altura do Node, mas redimensionamento
        // o tamanho em relação à altura da tela fornece um valor invertido porque o Y da tela
        // a coordenada vai do topo para a parte inferior, enquanto o Y=0 do gráfico
        // está na parte inferior. Portanto, subtraindo a contagem de documentos escalonada da altura da tela
        // (que é o limite "inferior" da escala "y") nos dá o valor correto
        {
          tipo: fórmula
          expr: range('y')[0]-redimensionar('y', datum.size)
          como: strokeWidth
        }
        // A dica de ferramenta precisa da porcentagem de tráfego total de cada link individual
        {
          tipo: fórmula
          expr: datum.size/domain('y')[1]
          como: porcentagem
        }
      ]
    }
  ]
  redimensionar: [
    {
      // calcula o posicionamento do empilhamento horizontal
      nome: x
      tipo: band
      intervalo: largura
      domain: ["stk1", "stk2"]
      paddingOuter: 0,05
      paddingInner: 0,95
    }
    {
      // este redimensionamento vai até o valor y1 mais alto de todos os Node
      nome: y
      tipo: linear
      intervalo: altura
      domínio: {data: "nodes", field: "y1"}
    }
    {
      // use rawData para garantir que as cores permaneçam as mesmas ao clicar.
      name: color
      tipo: ordinal
      range: category
      domínio: {data: "rawData", fields: ["stk1", "stk2"]}
    }
    {
      // este redimensionar é usado para mapear ids internos (stk1, stk2) para nomes de stack
      name: stackNames
      tipo: ordinal
      range: ["Source", "Destination"]
      domain: ["stk1", "stk2"]
    }
  ]
  axes: [
    {
      // o eixo x deve usar formatação de rótulo personalizada para imprimir nomes de stack adequados
      orient: bottom
      redimensionar: x
      codificar: {
        labels: {
          atualização: {
            texto: {scale: "stackNames", field: "value"}
          }
        }
      }
    }
    {orient: "left", scale: "y"}
  ]
  marcas: [
    {
      // desenhar a linha de conexão entre os stacks
      tipo: caminho
      nome: edgeMark
      de: {data: "edges"}
      // isso evita alguns problemas de autodimensionamento com strokeWidth grande para caminhos
      clip: true
      codificar: {
        atualização: {
          // Por padrão, use a cor do node à esquerda, exceto ao mostrar o tráfego
          // de apenas um país, caso em que use a cor de destino.
          stroke: [
            {
              test: groupSelector && groupSelector.stack=='stk1'
              redimensionar: cor
              campo: stk2
            }
            {scale: "color", field: "stk1"}
          ]
          strokeWidth: {field: "strokeWidth"}
          caminho: {field: "path"}
          // ao mostrar todo o tráfego e passar o mouse sobre um país,
          // destacar o tráfego daquele país.
          strokeOpacity: {
            signal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2) ? 0,9 : 0,3
          }
          // Garanta que as arestas selecionadas ao passar o ponteiro do mouse apareçam no topo
          zindex: {
            signal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2) ? 1 : 0
          }
          // formatar string de dica de ferramenta
          dica de ferramenta: {
            signal: datum.stk1 + ' → ' + datum.stk2 + '    ' + format(datum.size, ',.0f') + '   (' + format(datum.percentage, '.1%') + ')'
          }
        }
        // Realce simples ao passar o mouse sobre uma única linha
        hover: {
          strokeOpacity: {value: 1}
        }
      }
    }
    {
      // desenhar grupos de stack (países)
      tipo: rect
      name: groupMark
      de: {data: "groups"}
      codificar: {
        entrar: {
          preencher: {scale: "color", field: "grpId"}
          largura: {scale: "x", band: 1}
        }
        atualização: {
          x: {scale: "x", field: "stack"}
          y: {field: "scaledY0"}
          y2: {field: "scaledY1"}
          fillOpacity: {value: 0.6}
          dica de ferramenta: {
            sinal: datum.grpId + '   ' + format(datum.total, ',.0f') + '   (' + format(datum.percentage, '.1%') + ')'
          }
        }
        hover: {
          fillOpacity: {value: 1}
        }
      }
    }
    {
      // desenhar rótulos de código de país no lado interno da stack
      tipo: texto
      de: {data: "groups"}
      // não processar eventos para os rótulos - caso contrário, o mouseover da linha fica sujo
      interactive: falso
      codificar: {
        atualização: {
          // dependendo de qual stack seja, posicione x com algum preenchimento
          x: {
            signal: redimensionar('x', datum.stack) + (datum.rightLabel ? bandwidth('x') + 8 : -8)
          }
          // meio do grupo
          yc: {signal: "(datum.scaledY0 + datum.scaledY1)/2"}
          alinhar: {signal: "datum.rightLabel ? 'left' : 'right'"}
          linha de base: {value: "middle"}
          fontWeight: {value: "bold"}
          // exibir apenas o rótulo de texto se a altura do grupo for grande o suficiente
          texto: {signal: "abs(datum.scaledY0-datum.scaledY1) > 13 ? datum.grpId : ''"}
        }
      }
    }
    {
      // Crie um botão "show all". Exibido apenas quando um país é selecionado.
      tipo: grupo
      dados: [
        // Precisamos fazer com que o botão apareça apenas quando o sinal groupSelector for verdadeiro.
        // Cada marca é desenhada tantas vezes quantos forem os elementos nos dados de suporte.
        // O que significa que, se a lista de valores estiver vazia, ela não será desenhada.
        // Aqui eu crio uma fonte de dados com um objeto vazio e filtro essa lista
        // com base no valor do sinal. Isso só pode ser feito em um grupo.
        {
          name: dataForShowAll
          valores: [{}]
          transformação: [{type: "filter", expr: "groupSelector"}]
        }
      ]
      // Definir tamanho e posicionamento do botão
      codificar: {
        entrar: {
          xc: {signal: "width/2"}
          y: {value: 30}
          largura: {value: 80}
          height: {value: 30}
        }
      }
      marcas: [
        {
          // Este grupo é exibido como um botão com cantos arredondados.
          tipo: grupo
          // o nome da marca permite a captura de sinal
          name: groupReset
          // Exibe o botão apenas se dataForShowAll tiver valores.
          de: {data: "dataForShowAll"}
          codificar: {
            entrar: {
              cornerRadius: {value: 6}
              preencher: {value: "#f5f5f5"}
              stroke: {value: "#c1c1c1"}
              strokeWidth: {value: 2}
              // usar o tamanho do grupo pai
              height: {
                campo: {group: "height"}
              }
              largura: {
                campo: {group: "width"}
              }
            }
            atualização: {
              // os grupos são transparentes por padrão
              opacity: {value: 1}
            }
            hover: {
              opacity: {value: 0.7}
            }
          }
          marcas: [
            {
              tipo: texto
              // se verdadeiro, isso evitará clicar no botão quando estiver sobre o texto.
              interactive: falso
              codificar: {
                entrar: {
                  // centralizar texto no grupo paren
                  xc: {
                    campo: {group: "width"}
                    mult: 0,5
                  }
                  yc: {
                    campo: {group: "height"}
                    mult: 0,5
                    offset: 2
                  }
                  alinhar: {value: "center"}
                  linha de base: {value: "middle"}
                  fontWeight: {value: "bold"}
                  texto: {value: "Show All"}
                }
              }
            }
          ]
        }
      ]
    }
  ]
  sinais: [
    {
      // usado para destacar o tráfego de/para o mesmo país
      name: groupHover
      valor: {}
      on: [
        {
          eventos: @groupMark:mouseover
          update: "{stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}"
        }
        {events: "mouseout", update: "{}"}
      ]
    }
    // usado para filtrar apenas os dados relacionados ao país selecionado
    {
      name: groupSelector
      value: falso
      on: [
        {
          // Clicar em groupMark define este sinal para os valores de filtro
          eventos: @groupMark:click!
          update: "{stack:datum.stack, stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}"
        }
        {
          // Clicar no botão "show all" (mostrar tudo) ou clicar duas vezes em qualquer lugar redefine isso
          eventos: [
            {type: "click", markname: "groupReset"}
            {type: "dblclick"}
          ]
          update: "false"
        }
      ]
    }
  ]
}

Componentes de destaque

Para destacar os componentes das pilhas e gerar a imagem de explicação acima, substitua a seção url por estes valores fixos e adicione uma marca adicional após todas as outras marcas.

values: {
  agregações: { table: { buckets: [
    {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}
]}}}


{
  tipo: rect
  de: {data: "nodes"}
  codificar: {
    entrar: {
      stroke: {value: "#000"}
      strokeWidth: {value: 2}
      largura: {scale: "x", band: 1}
      x: {scale: "x", field: "stack"}
      y: {field: "y0", scale: "y"}
      y2: {field: "y1", scale: "y"}
    }
  }
}

Links úteis:

Dúvidas e comentários

Para discutir este post do blog, use nosso fórum