В далеком 2016 году я занялся разработкой дашбордов. Я использовал d3 для построения интерактивных диаграмм, поддерживаемых старым добрым jQuery.

Затем, как это бывает со всеми успешными проектами, я стал получать все больше и больше запросов, запросов, которые усложняли проект. Вскоре мой хороший код превратился в хорошее блюдо из спагетти. Я терял контроль над кодом и знал, что пришло время оценить будущее проекта.

В конце концов я выбрал Angular и начал свои проекты с нуля. Angular — фантастический фреймворк, но даже сегодня он не превосходит потрясающие функциональные возможности d3 в отношении создания диаграмм. И поэтому я пошел на компромисс, сбалансировав две конкурирующие библиотеки для управления DOM: SVG для D3, все остальное для Angular. И это сработало! На самом деле, это сработало так хорошо, что когда-то одна html-страница + d3 + jQuery превратилась в полнофункциональную экосистему (laravel API + Angular Framework, управляющая несколькими интерактивными панелями + d3 для создания диаграмм).

Есть ли система, которая позволяет нам развернуть новый интерактивный инструмент за долю времени, которая потребовалась бы, если бы нам нужно было начать с нуля?

И все начинается с

import * as d3 from ‘d3’;

Оттуда вы захватываете хост-компонент во время создания с помощью

constructor(_elementRef: ElementRef) {
    this._host = _elementRef.nativeElement;
}

И после захвата svg в ngOnInit все готово. Если вы знаете d3 и знаете Angular, то вы знаете, как делать диаграммы в Angular.

ngOnInit(): void {
    this.svg = d3.select(this._host).select('svg');
}

Все еще сомневаетесь в гибридных отношениях между D3 и Angular? Итак, вот практический пример:

Отслеживание данных Covid с течением времени

Итак, вот наша история:

Ваш босс попросил вас добавить простую диаграмму для визуализации данных Covid в США. Ему нужна полноценная линейная диаграмма, на которой пользователь может просматривать данные, наводя курсор на диаграмму и выделяя определенные части информации. На диаграмме должны использоваться последние дневные данные, доступные из API The COVID Tracking Project.

Он хочет увидеть первую итерацию диаграммы как можно скорее.

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

Это деликатная идея проверить, соответствуют ли данные вашей диаграмме. Визуализация данных — это рассказывание историй. Если пользователи не могут создавать свои собственные истории с помощью визуализаций, цель теряется.

Получение данных из API

Начнем с простого сервиса ApiService для получения данных из API.

Добавление компонента диаграммы

Затем мы создаем компонент LineChart с вводом данных (из API) и добавляем его в AppComponent.

Внутренний код диаграммы

Теперь, когда диаграмма интегрирована в приложение, пора приступить к работе с d3.

Наше программирование d3 должно отражать физическую (т.е. цифровую) версию диаграммы.

Итак, наш список покупок (для упрощенной версии):

  • Размеры диаграммы
  • Внутренние размеры (размеры — поля)
  • Горизонтальная ось - с представленными месяцами.
  • По вертикальной оси — количество случаев ковида.
  • Шкала времени — d3.scaleTime
  • CovidNumberScale — d3.scaleLinear
  • Цветовая шкала, для раскрашивания линий — d3.scaleOrdinal(d3.schemeCategory10)
  • Функция для преобразования строк времени из API в даты — d3.timeParse('%Y%m%d')
  • Генератор строк для преобразования данных в пути (линии svg) — d3.line
  • Выбор параметров для отображения: ["hospitalized", "death"]
  • Преобразователь данных для преобразования данных API в данные для диаграммы.
  • Легенда

Жизненный цикл диаграммы

График имеет три основных этапа:

  • Инициализация — добавляются все фиксированные (например, вспомогательные контейнеры и метки, размеры) и динамические (линии, легенды, оси, масштабы…)
  • Внешние изменения — когда обновляются все динамические компоненты.
  • Внутренние выделения и фильтрация (например, при наведении на строки отображается всплывающая подсказка)

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

