На пути к обновляемым диаграммам D3.js

Опубликовано: 2022-03-11

Введение

D3.js — это библиотека с открытым исходным кодом для визуализации данных, разработанная Майком Бостоком. D3 означает документы, управляемые данными, и, как следует из названия, библиотека позволяет разработчикам легко создавать и манипулировать элементами DOM на основе данных. Хотя D3.js не ограничен возможностями библиотеки, он обычно используется с элементами SVG и предлагает мощные инструменты для разработки визуализаций векторных данных с нуля.

Обновляемый шаблон диаграммы позволяет легко создавать диаграммы D3.js
Твитнуть

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

 var milesRun = [2, 5, 4, 1, 2, 6, 5]; d3.select('body').append('svg') .attr('height', 300) .attr('width', 800) .selectAll('rect') .data(milesRun) .enter() .append('rect') .attr('y', function (d, i) { return i * 40 }) .attr('height', 35) .attr('x', 0) .attr('width', function (d) { return d*100}) .style('fill', 'steelblue');

Чтобы увидеть его в действии, зайдите на bl.ocks.org.

Если этот код выглядит знакомым, это здорово. Если нет, я обнаружил, что учебные пособия Скотта Мюррея являются отличным ресурсом для начала работы с D3.js.

Как фрилансер, проработавший сотни часов при разработке с помощью D3.js, мой шаблон разработки претерпел эволюцию, всегда с конечной целью создания наиболее полного клиентского и пользовательского опыта. Как я расскажу более подробно позже, шаблон Майка Бостока для повторно используемых диаграмм предлагает проверенный и верный метод для реализации одной и той же диаграммы в любом количестве выборов. Однако его ограничения реализуются после инициализации диаграммы. Если бы я хотел использовать переходы D3 и шаблоны обновления с помощью этого метода, изменения в данных должны были обрабатываться полностью в той же области, в которой была сгенерирована диаграмма. На практике это означало реализацию фильтров, раскрывающихся списков, ползунков и параметров изменения размера в рамках одной и той же функции.

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

D3.js Графики Паттерн Прогрессия

Шаг 1: Переменные конфигурации

Когда я начал использовать D3.js для разработки визуализаций, стало очень удобно использовать переменные конфигурации для быстрого определения и изменения характеристик диаграммы. Это позволило моим диаграммам обрабатывать все данные разной длины и значения. Тот же фрагмент кода, который отображал пройденные мили, теперь может отображать более длинный список температур без каких-либо сбоев:

 var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68]; var height = 300; var width = 800; var barPadding = 1; var barSpacing = height / highTemperatures.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(highTemperatures); var widthScale = width / maxValue; d3.select('body').append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(highTemperatures) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', 'steelblue');

Чтобы увидеть его в действии, зайдите на bl.ocks.org.

Обратите внимание, как высота и ширина столбцов масштабируются в зависимости от размера и значений данных. Изменяется одна переменная, а об остальном заботятся.

Шаг 2: Простое повторение через функции

Кодирование никогда не должно быть упражнением в копипасте

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

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

 var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; function drawChart(dom, data, options) { var width = options.width || 800; var height = options.height || 200; var barPadding = options.barPadding || 1; var fillColor = options.fillColor || 'steelblue'; var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(dom).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); } var weatherOptions = {fillColor: 'coral'}; drawChart('#weatherHistory', highTemperatures, weatherOptions); var runningOptions = {barPadding: 2}; drawChart('#runningHistory', milesRun, runningOptions);

Чтобы увидеть его в действии, зайдите на bl.ocks.org.

В этом контексте также важно сделать примечание о выборе D3.js. Всегда следует избегать общего выбора, такого как d3.selectAll('rect') . Если SVG присутствуют где-то еще на странице, все rect на странице становятся частью выделения. Вместо этого, используя переданную ссылку DOM, создайте один объект svg , на который вы можете ссылаться при добавлении и обновлении элементов. Этот метод также может улучшить время выполнения создания диаграммы, поскольку использование опорных столбцов также предотвращает необходимость повторного выбора D3.js.

Шаг 3: Цепочка методов и выбор

В то время как предыдущий скелет с использованием объектов конфигурации очень распространен в библиотеках JavaScript, Майк Босток, создатель D3.js, рекомендует другой шаблон для создания повторно используемых диаграмм. Короче говоря, Майк Босток рекомендует реализовывать диаграммы как замыкания с помощью методов получения-установки. Добавляя некоторую сложность реализации диаграммы, установка параметров конфигурации становится очень простой для вызывающей стороны, просто используя цепочку методов:

 // Using Mike Bostock's Towards Reusable Charts Pattern function barChart() { // All options that should be accessible to caller var width = 900; var height = 200; var barPadding = 1; var fillColor = 'steelblue'; function chart(selection){ selection.each(function (data) { var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(this).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); }); } chart.width = function(value) { if (!arguments.length) return margin; width = value; return chart; }; chart.height = function(value) { if (!arguments.length) return height; height = value; return chart; }; chart.barPadding = function(value) { if (!arguments.length) return barPadding; barPadding = value; return chart; }; chart.fillColor = function(value) { if (!arguments.length) return fillColor; fillColor = value; return chart; }; return chart; } var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; var runningChart = barChart().barPadding(2); d3.select('#runningHistory') .datum(milesRun) .call(runningChart); var weatherChart = barChart().fillColor('coral'); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);

