Data Visualization with Angular and D3

D3.js is a JavaScript library for manipulating documents based on input data. Angular is a framework that boasts high performance data binding.

Below I will consider one good approach to using all this power. From D3 simulations to SVG injections and the use of template syntax.

image
Demo: positive numbers up to 300 connected with their dividers.

For coolers that will not read this article, the link to the repository with the example code is below. For all other middles (of course, this is not you), the code in this article is simplified for readability.

Source Code (recently updated to Angular 5)
Demo

How to easily make such cool nishtyaki


Below I will present one approach to using Angular + D3. We will go through the following steps:

  1. Project Initialization
  2. Creating angular d3 interfaces
  3. Simulation generation
  4. Binding simulation data to the document via angular
  5. Binding user interaction to the graph
  6. Performance optimization through change detection mechanism
  7. Publishing and nagging about angular versioning strategies

So, open your terminal, run the code editors and do not forget to inflame the clipboard, we begin to dive into the code.

Application structure


We will separate the code associated with d3 and svg. I will describe everything in more detail when the necessary files are created, but for now, here is the structure of our future application:

d3 |- models |- directives |- d3.service.ts visuals |- graph |- shared 

Initializing Angular Applications


Run the Angular application project. Angular 5, 4 or 2 our code has been tested on all three versions.

If you don't have angular-cli yet, install it quickly.

 npm install -g @angular/cli 

Then generate a new project:

 ng new angular-d3-example 

Your application will be created in the angular-d3-example folder. Run the ng serve command from the root of this directory, the application will be available at localhost:4200 .

D3 Initialization


Do not forget to install his TypeSctipt ad.

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

Creating angular d3 interfaces


For correct use of d3 (or any other libraries) inside the framework, it is best to interact through the custom interface, which we will define using classes, angular services and directives. By doing so, we will separate the main functionality from the components that will use it. This will make our application structure more flexible and scalable, and isolates bugs.

Our D3 folder will have the following structure:

 d3 |- models |- directives |- d3.service.ts 

models will provide type safety and will provide datum objects.
directives will tell the elements how to use the d3 functionality.
d3.service.ts will provide all the methods for using d3 models, directives, as well as external components of the application.

This service will contain computational models and behaviors. The getForceDirectedGraph method will return an instance of a directed graph. The applyZoomableBehaviour and applyDraggableBehaviour allow you to associate user interaction with the corresponding behaviors.

 // path : d3/d3.service.ts import { Injectable } from '@angular/core'; import * as d3 from 'd3'; @Injectable() export class D3Service { /** This service will provide methods to enable user interaction with elements * while maintaining the d3 simulations physics */ constructor() {} /** A method to bind a pan and zoom behaviour to an svg element */ applyZoomableBehaviour() {} /** A method to bind a draggable behaviour to an svg element */ applyDraggableBehaviour() {} /** The interactable graph we will simulate in this article * This method does not interact with the document, purely physical calculations with d3 */ getForceDirectedGraph() {} } 

Force Directed Graph


We proceed to the creation of a class of oriented graph and related models. Our graph consists of nodes (nodes) and arcs (links), let's define the corresponding models.

 // path : d3/models/index.ts export * from './node'; export * from './link'; // To be implemented in the next gist export * from './force-directed-graph'; 

 // path : d3/models/link.ts import { Node } from './'; // Implementing SimulationLinkDatum interface into our custom Link class export class Link implements d3.SimulationLinkDatum<Node> { // Optional - defining optional implementation properties - required for relevant typing assistance index?: number; // Must - defining enforced implementation properties source: Node | string | number; target: Node | string | number; constructor(source, target) { this.source = source; this.target = target; } } 

 // path : d3/models/node.ts // Implementing SimulationNodeDatum interface into our custom Node class export class Node extends d3.SimulationNodeDatum { // Optional - defining optional implementation properties - required for relevant typing assistance index?: number; x?: number; y?: number; vx?: number; vy?: number; fx?: number | null; fy?: number | null; id: string; constructor(id) { this.id = id; } } 

After the main models are declared by the graph manipulation, let's declare the model of the graph itself.

 // path : d3/models/force-directed-graph.ts import { EventEmitter } from '@angular/core'; import { Link } from './link'; import { Node } from './node'; import * as d3 from 'd3'; const FORCES = { LINKS: 1 / 50, COLLISION: 1, CHARGE: -1 } export class ForceDirectedGraph { public ticker: EventEmitter<d3.Simulation<Node, Link>> = new EventEmitter(); public simulation: d3.Simulation<any, any>; public nodes: Node[] = []; public links: Link[] = []; constructor(nodes, links, options: { width, height }) { this.nodes = nodes; this.links = links; this.initSimulation(options); } initNodes() { if (!this.simulation) { throw new Error('simulation was not initialized yet'); } this.simulation.nodes(this.nodes); } initLinks() { if (!this.simulation) { throw new Error('simulation was not initialized yet'); } // Initializing the links force simulation this.simulation.force('links', d3.forceLink(this.links) .strength(FORCES.LINKS) ); } initSimulation(options) { if (!options || !options.width || !options.height) { throw new Error('missing options when initializing simulation'); } /** Creating the simulation */ if (!this.simulation) { const ticker = this.ticker; // Creating the force simulation and defining the charges this.simulation = d3.forceSimulation() .force("charge", d3.forceManyBody() .strength(FORCES.CHARGE) ); // Connecting the d3 ticker to an angular event emitter this.simulation.on('tick', function () { ticker.emit(this); }); this.initNodes(); this.initLinks(); } /** Updating the central force of the simulation */ this.simulation.force("centers", d3.forceCenter(options.width / 2, options.height / 2)); /** Restarting the simulation internal timer */ this.simulation.restart(); } } 

Since we have defined our models, let's also update the getForceDirectedGraph method in D3Service

 getForceDirectedGraph(nodes: Node[], links: Link[], options: { width, height} ) { let graph = new ForceDirectedGraph(nodes, links, options); return graph; } 

Creating an instance of ForceDirectedGraph will return the following object

 ForceDirectedGraph { ticker: EventEmitter, simulation: Object } 

This object contains the simulation property with the data passed to us, as well as the ticker property containing the event emitter, which is triggered at each simulation tick. Here is how we will use it:

 graph.ticker.subscribe((simulation) => {}); 

We will define the rest of the D3Service class later, but for now let's try to bind the data of the simulation object to the document.

Simulation bind


We have an instance of the ForceDirectedGraph object, it contains constantly updated data of vertices (node) and arcs (link). You can bind this data to a document, in a d3's way (like a savage):

 function ticked() { node .attr("cx", function(d) { return dx; }) .attr("cy", function(d) { return dy; }); }<source>  ,   21 ,        ,     .   Angular   . <h3><i>: SVG  Angular</i></h3> <h3>SVG   Angular</h3>   SVG,       svg  html .   Angular     SVG    Angular  (        <code>svg</code>).     SVG      : <ol> <li>      <code>svg</code>.</li> <li>  “svg”,   Angular',  <code><svg:line></code></li> </ol> <source lang="xml"> <svg> <line x1="0" y1="0" x2="100" y2="100"></line> </svg> 

app.component.html

 <svg:line x1="0" y1="0" x2="100" y2="100"></svg:line> 

link-example.component.html

SVG components in Angular


Assigning selectors to components that are in the SVG namespace will not work as usual. They can only be applied via the attribute selector.

 <svg> <g [lineExample]></g> </svg> 

app.component.html

 import { Component } from '@angular/core'; @Component({ selector: '[lineExample]', template: `<svg:line x1="0" y1="0" x2="100" y2="100"></svg:line>` }) export class LineExampleComponent { constructor() {} } 

link-example.component.ts
Note the svg prefix in the component template

The end of the interlude


Simulation snapping - visual part


Armed with the ancient knowledge of svg, we can begin to create components that will tamper with our data. Isolating them in the visuals folder, then we will create a shared folder (where we will place the components that can be used by other types of graphs) and the main graph folder, which will contain all the code needed to display the directed graph (Force Directed Graph).

 visuals |- graph |- shared 

Graph visualization


Create our root component that will generate the graph and bind it to the document. We pass it nodes (nodes) and arcs (links) through the component's input attributes.

 <graph [nodes]="nodes" [links]="links"></graph> 

The component takes the nodes and links properties and creates an instance of the ForceDirectedGraph class

 // path : visuals/graph/graph.component.ts import { Component, Input } from '@angular/core'; import { D3Service, ForceDirectedGraph, Node } from '../../d3'; @Component({ selector: 'graph', template: ` <svg #svg [attr.width]="_options.width" [attr.height]="_options.height"> <g> <g [linkVisual]="link" *ngFor="let link of links"></g> <g [nodeVisual]="node" *ngFor="let node of nodes"></g> </g> </svg> `, styleUrls: ['./graph.component.css'] }) export class GraphComponent { @Input('nodes') nodes; @Input('links') links; graph: ForceDirectedGraph; constructor(private d3Service: D3Service) { } ngOnInit() { /** Receiving an initialized simulated graph from our custom d3 service */ this.graph = this.d3Service.getForceDirectedGraph(this.nodes, this.links, this.options); } ngAfterViewInit() { this.graph.initSimulation(this.options); } private _options: { width, height } = { width: 800, height: 600 }; get options() { return this._options = { width: window.innerWidth, height: window.innerHeight }; } } 

NodeVisual component


Next, let's add a component to render the node (node), it will display a circle with the id of the node.

 // path : visuals/shared/node-visual.component.ts import { Component, Input } from '@angular/core'; import { Node } from '../../../d3'; @Component({ selector: '[nodeVisual]', template: ` <svg:g [attr.transform]="'translate(' + node.x + ',' + node.y + ')'"> <svg:circle cx="0" cy="0" r="50"> </svg:circle> <svg:text> {{node.id}} </svg:text> </svg:g> ` }) export class NodeVisualComponent { @Input('nodeVisual') node: Node; } 

