Libro de cocina de D3js en español

Introducción a D3JS

D3JS es una biblioteca Javascript que permite realizar documentos orientados a datos. Su fuerte es proporcionar maneras de interactuar y visualizar datos usando los navegadores web. A simple vista parece estar orientado a científicos que trabajan sobre datos, pero combinado con el poder de SVG o Canvas puede ser increíblemente útil en la construcción de elementos visuales para aplicaciones web.

  • NPM: npm install d3

Historia

D3JS ha sido creado por Mike Bostock, un desarrollador estadounidense especializado en el tratado de datos y su visualización. Trabajó para el New York Times mientras desarrollaba la biblioteca y tiene una vasta cantidad de ejemplos de su uso publicados.


Consideraciones para novatos

A lo largo de las versiones mayores de D3JS se han introducido diversos cambios no compatibles hacia atrás, los cuales pueden consultarse en el CHANGELOG.

El cambio más drástico ha sido el paso de la versión 3 a la 4.

Estructura del código

A partir de la versión 4, D3JS se ha vuelto una biblioteca modular, es decir, cada módulo se encuentra en un repositorio separado, por lo cual pueden importarse los módulos necesarios para empaquetarlos en un bundle sin depender del resto de códigos no usados.

Por lo tanto si encuentras ejemplos en la web, mira siempre la versión en la que estén diseñados, puesto que si es la versión 3 y estás utilizando la 4, (depende de tu conocimiento de la biblioteca y la complejidad del ejemplo) te puede ser difícil de trascribir y es probable que salten errores.

Nota

Abriendo la consola en cada página de este libro puedes comprobar la versión en la que están desarrollados sus ejemplos.

D3JS ofrece un bundle (archivo) que incluye el núcleo de la biblioteca, que es el que importarás si estás simplemente probando en desarrollo o no usas empaquetado (Webpack, Rollup…). Puedes ver los módulos que incluye el bundle nuclear en el index del repositorio principal. Si encuentras algún ejemplo con módulos no incluídos en el bundle estándar, tendrás que instalarlos o linkearlos por separado.

D3JS es el infierno

Es lo que pensarás al principio y es lo que pensaba yo dándome cabezazos contra los conceptos centrales de la biblioteca. ¿Cómo que puedo crear elementos fantasma con datos que no existen pero que van a entrar? ¿Cómo que los datos van a salir? ¿Por qué todas las funciones se encadenan?

Otra barrera de entrada a esta biblioteca es que debes saber SVG o Canvas, cuanto más mejor. Si estás perdido, échale un ojo a la guía sobre SVG que publiqué hace meses (prometo terminar de incluirla en este libro).

Comprende desde cosas muy básicas como selecciones estilo jQuery a diseños predefinidos donde volcar tus datos los cuales hay que comprender perfectamente antes de ponerse manos a la obra. Lo único que puedo aconsejarte es que estudias bien los ejemplos, luego todo será más sencillo.

Nota

No debes perder nunca de vista la referencia, que está escrita en Markdown en el README.md del repositorio de cada módulo.

Selecciones — d3-selection

El módulo d3-selection está incluido en la distribución estándar de D3JS y proporciona funciones para seleccionar elementos del DOM. Usa las cadenas de texto de selecciones estandarizadas por la W3C:

// Selección de un nodo
var a = d3.select("a");
console.log(a);

// Selección de todos
var lis = d3.selectAll("li");

// Selección anidada
var as = d3.selectAll("li")
    .select("a");

Si ves la salida de la consola, puedes comprobar como la selección devuelve un objeto con los atributos _groups y _parents.

  • _groups: representa a los grupos de objetos que han sido seleccionados. D3JS almacena agrupados los objetos para ejecutar operaciones sobre ellos. Es una subclase de Array.
  • _parents: representa los padres de los grupos de objetos. Estos dependerán de como se seleccionan. Usando fuciones como d3.select en lugar de usar sintaxis encadenada con .select sobre otro objeto, devolverá un array con el objeto raíz del documento HTML.

Nota

