D3.js — это JavaScript библотека для манипулирования документами на основе входных данных. Angular — фреймворк, который может похвастаться высокой производительностью привязки данных.
Ниже я рассмотрю один хороший подход по использованию всей этой мощи. От симуляций D3 до SVG-инъекций и использования синтаксиса шаблонизатора.
Демо: положительные числа до 300 соединенные со своими делителями.
Для кулхацкеров, которые не будут читать данную статью, ссылка на репозиторий с кодом примера находится ниже. Для всех же остальных середнячков (конечно же, это не ты) код в этой статье упрощен для удобочитаемости.
Исходный код (недавно обновлен до Angular 5)
Демо
Ниже я представлю один подход к использованию Angular+D3. Мы пройдем следующие шаги:
Итак, открывайте свой терминал, запускайте редакторы кода и не забудьте разгореть буфер обмена, начинаем погружение в код.
Мы отделим код связанный с d3 и svg. Я опишу все поподробнее, когда будут созданы необходимые файлы, а пока вот структура нашего будущего приложения:
Запустите проект Angular приложения. Angular 5, 4 или 2 наш код был протестирован на всех трех версиях.
Если у вас еще нет angular-cli, быстренько его установите
Затем сгенерируйте новый проект:
Ваше приложение создастся в папке
Не забудьте установить и его TypeSctipt объявление.
Для корректного использования d3 (или любой другой библиотек) внутри фреймворка, лучше всего взаимодействовать через кастомный интферфейс, который мы определим посредством классов, angular сервисов и директив. Поступая таким образом, мы отделим главную функциональность от компонентов, которые будут ее использовать. Это сделает структуру нашего приложения более гибкой и масштабируемой, и изолирует баги.
Наша папка с D3 будеть иметь следующую структуру:
Этот сервис будет содержать вычислительные модели и поведения. Метод
Приступим к созданию класса ориентированного графа и сопутствующих моделей. Наш граф состоит из вершин(nodes) и дуг(links), давайте определим соответствующие модели.
После объявления основных моделей манипуляцией графом, давайте объявим модель самого графа.
Раз уж мы определили наши модели, давайте также обновим метод
Создание экземпляра
Этот объект содержит свойство
Остальные методы класса
У нас есть экземляр объекта
app.component.html
link-example.component.html
Назначение селекторов компонентам, которые находятся в пространстве имен SVG не будет работать, как обычно. Они могут быть применены только через селектор аттрибута
app.component.html
link-example.component.ts
Заметьте префикс svg в шаблоне компонента
Вооружившись древним знаением svg, мы можем начать создавать компоненты, которые будут одображать наши данные. Изолировав их в папке
Создадим наш корневой компонент, который будет генерировать граф и привязывать его к документу. Мы передаем ему вершины(nodes) и дуги(links) через input-аттрибуты компонента.
Компонент принимает свойства
Дальше, давайте добавим компонент для визуализации вершины(node), он будет отображать кружок с id вершины.
А вот и компонент для визуализации дуги(link):
Вернемся как к d3-части приложения, начнем создание директив и методов для сервиса, которые дадут нам крутые способы взаимодействия с графом.
Добавим-ка привязки для функции зума, так чтобы потом это можно было запросто использовать:
Для добавления возможности перетаскивани, нам необходимо иметь доступ к объекту симуляции, чтобы можно было приостанавливать прорисовку при перетаскивании.
Итак, что мы в итоге имеем:
Вы наверняка сейчас думаете: “Мои данные симуляции постоянно изменяются, angular при помощи отслеживания изменений(change detection) постоянно привязывает эти данные к документу, но зачем мне так делать, я хочу самостоятельно обновлять граф после каждого тика симуляции.”
Ну, вы отчасти правы, я сравнил результаты тестов производительности при разных механизмах отслеживания изменений и оказывается, что при потиковом применении изменений, мы получаем хороший прирост в производительности.
Установим отслеживание изменений в метод onPush (изменения будут отслежены только при полной замене ссылок на объекты).
Ссылки на объекты вершин и дуг не изменяются, соответсвенно и изменения не будут отслежены. Это замечательно! Теперь мы можем контроллировать отслеживание изменений и отмечать его на проверки при каждом тике симуляции (используя event emitter тикера, который мы установили).
Теперь Angular будет обновлять граф на каждом тике, это то что нам надо.
Вы пережили эту статью и создали крутую, масштабируемую визуализацию. Надеюсь что все было понятно и полезно. Если нет — дайте мне знать!
Спасибо за чтение!
Liran Sharir
Ниже я рассмотрю один хороший подход по использованию всей этой мощи. От симуляций D3 до SVG-инъекций и использования синтаксиса шаблонизатора.
Демо: положительные числа до 300 соединенные со своими делителями.
Для кулхацкеров, которые не будут читать данную статью, ссылка на репозиторий с кодом примера находится ниже. Для всех же остальных середнячков (конечно же, это не ты) код в этой статье упрощен для удобочитаемости.
Исходный код (недавно обновлен до Angular 5)
Демо
Как запросто делать такие крутые ништяки
Ниже я представлю один подход к использованию 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; });
}<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);
}
}
Итак, что мы в итоге имеем:
- Генерация графа и симуляция через 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 будет обновлять граф на каждом тике, это то что нам надо.
Вот и все!
Вы пережили эту статью и создали крутую, масштабируемую визуализацию. Надеюсь что все было понятно и полезно. Если нет — дайте мне знать!
Спасибо за чтение!
Liran Sharir