Către diagrame D3.js actualizabile

Publicat: 2022-03-11

Introducere

D3.js este o bibliotecă open source pentru vizualizări de date dezvoltată de Mike Bostock. D3 înseamnă documente bazate pe date și, așa cum sugerează și numele, biblioteca permite dezvoltatorilor să genereze și să manipuleze cu ușurință elemente DOM pe baza datelor. Deși nu este limitat de capacitățile bibliotecii, D3.js este folosit de obicei cu elemente SVG și oferă instrumente puternice pentru dezvoltarea vizualizărilor de date vectoriale de la zero.

Modelul de diagramă actualizabil permite simplificarea diagramelor D3.js
Tweet

Să începem cu un exemplu simplu. Să presupunem că te antrenezi pentru o cursă de 5 km și vrei să faci o diagramă cu bare orizontale cu numărul de mile pe care i-ai parcurs în fiecare zi a ultimei săptămâni:

 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');

Pentru a-l vedea în acțiune, verificați-l pe bl.ocks.org.

Dacă acest cod pare familiar, este grozav. Dacă nu, tutorialele lui Scott Murray sunt o resursă excelentă pentru a începe cu D3.js.

Ca freelancer care a lucrat sute de ore la dezvoltarea cu D3.js, modelul meu de dezvoltare a trecut printr-o evoluție, întotdeauna cu scopul final de a crea cele mai cuprinzătoare experiențe pentru clienți și utilizatori. După cum voi discuta mai detaliat mai târziu, modelul lui Mike Bostock pentru diagrame reutilizabile a oferit o metodă încercată și adevărată pentru implementarea aceleiași diagrame în orice număr de selecții. Cu toate acestea, limitările sale sunt realizate odată ce diagrama este inițializată. Dacă doream să folosesc tranzițiile D3 și modelele de actualizare cu această metodă, modificările datelor trebuiau tratate în întregime în același domeniu în care a fost generat graficul. În practică, aceasta a însemnat implementarea filtrelor, selectărilor drop-down, glisoarelor și opțiunilor de redimensionare, toate în cadrul aceluiași domeniu de activitate.

După ce am experimentat în mod repetat aceste limitări direct, am vrut să creez o modalitate de a profita de toată puterea D3.js. De exemplu, ascultați modificările dintr-un meniu derulant al unei componente complet separate și declanșați fără probleme actualizările diagramelor de la datele vechi la cele noi. Am vrut să pot preda controalele diagramei cu funcționalitate completă și să fac acest lucru într-un mod logic și modular. Rezultatul este un model de diagramă actualizabil și voi trece prin progresul meu complet până la crearea acestui model.

Progresia modelului diagramelor D3.js

Pasul 1: Variabile de configurare

Pe măsură ce am început să folosesc D3.js pentru a dezvolta vizualizări, a devenit foarte convenabil să folosesc variabilele de configurare pentru a defini și modifica rapid specificațiile unei diagrame. Acest lucru a permis graficelor mele să gestioneze toate lungimile și valorile diferite ale datelor. Aceeași bucată de cod care a afișat kilometri alergați ar putea afișa acum o listă mai lungă de temperaturi fără niciun sughiț:

 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');

Pentru a-l vedea în acțiune, verificați-l pe bl.ocks.org.

Observați modul în care înălțimile și lățimile barelor sunt scalate în funcție atât de dimensiunea, cât și de valorile datelor. O variabilă este schimbată, iar restul este îngrijit.

Pasul 2: Repetiție ușoară prin funcții

Codarea nu ar trebui să fie niciodată un exercițiu de copy-paste

Codarea nu ar trebui să fie niciodată un exercițiu de copy-paste
Tweet

Abstragând o parte din logica de afaceri, putem crea un cod mai versatil, care este gata să gestioneze un șablon generalizat de date. Următorul pas este să împachetați acest cod într-o funcție de generare, care reduce inițializarea la o singură linie. Funcția are trei argumente: datele, o țintă DOM și un obiect opțiuni care poate fi folosit pentru a suprascrie variabilele de configurare implicite. Uitați-vă la cum se poate face acest lucru:

 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);

Pentru a-l vedea în acțiune, verificați-l pe bl.ocks.org.

De asemenea, este important să faceți o notă despre selecțiile D3.js în acest context. Selecții generale precum d3.selectAll('rect') ar trebui să fie întotdeauna evitate. Dacă SVG-urile sunt prezente în altă parte a paginii, toate rect de pe pagină devin parte a selecției. În schimb, folosind referința DOM transmisă, creați un obiect svg la care vă puteți referi atunci când adăugați și actualizați elemente. Această tehnică poate îmbunătăți, de asemenea, timpul de execuție al generării diagramelor, deoarece utilizarea unei referințe precum barele previne, de asemenea, nevoia de a face din nou selecția D3.js.

