Data Visualization with Angular and D3

https://medium.com/netscape/visualizing-data-with-angular-and-d3-209dde784aeb
  • Transfer
  • Tutorial
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 folder angular-d3-example. Run the command ng servefrom 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 the structure of our application more flexible and scalable, and isolates the bugs.

Our D3 folder will have the following structure:

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

modelswill provide type safety and will provide datum objects.
directiveswill tell the elements how to use the d3 functionality.
d3.service.tswill 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 method getForceDirectedGraphwill return an instance of a directed graph. Methods applyZoomableBehaviourand applyDraggableBehaviourallow you to associate user interaction with the appropriate behaviors.

// path : d3/d3.service.tsimport { Injectable } from'@angular/core';
import * as d3 from'd3';
@Injectable()
exportclassD3Service{
    /** 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.tsexport * from'./node';
export * from'./link';
// To be implemented in the next gistexport * from'./force-directed-graph';

// path : d3/models/link.tsimport { Node } from'./';
// Implementing SimulationLinkDatum interface into our custom Link classexportclassLinkimplementsd3.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 classNodeextendsd3.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.tsimport { 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 classForceDirectedGraph{
    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 simulationthis.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 chargesthis.simulation = d3.forceSimulation()
            .force("charge",
                d3.forceManyBody()
                    .strength(FORCES.CHARGE)
            );
            // Connecting the d3 ticker to an angular event emitterthis.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 method getForceDirectedGraphinD3Service

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

Creating an instance ForceDirectedGraphwill return the next object.

ForceDirectedGraph {
  ticker: EventEmitter,
  simulation: Object
}

This object contains a property simulationwith the data transmitted by us, as well as a property tickercontaining an event emitter, which is triggered with each simulation tick. Here is how we will use it:

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

D3ServiceWe will define the rest of the class methods later, but for now let's try to bind the object data simulationto the document.

Simulation bind


We have an instance of the object ForceDirectedGraph, 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 d.x; })
        .attr("cy", function(d) { return d.y; });
}<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><sourcelang="xml"><svg><linex1="0"y1="0"x2="100"y2="100"></line></svg>

app.component.html

<svg:linex1="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>`
})
exportclassLineExampleComponent {
    constructor() {}
}

link-example.component.ts
Notice 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 a folder visuals, then we will create a folder shared(where we will place the components that can be used by other types of graphs) and the main folder graph, 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>

Component takes properties nodesand linkscreates an instance of the classForceDirectedGraph

// path : visuals/graph/graph.component.tsimport { 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 classGraphComponent{
  @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() {
    returnthis._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.tsimport { 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 classNodeVisualComponent{
  @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 classD3Service {
  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.tsimport { Directive, Input, ElementRef } from '@angular/core';
import { D3Service } from '../d3.service';
@Directive({
    selector: '[zoomableOf]'
})
export classZoomableDirective{
    @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 classD3Service {
  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.tsimport { Directive, Input, ElementRef } from '@angular/core';
import { Node, ForceDirectedGraph } from '../models';
import { D3Service } from '../d3.service';
@Directive({
    selector: '[draggableNode]'
})
export classDraggableDirective{
    @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 probably are 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 myself after each simulation tick.”

Well, you're kind of right , I compared the results of performance tests with different mechanisms for tracking changes, and it turns out that when we apply changes, we get a good performance gain.

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

Also popular now: