main banner

Development

Angular and Data-Driven Documents (D3.js) Part 2: Adding Interactivity

In a previous article, I wrote a simple D3.js plot chart. The chart represents a simple line chart that displays visually the price of Bitcoin over time (here is a link to the example). This chart only includes the visualization but it does not have any interactivity. In this article, I will show how to add a simple tooltip and some interactivity to show how D3 and Angular can play along and achieve great results.

The project so far



As of now, the project structure looks as follows:


app

  • D3

    • charts

      • line-chart.component.ts

      • line-chart.component.html

    • d3-visualization.component.ts

    • d3-visualization.component.html

    • d3-visualization.service.ts

  • app.component.ts

  • app.component.html

  • app.module.ts


Adding lines and circle on hover

When we hover over the chart we want to display two lines that point to each axis for reference. In that intersection, we will display a circle. These lines will appear on the “mouseover” event, disappear on “mouseout” events, and update on “mousemove” event.

 

1. Create a new method: setTooltip


setTooltip (): void {}

 

2. We will add the following:


We will create a new container that will listen to mouse events. For this, we create a selection object as a component attribute.


focus: Selection<any, any, any, any>;


Inside setTooltip()we set the following property to focus:


this.focus = this.svg
.append("g")
.attr("class", "focus")
.style("display", "none");


We are appending a ‘g’ element to our existing svg element. Next, we create the lines and circle that will display on ‘mousemove’.


this.focus
.append("line")
.attr("class", "x-hover-line hover-line")
.attr("y1", 0)
.attr("y2", this.height);

this.focus
.append("line")
.attr("class", "y-hover-line hover-line")
.attr("x1", 0)
.attr("x2", this.width);

this.focus.append("circle").attr("r", 7.5);


Next, we append a rect element that measures the width and height of the chart. Here we add all the event listeners. For this, we will need to add the following styles (line-chart.component.css):


::ng-deep .line {
fill: none;
stroke-width: 2px;
}

::ng-deep .overlay {
fill: none;
pointer-events: all;
}

::ng-deep .focus circle {
fill: #f1f3f3;
stroke: #777;
stroke-width: 3px;
}

::ng-deep .hover-line {
stroke: #777;
stroke-width: 2px;
stroke-dasharray: 3, 3;
}


  1. Call setTooltip() inside ngOnInit().


ngOnInit(): void {
this.setSvgArea();
this.setAxes();
this.setTooltip();
this.displayLine();

}


At this point, if you hover over your chart you should see two sets of lines and a circle. The next thing we want to do is adding a tooltip that displays the date and value of the hovered element.


Adding a tooltip

 

1. Add tooltip styling


For our tooltip, we can use the following styles:


.d3-tooltip {
position: absolute;
top: 50;
left: 130px;
display: block;
width: auto;
height: auto;
padding: 0.5rem 1rem;
background-color: #777;
border: 1px solid #777;
color: #f1f3f3;
border-radius: 5px;
z-index: 8;
}


This can be added in line-chart.component.css.

 

2. Create a new directive for a custom template

 

We will add a directive that we can use so that we can display our data in a custom way and leverage the @angular/common pipes to display the date property and display the value as currency. So let's go ahead and create it in the Angular CLI.


ng g directive D3/directives/d3-tooltip.directive


Our directive should look like this:


import { Directive, TemplateRef } from "@angular/core";

@Directive({
selector: "[d3Tooltip]",
})
export class D3TooltipDirective {
constructor(public tpl: TemplateRef<any>) {}
}

 

3. Create tooltip template


Next, we can add out the tooltip template in the d3-visualization.component as a child element of app-line-chart.


<ng-container *ngIf="data$ | async as data">
<app-line-chart [data]="data">
<ng-template d3Tooltip let-d>
<ng-container *ngIf="d">
<p>
Date:
<span class="font-weight-bold">{{
d.date | date: "mediumDate"
}}</span>
</p>
<hr />
<p>
<span class="font-weight-bold">{{
d.value | currency: "USD"
}}</span>
<span class="font-italic"> (USD)</span>
</p>
</ng-container>
</ng-template>
</app-line-chart>
</ng-container>

 

4. Add tooltip reference


After creating the template we need to reference it in line-chart.component.html and line-chart.component.ts. 


Edit line-chart.component.html to look like this: 


<div class="card">
<div class="card-header">Example: Bitcoin Price Over Time</div>
<div class="card-body" style="position: relative">
<div *ngIf="tooltipTemplate && hovered" class="d3-tooltip">
<ng-container
*ngTemplateOutlet="
tooltipTemplate?.tpl;
context: { $implicit: hovered }
"
></ng-container>
</div>
<figure #chartArea></figure>
</div>
</div>


