Exemple de création et d'intégration d'un graphe D3JS : heatmap (histogramme 2D)

Last modified by Aurelie Bertrand on 2024/06/21 11:09

 

Cet exemple est proposé afin d'expliciter à l'aide d'un cas concret la configuration d'un modèle de type D3. 

Dans cet exemple, nous allons détailler la création d'un modèle de heatmap (histogramme 2D) à l'aide de d3.js.

Objectif

L'objectif est de développer un modèle D3 dans DigDash à partir d'un template de heatmap d3.js existant.

Une heatmap ou histogramme 2D prend en paramètre :

  • deux dimensions (une sur l'axe horizontal, une sur l'axe vertical)
  • une mesure : elle paramètre la couleur de chaque cellule

C'est le même principe qu'un tableau croisé avec format alerteur.

Plusieurs templates sont disponibles sur le site d3-graph-gallery https://d3-graph-gallery.com/heatmap#heatmap.

Pour démarrer, nous allons nous baser sur le plus simple d'entre eux : https://d3-graph-gallery.com/graph/heatmap_basic.html. En général, c'est mieux si le code du template exploite une source de données distante par fetching, cela permet de faciliter l'intégration ; c'est le cas ici.

heatmap_basic.png

Préparer le nouveau modèle D3

Afin de pouvoir exploiter un certain nombre de fonctionnalités existantes dans les modèles D3 livrés avec le CMDR, le mieux pour commencer est de dupliquer un des modèles existants dans le gestionnaire de modèles D3.

Les deux modèles les plus simples sont Text Report (qui accepte un nombre indéterminé de dimensions et de mesures en entrée) et Horizontal Bar (qui attend une dimension et une mesure). Horizontal Bar est conseillé comme base de départ lorsque la structure des données attendue est relativement simple tandis que Text Report peut être plus adapté si la structure de données est plus complexe (structures arborescentes) et qui vont nécessiter plus de préparation.

Pour commencer on va donc dupliquer le modèle Horizontal Bar.

Dupliquer le modèle Horizontal Bar

Ouvrir le gestionnaire de modèles D3. Récupérer les modèles du CMDR si ce n'est pas déjà fait et dupliquer le modèle Horizontal Bar :

heatmap1.png

L'interface vous propose de dupliquer des fichiers sur le serveur D3templates.

Cela va être nécessaire pour le fichier contenant la fonction de plugin et le fichier de construction du graphe. C'est possible pour la feuille de style CSS mais ici nous allons la retirer du modèle.

Donner le nom Heatmap à votre nouveau modèle et adaptez les noms de fichiers dupliqués à ce nouveau modèle conformément à la convention préconisée :

  • heatmap_plugin.js pour le fichier de plugin (entrée de ddjson)
  • heatmap_chart.js pour la construction de la visualisation

heatmap2.png

Validez et retirez le fichier horizontalBar_style.css du nouveau modèle. On peut garder les fichier "common".

Changez ensuite le nom de la fonction de plugin en heatmapPlugin et adaptez le contenu du fichier heatmap_plugin.js :

heatmap3.png

Vous pouvez d'ores et déjà enregistrer le fichier heatmap_plugin.js et valider le gestionnaire de modèles D3.

Retoucher la fonction de plugin

Quelques retouches simples au niveau de la fonction de plugin :

  • supprimer la variable drawDuration des specific features et ses références dans le code
  • commenter les dernières lignes correspondant à l'instanciation de l'ancien graphe Horizontal Bar
  • adapter la préparation de données (dataset) : on attend deux dimensions au lieu d'une seule dans l'ancien modèle

Le code du fichier heatmap_plugin.js est maintenant le suivant :

var divContainer;
var width;
var height;

// Default specific features (flow parameters)
const SPECIFIC_FEATURES = {
    useColorPalette: "true"
};
// Current specific features
var flowSpecificFeatures = SPECIFIC_FEATURES;
var useColorPalette;

