D3JS - Animation & Transition

Tout ce que vous avez toujours voulu savoir sur l'animation avec D3JS
d3js7.x
Sources :

Introduction

D3JS permet de réaliser des transitions sur un ou plusieurs attributs d'un objet SVG. Une transition représente la modification de la valeur d'un attribut ou plusieurs attributs sur un objet existant. Elle doit être mathématique pour permettre à la librairie de trouver les valeurs intermédiaires permettant de représenter cette transition. Ainsi dans notre 1er exemple, D3JS calcule lui-même toutes les valeurs de cx entre sa valeur d'origine (150) et la valeur finale (850). Les transitions peuvent être appliquées sur tout type d'attribut et sur le CSS, elles peuvent être combinées, s'enchaîner, durer plus ou moins longtemps et paramétrées pour être (par exemple) rapides au début et lentes à la fin. Enfin sachez qu'une transition doit partir d'une situation existante, le cercle de notre premier exemple doit exister même si son rayon peut être égal à 0 comme son opacité.

Exemples simples

Ce premier exemple montre la transition d'un cercle d'une position cx égale à 150 vers une nouvelle position égale à 850. Nous introduisons au passage la fonction duration exprimée en millisecondes qui permet de préciser la durée de la transition.

var circleMove = svg.append("circle")
	.attr("cx",150)
	.attr("cy",50)
	.attr("r",30);
	
circleMove.transition()
	.duration(2000)
	.attr("cx", 850);

Ici nous faisons varier la couleur via l'attribut fill du rouge vers le bleu.

var circleColor = svg.append("circle")
	.attr("cx",150)
	.attr("cy",50)
	.attr("r",30)
	.attr("fill", "red");
	
circleColor.transition()
	.duration(2000)
	.attr("fill", "blue");

Un nouvel exemple pour jouer cette fois-ci sur le style CSS du cercle et le faire disparaitre.

var circleOpacity = svg.append("circle")
	.attr("cx",150)
	.attr("cy",50)
	.attr("r",30);
	
circleOpacity.transition()
	.duration(2000)
	.style("opacity", "0");

Ce dernier exemple combine toutes les modifications vues précédemment en une seule. Il est bien sûr possible de faire varier plusieurs attributs en même temps.

var circleAllCombine = svg.append("circle")
	.attr("cx",150)
	.attr("cy",50)
	.attr("r",30)
	.attr("fill", "red");
	
circleAllCombine.transition()
	.duration(2000)
	.attr("cx", 850)
	.attr("fill", "blue")
	.style("opacity", "0.2");

Paramétrage

Duration & Delay

Nous l'avons déjà vu une transition peut durer plus ou moins longtemps grâce à la fonction duration. Une autre fonction utile permet de définir un délai avant son démarrage, la fonction delay.

circleDurationDelay1.transition()
	.duration(2000)
	.attr("x", 750);
	
circleDurationDelay2.transition()
	.duration(4000)
	.attr("x", 750);
	
circleDurationDelay3.transition()
	.delay(2000)
	.duration(2000)
	.attr("x", 750);

Start & End

Si vous souhaitez matérialiser le fait qu'une transition soit en cours, vous pouvez par exemple modifier son opacité. Dans l'exemple suivant nous déplaçons notre cercle après un délai de 750ms. Dès que la transition commence (start) nous modifions son opacité et dès qu'elle se termine (end) nous la remettons à sa valeur initiale.

circleMove.transition()
	.duration(2000)
	.delay(750)
	.attr("cx", 850)
	.each('start',function(){ d3.select(this).style("opacity", "0.2"); })
	.each('end',  function(){ d3.select(this).style("opacity", "1"); });

Enchainement

Il existe plusieurs façons d'enchainer les transitions sur un même objet. La plus simple est d'utiliser la fonction transition en cascade comme dans l'exemple suivant.

circleMove
	.transition()
	.duration(500)
	.attr("cx", 850)
	.transition()
	.duration(500)
	.attr("cx",150)
	.transition()
	.duration(500)
	.attr("cx",650)
	.transition()
	.duration(500)
	.attr("cx",350)
	.transition()
	.duration(500)
	.attr("cx",500);

Si le nombre de transitions devient important, il peut être plus élégant et lisible d'utiliser une fonction récursive couplée avec l'évènement de fin de transition comme dans cet exemple.