Этап инициализации

Мы настраиваем:

  • SVG: this.svg = this.host.select('svg')
  • Габаритные размеры:
margins = {
  top: 20,
  right: 80,
  bottom: 20,
  left: 30
};
const dims = this.svg.node().getBoundingClientRect();
this.innerWidth = dims.width - this.margins.left - this.margins.right;
this.innerHeight = dims.height - this.margins.top - this.margins.bottom;
  • контейнеры оси, контейнер данных, легенда Контейнер заголовок, правильно расположен
// horizontal axis container
this.svg.append('g').attr('class', 'horizontalAxisContainer')
  .attr('transform', `translate(${this.margins.left}, ${this.margins.top + this.innerHeight})`);

// vertical axis container
this.svg.append('g').attr('class', 'verticalAxisContainer')
  .attr('transform', `translate(${this.margins.left}, ${this.margins.top})`);

// data container
this.svg.append('g').attr('class', 'dataContainer')
  .attr('transform', `translate(${this.margins.left}, ${this.margins.top})`);

// legend container
this.svg.append('g').attr('class', 'legend')
  .attr('transform', `translate(${this.margins.left + this.innerWidth}, ${this.margins.top})`);

// title
this.svg.append('g').attr('transform', `translate(${this.margins.left + 0.5 * this.innerWidth}, 20)`)
  .append('text')
  .attr('class', 'label label--title')
  .style('text-anchor', 'middle')
  .text('Covid 19 evolution in the US');

Этап внешних изменений

Мы настраиваем все динамические элементы, которые зависят от данных

  • Преобразованные данные для строк
// lines data generates the series for the lines
this.linesData = this.variables.map((v) => {
  return {
    name: v,
    data: this.data.map((d) => {
      return {
        x: this.timeParse(d.date),
        y: d[v]
      };
    })
  };
});
  • Весы
// scales
const timeDomain = d3.extent(this.data, d => this.timeParse(d.date));
const maxValue = d3.max(this.linesData, d => d3.max(d.data, elem => elem.y));

this.timeScale = d3.scaleTime()
  .domain(timeDomain || [])
  .range([0, this.innerWidth]);

this.covidNumbersScale = d3.scaleLinear()
  .domain([0, maxValue])
  .range([this.innerHeight, 0]);

this.colors = d3.scaleOrdinal(d3.schemeCategory10)
  .domain(this.variables);
  • Ось
// axis
const horizontalAxis = d3.axisBottom(this.timeScale).ticks(d3.timeMonth.every(2)).tickSizeOuter(0);

const verticalAxis = d3.axisLeft(this.covidNumbersScale).tickFormat(d3.format('~s'));
this.svg.select('g.horizontalAxisContainer').call(horizontalAxis);

this.svg.select('g.verticalAxisContainer').call(verticalAxis);
  • Линии
// line generator
this.lineGenerator = d3.line()
  .x(d => this.timeScale(d.x))
  .y(d => this.covidNumbersScale(d.y));
//draw lines

this.svg.select('g.dataContainer')
  .selectAll('path.data')
  .data(this.linesData, (series) => series.name)
  .join(
    enter => enter.append('path').attr('class', 'data')
        .style('fill', 'none')
        .style('stroke', series => this.colors(series.name))
        .attr('d', d => this.lineGenerator(d.data)),
    update => update
      .call(upd => upd.transition()
        .attr('d', d => this.lineGenerator(d.data))
      ),
    exit => exit.remove()
  );
  • Легенда (продолжение следует)

Вывод

Добавлять сгенерированные d3 диаграммы в d3 так же просто, как работать только с d3, и даже проще, учитывая, что мы можем использовать всю мощь машинописного языка.

При некотором опыте мы можем создать полную диаграмму за один день или меньше, в зависимости от ее сложности и доступных функций.