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.


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 commakelogs. 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@timestamppara 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.

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 (
y0ay1). 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 de0..n1-1, o segundo serian1..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:
- Documentos do Vega para Kibana
- Documentação do Vega
- Exemplos do Vega
- Documentação do Vega-Lite
- Exemplos do Vega-Lite
Dúvidas e comentários
Para discutir este post do blog, use nosso fórum