Visualisation Sankey avec Vega dans Kibana 6.2
Pour poursuivre notre série sur la création de graphes Vega personnalisés dans Kibana, nous allons aborder aujourd'hui un graphe Sankey simple à deux niveaux pour illustrer les modèles de trafic réseau. (La dernière fois, nous avons abordé la visualisation Vega personnalisée dans Kibana.) Un diagramme de Sankey est un type d'organigramme où la largeur des lignes est proportionnelle à la quantité de flux. Chaque entrée dans les données d'exemple contient le code pays d'origine et de destination. Le graphe disposera de deux modes : « tous vers tous » (par défaut) ; il permettra en outre aux utilisateurs de sélectionner le pays d'origine ou de destination, et d'afficher uniquement le trafic associé.


Prérequis
- Télécharger Elasticsearch 6.2 ou version ultérieure
- Décompressez et lancez l'exécution de
bin/elasticsearch - Télécharger Kibana 6.2 ou version ultérieure(identique version qu'Elasticsearch)
- Décompressez et exécutez
bin/kibana - Utilisez l'utilitaire makelogs pour générer des exemples de données. Installez-le avec
npm install -g makelogs, effectuez l’exécution avecmakelogs. Vous souhaiterez peut-être générer un ensemble de données plus volumineux avec un paramètre-c 100k. Ceci suppose que vous disposez déjà de NPM. Ne faites pas cela sur un cluster de production ! - Accédez à http://localhost:5601/
- Cliquez sur l'onglet Management (Gestion) (dernière icône à gauche), créez un nouveau modèle d'indexation : saisissez
logstash-*pour le modèle d'indexation, cliquez sur Next step (Étape suivante), choisissez@timestamppour le filtre temporel, puis cliquez sur Create index pattern (Créer le modèle d'indexation). - Testez le bon fonctionnement en utilisant l'onglet Discover, en réglant le filtre temporel dans le coin supérieur droit sur la dernière heure et en observant les données générées aléatoirement.
données
Pour cet exemple, j'utiliserai geo.src (en tant que première stack) et geo.dest (en tant que deuxième stack) champs issus des données aléatoires générées par l'utilitaire makelogs. Les champs représentent la source et la destination du trafic. Cette requête effectuerait une agrégation sur les deux champs en utilisant l'agrégation composite Elasticsearch, compter le nombre de documents pour chaque combinaison et renvoyer les 10 premiers.
GET logstash-*/_search
{
"size":0,
"aggs": {
"tableau" : {
"composite": {
"size": 10,
"sources" : [
{"stk1": {"terms": {"champ": "geo.src"}}},
{"stk2": {"terms": {"champ": "geo.dest"}}}
]
}
}
}
}
Essayez d'exécuter cette requête dans l'onglet Dev Tools (Outils de développement) – vous devriez voir quelque chose qui ressemble à ce qui suit :
{
// ... saut de certaines méta-informations ...
"aggregations": {
"tableau" : {
"buckets": [
{
"key" : {
"stk1": "AE",
"stk2": "AR"
},
"doc_count": 1
},
{
"key" : {
"stk1": "AE",
"stk2": "BD"
},
"doc_count": 2
},
// ... 8 objets supplémentaires
]
}
}
Stratégie de visualisation
Nous pouvons considérer le graphe de Sankey comme ayant des Node organisés en suites, et des arêtes connectant les Node. Pour ce graphe, nous n'avons que deux suites : la source et la destination. Les deux suites sont représentées sous forme de barres verticales, une de chaque côté de l'écran. Chaque suite comporte des pays empilés les uns sur les autres. Chaque pays se compose de Node - des portions du pays source qui sont connectées aux Node du pays de destination.

Nous devons disposer de plusieurs tables de données pour tracer le graphe ci-dessus :
- Node Ce tableau ne sera pas utilisé directement pour le dessin, mais servira de source de données pour les autres tableaux. Nous pouvons visualiser les Node comme des boîtes noires dans l'image ci-dessus ; nous en avons 12 ici. Chaque Node se compose du nombre de documents qu'il représente, de l'ID de la suite, du code pays et de la position au sein de la suite (
y0ày1). Notez que ce tableau ne contient aucune coordonnée d'« écran », uniquement les données sources. Même la position dans la suite est exprimée en termes de nombre de documents. Le premier Node irait de0àn1-1, le deuxième den1àn2-1, etc. De plus, comme nous avons deux suites, il y aurait deux premiers, deuxièmes, ... Node, un pour chaque suite. - arêtes Une liste de lignes (6 dans ce cas), chacune connectant un Node sur les côtés gauche et droit. La ligne nécessitera une paire de coordonnées (x, y), une épaisseur de ligne (strikeWidth) et une couleur de ligne (identique à celle du Node source). Ce tableau est généré en prenant toutes les lignes
stack=="stk1"de la table Node, et en recherchant le Node correspondant pour la pile de destination. Une transformation linkpath génère la chaîne de chemin SVG décrivant la ligne courbe. - groups Chaque groupe combine tous les Node pour le même pays et pour la même stack. Dans ce graphe, nous avons 6 groupes, 3 de chaque côté. Chaque groupe doit avoir un ID de stack (stk1 ou stk2), la valeur Y de début et de fin (y0 et y1, de manière similaire à la table des Node) et le code pays. Cette table est également générée à partir de la table des Node, en la regroupant par ID de stack et code pays, et en l'empilant de manière similaire aux Node. Les valeurs y s'aligneront sur la table des Node car nous trions les deux tables sur les mêmes valeurs.
En plus des tables, j’utilise trois échelles pour projeter depuis l’"espace de coordonnées des données" vers l’"espace de coordonnées de l’écran". Le scaler "x" détermine l’emplacement des piles stk1 et stk2 sur l’écran. Le scaler "y" carte la hauteur du graphe à la hauteur totale de tous les Node dans la pile la plus haute (dans notre cas, les deux piles ont la même hauteur). Enfin, le scaler "color" assigne une couleur à chaque pays.
La dernière étape consiste à convertir les tables de données en visuels à l'aide de trois repères : le repère path pour les arêtes, le repère rect pour les piles et le repère de texte pour les étiquettes de pays.
Interactions
À ce stade, vous verrez le graphe attendu, mais vous souhaiterez souvent définir la manière dont les utilisateurs peuvent interagir avec le graphe. Cela s'effectue à l'aide de signaux - des variables dynamiques qui modifient leur valeur en fonction d'événements et qui peuvent être utilisées dans diverses expressions.
Lorsqu'un utilisateur clique sur un groupe de pays dans la pile source, le graphe doit masquer toutes les données qui ne proviennent pas du pays sélectionné. De même, cela doit fonctionner lorsque vous cliquez sur le côté destination. Ceci est effectué en définissant un signal groupSelector. Lorsque groupMark est cliqué, le signal est défini sur le code pays en tant que source ou destination. La table des Node est automatiquement filtrée en fonction de la valeur du signal, et le graphe est redessiné comme si aucune autre donnée n'existait. Pour revenir en arrière, j'affiche une zone rectangulaire au milieu avec un repère textuel. Cliquer sur la case réinitialise le signal, restaurant ainsi la liste complète dans le tableau des Node.
Une autre interaction est le « survol » des pays dans les piles. Lors de l'événement de survol groupMark, le signal groupHover est défini de manière similaire au groupSelector. Le signal est utilisé pour contrôler la transparence des lignes d'arêtes.
Débogage et exploration
Comprendre le code Vega peut parfois s'avérer difficile. Heureusement, vous pouvez accéder à l'état interne de Vega à tout moment à l'aide des outils de débogage du navigateur. Dans Firefox et Chrome, utilisez Control+Shift+I (Windows, Linux) ou Command+Option+I (Mac). Utilisez la console pour voir le contenu du tableau, par ex. console.table(VEGA_DEBUG.view.données('edges')) ou l'état d'un signal avec VEGA_DEBUG.view.signal('height'). Vous pouvez même visualiser les valeurs internes de scaler avec VEGA_DEBUG.view._runtime.scales.y.
Code Vega
Consultez les commentaires du code pour obtenir une explication sur le rôle de chaque ligne, ou lisez la documentation Vega. Notez que ce code utilise HJSON, une forme de JSON plus lisible.
{
$schema: https://vega.github.io/schema/vega/v3.0.json
données: [
{
// interroger ES en fonction de la plage de temps et de la chaîne de filtre actuellement sélectionnées
nom : rawData
url : {
%context%: vrai
%timefield%: @timestamp
index : logstash-*
corps : {
size : 0
aggs: {
tableau : {
composite : {
size: 10000
sources: [
{
stk1: {
termes : {field: "geo.src"}
}
}
{
stk2 : {
termes : {field: "geo.dest"}
}
}
]
}
}
}
}
}
// À partir du résultat, ne prenez que les données qui nous intéressent
format : {property: "aggregations.table.buckets"}
// Convertir key.stk1 -> stk1 pour un accès plus simple ci-dessous
transformer : [
{type: "formula", expr: "datum.key.stk1", as: "stk1"}
{type: "formula", expr: "datum.key.stk2", as: "stk2"}
{type: "formula", expr: "datum.doc_count", as: "size"}
]
}
{
nom : Node
source : rawData
transformer : [
// lorsqu'un pays est sélectionné, filtrer les données non liées
{
type : filtre
expr: !groupSelector || groupSelector.stk1 == datum.stk1 || groupSelector.stk2 == datum.stk2
}
// Définir une nouvelle clé pour les recherches ultérieures - identifie chaque Node
{type: "formula", expr: "datum.stk1+datum.stk2", as: "key"}
// au lieu de chaque ligne de table, créez deux nouvelles lignes,
// un pour la source (stack=stk1) et un pour le Node de destination (stack=stk2).
// Le code pays stocké dans les champs stk1 et stk2 est placé dans le champ grpId.
{
type : pli
champs : ["stk1", "stk2"]
as: ["stack", "grpId"]
}
// Créez une clé de tri, différente pour les piles stk1 et stk2.
// Le séparateur d'espace assure un ordre de tri correct dans certains cas particuliers.
{
type : formule
expr: datum.stack == 'stk1' ? datum.stk1+' '+datum.stk2 : datum.stk2+' '+datum.stk1
as : sortField
}
// Calculer les positions y0 et y1 pour empiler les Node les uns sur les autres,
// indépendamment pour chaque suite, et en veillant à ce qu'ils soient dans le bon ordre,
// alphabétique depuis le haut (inversé sur l'axe des Y)
{
type : suite
groupby: ["stack"]
trier : {field: "sortField", order: "descending"}
champ : taille
}
// calculer le point central vertical pour chaque Node, utilisé pour dessiner les arêtes
{type: "formula", expr: "(datum.y0+datum.y1)/2", as: "yc"}
]
}
{
name : groups
source : Node
transformer : [
// combiner tous les Node en groupes par pays, en additionnant le nombre de documents
{
type : aggregate
groupby : ["stack", "grpId"]
champs : ["size"]
ops : ["sum"]
as: ["total"]
}
// recalculer les valeurs de cumul y0,y1
{
type : suite
groupby: ["stack"]
trier : {field: "grpId", order: "descending"}
champ : total
}
// projeter les valeurs y0 et y1 sur les coordonnées d'écran
// le faire une seule fois ici au lieu de le faire plusieurs fois dans les marques
{type: "formula", expr: "scale('y', datum.y0)", as: "scaledY0"}
{type: "formula", expr: "scale('y', datum.y1)", as: "scaledY1"}
// indicateur booléen si l'étiquette doit être à droite de la pile
{type: "formula", expr: "datum.stack == 'stk1'", as: "rightLabel"}
// Calculer le pourcentage de trafic pour ce pays en utilisant le scaler « y »
// borne supérieure du domaine, qui représente le trafic total
{
type : formule
expr: datum.total/domain('y')[1]
en tant que : pourcentage
}
]
}
{
// Il s'agit d'une table de recherche temporaire contenant tous les Node de la pile 'stk2'
name : destinationNodes
source : Node
transformer : [
{type: "filter", expr: "datum.stack == 'stk2'"}
]
}
{
nom : edges
source : Node
transformer : [
// nous ne voulons que les Node de la pile de gauche
{type: "filter", expr: "datum.stack == 'stk1'"}
// trouver le Node correspondant dans la pile de droite, le conserver en tant que "target"
{
type : recherche
de : destinationNodes
clé : key
champs: ["key"]
as : ["target"]
}
// calculer le chemin de lien SVG entre les piles stk1 et stk2 pour la paire de Node
{
type : linkpath
orientation : horizontale
forme : diagonale
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')"}
}
// Une petite astuce pour calculer l'épaisseur de la ligne.
// La valeur doit être identique à la hauteur du Node, mais le scaling
// la taille par rapport à la hauteur de l'écran donne une valeur inversée car l'axe Y de l'écran
// la coordonnée va du haut vers le bas, tandis que le Y=0 du graphe
// se trouve en bas. La soustraction du nombre de documents mis à l'échelle de la hauteur de l'écran
// (qui est la borne « inférieure » du « y » scaler) nous donne la bonne valeur
{
type : formule
expr : range('y')[0]-scaler('y', datum.size)
as: strokeWidth
}
// L'infobulle a besoin du pourcentage de trafic global pour chaque lien individuel
{
type : formule
expr : datum.size/domain('y')[1]
en tant que : pourcentage
}
]
}
]
scaler: [
{
// calcule le positionnement de la pile horizontale
name : x
type : band
plage : largeur
domain: ["stk1", "stk2"]
paddingOuter : 0,05
paddingInner: 0,95
}
{
// ce scaler monte jusqu’à la valeur y1 la plus élevée de tous les Node
nom : y
type : linear
plage : hauteur
domaine : {data: "nodes", field: "y1"}
}
{
// utilisez rawData pour garantir que les couleurs restent les mêmes lors du clic.
nom : couleur
type : ordinal
plage : catégorie
domaine : {data: "rawData", fields: ["stk1", "stk2"]}
}
{
// ce scaler est utilisé pour Maps les identifiants internes (stk1, stk2) aux noms de la suite
nom : stackNames
type : ordinal
range: ["Source", "Destination"]
domain: ["stk1", "stk2"]
}
]
axes : [
{
// l'axe x doit utiliser un formatage de libellé personnalisé pour afficher les noms de suite appropriés
orient : bas
scaler : x
encodage : {
étiquettes : {
mise à jour : {
texte : {scale: "stackNames", field: "value"}
}
}
}
}
{orient: "left", scale: "y"}
]
marques : [
{
// tracer la ligne de connexion entre les stacks
type : parcours
nom : edgeMark
depuis : {data: "edges"}
// ceci permet de prévenir certains problèmes d’autodimensionnement avec une valeur strokeWidth élevée pour les chemins
clip : true
encodage : {
mise à jour : {
// Par défaut, utilisez la couleur du Node de gauche, sauf lors de l'affichage du trafic
// à partir d'un seul pays, auquel cas utilisez la couleur de destination.
stroke : [
{
test : groupSelector && groupSelector.stack=='stk1'
scaler : couleur
champ : stk2
}
{scale: "color", field: "stk1"}
]
strokeWidth : {field: "strokeWidth"}
path : {field: "path"}
// lors de l'affichage de tout le trafic et du survol d'un pays,
// mettre en évidence le trafic provenant de ce pays.
strokeOpacity : {
signal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2) ? 0,9 : 0,3
}
// Assurez-vous que les arêtes sélectionnées au survol s'affichent au premier plan
zindex: {
signal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2) ? 1 : 0
}
// formater la chaîne d'infobulle
infobulle : {
signal : datum.stk1 + ' → ' + datum.stk2 + ' ' + format(datum.size, ',.0f') + ' (' + format(datum.percentage, '.1%') + ')'
}
}
// Mise en surbrillance simple au survol d'une seule ligne
survol : {
strokeOpacity : {value: 1}
}
}
}
{
// dessiner les groupes de la suite (pays)
type : rect
name : groupMark
depuis : {data: "groups"}
encodage : {
C'est là qu'intervient : {
remplissage : {scale: "color", field: "grpId"}
width : {scale: "x", band: 1}
}
mise à jour : {
x : {scale: "x", field: "stack"}
y : {field: "scaledY0"}
y2 : {field: "scaledY1"}
fillOpacity: {value: 0.6}
infobulle : {
signal : datum.grpId + ' ' + format(datum.total, ',.0f') + ' (' + format(datum.percentage, '.1%') + ')'
}
}
survol : {
fillOpacity: {value: 1}
}
}
}
{
// dessiner les étiquettes de code pays sur le côté intérieur de la pile
type : text
depuis : {data: "groups"}
// ne pas traiter les événements pour les étiquettes - sinon le survol de la ligne n'est pas propre
interactive : faux
encodage : {
mise à jour : {
// selon la pile concernée, positionnez x avec un peu de remplissage
x : {
signal: scaler('x', datum.stack) + (datum.rightLabel ? bandwidth('x') + 8 : -8)
}
// milieu du groupe
yc : {signal: "(datum.scaledY0 + datum.scaledY1)/2"}
aligner : {signal: "datum.rightLabel ? 'left' : 'right'"}
niveau de référence : {value: "middle"}
fontWeight : {value: "bold"}
// afficher l'étiquette de texte uniquement si la hauteur du groupe est suffisante
texte : {signal: "abs(datum.scaledY0-datum.scaledY1) > 13 ? datum.grpId : ''"}
}
}
}
{
// Créer un bouton "show all" (tout afficher). Affiché uniquement lorsqu'un pays est sélectionné.
type : groupe
données: [
// Nous devons faire en sorte que le bouton ne s'affiche que lorsque le signal groupSelector est vrai.
// Chaque marque est dessinée autant de fois qu'il y a d'éléments dans les données de support.
// Ce qui signifie que si la liste des valeurs est vide, elle ne sera pas dessinée.
// Ici, je crée une source de données avec un objet vide, et je filtre cette liste
// basé sur la valeur du signal. Cela ne peut être effectué qu'au sein d'un groupe.
{
nom : dataForShowAll
valeurs : [{}]
transformer : [{type: "filter", expr: "groupSelector"}]
}
]
// Définir la taille et le positionnement du bouton
encodage : {
C'est là qu'intervient : {
xc : {signal: "width/2"}
y : {value: 30}
width : {value: 80}
hauteur : {value: 30}
}
}
marques : [
{
// Ce groupe est affiché sous forme de bouton aux coins arrondis.
type : groupe
// le nom de la marque permet la capture de signaux
nom : groupReset
// Affiche le bouton uniquement si dataForShowAll contient des valeurs.
depuis : {data: "dataForShowAll"}
encodage : {
C'est là qu'intervient : {
cornerRadius : {value: 6}
remplissage : {value: "#f5f5f5"}
trait : {value: "#c1c1c1"}
strokeWidth : {value: 2}
// utiliser la taille du groupe parent
height : {
champ : {group: "height"}
}
width : {
champ : {group: "width"}
}
}
mise à jour : {
// les groupes sont transparents par défaut
opacité : {value: 1}
}
survol : {
opacité : {value: 0.7}
}
}
marques : [
{
type : text
// si vrai, cela empêchera de cliquer sur le bouton lorsqu'il est au-dessus du texte.
interactive : faux
encodage : {
C'est là qu'intervient : {
// centrer le texte dans le groupe de parenthèses
xc : {
champ : {group: "width"}
mult : 0,5
}
yc: {
champ : {group: "height"}
mult : 0,5
offset : 2
}
aligner : {value: "center"}
niveau de référence : {value: "middle"}
fontWeight : {value: "bold"}
texte : {value: "Show All"}
}
}
}
]
}
]
}
]
signaux : [
{
// utilisé pour mettre en évidence le trafic vers/depuis le même pays
nom : groupHover
valeur : {}
sur : [
{
événements : @groupMark:mouseover
mise à jour : "{stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}"
}
{events: "mouseout", update: "{}"}
]
}
// utilisé pour filtrer uniquement les données relatives au pays sélectionné
{
nom : groupSelector
valeur : faux
sur : [
{
// Cliquer sur groupMark définit ce signal sur les valeurs du filtre
événements : @groupMark:click !
mise à jour : "{stack:datum.stack, stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}"
}
{
// Cliquer sur le bouton "show all" (afficher tout) ou double-cliquer n'importe où permet de réinitialiser
événements : [
{type: "click", markname: "groupReset"}
{type: "dblclick"}
]
update: "false"
}
]
}
]
}
Mise en évidence des composants
Pour mettre en évidence les composants des piles et générer l'image explicative ci-dessus, remplacez la section url par ces valeurs codées en dur et ajoutez une marque supplémentaire après toutes les autres marques.
valeurs : {
agrégations : { 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}
]}}}
{
type : rect
depuis : {data: "nodes"}
encodage : {
C'est là qu'intervient : {
trait : {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"}
}
}
}
Liens utiles :
Questions et commentaires
Pour discuter de cet article de blog, veuillez utiliser notre forum