Визуализация данных при помощи Angular и D3

в 17:24, , рубрики: angular, angular2, angular5, D3, d3.js, data, datavisualization, javascript, TypeScript, visualization

D3.js — это JavaScript библотека для манипулирования документами на основе входных данных. Angular — фреймворк, который может похвастаться высокой производительностью привязки данных.

Ниже я рассмотрю один хороший подход по использованию всей этой мощи. От симуляций D3 до SVG-инъекций и использования синтаксиса шаблонизатора.

image
Демо: положительные числа до 300 соединенные со своими делителями.

Для кулхацкеров, которые не будут читать данную статью, ссылка на репозиторий с кодом примера находится ниже. Для всех же остальных середнячков (конечно же, это не ты) код в этой статье упрощен для удобочитаемости.

Исходный код: https://github.com/lsharir/angular-d3-graph-example (недавно обновлен до Angular 5)
Демо: https://lsharir.github.io/angular-d3-graph-example/

Как запросто делать такие крутые ништяки

Ниже я представлю один подход к использованию Angular+D3. Мы пройдем следующие шаги:

  1. Инициализация проекта
  2. Создание интерфейсов d3 для angular
  3. Генерация симуляции
  4. Привязка данных симуляции к документу через angular
  5. Привязка пользовательского взаимодействия к графу
  6. Оптимизация производительности через механизм отслеживания изменений(change detection)
  7. Публикация и нытье по поводу стратегии версионирования angular

Итак, открывайте свой терминал, запускайте редакторы кода и не забудьте разгореть буфер обмена, начинаем погружение в код.

Структура приложения

Мы отделим код связанный с d3 и svg. Я опишу все поподробнее, когда будут созданы необходимые файлы, а пока вот структура нашего будущего приложения:

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

Инициализация Angular приложения

Запустите проект Angular приложения. Angular 5, 4 или 2 наш код был протестирован на всех трех версиях.

Если у вас еще нет angular-cli, быстренько его установите

npm install -g @angular/cli

Затем сгенерируйте новый проект:

ng new angular-d3-example

Ваше приложение создастся в папке angular-d3-example. Запустите команду ng serve из корня этой директории, приложение будет доступно по адресу localhost:4200.

Инициализация D3

Не забудьте установить и его TypeSctipt объявление.

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

Создание интерфейсов d3 для angular

Для корректного использования d3 (или любой другой библиотек) внутри фреймворка, лучше всего взаимодействовать через кастомный интферфейс, который мы определим посредством классов, angular сервисов и директив. Поступая таким образом, мы отделим главную функциональность от компонентов, которые будут ее использовать. Это сделает структуру нашего приложения более гибкой и масштабируемой, и изолирует баги.

Наша папка с D3 будеть иметь следующую структуру:

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

models обеспечат безопасность типов и будут предоставлять объекты datum.
directives будут указывать элементам, как использовать функционал d3.
d3.service.ts предоставит все методы, в пользование моделям d3, директивам, а также внешним компонентам приложения.

Этот сервис будет содержать вычислительные модели и поведения. Метод getForceDirectedGraph будет возвращать экземпляр ориентированного графа. Методы applyZoomableBehaviour иapplyDraggableBehaviour позволят связать пользовательское взаимодействие с соответствующими поведениями.

// 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)

Приступим к созданию класса ориентированного графа и сопутствующих моделей. Наш граф состоит из вершин(nodes) и дуг(links), давайте определим соответствующие модели.

// 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;
    }
}

После объявления основных моделей манипуляцией графом, давайте объявим модель самого графа.

// 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();
    }
}

Раз уж мы определили наши модели, давайте также обновим метод getForceDirectedGraph в D3Service

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

Создание экземпляра ForceDirectedGraph вернет следующий объект

ForceDirectedGraph {
ticker: EventEmitter,
simulation: Object
}

Этот объект содержит свойство simulation с переданными нами данными, а также свойство ticker содержащее event emitter, который срабатывает при каждом тике симуляции. Вот как мы будем этим пользоваться:

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

Остальные методы класса D3Service мы определим попозже, а пока попробуем привязять данные объекта simulation к документу.