// This chart expects one dimension and one measure
function heatmapPlugin(ddjson, ddobj, exportCallback) {
    console.log(">> Digdash d3.js heatmap");
    console.log(ddjson);
   // --------------------------------------------------------------------------
   // Loading specific features
   flowSpecificFeatures = loadSpecificFeatures(ddjson, SPECIFIC_FEATURES);
    useColorPalette = (String(flowSpecificFeatures.useColorPalette) === 'true');
   // --------------------------------------------------------------------------
   // for export we need no transition
   if (ddCtrl && ddCtrl.__nodejs) {
        width = window.screen.width; //__ow * __rootGO.clientToCoord;
       height = window.screen.height;//__oh * __rootGO.clientToCoord;
       if (exportCallback) {
            setTimeout(exportCallback, 1000); // approx chart drawing time
       }
    } else {
       width = document.body.clientWidth; //__ow * __rootGO.clientToCoord;
      height = document.body.clientHeight;//__oh * __rootGO.clientToCoord;  
   }

   var dataset = [];
   for (let memberKey of ddjson.lines) {
       let x = {};
        x.value = ddjson.getValue(memberKey, 0);
        x.dimV = ddjson.getMemberByKey(memberKey, 0).formated;
        x.dimH = ddjson.getMemberByKey(memberKey, 1).formated;
        dataset.push(x);
    }
   // Show dataset in console
   console.log(dataset);
   
   var measureName = ddjson.measures[0].id;

    divContainer = d3.select("body").append("div");
/* var horizontalChart = BarChart(dataset, {
      x: d => d.value,
      y: d => d.member,
      width: width,
      height: height,
      xLabel: measureName + " →",
      marginLeft: 120,
      marginRight: 20,
      colorize: i => {
          // You can use flow color palette
          if (useColorPalette && ddjson.colors != null) {
              var colorIndex = i%ddjson.colors.length;
              var colorObj = ddjson.colors[colorIndex];
              if (colorObj != null) {
                  return colorObj.hex;
              } else {
                  return "black";
              }
          } else {
              // Or some d3 color gradient using interpolator
              // see https://github.com/d3/d3-interpolate
              const X = d3.map(dataset, d => d.value);
              return d3.interpolateHsl("red", "green")((X[i]-d3.min(X))/(d3.max(X) - d3.min(X)));
          }
      }
    });
    divContainer.node().appendChild(horizontalChart);*/

}

Vous pouvez enregistrer les modifications et créer un flux à partir de ce modèle D3.

Pour l'instant il y a juste une div vide dans le body, ainsi que ddjson et le dataset affichés dans la console du navigateur :

heatmap4.png

Intégrer le template d3.js existant

Adaptation de heatmap_chart.js

L'intégration du template (le remplacement de la fonction BarChart par une nouvelle fonction Heatmap dans le fichier heatmap_chart.js) peut se faire directement dans l'édition du flux.

Ouvrir l'onglet D3 dans le panneau latéral : vous avez accès à une copie des fichiers de ressources du modèle D3. Sélectionner heatmap_chart.js (la version temporaire du fichier homonyme située dans le dossier D3templates) et cliquer sur l'icône "étendre".

Renommer BarChart en Heatmap et adapter le contenu de la fonction de plugin dans le fichier heatmap_plugin.js en conséquence (décommenter les dernières lignes).

Tous les paramétrages spécifiques à l'ancienne visualisation (axes, échelles...) peuvent être supprimés, tandis que le padding (espacement entre les cellules) peut être passé en paramètre.

A partir de l'instanciation de l'élément svg (d3.create("svg")), on va remplacer l'ancienne construction par la nouvelle, et garder la dernière ligne de retour. On peut retoucher au fur et à mesure en regardant le résultat dans la zone de rendu du flux.

Attention : nous travaillons avec une version 6 de d3.js tandis que le template est développé avec une version 4. Il faut adapter certaines syntaxes dont la ligne d3.csv(...). Il faut également distinguer l'élément svg de l'élément groupe principal décalé par rapport aux marges. Ce groupe principal servira de parent pour l'info-bulle.

Toutes ces modifications conduisent à une première version de la fonction Heatmap en cours de développement.

// https://d3-graph-gallery.com/graph/heatmap_basic.html
function Heatmap(data, {
    marginTop = 10, // the top margin, in pixels
   marginRight = 50, // the right margin, in pixels
   marginBottom = 30, // the bottom margin, in pixels
   marginLeft = 50, // the left margin, in pixels
   width = 640, // the outer width of the chart, in pixels
   height, // outer height, in pixels
   padding = 0.05 // spacing between cells
} = {}) {

   const margin = {
        top: marginTop,
        right: marginRight,
        bottom: marginBottom,
        left: marginLeft
    };
   
   // Building tooltip
   const tooltip = new Tooltip(d3.select("body"), {
        toggleWidth: 150,
        toggleHeight: 50
    });
   
   // Building chart
   const svg = d3.create("svg")
    .attr("width", width)
    .attr("height", height)
    .attr("viewBox", [0, 0, width, height])
    .attr("style", "max-width: 100%; height: auto; height: intrinsic")
   
   const mainGroup = svg.append("g")
        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
   
    width = width - margin.left - margin.right,
    height = height - margin.top - margin.bottom;

   // Labels of row and columns
   var myGroups = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]
   var myVars = ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10"]
   
   // Build X scales and axis:
   var x = d3.scaleBand()
      .range([ 0, width ])
      .domain(myGroups)
      .padding(padding);
    mainGroup.append("g")
      .attr("transform", "translate(0," + height + ")")
      .call(d3.axisBottom(x))
   
   // Build Y scales and axis:
   var y = d3.scaleBand()
      .range([ height, 0 ])
      .domain(myVars)
      .padding(padding);
    mainGroup.append("g")
      .call(d3.axisLeft(y));
   
   // Build color scale
   var myColor = d3.scaleLinear()
      .range(["white", "#69b3a2"])
      .domain([1,100])
   
   //Read the data
   d3.csv("https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/heatmap_data.csv").then(data => {
        mainGroup.selectAll()
          .data(data)
          .enter()
          .append("rect")
          .attr("x", function(d) { return x(d.group) })
          .attr("y", function(d) { return y(d.variable) })
          .attr("width", x.bandwidth() )
          .attr("height", y.bandwidth() )
          .style("fill", function(d) { return myColor(d.value)} )
    });

   return svg.node();
}

