У Яндекс.Карт давно просили сделать средство для визуализации данных с помощью тепловых карт — и мы наконец его сделали. Если вы — один из тех, кому это было очень нужно, можете переходить сразу к чтению документации на гитхабе. Если же вы ещё не знаете, нужно ли это вам, — можете потратить немного времени, чтобы почитать статью про то, что такое тепловые карты, как они генерируются на клиенте, и как мы совместили генерацию тепловой карты с картой географической.
Отображение географических точек из Википедии
Что такое тепловые карты, и зачем они нужны
Итак, обо всем по порядку. Для начала давайте определимся, что такое тепловые карты и с чем их едят? Как подсказывает мне капитан очевидность википедия, тепловые карты (они же теплокарты, они же heatmap) — это графическое представление данных, где дополнительные переменные отображаются при помощи цвета. Такой вид отображения бывает очень удобным. Например, им часто пользуются веб-аналитики, чтобы увидеть наиболее активные части страниц сайта.
Вот такие карты кликов позволяет строить Яндекс.Метрика:
Иногда бывает полезным нанесение каких-то количественных показателей на географическую карту, как в случае отображения зон покрытия мобильной связи/интернета у МТС:
Именно такие кейсы и призван решать модуль тепловых карт, который мы создали. Перед тем как я перейду к описанию самого процесса создания модуля, я хотел бы сказать еще пару слов о том, что из себя представляет наша модульная система, и как вы можете ей воспользоваться.
Модульная система
В версии 2.1 мы открыли доступ пользователям к нашей модульной системе, которая написана на основе YModules, разработанной нашим коллегой dfilatov. Эта модульная система имеет много разных приятных фич, таких как асинхронный resolve зависимостей, переопределение модулей, etc. Она была уже достаточно подробно описана автором на Хабре, так что если интересно, можете почитать.
Открытие модульной системы принесло нам приятный бонус — возможность для внешних разработчиков создавать собственные модули. Вроде бы и ничего архиважного, но благодаря этому наши пользователи теперь могут:
- самостоятельно писать новую функциональность для API Яндекс.Карт и делиться им в удобном виде с другими разработчиками;
- использовать нашу модульную систему как основную, если приложение целиком и полностью завязано на картах.
В качестве примера первого мы и создали тепловые карты.
Поскольку написать свои тепловые карты не было самоцелью данной затеи (главной задачей было сделать готовое решения для API Яндекс.Карт), перед тем как начать писать код и думать над алгоритмом работы, естественно, я полез на github искать какие-то готовые решения. Вполне ожидаемо было то, что разных реализаций тепловых карт было там чуть больше, чем достаточно (почти две с половиной сотни).
Немного изучив исходники разных проектов, я остановил свое внимание на библиотеке simpleheat авторства Mourner. У нее было два ключевых преимущества:
- код всего проекта занимал около сотни строчек;
- тепловая карта хорошо держала 10к точек без напряга при отрисовках (при большем количестве данных тестировать уже как-то бессмысленно, поскольку отдавать такие объемы данных только для отрисовки картинки на клиент крайне неразумно).
В конечном итоге мне, конечно, пришлось переписать значительную ее часть, но все равно, мне кажется, что это был лучший выбор. Все остальные решения были куда более громоздкими, но особых плюшек не предоставляли.
Алгоритм отрисовки тепловых карт
Пообщавшись с коллегами, я понял, что все видели тепловые карты, все знают, зачем и что это. Но почти никто не знал, как они отрисовываются. Именно поэтому я постараюсь описать эту часть более детально.
API Яндекс.Карт предоставляет возможность для отображения собственной подложки для карты, реализуется это с помощью специального класса Layer. На вход ему необходимо передать функцию, которая по номеру тайла и уровню масштабирования вернет url для загрузки тайла. Кто еще не знаком с тайлами и тайловой графикой можете немного почитать о них в википедии и у нас в документации.
Написание функции генератора url'ов для получения тайлов — это фактически и есть вся задача создания тепловой карты для нашего API.
Когда мы определились с тем, что от нас нужно, мы начали думать над тем, как это сделать. Есть два принципиально различных метода для задания тепловой карты:
- с помощью двухмерного скалярного (плоского) поля (фактически это функция двух переменных);
- с помощью набора простых или взвешенных точек (каждой точке в соответствие ставится какое-то положительное число — ее вес).
Первый метод является более универсальным и включает в себя второй, но в тоже время он очень неудобен для использовании на практике (как часто вам предоставляют данные в виде функций нескольких переменных?), да и выглядит странно и непонятно для неподготовленных пользователей. Поэтому без лишних угрызений совести мы приняли решение, что будем использовать именно второй метод.
Для удобства работы пользователей мы решили, что будем поддерживать все самые популярные форматы входных данных, которые используются в API (Number[][], IGeoObject, IGeoObject[], ICollection, ICollection[], GeoQueryResult, JSON), из-за этого нам пришлось наложить не сильно приятное ограничение на программный интерфейс теплокарт. Теплокарте можно задавать только набор данных и нельзя удалять или добавлять точки из этого набора. Таким образом, для работы с данными мы предоставляем всего лишь два метода: getData() и setData().
После того, как мы получили данные, мы приводим их к единому формату и переводим в глобальные пиксельные координаты. С такими данными уже относительно просто работать, поскольку для каждого тайла можно легко сказать, какие точки в него попадают, а какие нет.
После того, как данные были предподготовлены можно начать их отрисовывать. Как отрисовывать вопроса, вроде, не стоит (Canvas — наше все, тем более, у него есть замечательная функциональность getDataURL, особенно необходимый в нашем случае, поскольку именно url тайла мы должны предоставить API).
Для отрисовки каждой отдельной точки будем использовать кисть (рисунок слева), которая представляет из себя черно-белый градиент и рисуется на canvas'е весьма просто:
var brush = document.createElement('canvas'),
context = brush.getContext('2d'),
radius = 20,
gradient = context.createRadialGradient(radius, radius, 0, radius, radius, radius);
gradient.addColorStop(0, 'rgba(0,0,0,1)');
gradient.addColorStop(1, 'rgba(0,0,0,0)');
context.fillStyle = gradient;
context.fillRect(0, 0, 2 * radius, 2 * radius);
Вес точки будет определять, с какой прозрачностью кисть будет «рисовать» точку на тайле. После того, как мы отрисуем все точки тайла, у нас получится такой себе негатив нашего тайла тепловой карты.
var canvas = document.createElement('canvas'),
context = canvas.getContext('2d'),
maxOfWeights = 1,
radius = 20;
context.clearRect(0, 0, 256, 256);
for (var i = 0, length = points.length; i < length; i++) {
context.globalAlpha = Math.min(points[i].weight / maxOfWeights, 1);
context.drawImage(
brush, points[i].coords[0] - radius, points[i].coords[1] - radius
);
}
После чего тайл будет раскрашен установлением цвета каждому пикселю из градиента (options.gradient) в соответствии со значением его прозрачности. Прозрачность же каждого пикселя тайла будет равна общей прозрачности тепловой карты (options.opacity).
// Создаем градиент.
var canvas = document.createElement('canvas'),
context = canvas.getContext('2d'),
gradient = context.createLinearGradient(0, 0, 0, 256),
gradientOption = {
0.1: 'rgba(128, 255, 0, 0.7)',
0.2: 'rgba(255, 255, 0, 0.8)',
0.7: 'rgba(234, 72, 58, 0.9)',
1.0: 'rgba(162, 36, 25, 1)'
};
canvas.width = 1;
canvas.height = 256;
for (var i in gradientOption) {
if (gradientOption.hasOwnProperty(i)) {
gradient.addColorStop(i, gradientOption[i]);
}
}
context.fillStyle = gradient;
context.fillRect(0, 0, 1, 256);
// Раскрашиваем пиксели тайла.
var gradientData = context.getImageData(0, 0, 1, 256).data;
var opacity = 0.5
for (var i = 3, length = pixels.length, j; i < length; i += 4) {
if (pixels[i]) {
j = 4 * pixels[i];
pixels[i - 3] = gradientData[j];
pixels[i - 2] = gradientData[j + 1];
pixels[i - 1] = gradientData[j + 2];
pixels[i] = opacity * (gradientData[j + 3] || 255);
}
}
Вроде как и все, но нет. Всегда найдутся какие-то исключительные ситуации, которые придется обработать дополнительно. И в нашем случае возможность задания неограниченного сверху веса точки может привести к тому, что одна точка «погасит» все остальные. Так, например, если добавить на карту несколько сотен точек с весом один и одну точку с весом тысяча, то видна будет только последняя (рисунок слева).
Поэтому во избежание таких ситуаций мы ввели дополнительную опцию intensityOfMidpoint — это параметр, который задает, какая прозрачность (фактически определяет какой цвет) должна быть у медианной по весу точки. Таким образом, нам удастся сгладить экстремумы для обычных пользователей (рисунок справа), а остальные смогут подстроить опцию до нужных значений.
Как этим пользоваться
Подробная инструкция по загрузке модуля есть в документации на github'e. После чего для использования достаточно просто подключить его через модульную систему.
ymaps.modules.require(['Heatmap'], function (Heatmap) {
var data = [[37.782551, -122.445368], [37.782745, -122.444586]],
heatmap = new Heatmap(data);
heatmap.setMap(myMap);
});
Также мы подготовили небольшое демо, которое позволит наглядно увидеть работу большинства опций.
Все свои вопросы/пожелания/возмущения или благодарности можете писать в issues на github'е, у нас в клубе или же напрямую мне на почту alt-j@yandex-team.ru.
Вместо заключения
Как вы, наверное, поняли писать свои модули для API Яндекс.Карт весело и просто. Пробуйте, экспериментируйте, делитесь с нами вашими результатами. Еще раз приведу список важных ссылок:
- модуль тепловых карт на github'e;
- документация API Яндекс.Карт;
- документация нашей модульной системы;
- клуб для общения про API Яндекс.Карт.
Автор: alt-j