Verso grafici D3.js aggiornabili
Pubblicato: 2022-03-11introduzione
D3.js è una libreria open source per visualizzazioni di dati sviluppata da Mike Bostock. D3 sta per documenti basati sui dati e, come suggerisce il nome, la libreria consente agli sviluppatori di generare e manipolare facilmente elementi DOM in base ai dati. Sebbene non sia limitato dalle capacità della libreria, D3.js viene in genere utilizzato con elementi SVG e offre potenti strumenti per lo sviluppo da zero di visualizzazioni di dati vettoriali.
Cominciamo con un semplice esempio. Supponiamo che ti stai allenando per una gara di 5 km e desideri creare un grafico a barre orizzontale del numero di miglia che hai percorso ogni giorno dell'ultima settimana:
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');
Per vederlo in azione, dai un'occhiata su bl.ocks.org.
Se questo codice sembra familiare, è fantastico. In caso contrario, ho trovato i tutorial di Scott Murray un'ottima risorsa per iniziare con D3.js.
Come libero professionista che ha lavorato centinaia di ore di sviluppo con D3.js, il mio modello di sviluppo ha subito un'evoluzione, sempre con l'obiettivo finale di creare l'esperienza cliente e utente più completa. Come discuterò più dettagliatamente in seguito, il modello di Mike Bostock per i grafici riutilizzabili offriva un metodo collaudato per implementare lo stesso grafico in un numero qualsiasi di selezioni. Tuttavia, i suoi limiti si realizzano una volta inizializzato il grafico. Se volevo utilizzare le transizioni di D3 e aggiornare i modelli con questo metodo, le modifiche ai dati dovevano essere gestite interamente all'interno dello stesso ambito in cui era stato generato il grafico. In pratica, ciò significava implementare filtri, selezioni a discesa, dispositivi di scorrimento e opzioni di ridimensionamento all'interno dello stesso ambito di funzione.
Dopo aver sperimentato ripetutamente queste limitazioni in prima persona, ho voluto creare un modo per sfruttare tutta la potenza di D3.js. Ad esempio, ascoltare le modifiche su un menu a discesa di un componente completamente separato e attivare senza problemi gli aggiornamenti dei grafici dai vecchi dati a quelli nuovi. Volevo essere in grado di trasferire i controlli del grafico con tutte le funzionalità e farlo in un modo logico e modulare. Il risultato è un pattern grafico aggiornabile, e ho intenzione di seguire la mia completa progressione verso la creazione di questo pattern.
Progressione del modello dei grafici D3.js
Passaggio 1: variabili di configurazione
Quando ho iniziato a utilizzare D3.js per sviluppare visualizzazioni, è diventato molto conveniente utilizzare le variabili di configurazione per definire e modificare rapidamente le specifiche di un grafico. Ciò ha consentito ai miei grafici di gestire tutte le diverse lunghezze e valori dei dati. Lo stesso pezzo di codice che mostrava le miglia percorse ora poteva visualizzare un elenco più lungo di temperature senza alcun singhiozzo:
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');
Per vederlo in azione, dai un'occhiata su bl.ocks.org.
Nota come le altezze e le larghezze delle barre vengono ridimensionate in base sia alle dimensioni che ai valori dei dati. Una variabile viene modificata e il resto viene curato.
Passaggio 2: facile ripetizione tramite funzioni
Astraendo parte della logica aziendale, siamo in grado di creare codice più versatile pronto per gestire un modello generalizzato di dati. Il passaggio successivo consiste nel racchiudere questo codice in una funzione di generazione, che riduce l'inizializzazione a una sola riga. La funzione accetta tre argomenti: i dati, una destinazione DOM e un oggetto opzioni che può essere utilizzato per sovrascrivere le variabili di configurazione predefinite. Dai un'occhiata a come questo può essere fatto:
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);
Per vederlo in azione, dai un'occhiata su bl.ocks.org.
È anche importante prendere nota delle selezioni di D3.js in questo contesto. Le selezioni generali come d3.selectAll('rect')
dovrebbero essere sempre evitate. Se gli SVG sono presenti da qualche altra parte nella pagina, tutti i rect
nella pagina diventano parte della selezione. Invece, usando il riferimento DOM passato, crea un oggetto svg
a cui puoi fare riferimento quando aggiungi e aggiorni elementi. Questa tecnica può anche migliorare il runtime di generazione del grafico, poiché l'utilizzo di un riferimento come le barre evita anche di dover effettuare nuovamente la selezione di D3.js.
Passaggio 3: concatenamento e selezioni di metodi
Mentre lo scheletro precedente che utilizzava gli oggetti di configurazione è molto comune nelle librerie JavaScript, Mike Bostock, il creatore di D3.js, consiglia un altro modello per la creazione di grafici riutilizzabili. In breve, Mike Bostock consiglia di implementare i grafici come chiusure con metodi getter-setter. Pur aggiungendo una certa complessità all'implementazione del grafico, l'impostazione delle opzioni di configurazione diventa molto semplice per il chiamante semplicemente usando il concatenamento dei metodi:
// 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);
Per vederlo in azione, dai un'occhiata su bl.ocks.org.
L'inizializzazione del grafico utilizza la selezione D3.js, legando i dati rilevanti e passando la selezione DOM come this
contesto alla funzione generatore. La funzione del generatore racchiude le variabili predefinite in una chiusura e consente al chiamante di modificarle tramite il concatenamento di metodi con funzioni di configurazione che restituiscono l'oggetto grafico. In questo modo, il chiamante può eseguire il rendering dello stesso grafico su più selezioni alla volta o utilizzare un grafico per eseguire il rendering dello stesso grafico su selezioni diverse con dati diversi, il tutto evitando di passare intorno a un ingombrante oggetto opzioni.
Passaggio 4: un nuovo modello per i grafici aggiornabili
Il modello precedente suggerito da Mike Bostock ci dà, come sviluppatori di grafici, molta potenza all'interno della funzione del generatore. Dato un set di dati e tutte le configurazioni concatenate passate, controlliamo tutto da lì. Se i dati devono essere modificati dall'interno, possiamo utilizzare le transizioni appropriate invece di ridisegnare da zero. Anche cose come il ridimensionamento delle finestre possono essere gestite in modo elegante, creando funzionalità reattive come l'utilizzo di testo abbreviato o la modifica delle etichette degli assi.

