Auf dem Weg zu aktualisierbaren D3.js-Diagrammen
Veröffentlicht: 2022-03-11Einführung
D3.js ist eine von Mike Bostock entwickelte Open-Source-Bibliothek für Datenvisualisierungen. D3 steht für datengesteuerte Dokumente, und wie der Name schon sagt, ermöglicht die Bibliothek Entwicklern, DOM-Elemente auf der Grundlage von Daten einfach zu generieren und zu manipulieren. Obwohl nicht durch die Fähigkeiten der Bibliothek eingeschränkt, wird D3.js normalerweise mit SVG-Elementen verwendet und bietet leistungsstarke Tools für die Entwicklung von Vektordatenvisualisierungen von Grund auf neu.
Beginnen wir mit einem einfachen Beispiel. Angenommen, Sie trainieren für ein 5-km-Rennen und möchten ein horizontales Balkendiagramm der Anzahl der Meilen erstellen, die Sie jeden Tag der letzten Woche gelaufen sind:
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');
Um es in Aktion zu sehen, schau es dir auf bl.ocks.org an.
Wenn Ihnen dieser Code bekannt vorkommt, ist das großartig. Wenn nicht, fand ich die Tutorials von Scott Murray eine ausgezeichnete Ressource für den Einstieg in D3.js.
Als Freiberufler, der Hunderte von Stunden an der Entwicklung mit D3.js gearbeitet hat, hat sich mein Entwicklungsmuster weiterentwickelt, immer mit dem Endziel, die umfassendsten Kunden- und Benutzererlebnisse zu schaffen. Wie ich später ausführlicher erörtern werde, bot Mike Bostocks Muster für wiederverwendbare Diagramme eine erprobte und wahre Methode, um dasselbe Diagramm in einer beliebigen Anzahl von Auswahlen zu implementieren. Die Einschränkungen werden jedoch erkannt, sobald das Diagramm initialisiert ist. Wenn ich die Übergänge und Aktualisierungsmuster von D3 mit dieser Methode verwenden wollte, mussten Änderungen an den Daten vollständig im selben Bereich behandelt werden, in dem das Diagramm generiert wurde. In der Praxis bedeutete dies die Implementierung von Filtern, Dropdown-Auswahlen, Schiebereglern und Größenänderungsoptionen, alle innerhalb desselben Funktionsumfangs.
Nachdem ich diese Einschränkungen wiederholt aus erster Hand erfahren hatte, wollte ich eine Möglichkeit schaffen, die volle Leistungsfähigkeit von D3.js zu nutzen. Zum Beispiel auf Änderungen in einem Dropdown einer völlig separaten Komponente lauschen und Diagrammaktualisierungen nahtlos von alten Daten auf neue auslösen. Ich wollte die Chart Controls mit voller Funktionalität übergeben können, und zwar logisch und modular. Das Ergebnis ist ein aktualisierbares Diagrammmuster, und ich werde meinen vollständigen Fortschritt zur Erstellung dieses Musters durchgehen.
D3.js-Diagrammmusterverlauf
Schritt 1: Konfigurationsvariablen
Als ich anfing, D3.js zum Entwickeln von Visualisierungen zu verwenden, wurde es sehr praktisch, Konfigurationsvariablen zu verwenden, um die Spezifikationen eines Diagramms schnell zu definieren und zu ändern. Dadurch konnten meine Diagramme alle unterschiedlichen Längen und Werte von Daten verarbeiten. Derselbe Codeabschnitt, der die gelaufenen Meilen anzeigte, könnte jetzt eine längere Liste von Temperaturen ohne Schluckauf anzeigen:
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');
Um es in Aktion zu sehen, schau es dir auf bl.ocks.org an.
Beachten Sie, wie die Höhen und Breiten der Balken basierend auf der Größe und den Werten der Daten skaliert werden. Eine Variable wird geändert, und der Rest wird erledigt.
Schritt 2: Einfache Wiederholung durch Funktionen
Indem wir einen Teil der Geschäftslogik abstrahieren, können wir vielseitigeren Code erstellen, der bereit ist, eine verallgemeinerte Datenvorlage zu verarbeiten. Der nächste Schritt besteht darin, diesen Code in eine Generierungsfunktion zu verpacken, wodurch die Initialisierung auf nur eine Zeile reduziert wird. Die Funktion akzeptiert drei Argumente: die Daten, ein DOM-Ziel und ein Optionsobjekt, das zum Überschreiben von Standardkonfigurationsvariablen verwendet werden kann. Schauen Sie sich an, wie dies geschehen kann:
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);
Um es in Aktion zu sehen, schau es dir auf bl.ocks.org an.
Es ist auch wichtig, in diesem Zusammenhang eine Anmerkung zur D3.js-Auswahl zu machen. Allgemeine Auswahlen wie d3.selectAll('rect')
sollten immer vermieden werden. Wenn SVGs an anderer Stelle auf der Seite vorhanden sind, werden alle rect
auf der Seite Teil der Auswahl. Erstellen Sie stattdessen mithilfe der übergebenen DOM-Referenz ein svg
Objekt, auf das Sie beim Anhängen und Aktualisieren von Elementen verweisen können. Diese Technik kann auch die Laufzeit der Diagrammerstellung verbessern, da die Verwendung einer Referenz wie Balken auch verhindert, dass die D3.js-Auswahl erneut vorgenommen werden muss.
Schritt 3: Methodenverkettung und Auswahl
Während das vorherige Gerüst, das Konfigurationsobjekte verwendet, in JavaScript-Bibliotheken sehr verbreitet ist, empfiehlt Mike Bostock, der Schöpfer von D3.js, ein anderes Muster zum Erstellen wiederverwendbarer Diagramme. Kurz gesagt empfiehlt Mike Bostock, Diagramme als Closures mit Getter-Setter-Methoden zu implementieren. Während die Diagrammimplementierung etwas komplexer wird, wird das Festlegen von Konfigurationsoptionen für den Aufrufer sehr einfach, indem einfach Methodenverkettung verwendet wird:
// 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);
Um es in Aktion zu sehen, schau es dir auf bl.ocks.org an.
Die Diagramminitialisierung verwendet die D3.js-Auswahl, bindet die relevanten Daten und übergibt die DOM-Auswahl als this
-Kontext an die Generatorfunktion. Die Generatorfunktion umschließt Standardvariablen mit einem Abschluss und ermöglicht dem Aufrufer, diese durch Methodenverkettung mit Konfigurationsfunktionen zu ändern, die das Diagrammobjekt zurückgeben. Auf diese Weise kann der Aufrufer das gleiche Diagramm für mehrere Auswahlen gleichzeitig rendern oder ein Diagramm verwenden, um das gleiche Diagramm für verschiedene Auswahlen mit unterschiedlichen Daten zu rendern, ohne ein sperriges Optionsobjekt herumreichen zu müssen.
Schritt 4: Ein neues Muster für aktualisierbare Diagramme
Das vorherige Muster, das von Mike Bostock vorgeschlagen wurde, gibt uns als Diagrammentwickler viel Kraft innerhalb der Generatorfunktion. Ausgehend von einem Datensatz und allen übergebenen verketteten Konfigurationen steuern wir alles von dort aus. Wenn Daten von innen geändert werden müssen, können wir geeignete Übergänge verwenden, anstatt nur von Grund auf neu zu zeichnen. Sogar Dinge wie die Größenänderung von Fenstern können elegant gehandhabt werden, indem reaktionsschnelle Funktionen wie die Verwendung von abgekürztem Text oder das Ändern von Achsenbeschriftungen erstellt werden.