Con el siguiente código, hemos aumentado el tamaño del título de este capítulo y lo hemos centrado:

d3.select("h1")
  .style("font-size", "2em")
  .style("text-align", "center");

Trabajando con datos

D3JS es una biblioteca enfocada a representar datos de forma gráfica. Para ello, cuenta con funciones que están especializadas en enlazar datos y elementos, para ir actualizándolos sincronizados.

Enlazando datos y elementos - selection.data

Jugando con las selecciones podemos hacer lo siguiente:

<div id="contenedor-vacio"></div>
<script>
    var spans = d3.select("contenedor-vacio")
        .selectAll("span");

    console.log(spans);
    /**
     * Rt {_groups: Array(0), _parents: Array(0)}
     *   _groups: []
     *   _parents: []
     *   __proto__: Object
     **/
</script>

Como puedes ver, devuelve una selección vacía. Esto no parece tener mucho sentido, ¿por qué obtener los elementos span de un div vacío? Pues en D3JS esto es muy común.

Supongamos que tenemos los siguientes datos que representan los días de la semana:

var data = [
    {day: "Monday", days_until_next_weekend: 5},
    {day: "Tuesday", days_until_next_weekend: 4},
    {day: "Wednesday", days_until_next_weekend: 3},
    {day: "Thursday", days_until_next_weekend: 2},
    {day: "Friday", days_until_next_weekend: 1},
    {day: "Saturday", days_until_next_weekend: 7},
    {day: "Sunday", days_until_next_weekend: 6},
]

Podríamos representar esos datos como elementos SVG haciendo lo siguiente:

for (var i=0; i<data.length; i++) {
    var g = svg.append("g");
    g.attr("transform", "translate(" + (90*i+30) + "," + height/2 + ")");

    var circle = g.append("circle");
    circle.attr("r", 10);
    circle.attr("fill", "red");
    circle.attr("fill-opacity", 0.2);

    var text = g.append("text");
    text.text(data[i].day);
}

Pero, ¿qué pasaría si queremos actualizar esos datos en tiempo real? Tendríamos que atravesar el DOM buscando el dato que queremos y eliminar sus elementos correspondientes. Además, fíjate en la sintaxis. ¿No es demencial?

Ahora, veamos el mismo ejemplo usando D3:

var g = svg.selectAll("g")
  .data(data)
  .enter().append("g")
    .attr("transform",function(d, i){
        return "translate(" + (90*i+30) + "," + height/2 + ")";
    })

g.append("circle")
  .attr("r", 10)
  .attr("fill", "red")
  .attr("fill-opacity", 0.2)

g.append("text")
  .text(function(d) { return d.day; });

Presta atención a la primera línea: svg.selectAll("g"). ¡Estamos seleccionando elementos que no existen! Así creamos una selección vacía y en la siguiente línea le pasamos nuestros datos a esa selección con la función selection.data.

Para comprobar lo que hace esta extraña función podemos hacer:

var g = svg.selectAll("g").data(data);
console.log(g);

/**
 * Rt {_groups: Array(1), _parents: Array(1), _enter: Array(1), _exit: Array(1)}
 *   _enter: [Array(7)]
 *   _exit: [Array(0)]
 *   _groups: [Array(7)]
 *   _parents: [svg#data2]
 **/

Mira que interesante. Tenemos un objeto muy parecido a una selección. Las propiedades _groups y _parents siguen siendo los grupos de objetos y los padres (en este caso el elemento SVG raíz), que los vimos al principio de este capítulo. Pero ahora han aparecido dos propiedades más: _enter y _exit:

  • _enter: Es la propiedad donde se almacena la selección de los datos que han entrado a la selección. Para obtener los elementos de esta selección usamos la función selection.enter.
  • _exit: Es la propiedad donde se almacena la selección de los datos que existen en los elementos del DOM seleccionados pero ya no existen entre los datos que han entrado a la selección, es decir, correponde a los datos que salen. Para obtener los elementos de esta selección usamos la función selection.exit.

Patrón de actualización

