Teil 2: Level-up - Pie-Charts mit höherem Schwierigkeitsgrad

D3.js für engagierte Angular-Entwickler: Pie-Charts erstellen
Keine Kommentare

Vom Bubble-Chart zum Pie-Chart: Klingt gar nicht so schwer, oder? Mit Angular und D3.js gibt es aber doch einige Herausforderungen auf dem Weg zum Pie-Chart zu überwinden. Diesmal kommen wir nicht mit Halbwissen davon, sondern müssen uns richtig mit D3.js beschäftigen.

Kurz nachdem ich im ersten Teil dieser Serie gezeigt hatte, wie einfach es ist, ein Bubble-Chart mit Angular und D3.js zu erstellen, bekam ich die Aufgabe, ein Tortendiagramm zu erstellen. Ich fragte mich schnell, ob ich zu viel versprochen hatte. Das Tortendiagramm erwies sich als erstaunlich schwierige Aufgabe.

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

Ein Hinweis vorab: Dieser Artikel enthält eine Menge Quelltext. Wahrscheinlich hilft es ihnen, den Quelltext auch live in Ihrer IDE zu sehen. Sie finden das komplette, lauffähige Projekt auf GitHub und die fertige Komponente auf npm.

Wo die Reise hingeht: Pie-Charts mit Angular und D3.js

Wir wollen eine ganz einfache Angular-Komponente bauen, die so verwendet werden kann:

<lib-pie-chart [data]="pieChartData"></lib-pie-chart>
export class AppComponent {
    public pieChartData: Array = [
            { value: 10, caption: 'apples', color: 'green' },
            { value: 20, caption: 'oranges', color: 'orange' },
            { value: 30, caption: 'bananas', color: 'yellow' }
          ];
}

Reverse Engineering

Im letzten Teil der Serie haben wir gelernt, dass wir bei D3.js mit Reverse Engineering schnelle Erfolge erzielen. Als Basis habe ich eine relativ aktuelle Demo von Mike Bostock ausgewählt, einem der führenden Köpfe hinter dem D3-Projekt. Der HTML-Code der Demo sieht allerdings abschreckend aus. Es ist nicht leicht zu erkennen, wie sich die vielen Zahlen in die Tortenstücke des Diagramms übersetzen:

<svg width="960" height="500">
    <g transform="translate(480,250)">
        <g class="arc">
            <path d="M0,-240A240,240,0,0,1,107,-214L0,0Z" fill="#98abc5"></path>
            <text transform="translate(48,-204)" dy="0.35em"><5</text>
        </g>
        <g class="arc">
            <path d="M107,-214A240,240,0,0,1,226,-79L0,0Z" fill="#8a89a6"></path>
            <text transform="translate(157,-139)" dy="0.35em">5-13</text>
        </g>
...
        <g class="arc">
            <path d="M-25,-238A240,240,0,0,1,0,-240L0,0Z" fill="#ff8c00"></path>
            <text transform="translate(-11,-209)" dy="0.35em">≥65</text>
        </g>
    </g>
</svg>

Dabei habe ich den Code schon vereinfacht, indem ich die Kommastellen weggelassen habe. Wenn Sie sich gut mit Mathematik auskennen, sind die Kommastellen keine Überraschung: In der Geometrie spielen trigonometrische Funktionen wir Sinus und Kosinus immer eine Rolle, und die produzieren selten glatte Zahlen.

Im Quelltext tauchen „Pfade“ auf. Aus dem Kontext können wir erraten, dass es sich dabei jeweils um ein Tortenstück handeln muss. Aber was bedeutet eine Angabe wie d=“M107,-214A240,240,0,0,1,226,-79L0,0Z„?

Pfaddefinitionen mit SVG

Wenn Sie ein paar Minuten Zeit haben, schauen Sie sich doch mal diese Einführung über SVG-Pfade an. Dort stellt sich heraus, dass der lange Bandwurm von Buchstaben und Zahlen eine eigene, kleine Programmiersprache ist:

M107,-214 Setze den virtuellen Zeichenstift an der Position (107, -214) auf.
A240,240,0,0,1,226,-79 Zeichne einen elliptischen Bogen mit dem Radius 240 beginnend an der Position unseres Zeichenstiftes (107, -214) und endend bei der Position (226, -79). Zeichne die Ellipse ohne X-Achsen-Rotation (0), verwende den kurzen Weg um die Ellipse (0) und zeichne mit dem Uhrzeigersinn. Das Ergebnis ist der äußere Rand eines unserer Tortenstück.
L0,0
 
