D3.js für engagierte Angular-Entwickler, Teil 3

Animationen mit D3.js und Angular erstellen – so geht’s!
Keine Kommentare

Wer animierte Diagramme mit D3.js und Angular erstellen möchte, muss einiges beachten. Teil 3 unserer Artikel-Serie „D3.js für engagierte Angular-Entwickler“ verrät, worauf es ankommt, damit am Ende alles fließt.

Stephan Rauh hatte sich in seinem Artikel „D3.js für engagierte Angular-Entwickler: Pie-Charts erstellen“ bereits damit beschäftigt, wie sich D3.js mit Angular sinnvoll für Diagramme einsetzen lässt. Ein Problem dabei bestand in der Limitierung, dass Änderungen sich immer sofort auswirken und fließende Übergänge per Animationen anscheinend mit diesem Ansatz nicht ohne Konflikte möglich sind.

D3.js und Angular: Animierte Pie-Charts

Bei der Umsetzung einer Pie-Chart-Komponente unter Angular bin ich auf eben diese Limitierung gestoßen. Nach einigem Recherchieren und Rumprobieren kam ich dann auf einen möglichen Lösungsansatz. Und auch hier: Animationen mit D3.js und Angular sind einfacher umzusetzen als man denken mag.

Zur Erläuterung der Funktionsweise verwende ich im Anschluss ein einfaches Beispiel, da die Anwendung auf die Pie-Chart-Komponente durch die vielen mathematischen Berechnungen wesentlich komplexer ist. Das eigentliche Problem geht dabei eher unter. Der Quellcode der Pie-Chart-Komponente steht auf GitHub zur Verfügung.

D3.js für engagierte Angular-Entwickler – die Artikelserie

Teil 1: Bubble-Charts
Teil 2: Pie-Charts
Teil 3: Animierte Diagramme
Teil 4: Drag & Drop mit Angular

Wo liegt das Problem?

Animationen mit D3.js zu erstellen, ist im Prinzip einfach. Mit dem funktionalen Programmieransatz lassen sich Animationen mit wenigen Zeilen Quellcode realisieren.

 
svg
  .selectAll('rect')
  .attr('x', 10)
  .transition()
  .duration(1000)
  .attr('x', 100);

Was passiert hier? Alle rect-Elemente innerhalb des SVG-Elementes werden zunächst initial an der X-Koordinate 10 platziert. Mit der Methode transition() wird danach eine Animation definiert. Über eine Dauer von 1000 Millisekunden erfolgt dann die animierte Verschiebung der Elemente auf die X-Koordinate 100. Fast schon trivial, oder?

Nehmen wir an, wir haben innerhalb einer Angular-Komponente ein Array rects mit den Koordinaten und den Dimensionen der einzelnen Rechtecke. Die HTML-Datei für diese Komponente hat den folgenden Aufbau:

 
<svg>
  <rect *ngFor="let rect of rects"
    [attr.x]="rect.x" [attr.width]="rect.width"
    [attr.y]="rect.y" [attr.height]="rect.height"
    />
</svg>

Würden wir die Animation über den beschriebenen Ansatz ausführen, dann wären unsere Daten inkonsistent. Die Animation würde funktionieren, allerdings werden die X-Koordinaten direkt auf den rect-Elementen angepasst und nicht innerhalb unseres rects-Arrays. Eine Änderung der X-Koordinaten innerhalb des Arrays würde sich auch nicht mehr auf unsere rect-Elemente auswirken.

Mit anderen Worten: uns geht die Eigenschaftsbindung zwischen dem HTML-Template und der TypeScript-Logik verloren. Also ausgerechnet eines der Kernfeatures von Angular.

Ein vielleicht wichtiger Hinweis an dieser Stelle: Innerhalb der Methode, in der wir die Array-Elemente erzeugen, können wir nicht über die D3.js-Methode select bzw. selectAll auf diese zugreifen. Diese stehen erst nach dem nächsten Rendering-Zyklus zur Verfügung. Wenn dies notwendig sein sollte, kann man sich über einen kleinen Trick behelfen. Der Zugriff über die D3.js-Methoden muss dann einfach innerhalb einer setTimeout-Closure erfolgen.

 
this.rects = [...];
setTimeout(() => {
  svg
    .selectAll('rect')
    ...
},0);

