Introduction à D3.js et aux documents pilotés par les données

La sympathique D3.js est une bibliothèque qui permet de manipuler des documents à partir de jeux de données de manière très puissante et élégante. Elle est particulièrement adaptée à tout projet de présentation graphique de données, mais pas que.

Pour vous faire une idée des possibilités de la bête, je vous encourage à jeter un œil sur la liste d'exemples qui en devient presque ridicule tant elle est immense. Impressionnant, n'est-ce pas ?

À quoi sert D3 et comment fonctionne-t-elle (car j'ai décidé qu'elle serait féminine) ? Amusons-nous avec pour le découvrir.

Avertissement : si votre navigateur ne supporte pas svg, vous ne pourrez pas profiter des chatoyants exemples de ce billet. Tant pis pour vous.

Qu'est-ce que d3.js ?

Pour faire simple, D3 permet de transformer un document en fonction de données que vous lui passez. D3 permet donc de représenter des données en utilisant les standards du Web (SVG, HTML, CSS, Javascript). C'est en fait un outil de transformation, non de présentation. D'ailleurs, « D3 » vient de « Data driven documents ».

Prenons un exemple basique : imaginons que je dispose d'une API qui renvoie un nombre aléatoire de paragraphes de faux texte (lorem ipsum…). Dans le cadre de la construction d'un générateur de faux-texte minimaliste et efficace, je veux afficher ces paragraphes dans des balises <p> dans une page HTML.

Accrochez-vous à votre siège et jetez un rapide coup d'œil à ce code que je me suis forcé à commettre :

<div id="lorem-container"></div>
<script>
var container = $('#lorem-container');
container.html('');
var data = ['bla bla bla', 'gloubigoulba', 'schtroumpf'];
$.each(data, function(paragraph) {
    var p = $('');
    p.text(paragraph);
    container.append(p);
});

Ouh ! Bouh ! Comme c'est moche ! Et encore, je vous laisse imaginer la tronche de ce code sans le sucre syntaxique offert par jQuery.

Voici maintenant la version D3 :

<div id="lorem-container"></div>
<script>
var data = ['bla bla bla', 'gloubigoulba', 'schtroumpf'];
var paragraphs = d3.select('#lorem-container').selectAll('p')
    .data(data)
  .enter()
    .append('p')
    .text(function(d) { return d; });

Ce code est confondant d'élégance et de simplicité, même s'il mérite une petite explication.

Comprendre les sélections de D3

Déroulons le code précédent pas à pas pour en comprendre le fonctionnement.

var paragraphs = d3.select('#container').selectAll('p')

Les méthodes `select` et `selectAll` permettent de retourner une sélection d'éléments, un peu à la manière de jQuery. On peut très facilement affecter des propriétés à tous les éléments de la sélection d'un coup, par exemple :

var paragraphs = d3.select('#container').selectAll('p')
    .style('background-color', 'black')
    .text('initial text');

Ok, mais dans le cas présent, il n'existe pas d'éléments « p » dans mon document, donc ma sélection est vide ? Oui, lisez la suite pour comprendre.

.data(data)

La fonction `data` permet de coupler une sélection d'éléments à un jeu de données, c'est à dire que chaque entrée du tableau `data` aura une correspondance directe avec un et un seul élément de la sélection. Certes, mais que se passe-t-il si le nombre d'éléments de la sélection ne correspond pas à la quantité de données (c'est le cas ici, notre sélection est toujours vide) ? Ou si des données disparaissent du tableau et que d'autres y sont insérées entre deux mises à jour ?

En fait, cette opération va transformer notre unique sélection d'éléments en trois sélections distinctes, portant les petits noms suivants :

  1. update : les éléments de la sélection initiale qui sont liés à une entrée du tableau `data` ;
  2. enter : les éléments du tableau `data` « en trop » qui ne correspondent à aucun nœud dans le DOM ;
  3. exit : les éléments de la sélection qui étaient liés à une donnée qui a maintenant disparu ;

D3 nous permet donc de répondre à trois questions :

  1. Que fait-on des éléments déjà présent dans le document alors qu'ils représentent des données qui ont peut-être été mises à jour ?
  2. Que fait-on lorsque l'on reçoit de nouvelles données ?
  3. Que fait-on des nœuds qui ne servent plus à rien ?

Dans notre exemple, notre fonction n'est appelée qu'une fois, un seul cas de figure va se présenter. Poursuivons notre analyse.

.enter()

La fonction `enter` nous permet de travailler sur la sélection correspondante, i.e les données sans nœud correspondant.

.append('p')

Pour chaque entrée du tableau `data`, on créé un nouveau nœud <p>

.text(function(d) { return d; });

Et pour chacun de ces nouveaux éléments, on déclare que le texte correspondra au datum (entrée du tableau `data`) correspondant.

Pfiou !

Travailler avec les sélections

Si vous êtes comme moi, il vous faudra sans doute relire la section précédente trois ou quatre fois avant de bien comprendre de quoi il retourne. Voici un exemple plus complet qui fait intervenir les trois sélections. C'est très exactement ce code qui est utilisé sur mon générateur de Lorem ipsum.

// Chaque fois que je clique sur un bouton, je récupère de nouveaux
// paragraphes de faux textes, que je veux afficher à la place
// des précédents.
function update(data) {
    var paragraphs = d3.select('#text').selectAll('p')
        .data(data);

    // On créé les éléments "p" pour les nouvelles données
    paragraphs.enter()
        .append('p');

    // `selectAll` renvoie la section "update"
    // On configure le texte des nœuds
    paragraphs.text(function (d) { return d; });

    // On supprime les nœuds qui n'ont plus lieu d'être
    paragraphs.exit().remove();
}