Ma cosa succede se i dati vengono modificati dall'esterno dell'ambito della funzione del generatore? O cosa succede se il grafico deve essere ridimensionato a livello di codice? Potremmo semplicemente richiamare di nuovo la funzione del grafico, con i nuovi dati e la nuova configurazione delle dimensioni. Tutto sarebbe stato ridisegnato e voilà. Problema risolto.
Sfortunatamente, ci sono una serie di problemi con questa soluzione.
Prima di tutto, stiamo quasi inevitabilmente eseguendo calcoli di inizializzazione non necessari. Perché manipolare dati complessi quando tutto ciò che dobbiamo fare è ridimensionare la larghezza? Questi calcoli possono essere necessari la prima volta che viene inizializzato un grafico, ma certamente non ad ogni aggiornamento che dobbiamo fare. Ogni richiesta programmatica richiede alcune modifiche e, in quanto sviluppatori, sappiamo esattamente quali sono queste modifiche. Ne più ne meno. Inoltre, nell'ambito del grafico, abbiamo già accesso a molte cose di cui abbiamo bisogno (oggetti SVG, stati dei dati correnti e altro) che semplificano l'implementazione delle modifiche.
Prendi ad esempio l'esempio del grafico a barre sopra. Se volessimo aggiornare la larghezza, e lo facessimo ridisegnando l'intero grafico, attiveremmo molti calcoli non necessari: trovare il valore massimo dei dati, calcolare l'altezza della barra e renderizzare tutti questi elementi SVG. In realtà, una volta che la width
è stata assegnata al suo nuovo valore, le uniche modifiche che dobbiamo apportare sono:
width = newWidth; widthScale = width / maxValue; bars.attr('width', function(d) { return d*widthScale}); svg.attr('width', width);
Ma migliora ancora. Dato che ora abbiamo un po' di storia del grafico, possiamo usare le transizioni integrate di D3 per aggiornare i nostri grafici e animarli facilmente. Continuando con l'esempio sopra, aggiungere una transizione sulla width
è semplice come cambiare
bars.attr('width', function(d) { return d*widthScale});
a
bars.transition().duration(1000).attr('width', function(d) { return d*widthScale});
Ancora meglio, se consentiamo a un utente di trasferire un nuovo set di dati, possiamo utilizzare le selezioni di aggiornamento di D3 (invio, aggiornamento e uscita) per applicare anche le transizioni ai nuovi dati. Ma come consentiamo nuovi dati? Se ricordi, la nostra precedente implementazione ha creato un nuovo grafico come questo:
d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
Abbiamo associato i dati a una selezione D3.js e chiamato il nostro grafico riutilizzabile. Eventuali modifiche ai dati dovrebbero essere apportate associando nuovi dati alla stessa selezione. In teoria, potremmo utilizzare il vecchio modello e sondare la selezione per i dati esistenti, quindi aggiornare i nostri risultati con i nuovi dati. Non solo questo è disordinato e complicato da implementare, ma richiederebbe il presupposto che il grafico esistente fosse dello stesso tipo e forma.
Invece, con alcune modifiche alla struttura della funzione del generatore di JavaScript, possiamo creare un grafico che consentirà al chiamante di richiedere facilmente modifiche esternamente tramite il concatenamento di metodi. Mentre prima che la configurazione e i dati fossero impostati e quindi lasciati inalterati, il chiamante ora può fare qualcosa del genere, anche dopo che il grafico è stato inizializzato:
weatherChart.width(420);
Il risultato è una transizione graduale a una nuova larghezza dal grafico esistente. Senza calcoli inutili e con transizioni eleganti, il risultato è un cliente felice.
Questa funzionalità extra viene fornita con un leggero aumento dello sforzo degli sviluppatori. Uno sforzo, tuttavia, che ho ritenuto valere la pena storicamente. Ecco uno scheletro del grafico aggiornabile:
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; }
Per vedere completamente implementato, dai un'occhiata su blocks.org.
Esaminiamo la nuova struttura. Il più grande cambiamento rispetto alla precedente implementazione della chiusura è l'aggiunta di funzioni di aggiornamento. Come discusso in precedenza, queste funzioni sfruttano le transizioni di D3.js e i modelli di aggiornamento per apportare senza problemi le modifiche necessarie in base ai nuovi dati o alle configurazioni dei grafici. Per renderli accessibili al chiamante, le funzioni vengono aggiunte come proprietà al grafico. E per renderlo ancora più semplice, sia la configurazione iniziale che gli aggiornamenti vengono gestiti tramite la stessa funzione:
chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; };
Si noti che updateWidth
non sarà definito fino a quando il grafico non sarà stato inizializzato. Se è undefined
, la variabile di configurazione verrà impostata globalmente e utilizzata nella chiusura del grafico. Se la funzione del grafico è stata chiamata, tutte le transizioni vengono trasferite alla funzione updateWidth
, che utilizza la variabile di width
modificata per apportare le modifiche necessarie. Qualcosa come questo:
updateWidth = function() { widthScale = width / maxValue; bars.transition().duration(1000).attr('width', function(d) { return d*widthScale}); svg.transition().duration(1000).attr('width', width); };
Con questa nuova struttura, i dati per il grafico vengono passati attraverso il concatenamento di metodi proprio come qualsiasi altra variabile di configurazione, invece di associarli a una selezione D3.js. La differenza:
var weatherChart = barChart(); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
che diventa:
var weatherChart = barChart().data(highTemperatures); d3.select('#weatherHistory') .call(weatherChart);
Quindi abbiamo apportato alcune modifiche e aggiunto un po' di impegno da parte degli sviluppatori, vediamo i vantaggi.
Supponiamo che tu abbia una nuova richiesta di funzionalità: "Aggiungi un menu a discesa in modo che l'utente possa passare da alte temperature a basse temperature. E fai anche il cambio di colore mentre ci sei. Invece di cancellare il grafico corrente, associare i nuovi dati e ridisegnare da zero, ora puoi effettuare una semplice chiamata quando è selezionata la bassa temperatura:
weatherChart.data(lowTemperatures).fillColor('blue');
e goditi la magia. Non solo salviamo i calcoli, ma aggiungiamo un nuovo livello di comprensione alla visualizzazione mentre si aggiorna, cosa che prima non era possibile.
Qui è necessaria un'importante parola di cautela sulle transizioni. Fai attenzione quando pianifichi più transizioni sullo stesso elemento. L'avvio di una nuova transizione annullerà implicitamente tutte le transizioni in esecuzione in precedenza. Ovviamente, più attributi o stili possono essere modificati su un elemento in una transizione avviata da D3.js, ma mi sono imbattuto in alcuni casi in cui più transizioni vengono attivate contemporaneamente. In questi casi, considera l'utilizzo di transizioni simultanee sugli elementi padre e figlio durante la creazione delle funzioni di aggiornamento.
Un cambio di filosofia
Mike Bostock introduce le chiusure come un modo per incapsulare la generazione dei grafici. Il suo modello è ottimizzato per creare lo stesso grafico con dati diversi in molti luoghi. Negli anni in cui ho lavorato con D3.js, tuttavia, ho riscontrato una leggera differenza nelle priorità. Invece di utilizzare un'istanza di un grafico per creare la stessa visualizzazione con dati diversi, il nuovo modello che ho introdotto consente al chiamante di creare facilmente più istanze di un grafico, ognuna delle quali può essere completamente modificata anche dopo l'inizializzazione. Inoltre, ciascuno di questi aggiornamenti viene gestito con pieno accesso allo stato corrente del grafico, consentendo allo sviluppatore di eliminare i calcoli non necessari e sfruttare la potenza di D3.js per creare esperienze utente e client più fluide.