Zeichne eine Line von der aktuellen Position zum Nullpunkt (0, 0).
Z Ende des Pfades, d.h. zeichne eine Line von der aktuellen Position (Nullpunkt) zum Beginn des Pfades (107, -214).

Der Rest ist also ganz einfach. Wir schauen in unserem Mathebuch nach, wie das mit den geometrischen Funktionen funktioniert, berechnen die Koordinaten mit Tangens und Arkustangens und setzen den Pfad Stück für Stück zusammen.

Besonders attraktiv klingt diese Aussicht nicht. Bei den Winkelfunktionen müssen wir immer auf die Sonderfälle achten und es wäre schöner, einfach die Winkel anzugeben.

Erster Ansatz: D3 berechnet den Pfad für uns

Diese Art von Berechnungen ist das Herzstück von D3.js. Für die Pfadberechnungen hat D3 eine „Mikro-Library“ namens d3-path. Wir können Sie als eigenständige Bibliothek verwenden.

Zunächst müssen wir sie installieren:

npm install d3-path --save
npm install @types/d3 --save-dev

Die Typdefinitionen (@types/d3) sind bei einem so dynamischen Projekt wie D3 immer ein Ärgernis: Sie scheinen nie aktuell zu sein. Mir fallen immer wieder Funktionen auf, die es in D3.js gibt, aber noch nicht in den Type Definitions. Ich verwende sie trotzdem, weil sie trotz allem eine Unterstützung sind, auch wenn die Codevervollständigung der IDE nicht alle Funktionen zeigt. An dieser Stelle der Appell: Wenn Ihnen eine fehlende Funktion auffällt, nehmen Sie sich ein wenig Zeit und reichen Sie einen Pull-Request ein. Ihre Kollegen werden es Ihnen danken!

Eine sehr schöne Eigenschaft von d3-path ist, dass diese die gleiche API wie das Canvas-Objekt von JavaScript hat. Damit wird die Berechnung unseres Pfades angenehm einfach:

import { path } from 'd3-path';
...
const context = path();
context.moveTo(0, 0);
context.arc(0, 0, this.radius, lastAngle, newAngle, false);
console.log(context.toString());

Winkel mit Grad und Radiant

Es gibt nur eine Kleinigkeit zu beachten, die Sie vielleicht schon von den Winkelfunktionen kennen. In den neueren Versionen von D3.js wird nicht mehr mit Grad gerechnet, sondern mit der mathematischen Variante Radiant. 180° entsprechen π Radiant, und 360° entsprechend 2π Radiant.

Mit diesem Wissen können wir jetzt die Pfade unseres Tortendiagramms zeichnen:

@Input() data: Array<PieChartData> = [];

function drawPieChart() {
    const sum = this.data.reduce((p, c) => p + c.value, 0);
    let lastAngle = 0;
    this.data.forEach(d => {
        const newAngle = lastAngle + ((2 * Math.PI) / sum) * d.value;
        const context = path();
        context.moveTo(0, 0);
        context.arc(0, 0, this.radius, lastAngle, newAngle, false);
        d.path = context.toString();
        lastAngle = newAngle;
    });
}

Zweiter Ansatz: D3 erledigt die ganze harte Arbeit für uns

Verglichen mit dem Algorithmus von Mike Bostock ist das immer noch etwas umständlich. Das ist mir vor allem später im Verlauf meines Projektes aufgefallen, als ich die Beschriftungen hinzufügen wollte. Der Clou ist: D3.js „weiß“, wie es die Texte optimal positioniert. Wir müssen das erst herausfinden.

Es gibt zwei weitere D3 Mikro-Libraries, die wir nutzen können, um das Programm eleganter zu gestalten:

npm install d3-scale --save
npm install d3-shape --save

Der große Vorteil: jetzt können wir die Funktionen arc() und pie() nutzen – also quasi die Sprache des Fachbereiches verwenden. Und mit Hilfe der Funktion centroid() können wir auch gleich die Beschriftungen richtig positionieren:

ngOnChanges(changes: any): void {
    const labelPositionGenerator = arc()
      .outerRadius(this.radius - 40)
      .innerRadius(this.radius - 40);

    const pieChartDataGenerator = pie<PieChartData>()
      .sort(null)
      .value((d: PieChartData) => d.value);

    const svgPathGenerator = arc()
      .outerRadius(this.radius - 10)
      .innerRadius(0);

    const x: PieArcDatum<InternalPieChartData>[] = pieChartDataGenerator(this.data);

    this.chartdata = x.map(element => {
      return {
        ...element,
        innerRadius: this.radius - 40,
        outerRadius: this.radius
      };
    });

    this.chartdata.forEach(d => {
      d.data.path = svgPathGenerator(d);
      d.data.textPosition = labelPositionGenerator.centroid(d);
    });
  }
  }
