D3.js — это JavaScript библотека для манипулирования документами на основе входных данных. Angular — фреймворк, который может похвастаться высокой производительностью привязки данных.
Ниже я рассмотрю один хороший подход по использованию всей этой мощи. От симуляций D3 до SVG-инъекций и использования синтаксиса шаблонизатора.
Демо: положительные числа до 300 соединенные со своими делителями.
Для кулхацкеров, которые не будут читать данную статью, ссылка на репозиторий с кодом примера находится ниже. Для всех же остальных середнячков (конечно же, это не ты) код в этой статье упрощен для удобочитаемости.
Исходный код: https://github.com/lsharir/angular-d3-graph-example (недавно обновлен до Angular 5)
Демо: https://lsharir.github.io/angular-d3-graph-example/
Как запросто делать такие крутые ништяки
Ниже я представлю один подход к использованию Angular+D3. Мы пройдем следующие шаги:
- Инициализация проекта
- Создание интерфейсов d3 для angular
- Генерация симуляции
- Привязка данных симуляции к документу через angular
- Привязка пользовательского взаимодействия к графу
- Оптимизация производительности через механизм отслеживания изменений(change detection)
- Публикация и нытье по поводу стратегии версионирования 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 элементы у нас есть два варианта:
- Занудно держать их всех внутри тега
svg
. - Добавлять префикс “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);
}
}
Итак, что мы в итоге имеем:
- Генерация графа и симуляция через D3
- Привязка данных симуляции к документу при помощи Angular
- Пользовательское взаимодействие с графом через 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 будет обновлять граф на каждом тике, это то что нам надо.
Вот и все!
Вы пережили эту статью и создали крутую, масштабируемую визуализацию. Надеюсь что все было понятно и полезно. Если нет — дайте мне знать!
Спасибо за чтение!
Автор: Йосиф Крошный