Manipulação definitiva de coleta de dados na memória com Supergroup.js

Publicados: 2022-03-11

A manipulação de dados na memória geralmente resulta em uma pilha de código de espaguete. A manipulação em si pode ser bastante simples: agrupar, agregar, criar hierarquias e realizar cálculos; mas uma vez que o código de processamento de dados é escrito e os resultados são enviados para a parte do aplicativo onde eles são necessários, as necessidades relacionadas continuam a surgir. Uma transformação semelhante dos dados pode ser necessária em outra parte do aplicativo, ou mais detalhes podem ser necessários: metadados, contexto, dados pai ou filho, etc. Em aplicativos de visualização ou relatórios complexos, particularmente, depois de inserir dados em alguma estrutura para um dada a necessidade, percebe-se que dicas de ferramentas ou destaques ou detalhamentos sincronizados exercem pressões inesperadas sobre os dados transformados. Pode-se abordar esses requisitos:

  1. Encher mais detalhes e mais níveis nos dados transformados até que seja enorme e desajeitado, mas satisfaça as necessidades de todos os cantos e recantos do aplicativo que ele eventualmente visita.
  2. Escrevendo novas funções de transformação que precisam unir algum nó já processado à fonte de dados global para trazer novos detalhes.
  3. Projetar classes de objetos complexas que de alguma forma sabem como lidar com todos os contextos em que acabam.

Depois de construir software centrado em dados por 20 ou 30 anos como eu, começa-se a suspeitar que eles estão resolvendo o mesmo conjunto de problemas repetidamente. Trazemos loops complexos, compreensões de listas, funções analíticas de banco de dados, funções map ou groupBy ou até mesmo mecanismos de relatórios completos. À medida que nossas habilidades se desenvolvem, ficamos melhores em tornar qualquer pedaço de código de manipulação de dados inteligente e conciso, mas o espaguete ainda parece proliferar.

Neste artigo, veremos a biblioteca JavaScript Supergroup.js - equipada com algumas funções poderosas de manipulação, agrupamento e agregação de coleta de dados na memória - e como ela pode ajudá-lo a resolver alguns desafios comuns de manipulação em conjuntos de dados limitados.

O problema

Durante meu primeiro compromisso com a Toptal, fiquei convencido desde o primeiro dia de que a API e as rotinas de gerenciamento de dados da base de código que eu estava adicionando haviam sido excessivamente especificadas. Era um aplicativo D3.js para análise de dados de marketing. A aplicação já tinha uma atraente visualização de gráfico de barras agrupadas/empilhadas e exigia a construção de uma visualização de mapa coroplético. O gráfico de barras permitia ao usuário exibir 2, 3 ou 4 dimensões arbitrárias internamente chamadas x0, x1, y0 e y1, sendo x1 e y1 opcionais.

Supergroup.js - Total

Na construção de legendas, filtros, dicas de ferramentas, títulos e no cálculo de totais ou diferenças ano a ano, x0, x1, y0 e y1 foram referidos em todo o código, e ubíquamente em todo o código havia lógica condicional para lidar a presença ou ausência de dimensões opcionais.

Mas poderia ter sido pior. O código pode ter se referido diretamente a dimensões de dados subjacentes específicas (por exemplo, ano, orçamento, nível, categoria de produto, etc.) Em vez disso, foi pelo menos generalizado para as dimensões de exibição desse gráfico de barras agrupado/empilhado. Mas quando outro tipo de gráfico se tornou um requisito, onde as dimensões de x0, x1, y0 e y1 não fariam sentido, uma parte significativa do código teve que ser totalmente reescrita - código que lida com legendas, filtros, dicas de ferramentas, títulos , cálculos resumidos e construção e renderização de gráficos.