Привязка симуляции

У нас есть экземляр объекта ForceDirectedGraph, он содержит постоянно-обновляемые данные вершин(node) и дуг(link). Вы можете привязать эти данные к документу, по-d3'шному (как дикарь):

function ticked() {
node
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; });
}

К счастью, на улице 21ый век, человечество эволюционировало к использованию инструментов эффективной привязки данных, вместо бездумного изменения аттрибутов элементов. Вот где Angular засверкает своими мышцами.

Интермедия: SVG и Angular

SVG шаблонизация с Angular

Запоздалая имплементация SVG, вылилась в создание ограничивающего пространства имен svg внутри html документа. Вот почему Angular не может распознать объявленные SVG элементы в темплейтах Angular компонентов (Если только они не есть явными потомками тега svg).

Чтобы правильно скомпилировать наши SVG элементы у нас есть два варианта:

  1. Занудно держать их всех внутри тега svg.
  2. Добавлять префикс “svg”, чтобы объяснить Angular'у, что происходит<svg:line>
<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 компоненты в Angular

Назначение селекторов компонентам, которые находятся в пространстве имен SVG не будет работать, как обычно. Они могут быть применены только через селектор аттрибута

<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
Заметьте префикс svg в шаблоне компонента

Конец интермедии

Привязка симуляции —  визуальная часть

Вооружившись древним знаением svg, мы можем начать создавать компоненты, которые будут одображать наши данные. Изолировав их в папке visuals, затем мы создадим папку shared (куда поместим компоненты, которые могут быть использованны другими видами графов) и главную папку graph, которая будет содержать весь код необходимый для отображения ориентированного графа (Force Directed Graph).

visuals
|- graph
|- shared

Визуализация графа

Создадим наш корневой компонент, который будет генерировать граф и привязывать его к документу. Мы передаем ему вершины(nodes) и дуги(links) через input-аттрибуты компонента.

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

Компонент принимает свойства nodes и links и создает экземпляр класса ForceDirectedGraph

// 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

Дальше, давайте добавим компонент для визуализации вершины(node), он будет отображать кружок с id вершины.

// 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

А вот и компонент для визуализации дуги(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;
}

Поведения

Вернемся как к d3-части приложения, начнем создание директив и методов для сервиса, которые дадут нам крутые способы взаимодействия с графом.

Поведение — зум

Добавим-ка привязки для функции зума, так чтобы потом это можно было запросто использовать:

<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);
    }
}

Поведение—перетаскивание 

Для добавления возможности перетаскивани, нам необходимо иметь доступ к объекту симуляции, чтобы можно было приостанавливать прорисовку при перетаскивании.

<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);
    }
}

Итак, что мы в итоге имеем:

  1. Генерация графа и симуляция через D3
  2. Привязка данных симуляции к документу при помощи Angular
  3. Пользовательское взаимодействие с графом через d3

Вы наверняка сейчас думаете: “Мои данные симуляции постоянно изменяются, angular при помощи отслеживания изменений(change detection) постоянно привязывает эти данные к документу, но зачем мне так делать, я хочу самостоятельно обновлять граф после каждого тика симуляции.”

Ну, вы отчасти правы, я сравнил результаты тестов производительности при разных механизмах отслеживания изменений и оказывается, что при потиковом применении изменений, мы получаем хороший прирост в производительности.

Angular, D3 и отслеживание изменений(Change Detection)

Установим отслеживание изменений в метод onPush (изменения будут отслежены только при полной замене ссылок на объекты).

Ссылки на объекты вершин и дуг не изменяются, соответсвенно и изменения не будут отслежены. Это замечательно! Теперь мы можем контроллировать отслеживание изменений и отмечать его на проверки при каждом тике симуляции (используя event emitter тикера, который мы установили).

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();
    });
  }
}

Теперь Angular будет обновлять граф на каждом тике, это то что нам надо.

Вот и все!

Вы пережили эту статью и создали крутую, масштабируемую визуализацию. Надеюсь что все было понятно и полезно. Если нет — дайте мне знать!

Спасибо за чтение!

Liran Sharir

Автор: Йосиф Крошный

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js