Vers des graphiques D3.js actualisables
Publié: 2022-03-11introduction
D3.js est une bibliothèque open source pour les visualisations de données développée par Mike Bostock. D3 signifie documents pilotés par les données et, comme son nom l'indique, la bibliothèque permet aux développeurs de générer et de manipuler facilement des éléments DOM basés sur des données. Bien qu'il ne soit pas limité par les capacités de la bibliothèque, D3.js est généralement utilisé avec des éléments SVG et offre des outils puissants pour développer des visualisations de données vectorielles à partir de zéro.
Commençons par un exemple simple. Supposons que vous vous entraîniez pour une course de 5 km et que vous souhaitiez créer un graphique à barres horizontales du nombre de kilomètres parcourus chaque jour de la semaine dernière :
var milesRun = [2, 5, 4, 1, 2, 6, 5]; d3.select('body').append('svg') .attr('height', 300) .attr('width', 800) .selectAll('rect') .data(milesRun) .enter() .append('rect') .attr('y', function (d, i) { return i * 40 }) .attr('height', 35) .attr('x', 0) .attr('width', function (d) { return d*100}) .style('fill', 'steelblue');
Pour le voir en action, consultez-le sur bl.ocks.org.
Si ce code vous semble familier, c'est très bien. Sinon, j'ai trouvé que les tutoriels de Scott Murray étaient une excellente ressource pour démarrer avec D3.js.
En tant que pigiste ayant travaillé des centaines d'heures à développer avec D3.js, mon modèle de développement a connu une évolution, toujours dans le but de créer les expériences client et utilisateur les plus complètes. Comme je l'expliquerai plus en détail plus tard, le modèle de Mike Bostock pour les graphiques réutilisables offrait une méthode éprouvée pour implémenter le même graphique dans n'importe quel nombre de sélections. Cependant, ses limitations sont réalisées une fois le graphique initialisé. Si je voulais utiliser les transitions et les modèles de mise à jour de D3 avec cette méthode, les modifications apportées aux données devaient être entièrement gérées dans le cadre de la génération du graphique. En pratique, cela signifiait implémenter des filtres, des sélections déroulantes, des curseurs et des options de redimensionnement dans la même portée de fonction.
Après avoir expérimenté à plusieurs reprises ces limitations, j'ai voulu créer un moyen d'exploiter toute la puissance de D3.js. Par exemple, écouter les modifications apportées à une liste déroulante d'un composant complètement séparé et déclencher de manière transparente des mises à jour de graphiques d'anciennes données vers de nouvelles. Je voulais pouvoir transmettre les commandes du graphique avec toutes les fonctionnalités, et le faire de manière logique et modulaire. Le résultat est un modèle de graphique pouvant être mis à jour, et je vais parcourir ma progression complète vers la création de ce modèle.
Progression des modèles de graphiques D3.js
Étape 1 : Variables de configuration
Lorsque j'ai commencé à utiliser D3.js pour développer des visualisations, il est devenu très pratique d'utiliser des variables de configuration pour définir et modifier rapidement les spécifications d'un graphique. Cela a permis à mes graphiques de gérer toutes les différentes longueurs et valeurs de données. Le même morceau de code qui affichait les kilomètres parcourus pouvait désormais afficher une liste plus longue de températures sans aucun problème :
var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68]; var height = 300; var width = 800; var barPadding = 1; var barSpacing = height / highTemperatures.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(highTemperatures); var widthScale = width / maxValue; d3.select('body').append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(highTemperatures) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', 'steelblue');
Pour le voir en action, consultez-le sur bl.ocks.org.
Remarquez comment les hauteurs et les largeurs des barres sont mises à l'échelle en fonction de la taille et des valeurs des données. Une variable est modifiée et le reste est pris en charge.
Étape 2 : Répétition facile à travers les fonctions
En faisant abstraction d'une partie de la logique métier, nous sommes en mesure de créer un code plus polyvalent, prêt à gérer un modèle généralisé de données. L'étape suivante consiste à encapsuler ce code dans une fonction de génération, ce qui réduit l'initialisation à une seule ligne. La fonction prend trois arguments : les données, une cible DOM et un objet d'options qui peut être utilisé pour écraser les variables de configuration par défaut. Jetez un oeil à la façon dont cela peut être fait:
var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; function drawChart(dom, data, options) { var width = options.width || 800; var height = options.height || 200; var barPadding = options.barPadding || 1; var fillColor = options.fillColor || 'steelblue'; var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(dom).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); } var weatherOptions = {fillColor: 'coral'}; drawChart('#weatherHistory', highTemperatures, weatherOptions); var runningOptions = {barPadding: 2}; drawChart('#runningHistory', milesRun, runningOptions);
Pour le voir en action, consultez-le sur bl.ocks.org.
Il est également important de noter les sélections D3.js dans ce contexte. Les sélections générales comme d3.selectAll('rect')
doivent toujours être évitées. Si des SVG sont présents ailleurs sur la page, tous les rect
de la page font partie de la sélection. Au lieu de cela, en utilisant la référence DOM transmise, créez un objet svg
auquel vous pouvez vous référer lors de l'ajout et de la mise à jour d'éléments. Cette technique peut également améliorer le temps d'exécution de la génération de graphiques, car l'utilisation d'une référence comme les barres évite également d'avoir à refaire la sélection D3.js.
Étape 3 : Enchaînement des méthodes et sélections
Alors que le squelette précédent utilisant des objets de configuration est très courant dans les bibliothèques JavaScript, Mike Bostock, le créateur de D3.js, recommande un autre modèle pour créer des graphiques réutilisables. En bref, Mike Bostock recommande d'implémenter des graphiques en tant que fermetures avec des méthodes getter-setter. Tout en ajoutant une certaine complexité à l'implémentation du graphique, la définition des options de configuration devient très simple pour l'appelant en utilisant simplement le chaînage de méthodes :
// Using Mike Bostock's Towards Reusable Charts Pattern function barChart() { // All options that should be accessible to caller var width = 900; var height = 200; var barPadding = 1; var fillColor = 'steelblue'; function chart(selection){ selection.each(function (data) { var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(this).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); }); } chart.width = function(value) { if (!arguments.length) return margin; width = value; return chart; }; chart.height = function(value) { if (!arguments.length) return height; height = value; return chart; }; chart.barPadding = function(value) { if (!arguments.length) return barPadding; barPadding = value; return chart; }; chart.fillColor = function(value) { if (!arguments.length) return fillColor; fillColor = value; return chart; }; return chart; } var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; var runningChart = barChart().barPadding(2); d3.select('#runningHistory') .datum(milesRun) .call(runningChart); var weatherChart = barChart().fillColor('coral'); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
Pour le voir en action, consultez-le sur bl.ocks.org.
L'initialisation du graphique utilise la sélection D3.js, liant les données pertinentes et transmettant la sélection DOM comme contexte this
à la fonction génératrice. La fonction générateur enveloppe les variables par défaut dans une fermeture et permet à l'appelant de les modifier via un chaînage de méthodes avec des fonctions de configuration qui renvoient l'objet graphique. Ce faisant, l'appelant peut rendre le même graphique à plusieurs sélections à la fois, ou utiliser un graphique pour rendre le même graphique à différentes sélections avec des données différentes, tout en évitant de passer un objet d'options volumineux.
Étape 4 : Un nouveau modèle pour les graphiques pouvant être mis à jour
Le modèle précédent suggéré par Mike Bostock nous donne, en tant que développeurs de cartes, beaucoup de puissance dans la fonction de générateur. Étant donné un ensemble de données et toutes les configurations enchaînées transmises, nous contrôlons tout à partir de là. Si les données doivent être modifiées de l'intérieur, nous pouvons utiliser des transitions appropriées au lieu de simplement redessiner à partir de zéro. Même des éléments tels que le redimensionnement des fenêtres peuvent être gérés avec élégance, en créant des fonctionnalités réactives telles que l'utilisation de texte abrégé ou la modification des étiquettes d'axe.

