Daten mit D3 visualisieren (Teil 2)

Im ersten Teil habe ich gezeigt, wie man die Daten aus einer JSON-Datei mit D3 auslesen und als Liste darstellen kann. Im zweiten Teil möchte ich das Beispiel um eine Suchfunktion erweitern, die die Liste auf Einträge mit einer Übereinstimmung beschränkt.

Motivation

Das Erstellen einer statischen Liste erfordert noch nicht den Einsatz von D3. Ändern sich die anzuzeigenden Daten durch die Interaktion des Benutzers, ist der Einsatz von D3 durchaus sinnvoll. Für die Interaktion verwende ich ein einfaches Textfeld, wie es HTML5 mit <input type="search"> zur Verfügung stellt. Eine entschiedene Rolle, beim Aktualisieren der Liste, spielen die D3-Methoden enter() und exit(), die ich weiter unten genauer erkläre.

Das Filtern der Daten

Auf ein Array kann man in JavaScript die Methode filter() anwenden, an die eine Funktion als Filter übergeben wird. Diese Funktion heißt bei mir findMatch(). Das Ergebnis des Aufrufs von filter() ist ein Array, welches nur die Einträge enthält, für die findMatch() true zurückgegeben hat.

function findMatch(element) {
  "use strict";
  if (!this) {
    return true;
  }
  if (element.name.toLowerCase().indexOf(this.toLowerCase()) > -1) {
    return true;
  }
  if (element.description.toLowerCase().indexOf(this.toLowerCase()) > -1) {
    return true;
  }
  return false;
}

In der Funktion findMatch(element) wird das aktuelle Element des Array mit element referenziert, während der Suchbegriff als this übergeben wird. Schaut man sich die Dokumentation zu filter() an, so findet man arr.filter(callback[, thisArg]). Es gibt somit einen optionalen Parameter, der this in der Funktion callback(element, index, array) überschreibt. Auf diese Weise ist bei meiner Implementierung thisArg gleichbedeutend false, wenn ein leerer String ("") übergeben wird. Der Filter liefert somit für jedes Element true.

Mehrdimensionale Datensätze in D3

Beim Aufruf der Website werden die Daten aus der JSON-Datei geladen, wie es schon in Teil 1 beschrieben ist. Das Format lässt sich jedoch nicht direkt mit D3 verarbeiten, da d3.json die Daten als Object lädt, was an der von mir genutzten Struktur der JSON-Datei liegt. Deshalb werden die Arrays aus dem Object extrahiert und in ein neues Array gespeichert. Die bisherigen Bezeichner der Kategorien werden als Attribut name den Arrays zugewiesen:

d3.json("/data/cv.json", function (data){
    "use strict";
    dataSet = [];
    var i = 0;
    for (var cat in data) {
        if (data.hasOwnProperty(cat)) {
            dataSet[i] = data[cat];
            dataSet[i].name = cat;
            i++;
        }
    }
});

Mein JSON-Object habe ich somit in ein Array konvertiert, welches wiederum Arrays enthält und eine Art Matrix darstellt. Schaut man in die Dokumentation zu selection.data, findet man dort ein Beispiel für die Verarbeitung einer 2D-Matrix. Zuerst wird die Matrix an eine Selektion gebunden. Jede Zeile der Matrix wird an eine Zeile der HTML-Tabelle gebunden. Anschließend wählt man in jeder Zeile die Spaltenelemente (td) aus und bindet die einzelnen Zahlen an diese. Dabei erleichtert uns D3 die Implementierung, da es die Daten als Parameter für eine Callback-Funktion zur Verfügung stellt (tr.selectAll("td").data(function(d) { return d; })).

Meine Implementierung

Die Methoden enter() und exit() lassen sich leicht verstehen, wenn man den Blog-Eintrag Thinking with Joins von Mike Bostock liest.

Bei meiner Implementierung gehe ich ähnlich vor, wie in dem Beispiel. Die Zeilen sind bei mir div-Elemente mit der Klasse category, die eine Überschrift (h2) enthalten. Beim ersten Aufruf liefert selectAll(".category") noch eine leere Liste, da anfangs noch keine div-Elemente mit der Klasse category existieren. Ich rufe deshalb die Methode enter() auf. Bei categiries handelt es sich um ein Objekt, welches für jedes Element des verknüpften Datensatzes ein verknüpftes Element im DOM besitzt soll. Die Methode enter() filtert alle Elemente heraus, welche noch kein Gegenstück im DOM besitzen. Für all diese Elemente wird mit append("div") ein div-Element erzeugt, welches unterhalb der schon bestehenden Elemente eingefügt wird. Die folgenden Zeilen beziehen sich auf diese neuen div-Elemente, da append("div") eine Art Liste aus diesen neu erzeugten Elementen zurückgibt. Anschließend kommt das Gegenstück zu enter(), und zwar exit() zum Einsatz. Damit wird die Selektion auf Elemente reduziert, die zwar im DOM vorhanden sind, denen jedoch kein Element des Datensatzes zugewiesen werden kann. Diese überflüssigen Elemente werden mit remove() entfernt.

categiries = d3.select("#d3-include")
  .selectAll(".category")
  .data(dataSet);
categiries.select("h2")
  .text(function(d, i) {
    return d.name;
  });
