Ja, der Blog lebt noch 🙂 Und Heute schreibe ich etwas zur D3 Javascript Bibliothek!

Ich habe vor etwa fünf Jahren mal „D3“ (d3js.org) als „Grafik“ Tool in einer Linksammlung aufgeführt, ohne mich (damals) allzu sehr damit beschäftigt zu haben. Das hat sich zum Glück in den letzten Jahren stark geändert! Ich habe inzwischen diverse Visualisierungen umgesetzt und würde die D3 Javascript Bibliothek selbst einem MooTools, jQuery oder Prototype vorziehen.
Zumindest habe ich noch keinen Anwendungsfall gehabt bei dem ich auf eine andere Bibliothek angewiesen war. Man muss lediglich berücksichtigen das man hier nicht die Plugin Vielfalt eines jQuery zur Verfügung hat 😉

Für „einfache Dinge“ wie z.B. Manipulationen am DOM, Animationen oder Selektieren von Elementen ist es definitiv meine erste Wahl. Auch asynchrone Requests (Ajax) werden mittels fetch und Promise API unterstützt, allerdings gibt es hier keine Fallbacks für ältere Browser.

D3 bietet gleich zwei entscheidende Vorteile mit denen sich die Bibliothek von der Konkurrenz abhebt: Dutzende Algorithmen und Komponenten zum Darstellen beinahe beliebiger interaktiver Visualisierungen und der Umgang mit Daten.

Wenn man also das volle potenzial von D3 nutzen möchte muss man das Konzept der internen Datenhaltung verstehen – der Rest sollte dann durch reines „rumprobieren“ zu schaffen sein 🙂 Es existiert außerdem eine hervorragende Dokumentation und unzählige Beispiele zu allen möglichen Anwendungsfällen.

Kurz zur API selbst – wie bei den meisten anderen Bibliotheken selektiert man Elemente im DOM mittels CSS Selektor:

// Einzelnes Element selektieren.
const singleElement = d3.select('#css-selector');

// Alle passenden Elemente selektieren.
const multipleElements = d3.selectAll('.css-selector');

// Leere Kollektion selektieren.
const noElements = d3.selectAll('.non-existent');

Anschließend kann man mittels select und selectAll vom aktuellen Element aus weiter „nach unten“ selektieren:

// Einzelnes Unter-Element selektieren.
const singleSubElement = singleElement.select('.sub');

// Alle passenden Unter-Elemente selektieren.
const multipleSubElements = multipleElements.selectAll('.css-selector');

// Leere Unter-Kollektion selektieren.
const noSubElements = noElements.selectAll('.non-existent');

Anschließend kann man z.B. die Attribute oder Kinder von singleElement und multipleElements bearbeiten. Dabei macht es, wie bei jQuery, keinen unterschied ob wir ein, kein oder mehrere Elemente verarbeiten – wir arbeiten immer mit „Kollektionen“:

// Attribute ändern.
singleElement
    .attr('attribute', 'value')
    .attr('attribute', function(data, index) {
        return 'value';
    });

// (Inline-) Styling ändern.
multipleElements.style('background', 'red');

// Neue Elemente anhängen.
singleElement.append('p');

Der Umgang mit Daten

Okay, kommen wir zum interessanten Part: Der Umgang mit Daten. Wir können mittels data Methode Daten an eine Kollektion hängen, dabei spielt es keine Rolle ob die Kollektion Elemente beinhaltet oder nicht (das ist Teil des Konzeptes).

Wir arbeiten mit folgendem HTML:

<div id="collection">
    <div class="data-element">1</div>
</div>

Die Elemente im DOM werden nun selektiert und mit Daten gefüttert:

const currentDivs = d3.select('#collection')
    .selectAll('.data-element')
    .data([1, 2, 3, 4, 5]);

