Produit

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é.

Trafic entre tous les pays

Trafic depuis un pays sélectionné

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 avec makelogs. 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 @timestamp pour 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.

Composants Sankey

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 de 0 à n1-1, le deuxième de n1 à 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