Manipulación definitiva de recopilación de datos en memoria con Supergroup.js
Publicado: 2022-03-11La manipulación de datos en memoria a menudo da como resultado una pila de código espagueti. La manipulación en sí puede ser bastante simple: agrupar, agregar, crear jerarquías y realizar cálculos; pero una vez que se escribe el código de manipulación de datos y los resultados se envían a la parte de la aplicación donde se necesitan, siguen surgiendo necesidades relacionadas. Es posible que se requiera una transformación similar de los datos en otra parte de la aplicación, o que se necesiten más detalles: metadatos, contexto, datos principales o secundarios, etc. Particularmente en aplicaciones de visualización o informes complejos, después de calzar datos en alguna estructura para un dada la necesidad, uno se da cuenta de que la información sobre herramientas o los resaltados sincronizados o los desgloses ejercen presiones inesperadas sobre los datos transformados. Uno podría abordar estos requisitos de la siguiente manera:
- Rellenar más detalles y más niveles en los datos transformados hasta que sea enorme y desgarbado pero satisfaga las necesidades de todos los rincones y grietas de la aplicación que finalmente visita.
- Escribir nuevas funciones de transformación que tienen que unir algún nodo ya procesado a la fuente de datos global para traer nuevos detalles.
- Diseñar clases de objetos complejos que de alguna manera sepan cómo manejar todos los contextos en los que terminan.
Después de crear software centrado en datos durante 20 o 30 años como yo, uno comienza a sospechar que están resolviendo el mismo conjunto de problemas una y otra vez. Incorporamos bucles complejos, comprensión de listas, funciones analíticas de bases de datos, funciones de mapa o groupBy, o incluso motores de informes completos. A medida que nuestras habilidades se desarrollan, mejoramos en hacer que cualquier fragmento de código de manipulación de datos sea inteligente y conciso, pero los espaguetis todavía parecen proliferar.
En este artículo, echaremos un vistazo a la biblioteca de JavaScript Supergroup.js, equipada con algunas potentes funciones de manipulación, agrupación y agregación de recopilación de datos en memoria, y cómo puede ayudarlo a resolver algunos desafíos comunes de manipulación en conjuntos de datos limitados.
El problema
Durante mi primera participación en Toptal, estuve convencido desde el primer día de que las rutinas de administración de datos y API del código base que estaba agregando se habían sobreespecificado irremediablemente. Era una aplicación D3.js para analizar datos de marketing. La aplicación ya tenía una atractiva visualización de gráfico de barras agrupadas/apiladas y requería que se construyera una visualización de mapa de coropletas. El gráfico de barras permitía al usuario mostrar 2, 3 o 4 dimensiones arbitrarias llamadas internamente x0, x1, y0 e y1, siendo x1 e y1 opcionales.
En la construcción de leyendas, filtros, información sobre herramientas, títulos y el cálculo de totales o diferencias de un año a otro, x0, x1, y0 e y1 se mencionaron en todo el código y, de forma ubicua, en todo el código había lógica condicional para manejar la presencia o ausencia de dimensiones opcionales.
Aunque podría haber sido peor. El código podría haberse referido directamente a dimensiones de datos subyacentes específicas (p. ej., año, presupuesto, nivel, categoría de producto, etc.). Más bien, al menos se generalizó a las dimensiones de visualización de este gráfico de barras agrupadas/apiladas. Pero cuando otro tipo de gráfico se convirtió en un requisito, uno en el que las dimensiones de x0, x1, y0 e y1 no tendrían sentido, una parte significativa del código tuvo que reescribirse por completo: código que trata leyendas, filtros, información sobre herramientas, títulos , cálculos resumidos y construcción y representación de gráficos.
Nadie quiere decirle a su cliente: "Sé que es solo mi primer día aquí, pero antes de implementar lo que me pediste, ¿puedo refactorizar todo el código usando una biblioteca de manipulación de datos de Javascript que escribí yo mismo?" Por un golpe de gran suerte, me salvé de esta vergüenza cuando me presentaron a un programador cliente que estaba a punto de refactorizar el código de todos modos. Con una mente abierta y una gracia inusuales, el cliente me invitó al proceso de refactorización a través de una serie de sesiones de programación en pareja. Estaba dispuesto a probar Supergroup.js y, en cuestión de minutos, comenzamos a reemplazar grandes franjas de código retorcido con pequeñas llamadas concisas a Supergroup.
Lo que vimos en el código era típico de los enredos que surgen al tratar con estructuras de datos jerárquicos o agrupados, particularmente en aplicaciones D3 una vez que se vuelven más grandes que las demostraciones. Estos problemas surgen con las aplicaciones de informes en general, en aplicaciones CRUD que implican filtrar o navegar a pantallas o registros específicos, en herramientas de análisis, herramientas de visualización, prácticamente cualquier aplicación donde se utilizan suficientes datos para requerir una base de datos.
Manipulación en memoria
Tome una API Rest para búsqueda por facetas y operaciones CRUD, por ejemplo, podría terminar con una o más llamadas API para obtener el conjunto de campos y valores (tal vez con recuentos de registros) para todos los parámetros de búsqueda, otra llamada API para obtener un registro específico y otras llamadas para obtener grupos de registros para informes o algo así. Entonces, es probable que todo esto se complique por la necesidad de imponer filtros temporales basados en la selección o los permisos del usuario.
Si es poco probable que su base de datos supere las decenas o cientos de miles de registros, o si tiene formas sencillas de limitar el universo inmediato de interés a un conjunto de datos de ese tamaño, probablemente podría desechar toda su complicada API Rest (excepto la parte de permisos ), y tener una sola llamada que diga “consígueme todos los registros”. Vivimos en un mundo con compresión rápida, velocidades de transferencia rápidas, mucha memoria en el front-end y motores de Javascript veloces. Establecer esquemas de consulta complejos que deben ser entendidos y mantenidos por el cliente y el servidor a menudo es innecesario. La gente ha escrito bibliotecas para ejecutar consultas SQL directamente en colecciones de registros JSON, porque la mayor parte del tiempo no necesita toda la optimización de un RDBMS. Pero incluso eso es excesivo. A riesgo de sonar increíblemente grandioso, Supergroup es más fácil de usar y más poderoso que SQL la mayor parte del tiempo.
Supergroup es básicamente d3.nest, underscore.groupBy o underscore.nest con esteroides. Bajo el capó, utiliza groupBy de lodash para la operación de agrupación. La estrategia central es convertir cada parte de los datos originales en metadatos y enlaces al resto del árbol inmediatamente accesibles en cada nodo; y cada nodo o lista de nodos está sobrecargado con un pastel de bodas de azúcar sintáctico, de modo que casi cualquier cosa que desee saber desde cualquier lugar del árbol está disponible en una expresión breve.
Supergrupo en acción
Para demostrar algo de la dulzura sintáctica de Supergroup, he secuestrado una copia de Mister Nester de Shan Carter. Un anidamiento simple de dos niveles usando d3.nest se ve así:
d3.nest() .key(function(d) { return d.year; }) .key(function(d) { return d.fips; }) .map(data);El equivalente con Supergrupo sería:
_.supergroup(data,['year','fips']).d3NestMap();La llamada final allí a d3NestMap() simplemente pone la salida de Supergroup en el mismo formato (pero no muy útil en mi opinión) como nest.map() de d3:
{ "1970": { "6001": [ { "fips": "6001", "totalpop": "1073180", "pctHispanic": "0.126", "year": "1970" } ], "6003": [ { "fips": "6003", "totalpop": "510", "pctHispanic": "NA", "year": "1970" } ], ... } }Digo "no muy útil" porque las selecciones D3 deben vincularse a matrices, no a mapas. ¿Qué es un "nodo" en esta estructura de datos del mapa? "1970" o "6001", son solo cadenas y claves en un mapa de nivel superior o segundo. Entonces, un nodo sería a lo que apuntan las claves. "1970" apunta a un mapa de segundo nivel, "6001" apunta a una matriz de registros sin procesar. Este anidamiento de mapas se puede leer en la consola y está bien para buscar valores, pero para las llamadas D3 necesita datos de matriz, por lo que usa nest.entries() en lugar de nest.map():
[ { "key": "1970", "values": [ { "key": "6001", "values": [ { "fips": "6001", "totalpop": "1073180", "pctHispanic": "0.126", "year": "1970" } ] }, { "key": "6003", "values": [ { "fips": "6003", "totalpop": "510", "pctHispanic": "NA", "year": "1970" } ] }, ... ] }, ... ]Ahora tenemos arreglos anidados de pares clave/valor: el nodo 1970 tiene una clave de “1970” y un valor que consiste en un arreglo de pares clave/valor de segundo nivel. 6001 es otro par clave/valor. Su clave también es una cadena que lo identifica, pero el valor es una matriz de registros sin formato. Tenemos que tratar estos nodos del segundo nivel de hoja, así como los nodos de nivel de hoja, de manera diferente a los nodos que se encuentran más arriba en el árbol. Y los nodos en sí mismos no contienen evidencia de que "1970" sea un año y "6001" sea un código fips, o que 1970 sea el padre de este nodo 6001 en particular. Demostraré cómo Supergroup resuelve estos problemas, pero primero observe el valor de retorno inmediato de una llamada de Supergroup. A primera vista, es solo una serie de "claves" de nivel superior:
_.supergroup(data,['year','fips']); // [ 1970, 1980, 1990, 2000, 2010 ]"Ok, eso es bueno", dices. “Pero, ¿dónde está el resto de los datos?” Las cadenas o números en la lista Supergroup son en realidad objetos String o Number, sobrecargados con más propiedades y métodos. Para los nodos por encima del nivel de hoja, hay una propiedad de niños ("niños" es el nombre predeterminado, podría llamarlo de otra manera) que contiene otra lista de Supergrupo de nodos de segundo nivel:
_.supergroup(data,['year','fips'])[0].children; // [ 6001, 6003, 6005, 6007, 6009, 6011, ... ] Función de información sobre herramientas que funciona
Para demostrar otras características y cómo funciona todo esto, hagamos una lista anidada simple usando D3, y veamos cómo creamos una función útil de información sobre herramientas que puede funcionar en cualquier nodo de la lista.
d3.select('body') .selectAll('div.year') .data(_.supergroup(data,['year','fips'])) .enter() .append('div').attr('class','year') .on('mouseover', tooltip) .selectAll('div.fips') .data(function(d) { return d.children; }) .enter() .append('div').attr('class','fips') .on('mouseover', tooltip); function tooltip(node) { // comments show values for a second-level node var typeOfNode = node.dim; // fips var nodeValue = node.toString(); // 6001 var totalPopulation = node.aggregate(d3.sum, 'totalpop'); // 1073180 var pathToRoot = node.namePath(); // 1970/6001 var fieldPath = node.dimPath(); // year/fips var rawRecordCount = node.records.length; var parentPop = node.parent.aggregate(d3.sum, 'totalpop'); var percentOfGroup = 100 * totalPopulation / parentPop; var percentOfAll = 100 * totalPopulation / node.path()[0].aggregate(d3.sum,'totalPop'); ... };Esta función de información sobre herramientas funcionará para casi cualquier nodo a cualquier profundidad. Dado que los nodos en el nivel superior no tienen padres, podemos hacer esto para solucionarlo:

var byYearFips = _.supergroup(data,['year','fips']); var root = byYearFips.asRootVal();Ahora tenemos un nodo raíz que es padre de todos los nodos Year. No tenemos que hacer nada con él, pero ahora nuestra información sobre herramientas funcionará porque node.parent tiene algo que señalar. Y node.path()[0], que se suponía que apuntaba a un nodo que representaba todo el conjunto de datos, en realidad lo hace.
En caso de que no fuera obvio a partir de los ejemplos anteriores, namePath, dimPath y path brindan una ruta desde la raíz hasta el nodo actual:
var byYearFips = _.supergroup(data,['year','fips']); // BTW, you can give a delimiter string to namePath or dimPath otherwise it defaults to '/': byYearFips[0].children[0].namePath(' --> '); // ==> "1970 --> 6001" byYearFips[0].children[0].dimPath(); // ==> "year/fips" byYearFips[0].children[0].path(); // ==> [1970,6001] // after calling asRootVal, paths go up one more level: var root = byYearFips.asRootVal('Population by Year/Fips'); // you can give the root node a name or it defaults to 'Root' byYearFips[0].children[0].namePath(' --> '); // ==> undefined byYearFips[0].children[0].dimPath(); // ==> "root/year/fips" byYearFips[0].children[0].path(); // ==> ["Population by Year/Fips",1970,6001] // from any node, .path()[0] will point to the root: byYearFips[0].children[0].path()[0] === root; // ==> trueAgregue en el lugar cuando lo necesite
El código de información sobre herramientas anterior también utilizó el método "agregado". "agregado" se llama a un solo nodo y toma dos parámetros:
- Una función de agregación que espera una matriz (generalmente de números).
- Ya sea un nombre de campo del campo que se extraerá de los registros agrupados en ese nodo o una función que se aplicará a cada uno de esos registros.
También hay un método de conveniencia de "agregados" en las listas (la lista de grupos de nivel superior o los grupos secundarios de cualquier nodo). Puede devolver una lista o un mapa.
_.supergroup(data,'year').aggregates(d3.sum,'totalpop'); // ==> [19957304,23667902,29760021,33871648,37253956] _.supergroup(data,'year').aggregates(d3.sum,'totalpop','dict'); // ==> {"1970":19957304,"1980":23667902,"1990":29760021,"2000":33871648,"2010":37253956}Matrices que actúan como mapas
Con d3.nest, tendemos a usar .entries() en lugar de .map(), como dije antes, porque los "mapas" no te permiten usar toda la funcionalidad D3 (o subrayado) que depende de las matrices. Pero cuando usa .entries() para generar matrices, no puede realizar una búsqueda simple por valor clave. Por supuesto, Supergroup proporciona el azúcar sintáctico que desea para que no tenga que recorrer una matriz completa cada vez que desee un solo valor:
_.supergroup(data,['year','fips']).lookup(1980); // ==> 1980 _.supergroup(data,['year','fips']).lookup([1980,6011]).namePath(); // ==> "1980/6011"Comparación de nodos a lo largo del tiempo
Un método .previous() en los nodos le permite acceder al nodo anterior en una lista de Supergrupo. Puedes usar .sort(
_.chain(data) .supergroup(['fips','year']) .map(function(fips) { return [fips, _.chain(fips.children.slice(1)) .map(function(year) { return [year, year.aggregate(d3.sum,'totalpop') + ' (' + Math.round( (year.aggregate(d3.sum, 'totalpop') / year.previous().aggregate(d3.sum,'totalpop') - 1) * 100) + '% change from ' + year.previous() + ')' ]; }).object().value() ] }).object().value(); ==> { "6001": { "1980": "1105379 (3% change from 1970)", "1990": "1279182 (16% change from 1980)", "2000": "1443741 (13% change from 1990)", "2010": "1510271 (5% change from 2000)" }, "6003": { "1980": "1097 (115% change from 1970)", "1990": "1113 (1% change from 1980)", "2000": "1208 (9% change from 1990)", "2010": "1175 (-3% change from 2000)" }, ... }Datos tabulares para diseños de jerarquía D3.js
Supergroup hace mucho más de lo que he mostrado aquí hasta ahora. Para las visualizaciones D3 basadas en d3.layout.hierarchy, el código de ejemplo en la galería D3 generalmente comienza con los datos en formato de árbol (este ejemplo de Treemap, por ejemplo). Supergroup le permite preparar fácilmente datos tabulares para visualizaciones d3.layout.hierarchy (ejemplo). Todo lo que necesita es el nodo raíz devuelto por .asRootVal() y luego ejecutar root.addRecordsAsChildrenToLeafNodes(). d3.layout.hierarchy espera que el nivel inferior de los nodos secundarios sea una matriz de registros sin formato. addRecordsAsChildrenToLeafNodes toma los nodos hoja de un árbol de supergrupo y copia la matriz .records en una propiedad .children. No es la forma en que a Supergroup le gustan las cosas, pero funcionará bien para Treemaps, Clusters, Partitions, etc. (d3.layout.hierarchy docs).
Al igual que el método d3.layout.hierarchy.nodes que devuelve todos los nodos de un árbol como una sola matriz, Supergroup proporciona .descendants() para que todos los nodos comiencen desde un nodo específico, .flattenTree() para que todos los nodos comiencen de una lista de supergrupo regular y .leafNodes() para obtener solo una matriz de los nodos hoja.
Agrupación y agregación por campos de valores múltiples
Sin entrar en detalles exhaustivos, mencionaré que Supergroup tiene algunas características para manejar situaciones que ocurren con menos frecuencia pero con la suficiente frecuencia como para merecer un tratamiento especial.
A veces desea agrupar por un campo que puede tener más de un valor. En los campos relacionales o tabulares, los campos de valores múltiples generalmente no deberían aparecer (se rompen primero en la forma normal), pero pueden ser útiles. Así es como Supergroup maneja tal caso:
var bloggers = [ { name:"Ridwan", profession:["Programmer"], articlesPublished:73 }, { name:"Sigfried", profession:["Programmer","Spiritualist"], articlesPublished:2 }, ]; // the regular way _.supergroup(bloggers, 'profession').aggregates(_.sum, 'articlesPublished','dict'); // ==> {"Programmer":73,"Programmer,Spiritualist":2} // with multiValuedGroups _.supergroup(bloggers, 'profession',{multiValuedGroups:true}).aggregates(_.sum, 'articlesPublished','dict'); // ==> {"Programmer":75,"Spiritualist":2}Como puede ver, con multiValuedGroup, la suma de todos los artículos publicados en la lista del grupo es mayor que el número total real de artículos publicados porque el registro de Sigfried se cuenta dos veces. A veces este es el comportamiento deseado.
Convertir tablas jerárquicas en árboles
Otra cosa que puede surgir ocasionalmente es una estructura tabular que representa un árbol a través de relaciones primarias/secundarias explícitas entre registros. Aquí hay un ejemplo de una pequeña taxonomía:
| pags | C |
|---|---|
| animal | mamífero |
| animal | reptil |
| animal | pez |
| animal | pájaro |
| planta | árbol |
| planta | hierba |
| árbol | roble |
| árbol | arce |
| roble | alfiler de roble |
| mamífero | primate |
| mamífero | bovino |
| bovino | vaca |
| bovino | buey |
| primate | mono |
| primate | mono |
| mono | chimpancé |
| mono | gorila |
| mono | me |
tree = _.hierarchicalTableToTree(taxonomy, 'p', 'c'); // top-level nodes ==> ["animal","plant"] _.invoke(tree.flattenTree(), 'namePath'); // call namePath on every node ==> ["animal", "animal/mammal", "animal/mammal/primate", "animal/mammal/primate/monkey", "animal/mammal/primate/ape", "animal/mammal/primate/ape/chimpanzee", "animal/mammal/primate/ape/gorilla", "animal/mammal/primate/ape/me", "animal/mammal/bovine", "animal/mammal/bovine/cow", "animal/mammal/bovine/ox", "animal/reptile", "animal/fish", "animal/bird", "plant", "plant/tree", "plant/tree/oak", "plant/tree/oak/pin oak", "plant/tree/maple", "plant/grass"]Conclusión
Así que ahí lo tenemos. He estado usando Supergroup en todos los proyectos de Javascript en los que he trabajado durante los últimos tres años. Sé que resuelve muchos problemas que surgen constantemente en la programación centrada en datos. La API y la implementación no son nada perfectas, y estaría encantado de encontrar colaboradores interesados en trabajar conmigo.
Después de un par de días de refactorización en ese proyecto de cliente, recibí un mensaje de Dave, el programador con el que estaba trabajando:
Dave: Debo decir que soy un gran admirador de los supergrupos. Está limpiando una tonelada.
Sigfried: Sí. Voy a pedir un testimonio en algún momento :).
Dave: Hah absolutamente.
Si le da una vuelta y surge alguna pregunta o problema, escriba una línea en la sección de comentarios o publique un problema en el repositorio de GitHub.