var positions = [850, 200, 800, 250, 750, 300, 700, 350, 650, 400, 600, 450, 550, 500];
function animateMulti(node, positions, i) {
	node.transition()
		.duration(300)
		.attr("cx", positions[i])
		.on('end',  function() {
			if (i < (positions.length - 1)) {
				animateMulti(d3.select(this), positions, ++i);
			}
		});
}
animateMulti(circleMultiTransition, positions, 0);

Nous utilisons ici le principe de récursion : la fonction animateMulti s'appelle elle-même tant que la condition de la ligne 7 reste vrai, c'est-à-dire tant qu'il y a des index à traiter dans le tableau initial. Bien sûr rien ne vous empêche dans votre tableau de stocker plusieurs informations à la fois et donc de faire varier plusieurs attributs.

var variations = [
	{
		"cx" : 850, 
		"fill" : "red"
	}, {
		"cx": 200, 
		"fill" : "black"
	} // ...
];

Ease

Vous avez dû noter depuis le début de ces exemples que chaque transition est lente au début, d'une vitesse moyenne au milieu et à nouveau lente à la fin. Cette façon de faire varier la vitesse de la transition est celle par défaut (nommé cubic-in-out) et il est possible de la paramétrer avec la fonction ease. Vous pouvez cliquer sur les cercles dans l'exemple suivant pour voir les différents paramètres que cette fonction peut prendre. La première transition (linear) se définit ainsi :

d3.select(this)
	.transition()
	.duration(2000)
	.ease(d3.easeLinear)
	.attr("cx", 850);

Il faut reconnaître que les trois dernières sont plutôt amusantes. Il est possible d'aller un peu plus loin avec cette fonction (par exemple en l'inversant grâce au suffixe -out, ainsi pour inverser la fonction exponentielle il faut utiliser "exp-out"). Si besoin jetez un oeil à la documentation (en anglais) : Spécifications de la fonction ease ou posez vos questions dans la section commentaire.

Interpolation & Tween

Supposons un instant que vous soyez fan de la lettre Z, vous ne supportez pas qu'un texte ne contienne pas que des Z, nous allons vous montrer comment assouvir cette passion. Dans notre premier exemple, le cercle se déplace d'une position cx = 150 vers une position cy = 850. D3JS réalise lui-même l'interpolation, il calcule 151, 152... pour déplacer le cercle. Pour notre texte nous souhaitons que toutes les lettres se "déplacent" vers Z, c'est-à-dire que si ma lettre est E, alors nous souhaitons qu'elle prenne les valeurs F, G, H... jusqu'à Z. Pour commencer construisons le texte que vous voyez un peu plus bas.

var alphabet = svg.append("g");
for (var i = 0; i < 26; ++i) {
	alphabet.append("text")
		.attr("x", 200 + i * 25)
		.attr("y", 50)
		.attr("font-family", "Courier")
		.attr("font-size", "40px")
		.text(String.fromCharCode(65 + Math.random() * 26));
}

Ce code génère aléatoirement 26 objets SVG de type text contenant chacun une lettre au hasard. Notez que notre lettre est obtenue par la fonction String.fromCharCode() qui convertit un code ASCII en une lettre. Ainsi 65 représente le A et 90 le Z. Si nous effectuons une transition standard le résultat ne sera pas satisfaisant.

alphabetWrong.selectAll('text')
	.transition()
	.ease(d3.easeLinear)
	.duration(10000)
	.text('Z');

En effet, D3JS ne sait pas nativement interpoler des lettres. Il faut que nous lui indiquions que ces lettres peuvent être réprésentées par des nombres qui eux-mêmes peuvent être interpolés. Pour cela nous devons introduire la fonction tween qui définit la façon dont elle se déroule. Nous commençons par récupérer la valeur numérique de notre lettre à la ligne 6, ainsi pour la lettre E cela donne 69. A la ligne suivante nous construisons une fonction d'interpolation (arrondie) entre 69 et 90 (ce serait différent pour une autre lettre, ainsi pour la lettre Y, l'interpolation se ferait entre 89 et 90, c'est pour cette raison que nous traitons lettre par lettre). C'est tout, maintenant D3JS est capable de remplir le vide entre ces deux valeurs. La ligne 8 retourne la fonction qu'il va utiliser, cette fonction convertie les valeurs 70, 71... jusque 90. On se sert du résultat pour définir la nouvelle lettre au fur et à mesure toujours grâce à la fonction String.fromCharCode(). Pour information t est un nombre réel compris entre 0 et 1.