categiries.enter().append("div")
  .attr("class", "category, col-xs-12")
  .append("h2")
  .text(function(d, i) {
    return d.name;
  });
categiries.exit().remove();
updateList();

Hinter der Funktion updateList() verbirgt sich die Implementierung der Spaltenelemente. Diesen Teil rufe ich auch bei der Nutzung des Filters auf, weshalb er eine eigene Funktion darstellt. categiries ist dieselbe Variable, die im vorherigen Code-Ausschnitt genutzt wurde. Sie enthält alle div-Elemente mit der Klasse category, die mit meinen Daten verknüpft sind. In jedem dieser div-Elemente selektiere ich alle Elemente, die die Klasse entry besitzen. Auch dabei handelt es sich um div-Elemente. In der Methode data() verwende ich eine anonyme Funktion, damit ich auf den Datensatz des Übergeordneten Elementes der Klasse category zugreifen kann. Dieser Datensatz ist ein Array, auf welchem ich die Methode filter() aufrufen kann. Wie dieser Filter funktioniert, das habe ich schon weiter oben erklärt. Das Argument filterValue stammt aus dem Textfeld, welches mit Hilfe von jQuery ausgelesen wird.

Die weitere Vorgehensweise ähnelt der bisherigen. Erst werden neue Elemente erzeugt und anschießend werden alle Elemente an die Funktion innerHTML() übergeben. Diese Funktion erzeugt den Inhalt der div-Elemente mit Hilfe der verknüpften Daten. Zum Abschluss werden überzählige Elemente aus dem DOM entfernt. Das Modifizieren aller Elemente ist hier besonders wichtig. Durch den Filter ändert sich die Position der einzelnen Elemente des Datensatzes. Nur wenn man den Datensatz ausschließlich am Ende bescheidet, reicht der Aufruf von exit().remove() aus.

function updateList(filterValue) {
  "use strict";
  var entries = categiries.selectAll(".entry").data(function(d) {
    return d.filter(findMatch, filterValue);
  });
  entries.enter().append("div").classed("entry", true);
  innerHTML(entries);
  entries.exit().remove();
}

function filterList() {
  "use strict";
  var value = $("#filter-input").val();
  updateList(value);
}
document.getElementById("filter-input").oninput = filterList;

Der vollständige Quellcode

HTML

Dies ist nicht der vollständige HTML-Code, sondern nur der für die Visualisierung relevante Teil.

<div class="row">
  <div class="col-xs-12">
    <label for="filter">
      Filter:<input type="search" name="filter" value="" id="filter-input" maxlength="256" />
    </label>
    <p class="text-justify">Mit Hilfe des Textfeldes kann man die Liste auf Einträge begrenzen, die den Suchbegriff enthalten. Dabei werden Titel und die Beschreibung durchsucht. Auch die URLs der angegebenen Links werden bei der Suche mit einbezogen. Groß- und Kleinschreibung spielen keine Rolle.</p>
  </div>
</div>

<div class="row">
  <div id="d3-include" class="col-xs-12"></div>
</div>

JavaScript

var dataSet;
var categiries;

function createStars(date) {
  "use strict";
 var content = "";
 var j = 0;
 while (j < date.value) {
   content += "<span class='glyphicon glyphicon-star' aria-hidden='true'></span>";
   j++;
 }
 while (j < 5) {
   content += "<span class='glyphicon glyphicon-star-empty' aria-hidden='true'></span>";
   j++;
 }
 return content;
}

function innerHTML(div) {
  "use strict";
  div.html("");
  div.append("h3")
   .text(function(date) {
     return date.name;
   });
  div.append("p")
   .html(function(date) {
     return createStars(date);
   });
  div.append("p")
   .attr("class", "text-justify")
   .html(function(date) {
     return date.description;
   });
}

function findMatch(element) {
  "use strict";
  if (!this) {
    return true;
  }
  if (element.name.toLowerCase().indexOf(this.toLowerCase()) > -1) {
    return true;
  }
  if (element.description.toLowerCase().indexOf(this.toLowerCase()) > -1) {
    return true;
  }
  return false;
}

function updateList(filterValue) {
  "use strict";
  var entries = categiries.selectAll(".entry").data(function(d) {
    return d.filter(findMatch, filterValue);
  });
  entries.enter().append("div").classed("entry", true);
  innerHTML(entries);
  entries.exit().remove();
}

function initList() {
  "use strict";
  categiries = d3.select("#d3-include")
    .selectAll(".category")
    .data(dataSet);
  categiries.select("h2")
    .text(function(d) {
      return d.name;
    });
  categiries.enter().append("div")
    .attr("class", "category, col-xs-12")
    .append("h2")
    .text(function(d) {
      return d.name;
    });
  categiries.exit().remove();
  updateList();
}

function filterList() {
  "use strict";
  var value = $("#filter-input").val();
  updateList(value);
}
document.getElementById("filter-input").oninput = filterList;

d3.json("/data/cv.json", function (data){
 "use strict";
 dataSet = [];
 var i = 0;
 for (var cat in data) {
   if (data.hasOwnProperty(cat)) {
     dataSet[i] = data[cat];
     dataSet[i].name = cat;
     i++;
   }
 }
 initList();
});