Pourquoi vouloir faire cohabiter Vue et D3 ?

Vue est un excellent framework pour construire des applications web complexes. Son coté minimaliste lui permet de rester léger mais si on veut créer une application riche en visualisations, Vue ne suffit pas. Il faut alors se tourner vers d’autres outils comme la librairie D3 de Mike Bostock.

D3 permet de créer des visualisations complexes, dynamiques et adaptées aux besoins les plus fins, mais son fonctionnement est indépendant de tout framework, ce qui rend l’intégration d’une visualisation D3 à un composant Vue à la charge du développeur.

Dans cet article nous verrons comment faire cohabiter ces deux outils pour créer un composant Vue qui contient une visualisation D3.

Fonctionnement de Vue

Vue est un framework qui s’appuie sur le principe de composants réutilisables. En découpant notre application dans plusieurs composants à responsabilité réduite, on crée facilement une application puissante et maintenable.

Un point très intéressant de Vue est la réactivité de son modèle de données. Les changements de données sont détectés automatiquement et les composants qui en dépendent se mettent à jour immédiatement.

Fonctionnement de D3

D3 est une librairie de visualisation plutôt bas-niveau. Il n’y a pas de graphiques tout fait, mais plutôt une collection de fonctions qui permettent de faire des visualisations adaptées aux besoins les plus fins.

D3 est composé de plusieurs modules, qui vont des fonctions de mathématiques et d’analyse, à des outils pour modifier directement le DOM.

Dans cet article nous utiliserons qu’une petite sous partie de l’ensemble des modules de D3 :

  • d3-array Des fonctions sur les listes
  • d3-scale Des fonctions pour faire correspondre des données vers un espace différent, comme un nombre de pixels
  • d3-selection Des fonctions pour modifier le DOM

Outre l’absence totale de composants (D3 n’est pas un framework) une grosse différence entre D3 et Vue est la façon de modifier le DOM à partir des données.

Vue utilise un DOM virtuel qui est créé à partir de l’architecture des composants, et qui se met à jour avec son modèle de données réactif.

D3 modifie directement le DOM et se met à jour manuellement avec un pattern appelé “General Update Pattern”

Un composant histogramme avec D3 dans une application Vue

Pour le besoin de cet article nous allons créer un composant réutilisable qui utilise D3 pour afficher un histogramme.

Ce composant aura 4 propriétés :

  • un tableau de valeurs
  • le nombre de barres qui composent l’histogramme
  • la hauteur du graphique
  • la largeur du graphique

Pour l’affichage de l’histogramme dans le DOM, on va utiliser un svg inline.

Le composant a donc pour structure :

<template>
  <svg :width="width" :height="height"></svg>
</template>
<script>
import * as d3 from "d3";

export default {
  name: "Histogram",
  props: {
    // taille en pixel du composant
    width: { type: Number },
    height: { type: Number },

    // Une liste de nombres
    data: { type: Array },

    // Combien de Bins (combien de barres)
    numBins: { type: Number, default: 40 }
  },
}
</script>

Histogramme des données

D3 a une fonction qui permet de facilement compter la distribution d’un tableau de données dans plusieurs bins. Chaque bin représente le nombre de valeurs entre deux bornes. Par exemple la bin 0-10 contient l’ensemble des points compris entre 0 et 10.

Nous allons utiliser une computed prop de Vue pour créer la distribution qui nous servira ensuite pour l’affichage de l’histogramme. Mettre cette fonction dans une computed nous permet de s’assurer qu’elle est toujours à jour par rapport à notre propriété this.data

histogram() {
  const countHistogram = d3
    .histogram()
    .domain(d3.extent(this.data)) // récupère le min et le max de this.data dans un tableau
    .thresholds(this.numBins); // on assigne le nombre de bins
  return countHistogram(this.data) // on appelle la fonction sur nos nouvelles données
    .map(bins => ({ x: bins.x0, y: bins.length })); // on garde uniquement le nombre d'éléments et la valeur de départ de la bin
}

Render

Pour créer le svg de l’histogramme nous avons besoin de créer un rect pour chaque bin. Contrairement aux composants classiques, le template Vue ne contient pas ce qui va être affiché, c’est D3 qui va s’en charger.

On va donc créer une fonction renderSvg qui peut accéder au DOM directement avec this.$el

