D3JS - Frequency Trails

Construire un graphique de type pistes de fréquence (Frequency Trails) avec gestion de la souris avancée
d3js7.x
Sources :

Introduction

Cette visualisation présente les anomalies de températures de 1900 à 2023. On part de la température moyenne du globe pendant tout le 20ème siècle. Ensuite pour chaque mois et chaque année on affiche la différence entre cette température et la température de l'année. Ainsi dans les premières décennies du 20ème les courbes sont principalement bleues car les températures constatées sont en-dessous de la moyenne. En approchant de la fin du 20ème siècle et au début du suivant les courbes deviennent uniquement rouges.

Les données

Les données utilisées proviennent du NOAA et peuvent être extraites directement sous la forme d'un CSV.

date,value
190001,-0.35
190002,-0.07
190003,-0.04
190004,-0.09

Initialisation

L'initialisation contient quelques particularités :

  • areaHeight représente la hauteur en pixel du graphique associé à chaque décennie, il est possible de la faire varier pour que le graphique empiète plus ou moins sur les suivants
  • Nous définissons deux d3.area car pour chaque décennie nous dessinons deux graphiques, un pour les valeurs négatives et un autre pour les valeurs positives
Pour le reste on insère toujours notre SVG sur un élément DIV existant dans la page, les marges dépendent des légendes qui sont associées et les valeurs d.date, d.valueP et d.valueN seront définies juste en-dessous.

const margin = {top: 20, right: 40, bottom: 100, left: 40},
    areaHeight = 90;
    width = document.getElementById("container").offsetWidth - margin.left - margin.right,
    height = 600 - margin.top - margin.bottom;

let data = [], // Contiendra les données du CSV restructurées
    verticalLine = null;

const svg = d3.select("#chart").append("svg")
    .attr("id", "svg")
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom);

let group = svg.append("g")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

const x = d3.scaleBand()
    .range([0, width]);

const y = d3.scaleLinear()
    .range([areaHeight, 0]);

var areaP = d3.area()
    .x(d => x(d.date))
    .y0(areaHeight)
    .y1(d => y(d.valueP));

var areaN = d3.area()
    .x(d => x(d.date))
    .y0(areaHeight)
    .y1(d => y(d.valueN));

Chargement du CSV

Lorsque le fichier CSV est chargé nous le transformons pour construire le tableau data.

[{
    "year":"2010",
    "values": [{
        "date":"1001",
        "valueP":0.74,
        "valueN":null
    }, {
        "date":"1002",
        "valueP":0.8,
        "valueN":null
    }, //...
    ]},
    "year":"2000",
    "values": //...
}]

Ensuite nous ajoutons les deux Areas, les textes et la gestion de la souris.

d3.csv('/tutorials/d3js/frequency-trails/world-temperature-1900-2020.csv').then(function(csv) {
    csv.forEach(d => {
        let year = d.date.substring(0, 3) + "0"; // On regroupe par décennie
        let yearData = null;
        for (let i = 0; i < data.length; ++i) {
            if (data[i].year === year) {
                yearData = data[i];
            }
        }
        if (yearData === null) {
            yearData = {"year": year, "values": []};
            data.push(yearData);
        }
        yearData.values.push({
            "date": d.date.substring(2, 6), 
            "valueP": +d.value >= 0 ? +d.value : 0, 
            "valueN": +d.value < 0 ? +d.value : 0
        });
    });
    data = data.reverse(); // On veut les années les plus anciennes en bas.

    addAreas();
    addTexts();
    handleMouse();
});

Ajout des graphiques

Le code a été découpé en fonctions pour qu'il soit plus simple à présenter. Ce n'est pas optimal en termes de performances car on parcourt deux fois la liste des données mais au vu du volume ça n'est pas très grave. La construction des deux areas (une pour les valeurs positives et une pour les valeurs négatives) est assez simple. Il faut bien penser à décaler chaque area au fur et à mesure du parcours des données. Nous réalisons cette opération avec la variable transY et la transformation appliquée à chaque area.

function addAreas() {
    for (let i = 0; i < data.length; ++i) {
        x.domain(data[i].values.map(d => d.date));
        y.domain([0, 1.4]); // Depuis le site source

        let transY = (height / data.length) * i;

        group.append("path")
            .datum(data[i].values)
            .attr("id", "areaP_" + i)
            .attr("fill", "#fc9272")
            .attr("stroke", "#de2d26")
            .attr("transform", "translate(0," + transY + ")")
            .attr("d", areaP);

        group.append("path")
            .datum(data[i].values)
            .attr("id", "areaN_" + i)
            .attr("fill", "#9ecae1")
            .attr("stroke", "#3182bd")
            .attr("transform", "translate(0," + transY + ")")
            .attr("d", areaN);
    }
}

