[Из песочницы] Простые графики с помощью D3.js

Gualtiero Boffi | Dreamstime.com

D3.js (или просто D3) — это JavaScript-библиотека для обработки и визуализации данных с невероятно огромными возможностями. Я, когда впервые узнал про нее, наверное, потратил не менее двух часов, просто просматривая примеры визуализации данных, созданных на D3. И конечно, когда мне самому понадобилось строить графики для небольшого внутреннего сайта на нашем предприятии, первым делом вспомнил про D3 и с мыслью, что «сейчас я всех удивлю крутейшей визуализацией», взялся изучать исходники примеров…

… и понял, что сам абсолютно ничего не понимаю! Странная логика работы библиотеки, в примерах целая куча строк кода, чтобы создать простейший график — это был конечно же удар, главным образом по самолюбию. Ладно, утер сопли — понял, что с наскоку D3 не взять и для понимания этой библиотеки надо начинать с самых ее основ. Потому решил пойти другим путем — взять за основу для своих графиков одну из библиотек — надстроек на D3. Как выяснилось, библиотек таких довольно много — значит не один я такой, непонимающий (говорило мое поднимающееся из пепла самолюбие).

Попробовав несколько библиотек, остановился на dimple как на более или менее подходящей для моих нужд, отстроил с ее помощью все свои графики, но неудовлетворенность осталась. Некоторые вещи работали не так, как хотелось бы, другой функционал без глубокого копания в dimple не удалось реализовать, и он был отложен. Да и вообще, если нужно глубоко копать, то лучше это делать напрямую с D3, а не с дополнительной настройкой, богатый функционал которой в моем случае используется не более, чем на пять-десять процентов, а вот нужных мне настроек наоборот не хватало. И поэтому случилось то, что случилось — D3.js.


Попытка номер два

Первым делом перечитал все, что есть по D3 на Хабре. И в комментарии под одной из статей увидел ссылку на книгу Interactive Data Visualization for the Web. Открыл, глянул, начал читать — и моментально втянулся! Книга написана на простом и понятном английском, плюс автор сам по себе прекрасный и интересный рассказчик, хорошо раскрывающий тему D3 с азов.

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

Итак (мое самое любимое слово), приступим.

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

Представьте себе какой нибудь завод по добыче и переработке, допустим, железной руды на каком нибудь условном месторождении («свечной заводик бери», — напоминают мне крылатую фразу классиков литературы из-за плеча, но данные уже подготовлены, — потому свечной заводик отложен до следующего раза).

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

Вышеназванные данные сервер нам будет предоставлять в виде следующего tsv файла:

Category    Date    Metal month    Mined %
fact    25.10.2010    2234    0.88
fact    25.11.2010    4167    2.55
...
plan    25.09.2010    1510    1
plan    25.10.2010    2790    2
plan    25.11.2010    3820    4
...

Где в столбце Category находятся плановые или фактические значения, Date — это данные за каждый месяц (у нас датируются 25-м числом), Metal month — сколько металла за месяц, которое мы запланировали (или получили) и столбец Mined % — какой процент металла добыт на текущий момент.

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

Первым делом с помощью функции d3.tsv загрузим данные:

