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
- charts
- 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.
- Create a new method: setTooltip
setTooltip (): void {}
- 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;
}
- 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
- 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.
- Create a new directive for a custom template
We will add a directive that we can use to display our data in a custom way and leverage the @angular/common pipes to display the date property and 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({ |
- 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> |
- 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;
- 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.
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
- directives
- app.component.ts
- app.component.html
- app.module.ts
The files that have been edited or added should be the following
- d3-tooltip.directive
import { Directive, TemplateRef } from “@angular/core”;
@Directive({ |
- 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;
}
- 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));
});
}
}
- 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> |
- 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 complete reference.