Como dijimos al principio, D3JS no sólo es capaz de enlazar datos a elementos y representarlos, si no que es capaz de actualizarlos en tiempo real. Para ello, es fundamental conocer el patrón de actualización y como implementarlo en D3JS.

Este sigue los siguientes pasos:

  1. Antes de la ejecución del código, no existen elementos en el DOM.
  2. Cuando se ejecuta por primera vez una selección vacía con selectAll y le enlazamos datos con selection.data, la selección sigue estando vacía, pero se añaden las propiedades _enter y _exit y estas esperan a que entren o salgan datos.
  3. Cuando se ejecuta la función selection.enter, seleccionamos todos los datos que entraron con selection.data preparados para ser manipulados. En este paso es muy común añadir los elementos correspondientes a los datos al DOM con selection.append o selection.insert.
  4. Como estamos en un patrón de actualización, tenemos que ejecutar un timer (ver Timer — d3-timer), es decir, tenemos que hacer que se establezca algún tipo de bucle para que se pueda actualizar el contenido de la presentación.
  5. A partir de la segunda vez que aplicamos la función selection.data sobre un elemento ocurre lo siguiente:
    • La selección exit almacenará los elementos cuyos datos definimos la primera vez pero ahora no se encuentran entre los nuevos datos. Es muy común aplicar sobre ellos la función selection.remove para eliminarlos.
    • La selección enter alamacenará los elementos cuyos datos han entrado nuevos, los cuales no existían antes en el DOM.

Escalas — d3-scale

Las escalas son abstracciones que nos permiten representar una dimensión de datos abstractos en una representación visual. Pueden representar cualquier codificación visual, tales como divergencia de colores, anchos de trazo o tamaños de símbolos. Dependendiendo del tipo de datos que queramos representar debemos elegir un tipo de escala adecuado.

Las escalas se componen de dos elementos principales: dominios y rangos.

Dominios y rangos

Las escalas mapean un dominio de entrada a un rango de salida. Por lo tanto, las funciones de escala toman un intervalo y lo transforman en otro.

var x = d3.scaleLinear()
    .domain([10, 130])
    .range([0, 960]);

// Obtener un valor del rango de su equivalente del dominio
console.log(x(20));   // 80
console.log(x(5));    // -40

// Obtener un valor del dominio de su equivalente del rango
console.log(x.invert(960));  // 130

Como puedes ver en el ejemplo anterior, el dominio se ha establecido entre 10 y 130 y el rango entre 0 y 960. Para obtener el valor del rango que equivale a un cierto valor del dominio llamamos a la escala como función pasándole el valor del dominio del cual queremos consultar su equivalencia en el rango. Para el proceso contrario usamos la función invert().

Nota

Para calcular cuantas unidades de rango equivale cada una de dominio: \((range_y - range_x) / (domain_y - domain_x)\).

Las escalas no sólo representan datos matemáticos, si no cualquier rango de matices, como por ejemplo gamas de colores:

var color = d3.scaleLinear()
    .domain([0, 100])
    .range(["brown", "steelblue"])
console.log(color(30));  // rgb(137, 68, 83)

Interpoladores

Otra noción básica de las escalas son los interpoladores. En matemáticas la interpolación es la obtención de nuevos puntos partiendo del conocimiento de un conjunto discreto de puntos.

Nota

Aquí puedes ver ejemplos de interpoladores usados en D3JS para mapear escalas de colores y una implementación básica en Python .

  • Interpolar (RAE): Calcular el valor aproximado de una magnitud en un intervalo cuando se conocen algunos de los valores que toma a uno y otro lado de dicho intervalo.

Escalas continuas

Las escalas continuas mapean un dominio cuantitativo de entrada a un rango continuo de salida.

Clamping

El clamping o «represión» (en español) es un atributo que está desactivado por defecto en la mayoría de escalas. Al activarlo, no podremos acceder a los valores fuera de rango y dominio: al intentarlo devolverá los valores del extremo.

var x = d3.scaleLinear()
    .domain([10, 130])
    .range([0, 960]);

// Clamping desactivado, acceso a los valores externos al mapeo
x(-10);          // -160
x.invert(-160);  // -10

