Представление большого объема данных в виде линейных графиков с D3.js

Пять примеров повышения интерактивности и удобства чтения

Mikhail Shabrikov
factorymn
9 min readFeb 15, 2018

--

В этой статье мы разберём несколько приёмов, как сделать обычный линейный график более информативным и интерактивным. Для этого мы будем использовать JavaScript-библиотеку для визуализации данных — D3.js. Возьмём на вооружение хаб открытых данных и визуализируем статистику «динамики средневзвешенных процентных ставок по кредитам, предоставленным юридическим и физическим лицам» для регионов, входящих в состав Центрального федерального округа России. Я прокомментировал фрагменты кода, однако замечу, что человеку не знакомому с базовыми концептами библиотеки D3.js (такими как selection, scale, update pattern, transform, zoom), многие моменты могут показаться не очевидными. Также здесь используется последняя — четвёртая версия библиотеки, имеющая ряд существенных отличий от третьей версии, долгое время бывшей в ходу. Код примеров написан с использованием ES6 синтаксиса, и если вы будете переносить какие-то части кода в ваш проект, работающий в среде не поддерживающей какую-либо из используемых фич, убедитесь, что вы используете транслятор кода в нужную вам версию (скорее всего это будет Babel), либо сделайте это онлайн.

Перед стартом давайте ознакомимся со структурой наших исходных данных:

Каждая строка данных в csv файле это: ID региона (поле regionId), название региона (regionName), дата (date) и значение процентной ставки (percent).

Отрисовываем графики

Установив русскую дефолтную локаль, загружаем csv файл с данными с помощью метода d3.csv который получает url ресурса и callback-функцию. После загрузки библиотека распарсит содержимое csv файла и вызовет callback-функцию, передав первым аргументом массив данных.

Непосредственно в функции для отрисовки графика первым делом определяем дефолтные размеры и три шкалы, которые нам понадобятся — шкала оси X, шкала оси Y и цветовая шкала, которую мы будем использовать для задания цвета графикам. Добавляем на страницу и сохраняем в переменную svg DOM-ноду, которая будет являться контейнером для прочих элементов визуализации.

Обновим свойства date и percent каждого элемента нашего массива данных, преобразовав их значения из строки в Date-объект и из строки в число, соответственно. Теперь мы можем задать domain для наших шкал.

Определяем ось X (xAxis) и ось Y (yAxis), добавляем их на страницу. Обратите внимание, мы задали tickSize для оси X равный ширине графика, а для оси Y равный высоте. За счет этого мы получили сетку в области графиков.

Затем отрисовываем непосредственно линии графика. Проделав еще ряд манипуляций с исходным массивом данных, получаем объект (сохранен в переменной regions) в виде: ключи объекта — ID регионов, значения — массив данных (дата/процентная ставка) для данного региона. Таким образом, используя массив ID регионов для привязки данных - .data(regionIds), мы передаем необходимые данные в lineGenerator (функцию, которая вернет нужное значение аргумента d) просто обращаясь к объекту по ключу:

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

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

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

1. Добавляем легенду и возможность управления видимостью графиков

Изменившийся код

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

Сохраним в переменной regionNamesById объект с названиями регионов. Изменим структуру объекта, ранее сохраненного в переменной regions, теперь мы храним не только массив данных для определенного региона (свойство data), но и статус видимости линии данного региона на графике (свойство enabled).

Снизу от графика выводим список регионов и соответствующий им цвет линии графика. Вешаем обработчик события клика на элемент легенды (функция clickLegendHandler).

Мы будем изменять значение свойства enabled для данного региона. После чего заново запускать функцию отрисовки графиков redrawChart:

Фильтруем массив ID регионов, выбирая таким образом только регионы с включенным состоянием отображения. Полученный массив используем для привязки данных. Так как графики будут перерисовываться, следует на забывать удалять старые DOM-ноды перед добавлением новых: paths.exit().remove(). Запустите codepen ниже и посмотрите как это работает.

Как вы можете видеть, мы также добавили опции «скрыть все» и «показать все» (см. исходный код примера со строки 143). График уже стал намного дружественнее к пользователю. Мы можем скрыть все линии и выбрать 3–4 региона, которые нас интересуют в данный момент.

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

2. Добавляем разбиение Вороного

Изменившийся код

Диаграмма/разбиение Вороного конечного множества точек S на плоскости представляет такое разбиение плоскости, при котором каждая область этого разбиения образует множество точек, более близких к одному из элементов множества S, чем к любому другому элементу множества.

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

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

Весь сложный алгоритм разбиения создатели библиотеки реализовали за нас. Нам необходимо лишь определить функции для вычисления позиции по осям X и Y, а также, метод extend, куда мы передаем массив двух координат —верхнего левого и нижнего правого угла области, внутри которой должно произойти разбиение. Сохраним в переменную voronoiGroup DOM-ноду, которая будет служить контейнером для добавляемых полигонов разбиения. Добавляя, либо удаляя класс voronoi-show у этого элемента, мы будем управлять видимостью границ полигонов при переключении чекбокса «Показать разбиение Вороного».