Ajout des textes

L'ajout des textes reprend la même logique, on parcourt les données de chaque décennie et on ajoute trois textes à chaque fois. L'affichage des décennies étant fixe, la classe CSS associée est différente.

function addTexts() {
    for (let i = 0; i < data.length; ++i) {
        let transY = (height / data.length) * i;

        group.append("text") // Affichage de la décennie à gauche
            .attr("class", "text-legend-fixed")
            .attr("x", 0)
            .attr("dx", "-5px")
            .attr("y", transY + areaHeight)
            .attr("text-anchor", "end")
            .text(data[i].year);

        group.append("text") // Affichage dynamique de la date en fonction de la position de la souris
            .attr("id", "text_date_" + i)
            .attr("class", "text-legend")
            .attr("x", width)
            .attr("dx", "-5px")
            .attr("y", transY + areaHeight)
            .attr("text-anchor", "start");

        group.append("text") // Affichage dynamique de la température en fonction de la position de la souris
            .attr("id", "text_temp_" + i)
            .attr("class", "text-legend")
            .attr("x", width)
            .attr("dx", "-5px")
            .attr("y", transY + areaHeight + 15)
            .attr("text-anchor", "start");
    }
}

Gestion de la souris

Pour la gestion de la souris nous ajoutons au graphique une barre verticale que l'on va déplacer quand la souris se déplace. Lorsque la souris quitte le graphique nous voulons masquer la barre verticale ainsi que les dates et les températures affichées sur la droite, c'est pour cela que nous ajoutons un rectangle transparent qui couvre le graphique. Lorsqu'on entre dans le graphique (mouseover) on rend visible la ligne verticale et les deux textes et lorsqu'on en sort (mouseout) on les masque.

function handleMouse() {
    verticalLine = group.append("line")
        .attr("class", "vertical-line")
        .attr("x1",0)
        .attr("y1",0)
        .attr("x2",0)
        .attr("y2", height + margin.top + margin.bottom)
        .style("opacity", 0);

    group.append("rect")
        .attr("class", "overlay")
        .attr("width", width)
        .attr("height", height + margin.top + margin.bottom)
        .on("mouseover", function() {
            svg.selectAll(".text-legend")
                .style("display", null);
            svg.selectAll(".vertical-line")
                .style("display", null);
        })
        .on("mouseout", function() {
            svg.selectAll(".text-legend")
                .style("display", "none");
            svg.selectAll(".vertical-line")
                .style("display", "none");
        })
        .on("mousemove", mousemove);
}

Gestion du mousemove

Nous nous intéressons uniquement à la position horizontale de la souris, c'est pour cette raison que nous récupérons mouse[0] (mouse[1] contient la position verticale). Cette position est exprimée en pixels et nous la divisons par la largeur de chaque step. Pour chaque area il y a 120 steps représentant les 10 années multipliées par 12 mois. Cela correspond à notre tableau contenu dans la variable data qui contient pour chaque décennie 120 entrées. Si la valeur de j est au-dessous ou en-dessous nous ne faisons rien. Sinon nous positionnons la barre à la position de la souris et nous définissons les textes de chaque décennie.

function mousemove(event) {
    let mouse = d3.pointer(event),
        j = Math.floor((mouse[0] / x.step()));

    if (j < 0 || j >= 120) { 
        return; 
    }

    // Positionnement de la barre verticale toujours en tenant compte de la marge
    verticalLine.attr("x1", mouse[0]);
    verticalLine.attr("x2", mouse[0]);
    verticalLine.style("opacity", 1);

    for (let i = 0; i < data.length; ++i) {
        d3.select("#text_date_" + i)
            .text(data[i].values[j].date.substring(2, 4) + "/" + data[i].year.substring(0, 2) + data[i].values[j].date.substring(0, 2));
        d3.select("#text_temp_" + i)
            .text(data[i].values[j].valueP !== 0 ? "+" + data[i].values[j].valueP : data[i].values[j].valueN)
            .style("fill", data[i].values[j].valueP !== 0 ? "#de2d26" : "#3182bd")
            .style("font-weight", "bold");
    }
}

Conclusion

Ce tutoriel n'introduit aucune nouveauté par rapport aux précédents mais permet de construire une nouvelle représentation d'une évolution temporelle. Comme toujours la section commentaire est présente pour toutes vos questions.

COMMENTAIRES