d3-scale

Premièrement nous avons besoin de créer des scales. Ces scales nous permettent d’associer des valeurs sur des positions en pixels. Par exemple, nous avons une première bin qui contient 10 éléments, la question est de savoir quelle est la taille et la position du rectangle associé. Nous avons 2 scales :

La scale x qui associe une valeur à l’axe horizontal du graphique. Il y a les valeurs de l’histogramme (le domain pour d3) et un axe qui fait 300 pixels, et nous voulons une marge entre chaque barre de 20%.

const x = d3
  .scaleBand()
  .padding(0.2)
  .domain(this.histogram.map(d => d.x))
  .rangeRound([0, this.width]);

Et l’autre scale, y qui associe le nombre de points dans chaque bin à une hauteur du rectangle. Le domain contient les valeurs extrêmes, c’est à dire 0 et la taille de la plus grande bin.

const y = d3
  .scaleLinear()
  .domain([0, d3.max(this.histogram, d => d.y)])
  .range([this.height, 0]);

D3 General update pattern

Enfin il faut afficher les points, je ne rentrerai pas trop dans les détails, mais le code suivant parcourt en même temps this.histogram et le selecteur 'rect' et créé un rect pour chaque data supplémentaire. Si vous voulez plus de détails sur les sélections d3, je vous conseille les exemples de General Update Pattern par Mike Bostock

this.svg.selectAll("rect").data(this.histogram)
  .enter() // quand il y a plus de données que d'élements rect
  .append("rect") // on rajoute un élément rect avec la class "bar"
  .attr("class", "bar")
  .merge(selection) // on fait un update et un enter
  .attr(
    "transform",
    d => `translate(${x(d.x)} ${this.height}) scale(1 -1)`
  ) // on positionne la barre horizontalement
  .attr("width", x.bandwidth()) // largeur de la barre est définie par la scale x
  .attr("height", d => y(d.y)); // on change la hauteur de la barre

Watchers

Comme D3 modifie directement le DOM, il n’est pas lié à la réactivité des données Vue. C’est donc à nous de décider quand et pourquoi il faut appeler renderSvg().

La méthode renderSvg() doit être appelée à chaque fois que l’on veut modifier l’intérieur du svg. C’est donc quand les props data et numBins changent que nous devons modifier le DOM à nouveau.

Il suffit d’ajouter un champ watch à notre composant

watch: {
  data: "renderSvg",
  numBins: "renderSvg"
}

De plus au moment de la création de notre histogramme nous devons aussi afficher le svg dans la fonction mounted()

Démo

Inconvénients de cette méthode

Intégrer D3 dans Vue de cette façon apporte son lot de difficultés supplémentaires, qui peuvent être contournées plus ou moins facilement.

Mise à jour asynchrone de Vue

Vue modifie le DOM de façon asynchrone, c’est à dire qu’un changement de donnée ne modifie pas immédiatement le DOM. Cependant dans notre exemple nous utilisons d3 pour modifier le DOM immédiatement (avec le watcher), ce qui peut compliquer certains évenements.

Par exemple :

Si une partie du composant fonctionne avec Vue et l’autre avec D3 et que nous avons besoin de calculer la taille d’une div depuis le DOM, la div risque de ne pas exister au moment du watch.

La solution classique est d’utiliser la fonction de Vue $nextTick() qui permet d’attendre l’update de Vue avant de faire nos propres changements au DOM

Si vous voulez plus d’informations sur le comportement de Vue je vous conseille la très bonne documentation de Vue - Async Update Queue

Scoped style

Vue permet d’appliquer un style uniquement au niveau d’un composant, avec le scoped CSS. Ce CSS ne sera pas appliqué aux éléments créés par D3.

Pour contourner ce problème Vue propose une syntaxe spéciale, le Deep Selector :

.histogram >>> rect.bar {
  fill: #009688;
}

Ici .histogram est appliqué par Vue et rect.bar par D3. Le selector >>> permet d’appliquer le style même dans le cas d’un style de type scoped

Conclusion

Nous avons pu voir qu’il est relativement simple de faire co-exister D3 dans un composant Vue. En prenant bien en compte les particularités des deux librairies on peut facilement créer des graphiques réutilisables, dynamiques et intégrés dans une application Vue.