d3.tsv("sample.tsv", function(error, data) {
      if (error) throw error;
    //здесь будет дальнейший код построения графика
}

Загрузка данных в D3 очень проста. Если вам нужно загрузить данные в другом формате, например в csv, просто меняйте меняете вызов с d3.tsv на d3.сsv. Ваши данные в формате JSON? Меняете вызов на d3.json. Я пробовал все три формата и остановился на tsv, как на наиболее удобным для меня. Вы же можете использовать любой, какой понравится, или вообще генерировать данные непосредственно в программе.

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

i01

Если присмотреться внимательно к рисунку, то видно, что данные у нас загружены в виде строк, потому следующий этап работы программы — это приведение дат к типу данных date, а цифровых значений — к типу numeric. Без этих приведений D3 не сможет правильно обрабатывать даты, а к цифровым значениям будет применять избирательный подход, т.е. какие-то цифры будут браться, а другие — просто игнорироваться. Для этих приведений вызовем следующую функцию:

      preParceDann("Date","%d.%m.%Y",["Metal month","Mined %"],data);

В параметрах этой функции мы передаем название столбца, в котором записаны даты, формат даты в соответствии с правилами записи дат в D3; затем идет массив с названиями столбцов, для которых нужно сделать преобразование цифровых значений. И последний параметр — это данные, которые мы загрузили ранее. Сама функция преобразования совсем небольшая и потому, чтобы снова к ней не возвращаться, приведу ее сразу:

function preParceDann(dateColumn,dateFormat,usedNumColumns,data){
  var parse = d3.time.format(dateFormat).parse;
  data.forEach(function(d) {
    d[dateColumn] = parse(d[dateColumn]);
    for (var i = 0, len = usedNumColumns.length; i < len; i += 1) {
          d[usedNumColumns[i]] = +d[usedNumColumns[i]];
    }
  });
};

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

После выполнения данной функции наши данные представляются уже в таком виде:

i02

Сразу отвечаю на возможный вопрос — Зачем в этой функции усложнение с указанием списка столбцов, в которых нужно форматировать цифровые данные? — и ответ этот прост: в реальной таблице может быть (и есть) гораздо большее количество столбцов и не все из них могут быть цифровые. Да и строить непременно по всем столбцам графики мы не будем, так зачем же лишние манипуляции по преобразованию данных?

Прежде чем перейти к следующему действию, вспомним наш файл данных — в нем последовательно записаны сначала фактические, а затем проектные данные. Если мы сейчас отстроим данные, как они есть, то получим полную кашу. Потому что и факт, и план отрисуются в виде одной диаграммы. Поэтому проводим еще одну манипуляцию с данными при помощи функции D3 с любопытным названием nest (гнездо):

      var dataGroup = d3.nest()
          .key(function(d) { return d.Category; })
          .entries(data);

В результате работы этой функции получаем следующий набор данных:

i03

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

Все, с подготовкой данных мы закончили — теперь переходим к заданию параметров для построения графика:

      var param = {
        parentSelector: "#chart1",
        width: 600,
        height: 300,
        title: "Iron mine work",
        xColumn: "Date",
        xColumnDate: true,
        yLeftAxisName: "Tonnes",
        yRightAxisName: "%",
        categories: [
          {name: "plan", width: "1px"},
          {name: "fact", width: "2px"}
        ],
        series: [
          {yColumn: "Metal month", color: "#ff6600", yAxis: "left"},
          {yColumn: "Mined %", color: "#0080ff", yAxis: "right"}
        ]
      };

Здесь все просто:


Параметр Значение
parentSelector id элемента нашей странички, в котором будет отстроен график
width: 600 ширина
height: 300 высота
title: «Iron mine work» заголовок
xColumn: «Date» название столбца, из которого будут браться координаты для оси Х
xColumnDate: true если true, то ось x — это даты (к сожалению, данный функционал еще недоделан, т.е. по оси x мы можем строить только даты)
yLeftAxisName: «Tonnes» название левой оси y
yRightAxisName:»%» названия правой оси y
categories: долго думал, как же назвать то. что вылетает из «гнезда» D3 и ничего лучше категорий не придумал. Для каждой категории задается наименование — как она прописана в наших данных и ширина построения
series: обственно, сами диаграммы, задаем, из какого столбца берем значения для оси y, цвет, и к какой оси диаграмма будет относится, левой или правой

Все исходные данные мы задали, теперь наконец вызываем построение графика и наслаждаемся результатом:

      d3sChart(param,data,dataGroup);

i04

Что же видим мы на этом графике? А видим мы, что планы были через чур оптимистичны и чтобы иметь достоверный прогноз нужно делать неизбежную корректировку. Также необходимо присмотреться и к производству, уж больно рваный фактический график… Ладно, ладно — это мы уже лезем туда, куда нас, программистов, никто не звал, поэтому возвращаемся к нашим баранам — как же этот график строится?

Снова повторю вызов функции построения графика:

      d3sChart(param,data,dataGroup);

Глядя на нее возникает резонный вопрос, который вы возможно хотите мне задать — Зачем в функцию передаются два массива данных, data и dataGroup? Отвечаю: исходный массив данных нужен для того, чтобы правильно задать диапазон данных для осей. Подозреваю, звучит это не очень понятно —, но постараюсь вскоре этот момент объяснить.

Первое что мы выполняем в функции построения — это проверяем, есть ли вообще в наличии объект, в котором мы будем строить график. И если этого самого объекта нет — сильно ругаемся:

function d3sChart (param,data,dataGroup){

  // check availability the object, where is displayed chart
  var selectedObj = null;
  if (param.parentSelector === null || param.parentSelector === undefined) { parentSelector = "body"; };
  selectedObj = d3.select(param.parentSelector);
  if (selectedObj.empty()) {
      throw "The '" + param.parentSelector + "' selector did not match any elements.  Please prefix with '#' to select by id or '.' to select by class";
  };

Следующие наши действия: инициализируем различные отступы, размеры и создаем шкалы.

  var margin = {top: 30, right: 40, bottom: 30, left: 50},
      width = param.width - margin.left - margin.right,
      height = param.height - margin.top - margin.bottom;

  // set the scale for the transfer of real values
  var xScale = d3.time.scale().range([0, width]);
  var yScaleLeft = d3.scale.linear().range([height, 0]);
  var yScaleRight = d3.scale.linear().range([height, 0]);

Не забываем, что библиотека наша только-только вылупилась и настраивать кое — какие вещи (например отступы) извне еще не приучена, ввиду искуственно мной ускоренного инкубационного процесса. Поэтому еще раз прошу понять и простить.

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

Продолжаем, шкалы мы создали, теперь нужно их настроить. И вот здесь то как раз и пригодится тот исходный набор данных. Если совсем по-простому — предыдущими действиями мы задали диапазон шкал в координатах графика, следующими же командами мы связываем этот диапазон с диапазонами данных:

  xScale.domain([d3.min(data, function(d) { return d[param.xColumn]; }), d3.max(data, function(d) { return d[param.xColumn]; })]);
  yScaleLeft.domain([0,d3.max(data, function(d) { return d[param.series[0].yColumn]; })]);
  yScaleRight.domain([0,d3.max(data, function(d) { return d[param.series[1].yColumn]; })]);

Для шкалы X мы задаем минимальным значением минимальную дату в наших данных, максимальным — максимальную. Для осей Y за минимум берем 0, максимум же также узнаем из данных. Вот для этого и были нужны не разбитые данные — чтобы узнать минимальные и максимальные значения.

Следующее действие — настраиваем оси. Тут начинается небольшая путаница. В D3 есть шкалы (scales) и оси (axis). Шкалы отвечают за преобразование исходных координат в координаты области построения, оси же предназначены для отображения на графиках тех палочек и черточек, которые мы видим на графике и которые по русски называются «шкалы координат, отложенные по осям X и Y». Поэтому, в дальнейшем, если я пишу шкала, имейте ввиду что речь идет об axis, т.е. об отрисовке шкалы на графике.

Итак, напоминаю — у нас две шкалы для оси Y и одна шкала для оси X, с которой пришлось изрядно повозиться. Дело в том, что меня совершенно не устраивало, как D3 по умолчанию выводит шкалу дат. Но все мои попытки настроить подписи дат так, как мне это нужно, разбивались, как волны, о скалы мощности и монументальности этой библиотеки. Потому пришлось пойти на подлог и обман: я создал две шкалы по оси X. На одной шкале у меня выводятся только годы, на другой месяцы. Для месяцев добавлена небольшая проверка, которая исключает первый месяц из вывода. И ведь всего пару предложений назад я обвинял эту библиотеку в монументальности, а тут такой замечательный пример гибкости.

  var xAxis = d3.svg.axis().scale(xScale).orient("bottom")
              .ticks(d3.time.year,1).tickFormat(d3.time.format("%Y"))
              .tickSize(10);
  var monthNameFormat = d3.time.format("%m");
  var xAxis2 = d3.svg.axis().scale(xScale).orient("bottom")
              .ticks(d3.time.month,2).tickFormat(function(d) { var a = monthNameFormat(d); if (a == "01") {a = ""}; return a;})
              .tickSize(2);
  var yAxisLeft = d3.svg.axis().scale(yScaleLeft).orient("left");
  var yAxisRight = d3.svg.axis().scale(yScaleRight).orient("right");

Продолжаем рассматривать код. Все подготовительные работы мы провели и теперь приступаем непосредственно к формированию изображения. Следующие 4 строчки кода последовательно создают область svg, рисуют оконтуривающую рамку, создают с заданным смещением группу объектов svg, в которой будет строится наш график. И последнее действие — выводится заголовок.

  var svg = selectedObj.append("svg")
      .attr({width: param.width, height: param.height});

  // outer border
  svg.append("rect").attr({width: param.width, height: param.height})
                    .style({"fill": "none", "stroke": "#ccc"});

  // create group in svg for generate graph
  var g = svg.append("g").attr({transform: "translate(" + margin.left + "," + margin.top + ")"});

  // add title
  g.append("text").attr("x", margin.left) .attr("y", 0 - (margin.top / 2))
        .attr("text-anchor", "middle").style("font-size", "14px")
        .text(param.title);

Следующий большой кусок кода подписывает единицы измерения наших 3-х осей. Думаю здесь все понятно и подробно рассматривать не нужно:

  g.append("g").attr("class", "x axis").attr("transform", "translate(0," + height + ")")
    .call(xAxis)
    .append("text")
      .attr("x", width-20).attr("dx", ".71em")
      .attr("y", -4).style("text-anchor", "end")
      .text(param.xColumn);
  g.append("g").attr("class", "x axis2").attr("transform", "translate(0," + height + ")")
    .call(xAxis2);
  g.append("g").attr("class", "y axis")
    .call(yAxisLeft)
    .append("text").attr("transform", "rotate(-90)")
      .attr("y", 6).attr("dy", ".71em").style("text-anchor", "end")
      .text(param.yLeftAxisName);
  g.append("g").attr("class", "y axis").attr("transform", "translate(" + width + " ,0)")
    .call(yAxisRight)
    .append("text").attr("transform", "rotate(-90)")
      .attr("y", -14).attr("dy", ".71em").style("text-anchor", "end")
      .text(param.yRightAxisName);

Ну и, наконец, ядро функции построения графика — отрисовка самих диаграмм:

  dataGroup.forEach(function(d, i) {
    for (var i = 0, len = param.categories.length; i < len; i += 1) {
      if (param.categories[i].name == d.key){
        for (var j = 0, len1 = param.series.length; j < len1; j += 1) {
          if (param.series[j].yAxis == "left"){
            // init line for left axis
            var line = d3.svg.line()
              .x(function(d) { return xScale(d[param.xColumn]); })
              .y(function(d) { return yScaleLeft(d[param.series[j].yColumn] ); });
          };
          if (param.series[j].yAxis == "right"){
            // init line for right axis
            var line = d3.svg.line()
              .x(function(d) { return xScale(d[param.xColumn]); })
              .y(function(d) { return yScaleRight(d[param.series[j].yColumn] ); });
          };
          // draw line
          g.append("path").datum(d.values)
            .style({"fill": "none", "stroke": param.series[j].color, "stroke-width": param.categories[i].width})
            .attr("d", line);
        };
      };
    };
  });

«Три вложенных друг в друга цикла!» — в ярости воскликните вы. И будете совершенно правы в своем негодовании — сам не люблю делать такие вложенные конструкции, но иногда приходится. В третьей вложенности цикла мы инициализируем наши линии диаграмм, где в зависимости от series указываем, к правой или левой шкале будет относится эта линия. После этого, во второй вложенности мы уже выводим линию на график, задавая ее толщину из свойств категорий. Т.е. фактически у нас на построении задействованы всего две строчки кода, все остальное лишь обвязка, необходимая для обработки различного количества диаграмм на графике.

Ну и последние действие с нашим графиком — это вывод легенды. С легендой я каюсь — тут уже торопился и сделал ее на тяп-ляп, код этот будет в скором времени переписан и показываю я его лишь для того чтобы еще раз продемонстрировать, что в D3 все довольно таки просто. А еще — вот хороший пример того, как делать не нужно:

var legend = svg.append("g").attr("class", "legend").attr("height", 40).attr("width", 200)
  .attr("transform", "translate(180,20)");   

  legend.selectAll('rect').data(param.series).enter()
    .append("rect").attr("y", 0 - (margin.top / 2)).attr("x", function(d, i){ return i *  90;})
    .attr("width", 10).attr("height", 10)
    .style("fill", function(d) {return d.color; });

  legend.selectAll('text').data(param.series).enter()
    .append("text").attr("y", 0 - (margin.top / 2)+10).attr("x", function(d, i){ return i *  90 + 11;})
    .text(function(d) { return d.yColumn; });

// add legend for categories
var legend1 = svg.append("g").attr("class", "legend").attr("height", 40).attr("width", 200)
  .attr("transform", "translate(350,20)");   

  legend1.selectAll('line').data(param.categories).enter()
    .append("line").attr("y1", 0 - (margin.top / 2)+5).attr("x1", function(d, i){ return i *  60;})
    .attr("y2", 0 - (margin.top / 2)+5).attr("x2", function(d, i){ return i *  60+15;})
    .style("stroke", "black").style("stroke-width", function(d) { return d.width; });

  legend1.selectAll('text').data(param.categories).enter()
    .append("text").attr("y", 0 - (margin.top / 2)+10).attr("x", function(d, i){ return i *  60 + 17;})
    .text(function(d) { return d.name; });

Вот и все. Спасибо за внимание! Надеюсь, что не разочаровал вас своей статьей.

Код и исходный пример данных можно скачать с Гитхаба.

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

© Habrahabr.ru