Mais que se passe-t-il si les données sont modifiées depuis l'extérieur de la portée de la fonction de générateur ? Ou que se passe-t-il si le graphique doit être redimensionné par programmation ? Nous pourrions simplement appeler à nouveau la fonction de graphique, avec les nouvelles données et la nouvelle configuration de taille. Tout serait redessiné, et voilà. Problème résolu.
Malheureusement, il y a un certain nombre de problèmes avec cette solution.
Tout d'abord, nous effectuons presque inévitablement des calculs d'initialisation inutiles. Pourquoi manipuler des données complexes alors que tout ce que nous avons à faire est de redimensionner la largeur ? Ces calculs peuvent être nécessaires la première fois qu'un graphique est initialisé, mais certainement pas à chaque mise à jour que nous devons effectuer. Chaque requête programmatique nécessite quelques modifications et, en tant que développeurs, nous savons exactement quels sont ces changements. Ni plus ni moins. De plus, dans le cadre du graphique, nous avons déjà accès à de nombreux éléments dont nous avons besoin (objets SVG, états actuels des données, etc.), ce qui facilite la mise en œuvre des modifications.
Prenons par exemple l'exemple de graphique à barres ci-dessus. Si nous voulions mettre à jour la largeur, et le faisions en redessinant tout le graphique, nous déclencherions de nombreux calculs inutiles : trouver la valeur maximale des données, calculer la hauteur de la barre et rendre tous ces éléments SVG. Vraiment, une fois la width
affectée à sa nouvelle valeur, les seules modifications que nous devons apporter sont :
width = newWidth; widthScale = width / maxValue; bars.attr('width', function(d) { return d*widthScale}); svg.attr('width', width);
Mais ça va encore mieux. Puisque nous avons maintenant un historique du graphique, nous pouvons utiliser les transitions intégrées de D3 pour mettre à jour nos graphiques et les animer facilement. En continuant avec l'exemple ci-dessus, ajouter une transition sur la width
est aussi simple que de changer
bars.attr('width', function(d) { return d*widthScale});
pour
bars.transition().duration(1000).attr('width', function(d) { return d*widthScale});
Mieux encore, si nous autorisons un utilisateur à transmettre un nouvel ensemble de données, nous pouvons utiliser les sélections de mise à jour de D3 (entrée, mise à jour et sortie) pour également appliquer des transitions aux nouvelles données. Mais comment autoriser de nouvelles données ? Si vous vous souvenez, notre implémentation précédente a créé un nouveau graphique comme celui-ci :
d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
Nous avons lié les données à une sélection D3.js et appelé notre graphique réutilisable. Toute modification des données devrait être effectuée en liant de nouvelles données à la même sélection. Théoriquement, nous pourrions utiliser l'ancien modèle et sonder la sélection des données existantes, puis mettre à jour nos résultats avec les nouvelles données. Non seulement cela est désordonné et compliqué à mettre en œuvre, mais cela nécessiterait de supposer que le graphique existant était du même type et de la même forme.
Au lieu de cela, avec quelques modifications apportées à la structure de la fonction du générateur JavaScript, nous pouvons créer un graphique qui permettra à l'appelant de demander facilement des modifications en externe via le chaînage de méthodes. Alors qu'avant la configuration et les données étaient définies puis laissées intactes, l'appelant peut désormais faire quelque chose comme ceci, même après l'initialisation du graphique :
weatherChart.width(420);
Le résultat est une transition en douceur vers une nouvelle largeur à partir du graphique existant. Sans calculs inutiles et avec des transitions élégantes, le résultat est un client heureux.
Cette fonctionnalité supplémentaire s'accompagne d'une légère augmentation de l'effort des développeurs. Un effort, cependant, que j'ai trouvé historiquement valable. Voici un squelette du graphique pouvant être mis à jour :
function barChart() { // All options that should be accessible to caller var data = []; var width = 800; //... the rest var updateData; var updateWidth; //... the rest function chart(selection){ selection.each(function () { // //draw the chart here using data, width // updateWidth = function() { // use width to make any changes }; updateData = function() { // use D3 update pattern with data } }); } chart.data = function(value) { if (!arguments.length) return data; data = value; if (typeof updateData === 'function') updateData(); return chart; }; chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; }; //... the rest return chart; }
Pour voir entièrement mis en œuvre, consultez-le sur bl.ocks.org.
Passons en revue la nouvelle structure. Le plus grand changement par rapport à l'implémentation de fermeture précédente est l'ajout de fonctions de mise à jour. Comme indiqué précédemment, ces fonctions exploitent les transitions D3.js et mettent à jour les modèles pour apporter en douceur les modifications nécessaires en fonction des nouvelles données ou configurations de graphique. Pour les rendre accessibles à l'appelant, des fonctions sont ajoutées en tant que propriétés au graphique. Et pour le rendre encore plus simple, la configuration initiale et les mises à jour sont gérées par la même fonction :
chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; };
Notez que updateWidth
ne sera pas défini tant que le graphique n'aura pas été initialisé. Si elle est undefined
, la variable de configuration sera définie globalement et utilisée dans la fermeture du graphique. Si la fonction de graphique a été appelée, toutes les transitions sont transmises à la fonction updateWidth
, qui utilise la variable de width
modifiée pour apporter les modifications nécessaires. Quelque chose comme ça:
updateWidth = function() { widthScale = width / maxValue; bars.transition().duration(1000).attr('width', function(d) { return d*widthScale}); svg.transition().duration(1000).attr('width', width); };
Avec cette nouvelle structure, les données du graphique sont transmises via le chaînage de méthodes comme n'importe quelle autre variable de configuration, au lieu de les lier à une sélection D3.js. La différence:
var weatherChart = barChart(); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
qui devient :
var weatherChart = barChart().data(highTemperatures); d3.select('#weatherHistory') .call(weatherChart);
Nous avons donc apporté quelques modifications et ajouté un peu d'effort de développement, voyons les avantages.
Supposons que vous ayez une nouvelle demande de fonctionnalité : "Ajoutez une liste déroulante pour que l'utilisateur puisse basculer entre les températures élevées et les températures basses. Et faites en sorte que la couleur change aussi pendant que vous y êtes. Au lieu d'effacer le graphique actuel, de lier les nouvelles données et de redessiner à partir de zéro, vous pouvez désormais passer un simple appel lorsque la basse température est sélectionnée :
weatherChart.data(lowTemperatures).fillColor('blue');
et profitez de la magie. Non seulement nous économisons des calculs, mais nous ajoutons un nouveau niveau de compréhension à la visualisation au fur et à mesure de sa mise à jour, ce qui n'était pas possible auparavant.
Une mise en garde importante à propos des transitions s'impose ici. Soyez prudent lorsque vous planifiez plusieurs transitions sur le même élément. Le démarrage d'une nouvelle transition annulera implicitement toutes les transitions en cours d'exécution. Bien sûr, plusieurs attributs ou styles peuvent être modifiés sur un élément dans une transition initiée par D3.js, mais j'ai rencontré des cas où plusieurs transitions sont déclenchées simultanément. Dans ces cas, envisagez d'utiliser des transitions simultanées sur les éléments parent et enfant lors de la création de vos fonctions de mise à jour.
Un changement de philosophie
Mike Bostock présente les fermetures comme un moyen d'encapsuler la génération de graphiques. Son modèle est optimisé pour créer le même graphique avec des données différentes à de nombreux endroits. Au cours de mes années de travail avec D3.js, cependant, j'ai trouvé une légère différence dans les priorités. Au lieu d'utiliser une instance d'un graphique pour créer la même visualisation avec des données différentes, le nouveau modèle que j'ai introduit permet à l'appelant de créer facilement plusieurs instances d'un graphique, chacune pouvant être entièrement modifiée même après l'initialisation. De plus, chacune de ces mises à jour est gérée avec un accès complet à l'état actuel du graphique, permettant au développeur d'éliminer les calculs inutiles et d'exploiter la puissance de D3.js pour créer des expériences utilisateur et client plus transparentes.