Pasul 3: Metoda de înlănțuire și selecții

În timp ce scheletul anterior care utilizează obiecte de configurare este foarte comun în bibliotecile JavaScript, Mike Bostock, creatorul D3.js, recomandă un alt model pentru crearea diagramelor reutilizabile. Pe scurt, Mike Bostock recomandă implementarea graficelor ca închideri cu metode getter-setter. În timp ce adaugă o oarecare complexitate implementării diagramei, setarea opțiunilor de configurare devine foarte simplă pentru apelant prin simpla utilizare a metodei înlănțuirii:

 // 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);

Pentru a-l vedea în acțiune, verificați-l pe bl.ocks.org.

Inițializarea diagramei folosește selecția D3.js, legând datele relevante și trecând selecția DOM this context în funcția de generator. Funcția de generator include variabilele implicite într-o închidere și permite apelantului să le schimbe prin înlănțuirea metodei cu funcții de configurare care returnează obiectul diagramă. Făcând acest lucru, apelantul poate reda aceeași diagramă la mai multe selecții simultan sau poate folosi o diagramă pentru a reda același grafic la diferite selecții cu date diferite, evitând în același timp trecerea în jurul unui obiect de opțiuni voluminos.

Pasul 4: Un nou model pentru diagrame actualizabile

Modelul anterior sugerat de Mike Bostock ne oferă, în calitate de dezvoltatori de diagrame, multă putere în funcția de generator. Având în vedere un set de date și orice configurație înlănțuită transmisă, controlăm totul de acolo. Dacă datele trebuie modificate din interior, putem folosi tranzițiile adecvate în loc să redesenăm doar de la zero. Chiar și lucruri precum redimensionarea ferestrelor pot fi gestionate elegant, creând caracteristici receptive, cum ar fi utilizarea textului abreviat sau schimbarea etichetelor axelor.

Dar ce se întâmplă dacă datele sunt modificate din afara domeniului de aplicare al funcției de generator? Sau ce se întâmplă dacă diagrama trebuie redimensionată programatic? Am putea apela din nou funcția diagramă, cu noile date și noua configurație de dimensiune. Totul ar fi redesenat și voila. Problema rezolvata.

Din păcate, există o serie de probleme cu această soluție.

În primul rând, efectuăm aproape inevitabil calcule de inițializare inutile. De ce facem manipularea complexă a datelor când tot ce trebuie să facem este să mărim lățimea? Aceste calcule pot fi necesare prima dată când o diagramă este inițializată, dar cu siguranță nu pentru fiecare actualizare pe care trebuie să o facem. Fiecare solicitare programatică necesită unele modificări și, în calitate de dezvoltatori, știm exact care sunt aceste modificări. Nici mai mult nici mai puțin. Mai mult, în domeniul diagramei, avem deja acces la o mulțime de lucruri de care avem nevoie (obiecte SVG, stări curente ale datelor și multe altele) făcând modificări ușor de implementat.

Luați, de exemplu, exemplul de diagramă cu bare de mai sus. Dacă am dori să actualizăm lățimea și am face acest lucru prin redesenarea întregii diagrame, am declanșa o mulțime de calcule inutile: găsirea valorii maxime a datelor, calcularea înălțimii barei și redarea tuturor acestor elemente SVG. Într-adevăr, odată ce width este atribuită noii sale valori, singurele modificări pe care trebuie să le facem sunt:

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

Dar devine și mai bine. Deoarece acum avem ceva istoric al diagramei, putem folosi tranzițiile integrate din D3 pentru a ne actualiza diagramele și a le anima cu ușurință. Continuând cu exemplul de mai sus, adăugarea unei tranziții pe width este la fel de simplă ca schimbarea

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

la

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

Și mai bine, dacă permitem unui utilizator să treacă un nou set de date, putem folosi selecțiile de actualizare ale D3 (intrare, actualizare și ieșire) pentru a aplica și tranziții la date noi. Dar cum permitem noi date? Dacă vă amintiți, implementarea noastră anterioară a creat o nouă diagramă ca aceasta:

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

Am legat datele la o selecție D3.js și am numit diagrama noastră reutilizabilă. Orice modificare a datelor ar trebui făcută prin legarea de date noi la aceeași selecție. Teoretic, am putea folosi modelul vechi și am putea analiza selecția pentru datele existente și apoi să ne actualizăm constatările cu noile date. Nu numai că este dezordonat și complicat de implementat, dar ar necesita presupunerea că diagrama existentă ar fi de același tip și formă.

