Главная » Хабрахабр » [Перевод] Визуализация данных при помощи Angular и D3

[Перевод] Визуализация данных при помощи Angular и D3

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

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

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

Исходный код (недавно обновлен до Angular 5)
Демо

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

Ниже я представлю один подход к использованию 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 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; });
}<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 компоненты в 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"></graph>

Компонент принимает свойства 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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Перевод] Представляем Amazon Corretto, бесплатный дистрибутив OpenJDK с долгосрочной поддержкой

Многие наши клиенты стали беспокоиться о том, что они будут вынуждены платить за LTS-версию Java при выполнении своей рабочей нагрузки. Java является одним из самых популярных языков, используемых клиентами AWS, и мы стремимся поддерживать Java, сохраняя эту поддержку бесплатной. Однако, ...

Автомобиль на водороде. Пора ли прощаться с бензином?

К нашей прошлой статье о водородной энергетике вы написали очень интересные и справедливые комментарии, ответы на которые вы сможете найти в этом материале, посвященном использованию водорода в автомобилях. Привет, Хабр! Но при этом водород считается наиболее перспективным видом альтернативного топлива ...