company_banner

На Яндекс.Картах теперь можно создавать тепловые карты

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


    Отображение географических точек из Википедии

    Что такое тепловые карты, и зачем они нужны


    Итак, обо всем по порядку. Для начала давайте определимся, что такое тепловые карты и с чем их едят? Как подсказывает мне капитан очевидность википедия, тепловые карты (они же теплокарты, они же 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 Яндекс.Карт весело и просто. Пробуйте, экспериментируйте, делитесь с нами вашими результатами. Еще раз приведу список важных ссылок:

    Яндекс

    513,29

    Как мы делаем Яндекс

    Поделиться публикацией
    Комментарии 40
      0
      Тепловые карты выглядят намного естественнее, если цвет и прозрачность убывают от центра не линейно
        +3
        Градиент цвета задается специальной опцией gradient, поэтому вы сможете откорректировать его как вам захочется. Прозрачность же задается одна для всего слоя heatmap'а.
          0
          Я имею в виду прозрачность краёв кисти. Кроме того, приятно, когда даже без задания дополнительных опций всё работает хорошо.
            0
            Можно еще вспомнить о Metaball и расчетах в плавающей точке.
            Квадратичное падение «яркости» от расстояния — это то, что легко сделать через более точную настройку «кисти» — можно даже алгоритически ее сгенерировать, без градиентов.
            Но это не тот «внешний вид», который часто требуется задачам «подсветки» данных.
              0
              Вот я как раз алгоритмически кисти и генерировал, имитировал естественную светимость яркой точки, и выходило хорошо. Кстати, как раз на примеры Metaball и получалось похоже.
                0
                Опять же — 256 градаций для реального нормального «квадратичного спада» — это мало. При интерференции 3+ точек пойдут очень четкие градиенты, времен VGA.
                Тут или webgl подключать, с float-point рендер-таргетами, или считать попиксельно самому — а это не совсем реалтайм.
                  0
                  если речь только о канале прозрачности, то даже 256 градаций дают довольно неплохой результат
              +2
              В текущей версии прозрачность задается на весь слой и не изменяется от текущей «температуры» точки, это сделали для того, чтобы не уменьшать читабельность самой карты, возможно, это не идеальное решение, но показалось, что такое решение весьма удобно. А в целом, я готов к дискуссии и допиливанию;)

              В качестве градиента по умолчанию используется самый стандартый градиент для всех тепловых карт, люди к этому привыкли и поэтому решили, что не стоит переворачивать с ног на голову представление пользователей о тепловых картах.
                +1
                У кисти цвета задаются как RGBA. То есть, в них есть компонент «прозрачность». То есть, по краям кисть более прозрачная, чем в центре. Вот об этой прозрачности я и говорю.

                Про стандарты на тепловые карты я не слышал. Даже если они есть, отказ от старых плохих стандартов с целью перехода на хорошие новые стандарты часто приносит компаниям хорошие дивиденты.
                  +2
                  Аа, с этой прозрачностью, да, можно что-то сделать. Было бы здорово, если бы вы прислали нам pull-request c градиентом, который считаете более приятным, чтобы мы говорили не о каких-от абстрактных значениях, а о реальных цветах.
                  Мы были бы вам очень благодарны;)
                    –2
                    Нравящийся мне вариант задаётся алгоритмически, и писать его самостоятельно ещё раз мне лень :)
          +1
          Не совсем понятно, что именно меняет параметр dissipating
            +2
            Это булевое значение, которое говорит нужно ли уменьшать пиксельный размер точек при уменьшении зума. Просто в некоторых случаях, если этого не делать, то на маленьких зумах все точки сливаются в одно большое красное пятно.
              0
              По словесному описанию как-будто понятно, но работа его в демке выглядит неочевидной…
            +4
            Около года назад тоже понадобилась тепловая карта, и я наткнулся на решение на WebGL о котором говорилось следующее:
            You can draw around 500'000 individual data points per second in realtime. Around 10'000 per frame are no problem.
            Перевод
            Можно рисовать 500к точек в реальном времени. 10к на кадр без проблем.

            Использовать решение очень легко, по ссылке приведён пример и демо.
              0
              Это волшебно. Плюс библиотека поддерживает не только «вес» точки, но и размер.
                0
                Мне особенно понравилось медленное «угасание» (decay) старых точек — мне как раз нужна была динамичная карта (для статичной надо указать decay=0). Да и параметр «расброс» (spread) делает карту живее, что-ли…
                  0
                  Но и то, и то выходит за рамки задачи визуализации данных.
                    0
                    А что, наличие настоящей оси времени в визуализации превращает «задачу визуализации» в тыкву?
              0
              Фичреквест к Яндексу или тем разработчикам, которые не настолько ленивы, как я: сделайте кто-нибудь реальную тепловую карту на основе этого API и данных Погоды, реал-тайм.
                +1
                Можно будет попробовать нафигачить, делать то почти ничего и не надо;)
                Только вряд ли для этого Яндекс.Погода подойдет, насколько я знаю, API у них нет, а вот у
                openweathermap можно получить данные вполне легально.
                  –1
                  Публичного API нет, но есть вполне доступные данные в xml.
                    +1
                    1) Я не уверен, что это не нарушает пользовательское соглашение.
                    2) Не стоит строить свои системы на хаках в чужих, это редко приводит к чему-то хорошему.
                      0
                      В общем, конечно, согласен.
                      Но какие тут системы, я ж просто красивую и интересную «демку» к новому API Я.Карт предлагаю. :) Тем более, обращение было, в т.ч., к самим яндексоидам. Ну и, на самом деле, в данном случае не принципиально, откуда данные брать.
                0
                А шкалу цветов почему не нарисовали?
                  0
                  Еще б добавление пользовательских (внешних) слоев добавили… как для ПК версии так и для мобильной особенно. Обсуждается уже 3 года, а результата так и нет…

                  Т.е. чтоб пользователь мог добавить кастомный (не яндексовый) слой указав его URL дабы подтянуть список объектов наносимых на карту.
                  По аналогии как сейчас сделан показ Яндекс.Фото на maps.yandex.ru.
                    0
                    Фотки работают на технологии активных областей, которая уже давно есть в апи. Недавно мы выпустили еще один модуль, который позволяет быстро показывать точечные объекты и подгружать их по требованию. Уточните, что вы имеете в виду под пользовательским слоем.
                      0
                      Пользовательский слой — набор объектов из внешнего источника (не Яндекс) для отображения на вашей карте. Повторюсь снова: URL на внешний вебсервис, который в определенном формате (к примеру json) выдает перечень объектов для отображения.
                      А уж активными областями или как то иначе их отображать — решать вам.

                      Грубо говоря на maps.yandex.ru и/или мобильном приложении добавить пункт меню «Подключить слой», выбрав который пользователь сам вводит источник данных.

                      В дальнейшем наиболее интересные источники данных сможете включить в одобренные Яндексом и т.п.
                        0
                        Это как у гула хотите, у которого поиск kml файла в гуловой карте просто показывает его содержимое поверх неё?
                          0
                          *гугла
                    0
                    Лучше бы расширили API линейки. А то ни хинт изменить, ни расстояние получить. Приходится городить свои велосипеды.
                      0
                      Если вы поделитесь с нами своим юз-кейсом и подробнее опишите функциональность, которую вам хотелось бы увидеть в линейке, мы с удовольствием добавим её в наши планы.
                        +1
                        Ну что ж, приступим:
                        • Нет возможности получить расстояние (нужно .getDistance());
                        • Нет возможности изменить хинт (событие onHintRequested(ev, [ruler|distance])?);
                        • Нет события изменения состояния линейки (изменения расстояния);
                        • Нет возможности застайлить линейку (изменить вид точек, изменить цвет линии);
                        • Нет возможности получить количество точек, через которые проходит линейка.


                        Q: Для чего это нужно?
                        A: Буквально пару недель назад мне понадобилось сделать штуку для расчета времени полета вертолета из произвольной точки A в точку B[,C[,D[,...]]] с учетом приземлений, и выводить в хинте (там, где сейчас выводится расстояние) информацию по времени и прочие вещи. Так как маршрут произвольный — Route не подходит, так как он привязывается к дорогам (и если реализовывать возможность кастомизации хинта — нужно делать это не только статическим шаблоном, т.к. содержимое оного может меняться от расстояния).
                        В итоге пришлось использовать обычную Polyline, и считать расстояние между всеми точками самому.
                        Также, эта возможность кастомизации поможет для карт для каких-либо расчетов связанных с пешеходными и морскими (не уверен в корректности этого слова, но думаю поймете) маршрутами. :)
                          0
                          интересно, Вам ответили?
                            0
                            Как видите — нет :)
                              0
                              ну, надеялся хоть в личку… эх
                      0
                      Существует ли возможность раскрасить не по количеству точек, а по некоторым параметрам для каждой точки?
                      Например. Есть 100 квартир, стоимость у квартир различается. Я размещаю все 100 квартир на карте и задаю для каждой точки определенное значение (стоимость квартиры) и в итоге получаю тепловую карту стоимости квартир по городу.
                      Данная реализация была бы значительно интересней, чем простое цветовое представление распределения точек.
                      У себя на проекте реализовать получилось, но хотелось бы, чтобы это отображалось прямо на вашей карте, т.к. используем именно Яндекс.Карты для поиска вариантов на карте.
                      Как это выглядит сейчас: http://kvarnado.ru/аналитика/
                      Как это реализовано:
                      • Разбиваю карту на квадраты, смотрю среднюю стоимость какого-либо типа жилья в каждом квадрате.
                      • В зависимости от максимального и минимального значения среди всех квадратов задаются зависимости стоимость->цвет
                      • В зависимости от цвета данного района рисую на карте круг данного цвета.

                      Можно было бы еще дополнить это. Например, прозрачность цветного круга зависела бы от количества вариантов и т.д.
                        0
                        На данный момент такой функциональности нет, и я не уверен, что она появится, поскольку такой кейс использования тепловых карт все же реже. Но схожих картинок, как у вас в проекте, можно добиться и с нашим модулем несколькими методами:
                        • отквантовать данные на несколько дискретных уровней, задать для каждого соответствующий градиент (изменяя в нем только прозрачность, но не цвет), для каждого уровня задать отдельный слой heatmap'а и наложить его на карту
                        • расставить точки на большом расстоянии друг от друга, чтобы они минимально аффектили друг друга, задать каждой точке вес равный определенному значению (дополнительные данные, которые вы хотите отобразить)
                          +1
                          они ж вроде механизм плагинов демонстрируют, типа можно свой по аналогии написать
                            +1
                            Я уже за прошедшее время реализовал таки.
                            Может пост запилю. Интересно получилось, но боюсь, что мой быдло-код раскритикуют )

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

                        Самое читаемое