La visualisation est basée sur des données statiques récupérées depuis un fichier csv chargé grâce à l'API Fetch de d3.js. Voici ce que ça donne :

heatmap5.png

Exploiter les données passées en entrée du flux

On va maintenant remplacer les données en dur du template intégré, par les données du flux DigDash. Ici c'est relativement simple car la structure de données est elle-même simple et ne nécessite pas de préparation poussée. On va donc se contenter de mettre à jour les attributs myGroups et myVars, et extraire la callback de la Promise du fetch d3.csv(...). Ne pas oublier de renommer les objets (d.dimH et d.dimV à la place de d.group et d.variable) d'après ce qui a été défini dans la fonction de plugin !

// Labels of row and columns
var myGroups = data.map(item => item.dimH);
var myVars = data.map(item => item.dimV);

// ...

mainGroup.selectAll()
          .data(data)
          .enter()
          .append("rect")
          .attr("x", function(d) { return x(d.dimH) })
          .attr("y", function(d) { return y(d.dimV) })
          .attr("width", x.bandwidth() )
          .attr("height", y.bandwidth() )
          .style("fill", function(d) { return myColor(d.value)} );

return svg.node();

Voilà à quoi devrait ressembler votre flux à présent :

heatmap6.png

Si les cellules sont toutes noires comme ici, c'est qu'il faut adapter la fonction de remplissage des cellules aux nouvelles valeurs.

Au lieu d'utiliser un dégradé correspondant à l'intervalle [1, 100], on peut se baser sur les valeurs minimales et maximales des données d'entrées. Soit :

// Min and max values
var minValue = Math.min(...data.map(item => item.value));
var maxValue = Math.max(...data.map(item => item.value));
// Build color scale
var myColor = d3.scaleLinear()
      .range(["white", "#69b3a2"])
      .domain([minValue, maxValue]);

Et voilà le résultat :

heatmap7.png

Changeons les hiérarchies dimensionnelles afin d'avoir plus de cellules :

heatmap8.png

Voici finalement le code provisoire de heatmap_chart.js :

// https://observablehq.com/@d3/horizontal-bar-chart
function Heatmap(data, {
    marginTop = 10, // the top margin, in pixels
   marginRight = 50, // the right margin, in pixels
   marginBottom = 30, // the bottom margin, in pixels
   marginLeft = 50, // the left margin, in pixels
   width = 640, // the outer width of the chart, in pixels
   height, // outer height, in pixels
   padding = 0.05 // spacing between cells
} = {}) {
   
   const margin = {
        top: marginTop,
        right: marginRight,
        bottom: marginBottom,
        left: marginLeft
    };
   
   // Building tooltip
   const tooltip = new Tooltip(d3.select(heatMapGroupId), {
        toggleWidth: 150,
        toggleHeight: 50
    });
   
   // Building chart
   const svg = d3.create("svg")
    .attr("width", width)
    .attr("height", height)
    .attr("viewBox", [0, 0, width, height])
    .attr("style", "max-width: 100%; height: auto; height: intrinsic")
   
   const mainGroup = svg.append("g")
        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
   
    width = width - margin.left - margin.right,
    height = height - margin.top - margin.bottom;

   // Labels of row and columns
   var myGroups = data.map(item => item.dimH);
   var myVars = data.map(item => item.dimV);
   
   // Build X scales and axis:
   var x = d3.scaleBand()
      .range([ 0, width ])
      .domain(myGroups)
      .padding(padding);
    mainGroup.append("g")
      .attr("transform", "translate(0," + height + ")")
      .call(d3.axisBottom(x))
   
   // Build Y scales and axis:
   var y = d3.scaleBand()
      .range([ height, 0 ])
      .domain(myVars)
      .padding(padding);
    mainGroup.append("g")
      .call(d3.axisLeft(y));
   
   // Min and max values
   var minValue = Math.min(...data.map(item => item.value));
   var maxValue = Math.max(...data.map(item => item.value));
   // Build color scale
   var myColor = d3.scaleLinear()
      .range(["white", "#69b3a2"])
      .domain([minValue, maxValue]);
     
    mainGroup.selectAll()
          .data(data)
          .enter()
          .append("rect")
          .attr("x", function(d) { return x(d.dimH) })
          .attr("y", function(d) { return y(d.dimV) })
          .attr("width", x.bandwidth() )
          .attr("height", y.bandwidth() )
          .style("fill", function(d) { return myColor(d.value)} );

   return svg.node();
}

Et celui de heatmap_plugin.js :

var divContainer;
var width;
var height;

// Default specific features (flow parameters)
const SPECIFIC_FEATURES = {
    useColorPalette: "true"
};
// Current specific features
var flowSpecificFeatures = SPECIFIC_FEATURES;
var useColorPalette;