// Activación del clamping
x.clamp(true);
// Ahora no se permite acceder a los valores externos
x(-10);          // 0
x.invert(-160);  // 10

Ticks

La función escala_continua.ticks([count]) devuelve aproximadamente count valores del dominio de la escala (por defecto 10 si el parametro count no es especificado).

var x = d3.scaleLinear()
    .domain([0, 100])
    .range([3000, 5000])
x.ticks(5);     // Array [ 0, 20, 40, 60, 80, 100 ]

Los valores devueltos están uniformemente espaciados, tienen valores legibles por humanos (como múltiplos de potencias de 10) y se garantiza que estarán dentro de la extensión del dominio. Los ticks son usados a menudo para mostrar líneas de referencia o marcas, en conjunción con los datos visualizados.

Escalas lineales - d3.scaleLinear()

Esta función contruye una nueva escala con dominio y rango [0, 1], el interpolador por defecto y el clamping desactivado. Este tipo de escalas son una buena elección para datos cuantitativos continuos porque estos preservan diferencias proporcionales.

Nota

Cada valor del rango y puede ser expresado como una función del valor del dominio x: \(y = mx + b\).

Escalas exponenciales - d3.scalePow()

Construye una escala continua con dominio y rango [0, 1], exponente 1, el interpolador por defecto y el clamping desactivado. Esta escala será igual que una escala lineal si mantenemos el exponente a 1. Para cambiarlo podemos usar el método exponent():

var x = d3.scalePow()
    .domain([0, 10])
    .range([0, 100])
console.log(x(4)); // 40

x.exponent(2);
console.log(x(4)); // 16

Nota

Cada valor del rango y puede ser expresado como una función del valor de dominio x: \(y = mx^k + b\), donde k es el valor del exponente.

Escalas logarítmicas - d3.scaleLog()

Las escalas logarítmicas son similares a las escalas lineales, excepto en que aplica una transformación logarítmica es aplicada a los valores dominio de entrada antes de que el los valores del rango de salida sean calculados.

Nota

El mapeo al valor del rango y puede ser expresado com una función del valor de dominio x: \(y = m log(x) + b.\)

Escalas de tiempo - d3.scaleTime()

Las escalas de tiempo son una variante de las escalas lineales que tienen un dominio temporal: los valores de dominio son coercidos a fechas en lugar de números y la función invert() devuelve una fecha asimismo. Estas escalas implementan ticks basados en intervalos de calendarios, eliminando el dolor de generar ejes para dominios temporales.

var x = d3.scaleTime()     // Year, month, day
    .domain([new Date(2010, 8, 12), new Date(2011, 8, 12)])
    .range([0, 100]);

x(new Date(2010, 11, 12));  // 24.942922374429223
x(new Date(2011, 2, 2));    // 46.86073059360731
x.invert(200);       // Date 2012-09-10T22:00:00.000Z
x.invert(640);       // 2017-02-02T22:00:00.000Z
var x = d3.scaleTime()
    .domain([new Date(1900, 1, 1), new Date(2000, 1, 1)])
    .range([0, 36500]);
x.ticks(3);  /* Array [ Date 1949-12-31T23:00:00.000Z,
                        Date 1999-12-31T23:00:00.000Z ] */

Escalas secuenciales

Este tipo de escalas son similares a las escalas Escalas continuas en que mapean un dominio de entrada numérico a un rango de salida. Sin embargo, a diferencia de las continuas, el rango de salida de una escala secuencial es fijado por su interpolador y no es configurable.

var interpolator = function(t){ return t*2 };
var secuencial = d3.scaleSequential(interpolator)
      .domain([1, 100]);
console.log(secuencial(99)); // 1.97979797979798

Ejes — d3-axis

El componente de D3 axis renderiza marcas de referencia para escalas (ver Escalas — d3-scale).

Funciones

