Em direção a gráficos D3.js atualizáveis

Publicados: 2022-03-11

Introdução

D3.js é uma biblioteca de código aberto para visualizações de dados desenvolvida por Mike Bostock. D3 significa documentos orientados a dados e, como o próprio nome sugere, a biblioteca permite que os desenvolvedores gerem e manipulem facilmente elementos DOM com base em dados. Embora não seja limitado pelos recursos da biblioteca, o D3.js normalmente é usado com elementos SVG e oferece ferramentas poderosas para desenvolver visualizações de dados vetoriais a partir do zero.

O padrão de gráfico atualizável permite tornar os gráficos D3.js fáceis
Tweet

Vamos começar com um exemplo simples. Suponha que você esteja treinando para uma corrida de 5 km e queira fazer um gráfico de barras horizontais do número de quilômetros que correu em cada dia da última semana:

 var milesRun = [2, 5, 4, 1, 2, 6, 5]; d3.select('body').append('svg') .attr('height', 300) .attr('width', 800) .selectAll('rect') .data(milesRun) .enter() .append('rect') .attr('y', function (d, i) { return i * 40 }) .attr('height', 35) .attr('x', 0) .attr('width', function (d) { return d*100}) .style('fill', 'steelblue');

Para vê-lo em ação, confira em bl.ocks.org.

Se este código parece familiar, isso é ótimo. Se não, achei os tutoriais de Scott Murray um excelente recurso para começar a usar o D3.js.

Como freelancer que trabalhou centenas de horas desenvolvendo com D3.js, meu padrão de desenvolvimento passou por uma evolução, sempre com o objetivo final de criar as experiências mais abrangentes para clientes e usuários. Como discutirei com mais detalhes posteriormente, o padrão de Mike Bostock para gráficos reutilizáveis ​​ofereceu um método testado e comprovado para implementar o mesmo gráfico em qualquer número de seleções. No entanto, suas limitações são percebidas uma vez que o gráfico é inicializado. Se eu quisesse usar as transições e padrões de atualização do D3 com esse método, as alterações nos dados teriam que ser tratadas inteiramente dentro do mesmo escopo em que o gráfico foi gerado. Na prática, isso significava implementar filtros, seleções suspensas, controles deslizantes e opções de redimensionamento, tudo dentro do mesmo escopo de função.

Depois de experimentar repetidamente essas limitações em primeira mão, eu queria criar uma maneira de aproveitar todo o poder do D3.js. Por exemplo, ouvir as alterações em uma lista suspensa de um componente completamente separado e acionar perfeitamente as atualizações do gráfico de dados antigos para novos. Eu queria poder entregar os controles do gráfico com total funcionalidade e fazê-lo de uma maneira lógica e modular. O resultado é um padrão de gráfico atualizável, e vou percorrer minha progressão completa para criar esse padrão.

Progressão de padrão de gráficos D3.js

Etapa 1: variáveis ​​de configuração

Quando comecei a usar o D3.js para desenvolver visualizações, tornou-se muito conveniente usar variáveis ​​de configuração para definir e alterar rapidamente as especificações de um gráfico. Isso permitiu que meus gráficos lidassem com todos os diferentes comprimentos e valores de dados. O mesmo pedaço de código que exibia milhas percorridas agora pode exibir uma lista mais longa de temperaturas sem soluços:

 var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68]; var height = 300; var width = 800; var barPadding = 1; var barSpacing = height / highTemperatures.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(highTemperatures); var widthScale = width / maxValue; d3.select('body').append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(highTemperatures) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', 'steelblue');

Para vê-lo em ação, confira em bl.ocks.org.

Observe como as alturas e larguras das barras são dimensionadas com base no tamanho e nos valores dos dados. Uma variável é alterada, e o resto é cuidado.

Etapa 2: repetição fácil por meio de funções

A codificação nunca deve ser um exercício de copiar e colar

