Hacia gráficos D3.js actualizables

Publicado: 2022-03-11

Introducción

D3.js es una biblioteca de código abierto para visualizaciones de datos desarrollada por Mike Bostock. D3 significa documentos basados ​​en datos y, como sugiere su nombre, la biblioteca permite a los desarrolladores generar y manipular fácilmente elementos DOM en función de los datos. Aunque no está limitado por las capacidades de la biblioteca, D3.js generalmente se usa con elementos SVG y ofrece herramientas poderosas para desarrollar visualizaciones de datos vectoriales desde cero.

El patrón de gráfico actualizable permite hacer que los gráficos D3.js sean fáciles
Pío

Comencemos con un ejemplo simple. Suponga que está entrenando para una carrera de 5 km y desea hacer un gráfico de barras horizontales de la cantidad de millas que ha corrido cada día de la ú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 verlo en acción, échale un vistazo en bl.ocks.org.

Si este código parece familiar, eso es genial. Si no, los tutoriales de Scott Murray me parecieron un excelente recurso para comenzar con D3.js.

Como autónomo que ha trabajado cientos de horas desarrollando con D3.js, mi patrón de desarrollo ha evolucionado, siempre con el objetivo final de crear las experiencias de usuario y cliente más completas. Como discutiré con más detalle más adelante, el patrón de Mike Bostock para gráficos reutilizables ofreció un método probado y verdadero para implementar el mismo gráfico en cualquier número de selecciones. Sin embargo, sus limitaciones se dan cuenta una vez que se inicializa el gráfico. Si quería usar las transiciones de D3 y actualizar los patrones con este método, los cambios en los datos tenían que manejarse completamente dentro del mismo ámbito en el que se generó el gráfico. En la práctica, esto significó implementar filtros, selecciones desplegables, controles deslizantes y opciones de cambio de tamaño, todo dentro del mismo alcance de función.

Después de experimentar repetidamente estas limitaciones de primera mano, quería crear una forma de aprovechar todo el poder de D3.js. Por ejemplo, escuchar los cambios en un menú desplegable de un componente completamente separado y activar sin problemas actualizaciones de gráficos de datos antiguos a nuevos. Quería poder entregar los controles de gráficos con todas las funciones y hacerlo de una manera lógica y modular. El resultado es un patrón de gráfico actualizable, y voy a recorrer mi progresión completa para crear este patrón.

Progresión del patrón de gráficos D3.js

Paso 1: Variables de configuración

Cuando comencé a usar D3.js para desarrollar visualizaciones, se volvió muy conveniente usar variables de configuración para definir y cambiar rápidamente las especificaciones de un gráfico. Esto permitió que mis gráficos manejaran todas las diferentes longitudes y valores de datos. El mismo fragmento de código que mostraba las millas recorridas ahora podía mostrar una lista más larga de temperaturas sin contratiempos:

 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 verlo en acción, échale un vistazo en bl.ocks.org.

Observe cómo se escalan las alturas y anchuras de las barras según el tamaño y los valores de los datos. Se cambia una variable y se soluciona el resto.

Paso 2: fácil repetición a través de funciones

La codificación nunca debe ser un ejercicio de copiar y pegar

La codificación nunca debe ser un ejercicio de copiar y pegar
Pío

Al abstraer parte de la lógica comercial, podemos crear un código más versátil que está listo para manejar una plantilla de datos generalizada. El siguiente paso es envolver este código en una función de generación, lo que reduce la inicialización a una sola línea. La función toma tres argumentos: los datos, un objetivo DOM y un objeto de opciones que se puede usar para sobrescribir las variables de configuración predeterminadas. Echa un vistazo a cómo se puede hacer esto:

 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 verlo en acción, échale un vistazo en bl.ocks.org.

También es importante hacer una nota sobre las selecciones de D3.js en este contexto. Las selecciones generales como d3.selectAll('rect') siempre deben evitarse. Si los SVG están presentes en algún otro lugar de la página, todos los rect de la página pasan a formar parte de la selección. En su lugar, utilizando la referencia DOM pasada, cree un objeto svg al que pueda hacer referencia al agregar y actualizar elementos. Esta técnica también puede mejorar el tiempo de ejecución de la generación de gráficos, ya que el uso de una referencia como las barras también evita tener que volver a realizar la selección D3.js.

Paso 3: Encadenamiento y selecciones de métodos

Si bien el esqueleto anterior que utiliza objetos de configuración es muy común en las bibliotecas de JavaScript, Mike Bostock, el creador de D3.js, recomienda otro patrón para crear gráficos reutilizables. En resumen, Mike Bostock recomienda implementar gráficos como cierres con métodos getter-setter. Si bien agrega algo de complejidad a la implementación del gráfico, establecer las opciones de configuración se vuelve muy sencillo para la persona que llama simplemente usando el encadenamiento 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 verlo en acción, échale un vistazo en bl.ocks.org.

La inicialización del gráfico usa la selección D3.js, vinculando los datos relevantes y pasando la selección DOM como el contexto this a la función del generador. La función del generador envuelve las variables predeterminadas en un cierre y permite que la persona que llama las cambie a través del encadenamiento de métodos con funciones de configuración que devuelven el objeto del gráfico. Al hacer esto, la persona que llama puede representar el mismo gráfico en varias selecciones a la vez, o usar un gráfico para representar el mismo gráfico en diferentes selecciones con diferentes datos, todo mientras evita pasar un objeto de opciones voluminoso.

Paso 4: un nuevo patrón para gráficos actualizables

El patrón anterior sugerido por Mike Bostock nos brinda, como desarrolladores de gráficos, mucho poder dentro de la función de generador. Dado un conjunto de datos y cualquier configuración encadenada pasada, controlamos todo desde allí. Si es necesario cambiar los datos desde adentro, podemos usar las transiciones apropiadas en lugar de simplemente volver a dibujar desde cero. Incluso cosas como el cambio de tamaño de la ventana se pueden manejar con elegancia, creando funciones receptivas como el uso de texto abreviado o el cambio de etiquetas de ejes.

Pero, ¿qué pasa si los datos se modifican desde fuera del alcance de la función del generador? ¿O qué pasa si el gráfico necesita ser redimensionado programáticamente? Podríamos simplemente volver a llamar a la función de gráfico, con los nuevos datos y la nueva configuración de tamaño. Todo sería redibujado, y listo. Problema resuelto.

Desafortunadamente, hay una serie de problemas con esta solución.

En primer lugar, casi inevitablemente estamos realizando cálculos de inicialización innecesarios. ¿Por qué hacer una manipulación de datos compleja cuando todo lo que tenemos que hacer es escalar el ancho? Estos cálculos pueden ser necesarios la primera vez que se inicializa un gráfico, pero ciertamente no en cada actualización que necesitamos hacer. Cada solicitud programática requiere alguna modificación y, como desarrolladores, sabemos exactamente cuáles son estos cambios. Ni mas ni menos. Además, dentro del alcance del gráfico, ya tenemos acceso a muchas cosas que necesitamos (objetos SVG, estados de datos actuales y más), lo que hace que los cambios sean fáciles de implementar.

Tomemos, por ejemplo, el ejemplo del gráfico de barras anterior. Si quisiéramos actualizar el ancho y lo hiciéramos redibujando todo el gráfico, desencadenaríamos muchos cálculos innecesarios: encontrar el valor máximo de datos, calcular la altura de la barra y representar todos estos elementos SVG. Realmente, una vez que el width se asigna a su nuevo valor, los únicos cambios que necesitamos hacer son:

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

Pero se pone aún mejor. Dado que ahora tenemos algo de historial del gráfico, podemos usar las transiciones integradas de D3 para actualizar nuestros gráficos y animarlos fácilmente. Continuando con el ejemplo anterior, agregar una transición en el width es tan simple como cambiar

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

para

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

Aún mejor, si permitimos que un usuario pase un nuevo conjunto de datos, podemos usar las selecciones de actualización de D3 (ingresar, actualizar y salir) para aplicar también transiciones a nuevos datos. Pero, ¿cómo permitimos nuevos datos? Si recuerda, nuestra implementación anterior creó un nuevo gráfico como este:

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

Vinculamos los datos a una selección de D3.js y llamamos a nuestro gráfico reutilizable. Cualquier cambio en los datos tendría que hacerse vinculando nuevos datos a la misma selección. Teóricamente, podríamos usar el patrón anterior y sondear la selección de datos existentes, y luego actualizar nuestros hallazgos con los nuevos datos. Esto no solo es desordenado y complicado de implementar, sino que requeriría asumir que el gráfico existente era del mismo tipo y forma.

En cambio, con algunos cambios en la estructura de la función del generador de JavaScript, podemos crear un gráfico que permitirá a la persona que llama solicitar cambios fácilmente de forma externa a través del encadenamiento de métodos. Mientras que antes la configuración y los datos se establecían y luego se dejaban intactos, la persona que llama ahora puede hacer algo como esto, incluso después de inicializar el gráfico:

 weatherChart.width(420);

El resultado es una transición suave a un nuevo ancho del gráfico existente. Sin cálculos innecesarios y con transiciones elegantes, el resultado es un cliente feliz.

Sin cálculos innecesarios + transiciones elegantes = cliente feliz

Sin cálculos innecesarios + transiciones elegantes = cliente feliz
Pío

Esta funcionalidad adicional viene con un ligero aumento en el esfuerzo del desarrollador. Un esfuerzo, sin embargo, que he encontrado que vale la pena históricamente. Aquí hay un esqueleto del gráfico actualizable:

 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 la implementación completa, échale un vistazo en bl.ocks.org.

Repasemos la nueva estructura. El mayor cambio con respecto a la implementación de cierre anterior es la adición de funciones de actualización. Como se discutió anteriormente, estas funciones aprovechan las transiciones D3.js y actualizan los patrones para realizar sin problemas los cambios necesarios en función de los nuevos datos o configuraciones de gráficos. Para hacerlos accesibles para la persona que llama, se agregan funciones como propiedades al gráfico. Y para hacerlo aún más fácil, tanto la configuración inicial como las actualizaciones se manejan a través de la misma función:

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

Tenga en cuenta que updateWidth no se definirá hasta que se haya inicializado el gráfico. Si no está undefined , la variable de configuración se establecerá globalmente y se usará en el cierre del gráfico. Si se ha llamado a la función de gráfico, todas las transiciones se transfieren a la función updateWidth , que usa la variable de width modificada para realizar los cambios necesarios. Algo como esto:

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

Con esta nueva estructura, los datos del gráfico se pasan a través del encadenamiento de métodos como cualquier otra variable de configuración, en lugar de vincularlos a una selección de D3.js. La diferencia:

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

que se convierte en:

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

Así que hicimos algunos cambios y añadimos un poco de esfuerzo del desarrollador, veamos los beneficios.

Supongamos que tiene una nueva solicitud de función: “Agregue un menú desplegable para que el usuario pueda cambiar entre temperaturas altas y bajas. Y haz que el color cambie también mientras lo haces”. En lugar de borrar el gráfico actual, vincular los nuevos datos y volver a dibujar desde cero, ahora puede hacer una simple llamada cuando se selecciona baja temperatura:

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

y disfruta de la magia. No solo guardamos los cálculos, sino que agregamos un nuevo nivel de comprensión a la visualización a medida que se actualiza, lo que antes no era posible.

Aquí se necesita una importante advertencia sobre las transiciones. Tenga cuidado al programar varias transiciones en el mismo elemento. Comenzar una nueva transición cancelará implícitamente todas las transiciones que se estaban ejecutando anteriormente. Por supuesto, se pueden cambiar varios atributos o estilos en un elemento en una transición iniciada por D3.js, pero me he encontrado con algunos casos en los que se activan varias transiciones simultáneamente. En estos casos, considere usar transiciones simultáneas en elementos primarios y secundarios al crear sus funciones de actualización.

Un cambio de filosofía

Mike Bostock presenta los cierres como una forma de encapsular la generación de gráficos. Su patrón está optimizado para crear el mismo gráfico con diferentes datos en muchos lugares. Sin embargo, en mis años trabajando con D3.js, encontré una ligera diferencia en las prioridades. En lugar de usar una instancia de un gráfico para crear la misma visualización con datos diferentes, el nuevo patrón que introduje le permite a la persona que llama crear fácilmente varias instancias de un gráfico, cada una de las cuales se puede modificar por completo incluso después de la inicialización. Además, cada una de estas actualizaciones se maneja con acceso completo al estado actual del gráfico, lo que permite al desarrollador eliminar cálculos innecesarios y aprovechar el poder de D3.js para crear experiencias de usuario y cliente más fluidas.