Главная » Хабрахабр » [Перевод] Визуализация данных при помощи 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 Интересное!

[Перевод] Браузеры отключают звук в вашем WebRTC-приложении. Стоп, что?

Технология WebRTC (голосовые и видеозвонки) хороша тем, что встроена прямо в веб, который, разумеется, прекрасно подходит для WebRTC. Однако иногда веб доставляет немало хлопот, когда нужды WebRTC идут вразрез с общими требованиями к использованию браузеров. Последний пример – автовоспроизведение (далее ...

Создатель игры while True: learn() о программировании в геймдеве, проблемах с VR и симуляции ML

Постоянно выступал, проводил Gamesjam, был частым гостем подкаста Как делают игры. Несколько лет назад мне казалось, что Олег Чумаков (тогда еще из Nival) был самым известным программистом геймдева. Но вы все знаете, с виртуальной реальностью что-то пошло не так, как ...