Inside line-chart.component.ts we should add the template reference:


@ContentChild(D3TooltipDirective) tooltipTemplate: D3TooltipDirective;

 

5. Make the tooltip interactive


We need a property that contains the hovered element:


hovered: { date: Date; value: number };


This needs to be updated every time we receive a “mousemove” event, so for this purpose, we will add this line inside setTooltip:


this.hovered = d;


setTooltip should look like this:


setTooltip(): void {
this.focus = this.svg
.append("g")
.attr("class", "focus")
.style("display", "none");

this.focus
.append("line")
.attr("class", "x-hover-line hover-line")
.attr("y1", 0)
.attr("y2", this.height);

this.focus
.append("line")
.attr("class", "y-hover-line hover-line")
.attr("x1", 0)
.attr("x2", this.width);

this.focus.append("circle").attr("r", 7.5);

this.svg
.append("rect")
.attr("class", "overlay")
.attr("width", this.width)
.attr("height", this.height)
.on("mouseover", () => this.focus.style("display", null))
.on("mouseout", () => {
this.focus.style("display", "none");
this.hovered = undefined;
})
.on("mousemove", (e) => {
const bisectDate = bisector((d: any) => d.date).left;
const x0 = this.x.invert(pointer(e)[0]);
const i = bisectDate(this.data, x0, 1);
const d0 = this.data[i - 1];
const d1 = this.data[i];
const d = (x0 as any) - d0.date > d1.date - (x0 as any) ? d1 : d0;

this.hovered = d;

this.focus.attr(
"transform",
`translate(${this.x(d.date)}, ${this.y(d.value)})`
);

this.focus
.select(".x-hover-line")
.attr("y2", this.height - this.y(d.value));

this.focus.select(".y-hover-line").attr("x2", -this.x(d.date));
});
}


In conclusion


With all the elements in place, the result will be what the image below shows.

Bitcoin Over time

 

The project structure with the new elements should be something similar to this.


app

  • D3

    • directives

      • d3-tooltip.directive.ts

    • charts

      • line-chart.component.css

      • line-chart.component.ts

      • line-chart.component.html

    • d3-visualization.component.ts

    • d3-visualization.service.ts

  • app.component.ts

  • app.component.html

  • app.module.ts


The files that have been edited or added should be the following

 

1. d3-tooltip.directive


import { Directive, TemplateRef } from "@angular/core";

@Directive({
selector: "[d3Tooltip]",
})
export class D3TooltipDirective {
constructor(public tpl: TemplateRef<any>) {}
}

 

2. line-chart.component.css


::ng-deep .line {
fill: none;
stroke-width: 2px;
}

::ng-deep .overlay {
fill: none;
pointer-events: all;
}

::ng-deep .focus circle {
fill: #f1f3f3;
stroke: #777;
stroke-width: 3px;
}

::ng-deep .hover-line {
stroke: #777;
stroke-width: 2px;
stroke-dasharray: 3, 3;
}

.d3-tooltip {
position: absolute;
top: 50;
left: 130px;
display: block;
width: auto;
height: auto;
padding: 0.5rem 1rem;
background-color: #777;
border: 1px solid #777;
color: #f1f3f3;
border-radius: 5px;
z-index: 8;
}

 

3. line-chart.component.ts

 

import {
Component,
ContentChild,
ElementRef,
Input,
OnInit,
ViewChild,
} from "@angular/core";
import {
Selection,
select,
scaleTime,
scaleLinear,
max,
extent,
axisBottom,
axisLeft,
line,
pointer,
ScaleTime,
ScaleLinear,
bisector,
} from "d3";
import { D3TooltipDirective } from "../directives/d3-tooltip.directive";