В функции отрисовки графиков redrawChart мы теперь можем вызвать метод voronoi.polygons, передав единственным аргументом массив исходных данных, и получить на выходе массивы точек для построения полигонов — рассчитанных как раз с использованием алгоритма построения разбиения Вороного.

В функциях-обработчиках событий mouseover и mouseout мы будем, соответственно, добавлять или удалять класс region-hover для увеличения толщины линии, либо скрывать все линии, кроме активной в момент клика, либо возвращать графику предыдущее состояние (последующий клик).

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

3. Отображаем актуальные данные при перемещении курсора в области графиков

Изменившийся код

Добавим и сохраним в переменную legendsValues набор нод, в которых мы будем выводить значение величины процентной ставки для соответствующего региона. А над этим списком будем выводить соответствующую дату в формате день-месяц-год (legendsDate).

Создадим и сохраним в переменную percentsByDate объект, наполним его данными таким образом, что мы могли получить величину процентной ставки в конкретную дату в конкретном регионе следующим образом:

Обновлять значения будем при каждом пересечении курсором мыши (функция обработчик события mouseover) сегмента разбиения Вороного. Здесь же мы будем задавать смещение для точки (элемент в переменной hoverDot), показывающей, какая именно дата сейчас выделена на активной линии. Получившийся результат вы можете посмотреть, запустив пример ниже.

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

4. Добавляем возможность масштабирования графиков

Изменившийся код

Еще одной примечательной фичей библиотеки D3.js, для реализации которой потребуется лишь несколько строк кода, является возможность масштабирования определенной области (d3.zoom).

Определяем пороговые значения для величины масштабирования и смещения (помимо масштабирования мы также сможем смещать графики drag-and-drop-ом), указываем обработчики событий start и zoom и передаем в метод call на выбранном с помощью метода d3.select DOM элементе (в нашем случае это контейнер для полигонов разбиения Вороного).

Мы будем использовать clipPath для ограничения области видимости элементов, которые в результате масштабирования могут частично или полностью оказаться за пределами области графиков.

В функции обработчике события zoom, используя параметры объекта события d3.event.transform мы получаем новые значения для шкал координатных осей (rescaledX и rescaledY) и, используя их, обновляем отсечки на координатных осях, отрисовываем отмасштабированные линии графика.

Значения rescaledX и rescaledY мы также используем для генератора линий, а также в обработчике события mouseover полигонов разбиения Вороного для задания смещения точки-указателя.

Мы также добавим кнопку “Сбросить зум”, чтобы пользователь мог вернуть графики к первоначальному состоянию одним кликом.

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

5. Показываем превью с отображением отмасштабированного участка

Изменившийся код

Сразу же начну раздел с гифки, демонстрирующей как это будет работать:

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

Определяем шкалы и функцию для генерации линий графиков на превью.

Собственно превью будет состоять из линии (мы будем показывать лишь одну, последнюю-активную линию) и двух элементов rect : статичного фона и элемента, который будет изменять свои размеры при масштабировании (сохранен в переменную draggedNode). Мы также реализуем возможность перемещения этого элемента мышью с помощью d3-drag behavior.

В функции dragged мы обновляем значения атрибутов x и y самого перетаскиваемого элемента, а также программно устанавливаем значение трансформации для масштабируемой области графиков: используя значение ratio задаем смещение по осям, для значения scale используем величину currentTransformationValue, которая имеет начальное значение равное 1 и изменяется при масштабировании (см. ниже).

Внутри функции обработчика события zoom вычисляем и обновляем атрибуты x, y, width и height у элемента draggedNode, таким образом мы достигаем того, что этот элемент покрывает на превью область, соответствующую отмасштабированной.

Отрисовка превью активной линии происходит в функциях redrawChart и voronoiMouseover (см. код со строки 348 и 426 соответственно).

И последнее, итоговое демо, включающее все, что мы сделали в рамках этой статьи:

Совершенство недостижимо, и мы можем придумать ещё несколько способов улучшения данной визуализации. Например, используя d3-brush, мы можем реализовать выделение произвольной области графиков с последующим масштабированием. Но уже и того, что мы реализовали в рамках этой статьи достаточно, чтобы убедится насколько широки возможности используемой библиотеки. Отдавая должное многочисленным библиотекам-надстройкам над D3.js (C3.js, Semiotic, Billboard.js и подобные), упрощающим задачу создания визуализаций до уровня определения объекта-конфигурации, я остаюсь сторонником использования именно “нативного D3.js”, а для проектов с потенциально высокой вероятностью долгосрочной поддержки и развития и вовсе считаю это единственным верным вариантом.

P.S. Эта статья вдохновлена и использует в своей основе данное демо.

--

--