Ninguém quer dizer ao cliente: “Sei que é apenas meu primeiro dia aqui, mas antes de implementar o que você pediu, posso refatorar todo o código usando uma biblioteca de manipulação de dados Javascript que escrevi?” Por um golpe de sorte, fui salvo desse constrangimento quando fui apresentado a um programador cliente que estava prestes a refatorar o código de qualquer maneira. Com uma mente aberta e graça incomuns, o cliente me convidou para o processo de refatoração por meio de uma série de sessões de programação em pares. Ele estava disposto a dar uma chance ao Supergroup.js e, em poucos minutos, estávamos começando a substituir grandes porções de códigos retorcidos por pequenas chamadas incisivas para o Supergroup.

O que vimos no código foi típico dos emaranhados que surgem ao lidar com estruturas de dados hierárquicas ou agrupadas, principalmente em aplicativos D3, uma vez que ficam maiores que demos. Esses problemas surgem com aplicativos de relatórios em geral, em aplicativos CRUD que envolvem filtragem ou perfuração para telas ou registros específicos, em ferramentas de análise, ferramentas de visualização, praticamente qualquer aplicativo em que são usados ​​dados suficientes para exigir um banco de dados.

Manipulação na memória

Pegue uma API Rest para pesquisa facetada e operações CRUD, por exemplo, você pode acabar com uma ou mais chamadas de API para obter o conjunto de campos e valores (talvez com contagens de registros) para todos os parâmetros de pesquisa, outra chamada de API para obter um registro específico, e outras chamadas para obter grupos de registros para relatórios ou algo assim. Então, tudo isso provavelmente será complicado pela necessidade de impor filtros temporários com base na seleção ou nas permissões do usuário.

Se é improvável que seu banco de dados exceda dezenas ou centenas de milhares de registros, ou se você tiver maneiras fáceis de limitar o universo imediato de interesse a um conjunto de dados desse tamanho, provavelmente poderá descartar toda a sua complicada API Rest (exceto a parte de permissões ), e ter uma única chamada que diz “me dê todos os registros”. Estamos vivendo em um mundo com compactação rápida, velocidades de transferência rápidas, muita memória no front-end e mecanismos Javascript rápidos. O estabelecimento de esquemas de consulta complexos que precisam ser entendidos e mantidos pelo cliente e servidor geralmente é desnecessário. As pessoas escreveram bibliotecas para executar consultas SQL diretamente em coleções de registros JSON, porque na maioria das vezes você não precisa de toda a otimização de um RDBMS. Mas mesmo isso é exagero. Correndo o risco de soar insanamente grandioso, o Supergroup é mais fácil de usar e mais poderoso que o SQL na maioria das vezes.

Supergrupo é basicamente d3.nest, underscore.groupBy ou underscore.nest em esteróides. Sob o capô, ele usa groupBy do lodash para a operação de agrupamento. A estratégia central é transformar todos os dados originais em metadados e links para o restante da árvore imediatamente acessíveis em cada nó; e cada nó ou lista de nós é sobrecarregado com um bolo de casamento de açúcar sintático, de modo que quase tudo o que você deseja saber de qualquer lugar na árvore está disponível em uma expressão curta.

Supergrupo em ação

Para demonstrar alguma doçura sintática do Supergrupo, eu roubei uma cópia de Mister Nester de Shan Carter. Um aninhamento simples de dois níveis usando d3.nest se parece com:

 d3.nest() .key(function(d) { return d.year; }) .key(function(d) { return d.fips; }) .map(data);

O equivalente com Supergrupo seria:

 _.supergroup(data,['year','fips']).d3NestMap();

A chamada à direita para d3NestMap() apenas coloca a saída do Supergrupo no mesmo formato (mas não muito útil na minha opinião) que o nest.map() do d3:

 { "1970": { "6001": [ { "fips": "6001", "totalpop": "1073180", "pctHispanic": "0.126", "year": "1970" } ], "6003": [ { "fips": "6003", "totalpop": "510", "pctHispanic": "NA", "year": "1970" } ], ... } }