Was aber, wenn die Daten von außerhalb des Bereichs der Generatorfunktion geändert werden? Oder was ist, wenn die Größe des Diagramms programmgesteuert geändert werden muss? Wir könnten die Diagrammfunktion einfach erneut aufrufen, mit den neuen Daten und der neuen Größenkonfiguration. Alles würde neu gezeichnet, und voila. Problem gelöst.
Leider gibt es bei dieser Lösung eine Reihe von Problemen.
Zunächst einmal führen wir fast zwangsläufig unnötige Initialisierungsberechnungen durch. Warum komplexe Datenmanipulation, wenn wir nur die Breite skalieren müssen? Diese Berechnungen können erforderlich sein, wenn ein Diagramm zum ersten Mal initialisiert wird, aber sicherlich nicht bei jeder Aktualisierung, die wir vornehmen müssen. Jede programmatische Anfrage erfordert einige Änderungen, und als Entwickler wissen wir genau, was diese Änderungen sind. Nicht mehr und nicht weniger. Darüber hinaus haben wir innerhalb des Diagrammbereichs bereits Zugriff auf viele Dinge, die wir benötigen (SVG-Objekte, aktuelle Datenzustände und mehr), sodass Änderungen einfach implementiert werden können.
Nehmen Sie zum Beispiel das obige Balkendiagrammbeispiel. Wenn wir die Breite aktualisieren wollten und dies durch Neuzeichnen des gesamten Diagramms tun würden, würden wir viele unnötige Berechnungen auslösen: das Finden des maximalen Datenwerts, das Berechnen der Balkenhöhe und das Rendern all dieser SVG-Elemente. Sobald die width
ihrem neuen Wert zugewiesen ist, müssen wir nur noch folgende Änderungen vornehmen:
width = newWidth; widthScale = width / maxValue; bars.attr('width', function(d) { return d*widthScale}); svg.attr('width', width);
Aber es kommt noch besser. Da wir jetzt einen gewissen Verlauf des Diagramms haben, können wir die in D3 integrierten Übergänge verwenden, um unsere Diagramme zu aktualisieren und sie einfach zu animieren. Um mit dem obigen Beispiel fortzufahren, ist das Hinzufügen eines Übergangs zur width
so einfach wie das Ändern
bars.attr('width', function(d) { return d*widthScale});
zu
bars.transition().duration(1000).attr('width', function(d) { return d*widthScale});
Noch besser: Wenn wir einem Benutzer erlauben, einen neuen Datensatz zu übergeben, können wir die Aktualisierungsauswahl von D3 (Eingabe, Aktualisierung und Beendigung) verwenden, um auch Übergänge auf neue Daten anzuwenden. Aber wie lassen wir neue Daten zu? Wenn Sie sich erinnern, hat unsere vorherige Implementierung ein neues Diagramm wie dieses erstellt:
d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
Wir haben Daten an eine D3.js-Auswahl gebunden und unser wiederverwendbares Diagramm aufgerufen. Jegliche Änderungen an den Daten müssten vorgenommen werden, indem neue Daten an dieselbe Auswahl gebunden werden. Theoretisch könnten wir das alte Muster verwenden und die Auswahl auf vorhandene Daten untersuchen und dann unsere Ergebnisse mit den neuen Daten aktualisieren. Dies ist nicht nur chaotisch und kompliziert zu implementieren, sondern würde auch die Annahme erfordern, dass das vorhandene Diagramm vom gleichen Typ und in der gleichen Form ist.
Stattdessen können wir mit einigen Änderungen an der Struktur der JavaScript-Generatorfunktion ein Diagramm erstellen, das es dem Aufrufer ermöglicht, Änderungen einfach extern durch Methodenverkettung anzufordern. Während zuvor Konfiguration und Daten eingestellt und dann unberührt gelassen wurden, kann der Anrufer jetzt so etwas tun, auch nachdem das Diagramm initialisiert wurde:
weatherChart.width(420);
Das Ergebnis ist ein glatter Übergang zu einer neuen Breite von dem bestehenden Diagramm. Ohne unnötige Berechnungen und mit glatten Übergängen ist das Ergebnis ein zufriedener Kunde.
Diese zusätzliche Funktionalität ist mit einem leichten Anstieg des Entwickleraufwands verbunden. Ein Aufwand, der sich meiner Meinung nach aber historisch gelohnt hat. Hier ist ein Skelett des aktualisierbaren Diagramms:
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; }
Um zu sehen, wie es vollständig implementiert ist, schau es dir auf bl.ocks.org an.
Sehen wir uns die neue Struktur an. Die größte Änderung gegenüber der vorherigen Closure-Implementierung ist das Hinzufügen von Update-Funktionen. Wie bereits erwähnt, nutzen diese Funktionen D3.js-Übergänge und Aktualisierungsmuster, um alle erforderlichen Änderungen basierend auf neuen Daten oder Diagrammkonfigurationen reibungslos vorzunehmen. Um diese dem Aufrufer zugänglich zu machen, werden dem Diagramm Funktionen als Eigenschaften hinzugefügt. Und um es noch einfacher zu machen, werden sowohl die Erstkonfiguration als auch Updates über dieselbe Funktion abgewickelt:
chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; };
Beachten Sie, dass updateWidth
erst definiert wird, wenn das Diagramm initialisiert wurde. Wenn es undefined
ist, wird die Konfigurationsvariable global gesetzt und beim Schließen des Diagramms verwendet. Wenn die Diagrammfunktion aufgerufen wurde, werden alle Übergänge an die updateWidth
Funktion übergeben, die die geänderte width
verwendet, um alle erforderlichen Änderungen vorzunehmen. Etwas wie das:
updateWidth = function() { widthScale = width / maxValue; bars.transition().duration(1000).attr('width', function(d) { return d*widthScale}); svg.transition().duration(1000).attr('width', width); };
Mit dieser neuen Struktur werden die Daten für das Diagramm wie jede andere Konfigurationsvariable durch Methodenverkettung übergeben, anstatt sie an eine D3.js-Auswahl zu binden. Der Unterschied:
var weatherChart = barChart(); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
was wird:
var weatherChart = barChart().data(highTemperatures); d3.select('#weatherHistory') .call(weatherChart);
Wir haben also einige Änderungen vorgenommen und ein wenig Entwickleraufwand hinzugefügt, sehen wir uns die Vorteile an.
Angenommen, Sie haben eine neue Funktionsanfrage: „Fügen Sie ein Dropdown hinzu, damit der Benutzer zwischen hohen und niedrigen Temperaturen wechseln kann. Und wenn Sie schon dabei sind, ändern Sie auch die Farbe.“ Anstatt das aktuelle Diagramm zu löschen, die neuen Daten zu binden und von Grund auf neu zu zeichnen, können Sie jetzt einen einfachen Anruf tätigen, wenn niedrige Temperatur ausgewählt ist:
weatherChart.data(lowTemperatures).fillColor('blue');
und genieße die Magie. Wir sparen nicht nur Berechnungen, sondern fügen der Visualisierung bei Aktualisierungen eine neue Ebene des Verständnisses hinzu, was vorher nicht möglich war.
Ein wichtiger Hinweis zu Übergängen ist hier erforderlich. Seien Sie vorsichtig, wenn Sie mehrere Übergänge für dasselbe Element planen. Das Starten eines neuen Übergangs bricht implizit alle zuvor laufenden Übergänge ab. Natürlich können mehrere Attribute oder Stile für ein Element in einem von D3.js initiierten Übergang geändert werden, aber ich bin auf einige Fälle gestoßen, in denen mehrere Übergänge gleichzeitig ausgelöst wurden. Ziehen Sie in diesen Fällen die Verwendung gleichzeitiger Übergänge für übergeordnete und untergeordnete Elemente in Betracht, wenn Sie Ihre Aktualisierungsfunktionen erstellen.
Ein Wandel in der Philosophie
Mike Bostock führt Closures ein, um die Diagrammerstellung zu kapseln. Sein Muster ist optimiert, um an vielen Stellen dasselbe Diagramm mit unterschiedlichen Daten zu erstellen. In meiner jahrelangen Arbeit mit D3.js habe ich jedoch einen leichten Unterschied in den Prioritäten festgestellt. Anstatt eine Instanz eines Diagramms zu verwenden, um dieselbe Visualisierung mit unterschiedlichen Daten zu erstellen, ermöglicht das neue Muster, das ich eingeführt habe, dem Aufrufer, problemlos mehrere Instanzen eines Diagramms zu erstellen, von denen jede auch nach der Initialisierung vollständig geändert werden kann. Darüber hinaus wird jede dieser Aktualisierungen mit vollem Zugriff auf den aktuellen Status des Diagramms gehandhabt, sodass der Entwickler unnötige Berechnungen eliminieren und die Leistungsfähigkeit von D3.js nutzen kann, um nahtlosere Benutzer- und Kundenerlebnisse zu schaffen.