Alternative Ansätze

D3.js stellt uns die Methode attrTween zur Verfügung, mit deren Hilfe wir individuelle Attribut-Änderungen während der laufenden Animation durchführen können.

Gehen wir davon aus, dass wir unsere Einträge im rects-Array bereits entsprechend initialisiert haben und uns nur noch auf die Animation beschränken wollen. So könnte eine Realisation der oben beschriebenen Funktionalität mit der attrTween-Methode wie folgt aussehen:

 
public moveRectsTo(x: number): void {
  svg
    .selectAll('rect')
    .transition()
    .duration(1000)
    .attrTween('x', (data, idx, nodeList) => {
      const i = d3.interpolate(this.rects[idx].x, x);
      return (t) => {
        return i(t).toString();
      };
    });
};

Was passiert hier? Im Prinzip das gleiche wie bei der ersten Animation. Die zu erreichende X-Koordinate übergeben wir der moveRectsTo-Methode als Parameter. Beim Starten der Animation wird über die D3.js-Funktionalitäten die attrTween-Methode einmalig je rect-Element ausgeführt. Hier passieren zwei Dinge. Erstens wird über die D3.js-Methode interpolate eine Interpolierungsfunktion erzeugt, die als Parameter den Ausgangszustand und den Endzustand erwartet. Diese Funktion kann anschließend mit einer Zahl zwischen 0 und 1 als Parameter aufgerufen werden und liefert dann den entsprechenden Zustand zurück. Zweitens geben wir eine sogenannte Factory-Funktion zurück, die während der Animation laufend ausgeführt wird und anhand des Animationsfortschritts t in Prozent (0-1) den aktuellen Wert für die X-Koordinate berechnet und zurückgibt.

In unserem Beispiel könnte man diese Interpolierungsfunktion auch relativ einfach selbst schreiben. Sie könnte wie folgt aussehen:

 
const interpolate = (from: number, to: number): ((t: number) => string) => {
  return (t: number): string => {
    return ( from + ( ( to - from ) * t ) ).toString();
  };
};

Da die D3.js-Methode interpolate allerdings auch hexadezimale Farbwerte oder auch ganze Objekte verarbeiten kann und somit wesentlich flexibler und schlanker ist, würde ich hier auch immer auf diese zurückgreifen – warum das Rad neu erfinden?

Das eigentliche Problem haben wir damit aber auch nicht gelöst. Im Prinzip macht unser neuer Algorithmus genau dasselbe wie der erste, er ist einfach nur länger und komplizierter geworden.

Mögliche Lösungen für Animationen in D3.js mit Angular

Diejenigen unter uns, die sich gut mit JavaScript auskennen, können jetzt sagen: „Das lässt sich ja mit nur einer kleinen Änderung lösen. Wir speichern einfach den berechneten Wert bei der Rückgabe wieder in dem rects-Array.“:

 
public moveRectsTo(x: number): void {
  svg
    .selectAll('rect')
    .transition()
    .duration(1000)
    .attrTween('x', (data, idx, nodeList) => {
      const i = d3.interpolate(this.rects[idx].x, x);
      return (t) => {
        this.rects[idx].x = i(t);
        return this.rects[idx].x.toString();
      };
    });
};

Stimmt, die X-Koordinaten sind nun auch während der Animation jederzeit über das rects-Array abrufbar und die Daten somit konsistent. Eine Änderung der X-Koordinaten direkt im rects-Array würde sich nun auch auf die Darstellung auswirken.

Warum Angular in diesem Fall die Eigenschaftsbindung aufrecht erhält, obwohl wir über die attrTween-Methode den Attributwert für x überschreiben, ist mir selbst auch nicht klar. Ich vermute, dass die Eigenschaftsbindung einfach darum erhalten bleibt, weeil keine weitere Zuweisung durchgeführt wird, wenn der über D3.js zuzuweisende Attributwert bereits mit dem über Angular zugewiesenen Attributwert übereinstimmt. Vertrauen will ich darauf aber ehrlich gesagt selbst nicht…