// This chart expects two dimensions and one measure
function heatmapPlugin(ddjson, ddobj, exportCallback)
{
    console.log(">> Digdash d3.js heatmap");
 console.log(ddjson);
   // --------------------------------------------------------------------------
   // Loading specific features
   flowSpecificFeatures = loadSpecificFeatures(ddjson, SPECIFIC_FEATURES);
    useColorPalette = (String(flowSpecificFeatures.useColorPalette) === 'true');
   // --------------------------------------------------------------------------
   // for export we need no transition
if (ddCtrl && ddCtrl.__nodejs)
 {
  width = window.screen.width; //__ow * __rootGO.clientToCoord;
 height = window.screen.height;//__oh * __rootGO.clientToCoord;
 if (exportCallback) {
      setTimeout(exportCallback, 1000); // approx chart drawing time
 }
 }
else
 {
  width = document.body.clientWidth; //__ow * __rootGO.clientToCoord;
 height = document.body.clientHeight;//__oh * __rootGO.clientToCoord;  
}

   var dataset = [];
   for (let memberKey of ddjson.lines) {
       let x = {};
        x.value = ddjson.getValue(memberKey, 0);
        x.dimV = ddjson.getMemberByKey(memberKey, 0).formated;
        x.dimH = ddjson.getMemberByKey(memberKey, 1).formated;
        dataset.push(x);
    }
   
   var measureName = ddjson.measures[0].id;

    divContainer = d3.select("body").append("div");
var heatmapChart = Heatmap(dataset, {
      width: width,
      height: height,
      padding: 0.08
    });
    divContainer.node().appendChild(heatmapChart);
}

Penser à valider le flux ainsi créé et enregistrer sur le serveur les modifications apportées aux contenus des fichiers :

heatmap9.png

On a réussi à implémenter un nouveau modèle D3 de heatmap à partir d'un template existant et à créer un flux avec.

Développement du modèle

Ajouter une info-bulle

Les interactions sur un flux de type D3 doivent être directement implémentées dans le code du modèle, et non pas paramétrées depuis l'interface du Studio comme c'est le cas pour les autres types de flux.

Pour ajouter une info-bulle à votre modèle de visualisation D3, deux fichiers sont disponibles dans les ressources :

  • common_tooltip-1.js : permet de créer une info-bulle Tooltip munie des fonctions showTooltip(text) et hideTooltip()
  • common_tooltip-1.css : feuille de style de l'info-bulle

Ce sont des ressources communes à plusieurs modèles, donc si vous modifiez leur contenu, tous les modèles qui les utilisent seront impactés. Si besoin, dupliquez ces fichiers pour les affecter à votre modèle.

Un objet Tooltip est un élément html et doit donc s'instancier sur un élément html du DOM (pas sur un élément svg !).

var tooltipParent;

function Tooltip(parent, {
    className = "tooltip-1",
    toggleWidth = 150, // If available right space is under 150px, tooltip switches to the left of cursor
   toggleHeight = 80 // If available bottom space is under 80px, tooltip switches to the top of cursor
   
} = {}) {
   this.tooltip = parent.append("div")
        .attr("id", "tooltip")
        .attr("class", className)
        .style("position", "absolute")
        .style("display", "none");
       
    tooltipParent = parent;
   this.toggleWidth = toggleWidth;
   this.toggleHeight = toggleHeight;
}

Tooltip.prototype.showTooltip = function(text) {
   // Tooltip position is adjusted to be always inside chart container
   let docRect = document.body.getBoundingClientRect();
   let dW = 15;
   let dH = 10;
   if (event.pageX + dW + this.toggleWidth > docRect.right) {
        dW = - this.toggleWidth;
    }
   if (event.pageY + dH + this.toggleHeight > docRect.bottom) {
        dH = - this.toggleHeight;
    }
   
   let tooltipY = event.pageY + dH;
   let tooltipX = event.pageX + dW;
   
   this.tooltip.text(text)
    .style("display", "block")
    .style("top", tooltipY + 10 + "px")
    .style("left", tooltipX + "px");
}

Tooltip.prototype.hideTooltip = function() {
  this.tooltip.style("display", "none")
}

Vous pouvez consulter le modèle D3 Horizontal Bar et son fichier horizontalBar_chart.js pour avoir un modèle d'implémentation.

On appelle showTooltip(text) au mousemove et hideTooltip() au mouseout. Le texte de l'info-bulle peut être paramétré par la donnée correspondante à l'élément survolé (d3.js lie automatiquement les données aux éléments graphiques).

On peut ajouter un évènement au mouseover pour changer l'apparence de l'élément survolé (par exemple dessiner une bordure).

    // Building tooltip
   const tooltip = new Tooltip(d3.select("body"), {
        toggleWidth: 150,
        toggleHeight: 50
    });
   
   // Building chart
   // ...
   // ...
     
    mainGroup.selectAll()
          .data(data)
          .enter()
          .append("rect")
          .attr("x", function(d) { return x(d.dimH) })
          .attr("y", function(d) { return y(d.dimV) })
          .attr("width", x.bandwidth() )
          .attr("height", y.bandwidth() )
          .style("fill", function(d) { return myColor(d.value)} )
        .on("mouseover", function(event, i) {
           // change the selection style: width and color of the stroke which allows up to control of the shape of the ends of lines
           d3.select(this)
            .style("cursor", "pointer")
            .attr("stroke-width", '2')
            .attr("stroke", "black");
        })
        .on("mousemove", function(event, d) {
            tooltip.showTooltip(`${d.dimV} - ${d.dimH} : ${d.value}`);
        })
        .on("mouseout", function(event, d) {
           // change the selection style
           d3.select(this)
            .attr('stroke-width', '0')
            tooltip.hideTooltip();
        });

heatmap10.png

Adapter la fonction de remplissage

La fonction de remplissage est une fonction qui à une valeur numérique (la valeur correspondante à la cellule) renvoie un code couleur hexadécimal.

On va déplacer la fonction de remplissage, que nous allons appeler colorize, pour la placer en paramètre du constructeur de Heatmap.

d3.js propose une multitude de fonctions de couleurs https://observablehq.com/@d3/working-with-color : fonctions de palettes et fonctions de dégradés, notamment avec le module d3-interpolate https://github.com/d3/d3-interpolate

Ici plusieurs implémentations sont possibles en fonction du rendu désiré, mais il vaut mieux que cette fonction colorize soit définie sur l'intervalle [0, 1] afin d'être exploitable dans le cas général, et ensuite projeter l'intervalle [minimum, maximum] sur [0, 1] :

// https://observablehq.com/@d3/horizontal-bar-chart
function Heatmap(data, {
    marginTop = 10, // the top margin, in pixels
   marginRight = 50, // the right margin, in pixels
   marginBottom = 30, // the bottom margin, in pixels
   marginLeft = 50, // the left margin, in pixels
   width = 640, // the outer width of the chart, in pixels
   height, // outer height, in pixels
   padding = 0.05, // spacing between cells
   colorize = d3.scaleLinear() // color scale function
             .range(["white", "#69b3a2"])
              .domain([0, 1])
} = {}) {
   // ...
   // ...

   // Min and max values
   var minValue = Math.min(...data.map(item => item.value));
   var maxValue = Math.max(...data.map(item => item.value));
   // Scaling from [min, max] to [0, 1]
   var g = d3.scaleLinear()
            .domain([minValue, maxValue])
            .range([0,1])
   var fillFunction = v => colorize(g(v))
     
    mainGroup.selectAll()
          .data(data)
          .enter()
          .append("rect")
          .attr("x", function(d) { return x(d.dimH) })
          .attr("y", function(d) { return y(d.dimV) })
          .attr("width", x.bandwidth() )
          .attr("height", y.bandwidth() )
          .style("fill", d => fillFunction(d.value))
        .on("mouseover", function(event, i) {
           // change the selection style: width and color of the stroke which allows up to control of the shape of the ends of lines
           d3.select(this)
            .style("cursor", "pointer")
            .attr("stroke-width", '2')
            .attr("stroke", "black");
        })
        .on("mousemove", function(event, d) {
            tooltip.showTooltip(`${d.dimV} - ${d.dimH} : ${d.value}`);
        })
        .on("mouseout", function(event, d) {
           // change the selection style
           d3.select(this)
            .attr('stroke-width', '0')
            tooltip.hideTooltip();
        });

   return svg.node();
}

Passons une nouvelle fonction de remplissage dans heatmap_plugin.js : l'interpolation HslLong allant de rouge pour les valeurs minimales à vert turquoise pour les valeurs maximales

var heatmapChart = Heatmap(dataset, {
      width: width,
      height: height,
      padding: 0.08,
      colorize: d3.interpolateHslLong("red", "#00ffff")
});

heatmap12.png

Fonction de remplissage propre à chaque flux

Pour le moment, la fonction de remplissage définie dans le code du modèle D3 sera identique pour tous les flux.

Pour que chaque flux ait sa propre fonction de remplissage des cellules, plusieurs stratégies sont possibles :

  • passer la fonction de remplissage en tant que fonctionnalité avancée (specific feature)
  • paramétrer le dégradé à partir de la palette de couleur sélectionnée
  • paramétrer le dégradé à partir d'un format alerteur

Détaillons la première option : la fonction de remplissage sera passée en tant que chaîne de caractère depuis l'onglet "Paramètres de visualisation" de l'éditeur de flux et instanciée à partir de cette chaîne dans la fonction de plugin.

Par défaut on reçoit une chaîne de caractère à partir de laquelle on construit une fonction javascript :

var divContainer;
var width;
var height;

// Default specific features (flow parameters)
const SPECIFIC_FEATURES = {
    colorizeFunction: "return d3.interpolateHslLong('red', '#00ffff')"
};
// Current specific features
var flowSpecificFeatures = SPECIFIC_FEATURES;
var useColorPalette;

// This chart expects two dimensions and one measure
function heatmapPlugin(ddjson, ddobj, exportCallback)
{
    console.log(">> Digdash d3.js heatmap");
 console.log(ddjson);
   // --------------------------------------------------------------------------
   // Loading specific features
   flowSpecificFeatures = loadSpecificFeatures(ddjson, SPECIFIC_FEATURES);
    colorizeFunction = (new Function(String(flowSpecificFeatures.colorizeFunction)));
   // --------------------------------------------------------------------------
   // for export we need no transition
if (ddCtrl && ddCtrl.__nodejs)
 {
  width = window.screen.width; //__ow * __rootGO.clientToCoord;
 height = window.screen.height;//__oh * __rootGO.clientToCoord;
 if (exportCallback) {
      setTimeout(exportCallback, 1000); // approx chart drawing time
 }
 }
else
 {
  width = document.body.clientWidth; //__ow * __rootGO.clientToCoord;
 height = document.body.clientHeight;//__oh * __rootGO.clientToCoord;  
}

   var dataset = [];
   for (let memberKey of ddjson.lines) {
       let x = {};
        x.value = ddjson.getValue(memberKey, 0);
        x.dimV = ddjson.getMemberByKey(memberKey, 0).formated;
        x.dimH = ddjson.getMemberByKey(memberKey, 1).formated;
        dataset.push(x);
    }
   
   var measureName = ddjson.measures[0].id;

    divContainer = d3.select("body").append("div");
var heatmapChart = Heatmap(dataset, {
      width: width,
      height: height,
      padding: 0.08,
      colorize: colorizeFunction.call(this)
    });
    divContainer.node().appendChild(heatmapChart);
}

Le paramétrage s'effectue ainsi depuis l'éditeur de flux :

heatmap13.png

Exploiter un format alerteur

Etant donné que cette visualisation est équivalente à un tableau croisé avec format alerteur, il peut être intéressant de voir comment récupérer un format alerteur affecté à la mesure.

Pour récupérer un format affecté à la mesure passée en entrée du flux, ajouter ces lignes dans la fonction de plugin :

var formats = ddjson.selInfo.values.format; // Array of formats
var format0 = formats[0];

if (format0 && format0.alerters) {
  // Working with alerter format ...
}

Si le format est de type alerteur, l'objet format possède un champ alerters qui est un tableau d'objets Alerters :

heatmap11.png

Vous pouvez ainsi adapter à nouveau votre fonction de remplissage au format alerteur affecté à la mesure du flux, par exemple en construisant une fonction de dégradé à partir de chacune des bornes des alerteurs, et en gardant une fonction de remplissage par défaut au cas où un format alerteur ne serait pas défini sur la mesure.

La fonction de remplissage sera alors propre à chaque flux (elle dépendra du format affecté à la mesure) au lieu d'être identique pour tous les flux.

Paramétrer une interaction au clic sur une cellule

Une interaction au clic s'implémente de la même manière que l'info-bulle pour l'interaction au survol.

Pour que chaque flux ait sa propre interaction, il faudra passer le code Javascript de l'interaction via les fonctionnalités avancées, sinon elle sera la même pour tous les flux.

Les interactions propres à DigDash (filtrage, ouverture de flux en popup, navigation dans la hiérarchie) utilisent les fonctions de l'API Dashboard. Trois implémentations simplifiées sont disponibles dans ddjson.interactions 

  • ddjson.interactions.doChangeDimension(dimExplorer, memberId) : pour changer de dimension si un chemin de navigation est activé
  • ddjson.interactions.doDrillDown(dimExplorer, memberId) : pour naviguer dans la hiérarchie si l'exploration est activée
  • ddjson.interactions.doFilter(dimExplorer, memberId) : pour filtrer la dimension sur le membre correspondant

Avant toute chose, on va commencer par implémenter un évènement au clic sur la cellule et on passe la fonction correspondante en paramètre du constructeur du graphe :

// https://observablehq.com/@d3/horizontal-bar-chart
function Heatmap(data, {
    marginTop = 10, // the top margin, in pixels
   marginRight = 50, // the right margin, in pixels
   marginBottom = 30, // the bottom margin, in pixels
   marginLeft = 50, // the left margin, in pixels
   width = 640, // the outer width of the chart, in pixels
   height, // outer height, in pixels
   padding = 0.05, // spacing between cells
   colorize = d3.scaleLinear() // color scale function
             .range(["white", "#69b3a2"])
              .domain([0, 1]),
    onClick = function(event, d) {
       // console.log(d); // showing datum
   }
} = {}) {
   // ...
   // ...
   
    mainGroup.selectAll()
          .data(data)
          .enter()
          .append("rect")
          .attr("x", function(d) { return x(d.dimH) })
          .attr("y", function(d) { return y(d.dimV) })
          .attr("width", x.bandwidth() )
          .attr("height", y.bandwidth() )
          .style("fill", d => fillFunction(d.value))
        .on("mouseover", function(event, i) {
           // change the selection style: width and color of the stroke which allows up to control of the shape of the ends of lines
           d3.select(this)
            .style("cursor", "pointer")
            .attr("stroke-width", '2')
            .attr("stroke", "black");
        })
        .on("mousemove", function(event, d) {
            tooltip.showTooltip(`${d.dimV} - ${d.dimH} : ${d.value}`);
        })
        .on("mouseout", function(event, d) {
           // change the selection style
           d3.select(this)
            .attr('stroke-width', '0')
            tooltip.hideTooltip();
        })
        .on("click", onClick)

   return svg.node();
}