În schimb, cu unele modificări aduse structurii funcției de generare JavaScript, putem crea o diagramă care va permite apelantului să solicite cu ușurință modificări externe prin înlănțuirea metodei. În timp ce înainte ca configurația și datele să fie setate și apoi lăsate neatinse, apelantul poate acum să facă ceva de genul acesta, chiar și după inițializarea diagramei:

 weatherChart.width(420);

Rezultatul este o tranziție lină la o nouă lățime din diagrama existentă. Fără calcule inutile și cu tranziții elegante, rezultatul este un client fericit.

Fără calcule inutile + tranziții elegante = client fericit

Fără calcule inutile + tranziții elegante = client fericit
Tweet

Această funcționalitate suplimentară vine cu o ușoară creștere a efortului dezvoltatorului. Un efort, însă, despre care am găsit că merită din punct de vedere istoric timpul. Iată un schelet al diagramei actualizabile:

 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; }

Pentru a vedea implementat complet, verificați-l pe bl.ocks.org.

Să revizuim noua structură. Cea mai mare schimbare față de implementarea anterioară a închiderii este adăugarea de funcții de actualizare. După cum sa discutat anterior, aceste funcții folosesc tranzițiile D3.js și modelele de actualizare pentru a face fără probleme orice modificări necesare pe baza unor noi date sau configurații ale diagramelor. Pentru a le face accesibile apelantului, funcțiile sunt adăugate ca proprietăți în diagramă. Și pentru a face și mai ușor încă, atât configurația inițială, cât și actualizările sunt gestionate prin aceeași funcție:

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

Rețineți că updateWidth nu va fi definit până când diagrama nu a fost inițializată. Dacă este undefined , atunci variabila de configurare va fi setată global și utilizată în închiderea diagramei. Dacă funcția diagramă a fost apelată, atunci toate tranzițiile sunt transmise funcției updateWidth , care utilizează variabila de width modificată pentru a face modificările necesare. Ceva de genul:

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

Cu această nouă structură, datele pentru diagramă sunt transmise prin înlănțuirea metodei la fel ca orice altă variabilă de configurare, în loc să le lege la o selecție D3.js. Diferența:

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

care devine:

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

Așa că am făcut câteva modificări și am adăugat un pic de efort pentru dezvoltatori, să vedem beneficiile.

Să presupunem că aveți o nouă solicitare de funcție: „Adăugați un meniu derulant, astfel încât utilizatorul să poată schimba între temperaturi ridicate și temperaturi scăzute. Și faceți și culoarea să se schimbe în timp ce sunteți la asta.” În loc să ștergeți diagrama curentă, să legați noile date și să redesenați de la zero, acum puteți efectua un apel simplu când este selectată temperatura scăzută:

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

și bucurați-vă de magie. Nu numai că salvăm calculele, dar adăugăm un nou nivel de înțelegere vizualizării pe măsură ce se actualizează, ceea ce nu era posibil înainte.

Aici este nevoie de un cuvânt de precauție important despre tranziții. Aveți grijă când programați mai multe tranziții pe același element. Începerea unei noi tranziții va anula implicit toate tranzițiile care au fost executate anterior. Desigur, mai multe atribute sau stiluri pot fi modificate pe un element dintr-o tranziție inițiată de D3.js, dar am întâlnit câteva cazuri în care mai multe tranziții sunt declanșate simultan. În aceste cazuri, luați în considerare utilizarea tranzițiilor concurente pe elementele părinte și secundare atunci când creați funcțiile de actualizare.

O schimbare în filozofie

Mike Bostock introduce închiderile ca o modalitate de a încapsula generarea de diagrame. Modelul său este optimizat pentru a crea aceeași diagramă cu date diferite în multe locuri. În anii în care am lucrat cu D3.js, totuși, am găsit o mică diferență în priorități. În loc să folosească o singură instanță a unei diagrame pentru a crea aceeași vizualizare cu date diferite, noul model pe care l-am introdus permite apelantului să creeze cu ușurință mai multe instanțe ale unei diagrame, fiecare dintre acestea putând fi modificată complet chiar și după inițializare. În plus, fiecare dintre aceste actualizări este gestionată cu acces deplin la starea actuală a diagramei, permițând dezvoltatorului să elimine calculele inutile și să valorifice puterea D3.js pentru a crea experiențe mai simple pentru utilizatori și clienți.