D3 provee 4 métodos, con sus nombres indicando su alineamiento, para crear un generador de eje: d3.axisTop([scale]), d3.axisRight([scale]), d3.axisBottom([scale]) and d3.axisLeft([scale]). Un eje alineado arriba (axisTop) tiene los ticks dibujados debajo del eje. Un eje alineado abajo (axisBottom) es horizontal y tiene sus ticks dibujados debajo del eje. Un eje alineado a la izquierda (axisLeft) es vertical y tiene sus ticks alineados a la izquierda del eje, y el eje alineado a la derecha (axisRight) en el lado opuesto con los ejes dibujados en el lado exterior.

Todas admiten una escala como primer parámetro, pero esta también puede ser añadida mediante la función axis.scale([scale]).

Para añadir el eje a una selección usamos la función selection.call([axis]).

Paso a paso

Input

<!-- Creamos una figura SVG con dimensiones -->
<svg width="100%" height="40">
  <g class="eje">  <!-- Dentro ubicamos un grupo -->
</svg>

<style>
  .eje {
    fill: none;
    stroke: #aaa;
  }
</style>

<script>
  // Creamos una escala
  var scale = d3.scaleLinear()
      .domain([0, 1000])
      .range([0, 600]);

  // Creamos un axis pasándole la escala
  var axis = d3.axisBottom(scale)

  // Seleccionamos el grupo dentro del svg
  d3.select('.eje')  // Lo movemos a la derecha
      .attr("transform", "translate(40, 0)")
      .call(axis);  // Llamamos al eje para insertarlo
</script>

Output

Si observamos el código HTML renderizado por D3, veremos que cada tick en el eje es un grupo en SVG con el siguiente código.

<g class="tick" opacity="1" transform="translate(240.5,0)">
  <line stroke="#000" y2="6"></line>
  <text fill="#000" y="9" dy="0.71em">400</text>
</g>

Nota

El elemento g es un contenedor usado para agrupar objetos. Las transformaciones aplicadas al elemento g son realizadas sobre todos los elementos hijos del mismo. Los atributos aplicados son heredados por los elementos hijos. Además, puede ser usado para definir objetos complejos que pueden luego ser referenciados con el elemento <use>.

Cambiar el color de un eje

Veamos un ejemplo en el que cambiamos el color de un eje, el cual nos servirá para observar más de cerca los elementos HTML renderizados como ejes por D3js.

Input

<style>
  .ejeVerde line{
    stroke: green;
  }

  .ejeVerde path{
    stroke: green;
  }

  .ejeVerde text{
    fill: green;
  }
</style>

<div id="container"></div>

<script>
  var scale = d3.scaleLinear()
      .domain([0, 1000])
      .range([0, 600]);

  var axis = d3.axisTop(scale);

  var svg = d3.select("#container")
      .append("svg")
      .attr("width", "100%")
      .attr("height", 40)
    .append("g")
      .attr("class", "ejeVerde")
      .attr("transform", "translate(40, 20)")
      .call(axis)
</script>

Output

Como puedes observar en el código anterior, debemos establecer 3 propiedades CSS;

  • La propiedad stroke del elemento path. Este elemento se encarga de dibujar la línea horiontal a lo largo de todo el eje.
  • La propiedad stroke del elemento line. Este se encarga de las líneas verticales que van desde el path hasta el número.
  • La propiedad fill del elemento text. Este se encarga de los números.

Ejemplos de escalas

Puedes ampliar la imagen y ver el código fuente que la renderiza en este enlace.

_images/d3-axis.png

Escalas de colores

En los siguientes ejemplos vamos a coger una escala que empieza y termina en los colores mostrados abajo (, ). Vamos a mapearla como una escala lineal en D3 y aplicaremos diferentes interpolaciones de color.



El código común a todas las escalas es el siguiente:

var width = 700,
    height = 175,
    lenght = 20;  // Cantidad de colores en cada barra
var unit = width/lenght;  // Ancho de cada color

/**
 * Renderiza una barra de colores en un contenedor SVG
 *   usando un interpolador de D3.
 *
 * @param {string} svgId - Identificador del elemento SVG
 *   donde será renderizada la barra de colores.
 * @param {object} interpolator - Interpolador D3 usado
 *   para construir la escala.
 **/