Filtrer sur un membre

Pour filtrer sur un couple dimension-membre, vous pouvez appeler la fonction ddCtrl.filter. Deux appels sont possibles selon que les dimensions sont hiérarchisées ou non :

// Filtering on one dimension (current hierarchy and current level)
ddCtrl.filter(dimensionId, memberId);

// Filtering on one dimension depending on hierarchies
var filterOperand = new ddCtrl.FilterOperand(
                          dimensionId,
                          memberId,
                          hierarchyId,
                          levelId);
ddCtrl.filter(filterOperand);

L'utilisation de filterOperand fonctionnera dans le cas général.

L'appel de ddjson.interactions.doFilter est plus simple car les objets de type DimExplorer contiennent les informations passées en paramètres de FilterOperand.

Pour le moment, la donnée associée à chaque cellule ne contient pas suffisamment d'information. Il faut lui passer la liste des membres correspondant pour pouvoir l'exploiter à l'évènement clic.
Nous pouvons ensuite implémenter le filtrage sur une dimension :

var divContainer;
var width;
var height;

// Default specific features (flow parameters)
const SPECIFIC_FEATURES = {
    colorizeFunction: "return d3.interpolateHslLong('red', '#00ffff')"
};
// Current specific features
var flowSpecificFeatures = SPECIFIC_FEATURES;
var useColorPalette;

// This chart expects two dimensions and one measure
function heatmapPlugin(ddjson, ddobj, exportCallback)
{
    console.log(">> Digdash d3.js heatmap");
 console.log(ddjson);
   // --------------------------------------------------------------------------
   // Loading specific features
   flowSpecificFeatures = loadSpecificFeatures(ddjson, SPECIFIC_FEATURES);
    colorizeFunction = (new Function(String(flowSpecificFeatures.colorizeFunction)));
   // --------------------------------------------------------------------------
   // for export we need no transition
if (ddCtrl && ddCtrl.__nodejs)
 {
  width = window.screen.width; //__ow * __rootGO.clientToCoord;
 height = window.screen.height;//__oh * __rootGO.clientToCoord;
 if (exportCallback) {
      setTimeout(exportCallback, 1000); // approx chart drawing time
 }
 }
else
 {
  width = document.body.clientWidth; //__ow * __rootGO.clientToCoord;
 height = document.body.clientHeight;//__oh * __rootGO.clientToCoord;  
}

   var dataset = [];
   for (let memberKey of ddjson.lines) {
       let x = {};
        x.value = ddjson.getValue(memberKey, 0);
       // Setting members
       x.members = [];
        x.members.push(ddjson.getMemberByKey(memberKey, 0));
        x.members.push(ddjson.getMemberByKey(memberKey, 1));
        x.dimV = ddjson.getMemberByKey(memberKey, 0).formated; // on vertical axis
       x.dimH = ddjson.getMemberByKey(memberKey, 1).formated; // on horizontal axis
       dataset.push(x);
    }
   
   var measureName = ddjson.measures[0].id;

    divContainer = d3.select("body").append("div");
var heatmapChart = Heatmap(dataset, {
      width: width,
      height: height,
      padding: 0.08,
      colorize: colorizeFunction.call(this),
      onClick: (event, d) => {
          console.log(d);
         let colIndex = 1; // The column index on which dimension explorer will be filtered
         let dimIndex = 0; // In case of several dimensions would be set on one column
         let dimExplorers = ddjson.dimensionsByColumns[colIndex]; // DimExplorers on the column
         let dimExplorer = dimExplorers[dimIndex];
         let memberId = d.members[colIndex].id;
         // Performing filter interaction
         ddjson.interactions.doFilter(dimExplorer, memberId);
      }
    });
    divContainer.node().appendChild(heatmapChart);
}

Attention, cette implémentation ne fonctionnera pas dans le cas où une colonne supporterait plusieurs dimensions. Il faudrait pour cela retrouver chaque identifiant de membre correspondant à chacune des dimensions déposées sur la colonne (solution possible : split de l'identifiant du membre multi-dimensionnel).

heatmap14.png

On constate que l'échelle de couleurs de remplissage se met automatiquement à jour en fonction des nouvelles valeurs minimales et maximales des nouvelles données filtrées.

Ce n'est pas incohérent, mais si vous souhaitez conserver les couleurs des cellules avant filtrage, il faut que la visualisation filtrée se base sur les mêmes valeurs extrêmes qu'avant filtrage : soit utiliser des valeurs constantes (par exemple 0-100), soit en étant capable de les évaluer à partir du modèle de données (disponible avec ddjson.selInfo.dm).

Et pour filtrer sur l'intersection des deux membres, il suffit d'exécuter les deux filtrages consécutivement :

var filteringHeatmap = function(datum, colIndex, dimIndex=0) {
      let dimExplorers = ddjson.dimensionsByColumns[colIndex]; // DimExplorers on the column
      let dimExplorer = dimExplorers[dimIndex];
      let memberId = d.members[colIndex].id;
      // Performing filter interaction
      ddjson.interactions.doFilter(dimExplorer, memberId);
}

var heatmapChart = Heatmap(dataset, {
      width: width,
      height: height,
      padding: 0.08,
      colorize: colorizeFunction.call(this),
      onClick: (event, d) => {
          console.log(d);
          ddCtrl.beginSelection(); // optim
          filteringHeatmap(d, 0);
          filteringHeatmap(d, 1);
          ddCtrl.commitSelection(); // optim
     }
    });
    divContainer.node().appendChild(heatmapChart);
}

