[Из песочницы] Простые графики с помощью D3.js
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, как на наиболее удобным для меня. Вы же можете использовать любой, какой понравится, или вообще генерировать данные непосредственно в программе.
На приведенном рисунке можно увидеть, как выглядят загруженные данные в нашей программе.
Если присмотреться внимательно к рисунку, то видно, что данные у нас загружены в виде строк, потому следующий этап работы программы — это приведение дат к типу данных 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]];
}
});
};
Здесь мы инициализируем функцию прасинга дат и затем для каждой строки данных конвертируем даты, а для заданных столбцов переводим строки в цифры.
После выполнения данной функции наши данные представляются уже в таком виде:
Сразу отвечаю на возможный вопрос — Зачем в этой функции усложнение с указанием списка столбцов, в которых нужно форматировать цифровые данные? — и ответ этот прост: в реальной таблице может быть (и есть) гораздо большее количество столбцов и не все из них могут быть цифровые. Да и строить непременно по всем столбцам графики мы не будем, так зачем же лишние манипуляции по преобразованию данных?
Прежде чем перейти к следующему действию, вспомним наш файл данных — в нем последовательно записаны сначала фактические, а затем проектные данные. Если мы сейчас отстроим данные, как они есть, то получим полную кашу. Потому что и факт, и план отрисуются в виде одной диаграммы. Поэтому проводим еще одну манипуляцию с данными при помощи функции D3 с любопытным названием nest (гнездо):
var dataGroup = d3.nest()
.key(function(d) { return d.Category; })
.entries(data);
В результате работы этой функции получаем следующий набор данных:
где мы видим что наш массив данных уже разбит на два подмассива: один факт, другой план.
Все, с подготовкой данных мы закончили — теперь переходим к заданию параметров для построения графика:
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);
Что же видим мы на этом графике? А видим мы, что планы были через чур оптимистичны и чтобы иметь достоверный прогноз нужно делать неизбежную корректировку. Также необходимо присмотреться и к производству, уж больно рваный фактический график… Ладно, ладно — это мы уже лезем туда, куда нас, программистов, никто не звал, поэтому возвращаемся к нашим баранам — как же этот график строится?
Снова повторю вызов функции построения графика:
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 от автора так все перемешано, что не понимая логики работы и не имея начальных знаний по этой библиотеке, очень трудно разобраться, где заканчиваются манипуляция с данными, а где начинается манипуляции с представлением этих данных, и наоборот.