on
[PT-BR] Construindo um bubble chart reutilizável: uma introdução gentil a d3
Quando eu comecei a aprender d3 a sensação era de que nada fazia sentido. Depois desse fase inicial as coisas ainda continuavam sem fazer muito sentido, até que eu descobri os Reusable Charts. Nesse post vou mostrar como criar um bubble chart com esse padrão, e ao mesmo tempo proporcionar uma introdução mais gentil ao d3. Como conjunto de dados vamos usar os posts do freeCodeCamp publicados em janeiros de 2017.
[caption id="attachment_1586" align="aligncenter" width="495"] O gráfico que vamos fazer[/caption]
Sobre d3
d3 é uma biblioteca para visualização de dados, escrita em javascript; d3 associa dados ao Document Object Model (DOM) de uma página, possibilitando assim a publicação de dados na web.
Muitas vezes precisamos reutilizar um gráfico em outro projeto, ou até mesmo compartilhar o gráfico com outras pessoas. Para isso Mike Bostock (o criador do d3) propôs um modelo chamado Reusable Charts. Aqui nesse post vamos ver uma abordagem com algumas modificações, apresentada por Pablo Navarro Castillo no livro Mastering D3.js.
Reusable Charts
Os gráficos que seguem o modelo do reusable chart precisar ser configuráveis ― queremos modificar a aparência e o comportamento do gráfico sem precisar modificar o código em si e independentes ― queremos que cada elemento do gráfico seja associado a uma instância dos dados, de forma independente. Essa característica tem a ver com a forma que D3 associa as instancias dos dados aos elementos do DOM. Quando estivermos criando o gráfico isso vai ficar mais claro.
Nosso bubble chart
Vamos primeiro definir que elementos do gráfico podem ser customizados:
- O tamanho do gráfico
- O dataset de entrada dos dados
Definindo o tamanho do gráfico
Vamos começar criando uma função para encapsular todas as variáveis do grafo e definir os valores default. Essa estrutura é denominada closure.
// bubble_graph.js var bubbleChart = function () { var width = 600, height = 400; function chart(selection){ // vamos chegar lá } return chart; }
Queremos poder criar gráficos de tamanhos diferentes sem precisar mexer no código. Para isso vamos criar bubble charts da seguinte forma:
// bubble_graph.html var chart = bubbleChart().width(300).height(200)
Vamos agora adicionar os métodos para acessar as variáveis width e height e assim poder criar os gráficos com tamanhos diferentes:
// bubble_graph.js var bubbleChart = function () { var width = 600 height = 400; function chart(selection){ // vamos chegar lá } chart.width() = function(value) { if (!arguments.length) { return width; } width = value; return chart; } chart.height() = function(value) { if (!arguments.length) { return height; } height = value; return chart; } return chart; }
Caso só chamemos a função bubbleChart() [ou seja, sem os atributos de width e height] o gráfico é criado com os valores default que definimos dentro do closure. Se chamado sem argumentos, o método retorna o valor da variável:
// bubble_graph.html bubbleChart().width() // retorna 600
Você pode estar se perguntando por que todos os métodos de acesso retornam chart. Isso é um padrão em Javascript para poder simplificar o código, chamado method chaining. Com ele podemos fazer chamadas do tipo
// bubble_graph.html var chart = bubbleChart().width(600).height(400);
em vez de
var chart = bubbleChart();
chart.setWidth(600);
chart.setHeight(400);
Passando os dados para o gráfico
Agora vamos entender como os dados são passados para o gráfico. A div com o gráfico tem um elemento svg, e cada instância dos dados corresponde a um círculo no gráfico
// bubble_graph.html <svg width="600" height="400"> <circle></circle> //um artigo presente nos dados <circle></circle> //outro artigo presente nos dados ... </svg>
Para passar os dados para o DOM vamos utilizar a função .data() do D3. Primeiro precisamos carregar os dados a partir do arquivo csv para o nosso código. Vamos usar a função d3.csv():
// bubble_graph.html d3.csv('file.csv', function(error, our_data) { var data = our_data; //here we can do what we want with our data }
// medium_january.csv
| title | category | views |
|--------------------------------------|--------------|-------|
| Nobody wants to use software | Development | 2700 |
| Lossless Web Navigation with Trails | Design | 688 |
| The Rise of the Data Engineer | Data Science | 862 |
Precisamos associar os dados a elementos do DOM. Para isso precisamos de selections do D3.
d3.select()
Uma selection permite que a gente faça alterações no DOM (inserir, remover elementos, etc.), entre outras coisas.
Para passar os dados pegamos a seleção através do D3, e passamos os dados através da função data:
d3.csv('medium_january.csv', function(error, our_data) { if (error) { console.error('Error getting or parsing the data.'); throw error; } var chart = bubbleChart().width(600).height(400); d3.select('#chart').data(our_data).call(chart); });
d3.selectAll()
<body>
</body>
d3.select("body").selectAll("div") seleciona todas as divs pra nós.
d3.enter()
Agora vamos aprender sobre uma função muito importante em D3: enter(). Digamos que temos a tag body e um array com dados var our_data = [1, 2, 3]. Queremos percorrer cada elemento do array de dados e criar uma nova div para cada um desses elementos. Podemos fazer isso com o seguinte código:
<!-- antes --> <body> //vazio aqui </body> ---- var our_data = [1, 2, 3] var div = d3.select("body") .selectAll("div") .data(our_data) .enter().append("div"); --- <!-- depois --> <body>
</body>
Por que a gente precisa fazer um selectAll('div') se esses elementos nem existem ainda?
Porque em vez de dizer como fazer algo, em d3 nós dizemos o que nós queremos (mais informações aqui).
No caso, queremos associar cada div com um elemento do array de dados. É isso que estamos dizendo com o selectAll("div").
var div = d3.select("body") .selectAll("div") //aqui estamos dizendo "ei d3, os dados vão ser uma div" .data(our_data) .enter().append("div");
O enter() retorna justamente a seleção, já associada a cada elemento do array de dados. Por fim nós finalmente pegamos essa seleção e adicionamos a div ao DOM com o .append('div').
Agora que já entendemos como o enter() funciona, vamos voltar a falar sobre como passar nosso conjunto de dados para o gráfico.
Como já vimos, vamos utilizar o método data(). Por padrão d3.selection.data() [notem que o data() está sem argumentos] apenas retorna o primeiro argumento da seleção, mas precisamos do array inteiro porque vamos usar a função d3.forceSimulation (mais sobre ela mais a frente).
Para pegar esse array inteiro, vamos fazer um pequeno 'hack' dentro da função chart do nosso gráfico. Se fizermos selection.enter().data() nós conseguimos ter acesso a todos os dados do array. Não entendeu nada? Calma. Não precisa se preocupar muito com essa parte agora. Vamos utilizar esse acesso aos dados da forma correta (sem ser um hack), então se essa parte não ficou muito clara agora pode ficar tranquilo.
d3.forceSimulation
Como nosso gráfico é um bubbleChart, precisamos de algo para simular a física dos círculos. Para isso vamos usar a função d3.forceSimulation([nodes]). Precisamos dizer que tipo de força vai modificar a posição ou a velocidade dos nós. No nosso caso vamos utilizar d3.forceManyBody().
var simulation = d3.forceSimulation(data) .force("charge", d3.forceManyBody().strength([-50])) .force("x", d3.forceX()) .force("y", d3.forceY()) .on("tick", ticked);
Queremos afastar um pouco os nós dos outros, por isso usamos o .strength([-50]) (um valor positivo faz os nós se atraírem, um valor negativo faz eles se repelirem).
Para que os nós não se espalhem pelo espaço do svg, vamos puxá-los para a posição 0 com d3.forceX()) e d3.forceY()). Faça um teste e remova essas duas linhas do código para ver o que acontece.
Quando você atualiza a página, pode ver que os círculos vão se ajustando até finalmente se estabilizarem. Essa atualização é feita pela função ticked(). O d3.forceManyBody()
vai alterando as posições x e y de cada nó, e a função ticked() atualiza esse valor no DOM (os atributos cx e cy).
function ticked(e) { node.attr("cx", function(d) { return d.x; }) .attr("cy", function(d) { return d.y; }); }
d3.scales
Agora vamos para a parte divertida: adicionar os círculos. Lembra do enter()? Vamos precisar dele agora. Queremos que o raio seja proporcional a quantidade de views de cada artigo, para isso vamos usar uma escala linear: d3.scaleLinear().
Precisamos dizer duas coisas:
- domain - o valor mínimo e máximo da entrada de dados (no nosso caso, o número mínimo e máximo de views)
- range - o valor mínimo e máximo de saída da escala (no nosso caso, queremos que o tamanho do raio menor seja 5 e o do raio maior seja 18).
Para pegar o valor mínimo e máximo de views vamos usar d3.min() e d3.max(). Nossa escala vai ser definida dessa forma:
var scaleRadius = d3.scaleLinear(). domain([d3.min(data, function(d) { return +d.views; }), d3.max(data, function(d) { return +d.views; })]). range([5,18])
Agora quando fomos adicionar os círculos, passamos a quantidade de views para a escala e ela retorna o valor do raio.
var node = svg.selectAll("circle") .data(data) .enter() .append("circle") .attr('r', function(d) { return scaleRadius(d.views)}) });
Para colorir os círculos vamos usar uma escala categórica: d3.scaleOrdinal(). Nesse caso em vez de valores contínuos, a escala retorna valores discretos. Nosso conjunto de dados tem 3 categorias: Design, Development e Data Science. Vamos mapear cada uma dessas categorias com uma cor. d3.schemeCategory10 nos dá uma lista com 10 cores, o que é já suficiente pra a gente.
var colorCircles = d3.scaleOrdinal(d3.schemeCategory10); var node = svg.selectAll("circle") .data(data) .enter() .append("circle") .attr('r', function(d) { return scaleRadius(d.views)}) .style("fill", function(d) { return colorLegend(d.category) })
Queremos que os círculos sejam desenhados no meio do nosso svg, então transladamos cada um para o meio (metade da width e metade da height). Faça um teste e remova isso do código para ver o que acontece.
var node = svg.selectAll("circle") .data(data) .enter() .append("circle") .attr('r', function(d) { return scaleRadius(d[columnForRadius])}) .style("fill", function(d) { return colorCircles(d[columnForColors]) }) .attr('transform', 'translate(' + [width / 2, height / 2] + ')');
Agora para finalizar vamos adicionar as tooltips aos gráficos. Elas precisam aparecer sempre que colocarmos o mouse em cima dos círculos. Primeiro definimos como elas devem ser:
var tooltip = selection .append("div") .style("position", "absolute") .style("visibility", "hidden") .style("color", "white") .style("padding", "8px") .style("background-color", "#626D71") .style("border-radius", "6px") .style("text-align", "center") .style("font-family", "monospace") .style("width", "400px") .text("");
E depois chamamos elas para cada círculo.
var node = svg.selectAll("circle") .data(data) .enter() .append("circle") .attr('r', function(d) { return scaleRadius(d[columnForRadius])}) .style("fill", function(d) { return colorCircles(d[columnForColors]) }) .attr('transform', 'translate(' + [width / 2, height / 2] + ')') .on("mouseover", function(d){tooltip.html(d[columnForColors] +" "+d.title+" "+d[columnForRadius]+" views"); return tooltip.style("visibility", "visible");}) .on("mousemove", function(){return tooltip.style("top", (d3.event.pageY-10)+"px").style("left",(d3.event.pageX+10)+"px");}) .on("mouseout", function(){return tooltip.style("visibility", "hidden");});
O mouseover é adicionado quando o mouse é colocado em cima do círculo, e o mousemove segue o cursor ao mexer o mouse. d3.event.pageY e d3.event.pageX retornam as coordenadas do mouse.
É isso! Você pode ver o código completo aqui.