Et sinon, dans la vraie vie ?

J'avoue qu'utiliser D3 pour un besoin aussi simple, c'est un peu comme sortir les Canadair pour éteindre une allumette. Mais si vous avez compris les sélections, vous avez compris D3. Pour autant, c'est loin d'être la seule fonctionnalité de l'outil, et l'API est longue comme le bras. Outils mathématique, gestion du temps, helpers graphiques, chargement de ressources distantes, cartographie, géométrie, etc. Il-y-a de quoi s'amuser.

Voici un exemple un peu plus complet. Nous souhaitons par exemple visualiser des données économiques et sociales sur les pays émergents, même si pour faciliter la démonstration nous nous contenterons de données générées aléatoirement.

Voici le code de cette petite merveille.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Data</title>
    <style>
    .axis text {
        font: 10px sans-serif;
    }
    .axis path,
    .axis line {
        fill: none;
        stroke: #000;
        shape-rendering: crispEdges;
    }
    </style>
</head>
<body>
    <div id="chart" style="width: 600px; height: 400px;"></div>
    <button id="update-data">Update data</button>
    <script src="d3.js"></script>
    <script src="vizu.js"></script>
</body>
</html>

Et vizu.js. Je vous préviens, ce n'est pas du très beau js, mais il est tard et j'ai la flemme.

// Convention D3 pour gérer la marge du graphique
var margin = { top: 20, right: 20, bottom: 30, left: 40 },
    width = 600 - margin.left - margin.right,
    height = 400 - margin.top - margin.bottom;

// D3 nous propose des outils pour gérer les échelles de valeurs de nos données.
// Ici, nos données seront réparties sur un intervalle continu.
// Nous savons que nos données d'entrées sont comprises entre 0 et 100.
// En sortie, nous les étalerons sur l'axe des abscisses soit la largeur
// du graphe.
var x = d3.scale.linear()
    .domain([0, 100])  // domaine d'entrée
    .range([0, width]);  // domaine de sortie

// Même chose pour l'axe des ordonnées.
// Notez qu'en SVG, la coordonnée (0, 0) se trouve en haut à gauche
var y = d3.scale.linear()
    .range([0, height])
    .domain([0, 100]);

var r = d3.scale.linear()
    .range([10, 50])
    .domain([0, 10]);

// Helper qui renvoie des couleurs
// pour un rendu sympathique
var color = d3.scale.category20();

// Nous créons l'élément SVG nécessaire au rendu du graphique.
// Et hop ! direct dans le DOM.
var chart = d3.select('#chart').append('svg')
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom)
  .append("g")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

// Que serait un graphique sans axes ?
// L'api est généreuse et contient tout ce qu'il faut
// pour produire un rendu élégant
var xAxis = d3.svg.axis()
    .scale(x)
    .orient("bottom");
chart.append("g")
    .attr("class", "x axis")
    .attr("transform", "translate(0," + height + ")")
    .call(xAxis);

var yAxis = d3.svg.axis()
    .scale(y)
    .orient("left");
chart.append("g")
    .attr("class", "y axis")
    .call(yAxis);

/*
 * Notre fonction de génération de données aléatoires
 * Retourne un résultat de la forme suivante :
 * [
 *     { x: 10, y: 55, r: 5},
 *     { x: 87, y: 42, r: 2},
 *     …
 * ]
 */
function getData() {
    var _randomData = function() {
        return {
            x: Math.floor(Math.random() * 100),
            y: Math.floor(Math.random() * 100),
            r: Math.floor(Math.random() * 10),
        };
    };
    var nbData = Math.floor(Math.random() * 8) + 3;
    return d3.range(nbData).map(_randomData);
};

/*
 * C'est ici que nous allons afficher nos données sous
 * forme de cercles chamarés et chatoyants.
 *
 * Le paramètre data est bien évidemment un tableau d'objets
 */
function redraw(data) {

    // Créé la sélection initiale, et bind les données
    var circles = chart.selectAll('circle')
        .attr("class", "chart")
        .data(data);

    // Lorsque l'on créé les nœuds, on les place directement au coordonnées
    // correctes, mais avec un rayon de 0, ce qui permettra une
    // animation des plus primesautières.
    circles.enter().append('circle')
        .attr("cx", function(d) { return x(d.x); })
        .attr("cy", function(d) { return height - y(d.y); })
        .attr("r", 0);

    // Idem, lorsqu'une donnée n'existe plus, on fait disparaître le
    // cercle correspondant en réduisant élégamment son rayon à 0
    circles.exit()
        .transition()
        .duration(750)
        .attr("r", 0)
        .remove();

    // Voici maintenant le traitement effectués sur les nœuds liés à
    // des données existantes. Notez que les nœuds de la sélection `enter`
    // seront également concernés ici.
    circles
        .attr("fill", function(d, i) { return color(i); })
        .transition()
        .duration(750)
        .attr("cx", function(d) { return x(d.x); })
        .attr("cy", function(d) { return height - y(d.y); })
        .attr("r", function(d) { return r(d.r); });
};
var updateButton = document.getElementById('update-data');
updateButton.addEventListener('click', function() {
    data = getData();
    redraw(data);
});
data = getData();
redraw(data);

Voilà pour l'exemple un peu plus complet. Ce n'est qu'un tout petit aperçu de ce qu'il est possible de faire avec D3.js. Je laisse le reste à votre imagination fertile. Et bonne année !