A codificação nunca deve ser um exercício de copiar e colar
Tweet

Ao abstrair parte da lógica de negócios, podemos criar um código mais versátil que está pronto para lidar com um modelo generalizado de dados. A próxima etapa é envolver esse código em uma função de geração, o que reduz a inicialização a apenas uma linha. A função recebe três argumentos: os dados, um destino DOM e um objeto de opções que pode ser usado para substituir as variáveis ​​de configuração padrão. Veja como isso pode ser feito:

 var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; function drawChart(dom, data, options) { var width = options.width || 800; var height = options.height || 200; var barPadding = options.barPadding || 1; var fillColor = options.fillColor || 'steelblue'; var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(dom).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); } var weatherOptions = {fillColor: 'coral'}; drawChart('#weatherHistory', highTemperatures, weatherOptions); var runningOptions = {barPadding: 2}; drawChart('#runningHistory', milesRun, runningOptions);

Para vê-lo em ação, confira em bl.ocks.org.

Também é importante fazer uma observação sobre as seleções de D3.js neste contexto. Seleções gerais como d3.selectAll('rect') devem sempre ser evitadas. Se SVGs estiverem presentes em algum outro lugar da página, todos os rect 's na página se tornarão parte da seleção. Em vez disso, usando a referência DOM passada, crie um objeto svg ao qual você possa se referir ao anexar e atualizar elementos. Essa técnica também pode melhorar o tempo de execução da geração do gráfico, pois usar uma referência como barras também evita ter que fazer a seleção do D3.js novamente.

Etapa 3: Encadeamento e seleções de métodos

Embora o esqueleto anterior usando objetos de configuração seja muito comum em bibliotecas JavaScript, Mike Bostock, o criador do D3.js, recomenda outro padrão para criar gráficos reutilizáveis. Em suma, Mike Bostock recomenda a implementação de gráficos como encerramentos com métodos getter-setter. Ao adicionar alguma complexidade à implementação do gráfico, definir as opções de configuração torna-se muito simples para o chamador simplesmente usando o encadeamento de métodos:

 // Using Mike Bostock's Towards Reusable Charts Pattern function barChart() { // All options that should be accessible to caller var width = 900; var height = 200; var barPadding = 1; var fillColor = 'steelblue'; function chart(selection){ selection.each(function (data) { var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(this).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); }); } chart.width = function(value) { if (!arguments.length) return margin; width = value; return chart; }; chart.height = function(value) { if (!arguments.length) return height; height = value; return chart; }; chart.barPadding = function(value) { if (!arguments.length) return barPadding; barPadding = value; return chart; }; chart.fillColor = function(value) { if (!arguments.length) return fillColor; fillColor = value; return chart; }; return chart; } var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; var runningChart = barChart().barPadding(2); d3.select('#runningHistory') .datum(milesRun) .call(runningChart); var weatherChart = barChart().fillColor('coral'); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);

Para vê-lo em ação, confira em bl.ocks.org.

A inicialização do gráfico usa a seleção D3.js, vinculando os dados relevantes e passando a seleção DOM como o contexto this para a função geradora. A função do gerador envolve as variáveis ​​padrão em um encerramento e permite que o chamador as altere por meio do encadeamento de métodos com funções de configuração que retornam o objeto gráfico. Ao fazer isso, o chamador pode renderizar o mesmo gráfico para várias seleções ao mesmo tempo ou usar um gráfico para renderizar o mesmo gráfico para diferentes seleções com dados diferentes, evitando passar um objeto de opções volumoso.

Etapa 4: um novo padrão para gráficos atualizáveis

O padrão anterior sugerido por Mike Bostock nos dá, como desenvolvedores de gráficos, muito poder dentro da função geradora. Dado um conjunto de dados e quaisquer configurações encadeadas transmitidas, controlamos tudo a partir daí. Se os dados precisarem ser alterados de dentro, podemos usar transições apropriadas em vez de apenas redesenhar do zero. Mesmo coisas como redimensionamento de janelas podem ser tratadas com elegância, criando recursos responsivos, como usar texto abreviado ou alterar rótulos de eixo.