@Component({
selector: "app-line-chart",
templateUrl: "./line-chart.component.html",
styleUrls: ["./line-chart.component.css"],
})
export class LineChartComponent implements OnInit {
@Input() data: { date: any; value: number }[];
@ViewChild("chartArea", { static: true }) chartArea: ElementRef<HTMLElement>;
@ContentChild(D3TooltipDirective) tooltipTemplate: D3TooltipDirective;

margin = { top: 10, right: 20, bottom: 30, left: 60 };
width: number;
height = 400 - this.margin.top - this.margin.bottom;
svg: Selection<any, any, any, any>;
x: ScaleTime<any, any>;
y: ScaleLinear<any, any>;
focus: Selection<any, any, any, any>;
hovered: { date: Date; value: number };

ngOnInit(): void {
this.setSvgArea();
this.setAxes();
this.setTooltip();
this.displayLine();
}

setSvgArea(): void {
this.width =
this.chartArea.nativeElement.offsetWidth -
this.margin.left -
this.margin.right;

this.svg = select(this.chartArea.nativeElement)
.append("svg")
.attr("width", this.width + this.margin.left + this.margin.right)
.attr("height", this.height + this.margin.top + this.margin.bottom)
.append("g")
.attr("transform", `translate(${this.margin.left}, ${this.margin.top})`);
}

setAxes(): void {
this.x = scaleTime()
.domain(extent(this.data, (d) => d.date))
.range([0, this.width]);
this.y = scaleLinear()
.domain([0, max(this.data, (d) => d.value)])
.range([this.height, 0]);

this.svg
.append("g")
.attr("transform", `translate(0, ${this.height})`)
.call(axisBottom(this.x));
this.svg.append("g").call(axisLeft(this.y));
}

displayLine(): void {
this.svg
.append("path")
.attr("class", "line")
.datum(this.data)
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("strokewidth", 1.5)
.attr(
"d",
line<{ date: any; value: number }>()
.x((d) => this.x(d.date))
.y((d) => this.y(d.value))
);
}

setTooltip(): void {
this.focus = this.svg
.append("g")
.attr("class", "focus")
.style("display", "none");

this.focus
.append("line")
.attr("class", "x-hover-line hover-line")
.attr("y1", 0)
.attr("y2", this.height);

this.focus
.append("line")
.attr("class", "y-hover-line hover-line")
.attr("x1", 0)
.attr("x2", this.width);

this.focus.append("circle").attr("r", 7.5);

this.focus.append("text").attr("x", 15).attr("dy", ".31em");

this.svg
.append("rect")
.attr("class", "overlay")
.attr("width", this.width)
.attr("height", this.height)
.on("mouseover", () => this.focus.style("display", null))
.on("mouseout", () => {
this.focus.style("display", "none");
this.hovered = undefined;
})
.on("mousemove", (e) => {
const bisectDate = bisector((d: any) => d.date).left;
const x0 = this.x.invert(pointer(e)[0]);
const i = bisectDate(this.data, x0, 1);
const d0 = this.data[i - 1];
const d1 = this.data[i];
const d = (x0 as any) - d0.date > d1.date - (x0 as any) ? d1 : d0;

this.hovered = d;

this.focus.attr(
"transform",
`translate(${this.x(d.date)}, ${this.y(d.value)})`
);

this.focus
.select(".x-hover-line")
.attr("y2", this.height - this.y(d.value));

this.focus.select(".y-hover-line").attr("x2", -this.x(d.date));
});
}
}

 

4. line-chart.component.html


<div class="card">
<div class="card-header">Example: Bitcoin Price Over Time</div>
<div class="card-body" style="position: relative">
<div *ngIf="tooltipTemplate && hovered" class="d3-tooltip">
<ng-container
*ngTemplateOutlet="
tooltipTemplate?.tpl;
context: { $implicit: hovered }
"
></ng-container>
</div>
<figure #chartArea></figure>
</div>
</div>

 

5. d3-visualization.component.ts


import { Component, OnDestroy, OnInit } from "@angular/core";
import { Observable, Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { D3VisualizationService } from "../D3/d3-visualization.service";

@Component({
selector: "app-d3-visualization",
template: `
<ng-container *ngIf="data$ | async as data">
<app-line-chart [data]="data">
<ng-template d3Tooltip let-d>
<ng-container *ngIf="d">
<p>
Date:
<span class="font-weight-bold">{{
d.date | date: "mediumDate"
}}</span>
</p>
<hr />
<p>
<span class="font-weight-bold">{{
d.value | currency: "USD"
}}</span>
<span class="font-italic"> (USD)</span>
</p>
</ng-container>
</ng-template>
</app-line-chart>
</ng-container>
`,
})
export class D3VisualizationComponent implements OnInit, OnDestroy {
data$: Observable<any>;
private unsubscribe$ = new Subject<void>();

constructor(private service: D3VisualizationService) {}

ngOnInit(): void {
this.data$ = this.service.fetchData().pipe(takeUntil(this.unsubscribe$));
}

ngOnDestroy(): void {
this.unsubscribe$.next();
this.unsubscribe$.complete();
}
}


A complete example can be found in Stackblitz for a complete reference.


Arturo E.

Amateur Musician, Combat Sports Enthusiast, Philosophy and history lover and a software engineer specialized in Javascript technologies.

Articles