Eine für mich saubere Lösung besteht ebenfalls nur aus einer weiteren kleinen Anpassung. Die Darstellung und die Daten im Hintergrund sind während unserer Animation inzwischen konsistent. Wir müssen nur noch die Angular-Bindung sicher aufrechterhalten. Und das ist einfach möglich. Als ersten Parameter übergeben wir der attrTween-Methode den Namen des anzupassenden HTML-Attributs – hier „x“. Ändern wir diesen „Attributnamen“ in einen, der für das entsprechende Element gar nicht existiert, z. B. „transition-x“ so haben wir das Problem tatsächlich gelöst.

Der Quellcode eines möglichen Lösungsansatzes könnte dann wie folgt aussehen:

 
public moveRectsTo(x: number): void {
  svg
    .selectAll('rect')
    .transition()
    .duration(1000)
    .attrTween('transition-x', (data, idx, nodeList) => {
      const i = d3.interpolate(this.rects[idx].x, x);
      return (t) => {
        this.rects[idx].x = i(t);
        return this.rects[idx].x.toString();
      };
    });
};

Profi-Level: Mehrere Eigenschaftsänderungen in einer Animation

Wenn wir nicht nur die X-Koordinaten, sondern auch die Y-Koordinaten oder die Dimensionen verändern wollen, können wir dies einfach innerhalb der bestehenden attrTween-Methode ergänzen. Da keine direkte Attributzuweisung mehr seitens D3.js erfolgt, ist es nicht notwendig, mehrere attrTween-Methoden zu verwenden – natürlich kann das aber je nach Kontext Sinn ergeben.

 
public moveRectsTo06(x: number, y: number): void {
  svg
    .selectAll('rect')
    .transition()
    .duration(1000)
    .attrTween('transition-dummy', (data, idx, nodeList) => {
      const iX = d3.interpolate(this.rects[idx].x, x);
      const iY = d3.interpolate(this.rects[idx].y, y);
      return (t) => {
        this.rects[idx].x = iX(t);
        this.rects[idx].y = iY(t);
        return '';
      };
    });
};

Die Rückgabe eines Leerstrings innerhalb der Factory-Funktion ist nur bei Verwendung der D3.js-Typ-Bibliothek (@types/d3) notwendig, da HTML-Attributwerte immer vom Typ String sind.

Eigenschaftsänderungen in der laufenden Animation

Bei der Umsetzung der Pie-Chart-Komponente bin ich über ein weiteres Problem gestolpert. Durch Benutzeraktionen innerhalb einer laufenden Animation wurden die Eigenschaftswerte immer wieder gegenseitig überschrieben. Das hat zu sehr seltsamen Effekten in der Darstellung und teilweise wieder zu inkonsistenten Daten geführt.

Auch hier habe ich versucht, einen möglichst pragmatischen Lösungsansatz zu finden. Wenn wir die Daten doppelt vorhalten würden, sozusagen ein Array welches den aktuellen Zustand beschreibt und ein weiteres Array welches den Endzustand beschreibt, können wir das Problem geschickt und einfach lösen.

Nehmen wir in unserem Beispiel das rects-Array als Endzustand und definieren ein zusätzliches Array rectsState, welches wir initial mit den Daten aus dem rects-Array initialisieren. Bei jeder Änderung innerhalb des rects-Arrays lösen wir unsere Animation aus. Die Animationsmethode könnte dabei wie folgt aussehen:

 
public animateRects(): void {
  svg
    .selectAll('rect')
    .transition()
    .duration(1000)
    .attrTween('transition-dummy', (data, idx, nodeList) => {
      const iX = d3.interpolate(this.rectsState[idx].x, this.rects[idx].x);
      const iY = d3.interpolate(this.rectsState[idx].y, this.rects[idx].y);
      return (t) => {
        this.rectsState[idx].x = iX(t);
        this.rectsState[idx].y = iY(t);
        return '';
      };
    });
};