alphabet.selectAll('text')
	.transition()
	.ease(d3.easeLinear)
	.duration(10000)
	.tween('text', function() {
		var currentValue = this.textContent.charCodeAt(0);
		var i = d3.interpolateRound(currentValue, 90); // 90 = Z
		return function(t) {
			this.textContent = String.fromCharCode(i(t));
		};
	});

L'important dans cet exemple est que nous sommes parties d'une situation ou l'interpolation était impossible (entre des lettres) et que nous avons trouvé une représentation qui la rendait possible. Sachez qu'il existe des fonctions toutes prêtes pour réaliser divers types d'interpolations : Liste des interpolations.

Exemples avancés

Cette dernière partie présente comment les mécanismes de transitions peuvent être utilisés sur autre chose que des cercles. Avec tout ce que nous avons vu précédemment vous comprenez que vous n'êtes limités par rien, il vous suffit d'imaginer une transition complexe puis de la découper au fur et à mesure en transitions plus simples pour pouvoir la coder en piochant dans les exemples précédents.

Bar Chart

Nous n'allons pas réétudier ici la façon de les construire mais juste présenter comment y intégrer des transitions. Avant d'obtenir le graphique ci-dessous nous avons créé les domaines xBar et yBar. Ensuite nous générons des données aléatoires et créons 4 rectangles. Voici le premier code permettant de réaliser une transition sur un Bar Chart.

var data = getRandomData(4, 150);
xBar.domain(data.map(d => d.name));
svg.selectAll("rect")
	.data(data)
	.transition()
	.duration(2000)
	.attr("y", d => yBar(d.value))
	.attr("height", d => 200 - yBar(d.value));

Le code est assez simple et la sélection ressemble à ce que nous avons déjà vu pour les lettres. A la première ligne nous générons de nouvelles données ce qui nous oblige à la suivante à recalculer notre domaine (les bornes min et max ont probablement évolué). Comme dans les autres exemples nous faisons varier les attributs de notre choix dans une transition de 2 secondes, ici y pour placer la barre en bas et height pour modifier sa hauteur. L'important ici est de voir que l'on associe notre nouveau jeu de données à nos rectangles après avoir fait un selectAll.

Ce second exemple réalise une transition intermédiaire qui vise à mettre toutes les colonnes à 0 avant de définir une nouvelle valeur.

var data = getRandomData(4, 150);
xBar.domain(data.map(d => d.name));
svg.selectAll("rect")
	.data(data)
	.transition()
	.duration(1000)
	.attr("y", 200)
	.attr("height", 0)
	.transition()
	.duration(1000)
	.attr("y", d => yBar(d.value))
	.attr("height", d => 200 - yBar(d.value));

La question que vous pouvez vous poser sur ces deux exemples est la suivante : Quelle est l'animation la plus utile ? Dans la première les bars s'agrandissent ou se réduisent en fonction de leur nouvelle hauteur ce qui permet de voir une évolution d'années en années par exemple. En revanche dans le second exemple toutes les bars sont réduites à 0 puis leur nouvelle valeur est définie ce qui rend plus difficile la comparaison visuelle.

Line Chart

Le principe reste identique pour les Line Chart, nous avons préalablement construit xLine et yLine ainsi que la fonction line. Pour animer notre graphique, nous générons des données aléatoires, les domaines des axes X et Y sont recalculés. La transition consiste à mettre à jour le path existant. On lui associe les nouvelles données et on appelle la fonction line.

var data = getRandomData(100, 150);
xLine.domain(data.map(d => d.name));
yLine.domain(d3.extent(data, d => d.value));
linePath.datum(data)
	.transition()
	.ease(d3.easeLinear)
	.duration(2000)
	.attr("d", line);

Nous continuons avec ce second exemple qui construit le path au fur et à mesure. Le code complet est cette fois-ci présentée, tout est fait en une seule fois car nous partons d'une situation vide. Si vous exécutez le code jusqu'à la ligne 17 la graphique sera affichée sans aucune animation. Le code qui suit est plus subtile que les précédents et pas évident à comprendre. La ligne 20 permet d'obtenir la longueur de la ligne en pixel. Ensuite, nous modifions deux attributs sur cette ligne, stroke-dasharray et stroke-dashoffset. Le premier détermine la longueur de nos pointillés et nous le définissons à la longueur de la ligne. Le second permet de déplacer le début de la ligne que nous représentons. L'animation consiste à ramener ce dernier attribut à 0 ce qui permet de faire apparaître la ligne au fur et à mesure.

var xLine = d3.scaleBand()
	.range([0, 800])
	.padding(0.1);
var yLine = d3.scaleLinear()
	.range([100, 0]);
var line = d3.line()
	.x(d => xLine(d.name))
	.y(d => yLine(d.value));
var data = getRandomData(100, 150);
xLine.domain(data.map(d => d.name));
yLine.domain(d3.extent(data, d => d.value));
var linePath = svg.append("path")
	.datum(data)
	.attr("d", line)
	.style("fill", "none")
	.style("stroke", "#3498db")
	.style("stroke-width", "1px")
	.attr("transform", "translate(150, 0)");
	
var linePathLength = linePath.node().getTotalLength(); // LIGNE 20
linePath
	.attr("stroke-dasharray", linePathLength)
	.attr("stroke-dashoffset", linePathLength)
	.transition()
		.duration(4000)
		.ease(d3.easeLinear)
		.attr("stroke-dashoffset", 0);

Area Chart

Pour réaliser des transitions sur des Area Chart, nous reprenons presque le même code que pour les Linear Chart : génération de nouvelles données, mise à jour des domaines puis transition.

var data = getRandomData(25, 100);
xArea.domain(data.map(d => d.name));
yArea.domain(d3.extent(data, d => d.value));
areaPath.datum(data)
	.transition()
		.duration(2000)
		.attr("d", area);

Nous n'avons pas trouvé comme faire apparaître le graphique de la gauche vers la droite comme nous l'avons fait pour les lignes. En revanche, nous pouvons le faire apparaître du bas vers le haut avec le code suivant. Il suffit de définir areaStart et area pour faire une transition ou toutes les valeurs sont au départ à 0 vers nos données aléatoires.

var data = getRandomData(25, 100);
var xArea = d3.scaleBand()
	.range([0, 800])
	.padding(0.1);
var yArea = d3.scaleLinear()
	.range([100, 0]);
xArea.domain(data.map(d => d.name));
yArea.domain(d3.extent(data, d => d.value));
var areaStart = d3.area()
	.x(d => xArea(d.name))
	.y0(100)
	.y1(100);
var area = d3.area()
	.x(d => xArea(d.name))
	.y0(100)
	.y1(d => yArea(d.value));
var areaPath = svg.append("path")
	.datum(data)
	.attr("class", "area")
	.attr("transform", "translate(150, 0)")
	.attr("d", areaStart)
	.transition()
		.duration(2000)
		.attr("d", area);

Souvenez-vous que pour toute transition il faut partir d'une situation existante, même si l'objet n'est pas visible à l'écran il doit exister. Il n'est pas possible de partir de rien et de faire une transition vers une situation. Si vous voulez faire apparaître un objet il faudra créer une situation de départ invisible (taille de cercle à 0, hauteur à 0...) avant de faire votre transition vers la situation finale.

Conclusion

Nous terminons ici ce tutoriel. Bien sûr nous n'avons pas étudié tous les objets sur lesquels il est possible de faire des transitions mais si vous rencontrez des difficultés sur les vôtres n'hésitez pas à les partager dans la section commentaire.

COMMENTAIRES

jean-manuel Meny


Bonjour

Une question sur la fonction animateMulti.

Une boucle semble fonctionner également (code ci-dessous).
Y a-t-il un avantage à ce choix récursif plutôt que le choix d'une boucle ?

merci d'avance

<html lang="fr">
<head>
<meta charset="utf-8">
<title> cercle </title>

<script src="https://d3js.org/d3.v4.min.js"></script>
</head>

<body>

<svg width="1000px" height="300px" id="figure">
</svg>

<script>
var fig = d3.select("#figure");

var cercle = fig.append("circle")
.attr("cx",150)
.attr("cy",50)
.attr("r",30)
.attr("fill", "red");

var positions = [850, 200, 800, 250, 750, 300, 700, 350, 650, 400, 600, 450, 550, 500];

for(var i = 0; i < positions.length; i += 1)
{
cercle = cercle.transition()
.duration(3000)
.attr("cx", positions[i]);
}

</script>

</body>
</html>

ericfrigot


Bonjour,
Je ne suis pas sûr de savoir pourquoi cela fonctionne mais il y a bien une différence. Dans votre exemple, l'ensemble des transitions est créé dès le début alors qu'avec la récursion il faut attendre la fin de la première transition avant de créé la seconde. Cela fonctionne peut-être car votre exemple est simple mais il est possible qu'une boucle for créé des situations non souhaitées dans un cas plus complexe. Si vous maitrisez l'anglais, la question mériterait d'être posée sur stackoverflow pour bien comprendre pourquoi cela fonctionne quand même. Eric