Mas e se os dados forem modificados fora do escopo da função geradora? Ou se o gráfico precisar ser redimensionado programaticamente? Poderíamos apenas chamar a função chart novamente, com os novos dados e a nova configuração de tamanho. Tudo seria redesenhado, e voila. Problema resolvido.

Infelizmente, há uma série de problemas com esta solução.

Em primeiro lugar, estamos quase inevitavelmente realizando cálculos de inicialização desnecessários. Por que manipular dados complexos quando tudo o que precisamos fazer é dimensionar a largura? Esses cálculos podem ser necessários na primeira vez que um gráfico é inicializado, mas certamente não em todas as atualizações que precisamos fazer. Cada solicitação programática requer alguma modificação e, como desenvolvedores, sabemos exatamente quais são essas alterações. Nem mais nem menos. Além disso, dentro do escopo do gráfico, já temos acesso a muitas coisas de que precisamos (objetos SVG, estados de dados atuais e muito mais) tornando as alterações fáceis de implementar.

Veja, por exemplo, o exemplo do gráfico de barras acima. Se quiséssemos atualizar a largura e fizéssemos isso redesenhando todo o gráfico, acionaríamos muitos cálculos desnecessários: encontrar o valor máximo dos dados, calcular a altura da barra e renderizar todos esses elementos SVG. Realmente, uma vez que a width é atribuída ao seu novo valor, as únicas alterações que precisamos fazer são:

 width = newWidth; widthScale = width / maxValue; bars.attr('width', function(d) { return d*widthScale}); svg.attr('width', width);

Mas fica ainda melhor. Como agora temos algum histórico do gráfico, podemos usar as transições integradas do D3 para atualizar nossos gráficos e animá-los facilmente. Continuando com o exemplo acima, adicionar uma transição na width é tão simples quanto alterar

 bars.attr('width', function(d) { return d*widthScale});

para

 bars.transition().duration(1000).attr('width', function(d) { return d*widthScale});

Melhor ainda, se permitirmos que um usuário passe um novo conjunto de dados, podemos usar as seleções de atualização do D3 (entrar, atualizar e sair) para também aplicar transições a novos dados. Mas como permitimos novos dados? Se você se lembra, nossa implementação anterior criou um novo gráfico como este:

 d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);

Ligamos os dados a uma seleção D3.js e chamamos nosso gráfico reutilizável. Quaisquer alterações nos dados teriam que ser feitas vinculando novos dados à mesma seleção. Teoricamente, poderíamos usar o padrão antigo e sondar a seleção de dados existentes e, em seguida, atualizar nossas descobertas com os novos dados. Isso não é apenas confuso e complicado de implementar, mas exigiria a suposição de que o gráfico existente era do mesmo tipo e forma.

Em vez disso, com algumas alterações na estrutura da função geradora de JavaScript, podemos criar um gráfico que permitirá ao chamador solicitar alterações externamente facilmente por meio do encadeamento de métodos. Enquanto antes a configuração e os dados eram definidos e deixados intocados, o chamador agora pode fazer algo assim, mesmo após o gráfico ser inicializado:

 weatherChart.width(420);

O resultado é uma transição suave para uma nova largura do gráfico existente. Sem cálculos desnecessários e com transições elegantes, o resultado é um cliente satisfeito.

Sem cálculos desnecessários + transições elegantes = cliente feliz

Sem cálculos desnecessários + transições elegantes = cliente feliz
Tweet