Was hier passiert: Wir selektieren alle .data-element Elemente und füttern das Array [1, 2, 3, 4, 5] als Daten. Doch wir haben hier einen Konflikt: Wir haben in unserer Kollektion nur ein Element selektiert, doch besitzen 5 Datensätze… Hier kommen die Methoden enter und exit ins Spiel (bzw. seit D3 v5.8.0 optional auch join). Mit Hilfe dieser zwei Methoden können wir folgendes tun:

  • Neue Elemente (aus den übergebenen Daten) erstellen
  • Vorhandene Elemente aktualisieren
  • Obsolete Elemente (die nicht mehr Bestandteil der Daten sind) löschen
// Neue Kollektion aus "neuen" Daten.
const newDivs = currentDivs.enter();

// Neue Kollektion aus "entfernten" Daten.
const oldDivs = currentDivs.exit();

Okay – wir haben nun also insgesamt drei Kollektionen currentDivs, newDivs und oldDivs. Diese repräsentieren die drei Status unserer Elemente und können uns Helfen das ganze auch verständlich zu visualisieren:

// Färbt die Rahmen aller "aktuellen" DIVs grau.
currentDivs.style('border', '1px solid #aaa');

// Färbt die Rahmen aller "neuen" DIVs grün.
newDivs.style('border', '1px solid #0f0');

// Färbt die Rahmen aller "alten" DIVs rot.
oldDivs.style('border', '1px solid #f00');

Führen wir den Code aus, sollte das Ergebnis so aussehen:

Initialer Zustand
Initialer Zustand

Bauen wir das Ganze mal als Funktion auf, damit wir den Code mit verschiedenen Daten aufrufen können:

const update = myData => {
	const currentDivs = d3.select('#collection')
		.selectAll('.data-element')
		.data(myData);

	const newDivs = currentDivs
		.enter()
		.append('div')
		.attr('class', 'data-element')
                .text(d => d);

	const oldDivs = currentDivs
		.exit();

	currentDivs.style('border', '1px solid #aaa');
	newDivs.style('border', '1px solid #0f0');
	oldDivs.style('border', '1px solid #f00');
}

update([1, 2, 3, 4, 5]);

Das Ergebnis bleibt das gleiche, doch nun können wir update mehrfach (mit anderen Daten) aufrufen:

// Initialer Zustand.
update([1, 2, 3, 4, 5]);

// Zweiter Zustand nach einer Sekunde.
d3.timeout(() => update([2, 3, 4]), 1000);

Führen wir diesen Code aus erscheint uns aber eine etwas merkwürdige Darstellung… Der Initiale Status bleibt korrekt, doch dann folgt ein (scheinbar) falscher Zustand:

Der zweite Zustand
Zweiter Zustand

Für den zweiten Zustand erwarten wir eigentlich, das 1 und 5 rot markiert werden und 2, 3 und 4 grau… Doch natürlich gibt es eine logische Erklärung für diese Erscheinung:

  1. D3 arbeitet Standardmäßig mit dem Index der Daten um Elemente zu identifizieren – das können wir aber mittels zweitem Parameter für .data() erledigen.
  2. Wir haben die Inhalte für currentDivs nicht aktualisiert. Hätten wir das getan (mittels currentDivs.text(d => d)) würden wir 2, 3 und 4 in den ersten grauen Kästen stehen haben

Elemente besser identifizieren

Definieren wir also wie D3 die Elemente der Kollektionen identifizieren soll:

// Nutze den Daten-Wert anstatt des Index:
const currentDivs = d3.select('#collection')
	.selectAll('.data-element')
	.data(myData, (data, index) => data);

Anstatt des Index wird nun der Wert selbst verwendet. Das funktioniert in diesem Code allerdings nur aus zwei Gründen:

  • Wir arbeiten mit einem eindimensionalen Array
  • Jeder Wert ist einmalig

Arbeiten wir Beispielsweise mit einem mehrdimensionalen Array, Objekten oder mit doppelten Inhalten müssen wir uns etwas anderes einfallen lassen.

