Tutorial d3.js
Category : Noticias
D3.js es una librería de javascript que nos permite manipular documentos basándonos en sets de datos.
Esto es especialmente interesante cuando queremos hacer visualizaciones avanzadas cómo estas:
En jortilles estamos haciendo cosas muy impactantes gracias a D3.js
Con este post no pretendemos hacer un tutorial completo de d3.js sino ofrecer una introducción a los elementos más básicos que nos den una idea de las posibilidades de esta librería.
Para ello nos hemos fijado como objetivo representar relaciones entre dos grupos A y B mediante nodos y aristas, que representaran los elementos de cada grupo y las relaciones entre ellos. Al final del tutorial conseguiremos algo parecido a esto (pasa el cursor por encima de los nodos):
Para empezar a usar d3js podemos descargar la librería en nuestro equipo o podemos añadir ésto a nuestro documento html:
Nuestro set de datos
Para representar un set de datos lo primero que necesitamos es formalizar algún tipo de representación para estos datos. D3.js ofrece soporte para importar datos desde distintos formatos (CSV, JSON, etc.), para nuestro ejemplo crearemos un set de objetos con algunos atributos, formato al que es fácil llegar desde un archivo JSON o un CSV. En un caso real lo normal seria tener los datos en una base de datos y construir el archivo csv o json para representarlos, en todo caso, el resultado final seria algo parecido a lo siguiente:
var nodes = [ {name: "0", x:100, y:100, r:12, tag: "Cliente 1"}, {name: "1", x:100, y:250, r:6, tag: "Cliente 2"}, {name: "2", x:100, y:400, r:20, tag: "Cliente 3"}, {name: "3", x:100, y:550, r:25, tag: "Cliente 4"}, {name: "4", x:430, y:150, r:9, tag: "Producto 1"}, {name: "5", x:430, y:220, r:30, tag: "Producto 2"}, {name: "6", x:430, y:290, r:27, tag: "Producto 3"}, {name: "7", x:430, y:340, r:11, tag: "Producto 4"}, {name: "8", x:430, y:420, r:32, tag: "Producto 5"}, {name: "9", x:430, y:530, r:15, tag: "Producto 6"}, {name: "10", x:700, y:100, r:21, tag: "Cliente 5"}, {name: "11", x:700, y:250, r:30, tag: "Cliente 6"}, {name: "12", x:700, y:400, r:23, tag: "Cliente 7"}, {name: "13", x:700, y:550, r:19, tag: "Cliente 8"}, ];
Con ésto tenemos un set de 14 elementos con un nombre, una posición, una propiedad r, que será el radio de nuestros nodos y la etiqueta que mostraremos.
Lo siguiente que necesitamos es generar las conexiones entre los nodos. Lo haremos definiendo otro set de objetos que contendrá objetos del tipo {origen, destino} y que nos servirán para visualizar las aristas. Origen y destino serán los objetos que hemos definido en el vector nodes.
var edges = [ {source: nodes[0], target: nodes[4]}, {source: nodes[0], target: nodes[9]}, {source: nodes[0], target: nodes[6]}, {source: nodes[0], target: nodes[7]}, {source: nodes[1], target: nodes[5]}, {source: nodes[1], target: nodes[4]}, {source: nodes[1], target: nodes[6]}, {source: nodes[1], target: nodes[7]}, {source: nodes[1], target: nodes[9]}, {source: nodes[2], target: nodes[8]}, {source: nodes[2], target: nodes[9]}, {source: nodes[2], target: nodes[5]}, {source: nodes[3], target: nodes[4]}, {source: nodes[3], target: nodes[8]}, {source: nodes[3], target: nodes[6]}, {source: nodes[3], target: nodes[5]}, {source: nodes[3], target: nodes[7]}, {source: nodes[3], target: nodes[9]}, {source: nodes[10], target: nodes[8]}, {source: nodes[10], target: nodes[9]}, {source: nodes[10], target: nodes[5]}, {source: nodes[11], target: nodes[4]}, {source: nodes[11], target: nodes[8]}, {source: nodes[11], target: nodes[6]}, {source: nodes[12], target: nodes[5]}, {source: nodes[12], target: nodes[7]}, {source: nodes[13], target: nodes[9]}, ];
Añadiendo elementos mediante d3.js
Una de las opciones que tenemos para dibujar con d3 (circulos, cuadrados, etc) es mediante SVG, así que lo primero que necesitamos es crear el contenedor svg donde pintaremos nuestros datos. Primero definimos una tamaño, mediante el método select() seleccionamos el elemento html donde colocaremos el contenedor svg y mediante el método append() añadimos el contenedor.
//svg element var w = 1300; var h = 1000; var svg = d3.select("body").append("svg").attr({"width":w,"height":h});
Podemos seleccionar elementos de la misma forma en que lo hacemos con CSS. También podemos cambiar o añadir atributos y estilos, como veremos mas adelante.
Estableciendo las relaciones entre nuestros datos y su representación
Una vez tenemos el contenedor svg, el siguiente paso es establecer la relación entre datos y representación. En nuestro caso queremos pintar nodos y aristas.
Aristas
Para las aristas crearemos elementos svg de tipo line. Partimos del elememento svg que hemos definido como contenedor (mediante la variable svg que hemos definido podemos acceder y modificar el contenido del elemento html svg al que se refiere).
Aquí lo que hacemos es:
- selectAll(“line”) => Acceder al contenedor y seleccionar todos los elementos de tipo line (en este momento ninguno, puesto que todavía no se han creado)
- .data(edges).enter() = > asociamos cada objeto contenido en la variable edges a la selección, como la selección está vacía todos los objetos de la variable edges estan “libres”.
- .append(“line”) => Para cada objeto “libre” creamos una nueva línea, que definimos partiendo de las propiedades del objeto correspondiente (radio y posición). Además le añadimos un color, opacidad y desactivamos la propiedad “pointer-events”.
- Para definir la id de cada linea usamos una función que usa como parámetros de entrada d, que es el objeto en si e i, que es el índice del objeto. Más adelante veremos la utilidad de añadir una id a cada elemento que generamos.
//linking data to circles and lines (nodes and edges) var edge = svg.selectAll("line") .data(edges) .enter() .append("line") .attr("id",function(d,i) {return 'edge'+i}) .attr('marker-end','url(#circle)') .attr() .attr("x1", function(d) { return d.source.x }) .attr("y1", function(d) { return d.source.y }) .attr("x2", function(d) { return d.target.x }) .attr("y2", function(d) { return d.target.y }) .style("stroke", "rgb(135,206,250)") .style("stroke-opacity", "0.4") .style("pointer-events", "none");
El proceso de asociación entre datos y elementos es un proceso algo mas complejo y está explicado en detalle aquí. Pero fijémonos en que d3.js nos permite seleccionar elementos mediante select() y acceder y modificar sus atributos y propiedades mediante attr() y style(), entre otras funciones. Además, como habréis visto, nos permite encadenar funciones tanto horizontal como verticalmente. Es decir, todo lo escrito en el código de arriba se puede leer como una sola línia: svg.selecAll().dada().append().attr().attr() […] etc.
Nodos
Para los nodos seguimos el mismo esquema:
var node = svg.selectAll("circle") .data(nodes) .enter() .append("circle") .attr("id",function(d,i) {return 'circle'+i}) .attr("r", function(d) { return d.r }) .attr("cx", function(d) { return d.x; }) .attr("cy", function(d) { return d.y; }) .attr("fill", "rgb(135,206,250)") .attr("fill-opacity", "1")
Sólo con esto y un documento html básico como éste:
Obtenemos este resultado:
Hasta este punto lo único que hemos hecho es asociar unos datos representados como objetos a unos elementos gráficos contenidos en un elemento svg.
Llegados a este punto nos podemos plantear dar a nuestra gráfica un aspecto dinámico que aporte algo más a la representación. Por ahora sólo tenemos 14 nodos, pero imaginemos un contexto en el que tengamos 50, o 100. ¿No seria genial que al pasar el cursor por encima de un nodo se resaltasen los nodos con los que se relaciona y las aristas que los conectan? Seria genial y ayudaría a visualizar de manera rápida y esquemática las relaciones entre nuestros elementos, así que vamos a ello.
Lo primero que necesitamos es añadir los atributos mouseover y mouseout a los nodos a continuación de las propiedades que ya tenia:
var node = svg.selectAll("circle") .data(nodes) .enter() .append("circle") .attr("id",function(d,i) {return 'circle'+i}) .attr("r", function(d) { return d.r }) .attr("cx", function(d) { return d.x; }) .attr("cy", function(d) { return d.y; }) .attr("fill", "rgb(135,206,250)") .attr("fill-opacity", "1") .on("mouseover", handleMouseOver) .on("mouseout", handleMouseOut)
Y el siguiente paso es definir el comportamiento que disparamos con las acciones. Para ello primero creamos una función que recibirá como parámetros el índice respecto el vector de nodos del nodo sobre el que posicionamos el cursor y nos devolverá un vector de vectores con las aristas que salen de ese nodo y los nodos origen y destino de cada arista:
En caso de seleccionar éste nodo origen, nuestra función nos devolvería algo así, suponiendo que los nodos y aristas tienen los nombres que mostramos:
[[#node3], [#node4, #node5, #node6,], [#edge1, #edge3, #edge4]]
Con ésto tendremos toda la información necesaria para seleccionar los elementos y transformarlos como deseemos.
Lo que hacemos es:
- Recorremos el vector de aristas (edges) que hemos definido al principio
- Si el nombre del nodo origen o del nodo destino de la arista z es igual al índice que pasamos a la función (recordemos que el nombre de cada nodo es su índice respecto el vector nodes), entonces añadimos el nombre de esa arista al vector de aristas que se deben iluminar.
- Además, si se cumple la condición, seleccionamos los nodos origen y destino de esa arista y los añadimos a los correspondientes vectores origen y destino de nodos que queremos iluminar.
Éste código funciona por cómo hemos construido los nombres de nodos y aristas, según del tipo de datos que tengamos y de cómo los organicemos deberemos modificar un poco el código para que se adapte a nuestros propósitos.
function linked (index){ b = [[],[],[]] for (var z = 0; z < edges.length; z++){ if(edges[z].source.name == index || edges[z].target.name == index){ b[2].push('#edge' + z); var origin = "#circle" + edges[z].source.name; var target = "#circle" + edges[z].target.name; if (!b[0].includes(origin)){ b[0].push(origin); } if (!b[1].includes(target)){ b[1].push(target); } } } return b; }
Ahora definimos el comportamiento de los elementos cuando pasamos el cursor por encima de un nodo d con indice i:
function handleMouseOver(d, i) { links = linked(i); console.log("edges: "); for(var z = 0; z < links[2].length; z++){ //recorremos las aristas que pintaremos console.log(links[2][z]); d3.select(links[2][z]).transition().duration(600).style("stroke", "rgb(30,144,255)").style("stroke-opacity", "0.9"); } console.log("origin: "); for(var z = 0; z < links[0].length; z++){ //recorremos los nodos origen console.log(links[0][z] + " "); d3.select(links[0][z]).transition().duration(600) .attr("r", function(d) { return d.r + 3 }).attr("fill","rgb(70,130,180)").attr("fill-opacity", "1"); } console.log("target: "); for(var z = 0; z < links[1].length; z++){ //recorremos los nodos destino console.log(links[1][z] + " "); d3.select(links[1][z]).transition().duration(600) .attr("r", function(d) { return d.r + 3 }).attr("fill","rgb(65,10,225)").attr("fill-opacity", "1"); } }
Nuestra función recoge el elemento sobre el que se posiciona el cursor (d) y su índice respecto de la selección de la que forma parte (i).
Primero llamamos a la función linked() que hemos definido y que nos da la lista de nodos y aristas a iluminar dado el índice (i) del nodo sobre el que nos posicionamos.
Luego recorremos el vector de aristas, de nodos origen y destino y establecemos un tiempo de transición entre el estado actual y un estado futuro mediante la función transition() y a continuación definimos el estado futuro. En éste caso cambiamos la opacidad, aumentamos un poco el radio de los nodos y cambiamos su color en función de si son origen o destino.
Para la función mouseover seguimos el proceso inverso, restableciendo el estado de nodos y aristas:
function handleMouseOut(d, i) { links = linked(i); for(var z = 0; z < links[2].length; z++){ d3.select(links[2][z]).transition().duration(600).style("stroke", "rgb(135,206,250)").style("stroke-opacity", "0.4"); } for(var z = 0; z < links[0].length; z++){ d3.select(links[0][z]).transition().duration(900) .attr("r", function(d) { return d.r }).attr("fill","rgb(135,206,250)").attr("fill-opacity", "1"); } for(var z = 0; z < links[1].length; z++){ d3.select(links[1][z]).transition().duration(900) .attr("r", function(d) { return d.r }).attr("fill","rgb(135,206,250)").attr("fill-opacity", "1"); } }
Hay que destacar d3.js gestiona el proceso de transición por nosotros, el cambio de radio y de color no son bruscos, sino que se realizan progresivamente ofreciendo una transición suave que durará tanto como hayamos definido en transition(). duration(). Recomendamos seguir el link e investigar un poco más acerca de este proceso, aquí mostramos un ejemplo simple, pero el proceso se puede desarrollar para ir bastante mas lejos.
Para terminar, lo último que haremos será añadir a los nodos una etiqueta con su nombre.
Para esto nos bastará con crear un elemento “div” para cada nodo y posicionarlo donde queramos:
for (var i = 0; i < nodes.length; i++){ d3.select("body").append("div").attr("class", "node_names").style("left", (nodes[i].x + 30) + "px").style("top", (nodes[i].y + 20) + "px").html(nodes[i].tag); }
En nuestro documento css hemos definido el elemento node_names para darle las propiedades que nos interesan; la posición de cada etiqueta la hacemos relativa a la posición del nodo correspondiente y le añadimos el texto con su nombre mediante la función .html().
Con esto ya lo tenemos todo, esperemos que esta introducción a d3.js os anime a estudiar esta librería y sacarle todo el provecho que merece.