Angular / d3.js / charts

ngx-d3-pie-chart

Posted on

I promised to much. My last post showed how to implement a D3.js chart without having to understand D3.js. But it's not always that easy. Today's example is slightly more difficult. We'll end up with an Angular component that's almost identical to the native D3.js solution.

We're going to write an Angular pie chart component. The article is a bit heavy on the source-code side. Probably it's easier to understand if you open the source code in your IDE. I've uploaded the source code to GitHub and the binaries to npm.

Sketch of the final component

The idea is to write a component you can use like any other Angular component:

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

Reverse engeneering

My previous article suggested to reverse-engeneer an existing demo, so that's what we'll do. I've selected Mike Bostock's demo published in November 2017. Mike Bostock is one of the key developers of the D3.js project (and probably even the soul of the project).

Reverse engeneering confronted me with SVG code that's not easily translated to easy-to-grasp coordinates:

<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>

The original code looked even worse. I've omitted the decimals for the sake of brevity. Even so, the source code looks a bit shocking. If you know your math, you may guess that the coordinates of the text are calculated by geometric functions - something linke sin() and cos(). But what does a path definition like d="M107,-214A240,240,0,0,1,226,-79L0,0Z" mean?

SVG path programming language

I guess you're a hurried developer, so you're more interested in the result. But if you're curious, have a look at this nice explanation of SVG paths. The long string of numbers and letters is a tiny programming language. Actually, it's quite simple:

M107,-214
Put the virtual pen down at (107, -214)
A240,240,0,0,1,226,-79
Draw an elliptical arc with radius 240. The center of the arc is (0,0). It's drawn from the current location of the pen to (226, -79). Putting it all together, that's a pie slice.
L0,0
draw a line to (0, 0)
Z
End of the path

So all we have to do is to look in our Math textbook, find out how the sinus and cosinus functions work, and assemble the path string manually. Along the way, you'll notice that you need tangens and arcustangens instead of sin() and cos(). At this point, things usually get a bit messy.

Approach 1: let D3 calculate the path

Fear not. That's exactly what D3.js does. What we're looking for is the micro-library d3-path. We can use it as a stand-alone library. First, we have to install it:

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

The type definition file is annoying because it never seems to be up-to-date. Prepare yourself for declaring functions that didn't make it into the @types/d3 library. If you're a nice person, please contribute your addition to the @types project while you're at it. Thanks!

Be that as it may, the type definitions will make our live both easier and more complicated. D3.js has been written with dynamic types in mind. We'll see that in a minute. I suggest to add it anyway. I consider type safety and the better editor support convincing enough.

The nice thing about d3/path is that is has the API as Canvas. We calculate the arc like so:

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());

Degrees and radiant

That's better. Now we only have to calculate the start angle and the end angle of the arc. However, this angle is not given in degrees, but in a number popular among mathematicians. 180° is equivalent to "one pi radiant". 360° is 2 pi radiant. So the pie chart coordinates are calculated like so:

@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;
    });
}

Approach 2: let D3.js do the hard work for you

This approach works, but looking at Mike's native D3.js solution, it's a bit cumbersome. You have to know a lot about math. By adding two other D3.js micro-libraries we can avoid that:

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

Now we can make use of the functions arc() and pie. This, in turn, makes it easier to add labels to the pie chart slices. By the way, you find the HTML template a at the end of the article.

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>

Types and data structures

It's high time to show you the underlying data structure. First, there's the API show to the user:

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

The user passes the numerical value determining the size of the slice, a text, and a color. Internally, we need to store the path of the arc and the position of the text:

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

The pie() function takes that object and returns a PieArcDatum, which contains the angles and stores the original object in the data attribute. The function used to calculate the position of the text expects a DefaultArcObject.

All this results in a conundrum of defining and converting types. I suppose the D3.js tradition is to simply add the required values to the existing data structure. My TypeScript solution copies and maps the data to achieve the same goal:

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
        };
    });
   ...
}

Wrapping it up?

Comparing the final algorithm to Mike's native D3.js approach, it's astonishing that this time both algorithms are almost identical. Simplifying things a bit, all we did is to remove the append() function calls from the algorithm and move them to the HTML snippet of the component.

Nonetheless, the new component integrates nicely into the Angular environment. We can add event handlers calling methods in an Angular component, and the component is automatically redrawn when the input data changes.