This is about making charts in Angular without having to make the data first. The usual way to draw a chart is to build an object that describes it and hand that to a chart component. The other way is to write the chart in the template, out of components, the same way as the rest of the page.
The data-driven way
The way that seems natural at first for developers is to put the data into an object. The chart's data gets types of its own, kept simple and immutable:
export interface BarChartItem {
readonly label: string;
readonly value: number;
readonly color: string;
}
export interface BarChart {
readonly title: string;
readonly items: ReadonlyArray<BarChartItem>;
}
The page builds one, and passes it in as an input:
export class SalesPageComponent {
readonly salesChart: BarChart = {
title: 'Sales (£k)',
items: [
{ label: 'Jan', value: 42, color: '#0e7490' },
{ label: 'Feb', value: 38, color: '#0e7490' },
{ label: 'Mar', value: 51, color: '#0e7490' },
{ label: 'Apr', value: 47, color: '#0e7490' },
{ label: 'May', value: 60, color: '#0e7490' },
{ label: 'Jun', value: 66, color: '#c2410c' }
]
};
}
<chart-bar [chart]="salesChart"></chart-bar>
And the chart component goes through the items and draws a bar for each one:
@Component({
selector: 'chart-bar',
template: `
<h3>{{ chart.title }}</h3>
<div class="bars">
<div class="item" *ngFor="let item of chart.items">
<div class="track">
<div class="bar" [style.height.%]="item.value / max * 100" [style.background]="item.color"></div>
</div>
<div class="label">{{ item.label }}</div>
</div>
</div>
`
})
export class ChartBarComponent {
@Input() chart: BarChart;
get max(): number {
return Math.max(...this.chart.items.map(item => item.value));
}
}
It works, but every kind of chart needs its own set of types, and this adds a layer of data conversion from what the application already has into the objects required for the chart. The chart is described in TypeScript, away from the template where the rest of the page is described. And because the data is immutable, changing one bar means building a new chart object.
The declarative way
The declarative way avoids all of those types. Instead of one chart component that takes an object, there's a chart-bar component and a chart-bar-item component, and the item has its own label, value and colour:
<chart-bar title="Sales (£k)">
<chart-bar-item label="Jan" [value]="42"></chart-bar-item>
<chart-bar-item label="Feb" [value]="38"></chart-bar-item>
<chart-bar-item label="Mar" [value]="51"></chart-bar-item>
<chart-bar-item label="Apr" [value]="47"></chart-bar-item>
<chart-bar-item label="May" [value]="60"></chart-bar-item>
<chart-bar-item label="Jun" [value]="66" color="#c2410c"></chart-bar-item>
</chart-bar>
It draws exactly the same chart. When the numbers come from the application, the items come straight from whatever data is already there, with no chart object in between:
<chart-bar title="Sales (£k)">
<chart-bar-item
*ngFor="let month of report.months"
[label]="month.name"
[value]="month.sales">
</chart-bar-item>
</chart-bar>
This saves having both the data objects describing the chart and the conversion code. The chart only needs to know one thing about its items: the largest value, to set the scale. It finds its items with @ContentChildren, and each item draws its own bar, asking the chart it's in for the scale. An item gets the chart through dependency injection, just by asking for it in its constructor, because the chart is the element around it:
@Component({
selector: 'chart-bar',
template: `
<h3>{{ title }}</h3>
<div class="bars"><ng-content></ng-content></div>
`,
styles: [`
.bars { display: flex; gap: 16px; height: 240px; }
`]
})
export class ChartBarComponent {
@Input() title = '';
@ContentChildren(forwardRef(() => ChartBarItemComponent)) items: QueryList<ChartBarItemComponent>;
get max(): number {
return this.items ? Math.max(0, ...this.items.map(item => item.value)) : 0;
}
}
@Component({
selector: 'chart-bar-item',
template: `
<div class="track">
<div class="bar" [style.height.%]="height" [style.background]="color"></div>
</div>
<div class="label">{{ label }}</div>
`,
styles: [`
:host { flex: 1; display: flex; flex-direction: column; }
.track { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; }
.bar { border-radius: 3px; }
.label { text-align: center; padding-top: 6px; }
`]
})
export class ChartBarItemComponent {
@Input() label = '';
@Input() value = 0;
@Input() color = '#0e7490';
constructor(private chart: ChartBarComponent) {}
get height(): number {
return this.chart.max ? this.value / this.chart.max * 100 : 0;
}
}
This is the whole idea: lean on the template, instead of building data in TypeScript to pass into a component. There's less code, because nothing is turned into chart types and back again, and the page stays a description of what's on it.
Taking it further
Getting rid of the chart data types is only the start. Because now each part of the chart is a component in the template, it can have content of its own. With one <ng-content> added inside the item's bar, anything put inside a chart-bar-item is shown in the bar itself:
<div class="bar" [style.height.%]="height" [style.background]="color">
<ng-content></ng-content>
</div>
So ordinary HTML goes inside the bars, written where the chart is:
<chart-bar title="Sales (£k)">
<chart-bar-item
*ngFor="let month of report.months"
[label]="month.name"
[value]="month.sales"
[color]="month.best ? '#c2410c' : '#0e7490'">
<strong>{{ month.sales }}</strong>
<span class="badge" *ngIf="month.best">Best month</span>
</chart-bar-item>
</chart-bar>
Everything else Angular templates do works on the parts of a chart in the same way: *ngIf on an item to leave a bar out, (click) on an item to open that month, a class or a tooltip on one bar. This stuff can get arbitrarily complex if you are using data to describe the chart; you are basically rebuilding Angular and the DOM piece by piece with every requirement that comes in. A tooltip wants a title and a line of text, an overlay wants a button, and before long the chart's options are describing titles, text and buttons: a second, smaller copy of the application's component library, growing a field at a time. Once a chart needs more than numbers, you either abstract more and more of your interface into a data object, or you write the chart in the template, where all those components already exist.
A pie chart
Other kinds of chart are written the same way. A pie chart is a chart-pie with a chart-pie-slice for each slice. The slices are SVG groups rather than elements of their own, for a reason that comes up below:
<chart-pie title="Sales by region">
<svg:g chart-pie-slice label="North" [value]="120" color="#0e7490"></svg:g>
<svg:g chart-pie-slice label="South" [value]="80" color="#c2410c"></svg:g>
<svg:g chart-pie-slice label="East" [value]="60" color="#64777e"></svg:g>
<svg:g chart-pie-slice label="West" [value]="40" color="#9fc9d6"></svg:g>
</chart-pie>
A line chart
A line chart has one more level: a chart-line-series for each line, and a chart-line-point for each point on it:
<chart-line title="Sales (£k)" [max]="80">
<svg:g chart-line-series label="2018" color="#64777e">
<svg:g chart-line-point
*ngFor="let month of lastYear.months"
[label]="month.name"
[value]="month.sales">
</svg:g>
</svg:g>
<svg:g chart-line-series label="2019" color="#0e7490">
<svg:g chart-line-point
*ngFor="let month of thisYear.months"
[label]="month.name"
[value]="month.sales">
</svg:g>
</svg:g>
</chart-line>
What each part really is
Every part of these charts is a real component that draws itself. The chart only does the part that needs all of its items at once.
A bar item draws its own bar, with its own content in it, and the chart only gives it the scale. That's easy, because each bar is a box of its own, and the bars sit side by side.
A pie slice is harder. Where a slice starts depends on every slice before it, and the whole pie is one SVG drawing, not boxes side by side. So the pie works out the angles, and each slice draws itself with them, as an SVG group, in the same way as SVG components in Angular. That's why the slices are written as <svg:g chart-pie-slice>: they're in the page's template, outside any <svg>, so they need the prefix to be made as SVG groups, and the pie projects them into its own <svg>.
The pie provides an object through dependency injection, a PieLayout, which works out where every slice goes. Each slice asks for it in its constructor, tells it when its value changes, and reads its own path back from it:
export interface PieArc {
readonly path: string;
readonly index: number;
}
const NO_ARC: PieArc = { path: '', index: 0 };
@Injectable()
export class PieLayout {
private slices: ReadonlyArray<ChartPieSliceComponent> = [];
private arcs = new Map<ChartPieSliceComponent, PieArc>();
setSlices(slices: ReadonlyArray<ChartPieSliceComponent>): void {
this.slices = slices;
this.update();
}
update(): void {
const total = this.slices.reduce((sum, slice) => sum + slice.value, 0);
const arcs = new Map<ChartPieSliceComponent, PieArc>();
let start = 0;
this.slices.forEach((slice, index) => {
const end = start + slice.value / total;
arcs.set(slice, { path: arcPath(start, end), index });
start = end;
});
this.arcs = arcs;
}
arcFor(slice: ChartPieSliceComponent): PieArc {
return this.arcs.get(slice) || NO_ARC;
}
}
@Component({
selector: 'chart-pie',
providers: [PieLayout],
template: `
<h3>{{ title }}</h3>
<svg viewBox="0 0 400 200">
<ng-content></ng-content>
</svg>
`
})
export class ChartPieComponent implements AfterContentInit {
@Input() title = '';
@ContentChildren(forwardRef(() => ChartPieSliceComponent)) slices: QueryList<ChartPieSliceComponent>;
constructor(private layout: PieLayout) {}
ngAfterContentInit(): void {
this.layout.setSlices(this.slices.toArray());
this.slices.changes.subscribe(() => this.layout.setSlices(this.slices.toArray()));
}
}
@Component({
selector: 'g[chart-pie-slice]',
template: `
<svg:path [attr.d]="arc.path" [attr.fill]="color"></svg:path>
<svg:g svg-translate [x]="250" [y]="40 + arc.index * 30">
<svg:g svg-rect [width]="14" [height]="14" [fill]="color"></svg:g>
<svg:g svg-text [x]="24" [y]="12" [size]="13">{{ label }}</svg:g>
<svg:g svg-text [x]="120" [y]="12" [size]="13" anchor="end" fill="#64777e">{{ value }}</svg:g>
</svg:g>
<ng-content></ng-content>
`
})
export class ChartPieSliceComponent implements OnChanges {
@Input() label = '';
@Input() value = 0;
@Input() color = '#0e7490';
constructor(private layout: PieLayout) {}
get arc(): PieArc {
return this.layout.arcFor(this);
}
ngOnChanges(): void {
this.layout.update();
}
}
const CX = 100;
const CY = 100;
const RADIUS = 95;
function point(fraction: number): string {
const angle = 2 * Math.PI * fraction - Math.PI / 2;
return `${CX + RADIUS * Math.cos(angle)} ${CY + RADIUS * Math.sin(angle)}`;
}
function arcPath(start: number, end: number): string {
const large = end - start > 0.5 ? 1 : 0;
return `M ${CX} ${CY} L ${point(start)} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${point(end)} Z`;
}
Getting the order right
The pie has to know the order of its slices, and that needs @ContentChildren. The obvious way is for each slice to add itself to the layout in its constructor, but then the slices are in the order they were made. A list from *ngFor starts out in the right order, but when a slice is later added to the middle of the list, that slice is made last, and it's drawn at the end of the pie.
@ContentChildren keeps its list in the order of the template, and tells you whenever it changes, so the pie takes the order from there and hands it to the layout. That also covers slices being added and removed, so they don't need to register themselves at all. Dependency injection is only how each slice finds the layout.
The layout works out the arcs again only when the slices or their values change, not every time the pie is checked, so each slice reads the same path until something actually changes.
Content in a slice
Because a slice draws itself, it can have content of its own, like a bar can. Anything inside a chart-pie-slice goes into its group, so it has to be SVG rather than HTML, but a label or a marker for one slice is still written on that slice, where the chart is.
The line chart works the same way. The chart works out the scale and where each point goes, each series draws its line through its points, in the order @ContentChildren finds them, and each point draws its own marker. So a point can have content too, like a label on the best month.
This is the idea from DOM as data: the components in the template are the chart's data, as well as being what's drawn. There's no chart object anywhere, only the template, and each kind of chart decides how to draw what the page says it is.
See also
- SVG components in Angular - components that are part of an SVG drawing, and a bar chart made from them
- DOM as data - a CSV download whose rows and columns are components, previewed in the page
- Angular best practices for large teams: lean on the template - the principle behind these charts, and the user interface library they were part of