Nach jeder Änderung innerhalb des rects-Arrays lösen wir diese Animationsmethode aus. Wir sehen, dass die Animationen fließend ineinander übergehen.

Beim Auslösen einer neuen Animation per transition wird eine möglicherweise noch aktive Animation auf den Elementen durch die D3.js-Routinen automatisch gestoppt. Leider werden die Animationen asynchron ausgeführt, d. h. der letzte Zyklus wird immer noch für alle Elemente ausgeführt. Dies kann zu Verwerfungen führen, wenn neue Elemente eingefügt oder auch wenn Elemente aus dem Array entfernt werden. Durch das Aufrufen der D3.js-Methode interrupt auf die betroffenen Elemente können wir dieses Verhalten umgehen.

Fügen wir dazu in unserer animateRects-Methode am Anfang noch die folgenden Zeilen zur Unterbrechung der laufenden Animation ein:

 
public animateRects(): void {
  svg
    .selectAll('rect')
    .interrupt();
  ...
};

Wie reagiere ich automatisch auf Eigenschaftsänderungen?

Auch auf dieses Problem bin ich während der Pie-Chart-Komponenten-Entwicklung gestoßen. Wie lässt es sich vermeiden, dass ich die Animationsmethode jedes Mal von Hand auslösen muss?

Bei jeder Angular-Komponente kann man über die Methode ngOnChanges auf Eigenschaftsänderungen reagieren. Das Problem dabei: ngOnChanges wird nur ausgelöst, wenn Eigenschaftswerte von außen geändert werden, also die entsprechenden Eigenschaften mit dem @Input-Dekorator gekennzeichnet sind. Weiterhin werden Änderungen innerhalb von Arrays oder Objekten nicht erkannt.

Die Angular-Komponenten-Methode ngDoCheck wird bei jedem Prüfungszyklus ausgelöst, d.h. wenn Angular selbst prüft, ob sich Eigenschaftswerte geändert haben. Hier können wir eine Prüfung implementieren und analysieren ob sich für die Animation relevante Werte verändert haben.

Da sich während laufenden Animationen die Werte in dem rectsState-Array stetig ändern, können wir zur Prüfung nicht auf diese zurückgreifen. Wir benötigen ein weiteres Array, das den jeweils zuletzt erkannten zu erreichenden Endstatus beinhaltet. In unserem Beispiel habe ich ein Array rectsLast definiert und initial mit den Werten aus dem rects-Array belegt. Die ngDoCheck-Methode könnte dann wie folgt implementiert werden:

 
ngDoCheck(){
  let changed = (this.rects.length !== this.rectsLast.length);
  if(!changed){
    for(let i=0; i<this.rects.length; ++i){
      const a = this.rects[i];
      const b = this.rectsLast[i];
      changed = changed || (a.x !== b.x) || (a.y !== b.y) 
                || (a.width !== b.width) || (a.height !== b.height);
      if(changed) break;
    }
  }
  if(changed){
    this.animateRects();
  }
};

Innerhalb unserer animateRects-Methode duplizieren wir immer zunächst den zu erreichenden Endzustand aus dem rects-Array in das Array rectsLast. Es ist dabei zu beachten, dass wir eine echte Kopie erzeugen und nicht nur ein neues Array auf die bestehenden Objekt-Referenzen. Im Beispiel habe ich mir über die JSON-Methoden stringify und parse beholfen um eine echte Kopie zu erzeugen.

 
public animateRects(): void {
    this.rectsLast = JSON.parse(JSON.stringify(this.rects));
    ...
};

Et voilá: Bei jeder Änderung der Koordinaten im rects-Array werden diese mit Animationen dargestellt und dies ohne zusätzliche Methoden-Aufrufe oder sich Gedanken machen zu müssen, ob vielleicht noch eine Animation läuft.


Dieser Artikel erschien zuerst auf Englisch auf Beyond Java.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -