Ultimamente, he estado estudiando las librerías javascript D3.js. La verdad, es que me ha gustado bastante su planteamiento. Porque, en lugar de ser una librería orientada a facilitar la publicación de gráficas clásicas con más o menos opciones de configuración (que es lo que yo presuponía), su diseñador ha decidido crear una serie de recursos muy básicos (pero al mismo tiempo, muy potentes) de propósito general que facilitan la tarea de crear visualizaciones de cualquier tipo asociadas a series de datos de diversa naturaleza.
Leamos la descripción que figura en su propia página web:
«D3 allows you to bind arbitrary data to a Document Object Model (DOM), and then apply data-driven transformations to the document. For example, you can use D3 to generate an HTML table from an array of numbers. Or, use the same data to create an interactive SVG bar chart with smooth transitions and interaction.»
«D3 te permite asociar un conjunto arbitrario de datos a un DOM, y aplicar entonces al documento transformaciones orientadas a los datos. Por ejemplo, puedes usar D3 para generar una tabla HTML a partir de un array de números. O puedes usar esos mismos datos para crear una gráfica SVG interactiva con transiciones y que responda a interacciones.»
Nos lo dice claramente: podemos usar D3 tanto para generar tablas HTML o para generar gráficas SVG. Eso sí, donde realmente se aprecian las posibilidades de D3 será cuando generemos gráficas SVG. Porque, aparte de las funciones primitivas de propósito general, tiene funciones específicamente orientadas a generar visualizaciones SVG.
Las posibilidades que ofrece son, por lo tanto, inmensas. Porque al no limitarse a crear las gráficas tradicionales (barras verticales, barras horizontales, líneas, tartas, etc) el límite lo pones tú con tu imaginación y tu capacidad para dominar estas primitivas (aunque también te hará falta dominar los elementos SVG).
Los datos del IBEX35
Para comenzar a practicar con esta librería y sus posibilidades, he decidido jugar con unas fuentes de datos que siempre me han llamado la atención: los índices bursátiles. Sin llegar en absoluto a dedicarme al análisis bursátil de tipo técnico, siempre me ha fascinado ver cómo las gráficas bursátiles evolucionan con el tiempo. Así que me ha parecido que ésta es una buena oportunidad para jugar, como digo, con esta fuente de datos.
Entre todas las posibles fuentes de datos bursátiles, he decidido centrarme en el IBEX35. El motivo es que es un índice compuesto y me resultaba mucho más adecuado para los estudios de evolución que me gustaría realizar que si hubiera escogido un índice de una empresa individual. Para acceder a sus datos, he usado los datos que he recogido de Quandl. Me hubiera gustado encontrar alguna fuente de datos online (con una API a la que pedir estos datos) pero como mi prioridad era practicar con D3, he preferido descargar los datos del IBEX35 desde Quandl y recopilarlos en un fichero CSV que usaré en mis prácticas.
Práctica 1: Volcar todo el histórico de datos en una gráfica lineal
Vamos a ver primero el resultado final:
Podéis abrir este mismo resultado en una ventana independiente aquí. Si lo investigáis con cualquier herramienta de inspección del DOM como Firebug (o cualquiera de las herramientas que ya vienen integradas con la nueva generación de navegadores) veréis que lo que he generado usando D3 es un objeto SVG y que podéis ver su estructura y los distintos elementos SVG que lo componen.
El código HTML
El código HTML asociado no lo he complicado mucho: lo he generado usando una plantilla de HTML5 y Twitter Bootstrap de yeoman. Aparte, tiene asociadas las librerías javascript jQuery y D3.
El único elemento importante del documento es éste:
1 |
<div id="ibex35"></div> |
O sea, defino un div
con id ibex35
que será donde mi código javascript insertará mi gráfica SVG.
La fuente de datos
Como he adelantado un poco antes, he preparado un fichero en formato CSV donde he recopilado el histórico de datos. Este fichero, tiene la siguiente estructura:
1 2 3 4 5 6 7 8 9 |
Date,High 1994-01-03,3654.70 1994-01-04,3675.50 1994-01-05,3625.20 1994-01-07,3644.40 1994-01-10,3678.20 1994-01-11,3712.50 1994-01-12,3712.30 1994-01-13,3698.20 |
Podéis comprobar que su estructura es muy simple:
- La primera línea, indica los nombres de los dos datos: «Date» y «High».
- Una línea por movimiento.
- Cada línea tiene dos datos, separados por comas: la fecha y el valor.
(El valor tiene como nombre «High» porque de todos los posibles valores asociados al día (valor de apertura, valor de cierre, mínimo del día, máximo del día), me he concentrado en el valor máximo que alcanzó en el día).
El código javascript
El código javascript asociado es el siguiente:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
'use strict'; (function(d3) { jQuery(function() { var data = []; var width = parseInt(jQuery('#ibex35').css('width')); var height = 500; var margins = { top: 20, right: 50, bottom: 20, left: 50 }; var redrawData = function redrawData() { var svg = d3.selectAll('div#ibex35') .append('svg') .attr('width', width) .attr('height', height); var xRange = d3.time.scale() .range([margins.left, width - margins.right]) .domain([d3.min(data, function(d) { return d.date; }), d3.max(data, function(d) { return d.date; })]); var yRange = d3.scale.linear() .range([height - margins.bottom, margins.top]) .domain([d3.min(data, function(d) { return d.high; }), d3.max(data, function(d) { return d.high; })]); var xAxis = d3.svg.axis() .scale(xRange) .tickSize(5) .tickSubdivide(true); var yAxis = d3.svg.axis() .scale(yRange) .tickSize(5) .orient('left') .tickSubdivide(true); var valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRange(d.high); }) .interpolate('linear'); svg.append('svg:g') .attr('class', 'x axis') .attr('transform', 'translate(0,' + (height - margins.bottom) + ')') .call(xAxis); svg.append('svg:g') .attr('class', 'y axis left') .attr('transform', 'translate(' + (margins.left) + ',0)') .call(yAxis); svg.append('svg:path') .attr('d', valueFunc(data)) .attr('stroke', 'blue') .attr('stroke-width', 2) .attr('fill', 'none'); }; d3.csv('./data/YAHOO-IBEX.csv') .row(function(d) { return { date: new Date(d.Date), high: +d.High }; }) .get(function(error, rows) { data = rows; redrawData(); }); }); })(window.d3); |
Vamos a analizar este código, poco a poco:
Carga del código javascript
1 2 3 4 5 6 7 |
'use strict'; (function(d3) { jQuery(function() { // el resto del código viene aquí }); })(window.d3); |
- Intentemos mantener las buenas costumbres: activo el modo
strict
de javascript y encapsulo todo mi código dentro de una IIFE (Immediately-Invoked Function Expression, prefiero no traducirlo). A esta IIFE, le paso como parámetro una referencia al objectowindow.d3
. - Aprovecho el evento
ready
de jQuery para cargar, por fin, todo mi código.
Definición de variables globales
1 2 3 4 5 6 7 8 9 |
var data = []; var width = parseInt(jQuery('#ibex35').css('width')); var height = 500; var margins = { top: 20, right: 50, bottom: 20, left: 50 }; |
Declaro una serie de variables que usaré en el resto del código:
data
, para guardar los datos a mostrar en la gráfica, una vez que los lea de su fuente.width
, para la anchura de mi gráfica. La calculo a partir de la anchura deldiv
contenedor (que a su vez, está configurado por css para tener una anchura del 95% del ancho posible).height
, para la altura de mi gráfica.margins
es un objeto donde guardo los márgenes internos dentro de la gráfica. Estos márgenes internos, están reservados para mostrar información complementaria (ejes, títulos, leyendas, etc).
Lectura de la fuente de datos
Al final del todo el código, leo la fuente de datos:
1 2 3 |
d3.csv('./data/YAHOO-IBEX.csv') .row(function(d) { return { date: new Date(d.Date), high: +d.High }; }) .get(function(error, rows) { data = rows; redrawData(); }); |
- Uso la función
d3.csv
para leer los datos. Esta función, está ya preparada para parsear correctamente un fichero formateado como CSV. Devuelve unArray
donde cada elemento es un objeto que tiene una propiedad por cada columna definida dentro del fichero. Como el fichero tiene configurados que las dos columnas se llaman «Date» y «High», las dos propiedades tienen los mismos nombres. - Cuando termine la lectura de los datos, se invocará al método
.get()
. Dentro de este método, asigno los datos leídos (rows
) a la variabledata
y llamo a mi funciónredrawData()
. - El punto más sutil es el método
.row()
. Este método será invocado para cada fila de datos leída y permite hacer un procesamiento de cada línea. Los datos son importados siempre como tipoString
. En este caso, como sé que la propiedadDate
tiene una fecha, lo convierto en un objetoDate
. Además, como sé que la propiedadHigh
tiene un número, lo convierto a número. De este modo, mi funciónredrawData()
encontrará un array con datos perfectamente procesados.
Dibujo la gráfica
Por último, la función redrawData()
que será la encargada de dibujar la gráfica:
1 2 3 4 5 |
var redrawData = function redrawData() { var svg = d3.selectAll('div#ibex35') .append('svg') .attr('width', width) .attr('height', height); |
Para aquellos que estamos acostumbrados a jQuery, la lectura de esta primera parte es casi inmediata:
- Selecciona el
div
con idibex35
(el que definimos en nuestro documento html para contener la gráfica). - Le añade a este
div
un elementosvg
. - A este elemento
svg
, le configura sus atributoswidth
yheight
con los valores de las variables definidas previamente. - Por último, guardamos una referencia a este elemento
svg
recién añadido dentro de la variablesvg
para poder manipularlo un poco más adelante.
Efectivamente, D3 nos permite seleccionar elementos del DOM usando el método selectAll
(tiene otro más, select
que es ligeramente distinto). selectAll
espera como argumento cualquier selector CSS válido.
También podemos añadir nuevos elementos usando el método append
. En este caso, hemos añadido un elemento svg
pero podíamos haber añadido cualquier elemento html válido (un div
, un table
, etc).
Además, lo mismo que ocurre con jQuery, los métodos D3 son encadenables.
Defino las escalas horizontal y vertical
1 2 3 4 5 6 7 |
var xRange = d3.time.scale() .range([margins.left, width - margins.right]) .domain([d3.min(data, function(d) { return d.date; }), d3.max(data, function(d) { return d.date; })]); var yRange = d3.scale.linear() .range([height - margins.bottom, margins.top]) .domain([d3.min(data, function(d) { return d.high; }), d3.max(data, function(d) { return d.high; })]); |
¿Para qué sirven estas dos escalas xRange
y yRange
? Veamos:
- Tenemos un objeto
svg
al que tendremos que añadir todos los elementos SVG que componen nuestra gráfica. Esto, supone posicionar estos elementos dentro delsvg
para que su posición represente visualmente la información que necesitamos mostrar. - Así, si tenemos un eje horizontal que representa la evolución temporal y tenemos que pintar un circulo para representar una fecha determinada dentro de este eje horizontal, tendremos que calcular la posición horizontal donde tenemos que dibujarlo para que refleje fielmente la posición temporal que representa. Cada fecha, tendrá asignada una posición más o menos hacia la derecha en función de que la fecha sea más o menos moderna.
- No es demasiado problemático escribir una función para efectuar este mapeo / escalado entre el valor real y su posición dentro del objeto
svg
. Pero no es necesario programar esta función, porque D3 ya nos proporciona una serie de funciones para realizarlo. Digo que son varias funciones, porque nos podemos encontrar varios tipos de ejes y por lo tanto varios tipos de mapeos.
Como he explicado, nuestro eje horizontal representa una escala temporal. Para representar escalas temporales, usaremos d3.time.scale
:
1 2 3 |
var xRange = d3.time.scale() .range([margins.left, width - margins.right]) .domain([d3.min(data, function(d) { return d.date; }), d3.max(data, function(d) { return d.date; })]); |
- Creo una escala temporal usando
d3.time.scale
. - Uso el método
.range
para asignar los valores que abarcarán las posiciones extremas dentro del gráfico. En nuestro caso, son las coordenadas izquierda y derecha. Además, fijaros que dichas posiciones no abarcan el 100% de la anchura del objetosvg
(desde0
hastawidth
) sino que abarca un poco menos, desdemargins.left
hastawidth - margins.right
para dejar libres los márgenes izquierdo y derecho. - Por último, el método
.domain
configura qué valores se mapean con estas posiciones. En nuestro caso, serán las fechas mínima y máxima que encontremos dentro del fichero csv que leamos. - Para calcular las fechas mínima y máxima, usamos las funciones
d3.min
yd3.max
que nos permiten recorrer nuestro array de datosdata
e indicamos que queremos buscar los mínimos y máximos de la propiedaddate
.
El eje vertical, lo usaré para representar los valores. Es código es muy parecido … pero con una complejidad adicional:
1 2 3 |
var yRange = d3.scale.linear() .range([height - margins.bottom, margins.top]) .domain([d3.min(data, function(d) { return d.high; }), d3.max(data, function(d) { return d.high; })]); |
- Como son valores numéricos contínuos, usaré
d3.scale.linear
- Para configurar las posiciones verticales mínima y máxima, usaré de nuevo el método
.range
.
Pero para definir estos valores mínimo y máximo, tenemos un problema que no tuvimos al definir el eje horizontal: para posicionar elementos dentro de nuestro objeto svg
, su origen de coordenadas está en el extremo superior izquierda … mientras que el origen de coordenadas para nuestra gráfica queremos que esté en el extremo inferior izquierda ¿Qué consecuencias tiene esta diferencia?:
- En el eje horizontal el valor mínimo (la fecha más antigua) corresponde con la posición más pequeña (
margins.left
) y el valor máximo corresponde con la posición más grande (width - margins.right
). - En el eje vertical, por el contrario, el valor mínimo querremos posicionarlo abajo del todo (que corresponde con la posición más alta) mientras que el valor máximo lo querremos posicionar en la parte superior (o sea, la posición más baja).
- Por eso, al definir el
.range
de nuestro eje vertical, indicamos que el extremo inferior es el mayor valor (height - margins.bottom
, en lugar deheight
para preservar un margen inferior) mientras que el extremo superior es el valor más pequeño (margins.top
en lugar de0
para preservar un margen superior).
Defino los ejes vertical y horizontal
1 2 3 4 5 6 7 8 9 10 |
var xAxis = d3.svg.axis() .scale(xRange) .tickSize(5) .tickSubdivide(true); var yAxis = d3.svg.axis() .scale(yRange) .tickSize(5) .orient('left') .tickSubdivide(true); |
La función d3.svg.axis
sirve para definir un eje. Creo que es de compresión directa:
- Usa el método
.scale
para configurar qué valores abarca. A este método, le pasa las variablesxRange
eyRange
que hemos definido previamente. - El método
.tickSize
sirve para indicar cuántas marcas queremos poner en el eje. Ojo: el eje es inteligente. ¿Qué quiere decir esto? Que usará el valor que le pasemos a.tickSize
como referencia pero a la hora de dibujarlo podrá añadir / quitar marcas en función de los valores a representar. - El método
.orient
sirve para indicar en qué lado (arriba, abajo, izquierda, derecha) vamos a dibujar el eje. Así, las marcas las dibujará en la parte exterior.
IMPORTANTE: d3.svg.axis
define el eje pero no lo dibuja (lo dibujaremos un poco más adelante, usando las referencias xAxis
e yAxis
).
Defino la gráfica
1 2 3 4 |
var valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRange(d.high); }) .interpolate('linear'); |
Tal como explicaba antes, D3 proporciona una serie de funciones primitivas. Podía haber usado estas funciones primitivas para dibujar mi gráfica, línea a línea. Pero también explicaba que D3 proporciona funciones específicas para manejar elementos SVG. Como esta función d3.svg.line
que permite definir una gráfica linea de una sola tacada:
- La gráfica, estará definida por una serie de puntos con coordenadas (
x,y
) enlazados por líneas. - Las posiciones
x
la definimos mediante una función anónima. ¿Qué recibe esta función? un datod
. ¿Qué devuelve esta función? la posición horizontal asociada a este datod
. En nuestro caso, usamos la variablexRange
que tiene configurado el eje horizontal y le paso como parámetro la propiedaddate
del datod
. - Para las posiciones
y
le paso otra función anónima similar a la anterior. Excepto que la función que calcular el mapeo es la variableyRange
a la que le paso el valord.high
a mapear en la gráfica. - Por último, uso
.interpolate
para definir que quiero conectar los puntos mediante líneas.
De nuevo, lo mismo que el paso anterior no he dibujado nada. Sólamente he definido cómo quiero que sea mi gráfica. Si os fijáis, he definido los mapeos y todo está preparado para cuando le pase a esta funcion valueFunc
los datos a mapear (un poco más adelante veremos cómo).
Dibujo los ejes horizontal y vertical
1 2 3 4 5 6 7 8 9 |
svg.append('svg:g') .attr('class', 'x axis') .attr('transform', 'translate(0,' + (height - margins.bottom) + ')') .call(xAxis); svg.append('svg:g') .attr('class', 'y axis left') .attr('transform', 'translate(' + (margins.left) + ',0)') .call(yAxis); |
Por fin, dibujo los ejes horizontal y vertical que he definido previamente en las variables xAxis
e yAxis
:
- Añado un elemento
g
al que le asigno las clasesx axis
- A este elemento
g
le someto un cambio de coordenadas a la posición(0, height - margins.bottom)
(o sea, la esquina inferior izquierda) - Con este elemento
g
correctamente, posicionado, dibujo dentro de él mi eje horizontal llamando.call(xAxis)
. - Para el eje vertical, el procedimiento es similar pero lo posiciono en
(margins.left, 0)
(o sea, la esquina superior izquierda)
Dibujo la gráfica
1 2 3 4 5 |
svg.append('svg:path') .attr('d', valueFunc(data)) .attr('stroke', 'blue') .attr('stroke-width', 2) .attr('fill', 'none'); |
Por último, dibujo la gráfica:
- Añado un objeto
path
con una serie de atributosstroke
,stroke-width
yfill
. - Uso
.attr('d', valueFunc(data))
para pasarle a este objetopath
la funciónValueFunc
que definí previamente. A esta función, le paso como parámeto la variabledata
que tiene los datos a dibujar … y D3 se encarga de todo lo demás :).
Conclusiones
Tal como decía al principio, me ha gustado mucho el diseño de D3. Su organización en funciones básicas orientadas a facilitar el mapeo entre datos y objetos DOM es impresionante. Para este ejemplo concreto, hemos usado la función d3.svg.line
que nos ha facilitado la tarea de dibujar la gráfica. Pero también podríamos haber dibujado nuestra gráfica línea a linea si lo hubiéramos necesitado. Los tipos de gráficas que podemos obtener no tienen límites más que nuestra imaginación y el dominio de las primitivas. En futuras entradas, intentaré ir añadiendo más funciones primitivas de D3 conforme vaya retocando esta primera gráfica añadiéndole más componentes.
En cuanto a la curva de aprendizaje de D3, personalmente me he sentido muy cómodo. Pero estamos hablando de que tengo bastante rodaje tanto con javascript y jQuery, lo que facilita mucho la vida a la hora de manejar D3. Si no hubiera tenido esta experiencia, se hubiera hecho mucho más cuesta arriba.
No sé si este artículo servirá a mucha gente, porque no me he dedicado a explicar detalladamente las funciones que he usado. Me he limitado a usarlas lo más claramente posible y a explicarlas por encima. Si alguien tiene dudas o comentarios, son bienvenidos. Y si a alguien le ha resultado interesante, me alegro.
En próximas entradas, como digo, iré retocando este ejemplo básico. De este modo espero que el lector pueda estudiar cómo el código evoluciona y cómo uso los distintos elementos de D3 que he ido incorporando a mi arsenal (¡realmente, no he hecho más que empezar!)
como pudiera poner el eje de la x arriba???
He creado una pequeña entrada Gestión de los ejes de coordenadas