LinkVisual component


Here is the component for arc visualization (link):

 // path : visuals/shared/link-visual.component.ts import { Component, Input } from '@angular/core'; import { Link } from '../../../d3'; @Component({ selector: '[linkVisual]', template: ` <svg:line [attr.x1]="link.source.x" [attr.y1]="link.source.y" [attr.x2]="link.target.x" [attr.y2]="link.target.y" ></svg:line> ` }) export class LinkVisualComponent { @Input('linkVisual') link: Link; } 

Behavior


Let's go back to the d3-part of the application, let's start creating directives and methods for the service, which will give us cool ways to interact with the graph.

Behavior - Zoom


Add bindings for the zoom function, so that later it can be easily used:

 <svg #svg> <g [zoomableOf]="svg"></g> </svg> 

 // path : d3/d3.service.ts // ... export class D3Service { applyZoomableBehaviour(svgElement, containerElement) { let svg, container, zoomed, zoom; svg = d3.select(svgElement); container = d3.select(containerElement); zoomed = () => { const transform = d3.event.transform; container.attr("transform", "translate(" + transform.x + "," + transform.y + ") scale(" + transform.k + ")"); } zoom = d3.zoom().on("zoom", zoomed); svg.call(zoom); } // ... } 

 // path : d3/directives/zoomable.directive.ts import { Directive, Input, ElementRef } from '@angular/core'; import { D3Service } from '../d3.service'; @Directive({ selector: '[zoomableOf]' }) export class ZoomableDirective { @Input('zoomableOf') zoomableOf: ElementRef; constructor(private d3Service: D3Service, private _element: ElementRef) {} ngOnInit() { this.d3Service.applyZoomableBehaviour(this.zoomableOf, this._element.nativeElement); } } 

Behavior — Drag and Drop


To add drag and drop capabilities, we need to have access to the simulation object so that we can pause drawing while dragging.

 <svg #svg> <g [zoomableOf]="svg"> <!-- links --> <g [nodeVisual]="node" *ngFor="let node of nodes" [draggableNode]="node" [draggableInGraph]="graph"> </g> </g> </svg> 

 // path : d3/d3.service.ts // ... export class D3Service { applyDraggableBehaviour(element, node: Node, graph: ForceDirectedGraph) { const d3element = d3.select(element); function started() { /** Preventing propagation of dragstart to parent elements */ d3.event.sourceEvent.stopPropagation(); if (!d3.event.active) { graph.simulation.alphaTarget(0.3).restart(); } d3.event.on("drag", dragged).on("end", ended); function dragged() { node.fx = d3.event.x; node.fy = d3.event.y; } function ended() { if (!d3.event.active) { graph.simulation.alphaTarget(0); } node.fx = null; node.fy = null; } } d3element.call(d3.drag() .on("start", started)); } // ... } 

 // path : d3/directives/draggable.directives.ts import { Directive, Input, ElementRef } from '@angular/core'; import { Node, ForceDirectedGraph } from '../models'; import { D3Service } from '../d3.service'; @Directive({ selector: '[draggableNode]' }) export class DraggableDirective { @Input('draggableNode') draggableNode: Node; @Input('draggableInGraph') draggableInGraph: ForceDirectedGraph; constructor(private d3Service: D3Service, private _element: ElementRef) { } ngOnInit() { this.d3Service.applyDraggableBehaviour(this._element.nativeElement, this.draggableNode, this.draggableInGraph); } } 

So, what we have in the end:

  1. Graph generation and simulation via D3
  2. Binding simulation data to a document using Angular
  3. User interaction with the graph through d3

You are probably thinking now: “My simulation data is constantly changing, angular using change detection (change detection) constantly binds this data to a document, but why should I do that, I want to update the graph on my own after each tick of the simulation.”

Well, you are partly right, I compared the results of performance tests with different mechanisms for tracking changes and it turns out that when we apply changes in ticks, we get a good performance boost.

Angular, D3 and Change Detection


Install the change tracking in the onPush method (the changes will be tracked only when the object links are completely replaced).

References to vertex and arc objects do not change, respectively, and changes will not be tracked. It is wonderful! Now we can control the change tracking and mark it to be checked at every simulation tick (using the event emitter ticker that we set).

 import { Component, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; @Component({ selector: 'graph', changeDetection: ChangeDetectionStrategy.OnPush, template: `<!-- svg, nodes and links visuals -->` }) export class GraphComponent { constructor(private ref: ChangeDetectorRef) { } ngOnInit() { this.graph = this.d3Service.getForceDirectedGraph(...); this.graph.ticker.subscribe((d) => { this.ref.markForCheck(); }); } } 

Now Angular will update the graph on every tick, this is what we need.

That's all!


You survived this article and created a cool, scalable visualization. I hope that everything was clear and useful. If not, let me know!

Thank you for reading!

Liran sharir

Source: https://habr.com/ru/post/414785/


All Articles