Naviguer dans la hiérarchie

Disponible sur versions 2023R1 et postérieures

Pour implémenter des interactions de type exploration dans une hiérarchie ou parcours d'un chemin de navigation, vous devez d'abord activer les navigations disponibles dans l'édition du flux, onglet "Interactions" :

heatmap15.png

L'activation d'une navigation rend disponible l'exploration qui se matérialise dans la visualisation par le fil d'Ariane (breadcrumb) :

heatmap16.png

Puis dans le code du modèle D3, appeler la fonction ddjson.interactions.doDrillDown qui prend les mêmes paramètres que la fonction de filtrage :

    divContainer = d3.select("body").append("div");
   var heatmapChart = Heatmap(dataset, {
      width: width,
      height: height,
      padding: 0.08,
      colorize: colorizeFunction.call(this),
      onClick: (event, d) => {
          console.log(d);
         let colIndex = 1; // The column index on which dimension explorer will be drilled
         let dimIndex = 0; // In case of several dimensions would be set on one column
         let dimExplorers = ddjson.dimensionsByColumns[colIndex]; // DimExplorers on the column
         let dimExplorer = dimExplorers[dimIndex];
         let memberId = d.members[colIndex].id;
         // Performing drill interaction
         ddjson.interactions.doDrillDown(dimExplorer, memberId);
      }
    });

En activant plusieurs hiérarchies, vous avez ainsi la possibilité d'exploiter plusieurs navigations. Par exemple, au clic, naviguer en même temps sur toutes les hiérarchies répertoriées à l'intersection des dimensions interceptées.

Il faut néanmoins garder à l'esprit que tout ce qui est codé dans le modèle D3 sera traité par tous les flux utilisant ce modèle. Dans l'exemple précédent, le modèle D3 attend une navigation hiérarchique disponible et activée sur la dimension passée en colonne 2. Découpler davantage les fonctions d'interaction entre le modèle et le flux, à l'aide des fonctionnalités spécifiques, demande plus d'effort mais offrira plus de souplesse à l'usage. 

Paramétrage depuis le studio

Comme toute fonctionnalité spécifique au flux exploitable par le modèle D3, elle peut être passée dans le tableau des fonctionnalités spécifiques des paramètres de visualisation et récupérée par la fonction de plugin.

Par exemple, 

// Default specific features (flow parameters)
const SPECIFIC_FEATURES = {
    colorizeFunction: "return d3.interpolateHslLong('red', '#00ffff')",
    onClickInteraction: "filter"
};
// This chart expects two dimensions and one measure
function heatmapPlugin(ddjson, ddobj, exportCallback) {
    console.log(">> Digdash d3.js heatmap");
 console.log(ddjson);
   // --------------------------------------------------------------------------
   // Loading specific features
   flowSpecificFeatures = loadSpecificFeatures(ddjson, SPECIFIC_FEATURES);
    colorizeFunction = (new Function(String(flowSpecificFeatures.colorizeFunction)));
    onClickInteraction = String(flowSpecificFeatures.onClickInteraction);
   // --------------------------------------------------------------------------
// ...
   var heatmapChart = Heatmap(dataset, {
          width: width,
          height: height,
          padding: 0.08,
          colorize: colorizeFunction.call(this),
          onClick: (event, d) => {
              console.log(d);
             switch(onClickInteraction) {
                 case "filter":
                      ddCtrl.beginSelection();
                      filteringHeatmap(d, 0);
                      filteringHeatmap(d, 1);
                      ddCtrl.commitSelection();
                     break;
                 case "filterHorizontal":
                      filteringHeatmap(d, 1);
                     break;
                 case "openFlow":
                     // to be completed
                     break;
                 default:
                     // to be completed
             }
          }
    });
   // ...
}

Conclusion

Cette fiche résolution a détaillé la construction d'un modèle D3 de heatmap modulable.

En fonction des besoins, d'autres fonctionnalités peuvent être développées :

- style des axes

- style des cellules

- effets d'affichage (transitions)


A partir de cet exemple, vous devriez être capable de créer un modèle de Hexbin.

C'est une visualisation comparable à la Heatmap mais dont les cellules sont de forme hexagonale. Elle peut permettre de représenter une densité.

https://d3-graph-gallery.com/img/graph/density2d_hexbin.png