var renderColorsBar = function(svgId, interpolator) {
    var colorScale = d3.scaleLinear()
        .domain([1, lenght])
        .interpolate(interpolator)
        .range([d3.rgb(color1), d3.rgb(color2)]);

    for (var i = 0; i < lenght; i++) {
       d3.select(svgId)
          .attr("height", height)
          .attr("width", width)
        .append("rect")
          .attr("x",  i*unit )
          .attr("y", 0)
          .attr("width", unit)
          .attr("height", 200)
          .style("fill", colorScale(i));
    }
}

Interpoladores desde d3-interpolate

d3.interpolateRgb(a, b)

Devuelve un interpolador en el espacio de color RGB entre los colores a y b con un parámetro gamma configurable (1 si no es especificado).

Podemos cambiar el parámetro gamma de un interpolador con la función interpolator.gamma(x).

Input

<svg id="colors-interpolate-rgb-gamma"></div>

<script>
  var interpolator = d3.interpolateRgb.gamma(2);
  renderColorsBar("#colors-interpolate-rgb-gamma", interpolator);
</script>

Output

d3.interpolateHsl(a, b)

Devuelve un interpolador en el espacio de color HSL entre los colores a y b.

d3.interpolateLab(a, b)

Devuelve un interpolador en el espacio de color Lab entre los colores a y b.

d3.interpolateHcl(a, b)

Devuelve un interpolador en el espacio de color HCL entre los colores a y b.

d3.interpolateCubehelix(a, b)

Devuelve un interpolador en el espacio de color Cubehelix entre los colores a y b.

Un interpolador de color RGB básico en Python

Para entender claramente lo que hacen los interpoladores, pongamos este sencillo interpolador RGB de ejemplo con Python (el código fuente está sacado de aquí):

import string

def make_color_tuple(color):
    """Convierte algo como "#000000" en "0,0,0"
    ó "#FFFFFF" en "255,255,255".
    """
    R = color[1:3]
    G = color[3:5]
    B = color[5:7]

    R = int(R, 16)
    G = int(G, 16)
    B = int(B, 16)

    return R,G,B

def interpolate_tuple( startcolor, goalcolor, steps ):
    """Toma dos colores RGB o los mezcla en
    un número específico de pasos. Devuelve
    la lista de todos los colores generados.
    """
    R = startcolor[0]
    G = startcolor[1]
    B = startcolor[2]

    targetR = goalcolor[0]
    targetG = goalcolor[1]
    targetB = goalcolor[2]

    DiffR = targetR - R
    DiffG = targetG - G
    DiffB = targetB - B

    buffer = []

    for i in range(0, steps +1):
        iR = R + (DiffR * i / steps)
        iG = G + (DiffG * i / steps)
        iB = B + (DiffB * i / steps)

        hR = string.replace(hex(iR), "0x", "")
        hG = string.replace(hex(iG), "0x", "")
        hB = string.replace(hex(iB), "0x", "")

        if len(hR) == 1:
            hR = "0" + hR
        if len(hB) == 1:
            hB = "0" + hB

        if len(hG) == 1:
            hG = "0" + hG

        color = string.upper("#"+hR+hG+hB)
        buffer.append(color)

    return buffer

def interpolate(startcolor, goalcolor, steps):
    """Envoltura para la función ``interpolate_tuple``
    que acepta colores como "#CCCCCC".
    """
    start_tuple = make_color_tuple(startcolor)
    goal_tuple = make_color_tuple(goalcolor)

    return interpolate_tuple(start_tuple, goal_tuple, steps)


def printchart(startcolor, endcolor, steps):
    """Imprime los colores que forman la escala
    en formato exadecimal.

    :param startcolor: Color de comienzo.
    :type startcolor: str

    :param endcolor: Color final.
    :type endcolor: str

    :param steps: Número de pasos de la escala.
    :type steps: int
    """
    colors = interpolate(startcolor, endcolor, steps)

    for color in colors:
        print(color)


# Muestra 16 valores de gradiente entre esos dos colores
printchart("#999933", "#6666FF", 16)