Essa funcionalidade extra vem com um pequeno aumento no esforço do desenvolvedor. Um esforço, no entanto, que eu encontrei para valer a pena o tempo historicamente. Aqui está um esqueleto do gráfico atualizável:

 function barChart() { // All options that should be accessible to caller var data = []; var width = 800; //... the rest var updateData; var updateWidth; //... the rest function chart(selection){ selection.each(function () { // //draw the chart here using data, width // updateWidth = function() { // use width to make any changes }; updateData = function() { // use D3 update pattern with data } }); } chart.data = function(value) { if (!arguments.length) return data; data = value; if (typeof updateData === 'function') updateData(); return chart; }; chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; }; //... the rest return chart; }

Para ver totalmente implementado, confira em bl.ocks.org.

Vamos rever a nova estrutura. A maior mudança em relação à implementação de encerramento anterior é a adição de funções de atualização. Conforme discutido anteriormente, essas funções aproveitam as transições do D3.js e os padrões de atualização para fazer as alterações necessárias sem problemas com base em novos dados ou configurações de gráfico. Para torná-los acessíveis ao chamador, as funções são adicionadas como propriedades ao gráfico. E para facilitar ainda mais, tanto a configuração inicial quanto as atualizações são tratadas através da mesma função:

 chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; };

Observe que updateWidth não será definido até que o gráfico seja inicializado. Se for undefined , a variável de configuração será definida globalmente e usada no fechamento do gráfico. Se a função de gráfico foi chamada, todas as transições são entregues à função updateWidth , que usa a variável de width alterada para fazer as alterações necessárias. Algo assim:

 updateWidth = function() { widthScale = width / maxValue; bars.transition().duration(1000).attr('width', function(d) { return d*widthScale}); svg.transition().duration(1000).attr('width', width); };

Com essa nova estrutura, os dados do gráfico são passados ​​por meio do encadeamento de métodos como qualquer outra variável de configuração, em vez de vinculá-los a uma seleção D3.js. A diferença:

 var weatherChart = barChart(); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);

que se torna:

 var weatherChart = barChart().data(highTemperatures); d3.select('#weatherHistory') .call(weatherChart);

Então, fizemos algumas mudanças e adicionamos um pouco de esforço do desenvolvedor, vamos ver os benefícios.

Digamos que você tenha uma nova solicitação de recurso: “Adicione uma lista suspensa para que o usuário possa alternar entre altas e baixas temperaturas. E faça a cor mudar também enquanto você está nisso.” Em vez de limpar o gráfico atual, vincular os novos dados e redesenhar do zero, agora você pode fazer uma chamada simples quando a temperatura baixa é selecionada:

 weatherChart.data(lowTemperatures).fillColor('blue');

e aproveite a magia. Não apenas estamos salvando cálculos, mas adicionamos um novo nível de compreensão à visualização à medida que ela é atualizada, o que não era possível antes.

Uma palavra importante de cautela sobre as transições é necessária aqui. Tenha cuidado ao agendar várias transições no mesmo elemento. Iniciar uma nova transição cancelará implicitamente todas as transições em execução anteriormente. É claro que vários atributos ou estilos podem ser alterados em um elemento em uma transição iniciada pelo D3.js, mas encontrei alguns casos em que várias transições são acionadas simultaneamente. Nesses casos, considere usar transições simultâneas em elementos pai e filho ao criar suas funções de atualização.

Uma mudança na filosofia

Mike Bostock apresenta closures como uma forma de encapsular a geração de gráficos. Seu padrão é otimizado para criar o mesmo gráfico com dados diferentes em muitos lugares. Em meus anos trabalhando com D3.js, entretanto, encontrei uma pequena diferença nas prioridades. Em vez de usar uma instância de um gráfico para criar a mesma visualização com dados diferentes, o novo padrão que introduzi permite que o chamador crie facilmente várias instâncias de um gráfico, cada uma das quais pode ser totalmente modificada mesmo após a inicialização. Além disso, cada uma dessas atualizações é tratada com acesso total ao estado atual do gráfico, permitindo que o desenvolvedor elimine cálculos desnecessários e aproveite o poder do D3.js para criar experiências mais perfeitas para usuários e clientes.