Чтобы увидеть его в действии, зайдите на bl.ocks.org.

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

Шаг 4: Новый шаблон для обновляемых диаграмм

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

Но что, если данные изменяются вне области действия функции-генератора? Или что, если размер диаграммы нужно изменить программно? Мы могли бы просто снова вызвать функцию диаграммы с новыми данными и конфигурацией нового размера. Все бы перерисовать, и вуаля. Задача решена.

К сожалению, есть ряд проблем с этим решением.

Во-первых, мы почти неизбежно выполняем ненужные вычисления инициализации. Зачем выполнять сложные манипуляции с данными, когда все, что нам нужно сделать, это масштабировать ширину? Эти расчеты могут быть необходимы при первой инициализации диаграммы, но, конечно, не при каждом обновлении, которое нам нужно сделать. Каждый программный запрос требует некоторой модификации, и как разработчики мы точно знаем, что это за изменения. Ни больше ни меньше. Более того, в области диаграммы у нас уже есть доступ ко многим вещам, которые нам нужны (объекты SVG, текущие состояния данных и т. д.), что упрощает реализацию изменений.

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

 width = newWidth; widthScale = width / maxValue; bars.attr('width', function(d) { return d*widthScale}); svg.attr('width', width);

Но становится еще лучше. Поскольку теперь у нас есть некоторая история диаграммы, мы можем использовать встроенные переходы D3, чтобы обновлять наши диаграммы и легко анимировать их. Продолжая пример выше, добавить переход по width так же просто, как изменить

 bars.attr('width', function(d) { return d*widthScale});

к

 bars.transition().duration(1000).attr('width', function(d) { return d*widthScale});

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

 d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);

Мы привязали данные к выборке D3.js и назвали нашу повторно используемую диаграмму. Любые изменения данных должны быть сделаны путем привязки новых данных к тому же выбору. Теоретически мы могли бы использовать старый шаблон и проверить выборку на наличие существующих данных, а затем обновить наши результаты с помощью новых данных. Это не только запутанно и сложно реализовать, но и потребует предположения, что существующая диаграмма имеет тот же тип и форму.

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

 weatherChart.width(420);

В результате получается плавный переход к новой ширине от существующей диаграммы. Без лишних вычислений и с плавными переходами результат — довольный клиент.

Никаких лишних вычислений + плавные переходы = довольный клиент

Никаких лишних вычислений + плавные переходы = довольный клиент
Твитнуть

Эта дополнительная функциональность связана с небольшим увеличением усилий разработчиков. Усилие, однако, которое я считаю исторически стоящим времени. Вот скелет обновляемой диаграммы:

 function barChart() { // All options that should be accessible to caller var data = []; var width = 800; //... the rest var updateData; var updateWidth; //... the rest function chart(selection){ selection.each(function () { // //draw the chart here using data, width // updateWidth = function() { // use width to make any changes }; updateData = function() { // use D3 update pattern with data } }); } chart.data = function(value) { if (!arguments.length) return data; data = value; if (typeof updateData === 'function') updateData(); return chart; }; chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; }; //... the rest return chart; }

Чтобы увидеть полную реализацию, проверьте ее на bl.ocks.org.

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

 chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; };

Обратите внимание, что updateWidth не будет определен до тех пор, пока диаграмма не будет инициализирована. Если он не undefined , то конфигурационная переменная будет установлена ​​глобально и использована при закрытии графика. Если была вызвана функция диаграммы, то все переходы передаются функции updateWidth , которая использует измененную переменную width для внесения необходимых изменений. Что-то вроде этого:

 updateWidth = function() { widthScale = width / maxValue; bars.transition().duration(1000).attr('width', function(d) { return d*widthScale}); svg.transition().duration(1000).attr('width', width); };

С этой новой структурой данные для диаграммы передаются через цепочку методов, как и любая другая переменная конфигурации, вместо того, чтобы привязывать их к выбору D3.js. Разница:

 var weatherChart = barChart(); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);

который становится:

 var weatherChart = barChart().data(highTemperatures); d3.select('#weatherHistory') .call(weatherChart);

Итак, мы внесли некоторые изменения и добавили немного усилий разработчиков, давайте посмотрим на преимущества.

Допустим, у вас есть запрос на новую функцию: «Добавьте раскрывающийся список, чтобы пользователь мог переключаться между высокими и низкими температурами. И сделайте так, чтобы цвет менялся, пока вы это делаете». Вместо того, чтобы очищать текущую диаграмму, привязывать новые данные и перерисовывать с нуля, теперь вы можете сделать простой вызов, когда выбрана низкая температура:

 weatherChart.data(lowTemperatures).fillColor('blue');

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

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

Изменение в философии

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