<svg [attr.width]="width" [attr.height]="height">
    <g [attr.transform]="center">
        <path *ngFor="let d of chartdata" [attr.fill]="d.data.color" 
              stroke="white" stroke-width="1px" 
              [attr.d]="d.data.path"></path>
        <text *ngFor="let d of chartdata" 
              [attr.transform]="'translate(' + d.data.textPosition + ')'" 
              dy="0.35em">
              {{d.data.caption}}
        </text>
    </g>
</svg>

Datentypen und Datenstrukturen

Es wird Zeit, uns die Datenstrukturen genauer anzuschauen. Wir hatten weiter oben die Type Definitions von D3.js installiert. Sie machen unser Leben gleichzeitig einfacher und komplizierter. Der typische Programmierstil mit D3 nutzt die dynamischen Datentypen voll aus. Fehlende Attribute werden kurzerhand zu einem bestehenden Objekt hinzugefügt, so dass dieses Objekt direkt für eine Funktion verwendet werden kann.

Die API, die wir den Anwendern unserer Komponente bereitstellen wollen, sieht so aus:

export interface PieChartData {
    value: number;
    caption: string;
    color: string;
}

Die Tortenstücke werden durch einen numerischen Wert, eine Beschriftung und eine Farbe definiert. Winkel kommen hier nicht vor: D3.js rechnet die numerischen Werte der Tortenstücke automatisch um.

Für die Darstellung der Tortenstücke in der Template-Language brauchen wir eine ganz andere Datenstruktur:

export interface InternalPieChartData extends PieChartData {
    path?: string;
    textPosition?: [number, number];
}

Die Funktion pie() nimmt die numerischen Werte, berechnet daraus ein Array von PieArcDatum-Objekten. Das ist beinahe das Objekt, das der svgPathGenerator braucht, um die Pfade der Tortenstücke zu berechnen. Was noch fehlt, ist der Radius. Wegen der Beschriftung brauchen wir sogar zwei Radien – einen inneren und einen äußeren Radius. In JavaScript könnten wir die beiden Attribute einfach ergänzen. In TypeScript geht das nicht, weil die zusätzlichen Attribute nicht als optional deklariert sind. Also behelfen wir uns mit dem Spread-Operator, erzeugen neue Objekte mit dem Zieldatentyp und arbeiten mit einer Kopie des Original-Arrays weiter:

export class PieChartComponent implements OnChanges {
  @Input() data: Array<PieChartData> = [];

  public chartdata!: (PieArcDatum<InternalPieChartData> & DefaultArcObject)[];

  ngOnChanges(changes: any): void {
    ... 
    // calculate the pie slices from the input data
    const x: PieArcDatum<InternalPieChartData>[] = pieChartDataGenerator(this.data);

    // add the two fields required by the labelPositionGenerator:
    this.chartdata = x.map(element => {
        return <PieArcDatum<InternalPieChartData> & DefaultArcObject> {
        ...element,
        innerRadius: this.radius - 40,
        outerRadius: this.radius
        };
    });
   ...
}

Fazit

Was mich am meisten verblüfft hat, ist der Unterschied zwischen dem Bubble-Chart und dem Pie-Chart. Das Bubble-Chart kam fast ohne Unterstützung von D3.js aus. Das Pie-Chart verwendet die Funktionen von D3.js hingegen intensiv und die fertige Komponente ähnelt dem nativen D3-Algorithmus von Mike Bostock sehr. Vereinfacht ausgedrückt, haben wir lediglich die append()-Aufrufe des Originalalgorithmus durch die Template-Language von Angular ersetzt.

Nichtsdestotrotz haben wir jetzt eine Komponente, die sich nahtlos in das Ökosystem von Angular einfügt. Wir können Event Handler hinzufügen, die Methoden in anderen Angular-Komponenten aufrufen, und die Komponente wird automatisch neu gezeichnet, wenn sich die Daten ändern.

Ein Wunsch bleibt noch offen. Das Neu-Zeichnen der Daten passiert noch ohne jeden Übergang. Was wir uns wünschen, sind elegante Übergänge, Animationen und Transitionen. Wie das geht, erzählt mein Kollege Christoph Kaden im nächsten Teil dieser Serie.


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 -