Digo “não muito útil” porque as seleções D3 precisam ser vinculadas a arrays, não a mapas. O que é um “nó” nesta estrutura de dados do mapa? “1970” ou “6001”, são apenas strings e chaves em um mapa de nível superior ou de segundo nível. Então, um nó seria o que as chaves apontam. “1970” aponta para um mapa de segundo nível, “6001” aponta para uma matriz de registros brutos. Este aninhamento de mapa é legível no console e bom para procurar valores, mas para chamadas D3 você precisa de dados de array, então você usa nest.entries() em vez 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" } ] }, ... ] }, ... ]

Agora temos matrizes aninhadas de pares chave/valor: o nó 1970 tem uma chave de “1970” e um valor que consiste em uma matriz de pares chave/valor de segundo nível. 6001 é outro par chave/valor. Sua chave também é uma string que a identifica, mas o valor é uma matriz de registros brutos. Temos que tratar esses nós de segundo nível de folha, bem como nós de nível de folha, de maneira diferente dos nós mais acima na árvore. E os próprios nós não contêm evidências de que “1970” seja um ano e “6001” seja um código fips, ou que 1970 seja o pai desse nó 6001 específico. Demonstrarei como o Supergrupo resolve esses problemas, mas primeiro dê uma olhada no valor de retorno imediato de uma chamada de Supergrupo. À primeira vista, é apenas uma matriz de “chaves” de nível superior:

 _.supergroup(data,['year','fips']); // [ 1970, 1980, 1990, 2000, 2010 ]

"Ok, isso é bom", você diz. “Mas onde está o resto dos dados?” As strings ou números na lista Supergroup são, na verdade, objetos String ou Number, sobrecarregados com mais propriedades e métodos. Para nós acima do nível folha, há uma propriedade child (“children” é o nome padrão, você pode chamá-lo de outra coisa) segurando outra lista de nós de segundo nível do Supergrupo:

 _.supergroup(data,['year','fips'])[0].children; // [ 6001, 6003, 6005, 6007, 6009, 6011, ... ] 

Função de dica de ferramenta que funciona

Para demonstrar outros recursos e como tudo isso funciona, vamos fazer uma lista aninhada simples usando D3 e ver como criamos uma função de dica de ferramenta útil que pode funcionar em qualquer nó da 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 função de dica de ferramenta funcionará para quase qualquer nó em qualquer profundidade. Como os nós no nível superior não têm pais, podemos fazer isso para contornar isso:

 var byYearFips = _.supergroup(data,['year','fips']); var root = byYearFips.asRootVal();

Agora temos um nó raiz que é pai de todos os nós Ano. Não precisamos fazer nada com isso, mas agora nossa dica de ferramenta funcionará porque node.parent tem algo para apontar. E node.path()[0] que deveria apontar para um nó que representa todo o conjunto de dados realmente faz.

Caso não tenha ficado óbvio nos exemplos acima, namePath, dimPath e path fornecem um caminho da raiz para o nó atual:

 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; // ==> true

Agregar no local quando precisar

O código da dica de ferramenta acima também usou o método “agregado”. “aggregate” é chamado em um único nó e leva dois parâmetros:

  1. Uma função de agregação que espera uma matriz (geralmente de números).
  2. Um nome de campo do campo a ser extraído dos registros agrupados sob esse nó ou uma função a ser aplicada a cada um desses registros.

Há também um método de conveniência “agregado” nas listas (a lista de grupos de nível superior ou os grupos filhos de qualquer nó). Ele pode retornar uma lista ou um 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}

Arrays que agem como mapas

Com d3.nest tendemos a usar .entries() em vez de .map(), como eu disse anteriormente, porque “maps” não permitem que você use toda a funcionalidade D3 (ou Underscore) que depende de arrays. Mas quando você usa .entries() para gerar arrays, você não pode fazer uma pesquisa simples por valor de chave. É claro que o Supergroup fornece o açúcar sintático que você deseja para que você não precise percorrer uma matriz inteira toda vez que desejar um único valor:

 _.supergroup(data,['year','fips']).lookup(1980); // ==> 1980 _.supergroup(data,['year','fips']).lookup([1980,6011]).namePath(); // ==> "1980/6011"

Comparando nós ao longo do tempo

Um método .previous() em nós permite acessar o nó anterior em uma lista de supergrupos. Você pode usar .sort( ) ou .sortBy( ) em uma lista de supergrupos (incluindo uma lista dos filhos de qualquer nó) para garantir que os nós estejam na ordem correta antes de chamar .previous(). Aqui está algum código para relatar a mudança ano a ano na população por região de fips:

 _.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)" }, ... }

Dados tabulares para layouts de hierarquia do D3.js

O Supergrupo faz muito mais do que mostrei aqui até agora. Para visualizações D3 baseadas em d3.layout.hierarchy, o código de exemplo na galeria D3 geralmente começa com os dados em um formato de árvore (este exemplo de mapa de árvore, por exemplo). O Supergrupo permite que você prepare facilmente dados tabulares para visualizações d3.layout.hierarchy (exemplo). Tudo o que você precisa é do nó raiz retornado por .asRootVal() e, em seguida, executar root.addRecordsAsChildrenToLeafNodes(). d3.layout.hierarchy espera que o nível inferior dos nós filhos seja uma matriz de registros brutos. addRecordsAsChildrenToLeafNodes pega os nós folha de uma árvore Supergroup e copia a matriz .records para uma propriedade .children. Não é a maneira como o Supergrupo geralmente gosta das coisas, mas funcionará bem para Treemaps, Clusters, Partições, etc. (d3.layout.hierarchy docs).

Assim como o método d3.layout.hierarchy.nodes que retorna todos os nós em uma árvore como um único array, o Supergroup fornece .descendants() para obter todos os nós começando de algum nó específico, .flattenTree() para obter todos os nós começando de uma lista regular de Supergrupos e .leafNodes() para obter apenas uma matriz dos nós folha.

Agrupando e agregando por campos de vários valores

Sem entrar em detalhes exaustivos, mencionarei que o Supergrupo possui alguns recursos para lidar com situações que ocorrem com menos frequência, mas geralmente o suficiente para merecer um tratamento especial.

Às vezes, você deseja agrupar por um campo que pode ter mais de um valor. Em campos relacionais ou tabulares, com vários valores geralmente não devem ocorrer (eles quebram a primeira forma normal), mas podem ser úteis. Veja como o Supergrupo lida com esse 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 você pode ver, com multiValuedGroup, a soma de todos os artigos publicados na lista de grupos é maior que o número total real de artigos publicados porque o registro Sigfried é contado duas vezes. Às vezes, esse é o comportamento desejado.

Transformando tabelas hierárquicas em árvores

Outra coisa que pode surgir ocasionalmente é uma estrutura tabular que representa uma árvore por meio de relacionamentos pai/filho explícitos entre os registros. Aqui está um exemplo de uma pequena taxonomia:

p c
animal mamífero
animal réptil
animal peixe
animal pássaro
plantar árvore
plantar Relva
árvore Carvalho
árvore bordo
Carvalho alfinete de carvalho
mamífero primata
mamífero bovino
bovino vaca
bovino boi
primata macaco
primata macaco
macaco chimpanzé
macaco gorila
macaco mim
 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"]

Conclusão

Então só temos isso. Eu tenho usado o Supergroup em todos os projetos Javascript em que trabalhei nos últimos três anos. Eu sei que isso resolve muitos problemas que surgem constantemente na programação centrada em dados. A API e a implementação não são nada perfeitas, e ficaria muito feliz em encontrar colaboradores interessados ​​em trabalhar comigo.

Após alguns dias de refatoração naquele projeto cliente, recebi uma mensagem de Dave, o programador com quem estava trabalhando:

Dave: Devo dizer que sou um grande fã de supergrupos. Está limpando uma tonelada.

Sigfried: Sim. Vou pedir um depoimento em algum momento :).

Dave: Ah, com certeza.

Se você tentar e surgir alguma dúvida ou problema, deixe uma linha na seção de comentários ou poste um problema no repositório do GitHub.