Mit unserer letzten Anpassung erhalten wir folgende Ansicht für den Initialen Zustand:

Der neue initiale Zustand

Neuer initialer Zustand

Da D3 nun nicht mehr mit dem Index arbeitet sondern intern die tatsächlichen Werte verarbeitet, erscheinen alle Zeilen als „neu“ und das fest kodierte DIV als „alt“ (da D3 hierzu keinen Datensatz hat). Besser, oder?

Der neue zweite Zustand
Neuer zweiter Zustand

Auch der neue zweite Zustand sieht nun so aus, wie wir ihn erwartet haben. Damit die rot markierten Elemente zukünftig nicht mehr auftauchen, können wir diese mittels remove (inklusive optionaler Animation) entfernen:

// "Ausblenden" Animation und anschließendes entfernen der Elemente.
oldDivs.style('border', '1px solid #f00')
    .call(exit => exit.transition()
        .duration(750)
        .style('opacity', 0)
        .remove());

Das gleiche funktioniert natürlich auch für neue oder aktualisierte Elemente 🙂 Eine detailliertere Erklärung inkl. der Nutzung von join findet ihr hier.
Kurz zur Erläuterung: Mittels join könnt ihr enter, update und exit in einer einzelnen Methode und entsprechenden Callbacks abhandeln. Cooles Zeug 😎

Das komplette Skript

const update = myData => {
    const currentDivs = d3.select('#collection')
        .selectAll('.data-element')
        .data(myData, (data, index) => data);

    const newDivs = currentDivs.enter();

    const oldDivs = currentDivs.exit();

    currentDivs.style('border', '1px solid #aaa');
    
    newDivs
        .append('div')
        .attr('class', 'data-element')
        .style('height', 0)
        .style('opacity', 0)
        .style('border', '1px solid #0f0')
        .style('overflow', 'hidden')
        .text(d => d)
        .call(enter => enter.transition()
            .duration(750)
            .style('height', '20px')
            .style('opacity', 1));
            
    oldDivs
        .style('border', '1px solid #f00')
        .call(exit => exit.transition()
            .duration(750)
            .style('height', '0px')
            .style('opacity', 0)
            .remove());
}

// Initialer Status.
d3.timeout(() => update([1]), 1000);

// Zweiter Status.
d3.timeout(() => update([1, 2, 3, 4, 5]), 2000);

// Dritter Status.
d3.timeout(() => update([2, 3, 4]), 3000);

// Letzter Status.
d3.timeout(() => update([1, 7, 9, 10, 12]), 4000);

Was sonst noch?

Natürlich die Dutzenden Algorithmen und Komponenten zum erstellen von allen möglichen interaktiven Diagrammen! Darüber habe ich noch überhaupt nichts geschrieben, doch es ist eigentlich (neben dem Daten-Handling) DAS Aushängeschild der D3 Bibliothek … Vielleicht im nächsten Posting 😉

Was für mich außerdem noch für D3 spricht? Unter anderem der super aktive Entwickler Mike Bostock, die regelmäßigen neuen Versionen und die strikte Einhaltung des Semantic Versioning 🙂

Ich möchte auch zum Ende hin noch D3 in Depth empfehlen – das ist eine sehr ausführliche Dokumentation zum Thema D3 Javascript Bibliothek in der viele Themen inklusive Beispiele abgearbeitet werden – zum Zeitpunkt dieses Postings sind noch nicht alle Inhalte verfügbar, doch es wird (soweit ich weiß) aktiv daran geschrieben 🙂

I’m back, Baby!

Okay – vielleicht nicht so aktiv wie vor einigen Jahren noch, aber ich möchte hier gerne wieder etwas aktiver werden. Da ich beruflich nun auch mehr mit React und symfony arbeite dürfte es ja genügend frisches Futter geben.

One comment on “D3 Javascript Bibliothek

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.